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 Artist, 11 CopyrightScan, 12 Track, 13 TrackLike, 14 UserSession, 15 ) 16 from backend.models.database import Base
··· 10 Artist, 11 CopyrightScan, 12 Track, 13 + TrackComment, 14 TrackLike, 15 + UserPreferences, 16 UserSession, 17 ) 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 normalize_avatar_url, 7 ) 8 from backend._internal.atproto.records import ( 9 create_like_record, 10 create_track_record, 11 delete_record_by_uri, 12 ) 13 14 __all__ = [ 15 "create_like_record", 16 "create_track_record", 17 "delete_record_by_uri",
··· 6 normalize_avatar_url, 7 ) 8 from backend._internal.atproto.records import ( 9 + create_comment_record, 10 create_like_record, 11 create_track_record, 12 delete_record_by_uri, 13 ) 14 15 __all__ = [ 16 + "create_comment_record", 17 "create_like_record", 18 "create_track_record", 19 "delete_record_by_uri",
+136 -195
backend/src/backend/_internal/atproto/records.py
··· 150 raise ValueError(f"failed to refresh access token: {e}") from e 151 152 153 def build_track_record( 154 title: str, 155 artist: str, ··· 217 duration: int | None = None, 218 features: list[dict] | None = None, 219 image_url: str | None = None, 220 - ) -> tuple[str, str] | None: 221 """Create a track record on the user's PDS using the configured collection. 222 223 args: ··· 238 ValueError: if session is invalid 239 Exception: if record creation fails 240 """ 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 record = build_track_record( 253 title=title, 254 artist=artist, ··· 260 image_url=image_url, 261 ) 262 263 - # make authenticated request to create record 264 - url = f"{oauth_data['pds_url']}/xrpc/com.atproto.repo.createRecord" 265 payload = { 266 "repo": auth_session.did, 267 "collection": settings.atproto.track_collection, 268 "record": record, 269 } 270 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 - ) 279 280 - # success 281 - if response.status_code in (200, 201): 282 - result = response.json() 283 - return result["uri"], result["cid"] 284 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 - ) 304 305 306 async def update_record( 307 auth_session: AuthSession, 308 record_uri: str, 309 record: dict[str, Any], 310 - ) -> tuple[str, str] | None: 311 """Update an existing record on the user's PDS. 312 313 args: ··· 322 ValueError: if session is invalid or URI is malformed 323 Exception: if record update fails 324 """ 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 - ) 331 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 payload = { 349 "repo": repo, 350 "collection": collection, ··· 352 "record": record, 353 } 354 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 - ) 388 389 390 async def create_like_record( ··· 406 ValueError: if session is invalid 407 Exception: if record creation fails 408 """ 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 record = { 421 "$type": settings.atproto.like_collection, 422 "subject": { ··· 426 "createdAt": datetime.now(UTC).isoformat().replace("+00:00", "Z"), 427 } 428 429 - # make authenticated request to create record 430 - url = f"{oauth_data['pds_url']}/xrpc/com.atproto.repo.createRecord" 431 payload = { 432 "repo": auth_session.did, 433 "collection": settings.atproto.like_collection, 434 "record": record, 435 } 436 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}" 469 ) 470 471 472 async def delete_record_by_uri( ··· 483 ValueError: if session is invalid or URI is malformed 484 Exception: if record deletion fails 485 """ 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}") 500 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 payload = { 510 "repo": repo, 511 "collection": collection, 512 "rkey": rkey, 513 } 514 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 - ) 523 524 - # success 525 - if response.status_code in (200, 201, 204): 526 - return 527 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 542 543 - # all attempts failed 544 - raise Exception(f"Failed to delete record: {response.status_code} {response.text}")
··· 150 raise ValueError(f"failed to refresh access token: {e}") from e 151 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 + 216 def build_track_record( 217 title: str, 218 artist: str, ··· 280 duration: int | None = None, 281 features: list[dict] | None = None, 282 image_url: str | None = None, 283 + ) -> tuple[str, str]: 284 """Create a track record on the user's PDS using the configured collection. 285 286 args: ··· 301 ValueError: if session is invalid 302 Exception: if record creation fails 303 """ 304 record = build_track_record( 305 title=title, 306 artist=artist, ··· 312 image_url=image_url, 313 ) 314 315 payload = { 316 "repo": auth_session.did, 317 "collection": settings.atproto.track_collection, 318 "record": record, 319 } 320 321 + result = await _make_pds_request( 322 + auth_session, "POST", "com.atproto.repo.createRecord", payload 323 + ) 324 + return result["uri"], result["cid"] 325 326 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] 335 336 337 async def update_record( 338 auth_session: AuthSession, 339 record_uri: str, 340 record: dict[str, Any], 341 + ) -> tuple[str, str]: 342 """Update an existing record on the user's PDS. 343 344 args: ··· 353 ValueError: if session is invalid or URI is malformed 354 Exception: if record update fails 355 """ 356 + repo, collection, rkey = _parse_at_uri(record_uri) 357 358 payload = { 359 "repo": repo, 360 "collection": collection, ··· 362 "record": record, 363 } 364 365 + result = await _make_pds_request( 366 + auth_session, "POST", "com.atproto.repo.putRecord", payload 367 + ) 368 + return result["uri"], result["cid"] 369 370 371 async def create_like_record( ··· 387 ValueError: if session is invalid 388 Exception: if record creation fails 389 """ 390 record = { 391 "$type": settings.atproto.like_collection, 392 "subject": { ··· 396 "createdAt": datetime.now(UTC).isoformat().replace("+00:00", "Z"), 397 } 398 399 payload = { 400 "repo": auth_session.did, 401 "collection": settings.atproto.like_collection, 402 "record": record, 403 } 404 405 + result = await _make_pds_request( 406 + auth_session, "POST", "com.atproto.repo.createRecord", payload 407 ) 408 + return result["uri"] 409 410 411 async def delete_record_by_uri( ··· 422 ValueError: if session is invalid or URI is malformed 423 Exception: if record deletion fails 424 """ 425 + repo, collection, rkey = _parse_at_uri(record_uri) 426 427 payload = { 428 "repo": repo, 429 "collection": collection, 430 "rkey": rkey, 431 } 432 433 + await _make_pds_request( 434 + auth_session, 435 + "POST", 436 + "com.atproto.repo.deleteRecord", 437 + payload, 438 + success_codes=(200, 201, 204), 439 + ) 440 441 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. 450 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 19 accent_color: str 20 auto_advance: bool 21 22 23 class PreferencesUpdate(BaseModel): ··· 25 26 accent_color: str | None = None 27 auto_advance: bool | None = None 28 29 30 @router.get("/") ··· 46 await db.refresh(prefs) 47 48 return PreferencesResponse( 49 - accent_color=prefs.accent_color, auto_advance=prefs.auto_advance 50 ) 51 52 ··· 70 auto_advance=update.auto_advance 71 if update.auto_advance is not None 72 else True, 73 ) 74 db.add(prefs) 75 else: ··· 78 prefs.accent_color = update.accent_color 79 if update.auto_advance is not None: 80 prefs.auto_advance = update.auto_advance 81 82 await db.commit() 83 await db.refresh(prefs) 84 85 return PreferencesResponse( 86 - accent_color=prefs.accent_color, auto_advance=prefs.auto_advance 87 )
··· 18 19 accent_color: str 20 auto_advance: bool 21 + allow_comments: bool 22 23 24 class PreferencesUpdate(BaseModel): ··· 26 27 accent_color: str | None = None 28 auto_advance: bool | None = None 29 + allow_comments: bool | None = None 30 31 32 @router.get("/") ··· 48 await db.refresh(prefs) 49 50 return PreferencesResponse( 51 + accent_color=prefs.accent_color, 52 + auto_advance=prefs.auto_advance, 53 + allow_comments=prefs.allow_comments, 54 ) 55 56 ··· 74 auto_advance=update.auto_advance 75 if update.auto_advance is not None 76 else True, 77 + allow_comments=update.allow_comments 78 + if update.allow_comments is not None 79 + else False, 80 ) 81 db.add(prefs) 82 else: ··· 85 prefs.accent_color = update.accent_color 86 if update.auto_advance is not None: 87 prefs.auto_advance = update.auto_advance 88 + if update.allow_comments is not None: 89 + prefs.allow_comments = update.allow_comments 90 91 await db.commit() 92 await db.refresh(prefs) 93 94 return PreferencesResponse( 95 + accent_color=prefs.accent_color, 96 + auto_advance=prefs.auto_advance, 97 + allow_comments=prefs.allow_comments, 98 )
+2 -1
backend/src/backend/api/tracks/__init__.py
··· 3 from .router import router 4 5 # Import route modules to register handlers on the shared router. 6 from . import listing as _listing 7 - from . import likes as _likes 8 from . import mutations as _mutations 9 from . import playback as _playback 10 from . import uploads as _uploads
··· 3 from .router import router 4 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
+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 318 @computed_field 319 @property 320 def old_track_collection(self) -> str | None: 321 """Collection name for old namespace, if migration is active.""" 322 ··· 332 if self.scope_override: 333 return self.scope_override 334 335 - # base scopes: our track collection + our like collection 336 scopes = [ 337 f"repo:{self.track_collection}", 338 f"repo:{self.like_collection}", 339 ] 340 341 # if we have an old namespace, add old track collection too
··· 317 318 @computed_field 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 327 def old_track_collection(self) -> str | None: 328 """Collection name for old namespace, if migration is active.""" 329 ··· 339 if self.scope_override: 340 return self.scope_override 341 342 + # base scopes: our track, like, and comment collections 343 scopes = [ 344 f"repo:{self.track_collection}", 345 f"repo:{self.like_collection}", 346 + f"repo:{self.comment_collection}", 347 ] 348 349 # if we have an old namespace, add old track collection too
+2
backend/src/backend/models/__init__.py
··· 11 from backend.models.queue import QueueState 12 from backend.models.session import UserSession 13 from backend.models.track import Track 14 from backend.models.track_like import TrackLike 15 from backend.utilities.database import db_session, get_db, init_db 16 ··· 25 "QueueState", 26 "ScanResolution", 27 "Track", 28 "TrackLike", 29 "UserPreferences", 30 "UserSession",
··· 11 from backend.models.queue import QueueState 12 from backend.models.session import UserSession 13 from backend.models.track import Track 14 + from backend.models.track_comment import TrackComment 15 from backend.models.track_like import TrackLike 16 from backend.utilities.database import db_session, get_db, init_db 17 ··· 26 "QueueState", 27 "ScanResolution", 28 "Track", 29 + "TrackComment", 30 "TrackLike", 31 "UserPreferences", 32 "UserSession",
+5
backend/src/backend/models/preferences.py
··· 22 Boolean, nullable=False, default=True, server_default=text("true") 23 ) 24 25 # metadata 26 created_at: Mapped[datetime] = mapped_column( 27 DateTime(timezone=True),
··· 22 Boolean, nullable=False, default=True, server_default=text("true") 23 ) 24 25 + # artist preferences 26 + allow_comments: Mapped[bool] = mapped_column( 27 + Boolean, nullable=False, default=False, server_default=text("false") 28 + ) 29 + 30 # metadata 31 created_at: Mapped[datetime] = mapped_column( 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 let displayName = $state(''); 52 let bio = $state(''); 53 let avatarUrl = $state(''); 54 let savingProfile = $state(false); 55 let profileSuccess = $state(''); 56 let profileError = $state(''); ··· 104 await loadMyTracks(); 105 await loadArtistProfile(); 106 await loadMyAlbums(); 107 } catch (_e) { 108 console.error('error loading portal data:', _e); 109 error = 'failed to load portal data'; ··· 158 console.error('failed to load albums:', _e); 159 } finally { 160 loadingAlbums = false; 161 } 162 } 163 ··· 917 {/if} 918 </section> 919 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. 927 </p> 928 </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} 938 </main> 939 {/if} 940 ··· 1630 justify-content: flex-end; 1631 } 1632 1633 - .export-section { 1634 margin-top: 3rem; 1635 - padding: 2rem; 1636 background: #1a1a1a; 1637 border: 1px solid #2a2a2a; 1638 border-radius: 8px; 1639 } 1640 1641 - .export-header { 1642 - margin-bottom: 1.5rem; 1643 } 1644 1645 - .export-header h2 { 1646 - font-size: var(--text-page-heading); 1647 - margin-bottom: 0.75rem; 1648 } 1649 1650 - .export-description { 1651 - color: #aaa; 1652 - font-size: 0.95rem; 1653 - line-height: 1.6; 1654 margin: 0; 1655 } 1656 1657 - .export-main-btn { 1658 - width: 100%; 1659 - padding: 1rem; 1660 background: #3a7dff; 1661 color: white; 1662 border: none; 1663 border-radius: 6px; 1664 - font-size: 1rem; 1665 font-weight: 600; 1666 cursor: pointer; 1667 transition: all 0.2s; 1668 } 1669 1670 - .export-main-btn:hover:not(:disabled) { 1671 background: #2868e6; 1672 transform: translateY(-1px); 1673 box-shadow: 0 4px 12px rgba(58, 125, 255, 0.3); 1674 } 1675 1676 - .export-main-btn:disabled { 1677 opacity: 0.5; 1678 cursor: not-allowed; 1679 transform: none; 1680 } 1681 1682 - .export-main-btn:active:not(:disabled) { 1683 - transform: translateY(0); 1684 } 1685 </style>
··· 51 let displayName = $state(''); 52 let bio = $state(''); 53 let avatarUrl = $state(''); 54 + let allowComments = $state(false); 55 let savingProfile = $state(false); 56 let profileSuccess = $state(''); 57 let profileError = $state(''); ··· 105 await loadMyTracks(); 106 await loadArtistProfile(); 107 await loadMyAlbums(); 108 + await loadPreferences(); 109 } catch (_e) { 110 console.error('error loading portal data:', _e); 111 error = 'failed to load portal data'; ··· 160 console.error('failed to load albums:', _e); 161 } finally { 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'); 197 } 198 } 199 ··· 953 {/if} 954 </section> 955 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 964 </p> 965 </div> 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> 996 </main> 997 {/if} 998 ··· 1688 justify-content: flex-end; 1689 } 1690 1691 + /* your data section */ 1692 + .data-section { 1693 margin-top: 3rem; 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; 1703 background: #1a1a1a; 1704 border: 1px solid #2a2a2a; 1705 border-radius: 8px; 1706 + display: flex; 1707 + justify-content: space-between; 1708 + align-items: center; 1709 + gap: 1rem; 1710 + margin-bottom: 1rem; 1711 } 1712 1713 + .data-control:last-child { 1714 + margin-bottom: 0; 1715 } 1716 1717 + .control-info h3 { 1718 + font-size: 1rem; 1719 + font-weight: 600; 1720 + margin: 0 0 0.25rem 0; 1721 + color: #e8e8e8; 1722 } 1723 1724 + .control-description { 1725 + font-size: 0.85rem; 1726 + color: #888; 1727 margin: 0; 1728 } 1729 1730 + .export-btn { 1731 + padding: 0.6rem 1.25rem; 1732 background: #3a7dff; 1733 color: white; 1734 border: none; 1735 border-radius: 6px; 1736 + font-size: 0.9rem; 1737 font-weight: 600; 1738 cursor: pointer; 1739 transition: all 0.2s; 1740 + white-space: nowrap; 1741 + width: auto; 1742 } 1743 1744 + .export-btn:hover:not(:disabled) { 1745 background: #2868e6; 1746 transform: translateY(-1px); 1747 box-shadow: 0 4px 12px rgba(58, 125, 255, 0.3); 1748 } 1749 1750 + .export-btn:disabled { 1751 opacity: 0.5; 1752 cursor: not-allowed; 1753 transform: none; 1754 } 1755 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; 1802 } 1803 </style>
+387 -2
frontend/src/routes/track/[id]/+page.svelte
··· 9 import { player } from '$lib/player.svelte'; 10 import { queue } from '$lib/queue.svelte'; 11 import { auth } from '$lib/auth.svelte'; 12 import type { Track } from '$lib/types'; 13 14 // receive server-loaded data 15 let { data }: { data: PageData } = $props(); 16 17 let track = $state<Track>(data.track); 18 19 // reactive check if this track is currently playing 20 let isCurrentlyPlaying = $derived( 21 player.currentTrack?.id === track.id && !player.paused ··· 41 } 42 43 function handlePlay() { 44 - if (isCurrentlyPlaying) { 45 player.togglePlayPause(); 46 } else { 47 queue.playNow(track); 48 } 49 } ··· 52 queue.addTracks([track]); 53 } 54 55 onMount(async () => { 56 if (auth.isAuthenticated) { 57 await loadLikedState(); 58 } 59 }); 60 61 let shareUrl = $state(''); ··· 228 </div> 229 </div> 230 </div> 231 </main> 232 </div> 233 ··· 241 main { 242 flex: 1; 243 display: flex; 244 align-items: center; 245 - justify-content: center; 246 padding: 2rem; 247 padding-bottom: 8rem; 248 width: 100%; ··· 571 .btn-queue svg { 572 width: 18px; 573 height: 18px; 574 } 575 } 576 </style>
··· 9 import { player } from '$lib/player.svelte'; 10 import { queue } from '$lib/queue.svelte'; 11 import { auth } from '$lib/auth.svelte'; 12 + import { toast } from '$lib/toast.svelte'; 13 import type { Track } from '$lib/types'; 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 + 26 // receive server-loaded data 27 let { data }: { data: PageData } = $props(); 28 29 let track = $state<Track>(data.track); 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 + 38 // reactive check if this track is currently playing 39 let isCurrentlyPlaying = $derived( 40 player.currentTrack?.id === track.id && !player.paused ··· 60 } 61 62 function handlePlay() { 63 + if (player.currentTrack?.id === track.id) { 64 + // this track is already loaded - just toggle play/pause 65 player.togglePlayPause(); 66 } else { 67 + // different track or no track - start this one 68 queue.playNow(track); 69 } 70 } ··· 73 queue.addTracks([track]); 74 } 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 + 172 onMount(async () => { 173 if (auth.isAuthenticated) { 174 await loadLikedState(); 175 } 176 + await loadComments(); 177 }); 178 179 let shareUrl = $state(''); ··· 346 </div> 347 </div> 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} 419 </main> 420 </div> 421 ··· 429 main { 430 flex: 1; 431 display: flex; 432 + flex-direction: column; 433 align-items: center; 434 + justify-content: flex-start; 435 padding: 2rem; 436 padding-bottom: 8rem; 437 width: 100%; ··· 760 .btn-queue svg { 761 width: 18px; 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; 959 } 960 } 961 </style>