audio streaming app
plyr.fm
1#!/usr/bin/env python3
2# /// script
3# requires-python = ">=3.11"
4# dependencies = ["asyncpg", "boto3", "pydantic", "pydantic-settings", "typer"]
5# ///
6"""export platform costs to R2 for public dashboard
7
8usage:
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
14import asyncio
15import json
16import re
17from datetime import UTC, datetime, timedelta
18from typing import Any
19
20import typer
21from pydantic import Field
22from pydantic_settings import BaseSettings, SettingsConfigDict
23
24# billing constants
25AUDD_BILLING_DAY = 24
26
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)
31FIXED_COSTS = {
32 "fly_io": {
33 "total": 28.83,
34 "breakdown": {
35 "relay-api": 5.80, # prod backend
36 "relay-api-staging": 5.60,
37 "plyr-moderation": 0.24,
38 "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 },
46 "note": "~40% of org total ($28.83) is plyr.fm",
47 },
48 "neon": {
49 "total": 5.00,
50 "note": "postgres serverless (3 projects: dev/stg/prd)",
51 },
52 "cloudflare": {
53 "r2": 0.16,
54 "pages": 0.00,
55 "domain": 1.00,
56 "total": 1.16,
57 "note": "r2 egress is free, pages free tier",
58 },
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-09
64 "audd": {
65 "total_requests": 6781,
66 "included_requests": 6000, # 1000 + 5000 bonus
67 "billable_requests": 781,
68 "cost_per_request": 0.005, # $5 per 1000
69 "cost": 3.91, # 781 * $0.005
70 "note": "copyright detection API (indie plan)",
71 },
72}
73
74
75class Settings(BaseSettings):
76 model_config = SettingsConfigDict(env_file=(".env", "backend/.env"), extra="ignore")
77
78 neon_database_url: str | None = None
79 neon_database_url_prd: str | None = None
80 neon_database_url_stg: str | None = None
81 neon_database_url_dev: str | None = None
82
83 # r2 stats bucket (dedicated, shared across environments)
84 aws_access_key_id: str = ""
85 aws_secret_access_key: str = ""
86 r2_endpoint_url: str = ""
87 r2_stats_bucket: str = Field(
88 default="plyr-stats", validation_alias="R2_STATS_BUCKET"
89 )
90 r2_stats_public_url: str = Field(
91 default="https://pub-68f2c7379f204d81bdf65152b0ff0207.r2.dev",
92 validation_alias="R2_STATS_PUBLIC_URL",
93 )
94
95 def get_db_url(self, env: str) -> str:
96 """get database url for environment, converting to asyncpg format"""
97 url = getattr(self, f"neon_database_url_{env}", None) or self.neon_database_url
98 if not url:
99 raise ValueError(f"no database url for {env}")
100 return re.sub(r"postgresql\+\w+://", "postgresql://", url)
101
102
103settings = Settings()
104app = typer.Typer(add_completion=False)
105
106
107def get_billing_period_start() -> datetime:
108 """get the start of current billing period (24th of month)"""
109 now = datetime.now()
110 if now.day >= AUDD_BILLING_DAY:
111 return datetime(now.year, now.month, AUDD_BILLING_DAY)
112 else:
113 first_of_month = datetime(now.year, now.month, 1)
114 prev_month = first_of_month - timedelta(days=1)
115 return datetime(prev_month.year, prev_month.month, AUDD_BILLING_DAY)
116
117
118async def get_audd_stats(db_url: str) -> dict[str, Any]:
119 """fetch audd scan stats from postgres."""
120 import asyncpg
121
122 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
129 conn = await asyncpg.connect(db_url)
130 try:
131 # get database stats
132 row = await conn.fetchrow(
133 """
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
138 """,
139 billing_start,
140 )
141 db_total = row["total"]
142 db_flagged = row["flagged"]
143
144 # daily breakdown for chart
145 daily = await conn.fetch(
146 """
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)
153 ORDER BY date
154 """,
155 billing_start,
156 )
157
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)
170
171 return {
172 "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,
180 "daily": [
181 {
182 "date": r["date"].isoformat(),
183 "scans": r["scans"],
184 "flagged": r["flagged"],
185 }
186 for r in daily
187 ],
188 }
189 finally:
190 await conn.close()
191
192
193def build_cost_data(audd_stats: dict[str, Any]) -> dict[str, Any]:
194 """assemble full cost dashboard data"""
195 # calculate plyr-specific fly costs
196 plyr_fly = sum(FIXED_COSTS["fly_io"]["breakdown"].values())
197
198 monthly_total = (
199 plyr_fly
200 + FIXED_COSTS["neon"]["total"]
201 + FIXED_COSTS["cloudflare"]["total"]
202 + audd_stats["estimated_cost"]
203 )
204
205 return {
206 "generated_at": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
207 "monthly_estimate": round(monthly_total, 2),
208 "costs": {
209 "fly_io": {
210 "amount": round(plyr_fly, 2),
211 "breakdown": FIXED_COSTS["fly_io"]["breakdown"],
212 "note": "compute (2x shared-cpu VMs + moderation + transcoder)",
213 },
214 "neon": {
215 "amount": FIXED_COSTS["neon"]["total"],
216 "note": "postgres serverless",
217 },
218 "cloudflare": {
219 "amount": FIXED_COSTS["cloudflare"]["total"],
220 "breakdown": {
221 "r2_storage": FIXED_COSTS["cloudflare"]["r2"],
222 "pages": FIXED_COSTS["cloudflare"]["pages"],
223 "domain": FIXED_COSTS["cloudflare"]["domain"],
224 },
225 "note": "storage, hosting, domain",
226 },
227 "audd": {
228 "amount": audd_stats["estimated_cost"],
229 "scans_this_period": audd_stats["total_scans"],
230 "included_free": audd_stats["included_requests"],
231 "remaining_free": audd_stats["remaining_free"],
232 "flag_rate": audd_stats["flag_rate"],
233 "daily": audd_stats["daily"],
234 "note": "copyright detection API",
235 },
236 },
237 "support": {
238 "kofi": "https://ko-fi.com/zzstoatzz",
239 "message": "help cover moderation costs",
240 },
241 }
242
243
244async def upload_to_r2(data: dict[str, Any]) -> str:
245 """upload json to dedicated stats bucket"""
246 import boto3
247
248 bucket = settings.r2_stats_bucket
249 key = "costs.json"
250 body = json.dumps(data, indent=2).encode()
251
252 s3 = boto3.client(
253 "s3",
254 endpoint_url=settings.r2_endpoint_url,
255 aws_access_key_id=settings.aws_access_key_id,
256 aws_secret_access_key=settings.aws_secret_access_key,
257 )
258 s3.put_object(
259 Bucket=bucket,
260 Key=key,
261 Body=body,
262 ContentType="application/json",
263 CacheControl="public, max-age=3600",
264 )
265 return f"{settings.r2_stats_public_url}/{key}"
266
267
268@app.command()
269def main(
270 dry_run: bool = typer.Option(
271 False, "--dry-run", "-n", help="print json, don't upload"
272 ),
273 env: str = typer.Option("prd", "--env", "-e", help="environment: prd, stg, dev"),
274) -> None:
275 """export platform costs to R2 for public dashboard"""
276
277 async def run():
278 db_url = settings.get_db_url(env)
279 audd_stats = await get_audd_stats(db_url)
280 data = build_cost_data(audd_stats)
281
282 if dry_run:
283 print(json.dumps(data, indent=2))
284 return
285
286 url = await upload_to_r2(data)
287 print(f"uploaded to {url}")
288
289 asyncio.run(run())
290
291
292if __name__ == "__main__":
293 app()