feat: add top tracks section to homepage (#684)

adds a "top tracks" section above the existing "latest tracks" feed,
showing the 10 most-liked tracks on the platform. this helps surface
quality content instead of the homepage being dominated by bulk uploads.

backend:
- new `/tracks/top` endpoint returning tracks ordered by like count
- `get_top_track_ids()` aggregation helper

frontend:
- fetches top tracks concurrently with latest tracks on mount
- displays section only when there are liked tracks

🤖 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 56811776 ad0d6daf

Changed files
+175 -5
backend
src
backend
api
tracks
utilities
frontend
+81
backend/src/backend/api/tracks/listing.py
··· 28 28 get_comment_counts, 29 29 get_copyright_info, 30 30 get_like_counts, 31 + get_top_track_ids, 31 32 get_track_tags, 32 33 ) 33 34 from backend.utilities.tags import DEFAULT_HIDDEN_TAGS ··· 270 271 next_cursor=next_cursor, 271 272 has_more=has_more, 272 273 ) 274 + 275 + 276 + @router.get("/top") 277 + async def list_top_tracks( 278 + db: Annotated[AsyncSession, Depends(get_db)], 279 + limit: int = 10, 280 + session: AuthSession | None = Depends(get_optional_session), 281 + ) -> list[TrackResponse]: 282 + """Get top tracks by like count. 283 + 284 + Returns tracks ordered by number of likes (most liked first). 285 + Only returns tracks that have at least one like. 286 + """ 287 + limit = max(1, min(limit, 50)) 288 + 289 + # get top track IDs by like count 290 + top_track_ids = await get_top_track_ids(db, limit) 291 + if not top_track_ids: 292 + return [] 293 + 294 + # fetch tracks with relationships 295 + stmt = ( 296 + select(Track) 297 + .join(Artist) 298 + .options(selectinload(Track.artist), selectinload(Track.album_rel)) 299 + .where(Track.id.in_(top_track_ids)) 300 + ) 301 + result = await db.execute(stmt) 302 + tracks_by_id = {track.id: track for track in result.scalars().all()} 303 + 304 + # preserve order from top_track_ids 305 + tracks = [tracks_by_id[tid] for tid in top_track_ids if tid in tracks_by_id] 306 + 307 + # get authenticated user's liked tracks 308 + liked_track_ids: set[int] | None = None 309 + if session: 310 + liked_result = await db.execute( 311 + select(TrackLike.track_id).where(TrackLike.user_did == session.did) 312 + ) 313 + liked_track_ids = set(liked_result.scalars().all()) 314 + 315 + # batch fetch aggregations 316 + track_ids = [track.id for track in tracks] 317 + like_counts, comment_counts, track_tags = await asyncio.gather( 318 + get_like_counts(db, track_ids), 319 + get_comment_counts(db, track_ids), 320 + get_track_tags(db, track_ids), 321 + ) 322 + 323 + # resolve supporter status for gated content 324 + viewer_did = session.did if session else None 325 + supported_artist_dids: set[str] = set() 326 + if viewer_did: 327 + gated_artist_dids = { 328 + t.artist_did 329 + for t in tracks 330 + if t.support_gate and t.artist_did != viewer_did 331 + } 332 + if gated_artist_dids: 333 + supported_artist_dids = await get_supported_artists( 334 + viewer_did, gated_artist_dids 335 + ) 336 + 337 + # build responses 338 + track_responses = await asyncio.gather( 339 + *[ 340 + TrackResponse.from_track( 341 + track, 342 + liked_track_ids=liked_track_ids, 343 + like_counts=like_counts, 344 + comment_counts=comment_counts, 345 + track_tags=track_tags, 346 + viewer_did=viewer_did, 347 + supported_artist_dids=supported_artist_dids, 348 + ) 349 + for track in tracks 350 + ] 351 + ) 352 + 353 + return list(track_responses) 273 354 274 355 275 356 @router.get("/me")
+20
backend/src/backend/utilities/aggregations.py
··· 134 134 return f"{title} by {artist}" 135 135 136 136 137 + async def get_top_track_ids(db: AsyncSession, limit: int = 10) -> list[int]: 138 + """get track IDs ordered by like count (descending). 139 + 140 + args: 141 + db: database session 142 + limit: max number of track IDs to return 143 + 144 + returns: 145 + list of track IDs ordered by like count (most liked first) 146 + """ 147 + stmt = ( 148 + select(TrackLike.track_id) 149 + .group_by(TrackLike.track_id) 150 + .order_by(func.count(TrackLike.id).desc()) 151 + .limit(limit) 152 + ) 153 + result = await db.execute(stmt) 154 + return list(result.scalars().all()) 155 + 156 + 137 157 async def get_track_tags(db: AsyncSession, track_ids: list[int]) -> dict[int, set[str]]: 138 158 """get tags for multiple tracks in a single query. 139 159
+17
frontend/src/lib/tracks.svelte.ts
··· 192 192 } 193 193 } 194 194 195 + export async function fetchTopTracks(limit = 10): Promise<Track[]> { 196 + try { 197 + const response = await fetch(`${API_URL}/tracks/top?limit=${limit}`, { 198 + credentials: 'include' 199 + }); 200 + 201 + if (!response.ok) { 202 + throw new Error(`failed to fetch top tracks: ${response.statusText}`); 203 + } 204 + 205 + return await response.json(); 206 + } catch (e) { 207 + console.error('failed to fetch top tracks:', e); 208 + return []; 209 + } 210 + } 211 + 195 212 export async function fetchLikedTracks(): Promise<Track[]> { 196 213 try { 197 214 const response = await fetch(`${API_URL}/tracks/liked`, {
+57 -5
frontend/src/routes/+page.svelte
··· 7 7 import HiddenTagsFilter from '$lib/components/HiddenTagsFilter.svelte'; 8 8 import { player } from '$lib/player.svelte'; 9 9 import { queue } from '$lib/queue.svelte'; 10 - import { tracksCache } from '$lib/tracks.svelte'; 10 + import { tracksCache, fetchTopTracks } from '$lib/tracks.svelte'; 11 + import type { Track } from '$lib/types'; 11 12 import { auth } from '$lib/auth.svelte'; 12 13 import { APP_NAME, APP_TAGLINE, APP_CANONICAL_URL } from '$lib/branding'; 13 14 ··· 19 20 let hasTracks = $derived(tracks.length > 0); 20 21 let initialLoad = $state(true); 21 22 23 + // top tracks (most liked) 24 + let topTracks = $state<Track[]>([]); 25 + let loadingTopTracks = $state(true); 26 + let hasTopTracks = $derived(topTracks.length > 0); 27 + 22 28 // show loading during initial load or when actively loading with no cached data 23 29 let showLoading = $derived((initialLoad && !hasTracks) || (loadingTracks && !hasTracks)); 24 30 ··· 29 35 let sentinelElement = $state<HTMLDivElement | null>(null); 30 36 31 37 onMount(async () => { 32 - // fetch tracks from cache (will use cached data if recent) 33 - await tracksCache.fetch(); 38 + // fetch top tracks and latest tracks concurrently 39 + const [topResult] = await Promise.all([ 40 + fetchTopTracks(10), 41 + tracksCache.fetch() 42 + ]); 43 + topTracks = topResult; 44 + loadingTopTracks = false; 34 45 initialLoad = false; 35 46 }); 36 47 ··· 100 111 <Header user={auth.user} isAuthenticated={auth.isAuthenticated} onLogout={logout} /> 101 112 102 113 <main> 103 - <section class="tracks"> 114 + <!-- most liked section --> 115 + {#if loadingTopTracks} 116 + <section class="top-tracks"> 117 + <h2>top tracks</h2> 118 + <div class="loading-container compact"> 119 + <WaveLoading size="sm" message="loading..." /> 120 + </div> 121 + </section> 122 + {:else if hasTopTracks} 123 + <section class="top-tracks"> 124 + <h2>top tracks</h2> 125 + <div class="track-list"> 126 + {#each topTracks as track, i} 127 + <TrackItem 128 + {track} 129 + index={i} 130 + isPlaying={player.currentTrack?.id === track.id} 131 + onPlay={(t) => queue.playNow(t)} 132 + isAuthenticated={auth.isAuthenticated} 133 + /> 134 + {/each} 135 + </div> 136 + </section> 137 + {/if} 138 + 139 + <section class="tracks"> 104 140 <div class="section-header"> 105 141 <h2> 106 142 <button ··· 159 195 padding: 3rem 2rem; 160 196 } 161 197 198 + .loading-container.compact { 199 + padding: 1.5rem 1rem; 200 + } 201 + 202 + .top-tracks { 203 + margin-bottom: 2.5rem; 204 + } 205 + 206 + .top-tracks h2 { 207 + font-size: var(--text-page-heading); 208 + font-weight: 700; 209 + color: var(--text-primary); 210 + margin: 0 0 1.5rem 0; 211 + } 212 + 162 213 main { 163 214 max-width: 800px; 164 215 margin: 0 auto; ··· 226 277 padding: 0 0.75rem calc(var(--player-height, 0px) + 1.25rem + env(safe-area-inset-bottom, 0px)); 227 278 } 228 279 229 - .section-header h2 { 280 + .section-header h2, 281 + .top-tracks h2 { 230 282 font-size: var(--text-2xl); 231 283 } 232 284 }