fix: include tags in track and album detail API responses (#437)

- track detail endpoint now fetches and returns tags
- album detail endpoint now fetches tags for all tracks
- track detail page displays tags with links to tag pages

🤖 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 180d58d7 1a7519c5

Changed files
+47 -5
backend
src
backend
api
frontend
src
routes
track
+9 -3
backend/src/backend/api/albums.py
··· 18 from backend.models import Album, Artist, Track, TrackLike, get_db 19 from backend.schemas import TrackResponse 20 from backend.storage import storage 21 - from backend.utilities.aggregations import get_comment_counts, get_like_counts 22 from backend.utilities.hashing import CHUNK_SIZE 23 24 router = APIRouter(prefix="/albums", tags=["albums"]) ··· 262 tracks = track_result.scalars().all() 263 track_ids = [track.id for track in tracks] 264 if track_ids: 265 - like_counts, comment_counts = await asyncio.gather( 266 get_like_counts(db, track_ids), 267 get_comment_counts(db, track_ids), 268 ) 269 else: 270 - like_counts, comment_counts = {}, {} 271 272 # get authenticated user's likes for this album's tracks only 273 liked_track_ids: set[int] | None = None ··· 310 liked_track_ids, 311 like_counts, 312 comment_counts, 313 ) 314 for track in tracks 315 ]
··· 18 from backend.models import Album, Artist, Track, TrackLike, get_db 19 from backend.schemas import TrackResponse 20 from backend.storage import storage 21 + from backend.utilities.aggregations import ( 22 + get_comment_counts, 23 + get_like_counts, 24 + get_track_tags, 25 + ) 26 from backend.utilities.hashing import CHUNK_SIZE 27 28 router = APIRouter(prefix="/albums", tags=["albums"]) ··· 266 tracks = track_result.scalars().all() 267 track_ids = [track.id for track in tracks] 268 if track_ids: 269 + like_counts, comment_counts, track_tags = await asyncio.gather( 270 get_like_counts(db, track_ids), 271 get_comment_counts(db, track_ids), 272 + get_track_tags(db, track_ids), 273 ) 274 else: 275 + like_counts, comment_counts, track_tags = {}, {}, {} 276 277 # get authenticated user's likes for this album's tracks only 278 liked_track_ids: set[int] | None = None ··· 315 liked_track_ids, 316 like_counts, 317 comment_counts, 318 + track_tags=track_tags, 319 ) 320 for track in tracks 321 ]
+6 -2
backend/src/backend/api/tracks/playback.py
··· 10 from backend._internal.auth import get_session 11 from backend.models import Artist, Track, TrackLike, get_db 12 from backend.schemas import TrackResponse 13 - from backend.utilities.aggregations import get_like_counts 14 15 from .router import router 16 ··· 50 raise HTTPException(status_code=404, detail="track not found") 51 52 like_counts = await get_like_counts(db, [track_id]) 53 54 return await TrackResponse.from_track( 55 - track, liked_track_ids=liked_track_ids, like_counts=like_counts 56 ) 57 58
··· 10 from backend._internal.auth import get_session 11 from backend.models import Artist, Track, TrackLike, get_db 12 from backend.schemas import TrackResponse 13 + from backend.utilities.aggregations import get_like_counts, get_track_tags 14 15 from .router import router 16 ··· 50 raise HTTPException(status_code=404, detail="track not found") 51 52 like_counts = await get_like_counts(db, [track_id]) 53 + track_tags = await get_track_tags(db, [track_id]) 54 55 return await TrackResponse.from_track( 56 + track, 57 + liked_track_ids=liked_track_ids, 58 + like_counts=like_counts, 59 + track_tags=track_tags, 60 ) 61 62
+32
frontend/src/routes/track/[id]/+page.svelte
··· 407 {/if} 408 </div> 409 410 <div class="track-stats"> 411 <span class="plays">{track.play_count} {track.play_count === 1 ? 'play' : 'plays'}</span> 412 {#if track.like_count && track.like_count > 0} ··· 768 769 .track-stats .separator { 770 font-size: 0.7rem; 771 } 772 773 .mobile-side-buttons {
··· 407 {/if} 408 </div> 409 410 + {#if track.tags && track.tags.length > 0} 411 + <div class="track-tags"> 412 + {#each track.tags as tag} 413 + <a href="/tag/{encodeURIComponent(tag)}" class="tag-badge">{tag}</a> 414 + {/each} 415 + </div> 416 + {/if} 417 + 418 <div class="track-stats"> 419 <span class="plays">{track.play_count} {track.play_count === 1 ? 'play' : 'plays'}</span> 420 {#if track.like_count && track.like_count > 0} ··· 776 777 .track-stats .separator { 778 font-size: 0.7rem; 779 + } 780 + 781 + .track-tags { 782 + display: flex; 783 + flex-wrap: wrap; 784 + gap: 0.5rem; 785 + justify-content: center; 786 + } 787 + 788 + .tag-badge { 789 + display: inline-block; 790 + padding: 0.25rem 0.6rem; 791 + background: rgba(138, 179, 255, 0.15); 792 + color: #8ab3ff; 793 + border-radius: 4px; 794 + font-size: 0.85rem; 795 + font-weight: 500; 796 + text-decoration: none; 797 + transition: all 0.15s; 798 + } 799 + 800 + .tag-badge:hover { 801 + background: rgba(138, 179, 255, 0.25); 802 + color: #a8c8ff; 803 } 804 805 .mobile-side-buttons {