feat: add audd api cost tracking dashboard (#546)

terminal dashboard for monitoring AudD copyright scan API usage
and costs. tracks scans per billing period (24th of month), shows
remaining free requests, estimated costs, and daily breakdown with
plotext charts.

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

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

authored by zzstoatzz.io Claude and committed by GitHub 8fdef170 5d53ac11

Changed files
+255
scripts
+255
scripts/audd_costs.py
··· 1 + #!/usr/bin/env python3 2 + # /// script 3 + # requires-python = ">=3.11" 4 + # dependencies = ["asyncpg", "rich", "plotext", "typer", "pydantic-settings"] 5 + # /// 6 + """audd api cost tracker - because $0.005 adds up 7 + 8 + usage: 9 + uv run scripts/audd_costs.py # current billing period (prod) 10 + uv run scripts/audd_costs.py --all # all time stats 11 + 12 + set NEON_DATABASE_URL in .env (or NEON_DATABASE_URL_PRD, _STG, _DEV) 13 + """ 14 + 15 + import asyncio 16 + import re 17 + from datetime import datetime, timedelta 18 + from typing import Any 19 + 20 + import plotext as plt 21 + import typer 22 + from pydantic_settings import BaseSettings, SettingsConfigDict 23 + from rich.console import Console 24 + from rich.panel import Panel 25 + from rich.table import Table 26 + 27 + # audd indie plan pricing 28 + INCLUDED_REQUESTS = 6000 # 1000 + 5000 bonus 29 + COST_PER_REQUEST = 0.005 # $5 per 1000 30 + BILLING_DAY = 24 # payment expected on the 24th 31 + 32 + 33 + class Settings(BaseSettings): 34 + model_config = SettingsConfigDict(env_file=".env", extra="ignore") 35 + 36 + neon_database_url: str | None = None 37 + neon_database_url_prd: str | None = None 38 + neon_database_url_stg: str | None = None 39 + neon_database_url_dev: str | None = None 40 + 41 + def get_url(self, env: str) -> str: 42 + """get database url for environment, converting to asyncpg format""" 43 + url = getattr(self, f"neon_database_url_{env}", None) or self.neon_database_url 44 + if not url: 45 + raise ValueError( 46 + f"no database url for {env} - set NEON_DATABASE_URL or NEON_DATABASE_URL_{env.upper()}" 47 + ) 48 + # convert sqlalchemy dialect to plain postgres 49 + return re.sub(r"postgresql\+\w+://", "postgresql://", url) 50 + 51 + 52 + settings = Settings() 53 + 54 + console = Console() 55 + app = typer.Typer(add_completion=False) 56 + 57 + 58 + def get_billing_period_start() -> datetime: 59 + """get the start of current billing period (24th of month)""" 60 + now = datetime.now() 61 + if now.day >= BILLING_DAY: 62 + return datetime(now.year, now.month, BILLING_DAY) 63 + else: 64 + first_of_month = datetime(now.year, now.month, 1) 65 + prev_month = first_of_month - timedelta(days=1) 66 + return datetime(prev_month.year, prev_month.month, BILLING_DAY) 67 + 68 + 69 + async def query_scans( 70 + db_url: str, since: datetime | None = None 71 + ) -> list[dict[str, Any]]: 72 + """fetch scan data from postgres""" 73 + import asyncpg 74 + 75 + conn = await asyncpg.connect(db_url) 76 + try: 77 + if since: 78 + rows = await conn.fetch( 79 + """ 80 + SELECT DATE(scanned_at) as date, 81 + COUNT(*) as scans, 82 + COUNT(CASE WHEN is_flagged THEN 1 END) as flagged 83 + FROM copyright_scans 84 + WHERE scanned_at >= $1 85 + GROUP BY DATE(scanned_at) 86 + ORDER BY date 87 + """, 88 + since, 89 + ) 90 + else: 91 + rows = await conn.fetch( 92 + """ 93 + SELECT DATE(scanned_at) as date, 94 + COUNT(*) as scans, 95 + COUNT(CASE WHEN is_flagged THEN 1 END) as flagged 96 + FROM copyright_scans 97 + GROUP BY DATE(scanned_at) 98 + ORDER BY date 99 + """ 100 + ) 101 + return [dict(r) for r in rows] 102 + finally: 103 + await conn.close() 104 + 105 + 106 + async def get_totals(db_url: str, since: datetime | None = None) -> dict[str, int]: 107 + """get total counts""" 108 + import asyncpg 109 + 110 + conn = await asyncpg.connect(db_url) 111 + try: 112 + if since: 113 + row = await conn.fetchrow( 114 + """ 115 + SELECT COUNT(*) as total, 116 + COUNT(CASE WHEN is_flagged THEN 1 END) as flagged 117 + FROM copyright_scans 118 + WHERE scanned_at >= $1 119 + """, 120 + since, 121 + ) 122 + else: 123 + row = await conn.fetchrow( 124 + """ 125 + SELECT COUNT(*) as total, 126 + COUNT(CASE WHEN is_flagged THEN 1 END) as flagged 127 + FROM copyright_scans 128 + """ 129 + ) 130 + return {"total": row["total"], "flagged": row["flagged"]} 131 + finally: 132 + await conn.close() 133 + 134 + 135 + def calculate_cost(total_requests: int) -> tuple[int, float]: 136 + """calculate billable requests and cost""" 137 + billable = max(0, total_requests - INCLUDED_REQUESTS) 138 + cost = billable * COST_PER_REQUEST 139 + return billable, cost 140 + 141 + 142 + def display_dashboard( 143 + daily_data: list[dict[str, Any]], 144 + totals: dict[str, int], 145 + period_label: str, 146 + env: str, 147 + ) -> None: 148 + """render the cost dashboard""" 149 + console.print(f"\n[bold cyan]audd api costs[/] - {period_label} [{env}]\n") 150 + 151 + total = totals["total"] 152 + flagged = totals["flagged"] 153 + billable, cost = calculate_cost(total) 154 + remaining_free = max(0, INCLUDED_REQUESTS - total) 155 + 156 + # stats panel 157 + stats_table = Table(show_header=False, box=None, padding=(0, 2)) 158 + stats_table.add_column(style="dim") 159 + stats_table.add_column(style="bold green", justify="right") 160 + 161 + stats_table.add_row("total scans", f"{total:,}") 162 + stats_table.add_row("flagged (matches)", f"{flagged:,}") 163 + stats_table.add_row("flag rate", f"{flagged / total * 100:.1f}%" if total else "0%") 164 + stats_table.add_row("", "") 165 + stats_table.add_row("included requests", f"{INCLUDED_REQUESTS:,}") 166 + stats_table.add_row("remaining free", f"{remaining_free:,}") 167 + stats_table.add_row("billable requests", f"{billable:,}") 168 + stats_table.add_row( 169 + "estimated cost", 170 + f"[{'red' if cost > 0 else 'green'}]${cost:.2f}[/]", 171 + ) 172 + 173 + console.print( 174 + Panel(stats_table, title="[bold]usage & costs[/]", border_style="blue") 175 + ) 176 + 177 + if not daily_data: 178 + console.print("[dim]no scan data available[/]") 179 + return 180 + 181 + # extract data - use indices for x-axis to avoid plotext date parsing 182 + dates = [d["date"].strftime("%m/%d") for d in daily_data] 183 + scans = [d["scans"] for d in daily_data] 184 + flagged_counts = [d["flagged"] for d in daily_data] 185 + x = list(range(len(dates))) 186 + 187 + # daily scans chart 188 + plt.clear_figure() 189 + plt.theme("dark") 190 + plt.title("daily scans") 191 + plt.bar(x, scans, color="cyan", label="scans") 192 + plt.xticks(x, dates) 193 + plt.plotsize(80, 12) 194 + plt.show() 195 + print() 196 + 197 + # cumulative cost projection 198 + cumulative = [] 199 + running = 0 200 + for s in scans: 201 + running += s 202 + _, c = calculate_cost(running) 203 + cumulative.append(c) 204 + 205 + if any(c > 0 for c in cumulative): 206 + plt.clear_figure() 207 + plt.theme("dark") 208 + plt.title("cumulative cost ($)") 209 + plt.plot(x, cumulative, color="red", marker="braille") 210 + plt.xticks(x, dates) 211 + plt.plotsize(80, 10) 212 + plt.show() 213 + print() 214 + 215 + # flag rate over time 216 + rates = [f / s * 100 if s > 0 else 0 for f, s in zip(flagged_counts, scans)] 217 + plt.clear_figure() 218 + plt.theme("dark") 219 + plt.title("flag rate (%)") 220 + plt.plot(x, rates, color="yellow", marker="braille") 221 + plt.xticks(x, dates) 222 + plt.plotsize(80, 10) 223 + plt.show() 224 + print() 225 + 226 + 227 + @app.command() 228 + def main( 229 + all_time: bool = typer.Option(False, "--all", "-a", help="show all time stats"), 230 + env: str = typer.Option("prd", "--env", "-e", help="environment: prd, stg, dev"), 231 + ) -> None: 232 + """audd api cost tracker for plyr.fm""" 233 + try: 234 + db_url = settings.get_url(env) 235 + except ValueError as e: 236 + console.print(f"[red]error:[/] {e}") 237 + raise typer.Exit(1) 238 + 239 + async def run(): 240 + if all_time: 241 + since = None 242 + label = "all time" 243 + else: 244 + since = get_billing_period_start() 245 + label = f"billing period (since {since.strftime('%b %d')})" 246 + 247 + daily_data = await query_scans(db_url, since) 248 + totals = await get_totals(db_url, since) 249 + display_dashboard(daily_data, totals, label, env) 250 + 251 + asyncio.run(run()) 252 + 253 + 254 + if __name__ == "__main__": 255 + app()