feat: timed comments on tracks (#359)

* feat: add timed comments on tracks

- add TrackComment model with timestamp_ms for positioning
- add allow_comments toggle to UserPreferences (off by default)
- add ATProto fm.plyr.comment record support
- add GET/POST/DELETE endpoints for comments API
- add frontend UI for viewing comments on track page
- add toggle in portal settings for artists
- limit to 20 comments per track
- comments ordered by timestamp for playback sync

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

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

* fix: play button resume and clickable comment timestamps

- fix play button not resuming after pause (check if track is loaded, not playing state)
- make comment timestamps clickable to seek to that point in the song
- fix layout overlap with share button (add flex-direction: column to main)
- make comments section more compact with scroll overflow

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

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

* refactor: extract _make_pds_request utility for ATProto record operations

consolidates duplicated OAuth session handling and token refresh logic
from 5 separate functions into a single private utility:
- create_track_record
- update_record
- create_like_record
- delete_record_by_uri
- create_comment_record

also adds _parse_at_uri helper for AT URI parsing.

net reduction of ~147 lines.

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

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

* fix: address PR review feedback

- add db.rollback() before cleanup on comment creation failure
- add aria-label for accessibility on comment input and toggle
- fix seekToTimestamp race condition by waiting for loadedmetadata

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

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

* fix: address copilot review feedback

- fix Index syntax in track_comment.py (can't use .desc() in constructor)
- make migration idempotent for allow_comments column
- add console.error for failed comment loads

🤖 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 7ece78a8 d45bdd28

Changed files
+1389 -236
backend
frontend
src
routes
portal
track
+2
backend/alembic/env.py
··· 10 10 Artist, 11 11 CopyrightScan, 12 12 Track, 13 + TrackComment, 13 14 TrackLike, 15 + UserPreferences, 14 16 UserSession, 15 17 ) 16 18 from backend.models.database import Base
+96
backend/alembic/versions/2025_11_25_233843_20d550e3d14b_add_track_comments_table_and_allow_.py
··· 1 + """add track_comments table and allow_comments preference 2 + 3 + Revision ID: 20d550e3d14b 4 + Revises: b8a3d4500bb6 5 + Create Date: 2025-11-25 23:38:43.896501 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 = "20d550e3d14b" 18 + down_revision: str | Sequence[str] | None = "b8a3d4500bb6" 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 + # check if track_comments table already exists (dev has it) 26 + conn = op.get_bind() 27 + inspector = Inspector.from_engine(conn) 28 + existing_tables = inspector.get_table_names() 29 + 30 + if "track_comments" not in existing_tables: 31 + op.create_table( 32 + "track_comments", 33 + sa.Column("id", sa.Integer(), nullable=False), 34 + sa.Column("track_id", sa.Integer(), nullable=False), 35 + sa.Column("user_did", sa.String(), nullable=False), 36 + sa.Column("text", sa.Text(), nullable=False), 37 + sa.Column("timestamp_ms", sa.Integer(), nullable=False), 38 + sa.Column("atproto_comment_uri", sa.String(), nullable=False), 39 + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), 40 + sa.ForeignKeyConstraint(["track_id"], ["tracks.id"], ondelete="CASCADE"), 41 + sa.PrimaryKeyConstraint("id"), 42 + ) 43 + op.create_index( 44 + op.f("ix_track_comments_id"), "track_comments", ["id"], unique=False 45 + ) 46 + op.create_index( 47 + op.f("ix_track_comments_track_id"), 48 + "track_comments", 49 + ["track_id"], 50 + unique=False, 51 + ) 52 + op.create_index( 53 + op.f("ix_track_comments_user_did"), 54 + "track_comments", 55 + ["user_did"], 56 + unique=False, 57 + ) 58 + op.create_index( 59 + op.f("ix_track_comments_atproto_comment_uri"), 60 + "track_comments", 61 + ["atproto_comment_uri"], 62 + unique=True, 63 + ) 64 + op.create_index( 65 + "ix_track_comments_track_timestamp", 66 + "track_comments", 67 + ["track_id", "timestamp_ms"], 68 + unique=False, 69 + ) 70 + op.create_index( 71 + "ix_track_comments_user_created", 72 + "track_comments", 73 + ["user_did", "created_at"], 74 + unique=False, 75 + ) 76 + 77 + # add allow_comments to user_preferences (check if column exists first) 78 + existing_columns = [ 79 + col["name"] for col in inspector.get_columns("user_preferences") 80 + ] 81 + if "allow_comments" not in existing_columns: 82 + op.add_column( 83 + "user_preferences", 84 + sa.Column( 85 + "allow_comments", 86 + sa.Boolean(), 87 + server_default=sa.text("false"), 88 + nullable=False, 89 + ), 90 + ) 91 + 92 + 93 + def downgrade() -> None: 94 + """Downgrade schema.""" 95 + op.drop_column("user_preferences", "allow_comments") 96 + op.drop_table("track_comments")
+2
backend/src/backend/_internal/atproto/__init__.py
··· 6 6 normalize_avatar_url, 7 7 ) 8 8 from backend._internal.atproto.records import ( 9 + create_comment_record, 9 10 create_like_record, 10 11 create_track_record, 11 12 delete_record_by_uri, 12 13 ) 13 14 14 15 __all__ = [ 16 + "create_comment_record", 15 17 "create_like_record", 16 18 "create_track_record", 17 19 "delete_record_by_uri",
+136 -195
backend/src/backend/_internal/atproto/records.py
··· 150 150 raise ValueError(f"failed to refresh access token: {e}") from e 151 151 152 152 153 + async def _make_pds_request( 154 + auth_session: AuthSession, 155 + method: str, 156 + endpoint: str, 157 + payload: dict[str, Any], 158 + success_codes: tuple[int, ...] = (200, 201), 159 + ) -> dict[str, Any]: 160 + """make an authenticated request to the PDS with automatic token refresh. 161 + 162 + args: 163 + auth_session: authenticated user session 164 + method: HTTP method (POST, GET, etc.) 165 + endpoint: XRPC endpoint (e.g., "com.atproto.repo.createRecord") 166 + payload: request payload 167 + success_codes: HTTP status codes considered successful 168 + 169 + returns: 170 + response JSON dict (empty dict for 204 responses) 171 + 172 + raises: 173 + ValueError: if session is invalid 174 + Exception: if request fails after retry 175 + """ 176 + oauth_data = auth_session.oauth_session 177 + if not oauth_data or "access_token" not in oauth_data: 178 + raise ValueError( 179 + f"OAuth session data missing or invalid for {auth_session.did}" 180 + ) 181 + 182 + oauth_session = _reconstruct_oauth_session(oauth_data) 183 + url = f"{oauth_data['pds_url']}/xrpc/{endpoint}" 184 + 185 + for attempt in range(2): 186 + response = await oauth_client.make_authenticated_request( 187 + session=oauth_session, 188 + method=method, 189 + url=url, 190 + json=payload, 191 + ) 192 + 193 + if response.status_code in success_codes: 194 + if response.status_code == 204: 195 + return {} 196 + return response.json() 197 + 198 + # token expired - refresh and retry 199 + if response.status_code == 401 and attempt == 0: 200 + try: 201 + error_data = response.json() 202 + if "exp" in error_data.get("message", ""): 203 + logger.info( 204 + f"access token expired for {auth_session.did}, attempting refresh" 205 + ) 206 + oauth_session = await _refresh_session_tokens( 207 + auth_session, oauth_session 208 + ) 209 + continue 210 + except (json.JSONDecodeError, KeyError): 211 + pass 212 + 213 + raise Exception(f"PDS request failed: {response.status_code} {response.text}") 214 + 215 + 153 216 def build_track_record( 154 217 title: str, 155 218 artist: str, ··· 217 280 duration: int | None = None, 218 281 features: list[dict] | None = None, 219 282 image_url: str | None = None, 220 - ) -> tuple[str, str] | None: 283 + ) -> tuple[str, str]: 221 284 """Create a track record on the user's PDS using the configured collection. 222 285 223 286 args: ··· 238 301 ValueError: if session is invalid 239 302 Exception: if record creation fails 240 303 """ 241 - # get OAuth session data from database 242 - oauth_data = auth_session.oauth_session 243 - if not oauth_data or "access_token" not in oauth_data: 244 - raise ValueError( 245 - f"OAuth session data missing or invalid for {auth_session.did}" 246 - ) 247 - 248 - # reconstruct OAuthSession from database 249 - oauth_session = _reconstruct_oauth_session(oauth_data) 250 - 251 - # construct record 252 304 record = build_track_record( 253 305 title=title, 254 306 artist=artist, ··· 260 312 image_url=image_url, 261 313 ) 262 314 263 - # make authenticated request to create record 264 - url = f"{oauth_data['pds_url']}/xrpc/com.atproto.repo.createRecord" 265 315 payload = { 266 316 "repo": auth_session.did, 267 317 "collection": settings.atproto.track_collection, 268 318 "record": record, 269 319 } 270 320 271 - # try creating the record, refresh token if expired 272 - for attempt in range(2): # max 2 attempts: initial + 1 retry after refresh 273 - response = await oauth_client.make_authenticated_request( 274 - session=oauth_session, 275 - method="POST", 276 - url=url, 277 - json=payload, 278 - ) 321 + result = await _make_pds_request( 322 + auth_session, "POST", "com.atproto.repo.createRecord", payload 323 + ) 324 + return result["uri"], result["cid"] 279 325 280 - # success 281 - if response.status_code in (200, 201): 282 - result = response.json() 283 - return result["uri"], result["cid"] 284 326 285 - # token expired - refresh and retry 286 - if response.status_code == 401 and attempt == 0: 287 - try: 288 - error_data = response.json() 289 - if "exp" in error_data.get("message", ""): 290 - logger.info( 291 - f"access token expired for {auth_session.did}, attempting refresh" 292 - ) 293 - oauth_session = await _refresh_session_tokens( 294 - auth_session, oauth_session 295 - ) 296 - continue # retry with refreshed token 297 - except (json.JSONDecodeError, KeyError): 298 - pass # not a token expiration error 299 - 300 - # other error or retry failed 301 - raise Exception( 302 - f"Failed to create ATProto record: {response.status_code} {response.text}" 303 - ) 327 + def _parse_at_uri(uri: str) -> tuple[str, str, str]: 328 + """parse an AT URI into (repo, collection, rkey).""" 329 + if not uri.startswith("at://"): 330 + raise ValueError(f"Invalid AT URI format: {uri}") 331 + parts = uri.replace("at://", "").split("/") 332 + if len(parts) != 3: 333 + raise ValueError(f"Invalid AT URI structure: {uri}") 334 + return parts[0], parts[1], parts[2] 304 335 305 336 306 337 async def update_record( 307 338 auth_session: AuthSession, 308 339 record_uri: str, 309 340 record: dict[str, Any], 310 - ) -> tuple[str, str] | None: 341 + ) -> tuple[str, str]: 311 342 """Update an existing record on the user's PDS. 312 343 313 344 args: ··· 322 353 ValueError: if session is invalid or URI is malformed 323 354 Exception: if record update fails 324 355 """ 325 - # get OAuth session data from database 326 - oauth_data = auth_session.oauth_session 327 - if not oauth_data or "access_token" not in oauth_data: 328 - raise ValueError( 329 - f"OAuth session data missing or invalid for {auth_session.did}" 330 - ) 356 + repo, collection, rkey = _parse_at_uri(record_uri) 331 357 332 - # reconstruct OAuthSession from database 333 - oauth_session = _reconstruct_oauth_session(oauth_data) 334 - 335 - # parse the AT URI to get repo and collection 336 - # format: at://did:plc:.../collection/rkey 337 - if not record_uri.startswith("at://"): 338 - raise ValueError(f"Invalid AT URI format: {record_uri}") 339 - 340 - parts = record_uri.replace("at://", "").split("/") 341 - if len(parts) != 3: 342 - raise ValueError(f"Invalid AT URI structure: {record_uri}") 343 - 344 - repo, collection, rkey = parts 345 - 346 - # make authenticated request to update record 347 - url = f"{oauth_data['pds_url']}/xrpc/com.atproto.repo.putRecord" 348 358 payload = { 349 359 "repo": repo, 350 360 "collection": collection, ··· 352 362 "record": record, 353 363 } 354 364 355 - # try updating the record, refresh token if expired 356 - for attempt in range(2): # max 2 attempts: initial + 1 retry after refresh 357 - response = await oauth_client.make_authenticated_request( 358 - session=oauth_session, 359 - method="POST", 360 - url=url, 361 - json=payload, 362 - ) 363 - 364 - # success 365 - if response.status_code in (200, 201): 366 - result = response.json() 367 - return result["uri"], result["cid"] 368 - 369 - # token expired - refresh and retry 370 - if response.status_code == 401 and attempt == 0: 371 - try: 372 - error_data = response.json() 373 - if "exp" in error_data.get("message", ""): 374 - logger.info( 375 - f"access token expired for {auth_session.did}, attempting refresh" 376 - ) 377 - oauth_session = await _refresh_session_tokens( 378 - auth_session, oauth_session 379 - ) 380 - continue # retry with refreshed token 381 - except (json.JSONDecodeError, KeyError): 382 - pass # not a token expiration error 383 - 384 - # other error or retry failed 385 - raise Exception( 386 - f"Failed to update ATProto record: {response.status_code} {response.text}" 387 - ) 365 + result = await _make_pds_request( 366 + auth_session, "POST", "com.atproto.repo.putRecord", payload 367 + ) 368 + return result["uri"], result["cid"] 388 369 389 370 390 371 async def create_like_record( ··· 406 387 ValueError: if session is invalid 407 388 Exception: if record creation fails 408 389 """ 409 - # get OAuth session data from database 410 - oauth_data = auth_session.oauth_session 411 - if not oauth_data or "access_token" not in oauth_data: 412 - raise ValueError( 413 - f"OAuth session data missing or invalid for {auth_session.did}" 414 - ) 415 - 416 - # reconstruct OAuthSession from database 417 - oauth_session = _reconstruct_oauth_session(oauth_data) 418 - 419 - # construct like record 420 390 record = { 421 391 "$type": settings.atproto.like_collection, 422 392 "subject": { ··· 426 396 "createdAt": datetime.now(UTC).isoformat().replace("+00:00", "Z"), 427 397 } 428 398 429 - # make authenticated request to create record 430 - url = f"{oauth_data['pds_url']}/xrpc/com.atproto.repo.createRecord" 431 399 payload = { 432 400 "repo": auth_session.did, 433 401 "collection": settings.atproto.like_collection, 434 402 "record": record, 435 403 } 436 404 437 - # try creating the record, refresh token if expired 438 - for attempt in range(2): 439 - response = await oauth_client.make_authenticated_request( 440 - session=oauth_session, 441 - method="POST", 442 - url=url, 443 - json=payload, 444 - ) 445 - 446 - # success 447 - if response.status_code in (200, 201): 448 - result = response.json() 449 - return result["uri"] 450 - 451 - # token expired - refresh and retry 452 - if response.status_code == 401 and attempt == 0: 453 - try: 454 - error_data = response.json() 455 - if "exp" in error_data.get("message", ""): 456 - logger.info( 457 - f"access token expired for {auth_session.did}, attempting refresh" 458 - ) 459 - oauth_session = await _refresh_session_tokens( 460 - auth_session, oauth_session 461 - ) 462 - continue # retry with refreshed token 463 - except (json.JSONDecodeError, KeyError): 464 - pass # not a token expiration error 465 - 466 - # all attempts failed 467 - raise Exception( 468 - f"Failed to create like record: {response.status_code} {response.text}" 405 + result = await _make_pds_request( 406 + auth_session, "POST", "com.atproto.repo.createRecord", payload 469 407 ) 408 + return result["uri"] 470 409 471 410 472 411 async def delete_record_by_uri( ··· 483 422 ValueError: if session is invalid or URI is malformed 484 423 Exception: if record deletion fails 485 424 """ 486 - # get OAuth session data from database 487 - oauth_data = auth_session.oauth_session 488 - if not oauth_data or "access_token" not in oauth_data: 489 - raise ValueError( 490 - f"OAuth session data missing or invalid for {auth_session.did}" 491 - ) 492 - 493 - # reconstruct OAuthSession from database 494 - oauth_session = _reconstruct_oauth_session(oauth_data) 495 - 496 - # parse the AT URI to get repo and collection 497 - # format: at://did:plc:.../collection/rkey 498 - if not record_uri.startswith("at://"): 499 - raise ValueError(f"Invalid AT URI format: {record_uri}") 425 + repo, collection, rkey = _parse_at_uri(record_uri) 500 426 501 - parts = record_uri.replace("at://", "").split("/") 502 - if len(parts) != 3: 503 - raise ValueError(f"Invalid AT URI structure: {record_uri}") 504 - 505 - repo, collection, rkey = parts 506 - 507 - # make authenticated request to delete record 508 - url = f"{oauth_data['pds_url']}/xrpc/com.atproto.repo.deleteRecord" 509 427 payload = { 510 428 "repo": repo, 511 429 "collection": collection, 512 430 "rkey": rkey, 513 431 } 514 432 515 - # try deleting the record, refresh token if expired 516 - for attempt in range(2): 517 - response = await oauth_client.make_authenticated_request( 518 - session=oauth_session, 519 - method="POST", 520 - url=url, 521 - json=payload, 522 - ) 433 + await _make_pds_request( 434 + auth_session, 435 + "POST", 436 + "com.atproto.repo.deleteRecord", 437 + payload, 438 + success_codes=(200, 201, 204), 439 + ) 523 440 524 - # success 525 - if response.status_code in (200, 201, 204): 526 - return 527 441 528 - # token expired - refresh and retry 529 - if response.status_code == 401 and attempt == 0: 530 - try: 531 - error_data = response.json() 532 - if "exp" in error_data.get("message", ""): 533 - logger.info( 534 - f"access token expired for {auth_session.did}, attempting refresh" 535 - ) 536 - oauth_session = await _refresh_session_tokens( 537 - auth_session, oauth_session 538 - ) 539 - continue # retry with refreshed token 540 - except (json.JSONDecodeError, KeyError): 541 - pass # not a token expiration error 442 + async def create_comment_record( 443 + auth_session: AuthSession, 444 + subject_uri: str, 445 + subject_cid: str, 446 + text: str, 447 + timestamp_ms: int, 448 + ) -> str: 449 + """create a timed comment record on the user's PDS. 542 450 543 - # all attempts failed 544 - raise Exception(f"Failed to delete record: {response.status_code} {response.text}") 451 + args: 452 + auth_session: authenticated user session 453 + subject_uri: AT URI of the track being commented on 454 + subject_cid: CID of the track being commented on 455 + text: comment text content 456 + timestamp_ms: playback position in milliseconds when comment was made 457 + 458 + returns: 459 + comment record URI 460 + 461 + raises: 462 + ValueError: if session is invalid 463 + Exception: if record creation fails 464 + """ 465 + record = { 466 + "$type": settings.atproto.comment_collection, 467 + "subject": { 468 + "uri": subject_uri, 469 + "cid": subject_cid, 470 + }, 471 + "text": text, 472 + "timestampMs": timestamp_ms, 473 + "createdAt": datetime.now(UTC).isoformat().replace("+00:00", "Z"), 474 + } 475 + 476 + payload = { 477 + "repo": auth_session.did, 478 + "collection": settings.atproto.comment_collection, 479 + "record": record, 480 + } 481 + 482 + result = await _make_pds_request( 483 + auth_session, "POST", "com.atproto.repo.createRecord", payload 484 + ) 485 + return result["uri"]
+13 -2
backend/src/backend/api/preferences.py
··· 18 18 19 19 accent_color: str 20 20 auto_advance: bool 21 + allow_comments: bool 21 22 22 23 23 24 class PreferencesUpdate(BaseModel): ··· 25 26 26 27 accent_color: str | None = None 27 28 auto_advance: bool | None = None 29 + allow_comments: bool | None = None 28 30 29 31 30 32 @router.get("/") ··· 46 48 await db.refresh(prefs) 47 49 48 50 return PreferencesResponse( 49 - accent_color=prefs.accent_color, auto_advance=prefs.auto_advance 51 + accent_color=prefs.accent_color, 52 + auto_advance=prefs.auto_advance, 53 + allow_comments=prefs.allow_comments, 50 54 ) 51 55 52 56 ··· 70 74 auto_advance=update.auto_advance 71 75 if update.auto_advance is not None 72 76 else True, 77 + allow_comments=update.allow_comments 78 + if update.allow_comments is not None 79 + else False, 73 80 ) 74 81 db.add(prefs) 75 82 else: ··· 78 85 prefs.accent_color = update.accent_color 79 86 if update.auto_advance is not None: 80 87 prefs.auto_advance = update.auto_advance 88 + if update.allow_comments is not None: 89 + prefs.allow_comments = update.allow_comments 81 90 82 91 await db.commit() 83 92 await db.refresh(prefs) 84 93 85 94 return PreferencesResponse( 86 - accent_color=prefs.accent_color, auto_advance=prefs.auto_advance 95 + accent_color=prefs.accent_color, 96 + auto_advance=prefs.auto_advance, 97 + allow_comments=prefs.allow_comments, 87 98 )
+2 -1
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 6 8 from . import listing as _listing 7 - from . import likes as _likes 8 9 from . import mutations as _mutations 9 10 from . import playback as _playback 10 11 from . import uploads as _uploads
+243
backend/src/backend/api/tracks/comments.py
··· 1 + """Track timed comments endpoints.""" 2 + 3 + import logging 4 + from typing import Annotated 5 + 6 + from fastapi import Depends, HTTPException 7 + from pydantic import BaseModel, Field 8 + from sqlalchemy import func, select 9 + from sqlalchemy.ext.asyncio import AsyncSession 10 + 11 + from backend._internal import Session as AuthSession 12 + from backend._internal import require_auth 13 + from backend._internal.atproto import create_comment_record, delete_record_by_uri 14 + from backend.models import Artist, Track, TrackComment, UserPreferences, get_db 15 + 16 + from .router import router 17 + 18 + logger = logging.getLogger(__name__) 19 + 20 + # max comments per track (configurable via settings later) 21 + MAX_COMMENTS_PER_TRACK = 20 22 + 23 + 24 + class CommentCreate(BaseModel): 25 + """request body for creating a comment.""" 26 + 27 + text: str = Field(..., min_length=1, max_length=300) 28 + timestamp_ms: int = Field(..., ge=0) 29 + 30 + 31 + class CommentResponse(BaseModel): 32 + """response model for a single comment.""" 33 + 34 + id: int 35 + user_did: str 36 + user_handle: str 37 + user_display_name: str | None 38 + user_avatar_url: str | None 39 + text: str 40 + timestamp_ms: int 41 + created_at: str 42 + 43 + 44 + class CommentsListResponse(BaseModel): 45 + """response model for list of comments.""" 46 + 47 + comments: list[CommentResponse] 48 + count: int 49 + comments_enabled: bool 50 + 51 + 52 + @router.get("/{track_id}/comments") 53 + async def get_track_comments( 54 + track_id: int, 55 + db: Annotated[AsyncSession, Depends(get_db)], 56 + ) -> CommentsListResponse: 57 + """get all comments for a track, ordered by timestamp. 58 + 59 + public endpoint - no auth required. 60 + """ 61 + # check track exists and get artist's allow_comments setting 62 + track_result = await db.execute(select(Track).where(Track.id == track_id)) 63 + track = track_result.scalar_one_or_none() 64 + 65 + if not track: 66 + raise HTTPException(status_code=404, detail="track not found") 67 + 68 + # check if artist allows comments 69 + prefs_result = await db.execute( 70 + select(UserPreferences).where(UserPreferences.did == track.artist_did) 71 + ) 72 + prefs = prefs_result.scalar_one_or_none() 73 + comments_enabled = prefs.allow_comments if prefs else False 74 + 75 + if not comments_enabled: 76 + return CommentsListResponse(comments=[], count=0, comments_enabled=False) 77 + 78 + # fetch comments with user info 79 + stmt = ( 80 + select(TrackComment, Artist) 81 + .outerjoin(Artist, Artist.did == TrackComment.user_did) 82 + .where(TrackComment.track_id == track_id) 83 + .order_by(TrackComment.timestamp_ms) 84 + ) 85 + result = await db.execute(stmt) 86 + rows = result.all() 87 + 88 + comments = [ 89 + CommentResponse( 90 + id=comment.id, 91 + user_did=comment.user_did, 92 + user_handle=artist.handle if artist else comment.user_did, 93 + user_display_name=artist.display_name if artist else None, 94 + user_avatar_url=artist.avatar_url if artist else None, 95 + text=comment.text, 96 + timestamp_ms=comment.timestamp_ms, 97 + created_at=comment.created_at.isoformat(), 98 + ) 99 + for comment, artist in rows 100 + ] 101 + 102 + return CommentsListResponse( 103 + comments=comments, count=len(comments), comments_enabled=True 104 + ) 105 + 106 + 107 + @router.post("/{track_id}/comments") 108 + async def create_comment( 109 + track_id: int, 110 + body: CommentCreate, 111 + db: Annotated[AsyncSession, Depends(get_db)], 112 + auth_session: AuthSession = Depends(require_auth), 113 + ) -> CommentResponse: 114 + """create a timed comment on a track. 115 + 116 + requires auth. track owner must have allow_comments enabled. 117 + """ 118 + # get track 119 + track_result = await db.execute(select(Track).where(Track.id == track_id)) 120 + track = track_result.scalar_one_or_none() 121 + 122 + if not track: 123 + raise HTTPException(status_code=404, detail="track not found") 124 + 125 + if not track.atproto_record_uri or not track.atproto_record_cid: 126 + raise HTTPException( 127 + status_code=422, 128 + detail="track missing ATProto record - cannot comment", 129 + ) 130 + 131 + # check if artist allows comments 132 + prefs_result = await db.execute( 133 + select(UserPreferences).where(UserPreferences.did == track.artist_did) 134 + ) 135 + prefs = prefs_result.scalar_one_or_none() 136 + 137 + if not prefs or not prefs.allow_comments: 138 + raise HTTPException( 139 + status_code=403, 140 + detail="comments are disabled for this artist's tracks", 141 + ) 142 + 143 + # check comment limit 144 + count_result = await db.execute( 145 + select(func.count()).where(TrackComment.track_id == track_id) 146 + ) 147 + comment_count = count_result.scalar() or 0 148 + 149 + if comment_count >= MAX_COMMENTS_PER_TRACK: 150 + raise HTTPException( 151 + status_code=400, 152 + detail=f"track has reached maximum of {MAX_COMMENTS_PER_TRACK} comments", 153 + ) 154 + 155 + # create ATProto record 156 + comment_uri = None 157 + try: 158 + comment_uri = await create_comment_record( 159 + auth_session=auth_session, 160 + subject_uri=track.atproto_record_uri, 161 + subject_cid=track.atproto_record_cid, 162 + text=body.text, 163 + timestamp_ms=body.timestamp_ms, 164 + ) 165 + 166 + # create database record 167 + comment = TrackComment( 168 + track_id=track_id, 169 + user_did=auth_session.did, 170 + text=body.text, 171 + timestamp_ms=body.timestamp_ms, 172 + atproto_comment_uri=comment_uri, 173 + ) 174 + db.add(comment) 175 + await db.commit() 176 + await db.refresh(comment) 177 + 178 + except Exception as e: 179 + await db.rollback() 180 + logger.error( 181 + f"failed to create comment on track {track_id} by {auth_session.did}: {e}", 182 + exc_info=True, 183 + ) 184 + # cleanup ATProto record if created 185 + if comment_uri: 186 + try: 187 + await delete_record_by_uri(auth_session, comment_uri) 188 + logger.info( 189 + f"cleaned up orphaned ATProto comment record: {comment_uri}" 190 + ) 191 + except Exception as cleanup_exc: 192 + logger.error( 193 + f"failed to cleanup orphaned comment record: {cleanup_exc}" 194 + ) 195 + raise HTTPException(status_code=500, detail="failed to create comment") from e 196 + 197 + # get user info for response 198 + artist_result = await db.execute( 199 + select(Artist).where(Artist.did == auth_session.did) 200 + ) 201 + artist = artist_result.scalar_one_or_none() 202 + 203 + return CommentResponse( 204 + id=comment.id, 205 + user_did=comment.user_did, 206 + user_handle=artist.handle if artist else auth_session.did, 207 + user_display_name=artist.display_name if artist else None, 208 + user_avatar_url=artist.avatar_url if artist else None, 209 + text=comment.text, 210 + timestamp_ms=comment.timestamp_ms, 211 + created_at=comment.created_at.isoformat(), 212 + ) 213 + 214 + 215 + @router.delete("/comments/{comment_id}") 216 + async def delete_comment( 217 + comment_id: int, 218 + db: Annotated[AsyncSession, Depends(get_db)], 219 + auth_session: AuthSession = Depends(require_auth), 220 + ) -> dict: 221 + """delete a comment. only the author can delete their own comments.""" 222 + comment_result = await db.execute( 223 + select(TrackComment).where(TrackComment.id == comment_id) 224 + ) 225 + comment = comment_result.scalar_one_or_none() 226 + 227 + if not comment: 228 + raise HTTPException(status_code=404, detail="comment not found") 229 + 230 + if comment.user_did != auth_session.did: 231 + raise HTTPException(status_code=403, detail="can only delete your own comments") 232 + 233 + # delete ATProto record 234 + try: 235 + await delete_record_by_uri(auth_session, comment.atproto_comment_uri) 236 + except Exception as e: 237 + logger.error(f"failed to delete ATProto comment record: {e}") 238 + # continue with DB deletion anyway 239 + 240 + await db.delete(comment) 241 + await db.commit() 242 + 243 + return {"deleted": True}
+9 -1
backend/src/backend/config.py
··· 317 317 318 318 @computed_field 319 319 @property 320 + def comment_collection(self) -> str: 321 + """Collection name for timed comment records.""" 322 + 323 + return f"{self.app_namespace}.comment" 324 + 325 + @computed_field 326 + @property 320 327 def old_track_collection(self) -> str | None: 321 328 """Collection name for old namespace, if migration is active.""" 322 329 ··· 332 339 if self.scope_override: 333 340 return self.scope_override 334 341 335 - # base scopes: our track collection + our like collection 342 + # base scopes: our track, like, and comment collections 336 343 scopes = [ 337 344 f"repo:{self.track_collection}", 338 345 f"repo:{self.like_collection}", 346 + f"repo:{self.comment_collection}", 339 347 ] 340 348 341 349 # if we have an old namespace, add old track collection too
+2
backend/src/backend/models/__init__.py
··· 11 11 from backend.models.queue import QueueState 12 12 from backend.models.session import UserSession 13 13 from backend.models.track import Track 14 + from backend.models.track_comment import TrackComment 14 15 from backend.models.track_like import TrackLike 15 16 from backend.utilities.database import db_session, get_db, init_db 16 17 ··· 25 26 "QueueState", 26 27 "ScanResolution", 27 28 "Track", 29 + "TrackComment", 28 30 "TrackLike", 29 31 "UserPreferences", 30 32 "UserSession",
+5
backend/src/backend/models/preferences.py
··· 22 22 Boolean, nullable=False, default=True, server_default=text("true") 23 23 ) 24 24 25 + # artist preferences 26 + allow_comments: Mapped[bool] = mapped_column( 27 + Boolean, nullable=False, default=False, server_default=text("false") 28 + ) 29 + 25 30 # metadata 26 31 created_at: Mapped[datetime] = mapped_column( 27 32 DateTime(timezone=True),
+61
backend/src/backend/models/track_comment.py
··· 1 + """track comment model for timed comments on tracks.""" 2 + 3 + from datetime import UTC, datetime 4 + from typing import TYPE_CHECKING 5 + 6 + from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text 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.track import Track 13 + 14 + 15 + class TrackComment(Base): 16 + """timed comment on a track. 17 + 18 + indexes ATProto comment records (fm.plyr.comment) for efficient querying. 19 + the source of truth is the ATProto record on the user's PDS. 20 + """ 21 + 22 + __tablename__ = "track_comments" 23 + 24 + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) 25 + 26 + # which track is being commented on 27 + track_id: Mapped[int] = mapped_column( 28 + Integer, 29 + ForeignKey("tracks.id", ondelete="CASCADE"), 30 + nullable=False, 31 + index=True, 32 + ) 33 + track: Mapped["Track"] = relationship("Track", lazy="raise") 34 + 35 + # who commented 36 + user_did: Mapped[str] = mapped_column(String, nullable=False, index=True) 37 + 38 + # comment content 39 + text: Mapped[str] = mapped_column(Text, nullable=False) 40 + 41 + # playback position in milliseconds when comment was made 42 + timestamp_ms: Mapped[int] = mapped_column(Integer, nullable=False) 43 + 44 + # ATProto comment record URI (source of truth) 45 + atproto_comment_uri: Mapped[str] = mapped_column( 46 + String, nullable=False, unique=True, index=True 47 + ) 48 + 49 + # when it was created (indexed from ATProto record) 50 + created_at: Mapped[datetime] = mapped_column( 51 + DateTime(timezone=True), 52 + default=lambda: datetime.now(UTC), 53 + nullable=False, 54 + ) 55 + 56 + __table_args__ = ( 57 + # composite index for fetching comments ordered by timestamp 58 + Index("ix_track_comments_track_timestamp", "track_id", "timestamp_ms"), 59 + # composite index for user's comments (order by recency handled in queries) 60 + Index("ix_track_comments_user_created", "user_did", "created_at"), 61 + )
+278
backend/tests/api/test_track_comments.py
··· 1 + """tests for track comment api 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 import select 10 + from sqlalchemy.ext.asyncio import AsyncSession 11 + 12 + from backend._internal import Session, require_auth 13 + from backend.main import app 14 + from backend.models import Artist, Track, TrackComment, UserPreferences 15 + 16 + 17 + class MockSession(Session): 18 + """mock session for auth bypass in tests.""" 19 + 20 + def __init__(self, did: str = "did:test:commenter123"): 21 + self.did = did 22 + self.handle = "commenter.bsky.social" 23 + self.session_id = "test_session_id" 24 + self.access_token = "test_token" 25 + self.refresh_token = "test_refresh" 26 + self.oauth_session = { 27 + "did": did, 28 + "handle": "commenter.bsky.social", 29 + "pds_url": "https://test.pds", 30 + "authserver_iss": "https://auth.test", 31 + "scope": "atproto transition:generic", 32 + "access_token": "test_token", 33 + "refresh_token": "test_refresh", 34 + "dpop_private_key_pem": "fake_key", 35 + "dpop_authserver_nonce": "", 36 + "dpop_pds_nonce": "", 37 + } 38 + 39 + 40 + @pytest.fixture 41 + async def test_artist(db_session: AsyncSession) -> Artist: 42 + """create a test artist.""" 43 + artist = Artist( 44 + did="did:plc:artist123", 45 + handle="artist.bsky.social", 46 + display_name="Test Artist", 47 + ) 48 + db_session.add(artist) 49 + await db_session.commit() 50 + return artist 51 + 52 + 53 + @pytest.fixture 54 + async def test_track(db_session: AsyncSession, test_artist: Artist) -> Track: 55 + """create a test track.""" 56 + track = Track( 57 + title="Test Track", 58 + artist_did=test_artist.did, 59 + file_id="test123", 60 + file_type="mp3", 61 + extra={"duration": 180}, 62 + atproto_record_uri="at://did:plc:artist123/fm.plyr.track/test123", 63 + atproto_record_cid="bafytest123", 64 + ) 65 + db_session.add(track) 66 + await db_session.commit() 67 + await db_session.refresh(track) 68 + return track 69 + 70 + 71 + @pytest.fixture 72 + async def artist_with_comments_enabled( 73 + db_session: AsyncSession, test_artist: Artist 74 + ) -> UserPreferences: 75 + """create user preferences with comments enabled.""" 76 + prefs = UserPreferences( 77 + did=test_artist.did, 78 + allow_comments=True, 79 + ) 80 + db_session.add(prefs) 81 + await db_session.commit() 82 + return prefs 83 + 84 + 85 + @pytest.fixture 86 + async def commenter_artist(db_session: AsyncSession) -> Artist: 87 + """create the artist record for the commenter.""" 88 + artist = Artist( 89 + did="did:test:commenter123", 90 + handle="commenter.bsky.social", 91 + display_name="Test Commenter", 92 + ) 93 + db_session.add(artist) 94 + await db_session.commit() 95 + return artist 96 + 97 + 98 + @pytest.fixture 99 + def test_app(db_session: AsyncSession) -> Generator[FastAPI, None, None]: 100 + """create test app with mocked auth.""" 101 + 102 + async def mock_require_auth() -> Session: 103 + return MockSession() 104 + 105 + app.dependency_overrides[require_auth] = mock_require_auth 106 + yield app 107 + app.dependency_overrides.clear() 108 + 109 + 110 + async def test_get_comments_returns_empty_when_disabled( 111 + test_app: FastAPI, db_session: AsyncSession, test_track: Track 112 + ): 113 + """test that comments endpoint returns empty list when artist has comments disabled.""" 114 + async with AsyncClient( 115 + transport=ASGITransport(app=test_app), base_url="http://test" 116 + ) as client: 117 + response = await client.get(f"/tracks/{test_track.id}/comments") 118 + 119 + assert response.status_code == 200 120 + data = response.json() 121 + assert data["comments"] == [] 122 + assert data["comments_enabled"] is False 123 + 124 + 125 + async def test_get_comments_returns_list_when_enabled( 126 + test_app: FastAPI, 127 + db_session: AsyncSession, 128 + test_track: Track, 129 + artist_with_comments_enabled: UserPreferences, 130 + ): 131 + """test that comments endpoint returns comments when enabled.""" 132 + # add a test comment directly to DB 133 + comment = TrackComment( 134 + track_id=test_track.id, 135 + user_did="did:test:commenter123", 136 + text="great track!", 137 + timestamp_ms=45000, 138 + atproto_comment_uri="at://did:test:commenter123/fm.plyr.comment/abc", 139 + ) 140 + db_session.add(comment) 141 + await db_session.commit() 142 + 143 + async with AsyncClient( 144 + transport=ASGITransport(app=test_app), base_url="http://test" 145 + ) as client: 146 + response = await client.get(f"/tracks/{test_track.id}/comments") 147 + 148 + assert response.status_code == 200 149 + data = response.json() 150 + assert data["comments_enabled"] is True 151 + assert len(data["comments"]) == 1 152 + assert data["comments"][0]["text"] == "great track!" 153 + assert data["comments"][0]["timestamp_ms"] == 45000 154 + 155 + 156 + async def test_create_comment_fails_when_comments_disabled( 157 + test_app: FastAPI, db_session: AsyncSession, test_track: Track 158 + ): 159 + """test that creating a comment fails when artist has comments disabled.""" 160 + async with AsyncClient( 161 + transport=ASGITransport(app=test_app), base_url="http://test" 162 + ) as client: 163 + response = await client.post( 164 + f"/tracks/{test_track.id}/comments", 165 + json={"text": "hello", "timestamp_ms": 1000}, 166 + ) 167 + 168 + assert response.status_code == 403 169 + assert "disabled" in response.json()["detail"].lower() 170 + 171 + 172 + async def test_create_comment_success( 173 + test_app: FastAPI, 174 + db_session: AsyncSession, 175 + test_track: Track, 176 + artist_with_comments_enabled: UserPreferences, 177 + commenter_artist: Artist, 178 + ): 179 + """test successful comment creation.""" 180 + with patch("backend.api.tracks.comments.create_comment_record") as mock_create: 181 + mock_create.return_value = "at://did:test:commenter123/fm.plyr.comment/xyz" 182 + 183 + async with AsyncClient( 184 + transport=ASGITransport(app=test_app), base_url="http://test" 185 + ) as client: 186 + response = await client.post( 187 + f"/tracks/{test_track.id}/comments", 188 + json={"text": "awesome drop at this moment!", "timestamp_ms": 30000}, 189 + ) 190 + 191 + assert response.status_code == 200 192 + data = response.json() 193 + assert data["text"] == "awesome drop at this moment!" 194 + assert data["timestamp_ms"] == 30000 195 + assert data["user_did"] == "did:test:commenter123" 196 + 197 + # verify ATProto record was created 198 + mock_create.assert_called_once() 199 + 200 + # verify DB entry 201 + result = await db_session.execute( 202 + select(TrackComment).where(TrackComment.track_id == test_track.id) 203 + ) 204 + comment = result.scalar_one() 205 + assert comment.text == "awesome drop at this moment!" 206 + assert comment.timestamp_ms == 30000 207 + 208 + 209 + async def test_create_comment_respects_limit( 210 + test_app: FastAPI, 211 + db_session: AsyncSession, 212 + test_track: Track, 213 + artist_with_comments_enabled: UserPreferences, 214 + ): 215 + """test that comment limit is enforced.""" 216 + # add 20 comments (the limit) 217 + for i in range(20): 218 + comment = TrackComment( 219 + track_id=test_track.id, 220 + user_did=f"did:test:user{i}", 221 + text=f"comment {i}", 222 + timestamp_ms=i * 1000, 223 + atproto_comment_uri=f"at://did:test:user{i}/fm.plyr.comment/{i}", 224 + ) 225 + db_session.add(comment) 226 + await db_session.commit() 227 + 228 + # try to add 21st comment 229 + async with AsyncClient( 230 + transport=ASGITransport(app=test_app), base_url="http://test" 231 + ) as client: 232 + response = await client.post( 233 + f"/tracks/{test_track.id}/comments", 234 + json={"text": "one more", "timestamp_ms": 21000}, 235 + ) 236 + 237 + assert response.status_code == 400 238 + assert "maximum" in response.json()["detail"].lower() 239 + 240 + 241 + async def test_comments_ordered_by_timestamp( 242 + test_app: FastAPI, 243 + db_session: AsyncSession, 244 + test_track: Track, 245 + artist_with_comments_enabled: UserPreferences, 246 + ): 247 + """test that comments are returned ordered by timestamp.""" 248 + # add comments out of order 249 + for timestamp in [30000, 10000, 50000, 20000]: 250 + comment = TrackComment( 251 + track_id=test_track.id, 252 + user_did="did:test:user", 253 + text=f"at {timestamp}", 254 + timestamp_ms=timestamp, 255 + atproto_comment_uri=f"at://did:test:user/fm.plyr.comment/{timestamp}", 256 + ) 257 + db_session.add(comment) 258 + await db_session.commit() 259 + 260 + async with AsyncClient( 261 + transport=ASGITransport(app=test_app), base_url="http://test" 262 + ) as client: 263 + response = await client.get(f"/tracks/{test_track.id}/comments") 264 + 265 + assert response.status_code == 200 266 + comments = response.json()["comments"] 267 + timestamps = [c["timestamp_ms"] for c in comments] 268 + assert timestamps == [10000, 20000, 30000, 50000] 269 + 270 + 271 + async def test_get_comments_track_not_found(test_app: FastAPI): 272 + """test that 404 is returned for non-existent track.""" 273 + async with AsyncClient( 274 + transport=ASGITransport(app=test_app), base_url="http://test" 275 + ) as client: 276 + response = await client.get("/tracks/99999/comments") 277 + 278 + assert response.status_code == 404
+153 -35
frontend/src/routes/portal/+page.svelte
··· 51 51 let displayName = $state(''); 52 52 let bio = $state(''); 53 53 let avatarUrl = $state(''); 54 + let allowComments = $state(false); 54 55 let savingProfile = $state(false); 55 56 let profileSuccess = $state(''); 56 57 let profileError = $state(''); ··· 104 105 await loadMyTracks(); 105 106 await loadArtistProfile(); 106 107 await loadMyAlbums(); 108 + await loadPreferences(); 107 109 } catch (_e) { 108 110 console.error('error loading portal data:', _e); 109 111 error = 'failed to load portal data'; ··· 158 160 console.error('failed to load albums:', _e); 159 161 } finally { 160 162 loadingAlbums = false; 163 + } 164 + } 165 + 166 + async function loadPreferences() { 167 + try { 168 + const response = await fetch(`${API_URL}/preferences`, { 169 + credentials: 'include' 170 + }); 171 + if (response.ok) { 172 + const prefs = await response.json(); 173 + allowComments = prefs.allow_comments; 174 + } 175 + } catch (_e) { 176 + console.error('failed to load preferences:', _e); 177 + } 178 + } 179 + 180 + async function saveAllowComments(enabled: boolean) { 181 + try { 182 + const response = await fetch(`${API_URL}/preferences`, { 183 + method: 'POST', 184 + headers: { 'Content-Type': 'application/json' }, 185 + credentials: 'include', 186 + body: JSON.stringify({ allow_comments: enabled }) 187 + }); 188 + if (response.ok) { 189 + allowComments = enabled; 190 + toast.success(enabled ? 'comments enabled on your tracks' : 'comments disabled'); 191 + } else { 192 + toast.error('failed to update preference'); 193 + } 194 + } catch (_e) { 195 + console.error('failed to save preference:', _e); 196 + toast.error('failed to update preference'); 161 197 } 162 198 } 163 199 ··· 917 953 {/if} 918 954 </section> 919 955 920 - {#if tracks.length > 0} 921 - <section class="export-section"> 922 - <div class="export-header"> 923 - <h2>export your music</h2> 924 - <p class="export-description"> 925 - download all {tracks.length} {tracks.length === 1 ? 'track' : 'tracks'} as a zip archive. 926 - files are provided in their original format (mp3, wav, m4a) exactly as uploaded. 956 + <section class="data-section"> 957 + <h2>your data</h2> 958 + 959 + <div class="data-control"> 960 + <div class="control-info"> 961 + <h3>timed comments</h3> 962 + <p class="control-description"> 963 + allow other users to leave comments on your tracks 927 964 </p> 928 965 </div> 929 - <button 930 - class="export-main-btn" 931 - onclick={exportAllMedia} 932 - disabled={exportingMedia} 933 - > 934 - {exportingMedia ? 'exporting...' : tracks.length === 1 ? 'export track' : `export all ${tracks.length} tracks`} 935 - </button> 936 - </section> 937 - {/if} 966 + <label class="toggle-switch"> 967 + <input 968 + type="checkbox" 969 + aria-label="Allow timed comments on your tracks" 970 + checked={allowComments} 971 + onchange={(e) => saveAllowComments((e.target as HTMLInputElement).checked)} 972 + /> 973 + <span class="toggle-slider"></span> 974 + <span class="toggle-label">{allowComments ? 'enabled' : 'disabled'}</span> 975 + </label> 976 + </div> 977 + 978 + {#if tracks.length > 0} 979 + <div class="data-control"> 980 + <div class="control-info"> 981 + <h3>export tracks</h3> 982 + <p class="control-description"> 983 + {tracks.length === 1 ? 'download your track as a zip archive' : `download all ${tracks.length} tracks as a zip archive`} 984 + </p> 985 + </div> 986 + <button 987 + class="export-btn" 988 + onclick={exportAllMedia} 989 + disabled={exportingMedia} 990 + > 991 + {exportingMedia ? 'exporting...' : 'export'} 992 + </button> 993 + </div> 994 + {/if} 995 + </section> 938 996 </main> 939 997 {/if} 940 998 ··· 1630 1688 justify-content: flex-end; 1631 1689 } 1632 1690 1633 - .export-section { 1691 + /* your data section */ 1692 + .data-section { 1634 1693 margin-top: 3rem; 1635 - padding: 2rem; 1694 + } 1695 + 1696 + .data-section h2 { 1697 + font-size: var(--text-page-heading); 1698 + margin-bottom: 1.5rem; 1699 + } 1700 + 1701 + .data-control { 1702 + padding: 1.5rem; 1636 1703 background: #1a1a1a; 1637 1704 border: 1px solid #2a2a2a; 1638 1705 border-radius: 8px; 1706 + display: flex; 1707 + justify-content: space-between; 1708 + align-items: center; 1709 + gap: 1rem; 1710 + margin-bottom: 1rem; 1639 1711 } 1640 1712 1641 - .export-header { 1642 - margin-bottom: 1.5rem; 1713 + .data-control:last-child { 1714 + margin-bottom: 0; 1643 1715 } 1644 1716 1645 - .export-header h2 { 1646 - font-size: var(--text-page-heading); 1647 - margin-bottom: 0.75rem; 1717 + .control-info h3 { 1718 + font-size: 1rem; 1719 + font-weight: 600; 1720 + margin: 0 0 0.25rem 0; 1721 + color: #e8e8e8; 1648 1722 } 1649 1723 1650 - .export-description { 1651 - color: #aaa; 1652 - font-size: 0.95rem; 1653 - line-height: 1.6; 1724 + .control-description { 1725 + font-size: 0.85rem; 1726 + color: #888; 1654 1727 margin: 0; 1655 1728 } 1656 1729 1657 - .export-main-btn { 1658 - width: 100%; 1659 - padding: 1rem; 1730 + .export-btn { 1731 + padding: 0.6rem 1.25rem; 1660 1732 background: #3a7dff; 1661 1733 color: white; 1662 1734 border: none; 1663 1735 border-radius: 6px; 1664 - font-size: 1rem; 1736 + font-size: 0.9rem; 1665 1737 font-weight: 600; 1666 1738 cursor: pointer; 1667 1739 transition: all 0.2s; 1740 + white-space: nowrap; 1741 + width: auto; 1668 1742 } 1669 1743 1670 - .export-main-btn:hover:not(:disabled) { 1744 + .export-btn:hover:not(:disabled) { 1671 1745 background: #2868e6; 1672 1746 transform: translateY(-1px); 1673 1747 box-shadow: 0 4px 12px rgba(58, 125, 255, 0.3); 1674 1748 } 1675 1749 1676 - .export-main-btn:disabled { 1750 + .export-btn:disabled { 1677 1751 opacity: 0.5; 1678 1752 cursor: not-allowed; 1679 1753 transform: none; 1680 1754 } 1681 1755 1682 - .export-main-btn:active:not(:disabled) { 1683 - transform: translateY(0); 1756 + .toggle-switch { 1757 + display: flex; 1758 + align-items: center; 1759 + gap: 0.75rem; 1760 + cursor: pointer; 1761 + flex-shrink: 0; 1762 + } 1763 + 1764 + .toggle-switch input { 1765 + display: none; 1766 + } 1767 + 1768 + .toggle-slider { 1769 + width: 44px; 1770 + height: 24px; 1771 + background: #333; 1772 + border-radius: 12px; 1773 + position: relative; 1774 + transition: background 0.2s; 1775 + } 1776 + 1777 + .toggle-slider::after { 1778 + content: ''; 1779 + position: absolute; 1780 + top: 2px; 1781 + left: 2px; 1782 + width: 20px; 1783 + height: 20px; 1784 + background: #888; 1785 + border-radius: 50%; 1786 + transition: all 0.2s; 1787 + } 1788 + 1789 + .toggle-switch input:checked + .toggle-slider { 1790 + background: var(--accent, #3a7dff); 1791 + } 1792 + 1793 + .toggle-switch input:checked + .toggle-slider::after { 1794 + left: 22px; 1795 + background: white; 1796 + } 1797 + 1798 + .toggle-label { 1799 + font-size: 0.85rem; 1800 + color: #888; 1801 + min-width: 60px; 1684 1802 } 1685 1803 </style>
+387 -2
frontend/src/routes/track/[id]/+page.svelte
··· 9 9 import { player } from '$lib/player.svelte'; 10 10 import { queue } from '$lib/queue.svelte'; 11 11 import { auth } from '$lib/auth.svelte'; 12 + import { toast } from '$lib/toast.svelte'; 12 13 import type { Track } from '$lib/types'; 13 14 15 + interface Comment { 16 + id: number; 17 + user_did: string; 18 + user_handle: string; 19 + user_display_name: string | null; 20 + user_avatar_url: string | null; 21 + text: string; 22 + timestamp_ms: number; 23 + created_at: string; 24 + } 25 + 14 26 // receive server-loaded data 15 27 let { data }: { data: PageData } = $props(); 16 28 17 29 let track = $state<Track>(data.track); 18 30 31 + // comments state 32 + let comments = $state<Comment[]>([]); 33 + let commentsEnabled = $state(false); 34 + let loadingComments = $state(true); 35 + let newCommentText = $state(''); 36 + let submittingComment = $state(false); 37 + 19 38 // reactive check if this track is currently playing 20 39 let isCurrentlyPlaying = $derived( 21 40 player.currentTrack?.id === track.id && !player.paused ··· 41 60 } 42 61 43 62 function handlePlay() { 44 - if (isCurrentlyPlaying) { 63 + if (player.currentTrack?.id === track.id) { 64 + // this track is already loaded - just toggle play/pause 45 65 player.togglePlayPause(); 46 66 } else { 67 + // different track or no track - start this one 47 68 queue.playNow(track); 48 69 } 49 70 } ··· 52 73 queue.addTracks([track]); 53 74 } 54 75 76 + async function loadComments() { 77 + loadingComments = true; 78 + try { 79 + const response = await fetch(`${API_URL}/tracks/${track.id}/comments`); 80 + if (response.ok) { 81 + const data = await response.json(); 82 + comments = data.comments; 83 + commentsEnabled = data.comments_enabled; 84 + } else { 85 + console.error('failed to load comments: response not OK'); 86 + } 87 + } catch (e) { 88 + console.error('failed to load comments:', e); 89 + } finally { 90 + loadingComments = false; 91 + } 92 + } 93 + 94 + async function submitComment() { 95 + if (!newCommentText.trim() || submittingComment) return; 96 + 97 + // get current playback position (default to 0 if not playing this track) 98 + let timestampMs = 0; 99 + if (player.currentTrack?.id === track.id) { 100 + timestampMs = Math.floor((player.currentTime || 0) * 1000); 101 + } 102 + 103 + submittingComment = true; 104 + try { 105 + const response = await fetch(`${API_URL}/tracks/${track.id}/comments`, { 106 + method: 'POST', 107 + headers: { 'Content-Type': 'application/json' }, 108 + credentials: 'include', 109 + body: JSON.stringify({ 110 + text: newCommentText.trim(), 111 + timestamp_ms: timestampMs 112 + }) 113 + }); 114 + 115 + if (response.ok) { 116 + const comment = await response.json(); 117 + // insert comment in sorted position by timestamp 118 + const insertIndex = comments.findIndex(c => c.timestamp_ms > comment.timestamp_ms); 119 + if (insertIndex === -1) { 120 + comments = [...comments, comment]; 121 + } else { 122 + comments = [...comments.slice(0, insertIndex), comment, ...comments.slice(insertIndex)]; 123 + } 124 + newCommentText = ''; 125 + toast.success('comment added'); 126 + } else { 127 + const error = await response.json(); 128 + toast.error(error.detail || 'failed to add comment'); 129 + } 130 + } catch (e) { 131 + console.error('failed to submit comment:', e); 132 + toast.error('failed to add comment'); 133 + } finally { 134 + submittingComment = false; 135 + } 136 + } 137 + 138 + function formatTimestamp(ms: number): string { 139 + const totalSeconds = Math.floor(ms / 1000); 140 + const minutes = Math.floor(totalSeconds / 60); 141 + const seconds = totalSeconds % 60; 142 + return `${minutes}:${seconds.toString().padStart(2, '0')}`; 143 + } 144 + 145 + function seekToTimestamp(ms: number) { 146 + const doSeek = () => { 147 + if (player.audioElement) { 148 + player.audioElement.currentTime = ms / 1000; 149 + } 150 + }; 151 + 152 + // if this track is already loaded, seek immediately 153 + if (player.currentTrack?.id === track.id) { 154 + doSeek(); 155 + return; 156 + } 157 + 158 + // otherwise start playing and wait for audio to be ready 159 + queue.playNow(track); 160 + if (player.audioElement && player.audioElement.readyState >= 1) { 161 + doSeek(); 162 + } else { 163 + // wait for metadata to load before seeking 164 + const onReady = () => { 165 + doSeek(); 166 + player.audioElement?.removeEventListener('loadedmetadata', onReady); 167 + }; 168 + player.audioElement?.addEventListener('loadedmetadata', onReady); 169 + } 170 + } 171 + 55 172 onMount(async () => { 56 173 if (auth.isAuthenticated) { 57 174 await loadLikedState(); 58 175 } 176 + await loadComments(); 59 177 }); 60 178 61 179 let shareUrl = $state(''); ··· 228 346 </div> 229 347 </div> 230 348 </div> 349 + 350 + <!-- comments section --> 351 + {#if commentsEnabled} 352 + <section class="comments-section"> 353 + <h2 class="comments-title"> 354 + comments 355 + {#if comments.length > 0} 356 + <span class="comment-count">({comments.length})</span> 357 + {/if} 358 + </h2> 359 + 360 + {#if auth.isAuthenticated} 361 + <form class="comment-form" onsubmit={(e) => { e.preventDefault(); submitComment(); }}> 362 + <input 363 + type="text" 364 + class="comment-input" 365 + aria-label="Add a timed comment" 366 + placeholder={player.currentTrack?.id === track.id ? `comment at ${formatTimestamp((player.currentTime || 0) * 1000)}...` : 'add a comment...'} 367 + bind:value={newCommentText} 368 + maxlength={300} 369 + disabled={submittingComment} 370 + /> 371 + <button 372 + type="submit" 373 + class="comment-submit" 374 + disabled={!newCommentText.trim() || submittingComment} 375 + > 376 + {submittingComment ? '...' : 'post'} 377 + </button> 378 + </form> 379 + {:else} 380 + <p class="login-prompt"> 381 + <a href="/login">log in</a> to leave a comment 382 + </p> 383 + {/if} 384 + 385 + {#if loadingComments} 386 + <div class="comments-loading">loading comments...</div> 387 + {:else if comments.length === 0} 388 + <div class="no-comments">no comments yet</div> 389 + {:else} 390 + <div class="comments-list"> 391 + {#each comments as comment} 392 + <div class="comment"> 393 + <button 394 + class="comment-timestamp" 395 + onclick={() => seekToTimestamp(comment.timestamp_ms)} 396 + title="jump to {formatTimestamp(comment.timestamp_ms)}" 397 + > 398 + {formatTimestamp(comment.timestamp_ms)} 399 + </button> 400 + <div class="comment-content"> 401 + <div class="comment-header"> 402 + {#if comment.user_avatar_url} 403 + <img src={comment.user_avatar_url} alt="" class="comment-avatar" /> 404 + {:else} 405 + <div class="comment-avatar-placeholder"></div> 406 + {/if} 407 + <a href="/u/{comment.user_handle}" class="comment-author"> 408 + {comment.user_display_name || comment.user_handle} 409 + </a> 410 + </div> 411 + <p class="comment-text">{comment.text}</p> 412 + </div> 413 + </div> 414 + {/each} 415 + </div> 416 + {/if} 417 + </section> 418 + {/if} 231 419 </main> 232 420 </div> 233 421 ··· 241 429 main { 242 430 flex: 1; 243 431 display: flex; 432 + flex-direction: column; 244 433 align-items: center; 245 - justify-content: center; 434 + justify-content: flex-start; 246 435 padding: 2rem; 247 436 padding-bottom: 8rem; 248 437 width: 100%; ··· 571 760 .btn-queue svg { 572 761 width: 18px; 573 762 height: 18px; 763 + } 764 + } 765 + 766 + /* comments section */ 767 + .comments-section { 768 + width: 100%; 769 + max-width: 500px; 770 + margin-top: 1.5rem; 771 + padding-top: 1.5rem; 772 + border-top: 1px solid #2a2a2a; 773 + } 774 + 775 + .comments-title { 776 + font-size: 1rem; 777 + font-weight: 600; 778 + color: #e8e8e8; 779 + margin: 0 0 0.75rem 0; 780 + display: flex; 781 + align-items: center; 782 + gap: 0.5rem; 783 + } 784 + 785 + .comment-count { 786 + color: #888; 787 + font-weight: 400; 788 + } 789 + 790 + .comment-form { 791 + display: flex; 792 + gap: 0.5rem; 793 + margin-bottom: 0.75rem; 794 + } 795 + 796 + .comment-input { 797 + flex: 1; 798 + padding: 0.6rem 0.8rem; 799 + background: #1a1a1a; 800 + border: 1px solid #333; 801 + border-radius: 6px; 802 + color: #e8e8e8; 803 + font-size: 0.9rem; 804 + font-family: inherit; 805 + } 806 + 807 + .comment-input:focus { 808 + outline: none; 809 + border-color: var(--accent); 810 + } 811 + 812 + .comment-input::placeholder { 813 + color: #666; 814 + } 815 + 816 + .comment-submit { 817 + padding: 0.6rem 1rem; 818 + background: var(--accent); 819 + color: #000; 820 + border: none; 821 + border-radius: 6px; 822 + font-size: 0.9rem; 823 + font-weight: 600; 824 + font-family: inherit; 825 + cursor: pointer; 826 + transition: opacity 0.2s; 827 + } 828 + 829 + .comment-submit:disabled { 830 + opacity: 0.5; 831 + cursor: not-allowed; 832 + } 833 + 834 + .comment-submit:hover:not(:disabled) { 835 + opacity: 0.9; 836 + } 837 + 838 + .login-prompt { 839 + color: #888; 840 + font-size: 0.9rem; 841 + margin-bottom: 1rem; 842 + } 843 + 844 + .login-prompt a { 845 + color: var(--accent); 846 + text-decoration: none; 847 + } 848 + 849 + .login-prompt a:hover { 850 + text-decoration: underline; 851 + } 852 + 853 + .comments-loading, 854 + .no-comments { 855 + color: #666; 856 + font-size: 0.9rem; 857 + text-align: center; 858 + padding: 1rem; 859 + } 860 + 861 + .comments-list { 862 + display: flex; 863 + flex-direction: column; 864 + gap: 0.5rem; 865 + max-height: 300px; 866 + overflow-y: auto; 867 + } 868 + 869 + .comment { 870 + display: flex; 871 + gap: 0.6rem; 872 + padding: 0.5rem 0.6rem; 873 + background: #1a1a1a; 874 + border-radius: 6px; 875 + } 876 + 877 + .comment-timestamp { 878 + font-size: 0.8rem; 879 + font-weight: 600; 880 + color: var(--accent); 881 + background: rgba(138, 179, 255, 0.1); 882 + padding: 0.2rem 0.5rem; 883 + border-radius: 4px; 884 + white-space: nowrap; 885 + height: fit-content; 886 + border: none; 887 + cursor: pointer; 888 + transition: all 0.2s; 889 + font-family: inherit; 890 + } 891 + 892 + .comment-timestamp:hover { 893 + background: rgba(138, 179, 255, 0.25); 894 + transform: scale(1.05); 895 + } 896 + 897 + .comment-content { 898 + flex: 1; 899 + min-width: 0; 900 + } 901 + 902 + .comment-header { 903 + display: flex; 904 + align-items: center; 905 + gap: 0.5rem; 906 + margin-bottom: 0.25rem; 907 + } 908 + 909 + .comment-avatar { 910 + width: 20px; 911 + height: 20px; 912 + border-radius: 50%; 913 + object-fit: cover; 914 + } 915 + 916 + .comment-avatar-placeholder { 917 + width: 20px; 918 + height: 20px; 919 + border-radius: 50%; 920 + background: #333; 921 + } 922 + 923 + .comment-author { 924 + font-size: 0.85rem; 925 + font-weight: 500; 926 + color: #b0b0b0; 927 + text-decoration: none; 928 + } 929 + 930 + .comment-author:hover { 931 + color: var(--accent); 932 + } 933 + 934 + .comment-text { 935 + font-size: 0.9rem; 936 + color: #e8e8e8; 937 + margin: 0; 938 + line-height: 1.4; 939 + word-break: break-word; 940 + } 941 + 942 + @media (max-width: 768px) { 943 + .comments-section { 944 + margin-top: 1rem; 945 + padding-top: 1rem; 946 + } 947 + 948 + .comments-list { 949 + max-height: 200px; 950 + } 951 + 952 + .comment { 953 + padding: 0.5rem; 954 + } 955 + 956 + .comment-timestamp { 957 + font-size: 0.75rem; 958 + padding: 0.15rem 0.4rem; 574 959 } 575 960 } 576 961 </style>