feat: add tag filtering system with user-configurable hidden tags (#431)

* feat: add tag filtering system with user-configurable hidden tags

- Add tags and track_tags tables with migrations
- Create /tracks/tags endpoint with autocomplete and track counts
- Add TagInput component for upload/edit forms with suggestions
- Implement hidden_tags preference (defaults to ["ai"])
- Filter hidden tags from latest tracks list
- Add hidden tags management UI in settings
- Fix route ordering so /tracks/tags works correctly

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

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: move hidden tags filter inline with discovery feed

- Add explicit filter_hidden_tags parameter to /tracks endpoint
with smart defaults (auto-disable on artist pages)
- Move hidden tags UI from settings menu to inline eye icon
below "latest tracks" heading
- Collapsible filter: eye icon expands to show/edit hidden tags
- Add 5 regression tests for filter behavior
- Fix persistence: use $effect to fetch prefs when auth ready

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

Co-Authored-By: Claude <noreply@anthropic.com>

---------

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

authored by zzstoatzz.io Claude and committed by GitHub f583c3dd 9f6165b3

+76
backend/alembic/versions/2025_12_01_223433_ae401a1f0b56_add_tags_and_track_tags_tables.py
··· 1 + """add tags and track_tags tables 2 + 3 + Revision ID: ae401a1f0b56 4 + Revises: e3d1b1eebe4b 5 + Create Date: 2025-12-01 22:34:33.326665 6 + 7 + """ 8 + 9 + from collections.abc import Sequence 10 + 11 + import sqlalchemy as sa 12 + from sqlalchemy.engine.reflection import Inspector 13 + 14 + from alembic import op 15 + 16 + # revision identifiers, used by Alembic. 17 + revision: str = "ae401a1f0b56" 18 + down_revision: str | Sequence[str] | None = "e3d1b1eebe4b" 19 + branch_labels: str | Sequence[str] | None = None 20 + depends_on: str | Sequence[str] | None = None 21 + 22 + 23 + def upgrade() -> None: 24 + """Upgrade schema.""" 25 + conn = op.get_bind() 26 + inspector = Inspector.from_engine(conn) 27 + existing_tables = inspector.get_table_names() 28 + 29 + # create tags table 30 + if "tags" not in existing_tables: 31 + op.create_table( 32 + "tags", 33 + sa.Column("id", sa.Integer(), nullable=False), 34 + sa.Column("name", sa.String(), nullable=False), 35 + sa.Column("created_by_did", sa.String(), nullable=False), 36 + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), 37 + sa.ForeignKeyConstraint( 38 + ["created_by_did"], 39 + ["artists.did"], 40 + name="fk_tags_created_by_did", 41 + ), 42 + sa.PrimaryKeyConstraint("id"), 43 + ) 44 + op.create_index(op.f("ix_tags_id"), "tags", ["id"], unique=False) 45 + op.create_index(op.f("ix_tags_name"), "tags", ["name"], unique=True) 46 + op.create_index( 47 + op.f("ix_tags_created_by_did"), "tags", ["created_by_did"], unique=False 48 + ) 49 + 50 + # create track_tags join table 51 + if "track_tags" not in existing_tables: 52 + op.create_table( 53 + "track_tags", 54 + sa.Column("track_id", sa.Integer(), nullable=False), 55 + sa.Column("tag_id", sa.Integer(), nullable=False), 56 + sa.ForeignKeyConstraint( 57 + ["track_id"], 58 + ["tracks.id"], 59 + name="fk_track_tags_track_id", 60 + ondelete="CASCADE", 61 + ), 62 + sa.ForeignKeyConstraint( 63 + ["tag_id"], 64 + ["tags.id"], 65 + name="fk_track_tags_tag_id", 66 + ondelete="CASCADE", 67 + ), 68 + sa.PrimaryKeyConstraint("track_id", "tag_id"), 69 + ) 70 + op.create_index("ix_track_tags_tag_id", "track_tags", ["tag_id"], unique=False) 71 + 72 + 73 + def downgrade() -> None: 74 + """Downgrade schema.""" 75 + op.drop_table("track_tags") 76 + op.drop_table("tags")
+38
backend/alembic/versions/2025_12_01_225809_f60f46fb6014_add_hidden_tags_to_user_preferences.py
··· 1 + """add hidden_tags to user_preferences 2 + 3 + Revision ID: f60f46fb6014 4 + Revises: ae401a1f0b56 5 + Create Date: 2025-12-01 22:58:09.031041 6 + 7 + """ 8 + 9 + from collections.abc import Sequence 10 + 11 + import sqlalchemy as sa 12 + from sqlalchemy.dialects import postgresql 13 + 14 + from alembic import op 15 + 16 + # revision identifiers, used by Alembic. 17 + revision: str = "f60f46fb6014" 18 + down_revision: str | Sequence[str] | None = "ae401a1f0b56" 19 + branch_labels: str | Sequence[str] | None = None 20 + depends_on: str | Sequence[str] | None = None 21 + 22 + 23 + def upgrade() -> None: 24 + """Upgrade schema.""" 25 + op.add_column( 26 + "user_preferences", 27 + sa.Column( 28 + "hidden_tags", 29 + postgresql.JSONB(astext_type=sa.Text()), 30 + server_default=sa.text("'[\"ai\"]'::jsonb"), 31 + nullable=False, 32 + ), 33 + ) 34 + 35 + 36 + def downgrade() -> None: 37 + """Downgrade schema.""" 38 + op.drop_column("user_preferences", "hidden_tags")
+15 -1
backend/src/backend/api/preferences.py
··· 9 9 10 10 from backend._internal import Session, require_auth 11 11 from backend.models import UserPreferences, get_db 12 + from backend.utilities.tags import DEFAULT_HIDDEN_TAGS 12 13 13 14 router = APIRouter(prefix="/preferences", tags=["preferences"]) 14 15 ··· 19 20 accent_color: str 20 21 auto_advance: bool 21 22 allow_comments: bool 23 + hidden_tags: list[str] 22 24 23 25 24 26 class PreferencesUpdate(BaseModel): ··· 27 29 accent_color: str | None = None 28 30 auto_advance: bool | None = None 29 31 allow_comments: bool | None = None 32 + hidden_tags: list[str] | None = None 30 33 31 34 32 35 @router.get("/") ··· 42 45 43 46 if not prefs: 44 47 # create default preferences 45 - prefs = UserPreferences(did=session.did, accent_color="#6a9fff") 48 + prefs = UserPreferences( 49 + did=session.did, 50 + accent_color="#6a9fff", 51 + hidden_tags=list(DEFAULT_HIDDEN_TAGS), 52 + ) 46 53 db.add(prefs) 47 54 await db.commit() 48 55 await db.refresh(prefs) ··· 51 58 accent_color=prefs.accent_color, 52 59 auto_advance=prefs.auto_advance, 53 60 allow_comments=prefs.allow_comments, 61 + hidden_tags=prefs.hidden_tags or [], 54 62 ) 55 63 56 64 ··· 77 85 allow_comments=update.allow_comments 78 86 if update.allow_comments is not None 79 87 else False, 88 + hidden_tags=update.hidden_tags 89 + if update.hidden_tags is not None 90 + else list(DEFAULT_HIDDEN_TAGS), 80 91 ) 81 92 db.add(prefs) 82 93 else: ··· 87 98 prefs.auto_advance = update.auto_advance 88 99 if update.allow_comments is not None: 89 100 prefs.allow_comments = update.allow_comments 101 + if update.hidden_tags is not None: 102 + prefs.hidden_tags = update.hidden_tags 90 103 91 104 await db.commit() 92 105 await db.refresh(prefs) ··· 95 108 accent_color=prefs.accent_color, 96 109 auto_advance=prefs.auto_advance, 97 110 allow_comments=prefs.allow_comments, 111 + hidden_tags=prefs.hidden_tags or [], 98 112 )
+9 -6
backend/src/backend/api/tracks/__init__.py
··· 3 3 from .router import router 4 4 5 5 # Import route modules to register handlers on the shared router. 6 - from . import comments as _comments 7 - from . import likes as _likes 8 - from . import listing as _listing 9 - from . import mutations as _mutations 10 - from . import playback as _playback 11 - from . import uploads as _uploads 6 + # IMPORTANT: Order matters! Static paths (/tags, /liked, /me) must be imported 7 + # BEFORE modules with wildcard paths (/{track_id}) to ensure correct routing. 8 + from . import listing as _listing # /, /me, /me/broken 9 + from . import tags as _tags # /tags 10 + from . import likes as _likes # /liked, /{track_id}/like, /{track_id}/likes 11 + from . import uploads as _uploads # /, /uploads/{upload_id}/progress 12 + from . import comments as _comments # /{track_id}/comments, /comments/{comment_id} 13 + from . import mutations as _mutations # /{track_id}, /{track_id}/restore-record 14 + from . import playback as _playback # /{track_id}, /{track_id}/play 12 15 13 16 __all__ = ["router"]
+71 -10
backend/src/backend/api/tracks/listing.py
··· 13 13 from backend._internal import Session as AuthSession 14 14 from backend._internal import require_auth 15 15 from backend._internal.auth import get_session 16 - from backend.models import Artist, Track, TrackLike, get_db 16 + from backend.models import ( 17 + Artist, 18 + Tag, 19 + Track, 20 + TrackLike, 21 + TrackTag, 22 + UserPreferences, 23 + get_db, 24 + ) 17 25 from backend.schemas import TrackResponse 18 26 from backend.utilities.aggregations import ( 19 27 get_comment_counts, 20 28 get_copyright_info, 21 29 get_like_counts, 30 + get_track_tags, 22 31 ) 32 + from backend.utilities.tags import DEFAULT_HIDDEN_TAGS 23 33 24 34 from .router import router 25 35 ··· 29 39 db: Annotated[AsyncSession, Depends(get_db)], 30 40 request: Request, 31 41 artist_did: str | None = None, 42 + filter_hidden_tags: bool | None = None, 32 43 session_id_cookie: Annotated[str | None, Cookie(alias="session_id")] = None, 33 44 ) -> dict: 34 - """List all tracks, optionally filtered by artist DID.""" 45 + """List all tracks, optionally filtered by artist DID. 46 + 47 + Args: 48 + artist_did: Filter to tracks by this artist only. 49 + filter_hidden_tags: Whether to exclude tracks with user's hidden tags. 50 + - None (default): auto-decide based on context (filter on discovery feed, 51 + don't filter on artist pages) 52 + - True: always filter hidden tags 53 + - False: never filter hidden tags 54 + """ 35 55 from atproto_identity.did.resolver import AsyncDidResolver 36 56 37 57 # get authenticated user if cookie or auth header present 38 58 liked_track_ids: set[int] | None = None 59 + hidden_tags: list[str] = list(DEFAULT_HIDDEN_TAGS) 60 + auth_session = None 61 + 39 62 session_id = session_id_cookie or request.headers.get("authorization", "").replace( 40 63 "Bearer ", "" 41 64 ) ··· 45 68 ) 46 69 liked_track_ids = set(liked_result.scalars().all()) 47 70 71 + # get user's hidden tags preference 72 + prefs_result = await db.execute( 73 + select(UserPreferences).where(UserPreferences.did == auth_session.did) 74 + ) 75 + prefs = prefs_result.scalar_one_or_none() 76 + if prefs and prefs.hidden_tags is not None: 77 + hidden_tags = prefs.hidden_tags 78 + 48 79 stmt = ( 49 80 select(Track) 50 81 .join(Artist) ··· 55 86 if artist_did: 56 87 stmt = stmt.where(Track.artist_did == artist_did) 57 88 89 + # filter out tracks with hidden tags 90 + # when filter_hidden_tags is None (default), auto-decide: 91 + # - discovery feed (no artist_did): filter 92 + # - artist page (has artist_did): don't filter (show all their tracks) 93 + should_filter = ( 94 + filter_hidden_tags if filter_hidden_tags is not None else (artist_did is None) 95 + ) 96 + if hidden_tags and should_filter: 97 + # subquery: track IDs that have any of the hidden tags 98 + hidden_track_ids_subq = ( 99 + select(TrackTag.track_id) 100 + .join(Tag, TrackTag.tag_id == Tag.id) 101 + .where(Tag.name.in_(hidden_tags)) 102 + .distinct() 103 + .scalar_subquery() 104 + ) 105 + stmt = stmt.where(Track.id.not_in(hidden_track_ids_subq)) 106 + 58 107 stmt = stmt.order_by(Track.created_at.desc()) 59 108 result = await db.execute(stmt) 60 109 tracks = result.scalars().all() 61 110 62 - # batch fetch like, comment counts and copyright info for all tracks 111 + # batch fetch like, comment counts, copyright info, and tags for all tracks 63 112 track_ids = [track.id for track in tracks] 64 - like_counts, comment_counts, copyright_info = await asyncio.gather( 113 + like_counts, comment_counts, copyright_info, track_tags = await asyncio.gather( 65 114 get_like_counts(db, track_ids), 66 115 get_comment_counts(db, track_ids), 67 116 get_copyright_info(db, track_ids), 117 + get_track_tags(db, track_ids), 68 118 ) 69 119 70 120 # use cached PDS URLs with fallback on failure ··· 163 213 like_counts, 164 214 comment_counts, 165 215 copyright_info, 216 + track_tags, 166 217 ) 167 218 for track in tracks 168 219 ] ··· 187 238 result = await db.execute(stmt) 188 239 tracks = result.scalars().all() 189 240 190 - # batch fetch copyright info 241 + # batch fetch copyright info and tags 191 242 track_ids = [track.id for track in tracks] 192 - copyright_info = await get_copyright_info(db, track_ids) 243 + copyright_info, track_tags = await asyncio.gather( 244 + get_copyright_info(db, track_ids), 245 + get_track_tags(db, track_ids), 246 + ) 193 247 194 248 # fetch all track responses concurrently 195 249 track_responses = await asyncio.gather( 196 250 *[ 197 - TrackResponse.from_track(track, copyright_info=copyright_info) 251 + TrackResponse.from_track( 252 + track, copyright_info=copyright_info, track_tags=track_tags 253 + ) 198 254 for track in tracks 199 255 ] 200 256 ) ··· 231 287 result = await db.execute(stmt) 232 288 tracks = result.scalars().all() 233 289 234 - # batch fetch copyright info 290 + # batch fetch copyright info and tags 235 291 track_ids = [track.id for track in tracks] 236 - copyright_info = await get_copyright_info(db, track_ids) 292 + copyright_info, track_tags = await asyncio.gather( 293 + get_copyright_info(db, track_ids), 294 + get_track_tags(db, track_ids), 295 + ) 237 296 238 297 # fetch all track responses concurrently 239 298 track_responses = await asyncio.gather( 240 299 *[ 241 - TrackResponse.from_track(track, copyright_info=copyright_info) 300 + TrackResponse.from_track( 301 + track, copyright_info=copyright_info, track_tags=track_tags 302 + ) 242 303 for track in tracks 243 304 ] 244 305 )
+56 -2
backend/src/backend/api/tracks/mutations.py
··· 1 1 """Track mutation endpoints (delete/update/restore).""" 2 2 3 3 import contextlib 4 + import json 4 5 import logging 6 + from datetime import UTC, datetime 5 7 from typing import Annotated 6 8 7 9 import logfire ··· 22 24 ) 23 25 from backend._internal.atproto.tid import datetime_to_tid 24 26 from backend.config import settings 25 - from backend.models import Artist, Track, get_db 27 + from backend.models import Artist, Tag, Track, TrackTag, get_db 26 28 from backend.schemas import TrackResponse 27 29 from backend.storage import storage 30 + from backend.utilities.tags import normalize_tags 28 31 29 32 from .metadata_service import ( 30 33 apply_album_update, ··· 122 125 title: Annotated[str | None, Form()] = None, 123 126 album: Annotated[str | None, Form()] = None, 124 127 features: Annotated[str | None, Form()] = None, 128 + tags: Annotated[str | None, Form(description="JSON array of tag names")] = None, 125 129 image: UploadFile | None = File(None), 126 130 ) -> TrackResponse: 127 131 """Update track metadata (only by owner).""" ··· 174 178 track.image_url = image_url 175 179 image_changed = True 176 180 181 + # handle tags update 182 + updated_tags: set[str] = set() 183 + if tags is not None: 184 + try: 185 + tag_names: list[str] = json.loads(tags) 186 + except json.JSONDecodeError as e: 187 + raise HTTPException( 188 + status_code=400, detail="tags must be a JSON array" 189 + ) from e 190 + 191 + # normalize and deduplicate tag names 192 + tag_names_set = normalize_tags(tag_names) 193 + 194 + # delete existing track_tags for this track 195 + existing_track_tags = await db.execute( 196 + select(TrackTag).where(TrackTag.track_id == track_id) 197 + ) 198 + for tt in existing_track_tags.scalars(): 199 + await db.delete(tt) 200 + 201 + # get or create tags and create track_tags 202 + for tag_name in tag_names_set: 203 + # try to find existing tag 204 + tag_result = await db.execute(select(Tag).where(Tag.name == tag_name)) 205 + tag = tag_result.scalar_one_or_none() 206 + 207 + if not tag: 208 + # create new tag 209 + tag = Tag( 210 + name=tag_name, 211 + created_by_did=auth_session.did, 212 + created_at=datetime.now(UTC), 213 + ) 214 + db.add(tag) 215 + await db.flush() # get the tag.id 216 + 217 + # create track_tag association 218 + track_tag = TrackTag(track_id=track_id, tag_id=tag.id) 219 + db.add(track_tag) 220 + updated_tags.add(tag_name) 221 + 177 222 # always update ATProto record if any metadata changed 178 223 metadata_changed = ( 179 224 title_changed or album is not None or features is not None or image_changed ··· 195 240 await db.commit() 196 241 await db.refresh(track) 197 242 198 - return await TrackResponse.from_track(track) 243 + # build track_tags dict for response 244 + # if tags were updated, use updated_tags; otherwise query for existing 245 + if tags is not None: 246 + track_tags_dict = {track.id: updated_tags} 247 + else: 248 + from backend.utilities.aggregations import get_track_tags 249 + 250 + track_tags_dict = await get_track_tags(db, [track.id]) 251 + 252 + return await TrackResponse.from_track(track, track_tags=track_tags_dict) 199 253 200 254 201 255 async def _update_atproto_record(
+49
backend/src/backend/api/tracks/tags.py
··· 1 + """tag endpoints for track categorization.""" 2 + 3 + from typing import Annotated 4 + 5 + from fastapi import Depends, Query 6 + from pydantic import BaseModel 7 + from sqlalchemy import func, select 8 + from sqlalchemy.ext.asyncio import AsyncSession 9 + 10 + from backend.models import Tag, TrackTag, get_db 11 + 12 + from .router import router 13 + 14 + 15 + class TagWithCount(BaseModel): 16 + """tag with track count for autocomplete.""" 17 + 18 + name: str 19 + track_count: int 20 + 21 + 22 + @router.get("/tags") 23 + async def list_tags( 24 + db: Annotated[AsyncSession, Depends(get_db)], 25 + q: Annotated[str | None, Query(description="search query for tag names")] = None, 26 + limit: Annotated[int, Query(ge=1, le=100)] = 20, 27 + ) -> list[TagWithCount]: 28 + """list tags with track counts, optionally filtered by query. 29 + 30 + returns tags sorted by track count (most used first). 31 + use `q` parameter for prefix search (case-insensitive). 32 + """ 33 + # build query: tags with their track counts 34 + query = ( 35 + select(Tag.name, func.count(TrackTag.track_id).label("track_count")) 36 + .outerjoin(TrackTag, Tag.id == TrackTag.tag_id) 37 + .group_by(Tag.id, Tag.name) 38 + .order_by(func.count(TrackTag.track_id).desc(), Tag.name) 39 + .limit(limit) 40 + ) 41 + 42 + # apply prefix filter if query provided 43 + if q: 44 + query = query.where(Tag.name.ilike(f"{q}%")) 45 + 46 + result = await db.execute(query) 47 + rows = result.all() 48 + 49 + return [TagWithCount(name=row.name, track_count=row.track_count) for row in rows]
+45 -1
backend/src/backend/api/tracks/uploads.py
··· 5 5 import json 6 6 import logging 7 7 import tempfile 8 + from datetime import UTC, datetime 8 9 from pathlib import Path 9 10 from typing import Annotated 10 11 ··· 31 32 from backend._internal.jobs import job_service 32 33 from backend._internal.moderation import scan_track_for_copyright 33 34 from backend.config import settings 34 - from backend.models import Artist, Track 35 + from backend.models import Artist, Tag, Track, TrackTag 35 36 from backend.models.job import JobStatus, JobType 36 37 from backend.storage import storage 37 38 from backend.utilities.database import db_session 38 39 from backend.utilities.hashing import CHUNK_SIZE 39 40 from backend.utilities.progress import R2ProgressTracker 40 41 from backend.utilities.rate_limit import limiter 42 + from backend.utilities.tags import normalize_tags 41 43 42 44 from .router import router 43 45 from .services import get_or_create_album ··· 53 55 artist_did: str, 54 56 album: str | None, 55 57 features: str | None, 58 + tags: str | None, 56 59 auth_session: AuthSession, 57 60 image_path: str | None = None, 58 61 image_filename: str | None = None, ··· 334 337 await db.commit() 335 338 await db.refresh(track) 336 339 340 + # handle tags if provided 341 + if tags: 342 + try: 343 + tag_names: list[str] = json.loads(tags) 344 + if isinstance(tag_names, list): 345 + # normalize and deduplicate tag names 346 + tag_names_set = normalize_tags( 347 + [n for n in tag_names if isinstance(n, str)] 348 + ) 349 + 350 + for tag_name in tag_names_set: 351 + # try to find existing tag 352 + tag_result = await db.execute( 353 + select(Tag).where(Tag.name == tag_name) 354 + ) 355 + tag = tag_result.scalar_one_or_none() 356 + 357 + if not tag: 358 + # create new tag 359 + tag = Tag( 360 + name=tag_name, 361 + created_by_did=artist_did, 362 + created_at=datetime.now(UTC), 363 + ) 364 + db.add(tag) 365 + await db.flush() 366 + 367 + # create track_tag association 368 + track_tag = TrackTag( 369 + track_id=track.id, tag_id=tag.id 370 + ) 371 + db.add(track_tag) 372 + 373 + await db.commit() 374 + except json.JSONDecodeError: 375 + pass # ignore malformed tags 376 + except Exception as e: 377 + logger.warning(f"failed to add tags to track: {e}") 378 + 337 379 # send notification about new track 338 380 from backend._internal.notifications import notification_service 339 381 ··· 404 446 auth_session: AuthSession = Depends(require_artist_profile), 405 447 album: Annotated[str | None, Form()] = None, 406 448 features: Annotated[str | None, Form()] = None, 449 + tags: Annotated[str | None, Form(description="JSON array of tag names")] = None, 407 450 file: UploadFile = File(...), 408 451 image: UploadFile | None = File(None), 409 452 ) -> dict: ··· 502 545 auth_session.did, 503 546 album, 504 547 features, 548 + tags, 505 549 auth_session, 506 550 image_path, 507 551 image_filename,
+3
backend/src/backend/models/__init__.py
··· 11 11 from backend.models.preferences import UserPreferences 12 12 from backend.models.queue import QueueState 13 13 from backend.models.session import UserSession 14 + from backend.models.tag import Tag, TrackTag 14 15 from backend.models.track import Track 15 16 from backend.models.track_comment import TrackComment 16 17 from backend.models.track_like import TrackLike ··· 27 28 "PendingDevToken", 28 29 "QueueState", 29 30 "ScanResolution", 31 + "Tag", 30 32 "Track", 31 33 "TrackComment", 32 34 "TrackLike", 35 + "TrackTag", 33 36 "UserPreferences", 34 37 "UserSession", 35 38 "db_session",
+12
backend/src/backend/models/preferences.py
··· 3 3 from datetime import UTC, datetime 4 4 5 5 from sqlalchemy import Boolean, DateTime, String, text 6 + from sqlalchemy.dialects.postgresql import JSONB 6 7 from sqlalchemy.orm import Mapped, mapped_column 7 8 8 9 from backend.models.database import Base 10 + from backend.utilities.tags import DEFAULT_HIDDEN_TAGS 9 11 10 12 11 13 class UserPreferences(Base): ··· 25 27 # artist preferences 26 28 allow_comments: Mapped[bool] = mapped_column( 27 29 Boolean, nullable=False, default=False, server_default=text("false") 30 + ) 31 + 32 + # tag filtering preferences 33 + # stores a list of tag names that should be hidden from track listings 34 + # defaults to ["ai"] to hide AI-generated content by default 35 + hidden_tags: Mapped[list[str]] = mapped_column( 36 + JSONB, 37 + nullable=False, 38 + default=lambda: list(DEFAULT_HIDDEN_TAGS), 39 + server_default=text("'[\"ai\"]'::jsonb"), 28 40 ) 29 41 30 42 # metadata
+64
backend/src/backend/models/tag.py
··· 1 + """tag models for track labeling.""" 2 + 3 + from datetime import UTC, datetime 4 + from typing import TYPE_CHECKING 5 + 6 + from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, UniqueConstraint 7 + from sqlalchemy.orm import Mapped, mapped_column, relationship 8 + 9 + from backend.models.database import Base 10 + 11 + if TYPE_CHECKING: 12 + from backend.models.artist import Artist 13 + from backend.models.track import Track 14 + 15 + 16 + class Tag(Base): 17 + """globally shared tag for categorizing tracks.""" 18 + 19 + __tablename__ = "tags" 20 + 21 + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) 22 + name: Mapped[str] = mapped_column(String, nullable=False, unique=True, index=True) 23 + created_by_did: Mapped[str] = mapped_column( 24 + String, 25 + ForeignKey("artists.did"), 26 + nullable=False, 27 + index=True, 28 + ) 29 + created_at: Mapped[datetime] = mapped_column( 30 + DateTime(timezone=True), 31 + default=lambda: datetime.now(UTC), 32 + nullable=False, 33 + ) 34 + 35 + # relationships 36 + tracks: Mapped[list["TrackTag"]] = relationship( 37 + "TrackTag", back_populates="tag", cascade="all, delete-orphan" 38 + ) 39 + creator: Mapped["Artist"] = relationship("Artist", lazy="raise") 40 + 41 + 42 + class TrackTag(Base): 43 + """join table for track-tag many-to-many relationship.""" 44 + 45 + __tablename__ = "track_tags" 46 + __table_args__ = ( 47 + UniqueConstraint("track_id", "tag_id", name="uq_track_tag"), 48 + Index("ix_track_tags_tag_id", "tag_id"), 49 + ) 50 + 51 + track_id: Mapped[int] = mapped_column( 52 + Integer, 53 + ForeignKey("tracks.id", ondelete="CASCADE"), 54 + primary_key=True, 55 + ) 56 + tag_id: Mapped[int] = mapped_column( 57 + Integer, 58 + ForeignKey("tags.id", ondelete="CASCADE"), 59 + primary_key=True, 60 + ) 61 + 62 + # relationships 63 + track: Mapped["Track"] = relationship("Track", back_populates="track_tags") 64 + tag: Mapped["Tag"] = relationship("Tag", back_populates="tracks")
+4
backend/src/backend/models/track.py
··· 12 12 if TYPE_CHECKING: 13 13 from backend.models.album import Album 14 14 from backend.models.artist import Artist 15 + from backend.models.tag import TrackTag 15 16 16 17 17 18 class Track(Base): ··· 106 107 107 108 # relationships 108 109 album_rel: Mapped["Album | None"] = relationship("Album", back_populates="tracks") 110 + track_tags: Mapped[list["TrackTag"]] = relationship( 111 + "TrackTag", back_populates="track", cascade="all, delete-orphan", lazy="raise" 112 + )
+7
backend/src/backend/schemas.py
··· 64 64 like_count: int 65 65 comment_count: int 66 66 album: AlbumSummary | None 67 + tags: set[str] = set() 67 68 copyright_flagged: bool | None = ( 68 69 None # None = not scanned, False = clear, True = flagged 69 70 ) ··· 78 79 like_counts: dict[int, int] | None = None, 79 80 comment_counts: dict[int, int] | None = None, 80 81 copyright_info: dict[int, CopyrightInfo] | None = None, 82 + track_tags: dict[int, set[str]] | None = None, 81 83 ) -> "TrackResponse": 82 84 """build track response from Track model. 83 85 ··· 88 90 like_counts: optional dict of track_id -> like_count 89 91 comment_counts: optional dict of track_id -> comment_count 90 92 copyright_info: optional dict of track_id -> CopyrightInfo 93 + track_tags: optional dict of track_id -> set of tag names 91 94 """ 92 95 # check if user has liked this track 93 96 is_liked = liked_track_ids is not None and track.id in liked_track_ids ··· 128 131 copyright_flagged = track_copyright.is_flagged if track_copyright else None 129 132 copyright_match = track_copyright.primary_match if track_copyright else None 130 133 134 + # get tags for this track 135 + tags = track_tags.get(track.id, set()) if track_tags else set() 136 + 131 137 return cls( 132 138 id=track.id, 133 139 title=track.title, ··· 147 153 like_count=like_count, 148 154 comment_count=comment_count, 149 155 album=album_data, 156 + tags=tags, 150 157 copyright_flagged=copyright_flagged, 151 158 copyright_match=copyright_match, 152 159 )
+33 -1
backend/src/backend/utilities/aggregations.py
··· 8 8 from sqlalchemy.ext.asyncio import AsyncSession 9 9 from sqlalchemy.sql import func 10 10 11 - from backend.models import CopyrightScan, TrackComment, TrackLike 11 + from backend.models import CopyrightScan, Tag, TrackComment, TrackLike, TrackTag 12 12 13 13 14 14 @dataclass ··· 125 125 # get the most common match 126 126 (title, artist), _ = match_counts.most_common(1)[0] 127 127 return f"{title} by {artist}" 128 + 129 + 130 + async def get_track_tags(db: AsyncSession, track_ids: list[int]) -> dict[int, set[str]]: 131 + """get tags for multiple tracks in a single query. 132 + 133 + args: 134 + db: database session 135 + track_ids: list of track IDs to get tags for 136 + 137 + returns: 138 + dict mapping track_id -> set of tag names 139 + """ 140 + if not track_ids: 141 + return {} 142 + 143 + stmt = ( 144 + select(TrackTag.track_id, Tag.name) 145 + .join(Tag, TrackTag.tag_id == Tag.id) 146 + .where(TrackTag.track_id.in_(track_ids)) 147 + ) 148 + 149 + result = await db.execute(stmt) 150 + rows = result.all() 151 + 152 + # group tags by track_id 153 + tags_by_track: dict[int, set[str]] = {} 154 + for track_id, tag_name in rows: 155 + if track_id not in tags_by_track: 156 + tags_by_track[track_id] = set() 157 + tags_by_track[track_id].add(tag_name) 158 + 159 + return tags_by_track
+52
backend/src/backend/utilities/tags.py
··· 1 + """tag normalization and management utilities.""" 2 + 3 + import re 4 + 5 + # tags that are hidden by default for new users 6 + DEFAULT_HIDDEN_TAGS: list[str] = ["ai"] 7 + 8 + 9 + def normalize_tag(tag: str) -> str: 10 + """normalize a tag name to canonical form. 11 + 12 + - strips whitespace 13 + - converts to lowercase 14 + - collapses multiple spaces to single space 15 + - removes leading/trailing hyphens 16 + 17 + examples: 18 + " AI Generated " -> "ai generated" 19 + "Hip-Hop" -> "hip-hop" 20 + " test " -> "test" 21 + """ 22 + if not tag: 23 + return "" 24 + 25 + # strip and lowercase 26 + normalized = tag.strip().lower() 27 + 28 + # collapse multiple spaces to single space 29 + normalized = re.sub(r"\s+", " ", normalized) 30 + 31 + # remove leading/trailing hyphens 32 + normalized = normalized.strip("-") 33 + 34 + return normalized 35 + 36 + 37 + def normalize_tags(tags: list[str]) -> set[str]: 38 + """normalize a list of tags, deduplicating and filtering empty. 39 + 40 + returns a set of normalized, unique tag names. 41 + """ 42 + normalized = set() 43 + for tag in tags: 44 + n = normalize_tag(tag) 45 + if n: 46 + normalized.add(n) 47 + return normalized 48 + 49 + 50 + def is_tag_hidden_by_default(tag: str) -> bool: 51 + """check if a tag should be hidden by default.""" 52 + return normalize_tag(tag) in DEFAULT_HIDDEN_TAGS
+267
backend/tests/api/test_hidden_tags_filter.py
··· 1 + """tests for hidden tags filtering on track listing endpoints.""" 2 + 3 + from collections.abc import Generator 4 + from unittest.mock import patch 5 + 6 + import pytest 7 + from fastapi import FastAPI 8 + from httpx import ASGITransport, AsyncClient 9 + from sqlalchemy.ext.asyncio import AsyncSession 10 + 11 + from backend._internal import Session, require_auth 12 + from backend.main import app 13 + from backend.models import Artist, Tag, Track, TrackTag, UserPreferences, get_db 14 + 15 + 16 + class MockSession(Session): 17 + """mock session for auth bypass in tests.""" 18 + 19 + def __init__(self, did: str = "did:test:user123"): 20 + self.did = did 21 + self.handle = "testuser.bsky.social" 22 + self.session_id = "test_session_id" 23 + self.access_token = "test_token" 24 + self.refresh_token = "test_refresh" 25 + self.oauth_session = { 26 + "did": did, 27 + "handle": "testuser.bsky.social", 28 + "pds_url": "https://test.pds", 29 + "authserver_iss": "https://auth.test", 30 + "scope": "atproto transition:generic", 31 + "access_token": "test_token", 32 + "refresh_token": "test_refresh", 33 + "dpop_private_key_pem": "fake_key", 34 + "dpop_authserver_nonce": "", 35 + "dpop_pds_nonce": "", 36 + } 37 + 38 + 39 + @pytest.fixture 40 + async def artist(db_session: AsyncSession) -> Artist: 41 + """create a test artist.""" 42 + artist = Artist( 43 + did="did:plc:artist123", 44 + handle="artist.bsky.social", 45 + display_name="Test Artist", 46 + pds_url="https://test.pds", 47 + ) 48 + db_session.add(artist) 49 + await db_session.commit() 50 + await db_session.refresh(artist) 51 + return artist 52 + 53 + 54 + @pytest.fixture 55 + async def ai_tag(db_session: AsyncSession, artist: Artist) -> Tag: 56 + """create an 'ai' tag.""" 57 + tag = Tag(name="ai", created_by_did=artist.did) 58 + db_session.add(tag) 59 + await db_session.commit() 60 + await db_session.refresh(tag) 61 + return tag 62 + 63 + 64 + @pytest.fixture 65 + async def regular_track(db_session: AsyncSession, artist: Artist) -> Track: 66 + """create a track without any tags.""" 67 + track = Track( 68 + title="Regular Track", 69 + artist_did=artist.did, 70 + file_id="regular123", 71 + file_type="mp3", 72 + extra={"duration": 180}, 73 + atproto_record_uri="at://did:plc:artist123/fm.plyr.track/regular123", 74 + atproto_record_cid="bafyregular123", 75 + ) 76 + db_session.add(track) 77 + await db_session.commit() 78 + await db_session.refresh(track) 79 + return track 80 + 81 + 82 + @pytest.fixture 83 + async def ai_tagged_track( 84 + db_session: AsyncSession, artist: Artist, ai_tag: Tag 85 + ) -> Track: 86 + """create a track tagged with 'ai'.""" 87 + track = Track( 88 + title="AI Generated Track", 89 + artist_did=artist.did, 90 + file_id="aitrack123", 91 + file_type="mp3", 92 + extra={"duration": 120}, 93 + atproto_record_uri="at://did:plc:artist123/fm.plyr.track/aitrack123", 94 + atproto_record_cid="bafyaitrack123", 95 + ) 96 + db_session.add(track) 97 + await db_session.flush() 98 + 99 + track_tag = TrackTag(track_id=track.id, tag_id=ai_tag.id) 100 + db_session.add(track_tag) 101 + await db_session.commit() 102 + await db_session.refresh(track) 103 + return track 104 + 105 + 106 + @pytest.fixture 107 + async def user_with_hidden_ai_tag( 108 + db_session: AsyncSession, 109 + ) -> UserPreferences: 110 + """create user preferences with 'ai' as hidden tag.""" 111 + prefs = UserPreferences( 112 + did="did:test:user123", 113 + hidden_tags=["ai"], 114 + ) 115 + db_session.add(prefs) 116 + await db_session.commit() 117 + await db_session.refresh(prefs) 118 + return prefs 119 + 120 + 121 + @pytest.fixture 122 + def test_app(db_session: AsyncSession) -> Generator[FastAPI, None, None]: 123 + """create test app with mocked auth and db.""" 124 + 125 + async def mock_require_auth() -> Session: 126 + return MockSession() 127 + 128 + async def mock_get_session(session_id: str) -> Session | None: 129 + if session_id == "test_session": 130 + return MockSession() 131 + return None 132 + 133 + async def mock_get_db(): 134 + yield db_session 135 + 136 + app.dependency_overrides[require_auth] = mock_require_auth 137 + app.dependency_overrides[get_db] = mock_get_db 138 + 139 + with patch("backend.api.tracks.listing.get_session", mock_get_session): 140 + yield app 141 + 142 + app.dependency_overrides.clear() 143 + 144 + 145 + async def test_discovery_feed_filters_hidden_tags_by_default( 146 + test_app: FastAPI, 147 + db_session: AsyncSession, 148 + regular_track: Track, 149 + ai_tagged_track: Track, 150 + user_with_hidden_ai_tag: UserPreferences, 151 + ): 152 + """test that discovery feed (no artist_did) filters hidden tags.""" 153 + async with AsyncClient( 154 + transport=ASGITransport(app=test_app), base_url="http://test" 155 + ) as client: 156 + response = await client.get( 157 + "/tracks/", 158 + cookies={"session_id": "test_session"}, 159 + ) 160 + 161 + assert response.status_code == 200 162 + tracks = response.json()["tracks"] 163 + track_titles = [t["title"] for t in tracks] 164 + 165 + # regular track should be visible 166 + assert "Regular Track" in track_titles 167 + # ai-tagged track should be hidden 168 + assert "AI Generated Track" not in track_titles 169 + 170 + 171 + async def test_artist_page_shows_all_tracks_by_default( 172 + test_app: FastAPI, 173 + db_session: AsyncSession, 174 + artist: Artist, 175 + regular_track: Track, 176 + ai_tagged_track: Track, 177 + user_with_hidden_ai_tag: UserPreferences, 178 + ): 179 + """test that artist page (with artist_did) shows all tracks including hidden.""" 180 + async with AsyncClient( 181 + transport=ASGITransport(app=test_app), base_url="http://test" 182 + ) as client: 183 + response = await client.get( 184 + f"/tracks/?artist_did={artist.did}", 185 + cookies={"session_id": "test_session"}, 186 + ) 187 + 188 + assert response.status_code == 200 189 + tracks = response.json()["tracks"] 190 + track_titles = [t["title"] for t in tracks] 191 + 192 + # both tracks should be visible on artist page 193 + assert "Regular Track" in track_titles 194 + assert "AI Generated Track" in track_titles 195 + 196 + 197 + async def test_explicit_filter_hidden_tags_true_forces_filtering( 198 + test_app: FastAPI, 199 + db_session: AsyncSession, 200 + artist: Artist, 201 + regular_track: Track, 202 + ai_tagged_track: Track, 203 + user_with_hidden_ai_tag: UserPreferences, 204 + ): 205 + """test that filter_hidden_tags=true forces filtering even on artist page.""" 206 + async with AsyncClient( 207 + transport=ASGITransport(app=test_app), base_url="http://test" 208 + ) as client: 209 + response = await client.get( 210 + f"/tracks/?artist_did={artist.did}&filter_hidden_tags=true", 211 + cookies={"session_id": "test_session"}, 212 + ) 213 + 214 + assert response.status_code == 200 215 + tracks = response.json()["tracks"] 216 + track_titles = [t["title"] for t in tracks] 217 + 218 + # only regular track should be visible with explicit filtering 219 + assert "Regular Track" in track_titles 220 + assert "AI Generated Track" not in track_titles 221 + 222 + 223 + async def test_explicit_filter_hidden_tags_false_disables_filtering( 224 + test_app: FastAPI, 225 + db_session: AsyncSession, 226 + regular_track: Track, 227 + ai_tagged_track: Track, 228 + user_with_hidden_ai_tag: UserPreferences, 229 + ): 230 + """test that filter_hidden_tags=false disables filtering on discovery feed.""" 231 + async with AsyncClient( 232 + transport=ASGITransport(app=test_app), base_url="http://test" 233 + ) as client: 234 + response = await client.get( 235 + "/tracks/?filter_hidden_tags=false", 236 + cookies={"session_id": "test_session"}, 237 + ) 238 + 239 + assert response.status_code == 200 240 + tracks = response.json()["tracks"] 241 + track_titles = [t["title"] for t in tracks] 242 + 243 + # both tracks should be visible with filtering disabled 244 + assert "Regular Track" in track_titles 245 + assert "AI Generated Track" in track_titles 246 + 247 + 248 + async def test_unauthenticated_user_gets_default_hidden_tags( 249 + test_app: FastAPI, 250 + db_session: AsyncSession, 251 + regular_track: Track, 252 + ai_tagged_track: Track, 253 + ): 254 + """test that unauthenticated users get default hidden tags applied.""" 255 + async with AsyncClient( 256 + transport=ASGITransport(app=test_app), base_url="http://test" 257 + ) as client: 258 + # no session cookie - unauthenticated 259 + response = await client.get("/tracks/") 260 + 261 + assert response.status_code == 200 262 + tracks = response.json()["tracks"] 263 + track_titles = [t["title"] for t in tracks] 264 + 265 + # default hidden tags include 'ai', so ai-tagged track should be hidden 266 + assert "Regular Track" in track_titles 267 + assert "AI Generated Track" not in track_titles
+317
frontend/src/lib/components/HiddenTagsFilter.svelte
··· 1 + <script lang="ts"> 2 + import { API_URL } from '$lib/config'; 3 + import { tracksCache } from '$lib/tracks.svelte'; 4 + import { auth } from '$lib/auth.svelte'; 5 + 6 + let hiddenTags = $state<string[]>([]); 7 + let isExpanded = $state(false); 8 + let addingTag = $state(false); 9 + let newTag = $state(''); 10 + let inputEl = $state<HTMLInputElement | null>(null); 11 + let hasFetched = $state(false); 12 + 13 + // fetch preferences when auth becomes available 14 + $effect(() => { 15 + if (auth.isAuthenticated && !hasFetched) { 16 + hasFetched = true; 17 + fetchPreferences(); 18 + } 19 + }); 20 + 21 + async function fetchPreferences() { 22 + try { 23 + const response = await fetch(`${API_URL}/preferences/`, { 24 + credentials: 'include' 25 + }); 26 + if (response.ok) { 27 + const data = await response.json(); 28 + // use server value, falling back to default if not set 29 + hiddenTags = data.hidden_tags ?? ['ai']; 30 + } 31 + } catch (error) { 32 + console.error('failed to fetch preferences:', error); 33 + hiddenTags = ['ai']; // fallback to default on error 34 + } 35 + } 36 + 37 + async function saveHiddenTags(tags: string[]) { 38 + try { 39 + await fetch(`${API_URL}/preferences/`, { 40 + method: 'POST', 41 + headers: { 'Content-Type': 'application/json' }, 42 + credentials: 'include', 43 + body: JSON.stringify({ hidden_tags: tags }) 44 + }); 45 + } catch (error) { 46 + console.error('failed to save preferences:', error); 47 + } 48 + } 49 + 50 + async function removeTag(tag: string) { 51 + hiddenTags = hiddenTags.filter((t) => t !== tag); 52 + await saveHiddenTags(hiddenTags); 53 + tracksCache.invalidate(); 54 + tracksCache.fetch(true); 55 + } 56 + 57 + async function addTag(tag: string) { 58 + const normalized = tag.trim().toLowerCase(); 59 + if (normalized && !hiddenTags.includes(normalized)) { 60 + hiddenTags = [...hiddenTags, normalized]; 61 + await saveHiddenTags(hiddenTags); 62 + tracksCache.invalidate(); 63 + tracksCache.fetch(true); 64 + } 65 + newTag = ''; 66 + addingTag = false; 67 + } 68 + 69 + function handleKeydown(e: KeyboardEvent) { 70 + if (e.key === 'Enter') { 71 + e.preventDefault(); 72 + addTag(newTag); 73 + } else if (e.key === 'Escape') { 74 + addingTag = false; 75 + newTag = ''; 76 + } 77 + } 78 + 79 + function toggleExpanded() { 80 + isExpanded = !isExpanded; 81 + if (!isExpanded) { 82 + addingTag = false; 83 + newTag = ''; 84 + } 85 + } 86 + 87 + function startAddingTag() { 88 + addingTag = true; 89 + setTimeout(() => inputEl?.focus(), 0); 90 + } 91 + </script> 92 + 93 + {#if auth.isAuthenticated} 94 + <div class="filter-bar"> 95 + <button 96 + type="button" 97 + class="filter-toggle" 98 + class:has-filters={hiddenTags.length > 0} 99 + onclick={toggleExpanded} 100 + title={isExpanded ? 'collapse filters' : 'show hidden tag filters'} 101 + > 102 + <svg class="eye-icon" viewBox="0 0 24 24" fill="none"> 103 + {#if hiddenTags.length > 0} 104 + <!-- closed/hidden eye --> 105 + <path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" stroke="currentColor" stroke-width="1.5" opacity="0.4"/> 106 + <circle cx="12" cy="12" r="3.5" stroke="currentColor" stroke-width="1.5" opacity="0.4"/> 107 + <circle cx="12" cy="12" r="1.5" fill="currentColor" opacity="0.4"/> 108 + <line x1="4" y1="4" x2="20" y2="20" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/> 109 + {:else} 110 + <!-- open all-seeing eye --> 111 + <path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" stroke="currentColor" stroke-width="1.5"/> 112 + <circle cx="12" cy="12" r="3.5" stroke="currentColor" stroke-width="1.5"/> 113 + <circle cx="12" cy="12" r="1.5" fill="currentColor"/> 114 + <!-- subtle rays --> 115 + <path d="M12 2v2M12 20v2M4.5 4.5l1.5 1.5M18 18l1.5 1.5M2 12h2M20 12h2M4.5 19.5l1.5-1.5M18 6l1.5-1.5" stroke="currentColor" stroke-width="1" stroke-linecap="round" opacity="0.3"/> 116 + {/if} 117 + </svg> 118 + {#if hiddenTags.length > 0 && !isExpanded} 119 + <span class="filter-count">{hiddenTags.length}</span> 120 + {/if} 121 + </button> 122 + 123 + {#if isExpanded} 124 + <div class="tags-row"> 125 + <span class="filter-label">hiding:</span> 126 + {#each hiddenTags as tag} 127 + <button type="button" class="tag-chip" onclick={() => removeTag(tag)} title="unhide '{tag}'"> 128 + {tag} 129 + <span class="remove-icon">×</span> 130 + </button> 131 + {/each} 132 + 133 + {#if addingTag} 134 + <input 135 + bind:this={inputEl} 136 + type="text" 137 + bind:value={newTag} 138 + onkeydown={handleKeydown} 139 + onblur={() => { 140 + if (!newTag.trim()) addingTag = false; 141 + }} 142 + placeholder="tag" 143 + class="add-input" 144 + /> 145 + {:else} 146 + <button type="button" class="add-btn" onclick={startAddingTag}> 147 + + 148 + </button> 149 + {/if} 150 + 151 + {#if hiddenTags.length === 0 && !addingTag} 152 + <span class="empty-hint">none</span> 153 + {/if} 154 + </div> 155 + {/if} 156 + </div> 157 + {/if} 158 + 159 + <style> 160 + .filter-bar { 161 + display: flex; 162 + align-items: center; 163 + gap: 0.5rem; 164 + flex-wrap: wrap; 165 + padding: 0 0 0.5rem; 166 + font-size: 0.8rem; 167 + } 168 + 169 + .filter-toggle { 170 + display: inline-flex; 171 + align-items: center; 172 + gap: 0.3rem; 173 + padding: 0.25rem; 174 + background: transparent; 175 + border: none; 176 + color: var(--text-tertiary); 177 + cursor: pointer; 178 + transition: color 0.15s; 179 + border-radius: 4px; 180 + } 181 + 182 + .filter-toggle:hover { 183 + color: var(--text-secondary); 184 + } 185 + 186 + .filter-toggle.has-filters { 187 + color: var(--text-secondary); 188 + } 189 + 190 + .eye-icon { 191 + width: 18px; 192 + height: 18px; 193 + } 194 + 195 + .filter-count { 196 + font-size: 0.7rem; 197 + color: var(--text-tertiary); 198 + } 199 + 200 + .filter-label { 201 + color: var(--text-tertiary); 202 + white-space: nowrap; 203 + font-size: 0.75rem; 204 + font-family: inherit; 205 + } 206 + 207 + .tags-row { 208 + display: flex; 209 + align-items: center; 210 + gap: 0.35rem; 211 + flex-wrap: wrap; 212 + } 213 + 214 + .tag-chip { 215 + display: inline-flex; 216 + align-items: center; 217 + gap: 0.2rem; 218 + padding: 0.2rem 0.4rem; 219 + background: transparent; 220 + border: 1px solid var(--border-default); 221 + color: var(--text-secondary); 222 + border-radius: 3px; 223 + font-size: 0.75rem; 224 + font-family: inherit; 225 + cursor: pointer; 226 + transition: all 0.15s; 227 + min-height: 24px; 228 + } 229 + 230 + .tag-chip:hover { 231 + border-color: rgba(255, 107, 107, 0.5); 232 + color: #ff6b6b; 233 + } 234 + 235 + .tag-chip:active { 236 + transform: scale(0.97); 237 + } 238 + 239 + .remove-icon { 240 + font-size: 0.8rem; 241 + line-height: 1; 242 + opacity: 0.5; 243 + } 244 + 245 + .tag-chip:hover .remove-icon { 246 + opacity: 1; 247 + } 248 + 249 + .add-btn { 250 + display: inline-flex; 251 + align-items: center; 252 + justify-content: center; 253 + width: 24px; 254 + height: 24px; 255 + padding: 0; 256 + background: transparent; 257 + border: 1px dashed var(--border-default); 258 + border-radius: 3px; 259 + color: var(--text-tertiary); 260 + font-size: 0.8rem; 261 + cursor: pointer; 262 + transition: all 0.15s; 263 + } 264 + 265 + .add-btn:hover { 266 + border-color: var(--text-tertiary); 267 + color: var(--text-secondary); 268 + } 269 + 270 + .add-input { 271 + padding: 0.2rem 0.4rem; 272 + background: transparent; 273 + border: 1px solid var(--border-default); 274 + color: var(--text-primary); 275 + font-size: 0.75rem; 276 + font-family: inherit; 277 + min-height: 24px; 278 + width: 70px; 279 + outline: none; 280 + border-radius: 3px; 281 + } 282 + 283 + .add-input:focus { 284 + border-color: var(--text-tertiary); 285 + } 286 + 287 + .add-input::placeholder { 288 + color: var(--text-tertiary); 289 + } 290 + 291 + .empty-hint { 292 + color: var(--text-tertiary); 293 + font-size: 0.7rem; 294 + } 295 + 296 + /* mobile adjustments */ 297 + @media (max-width: 480px) { 298 + .filter-toggle { 299 + padding: 0.4rem; 300 + } 301 + 302 + .tag-chip { 303 + padding: 0.3rem 0.5rem; 304 + min-height: 28px; 305 + } 306 + 307 + .add-btn { 308 + width: 28px; 309 + height: 28px; 310 + } 311 + 312 + .add-input { 313 + min-height: 28px; 314 + width: 80px; 315 + } 316 + } 317 + </style>
+338
frontend/src/lib/components/TagInput.svelte
··· 1 + <script lang="ts"> 2 + import { API_URL } from '$lib/config'; 3 + 4 + interface TagSuggestion { 5 + name: string; 6 + track_count: number; 7 + } 8 + 9 + interface Props { 10 + tags: string[]; 11 + onAdd: (_tag: string) => void; 12 + onRemove: (_tag: string) => void; 13 + placeholder?: string; 14 + disabled?: boolean; 15 + } 16 + 17 + let { tags = $bindable([]), onAdd, onRemove, placeholder = 'add tag...', disabled = false }: Props = $props(); 18 + 19 + let inputValue = $state(''); 20 + let suggestions = $state<TagSuggestion[]>([]); 21 + let showSuggestions = $state(false); 22 + let searching = $state(false); 23 + let searchTimeout: ReturnType<typeof setTimeout> | null = null; 24 + let selectedIndex = $state(-1); 25 + 26 + async function searchTags() { 27 + if (inputValue.length < 1) { 28 + suggestions = []; 29 + return; 30 + } 31 + 32 + searching = true; 33 + try { 34 + const response = await fetch(`${API_URL}/tracks/tags?q=${encodeURIComponent(inputValue)}&limit=10`, { 35 + credentials: 'include' 36 + }); 37 + if (response.ok) { 38 + const data: TagSuggestion[] = await response.json(); 39 + // filter out tags already added 40 + suggestions = data.filter(s => !tags.includes(s.name)); 41 + showSuggestions = suggestions.length > 0; 42 + } 43 + } catch (e) { 44 + console.error('tag search failed:', e); 45 + } finally { 46 + searching = false; 47 + } 48 + } 49 + 50 + function handleInput() { 51 + selectedIndex = -1; 52 + if (searchTimeout) clearTimeout(searchTimeout); 53 + searchTimeout = setTimeout(searchTags, 200); 54 + } 55 + 56 + function addTag(tag: string) { 57 + const normalized = tag.trim().toLowerCase(); 58 + if (normalized && !tags.includes(normalized)) { 59 + onAdd(normalized); 60 + } 61 + inputValue = ''; 62 + suggestions = []; 63 + showSuggestions = false; 64 + selectedIndex = -1; 65 + } 66 + 67 + function selectSuggestion(suggestion: TagSuggestion) { 68 + addTag(suggestion.name); 69 + } 70 + 71 + function handleKeydown(e: KeyboardEvent) { 72 + if (e.key === 'Enter' || e.key === ',') { 73 + e.preventDefault(); 74 + if (selectedIndex >= 0 && selectedIndex < suggestions.length) { 75 + selectSuggestion(suggestions[selectedIndex]); 76 + } else if (inputValue.trim()) { 77 + addTag(inputValue); 78 + } 79 + } else if (e.key === 'Backspace' && !inputValue && tags.length > 0) { 80 + onRemove(tags[tags.length - 1]); 81 + } else if (e.key === 'Escape') { 82 + showSuggestions = false; 83 + selectedIndex = -1; 84 + } else if (e.key === 'ArrowDown') { 85 + e.preventDefault(); 86 + if (showSuggestions && suggestions.length > 0) { 87 + selectedIndex = Math.min(selectedIndex + 1, suggestions.length - 1); 88 + } 89 + } else if (e.key === 'ArrowUp') { 90 + e.preventDefault(); 91 + if (showSuggestions && suggestions.length > 0) { 92 + selectedIndex = Math.max(selectedIndex - 1, -1); 93 + } 94 + } 95 + } 96 + 97 + function handleBlur() { 98 + // delay to allow click on suggestion 99 + setTimeout(() => { 100 + if (inputValue.trim()) { 101 + addTag(inputValue); 102 + } 103 + showSuggestions = false; 104 + selectedIndex = -1; 105 + }, 150); 106 + } 107 + 108 + function handleClickOutside(e: MouseEvent) { 109 + const target = e.target as HTMLElement; 110 + if (!target.closest('.tag-input-wrapper')) { 111 + showSuggestions = false; 112 + } 113 + } 114 + </script> 115 + 116 + <svelte:window onclick={handleClickOutside} /> 117 + 118 + <div class="tag-input-wrapper"> 119 + <div class="tags-container"> 120 + {#each tags as tag} 121 + <span class="tag-chip"> 122 + {tag} 123 + <button 124 + type="button" 125 + class="tag-remove" 126 + onclick={() => onRemove(tag)} 127 + {disabled} 128 + >×</button> 129 + </span> 130 + {/each} 131 + <input 132 + type="text" 133 + bind:value={inputValue} 134 + oninput={handleInput} 135 + onkeydown={handleKeydown} 136 + onblur={handleBlur} 137 + onfocus={() => { if (suggestions.length > 0) showSuggestions = true; }} 138 + placeholder={tags.length === 0 ? placeholder : ''} 139 + class="tag-input" 140 + {disabled} 141 + autocomplete="off" 142 + autocapitalize="off" 143 + spellcheck="false" 144 + /> 145 + {#if searching} 146 + <span class="spinner">...</span> 147 + {/if} 148 + </div> 149 + 150 + {#if showSuggestions && suggestions.length > 0} 151 + <div class="suggestions"> 152 + {#each suggestions as suggestion, i} 153 + <button 154 + type="button" 155 + class="suggestion-item" 156 + class:selected={i === selectedIndex} 157 + onclick={() => selectSuggestion(suggestion)} 158 + > 159 + <span class="tag-name">{suggestion.name}</span> 160 + <span class="tag-count">{suggestion.track_count} {suggestion.track_count === 1 ? 'track' : 'tracks'}</span> 161 + </button> 162 + {/each} 163 + </div> 164 + {/if} 165 + </div> 166 + 167 + <style> 168 + .tag-input-wrapper { 169 + position: relative; 170 + width: 100%; 171 + } 172 + 173 + .tags-container { 174 + display: flex; 175 + flex-wrap: wrap; 176 + align-items: center; 177 + gap: 0.5rem; 178 + padding: 0.75rem; 179 + background: #0a0a0a; 180 + border: 1px solid #333; 181 + border-radius: 4px; 182 + min-height: 48px; 183 + transition: all 0.2s; 184 + } 185 + 186 + .tags-container:focus-within { 187 + border-color: #3a7dff; 188 + } 189 + 190 + .tag-chip { 191 + display: inline-flex; 192 + align-items: center; 193 + gap: 0.35rem; 194 + padding: 0.35rem 0.6rem; 195 + background: #1a2330; 196 + border: 1px solid #2a3a4a; 197 + color: #8ab3ff; 198 + border-radius: 20px; 199 + font-size: 0.9rem; 200 + font-weight: 500; 201 + } 202 + 203 + .tag-remove { 204 + display: inline-flex; 205 + align-items: center; 206 + justify-content: center; 207 + width: 18px; 208 + height: 18px; 209 + padding: 0; 210 + background: none; 211 + border: none; 212 + color: #888; 213 + cursor: pointer; 214 + font-size: 1.2rem; 215 + font-family: inherit; 216 + line-height: 1; 217 + transition: color 0.2s; 218 + } 219 + 220 + .tag-remove:hover { 221 + color: #ff6b6b; 222 + } 223 + 224 + .tag-remove:disabled { 225 + cursor: not-allowed; 226 + opacity: 0.5; 227 + } 228 + 229 + .tag-input { 230 + flex: 1; 231 + min-width: 120px; 232 + padding: 0; 233 + background: transparent; 234 + border: none; 235 + color: white; 236 + font-size: 1rem; 237 + font-family: inherit; 238 + outline: none; 239 + } 240 + 241 + .tag-input::placeholder { 242 + color: #666; 243 + } 244 + 245 + .tag-input:disabled { 246 + opacity: 0.5; 247 + cursor: not-allowed; 248 + } 249 + 250 + .spinner { 251 + color: #666; 252 + font-size: 0.85rem; 253 + margin-left: auto; 254 + } 255 + 256 + .suggestions { 257 + position: absolute; 258 + z-index: 100; 259 + width: 100%; 260 + max-height: 200px; 261 + overflow-y: auto; 262 + background: #1a1a1a; 263 + border: 1px solid #333; 264 + border-radius: 4px; 265 + margin-top: 0.25rem; 266 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 267 + scrollbar-width: thin; 268 + scrollbar-color: #333 #0a0a0a; 269 + } 270 + 271 + .suggestions::-webkit-scrollbar { 272 + width: 8px; 273 + } 274 + 275 + .suggestions::-webkit-scrollbar-track { 276 + background: #0a0a0a; 277 + border-radius: 4px; 278 + } 279 + 280 + .suggestions::-webkit-scrollbar-thumb { 281 + background: #333; 282 + border-radius: 4px; 283 + } 284 + 285 + .suggestions::-webkit-scrollbar-thumb:hover { 286 + background: #444; 287 + } 288 + 289 + .suggestion-item { 290 + width: 100%; 291 + display: flex; 292 + align-items: center; 293 + justify-content: space-between; 294 + padding: 0.75rem; 295 + background: transparent; 296 + border: none; 297 + border-bottom: 1px solid #2a2a2a; 298 + color: white; 299 + text-align: left; 300 + font-family: inherit; 301 + cursor: pointer; 302 + transition: all 0.15s; 303 + } 304 + 305 + .suggestion-item:last-child { 306 + border-bottom: none; 307 + } 308 + 309 + .suggestion-item:hover, 310 + .suggestion-item.selected { 311 + background: #222; 312 + } 313 + 314 + .tag-name { 315 + font-weight: 500; 316 + color: #e8e8e8; 317 + } 318 + 319 + .tag-count { 320 + font-size: 0.85rem; 321 + color: #888; 322 + } 323 + 324 + @media (max-width: 768px) { 325 + .tag-input { 326 + font-size: 16px; /* prevents zoom on iOS */ 327 + } 328 + 329 + .suggestions { 330 + max-height: 160px; 331 + } 332 + 333 + .tag-chip { 334 + padding: 0.3rem 0.5rem; 335 + font-size: 0.85rem; 336 + } 337 + } 338 + </style>
+24
frontend/src/lib/components/TrackItem.svelte
··· 173 173 </a> 174 174 </span> 175 175 {/if} 176 + {#if track.tags && track.tags.length > 0} 177 + <span class="tags-line"> 178 + {#each track.tags as tag} 179 + <span class="tag-badge">{tag}</span> 180 + {/each} 181 + </span> 182 + {/if} 176 183 </div> 177 184 <div class="track-meta"> 178 185 <span class="plays">{track.play_count} {track.play_count === 1 ? 'play' : 'plays'}</span> ··· 489 496 height: 14px; 490 497 opacity: 0.7; 491 498 flex-shrink: 0; 499 + } 500 + 501 + .tags-line { 502 + display: flex; 503 + flex-wrap: wrap; 504 + gap: 0.25rem; 505 + margin-top: 0.15rem; 506 + } 507 + 508 + .tag-badge { 509 + display: inline-block; 510 + padding: 0.1rem 0.4rem; 511 + background: rgba(138, 179, 255, 0.15); 512 + color: #8ab3ff; 513 + border-radius: 3px; 514 + font-size: 0.75rem; 515 + font-weight: 500; 492 516 } 493 517 494 518 .track-meta {
+1
frontend/src/lib/types.ts
··· 41 41 like_count?: number; 42 42 comment_count?: number; 43 43 features?: FeaturedArtist[]; 44 + tags?: string[]; 44 45 created_at?: string; 45 46 image_url?: string; 46 47 is_liked?: boolean;
+7 -1
frontend/src/lib/uploader.svelte.ts
··· 11 11 title: string; 12 12 album?: string; 13 13 features?: FeaturedArtist[]; 14 + tags?: string[]; 14 15 toastId: string; 15 16 eventSource?: EventSource; 16 17 xhr?: XMLHttpRequest; ··· 31 32 title: string, 32 33 album: string, 33 34 features: FeaturedArtist[], 34 - image?: File | null, 35 + image: File | null | undefined, 36 + tags: string[], 35 37 onSuccess?: () => void, 36 38 callbacks?: UploadProgressCallback 37 39 ): void { ··· 52 54 const handles = features.map(a => a.handle); 53 55 formData.append('features', JSON.stringify(handles)); 54 56 } 57 + if (tags.length > 0) { 58 + formData.append('tags', JSON.stringify(tags)); 59 + } 55 60 if (image) { 56 61 formData.append('image', image); 57 62 } ··· 91 96 title, 92 97 album, 93 98 features, 99 + tags, 94 100 toastId, 95 101 xhr 96 102 };
+2
frontend/src/routes/+page.svelte
··· 4 4 import TrackItem from '$lib/components/TrackItem.svelte'; 5 5 import Header from '$lib/components/Header.svelte'; 6 6 import WaveLoading from '$lib/components/WaveLoading.svelte'; 7 + import HiddenTagsFilter from '$lib/components/HiddenTagsFilter.svelte'; 7 8 import { player } from '$lib/player.svelte'; 8 9 import { queue } from '$lib/queue.svelte'; 9 10 import { tracksCache } from '$lib/tracks.svelte'; ··· 88 89 latest tracks 89 90 </button> 90 91 </h2> 92 + <HiddenTagsFilter /> 91 93 {#if showLoading} 92 94 <div class="loading-container"> 93 95 <WaveLoading size="lg" message="loading tracks..." />
+53
frontend/src/routes/portal/+page.svelte
··· 7 7 import WaveLoading from '$lib/components/WaveLoading.svelte'; 8 8 import MigrationBanner from '$lib/components/MigrationBanner.svelte'; 9 9 import BrokenTracks from '$lib/components/BrokenTracks.svelte'; 10 + import TagInput from '$lib/components/TagInput.svelte'; 10 11 import type { Track, FeaturedArtist, AlbumSummary } from '$lib/types'; 11 12 import { API_URL, getServerConfig } from '$lib/config'; 12 13 import { uploader } from '$lib/uploader.svelte'; ··· 37 38 let file = $state<File | null>(null); 38 39 let imageFile = $state<File | null>(null); 39 40 let featuredArtists = $state<FeaturedArtist[]>([]); 41 + let uploadTags = $state<string[]>([]); 40 42 let hasUnresolvedFeaturesInput = $state(false); 41 43 42 44 // track editing state ··· 44 46 let editTitle = $state(''); 45 47 let editAlbum = $state(''); 46 48 let editFeaturedArtists = $state<FeaturedArtist[]>([]); 49 + let editTags = $state<string[]>([]); 47 50 let editImageFile = $state<File | null>(null); 48 51 let hasUnresolvedEditFeaturesInput = $state(false); 49 52 ··· 333 336 const uploadAlbum = albumTitle; 334 337 const uploadFeatures = [...featuredArtists]; 335 338 const uploadImage = imageFile; 339 + const tagsToUpload = [...uploadTags]; 336 340 337 341 const clearForm = () => { 338 342 title = ''; ··· 340 344 file = null; 341 345 imageFile = null; 342 346 featuredArtists = []; 347 + uploadTags = []; 343 348 344 349 const fileInput = document.getElementById('file-input') as HTMLInputElement; 345 350 if (fileInput) { ··· 357 362 uploadAlbum, 358 363 uploadFeatures, 359 364 uploadImage, 365 + tagsToUpload, 360 366 async () => { 361 367 await loadMyTracks(); 362 368 await loadMyAlbums(); ··· 398 404 editTitle = track.title; 399 405 editAlbum = track.album?.title || ''; 400 406 editFeaturedArtists = track.features || []; 407 + editTags = track.tags || []; 401 408 } 402 409 403 410 function cancelEdit() { ··· 405 412 editTitle = ''; 406 413 editAlbum = ''; 407 414 editFeaturedArtists = []; 415 + editTags = []; 408 416 editImageFile = null; 409 417 } 418 + 410 419 411 420 async function saveTrackEdit(trackId: number) { 412 421 const formData = new FormData(); ··· 419 428 // send empty array to clear features 420 429 formData.append('features', JSON.stringify([])); 421 430 } 431 + // always send tags (empty array clears them) 432 + formData.append('tags', JSON.stringify(editTags)); 422 433 if (editImageFile) { 423 434 formData.append('image', editImageFile); 424 435 } ··· 845 856 </div> 846 857 847 858 <div class="form-group"> 859 + <label for="upload-tags">tags (optional)</label> 860 + <TagInput 861 + tags={uploadTags} 862 + onAdd={(tag) => { uploadTags = [...uploadTags, tag]; }} 863 + onRemove={(tag) => { uploadTags = uploadTags.filter(t => t !== tag); }} 864 + placeholder="type to search tags..." 865 + /> 866 + </div> 867 + 868 + <div class="form-group"> 848 869 <label for="file-input">audio file</label> 849 870 <input 850 871 id="file-input" ··· 922 943 /> 923 944 </div> 924 945 <div class="edit-field-group"> 946 + <label for="edit-tags" class="edit-label">tags (optional)</label> 947 + <TagInput 948 + tags={editTags} 949 + onAdd={(tag) => { editTags = [...editTags, tag]; }} 950 + onRemove={(tag) => { editTags = editTags.filter(t => t !== tag); }} 951 + placeholder="type to search tags..." 952 + /> 953 + </div> 954 + <div class="edit-field-group"> 925 955 <label for="edit-image" class="edit-label">artwork (optional)</label> 926 956 {#if track.image_url && !editImageFile} 927 957 <div class="current-image-preview"> ··· 1022 1052 <a href="/u/{track.artist_handle}/album/{track.album.slug}" class="album-link"> 1023 1053 {track.album.title} 1024 1054 </a> 1055 + </div> 1056 + {/if} 1057 + {#if track.tags && track.tags.length > 0} 1058 + <div class="meta-tags"> 1059 + {#each track.tags as tag} 1060 + <span class="meta-tag">{tag}</span> 1061 + {/each} 1025 1062 </div> 1026 1063 {/if} 1027 1064 </div> ··· 1818 1855 height: 14px; 1819 1856 opacity: 0.7; 1820 1857 flex-shrink: 0; 1858 + } 1859 + 1860 + .meta-tags { 1861 + display: flex; 1862 + flex-wrap: wrap; 1863 + gap: 0.25rem; 1864 + } 1865 + 1866 + .meta-tag { 1867 + display: inline-block; 1868 + padding: 0.1rem 0.4rem; 1869 + background: rgba(138, 179, 255, 0.15); 1870 + color: #8ab3ff; 1871 + border-radius: 3px; 1872 + font-size: 0.8rem; 1873 + font-weight: 500; 1821 1874 } 1822 1875 1823 1876 .track-date {