audio streaming app plyr.fm
38
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 2025.1210.061611 293 lines 9.8 kB view raw
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()