feat: add total duration to platform stats and artist analytics (#522)

- add `total_duration_seconds` to platform stats endpoint (`/stats`)
- add `total_duration_seconds` to artist analytics endpoint (`/artists/{did}/analytics`)
- display duration in homepage stats bar (header and menu variants)
- show duration as subtitle in artist page "total tracks" card
- add `formatDuration()` helper for human-readable format (e.g., "25h 32m")
- add `scripts/user_upload_stats.py` for viewing per-user upload durations
- add regression tests for stats and analytics endpoints

this lays groundwork for future upload caps per user.

🤖 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 f2ba21bf 726d0b11

Changed files
+460 -9
backend
frontend
src
lib
routes
u
[handle]
scripts
+11 -6
backend/src/backend/api/artists.py
··· 6 6 7 7 from fastapi import APIRouter, Depends, HTTPException 8 8 from pydantic import BaseModel, ConfigDict, field_validator 9 - from sqlalchemy import func, select 9 + from sqlalchemy import func, select, text 10 10 from sqlalchemy.ext.asyncio import AsyncSession 11 11 12 12 from backend._internal import Session, require_auth ··· 73 73 74 74 total_plays: int 75 75 total_items: int 76 + total_duration_seconds: int 76 77 top_item: TopItemResponse | None 77 78 top_liked: TopItemResponse | None 78 79 ··· 256 257 257 258 returns zeros if artist has no tracks. 258 259 """ 259 - # get total plays and item count in one query 260 + # get total plays, item count, and duration in one query 260 261 result = await db.execute( 261 - select(func.sum(Track.play_count), func.count(Track.id)).where( 262 - Track.artist_did == artist_did 263 - ) 262 + select( 263 + func.sum(Track.play_count), 264 + func.count(Track.id), 265 + func.coalesce(func.sum(text("(extra->>'duration')::int")), 0), 266 + ).where(Track.artist_did == artist_did) 264 267 ) 265 - total_plays, total_items = result.one() 268 + total_plays, total_items, total_duration = result.one() 266 269 total_plays = total_plays or 0 # handle None when no tracks 267 270 total_items = total_items or 0 271 + total_duration = total_duration or 0 268 272 269 273 # get top item by plays (only if artist has tracks) 270 274 top_item = None ··· 305 309 return AnalyticsResponse( 306 310 total_plays=total_plays, 307 311 total_items=total_items, 312 + total_duration_seconds=total_duration, 308 313 top_item=top_item, 309 314 top_liked=top_liked, 310 315 )
+8 -1
backend/src/backend/api/stats.py
··· 4 4 5 5 from fastapi import APIRouter, Depends 6 6 from pydantic import BaseModel 7 - from sqlalchemy import func, select 7 + from sqlalchemy import func, select, text 8 8 from sqlalchemy.ext.asyncio import AsyncSession 9 9 10 10 from backend.models import Track, get_db ··· 18 18 total_plays: int 19 19 total_tracks: int 20 20 total_artists: int 21 + total_duration_seconds: int 21 22 22 23 23 24 @router.get("") ··· 30 31 func.coalesce(func.sum(Track.play_count), 0), 31 32 func.count(Track.id), 32 33 func.count(func.distinct(Track.artist_did)), 34 + # sum duration from JSONB extra column (cast to int, coalesce nulls to 0) 35 + func.coalesce( 36 + func.sum(text("(extra->>'duration')::int")), 37 + 0, 38 + ), 33 39 ) 34 40 ) 35 41 row = result.one() ··· 38 44 total_plays=int(row[0]), 39 45 total_tracks=int(row[1]), 40 46 total_artists=int(row[2]), 47 + total_duration_seconds=int(row[3]), 41 48 )
+60
backend/tests/api/test_analytics.py
··· 143 143 # verify total metrics 144 144 assert data["total_plays"] == 160 # 100 + 50 + 10 145 145 assert data["total_items"] == 3 146 + assert data["total_duration_seconds"] == 0 # no duration set on tracks 146 147 147 148 # verify top played track 148 149 assert data["top_item"]["title"] == "Most Played" ··· 176 177 177 178 assert data["total_plays"] == 0 178 179 assert data["total_items"] == 0 180 + assert data["total_duration_seconds"] == 0 179 181 assert data["top_item"] is None 180 182 assert data["top_liked"] is None 181 183 ··· 214 216 215 217 assert data["total_plays"] == 50 216 218 assert data["total_items"] == 1 219 + assert data["total_duration_seconds"] == 0 # no duration set 217 220 assert data["top_item"]["title"] == "Unloved Track" 218 221 assert data["top_liked"] is None # no likes 222 + 223 + 224 + async def test_get_artist_analytics_with_duration( 225 + test_app: FastAPI, db_session: AsyncSession 226 + ): 227 + """test analytics returns total duration from tracks.""" 228 + artist = Artist( 229 + did="did:plc:artist_duration", 230 + handle="duration-artist.bsky.social", 231 + display_name="Duration Artist", 232 + ) 233 + db_session.add(artist) 234 + await db_session.flush() 235 + 236 + # create tracks with duration in extra field 237 + tracks = [ 238 + Track( 239 + title="Short Track", 240 + artist_did=artist.did, 241 + file_id="short_1", 242 + file_type="mp3", 243 + play_count=10, 244 + extra={"duration": 180}, # 3 minutes 245 + ), 246 + Track( 247 + title="Long Track", 248 + artist_did=artist.did, 249 + file_id="long_1", 250 + file_type="mp3", 251 + play_count=5, 252 + extra={"duration": 3600}, # 1 hour 253 + ), 254 + Track( 255 + title="No Duration Track", 256 + artist_did=artist.did, 257 + file_id="no_dur_1", 258 + file_type="mp3", 259 + play_count=3, 260 + extra={}, # no duration 261 + ), 262 + ] 263 + for track in tracks: 264 + db_session.add(track) 265 + await db_session.commit() 266 + 267 + async with AsyncClient( 268 + transport=ASGITransport(app=test_app), base_url="http://test" 269 + ) as client: 270 + response = await client.get(f"/artists/{artist.did}/analytics") 271 + 272 + assert response.status_code == 200 273 + data = response.json() 274 + 275 + assert data["total_plays"] == 18 # 10 + 5 + 3 276 + assert data["total_items"] == 3 277 + # duration should sum only tracks that have it: 180 + 3600 = 3780 278 + assert data["total_duration_seconds"] == 3780
+183
backend/tests/api/test_stats.py
··· 1 + """tests for platform stats api endpoint.""" 2 + 3 + import pytest 4 + from fastapi.testclient import TestClient 5 + from sqlalchemy.ext.asyncio import AsyncSession 6 + 7 + from backend.models import Artist, Track 8 + 9 + 10 + @pytest.fixture 11 + async def artist(db_session: AsyncSession) -> Artist: 12 + """create a test artist.""" 13 + artist = Artist( 14 + did="did:plc:stats_test_artist", 15 + handle="stats-test.bsky.social", 16 + display_name="Stats Test Artist", 17 + ) 18 + db_session.add(artist) 19 + await db_session.commit() 20 + return artist 21 + 22 + 23 + @pytest.fixture 24 + async def tracks_with_duration(db_session: AsyncSession, artist: Artist) -> list[Track]: 25 + """create multiple test tracks with duration metadata.""" 26 + tracks = [ 27 + Track( 28 + title="Short Track", 29 + artist_did=artist.did, 30 + file_id="track1", 31 + file_type="mp3", 32 + extra={"duration": 180}, # 3 minutes 33 + play_count=10, 34 + ), 35 + Track( 36 + title="Long Track", 37 + artist_did=artist.did, 38 + file_id="track2", 39 + file_type="mp3", 40 + extra={"duration": 3600}, # 1 hour 41 + play_count=5, 42 + ), 43 + Track( 44 + title="Medium Track", 45 + artist_did=artist.did, 46 + file_id="track3", 47 + file_type="mp3", 48 + extra={"duration": 300}, # 5 minutes 49 + play_count=20, 50 + ), 51 + ] 52 + for track in tracks: 53 + db_session.add(track) 54 + await db_session.commit() 55 + return tracks 56 + 57 + 58 + @pytest.fixture 59 + async def track_without_duration(db_session: AsyncSession, artist: Artist) -> Track: 60 + """create a test track without duration (legacy upload).""" 61 + track = Track( 62 + title="No Duration Track", 63 + artist_did=artist.did, 64 + file_id="track_noduration", 65 + file_type="mp3", 66 + extra={}, # no duration 67 + play_count=3, 68 + ) 69 + db_session.add(track) 70 + await db_session.commit() 71 + return track 72 + 73 + 74 + async def test_get_stats_returns_total_duration( 75 + client: TestClient, 76 + tracks_with_duration: list[Track], 77 + ) -> None: 78 + """stats endpoint returns total duration in seconds.""" 79 + response = client.get("/stats") 80 + assert response.status_code == 200 81 + 82 + data = response.json() 83 + assert "total_duration_seconds" in data 84 + 85 + # 180 + 3600 + 300 = 4080 seconds 86 + assert data["total_duration_seconds"] == 4080 87 + 88 + 89 + async def test_get_stats_duration_ignores_null( 90 + client: TestClient, 91 + tracks_with_duration: list[Track], 92 + track_without_duration: Track, 93 + ) -> None: 94 + """stats endpoint handles tracks without duration gracefully.""" 95 + response = client.get("/stats") 96 + assert response.status_code == 200 97 + 98 + data = response.json() 99 + # should still be 4080 (the track without duration doesn't add to total) 100 + assert data["total_duration_seconds"] == 4080 101 + # but track count should include all 4 102 + assert data["total_tracks"] == 4 103 + 104 + 105 + async def test_get_stats_empty_database(client: TestClient) -> None: 106 + """stats endpoint returns zeros for empty database.""" 107 + response = client.get("/stats") 108 + assert response.status_code == 200 109 + 110 + data = response.json() 111 + assert data["total_plays"] == 0 112 + assert data["total_tracks"] == 0 113 + assert data["total_artists"] == 0 114 + assert data["total_duration_seconds"] == 0 115 + 116 + 117 + async def test_get_stats_aggregates_play_counts( 118 + client: TestClient, 119 + tracks_with_duration: list[Track], 120 + ) -> None: 121 + """stats endpoint correctly aggregates play counts.""" 122 + response = client.get("/stats") 123 + assert response.status_code == 200 124 + 125 + data = response.json() 126 + # 10 + 5 + 20 = 35 total plays 127 + assert data["total_plays"] == 35 128 + 129 + 130 + async def test_get_stats_counts_distinct_artists( 131 + client: TestClient, 132 + db_session: AsyncSession, 133 + ) -> None: 134 + """stats endpoint counts distinct artists correctly.""" 135 + # create two artists 136 + artist1 = Artist( 137 + did="did:plc:artist1", 138 + handle="artist1.bsky.social", 139 + display_name="Artist 1", 140 + ) 141 + artist2 = Artist( 142 + did="did:plc:artist2", 143 + handle="artist2.bsky.social", 144 + display_name="Artist 2", 145 + ) 146 + db_session.add_all([artist1, artist2]) 147 + await db_session.flush() 148 + 149 + # create tracks from both artists 150 + tracks = [ 151 + Track( 152 + title="Track A1", 153 + artist_did=artist1.did, 154 + file_id="a1", 155 + file_type="mp3", 156 + extra={"duration": 100}, 157 + ), 158 + Track( 159 + title="Track A2", 160 + artist_did=artist1.did, 161 + file_id="a2", 162 + file_type="mp3", 163 + extra={"duration": 100}, 164 + ), 165 + Track( 166 + title="Track B1", 167 + artist_did=artist2.did, 168 + file_id="b1", 169 + file_type="mp3", 170 + extra={"duration": 100}, 171 + ), 172 + ] 173 + for track in tracks: 174 + db_session.add(track) 175 + await db_session.commit() 176 + 177 + response = client.get("/stats") 178 + assert response.status_code == 200 179 + 180 + data = response.json() 181 + assert data["total_tracks"] == 3 182 + assert data["total_artists"] == 2 183 + assert data["total_duration_seconds"] == 300
+17 -2
frontend/src/lib/components/PlatformStats.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 - import { statsCache } from '$lib/stats.svelte'; 3 + import { statsCache, formatDuration } from '$lib/stats.svelte'; 4 4 5 5 interface Props { 6 6 variant?: 'header' | 'menu'; ··· 44 44 <path d="M3 14c0-2.5 2-4.5 5-4.5s5 2 5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /> 45 45 </svg> 46 46 <span class="header-value">{stats.total_artists.toLocaleString()}</span> 47 + </div> 48 + <div class="header-stat" title="{formatDuration(stats.total_duration_seconds)} of music"> 49 + <svg class="header-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 50 + <circle cx="12" cy="12" r="10"></circle> 51 + <polyline points="12 6 12 12 16 14"></polyline> 52 + </svg> 53 + <span class="header-value">{formatDuration(stats.total_duration_seconds)}</span> 47 54 </div> 48 55 {/if} 49 56 </div> ··· 96 103 <span class="stats-menu-value">{stats.total_artists.toLocaleString()}</span> 97 104 <span class="stats-menu-label">{pluralize(stats.total_artists, 'artist', 'artists')}</span> 98 105 </div> 106 + <div class="stats-menu-item"> 107 + <svg class="menu-stat-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 108 + <circle cx="12" cy="12" r="10"></circle> 109 + <polyline points="12 6 12 12 16 14"></polyline> 110 + </svg> 111 + <span class="stats-menu-value">{formatDuration(stats.total_duration_seconds)}</span> 112 + <span class="stats-menu-label">of music</span> 113 + </div> 99 114 </div> 100 115 {/if} 101 116 </div> ··· 189 204 190 205 .stats-menu-grid { 191 206 display: grid; 192 - grid-template-columns: repeat(3, 1fr); 207 + grid-template-columns: repeat(2, 1fr); 193 208 gap: 0.5rem; 194 209 } 195 210
+18
frontend/src/lib/stats.svelte.ts
··· 6 6 total_plays: number; 7 7 total_tracks: number; 8 8 total_artists: number; 9 + total_duration_seconds: number; 10 + } 11 + 12 + /** 13 + * format seconds into human-readable duration string. 14 + * examples: "25h 32m", "3h 45m", "45m", "1h" 15 + */ 16 + export function formatDuration(totalSeconds: number): string { 17 + const hours = Math.floor(totalSeconds / 3600); 18 + const minutes = Math.floor((totalSeconds % 3600) / 60); 19 + 20 + if (hours === 0) { 21 + return `${minutes}m`; 22 + } 23 + if (minutes === 0) { 24 + return `${hours}h`; 25 + } 26 + return `${hours}h ${minutes}m`; 9 27 } 10 28 11 29 class StatsCache {
+1
frontend/src/lib/types.ts
··· 90 90 export interface Analytics { 91 91 total_plays: number; 92 92 total_items: number; 93 + total_duration_seconds: number; 93 94 top_item: TopItem | null; 94 95 top_liked: TopItem | null; 95 96 }
+11
frontend/src/routes/u/[handle]/+page.svelte
··· 4 4 import { API_URL } from '$lib/config'; 5 5 import { browser } from '$app/environment'; 6 6 import type { Analytics, Track, Playlist } from '$lib/types'; 7 + import { formatDuration } from '$lib/stats.svelte'; 7 8 import TrackItem from '$lib/components/TrackItem.svelte'; 8 9 import ShareButton from '$lib/components/ShareButton.svelte'; 9 10 import Header from '$lib/components/Header.svelte'; ··· 286 287 <div class="stat-card" transition:fade={{ duration: 200 }}> 287 288 <div class="stat-value">{analytics.total_items}</div> 288 289 <div class="stat-label">total tracks</div> 290 + {#if analytics.total_duration_seconds > 0} 291 + <div class="stat-duration">{formatDuration(analytics.total_duration_seconds)}</div> 292 + {/if} 289 293 </div> 290 294 {#if analytics.top_item} 291 295 <a href="/track/{analytics.top_item.id}" class="stat-card top-item" transition:fade={{ duration: 200 }}> ··· 669 673 font-size: 0.9rem; 670 674 text-transform: lowercase; 671 675 line-height: 1; 676 + } 677 + 678 + .stat-duration { 679 + margin-top: 0.5rem; 680 + font-size: 0.85rem; 681 + color: var(--text-secondary); 682 + font-variant-numeric: tabular-nums; 672 683 } 673 684 674 685 .stat-card.top-item {
+151
scripts/user_upload_stats.py
··· 1 + #!/usr/bin/env -S uv run --script --quiet 2 + """view per-user upload duration statistics. 3 + 4 + ## Context 5 + 6 + Track how much content each user has uploaded to support future upload caps. 7 + Currently informational - no enforcement yet. 8 + 9 + ## What This Script Does 10 + 11 + 1. Queries all tracks grouped by artist 12 + 2. Sums duration from extra JSONB column 13 + 3. Displays sorted by total upload time 14 + 15 + ## Usage 16 + 17 + ```bash 18 + # show all users with upload stats 19 + uv run scripts/user_upload_stats.py 20 + 21 + # show only users above a threshold (in hours) 22 + uv run scripts/user_upload_stats.py --min-hours 1 23 + 24 + # target specific environment 25 + DATABASE_URL=postgresql://... uv run scripts/user_upload_stats.py 26 + ``` 27 + """ 28 + 29 + import asyncio 30 + import logging 31 + import sys 32 + from pathlib import Path 33 + 34 + # add src to path so we can import backend modules 35 + sys.path.insert(0, str(Path(__file__).parent.parent / "backend" / "src")) 36 + 37 + from sqlalchemy import func, select, text 38 + 39 + from backend.models import Artist, Track 40 + from backend.utilities.database import db_session 41 + 42 + logging.basicConfig( 43 + level=logging.INFO, 44 + format="%(asctime)s - %(levelname)s - %(message)s", 45 + ) 46 + logger = logging.getLogger(__name__) 47 + 48 + 49 + def format_duration(total_seconds: int) -> str: 50 + """format seconds into human-readable duration string.""" 51 + hours = total_seconds // 3600 52 + minutes = (total_seconds % 3600) // 60 53 + 54 + if hours == 0: 55 + return f"{minutes}m" 56 + if minutes == 0: 57 + return f"{hours}h" 58 + return f"{hours}h {minutes}m" 59 + 60 + 61 + async def get_user_upload_stats(min_hours: float = 0) -> None: 62 + """query and display per-user upload statistics.""" 63 + 64 + min_seconds = int(min_hours * 3600) 65 + 66 + async with db_session() as db: 67 + # aggregate tracks by artist 68 + stmt = ( 69 + select( 70 + Track.artist_did, 71 + Artist.handle, 72 + Artist.display_name, 73 + func.count(Track.id).label("track_count"), 74 + func.coalesce( 75 + func.sum(text("(tracks.extra->>'duration')::int")), 76 + 0, 77 + ).label("total_seconds"), 78 + ) 79 + .join(Artist, Track.artist_did == Artist.did) 80 + .group_by(Track.artist_did, Artist.handle, Artist.display_name) 81 + .order_by(text("total_seconds DESC")) 82 + ) 83 + 84 + result = await db.execute(stmt) 85 + rows = result.all() 86 + 87 + if not rows: 88 + logger.info("no tracks found") 89 + return 90 + 91 + # also get totals 92 + total_stmt = select( 93 + func.count(Track.id), 94 + func.coalesce(func.sum(text("(tracks.extra->>'duration')::int")), 0), 95 + ) 96 + total_result = await db.execute(total_stmt) 97 + total_row = total_result.one() 98 + total_tracks = total_row[0] 99 + total_seconds = total_row[1] 100 + 101 + print("\n" + "=" * 80) 102 + print("USER UPLOAD STATISTICS") 103 + print("=" * 80) 104 + print( 105 + f"\nPlatform totals: {total_tracks} tracks, {format_duration(total_seconds)}" 106 + ) 107 + print("-" * 80) 108 + print(f"{'handle':<30} {'display name':<20} {'tracks':>8} {'duration':>12}") 109 + print("-" * 80) 110 + 111 + shown = 0 112 + for row in rows: 113 + artist_did, handle, display_name, track_count, user_seconds = row 114 + 115 + if user_seconds < min_seconds: 116 + continue 117 + 118 + shown += 1 119 + display = (display_name or handle)[:20] 120 + handle_str = handle[:30] if handle else artist_did[:30] 121 + 122 + print( 123 + f"{handle_str:<30} {display:<20} {track_count:>8} {format_duration(user_seconds):>12}" 124 + ) 125 + 126 + print("-" * 80) 127 + 128 + if min_hours > 0: 129 + hidden = len(rows) - shown 130 + print( 131 + f"showing {shown} users with >= {min_hours}h (hiding {hidden} below threshold)" 132 + ) 133 + else: 134 + print(f"total: {len(rows)} users") 135 + 136 + print() 137 + 138 + 139 + async def main() -> None: 140 + """main entry point.""" 141 + min_hours = 0.0 142 + 143 + for i, arg in enumerate(sys.argv): 144 + if arg == "--min-hours" and i + 1 < len(sys.argv): 145 + min_hours = float(sys.argv[i + 1]) 146 + 147 + await get_user_upload_stats(min_hours=min_hours) 148 + 149 + 150 + if __name__ == "__main__": 151 + asyncio.run(main())