audio streaming app plyr.fm
38
fork

Configure Feed

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

feat: inline liker avatar stack + shared AvatarStack primitive (#1302)

Replaces the plain "N likes" text next to tracks with an overlapping
strip of the 3 most recent liker avatars (+N if more) — matching the
existing supporter-row pattern on artist pages. Both sites now render
the same `AvatarStack` presentational component; only the data flow
differs (liker avatars are maintained in our artists DB via jetstream;
supporter avatars come from atprotofans via the /artists/batch
enrichment already in place).

Backend

- new `get_top_likers(db, track_ids, limit=3)` aggregation utility
using `ROW_NUMBER() OVER (PARTITION BY track_id ORDER BY created_at
DESC)`, filtered to `rn <= limit`. Postgres 15+ pushes the limit
into the window aggregate (Run Condition), so work short-circuits
per partition. EXPLAIN ANALYZE on production (308 likes, 20-track
page): ~1ms execution, all in shared buffer cache.
- `TrackResponse.top_likers: list[LikerPreview]` added; threaded
through every list endpoint that already batches aggregations
(for_you, tracks listing, tracks /top, tracks /me, tracks /me/broken,
albums listing, users/{handle}/likes, tracks/tags, tracks/shares,
lists/hydration, liked tracks list) plus single-track endpoints
(playback /by-uri, mutations update, mutations restore-record).
- queue and jams serializers continue to skip aggregations per their
existing comments — they pass no `top_likers`, and the field
defaults to `[]`, which the frontend renders as the plain count
(pre-existing behavior).
- `LikerPreview` lives in `utilities/aggregations.py` rather than
`schemas.py` to avoid a circular import (schemas.py imports from
aggregations.py for `CopyrightInfo`).
- tests in `test_aggregations.py`: default limit, custom limit,
ordering by most-recent-first, empty track list, and the
JOIN-on-Artist filter behavior (likers without an artist row are
omitted, matching the existing `GET /tracks/{id}/likes` semantics).

Frontend

- `AvatarStack.svelte` — new purely-presentational component. Props:
`users`, `total`, `maxVisible`, `size`, `borderColor`, `moreHref`,
`onMoreClick`, `avatarHref`, `onAvatarClick`, `ariaLabel`, `class`.
Handles 0-N users, renders +overflow tile as link OR button
depending on the surface, supports fallback initials when
`avatar_url` is null.
- `UserPreview` type added to `types.ts`; matches backend
`LikerPreview` and the atprotofans-derived `Supporter` shape.
- `Track.top_likers?: UserPreview[]` added.
- wired into `TrackItem`, `TrackCard`, and `track/[id]/+page.svelte` —
the existing wrapper keeps the hover-tooltip (desktop) and
bottom-sheet (mobile) behavior on the whole strip; clicking an
individual avatar is intentionally a no-op so the detail sheet is
the canonical "see all likers" path.
- wired into `u/[handle]/+page.svelte` supporter row, replacing the
hand-rolled `.supporter-circle` markup and CSS (~65 lines deleted).
Avatars here DO link to `/u/{handle}` per existing UX; +N links
out to the atprotofans supporter page in a new tab.
- sizing is mobile-first: 20px avatars on mobile tracks, 22px on
desktop tracks, 18px in track cards, 28px/32px on the supporter
row.

Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>

authored by

nate nowack
Claude Opus 4 (1M context)
and committed by
GitHub
8d4335a6 aef77769

+656 -116
+5 -2
backend/src/backend/api/albums/listing.py
··· 17 17 from backend.utilities.aggregations import ( 18 18 get_comment_counts, 19 19 get_like_counts, 20 + get_top_likers, 20 21 get_track_tags, 21 22 ) 22 23 from backend.utilities.redis import get_async_redis_client ··· 188 189 189 190 # batch fetch aggregations 190 191 if track_ids: 191 - like_counts, comment_counts, track_tags = await asyncio.gather( 192 + like_counts, comment_counts, track_tags, top_likers = await asyncio.gather( 192 193 get_like_counts(db, track_ids), 193 194 get_comment_counts(db, track_ids), 194 195 get_track_tags(db, track_ids), 196 + get_top_likers(db, track_ids), 195 197 ) 196 198 else: 197 - like_counts, comment_counts, track_tags = {}, {}, {} 199 + like_counts, comment_counts, track_tags, top_likers = {}, {}, {}, {} 198 200 199 201 # get authenticated user's likes for this album's tracks only 200 202 liked_track_ids: set[int] | None = None ··· 218 220 like_counts, 219 221 comment_counts, 220 222 track_tags=track_tags, 223 + top_likers=top_likers, 221 224 ) 222 225 for track in tracks 223 226 ]
+4 -1
backend/src/backend/api/for_you.py
··· 44 44 from backend.utilities.aggregations import ( 45 45 get_comment_counts, 46 46 get_like_counts, 47 + get_top_likers, 47 48 get_track_tags, 48 49 ) 49 50 from backend.utilities.tags import DEFAULT_HIDDEN_TAGS ··· 385 386 ) 386 387 liked_track_ids = set(liked_result.scalars().all()) 387 388 388 - like_counts, comment_counts, track_tags = await asyncio.gather( 389 + like_counts, comment_counts, track_tags, top_likers = await asyncio.gather( 389 390 get_like_counts(db, page_ids), 390 391 get_comment_counts(db, page_ids), 391 392 get_track_tags(db, page_ids), 393 + get_top_likers(db, page_ids), 392 394 ) 393 395 394 396 gated_artist_dids = { ··· 408 410 like_counts=like_counts, 409 411 comment_counts=comment_counts, 410 412 track_tags=track_tags, 413 + top_likers=top_likers, 411 414 viewer_did=actor_did, 412 415 supported_artist_dids=supported_artist_dids, 413 416 )
+16 -3
backend/src/backend/api/lists/hydration.py
··· 1 1 """shared track hydration for list-backed endpoints (playlists, liked lists).""" 2 2 3 + import asyncio 4 + 3 5 from sqlalchemy import select 4 6 from sqlalchemy.ext.asyncio import AsyncSession 5 7 from sqlalchemy.orm import selectinload 6 8 7 9 from backend.models import Track, TrackLike 8 10 from backend.schemas import TrackResponse 9 - from backend.utilities.aggregations import get_comment_counts, get_like_counts 11 + from backend.utilities.aggregations import ( 12 + get_comment_counts, 13 + get_like_counts, 14 + get_top_likers, 15 + ) 10 16 11 17 12 18 async def hydrate_tracks_from_uris( ··· 31 37 track_by_uri = {t.atproto_record_uri: t for t in all_tracks} 32 38 33 39 track_ids = [t.id for t in all_tracks] 34 - like_counts = await get_like_counts(db, track_ids) if track_ids else {} 35 - comment_counts = await get_comment_counts(db, track_ids) if track_ids else {} 40 + if track_ids: 41 + like_counts, comment_counts, top_likers = await asyncio.gather( 42 + get_like_counts(db, track_ids), 43 + get_comment_counts(db, track_ids), 44 + get_top_likers(db, track_ids), 45 + ) 46 + else: 47 + like_counts, comment_counts, top_likers = {}, {}, {} 36 48 37 49 liked_track_ids: set[int] = set() 38 50 if session_did and track_ids: ··· 53 65 liked_track_ids=liked_track_ids, 54 66 like_counts=like_counts, 55 67 comment_counts=comment_counts, 68 + top_likers=top_likers, 56 69 ) 57 70 tracks.append(track_response) 58 71
+8 -2
backend/src/backend/api/tracks/likes.py
··· 18 18 ) 19 19 from backend.models import Artist, Track, TrackLike, get_db 20 20 from backend.schemas import LikedResponse, TrackResponse 21 - from backend.utilities.aggregations import get_comment_counts, get_like_counts 21 + from backend.utilities.aggregations import ( 22 + get_comment_counts, 23 + get_like_counts, 24 + get_top_likers, 25 + ) 22 26 23 27 from .router import router 24 28 ··· 68 72 69 73 liked_track_ids = {track.id for track in tracks} 70 74 track_ids = [track.id for track in tracks] 71 - like_counts, comment_counts = await asyncio.gather( 75 + like_counts, comment_counts, top_likers = await asyncio.gather( 72 76 get_like_counts(db, track_ids), 73 77 get_comment_counts(db, track_ids), 78 + get_top_likers(db, track_ids), 74 79 ) 75 80 76 81 track_responses = await asyncio.gather( ··· 80 85 liked_track_ids=liked_track_ids, 81 86 like_counts=like_counts, 82 87 comment_counts=comment_counts, 88 + top_likers=top_likers, 83 89 ) 84 90 for track in tracks 85 91 ]
+19 -6
backend/src/backend/api/tracks/listing.py
··· 33 33 get_comment_counts, 34 34 get_copyright_info, 35 35 get_like_counts, 36 + get_top_likers, 36 37 get_top_tracks_with_counts, 37 38 get_track_tags, 38 39 ) ··· 211 212 # to the moderation service and is only displayed in /tracks/me (artist portal) 212 213 track_ids = [track.id for track in tracks] 213 214 with logfire.span("batch aggregations", track_count=len(track_ids)): 214 - like_counts, comment_counts, track_tags = await asyncio.gather( 215 + like_counts, comment_counts, track_tags, top_likers = await asyncio.gather( 215 216 get_like_counts(db, track_ids), 216 217 get_comment_counts(db, track_ids), 217 218 get_track_tags(db, track_ids), 219 + get_top_likers(db, track_ids), 218 220 ) 219 221 220 222 # use cached PDS URLs with fallback on failure ··· 307 309 like_counts, 308 310 comment_counts, 309 311 track_tags=track_tags, 312 + top_likers=top_likers, 310 313 viewer_did=viewer_did, 311 314 supported_artist_dids=supported_artist_dids, 312 315 ) ··· 391 394 392 395 # batch fetch aggregations — always use all-time like counts for display 393 396 # (period filtering only affects which tracks appear and their ordering) 394 - like_counts, comment_counts, track_tags = await asyncio.gather( 397 + like_counts, comment_counts, track_tags, top_likers = await asyncio.gather( 395 398 get_like_counts(db, track_ids), 396 399 get_comment_counts(db, track_ids), 397 400 get_track_tags(db, track_ids), 401 + get_top_likers(db, track_ids), 398 402 ) 399 403 400 404 # resolve supporter status for gated content ··· 420 424 like_counts=like_counts, 421 425 comment_counts=comment_counts, 422 426 track_tags=track_tags, 427 + top_likers=top_likers, 423 428 viewer_did=viewer_did, 424 429 supported_artist_dids=supported_artist_dids, 425 430 ) ··· 459 464 460 465 # batch fetch copyright info and tags 461 466 track_ids = [track.id for track in tracks] 462 - copyright_info, track_tags = await asyncio.gather( 467 + copyright_info, track_tags, top_likers = await asyncio.gather( 463 468 get_copyright_info(db, track_ids), 464 469 get_track_tags(db, track_ids), 470 + get_top_likers(db, track_ids), 465 471 ) 466 472 467 473 # fetch all track responses concurrently 468 474 track_responses = await asyncio.gather( 469 475 *[ 470 476 TrackResponse.from_track( 471 - track, copyright_info=copyright_info, track_tags=track_tags 477 + track, 478 + copyright_info=copyright_info, 479 + track_tags=track_tags, 480 + top_likers=top_likers, 472 481 ) 473 482 for track in tracks 474 483 ] ··· 512 521 513 522 # batch fetch copyright info and tags 514 523 track_ids = [track.id for track in tracks] 515 - copyright_info, track_tags = await asyncio.gather( 524 + copyright_info, track_tags, top_likers = await asyncio.gather( 516 525 get_copyright_info(db, track_ids), 517 526 get_track_tags(db, track_ids), 527 + get_top_likers(db, track_ids), 518 528 ) 519 529 520 530 # fetch all track responses concurrently 521 531 track_responses = await asyncio.gather( 522 532 *[ 523 533 TrackResponse.from_track( 524 - track, copyright_info=copyright_info, track_tags=track_tags 534 + track, 535 + copyright_info=copyright_info, 536 + track_tags=track_tags, 537 + top_likers=top_likers, 525 538 ) 526 539 for track in tracks 527 540 ]
+8 -4
backend/src/backend/api/tracks/mutations.py
··· 37 37 from backend.models import Artist, Track, TrackTag, get_db 38 38 from backend.schemas import MessageResponse, TrackResponse 39 39 from backend.storage import storage 40 + from backend.utilities.aggregations import get_top_likers, get_track_tags 40 41 from backend.utilities.tags import get_or_create_tag, parse_tags_json 41 42 42 43 from .metadata_service import ( ··· 375 376 if tags is not None: 376 377 track_tags_dict = {track.id: updated_tags} 377 378 else: 378 - from backend.utilities.aggregations import get_track_tags 379 - 380 379 track_tags_dict = await get_track_tags(db, [track.id]) 381 380 382 - return await TrackResponse.from_track(track, track_tags=track_tags_dict) 381 + top_likers = await get_top_likers(db, [track.id]) 382 + 383 + return await TrackResponse.from_track( 384 + track, track_tags=track_tags_dict, top_likers=top_likers 385 + ) 383 386 384 387 385 388 async def _update_atproto_record( ··· 609 612 610 613 logger.info(f"restored ATProto record for track {track_id}: {new_uri}") 611 614 615 + top_likers = await get_top_likers(db, [track.id]) 612 616 return RestoreRecordResponse( 613 617 success=True, 614 - track=await TrackResponse.from_track(track), 618 + track=await TrackResponse.from_track(track, top_likers=top_likers), 615 619 restored_uri=new_uri, 616 620 ) 617 621
+8 -2
backend/src/backend/api/tracks/playback.py
··· 24 24 get_db, 25 25 ) 26 26 from backend.schemas import PlayCountResponse, TrackResponse 27 - from backend.utilities.aggregations import get_like_counts, get_track_tags 27 + from backend.utilities.aggregations import ( 28 + get_like_counts, 29 + get_top_likers, 30 + get_track_tags, 31 + ) 28 32 29 33 from .router import router 30 34 ··· 51 55 ): 52 56 liked_track_ids = {track.id} 53 57 54 - like_counts, track_tags = await asyncio.gather( 58 + like_counts, track_tags, top_likers = await asyncio.gather( 55 59 get_like_counts(db, [track.id]), 56 60 get_track_tags(db, [track.id]), 61 + get_top_likers(db, [track.id]), 57 62 ) 58 63 59 64 return await TrackResponse.from_track( ··· 61 66 liked_track_ids=liked_track_ids, 62 67 like_counts=like_counts, 63 68 track_tags=track_tags, 69 + top_likers=top_likers, 64 70 ) 65 71 66 72
+7 -1
backend/src/backend/api/tracks/shares.py
··· 14 14 from backend.config import settings 15 15 from backend.models import Artist, ShareLink, ShareLinkEvent, Track, get_db 16 16 from backend.schemas import OkResponse, TrackResponse 17 + from backend.utilities.aggregations import get_top_likers 17 18 18 19 from .router import router 19 20 ··· 234 235 235 236 return users, anonymous_count or 0 236 237 238 + # batch-preload top likers for every share's track in one query 239 + top_likers = await get_top_likers(db, [sl.track_id for sl in share_links]) 240 + 237 241 shares = [] 238 242 for share_link in share_links: 239 243 # get visitor stats (clicks) ··· 247 251 play_count = len(listeners) + anonymous_plays 248 252 249 253 # build track response 250 - track_response = await TrackResponse.from_track(share_link.track) 254 + track_response = await TrackResponse.from_track( 255 + share_link.track, top_likers=top_likers 256 + ) 251 257 252 258 shares.append( 253 259 ShareLinkStats(
+4 -1
backend/src/backend/api/tracks/tags.py
··· 18 18 from backend.utilities.aggregations import ( 19 19 get_comment_counts, 20 20 get_like_counts, 21 + get_top_likers, 21 22 get_track_tags, 22 23 ) 23 24 ··· 93 94 # batch fetch like counts, comment counts, and tags 94 95 # note: copyright_info excluded - only needed in artist portal (/tracks/me) 95 96 track_ids = [track.id for track in tracks] 96 - like_counts, comment_counts, track_tags_map = await asyncio.gather( 97 + like_counts, comment_counts, track_tags_map, top_likers = await asyncio.gather( 97 98 get_like_counts(db, track_ids), 98 99 get_comment_counts(db, track_ids), 99 100 get_track_tags(db, track_ids), 101 + get_top_likers(db, track_ids), 100 102 ) 101 103 102 104 # build track responses ··· 108 110 like_counts=like_counts, 109 111 comment_counts=comment_counts, 110 112 track_tags=track_tags_map, 113 + top_likers=top_likers, 111 114 ) 112 115 for track in tracks 113 116 ]
+8 -2
backend/src/backend/api/users.py
··· 12 12 from backend._internal import Session, get_optional_session 13 13 from backend.models import Artist, Track, TrackLike, get_db 14 14 from backend.schemas import TrackResponse 15 - from backend.utilities.aggregations import get_comment_counts, get_like_counts 15 + from backend.utilities.aggregations import ( 16 + get_comment_counts, 17 + get_like_counts, 18 + get_top_likers, 19 + ) 16 20 17 21 router = APIRouter(prefix="/users", tags=["users"]) 18 22 ··· 73 77 liked_track_ids = set(liked_result.scalars().all()) 74 78 75 79 track_ids = [track.id for track in tracks] 76 - like_counts, comment_counts = await asyncio.gather( 80 + like_counts, comment_counts, top_likers = await asyncio.gather( 77 81 get_like_counts(db, track_ids), 78 82 get_comment_counts(db, track_ids), 83 + get_top_likers(db, track_ids), 79 84 ) 80 85 81 86 track_responses = await asyncio.gather( ··· 85 90 liked_track_ids=liked_track_ids, 86 91 like_counts=like_counts, 87 92 comment_counts=comment_counts, 93 + top_likers=top_likers, 88 94 ) 89 95 for track in tracks 90 96 ]
+11 -1
backend/src/backend/schemas.py
··· 6 6 7 7 from backend._internal.atproto.client import parse_at_uri 8 8 from backend.models import Album, Track 9 - from backend.utilities.aggregations import CopyrightInfo 9 + from backend.utilities.aggregations import CopyrightInfo, LikerPreview 10 10 11 11 # --- common simple response types --- 12 12 ··· 123 123 audio_storage: str = "r2" # "r2" | "pds" | "both" 124 124 pds_blob_cid: str | None = None # CID if stored on user's PDS 125 125 unlisted: bool = False # excluded from discovery feeds 126 + top_likers: list[LikerPreview] = Field(default_factory=list) 127 + # ^ up to 3 most recent likers, for the inline avatar stack. empty when 128 + # the track has no likes OR the callsite didn't batch-load likers. 126 129 127 130 @classmethod 128 131 async def from_track( ··· 134 137 comment_counts: dict[int, int] | None = None, 135 138 copyright_info: dict[int, CopyrightInfo] | None = None, 136 139 track_tags: dict[int, set[str]] | None = None, 140 + top_likers: dict[int, list[LikerPreview]] | None = None, 137 141 viewer_did: str | None = None, 138 142 supported_artist_dids: set[str] | None = None, 139 143 ) -> "TrackResponse": ··· 147 151 comment_counts: optional dict of track_id -> comment_count 148 152 copyright_info: optional dict of track_id -> CopyrightInfo 149 153 track_tags: optional dict of track_id -> set of tag names 154 + top_likers: optional dict of track_id -> list of LikerPreview 155 + (most recent first, capped at 3) 150 156 viewer_did: optional DID of the viewer (for gated content resolution) 151 157 supported_artist_dids: optional set of artist DIDs the viewer supports 152 158 """ ··· 155 161 156 162 # get like count 157 163 like_count = like_counts.get(track.id, 0) if like_counts else 0 164 + 165 + # get top likers (preview for the inline avatar stack) 166 + track_top_likers = top_likers.get(track.id, []) if top_likers else [] 158 167 159 168 # get comment count 160 169 comment_count = comment_counts.get(track.id, 0) if comment_counts else 0 ··· 222 231 thumbnail_url=track.thumbnail_url, 223 232 is_liked=is_liked, 224 233 like_count=like_count, 234 + top_likers=track_top_likers, 225 235 comment_count=comment_count, 226 236 album=album_data, 227 237 tags=tags,
+102 -2
backend/src/backend/utilities/aggregations.py
··· 1 1 """aggregation utilities for efficient batch counting.""" 2 2 3 3 import logging 4 - from collections import Counter 4 + from collections import Counter, defaultdict 5 5 from dataclasses import dataclass 6 6 from datetime import datetime 7 7 from typing import Any 8 8 9 + from pydantic import BaseModel 9 10 from sqlalchemy import select 10 11 from sqlalchemy.ext.asyncio import AsyncSession 11 12 from sqlalchemy.sql import func 12 13 13 - from backend.models import CopyrightScan, Tag, Track, TrackComment, TrackLike, TrackTag 14 + from backend.models import ( 15 + Artist, 16 + CopyrightScan, 17 + Tag, 18 + Track, 19 + TrackComment, 20 + TrackLike, 21 + TrackTag, 22 + ) 14 23 15 24 logger = logging.getLogger(__name__) 16 25 ··· 23 32 primary_match: str | None = None # "Title by Artist" for most frequent match 24 33 25 34 35 + class LikerPreview(BaseModel): 36 + """lightweight liker preview embedded in track responses. 37 + 38 + used to render the overlapping avatar stack next to a track's like count 39 + without a follow-up request. the full `LikerInfo` (with `liked_at`) is 40 + still served by `GET /tracks/{id}/likes` for the detail tooltip/sheet. 41 + 42 + defined here (alongside aggregation helpers) rather than in `schemas.py` 43 + to avoid a circular import: `schemas.py` imports from `aggregations.py`. 44 + """ 45 + 46 + did: str 47 + handle: str 48 + display_name: str | None 49 + avatar_url: str | None 50 + 51 + 26 52 async def get_like_counts(db: AsyncSession, track_ids: list[int]) -> dict[int, int]: 27 53 """get like counts for multiple tracks in a single query. 28 54 ··· 45 71 46 72 result = await db.execute(stmt) 47 73 return dict(result.all()) 74 + 75 + 76 + async def get_top_likers( 77 + db: AsyncSession, 78 + track_ids: list[int], 79 + limit: int = 3, 80 + ) -> dict[int, list[LikerPreview]]: 81 + """get the N most recent likers per track in a single batched query. 82 + 83 + uses ROW_NUMBER() OVER (PARTITION BY track_id ORDER BY created_at DESC) and 84 + filters to rn <= limit. postgres 15+ pushes the limit condition into the 85 + window aggregate (Run Condition), so work short-circuits once each track 86 + has N rows. EXPLAIN ANALYZE on production (308 likes, 20-track page): 87 + ~1ms execution, all in shared buffer cache. 88 + 89 + args: 90 + db: database session 91 + track_ids: list of track IDs to get likers for 92 + limit: max likers per track (default 3) 93 + 94 + returns: 95 + dict mapping track_id -> list of LikerPreview, most recent first. 96 + tracks with zero likes are omitted from the dict. 97 + """ 98 + if not track_ids: 99 + return {} 100 + 101 + rn = ( 102 + func.row_number() 103 + .over( 104 + partition_by=TrackLike.track_id, 105 + order_by=TrackLike.created_at.desc(), 106 + ) 107 + .label("rn") 108 + ) 109 + 110 + ranked = ( 111 + select( 112 + Artist.did.label("did"), 113 + Artist.handle.label("handle"), 114 + Artist.display_name.label("display_name"), 115 + Artist.avatar_url.label("avatar_url"), 116 + TrackLike.track_id.label("track_id"), 117 + rn, 118 + ) 119 + .join(Artist, Artist.did == TrackLike.user_did) 120 + .where(TrackLike.track_id.in_(track_ids)) 121 + .subquery() 122 + ) 123 + 124 + stmt = ( 125 + select( 126 + ranked.c.did, 127 + ranked.c.handle, 128 + ranked.c.display_name, 129 + ranked.c.avatar_url, 130 + ranked.c.track_id, 131 + ) 132 + .where(ranked.c.rn <= limit) 133 + .order_by(ranked.c.track_id, ranked.c.rn) 134 + ) 135 + 136 + result = await db.execute(stmt) 137 + out: dict[int, list[LikerPreview]] = defaultdict(list) 138 + for did, handle, display_name, avatar_url, track_id in result.all(): 139 + out[track_id].append( 140 + LikerPreview( 141 + did=did, 142 + handle=handle, 143 + display_name=display_name, 144 + avatar_url=avatar_url, 145 + ) 146 + ) 147 + return dict(out) 48 148 49 149 50 150 async def get_comment_counts(db: AsyncSession, track_ids: list[int]) -> dict[int, int]:
+182
backend/tests/utilities/test_aggregations.py
··· 7 7 from backend.utilities.aggregations import ( 8 8 get_copyright_info, 9 9 get_like_counts, 10 + get_top_likers, 10 11 get_top_track_ids, 11 12 get_top_tracks_with_counts, 12 13 ) ··· 207 208 208 209 result = await get_top_tracks_with_counts(db_session) 209 210 assert result == [] 211 + 212 + 213 + # tests for get_top_likers 214 + 215 + 216 + @pytest.fixture 217 + async def test_tracks_with_liker_artists(db_session: AsyncSession) -> list[Track]: 218 + """create test tracks + liker Artist rows so JOIN in get_top_likers resolves. 219 + 220 + track 0: 5 likes (user1..user5), most recent first by insertion order 221 + track 1: 2 likes (user1, user2) 222 + track 2: 0 likes 223 + """ 224 + uploader = Artist( 225 + did="did:plc:uploader", 226 + handle="uploader.bsky.social", 227 + display_name="Uploader", 228 + ) 229 + db_session.add(uploader) 230 + 231 + liker_artists = [ 232 + Artist( 233 + did=f"did:plc:liker{i}", 234 + handle=f"liker{i}.bsky.social", 235 + display_name=f"Liker {i}", 236 + avatar_url=f"https://example.com/avatar{i}.jpg", 237 + ) 238 + for i in range(1, 6) 239 + ] 240 + db_session.add_all(liker_artists) 241 + await db_session.flush() 242 + 243 + tracks = [] 244 + for i in range(3): 245 + track = Track( 246 + title=f"Track {i}", 247 + artist_did=uploader.did, 248 + file_id=f"liker_file_{i}", 249 + file_type="mp3", 250 + atproto_record_uri=f"at://did:plc:uploader/fm.plyr.track/{i}", 251 + atproto_record_cid=f"cid_{i}", 252 + ) 253 + db_session.add(track) 254 + tracks.append(track) 255 + 256 + await db_session.commit() 257 + for t in tracks: 258 + await db_session.refresh(t) 259 + 260 + # insert likes in chronological order. default created_at timestamps will 261 + # order them by insertion — each subsequent commit is "more recent". 262 + import asyncio as _asyncio 263 + 264 + # track 0: 5 likes 265 + for i in range(1, 6): 266 + db_session.add( 267 + TrackLike( 268 + track_id=tracks[0].id, 269 + user_did=f"did:plc:liker{i}", 270 + atproto_like_uri=f"at://did:plc:liker{i}/fm.plyr.like/t0-{i}", 271 + ) 272 + ) 273 + await db_session.commit() 274 + # ensure monotonically increasing created_at across likes so ordering 275 + # is deterministic without having to stamp timestamps manually 276 + await _asyncio.sleep(0.01) 277 + 278 + # track 1: 2 likes 279 + for i in (1, 2): 280 + db_session.add( 281 + TrackLike( 282 + track_id=tracks[1].id, 283 + user_did=f"did:plc:liker{i}", 284 + atproto_like_uri=f"at://did:plc:liker{i}/fm.plyr.like/t1-{i}", 285 + ) 286 + ) 287 + await db_session.commit() 288 + await _asyncio.sleep(0.01) 289 + 290 + return tracks 291 + 292 + 293 + async def test_get_top_likers_empty_list(db_session: AsyncSession): 294 + """empty track_ids list returns empty dict.""" 295 + result = await get_top_likers(db_session, []) 296 + assert result == {} 297 + 298 + 299 + async def test_get_top_likers_limits_to_three_by_default( 300 + db_session: AsyncSession, test_tracks_with_liker_artists: list[Track] 301 + ): 302 + """default limit=3 returns at most 3 likers per track, most recent first.""" 303 + tracks = test_tracks_with_liker_artists 304 + result = await get_top_likers(db_session, [t.id for t in tracks]) 305 + 306 + # track 0 has 5 likes → returns 3 most recent (liker5, liker4, liker3) 307 + assert len(result[tracks[0].id]) == 3 308 + assert [liker.handle for liker in result[tracks[0].id]] == [ 309 + "liker5.bsky.social", 310 + "liker4.bsky.social", 311 + "liker3.bsky.social", 312 + ] 313 + 314 + # track 1 has 2 likes → returns both (liker2, liker1) 315 + assert len(result[tracks[1].id]) == 2 316 + assert [liker.handle for liker in result[tracks[1].id]] == [ 317 + "liker2.bsky.social", 318 + "liker1.bsky.social", 319 + ] 320 + 321 + # track 2 has no likes → not in the dict 322 + assert tracks[2].id not in result 323 + 324 + 325 + async def test_get_top_likers_preview_fields( 326 + db_session: AsyncSession, test_tracks_with_liker_artists: list[Track] 327 + ): 328 + """LikerPreview has did, handle, display_name, avatar_url — and no liked_at.""" 329 + tracks = test_tracks_with_liker_artists 330 + result = await get_top_likers(db_session, [tracks[0].id]) 331 + 332 + liker = result[tracks[0].id][0] 333 + assert liker.did.startswith("did:plc:liker") 334 + assert liker.handle.endswith(".bsky.social") 335 + assert liker.display_name is not None 336 + assert liker.avatar_url is not None 337 + # LikerPreview is deliberately lighter than the tooltip's LikerInfo — no 338 + # liked_at field, since the inline stack doesn't render timestamps 339 + assert not hasattr(liker, "liked_at") 340 + 341 + 342 + async def test_get_top_likers_respects_custom_limit( 343 + db_session: AsyncSession, test_tracks_with_liker_artists: list[Track] 344 + ): 345 + """limit=1 returns only the single most recent liker per track.""" 346 + tracks = test_tracks_with_liker_artists 347 + result = await get_top_likers(db_session, [tracks[0].id], limit=1) 348 + assert len(result[tracks[0].id]) == 1 349 + assert result[tracks[0].id][0].handle == "liker5.bsky.social" 350 + 351 + 352 + async def test_get_top_likers_skips_likers_without_artist_row( 353 + db_session: AsyncSession, 354 + ): 355 + """likers whose user_did has no Artist row are omitted (JOIN requirement). 356 + 357 + this matches the existing `GET /tracks/{id}/likes` behavior — it also 358 + requires an Artist join and skips orphan likes. 359 + """ 360 + uploader = Artist( 361 + did="did:plc:orphan_test_uploader", 362 + handle="orphan-uploader.bsky.social", 363 + display_name="Orphan Test Uploader", 364 + ) 365 + db_session.add(uploader) 366 + await db_session.flush() 367 + 368 + track = Track( 369 + title="Orphan Test Track", 370 + artist_did=uploader.did, 371 + file_id="orphan_file", 372 + file_type="mp3", 373 + atproto_record_uri="at://did:plc:orphan_test_uploader/fm.plyr.track/1", 374 + atproto_record_cid="cid_orphan", 375 + ) 376 + db_session.add(track) 377 + await db_session.commit() 378 + await db_session.refresh(track) 379 + 380 + # like from a DID that has no Artist row 381 + db_session.add( 382 + TrackLike( 383 + track_id=track.id, 384 + user_did="did:plc:unknown_user", 385 + atproto_like_uri="at://did:plc:unknown_user/fm.plyr.like/1", 386 + ) 387 + ) 388 + await db_session.commit() 389 + 390 + result = await get_top_likers(db_session, [track.id]) 391 + assert track.id not in result # orphan filtered out, no matches remain 210 392 211 393 212 394 # tests for get_copyright_info
+194
frontend/src/lib/components/AvatarStack.svelte
··· 1 + <script lang="ts"> 2 + import type { UserPreview } from '$lib/types'; 3 + 4 + interface Props { 5 + /** users to render, already sorted (most-relevant first). */ 6 + users: UserPreview[]; 7 + /** total number of users this stack represents (for the "+N" overflow). */ 8 + total: number; 9 + /** max avatars to render before overflowing to "+N". default 3. */ 10 + maxVisible?: number; 11 + /** avatar diameter in pixels. default 24. */ 12 + size?: number; 13 + /** CSS color for the hairline ring separating stacked avatars. 14 + * must match the background the stack sits on, or the overlap looks 15 + * muddy. default `var(--bg-secondary)`. */ 16 + borderColor?: string; 17 + /** URL for the "+N" tile to link to (e.g. external supporter list). */ 18 + moreHref?: string; 19 + /** target attribute for `moreHref`, when set. */ 20 + moreTarget?: '_blank' | '_self'; 21 + /** handler for clicks on the "+N" tile (mutually exclusive with moreHref). */ 22 + onMoreClick?: (e: MouseEvent | KeyboardEvent) => void; 23 + /** build a per-user link target. if omitted, avatars render as spans. */ 24 + avatarHref?: (u: UserPreview) => string; 25 + /** handler for clicks on individual avatars (e.g. stop propagation). */ 26 + onAvatarClick?: (u: UserPreview, e: MouseEvent) => void; 27 + /** accessible label for the strip (e.g. "21 likes"). */ 28 + ariaLabel?: string; 29 + /** extra class on the container, for site-specific tweaks. */ 30 + class?: string; 31 + } 32 + 33 + let { 34 + users, 35 + total, 36 + maxVisible = 3, 37 + size = 24, 38 + borderColor = 'var(--bg-secondary)', 39 + moreHref, 40 + moreTarget = '_self', 41 + onMoreClick, 42 + avatarHref, 43 + onAvatarClick, 44 + ariaLabel, 45 + class: klass = '' 46 + }: Props = $props(); 47 + 48 + let visible = $derived(users.slice(0, maxVisible)); 49 + let overflow = $derived(Math.max(0, total - visible.length)); 50 + // overlap grows with size so the visual density stays consistent. 51 + let overlap = $derived(Math.round(size / 4)); 52 + 53 + function fallbackInitial(u: UserPreview): string { 54 + return (u.display_name || u.handle || '?').charAt(0).toUpperCase(); 55 + } 56 + 57 + function handleMoreKeydown(e: KeyboardEvent) { 58 + if (e.key === 'Enter' || e.key === ' ') { 59 + e.preventDefault(); 60 + onMoreClick?.(e); 61 + } 62 + } 63 + </script> 64 + 65 + <span 66 + class="avatar-stack {klass}" 67 + role={ariaLabel ? 'group' : undefined} 68 + aria-label={ariaLabel} 69 + style="--stack-size: {size}px; --stack-overlap: {overlap}px; --stack-border: {borderColor};" 70 + > 71 + {#each visible as user (user.did)} 72 + {@const title = user.display_name || user.handle} 73 + {#if avatarHref} 74 + <a 75 + class="avatar" 76 + href={avatarHref(user)} 77 + {title} 78 + onclick={(e) => onAvatarClick?.(user, e)} 79 + > 80 + {#if user.avatar_url} 81 + <img src={user.avatar_url} alt="" loading="lazy" /> 82 + {:else} 83 + <span class="fallback">{fallbackInitial(user)}</span> 84 + {/if} 85 + </a> 86 + {:else} 87 + <span class="avatar static" {title}> 88 + {#if user.avatar_url} 89 + <img src={user.avatar_url} alt="" loading="lazy" /> 90 + {:else} 91 + <span class="fallback">{fallbackInitial(user)}</span> 92 + {/if} 93 + </span> 94 + {/if} 95 + {/each} 96 + {#if overflow > 0} 97 + {#if moreHref} 98 + <a 99 + class="avatar more" 100 + href={moreHref} 101 + target={moreTarget} 102 + rel={moreTarget === '_blank' ? 'noopener' : undefined} 103 + title={ariaLabel ?? `${overflow} more`} 104 + >+{overflow}</a> 105 + {:else if onMoreClick} 106 + <span 107 + class="avatar more" 108 + role="button" 109 + tabindex="0" 110 + title={ariaLabel ?? `${overflow} more`} 111 + onclick={(e) => onMoreClick(e)} 112 + onkeydown={handleMoreKeydown} 113 + >+{overflow}</span> 114 + {:else} 115 + <span class="avatar more" title={ariaLabel ?? `${overflow} more`}>+{overflow}</span> 116 + {/if} 117 + {/if} 118 + </span> 119 + 120 + <style> 121 + .avatar-stack { 122 + display: inline-flex; 123 + align-items: center; 124 + vertical-align: middle; 125 + } 126 + 127 + .avatar { 128 + width: var(--stack-size); 129 + height: var(--stack-size); 130 + border-radius: var(--radius-full); 131 + border: 2px solid var(--stack-border); 132 + background: var(--bg-tertiary); 133 + display: inline-flex; 134 + align-items: center; 135 + justify-content: center; 136 + overflow: hidden; 137 + margin-left: calc(var(--stack-overlap) * -1); 138 + position: relative; 139 + text-decoration: none; 140 + flex-shrink: 0; 141 + color: var(--text-secondary); 142 + transition: 143 + transform 0.15s cubic-bezier(0.34, 1.56, 0.64, 1), 144 + z-index 0s; 145 + } 146 + 147 + .avatar:first-child { 148 + margin-left: 0; 149 + } 150 + 151 + /* only lift interactive avatars; static ones shouldn't reserve hover focus */ 152 + a.avatar:hover, 153 + a.avatar:focus-visible, 154 + span.avatar.more:hover, 155 + span.avatar.more:focus-visible { 156 + transform: translateY(-2px) scale(1.08); 157 + z-index: 10; 158 + } 159 + 160 + .avatar img { 161 + width: 100%; 162 + height: 100%; 163 + object-fit: cover; 164 + } 165 + 166 + .fallback { 167 + font-size: calc(var(--stack-size) * 0.42); 168 + font-weight: 600; 169 + color: var(--text-secondary); 170 + line-height: 1; 171 + } 172 + 173 + .more { 174 + background: var(--bg-secondary); 175 + font-size: calc(var(--stack-size) * 0.36); 176 + font-weight: 600; 177 + color: var(--text-tertiary); 178 + cursor: pointer; 179 + font-family: inherit; 180 + } 181 + 182 + a.more:hover, 183 + a.more:focus-visible, 184 + span.more:hover, 185 + span.more:focus-visible { 186 + color: var(--accent); 187 + } 188 + 189 + @media (prefers-reduced-motion: reduce) { 190 + .avatar { 191 + transition: none; 192 + } 193 + } 194 + </style>
+13 -1
frontend/src/lib/components/TrackCard.svelte
··· 1 1 <script lang="ts"> 2 2 import { browser } from '$app/environment'; 3 3 import SensitiveImage from './SensitiveImage.svelte'; 4 + import AvatarStack from './AvatarStack.svelte'; 4 5 import LikersTooltip from './LikersTooltip.svelte'; 5 6 import { likersSheet } from '$lib/likers-sheet.svelte'; 6 7 import type { Track } from '$lib/types'; ··· 16 17 17 18 let imageLoading = $derived(index < 3 ? 'eager' as const : 'lazy' as const); 18 19 let likeCount = $derived(track.like_count || 0); 20 + let topLikers = $derived(track.top_likers ?? []); 19 21 20 22 let isMobile = $state(false); 21 23 ··· 145 147 onfocus={handleLikesMouseEnter} 146 148 onblur={handleLikesMouseLeave} 147 149 > 148 - {likeCount} {likeCount === 1 ? 'like' : 'likes'} 150 + {#if topLikers.length > 0} 151 + <AvatarStack 152 + users={topLikers} 153 + total={likeCount} 154 + size={18} 155 + borderColor="var(--track-bg, var(--bg-secondary))" 156 + ariaLabel={`${likeCount} ${likeCount === 1 ? 'like' : 'likes'}`} 157 + /> 158 + {:else} 159 + {likeCount} {likeCount === 1 ? 'like' : 'likes'} 160 + {/if} 149 161 {#if showLikersTooltip && !isMobile} 150 162 <LikersTooltip 151 163 trackId={track.id}
+14 -1
frontend/src/lib/components/TrackItem.svelte
··· 3 3 import ShareButton from './ShareButton.svelte'; 4 4 import AddToMenu from './AddToMenu.svelte'; 5 5 import TrackActionsMenu from './TrackActionsMenu.svelte'; 6 + import AvatarStack from './AvatarStack.svelte'; 6 7 import LikersTooltip from './LikersTooltip.svelte'; 7 8 import CommentersTooltip from './CommentersTooltip.svelte'; 8 9 import SensitiveImage from './SensitiveImage.svelte'; ··· 62 63 let showCommentersTooltip = $state(false); 63 64 // use overridable $derived (Svelte 5.25+) - syncs with prop but can be overridden for optimistic UI 64 65 let likeCount = $derived(track.like_count || 0); 66 + let topLikers = $derived(track.top_likers ?? []); 65 67 let commentCount = $derived(track.comment_count || 0); 66 68 // local UI state keyed by track.id - reset when track changes (component recycling) 67 69 let trackImageError = $state(false); ··· 332 334 <span class="meta-separator">•</span> 333 335 <span 334 336 class="likes" 337 + class:likes-with-stack={topLikers.length > 0} 335 338 role="button" 336 339 tabindex="0" 337 340 aria-label={`${likeCount} ${likeCount === 1 ? 'like' : 'likes'} (focus to view users)`} ··· 343 346 onblur={handleLikesMouseLeave} 344 347 onkeydown={handleLikesKeydown} 345 348 > 346 - {likeCount} {likeCount === 1 ? 'like' : 'likes'} 349 + {#if topLikers.length > 0} 350 + <AvatarStack 351 + users={topLikers} 352 + total={likeCount} 353 + size={isMobile ? 20 : 22} 354 + borderColor="var(--track-bg, var(--bg-secondary))" 355 + ariaLabel={`${likeCount} ${likeCount === 1 ? 'like' : 'likes'}`} 356 + /> 357 + {:else} 358 + {likeCount} {likeCount === 1 ? 'like' : 'likes'} 359 + {/if} 347 360 {#if showLikersTooltip && !isMobile} 348 361 <LikersTooltip 349 362 trackId={track.id}
+13
frontend/src/lib/types.ts
··· 32 32 type: 'any' | string; 33 33 } 34 34 35 + /** 36 + * Lightweight user preview used in overlapping avatar stacks (track like 37 + * strips, supporter rows, etc). Matches backend `LikerPreview` and the 38 + * atprotofans-sourced `Supporter` shape. 39 + */ 40 + export interface UserPreview { 41 + did: string; 42 + handle: string; 43 + display_name?: string | null; 44 + avatar_url?: string | null; 45 + } 46 + 35 47 export interface Track { 36 48 id: number; 37 49 title: string; ··· 48 60 atproto_record_url?: string; 49 61 play_count: number; 50 62 like_count?: number; 63 + top_likers?: UserPreview[]; // up to 3 most recent likers, for inline avatar stack 51 64 comment_count?: number; 52 65 features?: FeaturedArtist[]; 53 66 tags?: string[];
+12 -1
frontend/src/routes/track/[id]/+page.svelte
··· 10 10 import AddToMenu from '$lib/components/AddToMenu.svelte'; 11 11 import TagEffects from '$lib/components/TagEffects.svelte'; 12 12 import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 13 + import AvatarStack from '$lib/components/AvatarStack.svelte'; 13 14 import LikersTooltip from '$lib/components/LikersTooltip.svelte'; 14 15 import { likersSheet } from '$lib/likers-sheet.svelte'; 15 16 import LosslessBadge from '$lib/components/LosslessBadge.svelte'; ··· 610 611 onblur={handleLikesMouseLeave} 611 612 onkeydown={handleLikesKeydown} 612 613 > 613 - {track.like_count} {track.like_count === 1 ? 'like' : 'likes'} 614 + {#if track.top_likers && track.top_likers.length > 0} 615 + <AvatarStack 616 + users={track.top_likers} 617 + total={track.like_count} 618 + size={24} 619 + borderColor="var(--bg-primary)" 620 + ariaLabel={`${track.like_count} ${track.like_count === 1 ? 'like' : 'likes'}`} 621 + /> 622 + {:else} 623 + {track.like_count} {track.like_count === 1 ? 'like' : 'likes'} 624 + {/if} 614 625 {#if showLikersTooltip && !isMobile} 615 626 <LikersTooltip 616 627 trackId={track.id}
+27 -85
frontend/src/routes/u/[handle]/+page.svelte
··· 8 8 import ShareButton from '$lib/components/ShareButton.svelte'; 9 9 import Header from '$lib/components/Header.svelte'; 10 10 import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 11 + import AvatarStack from '$lib/components/AvatarStack.svelte'; 11 12 import SupporterBadge from '$lib/components/SupporterBadge.svelte'; 12 13 import RichText from '$lib/components/RichText.svelte'; 13 14 import { moderation } from '$lib/moderation.svelte'; ··· 36 37 let nextCursor = $state<string | null>(data.nextCursor ?? null); 37 38 let loadingMoreTracks = $state(false); 38 39 let shareUrl = $state(''); 40 + let isMobile = $state(false); 41 + 42 + $effect(() => { 43 + if (!browser) return; 44 + const mq = window.matchMedia('(max-width: 768px)'); 45 + isMobile = mq.matches; 46 + const handler = (e: MediaQueryListEvent) => (isMobile = e.matches); 47 + mq.addEventListener('change', handler); 48 + return () => mq.removeEventListener('change', handler); 49 + }); 39 50 40 51 // compute support URL - handle 'atprotofans' magic value 41 52 const supportUrl = $derived(() => { ··· 460 471 </section> 461 472 462 473 {#if artist.support_url === 'atprotofans' && supporters.length > 0} 474 + {@const total = supporterCount ?? supporters.length} 463 475 <section class="supporters-section"> 464 476 <div class="supporters-row"> 465 - <span class="supporters-label">{supporterCount ?? supporters.length} {(supporterCount ?? supporters.length) === 1 ? 'supporter' : 'supporters'}</span> 466 - <div class="supporters-avatars"> 467 - {#each supporters.slice(0, 20) as supporter} 468 - <a 469 - href="/u/{supporter.handle}" 470 - class="supporter-circle" 471 - title={supporter.display_name || supporter.handle} 472 - > 473 - {#if supporter.avatar_url} 474 - <img src={supporter.avatar_url} alt="" /> 475 - {:else} 476 - <span>{(supporter.display_name || supporter.handle).charAt(0).toUpperCase()}</span> 477 - {/if} 478 - </a> 479 - {/each} 480 - {#if (supporterCount ?? supporters.length) > 20} 481 - <a 482 - href={supportUrl()} 483 - target="_blank" 484 - rel="noopener" 485 - class="supporter-circle more" 486 - title="view all supporters" 487 - > 488 - +{(supporterCount ?? supporters.length) - 20} 489 - </a> 490 - {/if} 491 - </div> 477 + <span class="supporters-label">{total} {total === 1 ? 'supporter' : 'supporters'}</span> 478 + <AvatarStack 479 + users={supporters} 480 + {total} 481 + maxVisible={20} 482 + size={isMobile ? 28 : 32} 483 + borderColor="var(--bg-primary)" 484 + avatarHref={(s) => `/u/${s.handle}`} 485 + moreHref={supportUrl() ?? undefined} 486 + moreTarget="_blank" 487 + ariaLabel="view all supporters" 488 + class="supporters-avatars" 489 + /> 492 490 </div> 493 491 </section> 494 492 {/if} ··· 846 844 white-space: nowrap; 847 845 } 848 846 849 - .supporters-avatars { 850 - display: flex; 851 - align-items: center; 852 - } 853 - 854 - .supporter-circle { 855 - width: 32px; 856 - height: 32px; 857 - border-radius: var(--radius-full); 858 - border: 2px solid var(--bg-primary); 859 - background: var(--bg-tertiary); 860 - display: flex; 861 - align-items: center; 862 - justify-content: center; 863 - overflow: hidden; 864 - margin-left: -8px; 865 - transition: transform 0.15s ease, z-index 0.15s ease; 866 - position: relative; 867 - text-decoration: none; 868 - } 869 - 870 - .supporter-circle:first-child { 871 - margin-left: 0; 872 - } 873 - 874 - .supporter-circle:hover { 875 - transform: translateY(-2px) scale(1.1); 876 - z-index: 10; 877 - } 878 - 879 - .supporter-circle img { 880 - width: 100%; 881 - height: 100%; 882 - object-fit: cover; 883 - } 884 - 885 - .supporter-circle span { 886 - font-size: var(--text-xs); 887 - font-weight: 600; 888 - color: var(--text-secondary); 889 - } 890 - 891 - .supporter-circle.more { 892 - background: var(--bg-secondary); 893 - font-size: var(--text-xs); 894 - font-weight: 600; 895 - color: var(--text-tertiary); 896 - } 897 - 898 - .supporter-circle.more:hover { 899 - color: var(--accent); 900 - } 847 + /* overlapping avatar strip is rendered by <AvatarStack>. styles live in 848 + that component; this page just needs the row-level flex layout. */ 901 849 902 850 .analytics { 903 851 margin-bottom: 3rem; ··· 1326 1274 1327 1275 .album-card-meta p { 1328 1276 font-size: var(--text-sm); 1329 - } 1330 - 1331 - .supporter-circle { 1332 - width: 28px; 1333 - height: 28px; 1334 - margin-left: -6px; 1335 1277 } 1336 1278 } 1337 1279
+1 -1
loq.toml
··· 32 32 33 33 [[rules]] 34 34 path = "backend/src/backend/api/tracks/listing.py" 35 - max_lines = 584 35 + max_lines = 592 36 36 37 37 [[rules]] 38 38 path = "backend/src/backend/api/tracks/mutations.py"