feat: public costs dashboard (#548)

* feat: public costs dashboard with daily export to R2

- add /costs page showing monthly infrastructure costs
- costs.json exported daily via GitHub Action to R2
- backend /config endpoint exposes costs_json_url
- ko-fi support link for community funding
- hardcoded Fly/Neon/Cloudflare costs, dynamic AudD from DB

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: correct audd cost calculation with one-time adjustment

the copyright_scans table was created Nov 24 but first scan recorded
Nov 30, so database counts are incomplete for this billing period.

- hardcode audd values for Nov 24 - Dec 24 from dashboard (6781 requests, $3.91)
- after Dec 24, automatically uses live database counts
- includes cleanup comment to remove hardcoded block after transition

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* docs: update STATUS.md - cost dashboard complete

- add public cost dashboard to recent work section
- remove from immediate priorities (done)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io Claude and committed by GitHub 7939c7ef 1e9aa38a

Changed files
+1012 -8
.github
workflows
backend
src
backend
frontend
src
routes
costs
scripts
+49
.github/workflows/export-costs.yml
··· 1 + # export platform costs to R2 for public dashboard 2 + # 3 + # runs daily at 6am UTC to update the costs.json file in R2 4 + # the frontend /costs page fetches this static JSON 5 + # 6 + # required secrets: 7 + # NEON_DATABASE_URL_PRD - production database URL 8 + # AWS_ACCESS_KEY_ID - R2 access key 9 + # AWS_SECRET_ACCESS_KEY - R2 secret key 10 + # R2_ENDPOINT_URL - R2 endpoint 11 + # R2_BUCKET - R2 bucket name 12 + # R2_PUBLIC_BUCKET_URL - public R2 URL 13 + 14 + name: export costs 15 + 16 + on: 17 + schedule: 18 + - cron: "0 6 * * *" # daily at 6am UTC 19 + workflow_dispatch: 20 + inputs: 21 + dry_run: 22 + description: "dry run (print JSON, don't upload)" 23 + type: boolean 24 + default: false 25 + 26 + jobs: 27 + export: 28 + runs-on: ubuntu-latest 29 + 30 + steps: 31 + - uses: actions/checkout@v4 32 + 33 + - uses: astral-sh/setup-uv@v4 34 + 35 + - name: Export costs to R2 36 + run: | 37 + ARGS="" 38 + if [ "${{ inputs.dry_run }}" = "true" ]; then 39 + ARGS="--dry-run" 40 + fi 41 + 42 + uv run scripts/costs/export_costs.py $ARGS 43 + env: 44 + NEON_DATABASE_URL_PRD: ${{ secrets.NEON_DATABASE_URL_PRD }} 45 + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 46 + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 47 + R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} 48 + R2_BUCKET: ${{ secrets.R2_BUCKET }} 49 + R2_PUBLIC_BUCKET_URL: ${{ secrets.R2_PUBLIC_BUCKET_URL }}
+17 -7
STATUS.md
··· 47 47 48 48 ### December 2025 49 49 50 + #### public cost dashboard (PR #548, Dec 9) 51 + 52 + - `/costs` page showing live platform infrastructure costs 53 + - daily export to R2 via GitHub Action, proxied through `/stats/costs` endpoint 54 + - includes fly.io, neon, cloudflare, and audd API costs 55 + - ko-fi integration for community support 56 + 50 57 #### docket background tasks & concurrent exports (PRs #534-546, Dec 9) 51 58 52 59 **docket integration** (PRs #534, #536, #539): ··· 187 194 188 195 ### immediate focus 189 196 - **moderation cleanup**: consolidate copyright detection, reduce AudD API costs, streamline labeler integration (issues #541-544) 190 - - **public cost dashboard**: interactive page showing platform running costs (fly.io, neon, audd, r2) - transparency + prompts for community support 191 197 192 198 ### feature ideas 193 199 - issue #334: add 'share to bluesky' option for tracks ··· 271 277 272 278 ## cost structure 273 279 274 - current monthly costs: ~$35-40/month 280 + current monthly costs: ~$18/month (plyr.fm specific) 275 281 276 - - fly.io backend (prod + staging): ~$10/month 277 - - fly.io transcoder: ~$0-5/month (auto-scales to zero) 282 + see live dashboard: [plyr.fm/costs](https://plyr.fm/costs) 283 + 284 + - fly.io (plyr apps only): ~$12/month 285 + - relay-api (prod): $5.80 286 + - relay-api-staging: $5.60 287 + - plyr-moderation: $0.24 288 + - plyr-transcoder: $0.02 278 289 - neon postgres: $5/month 279 - - audd audio fingerprinting: ~$10/month 280 - - cloudflare pages + R2: ~$0.16/month 290 + - cloudflare (R2 + pages + domain): ~$1.16/month 291 + - audd audio fingerprinting: $0-10/month (6000 free/month) 281 292 - logfire: $0 (free tier) 282 - - domain: ~$1/month 283 293 284 294 ## admin tooling 285 295
+31 -1
backend/src/backend/api/stats.py
··· 2 2 3 3 from typing import Annotated 4 4 5 - from fastapi import APIRouter, Depends 5 + import httpx 6 + from fastapi import APIRouter, Depends, HTTPException 7 + from fastapi.responses import Response 6 8 from pydantic import BaseModel 7 9 from sqlalchemy import func, select, text 8 10 from sqlalchemy.ext.asyncio import AsyncSession 9 11 12 + from backend.config import settings 10 13 from backend.models import Track, get_db 11 14 12 15 router = APIRouter(prefix="/stats", tags=["stats"]) ··· 46 49 total_artists=int(row[2]), 47 50 total_duration_seconds=int(row[3]), 48 51 ) 52 + 53 + 54 + @router.get("/costs") 55 + async def get_costs() -> Response: 56 + """proxy costs JSON from R2 to avoid CORS issues. 57 + 58 + the costs.json file is generated daily by a GitHub Action and uploaded 59 + to R2. this endpoint proxies it so the frontend can fetch without CORS. 60 + """ 61 + costs_url = settings.storage.costs_json_url 62 + if not costs_url: 63 + raise HTTPException(status_code=404, detail="costs dashboard not configured") 64 + 65 + async with httpx.AsyncClient() as client: 66 + try: 67 + resp = await client.get(costs_url, timeout=10) 68 + resp.raise_for_status() 69 + except httpx.HTTPError as e: 70 + raise HTTPException( 71 + status_code=502, detail=f"failed to fetch costs: {e}" 72 + ) from e 73 + 74 + return Response( 75 + content=resp.content, 76 + media_type="application/json", 77 + headers={"Cache-Control": "public, max-age=3600"}, 78 + )
+8
backend/src/backend/config.py
··· 270 270 271 271 @computed_field 272 272 @property 273 + def costs_json_url(self) -> str: 274 + """URL for the public costs dashboard JSON.""" 275 + if self.r2_public_bucket_url: 276 + return f"{self.r2_public_bucket_url.rstrip('/')}/stats/costs.json" 277 + return "" 278 + 279 + @computed_field 280 + @property 273 281 def allowed_image_origins(self) -> set[str]: 274 282 """Origins allowed for imageUrl validation.""" 275 283 origins = set()
+594
frontend/src/routes/costs/+page.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import Header from '$lib/components/Header.svelte'; 4 + import WaveLoading from '$lib/components/WaveLoading.svelte'; 5 + import { auth } from '$lib/auth.svelte'; 6 + import { APP_NAME } from '$lib/branding'; 7 + import { API_URL } from '$lib/config'; 8 + 9 + interface CostBreakdown { 10 + [key: string]: number; 11 + } 12 + 13 + interface DailyData { 14 + date: string; 15 + scans: number; 16 + flagged: number; 17 + } 18 + 19 + interface CostData { 20 + generated_at: string; 21 + monthly_estimate: number; 22 + costs: { 23 + fly_io: { 24 + amount: number; 25 + breakdown: CostBreakdown; 26 + note: string; 27 + }; 28 + neon: { 29 + amount: number; 30 + note: string; 31 + }; 32 + cloudflare: { 33 + amount: number; 34 + breakdown: CostBreakdown; 35 + note: string; 36 + }; 37 + audd: { 38 + amount: number; 39 + scans_this_period: number; 40 + included_free: number; 41 + remaining_free: number; 42 + flag_rate: number; 43 + daily: DailyData[]; 44 + note: string; 45 + }; 46 + }; 47 + support: { 48 + kofi: string; 49 + message: string; 50 + }; 51 + } 52 + 53 + let loading = $state(true); 54 + let error = $state<string | null>(null); 55 + let data = $state<CostData | null>(null); 56 + 57 + // derived values for bar chart scaling 58 + let maxCost = $derived( 59 + data 60 + ? Math.max( 61 + data.costs.fly_io.amount, 62 + data.costs.neon.amount, 63 + data.costs.cloudflare.amount, 64 + data.costs.audd.amount 65 + ) 66 + : 1 67 + ); 68 + 69 + let maxScans = $derived( 70 + data?.costs.audd.daily.length 71 + ? Math.max(...data.costs.audd.daily.map((d) => d.scans)) 72 + : 1 73 + ); 74 + 75 + onMount(async () => { 76 + try { 77 + const response = await fetch(`${API_URL}/stats/costs`); 78 + if (!response.ok) { 79 + throw new Error(`failed to load cost data: ${response.status}`); 80 + } 81 + data = await response.json(); 82 + } catch (e) { 83 + console.error('failed to load costs:', e); 84 + error = e instanceof Error ? e.message : 'failed to load cost data'; 85 + } finally { 86 + loading = false; 87 + } 88 + }); 89 + 90 + function formatDate(isoString: string): string { 91 + const date = new Date(isoString); 92 + return date.toLocaleDateString('en-US', { 93 + month: 'short', 94 + day: 'numeric', 95 + year: 'numeric', 96 + hour: 'numeric', 97 + minute: '2-digit' 98 + }); 99 + } 100 + 101 + function formatCurrency(amount: number): string { 102 + return `$${amount.toFixed(2)}`; 103 + } 104 + 105 + // calculate bar width as percentage of max 106 + function barWidth(amount: number, max: number): number { 107 + return Math.max(5, (amount / max) * 100); 108 + } 109 + 110 + async function logout() { 111 + await auth.logout(); 112 + window.location.href = '/'; 113 + } 114 + </script> 115 + 116 + <svelte:head> 117 + <title>platform costs | {APP_NAME}</title> 118 + <meta name="description" content="transparency dashboard showing {APP_NAME} running costs" /> 119 + </svelte:head> 120 + 121 + <Header user={auth.user} isAuthenticated={auth.isAuthenticated} onLogout={logout} /> 122 + 123 + <main> 124 + <div class="page-header"> 125 + <h1>platform costs</h1> 126 + <p class="subtitle">transparency dashboard for {APP_NAME} infrastructure</p> 127 + </div> 128 + 129 + {#if loading} 130 + <div class="loading"> 131 + <WaveLoading size="md" message="loading cost data..." /> 132 + </div> 133 + {:else if error} 134 + <div class="error-state"> 135 + <p>{error}</p> 136 + <p class="hint">cost data is updated daily. check back later.</p> 137 + </div> 138 + {:else if data} 139 + <!-- monthly total --> 140 + <section class="total-section"> 141 + <div class="total-card"> 142 + <span class="total-label">estimated monthly</span> 143 + <span class="total-amount">{formatCurrency(data.monthly_estimate)}</span> 144 + </div> 145 + <p class="updated">last updated: {formatDate(data.generated_at)}</p> 146 + </section> 147 + 148 + <!-- cost breakdown --> 149 + <section class="breakdown-section"> 150 + <h2>breakdown</h2> 151 + 152 + <div class="cost-bars"> 153 + <div class="cost-item"> 154 + <div class="cost-header"> 155 + <span class="cost-name">fly.io</span> 156 + <span class="cost-amount">{formatCurrency(data.costs.fly_io.amount)}</span> 157 + </div> 158 + <div class="cost-bar-bg"> 159 + <div 160 + class="cost-bar" 161 + style="width: {barWidth(data.costs.fly_io.amount, maxCost)}%" 162 + ></div> 163 + </div> 164 + <span class="cost-note">{data.costs.fly_io.note}</span> 165 + </div> 166 + 167 + <div class="cost-item"> 168 + <div class="cost-header"> 169 + <span class="cost-name">neon</span> 170 + <span class="cost-amount">{formatCurrency(data.costs.neon.amount)}</span> 171 + </div> 172 + <div class="cost-bar-bg"> 173 + <div 174 + class="cost-bar" 175 + style="width: {barWidth(data.costs.neon.amount, maxCost)}%" 176 + ></div> 177 + </div> 178 + <span class="cost-note">{data.costs.neon.note}</span> 179 + </div> 180 + 181 + <div class="cost-item"> 182 + <div class="cost-header"> 183 + <span class="cost-name">cloudflare</span> 184 + <span class="cost-amount">{formatCurrency(data.costs.cloudflare.amount)}</span> 185 + </div> 186 + <div class="cost-bar-bg"> 187 + <div 188 + class="cost-bar" 189 + style="width: {barWidth(data.costs.cloudflare.amount, maxCost)}%" 190 + ></div> 191 + </div> 192 + <span class="cost-note">{data.costs.cloudflare.note}</span> 193 + </div> 194 + 195 + <div class="cost-item"> 196 + <div class="cost-header"> 197 + <span class="cost-name">audd</span> 198 + <span class="cost-amount">{formatCurrency(data.costs.audd.amount)}</span> 199 + </div> 200 + <div class="cost-bar-bg"> 201 + <div 202 + class="cost-bar audd" 203 + style="width: {barWidth(data.costs.audd.amount, maxCost)}%" 204 + ></div> 205 + </div> 206 + <span class="cost-note">{data.costs.audd.note}</span> 207 + </div> 208 + </div> 209 + </section> 210 + 211 + <!-- audd details --> 212 + <section class="audd-section"> 213 + <h2>copyright scanning (audd)</h2> 214 + <div class="audd-stats"> 215 + <div class="stat"> 216 + <span class="stat-value">{data.costs.audd.scans_this_period.toLocaleString()}</span> 217 + <span class="stat-label">scans this period</span> 218 + </div> 219 + <div class="stat"> 220 + <span class="stat-value">{data.costs.audd.remaining_free.toLocaleString()}</span> 221 + <span class="stat-label">free remaining</span> 222 + </div> 223 + <div class="stat"> 224 + <span class="stat-value">{data.costs.audd.flag_rate}%</span> 225 + <span class="stat-label">flag rate</span> 226 + </div> 227 + </div> 228 + 229 + {#if data.costs.audd.daily.length > 0} 230 + <div class="daily-chart"> 231 + <h3>daily scans</h3> 232 + <div class="chart-bars"> 233 + {#each data.costs.audd.daily as day} 234 + <div class="chart-bar-container"> 235 + <div 236 + class="chart-bar" 237 + style="height: {Math.max(4, (day.scans / maxScans) * 100)}%" 238 + title="{day.date}: {day.scans} scans, {day.flagged} flagged" 239 + ></div> 240 + <span class="chart-label">{day.date.slice(5)}</span> 241 + </div> 242 + {/each} 243 + </div> 244 + </div> 245 + {/if} 246 + </section> 247 + 248 + <!-- support cta --> 249 + <section class="support-section"> 250 + <div class="support-card"> 251 + <div class="support-icon"> 252 + <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> 253 + <path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" /> 254 + </svg> 255 + </div> 256 + <div class="support-text"> 257 + <h3>support {APP_NAME}</h3> 258 + <p>{data.support.message}</p> 259 + </div> 260 + <a href={data.support.kofi} target="_blank" rel="noopener" class="kofi-button"> 261 + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> 262 + <path d="M23.881 8.948c-.773-4.085-4.859-4.593-4.859-4.593H.723c-.604 0-.679.798-.679.798s-.082 7.324-.022 11.822c.164 2.424 2.586 2.672 2.586 2.672s8.267-.023 11.966-.049c2.438-.426 2.683-2.566 2.658-3.734 4.352.24 7.422-2.831 6.649-6.916zm-11.062 3.511c-1.246 1.453-4.011 3.976-4.011 3.976s-.121.119-.31.023c-.076-.057-.108-.09-.108-.09-.443-.441-3.368-3.049-4.034-3.954-.709-.965-1.041-2.7-.091-3.71.951-1.01 3.005-1.086 4.363.407 0 0 1.565-1.782 3.468-.963 1.904.82 1.832 3.011.723 4.311zm6.173.478c-.928.116-1.682.028-1.682.028V7.284h1.77s1.971.551 1.971 2.638c0 1.913-.985 2.667-2.059 3.015z"/> 263 + </svg> 264 + buy me a coffee 265 + </a> 266 + </div> 267 + </section> 268 + 269 + <!-- footer note --> 270 + <p class="footer-note"> 271 + {APP_NAME} is an open-source project. 272 + <a href="https://github.com/zzstoatzz/plyr.fm" target="_blank" rel="noopener">view source</a> 273 + </p> 274 + {/if} 275 + </main> 276 + 277 + <style> 278 + main { 279 + max-width: 600px; 280 + margin: 0 auto; 281 + padding: 0 1rem calc(var(--player-height, 120px) + 2rem + env(safe-area-inset-bottom, 0px)); 282 + } 283 + 284 + .page-header { 285 + margin-bottom: 2rem; 286 + } 287 + 288 + .page-header h1 { 289 + font-size: var(--text-page-heading); 290 + margin: 0 0 0.5rem; 291 + } 292 + 293 + .subtitle { 294 + color: var(--text-tertiary); 295 + font-size: 0.9rem; 296 + margin: 0; 297 + } 298 + 299 + .loading { 300 + display: flex; 301 + justify-content: center; 302 + padding: 4rem 0; 303 + } 304 + 305 + .error-state { 306 + text-align: center; 307 + padding: 3rem 1rem; 308 + color: var(--text-secondary); 309 + } 310 + 311 + .error-state .hint { 312 + color: var(--text-tertiary); 313 + font-size: 0.85rem; 314 + margin-top: 0.5rem; 315 + } 316 + 317 + /* total section */ 318 + .total-section { 319 + margin-bottom: 2rem; 320 + } 321 + 322 + .total-card { 323 + display: flex; 324 + flex-direction: column; 325 + align-items: center; 326 + padding: 2rem; 327 + background: var(--bg-tertiary); 328 + border: 1px solid var(--border-subtle); 329 + border-radius: 12px; 330 + } 331 + 332 + .total-label { 333 + font-size: 0.8rem; 334 + text-transform: uppercase; 335 + letter-spacing: 0.08em; 336 + color: var(--text-tertiary); 337 + margin-bottom: 0.5rem; 338 + } 339 + 340 + .total-amount { 341 + font-size: 3rem; 342 + font-weight: 700; 343 + color: var(--accent); 344 + } 345 + 346 + .updated { 347 + text-align: center; 348 + font-size: 0.75rem; 349 + color: var(--text-tertiary); 350 + margin-top: 0.75rem; 351 + } 352 + 353 + /* breakdown section */ 354 + .breakdown-section { 355 + margin-bottom: 2rem; 356 + } 357 + 358 + .breakdown-section h2, 359 + .audd-section h2 { 360 + font-size: 0.8rem; 361 + text-transform: uppercase; 362 + letter-spacing: 0.08em; 363 + color: var(--text-tertiary); 364 + margin-bottom: 1rem; 365 + } 366 + 367 + .cost-bars { 368 + display: flex; 369 + flex-direction: column; 370 + gap: 1rem; 371 + } 372 + 373 + .cost-item { 374 + background: var(--bg-tertiary); 375 + border: 1px solid var(--border-subtle); 376 + border-radius: 8px; 377 + padding: 1rem; 378 + } 379 + 380 + .cost-header { 381 + display: flex; 382 + justify-content: space-between; 383 + align-items: center; 384 + margin-bottom: 0.5rem; 385 + } 386 + 387 + .cost-name { 388 + font-weight: 600; 389 + color: var(--text-primary); 390 + } 391 + 392 + .cost-amount { 393 + font-weight: 600; 394 + color: var(--accent); 395 + font-variant-numeric: tabular-nums; 396 + } 397 + 398 + .cost-bar-bg { 399 + height: 8px; 400 + background: var(--bg-primary); 401 + border-radius: 4px; 402 + overflow: hidden; 403 + margin-bottom: 0.5rem; 404 + } 405 + 406 + .cost-bar { 407 + height: 100%; 408 + background: var(--accent); 409 + border-radius: 4px; 410 + transition: width 0.3s ease; 411 + } 412 + 413 + .cost-bar.audd { 414 + background: var(--warning); 415 + } 416 + 417 + .cost-note { 418 + font-size: 0.75rem; 419 + color: var(--text-tertiary); 420 + } 421 + 422 + /* audd section */ 423 + .audd-section { 424 + margin-bottom: 2rem; 425 + } 426 + 427 + .audd-stats { 428 + display: grid; 429 + grid-template-columns: repeat(3, 1fr); 430 + gap: 1rem; 431 + margin-bottom: 1.5rem; 432 + } 433 + 434 + .stat { 435 + display: flex; 436 + flex-direction: column; 437 + align-items: center; 438 + padding: 1rem; 439 + background: var(--bg-tertiary); 440 + border: 1px solid var(--border-subtle); 441 + border-radius: 8px; 442 + } 443 + 444 + .stat-value { 445 + font-size: 1.25rem; 446 + font-weight: 700; 447 + color: var(--text-primary); 448 + font-variant-numeric: tabular-nums; 449 + } 450 + 451 + .stat-label { 452 + font-size: 0.7rem; 453 + color: var(--text-tertiary); 454 + text-align: center; 455 + margin-top: 0.25rem; 456 + } 457 + 458 + /* daily chart */ 459 + .daily-chart { 460 + background: var(--bg-tertiary); 461 + border: 1px solid var(--border-subtle); 462 + border-radius: 8px; 463 + padding: 1rem; 464 + } 465 + 466 + .daily-chart h3 { 467 + font-size: 0.75rem; 468 + text-transform: uppercase; 469 + letter-spacing: 0.05em; 470 + color: var(--text-tertiary); 471 + margin: 0 0 1rem; 472 + } 473 + 474 + .chart-bars { 475 + display: flex; 476 + align-items: flex-end; 477 + gap: 4px; 478 + height: 100px; 479 + } 480 + 481 + .chart-bar-container { 482 + flex: 1; 483 + display: flex; 484 + flex-direction: column; 485 + align-items: center; 486 + height: 100%; 487 + } 488 + 489 + .chart-bar { 490 + width: 100%; 491 + background: var(--accent); 492 + border-radius: 2px 2px 0 0; 493 + min-height: 4px; 494 + margin-top: auto; 495 + transition: height 0.3s ease; 496 + } 497 + 498 + .chart-bar:hover { 499 + opacity: 0.8; 500 + } 501 + 502 + .chart-label { 503 + font-size: 0.6rem; 504 + color: var(--text-tertiary); 505 + margin-top: 0.5rem; 506 + white-space: nowrap; 507 + } 508 + 509 + /* support section */ 510 + .support-section { 511 + margin-bottom: 2rem; 512 + } 513 + 514 + .support-card { 515 + display: flex; 516 + flex-direction: column; 517 + align-items: center; 518 + text-align: center; 519 + padding: 2rem; 520 + background: linear-gradient(135deg, 521 + color-mix(in srgb, var(--accent) 10%, var(--bg-tertiary)), 522 + var(--bg-tertiary) 523 + ); 524 + border: 1px solid var(--border-subtle); 525 + border-radius: 12px; 526 + } 527 + 528 + .support-icon { 529 + color: var(--accent); 530 + margin-bottom: 1rem; 531 + } 532 + 533 + .support-text h3 { 534 + margin: 0 0 0.5rem; 535 + font-size: 1.1rem; 536 + color: var(--text-primary); 537 + } 538 + 539 + .support-text p { 540 + margin: 0 0 1.5rem; 541 + color: var(--text-secondary); 542 + font-size: 0.9rem; 543 + } 544 + 545 + .kofi-button { 546 + display: inline-flex; 547 + align-items: center; 548 + gap: 0.5rem; 549 + padding: 0.75rem 1.5rem; 550 + background: #ff5e5b; 551 + color: white; 552 + border-radius: 8px; 553 + text-decoration: none; 554 + font-weight: 600; 555 + font-size: 0.9rem; 556 + transition: transform 0.15s, box-shadow 0.15s; 557 + } 558 + 559 + .kofi-button:hover { 560 + transform: translateY(-2px); 561 + box-shadow: 0 4px 12px rgba(255, 94, 91, 0.3); 562 + } 563 + 564 + /* footer */ 565 + .footer-note { 566 + text-align: center; 567 + font-size: 0.8rem; 568 + color: var(--text-tertiary); 569 + padding-bottom: 1rem; 570 + } 571 + 572 + .footer-note a { 573 + color: var(--accent); 574 + text-decoration: none; 575 + } 576 + 577 + .footer-note a:hover { 578 + text-decoration: underline; 579 + } 580 + 581 + @media (max-width: 480px) { 582 + .total-amount { 583 + font-size: 2.5rem; 584 + } 585 + 586 + .audd-stats { 587 + grid-template-columns: 1fr; 588 + } 589 + 590 + .chart-label { 591 + display: none; 592 + } 593 + } 594 + </style>
+313
scripts/costs/export_costs.py
··· 1 + #!/usr/bin/env python3 2 + # /// script 3 + # requires-python = ">=3.11" 4 + # dependencies = ["asyncpg", "boto3", "pydantic-settings", "typer"] 5 + # /// 6 + """export platform costs to R2 for public dashboard 7 + 8 + usage: 9 + uv run scripts/costs/export_costs.py # export to R2 (prod) 10 + uv run scripts/costs/export_costs.py --dry-run # print JSON, don't upload 11 + uv run scripts/costs/export_costs.py --env stg # use staging db 12 + """ 13 + 14 + import asyncio 15 + import json 16 + import re 17 + from datetime import UTC, datetime, timedelta 18 + from typing import Any 19 + 20 + import typer 21 + from pydantic_settings import BaseSettings, SettingsConfigDict 22 + 23 + # billing constants 24 + AUDD_BILLING_DAY = 24 25 + 26 + # hardcoded monthly costs (updated 2025-12-09) 27 + # source: fly.io cost explorer, neon billing, cloudflare billing, audd dashboard 28 + # NOTE: audd usage comes from their dashboard, not our database 29 + # (copyright_scans table only has data since Nov 30, 2025) 30 + FIXED_COSTS = { 31 + "fly_io": { 32 + "total": 28.83, 33 + "breakdown": { 34 + "relay-api": 5.80, # prod backend 35 + "relay-api-staging": 5.60, 36 + "plyr-moderation": 0.24, 37 + "plyr-transcoder": 0.02, 38 + # non-plyr apps (included in org total but not plyr-specific) 39 + # "bsky-feed": 7.46, 40 + # "pds-zzstoatzz-io": 5.48, 41 + # "zzstoatzz-status": 3.48, 42 + # "at-me": 0.58, 43 + # "find-bufo": 0.13, 44 + }, 45 + "note": "~40% of org total ($28.83) is plyr.fm", 46 + }, 47 + "neon": { 48 + "total": 5.00, 49 + "note": "postgres serverless (3 projects: dev/stg/prd)", 50 + }, 51 + "cloudflare": { 52 + "r2": 0.16, 53 + "pages": 0.00, 54 + "domain": 1.00, 55 + "total": 1.16, 56 + "note": "r2 egress is free, pages free tier", 57 + }, 58 + # audd: ONE-TIME ADJUSTMENT for Nov 24 - Dec 24 billing period 59 + # the copyright_scans table was created Nov 24 but first scan recorded Nov 30 60 + # so we hardcode this period from AudD dashboard. DELETE THIS after Dec 24 - 61 + # future periods will use live database counts. 62 + # source: https://dashboard.audd.io - checked 2025-12-09 63 + "audd": { 64 + "total_requests": 6781, 65 + "included_requests": 6000, # 1000 + 5000 bonus 66 + "billable_requests": 781, 67 + "cost_per_request": 0.005, # $5 per 1000 68 + "cost": 3.91, # 781 * $0.005 69 + "note": "copyright detection API (indie plan)", 70 + }, 71 + } 72 + 73 + 74 + class Settings(BaseSettings): 75 + model_config = SettingsConfigDict(env_file=(".env", "backend/.env"), extra="ignore") 76 + 77 + neon_database_url: str | None = None 78 + neon_database_url_prd: str | None = None 79 + neon_database_url_stg: str | None = None 80 + neon_database_url_dev: str | None = None 81 + 82 + # r2 for upload 83 + aws_access_key_id: str = "" 84 + aws_secret_access_key: str = "" 85 + r2_endpoint_url: str = "" 86 + r2_bucket: str = "" 87 + r2_public_bucket_url: str = "" 88 + 89 + def get_db_url(self, env: str) -> str: 90 + """get database url for environment, converting to asyncpg format""" 91 + url = getattr(self, f"neon_database_url_{env}", None) or self.neon_database_url 92 + if not url: 93 + raise ValueError(f"no database url for {env}") 94 + return re.sub(r"postgresql\+\w+://", "postgresql://", url) 95 + 96 + 97 + settings = Settings() 98 + app = typer.Typer(add_completion=False) 99 + 100 + 101 + def get_billing_period_start() -> datetime: 102 + """get the start of current billing period (24th of month)""" 103 + now = datetime.now() 104 + if now.day >= AUDD_BILLING_DAY: 105 + return datetime(now.year, now.month, AUDD_BILLING_DAY) 106 + else: 107 + first_of_month = datetime(now.year, now.month, 1) 108 + prev_month = first_of_month - timedelta(days=1) 109 + return datetime(prev_month.year, prev_month.month, AUDD_BILLING_DAY) 110 + 111 + 112 + async def get_audd_stats(db_url: str) -> dict[str, Any]: 113 + """fetch audd scan stats from postgres.""" 114 + import asyncpg 115 + 116 + billing_start = get_billing_period_start() 117 + audd_config = FIXED_COSTS["audd"] 118 + 119 + # ONE-TIME: use hardcoded values for Nov 24 - Dec 24 billing period 120 + # remove this check after Dec 24, 2025 121 + use_hardcoded = billing_start.month == 11 and billing_start.day == 24 122 + 123 + conn = await asyncpg.connect(db_url) 124 + try: 125 + # get database stats 126 + row = await conn.fetchrow( 127 + """ 128 + SELECT COUNT(*) as total, 129 + COUNT(CASE WHEN is_flagged THEN 1 END) as flagged 130 + FROM copyright_scans 131 + WHERE scanned_at >= $1 132 + """, 133 + billing_start, 134 + ) 135 + db_total = row["total"] 136 + db_flagged = row["flagged"] 137 + 138 + # daily breakdown for chart 139 + daily = await conn.fetch( 140 + """ 141 + SELECT DATE(scanned_at) as date, 142 + COUNT(*) as scans, 143 + COUNT(CASE WHEN is_flagged THEN 1 END) as flagged 144 + FROM copyright_scans 145 + WHERE scanned_at >= $1 146 + GROUP BY DATE(scanned_at) 147 + ORDER BY date 148 + """, 149 + billing_start, 150 + ) 151 + 152 + if use_hardcoded: 153 + # Nov 24 - Dec 24: use hardcoded values (incomplete db data) 154 + total = audd_config["total_requests"] 155 + included = audd_config["included_requests"] 156 + billable = audd_config["billable_requests"] 157 + cost = audd_config["cost"] 158 + else: 159 + # future billing periods: use live database counts 160 + total = db_total 161 + included = audd_config["included_requests"] 162 + billable = max(0, total - included) 163 + cost = round(billable * audd_config["cost_per_request"], 2) 164 + 165 + return { 166 + "billing_period_start": billing_start.isoformat(), 167 + "total_scans": total, 168 + "flagged": db_flagged, 169 + "flag_rate": round(db_flagged / db_total * 100, 1) if db_total else 0, 170 + "included_requests": included, 171 + "remaining_free": max(0, included - total), 172 + "billable_requests": billable, 173 + "estimated_cost": cost, 174 + "daily": [ 175 + { 176 + "date": r["date"].isoformat(), 177 + "scans": r["scans"], 178 + "flagged": r["flagged"], 179 + } 180 + for r in daily 181 + ], 182 + } 183 + finally: 184 + await conn.close() 185 + 186 + 187 + def build_cost_data(audd_stats: dict[str, Any]) -> dict[str, Any]: 188 + """assemble full cost dashboard data""" 189 + # calculate plyr-specific fly costs 190 + plyr_fly = sum(FIXED_COSTS["fly_io"]["breakdown"].values()) 191 + 192 + monthly_total = ( 193 + plyr_fly 194 + + FIXED_COSTS["neon"]["total"] 195 + + FIXED_COSTS["cloudflare"]["total"] 196 + + audd_stats["estimated_cost"] 197 + ) 198 + 199 + return { 200 + "generated_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"), 201 + "monthly_estimate": round(monthly_total, 2), 202 + "costs": { 203 + "fly_io": { 204 + "amount": round(plyr_fly, 2), 205 + "breakdown": FIXED_COSTS["fly_io"]["breakdown"], 206 + "note": "compute (2x shared-cpu VMs + moderation + transcoder)", 207 + }, 208 + "neon": { 209 + "amount": FIXED_COSTS["neon"]["total"], 210 + "note": "postgres serverless", 211 + }, 212 + "cloudflare": { 213 + "amount": FIXED_COSTS["cloudflare"]["total"], 214 + "breakdown": { 215 + "r2_storage": FIXED_COSTS["cloudflare"]["r2"], 216 + "pages": FIXED_COSTS["cloudflare"]["pages"], 217 + "domain": FIXED_COSTS["cloudflare"]["domain"], 218 + }, 219 + "note": "storage, hosting, domain", 220 + }, 221 + "audd": { 222 + "amount": audd_stats["estimated_cost"], 223 + "scans_this_period": audd_stats["total_scans"], 224 + "included_free": audd_stats["included_requests"], 225 + "remaining_free": audd_stats["remaining_free"], 226 + "flag_rate": audd_stats["flag_rate"], 227 + "daily": audd_stats["daily"], 228 + "note": "copyright detection API", 229 + }, 230 + }, 231 + "support": { 232 + "kofi": "https://ko-fi.com/zzstoatzz", 233 + "message": "help cover moderation costs", 234 + }, 235 + } 236 + 237 + 238 + async def upload_to_r2(data: dict[str, Any]) -> str: 239 + """upload json to r2 public bucket""" 240 + 241 + # s3-compatible signing for r2 242 + bucket = settings.r2_bucket 243 + key = "stats/costs.json" 244 + body = json.dumps(data, indent=2).encode() 245 + 246 + # use httpx with basic auth approach via presigned-like headers 247 + # actually simpler: use boto3-like signing or just httpx with aws4auth 248 + # for simplicity, let's use the s3 client approach 249 + 250 + try: 251 + import aioboto3 252 + except ImportError: 253 + # fallback to sync boto3 254 + import boto3 255 + 256 + s3 = boto3.client( 257 + "s3", 258 + endpoint_url=settings.r2_endpoint_url, 259 + aws_access_key_id=settings.aws_access_key_id, 260 + aws_secret_access_key=settings.aws_secret_access_key, 261 + ) 262 + s3.put_object( 263 + Bucket=bucket, 264 + Key=key, 265 + Body=body, 266 + ContentType="application/json", 267 + CacheControl="public, max-age=3600", # 1 hour cache 268 + ) 269 + return f"{settings.r2_public_bucket_url}/{key}" 270 + 271 + session = aioboto3.Session() 272 + async with session.client( 273 + "s3", 274 + endpoint_url=settings.r2_endpoint_url, 275 + aws_access_key_id=settings.aws_access_key_id, 276 + aws_secret_access_key=settings.aws_secret_access_key, 277 + ) as s3: 278 + await s3.put_object( 279 + Bucket=bucket, 280 + Key=key, 281 + Body=body, 282 + ContentType="application/json", 283 + CacheControl="public, max-age=3600", 284 + ) 285 + return f"{settings.r2_public_bucket_url}/{key}" 286 + 287 + 288 + @app.command() 289 + def main( 290 + dry_run: bool = typer.Option( 291 + False, "--dry-run", "-n", help="print json, don't upload" 292 + ), 293 + env: str = typer.Option("prd", "--env", "-e", help="environment: prd, stg, dev"), 294 + ) -> None: 295 + """export platform costs to R2 for public dashboard""" 296 + 297 + async def run(): 298 + db_url = settings.get_db_url(env) 299 + audd_stats = await get_audd_stats(db_url) 300 + data = build_cost_data(audd_stats) 301 + 302 + if dry_run: 303 + print(json.dumps(data, indent=2)) 304 + return 305 + 306 + url = await upload_to_r2(data) 307 + print(f"uploaded to {url}") 308 + 309 + asyncio.run(run()) 310 + 311 + 312 + if __name__ == "__main__": 313 + app()