feat: derive AudD costs from track duration for accurate billing (#600)

- calculate API requests from track duration (1 request = 12s audio)
- remove hardcoded Nov 24 fallback - now fully dynamic from DB
- add new fields: requests_this_period, base_cost, overage_cost, billable_requests
- update daily chart to show requests instead of scan count
- change GHA workflow from daily to hourly for near real-time visibility
- update frontend to display request-based metrics with explainer text

AudD billing: $5/mo base + $5/1k requests over 6000 free tier

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

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

authored by zzstoatzz.io Claude Opus 4.5 and committed by GitHub 5aa1109a b50e2b61

Changed files
+114 -82
.github
workflows
frontend
src
routes
costs
scripts
+5 -2
.github/workflows/export-costs.yml
··· 1 1 # export platform costs to R2 for public dashboard 2 2 # 3 - # runs daily at 6am UTC to update the costs.json file in R2 3 + # runs hourly to update the costs.json file in R2 4 4 # the frontend /costs page fetches this static JSON 5 + # 6 + # AudD costs are derived from track duration (1 request = 12s of audio) 7 + # so this gives near real-time cost visibility as uploads happen 5 8 # 6 9 # required secrets: 7 10 # NEON_DATABASE_URL_PRD - production database URL ··· 15 18 16 19 on: 17 20 schedule: 18 - - cron: "0 6 * * *" # daily at 6am UTC 21 + - cron: "0 * * * *" # hourly 19 22 workflow_dispatch: 20 23 inputs: 21 24 dry_run:
+35 -10
frontend/src/routes/costs/+page.svelte
··· 14 14 date: string; 15 15 scans: number; 16 16 flagged: number; 17 + requests: number; 17 18 } 18 19 19 20 interface CostData { ··· 36 37 }; 37 38 audd: { 38 39 amount: number; 40 + base_cost: number; 41 + overage_cost: number; 39 42 scans_this_period: number; 40 - included_free: number; 43 + requests_this_period: number; 44 + audio_seconds: number; 45 + free_requests: number; 41 46 remaining_free: number; 47 + billable_requests: number; 42 48 flag_rate: number; 43 49 daily: DailyData[]; 44 50 note: string; ··· 66 72 : 1 67 73 ); 68 74 69 - let maxScans = $derived( 75 + let maxRequests = $derived( 70 76 data?.costs.audd.daily.length 71 - ? Math.max(...data.costs.audd.daily.map((d) => d.scans)) 77 + ? Math.max(...data.costs.audd.daily.map((d) => d.requests)) 72 78 : 1 73 79 ); 74 80 ··· 213 219 <h2>copyright scanning (audd)</h2> 214 220 <div class="audd-stats"> 215 221 <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> 222 + <span class="stat-value">{data.costs.audd.requests_this_period.toLocaleString()}</span> 223 + <span class="stat-label">API requests</span> 218 224 </div> 219 225 <div class="stat"> 220 226 <span class="stat-value">{data.costs.audd.remaining_free.toLocaleString()}</span> 221 227 <span class="stat-label">free remaining</span> 222 228 </div> 223 229 <div class="stat"> 224 - <span class="stat-value">{data.costs.audd.flag_rate}%</span> 225 - <span class="stat-label">flag rate</span> 230 + <span class="stat-value">{data.costs.audd.scans_this_period.toLocaleString()}</span> 231 + <span class="stat-label">tracks scanned</span> 226 232 </div> 227 233 </div> 228 234 235 + <p class="audd-explainer"> 236 + 1 request = 12s of audio. {data.costs.audd.free_requests.toLocaleString()} free/month, 237 + then ${(5).toFixed(2)}/1k requests. 238 + {#if data.costs.audd.billable_requests > 0} 239 + <strong>{data.costs.audd.billable_requests.toLocaleString()} billable</strong> this period. 240 + {/if} 241 + </p> 242 + 229 243 {#if data.costs.audd.daily.length > 0} 230 244 <div class="daily-chart"> 231 - <h3>daily scans</h3> 245 + <h3>daily requests</h3> 232 246 <div class="chart-bars"> 233 247 {#each data.costs.audd.daily as day} 234 248 <div class="chart-bar-container"> 235 249 <div 236 250 class="chart-bar" 237 - style="height: {Math.max(4, (day.scans / maxScans) * 100)}%" 238 - title="{day.date}: {day.scans} scans, {day.flagged} flagged" 251 + style="height: {Math.max(4, (day.requests / maxRequests) * 100)}%" 252 + title="{day.date}: {day.requests} requests ({day.scans} tracks)" 239 253 ></div> 240 254 <span class="chart-label">{day.date.slice(5)}</span> 241 255 </div> ··· 428 442 display: grid; 429 443 grid-template-columns: repeat(3, 1fr); 430 444 gap: 1rem; 445 + margin-bottom: 1rem; 446 + } 447 + 448 + .audd-explainer { 449 + font-size: 0.8rem; 450 + color: var(--text-secondary); 431 451 margin-bottom: 1.5rem; 452 + line-height: 1.5; 453 + } 454 + 455 + .audd-explainer strong { 456 + color: var(--warning); 432 457 } 433 458 434 459 .stat {
+74 -70
scripts/costs/export_costs.py
··· 9 9 uv run scripts/costs/export_costs.py # export to R2 (prod) 10 10 uv run scripts/costs/export_costs.py --dry-run # print JSON, don't upload 11 11 uv run scripts/costs/export_costs.py --env stg # use staging db 12 + 13 + AudD billing model: 14 + - $5/month base (indie plan) 15 + - 6000 free requests/month (1000 base + 5000 bonus) 16 + - $5 per 1000 requests after free tier 17 + - 1 request = 12 seconds of audio 18 + - so a 5-minute track = ceil(300/12) = 25 requests 12 19 """ 13 20 14 21 import asyncio ··· 23 30 24 31 # billing constants 25 32 AUDD_BILLING_DAY = 24 33 + AUDD_SECONDS_PER_REQUEST = 12 34 + AUDD_FREE_REQUESTS = 6000 # 1000 base + 5000 bonus on indie plan 35 + AUDD_COST_PER_1000 = 5.00 # $5 per 1000 requests 36 + AUDD_BASE_COST = 5.00 # $5/month base 26 37 27 - # hardcoded monthly costs (updated 2025-12-09) 28 - # source: fly.io cost explorer, neon billing, cloudflare billing, audd dashboard 29 - # NOTE: audd usage comes from their dashboard, not our database 30 - # (copyright_scans table only has data since Nov 30, 2025) 38 + # fixed monthly costs (updated 2025-12-16) 39 + # fly.io: manually updated from cost explorer (TODO: use fly billing API) 40 + # neon: fixed $5/month 41 + # cloudflare: mostly free tier 31 42 FIXED_COSTS = { 32 43 "fly_io": { 33 - "total": 28.83, 34 44 "breakdown": { 35 45 "relay-api": 5.80, # prod backend 36 46 "relay-api-staging": 5.60, 37 47 "plyr-moderation": 0.24, 38 48 "plyr-transcoder": 0.02, 39 - # non-plyr apps (included in org total but not plyr-specific) 40 - # "bsky-feed": 7.46, 41 - # "pds-zzstoatzz-io": 5.48, 42 - # "zzstoatzz-status": 3.48, 43 - # "at-me": 0.58, 44 - # "find-bufo": 0.13, 45 49 }, 46 - "note": "~40% of org total ($28.83) is plyr.fm", 50 + "note": "compute (2x shared-cpu VMs + moderation + transcoder)", 47 51 }, 48 52 "neon": { 49 53 "total": 5.00, 50 - "note": "postgres serverless (3 projects: dev/stg/prd)", 54 + "note": "postgres serverless (fixed)", 51 55 }, 52 56 "cloudflare": { 53 57 "r2": 0.16, ··· 56 60 "total": 1.16, 57 61 "note": "r2 egress is free, pages free tier", 58 62 }, 59 - # audd: ONE-TIME ADJUSTMENT for Nov 24 - Dec 24 billing period 60 - # the copyright_scans table was created Nov 24 but first scan recorded Nov 30 61 - # so we hardcode this period from AudD dashboard. DELETE THIS after Dec 24 - 62 - # future periods will use live database counts. 63 - # source: https://dashboard.audd.io - checked 2025-12-10 64 - "audd": { 65 - "total_requests": 7826, # 6000 included + 1826 billable 66 - "included_requests": 6000, # 1000 + 5000 bonus 67 - "billable_requests": 1826, 68 - "cost_per_request": 0.005, # $5 per 1000 69 - "cost": 9.13, # 1826 * $0.005 70 - "note": "copyright detection API (indie plan)", 71 - }, 72 63 } 73 64 74 65 ··· 116 107 117 108 118 109 async def get_audd_stats(db_url: str) -> dict[str, Any]: 119 - """fetch audd scan stats from postgres.""" 110 + """fetch audd scan stats from postgres. 111 + 112 + calculates AudD API requests from track duration: 113 + - each 12 seconds of audio = 1 API request 114 + - derived by joining copyright_scans with tracks table 115 + """ 120 116 import asyncpg 121 117 122 118 billing_start = get_billing_period_start() 123 - audd_config = FIXED_COSTS["audd"] 124 - 125 - # ONE-TIME: use hardcoded values for Nov 24 - Dec 24 billing period 126 - # remove this check after Dec 24, 2025 127 - use_hardcoded = billing_start.month == 11 and billing_start.day == 24 128 119 129 120 conn = await asyncpg.connect(db_url) 130 121 try: 131 - # get database stats 122 + # get totals: scans, flagged, and derived API requests from duration 132 123 row = await conn.fetchrow( 133 124 """ 134 - SELECT COUNT(*) as total, 135 - COUNT(CASE WHEN is_flagged THEN 1 END) as flagged 136 - FROM copyright_scans 137 - WHERE scanned_at >= $1 125 + SELECT 126 + COUNT(*) as total_scans, 127 + COUNT(CASE WHEN cs.is_flagged THEN 1 END) as flagged, 128 + COALESCE(SUM(CEIL((t.extra->>'duration')::float / $2)), 0)::bigint as total_requests, 129 + COALESCE(SUM((t.extra->>'duration')::int), 0)::bigint as total_seconds 130 + FROM copyright_scans cs 131 + JOIN tracks t ON t.id = cs.track_id 132 + WHERE cs.scanned_at >= $1 138 133 """, 139 134 billing_start, 135 + AUDD_SECONDS_PER_REQUEST, 140 136 ) 141 - db_total = row["total"] 142 - db_flagged = row["flagged"] 137 + total_scans = row["total_scans"] 138 + flagged = row["flagged"] 139 + total_requests = row["total_requests"] 140 + total_seconds = row["total_seconds"] 143 141 144 - # daily breakdown for chart 142 + # daily breakdown for chart - now includes requests derived from duration 145 143 daily = await conn.fetch( 146 144 """ 147 - SELECT DATE(scanned_at) as date, 148 - COUNT(*) as scans, 149 - COUNT(CASE WHEN is_flagged THEN 1 END) as flagged 150 - FROM copyright_scans 151 - WHERE scanned_at >= $1 152 - GROUP BY DATE(scanned_at) 145 + SELECT 146 + DATE(cs.scanned_at) as date, 147 + COUNT(*) as scans, 148 + COUNT(CASE WHEN cs.is_flagged THEN 1 END) as flagged, 149 + COALESCE(SUM(CEIL((t.extra->>'duration')::float / $2)), 0)::bigint as requests 150 + FROM copyright_scans cs 151 + JOIN tracks t ON t.id = cs.track_id 152 + WHERE cs.scanned_at >= $1 153 + GROUP BY DATE(cs.scanned_at) 153 154 ORDER BY date 154 155 """, 155 156 billing_start, 157 + AUDD_SECONDS_PER_REQUEST, 156 158 ) 157 159 158 - if use_hardcoded: 159 - # Nov 24 - Dec 24: use hardcoded values (incomplete db data) 160 - total = audd_config["total_requests"] 161 - included = audd_config["included_requests"] 162 - billable = audd_config["billable_requests"] 163 - cost = audd_config["cost"] 164 - else: 165 - # future billing periods: use live database counts 166 - total = db_total 167 - included = audd_config["included_requests"] 168 - billable = max(0, total - included) 169 - cost = round(billable * audd_config["cost_per_request"], 2) 160 + # calculate costs 161 + billable_requests = max(0, total_requests - AUDD_FREE_REQUESTS) 162 + overage_cost = round(billable_requests * AUDD_COST_PER_1000 / 1000, 2) 163 + total_cost = AUDD_BASE_COST + overage_cost 170 164 171 165 return { 172 166 "billing_period_start": billing_start.isoformat(), 173 - "total_scans": total, 174 - "flagged": db_flagged, 175 - "flag_rate": round(db_flagged / db_total * 100, 1) if db_total else 0, 176 - "included_requests": included, 177 - "remaining_free": max(0, included - total), 178 - "billable_requests": billable, 179 - "estimated_cost": cost, 167 + "total_scans": total_scans, 168 + "total_requests": total_requests, 169 + "total_audio_seconds": total_seconds, 170 + "flagged": flagged, 171 + "flag_rate": round(flagged / total_scans * 100, 1) if total_scans else 0, 172 + "free_requests": AUDD_FREE_REQUESTS, 173 + "remaining_free": max(0, AUDD_FREE_REQUESTS - total_requests), 174 + "billable_requests": billable_requests, 175 + "base_cost": AUDD_BASE_COST, 176 + "overage_cost": overage_cost, 177 + "estimated_cost": total_cost, 180 178 "daily": [ 181 179 { 182 180 "date": r["date"].isoformat(), 183 181 "scans": r["scans"], 184 182 "flagged": r["flagged"], 183 + "requests": r["requests"], 185 184 } 186 185 for r in daily 187 186 ], ··· 209 208 "fly_io": { 210 209 "amount": round(plyr_fly, 2), 211 210 "breakdown": FIXED_COSTS["fly_io"]["breakdown"], 212 - "note": "compute (2x shared-cpu VMs + moderation + transcoder)", 211 + "note": FIXED_COSTS["fly_io"]["note"], 213 212 }, 214 213 "neon": { 215 214 "amount": FIXED_COSTS["neon"]["total"], 216 - "note": "postgres serverless", 215 + "note": FIXED_COSTS["neon"]["note"], 217 216 }, 218 217 "cloudflare": { 219 218 "amount": FIXED_COSTS["cloudflare"]["total"], ··· 222 221 "pages": FIXED_COSTS["cloudflare"]["pages"], 223 222 "domain": FIXED_COSTS["cloudflare"]["domain"], 224 223 }, 225 - "note": "storage, hosting, domain", 224 + "note": FIXED_COSTS["cloudflare"]["note"], 226 225 }, 227 226 "audd": { 228 227 "amount": audd_stats["estimated_cost"], 228 + "base_cost": audd_stats["base_cost"], 229 + "overage_cost": audd_stats["overage_cost"], 229 230 "scans_this_period": audd_stats["total_scans"], 230 - "included_free": audd_stats["included_requests"], 231 + "requests_this_period": audd_stats["total_requests"], 232 + "audio_seconds": audd_stats["total_audio_seconds"], 233 + "free_requests": audd_stats["free_requests"], 231 234 "remaining_free": audd_stats["remaining_free"], 235 + "billable_requests": audd_stats["billable_requests"], 232 236 "flag_rate": audd_stats["flag_rate"], 233 237 "daily": audd_stats["daily"], 234 - "note": "copyright detection API", 238 + "note": f"copyright detection ($5 base + ${AUDD_COST_PER_1000}/1k requests over {AUDD_FREE_REQUESTS})", 235 239 }, 236 240 }, 237 241 "support": {