Compare changes

Choose any two refs to compare.

Changed files
+6364 -1216
.claude
.github
.status_history
backend
docs
frontend
lexicons
moderation
redis
scripts
+66
.claude/commands/digest.md
···
··· 1 + --- 2 + description: Extract actionable insights from an external resource 3 + argument-hint: [URL, AT-URI, or description of resource] 4 + --- 5 + 6 + # digest 7 + 8 + break down an external resource and identify what it means for us. 9 + 10 + ## process 11 + 12 + 1. **fetch the resource**: $ARGUMENTS 13 + - use pdsx MCP for AT-URIs (bsky posts, leaflet docs, etc.) 14 + - use WebFetch for URLs 15 + - ask for clarification if the resource type is unclear 16 + 17 + 2. **extract concrete information**: 18 + - what are they doing? 19 + - what's their architecture/approach? 20 + - what are their constraints and priorities? 21 + - what's their roadmap? 22 + 23 + 3. **cross-reference with our state**: 24 + - check open issues for overlap or gaps 25 + - grep codebase for related implementations 26 + - identify where we align or diverge 27 + 28 + 4. **identify actionable implications** - the core output: 29 + - "given that X is true, we should consider Y" 30 + - specific issues to open or update 31 + - code changes to consider 32 + - integration opportunities 33 + - things we're missing or doing wrong 34 + 35 + 5. **present findings** - be direct: 36 + - lead with the implications, not the summary 37 + - include specific file:line or issue references 38 + - propose concrete next steps 39 + 40 + ## anti-patterns 41 + 42 + - philosophical musing without action items 43 + - "we're complementary" without specifics 44 + - agreeing that everything is fine 45 + - backpedaling when challenged 46 + 47 + ## output format 48 + 49 + ``` 50 + ## implications 51 + 52 + 1. **[actionable item]**: [reasoning] 53 + - related: `file.py:123` or issue #456 54 + - suggested: [specific change or issue to create] 55 + 56 + 2. **[actionable item]**: ... 57 + 58 + ## extracted facts 59 + 60 + - [concrete thing from the resource] 61 + - [another concrete thing] 62 + 63 + ## open questions 64 + 65 + - [things to clarify or investigate further] 66 + ```
+2
.claude/commands/status-update.md
··· 11 - deployment/infrastructure changes 12 - incidents and their resolutions 13 14 ## how to update 15 16 1. add a new subsection under `## recent work` with today's date
··· 11 - deployment/infrastructure changes 12 - incidents and their resolutions 13 14 + **tip**: after running `/deploy`, consider running `/status-update` to document what shipped. 15 + 16 ## how to update 17 18 1. add a new subsection under `## recent work` with today's date
+47 -3
.github/workflows/check-rust.yml
··· 11 contents: read 12 13 jobs: 14 check: 15 name: cargo check 16 runs-on: ubuntu-latest 17 timeout-minutes: 15 18 19 strategy: 20 matrix: 21 - service: [moderation, transcoder] 22 23 steps: 24 - uses: actions/checkout@v4 25 26 - name: install rust toolchain 27 uses: dtolnay/rust-toolchain@stable 28 29 - name: cache cargo 30 uses: Swatinem/rust-cache@v2 31 with: 32 workspaces: ${{ matrix.service }} 33 34 - name: cargo check 35 working-directory: ${{ matrix.service }} 36 run: cargo check --release 37 38 docker-build: 39 name: docker build 40 runs-on: ubuntu-latest 41 timeout-minutes: 10 42 - needs: check 43 44 strategy: 45 matrix: 46 - service: [moderation, transcoder] 47 48 steps: 49 - uses: actions/checkout@v4 50 51 - name: build docker image 52 working-directory: ${{ matrix.service }} 53 run: docker build -t ${{ matrix.service }}:ci-test .
··· 11 contents: read 12 13 jobs: 14 + changes: 15 + name: detect changes 16 + runs-on: ubuntu-latest 17 + outputs: 18 + moderation: ${{ steps.filter.outputs.moderation }} 19 + transcoder: ${{ steps.filter.outputs.transcoder }} 20 + steps: 21 + - uses: actions/checkout@v4 22 + - uses: dorny/paths-filter@v3 23 + id: filter 24 + with: 25 + filters: | 26 + moderation: 27 + - 'moderation/**' 28 + - '.github/workflows/check-rust.yml' 29 + transcoder: 30 + - 'transcoder/**' 31 + - '.github/workflows/check-rust.yml' 32 + 33 check: 34 name: cargo check 35 runs-on: ubuntu-latest 36 timeout-minutes: 15 37 + needs: changes 38 39 strategy: 40 + fail-fast: false 41 matrix: 42 + include: 43 + - service: moderation 44 + changed: ${{ needs.changes.outputs.moderation }} 45 + - service: transcoder 46 + changed: ${{ needs.changes.outputs.transcoder }} 47 48 steps: 49 - uses: actions/checkout@v4 50 + if: matrix.changed == 'true' 51 52 - name: install rust toolchain 53 + if: matrix.changed == 'true' 54 uses: dtolnay/rust-toolchain@stable 55 56 - name: cache cargo 57 + if: matrix.changed == 'true' 58 uses: Swatinem/rust-cache@v2 59 with: 60 workspaces: ${{ matrix.service }} 61 62 - name: cargo check 63 + if: matrix.changed == 'true' 64 working-directory: ${{ matrix.service }} 65 run: cargo check --release 66 67 + - name: skip (no changes) 68 + if: matrix.changed != 'true' 69 + run: echo "skipping ${{ matrix.service }} - no changes" 70 + 71 docker-build: 72 name: docker build 73 runs-on: ubuntu-latest 74 timeout-minutes: 10 75 + needs: [changes, check] 76 77 strategy: 78 + fail-fast: false 79 matrix: 80 + include: 81 + - service: moderation 82 + changed: ${{ needs.changes.outputs.moderation }} 83 + - service: transcoder 84 + changed: ${{ needs.changes.outputs.transcoder }} 85 86 steps: 87 - uses: actions/checkout@v4 88 + if: matrix.changed == 'true' 89 90 - name: build docker image 91 + if: matrix.changed == 'true' 92 working-directory: ${{ matrix.service }} 93 run: docker build -t ${{ matrix.service }}:ci-test . 94 + 95 + - name: skip (no changes) 96 + if: matrix.changed != 'true' 97 + run: echo "skipping ${{ matrix.service }} - no changes"
+43
.github/workflows/deploy-redis.yml
···
··· 1 + name: deploy redis 2 + 3 + on: 4 + push: 5 + branches: 6 + - main 7 + paths: 8 + - "redis/fly.toml" 9 + - "redis/fly.staging.toml" 10 + - ".github/workflows/deploy-redis.yml" 11 + workflow_dispatch: 12 + 13 + jobs: 14 + deploy-staging: 15 + name: deploy redis staging 16 + runs-on: ubuntu-latest 17 + concurrency: deploy-redis-staging 18 + steps: 19 + - uses: actions/checkout@v4 20 + 21 + - uses: superfly/flyctl-actions/setup-flyctl@master 22 + 23 + - name: deploy to fly.io staging 24 + run: flyctl deploy --config redis/fly.staging.toml --remote-only -a plyr-redis-stg 25 + working-directory: redis 26 + env: 27 + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN_REDIS }} 28 + 29 + deploy-prod: 30 + name: deploy redis prod 31 + runs-on: ubuntu-latest 32 + needs: deploy-staging 33 + concurrency: deploy-redis-prod 34 + steps: 35 + - uses: actions/checkout@v4 36 + 37 + - uses: superfly/flyctl-actions/setup-flyctl@master 38 + 39 + - name: deploy to fly.io prod 40 + run: flyctl deploy --config redis/fly.toml --remote-only -a plyr-redis 41 + working-directory: redis 42 + env: 43 + FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN_REDIS }}
+64
.github/workflows/run-moderation-loop.yml
···
··· 1 + # run moderation loop via workflow dispatch 2 + # 3 + # analyzes pending copyright flags, auto-resolves false positives, 4 + # creates review batches for human review, sends DM notification 5 + # 6 + # required secrets: 7 + # MODERATION_SERVICE_URL - moderation service base URL 8 + # MODERATION_AUTH_TOKEN - X-Moderation-Key header value 9 + # ANTHROPIC_API_KEY - for flag analysis 10 + # NOTIFY_BOT_HANDLE - bluesky bot handle for DMs 11 + # NOTIFY_BOT_PASSWORD - bluesky bot app password 12 + # NOTIFY_RECIPIENT_HANDLE - who receives DM notifications 13 + 14 + name: run moderation loop 15 + 16 + on: 17 + workflow_dispatch: 18 + inputs: 19 + dry_run: 20 + description: "dry run (analyze only, don't resolve or send DMs)" 21 + type: boolean 22 + default: true 23 + limit: 24 + description: "max flags to process (leave empty for all)" 25 + type: string 26 + default: "" 27 + env: 28 + description: "environment (for DM header)" 29 + type: choice 30 + options: 31 + - prod 32 + - staging 33 + - dev 34 + default: prod 35 + 36 + jobs: 37 + run: 38 + runs-on: ubuntu-latest 39 + 40 + steps: 41 + - uses: actions/checkout@v4 42 + 43 + - uses: astral-sh/setup-uv@v4 44 + 45 + - name: Run moderation loop 46 + run: | 47 + ARGS="" 48 + if [ "${{ inputs.dry_run }}" = "true" ]; then 49 + ARGS="$ARGS --dry-run" 50 + fi 51 + if [ -n "${{ inputs.limit }}" ]; then 52 + ARGS="$ARGS --limit ${{ inputs.limit }}" 53 + fi 54 + ARGS="$ARGS --env ${{ inputs.env }}" 55 + 56 + echo "Running: uv run scripts/moderation_loop.py $ARGS" 57 + uv run scripts/moderation_loop.py $ARGS 58 + env: 59 + MODERATION_SERVICE_URL: ${{ secrets.MODERATION_SERVICE_URL }} 60 + MODERATION_AUTH_TOKEN: ${{ secrets.MODERATION_AUTH_TOKEN }} 61 + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} 62 + NOTIFY_BOT_HANDLE: ${{ secrets.NOTIFY_BOT_HANDLE }} 63 + NOTIFY_BOT_PASSWORD: ${{ secrets.NOTIFY_BOT_PASSWORD }} 64 + NOTIFY_RECIPIENT_HANDLE: ${{ secrets.NOTIFY_RECIPIENT_HANDLE }}
+194
.status_history/2025-12.md
··· 412 - 6 original artists (people uploading their own distributed music) 413 414 **documentation**: see `docs/moderation/atproto-labeler.md`
··· 412 - 6 original artists (people uploading their own distributed music) 413 414 **documentation**: see `docs/moderation/atproto-labeler.md` 415 + 416 + --- 417 + 418 + ## Mid-December 2025 Work (Dec 8-16) 419 + 420 + ### visual customization (PRs #595-596, Dec 16) 421 + 422 + **custom backgrounds** (PR #595): 423 + - users can set a custom background image URL in settings with optional tiling 424 + - new "playing artwork as background" toggle - uses current track's artwork as blurred page background 425 + - glass effect styling for track items (translucent backgrounds, subtle shadows) 426 + - new `ui_settings` JSONB column in preferences for extensible UI settings 427 + 428 + **bug fix** (PR #596): 429 + - removed 3D wheel scroll effect that was blocking like/share button clicks 430 + - root cause: `translateZ` transforms created z-index stacking that intercepted pointer events 431 + 432 + --- 433 + 434 + ### performance & UX polish (PRs #586-593, Dec 14-15) 435 + 436 + **performance improvements** (PRs #590-591): 437 + - removed moderation service call from `/tracks/` listing endpoint 438 + - removed copyright check from tag listing endpoint 439 + - faster page loads for track feeds 440 + 441 + **moderation agent** (PRs #586, #588): 442 + - added moderation agent script with audit trail support 443 + - improved moderation prompt and UI layout 444 + 445 + **bug fixes** (PRs #589, #592, #593): 446 + - fixed liked state display on playlist detail page 447 + - preserved album track order during ATProto sync 448 + - made header sticky on scroll for better mobile navigation 449 + 450 + **iOS Safari fixes** (PRs #573-576): 451 + - fixed AddToMenu visibility issue on iOS Safari 452 + - menu now correctly opens upward when near viewport bottom 453 + 454 + --- 455 + 456 + ### mobile UI polish & background task expansion (PRs #558-572, Dec 10-12) 457 + 458 + **background task expansion** (PRs #558, #561): 459 + - moved like/unlike and comment PDS writes to docket background tasks 460 + - API responses now immediate; PDS sync happens asynchronously 461 + - added targeted album list sync background task for ATProto record updates 462 + 463 + **performance caching** (PR #566): 464 + - added Redis cache for copyright label lookups (5-minute TTL) 465 + - fixed 2-3s latency spikes on `/tracks/` endpoint 466 + - batch operations via `mget`/pipeline for efficiency 467 + 468 + **mobile UX improvements** (PRs #569, #572): 469 + - mobile action menus now open from top with all actions visible 470 + - UI polish for album and artist pages on small screens 471 + 472 + **misc** (PRs #559, #562, #563, #570): 473 + - reduced docket Redis polling from 250ms to 5s (lower resource usage) 474 + - added atprotofans support link mode for ko-fi integration 475 + - added alpha badge to header branding 476 + - fixed web manifest ID for PWA stability 477 + 478 + --- 479 + 480 + ### confidential OAuth client (PRs #578, #580-582, Dec 12-13) 481 + 482 + **confidential client support** (PR #578): 483 + - implemented ATProto OAuth confidential client using `private_key_jwt` authentication 484 + - when `OAUTH_JWK` is configured, plyr.fm authenticates with a cryptographic key 485 + - confidential clients earn 180-day refresh tokens (vs 2-week for public clients) 486 + - added `/.well-known/jwks.json` endpoint for public key discovery 487 + - updated `/oauth-client-metadata.json` with confidential client fields 488 + 489 + **bug fixes** (PRs #580-582): 490 + - fixed client assertion JWT to use Authorization Server's issuer as `aud` claim (not token endpoint URL) 491 + - fixed JWKS endpoint to preserve `kid` field from original JWK 492 + - fixed `OAuthClient` to pass `client_secret_kid` for JWT header 493 + 494 + **atproto fork updates** (zzstoatzz/atproto#6, #7): 495 + - added `issuer` parameter to `_make_token_request()` for correct `aud` claim 496 + - added `client_secret_kid` parameter to include `kid` in client assertion JWT header 497 + 498 + **outcome**: users now get 180-day refresh tokens, and "remember this account" on the PDS authorization page works (auto-approves subsequent logins). see #583 for future work on account switching via OAuth `prompt` parameter. 499 + 500 + --- 501 + 502 + ### pagination & album management (PRs #550-554, Dec 9-10) 503 + 504 + **tracks list pagination** (PR #554): 505 + - cursor-based pagination on `/tracks/` endpoint (default 50 per page) 506 + - infinite scroll on homepage using native IntersectionObserver 507 + - zero new dependencies - uses browser APIs only 508 + - pagination state persisted to localStorage for fast subsequent loads 509 + 510 + **album management improvements** (PRs #550-552, #557): 511 + - album delete and track reorder fixes 512 + - album page edit mode matching playlist UX (inline title editing, cover upload) 513 + - optimistic UI updates for album title changes (instant feedback) 514 + - ATProto record sync when album title changes (updates all track records + list record) 515 + - fixed album slug sync on rename (prevented duplicate albums when adding tracks) 516 + 517 + **playlist show on profile** (PR #553): 518 + - restored "show on profile" toggle that was lost during inline editing refactor 519 + - users can now control whether playlists appear on their public profile 520 + 521 + --- 522 + 523 + ### public cost dashboard (PRs #548-549, Dec 9) 524 + 525 + - `/costs` page showing live platform infrastructure costs 526 + - daily export to R2 via GitHub Action, proxied through `/stats/costs` endpoint 527 + - dedicated `plyr-stats` R2 bucket with public access (shared across environments) 528 + - includes fly.io, neon, cloudflare, and audd API costs 529 + - ko-fi integration for community support 530 + 531 + ### docket background tasks & concurrent exports (PRs #534-546, Dec 9) 532 + 533 + **docket integration** (PRs #534, #536, #539): 534 + - migrated background tasks from inline asyncio to docket (Redis-backed task queue) 535 + - copyright scanning, media export, ATProto sync, and teal scrobbling now run via docket 536 + - graceful fallback to asyncio for local development without Redis 537 + - parallel test execution with xdist template databases (#540) 538 + 539 + **concurrent export downloads** (PR #545): 540 + - exports now download tracks in parallel (up to 4 concurrent) instead of sequentially 541 + - significantly faster for users with many tracks or large files 542 + - zip creation remains sequential (zipfile constraint) 543 + 544 + **ATProto refactor** (PR #534): 545 + - reorganized ATProto record code into `_internal/atproto/records/` by lexicon namespace 546 + - extracted `client.py` for low-level PDS operations 547 + - cleaner separation between plyr.fm and teal.fm lexicons 548 + 549 + **documentation & observability**: 550 + - AudD API cost tracking dashboard (#546) 551 + - promoted runbooks from sandbox to `docs/runbooks/` 552 + - updated CLAUDE.md files across the codebase 553 + 554 + --- 555 + 556 + ### artist support links & inline playlist editing (PRs #520-532, Dec 8) 557 + 558 + **artist support link** (PR #532): 559 + - artists can set a support URL (Ko-fi, Patreon, etc.) in their portal profile 560 + - support link displays as a button on artist profile pages next to the share button 561 + - URLs validated to require https:// prefix 562 + 563 + **inline playlist editing** (PR #531): 564 + - edit playlist name and description directly on playlist detail page 565 + - click-to-upload cover art replacement without modal 566 + - cleaner UX - no more edit modal popup 567 + 568 + **platform stats enhancements** (PRs #522, #528): 569 + - total duration displayed in platform stats (e.g., "42h 15m of music") 570 + - duration shown per artist in analytics section 571 + - combined stats and search into single centered container for cleaner layout 572 + 573 + **navigation & data loading fixes** (PR #527): 574 + - fixed stale data when navigating between detail pages of the same type 575 + - e.g., clicking from one artist to another now properly reloads data 576 + 577 + **copyright moderation improvements** (PR #480): 578 + - enhanced moderation workflow for copyright claims 579 + - improved labeler integration 580 + 581 + **status maintenance workflow** (PR #529): 582 + - automated status maintenance using claude-code-action 583 + - reviews merged PRs and updates STATUS.md narratively 584 + 585 + --- 586 + 587 + ### playlist fast-follow fixes (PRs #507-519, Dec 7-8) 588 + 589 + **public playlist viewing** (PR #519): 590 + - playlists now publicly viewable without authentication 591 + - ATProto records are public by design - auth was unnecessary for read access 592 + - shared playlist URLs no longer redirect unauthenticated users to homepage 593 + 594 + **inline playlist creation** (PR #510): 595 + - clicking "create new playlist" from AddToMenu previously navigated to `/library?create=playlist` 596 + - this caused SvelteKit to reinitialize the layout, destroying the audio element and stopping playback 597 + - fix: added inline create form that creates playlist and adds track in one action without navigation 598 + 599 + **UI polish** (PRs #507-509, #515): 600 + - include `image_url` in playlist SSR data for og:image link previews 601 + - invalidate layout data after token exchange - fixes stale auth state after login 602 + - fixed stopPropagation blocking "create new playlist" link clicks 603 + - detail page button layouts: all buttons visible on mobile, centered AddToMenu on track detail 604 + - AddToMenu smart positioning: menu opens upward when near viewport bottom 605 + 606 + **documentation** (PR #514): 607 + - added lexicons overview documentation at `docs/lexicons/overview.md` 608 + - covers `fm.plyr.track`, `fm.plyr.like`, `fm.plyr.comment`, `fm.plyr.list`, `fm.plyr.actor.profile`
+7 -5
README.md
··· 119 │ └── src/routes/ # pages 120 ├── moderation/ # Rust labeler service 121 ├── transcoder/ # Rust audio service 122 ├── docs/ # documentation 123 └── justfile # task runner 124 ``` ··· 128 <details> 129 <summary>costs</summary> 130 131 - ~$35-40/month: 132 - - fly.io backend (prod + staging): ~$10/month 133 - - fly.io transcoder: ~$0-5/month (auto-scales to zero) 134 - neon postgres: $5/month 135 - - audd audio fingerprinting: ~$10/month 136 - - cloudflare (pages + r2): ~$0.16/month 137 138 </details> 139
··· 119 │ └── src/routes/ # pages 120 ├── moderation/ # Rust labeler service 121 ├── transcoder/ # Rust audio service 122 + ├── redis/ # self-hosted Redis config 123 ├── docs/ # documentation 124 └── justfile # task runner 125 ``` ··· 129 <details> 130 <summary>costs</summary> 131 132 + ~$20/month: 133 + - fly.io (backend + redis + moderation): ~$14/month 134 - neon postgres: $5/month 135 + - cloudflare (pages + r2): ~$1/month 136 + - audd audio fingerprinting: $5-10/month (usage-based) 137 + 138 + live dashboard: https://plyr.fm/costs 139 140 </details> 141
+104 -266
STATUS.md
··· 47 48 ### December 2025 49 50 - #### beartype + moderation cleanup (PRs #617-619, Dec 19) 51 52 - **runtime type checking** (PR #619): 53 - - enabled beartype runtime type validation across the backend 54 - - catches type errors at runtime instead of silently passing bad data 55 - - test infrastructure improvements: session-scoped TestClient fixture (5x faster tests) 56 - - disabled automatic perpetual task scheduling in tests 57 58 - **moderation cleanup** (PRs #617-618): 59 - - consolidated moderation code, addressing issues #541-543 60 - - `sync_copyright_resolutions` now runs automatically via docket Perpetual task 61 - - removed `init_db()` from lifespan (handled by alembic migrations) 62 63 --- 64 65 - #### UX polish (PRs #604-607, #613, #615, Dec 16-18) 66 67 - **login improvements** (PRs #604, #613): 68 - - login page now uses "internet handle" terminology for clarity 69 - - input normalization: strips `@` and `at://` prefixes automatically 70 71 - **artist page fixes** (PR #615): 72 - - track pagination on artist pages now works correctly 73 - - fixed mobile album card overflow 74 75 - **mobile + metadata** (PRs #605-607): 76 - - Open Graph tags added to tag detail pages for link previews 77 - - mobile modals now use full screen positioning 78 - - fixed `/tag/` routes in hasPageMetadata check 79 - 80 - **misc** (PRs #598-601): 81 - - upload button added to desktop header nav 82 - - background settings UX improvements 83 - - switched support link to atprotofans 84 - - AudD costs now derived from track duration for accurate billing 85 86 --- 87 88 - #### offline mode foundation (PRs #610-611, Dec 17) 89 - 90 - **experimental offline playback**: 91 - - new storage layer using Cache API for audio bytes + IndexedDB for metadata 92 - - `GET /audio/{file_id}/url` backend endpoint returns direct R2 URLs for client-side caching 93 - - "auto-download liked" toggle in experimental settings section 94 - - when enabled, bulk-downloads all liked tracks and auto-downloads future likes 95 - - Player checks for cached audio before streaming from R2 96 - - works offline once tracks are downloaded 97 - 98 - **robustness improvements**: 99 - - IndexedDB connections properly closed after each operation 100 - - concurrent downloads deduplicated via in-flight promise tracking 101 - - stale metadata cleanup when cache entries are missing 102 - 103 - --- 104 - 105 - #### visual customization (PRs #595-596, Dec 16) 106 - 107 - **custom backgrounds** (PR #595): 108 - - users can set a custom background image URL in settings with optional tiling 109 - - new "playing artwork as background" toggle - uses current track's artwork as blurred page background 110 - - glass effect styling for track items (translucent backgrounds, subtle shadows) 111 - - new `ui_settings` JSONB column in preferences for extensible UI settings 112 113 - **bug fix** (PR #596): 114 - - removed 3D wheel scroll effect that was blocking like/share button clicks 115 - - root cause: `translateZ` transforms created z-index stacking that intercepted pointer events 116 117 --- 118 119 - #### performance & UX polish (PRs #586-593, Dec 14-15) 120 - 121 - **performance improvements** (PRs #590-591): 122 - - removed moderation service call from `/tracks/` listing endpoint 123 - - removed copyright check from tag listing endpoint 124 - - faster page loads for track feeds 125 - 126 - **moderation agent** (PRs #586, #588): 127 - - added moderation agent script with audit trail support 128 - - improved moderation prompt and UI layout 129 - 130 - **bug fixes** (PRs #589, #592, #593): 131 - - fixed liked state display on playlist detail page 132 - - preserved album track order during ATProto sync 133 - - made header sticky on scroll for better mobile navigation 134 - 135 - **iOS Safari fixes** (PRs #573-576): 136 - - fixed AddToMenu visibility issue on iOS Safari 137 - - menu now correctly opens upward when near viewport bottom 138 - 139 - --- 140 - 141 - #### mobile UI polish & background task expansion (PRs #558-572, Dec 10-12) 142 - 143 - **background task expansion** (PRs #558, #561): 144 - - moved like/unlike and comment PDS writes to docket background tasks 145 - - API responses now immediate; PDS sync happens asynchronously 146 - - added targeted album list sync background task for ATProto record updates 147 - 148 - **performance caching** (PR #566): 149 - - added Redis cache for copyright label lookups (5-minute TTL) 150 - - fixed 2-3s latency spikes on `/tracks/` endpoint 151 - - batch operations via `mget`/pipeline for efficiency 152 - 153 - **mobile UX improvements** (PRs #569, #572): 154 - - mobile action menus now open from top with all actions visible 155 - - UI polish for album and artist pages on small screens 156 - 157 - **misc** (PRs #559, #562, #563, #570): 158 - - reduced docket Redis polling from 250ms to 5s (lower resource usage) 159 - - added atprotofans support link mode for ko-fi integration 160 - - added alpha badge to header branding 161 - - fixed web manifest ID for PWA stability 162 - 163 - --- 164 - 165 - #### confidential OAuth client (PRs #578, #580-582, Dec 12-13) 166 - 167 - **confidential client support** (PR #578): 168 - - implemented ATProto OAuth confidential client using `private_key_jwt` authentication 169 - - when `OAUTH_JWK` is configured, plyr.fm authenticates with a cryptographic key 170 - - confidential clients earn 180-day refresh tokens (vs 2-week for public clients) 171 - - added `/.well-known/jwks.json` endpoint for public key discovery 172 - - updated `/oauth-client-metadata.json` with confidential client fields 173 - 174 - **bug fixes** (PRs #580-582): 175 - - fixed client assertion JWT to use Authorization Server's issuer as `aud` claim (not token endpoint URL) 176 - - fixed JWKS endpoint to preserve `kid` field from original JWK 177 - - fixed `OAuthClient` to pass `client_secret_kid` for JWT header 178 - 179 - **atproto fork updates** (zzstoatzz/atproto#6, #7): 180 - - added `issuer` parameter to `_make_token_request()` for correct `aud` claim 181 - - added `client_secret_kid` parameter to include `kid` in client assertion JWT header 182 - 183 - **outcome**: users now get 180-day refresh tokens, and "remember this account" on the PDS authorization page works (auto-approves subsequent logins). see #583 for future work on account switching via OAuth `prompt` parameter. 184 - 185 - --- 186 - 187 - #### pagination & album management (PRs #550-554, Dec 9-10) 188 - 189 - **tracks list pagination** (PR #554): 190 - - cursor-based pagination on `/tracks/` endpoint (default 50 per page) 191 - - infinite scroll on homepage using native IntersectionObserver 192 - - zero new dependencies - uses browser APIs only 193 - - pagination state persisted to localStorage for fast subsequent loads 194 - 195 - **album management improvements** (PRs #550-552, #557): 196 - - album delete and track reorder fixes 197 - - album page edit mode matching playlist UX (inline title editing, cover upload) 198 - - optimistic UI updates for album title changes (instant feedback) 199 - - ATProto record sync when album title changes (updates all track records + list record) 200 - - fixed album slug sync on rename (prevented duplicate albums when adding tracks) 201 - 202 - **playlist show on profile** (PR #553): 203 - - restored "show on profile" toggle that was lost during inline editing refactor 204 - - users can now control whether playlists appear on their public profile 205 - 206 - --- 207 - 208 - #### public cost dashboard (PRs #548-549, Dec 9) 209 - 210 - - `/costs` page showing live platform infrastructure costs 211 - - daily export to R2 via GitHub Action, proxied through `/stats/costs` endpoint 212 - - dedicated `plyr-stats` R2 bucket with public access (shared across environments) 213 - - includes fly.io, neon, cloudflare, and audd API costs 214 - - ko-fi integration for community support 215 - 216 - #### docket background tasks & concurrent exports (PRs #534-546, Dec 9) 217 - 218 - **docket integration** (PRs #534, #536, #539): 219 - - migrated background tasks from inline asyncio to docket (Redis-backed task queue) 220 - - copyright scanning, media export, ATProto sync, and teal scrobbling now run via docket 221 - - graceful fallback to asyncio for local development without Redis 222 - - parallel test execution with xdist template databases (#540) 223 - 224 - **concurrent export downloads** (PR #545): 225 - - exports now download tracks in parallel (up to 4 concurrent) instead of sequentially 226 - - significantly faster for users with many tracks or large files 227 - - zip creation remains sequential (zipfile constraint) 228 - 229 - **ATProto refactor** (PR #534): 230 - - reorganized ATProto record code into `_internal/atproto/records/` by lexicon namespace 231 - - extracted `client.py` for low-level PDS operations 232 - - cleaner separation between plyr.fm and teal.fm lexicons 233 234 - **documentation & observability**: 235 - - AudD API cost tracking dashboard (#546) 236 - - promoted runbooks from sandbox to `docs/runbooks/` 237 - - updated CLAUDE.md files across the codebase 238 239 --- 240 241 - #### artist support links & inline playlist editing (PRs #520-532, Dec 8) 242 243 - **artist support link** (PR #532): 244 - - artists can set a support URL (Ko-fi, Patreon, etc.) in their portal profile 245 - - support link displays as a button on artist profile pages next to the share button 246 - - URLs validated to require https:// prefix 247 248 - **inline playlist editing** (PR #531): 249 - - edit playlist name and description directly on playlist detail page 250 - - click-to-upload cover art replacement without modal 251 - - cleaner UX - no more edit modal popup 252 253 - **platform stats enhancements** (PRs #522, #528): 254 - - total duration displayed in platform stats (e.g., "42h 15m of music") 255 - - duration shown per artist in analytics section 256 - - combined stats and search into single centered container for cleaner layout 257 - 258 - **navigation & data loading fixes** (PR #527): 259 - - fixed stale data when navigating between detail pages of the same type 260 - - e.g., clicking from one artist to another now properly reloads data 261 - 262 - **copyright moderation improvements** (PR #480): 263 - - enhanced moderation workflow for copyright claims 264 - - improved labeler integration 265 - 266 - **status maintenance workflow** (PR #529): 267 - - automated status maintenance using claude-code-action 268 - - reviews merged PRs and updates STATUS.md narratively 269 270 --- 271 272 - #### playlist fast-follow fixes (PRs #507-519, Dec 7-8) 273 - 274 - **public playlist viewing** (PR #519): 275 - - playlists now publicly viewable without authentication 276 - - ATProto records are public by design - auth was unnecessary for read access 277 - - shared playlist URLs no longer redirect unauthenticated users to homepage 278 279 - **inline playlist creation** (PR #510): 280 - - clicking "create new playlist" from AddToMenu previously navigated to `/library?create=playlist` 281 - - this caused SvelteKit to reinitialize the layout, destroying the audio element and stopping playback 282 - - fix: added inline create form that creates playlist and adds track in one action without navigation 283 - 284 - **UI polish** (PRs #507-509, #515): 285 - - include `image_url` in playlist SSR data for og:image link previews 286 - - invalidate layout data after token exchange - fixes stale auth state after login 287 - - fixed stopPropagation blocking "create new playlist" link clicks 288 - - detail page button layouts: all buttons visible on mobile, centered AddToMenu on track detail 289 - - AddToMenu smart positioning: menu opens upward when near viewport bottom 290 291 - **documentation** (PR #514): 292 - - added lexicons overview documentation at `docs/lexicons/overview.md` 293 - - covers `fm.plyr.track`, `fm.plyr.like`, `fm.plyr.comment`, `fm.plyr.list`, `fm.plyr.actor.profile` 294 295 --- 296 297 - #### playlists, ATProto sync, and library hub (PR #499, Dec 6-7) 298 299 - **playlists** (full CRUD): 300 - - create, rename, delete playlists with cover art upload 301 - - add/remove/reorder tracks with drag-and-drop 302 - - playlist detail page with edit modal 303 - - "add to playlist" menu on tracks with inline create 304 - - playlist sharing with OpenGraph link previews 305 306 - **ATProto integration**: 307 - - `fm.plyr.list` lexicon for syncing playlists/albums to user PDSes 308 - - `fm.plyr.actor.profile` lexicon for artist profiles 309 - - automatic sync of albums, liked tracks, profile on login 310 311 - **library hub** (`/library`): 312 - - unified page with tabs: liked, playlists, albums 313 - - nav changed from "liked" → "library" 314 - 315 - **related**: scope upgrade OAuth flow (PR #503), settings consolidation (PR #496) 316 317 --- 318 319 - #### sensitive image moderation (PRs #471-488, Dec 5-6) 320 321 - - `sensitive_images` table flags problematic images 322 - - `show_sensitive_artwork` user preference 323 - - flagged images blurred everywhere: track lists, player, artist pages, search, embeds 324 - - Media Session API respects sensitive preference 325 - - SSR-safe filtering for og:image link previews 326 327 --- 328 329 - #### teal.fm scrobbling (PR #467, Dec 4) 330 331 - - native scrobbling to user's PDS using teal's ATProto lexicons 332 - - scrobble at 30% or 30 seconds (same threshold as play counts) 333 - - toggle in settings, link to pdsls.dev to view records 334 - 335 - --- 336 337 - ### Earlier December / November 2025 338 339 - See `.status_history/2025-12.md` and `.status_history/2025-11.md` for detailed history including: 340 - - unified search with Cmd+K (PR #447) 341 - - light/dark theme system (PR #441) 342 - - tag filtering and bufo easter egg (PRs #431-438) 343 - developer tokens (PR #367) 344 - copyright moderation system (PRs #382-395) 345 - export & upload reliability (PRs #337-344) ··· 347 348 ## immediate priorities 349 350 ### known issues 351 - playback auto-start on refresh (#225) 352 - iOS PWA audio may hang on first play after backgrounding 353 354 - ### immediate focus 355 - - **moderation cleanup**: consolidate copyright detection, reduce AudD API costs, streamline labeler integration (issues #541-544) 356 - 357 - ### feature ideas 358 - - issue #334: add 'share to bluesky' option for tracks 359 - - issue #373: lyrics field and Genius-style annotations 360 - 361 ### backlog 362 - audio transcoding pipeline integration (#153) - transcoder service deployed, integration deferred 363 364 ## technical state 365 ··· 405 - ✅ copyright moderation with ATProto labeler 406 - ✅ docket background tasks (copyright scan, export, atproto sync, scrobble) 407 - ✅ media export with concurrent downloads 408 409 **albums** 410 - ✅ album CRUD with cover art ··· 436 437 ## cost structure 438 439 - current monthly costs: ~$18/month (plyr.fm specific) 440 441 see live dashboard: [plyr.fm/costs](https://plyr.fm/costs) 442 443 - - fly.io (plyr apps only): ~$12/month 444 - - relay-api (prod): $5.80 445 - - relay-api-staging: $5.60 446 - - plyr-moderation: $0.24 447 - - plyr-transcoder: $0.02 448 - neon postgres: $5/month 449 - - cloudflare (R2 + pages + domain): ~$1.16/month 450 - - audd audio fingerprinting: $0-10/month (6000 free/month) 451 - logfire: $0 (free tier) 452 453 ## admin tooling ··· 498 │ └── src/routes/ # pages 499 ├── moderation/ # Rust moderation service (ATProto labeler) 500 ├── transcoder/ # Rust audio transcoding service 501 ├── docs/ # documentation 502 └── justfile # task runner 503 ``` ··· 513 514 --- 515 516 - this is a living document. last updated 2025-12-19.
··· 47 48 ### December 2025 49 50 + #### self-hosted redis (PR #674-675, Dec 30) 51 52 + **replaced Upstash with self-hosted Redis on Fly.io** - ~$75/month → ~$4/month: 53 + - Upstash pay-as-you-go was charging per command (37M commands = $75) 54 + - self-hosted Redis on 256MB Fly VMs costs fixed ~$2/month per environment 55 + - deployed `plyr-redis` (prod) and `plyr-redis-stg` (staging) 56 + - added CI workflow for redis deployments on merge 57 58 + **no state migration needed** - docket stores ephemeral task queue data, job progress lives in postgres. 59 60 --- 61 62 + #### supporter-gated content (PR #637, Dec 22-23) 63 64 + **atprotofans paywall integration** - artists can now mark tracks as "supporters only": 65 + - tracks with `support_gate` require atprotofans validation before playback 66 + - non-supporters see lock icon and "become a supporter" CTA linking to atprotofans 67 + - artists can always play their own gated tracks 68 69 + **backend architecture**: 70 + - audio endpoint validates supporter status via atprotofans API before serving gated content 71 + - HEAD requests return 200/401/402 for pre-flight auth checks (avoids CORS issues) 72 + - `R2Storage.move_audio()` moves files between public/private buckets when toggling gate 73 + - background task handles bucket migration asynchronously 74 + - ATProto record syncs when toggling gate (updates `supportGate` field and `audioUrl`) 75 76 + **frontend**: 77 + - `playback.svelte.ts` guards queue operations with gated checks BEFORE modifying state 78 + - clicking locked track shows toast with CTA - does NOT interrupt current playback 79 + - portal shows support gate toggle in track edit UI 80 81 --- 82 83 + #### supporter badges (PR #627, Dec 21-22) 84 85 + **phase 1 of atprotofans integration**: 86 + - supporter badge displays on artist pages when logged-in viewer supports the artist 87 + - calls atprotofans `validateSupporter` API directly from frontend (public endpoint) 88 + - badge only shows when viewer is authenticated and not viewing their own profile 89 90 --- 91 92 + #### rate limit moderation endpoint (PR #629, Dec 21) 93 94 + **incident response**: detected suspicious activity - 72 requests in 17 seconds from a single IP targeting `/moderation/sensitive-images`. added `10/minute` rate limit using existing slowapi infrastructure. 95 96 --- 97 98 + #### end-of-year sprint planning (PR #626, Dec 20) 99 100 + **focus**: two foundational systems need solid experimental implementations by 2026. 101 102 + | track | focus | status | 103 + |-------|-------|--------| 104 + | moderation | consolidate architecture, add rules engine | in progress | 105 + | atprotofans | supporter validation, content gating | shipped (phase 1-3) | 106 107 + **research docs**: 108 + - [moderation architecture overhaul](docs/research/2025-12-20-moderation-architecture-overhaul.md) 109 + - [atprotofans paywall integration](docs/research/2025-12-20-atprotofans-paywall-integration.md) 110 111 --- 112 113 + #### beartype + moderation cleanup (PRs #617-619, Dec 19) 114 115 + **runtime type checking** (PR #619): 116 + - enabled beartype runtime type validation across the backend 117 + - catches type errors at runtime instead of silently passing bad data 118 + - test infrastructure improvements: session-scoped TestClient fixture (5x faster tests) 119 120 + **moderation cleanup** (PRs #617-618): 121 + - consolidated moderation code, addressing issues #541-543 122 + - `sync_copyright_resolutions` now runs automatically via docket Perpetual task 123 + - removed dead `init_db()` from lifespan (handled by alembic migrations) 124 125 --- 126 127 + #### UX polish (PRs #604-607, #613, #615, Dec 16-18) 128 129 + **login improvements** (PRs #604, #613): 130 + - login page now uses "internet handle" terminology for clarity 131 + - input normalization: strips `@` and `at://` prefixes automatically 132 133 + **artist page fixes** (PR #615): 134 + - track pagination on artist pages now works correctly 135 + - fixed mobile album card overflow 136 137 + **mobile + metadata** (PRs #605-607): 138 + - Open Graph tags added to tag detail pages for link previews 139 + - mobile modals now use full screen positioning 140 + - fixed `/tag/` routes in hasPageMetadata check 141 142 --- 143 144 + #### offline mode foundation (PRs #610-611, Dec 17) 145 146 + **experimental offline playback**: 147 + - storage layer using Cache API for audio bytes + IndexedDB for metadata 148 + - `GET /audio/{file_id}/url` backend endpoint returns direct R2 URLs for client-side caching 149 + - "auto-download liked" toggle in experimental settings section 150 + - Player checks for cached audio before streaming from R2 151 152 --- 153 154 + ### Earlier December 2025 155 156 + See `.status_history/2025-12.md` for detailed history including: 157 + - visual customization with custom backgrounds (PRs #595-596, Dec 16) 158 + - performance & moderation polish (PRs #586-593, Dec 14-15) 159 + - mobile UI polish & background task expansion (PRs #558-572, Dec 10-12) 160 + - confidential OAuth client for 180-day sessions (PRs #578-582, Dec 12-13) 161 + - pagination & album management (PRs #550-554, Dec 9-10) 162 + - public cost dashboard (PRs #548-549, Dec 9) 163 + - docket background tasks & concurrent exports (PRs #534-546, Dec 9) 164 + - artist support links & inline playlist editing (PRs #520-532, Dec 8) 165 + - playlist fast-follow fixes (PRs #507-519, Dec 7-8) 166 + - playlists, ATProto sync, and library hub (PR #499, Dec 6-7) 167 + - sensitive image moderation (PRs #471-488, Dec 5-6) 168 + - teal.fm scrobbling (PR #467, Dec 4) 169 + - unified search with Cmd+K (PR #447, Dec 3) 170 + - light/dark theme system (PR #441, Dec 2-3) 171 + - tag filtering and bufo easter egg (PRs #431-438, Dec 2) 172 173 + ### November 2025 174 175 + See `.status_history/2025-11.md` for detailed history including: 176 - developer tokens (PR #367) 177 - copyright moderation system (PRs #382-395) 178 - export & upload reliability (PRs #337-344) ··· 180 181 ## immediate priorities 182 183 + ### quality of life mode (Dec 29-31) 184 + 185 + end-of-year sprint [#625](https://github.com/zzstoatzz/plyr.fm/issues/625) complete. remaining days before 2026 are for minor polish and bug fixes as they arise. 186 + 187 + **what shipped in the sprint:** 188 + - moderation consolidation: sensitive images moved to moderation service (#644) 189 + - atprotofans: supporter badges (#627) and content gating (#637) 190 + 191 + **aspirational (deferred until scale justifies):** 192 + - configurable rules engine for moderation 193 + - time-release gating (#642) 194 + 195 ### known issues 196 - playback auto-start on refresh (#225) 197 - iOS PWA audio may hang on first play after backgrounding 198 199 ### backlog 200 - audio transcoding pipeline integration (#153) - transcoder service deployed, integration deferred 201 + - share to bluesky (#334) 202 + - lyrics and annotations (#373) 203 204 ## technical state 205 ··· 245 - ✅ copyright moderation with ATProto labeler 246 - ✅ docket background tasks (copyright scan, export, atproto sync, scrobble) 247 - ✅ media export with concurrent downloads 248 + - ✅ supporter-gated content via atprotofans 249 250 **albums** 251 - ✅ album CRUD with cover art ··· 277 278 ## cost structure 279 280 + current monthly costs: ~$20/month (plyr.fm specific) 281 282 see live dashboard: [plyr.fm/costs](https://plyr.fm/costs) 283 284 + - fly.io (backend + redis + moderation): ~$14/month 285 - neon postgres: $5/month 286 + - cloudflare (R2 + pages + domain): ~$1/month 287 + - audd audio fingerprinting: $5-10/month (usage-based) 288 - logfire: $0 (free tier) 289 290 ## admin tooling ··· 335 │ └── src/routes/ # pages 336 ├── moderation/ # Rust moderation service (ATProto labeler) 337 ├── transcoder/ # Rust audio transcoding service 338 + ├── redis/ # self-hosted Redis config 339 ├── docs/ # documentation 340 └── justfile # task runner 341 ``` ··· 351 352 --- 353 354 + this is a living document. last updated 2025-12-30.
+1
backend/.env.example
··· 32 R2_ENDPOINT_URL=https://8feb33b5fb57ce2bc093bc6f4141f40a.r2.cloudflarestorage.com 33 R2_PUBLIC_BUCKET_URL=https://pub-154b70b3121149eda0cf1ccbae78cb33.r2.dev 34 R2_PUBLIC_IMAGE_BUCKET_URL=https://pub-154b70b3121149eda0cf1ccbae78cb33.r2.dev 35 MAX_UPLOAD_SIZE_MB=1536 # max audio upload size (default: 1536MB / 1.5GB - supports 2-hour WAV) 36 37 # atproto
··· 32 R2_ENDPOINT_URL=https://8feb33b5fb57ce2bc093bc6f4141f40a.r2.cloudflarestorage.com 33 R2_PUBLIC_BUCKET_URL=https://pub-154b70b3121149eda0cf1ccbae78cb33.r2.dev 34 R2_PUBLIC_IMAGE_BUCKET_URL=https://pub-154b70b3121149eda0cf1ccbae78cb33.r2.dev 35 + R2_PRIVATE_BUCKET=audio-private-dev # private bucket for supporter-gated audio 36 MAX_UPLOAD_SIZE_MB=1536 # max audio upload size (default: 1536MB / 1.5GB - supports 2-hour WAV) 37 38 # atproto
+37
backend/alembic/versions/2025_12_22_190115_9ee155c078ed_add_support_gate_to_tracks.py
···
··· 1 + """add support_gate to tracks 2 + 3 + Revision ID: 9ee155c078ed 4 + Revises: f2380236c97b 5 + Create Date: 2025-12-22 19:01:15.063270 6 + 7 + """ 8 + 9 + from collections.abc import Sequence 10 + 11 + import sqlalchemy as sa 12 + from sqlalchemy.dialects import postgresql 13 + 14 + from alembic import op 15 + 16 + # revision identifiers, used by Alembic. 17 + revision: str = "9ee155c078ed" 18 + down_revision: str | Sequence[str] | None = "f2380236c97b" 19 + branch_labels: str | Sequence[str] | None = None 20 + depends_on: str | Sequence[str] | None = None 21 + 22 + 23 + def upgrade() -> None: 24 + """Add support_gate column for supporter-gated content.""" 25 + op.add_column( 26 + "tracks", 27 + sa.Column( 28 + "support_gate", 29 + postgresql.JSONB(astext_type=sa.Text()), 30 + nullable=True, 31 + ), 32 + ) 33 + 34 + 35 + def downgrade() -> None: 36 + """Remove support_gate column.""" 37 + op.drop_column("tracks", "support_gate")
+1 -1
backend/fly.staging.toml
··· 44 # - AWS_ACCESS_KEY_ID (cloudflare R2) 45 # - AWS_SECRET_ACCESS_KEY (cloudflare R2) 46 # - OAUTH_ENCRYPTION_KEY (generate: python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())') 47 - # - DOCKET_URL (upstash redis: rediss://default:xxx@xxx.upstash.io:6379)
··· 44 # - AWS_ACCESS_KEY_ID (cloudflare R2) 45 # - AWS_SECRET_ACCESS_KEY (cloudflare R2) 46 # - OAUTH_ENCRYPTION_KEY (generate: python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())') 47 + # - DOCKET_URL (self-hosted redis: redis://plyr-redis-stg.internal:6379)
+1 -1
backend/fly.toml
··· 39 # - AWS_ACCESS_KEY_ID (cloudflare R2) 40 # - AWS_SECRET_ACCESS_KEY (cloudflare R2) 41 # - OAUTH_ENCRYPTION_KEY (generate: python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())') 42 - # - DOCKET_URL (upstash redis: rediss://default:xxx@xxx.upstash.io:6379)
··· 39 # - AWS_ACCESS_KEY_ID (cloudflare R2) 40 # - AWS_SECRET_ACCESS_KEY (cloudflare R2) 41 # - OAUTH_ENCRYPTION_KEY (generate: python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())') 42 + # - DOCKET_URL (self-hosted redis: redis://plyr-redis.internal:6379)
+3
backend/src/backend/_internal/__init__.py
··· 32 from backend._internal.notifications import notification_service 33 from backend._internal.now_playing import now_playing_service 34 from backend._internal.queue import queue_service 35 36 __all__ = [ 37 "DeveloperToken", ··· 51 "get_pending_dev_token", 52 "get_pending_scope_upgrade", 53 "get_session", 54 "handle_oauth_callback", 55 "list_developer_tokens", 56 "notification_service", ··· 64 "start_oauth_flow", 65 "start_oauth_flow_with_scopes", 66 "update_session_tokens", 67 ]
··· 32 from backend._internal.notifications import notification_service 33 from backend._internal.now_playing import now_playing_service 34 from backend._internal.queue import queue_service 35 + from backend._internal.atprotofans import get_supported_artists, validate_supporter 36 37 __all__ = [ 38 "DeveloperToken", ··· 52 "get_pending_dev_token", 53 "get_pending_scope_upgrade", 54 "get_session", 55 + "get_supported_artists", 56 "handle_oauth_callback", 57 "list_developer_tokens", 58 "notification_service", ··· 66 "start_oauth_flow", 67 "start_oauth_flow_with_scopes", 68 "update_session_tokens", 69 + "validate_supporter", 70 ]
+9 -2
backend/src/backend/_internal/atproto/records/fm_plyr/track.py
··· 20 duration: int | None = None, 21 features: list[dict] | None = None, 22 image_url: str | None = None, 23 ) -> dict[str, Any]: 24 """Build a track record dict for ATProto. 25 26 args: 27 title: track title 28 artist: artist name 29 - audio_url: R2 URL for audio file 30 file_type: file extension (mp3, wav, etc) 31 album: optional album name 32 duration: optional duration in seconds 33 features: optional list of featured artists [{did, handle, display_name, avatar_url}] 34 image_url: optional cover art image URL 35 36 returns: 37 record dict ready for ATProto ··· 64 # validate image URL comes from allowed origin 65 settings.storage.validate_image_url(image_url) 66 record["imageUrl"] = image_url 67 68 return record 69 ··· 78 duration: int | None = None, 79 features: list[dict] | None = None, 80 image_url: str | None = None, 81 ) -> tuple[str, str]: 82 """Create a track record on the user's PDS using the configured collection. 83 ··· 85 auth_session: authenticated user session 86 title: track title 87 artist: artist name 88 - audio_url: R2 URL for audio file 89 file_type: file extension (mp3, wav, etc) 90 album: optional album name 91 duration: optional duration in seconds 92 features: optional list of featured artists [{did, handle, display_name, avatar_url}] 93 image_url: optional cover art image URL 94 95 returns: 96 tuple of (record_uri, record_cid) ··· 108 duration=duration, 109 features=features, 110 image_url=image_url, 111 ) 112 113 payload = {
··· 20 duration: int | None = None, 21 features: list[dict] | None = None, 22 image_url: str | None = None, 23 + support_gate: dict | None = None, 24 ) -> dict[str, Any]: 25 """Build a track record dict for ATProto. 26 27 args: 28 title: track title 29 artist: artist name 30 + audio_url: R2 URL for audio file (placeholder for gated tracks) 31 file_type: file extension (mp3, wav, etc) 32 album: optional album name 33 duration: optional duration in seconds 34 features: optional list of featured artists [{did, handle, display_name, avatar_url}] 35 image_url: optional cover art image URL 36 + support_gate: optional gating config (e.g., {"type": "any"}) 37 38 returns: 39 record dict ready for ATProto ··· 66 # validate image URL comes from allowed origin 67 settings.storage.validate_image_url(image_url) 68 record["imageUrl"] = image_url 69 + if support_gate: 70 + record["supportGate"] = support_gate 71 72 return record 73 ··· 82 duration: int | None = None, 83 features: list[dict] | None = None, 84 image_url: str | None = None, 85 + support_gate: dict | None = None, 86 ) -> tuple[str, str]: 87 """Create a track record on the user's PDS using the configured collection. 88 ··· 90 auth_session: authenticated user session 91 title: track title 92 artist: artist name 93 + audio_url: R2 URL for audio file (placeholder URL for gated tracks) 94 file_type: file extension (mp3, wav, etc) 95 album: optional album name 96 duration: optional duration in seconds 97 features: optional list of featured artists [{did, handle, display_name, avatar_url}] 98 image_url: optional cover art image URL 99 + support_gate: optional gating config (e.g., {"type": "any"}) 100 101 returns: 102 tuple of (record_uri, record_cid) ··· 114 duration=duration, 115 features=features, 116 image_url=image_url, 117 + support_gate=support_gate, 118 ) 119 120 payload = {
+121
backend/src/backend/_internal/atprotofans.py
···
··· 1 + """atprotofans integration for supporter validation. 2 + 3 + atprotofans is a creator support platform on ATProto. this module provides 4 + server-side validation of supporter relationships for content gating. 5 + 6 + the validation uses the three-party model: 7 + - supporter: has com.atprotofans.supporter record in their PDS 8 + - creator: has com.atprotofans.supporterProof record in their PDS 9 + - broker: has com.atprotofans.brokerProof record (atprotofans service) 10 + 11 + for direct atprotofans contributions (not via platform registration), 12 + the signer is the artist's own DID. 13 + 14 + see: https://atprotofans.leaflet.pub/3mabsmts3rs2b 15 + """ 16 + 17 + import asyncio 18 + 19 + import httpx 20 + import logfire 21 + from pydantic import BaseModel 22 + 23 + 24 + class SupporterValidation(BaseModel): 25 + """result of validating supporter status.""" 26 + 27 + valid: bool 28 + profile: dict | None = None 29 + 30 + 31 + async def validate_supporter( 32 + supporter_did: str, 33 + artist_did: str, 34 + timeout: float = 5.0, 35 + ) -> SupporterValidation: 36 + """validate if a user supports an artist via atprotofans. 37 + 38 + for direct atprotofans contributions, the signer is the artist's DID. 39 + 40 + args: 41 + supporter_did: DID of the potential supporter 42 + artist_did: DID of the artist (also used as signer) 43 + timeout: request timeout in seconds 44 + 45 + returns: 46 + SupporterValidation with valid=True if supporter, valid=False otherwise 47 + """ 48 + url = "https://atprotofans.com/xrpc/com.atprotofans.validateSupporter" 49 + params = { 50 + "supporter": supporter_did, 51 + "subject": artist_did, 52 + "signer": artist_did, # for direct contributions, signer = artist 53 + } 54 + 55 + with logfire.span( 56 + "atprotofans.validate_supporter", 57 + supporter_did=supporter_did, 58 + artist_did=artist_did, 59 + ): 60 + try: 61 + async with httpx.AsyncClient(timeout=timeout) as client: 62 + response = await client.get(url, params=params) 63 + 64 + if response.status_code != 200: 65 + logfire.warn( 66 + "atprotofans validation failed", 67 + status_code=response.status_code, 68 + response_text=response.text[:200], 69 + ) 70 + return SupporterValidation(valid=False) 71 + 72 + data = response.json() 73 + is_valid = data.get("valid", False) 74 + 75 + logfire.info( 76 + "atprotofans validation result", 77 + valid=is_valid, 78 + has_profile=data.get("profile") is not None, 79 + ) 80 + 81 + return SupporterValidation( 82 + valid=is_valid, 83 + profile=data.get("profile"), 84 + ) 85 + 86 + except httpx.TimeoutException: 87 + logfire.warn("atprotofans validation timeout") 88 + return SupporterValidation(valid=False) 89 + except Exception as e: 90 + logfire.error( 91 + "atprotofans validation error", 92 + error=str(e), 93 + exc_info=True, 94 + ) 95 + return SupporterValidation(valid=False) 96 + 97 + 98 + async def get_supported_artists( 99 + supporter_did: str, 100 + artist_dids: set[str], 101 + timeout: float = 5.0, 102 + ) -> set[str]: 103 + """batch check which artists a user supports. 104 + 105 + args: 106 + supporter_did: DID of the potential supporter 107 + artist_dids: set of artist DIDs to check 108 + timeout: request timeout per check 109 + 110 + returns: 111 + set of artist DIDs the user supports 112 + """ 113 + if not artist_dids: 114 + return set() 115 + 116 + async def check_one(artist_did: str) -> str | None: 117 + result = await validate_supporter(supporter_did, artist_did, timeout) 118 + return artist_did if result.valid else None 119 + 120 + results = await asyncio.gather(*[check_one(did) for did in artist_dids]) 121 + return {did for did in results if did is not None}
+2
backend/src/backend/_internal/background.py
··· 55 extra={"docket_name": settings.docket.name, "url": settings.docket.url}, 56 ) 57 58 async with Docket( 59 name=settings.docket.name, 60 url=settings.docket.url,
··· 55 extra={"docket_name": settings.docket.name, "url": settings.docket.url}, 56 ) 57 58 + # WARNING: do not modify Docket() or Worker() constructor args without 59 + # reading docs/backend/background-tasks.md - see 2025-12-30 incident 60 async with Docket( 61 name=settings.docket.name, 62 url=settings.docket.url,
+59 -2
backend/src/backend/_internal/background_tasks.py
··· 281 export_id, 282 JobStatus.PROCESSING, 283 f"downloading {total} tracks...", 284 - progress_pct=0, 285 result={"processed_count": 0, "total_count": total}, 286 ) 287 ··· 304 export_id, 305 JobStatus.PROCESSING, 306 "creating zip archive...", 307 - progress_pct=100, 308 result={ 309 "processed_count": len(successful_downloads), 310 "total_count": total, ··· 865 logfire.info("scheduled pds comment update", comment_id=comment_id) 866 867 868 # collection of all background task functions for docket registration 869 background_tasks = [ 870 scan_copyright, ··· 878 pds_create_comment, 879 pds_delete_comment, 880 pds_update_comment, 881 ]
··· 281 export_id, 282 JobStatus.PROCESSING, 283 f"downloading {total} tracks...", 284 + progress_pct=0.0, 285 result={"processed_count": 0, "total_count": total}, 286 ) 287 ··· 304 export_id, 305 JobStatus.PROCESSING, 306 "creating zip archive...", 307 + progress_pct=100.0, 308 result={ 309 "processed_count": len(successful_downloads), 310 "total_count": total, ··· 865 logfire.info("scheduled pds comment update", comment_id=comment_id) 866 867 868 + async def move_track_audio(track_id: int, to_private: bool) -> None: 869 + """move a track's audio file between public and private buckets. 870 + 871 + called when support_gate is toggled on an existing track. 872 + 873 + args: 874 + track_id: database ID of the track 875 + to_private: if True, move to private bucket; if False, move to public 876 + """ 877 + from backend.models import Track 878 + from backend.storage import storage 879 + 880 + async with db_session() as db: 881 + result = await db.execute(select(Track).where(Track.id == track_id)) 882 + track = result.scalar_one_or_none() 883 + 884 + if not track: 885 + logger.warning(f"move_track_audio: track {track_id} not found") 886 + return 887 + 888 + if not track.file_id or not track.file_type: 889 + logger.warning( 890 + f"move_track_audio: track {track_id} missing file_id/file_type" 891 + ) 892 + return 893 + 894 + result_url = await storage.move_audio( 895 + file_id=track.file_id, 896 + extension=track.file_type, 897 + to_private=to_private, 898 + ) 899 + 900 + # update r2_url: None for private, public URL for public 901 + if to_private: 902 + # moved to private - result_url is None on success, None on failure 903 + # we check by verifying the file was actually moved (no error logged) 904 + track.r2_url = None 905 + await db.commit() 906 + logger.info(f"moved track {track_id} to private bucket") 907 + elif result_url: 908 + # moved to public - result_url is the public URL 909 + track.r2_url = result_url 910 + await db.commit() 911 + logger.info(f"moved track {track_id} to public bucket") 912 + else: 913 + logger.error(f"failed to move track {track_id}") 914 + 915 + 916 + async def schedule_move_track_audio(track_id: int, to_private: bool) -> None: 917 + """schedule a track audio move via docket.""" 918 + docket = get_docket() 919 + await docket.add(move_track_audio)(track_id, to_private) 920 + direction = "private" if to_private else "public" 921 + logfire.info(f"scheduled track audio move to {direction}", track_id=track_id) 922 + 923 + 924 # collection of all background task functions for docket registration 925 background_tasks = [ 926 scan_copyright, ··· 934 pds_create_comment, 935 pds_delete_comment, 936 pds_update_comment, 937 + move_track_audio, 938 ]
+31
backend/src/backend/_internal/moderation_client.py
··· 35 error: str | None = None 36 37 38 class ModerationClient: 39 """client for the plyr.fm moderation service. 40 ··· 152 except Exception as e: 153 logger.warning("failed to emit copyright label for %s: %s", uri, e) 154 return EmitLabelResult(success=False, error=str(e)) 155 156 async def get_active_labels(self, uris: list[str]) -> set[str]: 157 """check which URIs have active (non-negated) copyright-violation labels.
··· 35 error: str | None = None 36 37 38 + @dataclass 39 + class SensitiveImagesResult: 40 + """result from fetching sensitive images.""" 41 + 42 + image_ids: list[str] 43 + urls: list[str] 44 + 45 + 46 class ModerationClient: 47 """client for the plyr.fm moderation service. 48 ··· 160 except Exception as e: 161 logger.warning("failed to emit copyright label for %s: %s", uri, e) 162 return EmitLabelResult(success=False, error=str(e)) 163 + 164 + async def get_sensitive_images(self) -> SensitiveImagesResult: 165 + """fetch all sensitive images from the moderation service. 166 + 167 + returns: 168 + SensitiveImagesResult with image_ids and urls 169 + 170 + raises: 171 + httpx.HTTPStatusError: on non-2xx response 172 + httpx.TimeoutException: on timeout 173 + """ 174 + async with httpx.AsyncClient(timeout=self.timeout) as client: 175 + response = await client.get( 176 + f"{self.labeler_url}/sensitive-images", 177 + # no auth required for this public endpoint 178 + ) 179 + response.raise_for_status() 180 + data = response.json() 181 + 182 + return SensitiveImagesResult( 183 + image_ids=data.get("image_ids", []), 184 + urls=data.get("urls", []), 185 + ) 186 187 async def get_active_labels(self, uris: list[str]) -> set[str]: 188 """check which URIs have active (non-negated) copyright-violation labels.
+127 -19
backend/src/backend/api/audio.py
··· 1 """audio streaming endpoint.""" 2 3 import logfire 4 - from fastapi import APIRouter, Depends, HTTPException 5 from fastapi.responses import RedirectResponse 6 from pydantic import BaseModel 7 from sqlalchemy import func, select 8 9 - from backend._internal import Session, require_auth 10 from backend.models import Track 11 from backend.storage import storage 12 from backend.utilities.database import db_session ··· 22 file_type: str | None 23 24 25 @router.get("/{file_id}") 26 - async def stream_audio(file_id: str): 27 """stream audio file by redirecting to R2 CDN URL. 28 29 - looks up track to get cached r2_url and file extension, 30 - eliminating the need to probe multiple formats. 31 32 images are served directly via R2 URLs stored in the image_url field, 33 not through this endpoint. 34 """ 35 - # look up track to get r2_url and file_type 36 async with db_session() as db: 37 # check for duplicates (multiple tracks with same file_id) 38 count_result = await db.execute( ··· 50 count=count, 51 ) 52 53 - # get the best track: prefer non-null r2_url, then newest 54 result = await db.execute( 55 - select(Track.r2_url, Track.file_type) 56 .where(Track.file_id == file_id) 57 .order_by(Track.r2_url.is_not(None).desc(), Track.created_at.desc()) 58 .limit(1) 59 ) 60 track_data = result.first() 61 62 - r2_url, file_type = track_data 63 64 - # if we have a valid r2_url cached, use it directly (zero HEADs) 65 if r2_url and r2_url.startswith("http"): 66 return RedirectResponse(url=r2_url) 67 ··· 72 return RedirectResponse(url=url) 73 74 75 @router.get("/{file_id}/url") 76 async def get_audio_url( 77 file_id: str, 78 - session: Session = Depends(require_auth), 79 ) -> AudioUrlResponse: 80 - """return direct R2 URL for offline caching. 81 82 - unlike the streaming endpoint which returns a 307 redirect, 83 - this returns the URL as JSON so the frontend can fetch and 84 - cache the audio directly via the Cache API. 85 86 - used for offline mode - frontend fetches from R2 and stores locally. 87 """ 88 async with db_session() as db: 89 result = await db.execute( 90 - select(Track.r2_url, Track.file_type) 91 .where(Track.file_id == file_id) 92 .order_by(Track.r2_url.is_not(None).desc(), Track.created_at.desc()) 93 .limit(1) ··· 97 if not track_data: 98 raise HTTPException(status_code=404, detail="audio file not found") 99 100 - r2_url, file_type = track_data 101 102 - # if we have a cached r2_url, return it 103 if r2_url and r2_url.startswith("http"): 104 return AudioUrlResponse(url=r2_url, file_id=file_id, file_type=file_type) 105
··· 1 """audio streaming endpoint.""" 2 3 import logfire 4 + from fastapi import APIRouter, Depends, HTTPException, Request, Response 5 from fastapi.responses import RedirectResponse 6 from pydantic import BaseModel 7 from sqlalchemy import func, select 8 9 + from backend._internal import Session, get_optional_session, validate_supporter 10 from backend.models import Track 11 from backend.storage import storage 12 from backend.utilities.database import db_session ··· 22 file_type: str | None 23 24 25 + @router.head("/{file_id}") 26 @router.get("/{file_id}") 27 + async def stream_audio( 28 + file_id: str, 29 + request: Request, 30 + session: Session | None = Depends(get_optional_session), 31 + ): 32 """stream audio file by redirecting to R2 CDN URL. 33 34 + for public tracks: redirects to R2 CDN URL. 35 + for gated tracks: validates supporter status and returns presigned URL. 36 + 37 + HEAD requests are used for pre-flight auth checks - they return 38 + 200/401/402 status without redirecting to avoid CORS issues. 39 40 images are served directly via R2 URLs stored in the image_url field, 41 not through this endpoint. 42 """ 43 + is_head_request = request.method == "HEAD" 44 + # look up track to get r2_url, file_type, support_gate, and artist_did 45 async with db_session() as db: 46 # check for duplicates (multiple tracks with same file_id) 47 count_result = await db.execute( ··· 59 count=count, 60 ) 61 62 + # get the track with gating info 63 result = await db.execute( 64 + select(Track.r2_url, Track.file_type, Track.support_gate, Track.artist_did) 65 .where(Track.file_id == file_id) 66 .order_by(Track.r2_url.is_not(None).desc(), Track.created_at.desc()) 67 .limit(1) 68 ) 69 track_data = result.first() 70 + r2_url, file_type, support_gate, artist_did = track_data 71 72 + # check if track is gated 73 + if support_gate is not None: 74 + return await _handle_gated_audio( 75 + file_id=file_id, 76 + file_type=file_type, 77 + artist_did=artist_did, 78 + session=session, 79 + is_head_request=is_head_request, 80 + ) 81 82 + # public track - use cached r2_url if available 83 if r2_url and r2_url.startswith("http"): 84 return RedirectResponse(url=r2_url) 85 ··· 90 return RedirectResponse(url=url) 91 92 93 + async def _handle_gated_audio( 94 + file_id: str, 95 + file_type: str, 96 + artist_did: str, 97 + session: Session | None, 98 + is_head_request: bool = False, 99 + ) -> RedirectResponse | Response: 100 + """handle streaming for supporter-gated content. 101 + 102 + validates that the user is authenticated and either: 103 + - is the artist who uploaded the track, OR 104 + - supports the artist via atprotofans 105 + before returning a presigned URL for the private bucket. 106 + 107 + for HEAD requests (used for pre-flight auth checks), returns 200 status 108 + without redirecting to avoid CORS issues with cross-origin redirects. 109 + """ 110 + # must be authenticated to access gated content 111 + if not session: 112 + raise HTTPException( 113 + status_code=401, 114 + detail="authentication required for supporter-gated content", 115 + ) 116 + 117 + # artist can always play their own gated tracks 118 + if session.did == artist_did: 119 + logfire.info( 120 + "serving gated content to owner", 121 + file_id=file_id, 122 + artist_did=artist_did, 123 + ) 124 + else: 125 + # validate supporter status via atprotofans 126 + validation = await validate_supporter( 127 + supporter_did=session.did, 128 + artist_did=artist_did, 129 + ) 130 + 131 + if not validation.valid: 132 + raise HTTPException( 133 + status_code=402, 134 + detail="this track requires supporter access", 135 + headers={"X-Support-Required": "true"}, 136 + ) 137 + 138 + # for HEAD requests, just return 200 to confirm access 139 + # (avoids CORS issues with cross-origin redirects) 140 + if is_head_request: 141 + return Response(status_code=200) 142 + 143 + # authorized - generate presigned URL for private bucket 144 + if session.did != artist_did: 145 + logfire.info( 146 + "serving gated content to supporter", 147 + file_id=file_id, 148 + supporter_did=session.did, 149 + artist_did=artist_did, 150 + ) 151 + 152 + url = await storage.generate_presigned_url(file_id=file_id, extension=file_type) 153 + return RedirectResponse(url=url) 154 + 155 + 156 @router.get("/{file_id}/url") 157 async def get_audio_url( 158 file_id: str, 159 + session: Session | None = Depends(get_optional_session), 160 ) -> AudioUrlResponse: 161 + """return direct URL for audio file. 162 163 + for public tracks: returns R2 CDN URL for offline caching. 164 + for gated tracks: returns presigned URL after supporter validation. 165 166 + used for offline mode - frontend fetches and caches locally. 167 """ 168 async with db_session() as db: 169 result = await db.execute( 170 + select(Track.r2_url, Track.file_type, Track.support_gate, Track.artist_did) 171 .where(Track.file_id == file_id) 172 .order_by(Track.r2_url.is_not(None).desc(), Track.created_at.desc()) 173 .limit(1) ··· 177 if not track_data: 178 raise HTTPException(status_code=404, detail="audio file not found") 179 180 + r2_url, file_type, support_gate, artist_did = track_data 181 182 + # check if track is gated 183 + if support_gate is not None: 184 + # must be authenticated 185 + if not session: 186 + raise HTTPException( 187 + status_code=401, 188 + detail="authentication required for supporter-gated content", 189 + ) 190 + 191 + # artist can always access their own gated tracks 192 + if session.did != artist_did: 193 + # validate supporter status 194 + validation = await validate_supporter( 195 + supporter_did=session.did, 196 + artist_did=artist_did, 197 + ) 198 + 199 + if not validation.valid: 200 + raise HTTPException( 201 + status_code=402, 202 + detail="this track requires supporter access", 203 + headers={"X-Support-Required": "true"}, 204 + ) 205 + 206 + # return presigned URL 207 + url = await storage.generate_presigned_url(file_id=file_id, extension=file_type) 208 + return AudioUrlResponse(url=url, file_id=file_id, file_type=file_type) 209 + 210 + # public track - return cached r2_url if available 211 if r2_url and r2_url.startswith("http"): 212 return AudioUrlResponse(url=r2_url, file_id=file_id, file_type=file_type) 213
+17 -12
backend/src/backend/api/moderation.py
··· 1 """content moderation api endpoints.""" 2 3 - from typing import Annotated 4 5 - from fastapi import APIRouter, Depends 6 from pydantic import BaseModel 7 - from sqlalchemy import select 8 - from sqlalchemy.ext.asyncio import AsyncSession 9 10 - from backend.models import SensitiveImage, get_db 11 12 router = APIRouter(prefix="/moderation", tags=["moderation"]) 13 ··· 22 23 24 @router.get("/sensitive-images") 25 async def get_sensitive_images( 26 - db: Annotated[AsyncSession, Depends(get_db)], 27 ) -> SensitiveImagesResponse: 28 """get all flagged sensitive images. 29 30 returns both image_ids (for R2-stored images) and full URLs 31 (for external images like avatars). clients should check both. 32 """ 33 - result = await db.execute(select(SensitiveImage)) 34 - images = result.scalars().all() 35 - 36 - image_ids = [img.image_id for img in images if img.image_id] 37 - urls = [img.url for img in images if img.url] 38 39 - return SensitiveImagesResponse(image_ids=image_ids, urls=urls)
··· 1 """content moderation api endpoints.""" 2 3 + import logging 4 5 + from fastapi import APIRouter, Request 6 from pydantic import BaseModel 7 + 8 + from backend._internal.moderation_client import get_moderation_client 9 + from backend.utilities.rate_limit import limiter 10 11 + logger = logging.getLogger(__name__) 12 13 router = APIRouter(prefix="/moderation", tags=["moderation"]) 14 ··· 23 24 25 @router.get("/sensitive-images") 26 + @limiter.limit("10/minute") 27 async def get_sensitive_images( 28 + request: Request, 29 ) -> SensitiveImagesResponse: 30 """get all flagged sensitive images. 31 32 + proxies to the moderation service which is the source of truth 33 + for sensitive image data. 34 + 35 returns both image_ids (for R2-stored images) and full URLs 36 (for external images like avatars). clients should check both. 37 """ 38 + client = get_moderation_client() 39 + result = await client.get_sensitive_images() 40 41 + return SensitiveImagesResponse( 42 + image_ids=result.image_ids, 43 + urls=result.urls, 44 + )
+18 -1
backend/src/backend/api/tracks/listing.py
··· 12 from sqlalchemy.orm import selectinload 13 14 from backend._internal import Session as AuthSession 15 - from backend._internal import get_optional_session, require_auth 16 from backend.config import settings 17 from backend.models import ( 18 Artist, ··· 233 await asyncio.gather(*[resolve_image(t) for t in tracks_needing_images]) 234 await db.commit() 235 236 # fetch all track responses concurrently with like status and counts 237 track_responses = await asyncio.gather( 238 *[ ··· 243 like_counts, 244 comment_counts, 245 track_tags=track_tags, 246 ) 247 for track in tracks 248 ]
··· 12 from sqlalchemy.orm import selectinload 13 14 from backend._internal import Session as AuthSession 15 + from backend._internal import get_optional_session, get_supported_artists, require_auth 16 from backend.config import settings 17 from backend.models import ( 18 Artist, ··· 233 await asyncio.gather(*[resolve_image(t) for t in tracks_needing_images]) 234 await db.commit() 235 236 + # resolve supporter status for gated content 237 + viewer_did = session.did if session else None 238 + supported_artist_dids: set[str] = set() 239 + if viewer_did: 240 + # collect artist DIDs with gated tracks (excluding viewer's own tracks) 241 + gated_artist_dids = { 242 + t.artist_did 243 + for t in tracks 244 + if t.support_gate and t.artist_did != viewer_did 245 + } 246 + if gated_artist_dids: 247 + supported_artist_dids = await get_supported_artists( 248 + viewer_did, gated_artist_dids 249 + ) 250 + 251 # fetch all track responses concurrently with like status and counts 252 track_responses = await asyncio.gather( 253 *[ ··· 258 like_counts, 259 comment_counts, 260 track_tags=track_tags, 261 + viewer_did=viewer_did, 262 + supported_artist_dids=supported_artist_dids, 263 ) 264 for track in tracks 265 ]
+64 -4
backend/src/backend/api/tracks/mutations.py
··· 1 """Track mutation endpoints (delete/update/restore).""" 2 3 import contextlib 4 import logging 5 from datetime import UTC, datetime 6 from typing import Annotated 7 8 import logfire 9 from fastapi import Depends, File, Form, HTTPException, UploadFile ··· 23 update_record, 24 ) 25 from backend._internal.atproto.tid import datetime_to_tid 26 - from backend._internal.background_tasks import schedule_album_list_sync 27 from backend.config import settings 28 from backend.models import Artist, Tag, Track, TrackTag, get_db 29 from backend.schemas import TrackResponse ··· 170 album: Annotated[str | None, Form()] = None, 171 features: Annotated[str | None, Form()] = None, 172 tags: Annotated[str | None, Form(description="JSON array of tag names")] = None, 173 image: UploadFile | None = File(None), 174 ) -> TrackResponse: 175 """Update track metadata (only by owner).""" ··· 196 track.title = title 197 title_changed = True 198 199 # track album changes for list sync 200 old_album_id = track.album_id 201 await apply_album_update(db, track, album) ··· 252 updated_tags.add(tag_name) 253 254 # always update ATProto record if any metadata changed 255 metadata_changed = ( 256 - title_changed or album is not None or features is not None or image_changed 257 ) 258 if track.atproto_record_uri and metadata_changed: 259 try: ··· 281 if new_album_id: 282 await schedule_album_list_sync(auth_session.session_id, new_album_id) 283 284 # build track_tags dict for response 285 # if tags were updated, use updated_tags; otherwise query for existing 286 if tags is not None: ··· 304 Exception: if ATProto record update fails 305 """ 306 record_uri = track.atproto_record_uri 307 - audio_url = track.r2_url 308 - if not record_uri or not audio_url: 309 return 310 311 updated_record = build_track_record( 312 title=track.title, 313 artist=track.artist.display_name, ··· 317 duration=track.duration, 318 features=track.features if track.features else None, 319 image_url=image_url_override or await track.get_image_url(), 320 ) 321 322 result = await update_record(
··· 1 """Track mutation endpoints (delete/update/restore).""" 2 3 import contextlib 4 + import json 5 import logging 6 from datetime import UTC, datetime 7 from typing import Annotated 8 + from urllib.parse import urljoin 9 10 import logfire 11 from fastapi import Depends, File, Form, HTTPException, UploadFile ··· 25 update_record, 26 ) 27 from backend._internal.atproto.tid import datetime_to_tid 28 + from backend._internal.background_tasks import ( 29 + schedule_album_list_sync, 30 + schedule_move_track_audio, 31 + ) 32 from backend.config import settings 33 from backend.models import Artist, Tag, Track, TrackTag, get_db 34 from backend.schemas import TrackResponse ··· 175 album: Annotated[str | None, Form()] = None, 176 features: Annotated[str | None, Form()] = None, 177 tags: Annotated[str | None, Form(description="JSON array of tag names")] = None, 178 + support_gate: Annotated[ 179 + str | None, 180 + Form(description="JSON object for supporter gating, or 'null' to remove"), 181 + ] = None, 182 image: UploadFile | None = File(None), 183 ) -> TrackResponse: 184 """Update track metadata (only by owner).""" ··· 205 track.title = title 206 title_changed = True 207 208 + # handle support_gate update 209 + # track migration direction: None = no move, True = to private, False = to public 210 + move_to_private: bool | None = None 211 + if support_gate is not None: 212 + was_gated = track.support_gate is not None 213 + if support_gate.lower() == "null" or support_gate == "": 214 + # removing gating - need to move file back to public if it was gated 215 + if was_gated and track.r2_url is None: 216 + move_to_private = False 217 + track.support_gate = None 218 + else: 219 + try: 220 + parsed_gate = json.loads(support_gate) 221 + if not isinstance(parsed_gate, dict): 222 + raise ValueError("support_gate must be a JSON object") 223 + if "type" not in parsed_gate: 224 + raise ValueError("support_gate must have a 'type' field") 225 + if parsed_gate["type"] not in ("any",): 226 + raise ValueError( 227 + f"unsupported support_gate type: {parsed_gate['type']}" 228 + ) 229 + # enabling gating - need to move file to private if it was public 230 + if not was_gated and track.r2_url is not None: 231 + move_to_private = True 232 + track.support_gate = parsed_gate 233 + except json.JSONDecodeError as e: 234 + raise HTTPException( 235 + status_code=400, detail=f"invalid support_gate JSON: {e}" 236 + ) from e 237 + except ValueError as e: 238 + raise HTTPException(status_code=400, detail=str(e)) from e 239 + 240 # track album changes for list sync 241 old_album_id = track.album_id 242 await apply_album_update(db, track, album) ··· 293 updated_tags.add(tag_name) 294 295 # always update ATProto record if any metadata changed 296 + support_gate_changed = move_to_private is not None 297 metadata_changed = ( 298 + title_changed 299 + or album is not None 300 + or features is not None 301 + or image_changed 302 + or support_gate_changed 303 ) 304 if track.atproto_record_uri and metadata_changed: 305 try: ··· 327 if new_album_id: 328 await schedule_album_list_sync(auth_session.session_id, new_album_id) 329 330 + # move audio file between buckets if support_gate was toggled 331 + if move_to_private is not None: 332 + await schedule_move_track_audio(track.id, to_private=move_to_private) 333 + 334 # build track_tags dict for response 335 # if tags were updated, use updated_tags; otherwise query for existing 336 if tags is not None: ··· 354 Exception: if ATProto record update fails 355 """ 356 record_uri = track.atproto_record_uri 357 + if not record_uri: 358 return 359 360 + # for gated tracks, use the API endpoint URL instead of r2_url 361 + # (r2_url is None for private bucket tracks) 362 + if track.support_gate is not None: 363 + backend_url = settings.atproto.redirect_uri.rsplit("/", 2)[0] 364 + audio_url = urljoin(backend_url + "/", f"audio/{track.file_id}") 365 + else: 366 + audio_url = track.r2_url 367 + if not audio_url: 368 + return 369 + 370 updated_record = build_track_record( 371 title=track.title, 372 artist=track.artist.display_name, ··· 376 duration=track.duration, 377 features=track.features if track.features else None, 378 image_url=image_url_override or await track.get_image_url(), 379 + support_gate=track.support_gate, 380 ) 381 382 result = await update_record(
+108 -24
backend/src/backend/api/tracks/uploads.py
··· 37 from backend._internal.image import ImageFormat 38 from backend._internal.jobs import job_service 39 from backend.config import settings 40 - from backend.models import Artist, Tag, Track, TrackTag 41 from backend.models.job import JobStatus, JobType 42 from backend.storage import storage 43 from backend.utilities.audio import extract_duration ··· 75 image_path: str | None = None 76 image_filename: str | None = None 77 image_content_type: str | None = None 78 79 80 async def _get_or_create_tag( ··· 119 upload_id: str, 120 file_path: str, 121 filename: str, 122 ) -> str | None: 123 - """save audio file to storage, returning file_id or None on failure.""" 124 await job_service.update_progress( 125 upload_id, 126 JobStatus.PROCESSING, 127 - "uploading to storage...", 128 phase="upload", 129 - progress_pct=0, 130 ) 131 try: 132 async with R2ProgressTracker( 133 job_id=upload_id, 134 - message="uploading to storage...", 135 phase="upload", 136 ) as tracker: 137 with open(file_path, "rb") as file_obj: 138 - file_id = await storage.save( 139 - file_obj, filename, progress_callback=tracker.on_progress 140 - ) 141 142 await job_service.update_progress( 143 upload_id, 144 JobStatus.PROCESSING, 145 - "uploading to storage...", 146 phase="upload", 147 progress_pct=100.0, 148 ) 149 - logfire.info("storage.save completed", file_id=file_id) 150 return file_id 151 152 except Exception as e: ··· 255 with open(ctx.file_path, "rb") as f: 256 duration = extract_duration(f) 257 258 - # save audio to storage 259 file_id = await _save_audio_to_storage( 260 - ctx.upload_id, ctx.file_path, ctx.filename 261 ) 262 if not file_id: 263 return ··· 279 ) 280 return 281 282 - # get R2 URL 283 - r2_url = await storage.get_url( 284 - file_id, file_type="audio", extension=ext[1:] 285 - ) 286 - if not r2_url: 287 - await job_service.update_progress( 288 - ctx.upload_id, 289 - JobStatus.FAILED, 290 - "upload failed", 291 - error="failed to get public audio URL", 292 ) 293 - return 294 295 # save image if provided 296 image_url = None ··· 338 phase="atproto", 339 ) 340 try: 341 atproto_result = await create_track_record( 342 auth_session=ctx.auth_session, 343 title=ctx.title, 344 artist=artist.display_name, 345 - audio_url=r2_url, 346 file_type=ext[1:], 347 album=ctx.album, 348 duration=duration, 349 features=featured_artists or None, 350 image_url=image_url, 351 ) 352 if not atproto_result: 353 raise ValueError("PDS returned no record data") ··· 403 atproto_record_cid=atproto_cid, 404 image_id=image_id, 405 image_url=image_url, 406 ) 407 408 db.add(track) ··· 467 album: Annotated[str | None, Form()] = None, 468 features: Annotated[str | None, Form()] = None, 469 tags: Annotated[str | None, Form(description="JSON array of tag names")] = None, 470 file: UploadFile = File(...), 471 image: UploadFile | None = File(None), 472 ) -> dict: ··· 477 album: Optional album name/ID to associate with the track. 478 features: Optional JSON array of ATProto handles, e.g., 479 ["user1.bsky.social", "user2.bsky.social"]. 480 file: Audio file to upload (required). 481 image: Optional image file for track artwork. 482 background_tasks: FastAPI background-task runner. ··· 491 except ValueError as e: 492 raise HTTPException(status_code=400, detail=str(e)) from e 493 494 # validate audio file type upfront 495 if not file.filename: 496 raise HTTPException(status_code=400, detail="no filename provided") ··· 577 image_path=image_path, 578 image_filename=image_filename, 579 image_content_type=image_content_type, 580 ) 581 background_tasks.add_task(_process_upload_background, ctx) 582 except Exception:
··· 37 from backend._internal.image import ImageFormat 38 from backend._internal.jobs import job_service 39 from backend.config import settings 40 + from backend.models import Artist, Tag, Track, TrackTag, UserPreferences 41 from backend.models.job import JobStatus, JobType 42 from backend.storage import storage 43 from backend.utilities.audio import extract_duration ··· 75 image_path: str | None = None 76 image_filename: str | None = None 77 image_content_type: str | None = None 78 + 79 + # supporter-gated content (e.g., {"type": "any"}) 80 + support_gate: dict | None = None 81 82 83 async def _get_or_create_tag( ··· 122 upload_id: str, 123 file_path: str, 124 filename: str, 125 + *, 126 + gated: bool = False, 127 ) -> str | None: 128 + """save audio file to storage, returning file_id or None on failure. 129 + 130 + args: 131 + upload_id: job tracking ID 132 + file_path: path to temp file 133 + filename: original filename 134 + gated: if True, save to private bucket (no public URL) 135 + """ 136 + message = "uploading to private storage..." if gated else "uploading to storage..." 137 await job_service.update_progress( 138 upload_id, 139 JobStatus.PROCESSING, 140 + message, 141 phase="upload", 142 + progress_pct=0.0, 143 ) 144 try: 145 async with R2ProgressTracker( 146 job_id=upload_id, 147 + message=message, 148 phase="upload", 149 ) as tracker: 150 with open(file_path, "rb") as file_obj: 151 + if gated: 152 + file_id = await storage.save_gated( 153 + file_obj, filename, progress_callback=tracker.on_progress 154 + ) 155 + else: 156 + file_id = await storage.save( 157 + file_obj, filename, progress_callback=tracker.on_progress 158 + ) 159 160 await job_service.update_progress( 161 upload_id, 162 JobStatus.PROCESSING, 163 + message, 164 phase="upload", 165 progress_pct=100.0, 166 ) 167 + logfire.info("storage.save completed", file_id=file_id, gated=gated) 168 return file_id 169 170 except Exception as e: ··· 273 with open(ctx.file_path, "rb") as f: 274 duration = extract_duration(f) 275 276 + # validate gating requirements if support_gate is set 277 + is_gated = ctx.support_gate is not None 278 + if is_gated: 279 + async with db_session() as db: 280 + prefs_result = await db.execute( 281 + select(UserPreferences).where( 282 + UserPreferences.did == ctx.artist_did 283 + ) 284 + ) 285 + prefs = prefs_result.scalar_one_or_none() 286 + if not prefs or prefs.support_url != "atprotofans": 287 + await job_service.update_progress( 288 + ctx.upload_id, 289 + JobStatus.FAILED, 290 + "upload failed", 291 + error="supporter gating requires atprotofans to be enabled in settings", 292 + ) 293 + return 294 + 295 + # save audio to storage (private bucket if gated) 296 file_id = await _save_audio_to_storage( 297 + ctx.upload_id, ctx.file_path, ctx.filename, gated=is_gated 298 ) 299 if not file_id: 300 return ··· 316 ) 317 return 318 319 + # get R2 URL (only for public tracks - gated tracks have no public URL) 320 + r2_url: str | None = None 321 + if not is_gated: 322 + r2_url = await storage.get_url( 323 + file_id, file_type="audio", extension=ext[1:] 324 ) 325 + if not r2_url: 326 + await job_service.update_progress( 327 + ctx.upload_id, 328 + JobStatus.FAILED, 329 + "upload failed", 330 + error="failed to get public audio URL", 331 + ) 332 + return 333 334 # save image if provided 335 image_url = None ··· 377 phase="atproto", 378 ) 379 try: 380 + # for gated tracks, use API endpoint URL instead of direct R2 URL 381 + # this ensures playback goes through our auth check 382 + if is_gated: 383 + # use backend URL for gated audio 384 + from urllib.parse import urljoin 385 + 386 + backend_url = settings.atproto.redirect_uri.rsplit("/", 2)[0] 387 + audio_url_for_record = urljoin( 388 + backend_url + "/", f"audio/{file_id}" 389 + ) 390 + else: 391 + # r2_url is guaranteed non-None here - we returned early above if None 392 + assert r2_url is not None 393 + audio_url_for_record = r2_url 394 + 395 atproto_result = await create_track_record( 396 auth_session=ctx.auth_session, 397 title=ctx.title, 398 artist=artist.display_name, 399 + audio_url=audio_url_for_record, 400 file_type=ext[1:], 401 album=ctx.album, 402 duration=duration, 403 features=featured_artists or None, 404 image_url=image_url, 405 + support_gate=ctx.support_gate, 406 ) 407 if not atproto_result: 408 raise ValueError("PDS returned no record data") ··· 458 atproto_record_cid=atproto_cid, 459 image_id=image_id, 460 image_url=image_url, 461 + support_gate=ctx.support_gate, 462 ) 463 464 db.add(track) ··· 523 album: Annotated[str | None, Form()] = None, 524 features: Annotated[str | None, Form()] = None, 525 tags: Annotated[str | None, Form(description="JSON array of tag names")] = None, 526 + support_gate: Annotated[ 527 + str | None, 528 + Form(description='JSON object for supporter gating, e.g., {"type": "any"}'), 529 + ] = None, 530 file: UploadFile = File(...), 531 image: UploadFile | None = File(None), 532 ) -> dict: ··· 537 album: Optional album name/ID to associate with the track. 538 features: Optional JSON array of ATProto handles, e.g., 539 ["user1.bsky.social", "user2.bsky.social"]. 540 + support_gate: Optional JSON object for supporter gating. 541 + Requires atprotofans to be enabled in settings. 542 + Example: {"type": "any"} - requires any atprotofans support. 543 file: Audio file to upload (required). 544 image: Optional image file for track artwork. 545 background_tasks: FastAPI background-task runner. ··· 554 except ValueError as e: 555 raise HTTPException(status_code=400, detail=str(e)) from e 556 557 + # parse and validate support_gate if provided 558 + parsed_support_gate: dict | None = None 559 + if support_gate: 560 + try: 561 + parsed_support_gate = json.loads(support_gate) 562 + if not isinstance(parsed_support_gate, dict): 563 + raise ValueError("support_gate must be a JSON object") 564 + if "type" not in parsed_support_gate: 565 + raise ValueError("support_gate must have a 'type' field") 566 + if parsed_support_gate["type"] not in ("any",): 567 + raise ValueError( 568 + f"unsupported support_gate type: {parsed_support_gate['type']}" 569 + ) 570 + except json.JSONDecodeError as e: 571 + raise HTTPException( 572 + status_code=400, detail=f"invalid support_gate JSON: {e}" 573 + ) from e 574 + except ValueError as e: 575 + raise HTTPException(status_code=400, detail=str(e)) from e 576 + 577 # validate audio file type upfront 578 if not file.filename: 579 raise HTTPException(status_code=400, detail="no filename provided") ··· 660 image_path=image_path, 661 image_filename=image_filename, 662 image_content_type=image_content_type, 663 + support_gate=parsed_support_gate, 664 ) 665 background_tasks.add_task(_process_upload_background, ctx) 666 except Exception:
+10
backend/src/backend/config.py
··· 256 validation_alias="R2_IMAGE_BUCKET", 257 description="R2 bucket name for image files", 258 ) 259 r2_endpoint_url: str = Field( 260 default="", 261 validation_alias="R2_ENDPOINT_URL",
··· 256 validation_alias="R2_IMAGE_BUCKET", 257 description="R2 bucket name for image files", 258 ) 259 + r2_private_bucket: str = Field( 260 + default="", 261 + validation_alias="R2_PRIVATE_BUCKET", 262 + description="R2 private bucket for supporter-gated audio (no public URL)", 263 + ) 264 + presigned_url_expiry_seconds: int = Field( 265 + default=3600, 266 + validation_alias="PRESIGNED_URL_EXPIRY_SECONDS", 267 + description="Expiry time in seconds for presigned URLs (default 1 hour)", 268 + ) 269 r2_endpoint_url: str = Field( 270 default="", 271 validation_alias="R2_ENDPOINT_URL",
+10
backend/src/backend/models/track.py
··· 87 nullable=False, default=False, server_default="false" 88 ) 89 90 @property 91 def album(self) -> str | None: 92 """get album name from extra (for ATProto compatibility)."""
··· 87 nullable=False, default=False, server_default="false" 88 ) 89 90 + # supporter-gated content (e.g., {"type": "any"} requires any atprotofans support) 91 + support_gate: Mapped[dict | None] = mapped_column( 92 + JSONB, nullable=True, default=None 93 + ) 94 + 95 + @property 96 + def is_gated(self) -> bool: 97 + """check if this track requires supporter access.""" 98 + return self.support_gate is not None 99 + 100 @property 101 def album(self) -> str | None: 102 """get album name from extra (for ATProto compatibility)."""
+20
backend/src/backend/schemas.py
··· 50 title: str 51 artist: str 52 artist_handle: str 53 artist_avatar_url: str | None 54 file_id: str 55 file_type: str ··· 70 None # None = not scanned, False = clear, True = flagged 71 ) 72 copyright_match: str | None = None # "Title by Artist" of primary match 73 74 @classmethod 75 async def from_track( ··· 81 comment_counts: dict[int, int] | None = None, 82 copyright_info: dict[int, CopyrightInfo] | None = None, 83 track_tags: dict[int, set[str]] | None = None, 84 ) -> "TrackResponse": 85 """build track response from Track model. 86 ··· 92 comment_counts: optional dict of track_id -> comment_count 93 copyright_info: optional dict of track_id -> CopyrightInfo 94 track_tags: optional dict of track_id -> set of tag names 95 """ 96 # check if user has liked this track 97 is_liked = liked_track_ids is not None and track.id in liked_track_ids ··· 135 # get tags for this track 136 tags = track_tags.get(track.id, set()) if track_tags else set() 137 138 return cls( 139 id=track.id, 140 title=track.title, 141 artist=track.artist.display_name, 142 artist_handle=track.artist.handle, 143 artist_avatar_url=track.artist.avatar_url, 144 file_id=track.file_id, 145 file_type=track.file_type, ··· 158 tags=tags, 159 copyright_flagged=copyright_flagged, 160 copyright_match=copyright_match, 161 )
··· 50 title: str 51 artist: str 52 artist_handle: str 53 + artist_did: str 54 artist_avatar_url: str | None 55 file_id: str 56 file_type: str ··· 71 None # None = not scanned, False = clear, True = flagged 72 ) 73 copyright_match: str | None = None # "Title by Artist" of primary match 74 + support_gate: dict[str, Any] | None = None # supporter gating config 75 + gated: bool = False # true if track is gated AND viewer lacks access 76 77 @classmethod 78 async def from_track( ··· 84 comment_counts: dict[int, int] | None = None, 85 copyright_info: dict[int, CopyrightInfo] | None = None, 86 track_tags: dict[int, set[str]] | None = None, 87 + viewer_did: str | None = None, 88 + supported_artist_dids: set[str] | None = None, 89 ) -> "TrackResponse": 90 """build track response from Track model. 91 ··· 97 comment_counts: optional dict of track_id -> comment_count 98 copyright_info: optional dict of track_id -> CopyrightInfo 99 track_tags: optional dict of track_id -> set of tag names 100 + viewer_did: optional DID of the viewer (for gated content resolution) 101 + supported_artist_dids: optional set of artist DIDs the viewer supports 102 """ 103 # check if user has liked this track 104 is_liked = liked_track_ids is not None and track.id in liked_track_ids ··· 142 # get tags for this track 143 tags = track_tags.get(track.id, set()) if track_tags else set() 144 145 + # resolve gated status for viewer 146 + # gated = true only if track has support_gate AND viewer lacks access 147 + gated = False 148 + if track.support_gate: 149 + is_owner = viewer_did and viewer_did == track.artist_did 150 + is_supporter = ( 151 + supported_artist_dids and track.artist_did in supported_artist_dids 152 + ) 153 + gated = not (is_owner or is_supporter) 154 + 155 return cls( 156 id=track.id, 157 title=track.title, 158 artist=track.artist.display_name, 159 artist_handle=track.artist.handle, 160 + artist_did=track.artist_did, 161 artist_avatar_url=track.artist.avatar_url, 162 file_id=track.file_id, 163 file_type=track.file_type, ··· 176 tags=tags, 177 copyright_flagged=copyright_flagged, 178 copyright_match=copyright_match, 179 + support_gate=track.support_gate, 180 + gated=gated, 181 )
+219
backend/src/backend/storage/r2.py
··· 95 96 self.audio_bucket_name = settings.storage.r2_bucket 97 self.image_bucket_name = settings.storage.r2_image_bucket 98 self.public_audio_bucket_url = settings.storage.r2_public_bucket_url 99 self.public_image_bucket_url = settings.storage.r2_public_image_bucket_url 100 101 # sync client for upload (used in background tasks) 102 self.client = boto3.client( ··· 439 image_bucket=self.image_bucket_name, 440 ) 441 return False
··· 95 96 self.audio_bucket_name = settings.storage.r2_bucket 97 self.image_bucket_name = settings.storage.r2_image_bucket 98 + self.private_audio_bucket_name = settings.storage.r2_private_bucket 99 self.public_audio_bucket_url = settings.storage.r2_public_bucket_url 100 self.public_image_bucket_url = settings.storage.r2_public_image_bucket_url 101 + self.presigned_url_expiry = settings.storage.presigned_url_expiry_seconds 102 103 # sync client for upload (used in background tasks) 104 self.client = boto3.client( ··· 441 image_bucket=self.image_bucket_name, 442 ) 443 return False 444 + 445 + async def save_gated( 446 + self, 447 + file: BinaryIO, 448 + filename: str, 449 + progress_callback: Callable[[float], None] | None = None, 450 + ) -> str: 451 + """save supporter-gated audio file to private R2 bucket. 452 + 453 + same as save() but uses the private bucket with no public URL. 454 + files in this bucket are only accessible via presigned URLs. 455 + 456 + args: 457 + file: file-like object to upload 458 + filename: original filename (used to determine media type) 459 + progress_callback: optional callback for upload progress 460 + """ 461 + if not self.private_audio_bucket_name: 462 + raise ValueError("R2_PRIVATE_BUCKET not configured") 463 + 464 + with logfire.span("R2 save_gated", filename=filename): 465 + # compute hash in chunks (constant memory) 466 + file_id = hash_file_chunked(file)[:16] 467 + logfire.info("computed file hash for gated content", file_id=file_id) 468 + 469 + # determine file extension - only audio supported for gated content 470 + ext = Path(filename).suffix.lower() 471 + audio_format = AudioFormat.from_extension(ext) 472 + if not audio_format: 473 + raise ValueError( 474 + f"unsupported audio type for gated content: {ext}. " 475 + f"supported: {AudioFormat.supported_extensions_str()}" 476 + ) 477 + 478 + key = f"audio/{file_id}{ext}" 479 + media_type = audio_format.media_type 480 + 481 + # get file size for progress tracking 482 + file_size = file.seek(0, 2) 483 + file.seek(0) 484 + 485 + logfire.info( 486 + "uploading gated content to private R2", 487 + bucket=self.private_audio_bucket_name, 488 + key=key, 489 + media_type=media_type, 490 + file_size=file_size, 491 + ) 492 + 493 + try: 494 + async with self.async_session.client( 495 + "s3", 496 + endpoint_url=self.endpoint_url, 497 + aws_access_key_id=self.aws_access_key_id, 498 + aws_secret_access_key=self.aws_secret_access_key, 499 + ) as client: 500 + upload_kwargs = { 501 + "Fileobj": file, 502 + "Bucket": self.private_audio_bucket_name, 503 + "Key": key, 504 + "ExtraArgs": {"ContentType": media_type}, 505 + } 506 + 507 + if progress_callback and file_size > 0: 508 + tracker = UploadProgressTracker(file_size, progress_callback) 509 + upload_kwargs["Callback"] = tracker 510 + 511 + await client.upload_fileobj(**upload_kwargs) 512 + except Exception as e: 513 + logfire.error( 514 + "R2 gated upload failed", 515 + error=str(e), 516 + bucket=self.private_audio_bucket_name, 517 + key=key, 518 + exc_info=True, 519 + ) 520 + raise 521 + 522 + logfire.info("R2 gated upload complete", file_id=file_id, key=key) 523 + return file_id 524 + 525 + async def generate_presigned_url( 526 + self, 527 + file_id: str, 528 + extension: str, 529 + expires_in: int | None = None, 530 + ) -> str: 531 + """generate a presigned URL for accessing gated content. 532 + 533 + presigned URLs allow time-limited access to private bucket objects 534 + without exposing credentials. the URL includes a signature that 535 + expires after the specified duration. 536 + 537 + args: 538 + file_id: the file identifier hash 539 + extension: file extension (e.g., "mp3", "flac") 540 + expires_in: optional override for expiry seconds (default from settings) 541 + 542 + returns: 543 + presigned URL string 544 + 545 + raises: 546 + ValueError: if private bucket not configured 547 + """ 548 + if not self.private_audio_bucket_name: 549 + raise ValueError("R2_PRIVATE_BUCKET not configured") 550 + 551 + ext = extension.lstrip(".") 552 + key = f"audio/{file_id}.{ext}" 553 + expiry = expires_in or self.presigned_url_expiry 554 + 555 + with logfire.span( 556 + "R2 generate_presigned_url", 557 + file_id=file_id, 558 + key=key, 559 + expires_in=expiry, 560 + ): 561 + async with self.async_session.client( 562 + "s3", 563 + endpoint_url=self.endpoint_url, 564 + aws_access_key_id=self.aws_access_key_id, 565 + aws_secret_access_key=self.aws_secret_access_key, 566 + config=Config(signature_version="s3v4"), 567 + ) as client: 568 + url = await client.generate_presigned_url( 569 + "get_object", 570 + Params={ 571 + "Bucket": self.private_audio_bucket_name, 572 + "Key": key, 573 + }, 574 + ExpiresIn=expiry, 575 + ) 576 + logfire.info( 577 + "generated presigned URL", 578 + file_id=file_id, 579 + expires_in=expiry, 580 + ) 581 + return url 582 + 583 + async def move_audio( 584 + self, 585 + file_id: str, 586 + extension: str, 587 + *, 588 + to_private: bool, 589 + ) -> str | None: 590 + """move an audio file between public and private buckets. 591 + 592 + copies the file to the destination bucket, then deletes from source. 593 + 594 + args: 595 + file_id: the file identifier hash 596 + extension: file extension (e.g., "mp3", "flac") 597 + to_private: if True, move public->private; if False, move private->public 598 + 599 + returns: 600 + new URL if successful (public URL or None for private), None on failure 601 + 602 + raises: 603 + ValueError: if private bucket not configured 604 + """ 605 + if not self.private_audio_bucket_name: 606 + raise ValueError("R2_PRIVATE_BUCKET not configured") 607 + 608 + ext = extension.lstrip(".") 609 + key = f"audio/{file_id}.{ext}" 610 + 611 + if to_private: 612 + src_bucket = self.audio_bucket_name 613 + dst_bucket = self.private_audio_bucket_name 614 + else: 615 + src_bucket = self.private_audio_bucket_name 616 + dst_bucket = self.audio_bucket_name 617 + 618 + with logfire.span( 619 + "R2 move_audio", 620 + file_id=file_id, 621 + key=key, 622 + to_private=to_private, 623 + ): 624 + try: 625 + async with self.async_session.client( 626 + "s3", 627 + endpoint_url=self.endpoint_url, 628 + aws_access_key_id=self.aws_access_key_id, 629 + aws_secret_access_key=self.aws_secret_access_key, 630 + ) as client: 631 + # copy to destination 632 + await client.copy_object( 633 + CopySource={"Bucket": src_bucket, "Key": key}, 634 + Bucket=dst_bucket, 635 + Key=key, 636 + ) 637 + logfire.info( 638 + "copied audio file", 639 + file_id=file_id, 640 + src=src_bucket, 641 + dst=dst_bucket, 642 + ) 643 + 644 + # delete from source 645 + await client.delete_object(Bucket=src_bucket, Key=key) 646 + logfire.info("deleted from source bucket", file_id=file_id) 647 + 648 + # return public URL if moved to public, None if moved to private 649 + if to_private: 650 + return None 651 + return f"{self.public_audio_bucket_url}/{key}" 652 + 653 + except ClientError as e: 654 + logfire.error( 655 + "R2 move_audio failed", 656 + file_id=file_id, 657 + error=str(e), 658 + exc_info=True, 659 + ) 660 + return None
+218 -3
backend/tests/api/test_audio.py
··· 271 test_app.dependency_overrides.pop(require_auth, None) 272 273 274 - async def test_get_audio_url_requires_auth(test_app: FastAPI): 275 - """test that /url endpoint returns 401 without authentication.""" 276 # ensure no auth override 277 test_app.dependency_overrides.pop(require_auth, None) 278 279 async with AsyncClient( 280 transport=ASGITransport(app=test_app), base_url="http://test" 281 ) as client: 282 - response = await client.get("/audio/somefile/url") 283 284 assert response.status_code == 401
··· 271 test_app.dependency_overrides.pop(require_auth, None) 272 273 274 + async def test_get_audio_url_gated_requires_auth( 275 + test_app: FastAPI, db_session: AsyncSession 276 + ): 277 + """test that /url endpoint returns 401 for gated content without authentication.""" 278 + # create a gated track 279 + artist = Artist( 280 + did="did:plc:gatedartist", 281 + handle="gatedartist.bsky.social", 282 + display_name="Gated Artist", 283 + ) 284 + db_session.add(artist) 285 + await db_session.flush() 286 + 287 + track = Track( 288 + title="Gated Track", 289 + artist_did=artist.did, 290 + file_id="gated-test-file", 291 + file_type="mp3", 292 + r2_url="https://cdn.example.com/audio/gated.mp3", 293 + support_gate={"type": "any"}, 294 + ) 295 + db_session.add(track) 296 + await db_session.commit() 297 + 298 # ensure no auth override 299 test_app.dependency_overrides.pop(require_auth, None) 300 301 async with AsyncClient( 302 transport=ASGITransport(app=test_app), base_url="http://test" 303 ) as client: 304 + response = await client.get(f"/audio/{track.file_id}/url") 305 + 306 + assert response.status_code == 401 307 + assert "authentication required" in response.json()["detail"] 308 + 309 + 310 + # gated content regression tests 311 + 312 + 313 + @pytest.fixture 314 + async def gated_track(db_session: AsyncSession) -> Track: 315 + """create a gated track for testing supporter access.""" 316 + artist = Artist( 317 + did="did:plc:gatedowner", 318 + handle="gatedowner.bsky.social", 319 + display_name="Gated Owner", 320 + ) 321 + db_session.add(artist) 322 + await db_session.flush() 323 + 324 + track = Track( 325 + title="Supporters Only Track", 326 + artist_did=artist.did, 327 + file_id="gated-regression-test", 328 + file_type="mp3", 329 + r2_url=None, # no cached URL - forces presigned URL generation 330 + support_gate={"type": "any"}, 331 + ) 332 + db_session.add(track) 333 + await db_session.commit() 334 + await db_session.refresh(track) 335 + 336 + return track 337 + 338 + 339 + @pytest.fixture 340 + def owner_session() -> Session: 341 + """session for the track owner.""" 342 + return Session( 343 + session_id="owner-session-id", 344 + did="did:plc:gatedowner", 345 + handle="gatedowner.bsky.social", 346 + oauth_session={ 347 + "access_token": "owner-access-token", 348 + "refresh_token": "owner-refresh-token", 349 + "dpop_key": {}, 350 + }, 351 + ) 352 + 353 + 354 + @pytest.fixture 355 + def non_supporter_session() -> Session: 356 + """session for a user who is not a supporter.""" 357 + return Session( 358 + session_id="non-supporter-session-id", 359 + did="did:plc:randomuser", 360 + handle="randomuser.bsky.social", 361 + oauth_session={ 362 + "access_token": "random-access-token", 363 + "refresh_token": "random-refresh-token", 364 + "dpop_key": {}, 365 + }, 366 + ) 367 + 368 + 369 + async def test_gated_stream_requires_auth(test_app: FastAPI, gated_track: Track): 370 + """regression: GET /audio/{file_id} returns 401 for gated content without auth.""" 371 + test_app.dependency_overrides.pop(require_auth, None) 372 + 373 + async with AsyncClient( 374 + transport=ASGITransport(app=test_app), base_url="http://test" 375 + ) as client: 376 + response = await client.get( 377 + f"/audio/{gated_track.file_id}", follow_redirects=False 378 + ) 379 + 380 + assert response.status_code == 401 381 + assert "authentication required" in response.json()["detail"] 382 + 383 + 384 + async def test_gated_head_requires_auth(test_app: FastAPI, gated_track: Track): 385 + """regression: HEAD /audio/{file_id} returns 401 for gated content without auth.""" 386 + test_app.dependency_overrides.pop(require_auth, None) 387 + 388 + async with AsyncClient( 389 + transport=ASGITransport(app=test_app), base_url="http://test" 390 + ) as client: 391 + response = await client.head(f"/audio/{gated_track.file_id}") 392 393 assert response.status_code == 401 394 + 395 + 396 + async def test_gated_head_owner_allowed( 397 + test_app: FastAPI, gated_track: Track, owner_session: Session 398 + ): 399 + """regression: HEAD /audio/{file_id} returns 200 for track owner.""" 400 + from backend._internal import get_optional_session 401 + 402 + test_app.dependency_overrides[get_optional_session] = lambda: owner_session 403 + 404 + try: 405 + async with AsyncClient( 406 + transport=ASGITransport(app=test_app), base_url="http://test" 407 + ) as client: 408 + response = await client.head(f"/audio/{gated_track.file_id}") 409 + 410 + assert response.status_code == 200 411 + finally: 412 + test_app.dependency_overrides.pop(get_optional_session, None) 413 + 414 + 415 + async def test_gated_stream_owner_redirects( 416 + test_app: FastAPI, gated_track: Track, owner_session: Session 417 + ): 418 + """regression: GET /audio/{file_id} returns 307 redirect for track owner.""" 419 + from backend._internal import get_optional_session 420 + 421 + mock_storage = MagicMock() 422 + mock_storage.generate_presigned_url = AsyncMock( 423 + return_value="https://presigned.example.com/audio/gated.mp3" 424 + ) 425 + 426 + test_app.dependency_overrides[get_optional_session] = lambda: owner_session 427 + 428 + try: 429 + with patch("backend.api.audio.storage", mock_storage): 430 + async with AsyncClient( 431 + transport=ASGITransport(app=test_app), base_url="http://test" 432 + ) as client: 433 + response = await client.get( 434 + f"/audio/{gated_track.file_id}", follow_redirects=False 435 + ) 436 + 437 + assert response.status_code == 307 438 + assert "presigned.example.com" in response.headers["location"] 439 + mock_storage.generate_presigned_url.assert_called_once() 440 + finally: 441 + test_app.dependency_overrides.pop(get_optional_session, None) 442 + 443 + 444 + async def test_gated_head_non_supporter_denied( 445 + test_app: FastAPI, gated_track: Track, non_supporter_session: Session 446 + ): 447 + """regression: HEAD /audio/{file_id} returns 402 for non-supporter.""" 448 + from backend._internal import get_optional_session 449 + 450 + test_app.dependency_overrides[get_optional_session] = lambda: non_supporter_session 451 + 452 + # mock validate_supporter to return invalid 453 + mock_validation = MagicMock() 454 + mock_validation.valid = False 455 + 456 + try: 457 + with patch( 458 + "backend.api.audio.validate_supporter", 459 + AsyncMock(return_value=mock_validation), 460 + ): 461 + async with AsyncClient( 462 + transport=ASGITransport(app=test_app), base_url="http://test" 463 + ) as client: 464 + response = await client.head(f"/audio/{gated_track.file_id}") 465 + 466 + assert response.status_code == 402 467 + assert response.headers.get("x-support-required") == "true" 468 + finally: 469 + test_app.dependency_overrides.pop(get_optional_session, None) 470 + 471 + 472 + async def test_gated_stream_non_supporter_denied( 473 + test_app: FastAPI, gated_track: Track, non_supporter_session: Session 474 + ): 475 + """regression: GET /audio/{file_id} returns 402 for non-supporter.""" 476 + from backend._internal import get_optional_session 477 + 478 + test_app.dependency_overrides[get_optional_session] = lambda: non_supporter_session 479 + 480 + # mock validate_supporter to return invalid 481 + mock_validation = MagicMock() 482 + mock_validation.valid = False 483 + 484 + try: 485 + with patch( 486 + "backend.api.audio.validate_supporter", 487 + AsyncMock(return_value=mock_validation), 488 + ): 489 + async with AsyncClient( 490 + transport=ASGITransport(app=test_app), base_url="http://test" 491 + ) as client: 492 + response = await client.get( 493 + f"/audio/{gated_track.file_id}", follow_redirects=False 494 + ) 495 + 496 + assert response.status_code == 402 497 + assert "supporter access" in response.json()["detail"] 498 + finally: 499 + test_app.dependency_overrides.pop(get_optional_session, None)
+83 -1
backend/tests/test_moderation.py
··· 4 5 import httpx 6 import pytest 7 from sqlalchemy import select 8 from sqlalchemy.ext.asyncio import AsyncSession 9 ··· 11 get_active_copyright_labels, 12 scan_track_for_copyright, 13 ) 14 - from backend._internal.moderation_client import ModerationClient, ScanResult 15 from backend.models import Artist, CopyrightScan, Track 16 17 ··· 519 520 # scan2 should still be flagged 521 assert scan2.is_flagged is True
··· 4 5 import httpx 6 import pytest 7 + from fastapi.testclient import TestClient 8 from sqlalchemy import select 9 from sqlalchemy.ext.asyncio import AsyncSession 10 ··· 12 get_active_copyright_labels, 13 scan_track_for_copyright, 14 ) 15 + from backend._internal.moderation_client import ( 16 + ModerationClient, 17 + ScanResult, 18 + SensitiveImagesResult, 19 + ) 20 from backend.models import Artist, CopyrightScan, Track 21 22 ··· 524 525 # scan2 should still be flagged 526 assert scan2.is_flagged is True 527 + 528 + 529 + # tests for sensitive images 530 + 531 + 532 + async def test_moderation_client_get_sensitive_images() -> None: 533 + """test ModerationClient.get_sensitive_images() with successful response.""" 534 + mock_response = Mock() 535 + mock_response.json.return_value = { 536 + "image_ids": ["abc123", "def456"], 537 + "urls": ["https://example.com/image.jpg"], 538 + } 539 + mock_response.raise_for_status.return_value = None 540 + 541 + client = ModerationClient( 542 + service_url="https://test.example.com", 543 + labeler_url="https://labeler.example.com", 544 + auth_token="test-token", 545 + timeout_seconds=30, 546 + label_cache_prefix="test:label:", 547 + label_cache_ttl_seconds=300, 548 + ) 549 + 550 + with patch("httpx.AsyncClient.get", new_callable=AsyncMock) as mock_get: 551 + mock_get.return_value = mock_response 552 + 553 + result = await client.get_sensitive_images() 554 + 555 + assert result.image_ids == ["abc123", "def456"] 556 + assert result.urls == ["https://example.com/image.jpg"] 557 + mock_get.assert_called_once() 558 + 559 + 560 + async def test_moderation_client_get_sensitive_images_empty() -> None: 561 + """test ModerationClient.get_sensitive_images() with empty response.""" 562 + mock_response = Mock() 563 + mock_response.json.return_value = {"image_ids": [], "urls": []} 564 + mock_response.raise_for_status.return_value = None 565 + 566 + client = ModerationClient( 567 + service_url="https://test.example.com", 568 + labeler_url="https://labeler.example.com", 569 + auth_token="test-token", 570 + timeout_seconds=30, 571 + label_cache_prefix="test:label:", 572 + label_cache_ttl_seconds=300, 573 + ) 574 + 575 + with patch("httpx.AsyncClient.get", new_callable=AsyncMock) as mock_get: 576 + mock_get.return_value = mock_response 577 + 578 + result = await client.get_sensitive_images() 579 + 580 + assert result.image_ids == [] 581 + assert result.urls == [] 582 + 583 + 584 + async def test_get_sensitive_images_endpoint( 585 + client: TestClient, 586 + ) -> None: 587 + """test GET /moderation/sensitive-images endpoint proxies to moderation service.""" 588 + mock_result = SensitiveImagesResult( 589 + image_ids=["image1", "image2"], 590 + urls=["https://example.com/avatar.jpg"], 591 + ) 592 + 593 + with patch("backend.api.moderation.get_moderation_client") as mock_get_client: 594 + mock_client = AsyncMock() 595 + mock_client.get_sensitive_images.return_value = mock_result 596 + mock_get_client.return_value = mock_client 597 + 598 + response = client.get("/moderation/sensitive-images") 599 + 600 + assert response.status_code == 200 601 + data = response.json() 602 + assert data["image_ids"] == ["image1", "image2"] 603 + assert data["urls"] == ["https://example.com/avatar.jpg"]
+29 -17
docs/backend/background-tasks.md
··· 39 DOCKET_WORKER_CONCURRENCY=10 # concurrent task limit 40 ``` 41 42 when `DOCKET_URL` is not set, docket is disabled and tasks fall back to `asyncio.create_task()` (fire-and-forget). 43 44 ### local development ··· 54 55 ### production/staging 56 57 - Redis instances are provisioned via Upstash (managed Redis): 58 59 - | environment | instance | region | 60 - |-------------|----------|--------| 61 - | production | `plyr-redis-prd` | us-east-1 (near fly.io) | 62 - | staging | `plyr-redis-stg` | us-east-1 | 63 64 set `DOCKET_URL` in fly.io secrets: 65 ```bash 66 - flyctl secrets set DOCKET_URL=rediss://default:xxx@xxx.upstash.io:6379 -a relay-api 67 - flyctl secrets set DOCKET_URL=rediss://default:xxx@xxx.upstash.io:6379 -a relay-api-staging 68 ``` 69 70 - note: use `rediss://` (with double 's') for TLS connections to Upstash. 71 72 ## usage 73 ··· 117 118 ## costs 119 120 - **Upstash pricing** (pay-per-request): 121 - - free tier: 10k commands/day 122 - - pro: $0.2 per 100k commands + $0.25/GB storage 123 124 - for plyr.fm's volume (~100 uploads/day), this stays well within free tier or costs $0-5/mo. 125 - 126 - **tips to avoid surprise bills**: 127 - - use **regional** (not global) replication 128 - - set **max data limit** (256MB is plenty for a task queue) 129 - - monitor usage in Upstash dashboard 130 131 ## fallback behavior 132
··· 39 DOCKET_WORKER_CONCURRENCY=10 # concurrent task limit 40 ``` 41 42 + ### ⚠️ worker settings - do not modify 43 + 44 + the worker is initialized in `backend/_internal/background.py` with pydocket's defaults. **do not change these settings without extensive testing:** 45 + 46 + | setting | default | why it matters | 47 + |---------|---------|----------------| 48 + | `heartbeat_interval` | 2s | changing this broke all task execution (2025-12-30 incident) | 49 + | `minimum_check_interval` | 1s | affects how quickly tasks are picked up | 50 + | `scheduling_resolution` | 1s | affects scheduled task precision | 51 + 52 + **2025-12-30 incident**: setting `heartbeat_interval=30s` caused all scheduled tasks (likes, comments, exports) to silently fail while perpetual tasks continued running. root cause unclear - correlation was definitive but mechanism wasn't found in pydocket source. reverted in PR #669. 53 + 54 + if you need to tune worker settings: 55 + 1. test extensively in staging with real task volume 56 + 2. verify ALL task types execute (not just perpetual tasks) 57 + 3. check logfire for task execution spans 58 + 59 when `DOCKET_URL` is not set, docket is disabled and tasks fall back to `asyncio.create_task()` (fire-and-forget). 60 61 ### local development ··· 71 72 ### production/staging 73 74 + Redis instances are self-hosted on Fly.io (redis:7-alpine): 75 76 + | environment | fly app | region | 77 + |-------------|---------|--------| 78 + | production | `plyr-redis` | iad | 79 + | staging | `plyr-redis-stg` | iad | 80 81 set `DOCKET_URL` in fly.io secrets: 82 ```bash 83 + flyctl secrets set DOCKET_URL=redis://plyr-redis.internal:6379 -a relay-api 84 + flyctl secrets set DOCKET_URL=redis://plyr-redis-stg.internal:6379 -a relay-api-staging 85 ``` 86 87 + note: uses Fly internal networking (`.internal` domain), no TLS needed within private network. 88 89 ## usage 90 ··· 134 135 ## costs 136 137 + **self-hosted Redis on Fly.io** (fixed monthly): 138 + - ~$2/month per instance (256MB shared-cpu VM) 139 + - ~$4/month total for prod + staging 140 141 + this replaced Upstash pay-per-command pricing which was costing ~$75/month at scale (37M commands/month). 142 143 ## fallback behavior 144
+3 -1
docs/backend/configuration.md
··· 27 28 # storage settings (cloudflare r2) 29 settings.storage.backend # from STORAGE_BACKEND 30 - settings.storage.r2_bucket # from R2_BUCKET (audio files) 31 settings.storage.r2_image_bucket # from R2_IMAGE_BUCKET (image files) 32 settings.storage.r2_endpoint_url # from R2_ENDPOINT_URL 33 settings.storage.r2_public_bucket_url # from R2_PUBLIC_BUCKET_URL (audio files) ··· 84 # storage 85 STORAGE_BACKEND=r2 # or "filesystem" 86 R2_BUCKET=your-audio-bucket 87 R2_IMAGE_BUCKET=your-image-bucket 88 R2_ENDPOINT_URL=https://xxx.r2.cloudflarestorage.com 89 R2_PUBLIC_BUCKET_URL=https://pub-xxx.r2.dev # for audio files
··· 27 28 # storage settings (cloudflare r2) 29 settings.storage.backend # from STORAGE_BACKEND 30 + settings.storage.r2_bucket # from R2_BUCKET (public audio files) 31 + settings.storage.r2_private_bucket # from R2_PRIVATE_BUCKET (gated audio files) 32 settings.storage.r2_image_bucket # from R2_IMAGE_BUCKET (image files) 33 settings.storage.r2_endpoint_url # from R2_ENDPOINT_URL 34 settings.storage.r2_public_bucket_url # from R2_PUBLIC_BUCKET_URL (audio files) ··· 85 # storage 86 STORAGE_BACKEND=r2 # or "filesystem" 87 R2_BUCKET=your-audio-bucket 88 + R2_PRIVATE_BUCKET=your-private-audio-bucket # for supporter-gated content 89 R2_IMAGE_BUCKET=your-image-bucket 90 R2_ENDPOINT_URL=https://xxx.r2.cloudflarestorage.com 91 R2_PUBLIC_BUCKET_URL=https://pub-xxx.r2.dev # for audio files
+2 -2
docs/deployment/environments.md
··· 7 | environment | trigger | backend URL | database | redis | frontend | storage | 8 |-------------|---------|-------------|----------|-------|----------|---------| 9 | **development** | local | localhost:8001 | plyr-dev (neon) | localhost:6379 (docker) | localhost:5173 | audio-dev, images-dev (r2) | 10 - | **staging** | push to main | api-stg.plyr.fm | plyr-stg (neon) | plyr-redis-stg (upstash) | stg.plyr.fm (main branch) | audio-staging, images-staging (r2) | 11 - | **production** | github release | api.plyr.fm | plyr-prd (neon) | plyr-redis-prd (upstash) | plyr.fm (production-fe branch) | audio-prod, images-prod (r2) | 12 13 ## workflow 14
··· 7 | environment | trigger | backend URL | database | redis | frontend | storage | 8 |-------------|---------|-------------|----------|-------|----------|---------| 9 | **development** | local | localhost:8001 | plyr-dev (neon) | localhost:6379 (docker) | localhost:5173 | audio-dev, images-dev (r2) | 10 + | **staging** | push to main | api-stg.plyr.fm | plyr-stg (neon) | plyr-redis-stg (fly.io) | stg.plyr.fm (main branch) | audio-staging, images-staging (r2) | 11 + | **production** | github release | api.plyr.fm | plyr-prd (neon) | plyr-redis (fly.io) | plyr.fm (production-fe branch) | audio-prod, images-prod (r2) | 12 13 ## workflow 14
+117
docs/frontend/design-tokens.md
···
··· 1 + # design tokens 2 + 3 + CSS custom properties defined in `frontend/src/routes/+layout.svelte`. Use these instead of hardcoding values. 4 + 5 + ## border radius 6 + 7 + ```css 8 + --radius-sm: 4px; /* tight corners (inputs, small elements) */ 9 + --radius-base: 6px; /* default for most elements */ 10 + --radius-md: 8px; /* cards, modals */ 11 + --radius-lg: 12px; /* larger containers */ 12 + --radius-xl: 16px; /* prominent elements */ 13 + --radius-2xl: 24px; /* hero elements */ 14 + --radius-full: 9999px; /* pills, circles */ 15 + ``` 16 + 17 + ## typography 18 + 19 + ```css 20 + /* scale */ 21 + --text-xs: 0.75rem; /* 12px - hints, captions */ 22 + --text-sm: 0.85rem; /* 13.6px - labels, secondary */ 23 + --text-base: 0.9rem; /* 14.4px - body default */ 24 + --text-lg: 1rem; /* 16px - body emphasized */ 25 + --text-xl: 1.1rem; /* 17.6px - subheadings */ 26 + --text-2xl: 1.25rem; /* 20px - section headings */ 27 + --text-3xl: 1.5rem; /* 24px - page headings */ 28 + 29 + /* semantic aliases */ 30 + --text-page-heading: var(--text-3xl); 31 + --text-section-heading: 1.2rem; 32 + --text-body: var(--text-lg); 33 + --text-small: var(--text-base); 34 + ``` 35 + 36 + ## colors 37 + 38 + ### accent 39 + 40 + ```css 41 + --accent: #6a9fff; /* primary brand color (user-customizable) */ 42 + --accent-hover: #8ab3ff; /* hover state */ 43 + --accent-muted: #4a7ddd; /* subdued variant */ 44 + --accent-rgb: 106, 159, 255; /* for rgba() usage */ 45 + ``` 46 + 47 + ### backgrounds 48 + 49 + ```css 50 + /* dark theme */ 51 + --bg-primary: #0a0a0a; /* main background */ 52 + --bg-secondary: #141414; /* elevated surfaces */ 53 + --bg-tertiary: #1a1a1a; /* cards, modals */ 54 + --bg-hover: #1f1f1f; /* hover states */ 55 + 56 + /* light theme overrides these automatically */ 57 + ``` 58 + 59 + ### borders 60 + 61 + ```css 62 + --border-subtle: #282828; /* barely visible */ 63 + --border-default: #333333; /* standard borders */ 64 + --border-emphasis: #444444; /* highlighted borders */ 65 + ``` 66 + 67 + ### text 68 + 69 + ```css 70 + --text-primary; /* high contrast */ 71 + --text-secondary; /* medium contrast */ 72 + --text-tertiary; /* low contrast */ 73 + --text-muted; /* very low contrast */ 74 + ``` 75 + 76 + ### semantic 77 + 78 + ```css 79 + --success: #22c55e; 80 + --warning: #f59e0b; 81 + --error: #ef4444; 82 + ``` 83 + 84 + ## usage 85 + 86 + ```svelte 87 + <style> 88 + .card { 89 + border-radius: var(--radius-md); 90 + background: var(--bg-tertiary); 91 + border: 1px solid var(--border-default); 92 + } 93 + 94 + .label { 95 + font-size: var(--text-sm); 96 + color: var(--text-secondary); 97 + } 98 + 99 + input:focus { 100 + border-color: var(--accent); 101 + } 102 + </style> 103 + ``` 104 + 105 + ## anti-patterns 106 + 107 + ```css 108 + /* bad - hardcoded values */ 109 + border-radius: 8px; 110 + font-size: 14px; 111 + background: #1a1a1a; 112 + 113 + /* good - use tokens */ 114 + border-radius: var(--radius-md); 115 + font-size: var(--text-base); 116 + background: var(--bg-tertiary); 117 + ```
+34
docs/frontend/state-management.md
··· 38 - is bound to form inputs (`bind:value={title}`) 39 - is checked in reactive blocks (`$effect(() => { ... })`) 40 41 use plain `let` for: 42 - constants that never change 43 - variables only used in functions/callbacks (not template)
··· 38 - is bound to form inputs (`bind:value={title}`) 39 - is checked in reactive blocks (`$effect(() => { ... })`) 40 41 + ### overridable `$derived` for optimistic UI (Svelte 5.25+) 42 + 43 + as of Svelte 5.25, `$derived` values can be temporarily overridden by reassignment. this is the recommended pattern for optimistic UI where you want to: 44 + 1. sync with a prop value (derived behavior) 45 + 2. temporarily override for immediate feedback (state behavior) 46 + 3. auto-reset when the prop updates 47 + 48 + ```typescript 49 + // ✅ RECOMMENDED for optimistic UI (Svelte 5.25+) 50 + let liked = $derived(initialLiked); 51 + 52 + async function toggleLike() { 53 + const previous = liked; 54 + liked = !liked; // optimistic update - works in 5.25+! 55 + 56 + try { 57 + await saveLike(liked); 58 + } catch { 59 + liked = previous; // revert on failure 60 + } 61 + } 62 + ``` 63 + 64 + this replaces the older pattern of `$state` + `$effect` to sync with props: 65 + 66 + ```typescript 67 + // ❌ OLD pattern - still works but more verbose 68 + let liked = $state(initialLiked); 69 + 70 + $effect(() => { 71 + liked = initialLiked; // sync with prop 72 + }); 73 + ``` 74 + 75 use plain `let` for: 76 - constants that never change 77 - variables only used in functions/callbacks (not template)
+1
docs/local-development/setup.md
··· 53 # storage (r2 or filesystem) 54 STORAGE_BACKEND=filesystem # or "r2" for cloudflare r2 55 R2_BUCKET=audio-dev 56 R2_IMAGE_BUCKET=images-dev 57 R2_ENDPOINT_URL=<your-r2-endpoint> 58 R2_PUBLIC_BUCKET_URL=<your-r2-public-url>
··· 53 # storage (r2 or filesystem) 54 STORAGE_BACKEND=filesystem # or "r2" for cloudflare r2 55 R2_BUCKET=audio-dev 56 + R2_PRIVATE_BUCKET=audio-private-dev # for supporter-gated content 57 R2_IMAGE_BUCKET=images-dev 58 R2_ENDPOINT_URL=<your-r2-endpoint> 59 R2_PUBLIC_BUCKET_URL=<your-r2-public-url>
+259
docs/research/2025-12-20-atprotofans-paywall-integration.md
···
··· 1 + # research: atprotofans paywall integration 2 + 3 + **date**: 2025-12-20 4 + **question**: how should plyr.fm integrate with atprotofans to enable supporter-gated content? 5 + 6 + ## summary 7 + 8 + atprotofans provides a creator support platform on ATProto. plyr.fm currently has basic support link integration (#562). The full platform integration model allows defining support tiers with metadata that round-trips through validation, enabling feature gating. Implementation should proceed in phases: read-only badge display first, then platform registration, then content gating. 9 + 10 + ## current integration 11 + 12 + from PR #562, plyr.fm has: 13 + - support link mode selector in portal: none / atprotofans / custom 14 + - eligibility check queries user's PDS for `com.atprotofans.profile/self` record 15 + - profile page shows support button linking to `atprotofans.com/u/{did}` 16 + 17 + **code locations:** 18 + - `frontend/src/routes/portal/+page.svelte:137-166` - eligibility check 19 + - `frontend/src/routes/u/[handle]/+page.svelte:38-44` - support URL derivation 20 + - `backend/src/backend/api/preferences.py` - support_url validation 21 + 22 + ## atprotofans API 23 + 24 + ### validated endpoints 25 + 26 + **GET `/xrpc/com.atprotofans.validateSupporter`** 27 + 28 + validates if a user supports an artist. 29 + 30 + ``` 31 + params: 32 + supporter: did (the visitor) 33 + subject: did (the artist) 34 + signer: did (the broker/platform that signed the support template) 35 + 36 + response (not a supporter): 37 + {"valid": false} 38 + 39 + response (is a supporter): 40 + { 41 + "valid": true, 42 + "profile": { 43 + "did": "did:plc:...", 44 + "handle": "supporter.bsky.social", 45 + "displayName": "Supporter Name", 46 + ...metadata from support template 47 + } 48 + } 49 + ``` 50 + 51 + **key insight**: the `metadata` field from the support template is returned in the validation response. this enables plyr.fm to define packages and check them at runtime. 52 + 53 + ### platform integration flow 54 + 55 + from issue #564: 56 + 57 + ``` 58 + 1. plyr.fm registers as platform with did:web:plyr.fm 59 + 60 + 2. artist creates support template from portal: 61 + POST /xrpc/com.atprotofans.proposeSupportTemplate 62 + { 63 + "platform": "did:web:plyr.fm", 64 + "beneficiary": "{artist_did}", 65 + "billingCycle": "monthly", 66 + "minAmount": 1000, // cents 67 + "fees": {"platform": "5percent"}, 68 + "metadata": {"package": "early-access", "source": "plyr.fm"} 69 + } 70 + → returns template_id 71 + 72 + 3. artist approves template on atprotofans.com 73 + 74 + 4. supporter visits atprotofans.com/support/{template_id} 75 + → pays, support record created with metadata 76 + 77 + 5. plyr.fm calls validateSupporter, gets metadata back 78 + → unlocks features based on package 79 + ``` 80 + 81 + ## proposed tier system 82 + 83 + | package | price | what supporter gets | 84 + |---------|-------|---------------------| 85 + | `supporter` | $5 one-time | badge on profile, listed in supporters | 86 + | `early-access` | $10/mo | new releases 1 week early | 87 + | `lossless` | $15/mo | access to FLAC/WAV downloads | 88 + | `superfan` | $25/mo | all above + exclusive tracks | 89 + 90 + artists would choose which tiers to offer. supporters select tier on atprotofans. plyr.fm validates and gates accordingly. 91 + 92 + ## implementation phases 93 + 94 + ### phase 1: read-only validation (week 1) - IMPLEMENTED 95 + 96 + **goal**: show supporter badges, no platform registration required 97 + 98 + **status**: completed 2025-12-20 99 + 100 + 1. **add validateSupporter calls to artist page** ✓ 101 + ```typescript 102 + // when viewing artist page, if viewer is logged in: 103 + const ATPROTOFANS_BROKER_DID = 'did:plc:7ewx3bksukdk6a4vycoykhhw'; 104 + const url = new URL('https://atprotofans.com/xrpc/com.atprotofans.validateSupporter'); 105 + url.searchParams.set('supporter', auth.user.did); 106 + url.searchParams.set('subject', artist.did); 107 + url.searchParams.set('signer', ATPROTOFANS_BROKER_DID); 108 + 109 + const response = await fetch(url.toString()); 110 + if (response.ok) { 111 + const data = await response.json(); 112 + isSupporter = data.valid === true; 113 + } 114 + ``` 115 + 116 + 2. **cache validation results** - deferred 117 + - frontend calls atprotofans directly (no backend cache needed initially) 118 + - can add redis cache later if rate limiting becomes an issue 119 + 120 + 3. **display supporter badge on profile** ✓ 121 + - heart icon with "supporter" label 122 + - tooltip: "you support this artist via atprotofans" 123 + - only shown when logged-in viewer is a supporter 124 + 125 + **files changed:** 126 + - `frontend/src/routes/u/[handle]/+page.svelte` - added validation logic 127 + - `frontend/src/lib/components/SupporterBadge.svelte` - new component 128 + 129 + **implementation notes:** 130 + - calls atprotofans directly from frontend (public endpoint, no auth needed) 131 + - uses broker DID `did:plc:7ewx3bksukdk6a4vycoykhhw` as signer 132 + - only checks if artist has `support_url: 'atprotofans'` 133 + - doesn't show on your own profile 134 + 135 + ### phase 2: platform registration (week 2) 136 + 137 + **goal**: let artists create plyr.fm-specific support tiers 138 + 139 + 1. **register plyr.fm as platform** 140 + - obtain `did:web:plyr.fm` (may already have) 141 + - register with atprotofans (talk to nick) 142 + 143 + 2. **add tier configuration to portal** 144 + ```typescript 145 + // portal settings 146 + let supportTiers = $state([ 147 + { package: 'supporter', enabled: true, minAmount: 500 }, 148 + { package: 'early-access', enabled: false, minAmount: 1000 }, 149 + ]); 150 + ``` 151 + 152 + 3. **create support templates on save** 153 + - call `proposeSupportTemplate` for each enabled tier 154 + - store template_ids in artist preferences 155 + 156 + 4. **link to support page** 157 + - instead of `atprotofans.com/u/{did}` 158 + - link to `atprotofans.com/support/{template_id}` 159 + 160 + **backend changes:** 161 + - new table: `support_templates` (artist_id, package, template_id, created_at) 162 + - new endpoint: `POST /artists/me/support-templates` 163 + - atprotofans API client 164 + 165 + ### phase 3: content gating (week 3+) 166 + 167 + **goal**: restrict content access based on support tier 168 + 169 + 1. **track-level gating** 170 + - new field: `required_support_tier` on tracks 171 + - values: null (public), 'supporter', 'early-access', 'lossless', 'superfan' 172 + 173 + 2. **validation on play/download** 174 + ```python 175 + async def check_access(track: Track, viewer_did: str) -> bool: 176 + if not track.required_support_tier: 177 + return True # public 178 + 179 + validation = await atprotofans.validate_supporter( 180 + supporter=viewer_did, 181 + subject=track.artist_did, 182 + signer="did:web:plyr.fm" 183 + ) 184 + 185 + if not validation.valid: 186 + return False 187 + 188 + viewer_tier = validation.profile.get("metadata", {}).get("package") 189 + return tier_includes(viewer_tier, track.required_support_tier) 190 + ``` 191 + 192 + 3. **early access scheduling** 193 + - new fields: `public_at` timestamp, `early_access_at` timestamp 194 + - track visible to early-access supporters before public 195 + 196 + 4. **lossless file serving** 197 + - store both lossy (mp3) and lossless (flac/wav) versions 198 + - check tier before serving lossless 199 + 200 + **database changes:** 201 + - add `required_support_tier` to tracks table 202 + - add `public_at`, `early_access_at` timestamps 203 + 204 + **frontend changes:** 205 + - track upload: tier selector 206 + - track detail: locked state for non-supporters 207 + - "become a supporter" CTA with link to atprotofans 208 + 209 + ## open questions 210 + 211 + 1. **what is the signer for existing atprotofans supporters?** 212 + - when artist just has `support_url: 'atprotofans'` without platform registration 213 + - likely `signer` = artist's own DID? 214 + 215 + 2. **how do we handle expired monthly subscriptions?** 216 + - atprotofans likely returns `valid: false` for expired 217 + - need to handle grace period for cached access? 218 + 219 + 3. **should lossless files be separate uploads or auto-transcoded?** 220 + - current: only one audio file per track 221 + - lossless requires either: dual upload or transcoding service 222 + 223 + 4. **what happens to gated content if artist disables tier?** 224 + - option A: content becomes public 225 + - option B: content stays gated, just no new supporters 226 + - option C: error state 227 + 228 + 5. **how do we display "this content is supporter-only" without revealing what's behind it?** 229 + - show track title/artwork but blur? 230 + - completely hide until authenticated? 231 + 232 + ## code references 233 + 234 + current integration: 235 + - `frontend/src/routes/portal/+page.svelte:137-166` - atprotofans eligibility check 236 + - `frontend/src/routes/u/[handle]/+page.svelte:38-44` - support URL handling 237 + - `backend/src/backend/api/preferences.py` - support_url validation 238 + 239 + ## external references 240 + 241 + - [atprotofans.com](https://atprotofans.com) - the platform 242 + - issue #564 - platform integration proposal 243 + - issue #562 - basic support link (merged) 244 + - StreamPlace integration example (from nick's description in #564) 245 + 246 + ## next steps 247 + 248 + 1. **test validateSupporter with real data** 249 + - find an artist who has atprotofans supporters 250 + - verify response format and metadata structure 251 + 252 + 2. **talk to nick about platform registration** 253 + - requirements for `did:web:plyr.fm` 254 + - API authentication for `proposeSupportTemplate` 255 + - fee structure options 256 + 257 + 3. **prototype phase 1 (badges)** 258 + - start with frontend-only validation calls 259 + - no backend changes needed initially
+239
docs/research/2025-12-20-moderation-architecture-overhaul.md
···
··· 1 + # research: moderation architecture overhaul 2 + 3 + **date**: 2025-12-20 4 + **question**: how should plyr.fm evolve its moderation architecture based on Roost Osprey and Bluesky Ozone patterns? 5 + 6 + ## summary 7 + 8 + plyr.fm has a functional but minimal moderation system: AuDD copyright scanning + ATProto label emission. Osprey (Roost) provides a powerful rules engine for complex detection patterns, while Ozone (Bluesky) offers a mature moderation workflow UI. The recommendation is a phased approach: first consolidate the existing Rust labeler with Python moderation logic, then selectively adopt patterns from both projects. 9 + 10 + ## current plyr.fm architecture 11 + 12 + ### components 13 + 14 + | layer | location | purpose | 15 + |-------|----------|---------| 16 + | moderation service | `moderation/` (Rust) | AuDD scanning, label signing, XRPC endpoints | 17 + | backend integration | `backend/src/backend/_internal/moderation.py` | orchestrates scans, stores results, emits labels | 18 + | moderation client | `backend/src/backend/_internal/moderation_client.py` | HTTP client with redis caching | 19 + | background tasks | `backend/src/backend/_internal/background_tasks.py` | `sync_copyright_resolutions()` perpetual task | 20 + | frontend | `frontend/src/lib/moderation.svelte.ts` | sensitive image state management | 21 + 22 + ### data flow 23 + 24 + ``` 25 + upload → schedule_copyright_scan() → docket task 26 + 27 + moderation service /scan 28 + 29 + AuDD API 30 + 31 + store in copyright_scans table 32 + 33 + if flagged → emit_label() → labels table (signed) 34 + 35 + frontend checks labels via redis-cached API 36 + ``` 37 + 38 + ### limitations 39 + 40 + 1. **single detection type**: only copyright via AuDD fingerprinting 41 + 2. **no rules engine**: hard-coded threshold (score >= X = flagged) 42 + 3. **manual admin ui**: htmx-based but limited (no queues, no workflow states) 43 + 4. **split architecture**: sensitive images in backend, copyright labels in moderation service 44 + 5. **no audit trail**: resolutions tracked but no event sourcing 45 + 46 + ## osprey architecture (roost) 47 + 48 + ### key concepts 49 + 50 + Osprey is a **rules engine** for real-time event processing, not just a labeler. 51 + 52 + **core components:** 53 + 54 + 1. **SML rules language** - declarative Python subset for signal combining 55 + ```python 56 + Spam_Rule = Rule( 57 + when_all=[ 58 + HasLabel(entity=UserId, label='new_account'), 59 + PostFrequency(user=UserId, window=TimeDelta(hours=1)) > 10, 60 + ], 61 + description="High-frequency posting from new account" 62 + ) 63 + ``` 64 + 65 + 2. **UDF plugin system** - extensible signals and effects 66 + ```python 67 + @hookimpl_osprey 68 + def register_udfs() -> Sequence[Type[UDFBase]]: 69 + return [TextContains, AudioFingerprint, BanUser] 70 + ``` 71 + 72 + 3. **stateful labels** - labels persist and are queryable in future rules 73 + 4. **batched async execution** - gevent greenlets with automatic batching 74 + 5. **output sinks** - kafka, postgres, webhooks for result distribution 75 + 76 + ### what osprey provides that plyr.fm lacks 77 + 78 + | capability | plyr.fm | osprey | 79 + |------------|---------|--------| 80 + | multi-signal rules | no | yes (combine 10+ signals) | 81 + | label persistence | yes (basic) | yes (with TTL, query) | 82 + | rule composition | no | yes (import, require) | 83 + | batched execution | no | yes (auto-batching UDFs) | 84 + | investigation UI | minimal | full query interface | 85 + | operator visibility | limited | full rule tracing | 86 + 87 + ### adoption considerations 88 + 89 + **pros:** 90 + - could replace hard-coded copyright threshold with configurable rules 91 + - would enable combining signals (e.g., new account + flagged audio + no bio) 92 + - plugin architecture aligns with plyr.fm's need for multiple moderation types 93 + 94 + **cons:** 95 + - heavy infrastructure (kafka, druid, postgres, redis) 96 + - python-based (plyr.fm moderation service is Rust) 97 + - overkill for current scale 98 + 99 + ## ozone architecture (bluesky) 100 + 101 + ### key concepts 102 + 103 + Ozone is a **moderation workflow UI** with queue management and team coordination. 104 + 105 + **review workflow:** 106 + ``` 107 + report received → reviewOpen → (escalate?) → reviewClosed 108 + 109 + muted / appealed / takendown 110 + ``` 111 + 112 + **action types:** 113 + - acknowledge, label, tag, mute, comment 114 + - escalate, appeal, reverse takedown 115 + - email (template-based) 116 + - takedown (PDS or AppView target) 117 + - strike (graduated enforcement) 118 + 119 + ### patterns applicable to plyr.fm 120 + 121 + 1. **queue-based review** - flagged content enters queue, moderators triage 122 + 2. **event-sourced audit trail** - every action is immutable event 123 + 3. **internal tags** - team metadata not exposed to users 124 + 4. **policy-linked actions** - associate decisions with documented policies 125 + 5. **bulk CSV import/export** - batch artist verification, label claims 126 + 6. **graduated enforcement (strikes)** - automatic actions at thresholds 127 + 7. **email templates** - DMCA notices, policy violations 128 + 129 + ### recent ozone updates (dec 2025) 130 + 131 + from commits: 132 + - `ae7c30b`: default to appview takedowns 133 + - `858b6dc`: fix bulk tag operations 134 + - `8a1f333`: age assurance events with access property 135 + 136 + haley's team focus: making takedowns and policy association more robust. 137 + 138 + ## recommendation: phased approach 139 + 140 + ### phase 1: consolidate (week 1) 141 + 142 + **goal**: unify moderation into single service, adopt patterns 143 + 144 + 1. **move sensitive images to moderation service** (issue #544) 145 + - add `sensitive_images` table to moderation postgres 146 + - add `/sensitive-images` endpoint 147 + - update frontend to fetch from moderation service 148 + 149 + 2. **add event sourcing for audit trail** 150 + - new `moderation_events` table: action, subject, actor, timestamp, details 151 + - log: scans, label emissions, resolutions, sensitive flags 152 + 153 + 3. **implement negation labels on track deletion** (issue #571) 154 + - emit `neg: true` when tracks with labels are deleted 155 + - cleaner label state 156 + 157 + ### phase 2: rules engine (week 2) 158 + 159 + **goal**: replace hard-coded thresholds with configurable rules 160 + 161 + 1. **add rule configuration** (can be simple JSON/YAML to start) 162 + ```yaml 163 + rules: 164 + copyright_violation: 165 + when: 166 + - audd_score >= 85 167 + actions: 168 + - emit_label: copyright-violation 169 + 170 + suspicious_upload: 171 + when: 172 + - audd_score >= 60 173 + - account_age_days < 7 174 + actions: 175 + - emit_label: needs-review 176 + ``` 177 + 178 + 2. **extract UDF-like abstractions** for signals: 179 + - `AuddScore(track_id)` 180 + - `AccountAge(did)` 181 + - `HasPreviousFlag(did)` 182 + 183 + 3. **add admin review queue** (borrowing from ozone patterns) 184 + - list items by state: pending, reviewed, dismissed 185 + - bulk actions 186 + 187 + ### phase 3: polish (week 3 if time) 188 + 189 + **goal**: robustness and UX 190 + 191 + 1. **graduated enforcement** - track repeat offenders, auto-escalate 192 + 2. **policy association** - link decisions to documented policies 193 + 3. **email templates** - DMCA notices, takedown confirmations 194 + 195 + ## code references 196 + 197 + current moderation code: 198 + - `moderation/src/main.rs:70-101` - router setup 199 + - `moderation/src/db.rs` - label storage 200 + - `moderation/src/labels.rs` - secp256k1 signing 201 + - `backend/src/backend/_internal/moderation.py` - scan orchestration 202 + - `backend/src/backend/_internal/moderation_client.py` - HTTP client 203 + - `backend/src/backend/_internal/background_tasks.py:180-220` - sync task 204 + 205 + osprey patterns to adopt: 206 + - `osprey_worker/src/osprey/engine/executor/executor.py` - batched execution model 207 + - `osprey_worker/src/osprey/worker/adaptor/plugin_manager.py` - plugin hooks 208 + - `example_plugins/register_plugins.py` - UDF registration pattern 209 + 210 + ozone patterns to adopt: 211 + - event-sourced moderation actions 212 + - review state machine (open → escalated → closed) 213 + - bulk workspace operations 214 + 215 + ## open questions 216 + 217 + 1. **should we rewrite moderation service in python?** 218 + - pro: unified stack, easier to add rules engine 219 + - con: rust is working, label signing is performance-sensitive 220 + 221 + 2. **how much of osprey do we actually need?** 222 + - full osprey: kafka + druid + postgres + complex infra 223 + - minimal: just the rule evaluation pattern with simple config 224 + 225 + 3. **do we need real-time event processing?** 226 + - current: batch via docket (5-min perpetual task) 227 + - osprey: real-time kafka streams 228 + - likely overkill for plyr.fm scale 229 + 230 + 4. **should admin UI move to moderation service?** 231 + - currently: htmx in rust service 232 + - alternative: next.js like ozone, or svelte in frontend 233 + 234 + ## external references 235 + 236 + - [Roost Osprey](https://github.com/roostorg/osprey) - rules engine 237 + - [Bluesky Ozone](https://github.com/bluesky-social/ozone) - moderation UI 238 + - [Roost roadmap](https://github.com/roostorg/community/blob/main/roadmap.md) 239 + - [ATProto Label Spec](https://atproto.com/specs/label)
+288
docs/research/2025-12-20-supporter-gated-content-architecture.md
···
··· 1 + # research: supporter-gated content architecture 2 + 3 + **date**: 2025-12-20 4 + **question**: how do we prevent direct R2 access to paywalled audio content? 5 + 6 + ## the problem 7 + 8 + current architecture: 9 + ``` 10 + upload → R2 (public bucket) → `https://pub-xxx.r2.dev/audio/{file_id}.mp3` 11 + 12 + anyone with URL can access 13 + ``` 14 + 15 + if we add supporter-gating at the API level, users can bypass it: 16 + 1. view network requests when a supporter plays a track 17 + 2. extract the R2 URL 18 + 3. share it directly (or access it themselves without being a supporter) 19 + 20 + ## solution options 21 + 22 + ### option 1: private bucket + presigned URLs 23 + 24 + **architecture:** 25 + ``` 26 + upload → R2 (PRIVATE bucket) → not directly accessible 27 + 28 + backend generates presigned URL on demand 29 + 30 + supporter validated → 1-hour presigned URL returned 31 + not supporter → 402 "become a supporter" 32 + ``` 33 + 34 + **how it works:** 35 + ```python 36 + # backend/storage/r2.py 37 + async def get_presigned_url(self, file_id: str, expires_in: int = 3600) -> str: 38 + """generate presigned URL for private bucket access.""" 39 + async with self.async_session.client(...) as client: 40 + return await client.generate_presigned_url( 41 + 'get_object', 42 + Params={'Bucket': self.private_audio_bucket, 'Key': f'audio/{file_id}.mp3'}, 43 + ExpiresIn=expires_in 44 + ) 45 + ``` 46 + 47 + **pros:** 48 + - strong access control - no way to bypass 49 + - URLs expire automatically 50 + - standard S3 pattern, well-supported 51 + 52 + **cons:** 53 + - presigned URLs use S3 API domain (`<account>.r2.cloudflarestorage.com`), not CDN 54 + - no Cloudflare caching (every request goes to origin) 55 + - potentially higher latency and costs 56 + - need separate bucket for gated content 57 + 58 + **cost impact:** 59 + - R2 egress is free, but no CDN caching means more origin requests 60 + - Class A operations (PUT, LIST) cost more than Class B (GET) 61 + 62 + ### option 2: dual bucket (public + private) 63 + 64 + **architecture:** 65 + ``` 66 + public tracks → audio-public bucket → direct CDN URLs 67 + gated tracks → audio-private bucket → presigned URLs only 68 + ``` 69 + 70 + **upload flow:** 71 + ```python 72 + if track.required_support_tier: 73 + bucket = self.private_audio_bucket 74 + else: 75 + bucket = self.public_audio_bucket 76 + ``` 77 + 78 + **pros:** 79 + - public content stays fast (CDN cached) 80 + - only gated content needs presigned URLs 81 + - gradual migration possible 82 + 83 + **cons:** 84 + - complexity of managing two buckets 85 + - track tier change = file move between buckets 86 + - still no CDN for gated content 87 + 88 + ### option 3: cloudflare access + workers (enterprise-ish) 89 + 90 + **architecture:** 91 + ``` 92 + all audio → R2 bucket (with custom domain) 93 + 94 + Cloudflare Worker validates JWT/supporter status 95 + 96 + pass → serve from R2 97 + fail → 402 response 98 + ``` 99 + 100 + **how it works:** 101 + - custom domain on R2 bucket (e.g., `audio.plyr.fm`) 102 + - Cloudflare Worker intercepts requests 103 + - worker validates supporter token from cookie/header 104 + - if valid, proxies request to R2 105 + 106 + **pros:** 107 + - CDN caching works (huge for audio streaming) 108 + - single bucket 109 + - flexible access control 110 + 111 + **cons:** 112 + - requires custom domain setup 113 + - worker invocations add latency (~1-5ms) 114 + - more infrastructure to maintain 115 + - Cloudflare Access (proper auth) requires Pro plan 116 + 117 + ### option 4: short-lived tokens in URL path 118 + 119 + **architecture:** 120 + ``` 121 + backend generates: /audio/{token}/{file_id} 122 + 123 + token = sign({file_id, expires, user_did}) 124 + 125 + frontend plays URL normally 126 + 127 + if token invalid/expired → 403 128 + ``` 129 + 130 + **how it works:** 131 + ```python 132 + # generate token 133 + token = jwt.encode({ 134 + 'file_id': file_id, 135 + 'exp': time.time() + 3600, 136 + 'sub': viewer_did 137 + }, SECRET_KEY) 138 + 139 + url = f"https://api.plyr.fm/audio/{token}/{file_id}" 140 + 141 + # validate on request 142 + @router.get("/audio/{token}/{file_id}") 143 + async def stream_gated_audio(token: str, file_id: str): 144 + payload = jwt.decode(token, SECRET_KEY) 145 + if payload['file_id'] != file_id: 146 + raise HTTPException(403) 147 + if payload['exp'] < time.time(): 148 + raise HTTPException(403, "URL expired") 149 + 150 + # proxy to R2 or redirect to presigned URL 151 + return RedirectResponse(await storage.get_presigned_url(file_id)) 152 + ``` 153 + 154 + **pros:** 155 + - works with existing backend 156 + - no new infrastructure 157 + - token validates both file and user 158 + 159 + **cons:** 160 + - every request hits backend (no CDN) 161 + - sharing URL shares access (until expiry) 162 + - backend becomes bottleneck for streaming 163 + 164 + ## recommendation 165 + 166 + **for MVP (phase 1-2)**: option 2 (dual bucket) 167 + 168 + rationale: 169 + - public content (majority) stays fast 170 + - gated content works correctly, just slightly slower 171 + - simple to implement with existing boto3 code 172 + - no new infrastructure needed 173 + 174 + **for scale (phase 3+)**: option 3 (workers) 175 + 176 + rationale: 177 + - CDN caching for all content 178 + - better streaming performance 179 + - more flexible access control 180 + - worth the complexity at scale 181 + 182 + ## implementation plan for dual bucket 183 + 184 + ### 1. create private bucket 185 + 186 + ```bash 187 + # create private audio bucket (no public access) 188 + wrangler r2 bucket create audio-private-dev 189 + wrangler r2 bucket create audio-private-staging 190 + wrangler r2 bucket create audio-private-prod 191 + ``` 192 + 193 + ### 2. add config 194 + 195 + ```python 196 + # config.py 197 + r2_private_audio_bucket: str = Field( 198 + default="", 199 + validation_alias="R2_PRIVATE_AUDIO_BUCKET", 200 + description="R2 private bucket for supporter-gated audio", 201 + ) 202 + ``` 203 + 204 + ### 3. update R2Storage 205 + 206 + ```python 207 + # storage/r2.py 208 + async def save_gated(self, file: BinaryIO, filename: str, ...) -> str: 209 + """save to private bucket for gated content.""" 210 + # same as save() but uses private_audio_bucket 211 + 212 + async def get_presigned_url(self, file_id: str, expires_in: int = 3600) -> str: 213 + """generate presigned URL for private content.""" 214 + key = f"audio/{file_id}.mp3" # need extension from DB 215 + return self.client.generate_presigned_url( 216 + 'get_object', 217 + Params={'Bucket': self.private_audio_bucket, 'Key': key}, 218 + ExpiresIn=expires_in 219 + ) 220 + ``` 221 + 222 + ### 4. update audio endpoint 223 + 224 + ```python 225 + # api/audio.py 226 + @router.get("/{file_id}") 227 + async def stream_audio(file_id: str, session: Session | None = Depends(optional_auth)): 228 + track = await get_track_by_file_id(file_id) 229 + 230 + if track.required_support_tier: 231 + # gated content - validate supporter status 232 + if not session: 233 + raise HTTPException(401, "login required") 234 + 235 + is_supporter = await validate_supporter( 236 + supporter=session.did, 237 + subject=track.artist_did 238 + ) 239 + 240 + if not is_supporter: 241 + raise HTTPException(402, "supporter access required") 242 + 243 + # return presigned URL for private bucket 244 + url = await storage.get_presigned_url(file_id) 245 + return RedirectResponse(url) 246 + 247 + # public content - use CDN URL 248 + return RedirectResponse(track.r2_url) 249 + ``` 250 + 251 + ### 5. upload flow change 252 + 253 + ```python 254 + # api/tracks.py (in upload handler) 255 + if required_support_tier: 256 + file_id = await storage.save_gated(file, filename) 257 + # no public URL - will be generated on demand 258 + r2_url = None 259 + else: 260 + file_id = await storage.save(file, filename) 261 + r2_url = f"{settings.storage.r2_public_bucket_url}/audio/{file_id}{ext}" 262 + ``` 263 + 264 + ## open questions 265 + 266 + 1. **what about tier changes?** 267 + - if artist makes public track → gated: need to move file to private bucket 268 + - if gated → public: move to public bucket 269 + - or: store everything in private, just serve presigned URLs for everything (simpler but slower) 270 + 271 + 2. **presigned URL expiry for long audio?** 272 + - 1 hour default should be plenty for any track 273 + - frontend can request new URL if needed mid-playback 274 + 275 + 3. **should we cache presigned URLs?** 276 + - could cache for 30 minutes to reduce generation overhead 277 + - but then revocation is delayed 278 + 279 + 4. **offline mode interaction?** 280 + - supporters could download gated tracks for offline 281 + - presigned URL works for initial download 282 + - cached locally, no expiry concern 283 + 284 + ## references 285 + 286 + - [Cloudflare R2 presigned URLs docs](https://developers.cloudflare.com/r2/api/s3/presigned-urls/) 287 + - [Cloudflare R2 + Access protection](https://developers.cloudflare.com/r2/tutorials/cloudflare-access/) 288 + - boto3 `generate_presigned_url()` - already available in our client
+53
docs/security.md
··· 24 * **`X-XSS-Protection: 1; mode=block`:** Enables browser cross-site scripting filters. 25 * **`Referrer-Policy: strict-origin-when-cross-origin`:** Controls how much referrer information is included with requests. 26 27 ## CORS 28 29 Cross-Origin Resource Sharing (CORS) is configured to allow:
··· 24 * **`X-XSS-Protection: 1; mode=block`:** Enables browser cross-site scripting filters. 25 * **`Referrer-Policy: strict-origin-when-cross-origin`:** Controls how much referrer information is included with requests. 26 27 + ## Supporter-Gated Content 28 + 29 + Tracks with `support_gate` set require atprotofans supporter validation before streaming. 30 + 31 + ### Access Model 32 + 33 + ``` 34 + request → /audio/{file_id} → check support_gate 35 + 36 + ┌──────────┴──────────┐ 37 + ↓ ↓ 38 + public gated track 39 + ↓ ↓ 40 + 307 → R2 CDN validate_supporter() 41 + 42 + ┌──────────┴──────────┐ 43 + ↓ ↓ 44 + is supporter not supporter 45 + ↓ ↓ 46 + presigned URL (5min) 402 error 47 + ``` 48 + 49 + ### Storage Architecture 50 + 51 + - **public bucket**: `plyr-audio` - CDN-backed, public read access 52 + - **private bucket**: `plyr-audio-private` - no public access, presigned URLs only 53 + 54 + when `support_gate` is toggled, a background task moves the file between buckets. 55 + 56 + ### Presigned URL Behavior 57 + 58 + presigned URLs are time-limited (5 minutes) and grant direct R2 access. security considerations: 59 + 60 + 1. **URL sharing**: a supporter could share the presigned URL. mitigation: short TTL, URLs expire quickly. 61 + 62 + 2. **offline caching**: if a supporter downloads content (via "download liked tracks"), the cached audio persists locally even if support lapses. this is **intentional** - they legitimately accessed it when authorized. 63 + 64 + 3. **auto-download + gated tracks**: the `gated` field is viewer-resolved (true = no access, false = has access). when liking a track with auto-download enabled: 65 + - **supporters** (`gated === false`): download proceeds normally via presigned URL 66 + - **non-supporters** (`gated === true`): download is skipped client-side to avoid wasted 402 requests 67 + 68 + ### ATProto Record Behavior 69 + 70 + when a track is gated, the ATProto `fm.plyr.track` record's `audioUrl` changes: 71 + - **public**: points to R2 CDN URL (e.g., `https://cdn.plyr.fm/audio/abc123.mp3`) 72 + - **gated**: points to API endpoint (e.g., `https://api.plyr.fm/audio/abc123`) 73 + 74 + this means ATProto clients cannot stream gated content without authentication through plyr.fm's API. 75 + 76 + ### Validation Caching 77 + 78 + currently, `validate_supporter()` makes a fresh call to atprotofans on every request. for high-traffic gated tracks, consider adding a short TTL cache (e.g., 60s in redis) to reduce latency and avoid rate limits. 79 + 80 ## CORS 81 82 Cross-Origin Resource Sharing (CORS) is configured to allow:
+1
frontend/CLAUDE.md
··· 6 - **state**: global managers in `lib/*.svelte.ts` using `$state` runes (player, queue, uploader, tracks cache) 7 - **components**: reusable ui in `lib/components/` (LikeButton, Toast, Player, etc) 8 - **routes**: pages in `routes/` with `+page.svelte` and `+page.ts` for data loading 9 10 gotchas: 11 - **svelte 5 runes mode**: component-local state MUST use `$state()` - plain `let` has no reactivity (see `docs/frontend/state-management.md`)
··· 6 - **state**: global managers in `lib/*.svelte.ts` using `$state` runes (player, queue, uploader, tracks cache) 7 - **components**: reusable ui in `lib/components/` (LikeButton, Toast, Player, etc) 8 - **routes**: pages in `routes/` with `+page.svelte` and `+page.ts` for data loading 9 + - **design tokens**: use CSS variables from `+layout.svelte` - never hardcode colors, radii, or font sizes (see `docs/frontend/design-tokens.md`) 10 11 gotchas: 12 - **svelte 5 runes mode**: component-local state MUST use `$state()` - plain `let` has no reactivity (see `docs/frontend/state-management.md`)
+29
frontend/src/lib/breakpoints.ts
···
··· 1 + /** 2 + * responsive breakpoints 3 + * 4 + * CSS media queries can't use CSS variables, so we document the values here 5 + * as the single source of truth. when changing breakpoints, update both this 6 + * file and the corresponding @media queries in components. 7 + * 8 + * usage in components: 9 + * @media (max-width: 768px) { ... } // mobile 10 + * @media (max-width: 1100px) { ... } // header mobile (needs margin space) 11 + */ 12 + 13 + /** standard mobile breakpoint - used by most components */ 14 + export const MOBILE_BREAKPOINT = 768; 15 + 16 + /** small mobile breakpoint - extra compact styles */ 17 + export const MOBILE_SMALL_BREAKPOINT = 480; 18 + 19 + /** 20 + * header mobile breakpoint - switch to mobile layout before margin elements 21 + * (stats, search, logout) crowd each other. 22 + */ 23 + export const HEADER_MOBILE_BREAKPOINT = 1300; 24 + 25 + /** content max-width used across pages */ 26 + export const CONTENT_MAX_WIDTH = 800; 27 + 28 + /** queue panel width */ 29 + export const QUEUE_WIDTH = 360;
+19 -17
frontend/src/lib/components/AddToMenu.svelte
··· 10 trackUri?: string; 11 trackCid?: string; 12 fileId?: string; 13 initialLiked?: boolean; 14 disabled?: boolean; 15 disabledReason?: string; ··· 25 trackUri, 26 trackCid, 27 fileId, 28 initialLiked = false, 29 disabled = false, 30 disabledReason, ··· 102 103 try { 104 const success = liked 105 - ? await likeTrack(trackId, fileId) 106 : await unlikeTrack(trackId); 107 108 if (!success) { ··· 437 justify-content: center; 438 background: transparent; 439 border: 1px solid var(--border-default); 440 - border-radius: 4px; 441 color: var(--text-tertiary); 442 cursor: pointer; 443 transition: all 0.2s; ··· 488 min-width: 200px; 489 background: var(--bg-secondary); 490 border: 1px solid var(--border-default); 491 - border-radius: 8px; 492 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); 493 overflow: hidden; 494 z-index: 10; ··· 508 background: transparent; 509 border: none; 510 color: var(--text-primary); 511 - font-size: 0.9rem; 512 font-family: inherit; 513 cursor: pointer; 514 transition: background 0.15s; ··· 542 border: none; 543 border-bottom: 1px solid var(--border-subtle); 544 color: var(--text-secondary); 545 - font-size: 0.85rem; 546 font-family: inherit; 547 cursor: pointer; 548 transition: background 0.15s; ··· 565 566 .playlist-list::-webkit-scrollbar-track { 567 background: transparent; 568 - border-radius: 4px; 569 } 570 571 .playlist-list::-webkit-scrollbar-thumb { 572 background: var(--border-default); 573 - border-radius: 4px; 574 } 575 576 .playlist-list::-webkit-scrollbar-thumb:hover { ··· 586 background: transparent; 587 border: none; 588 color: var(--text-primary); 589 - font-size: 0.9rem; 590 font-family: inherit; 591 cursor: pointer; 592 transition: background 0.15s; ··· 605 .playlist-thumb-placeholder { 606 width: 32px; 607 height: 32px; 608 - border-radius: 4px; 609 flex-shrink: 0; 610 } 611 ··· 637 gap: 0.5rem; 638 padding: 1.5rem 1rem; 639 color: var(--text-tertiary); 640 - font-size: 0.85rem; 641 } 642 643 .create-playlist-btn { ··· 650 border: none; 651 border-top: 1px solid var(--border-subtle); 652 color: var(--accent); 653 - font-size: 0.9rem; 654 font-family: inherit; 655 cursor: pointer; 656 transition: background 0.15s; ··· 673 padding: 0.625rem 0.75rem; 674 background: var(--bg-tertiary); 675 border: 1px solid var(--border-default); 676 - border-radius: 6px; 677 color: var(--text-primary); 678 font-family: inherit; 679 - font-size: 0.9rem; 680 } 681 682 .create-form input:focus { ··· 696 padding: 0.625rem 1rem; 697 background: var(--accent); 698 border: none; 699 - border-radius: 6px; 700 color: white; 701 font-family: inherit; 702 - font-size: 0.9rem; 703 font-weight: 500; 704 cursor: pointer; 705 transition: opacity 0.15s; ··· 719 height: 16px; 720 border: 2px solid var(--border-default); 721 border-top-color: var(--accent); 722 - border-radius: 50%; 723 animation: spin 0.8s linear infinite; 724 } 725 ··· 781 782 .menu-item { 783 padding: 1rem 1.25rem; 784 - font-size: 1rem; 785 } 786 787 .back-button {
··· 10 trackUri?: string; 11 trackCid?: string; 12 fileId?: string; 13 + gated?: boolean; 14 initialLiked?: boolean; 15 disabled?: boolean; 16 disabledReason?: string; ··· 26 trackUri, 27 trackCid, 28 fileId, 29 + gated, 30 initialLiked = false, 31 disabled = false, 32 disabledReason, ··· 104 105 try { 106 const success = liked 107 + ? await likeTrack(trackId, fileId, gated) 108 : await unlikeTrack(trackId); 109 110 if (!success) { ··· 439 justify-content: center; 440 background: transparent; 441 border: 1px solid var(--border-default); 442 + border-radius: var(--radius-sm); 443 color: var(--text-tertiary); 444 cursor: pointer; 445 transition: all 0.2s; ··· 490 min-width: 200px; 491 background: var(--bg-secondary); 492 border: 1px solid var(--border-default); 493 + border-radius: var(--radius-md); 494 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); 495 overflow: hidden; 496 z-index: 10; ··· 510 background: transparent; 511 border: none; 512 color: var(--text-primary); 513 + font-size: var(--text-base); 514 font-family: inherit; 515 cursor: pointer; 516 transition: background 0.15s; ··· 544 border: none; 545 border-bottom: 1px solid var(--border-subtle); 546 color: var(--text-secondary); 547 + font-size: var(--text-sm); 548 font-family: inherit; 549 cursor: pointer; 550 transition: background 0.15s; ··· 567 568 .playlist-list::-webkit-scrollbar-track { 569 background: transparent; 570 + border-radius: var(--radius-sm); 571 } 572 573 .playlist-list::-webkit-scrollbar-thumb { 574 background: var(--border-default); 575 + border-radius: var(--radius-sm); 576 } 577 578 .playlist-list::-webkit-scrollbar-thumb:hover { ··· 588 background: transparent; 589 border: none; 590 color: var(--text-primary); 591 + font-size: var(--text-base); 592 font-family: inherit; 593 cursor: pointer; 594 transition: background 0.15s; ··· 607 .playlist-thumb-placeholder { 608 width: 32px; 609 height: 32px; 610 + border-radius: var(--radius-sm); 611 flex-shrink: 0; 612 } 613 ··· 639 gap: 0.5rem; 640 padding: 1.5rem 1rem; 641 color: var(--text-tertiary); 642 + font-size: var(--text-sm); 643 } 644 645 .create-playlist-btn { ··· 652 border: none; 653 border-top: 1px solid var(--border-subtle); 654 color: var(--accent); 655 + font-size: var(--text-base); 656 font-family: inherit; 657 cursor: pointer; 658 transition: background 0.15s; ··· 675 padding: 0.625rem 0.75rem; 676 background: var(--bg-tertiary); 677 border: 1px solid var(--border-default); 678 + border-radius: var(--radius-base); 679 color: var(--text-primary); 680 font-family: inherit; 681 + font-size: var(--text-base); 682 } 683 684 .create-form input:focus { ··· 698 padding: 0.625rem 1rem; 699 background: var(--accent); 700 border: none; 701 + border-radius: var(--radius-base); 702 color: white; 703 font-family: inherit; 704 + font-size: var(--text-base); 705 font-weight: 500; 706 cursor: pointer; 707 transition: opacity 0.15s; ··· 721 height: 16px; 722 border: 2px solid var(--border-default); 723 border-top-color: var(--accent); 724 + border-radius: var(--radius-full); 725 animation: spin 0.8s linear infinite; 726 } 727 ··· 783 784 .menu-item { 785 padding: 1rem 1.25rem; 786 + font-size: var(--text-lg); 787 } 788 789 .back-button {
+7 -7
frontend/src/lib/components/AlbumSelect.svelte
··· 102 padding: 0.75rem; 103 background: var(--bg-primary); 104 border: 1px solid var(--border-default); 105 - border-radius: 4px; 106 color: var(--text-primary); 107 - font-size: 1rem; 108 font-family: inherit; 109 transition: all 0.2s; 110 } ··· 127 overflow-y: auto; 128 background: var(--bg-tertiary); 129 border: 1px solid var(--border-default); 130 - border-radius: 4px; 131 margin-top: 0.25rem; 132 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 133 } ··· 139 140 .album-results::-webkit-scrollbar-track { 141 background: var(--bg-primary); 142 - border-radius: 4px; 143 } 144 145 .album-results::-webkit-scrollbar-thumb { 146 background: var(--border-default); 147 - border-radius: 4px; 148 } 149 150 .album-results::-webkit-scrollbar-thumb:hover { ··· 203 } 204 205 .album-stats { 206 - font-size: 0.85rem; 207 color: var(--text-tertiary); 208 overflow: hidden; 209 text-overflow: ellipsis; ··· 212 213 .similar-hint { 214 margin-top: 0.5rem; 215 - font-size: 0.85rem; 216 color: var(--warning); 217 font-style: italic; 218 margin-bottom: 0;
··· 102 padding: 0.75rem; 103 background: var(--bg-primary); 104 border: 1px solid var(--border-default); 105 + border-radius: var(--radius-sm); 106 color: var(--text-primary); 107 + font-size: var(--text-lg); 108 font-family: inherit; 109 transition: all 0.2s; 110 } ··· 127 overflow-y: auto; 128 background: var(--bg-tertiary); 129 border: 1px solid var(--border-default); 130 + border-radius: var(--radius-sm); 131 margin-top: 0.25rem; 132 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 133 } ··· 139 140 .album-results::-webkit-scrollbar-track { 141 background: var(--bg-primary); 142 + border-radius: var(--radius-sm); 143 } 144 145 .album-results::-webkit-scrollbar-thumb { 146 background: var(--border-default); 147 + border-radius: var(--radius-sm); 148 } 149 150 .album-results::-webkit-scrollbar-thumb:hover { ··· 203 } 204 205 .album-stats { 206 + font-size: var(--text-sm); 207 color: var(--text-tertiary); 208 overflow: hidden; 209 text-overflow: ellipsis; ··· 212 213 .similar-hint { 214 margin-top: 0.5rem; 215 + font-size: var(--text-sm); 216 color: var(--warning); 217 font-style: italic; 218 margin-bottom: 0;
+15 -15
frontend/src/lib/components/BrokenTracks.svelte
··· 194 margin-bottom: 3rem; 195 background: color-mix(in srgb, var(--warning) 5%, transparent); 196 border: 1px solid color-mix(in srgb, var(--warning) 20%, transparent); 197 - border-radius: 8px; 198 padding: 1.5rem; 199 } 200 ··· 213 } 214 215 .section-header h2 { 216 - font-size: 1.5rem; 217 margin: 0; 218 color: var(--warning); 219 } ··· 222 padding: 0.5rem 1rem; 223 background: color-mix(in srgb, var(--warning) 20%, transparent); 224 border: 1px solid color-mix(in srgb, var(--warning) 50%, transparent); 225 - border-radius: 4px; 226 color: var(--warning); 227 font-family: inherit; 228 - font-size: 0.9rem; 229 font-weight: 600; 230 cursor: pointer; 231 transition: all 0.2s; ··· 248 background: color-mix(in srgb, var(--warning) 20%, transparent); 249 color: var(--warning); 250 padding: 0.25rem 0.6rem; 251 - border-radius: 12px; 252 - font-size: 0.85rem; 253 font-weight: 600; 254 } 255 ··· 263 .broken-track-item { 264 background: var(--bg-tertiary); 265 border: 1px solid color-mix(in srgb, var(--warning) 30%, transparent); 266 - border-radius: 6px; 267 padding: 1rem; 268 display: flex; 269 align-items: center; ··· 280 } 281 282 .warning-icon { 283 - font-size: 1.25rem; 284 flex-shrink: 0; 285 } 286 ··· 291 292 .track-title { 293 font-weight: 600; 294 - font-size: 1rem; 295 margin-bottom: 0.25rem; 296 color: var(--text-primary); 297 } 298 299 .track-meta { 300 - font-size: 0.9rem; 301 color: var(--text-secondary); 302 margin-bottom: 0.5rem; 303 } 304 305 .issue-description { 306 - font-size: 0.85rem; 307 color: var(--warning); 308 } 309 ··· 311 padding: 0.5rem 1rem; 312 background: color-mix(in srgb, var(--warning) 15%, transparent); 313 border: 1px solid color-mix(in srgb, var(--warning) 40%, transparent); 314 - border-radius: 4px; 315 color: var(--warning); 316 font-family: inherit; 317 - font-size: 0.9rem; 318 font-weight: 500; 319 cursor: pointer; 320 transition: all 0.2s; ··· 337 .info-box { 338 background: var(--bg-primary); 339 border: 1px solid var(--border-subtle); 340 - border-radius: 6px; 341 padding: 1rem; 342 - font-size: 0.9rem; 343 color: var(--text-secondary); 344 } 345
··· 194 margin-bottom: 3rem; 195 background: color-mix(in srgb, var(--warning) 5%, transparent); 196 border: 1px solid color-mix(in srgb, var(--warning) 20%, transparent); 197 + border-radius: var(--radius-md); 198 padding: 1.5rem; 199 } 200 ··· 213 } 214 215 .section-header h2 { 216 + font-size: var(--text-3xl); 217 margin: 0; 218 color: var(--warning); 219 } ··· 222 padding: 0.5rem 1rem; 223 background: color-mix(in srgb, var(--warning) 20%, transparent); 224 border: 1px solid color-mix(in srgb, var(--warning) 50%, transparent); 225 + border-radius: var(--radius-sm); 226 color: var(--warning); 227 font-family: inherit; 228 + font-size: var(--text-base); 229 font-weight: 600; 230 cursor: pointer; 231 transition: all 0.2s; ··· 248 background: color-mix(in srgb, var(--warning) 20%, transparent); 249 color: var(--warning); 250 padding: 0.25rem 0.6rem; 251 + border-radius: var(--radius-lg); 252 + font-size: var(--text-sm); 253 font-weight: 600; 254 } 255 ··· 263 .broken-track-item { 264 background: var(--bg-tertiary); 265 border: 1px solid color-mix(in srgb, var(--warning) 30%, transparent); 266 + border-radius: var(--radius-base); 267 padding: 1rem; 268 display: flex; 269 align-items: center; ··· 280 } 281 282 .warning-icon { 283 + font-size: var(--text-2xl); 284 flex-shrink: 0; 285 } 286 ··· 291 292 .track-title { 293 font-weight: 600; 294 + font-size: var(--text-lg); 295 margin-bottom: 0.25rem; 296 color: var(--text-primary); 297 } 298 299 .track-meta { 300 + font-size: var(--text-base); 301 color: var(--text-secondary); 302 margin-bottom: 0.5rem; 303 } 304 305 .issue-description { 306 + font-size: var(--text-sm); 307 color: var(--warning); 308 } 309 ··· 311 padding: 0.5rem 1rem; 312 background: color-mix(in srgb, var(--warning) 15%, transparent); 313 border: 1px solid color-mix(in srgb, var(--warning) 40%, transparent); 314 + border-radius: var(--radius-sm); 315 color: var(--warning); 316 font-family: inherit; 317 + font-size: var(--text-base); 318 font-weight: 500; 319 cursor: pointer; 320 transition: all 0.2s; ··· 337 .info-box { 338 background: var(--bg-primary); 339 border: 1px solid var(--border-subtle); 340 + border-radius: var(--radius-base); 341 padding: 1rem; 342 + font-size: var(--text-base); 343 color: var(--text-secondary); 344 } 345
+8 -8
frontend/src/lib/components/ColorSettings.svelte
··· 115 border: 1px solid var(--border-default); 116 color: var(--text-secondary); 117 padding: 0.5rem; 118 - border-radius: 4px; 119 cursor: pointer; 120 transition: all 0.2s; 121 display: flex; ··· 134 right: 0; 135 background: var(--bg-secondary); 136 border: 1px solid var(--border-default); 137 - border-radius: 6px; 138 padding: 1rem; 139 min-width: 240px; 140 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); ··· 147 align-items: center; 148 margin-bottom: 1rem; 149 color: var(--text-primary); 150 - font-size: 0.9rem; 151 } 152 153 .close-btn { 154 background: transparent; 155 border: none; 156 color: var(--text-secondary); 157 - font-size: 1.5rem; 158 cursor: pointer; 159 padding: 0; 160 width: 24px; ··· 182 width: 48px; 183 height: 32px; 184 border: 1px solid var(--border-default); 185 - border-radius: 4px; 186 cursor: pointer; 187 background: transparent; 188 } ··· 198 199 .color-value { 200 font-family: monospace; 201 - font-size: 0.85rem; 202 color: var(--text-secondary); 203 } 204 ··· 209 } 210 211 .presets-label { 212 - font-size: 0.85rem; 213 color: var(--text-tertiary); 214 } 215 ··· 222 .preset-btn { 223 width: 32px; 224 height: 32px; 225 - border-radius: 4px; 226 border: 2px solid transparent; 227 cursor: pointer; 228 transition: all 0.2s;
··· 115 border: 1px solid var(--border-default); 116 color: var(--text-secondary); 117 padding: 0.5rem; 118 + border-radius: var(--radius-sm); 119 cursor: pointer; 120 transition: all 0.2s; 121 display: flex; ··· 134 right: 0; 135 background: var(--bg-secondary); 136 border: 1px solid var(--border-default); 137 + border-radius: var(--radius-base); 138 padding: 1rem; 139 min-width: 240px; 140 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); ··· 147 align-items: center; 148 margin-bottom: 1rem; 149 color: var(--text-primary); 150 + font-size: var(--text-base); 151 } 152 153 .close-btn { 154 background: transparent; 155 border: none; 156 color: var(--text-secondary); 157 + font-size: var(--text-3xl); 158 cursor: pointer; 159 padding: 0; 160 width: 24px; ··· 182 width: 48px; 183 height: 32px; 184 border: 1px solid var(--border-default); 185 + border-radius: var(--radius-sm); 186 cursor: pointer; 187 background: transparent; 188 } ··· 198 199 .color-value { 200 font-family: monospace; 201 + font-size: var(--text-sm); 202 color: var(--text-secondary); 203 } 204 ··· 209 } 210 211 .presets-label { 212 + font-size: var(--text-sm); 213 color: var(--text-tertiary); 214 } 215 ··· 222 .preset-btn { 223 width: 32px; 224 height: 32px; 225 + border-radius: var(--radius-sm); 226 border: 2px solid transparent; 227 cursor: pointer; 228 transition: all 0.2s;
+9 -9
frontend/src/lib/components/HandleAutocomplete.svelte
··· 131 padding: 0.75rem; 132 background: var(--bg-primary); 133 border: 1px solid var(--border-default); 134 - border-radius: 4px; 135 color: var(--text-primary); 136 - font-size: 1rem; 137 font-family: inherit; 138 transition: border-color 0.2s; 139 box-sizing: border-box; ··· 159 top: 50%; 160 transform: translateY(-50%); 161 color: var(--text-muted); 162 - font-size: 0.85rem; 163 } 164 165 .results { ··· 170 overflow-y: auto; 171 background: var(--bg-tertiary); 172 border: 1px solid var(--border-default); 173 - border-radius: 4px; 174 margin-top: 0.25rem; 175 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 176 scrollbar-width: thin; ··· 183 184 .results::-webkit-scrollbar-track { 185 background: var(--bg-primary); 186 - border-radius: 4px; 187 } 188 189 .results::-webkit-scrollbar-thumb { 190 background: var(--border-default); 191 - border-radius: 4px; 192 } 193 194 .results::-webkit-scrollbar-thumb:hover { ··· 222 .avatar { 223 width: 36px; 224 height: 36px; 225 - border-radius: 50%; 226 object-fit: cover; 227 border: 2px solid var(--border-default); 228 flex-shrink: 0; ··· 231 .avatar-placeholder { 232 width: 36px; 233 height: 36px; 234 - border-radius: 50%; 235 background: var(--border-default); 236 flex-shrink: 0; 237 } ··· 252 } 253 254 .handle { 255 - font-size: 0.85rem; 256 color: var(--text-tertiary); 257 overflow: hidden; 258 text-overflow: ellipsis;
··· 131 padding: 0.75rem; 132 background: var(--bg-primary); 133 border: 1px solid var(--border-default); 134 + border-radius: var(--radius-sm); 135 color: var(--text-primary); 136 + font-size: var(--text-lg); 137 font-family: inherit; 138 transition: border-color 0.2s; 139 box-sizing: border-box; ··· 159 top: 50%; 160 transform: translateY(-50%); 161 color: var(--text-muted); 162 + font-size: var(--text-sm); 163 } 164 165 .results { ··· 170 overflow-y: auto; 171 background: var(--bg-tertiary); 172 border: 1px solid var(--border-default); 173 + border-radius: var(--radius-sm); 174 margin-top: 0.25rem; 175 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 176 scrollbar-width: thin; ··· 183 184 .results::-webkit-scrollbar-track { 185 background: var(--bg-primary); 186 + border-radius: var(--radius-sm); 187 } 188 189 .results::-webkit-scrollbar-thumb { 190 background: var(--border-default); 191 + border-radius: var(--radius-sm); 192 } 193 194 .results::-webkit-scrollbar-thumb:hover { ··· 222 .avatar { 223 width: 36px; 224 height: 36px; 225 + border-radius: var(--radius-full); 226 object-fit: cover; 227 border: 2px solid var(--border-default); 228 flex-shrink: 0; ··· 231 .avatar-placeholder { 232 width: 36px; 233 height: 36px; 234 + border-radius: var(--radius-full); 235 background: var(--border-default); 236 flex-shrink: 0; 237 } ··· 252 } 253 254 .handle { 255 + font-size: var(--text-sm); 256 color: var(--text-tertiary); 257 overflow: hidden; 258 text-overflow: ellipsis;
+15 -15
frontend/src/lib/components/HandleSearch.svelte
··· 179 padding: 0.75rem; 180 background: var(--bg-primary); 181 border: 1px solid var(--border-default); 182 - border-radius: 4px; 183 color: var(--text-primary); 184 - font-size: 1rem; 185 font-family: inherit; 186 transition: all 0.2s; 187 } ··· 201 right: 0.75rem; 202 top: 50%; 203 transform: translateY(-50%); 204 - font-size: 0.85rem; 205 color: var(--text-muted); 206 } 207 ··· 213 overflow-y: auto; 214 background: var(--bg-tertiary); 215 border: 1px solid var(--border-default); 216 - border-radius: 4px; 217 margin-top: 0.25rem; 218 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 219 } ··· 225 226 .search-results::-webkit-scrollbar-track { 227 background: var(--bg-primary); 228 - border-radius: 4px; 229 } 230 231 .search-results::-webkit-scrollbar-thumb { 232 background: var(--border-default); 233 - border-radius: 4px; 234 } 235 236 .search-results::-webkit-scrollbar-thumb:hover { ··· 277 .result-avatar { 278 width: 36px; 279 height: 36px; 280 - border-radius: 50%; 281 object-fit: cover; 282 border: 2px solid var(--border-default); 283 flex-shrink: 0; ··· 299 } 300 301 .result-handle { 302 - font-size: 0.85rem; 303 color: var(--text-tertiary); 304 overflow: hidden; 305 text-overflow: ellipsis; ··· 320 padding: 0.5rem 0.75rem; 321 background: color-mix(in srgb, var(--accent) 10%, var(--bg-tertiary)); 322 border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border-subtle)); 323 - border-radius: 20px; 324 color: var(--text-primary); 325 - font-size: 0.9rem; 326 } 327 328 .chip-avatar { 329 width: 24px; 330 height: 24px; 331 - border-radius: 50%; 332 object-fit: cover; 333 border: 1px solid var(--border-default); 334 } ··· 365 366 .max-features-message { 367 margin-top: 0.5rem; 368 - font-size: 0.85rem; 369 color: var(--warning); 370 } 371 ··· 374 padding: 0.75rem; 375 background: color-mix(in srgb, var(--warning) 10%, var(--bg-primary)); 376 border: 1px solid color-mix(in srgb, var(--warning) 20%, var(--border-subtle)); 377 - border-radius: 4px; 378 color: var(--warning); 379 - font-size: 0.9rem; 380 text-align: center; 381 } 382 ··· 401 402 .selected-artist-chip { 403 padding: 0.4rem 0.6rem; 404 - font-size: 0.85rem; 405 } 406 407 .chip-avatar {
··· 179 padding: 0.75rem; 180 background: var(--bg-primary); 181 border: 1px solid var(--border-default); 182 + border-radius: var(--radius-sm); 183 color: var(--text-primary); 184 + font-size: var(--text-lg); 185 font-family: inherit; 186 transition: all 0.2s; 187 } ··· 201 right: 0.75rem; 202 top: 50%; 203 transform: translateY(-50%); 204 + font-size: var(--text-sm); 205 color: var(--text-muted); 206 } 207 ··· 213 overflow-y: auto; 214 background: var(--bg-tertiary); 215 border: 1px solid var(--border-default); 216 + border-radius: var(--radius-sm); 217 margin-top: 0.25rem; 218 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 219 } ··· 225 226 .search-results::-webkit-scrollbar-track { 227 background: var(--bg-primary); 228 + border-radius: var(--radius-sm); 229 } 230 231 .search-results::-webkit-scrollbar-thumb { 232 background: var(--border-default); 233 + border-radius: var(--radius-sm); 234 } 235 236 .search-results::-webkit-scrollbar-thumb:hover { ··· 277 .result-avatar { 278 width: 36px; 279 height: 36px; 280 + border-radius: var(--radius-full); 281 object-fit: cover; 282 border: 2px solid var(--border-default); 283 flex-shrink: 0; ··· 299 } 300 301 .result-handle { 302 + font-size: var(--text-sm); 303 color: var(--text-tertiary); 304 overflow: hidden; 305 text-overflow: ellipsis; ··· 320 padding: 0.5rem 0.75rem; 321 background: color-mix(in srgb, var(--accent) 10%, var(--bg-tertiary)); 322 border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border-subtle)); 323 + border-radius: var(--radius-xl); 324 color: var(--text-primary); 325 + font-size: var(--text-base); 326 } 327 328 .chip-avatar { 329 width: 24px; 330 height: 24px; 331 + border-radius: var(--radius-full); 332 object-fit: cover; 333 border: 1px solid var(--border-default); 334 } ··· 365 366 .max-features-message { 367 margin-top: 0.5rem; 368 + font-size: var(--text-sm); 369 color: var(--warning); 370 } 371 ··· 374 padding: 0.75rem; 375 background: color-mix(in srgb, var(--warning) 10%, var(--bg-primary)); 376 border: 1px solid color-mix(in srgb, var(--warning) 20%, var(--border-subtle)); 377 + border-radius: var(--radius-sm); 378 color: var(--warning); 379 + font-size: var(--text-base); 380 text-align: center; 381 } 382 ··· 401 402 .selected-artist-chip { 403 padding: 0.4rem 0.6rem; 404 + font-size: var(--text-sm); 405 } 406 407 .chip-avatar {
+18 -18
frontend/src/lib/components/Header.svelte
··· 228 justify-content: center; 229 width: 44px; 230 height: 44px; 231 - border-radius: 10px; 232 background: transparent; 233 border: none; 234 color: var(--text-secondary); ··· 275 border: 1px solid var(--border-emphasis); 276 color: var(--text-secondary); 277 padding: 0.5rem 1rem; 278 - border-radius: 6px; 279 - font-size: 0.9rem; 280 font-family: inherit; 281 cursor: pointer; 282 transition: all 0.2s; ··· 309 } 310 311 .tangled-icon { 312 - border-radius: 4px; 313 opacity: 0.7; 314 transition: opacity 0.2s, box-shadow 0.2s; 315 } ··· 320 } 321 322 h1 { 323 - font-size: 1.5rem; 324 margin: 0; 325 color: var(--text-primary); 326 transition: color 0.2s; ··· 353 .nav-link { 354 color: var(--text-secondary); 355 text-decoration: none; 356 - font-size: 0.9rem; 357 transition: all 0.2s; 358 white-space: nowrap; 359 display: flex; 360 align-items: center; 361 gap: 0.4rem; 362 padding: 0.4rem 0.75rem; 363 - border-radius: 6px; 364 border: 1px solid transparent; 365 } 366 ··· 388 .user-handle { 389 color: var(--text-secondary); 390 text-decoration: none; 391 - font-size: 0.9rem; 392 padding: 0.4rem 0.75rem; 393 background: var(--bg-tertiary); 394 - border-radius: 6px; 395 border: 1px solid var(--border-default); 396 transition: all 0.2s; 397 white-space: nowrap; ··· 408 border: 1px solid var(--accent); 409 color: var(--accent); 410 padding: 0.5rem 1rem; 411 - border-radius: 6px; 412 - font-size: 0.9rem; 413 text-decoration: none; 414 transition: all 0.2s; 415 cursor: pointer; ··· 421 color: var(--bg-primary); 422 } 423 424 - /* Hide margin-positioned elements and switch to mobile layout at the same breakpoint. 425 - Account for queue panel (320px) potentially being open - need extra headroom */ 426 - @media (max-width: 1599px) { 427 .margin-left, 428 .logout-right { 429 display: none !important; ··· 442 } 443 } 444 445 - /* Smaller screens: compact header */ 446 @media (max-width: 768px) { 447 .header-content { 448 padding: 0.75rem 0.75rem; ··· 467 468 .nav-link { 469 padding: 0.3rem 0.5rem; 470 - font-size: 0.8rem; 471 } 472 473 .nav-link span { ··· 475 } 476 477 .user-handle { 478 - font-size: 0.8rem; 479 padding: 0.3rem 0.5rem; 480 } 481 482 .btn-primary { 483 - font-size: 0.8rem; 484 padding: 0.3rem 0.65rem; 485 } 486 }
··· 228 justify-content: center; 229 width: 44px; 230 height: 44px; 231 + border-radius: var(--radius-md); 232 background: transparent; 233 border: none; 234 color: var(--text-secondary); ··· 275 border: 1px solid var(--border-emphasis); 276 color: var(--text-secondary); 277 padding: 0.5rem 1rem; 278 + border-radius: var(--radius-base); 279 + font-size: var(--text-base); 280 font-family: inherit; 281 cursor: pointer; 282 transition: all 0.2s; ··· 309 } 310 311 .tangled-icon { 312 + border-radius: var(--radius-sm); 313 opacity: 0.7; 314 transition: opacity 0.2s, box-shadow 0.2s; 315 } ··· 320 } 321 322 h1 { 323 + font-size: var(--text-3xl); 324 margin: 0; 325 color: var(--text-primary); 326 transition: color 0.2s; ··· 353 .nav-link { 354 color: var(--text-secondary); 355 text-decoration: none; 356 + font-size: var(--text-base); 357 transition: all 0.2s; 358 white-space: nowrap; 359 display: flex; 360 align-items: center; 361 gap: 0.4rem; 362 padding: 0.4rem 0.75rem; 363 + border-radius: var(--radius-base); 364 border: 1px solid transparent; 365 } 366 ··· 388 .user-handle { 389 color: var(--text-secondary); 390 text-decoration: none; 391 + font-size: var(--text-base); 392 padding: 0.4rem 0.75rem; 393 background: var(--bg-tertiary); 394 + border-radius: var(--radius-base); 395 border: 1px solid var(--border-default); 396 transition: all 0.2s; 397 white-space: nowrap; ··· 408 border: 1px solid var(--accent); 409 color: var(--accent); 410 padding: 0.5rem 1rem; 411 + border-radius: var(--radius-base); 412 + font-size: var(--text-base); 413 text-decoration: none; 414 transition: all 0.2s; 415 cursor: pointer; ··· 421 color: var(--bg-primary); 422 } 423 424 + /* header mobile breakpoint - see $lib/breakpoints.ts 425 + switch to mobile before margin elements crowd each other */ 426 + @media (max-width: 1300px) { 427 .margin-left, 428 .logout-right { 429 display: none !important; ··· 442 } 443 } 444 445 + /* mobile breakpoint - see $lib/breakpoints.ts */ 446 @media (max-width: 768px) { 447 .header-content { 448 padding: 0.75rem 0.75rem; ··· 467 468 .nav-link { 469 padding: 0.3rem 0.5rem; 470 + font-size: var(--text-sm); 471 } 472 473 .nav-link span { ··· 475 } 476 477 .user-handle { 478 + font-size: var(--text-sm); 479 padding: 0.3rem 0.5rem; 480 } 481 482 .btn-primary { 483 + font-size: var(--text-sm); 484 padding: 0.3rem 0.65rem; 485 } 486 }
+11 -11
frontend/src/lib/components/HiddenTagsFilter.svelte
··· 126 align-items: center; 127 gap: 0.5rem; 128 flex-wrap: wrap; 129 - font-size: 0.8rem; 130 } 131 132 .filter-toggle { ··· 139 color: var(--text-tertiary); 140 cursor: pointer; 141 transition: all 0.15s; 142 - border-radius: 6px; 143 } 144 145 .filter-toggle:hover { ··· 157 } 158 159 .filter-count { 160 - font-size: 0.7rem; 161 color: var(--text-tertiary); 162 } 163 164 .filter-label { 165 color: var(--text-tertiary); 166 white-space: nowrap; 167 - font-size: 0.75rem; 168 font-family: inherit; 169 } 170 ··· 183 background: transparent; 184 border: 1px solid var(--border-default); 185 color: var(--text-secondary); 186 - border-radius: 3px; 187 - font-size: 0.75rem; 188 font-family: inherit; 189 cursor: pointer; 190 transition: all 0.15s; ··· 201 } 202 203 .remove-icon { 204 - font-size: 0.8rem; 205 line-height: 1; 206 opacity: 0.5; 207 } ··· 219 padding: 0; 220 background: transparent; 221 border: 1px dashed var(--border-default); 222 - border-radius: 3px; 223 color: var(--text-tertiary); 224 - font-size: 0.8rem; 225 cursor: pointer; 226 transition: all 0.15s; 227 } ··· 236 background: transparent; 237 border: 1px solid var(--border-default); 238 color: var(--text-primary); 239 - font-size: 0.75rem; 240 font-family: inherit; 241 min-height: 24px; 242 width: 70px; 243 outline: none; 244 - border-radius: 3px; 245 } 246 247 .add-input:focus {
··· 126 align-items: center; 127 gap: 0.5rem; 128 flex-wrap: wrap; 129 + font-size: var(--text-sm); 130 } 131 132 .filter-toggle { ··· 139 color: var(--text-tertiary); 140 cursor: pointer; 141 transition: all 0.15s; 142 + border-radius: var(--radius-base); 143 } 144 145 .filter-toggle:hover { ··· 157 } 158 159 .filter-count { 160 + font-size: var(--text-xs); 161 color: var(--text-tertiary); 162 } 163 164 .filter-label { 165 color: var(--text-tertiary); 166 white-space: nowrap; 167 + font-size: var(--text-xs); 168 font-family: inherit; 169 } 170 ··· 183 background: transparent; 184 border: 1px solid var(--border-default); 185 color: var(--text-secondary); 186 + border-radius: var(--radius-sm); 187 + font-size: var(--text-xs); 188 font-family: inherit; 189 cursor: pointer; 190 transition: all 0.15s; ··· 201 } 202 203 .remove-icon { 204 + font-size: var(--text-sm); 205 line-height: 1; 206 opacity: 0.5; 207 } ··· 219 padding: 0; 220 background: transparent; 221 border: 1px dashed var(--border-default); 222 + border-radius: var(--radius-sm); 223 color: var(--text-tertiary); 224 + font-size: var(--text-sm); 225 cursor: pointer; 226 transition: all 0.15s; 227 } ··· 236 background: transparent; 237 border: 1px solid var(--border-default); 238 color: var(--text-primary); 239 + font-size: var(--text-xs); 240 font-family: inherit; 241 min-height: 24px; 242 width: 70px; 243 outline: none; 244 + border-radius: var(--radius-sm); 245 } 246 247 .add-input:focus {
+7 -9
frontend/src/lib/components/LikeButton.svelte
··· 6 trackId: number; 7 trackTitle: string; 8 fileId?: string; 9 initialLiked?: boolean; 10 disabled?: boolean; 11 disabledReason?: string; 12 onLikeChange?: (_liked: boolean) => void; 13 } 14 15 - let { trackId, trackTitle, fileId, initialLiked = false, disabled = false, disabledReason, onLikeChange }: Props = $props(); 16 17 - let liked = $state(initialLiked); 18 let loading = $state(false); 19 20 - // update liked state when initialLiked changes 21 - $effect(() => { 22 - liked = initialLiked; 23 - }); 24 - 25 async function toggleLike(e: Event) { 26 e.stopPropagation(); 27 ··· 35 36 try { 37 const success = liked 38 - ? await likeTrack(trackId, fileId) 39 : await unlikeTrack(trackId); 40 41 if (!success) { ··· 70 class:disabled-state={disabled} 71 onclick={toggleLike} 72 title={disabled && disabledReason ? disabledReason : (liked ? 'unlike' : 'like')} 73 disabled={loading || disabled} 74 > 75 <svg width="16" height="16" viewBox="0 0 24 24" fill={liked ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> ··· 86 justify-content: center; 87 background: transparent; 88 border: 1px solid var(--border-default); 89 - border-radius: 4px; 90 color: var(--text-tertiary); 91 cursor: pointer; 92 transition: all 0.2s;
··· 6 trackId: number; 7 trackTitle: string; 8 fileId?: string; 9 + gated?: boolean; 10 initialLiked?: boolean; 11 disabled?: boolean; 12 disabledReason?: string; 13 onLikeChange?: (_liked: boolean) => void; 14 } 15 16 + let { trackId, trackTitle, fileId, gated, initialLiked = false, disabled = false, disabledReason, onLikeChange }: Props = $props(); 17 18 + // use overridable $derived (Svelte 5.25+) - syncs with prop but can be overridden for optimistic UI 19 + let liked = $derived(initialLiked); 20 let loading = $state(false); 21 22 async function toggleLike(e: Event) { 23 e.stopPropagation(); 24 ··· 32 33 try { 34 const success = liked 35 + ? await likeTrack(trackId, fileId, gated) 36 : await unlikeTrack(trackId); 37 38 if (!success) { ··· 67 class:disabled-state={disabled} 68 onclick={toggleLike} 69 title={disabled && disabledReason ? disabledReason : (liked ? 'unlike' : 'like')} 70 + aria-label={disabled && disabledReason ? disabledReason : (liked ? 'unlike' : 'like')} 71 disabled={loading || disabled} 72 > 73 <svg width="16" height="16" viewBox="0 0 24 24" fill={liked ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> ··· 84 justify-content: center; 85 background: transparent; 86 border: 1px solid var(--border-default); 87 + border-radius: var(--radius-sm); 88 color: var(--text-tertiary); 89 cursor: pointer; 90 transition: all 0.2s;
+9 -9
frontend/src/lib/components/LikersTooltip.svelte
··· 134 margin-bottom: 0.5rem; 135 background: var(--bg-secondary); 136 border: 1px solid var(--border-default); 137 - border-radius: 8px; 138 padding: 0.75rem; 139 min-width: 240px; 140 max-width: 320px; ··· 155 .error, 156 .empty { 157 color: var(--text-tertiary); 158 - font-size: 0.85rem; 159 text-align: center; 160 padding: 0.5rem; 161 } ··· 177 align-items: center; 178 gap: 0.75rem; 179 padding: 0.5rem; 180 - border-radius: 6px; 181 text-decoration: none; 182 transition: background 0.2s; 183 } ··· 190 .avatar-placeholder { 191 width: 32px; 192 height: 32px; 193 - border-radius: 50%; 194 flex-shrink: 0; 195 } 196 ··· 206 justify-content: center; 207 color: var(--text-tertiary); 208 font-weight: 600; 209 - font-size: 0.9rem; 210 } 211 212 .liker-info { ··· 217 .display-name { 218 color: var(--text-primary); 219 font-weight: 500; 220 - font-size: 0.9rem; 221 white-space: nowrap; 222 overflow: hidden; 223 text-overflow: ellipsis; ··· 225 226 .handle { 227 color: var(--text-tertiary); 228 - font-size: 0.8rem; 229 white-space: nowrap; 230 overflow: hidden; 231 text-overflow: ellipsis; ··· 233 234 .liked-time { 235 color: var(--text-muted); 236 - font-size: 0.75rem; 237 flex-shrink: 0; 238 } 239 ··· 248 249 .likers-list::-webkit-scrollbar-thumb { 250 background: var(--border-default); 251 - border-radius: 3px; 252 } 253 254 .likers-list::-webkit-scrollbar-thumb:hover {
··· 134 margin-bottom: 0.5rem; 135 background: var(--bg-secondary); 136 border: 1px solid var(--border-default); 137 + border-radius: var(--radius-md); 138 padding: 0.75rem; 139 min-width: 240px; 140 max-width: 320px; ··· 155 .error, 156 .empty { 157 color: var(--text-tertiary); 158 + font-size: var(--text-sm); 159 text-align: center; 160 padding: 0.5rem; 161 } ··· 177 align-items: center; 178 gap: 0.75rem; 179 padding: 0.5rem; 180 + border-radius: var(--radius-base); 181 text-decoration: none; 182 transition: background 0.2s; 183 } ··· 190 .avatar-placeholder { 191 width: 32px; 192 height: 32px; 193 + border-radius: var(--radius-full); 194 flex-shrink: 0; 195 } 196 ··· 206 justify-content: center; 207 color: var(--text-tertiary); 208 font-weight: 600; 209 + font-size: var(--text-base); 210 } 211 212 .liker-info { ··· 217 .display-name { 218 color: var(--text-primary); 219 font-weight: 500; 220 + font-size: var(--text-base); 221 white-space: nowrap; 222 overflow: hidden; 223 text-overflow: ellipsis; ··· 225 226 .handle { 227 color: var(--text-tertiary); 228 + font-size: var(--text-sm); 229 white-space: nowrap; 230 overflow: hidden; 231 text-overflow: ellipsis; ··· 233 234 .liked-time { 235 color: var(--text-muted); 236 + font-size: var(--text-xs); 237 flex-shrink: 0; 238 } 239 ··· 248 249 .likers-list::-webkit-scrollbar-thumb { 250 background: var(--border-default); 251 + border-radius: var(--radius-sm); 252 } 253 254 .likers-list::-webkit-scrollbar-thumb:hover {
+8 -8
frontend/src/lib/components/LinksMenu.svelte
··· 140 height: 32px; 141 background: transparent; 142 border: 1px solid var(--border-default); 143 - border-radius: 6px; 144 color: var(--text-secondary); 145 cursor: pointer; 146 transition: all 0.2s; ··· 171 width: min(320px, calc(100vw - 2rem)); 172 background: var(--bg-secondary); 173 border: 1px solid var(--border-default); 174 - border-radius: 12px; 175 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); 176 z-index: 101; 177 animation: slideIn 0.2s cubic-bezier(0.16, 1, 0.3, 1); ··· 186 } 187 188 .menu-header span { 189 - font-size: 0.9rem; 190 font-weight: 600; 191 color: var(--text-primary); 192 text-transform: uppercase; ··· 201 height: 28px; 202 background: transparent; 203 border: none; 204 - border-radius: 4px; 205 color: var(--text-secondary); 206 cursor: pointer; 207 transition: all 0.2s; ··· 224 gap: 1rem; 225 padding: 1rem; 226 background: transparent; 227 - border-radius: 8px; 228 text-decoration: none; 229 color: var(--text-primary); 230 transition: all 0.2s; ··· 245 } 246 247 .tangled-menu-icon { 248 - border-radius: 4px; 249 opacity: 0.7; 250 transition: opacity 0.2s, box-shadow 0.2s; 251 } ··· 263 } 264 265 .link-title { 266 - font-size: 0.95rem; 267 font-weight: 500; 268 color: var(--text-primary); 269 } 270 271 .link-subtitle { 272 - font-size: 0.8rem; 273 color: var(--text-tertiary); 274 } 275
··· 140 height: 32px; 141 background: transparent; 142 border: 1px solid var(--border-default); 143 + border-radius: var(--radius-base); 144 color: var(--text-secondary); 145 cursor: pointer; 146 transition: all 0.2s; ··· 171 width: min(320px, calc(100vw - 2rem)); 172 background: var(--bg-secondary); 173 border: 1px solid var(--border-default); 174 + border-radius: var(--radius-lg); 175 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); 176 z-index: 101; 177 animation: slideIn 0.2s cubic-bezier(0.16, 1, 0.3, 1); ··· 186 } 187 188 .menu-header span { 189 + font-size: var(--text-base); 190 font-weight: 600; 191 color: var(--text-primary); 192 text-transform: uppercase; ··· 201 height: 28px; 202 background: transparent; 203 border: none; 204 + border-radius: var(--radius-sm); 205 color: var(--text-secondary); 206 cursor: pointer; 207 transition: all 0.2s; ··· 224 gap: 1rem; 225 padding: 1rem; 226 background: transparent; 227 + border-radius: var(--radius-md); 228 text-decoration: none; 229 color: var(--text-primary); 230 transition: all 0.2s; ··· 245 } 246 247 .tangled-menu-icon { 248 + border-radius: var(--radius-sm); 249 opacity: 0.7; 250 transition: opacity 0.2s, box-shadow 0.2s; 251 } ··· 263 } 264 265 .link-title { 266 + font-size: var(--text-base); 267 font-weight: 500; 268 color: var(--text-primary); 269 } 270 271 .link-subtitle { 272 + font-size: var(--text-sm); 273 color: var(--text-tertiary); 274 } 275
+4 -4
frontend/src/lib/components/MigrationBanner.svelte
··· 152 .migration-banner { 153 background: var(--bg-tertiary); 154 border: 1px solid var(--border-default); 155 - border-radius: 8px; 156 padding: 1rem; 157 margin-bottom: 1.5rem; 158 } ··· 190 gap: 1rem; 191 background: color-mix(in srgb, var(--success) 10%, transparent); 192 border: 1px solid color-mix(in srgb, var(--success) 30%, transparent); 193 - border-radius: 6px; 194 padding: 1rem; 195 animation: slideIn 0.3s ease-out; 196 } ··· 239 .collection-name { 240 background: color-mix(in srgb, var(--text-primary) 5%, transparent); 241 padding: 0.15em 0.4em; 242 - border-radius: 3px; 243 font-family: monospace; 244 font-size: 0.95em; 245 color: var(--text-primary); ··· 265 .migrate-button, 266 .dismiss-button { 267 padding: 0.5rem 1rem; 268 - border-radius: 4px; 269 font-size: 0.9em; 270 font-family: inherit; 271 cursor: pointer;
··· 152 .migration-banner { 153 background: var(--bg-tertiary); 154 border: 1px solid var(--border-default); 155 + border-radius: var(--radius-md); 156 padding: 1rem; 157 margin-bottom: 1.5rem; 158 } ··· 190 gap: 1rem; 191 background: color-mix(in srgb, var(--success) 10%, transparent); 192 border: 1px solid color-mix(in srgb, var(--success) 30%, transparent); 193 + border-radius: var(--radius-base); 194 padding: 1rem; 195 animation: slideIn 0.3s ease-out; 196 } ··· 239 .collection-name { 240 background: color-mix(in srgb, var(--text-primary) 5%, transparent); 241 padding: 0.15em 0.4em; 242 + border-radius: var(--radius-sm); 243 font-family: monospace; 244 font-size: 0.95em; 245 color: var(--text-primary); ··· 265 .migrate-button, 266 .dismiss-button { 267 padding: 0.5rem 1rem; 268 + border-radius: var(--radius-sm); 269 font-size: 0.9em; 270 font-family: inherit; 271 cursor: pointer;
+4 -4
frontend/src/lib/components/PlatformStats.svelte
··· 182 gap: 0.5rem; 183 margin-bottom: 0.75rem; 184 color: var(--text-secondary); 185 - font-size: 0.7rem; 186 font-weight: 600; 187 text-transform: uppercase; 188 letter-spacing: 0.05em; ··· 203 background: linear-gradient(90deg, var(--bg-tertiary) 0%, var(--bg-hover) 50%, var(--bg-tertiary) 100%); 204 background-size: 200% 100%; 205 animation: shimmer 1.5s ease-in-out infinite; 206 - border-radius: 6px; 207 } 208 209 .stats-menu-grid { ··· 219 gap: 0.15rem; 220 padding: 0.6rem 0.4rem; 221 background: var(--bg-tertiary, #1a1a1a); 222 - border-radius: 6px; 223 } 224 225 .menu-stat-icon { ··· 229 } 230 231 .stats-menu-value { 232 - font-size: 0.95rem; 233 font-weight: 600; 234 color: var(--text-primary); 235 font-variant-numeric: tabular-nums;
··· 182 gap: 0.5rem; 183 margin-bottom: 0.75rem; 184 color: var(--text-secondary); 185 + font-size: var(--text-xs); 186 font-weight: 600; 187 text-transform: uppercase; 188 letter-spacing: 0.05em; ··· 203 background: linear-gradient(90deg, var(--bg-tertiary) 0%, var(--bg-hover) 50%, var(--bg-tertiary) 100%); 204 background-size: 200% 100%; 205 animation: shimmer 1.5s ease-in-out infinite; 206 + border-radius: var(--radius-base); 207 } 208 209 .stats-menu-grid { ··· 219 gap: 0.15rem; 220 padding: 0.6rem 0.4rem; 221 background: var(--bg-tertiary, #1a1a1a); 222 + border-radius: var(--radius-base); 223 } 224 225 .menu-stat-icon { ··· 229 } 230 231 .stats-menu-value { 232 + font-size: var(--text-base); 233 font-weight: 600; 234 color: var(--text-primary); 235 font-variant-numeric: tabular-nums;
+19 -19
frontend/src/lib/components/ProfileMenu.svelte
··· 276 height: 44px; 277 background: transparent; 278 border: 1px solid var(--border-default); 279 - border-radius: 8px; 280 color: var(--text-secondary); 281 cursor: pointer; 282 transition: all 0.15s; ··· 311 overflow-y: auto; 312 background: var(--bg-secondary); 313 border: 1px solid var(--border-default); 314 - border-radius: 16px; 315 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); 316 z-index: 101; 317 animation: slideIn 0.18s cubic-bezier(0.16, 1, 0.3, 1); ··· 326 } 327 328 .menu-header span { 329 - font-size: 0.9rem; 330 font-weight: 600; 331 color: var(--text-primary); 332 text-transform: uppercase; ··· 341 height: 36px; 342 background: transparent; 343 border: none; 344 - border-radius: 8px; 345 color: var(--text-secondary); 346 cursor: pointer; 347 transition: all 0.15s; ··· 371 min-height: 56px; 372 background: transparent; 373 border: none; 374 - border-radius: 12px; 375 text-decoration: none; 376 color: var(--text-primary); 377 font-family: inherit; ··· 414 } 415 416 .item-title { 417 - font-size: 0.95rem; 418 font-weight: 500; 419 color: var(--text-primary); 420 } 421 422 .item-subtitle { 423 - font-size: 0.8rem; 424 color: var(--text-tertiary); 425 overflow: hidden; 426 text-overflow: ellipsis; ··· 440 padding: 0.5rem 0.75rem; 441 background: transparent; 442 border: none; 443 - border-radius: 6px; 444 color: var(--text-secondary); 445 font-family: inherit; 446 - font-size: 0.85rem; 447 cursor: pointer; 448 transition: all 0.15s; 449 -webkit-tap-highlight-color: transparent; ··· 469 470 .settings-section h3 { 471 margin: 0; 472 - font-size: 0.75rem; 473 text-transform: uppercase; 474 letter-spacing: 0.08em; 475 color: var(--text-tertiary); ··· 490 min-height: 54px; 491 background: var(--bg-tertiary); 492 border: 1px solid var(--border-default); 493 - border-radius: 8px; 494 color: var(--text-secondary); 495 cursor: pointer; 496 transition: all 0.15s; ··· 518 } 519 520 .theme-btn span { 521 - font-size: 0.7rem; 522 text-transform: uppercase; 523 letter-spacing: 0.05em; 524 } ··· 533 width: 44px; 534 height: 44px; 535 border: 1px solid var(--border-default); 536 - border-radius: 8px; 537 cursor: pointer; 538 background: transparent; 539 flex-shrink: 0; ··· 544 } 545 546 .color-input::-webkit-color-swatch { 547 - border-radius: 4px; 548 border: none; 549 } 550 ··· 557 .preset-btn { 558 width: 36px; 559 height: 36px; 560 - border-radius: 6px; 561 border: 2px solid transparent; 562 cursor: pointer; 563 transition: all 0.15s; ··· 583 align-items: center; 584 gap: 0.75rem; 585 color: var(--text-primary); 586 - font-size: 0.9rem; 587 cursor: pointer; 588 padding: 0.5rem 0; 589 } ··· 592 appearance: none; 593 width: 48px; 594 height: 28px; 595 - border-radius: 999px; 596 background: var(--border-default); 597 position: relative; 598 cursor: pointer; ··· 608 left: 3px; 609 width: 20px; 610 height: 20px; 611 - border-radius: 50%; 612 background: var(--text-secondary); 613 transition: transform 0.15s, background 0.15s; 614 } ··· 635 border-top: 1px solid var(--border-subtle); 636 color: var(--text-secondary); 637 text-decoration: none; 638 - font-size: 0.9rem; 639 transition: color 0.15s; 640 } 641
··· 276 height: 44px; 277 background: transparent; 278 border: 1px solid var(--border-default); 279 + border-radius: var(--radius-md); 280 color: var(--text-secondary); 281 cursor: pointer; 282 transition: all 0.15s; ··· 311 overflow-y: auto; 312 background: var(--bg-secondary); 313 border: 1px solid var(--border-default); 314 + border-radius: var(--radius-xl); 315 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); 316 z-index: 101; 317 animation: slideIn 0.18s cubic-bezier(0.16, 1, 0.3, 1); ··· 326 } 327 328 .menu-header span { 329 + font-size: var(--text-base); 330 font-weight: 600; 331 color: var(--text-primary); 332 text-transform: uppercase; ··· 341 height: 36px; 342 background: transparent; 343 border: none; 344 + border-radius: var(--radius-md); 345 color: var(--text-secondary); 346 cursor: pointer; 347 transition: all 0.15s; ··· 371 min-height: 56px; 372 background: transparent; 373 border: none; 374 + border-radius: var(--radius-lg); 375 text-decoration: none; 376 color: var(--text-primary); 377 font-family: inherit; ··· 414 } 415 416 .item-title { 417 + font-size: var(--text-base); 418 font-weight: 500; 419 color: var(--text-primary); 420 } 421 422 .item-subtitle { 423 + font-size: var(--text-sm); 424 color: var(--text-tertiary); 425 overflow: hidden; 426 text-overflow: ellipsis; ··· 440 padding: 0.5rem 0.75rem; 441 background: transparent; 442 border: none; 443 + border-radius: var(--radius-base); 444 color: var(--text-secondary); 445 font-family: inherit; 446 + font-size: var(--text-sm); 447 cursor: pointer; 448 transition: all 0.15s; 449 -webkit-tap-highlight-color: transparent; ··· 469 470 .settings-section h3 { 471 margin: 0; 472 + font-size: var(--text-xs); 473 text-transform: uppercase; 474 letter-spacing: 0.08em; 475 color: var(--text-tertiary); ··· 490 min-height: 54px; 491 background: var(--bg-tertiary); 492 border: 1px solid var(--border-default); 493 + border-radius: var(--radius-md); 494 color: var(--text-secondary); 495 cursor: pointer; 496 transition: all 0.15s; ··· 518 } 519 520 .theme-btn span { 521 + font-size: var(--text-xs); 522 text-transform: uppercase; 523 letter-spacing: 0.05em; 524 } ··· 533 width: 44px; 534 height: 44px; 535 border: 1px solid var(--border-default); 536 + border-radius: var(--radius-md); 537 cursor: pointer; 538 background: transparent; 539 flex-shrink: 0; ··· 544 } 545 546 .color-input::-webkit-color-swatch { 547 + border-radius: var(--radius-sm); 548 border: none; 549 } 550 ··· 557 .preset-btn { 558 width: 36px; 559 height: 36px; 560 + border-radius: var(--radius-base); 561 border: 2px solid transparent; 562 cursor: pointer; 563 transition: all 0.15s; ··· 583 align-items: center; 584 gap: 0.75rem; 585 color: var(--text-primary); 586 + font-size: var(--text-base); 587 cursor: pointer; 588 padding: 0.5rem 0; 589 } ··· 592 appearance: none; 593 width: 48px; 594 height: 28px; 595 + border-radius: var(--radius-full); 596 background: var(--border-default); 597 position: relative; 598 cursor: pointer; ··· 608 left: 3px; 609 width: 20px; 610 height: 20px; 611 + border-radius: var(--radius-full); 612 background: var(--text-secondary); 613 transition: transform 0.15s, background 0.15s; 614 } ··· 635 border-top: 1px solid var(--border-subtle); 636 color: var(--text-secondary); 637 text-decoration: none; 638 + font-size: var(--text-base); 639 transition: color 0.15s; 640 } 641
+19 -18
frontend/src/lib/components/Queue.svelte
··· 1 <script lang="ts"> 2 import { queue } from '$lib/queue.svelte'; 3 import type { Track } from '$lib/types'; 4 5 let draggedIndex = $state<number | null>(null); ··· 167 ondragover={(e) => handleDragOver(e, index)} 168 ondrop={(e) => handleDrop(e, index)} 169 ondragend={handleDragEnd} 170 - onclick={() => queue.goTo(index)} 171 - onkeydown={(e) => e.key === 'Enter' && queue.goTo(index)} 172 > 173 <!-- drag handle for reordering --> 174 <button ··· 248 249 .queue-header h2 { 250 margin: 0; 251 - font-size: 1rem; 252 text-transform: uppercase; 253 letter-spacing: 0.12em; 254 color: var(--text-tertiary); ··· 262 263 .clear-btn { 264 padding: 0.25rem 0.75rem; 265 - font-size: 0.75rem; 266 font-family: inherit; 267 text-transform: uppercase; 268 letter-spacing: 0.08em; 269 background: transparent; 270 border: 1px solid var(--border-subtle); 271 color: var(--text-tertiary); 272 - border-radius: 4px; 273 cursor: pointer; 274 transition: all 0.15s ease; 275 } ··· 289 } 290 291 .section-label { 292 - font-size: 0.75rem; 293 text-transform: uppercase; 294 letter-spacing: 0.08em; 295 color: var(--text-tertiary); ··· 301 align-items: center; 302 justify-content: space-between; 303 padding: 1rem 1.1rem; 304 - border-radius: 10px; 305 background: var(--bg-secondary); 306 border: 1px solid var(--border-default); 307 gap: 1rem; ··· 315 } 316 317 .now-playing-card .track-artist { 318 - font-size: 0.9rem; 319 color: var(--text-secondary); 320 } 321 ··· 343 justify-content: space-between; 344 align-items: center; 345 color: var(--text-tertiary); 346 - font-size: 0.85rem; 347 text-transform: uppercase; 348 letter-spacing: 0.08em; 349 } 350 351 .section-header h3 { 352 margin: 0; 353 - font-size: 0.85rem; 354 font-weight: 600; 355 color: var(--text-secondary); 356 text-transform: uppercase; ··· 371 align-items: center; 372 gap: 0.5rem; 373 padding: 0.85rem 0.9rem; 374 - border-radius: 8px; 375 cursor: pointer; 376 transition: all 0.2s; 377 border: 1px solid var(--border-subtle); ··· 411 color: var(--text-muted); 412 cursor: grab; 413 touch-action: none; 414 - border-radius: 4px; 415 transition: all 0.2s; 416 flex-shrink: 0; 417 } ··· 448 } 449 450 .track-artist { 451 - font-size: 0.85rem; 452 color: var(--text-tertiary); 453 white-space: nowrap; 454 overflow: hidden; ··· 475 align-items: center; 476 justify-content: center; 477 transition: all 0.2s; 478 - border-radius: 4px; 479 opacity: 0; 480 flex-shrink: 0; 481 } ··· 498 499 .empty-up-next { 500 border: 1px dashed var(--border-subtle); 501 - border-radius: 6px; 502 padding: 1.25rem; 503 text-align: center; 504 color: var(--text-tertiary); ··· 523 524 .empty-state p { 525 margin: 0.5rem 0 0.25rem; 526 - font-size: 1.1rem; 527 color: var(--text-secondary); 528 } 529 530 .empty-state span { 531 - font-size: 0.9rem; 532 } 533 534 .queue-tracks::-webkit-scrollbar { ··· 541 542 .queue-tracks::-webkit-scrollbar-thumb { 543 background: var(--border-default); 544 - border-radius: 4px; 545 } 546 547 .queue-tracks::-webkit-scrollbar-thumb:hover {
··· 1 <script lang="ts"> 2 import { queue } from '$lib/queue.svelte'; 3 + import { goToIndex } from '$lib/playback.svelte'; 4 import type { Track } from '$lib/types'; 5 6 let draggedIndex = $state<number | null>(null); ··· 168 ondragover={(e) => handleDragOver(e, index)} 169 ondrop={(e) => handleDrop(e, index)} 170 ondragend={handleDragEnd} 171 + onclick={() => goToIndex(index)} 172 + onkeydown={(e) => e.key === 'Enter' && goToIndex(index)} 173 > 174 <!-- drag handle for reordering --> 175 <button ··· 249 250 .queue-header h2 { 251 margin: 0; 252 + font-size: var(--text-lg); 253 text-transform: uppercase; 254 letter-spacing: 0.12em; 255 color: var(--text-tertiary); ··· 263 264 .clear-btn { 265 padding: 0.25rem 0.75rem; 266 + font-size: var(--text-xs); 267 font-family: inherit; 268 text-transform: uppercase; 269 letter-spacing: 0.08em; 270 background: transparent; 271 border: 1px solid var(--border-subtle); 272 color: var(--text-tertiary); 273 + border-radius: var(--radius-sm); 274 cursor: pointer; 275 transition: all 0.15s ease; 276 } ··· 290 } 291 292 .section-label { 293 + font-size: var(--text-xs); 294 text-transform: uppercase; 295 letter-spacing: 0.08em; 296 color: var(--text-tertiary); ··· 302 align-items: center; 303 justify-content: space-between; 304 padding: 1rem 1.1rem; 305 + border-radius: var(--radius-md); 306 background: var(--bg-secondary); 307 border: 1px solid var(--border-default); 308 gap: 1rem; ··· 316 } 317 318 .now-playing-card .track-artist { 319 + font-size: var(--text-base); 320 color: var(--text-secondary); 321 } 322 ··· 344 justify-content: space-between; 345 align-items: center; 346 color: var(--text-tertiary); 347 + font-size: var(--text-sm); 348 text-transform: uppercase; 349 letter-spacing: 0.08em; 350 } 351 352 .section-header h3 { 353 margin: 0; 354 + font-size: var(--text-sm); 355 font-weight: 600; 356 color: var(--text-secondary); 357 text-transform: uppercase; ··· 372 align-items: center; 373 gap: 0.5rem; 374 padding: 0.85rem 0.9rem; 375 + border-radius: var(--radius-md); 376 cursor: pointer; 377 transition: all 0.2s; 378 border: 1px solid var(--border-subtle); ··· 412 color: var(--text-muted); 413 cursor: grab; 414 touch-action: none; 415 + border-radius: var(--radius-sm); 416 transition: all 0.2s; 417 flex-shrink: 0; 418 } ··· 449 } 450 451 .track-artist { 452 + font-size: var(--text-sm); 453 color: var(--text-tertiary); 454 white-space: nowrap; 455 overflow: hidden; ··· 476 align-items: center; 477 justify-content: center; 478 transition: all 0.2s; 479 + border-radius: var(--radius-sm); 480 opacity: 0; 481 flex-shrink: 0; 482 } ··· 499 500 .empty-up-next { 501 border: 1px dashed var(--border-subtle); 502 + border-radius: var(--radius-base); 503 padding: 1.25rem; 504 text-align: center; 505 color: var(--text-tertiary); ··· 524 525 .empty-state p { 526 margin: 0.5rem 0 0.25rem; 527 + font-size: var(--text-xl); 528 color: var(--text-secondary); 529 } 530 531 .empty-state span { 532 + font-size: var(--text-base); 533 } 534 535 .queue-tracks::-webkit-scrollbar { ··· 542 543 .queue-tracks::-webkit-scrollbar-thumb { 544 background: var(--border-default); 545 + border-radius: var(--radius-sm); 546 } 547 548 .queue-tracks::-webkit-scrollbar-thumb:hover {
+20 -20
frontend/src/lib/components/SearchModal.svelte
··· 276 backdrop-filter: blur(20px) saturate(180%); 277 -webkit-backdrop-filter: blur(20px) saturate(180%); 278 border: 1px solid var(--border-subtle); 279 - border-radius: 16px; 280 box-shadow: 281 0 24px 80px color-mix(in srgb, var(--bg-primary) 50%, transparent), 282 0 0 1px var(--border-subtle) inset; ··· 303 background: transparent; 304 border: none; 305 outline: none; 306 - font-size: 1rem; 307 font-family: inherit; 308 color: var(--text-primary); 309 } ··· 313 } 314 315 .search-shortcut { 316 - font-size: 0.7rem; 317 padding: 0.25rem 0.5rem; 318 background: var(--bg-tertiary); 319 border: 1px solid var(--border-default); 320 - border-radius: 5px; 321 color: var(--text-muted); 322 font-family: inherit; 323 } ··· 327 height: 16px; 328 border: 2px solid var(--border-default); 329 border-top-color: var(--accent); 330 - border-radius: 50%; 331 animation: spin 0.6s linear infinite; 332 } 333 ··· 351 352 .search-results::-webkit-scrollbar-track { 353 background: transparent; 354 - border-radius: 4px; 355 } 356 357 .search-results::-webkit-scrollbar-thumb { 358 background: var(--border-default); 359 - border-radius: 4px; 360 } 361 362 .search-results::-webkit-scrollbar-thumb:hover { ··· 371 padding: 0.75rem; 372 background: transparent; 373 border: none; 374 - border-radius: 8px; 375 cursor: pointer; 376 text-align: left; 377 font-family: inherit; ··· 396 align-items: center; 397 justify-content: center; 398 background: var(--bg-tertiary); 399 - border-radius: 8px; 400 - font-size: 0.9rem; 401 flex-shrink: 0; 402 position: relative; 403 overflow: hidden; ··· 409 width: 100%; 410 height: 100%; 411 object-fit: cover; 412 - border-radius: 8px; 413 } 414 415 .result-icon[data-type='track'] { ··· 441 } 442 443 .result-title { 444 - font-size: 0.9rem; 445 font-weight: 500; 446 white-space: nowrap; 447 overflow: hidden; ··· 449 } 450 451 .result-subtitle { 452 - font-size: 0.75rem; 453 color: var(--text-secondary); 454 white-space: nowrap; 455 overflow: hidden; ··· 463 color: var(--text-muted); 464 padding: 0.2rem 0.45rem; 465 background: var(--bg-tertiary); 466 - border-radius: 4px; 467 flex-shrink: 0; 468 } 469 ··· 471 padding: 2rem; 472 text-align: center; 473 color: var(--text-secondary); 474 - font-size: 0.9rem; 475 } 476 477 .search-hints { ··· 482 .search-hints p { 483 margin: 0 0 1rem 0; 484 color: var(--text-secondary); 485 - font-size: 0.85rem; 486 } 487 488 .hint-shortcuts { ··· 490 justify-content: center; 491 gap: 1.5rem; 492 color: var(--text-muted); 493 - font-size: 0.75rem; 494 } 495 496 .hint-shortcuts span { ··· 504 padding: 0.15rem 0.35rem; 505 background: var(--bg-tertiary); 506 border: 1px solid var(--border-default); 507 - border-radius: 4px; 508 font-family: inherit; 509 } 510 ··· 512 padding: 1rem; 513 text-align: center; 514 color: var(--error); 515 - font-size: 0.85rem; 516 } 517 518 /* mobile optimizations */ ··· 535 } 536 537 .search-input::placeholder { 538 - font-size: 0.85rem; 539 } 540 541 .search-results {
··· 276 backdrop-filter: blur(20px) saturate(180%); 277 -webkit-backdrop-filter: blur(20px) saturate(180%); 278 border: 1px solid var(--border-subtle); 279 + border-radius: var(--radius-xl); 280 box-shadow: 281 0 24px 80px color-mix(in srgb, var(--bg-primary) 50%, transparent), 282 0 0 1px var(--border-subtle) inset; ··· 303 background: transparent; 304 border: none; 305 outline: none; 306 + font-size: var(--text-lg); 307 font-family: inherit; 308 color: var(--text-primary); 309 } ··· 313 } 314 315 .search-shortcut { 316 + font-size: var(--text-xs); 317 padding: 0.25rem 0.5rem; 318 background: var(--bg-tertiary); 319 border: 1px solid var(--border-default); 320 + border-radius: var(--radius-sm); 321 color: var(--text-muted); 322 font-family: inherit; 323 } ··· 327 height: 16px; 328 border: 2px solid var(--border-default); 329 border-top-color: var(--accent); 330 + border-radius: var(--radius-full); 331 animation: spin 0.6s linear infinite; 332 } 333 ··· 351 352 .search-results::-webkit-scrollbar-track { 353 background: transparent; 354 + border-radius: var(--radius-sm); 355 } 356 357 .search-results::-webkit-scrollbar-thumb { 358 background: var(--border-default); 359 + border-radius: var(--radius-sm); 360 } 361 362 .search-results::-webkit-scrollbar-thumb:hover { ··· 371 padding: 0.75rem; 372 background: transparent; 373 border: none; 374 + border-radius: var(--radius-md); 375 cursor: pointer; 376 text-align: left; 377 font-family: inherit; ··· 396 align-items: center; 397 justify-content: center; 398 background: var(--bg-tertiary); 399 + border-radius: var(--radius-md); 400 + font-size: var(--text-base); 401 flex-shrink: 0; 402 position: relative; 403 overflow: hidden; ··· 409 width: 100%; 410 height: 100%; 411 object-fit: cover; 412 + border-radius: var(--radius-md); 413 } 414 415 .result-icon[data-type='track'] { ··· 441 } 442 443 .result-title { 444 + font-size: var(--text-base); 445 font-weight: 500; 446 white-space: nowrap; 447 overflow: hidden; ··· 449 } 450 451 .result-subtitle { 452 + font-size: var(--text-xs); 453 color: var(--text-secondary); 454 white-space: nowrap; 455 overflow: hidden; ··· 463 color: var(--text-muted); 464 padding: 0.2rem 0.45rem; 465 background: var(--bg-tertiary); 466 + border-radius: var(--radius-sm); 467 flex-shrink: 0; 468 } 469 ··· 471 padding: 2rem; 472 text-align: center; 473 color: var(--text-secondary); 474 + font-size: var(--text-base); 475 } 476 477 .search-hints { ··· 482 .search-hints p { 483 margin: 0 0 1rem 0; 484 color: var(--text-secondary); 485 + font-size: var(--text-sm); 486 } 487 488 .hint-shortcuts { ··· 490 justify-content: center; 491 gap: 1.5rem; 492 color: var(--text-muted); 493 + font-size: var(--text-xs); 494 } 495 496 .hint-shortcuts span { ··· 504 padding: 0.15rem 0.35rem; 505 background: var(--bg-tertiary); 506 border: 1px solid var(--border-default); 507 + border-radius: var(--radius-sm); 508 font-family: inherit; 509 } 510 ··· 512 padding: 1rem; 513 text-align: center; 514 color: var(--error); 515 + font-size: var(--text-sm); 516 } 517 518 /* mobile optimizations */ ··· 535 } 536 537 .search-input::placeholder { 538 + font-size: var(--text-sm); 539 } 540 541 .search-results {
+1 -1
frontend/src/lib/components/SearchTrigger.svelte
··· 24 border: 1px solid var(--border-default); 25 color: var(--text-secondary); 26 padding: 0.5rem; 27 - border-radius: 4px; 28 cursor: pointer; 29 transition: all 0.2s; 30 display: flex;
··· 24 border: 1px solid var(--border-default); 25 color: var(--text-secondary); 26 padding: 0.5rem; 27 + border-radius: var(--radius-sm); 28 cursor: pointer; 29 transition: all 0.2s; 30 display: flex;
+3 -3
frontend/src/lib/components/SensitiveImage.svelte
··· 70 margin-bottom: 4px; 71 background: var(--bg-primary); 72 border: 1px solid var(--border-default); 73 - border-radius: 4px; 74 padding: 0.25rem 0.5rem; 75 - font-size: 0.7rem; 76 color: var(--text-tertiary); 77 white-space: nowrap; 78 opacity: 0; ··· 89 transform: translate(-50%, -50%); 90 margin-bottom: 0; 91 padding: 0.5rem 0.75rem; 92 - font-size: 0.8rem; 93 } 94 95 .sensitive-wrapper.blur:hover .sensitive-tooltip {
··· 70 margin-bottom: 4px; 71 background: var(--bg-primary); 72 border: 1px solid var(--border-default); 73 + border-radius: var(--radius-sm); 74 padding: 0.25rem 0.5rem; 75 + font-size: var(--text-xs); 76 color: var(--text-tertiary); 77 white-space: nowrap; 78 opacity: 0; ··· 89 transform: translate(-50%, -50%); 90 margin-bottom: 0; 91 padding: 0.5rem 0.75rem; 92 + font-size: var(--text-sm); 93 } 94 95 .sensitive-wrapper.blur:hover .sensitive-tooltip {
+14 -14
frontend/src/lib/components/SettingsMenu.svelte
··· 181 border: 1px solid var(--border-default); 182 color: var(--text-secondary); 183 padding: 0.5rem; 184 - border-radius: 4px; 185 cursor: pointer; 186 transition: all 0.2s; 187 display: flex; ··· 200 right: 0; 201 background: var(--bg-secondary); 202 border: 1px solid var(--border-default); 203 - border-radius: 8px; 204 padding: 1.25rem; 205 min-width: 280px; 206 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45); ··· 216 align-items: center; 217 color: var(--text-primary); 218 font-weight: 600; 219 - font-size: 0.95rem; 220 } 221 222 .close-btn { ··· 245 246 .settings-section h3 { 247 margin: 0; 248 - font-size: 0.85rem; 249 text-transform: uppercase; 250 letter-spacing: 0.08em; 251 color: var(--text-tertiary); ··· 265 padding: 0.6rem 0.5rem; 266 background: var(--bg-tertiary); 267 border: 1px solid var(--border-default); 268 - border-radius: 6px; 269 color: var(--text-secondary); 270 cursor: pointer; 271 transition: all 0.2s; ··· 288 } 289 290 .theme-btn span { 291 - font-size: 0.7rem; 292 text-transform: uppercase; 293 letter-spacing: 0.05em; 294 } ··· 303 width: 48px; 304 height: 32px; 305 border: 1px solid var(--border-default); 306 - border-radius: 4px; 307 cursor: pointer; 308 background: transparent; 309 } ··· 319 320 .color-value { 321 font-family: monospace; 322 - font-size: 0.85rem; 323 color: var(--text-secondary); 324 } 325 ··· 332 .preset-btn { 333 width: 32px; 334 height: 32px; 335 - border-radius: 4px; 336 border: 2px solid transparent; 337 cursor: pointer; 338 transition: all 0.2s; ··· 354 align-items: center; 355 gap: 0.65rem; 356 color: var(--text-primary); 357 - font-size: 0.9rem; 358 } 359 360 .toggle input { 361 appearance: none; 362 width: 42px; 363 height: 22px; 364 - border-radius: 999px; 365 background: var(--border-default); 366 position: relative; 367 cursor: pointer; ··· 377 left: 2px; 378 width: 16px; 379 height: 16px; 380 - border-radius: 50%; 381 background: var(--text-secondary); 382 transition: transform 0.2s, background 0.2s; 383 } ··· 403 .toggle-hint { 404 margin: 0; 405 color: var(--text-tertiary); 406 - font-size: 0.8rem; 407 line-height: 1.3; 408 } 409 ··· 415 border-top: 1px solid var(--border-subtle); 416 color: var(--text-secondary); 417 text-decoration: none; 418 - font-size: 0.85rem; 419 transition: color 0.15s; 420 } 421
··· 181 border: 1px solid var(--border-default); 182 color: var(--text-secondary); 183 padding: 0.5rem; 184 + border-radius: var(--radius-sm); 185 cursor: pointer; 186 transition: all 0.2s; 187 display: flex; ··· 200 right: 0; 201 background: var(--bg-secondary); 202 border: 1px solid var(--border-default); 203 + border-radius: var(--radius-md); 204 padding: 1.25rem; 205 min-width: 280px; 206 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45); ··· 216 align-items: center; 217 color: var(--text-primary); 218 font-weight: 600; 219 + font-size: var(--text-base); 220 } 221 222 .close-btn { ··· 245 246 .settings-section h3 { 247 margin: 0; 248 + font-size: var(--text-sm); 249 text-transform: uppercase; 250 letter-spacing: 0.08em; 251 color: var(--text-tertiary); ··· 265 padding: 0.6rem 0.5rem; 266 background: var(--bg-tertiary); 267 border: 1px solid var(--border-default); 268 + border-radius: var(--radius-base); 269 color: var(--text-secondary); 270 cursor: pointer; 271 transition: all 0.2s; ··· 288 } 289 290 .theme-btn span { 291 + font-size: var(--text-xs); 292 text-transform: uppercase; 293 letter-spacing: 0.05em; 294 } ··· 303 width: 48px; 304 height: 32px; 305 border: 1px solid var(--border-default); 306 + border-radius: var(--radius-sm); 307 cursor: pointer; 308 background: transparent; 309 } ··· 319 320 .color-value { 321 font-family: monospace; 322 + font-size: var(--text-sm); 323 color: var(--text-secondary); 324 } 325 ··· 332 .preset-btn { 333 width: 32px; 334 height: 32px; 335 + border-radius: var(--radius-sm); 336 border: 2px solid transparent; 337 cursor: pointer; 338 transition: all 0.2s; ··· 354 align-items: center; 355 gap: 0.65rem; 356 color: var(--text-primary); 357 + font-size: var(--text-base); 358 } 359 360 .toggle input { 361 appearance: none; 362 width: 42px; 363 height: 22px; 364 + border-radius: var(--radius-full); 365 background: var(--border-default); 366 position: relative; 367 cursor: pointer; ··· 377 left: 2px; 378 width: 16px; 379 height: 16px; 380 + border-radius: var(--radius-full); 381 background: var(--text-secondary); 382 transition: transform 0.2s, background 0.2s; 383 } ··· 403 .toggle-hint { 404 margin: 0; 405 color: var(--text-tertiary); 406 + font-size: var(--text-sm); 407 line-height: 1.3; 408 } 409 ··· 415 border-top: 1px solid var(--border-subtle); 416 color: var(--text-secondary); 417 text-decoration: none; 418 + font-size: var(--text-sm); 419 transition: color 0.15s; 420 } 421
+3 -3
frontend/src/lib/components/ShareButton.svelte
··· 38 .share-btn { 39 background: var(--glass-btn-bg, transparent); 40 border: 1px solid var(--glass-btn-border, var(--border-default)); 41 - border-radius: 6px; 42 width: 32px; 43 height: 32px; 44 padding: 0; ··· 67 border: 1px solid var(--accent); 68 color: var(--accent); 69 padding: 0.25rem 0.75rem; 70 - border-radius: 4px; 71 - font-size: 0.75rem; 72 white-space: nowrap; 73 pointer-events: none; 74 animation: fadeIn 0.2s ease-in;
··· 38 .share-btn { 39 background: var(--glass-btn-bg, transparent); 40 border: 1px solid var(--glass-btn-border, var(--border-default)); 41 + border-radius: var(--radius-base); 42 width: 32px; 43 height: 32px; 44 padding: 0; ··· 67 border: 1px solid var(--accent); 68 color: var(--accent); 69 padding: 0.25rem 0.75rem; 70 + border-radius: var(--radius-sm); 71 + font-size: var(--text-xs); 72 white-space: nowrap; 73 pointer-events: none; 74 animation: fadeIn 0.2s ease-in;
+40
frontend/src/lib/components/SupporterBadge.svelte
···
··· 1 + <script lang="ts"> 2 + /** 3 + * displays a badge indicating the viewer supports the artist via atprotofans. 4 + * only shown when the logged-in user has validated supporter status. 5 + */ 6 + </script> 7 + 8 + <span class="supporter-badge" title="you support this artist via atprotofans"> 9 + <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> 10 + <path 11 + d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" 12 + /> 13 + </svg> 14 + <span class="label">supporter</span> 15 + </span> 16 + 17 + <style> 18 + .supporter-badge { 19 + display: inline-flex; 20 + align-items: center; 21 + gap: 0.3rem; 22 + padding: 0.2rem 0.5rem; 23 + background: color-mix(in srgb, var(--accent) 15%, transparent); 24 + border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); 25 + border-radius: var(--radius-sm); 26 + color: var(--accent); 27 + font-size: var(--text-xs); 28 + font-weight: 500; 29 + text-transform: lowercase; 30 + white-space: nowrap; 31 + } 32 + 33 + .supporter-badge svg { 34 + flex-shrink: 0; 35 + } 36 + 37 + .label { 38 + line-height: 1; 39 + } 40 + </style>
+10 -10
frontend/src/lib/components/TagInput.svelte
··· 178 padding: 0.75rem; 179 background: var(--bg-primary); 180 border: 1px solid var(--border-default); 181 - border-radius: 4px; 182 min-height: 48px; 183 transition: all 0.2s; 184 } ··· 195 background: color-mix(in srgb, var(--accent) 10%, transparent); 196 border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent); 197 color: var(--accent-hover); 198 - border-radius: 20px; 199 - font-size: 0.9rem; 200 font-weight: 500; 201 } 202 ··· 233 background: transparent; 234 border: none; 235 color: var(--text-primary); 236 - font-size: 1rem; 237 font-family: inherit; 238 outline: none; 239 } ··· 249 250 .spinner { 251 color: var(--text-muted); 252 - font-size: 0.85rem; 253 margin-left: auto; 254 } 255 ··· 261 overflow-y: auto; 262 background: var(--bg-secondary); 263 border: 1px solid var(--border-default); 264 - border-radius: 4px; 265 margin-top: 0.25rem; 266 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 267 scrollbar-width: thin; ··· 274 275 .suggestions::-webkit-scrollbar-track { 276 background: var(--bg-primary); 277 - border-radius: 4px; 278 } 279 280 .suggestions::-webkit-scrollbar-thumb { 281 background: var(--border-default); 282 - border-radius: 4px; 283 } 284 285 .suggestions::-webkit-scrollbar-thumb:hover { ··· 317 } 318 319 .tag-count { 320 - font-size: 0.85rem; 321 color: var(--text-tertiary); 322 } 323 ··· 332 333 .tag-chip { 334 padding: 0.3rem 0.5rem; 335 - font-size: 0.85rem; 336 } 337 } 338 </style>
··· 178 padding: 0.75rem; 179 background: var(--bg-primary); 180 border: 1px solid var(--border-default); 181 + border-radius: var(--radius-sm); 182 min-height: 48px; 183 transition: all 0.2s; 184 } ··· 195 background: color-mix(in srgb, var(--accent) 10%, transparent); 196 border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent); 197 color: var(--accent-hover); 198 + border-radius: var(--radius-xl); 199 + font-size: var(--text-base); 200 font-weight: 500; 201 } 202 ··· 233 background: transparent; 234 border: none; 235 color: var(--text-primary); 236 + font-size: var(--text-lg); 237 font-family: inherit; 238 outline: none; 239 } ··· 249 250 .spinner { 251 color: var(--text-muted); 252 + font-size: var(--text-sm); 253 margin-left: auto; 254 } 255 ··· 261 overflow-y: auto; 262 background: var(--bg-secondary); 263 border: 1px solid var(--border-default); 264 + border-radius: var(--radius-sm); 265 margin-top: 0.25rem; 266 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 267 scrollbar-width: thin; ··· 274 275 .suggestions::-webkit-scrollbar-track { 276 background: var(--bg-primary); 277 + border-radius: var(--radius-sm); 278 } 279 280 .suggestions::-webkit-scrollbar-thumb { 281 background: var(--border-default); 282 + border-radius: var(--radius-sm); 283 } 284 285 .suggestions::-webkit-scrollbar-thumb:hover { ··· 317 } 318 319 .tag-count { 320 + font-size: var(--text-sm); 321 color: var(--text-tertiary); 322 } 323 ··· 332 333 .tag-chip { 334 padding: 0.3rem 0.5rem; 335 + font-size: var(--text-sm); 336 } 337 } 338 </style>
+5 -5
frontend/src/lib/components/Toast.svelte
··· 61 backdrop-filter: blur(12px); 62 -webkit-backdrop-filter: blur(12px); 63 border: 1px solid rgba(255, 255, 255, 0.06); 64 - border-radius: 8px; 65 pointer-events: none; 66 - font-size: 0.85rem; 67 max-width: 450px; 68 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); 69 } 70 71 .toast-icon { 72 - font-size: 0.8rem; 73 flex-shrink: 0; 74 opacity: 0.7; 75 margin-top: 0.1rem; ··· 135 136 .toast { 137 padding: 0.35rem 0.7rem; 138 - font-size: 0.8rem; 139 max-width: 90vw; 140 } 141 142 .toast-icon { 143 - font-size: 0.75rem; 144 } 145 146 .toast-message {
··· 61 backdrop-filter: blur(12px); 62 -webkit-backdrop-filter: blur(12px); 63 border: 1px solid rgba(255, 255, 255, 0.06); 64 + border-radius: var(--radius-md); 65 pointer-events: none; 66 + font-size: var(--text-sm); 67 max-width: 450px; 68 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); 69 } 70 71 .toast-icon { 72 + font-size: var(--text-sm); 73 flex-shrink: 0; 74 opacity: 0.7; 75 margin-top: 0.1rem; ··· 135 136 .toast { 137 padding: 0.35rem 0.7rem; 138 + font-size: var(--text-sm); 139 max-width: 90vw; 140 } 141 142 .toast-icon { 143 + font-size: var(--text-xs); 144 } 145 146 .toast-message {
+18 -16
frontend/src/lib/components/TrackActionsMenu.svelte
··· 10 trackUri?: string; 11 trackCid?: string; 12 fileId?: string; 13 initialLiked: boolean; 14 shareUrl: string; 15 onQueue: () => void; ··· 24 trackUri, 25 trackCid, 26 fileId, 27 initialLiked, 28 shareUrl, 29 onQueue, ··· 101 102 try { 103 const success = liked 104 - ? await likeTrack(trackId, fileId) 105 : await unlikeTrack(trackId); 106 107 if (!success) { ··· 400 justify-content: center; 401 background: transparent; 402 border: 1px solid var(--border-default); 403 - border-radius: 4px; 404 color: var(--text-tertiary); 405 cursor: pointer; 406 transition: all 0.2s; ··· 472 } 473 474 .menu-item span { 475 - font-size: 1rem; 476 font-weight: 400; 477 flex: 1; 478 } ··· 506 border: none; 507 border-bottom: 1px solid var(--border-default); 508 color: var(--text-secondary); 509 - font-size: 0.9rem; 510 font-family: inherit; 511 cursor: pointer; 512 transition: background 0.15s; ··· 532 border: none; 533 border-bottom: 1px solid var(--border-subtle); 534 color: var(--text-primary); 535 - font-size: 1rem; 536 font-family: inherit; 537 cursor: pointer; 538 transition: background 0.15s; ··· 556 .playlist-thumb-placeholder { 557 width: 36px; 558 height: 36px; 559 - border-radius: 4px; 560 flex-shrink: 0; 561 } 562 ··· 588 gap: 0.5rem; 589 padding: 2rem 1rem; 590 color: var(--text-tertiary); 591 - font-size: 0.9rem; 592 } 593 594 .create-playlist-btn { ··· 601 border: none; 602 border-top: 1px solid var(--border-subtle); 603 color: var(--accent); 604 - font-size: 1rem; 605 font-family: inherit; 606 cursor: pointer; 607 transition: background 0.15s; ··· 625 padding: 0.75rem 1rem; 626 background: var(--bg-tertiary); 627 border: 1px solid var(--border-default); 628 - border-radius: 8px; 629 color: var(--text-primary); 630 font-family: inherit; 631 - font-size: 1rem; 632 } 633 634 .create-form input:focus { ··· 648 padding: 0.75rem 1rem; 649 background: var(--accent); 650 border: none; 651 - border-radius: 8px; 652 color: white; 653 font-family: inherit; 654 - font-size: 1rem; 655 font-weight: 500; 656 cursor: pointer; 657 transition: opacity 0.15s; ··· 671 height: 18px; 672 border: 2px solid var(--border-default); 673 border-top-color: var(--accent); 674 - border-radius: 50%; 675 animation: spin 0.8s linear infinite; 676 } 677 ··· 698 top: 50%; 699 transform: translateY(-50%); 700 margin-right: 0.5rem; 701 - border-radius: 8px; 702 min-width: 180px; 703 max-height: none; 704 animation: slideIn 0.15s cubic-bezier(0.16, 1, 0.3, 1); ··· 721 } 722 723 .menu-item span { 724 - font-size: 0.9rem; 725 } 726 727 .menu-item svg { ··· 735 736 .playlist-item { 737 padding: 0.625rem 1rem; 738 - font-size: 0.9rem; 739 } 740 741 .playlist-thumb,
··· 10 trackUri?: string; 11 trackCid?: string; 12 fileId?: string; 13 + gated?: boolean; 14 initialLiked: boolean; 15 shareUrl: string; 16 onQueue: () => void; ··· 25 trackUri, 26 trackCid, 27 fileId, 28 + gated, 29 initialLiked, 30 shareUrl, 31 onQueue, ··· 103 104 try { 105 const success = liked 106 + ? await likeTrack(trackId, fileId, gated) 107 : await unlikeTrack(trackId); 108 109 if (!success) { ··· 402 justify-content: center; 403 background: transparent; 404 border: 1px solid var(--border-default); 405 + border-radius: var(--radius-sm); 406 color: var(--text-tertiary); 407 cursor: pointer; 408 transition: all 0.2s; ··· 474 } 475 476 .menu-item span { 477 + font-size: var(--text-lg); 478 font-weight: 400; 479 flex: 1; 480 } ··· 508 border: none; 509 border-bottom: 1px solid var(--border-default); 510 color: var(--text-secondary); 511 + font-size: var(--text-base); 512 font-family: inherit; 513 cursor: pointer; 514 transition: background 0.15s; ··· 534 border: none; 535 border-bottom: 1px solid var(--border-subtle); 536 color: var(--text-primary); 537 + font-size: var(--text-lg); 538 font-family: inherit; 539 cursor: pointer; 540 transition: background 0.15s; ··· 558 .playlist-thumb-placeholder { 559 width: 36px; 560 height: 36px; 561 + border-radius: var(--radius-sm); 562 flex-shrink: 0; 563 } 564 ··· 590 gap: 0.5rem; 591 padding: 2rem 1rem; 592 color: var(--text-tertiary); 593 + font-size: var(--text-base); 594 } 595 596 .create-playlist-btn { ··· 603 border: none; 604 border-top: 1px solid var(--border-subtle); 605 color: var(--accent); 606 + font-size: var(--text-lg); 607 font-family: inherit; 608 cursor: pointer; 609 transition: background 0.15s; ··· 627 padding: 0.75rem 1rem; 628 background: var(--bg-tertiary); 629 border: 1px solid var(--border-default); 630 + border-radius: var(--radius-md); 631 color: var(--text-primary); 632 font-family: inherit; 633 + font-size: var(--text-lg); 634 } 635 636 .create-form input:focus { ··· 650 padding: 0.75rem 1rem; 651 background: var(--accent); 652 border: none; 653 + border-radius: var(--radius-md); 654 color: white; 655 font-family: inherit; 656 + font-size: var(--text-lg); 657 font-weight: 500; 658 cursor: pointer; 659 transition: opacity 0.15s; ··· 673 height: 18px; 674 border: 2px solid var(--border-default); 675 border-top-color: var(--accent); 676 + border-radius: var(--radius-full); 677 animation: spin 0.8s linear infinite; 678 } 679 ··· 700 top: 50%; 701 transform: translateY(-50%); 702 margin-right: 0.5rem; 703 + border-radius: var(--radius-md); 704 min-width: 180px; 705 max-height: none; 706 animation: slideIn 0.15s cubic-bezier(0.16, 1, 0.3, 1); ··· 723 } 724 725 .menu-item span { 726 + font-size: var(--text-base); 727 } 728 729 .menu-item svg { ··· 737 738 .playlist-item { 739 padding: 0.625rem 1rem; 740 + font-size: var(--text-base); 741 } 742 743 .playlist-thumb,
+151 -70
frontend/src/lib/components/TrackItem.svelte
··· 7 import type { Track } from '$lib/types'; 8 import { queue } from '$lib/queue.svelte'; 9 import { toast } from '$lib/toast.svelte'; 10 11 interface Props { 12 track: Track; ··· 37 const imageFetchPriority = index < 2 ? 'high' : undefined; 38 39 let showLikersTooltip = $state(false); 40 - let likeCount = $state(track.like_count || 0); 41 - let commentCount = $state(track.comment_count || 0); 42 let trackImageError = $state(false); 43 let avatarError = $state(false); 44 let tagsExpanded = $state(false); 45 46 // limit visible tags to prevent vertical sprawl (max 2 shown) 47 const MAX_VISIBLE_TAGS = 2; ··· 52 (track.tags?.length || 0) - MAX_VISIBLE_TAGS 53 ); 54 55 - // sync counts when track changes 56 - $effect(() => { 57 - likeCount = track.like_count || 0; 58 - commentCount = track.comment_count || 0; 59 - // reset error states when track changes (e.g. recycled component) 60 - trackImageError = false; 61 - avatarError = false; 62 - tagsExpanded = false; 63 - }); 64 - 65 // construct shareable URL - use /track/[id] for link previews 66 // the track page will redirect to home with query param for actual playback 67 const shareUrl = typeof window !== 'undefined' ··· 70 71 function addToQueue(e: Event) { 72 e.stopPropagation(); 73 queue.addTracks([track]); 74 toast.success(`queued ${track.title}`, 1800); 75 } 76 77 function handleQueue() { 78 queue.addTracks([track]); 79 toast.success(`queued ${track.title}`, 1800); 80 } ··· 126 {/if} 127 <button 128 class="track" 129 - onclick={(e) => { 130 // only play if clicking the track itself, not a link inside 131 if (e.target instanceof HTMLAnchorElement || (e.target as HTMLElement).closest('a')) { 132 return; 133 } 134 - onPlay(track); 135 }} 136 > 137 - {#if track.image_url && !trackImageError} 138 - <SensitiveImage src={track.image_url}> 139 - <div class="track-image"> 140 - <img 141 - src={track.image_url} 142 - alt="{track.title} artwork" 143 - width="48" 144 - height="48" 145 - loading={imageLoading} 146 - fetchpriority={imageFetchPriority} 147 - onerror={() => trackImageError = true} 148 - /> 149 </div> 150 - </SensitiveImage> 151 - {:else if track.artist_avatar_url && !avatarError} 152 - <SensitiveImage src={track.artist_avatar_url}> 153 - <a 154 - href="/u/{track.artist_handle}" 155 - class="track-avatar" 156 - > 157 - <img 158 - src={track.artist_avatar_url} 159 - alt={track.artist} 160 - width="48" 161 - height="48" 162 - loading={imageLoading} 163 - fetchpriority={imageFetchPriority} 164 - onerror={() => avatarError = true} 165 - /> 166 - </a> 167 - </SensitiveImage> 168 - {:else} 169 - <div class="track-image-placeholder"> 170 - <svg width="24" height="24" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg"> 171 - <circle cx="8" cy="5" r="3" stroke="currentColor" stroke-width="1.5" fill="none" /> 172 - <path d="M3 14c0-2.5 2-4.5 5-4.5s5 2 5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /> 173 - </svg> 174 - </div> 175 - {/if} 176 <div class="track-info"> 177 <div class="track-title">{track.title}</div> 178 <div class="track-metadata"> ··· 285 trackUri={track.atproto_record_uri} 286 trackCid={track.atproto_record_cid} 287 fileId={track.file_id} 288 initialLiked={track.is_liked || false} 289 disabled={!track.atproto_record_uri} 290 disabledReason={!track.atproto_record_uri ? "track's record is unavailable" : undefined} ··· 318 trackUri={track.atproto_record_uri} 319 trackCid={track.atproto_record_cid} 320 fileId={track.file_id} 321 initialLiked={track.is_liked || false} 322 shareUrl={shareUrl} 323 onQueue={handleQueue} ··· 336 gap: 0.75rem; 337 background: var(--track-bg, var(--bg-secondary)); 338 border: 1px solid var(--track-border, var(--border-subtle)); 339 - border-radius: 8px; 340 padding: 1rem; 341 box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); 342 transition: ··· 347 348 .track-index { 349 width: 24px; 350 - font-size: 0.85rem; 351 color: var(--text-muted); 352 text-align: center; 353 flex-shrink: 0; ··· 393 font-family: inherit; 394 } 395 396 .track-image, 397 .track-image-placeholder { 398 flex-shrink: 0; ··· 401 display: flex; 402 align-items: center; 403 justify-content: center; 404 - border-radius: 4px; 405 overflow: hidden; 406 background: var(--bg-tertiary); 407 border: 1px solid var(--border-subtle); ··· 435 } 436 437 .track-avatar img { 438 - border-radius: 50%; 439 border: 2px solid var(--border-default); 440 transition: border-color 0.2s; 441 } ··· 485 align-items: flex-start; 486 gap: 0.15rem; 487 color: var(--text-secondary); 488 - font-size: 0.9rem; 489 font-family: inherit; 490 min-width: 0; 491 width: 100%; ··· 505 506 .metadata-separator { 507 display: none; 508 - font-size: 0.7rem; 509 } 510 511 .artist-link { ··· 604 padding: 0.1rem 0.4rem; 605 background: color-mix(in srgb, var(--accent) 15%, transparent); 606 color: var(--accent-hover); 607 - border-radius: 3px; 608 - font-size: 0.75rem; 609 font-weight: 500; 610 text-decoration: none; 611 transition: all 0.15s; ··· 624 background: var(--bg-tertiary); 625 color: var(--text-muted); 626 border: none; 627 - border-radius: 3px; 628 - font-size: 0.75rem; 629 font-weight: 500; 630 font-family: inherit; 631 cursor: pointer; ··· 640 } 641 642 .track-meta { 643 - font-size: 0.8rem; 644 color: var(--text-tertiary); 645 display: flex; 646 align-items: center; ··· 654 655 .meta-separator { 656 color: var(--text-muted); 657 - font-size: 0.7rem; 658 } 659 660 .likes { ··· 701 justify-content: center; 702 background: transparent; 703 border: 1px solid var(--border-default); 704 - border-radius: 4px; 705 color: var(--text-tertiary); 706 cursor: pointer; 707 transition: all 0.2s; ··· 746 gap: 0.5rem; 747 } 748 749 .track-image, 750 .track-image-placeholder, 751 .track-avatar { ··· 753 height: 40px; 754 } 755 756 .track-title { 757 - font-size: 0.9rem; 758 } 759 760 .track-metadata { 761 - font-size: 0.8rem; 762 gap: 0.35rem; 763 } 764 765 .track-meta { 766 - font-size: 0.7rem; 767 } 768 769 .track-actions { ··· 786 padding: 0.5rem 0.65rem; 787 } 788 789 .track-image, 790 .track-image-placeholder, 791 .track-avatar { ··· 793 height: 36px; 794 } 795 796 .track-title { 797 - font-size: 0.85rem; 798 } 799 800 .track-metadata { 801 - font-size: 0.75rem; 802 } 803 804 .metadata-separator {
··· 7 import type { Track } from '$lib/types'; 8 import { queue } from '$lib/queue.svelte'; 9 import { toast } from '$lib/toast.svelte'; 10 + import { playTrack, guardGatedTrack } from '$lib/playback.svelte'; 11 12 interface Props { 13 track: Track; ··· 38 const imageFetchPriority = index < 2 ? 'high' : undefined; 39 40 let showLikersTooltip = $state(false); 41 + // use overridable $derived (Svelte 5.25+) - syncs with prop but can be overridden for optimistic UI 42 + let likeCount = $derived(track.like_count || 0); 43 + let commentCount = $derived(track.comment_count || 0); 44 + // local UI state keyed by track.id - reset when track changes (component recycling) 45 let trackImageError = $state(false); 46 let avatarError = $state(false); 47 let tagsExpanded = $state(false); 48 + let prevTrackId: number | undefined; 49 + 50 + // reset local UI state when track changes (component may be recycled) 51 + // using $effect.pre so state is ready before render 52 + $effect.pre(() => { 53 + if (prevTrackId !== undefined && track.id !== prevTrackId) { 54 + trackImageError = false; 55 + avatarError = false; 56 + tagsExpanded = false; 57 + } 58 + prevTrackId = track.id; 59 + }); 60 61 // limit visible tags to prevent vertical sprawl (max 2 shown) 62 const MAX_VISIBLE_TAGS = 2; ··· 67 (track.tags?.length || 0) - MAX_VISIBLE_TAGS 68 ); 69 70 // construct shareable URL - use /track/[id] for link previews 71 // the track page will redirect to home with query param for actual playback 72 const shareUrl = typeof window !== 'undefined' ··· 75 76 function addToQueue(e: Event) { 77 e.stopPropagation(); 78 + if (!guardGatedTrack(track, isAuthenticated)) return; 79 queue.addTracks([track]); 80 toast.success(`queued ${track.title}`, 1800); 81 } 82 83 function handleQueue() { 84 + if (!guardGatedTrack(track, isAuthenticated)) return; 85 queue.addTracks([track]); 86 toast.success(`queued ${track.title}`, 1800); 87 } ··· 133 {/if} 134 <button 135 class="track" 136 + onclick={async (e) => { 137 // only play if clicking the track itself, not a link inside 138 if (e.target instanceof HTMLAnchorElement || (e.target as HTMLElement).closest('a')) { 139 return; 140 } 141 + // use playTrack for gated content checks, fall back to onPlay for non-gated 142 + if (track.gated) { 143 + await playTrack(track); 144 + } else { 145 + onPlay(track); 146 + } 147 }} 148 > 149 + <div class="track-image-wrapper" class:gated={track.gated}> 150 + {#if track.image_url && !trackImageError} 151 + <SensitiveImage src={track.image_url}> 152 + <div class="track-image"> 153 + <img 154 + src={track.image_url} 155 + alt="{track.title} artwork" 156 + width="48" 157 + height="48" 158 + loading={imageLoading} 159 + fetchpriority={imageFetchPriority} 160 + onerror={() => trackImageError = true} 161 + /> 162 + </div> 163 + </SensitiveImage> 164 + {:else if track.artist_avatar_url && !avatarError} 165 + <SensitiveImage src={track.artist_avatar_url}> 166 + <a 167 + href="/u/{track.artist_handle}" 168 + class="track-avatar" 169 + > 170 + <img 171 + src={track.artist_avatar_url} 172 + alt={track.artist} 173 + width="48" 174 + height="48" 175 + loading={imageLoading} 176 + fetchpriority={imageFetchPriority} 177 + onerror={() => avatarError = true} 178 + /> 179 + </a> 180 + </SensitiveImage> 181 + {:else} 182 + <div class="track-image-placeholder"> 183 + <svg width="24" height="24" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg"> 184 + <circle cx="8" cy="5" r="3" stroke="currentColor" stroke-width="1.5" fill="none" /> 185 + <path d="M3 14c0-2.5 2-4.5 5-4.5s5 2 5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /> 186 + </svg> 187 + </div> 188 + {/if} 189 + {#if track.gated} 190 + <div class="gated-badge" title="supporters only"> 191 + <svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> 192 + <path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/> 193 + </svg> 194 </div> 195 + {/if} 196 + </div> 197 <div class="track-info"> 198 <div class="track-title">{track.title}</div> 199 <div class="track-metadata"> ··· 306 trackUri={track.atproto_record_uri} 307 trackCid={track.atproto_record_cid} 308 fileId={track.file_id} 309 + gated={track.gated} 310 initialLiked={track.is_liked || false} 311 disabled={!track.atproto_record_uri} 312 disabledReason={!track.atproto_record_uri ? "track's record is unavailable" : undefined} ··· 340 trackUri={track.atproto_record_uri} 341 trackCid={track.atproto_record_cid} 342 fileId={track.file_id} 343 + gated={track.gated} 344 initialLiked={track.is_liked || false} 345 shareUrl={shareUrl} 346 onQueue={handleQueue} ··· 359 gap: 0.75rem; 360 background: var(--track-bg, var(--bg-secondary)); 361 border: 1px solid var(--track-border, var(--border-subtle)); 362 + border-radius: var(--radius-md); 363 padding: 1rem; 364 box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); 365 transition: ··· 370 371 .track-index { 372 width: 24px; 373 + font-size: var(--text-sm); 374 color: var(--text-muted); 375 text-align: center; 376 flex-shrink: 0; ··· 416 font-family: inherit; 417 } 418 419 + .track-image-wrapper { 420 + position: relative; 421 + flex-shrink: 0; 422 + width: 48px; 423 + height: 48px; 424 + } 425 + 426 + .track-image-wrapper.gated::after { 427 + content: ''; 428 + position: absolute; 429 + inset: 0; 430 + background: rgba(0, 0, 0, 0.3); 431 + border-radius: var(--radius-sm); 432 + pointer-events: none; 433 + } 434 + 435 + .gated-badge { 436 + position: absolute; 437 + bottom: -4px; 438 + right: -4px; 439 + width: 18px; 440 + height: 18px; 441 + display: flex; 442 + align-items: center; 443 + justify-content: center; 444 + background: var(--accent); 445 + border: 2px solid var(--bg-secondary); 446 + border-radius: var(--radius-full); 447 + color: white; 448 + z-index: 1; 449 + } 450 + 451 .track-image, 452 .track-image-placeholder { 453 flex-shrink: 0; ··· 456 display: flex; 457 align-items: center; 458 justify-content: center; 459 + border-radius: var(--radius-sm); 460 overflow: hidden; 461 background: var(--bg-tertiary); 462 border: 1px solid var(--border-subtle); ··· 490 } 491 492 .track-avatar img { 493 + border-radius: var(--radius-full); 494 border: 2px solid var(--border-default); 495 transition: border-color 0.2s; 496 } ··· 540 align-items: flex-start; 541 gap: 0.15rem; 542 color: var(--text-secondary); 543 + font-size: var(--text-base); 544 font-family: inherit; 545 min-width: 0; 546 width: 100%; ··· 560 561 .metadata-separator { 562 display: none; 563 + font-size: var(--text-xs); 564 } 565 566 .artist-link { ··· 659 padding: 0.1rem 0.4rem; 660 background: color-mix(in srgb, var(--accent) 15%, transparent); 661 color: var(--accent-hover); 662 + border-radius: var(--radius-sm); 663 + font-size: var(--text-xs); 664 font-weight: 500; 665 text-decoration: none; 666 transition: all 0.15s; ··· 679 background: var(--bg-tertiary); 680 color: var(--text-muted); 681 border: none; 682 + border-radius: var(--radius-sm); 683 + font-size: var(--text-xs); 684 font-weight: 500; 685 font-family: inherit; 686 cursor: pointer; ··· 695 } 696 697 .track-meta { 698 + font-size: var(--text-sm); 699 color: var(--text-tertiary); 700 display: flex; 701 align-items: center; ··· 709 710 .meta-separator { 711 color: var(--text-muted); 712 + font-size: var(--text-xs); 713 } 714 715 .likes { ··· 756 justify-content: center; 757 background: transparent; 758 border: 1px solid var(--border-default); 759 + border-radius: var(--radius-sm); 760 color: var(--text-tertiary); 761 cursor: pointer; 762 transition: all 0.2s; ··· 801 gap: 0.5rem; 802 } 803 804 + .track-image-wrapper, 805 .track-image, 806 .track-image-placeholder, 807 .track-avatar { ··· 809 height: 40px; 810 } 811 812 + .gated-badge { 813 + width: 16px; 814 + height: 16px; 815 + bottom: -3px; 816 + right: -3px; 817 + } 818 + 819 + .gated-badge svg { 820 + width: 8px; 821 + height: 8px; 822 + } 823 + 824 .track-title { 825 + font-size: var(--text-base); 826 } 827 828 .track-metadata { 829 + font-size: var(--text-sm); 830 gap: 0.35rem; 831 } 832 833 .track-meta { 834 + font-size: var(--text-xs); 835 } 836 837 .track-actions { ··· 854 padding: 0.5rem 0.65rem; 855 } 856 857 + .track-image-wrapper, 858 .track-image, 859 .track-image-placeholder, 860 .track-avatar { ··· 862 height: 36px; 863 } 864 865 + .gated-badge { 866 + width: 14px; 867 + height: 14px; 868 + bottom: -2px; 869 + right: -2px; 870 + } 871 + 872 + .gated-badge svg { 873 + width: 7px; 874 + height: 7px; 875 + } 876 + 877 .track-title { 878 + font-size: var(--text-sm); 879 } 880 881 .track-metadata { 882 + font-size: var(--text-xs); 883 } 884 885 .metadata-separator {
+1 -1
frontend/src/lib/components/WaveLoading.svelte
··· 67 .message { 68 margin: 0; 69 color: var(--text-secondary); 70 - font-size: 0.9rem; 71 text-align: center; 72 } 73 </style>
··· 67 .message { 68 margin: 0; 69 color: var(--text-secondary); 70 + font-size: var(--text-base); 71 text-align: center; 72 } 73 </style>
+6 -6
frontend/src/lib/components/player/PlaybackControls.svelte
··· 244 align-items: center; 245 justify-content: center; 246 transition: all 0.2s; 247 - border-radius: 50%; 248 } 249 250 .control-btn svg { ··· 282 display: flex; 283 align-items: center; 284 justify-content: center; 285 - border-radius: 6px; 286 transition: all 0.2s; 287 position: relative; 288 } ··· 310 } 311 312 .time { 313 - font-size: 0.85rem; 314 color: var(--text-tertiary); 315 min-width: 45px; 316 font-variant-numeric: tabular-nums; ··· 382 background: var(--accent); 383 height: 14px; 384 width: 14px; 385 - border-radius: 50%; 386 margin-top: -5px; 387 transition: all 0.2s; 388 box-shadow: 0 0 0 8px transparent; ··· 419 background: var(--accent); 420 height: 14px; 421 width: 14px; 422 - border-radius: 50%; 423 border: none; 424 transition: all 0.2s; 425 box-shadow: 0 0 0 8px transparent; ··· 493 } 494 495 .time { 496 - font-size: 0.75rem; 497 min-width: 38px; 498 } 499
··· 244 align-items: center; 245 justify-content: center; 246 transition: all 0.2s; 247 + border-radius: var(--radius-full); 248 } 249 250 .control-btn svg { ··· 282 display: flex; 283 align-items: center; 284 justify-content: center; 285 + border-radius: var(--radius-base); 286 transition: all 0.2s; 287 position: relative; 288 } ··· 310 } 311 312 .time { 313 + font-size: var(--text-sm); 314 color: var(--text-tertiary); 315 min-width: 45px; 316 font-variant-numeric: tabular-nums; ··· 382 background: var(--accent); 383 height: 14px; 384 width: 14px; 385 + border-radius: var(--radius-full); 386 margin-top: -5px; 387 transition: all 0.2s; 388 box-shadow: 0 0 0 8px transparent; ··· 419 background: var(--accent); 420 height: 14px; 421 width: 14px; 422 + border-radius: var(--radius-full); 423 border: none; 424 transition: all 0.2s; 425 box-shadow: 0 0 0 8px transparent; ··· 493 } 494 495 .time { 496 + font-size: var(--text-xs); 497 min-width: 38px; 498 } 499
+138 -23
frontend/src/lib/components/player/Player.svelte
··· 4 import { nowPlaying } from '$lib/now-playing.svelte'; 5 import { moderation } from '$lib/moderation.svelte'; 6 import { preferences } from '$lib/preferences.svelte'; 7 import { API_URL } from '$lib/config'; 8 import { getCachedAudioUrl } from '$lib/storage'; 9 import { onMount } from 'svelte'; ··· 11 import TrackInfo from './TrackInfo.svelte'; 12 import PlaybackControls from './PlaybackControls.svelte'; 13 import type { Track } from '$lib/types'; 14 15 // check if artwork should be shown in media session (respects sensitive content settings) 16 function shouldShowArtwork(url: string | null | undefined): boolean { ··· 239 ); 240 }); 241 242 // get audio source URL - checks local cache first, falls back to network 243 - async function getAudioSource(file_id: string): Promise<string> { 244 try { 245 const cachedUrl = await getCachedAudioUrl(file_id); 246 if (cachedUrl) { ··· 249 } catch (err) { 250 console.error('failed to check audio cache:', err); 251 } 252 return `${API_URL}/audio/${file_id}`; 253 } 254 ··· 266 let previousTrackId = $state<number | null>(null); 267 let isLoadingTrack = $state(false); 268 269 $effect(() => { 270 if (!player.currentTrack || !player.audioElement) return; 271 272 // only load new track if it actually changed 273 if (player.currentTrack.id !== previousTrackId) { 274 const trackToLoad = player.currentTrack; 275 previousTrackId = trackToLoad.id; 276 player.resetPlayCount(); 277 isLoadingTrack = true; ··· 280 cleanupBlobUrl(); 281 282 // async: get audio source (cached or network) 283 - getAudioSource(trackToLoad.file_id).then((src) => { 284 - // check if track is still current (user may have changed tracks during await) 285 - if (player.currentTrack?.id !== trackToLoad.id || !player.audioElement) { 286 - // track changed, cleanup if we created a blob URL 287 if (src.startsWith('blob:')) { 288 - URL.revokeObjectURL(src); 289 } 290 - return; 291 - } 292 293 - // track if this is a blob URL so we can revoke it later 294 - if (src.startsWith('blob:')) { 295 - currentBlobUrl = src; 296 - } 297 298 - player.audioElement.src = src; 299 - player.audioElement.load(); 300 301 - // wait for audio to be ready before allowing playback 302 - player.audioElement.addEventListener( 303 - 'loadeddata', 304 - () => { 305 - isLoadingTrack = false; 306 - }, 307 - { once: true } 308 - ); 309 - }); 310 } 311 }); 312
··· 4 import { nowPlaying } from '$lib/now-playing.svelte'; 5 import { moderation } from '$lib/moderation.svelte'; 6 import { preferences } from '$lib/preferences.svelte'; 7 + import { toast } from '$lib/toast.svelte'; 8 import { API_URL } from '$lib/config'; 9 import { getCachedAudioUrl } from '$lib/storage'; 10 import { onMount } from 'svelte'; ··· 12 import TrackInfo from './TrackInfo.svelte'; 13 import PlaybackControls from './PlaybackControls.svelte'; 14 import type { Track } from '$lib/types'; 15 + 16 + // atprotofans base URL for supporter CTAs 17 + const ATPROTOFANS_URL = 'https://atprotofans.com'; 18 19 // check if artwork should be shown in media session (respects sensitive content settings) 20 function shouldShowArtwork(url: string | null | undefined): boolean { ··· 243 ); 244 }); 245 246 + // gated content error types 247 + interface GatedError { 248 + type: 'gated'; 249 + artistDid: string; 250 + artistHandle: string; 251 + requiresAuth: boolean; 252 + } 253 + 254 // get audio source URL - checks local cache first, falls back to network 255 + // throws GatedError if the track requires supporter access 256 + async function getAudioSource(file_id: string, track: Track): Promise<string> { 257 try { 258 const cachedUrl = await getCachedAudioUrl(file_id); 259 if (cachedUrl) { ··· 262 } catch (err) { 263 console.error('failed to check audio cache:', err); 264 } 265 + 266 + // for gated tracks, check authorization first 267 + if (track.gated) { 268 + const response = await fetch(`${API_URL}/audio/${file_id}`, { 269 + method: 'HEAD', 270 + credentials: 'include' 271 + }); 272 + 273 + if (response.status === 401) { 274 + throw { 275 + type: 'gated', 276 + artistDid: track.artist_did, 277 + artistHandle: track.artist_handle, 278 + requiresAuth: true 279 + } as GatedError; 280 + } 281 + 282 + if (response.status === 402) { 283 + throw { 284 + type: 'gated', 285 + artistDid: track.artist_did, 286 + artistHandle: track.artist_handle, 287 + requiresAuth: false 288 + } as GatedError; 289 + } 290 + } 291 + 292 return `${API_URL}/audio/${file_id}`; 293 } 294 ··· 306 let previousTrackId = $state<number | null>(null); 307 let isLoadingTrack = $state(false); 308 309 + // store previous playback state for restoration on gated errors 310 + let savedPlaybackState = $state<{ 311 + track: Track; 312 + src: string; 313 + currentTime: number; 314 + paused: boolean; 315 + } | null>(null); 316 + 317 $effect(() => { 318 if (!player.currentTrack || !player.audioElement) return; 319 320 // only load new track if it actually changed 321 if (player.currentTrack.id !== previousTrackId) { 322 const trackToLoad = player.currentTrack; 323 + const audioElement = player.audioElement; 324 + 325 + // save current playback state BEFORE changing anything 326 + // (only if we have a playing/paused track to restore to) 327 + if (previousTrackId !== null && audioElement.src && !audioElement.src.startsWith('blob:')) { 328 + const prevTrack = queue.tracks.find((t) => t.id === previousTrackId); 329 + if (prevTrack) { 330 + savedPlaybackState = { 331 + track: prevTrack, 332 + src: audioElement.src, 333 + currentTime: audioElement.currentTime, 334 + paused: audioElement.paused 335 + }; 336 + } 337 + } 338 + 339 + // update tracking state 340 previousTrackId = trackToLoad.id; 341 player.resetPlayCount(); 342 isLoadingTrack = true; ··· 345 cleanupBlobUrl(); 346 347 // async: get audio source (cached or network) 348 + getAudioSource(trackToLoad.file_id, trackToLoad) 349 + .then((src) => { 350 + // check if track is still current (user may have changed tracks during await) 351 + if (player.currentTrack?.id !== trackToLoad.id || !player.audioElement) { 352 + // track changed, cleanup if we created a blob URL 353 + if (src.startsWith('blob:')) { 354 + URL.revokeObjectURL(src); 355 + } 356 + return; 357 + } 358 + 359 + // successfully got source - clear saved state 360 + savedPlaybackState = null; 361 + 362 + // track if this is a blob URL so we can revoke it later 363 if (src.startsWith('blob:')) { 364 + currentBlobUrl = src; 365 } 366 + 367 + player.audioElement.src = src; 368 + player.audioElement.load(); 369 370 + // wait for audio to be ready before allowing playback 371 + player.audioElement.addEventListener( 372 + 'loadeddata', 373 + () => { 374 + isLoadingTrack = false; 375 + }, 376 + { once: true } 377 + ); 378 + }) 379 + .catch((err) => { 380 + isLoadingTrack = false; 381 382 + // handle gated content errors with supporter CTA 383 + if (err && err.type === 'gated') { 384 + const gatedErr = err as GatedError; 385 386 + if (gatedErr.requiresAuth) { 387 + toast.info('sign in to play supporter-only tracks'); 388 + } else { 389 + // show toast with supporter CTA 390 + const supportUrl = gatedErr.artistDid 391 + ? `${ATPROTOFANS_URL}/${gatedErr.artistDid}` 392 + : `${ATPROTOFANS_URL}/${gatedErr.artistHandle}`; 393 + 394 + toast.info('this track is for supporters only', 5000, { 395 + label: 'become a supporter', 396 + href: supportUrl 397 + }); 398 + } 399 + 400 + // restore previous playback if we had something playing 401 + if (savedPlaybackState && player.audioElement) { 402 + player.currentTrack = savedPlaybackState.track; 403 + previousTrackId = savedPlaybackState.track.id; 404 + player.audioElement.src = savedPlaybackState.src; 405 + player.audioElement.currentTime = savedPlaybackState.currentTime; 406 + if (!savedPlaybackState.paused) { 407 + player.audioElement.play().catch(() => {}); 408 + } 409 + savedPlaybackState = null; 410 + return; 411 + } 412 + 413 + // no previous state to restore - skip to next or stop 414 + if (queue.hasNext) { 415 + queue.next(); 416 + } else { 417 + player.currentTrack = null; 418 + player.paused = true; 419 + } 420 + return; 421 + } 422 + 423 + console.error('failed to load audio:', err); 424 + }); 425 } 426 }); 427
+4 -4
frontend/src/lib/components/player/TrackInfo.svelte
··· 156 flex-shrink: 0; 157 width: 56px; 158 height: 56px; 159 - border-radius: 4px; 160 overflow: hidden; 161 background: var(--bg-tertiary); 162 border: 1px solid var(--border-default); ··· 197 198 .player-title, 199 .player-title-link { 200 - font-size: 0.95rem; 201 font-weight: 600; 202 color: var(--text-primary); 203 margin-bottom: 0; ··· 384 385 .player-title, 386 .player-title-link { 387 - font-size: 0.9rem; 388 margin-bottom: 0; 389 } 390 391 .player-metadata { 392 - font-size: 0.8rem; 393 } 394 395 .player-title.scrolling,
··· 156 flex-shrink: 0; 157 width: 56px; 158 height: 56px; 159 + border-radius: var(--radius-sm); 160 overflow: hidden; 161 background: var(--bg-tertiary); 162 border: 1px solid var(--border-default); ··· 197 198 .player-title, 199 .player-title-link { 200 + font-size: var(--text-base); 201 font-weight: 600; 202 color: var(--text-primary); 203 margin-bottom: 0; ··· 384 385 .player-title, 386 .player-title-link { 387 + font-size: var(--text-base); 388 margin-bottom: 0; 389 } 390 391 .player-metadata { 392 + font-size: var(--text-sm); 393 } 394 395 .player-title.scrolling,
+8
frontend/src/lib/config.ts
··· 2 3 export const API_URL = PUBLIC_API_URL || 'http://localhost:8001'; 4 5 interface ServerConfig { 6 max_upload_size_mb: number; 7 max_image_size_mb: number;
··· 2 3 export const API_URL = PUBLIC_API_URL || 'http://localhost:8001'; 4 5 + /** 6 + * generate atprotofans support URL for an artist. 7 + * canonical format: https://atprotofans.com/support/{did} 8 + */ 9 + export function getAtprotofansSupportUrl(did: string): string { 10 + return `https://atprotofans.com/support/${did}`; 11 + } 12 + 13 interface ServerConfig { 14 max_upload_size_mb: number; 15 max_image_size_mb: number;
+187
frontend/src/lib/playback.svelte.ts
···
··· 1 + /** 2 + * playback helper - guards queue operations with gated content checks. 3 + * 4 + * all playback actions should go through this module to prevent 5 + * gated tracks from interrupting current playback. 6 + */ 7 + 8 + import { browser } from '$app/environment'; 9 + import { queue } from './queue.svelte'; 10 + import { toast } from './toast.svelte'; 11 + import { API_URL, getAtprotofansSupportUrl } from './config'; 12 + import type { Track } from './types'; 13 + 14 + interface GatedCheckResult { 15 + allowed: boolean; 16 + requiresAuth?: boolean; 17 + artistDid?: string; 18 + artistHandle?: string; 19 + } 20 + 21 + /** 22 + * check if a track can be played by the current user. 23 + * returns immediately for non-gated tracks. 24 + * for gated tracks, makes a HEAD request to verify access. 25 + */ 26 + async function checkAccess(track: Track): Promise<GatedCheckResult> { 27 + // non-gated tracks are always allowed 28 + if (!track.gated) { 29 + return { allowed: true }; 30 + } 31 + 32 + // gated track - check access via HEAD request 33 + try { 34 + const response = await fetch(`${API_URL}/audio/${track.file_id}`, { 35 + method: 'HEAD', 36 + credentials: 'include' 37 + }); 38 + 39 + if (response.ok) { 40 + return { allowed: true }; 41 + } 42 + 43 + if (response.status === 401) { 44 + return { 45 + allowed: false, 46 + requiresAuth: true, 47 + artistDid: track.artist_did, 48 + artistHandle: track.artist_handle 49 + }; 50 + } 51 + 52 + if (response.status === 402) { 53 + return { 54 + allowed: false, 55 + requiresAuth: false, 56 + artistDid: track.artist_did, 57 + artistHandle: track.artist_handle 58 + }; 59 + } 60 + 61 + // unexpected status - allow and let Player handle any errors 62 + return { allowed: true }; 63 + } catch { 64 + // network error - allow and let Player handle any errors 65 + return { allowed: true }; 66 + } 67 + } 68 + 69 + /** 70 + * show appropriate toast for denied access (from HEAD request). 71 + */ 72 + function showDeniedToast(result: GatedCheckResult): void { 73 + if (result.requiresAuth) { 74 + toast.info('sign in to play supporter-only tracks'); 75 + } else if (result.artistDid) { 76 + toast.info('this track is for supporters only', 5000, { 77 + label: 'become a supporter', 78 + href: getAtprotofansSupportUrl(result.artistDid) 79 + }); 80 + } else { 81 + toast.info('this track is for supporters only'); 82 + } 83 + } 84 + 85 + /** 86 + * show toast for gated track (using server-resolved status). 87 + */ 88 + function showGatedToast(track: Track, isAuthenticated: boolean): void { 89 + if (!isAuthenticated) { 90 + toast.info('sign in to play supporter-only tracks'); 91 + } else if (track.artist_did) { 92 + toast.info('this track is for supporters only', 5000, { 93 + label: 'become a supporter', 94 + href: getAtprotofansSupportUrl(track.artist_did) 95 + }); 96 + } else { 97 + toast.info('this track is for supporters only'); 98 + } 99 + } 100 + 101 + /** 102 + * check if track is accessible using server-resolved gated status. 103 + * shows toast if denied. no network call - instant feedback. 104 + * use this for queue adds and other non-playback operations. 105 + */ 106 + export function guardGatedTrack(track: Track, isAuthenticated: boolean): boolean { 107 + if (!track.gated) return true; 108 + showGatedToast(track, isAuthenticated); 109 + return false; 110 + } 111 + 112 + /** 113 + * play a single track now. 114 + * checks gated access before modifying queue state. 115 + * shows toast if access denied - does NOT interrupt current playback. 116 + */ 117 + export async function playTrack(track: Track): Promise<boolean> { 118 + if (!browser) return false; 119 + 120 + const result = await checkAccess(track); 121 + if (!result.allowed) { 122 + showDeniedToast(result); 123 + return false; 124 + } 125 + 126 + queue.playNow(track); 127 + return true; 128 + } 129 + 130 + /** 131 + * set the queue and optionally start playing at a specific index. 132 + * checks gated access for the starting track before modifying queue state. 133 + */ 134 + export async function playQueue(tracks: Track[], startIndex = 0): Promise<boolean> { 135 + if (!browser || tracks.length === 0) return false; 136 + 137 + const startTrack = tracks[startIndex]; 138 + if (!startTrack) return false; 139 + 140 + const result = await checkAccess(startTrack); 141 + if (!result.allowed) { 142 + showDeniedToast(result); 143 + return false; 144 + } 145 + 146 + queue.setQueue(tracks, startIndex); 147 + return true; 148 + } 149 + 150 + /** 151 + * add tracks to queue and optionally start playing. 152 + * if playNow is true, checks gated access for the first added track. 153 + */ 154 + export async function addToQueue(tracks: Track[], playNow = false): Promise<boolean> { 155 + if (!browser || tracks.length === 0) return false; 156 + 157 + if (playNow) { 158 + const result = await checkAccess(tracks[0]); 159 + if (!result.allowed) { 160 + showDeniedToast(result); 161 + return false; 162 + } 163 + } 164 + 165 + queue.addTracks(tracks, playNow); 166 + return true; 167 + } 168 + 169 + /** 170 + * go to a specific index in the queue. 171 + * checks gated access before changing position. 172 + */ 173 + export async function goToIndex(index: number): Promise<boolean> { 174 + if (!browser) return false; 175 + 176 + const track = queue.tracks[index]; 177 + if (!track) return false; 178 + 179 + const result = await checkAccess(track); 180 + if (!result.allowed) { 181 + showDeniedToast(result); 182 + return false; 183 + } 184 + 185 + queue.goTo(index); 186 + return true; 187 + }
+4 -2
frontend/src/lib/tracks.svelte.ts
··· 133 export const tracksCache = new TracksCache(); 134 135 // like/unlike track functions 136 - export async function likeTrack(trackId: number, fileId?: string): Promise<boolean> { 137 try { 138 const response = await fetch(`${API_URL}/tracks/${trackId}/like`, { 139 method: 'POST', ··· 148 tracksCache.invalidate(); 149 150 // auto-download if preference is enabled and file_id provided 151 - if (fileId && preferences.autoDownloadLiked) { 152 try { 153 const alreadyDownloaded = await isDownloaded(fileId); 154 if (!alreadyDownloaded) {
··· 133 export const tracksCache = new TracksCache(); 134 135 // like/unlike track functions 136 + // gated: true means viewer lacks access (non-supporter), false means accessible 137 + export async function likeTrack(trackId: number, fileId?: string, gated?: boolean): Promise<boolean> { 138 try { 139 const response = await fetch(`${API_URL}/tracks/${trackId}/like`, { 140 method: 'POST', ··· 149 tracksCache.invalidate(); 150 151 // auto-download if preference is enabled and file_id provided 152 + // skip download only if track is gated AND viewer lacks access (gated === true) 153 + if (fileId && preferences.autoDownloadLiked && gated !== true) { 154 try { 155 const alreadyDownloaded = await isDownloaded(fileId); 156 if (!alreadyDownloaded) {
+7
frontend/src/lib/types.ts
··· 27 tracks: Track[]; 28 } 29 30 export interface Track { 31 id: number; 32 title: string; ··· 36 file_type: string; 37 artist_handle: string; 38 artist_avatar_url?: string; 39 r2_url?: string; 40 atproto_record_uri?: string; 41 atproto_record_cid?: string; ··· 50 is_liked?: boolean; 51 copyright_flagged?: boolean | null; // null = not scanned, false = clear, true = flagged 52 copyright_match?: string | null; // "Title by Artist" of primary match 53 } 54 55 export interface User {
··· 27 tracks: Track[]; 28 } 29 30 + export interface SupportGate { 31 + type: 'any' | string; 32 + } 33 + 34 export interface Track { 35 id: number; 36 title: string; ··· 40 file_type: string; 41 artist_handle: string; 42 artist_avatar_url?: string; 43 + artist_did?: string; 44 r2_url?: string; 45 atproto_record_uri?: string; 46 atproto_record_cid?: string; ··· 55 is_liked?: boolean; 56 copyright_flagged?: boolean | null; // null = not scanned, false = clear, true = flagged 57 copyright_match?: string | null; // "Title by Artist" of primary match 58 + support_gate?: SupportGate | null; // if set, track requires supporter access 59 + gated?: boolean; // true if track is gated AND viewer lacks access 60 } 61 62 export interface User {
+54 -4
frontend/src/lib/uploader.svelte.ts
··· 23 onError?: (_error: string) => void; 24 } 25 26 // global upload manager using Svelte 5 runes 27 class UploaderState { 28 activeUploads = $state<Map<string, UploadTask>>(new Map()); ··· 34 features: FeaturedArtist[], 35 image: File | null | undefined, 36 tags: string[], 37 onSuccess?: () => void, 38 callbacks?: UploadProgressCallback 39 ): void { 40 const taskId = crypto.randomUUID(); 41 const fileSizeMB = file.size / 1024 / 1024; 42 const uploadMessage = fileSizeMB > 10 43 ? 'uploading track... (large file, this may take a moment)' 44 : 'uploading track...'; 45 // 0 means infinite/persist until dismissed 46 const toastId = toast.info(uploadMessage, 0); 47 48 if (!browser) return; 49 const formData = new FormData(); 50 formData.append('file', file); ··· 60 if (image) { 61 formData.append('image', image); 62 } 63 64 const xhr = new XMLHttpRequest(); 65 xhr.open('POST', `${API_URL}/tracks/`); ··· 70 xhr.upload.addEventListener('progress', (e) => { 71 if (e.lengthComputable && !uploadComplete) { 72 const percent = Math.round((e.loaded / e.total) * 100); 73 const progressMsg = `retrieving your file... ${percent}%`; 74 toast.update(toastId, progressMsg); 75 if (callbacks?.onProgress) { ··· 168 errorMsg = error.detail || errorMsg; 169 } catch { 170 if (xhr.status === 0) { 171 - errorMsg = 'network error: connection failed. check your internet connection and try again'; 172 } else if (xhr.status >= 500) { 173 errorMsg = 'server error: please try again in a moment'; 174 } else if (xhr.status === 413) { 175 errorMsg = 'file too large: please use a smaller file'; 176 } else if (xhr.status === 408 || xhr.status === 504) { 177 - errorMsg = 'upload timed out: please try again with a better connection'; 178 } 179 } 180 toast.error(errorMsg); ··· 186 187 xhr.addEventListener('error', () => { 188 toast.dismiss(toastId); 189 - const errorMsg = 'network error: connection failed. check your internet connection and try again'; 190 toast.error(errorMsg); 191 if (callbacks?.onError) { 192 callbacks.onError(errorMsg); ··· 195 196 xhr.addEventListener('timeout', () => { 197 toast.dismiss(toastId); 198 - const errorMsg = 'upload timed out: please try again with a better connection'; 199 toast.error(errorMsg); 200 if (callbacks?.onError) { 201 callbacks.onError(errorMsg);
··· 23 onError?: (_error: string) => void; 24 } 25 26 + function isMobileDevice(): boolean { 27 + if (!browser) return false; 28 + return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); 29 + } 30 + 31 + const MOBILE_LARGE_FILE_THRESHOLD_MB = 50; 32 + 33 + function buildNetworkErrorMessage(progressPercent: number, fileSizeMB: number, isMobile: boolean): string { 34 + const progressInfo = progressPercent > 0 ? ` (failed at ${progressPercent}%)` : ''; 35 + 36 + if (isMobile && fileSizeMB > MOBILE_LARGE_FILE_THRESHOLD_MB) { 37 + return `upload failed${progressInfo}: large files often fail on mobile networks. try uploading from a desktop or use WiFi`; 38 + } 39 + 40 + if (progressPercent > 0 && progressPercent < 100) { 41 + return `upload failed${progressInfo}: connection was interrupted. check your network and try again`; 42 + } 43 + 44 + return `upload failed${progressInfo}: connection failed. check your internet connection and try again`; 45 + } 46 + 47 + function buildTimeoutErrorMessage(progressPercent: number, fileSizeMB: number, isMobile: boolean): string { 48 + const progressInfo = progressPercent > 0 ? ` (stopped at ${progressPercent}%)` : ''; 49 + 50 + if (isMobile) { 51 + return `upload timed out${progressInfo}: mobile uploads can be slow. try WiFi or a desktop browser`; 52 + } 53 + 54 + if (fileSizeMB > 100) { 55 + return `upload timed out${progressInfo}: large file (${Math.round(fileSizeMB)}MB) - try a faster connection`; 56 + } 57 + 58 + return `upload timed out${progressInfo}: try again with a better connection`; 59 + } 60 + 61 // global upload manager using Svelte 5 runes 62 class UploaderState { 63 activeUploads = $state<Map<string, UploadTask>>(new Map()); ··· 69 features: FeaturedArtist[], 70 image: File | null | undefined, 71 tags: string[], 72 + supportGated: boolean, 73 onSuccess?: () => void, 74 callbacks?: UploadProgressCallback 75 ): void { 76 const taskId = crypto.randomUUID(); 77 const fileSizeMB = file.size / 1024 / 1024; 78 + const isMobile = isMobileDevice(); 79 + 80 + // warn about large files on mobile 81 + if (isMobile && fileSizeMB > MOBILE_LARGE_FILE_THRESHOLD_MB) { 82 + toast.info(`uploading ${Math.round(fileSizeMB)}MB file on mobile - ensure stable connection`, 5000); 83 + } 84 + 85 const uploadMessage = fileSizeMB > 10 86 ? 'uploading track... (large file, this may take a moment)' 87 : 'uploading track...'; 88 // 0 means infinite/persist until dismissed 89 const toastId = toast.info(uploadMessage, 0); 90 91 + // track upload progress for error messages 92 + let lastProgressPercent = 0; 93 + 94 if (!browser) return; 95 const formData = new FormData(); 96 formData.append('file', file); ··· 106 if (image) { 107 formData.append('image', image); 108 } 109 + if (supportGated) { 110 + formData.append('support_gate', JSON.stringify({ type: 'any' })); 111 + } 112 113 const xhr = new XMLHttpRequest(); 114 xhr.open('POST', `${API_URL}/tracks/`); ··· 119 xhr.upload.addEventListener('progress', (e) => { 120 if (e.lengthComputable && !uploadComplete) { 121 const percent = Math.round((e.loaded / e.total) * 100); 122 + lastProgressPercent = percent; 123 const progressMsg = `retrieving your file... ${percent}%`; 124 toast.update(toastId, progressMsg); 125 if (callbacks?.onProgress) { ··· 218 errorMsg = error.detail || errorMsg; 219 } catch { 220 if (xhr.status === 0) { 221 + errorMsg = buildNetworkErrorMessage(lastProgressPercent, fileSizeMB, isMobile); 222 } else if (xhr.status >= 500) { 223 errorMsg = 'server error: please try again in a moment'; 224 } else if (xhr.status === 413) { 225 errorMsg = 'file too large: please use a smaller file'; 226 } else if (xhr.status === 408 || xhr.status === 504) { 227 + errorMsg = buildTimeoutErrorMessage(lastProgressPercent, fileSizeMB, isMobile); 228 } 229 } 230 toast.error(errorMsg); ··· 236 237 xhr.addEventListener('error', () => { 238 toast.dismiss(toastId); 239 + const errorMsg = buildNetworkErrorMessage(lastProgressPercent, fileSizeMB, isMobile); 240 toast.error(errorMsg); 241 if (callbacks?.onError) { 242 callbacks.onError(errorMsg); ··· 245 246 xhr.addEventListener('timeout', () => { 247 toast.dismiss(toastId); 248 + const errorMsg = buildTimeoutErrorMessage(lastProgressPercent, fileSizeMB, isMobile); 249 toast.error(errorMsg); 250 if (callbacks?.onError) { 251 callbacks.onError(errorMsg);
+5 -5
frontend/src/routes/+error.svelte
··· 62 } 63 64 .error-message { 65 - font-size: 1.25rem; 66 color: var(--text-secondary); 67 margin: 0 0 0.5rem 0; 68 } 69 70 .error-detail { 71 - font-size: 1rem; 72 color: var(--text-tertiary); 73 margin: 0 0 2rem 0; 74 } ··· 76 .home-link { 77 color: var(--accent); 78 text-decoration: none; 79 - font-size: 1.1rem; 80 padding: 0.75rem 1.5rem; 81 border: 1px solid var(--accent); 82 - border-radius: 6px; 83 transition: all 0.2s; 84 } 85 ··· 98 } 99 100 .error-message { 101 - font-size: 1.1rem; 102 } 103 } 104 </style>
··· 62 } 63 64 .error-message { 65 + font-size: var(--text-2xl); 66 color: var(--text-secondary); 67 margin: 0 0 0.5rem 0; 68 } 69 70 .error-detail { 71 + font-size: var(--text-lg); 72 color: var(--text-tertiary); 73 margin: 0 0 2rem 0; 74 } ··· 76 .home-link { 77 color: var(--accent); 78 text-decoration: none; 79 + font-size: var(--text-xl); 80 padding: 0.75rem 1.5rem; 81 border: 1px solid var(--accent); 82 + border-radius: var(--radius-base); 83 transition: all 0.2s; 84 } 85 ··· 98 } 99 100 .error-message { 101 + font-size: var(--text-xl); 102 } 103 } 104 </style>
+32 -4
frontend/src/routes/+layout.svelte
··· 450 --text-muted: #666666; 451 452 /* typography scale */ 453 - --text-page-heading: 1.5rem; 454 --text-section-heading: 1.2rem; 455 - --text-body: 1rem; 456 - --text-small: 0.9rem; 457 458 /* semantic */ 459 --success: #4ade80; ··· 516 color: var(--accent-muted); 517 } 518 519 :global(body) { 520 margin: 0; 521 padding: 0; ··· 589 right: 20px; 590 width: 48px; 591 height: 48px; 592 - border-radius: 50%; 593 background: var(--bg-secondary); 594 border: 1px solid var(--border-default); 595 color: var(--text-secondary);
··· 450 --text-muted: #666666; 451 452 /* typography scale */ 453 + --text-xs: 0.75rem; 454 + --text-sm: 0.85rem; 455 + --text-base: 0.9rem; 456 + --text-lg: 1rem; 457 + --text-xl: 1.1rem; 458 + --text-2xl: 1.25rem; 459 + --text-3xl: 1.5rem; 460 + 461 + /* semantic typography (aliases) */ 462 + --text-page-heading: var(--text-3xl); 463 --text-section-heading: 1.2rem; 464 + --text-body: var(--text-lg); 465 + --text-small: var(--text-base); 466 + 467 + /* border radius scale */ 468 + --radius-sm: 4px; 469 + --radius-base: 6px; 470 + --radius-md: 8px; 471 + --radius-lg: 12px; 472 + --radius-xl: 16px; 473 + --radius-2xl: 24px; 474 + --radius-full: 9999px; 475 476 /* semantic */ 477 --success: #4ade80; ··· 534 color: var(--accent-muted); 535 } 536 537 + /* shared animation for active play buttons */ 538 + @keyframes -global-ethereal-glow { 539 + 0%, 100% { 540 + box-shadow: 0 0 8px 1px color-mix(in srgb, var(--accent) 25%, transparent); 541 + } 542 + 50% { 543 + box-shadow: 0 0 14px 3px color-mix(in srgb, var(--accent) 45%, transparent); 544 + } 545 + } 546 + 547 :global(body) { 548 margin: 0; 549 padding: 0; ··· 617 right: 20px; 618 width: 48px; 619 height: 48px; 620 + border-radius: var(--radius-full); 621 background: var(--bg-secondary); 622 border: 1px solid var(--border-default); 623 color: var(--text-secondary);
+1 -1
frontend/src/routes/+page.svelte
··· 227 } 228 229 .section-header h2 { 230 - font-size: 1.25rem; 231 } 232 } 233 </style>
··· 227 } 228 229 .section-header h2 { 230 + font-size: var(--text-2xl); 231 } 232 } 233 </style>
+146 -38
frontend/src/routes/costs/+page.svelte
··· 59 let loading = $state(true); 60 let error = $state<string | null>(null); 61 let data = $state<CostData | null>(null); 62 63 // derived values for bar chart scaling 64 let maxCost = $derived( ··· 72 : 1 73 ); 74 75 - let maxRequests = $derived( 76 - data?.costs.audd.daily.length 77 - ? Math.max(...data.costs.audd.daily.map((d) => d.requests)) 78 - : 1 79 - ); 80 81 onMount(async () => { 82 try { ··· 216 217 <!-- audd details --> 218 <section class="audd-section"> 219 - <h2>copyright scanning (audd)</h2> 220 <div class="audd-stats"> 221 <div class="stat"> 222 - <span class="stat-value">{data.costs.audd.requests_this_period.toLocaleString()}</span> 223 - <span class="stat-label">API requests</span> 224 </div> 225 <div class="stat"> 226 <span class="stat-value">{data.costs.audd.remaining_free.toLocaleString()}</span> 227 <span class="stat-label">free remaining</span> 228 </div> 229 <div class="stat"> 230 - <span class="stat-value">{data.costs.audd.scans_this_period.toLocaleString()}</span> 231 <span class="stat-label">tracks scanned</span> 232 </div> 233 </div> ··· 236 1 request = 12s of audio. {data.costs.audd.free_requests.toLocaleString()} free/month, 237 then ${(5).toFixed(2)}/1k requests. 238 {#if data.costs.audd.billable_requests > 0} 239 - <strong>{data.costs.audd.billable_requests.toLocaleString()} billable</strong> this period. 240 {/if} 241 </p> 242 243 - {#if data.costs.audd.daily.length > 0} 244 <div class="daily-chart"> 245 <h3>daily requests</h3> 246 <div class="chart-bars"> 247 - {#each data.costs.audd.daily as day} 248 <div class="chart-bar-container"> 249 <div 250 class="chart-bar" ··· 256 {/each} 257 </div> 258 </div> 259 {/if} 260 </section> 261 ··· 303 304 .subtitle { 305 color: var(--text-tertiary); 306 - font-size: 0.9rem; 307 margin: 0; 308 } 309 ··· 321 322 .error-state .hint { 323 color: var(--text-tertiary); 324 - font-size: 0.85rem; 325 margin-top: 0.5rem; 326 } 327 ··· 337 padding: 2rem; 338 background: var(--bg-tertiary); 339 border: 1px solid var(--border-subtle); 340 - border-radius: 12px; 341 } 342 343 .total-label { 344 - font-size: 0.8rem; 345 text-transform: uppercase; 346 letter-spacing: 0.08em; 347 color: var(--text-tertiary); ··· 356 357 .updated { 358 text-align: center; 359 - font-size: 0.75rem; 360 color: var(--text-tertiary); 361 margin-top: 0.75rem; 362 } ··· 368 369 .breakdown-section h2, 370 .audd-section h2 { 371 - font-size: 0.8rem; 372 text-transform: uppercase; 373 letter-spacing: 0.08em; 374 color: var(--text-tertiary); ··· 384 .cost-item { 385 background: var(--bg-tertiary); 386 border: 1px solid var(--border-subtle); 387 - border-radius: 8px; 388 padding: 1rem; 389 } 390 ··· 409 .cost-bar-bg { 410 height: 8px; 411 background: var(--bg-primary); 412 - border-radius: 4px; 413 overflow: hidden; 414 margin-bottom: 0.5rem; 415 } ··· 417 .cost-bar { 418 height: 100%; 419 background: var(--accent); 420 - border-radius: 4px; 421 transition: width 0.3s ease; 422 } 423 ··· 426 } 427 428 .cost-note { 429 - font-size: 0.75rem; 430 color: var(--text-tertiary); 431 } 432 ··· 435 margin-bottom: 2rem; 436 } 437 438 .audd-stats { 439 display: grid; 440 grid-template-columns: repeat(3, 1fr); ··· 443 } 444 445 .audd-explainer { 446 - font-size: 0.8rem; 447 color: var(--text-secondary); 448 margin-bottom: 1.5rem; 449 line-height: 1.5; ··· 460 padding: 1rem; 461 background: var(--bg-tertiary); 462 border: 1px solid var(--border-subtle); 463 - border-radius: 8px; 464 } 465 466 .stat-value { 467 - font-size: 1.25rem; 468 font-weight: 700; 469 color: var(--text-primary); 470 font-variant-numeric: tabular-nums; 471 } 472 473 .stat-label { 474 - font-size: 0.7rem; 475 color: var(--text-tertiary); 476 text-align: center; 477 margin-top: 0.25rem; ··· 481 .daily-chart { 482 background: var(--bg-tertiary); 483 border: 1px solid var(--border-subtle); 484 - border-radius: 8px; 485 padding: 1rem; 486 } 487 488 .daily-chart h3 { 489 - font-size: 0.75rem; 490 text-transform: uppercase; 491 letter-spacing: 0.05em; 492 color: var(--text-tertiary); ··· 496 .chart-bars { 497 display: flex; 498 align-items: flex-end; 499 - gap: 4px; 500 height: 100px; 501 } 502 503 .chart-bar-container { 504 - flex: 1; 505 display: flex; 506 flex-direction: column; 507 align-items: center; ··· 522 } 523 524 .chart-label { 525 - font-size: 0.6rem; 526 color: var(--text-tertiary); 527 - margin-top: 0.5rem; 528 white-space: nowrap; 529 } 530 531 /* support section */ ··· 544 var(--bg-tertiary) 545 ); 546 border: 1px solid var(--border-subtle); 547 - border-radius: 12px; 548 } 549 550 .support-icon { ··· 554 555 .support-text h3 { 556 margin: 0 0 0.5rem; 557 - font-size: 1.1rem; 558 color: var(--text-primary); 559 } 560 561 .support-text p { 562 margin: 0 0 1.5rem; 563 color: var(--text-secondary); 564 - font-size: 0.9rem; 565 } 566 567 .support-button { ··· 571 padding: 0.75rem 1.5rem; 572 background: var(--accent); 573 color: white; 574 - border-radius: 8px; 575 text-decoration: none; 576 font-weight: 600; 577 - font-size: 0.9rem; 578 transition: transform 0.15s, box-shadow 0.15s; 579 } 580 ··· 586 /* footer */ 587 .footer-note { 588 text-align: center; 589 - font-size: 0.8rem; 590 color: var(--text-tertiary); 591 padding-bottom: 1rem; 592 }
··· 59 let loading = $state(true); 60 let error = $state<string | null>(null); 61 let data = $state<CostData | null>(null); 62 + let timeRange = $state<'day' | 'week' | 'month'>('month'); 63 + 64 + // filter daily data based on selected time range 65 + // returns the last N days of data based on selection 66 + let filteredDaily = $derived.by(() => { 67 + if (!data?.costs.audd.daily.length) return []; 68 + const daily = data.costs.audd.daily; 69 + if (timeRange === 'day') { 70 + // show last 2 days (today + yesterday) for 24h view 71 + return daily.slice(-2); 72 + } else if (timeRange === 'week') { 73 + // show last 7 days 74 + return daily.slice(-7); 75 + } else { 76 + // show all (up to 30 days) 77 + return daily; 78 + } 79 + }); 80 + 81 + // calculate totals for selected time range 82 + let filteredTotals = $derived.by(() => { 83 + return { 84 + requests: filteredDaily.reduce((sum, d) => sum + d.requests, 0), 85 + scans: filteredDaily.reduce((sum, d) => sum + d.scans, 0) 86 + }; 87 + }); 88 89 // derived values for bar chart scaling 90 let maxCost = $derived( ··· 98 : 1 99 ); 100 101 + let maxRequests = $derived.by(() => { 102 + return filteredDaily.length ? Math.max(...filteredDaily.map((d) => d.requests)) : 1; 103 + }); 104 105 onMount(async () => { 106 try { ··· 240 241 <!-- audd details --> 242 <section class="audd-section"> 243 + <div class="audd-header"> 244 + <h2>api requests (audd)</h2> 245 + <div class="time-range-toggle"> 246 + <button 247 + class:active={timeRange === 'day'} 248 + onclick={() => (timeRange = 'day')} 249 + > 250 + 24h 251 + </button> 252 + <button 253 + class:active={timeRange === 'week'} 254 + onclick={() => (timeRange = 'week')} 255 + > 256 + 7d 257 + </button> 258 + <button 259 + class:active={timeRange === 'month'} 260 + onclick={() => (timeRange = 'month')} 261 + > 262 + 30d 263 + </button> 264 + </div> 265 + </div> 266 + 267 <div class="audd-stats"> 268 <div class="stat"> 269 + <span class="stat-value">{filteredTotals.requests.toLocaleString()}</span> 270 + <span class="stat-label">requests ({timeRange === 'day' ? '24h' : timeRange === 'week' ? '7d' : '30d'})</span> 271 </div> 272 <div class="stat"> 273 <span class="stat-value">{data.costs.audd.remaining_free.toLocaleString()}</span> 274 <span class="stat-label">free remaining</span> 275 </div> 276 <div class="stat"> 277 + <span class="stat-value">{filteredTotals.scans.toLocaleString()}</span> 278 <span class="stat-label">tracks scanned</span> 279 </div> 280 </div> ··· 283 1 request = 12s of audio. {data.costs.audd.free_requests.toLocaleString()} free/month, 284 then ${(5).toFixed(2)}/1k requests. 285 {#if data.costs.audd.billable_requests > 0} 286 + <strong>{data.costs.audd.billable_requests.toLocaleString()} billable</strong> this billing period. 287 {/if} 288 </p> 289 290 + {#if filteredDaily.length > 0} 291 <div class="daily-chart"> 292 <h3>daily requests</h3> 293 <div class="chart-bars"> 294 + {#each filteredDaily as day} 295 <div class="chart-bar-container"> 296 <div 297 class="chart-bar" ··· 303 {/each} 304 </div> 305 </div> 306 + {:else} 307 + <p class="no-data">no requests in this time range</p> 308 {/if} 309 </section> 310 ··· 352 353 .subtitle { 354 color: var(--text-tertiary); 355 + font-size: var(--text-base); 356 margin: 0; 357 } 358 ··· 370 371 .error-state .hint { 372 color: var(--text-tertiary); 373 + font-size: var(--text-sm); 374 margin-top: 0.5rem; 375 } 376 ··· 386 padding: 2rem; 387 background: var(--bg-tertiary); 388 border: 1px solid var(--border-subtle); 389 + border-radius: var(--radius-lg); 390 } 391 392 .total-label { 393 + font-size: var(--text-sm); 394 text-transform: uppercase; 395 letter-spacing: 0.08em; 396 color: var(--text-tertiary); ··· 405 406 .updated { 407 text-align: center; 408 + font-size: var(--text-xs); 409 color: var(--text-tertiary); 410 margin-top: 0.75rem; 411 } ··· 417 418 .breakdown-section h2, 419 .audd-section h2 { 420 + font-size: var(--text-sm); 421 text-transform: uppercase; 422 letter-spacing: 0.08em; 423 color: var(--text-tertiary); ··· 433 .cost-item { 434 background: var(--bg-tertiary); 435 border: 1px solid var(--border-subtle); 436 + border-radius: var(--radius-md); 437 padding: 1rem; 438 } 439 ··· 458 .cost-bar-bg { 459 height: 8px; 460 background: var(--bg-primary); 461 + border-radius: var(--radius-sm); 462 overflow: hidden; 463 margin-bottom: 0.5rem; 464 } ··· 466 .cost-bar { 467 height: 100%; 468 background: var(--accent); 469 + border-radius: var(--radius-sm); 470 transition: width 0.3s ease; 471 } 472 ··· 475 } 476 477 .cost-note { 478 + font-size: var(--text-xs); 479 color: var(--text-tertiary); 480 } 481 ··· 484 margin-bottom: 2rem; 485 } 486 487 + .audd-header { 488 + display: flex; 489 + justify-content: space-between; 490 + align-items: center; 491 + margin-bottom: 1rem; 492 + gap: 1rem; 493 + } 494 + 495 + .audd-header h2 { 496 + margin-bottom: 0; 497 + } 498 + 499 + .time-range-toggle { 500 + display: flex; 501 + gap: 0.25rem; 502 + background: var(--bg-tertiary); 503 + border: 1px solid var(--border-subtle); 504 + border-radius: var(--radius-base); 505 + padding: 0.25rem; 506 + } 507 + 508 + .time-range-toggle button { 509 + padding: 0.35rem 0.75rem; 510 + font-family: inherit; 511 + font-size: var(--text-xs); 512 + font-weight: 500; 513 + background: transparent; 514 + border: none; 515 + border-radius: var(--radius-sm); 516 + color: var(--text-secondary); 517 + cursor: pointer; 518 + transition: all 0.15s; 519 + } 520 + 521 + .time-range-toggle button:hover { 522 + color: var(--text-primary); 523 + } 524 + 525 + .time-range-toggle button.active { 526 + background: var(--accent); 527 + color: white; 528 + } 529 + 530 + .no-data { 531 + text-align: center; 532 + color: var(--text-tertiary); 533 + font-size: var(--text-sm); 534 + padding: 2rem; 535 + background: var(--bg-tertiary); 536 + border: 1px solid var(--border-subtle); 537 + border-radius: var(--radius-md); 538 + } 539 + 540 .audd-stats { 541 display: grid; 542 grid-template-columns: repeat(3, 1fr); ··· 545 } 546 547 .audd-explainer { 548 + font-size: var(--text-sm); 549 color: var(--text-secondary); 550 margin-bottom: 1.5rem; 551 line-height: 1.5; ··· 562 padding: 1rem; 563 background: var(--bg-tertiary); 564 border: 1px solid var(--border-subtle); 565 + border-radius: var(--radius-md); 566 } 567 568 .stat-value { 569 + font-size: var(--text-2xl); 570 font-weight: 700; 571 color: var(--text-primary); 572 font-variant-numeric: tabular-nums; 573 } 574 575 .stat-label { 576 + font-size: var(--text-xs); 577 color: var(--text-tertiary); 578 text-align: center; 579 margin-top: 0.25rem; ··· 583 .daily-chart { 584 background: var(--bg-tertiary); 585 border: 1px solid var(--border-subtle); 586 + border-radius: var(--radius-md); 587 padding: 1rem; 588 + overflow: hidden; 589 } 590 591 .daily-chart h3 { 592 + font-size: var(--text-xs); 593 text-transform: uppercase; 594 letter-spacing: 0.05em; 595 color: var(--text-tertiary); ··· 599 .chart-bars { 600 display: flex; 601 align-items: flex-end; 602 + gap: 2px; 603 height: 100px; 604 + width: 100%; 605 } 606 607 .chart-bar-container { 608 + flex: 1 1 0; 609 + min-width: 0; 610 display: flex; 611 flex-direction: column; 612 align-items: center; ··· 627 } 628 629 .chart-label { 630 + font-size: 0.55rem; 631 color: var(--text-tertiary); 632 + margin-top: 0.25rem; 633 white-space: nowrap; 634 + overflow: hidden; 635 + text-overflow: ellipsis; 636 + max-width: 100%; 637 } 638 639 /* support section */ ··· 652 var(--bg-tertiary) 653 ); 654 border: 1px solid var(--border-subtle); 655 + border-radius: var(--radius-lg); 656 } 657 658 .support-icon { ··· 662 663 .support-text h3 { 664 margin: 0 0 0.5rem; 665 + font-size: var(--text-xl); 666 color: var(--text-primary); 667 } 668 669 .support-text p { 670 margin: 0 0 1.5rem; 671 color: var(--text-secondary); 672 + font-size: var(--text-base); 673 } 674 675 .support-button { ··· 679 padding: 0.75rem 1.5rem; 680 background: var(--accent); 681 color: white; 682 + border-radius: var(--radius-md); 683 text-decoration: none; 684 font-weight: 600; 685 + font-size: var(--text-base); 686 transition: transform 0.15s, box-shadow 0.15s; 687 } 688 ··· 694 /* footer */ 695 .footer-note { 696 text-align: center; 697 + font-size: var(--text-sm); 698 color: var(--text-tertiary); 699 padding-bottom: 1rem; 700 }
+1 -1
frontend/src/routes/embed/track/[id]/+page.svelte
··· 184 .play-btn { 185 width: 48px; 186 height: 48px; 187 - border-radius: 50%; 188 background: #fff; 189 color: #000; 190 border: none;
··· 184 .play-btn { 185 width: 48px; 186 height: 48px; 187 + border-radius: var(--radius-full); 188 background: #fff; 189 color: #000; 190 border: none;
+64 -26
frontend/src/routes/library/+page.svelte
··· 1 <script lang="ts"> 2 import Header from '$lib/components/Header.svelte'; 3 import { auth } from '$lib/auth.svelte'; 4 - import { goto } from '$app/navigation'; 5 import { API_URL } from '$lib/config'; 6 import type { PageData } from './$types'; 7 import type { Playlist } from '$lib/types'; ··· 13 let newPlaylistName = $state(''); 14 let creating = $state(false); 15 let error = $state(''); 16 17 async function handleLogout() { 18 await auth.logout(); ··· 226 } 227 228 .page-header p { 229 - font-size: 0.9rem; 230 color: var(--text-tertiary); 231 margin: 0; 232 } ··· 244 padding: 1rem 1.25rem; 245 background: var(--bg-secondary); 246 border: 1px solid var(--border-default); 247 - border-radius: 12px; 248 text-decoration: none; 249 color: inherit; 250 transition: all 0.15s; ··· 262 .collection-icon { 263 width: 48px; 264 height: 48px; 265 - border-radius: 10px; 266 display: flex; 267 align-items: center; 268 justify-content: center; ··· 282 .playlist-artwork { 283 width: 48px; 284 height: 48px; 285 - border-radius: 10px; 286 object-fit: cover; 287 flex-shrink: 0; 288 } ··· 293 } 294 295 .collection-info h3 { 296 - font-size: 1rem; 297 font-weight: 600; 298 color: var(--text-primary); 299 margin: 0 0 0.15rem 0; ··· 303 } 304 305 .collection-info p { 306 - font-size: 0.85rem; 307 color: var(--text-tertiary); 308 margin: 0; 309 } ··· 332 } 333 334 .section-header h2 { 335 - font-size: 1.1rem; 336 font-weight: 600; 337 color: var(--text-primary); 338 margin: 0; ··· 346 background: var(--accent); 347 color: white; 348 border: none; 349 - border-radius: 8px; 350 font-family: inherit; 351 font-size: 0.875rem; 352 font-weight: 500; ··· 377 padding: 3rem 2rem; 378 background: var(--bg-secondary); 379 border: 1px dashed var(--border-default); 380 - border-radius: 12px; 381 text-align: center; 382 } 383 384 .empty-icon { 385 width: 64px; 386 height: 64px; 387 - border-radius: 16px; 388 display: flex; 389 align-items: center; 390 justify-content: center; ··· 394 } 395 396 .empty-state p { 397 - font-size: 1rem; 398 font-weight: 500; 399 color: var(--text-secondary); 400 margin: 0 0 0.25rem 0; 401 } 402 403 .empty-state span { 404 - font-size: 0.85rem; 405 color: var(--text-muted); 406 } 407 ··· 423 .modal { 424 background: var(--bg-primary); 425 border: 1px solid var(--border-default); 426 - border-radius: 16px; 427 width: 100%; 428 max-width: 400px; 429 box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); ··· 438 } 439 440 .modal-header h3 { 441 - font-size: 1.1rem; 442 font-weight: 600; 443 color: var(--text-primary); 444 margin: 0; ··· 452 height: 32px; 453 background: transparent; 454 border: none; 455 - border-radius: 8px; 456 color: var(--text-secondary); 457 cursor: pointer; 458 transition: all 0.15s; ··· 469 470 .modal-body label { 471 display: block; 472 - font-size: 0.85rem; 473 font-weight: 500; 474 color: var(--text-secondary); 475 margin-bottom: 0.5rem; ··· 480 padding: 0.75rem 1rem; 481 background: var(--bg-secondary); 482 border: 1px solid var(--border-default); 483 - border-radius: 8px; 484 font-family: inherit; 485 - font-size: 1rem; 486 color: var(--text-primary); 487 transition: border-color 0.15s; 488 } ··· 498 499 .modal-body .error { 500 margin: 0.5rem 0 0 0; 501 - font-size: 0.85rem; 502 color: #ef4444; 503 } 504 ··· 512 .cancel-btn, 513 .confirm-btn { 514 padding: 0.625rem 1.25rem; 515 - border-radius: 8px; 516 font-family: inherit; 517 - font-size: 0.9rem; 518 font-weight: 500; 519 cursor: pointer; 520 transition: all 0.15s; ··· 558 } 559 560 .page-header h1 { 561 - font-size: 1.5rem; 562 } 563 564 .collection-card { ··· 571 } 572 573 .collection-info h3 { 574 - font-size: 0.95rem; 575 } 576 577 .section-header h2 { 578 - font-size: 1rem; 579 } 580 581 .create-btn { 582 padding: 0.5rem 0.875rem; 583 - font-size: 0.85rem; 584 } 585 586 .empty-state {
··· 1 <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { replaceState, invalidateAll, goto } from '$app/navigation'; 4 import Header from '$lib/components/Header.svelte'; 5 import { auth } from '$lib/auth.svelte'; 6 + import { preferences } from '$lib/preferences.svelte'; 7 import { API_URL } from '$lib/config'; 8 import type { PageData } from './$types'; 9 import type { Playlist } from '$lib/types'; ··· 15 let newPlaylistName = $state(''); 16 let creating = $state(false); 17 let error = $state(''); 18 + 19 + onMount(async () => { 20 + // check if exchange_token is in URL (from OAuth callback) 21 + const params = new URLSearchParams(window.location.search); 22 + const exchangeToken = params.get('exchange_token'); 23 + const isDevToken = params.get('dev_token') === 'true'; 24 + 25 + // redirect dev token callbacks to settings page 26 + if (exchangeToken && isDevToken) { 27 + window.location.href = `/settings?exchange_token=${exchangeToken}&dev_token=true`; 28 + return; 29 + } 30 + 31 + if (exchangeToken) { 32 + // regular login - exchange token for session 33 + try { 34 + const exchangeResponse = await fetch(`${API_URL}/auth/exchange`, { 35 + method: 'POST', 36 + headers: { 'Content-Type': 'application/json' }, 37 + credentials: 'include', 38 + body: JSON.stringify({ exchange_token: exchangeToken }) 39 + }); 40 + 41 + if (exchangeResponse.ok) { 42 + // invalidate all load functions so they rerun with the new session cookie 43 + await invalidateAll(); 44 + await auth.initialize(); 45 + await preferences.fetch(); 46 + } 47 + } catch (_e) { 48 + console.error('failed to exchange token:', _e); 49 + } 50 + 51 + replaceState('/library', {}); 52 + } 53 + }); 54 55 async function handleLogout() { 56 await auth.logout(); ··· 264 } 265 266 .page-header p { 267 + font-size: var(--text-base); 268 color: var(--text-tertiary); 269 margin: 0; 270 } ··· 282 padding: 1rem 1.25rem; 283 background: var(--bg-secondary); 284 border: 1px solid var(--border-default); 285 + border-radius: var(--radius-lg); 286 text-decoration: none; 287 color: inherit; 288 transition: all 0.15s; ··· 300 .collection-icon { 301 width: 48px; 302 height: 48px; 303 + border-radius: var(--radius-md); 304 display: flex; 305 align-items: center; 306 justify-content: center; ··· 320 .playlist-artwork { 321 width: 48px; 322 height: 48px; 323 + border-radius: var(--radius-md); 324 object-fit: cover; 325 flex-shrink: 0; 326 } ··· 331 } 332 333 .collection-info h3 { 334 + font-size: var(--text-lg); 335 font-weight: 600; 336 color: var(--text-primary); 337 margin: 0 0 0.15rem 0; ··· 341 } 342 343 .collection-info p { 344 + font-size: var(--text-sm); 345 color: var(--text-tertiary); 346 margin: 0; 347 } ··· 370 } 371 372 .section-header h2 { 373 + font-size: var(--text-xl); 374 font-weight: 600; 375 color: var(--text-primary); 376 margin: 0; ··· 384 background: var(--accent); 385 color: white; 386 border: none; 387 + border-radius: var(--radius-md); 388 font-family: inherit; 389 font-size: 0.875rem; 390 font-weight: 500; ··· 415 padding: 3rem 2rem; 416 background: var(--bg-secondary); 417 border: 1px dashed var(--border-default); 418 + border-radius: var(--radius-lg); 419 text-align: center; 420 } 421 422 .empty-icon { 423 width: 64px; 424 height: 64px; 425 + border-radius: var(--radius-xl); 426 display: flex; 427 align-items: center; 428 justify-content: center; ··· 432 } 433 434 .empty-state p { 435 + font-size: var(--text-lg); 436 font-weight: 500; 437 color: var(--text-secondary); 438 margin: 0 0 0.25rem 0; 439 } 440 441 .empty-state span { 442 + font-size: var(--text-sm); 443 color: var(--text-muted); 444 } 445 ··· 461 .modal { 462 background: var(--bg-primary); 463 border: 1px solid var(--border-default); 464 + border-radius: var(--radius-xl); 465 width: 100%; 466 max-width: 400px; 467 box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); ··· 476 } 477 478 .modal-header h3 { 479 + font-size: var(--text-xl); 480 font-weight: 600; 481 color: var(--text-primary); 482 margin: 0; ··· 490 height: 32px; 491 background: transparent; 492 border: none; 493 + border-radius: var(--radius-md); 494 color: var(--text-secondary); 495 cursor: pointer; 496 transition: all 0.15s; ··· 507 508 .modal-body label { 509 display: block; 510 + font-size: var(--text-sm); 511 font-weight: 500; 512 color: var(--text-secondary); 513 margin-bottom: 0.5rem; ··· 518 padding: 0.75rem 1rem; 519 background: var(--bg-secondary); 520 border: 1px solid var(--border-default); 521 + border-radius: var(--radius-md); 522 font-family: inherit; 523 + font-size: var(--text-lg); 524 color: var(--text-primary); 525 transition: border-color 0.15s; 526 } ··· 536 537 .modal-body .error { 538 margin: 0.5rem 0 0 0; 539 + font-size: var(--text-sm); 540 color: #ef4444; 541 } 542 ··· 550 .cancel-btn, 551 .confirm-btn { 552 padding: 0.625rem 1.25rem; 553 + border-radius: var(--radius-md); 554 font-family: inherit; 555 + font-size: var(--text-base); 556 font-weight: 500; 557 cursor: pointer; 558 transition: all 0.15s; ··· 596 } 597 598 .page-header h1 { 599 + font-size: var(--text-3xl); 600 } 601 602 .collection-card { ··· 609 } 610 611 .collection-info h3 { 612 + font-size: var(--text-base); 613 } 614 615 .section-header h2 { 616 + font-size: var(--text-lg); 617 } 618 619 .create-btn { 620 padding: 0.5rem 0.875rem; 621 + font-size: var(--text-sm); 622 } 623 624 .empty-state {
+12 -12
frontend/src/routes/liked/+page.svelte
··· 345 } 346 347 .count { 348 - font-size: 0.85rem; 349 font-weight: 500; 350 color: var(--text-tertiary); 351 background: var(--bg-tertiary); 352 padding: 0.2rem 0.55rem; 353 - border-radius: 4px; 354 } 355 356 .header-actions { ··· 362 .queue-button, 363 .reorder-button { 364 padding: 0.75rem 1.5rem; 365 - border-radius: 24px; 366 font-weight: 600; 367 - font-size: 0.95rem; 368 font-family: inherit; 369 cursor: pointer; 370 transition: all 0.2s; ··· 419 } 420 421 .empty-state h2 { 422 - font-size: 1.5rem; 423 font-weight: 600; 424 color: var(--text-secondary); 425 margin: 0 0 0.5rem 0; 426 } 427 428 .empty-state p { 429 - font-size: 0.95rem; 430 margin: 0; 431 } 432 ··· 441 display: flex; 442 align-items: center; 443 gap: 0.5rem; 444 - border-radius: 8px; 445 transition: all 0.2s; 446 position: relative; 447 } ··· 473 color: var(--text-muted); 474 cursor: grab; 475 touch-action: none; 476 - border-radius: 4px; 477 transition: all 0.2s; 478 flex-shrink: 0; 479 } ··· 505 } 506 507 .section-header h2 { 508 - font-size: 1.25rem; 509 } 510 511 .count { 512 - font-size: 0.8rem; 513 padding: 0.15rem 0.45rem; 514 } 515 ··· 518 } 519 520 .empty-state h2 { 521 - font-size: 1.25rem; 522 } 523 524 .header-actions { ··· 528 .queue-button, 529 .reorder-button { 530 padding: 0.6rem 1rem; 531 - font-size: 0.85rem; 532 } 533 534 .queue-button svg,
··· 345 } 346 347 .count { 348 + font-size: var(--text-sm); 349 font-weight: 500; 350 color: var(--text-tertiary); 351 background: var(--bg-tertiary); 352 padding: 0.2rem 0.55rem; 353 + border-radius: var(--radius-sm); 354 } 355 356 .header-actions { ··· 362 .queue-button, 363 .reorder-button { 364 padding: 0.75rem 1.5rem; 365 + border-radius: var(--radius-2xl); 366 font-weight: 600; 367 + font-size: var(--text-base); 368 font-family: inherit; 369 cursor: pointer; 370 transition: all 0.2s; ··· 419 } 420 421 .empty-state h2 { 422 + font-size: var(--text-3xl); 423 font-weight: 600; 424 color: var(--text-secondary); 425 margin: 0 0 0.5rem 0; 426 } 427 428 .empty-state p { 429 + font-size: var(--text-base); 430 margin: 0; 431 } 432 ··· 441 display: flex; 442 align-items: center; 443 gap: 0.5rem; 444 + border-radius: var(--radius-md); 445 transition: all 0.2s; 446 position: relative; 447 } ··· 473 color: var(--text-muted); 474 cursor: grab; 475 touch-action: none; 476 + border-radius: var(--radius-sm); 477 transition: all 0.2s; 478 flex-shrink: 0; 479 } ··· 505 } 506 507 .section-header h2 { 508 + font-size: var(--text-2xl); 509 } 510 511 .count { 512 + font-size: var(--text-sm); 513 padding: 0.15rem 0.45rem; 514 } 515 ··· 518 } 519 520 .empty-state h2 { 521 + font-size: var(--text-2xl); 522 } 523 524 .header-actions { ··· 528 .queue-button, 529 .reorder-button { 530 padding: 0.6rem 1rem; 531 + font-size: var(--text-sm); 532 } 533 534 .queue-button svg,
+16 -16
frontend/src/routes/liked/[handle]/+page.svelte
··· 126 .avatar { 127 width: 64px; 128 height: 64px; 129 - border-radius: 50%; 130 object-fit: cover; 131 flex-shrink: 0; 132 } ··· 137 justify-content: center; 138 background: var(--bg-tertiary); 139 color: var(--text-secondary); 140 - font-size: 1.5rem; 141 font-weight: 600; 142 } 143 ··· 149 } 150 151 .user-info h1 { 152 - font-size: 1.5rem; 153 font-weight: 700; 154 color: var(--text-primary); 155 margin: 0; ··· 159 } 160 161 .handle { 162 - font-size: 0.9rem; 163 color: var(--text-tertiary); 164 text-decoration: none; 165 transition: color 0.15s; ··· 189 } 190 191 .count { 192 - font-size: 0.95rem; 193 font-weight: 500; 194 color: var(--text-secondary); 195 } ··· 208 background: transparent; 209 border: 1px solid var(--border-default); 210 color: var(--text-secondary); 211 - border-radius: 6px; 212 - font-size: 0.85rem; 213 font-family: inherit; 214 cursor: pointer; 215 transition: all 0.15s; ··· 241 } 242 243 .empty-state h2 { 244 - font-size: 1.5rem; 245 font-weight: 600; 246 color: var(--text-secondary); 247 margin: 0 0 0.5rem 0; 248 } 249 250 .empty-state p { 251 - font-size: 0.95rem; 252 margin: 0; 253 } 254 ··· 275 } 276 277 .avatar-placeholder { 278 - font-size: 1.25rem; 279 } 280 281 .user-info h1 { 282 - font-size: 1.25rem; 283 } 284 285 .handle { 286 - font-size: 0.85rem; 287 } 288 289 .section-header h2 { 290 - font-size: 1.25rem; 291 } 292 293 .count { 294 - font-size: 0.85rem; 295 } 296 297 .empty-state { ··· 299 } 300 301 .empty-state h2 { 302 - font-size: 1.25rem; 303 } 304 305 .btn-action { 306 padding: 0.45rem 0.7rem; 307 - font-size: 0.8rem; 308 } 309 310 .btn-action svg {
··· 126 .avatar { 127 width: 64px; 128 height: 64px; 129 + border-radius: var(--radius-full); 130 object-fit: cover; 131 flex-shrink: 0; 132 } ··· 137 justify-content: center; 138 background: var(--bg-tertiary); 139 color: var(--text-secondary); 140 + font-size: var(--text-3xl); 141 font-weight: 600; 142 } 143 ··· 149 } 150 151 .user-info h1 { 152 + font-size: var(--text-3xl); 153 font-weight: 700; 154 color: var(--text-primary); 155 margin: 0; ··· 159 } 160 161 .handle { 162 + font-size: var(--text-base); 163 color: var(--text-tertiary); 164 text-decoration: none; 165 transition: color 0.15s; ··· 189 } 190 191 .count { 192 + font-size: var(--text-base); 193 font-weight: 500; 194 color: var(--text-secondary); 195 } ··· 208 background: transparent; 209 border: 1px solid var(--border-default); 210 color: var(--text-secondary); 211 + border-radius: var(--radius-base); 212 + font-size: var(--text-sm); 213 font-family: inherit; 214 cursor: pointer; 215 transition: all 0.15s; ··· 241 } 242 243 .empty-state h2 { 244 + font-size: var(--text-3xl); 245 font-weight: 600; 246 color: var(--text-secondary); 247 margin: 0 0 0.5rem 0; 248 } 249 250 .empty-state p { 251 + font-size: var(--text-base); 252 margin: 0; 253 } 254 ··· 275 } 276 277 .avatar-placeholder { 278 + font-size: var(--text-2xl); 279 } 280 281 .user-info h1 { 282 + font-size: var(--text-2xl); 283 } 284 285 .handle { 286 + font-size: var(--text-sm); 287 } 288 289 .section-header h2 { 290 + font-size: var(--text-2xl); 291 } 292 293 .count { 294 + font-size: var(--text-sm); 295 } 296 297 .empty-state { ··· 299 } 300 301 .empty-state h2 { 302 + font-size: var(--text-2xl); 303 } 304 305 .btn-action { 306 padding: 0.45rem 0.7rem; 307 + font-size: var(--text-sm); 308 } 309 310 .btn-action svg {
+8 -8
frontend/src/routes/login/+page.svelte
··· 142 .login-card { 143 background: var(--bg-tertiary); 144 border: 1px solid var(--border-subtle); 145 - border-radius: 12px; 146 padding: 2.5rem; 147 max-width: 420px; 148 width: 100%; ··· 171 172 label { 173 color: var(--text-secondary); 174 - font-size: 0.9rem; 175 } 176 177 button.primary { ··· 180 background: var(--accent); 181 color: white; 182 border: none; 183 - border-radius: 8px; 184 - font-size: 0.95rem; 185 font-weight: 500; 186 font-family: inherit; 187 cursor: pointer; ··· 213 border: none; 214 color: var(--text-secondary); 215 font-family: inherit; 216 - font-size: 0.9rem; 217 cursor: pointer; 218 text-align: left; 219 } ··· 234 .faq-content { 235 padding: 0 0 1rem 0; 236 color: var(--text-tertiary); 237 - font-size: 0.85rem; 238 line-height: 1.6; 239 } 240 ··· 259 .faq-content code { 260 background: var(--bg-secondary); 261 padding: 0.15rem 0.4rem; 262 - border-radius: 4px; 263 font-size: 0.85em; 264 } 265 ··· 269 } 270 271 h1 { 272 - font-size: 1.5rem; 273 } 274 } 275 </style>
··· 142 .login-card { 143 background: var(--bg-tertiary); 144 border: 1px solid var(--border-subtle); 145 + border-radius: var(--radius-lg); 146 padding: 2.5rem; 147 max-width: 420px; 148 width: 100%; ··· 171 172 label { 173 color: var(--text-secondary); 174 + font-size: var(--text-base); 175 } 176 177 button.primary { ··· 180 background: var(--accent); 181 color: white; 182 border: none; 183 + border-radius: var(--radius-md); 184 + font-size: var(--text-base); 185 font-weight: 500; 186 font-family: inherit; 187 cursor: pointer; ··· 213 border: none; 214 color: var(--text-secondary); 215 font-family: inherit; 216 + font-size: var(--text-base); 217 cursor: pointer; 218 text-align: left; 219 } ··· 234 .faq-content { 235 padding: 0 0 1rem 0; 236 color: var(--text-tertiary); 237 + font-size: var(--text-sm); 238 line-height: 1.6; 239 } 240 ··· 259 .faq-content code { 260 background: var(--bg-secondary); 261 padding: 0.15rem 0.4rem; 262 + border-radius: var(--radius-sm); 263 font-size: 0.85em; 264 } 265 ··· 269 } 270 271 h1 { 272 + font-size: var(--text-3xl); 273 } 274 } 275 </style>
+73 -51
frontend/src/routes/playlist/[id]/+page.svelte
··· 12 import { toast } from "$lib/toast.svelte"; 13 import { player } from "$lib/player.svelte"; 14 import { queue } from "$lib/queue.svelte"; 15 import { fetchLikedTracks } from "$lib/tracks.svelte"; 16 import type { PageData } from "./$types"; 17 import type { PlaylistWithTracks, Track } from "$lib/types"; ··· 143 queue.playNow(track); 144 } 145 146 - function playNow() { 147 if (tracks.length > 0) { 148 - queue.setQueue(tracks); 149 - queue.playNow(tracks[0]); 150 - toast.success(`playing ${playlist.name}`, 1800); 151 } 152 } 153 ··· 604 605 // check if user owns this playlist 606 const isOwner = $derived(auth.user?.did === playlist.owner_did); 607 </script> 608 609 <svelte:window on:keydown={handleKeydown} /> ··· 862 </div> 863 864 <div class="playlist-actions"> 865 - <button class="play-button" onclick={playNow}> 866 - <svg 867 - width="20" 868 - height="20" 869 - viewBox="0 0 24 24" 870 - fill="currentColor" 871 - > 872 - <path d="M8 5v14l11-7z" /> 873 - </svg> 874 - play now 875 </button> 876 <button class="queue-button" onclick={addToQueue}> 877 <svg ··· 1338 .playlist-art { 1339 width: 200px; 1340 height: 200px; 1341 - border-radius: 8px; 1342 object-fit: cover; 1343 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 1344 } ··· 1346 .playlist-art-placeholder { 1347 width: 200px; 1348 height: 200px; 1349 - border-radius: 8px; 1350 background: var(--bg-tertiary); 1351 border: 1px solid var(--border-subtle); 1352 display: flex; ··· 1391 opacity: 0; 1392 transition: opacity 0.2s; 1393 pointer-events: none; 1394 - border-radius: 8px; 1395 font-family: inherit; 1396 } 1397 1398 .art-edit-overlay span { 1399 font-family: inherit; 1400 - font-size: 0.85rem; 1401 font-weight: 500; 1402 } 1403 ··· 1429 1430 .playlist-type { 1431 text-transform: uppercase; 1432 - font-size: 0.75rem; 1433 font-weight: 600; 1434 letter-spacing: 0.1em; 1435 color: var(--text-tertiary); ··· 1470 display: flex; 1471 align-items: center; 1472 gap: 0.75rem; 1473 - font-size: 0.95rem; 1474 color: var(--text-secondary); 1475 } 1476 ··· 1487 1488 .meta-separator { 1489 color: var(--text-muted); 1490 - font-size: 0.7rem; 1491 } 1492 1493 .show-on-profile-toggle { ··· 1496 gap: 0.5rem; 1497 margin-top: 0.75rem; 1498 cursor: pointer; 1499 - font-size: 0.85rem; 1500 color: var(--text-secondary); 1501 } 1502 ··· 1523 height: 32px; 1524 background: var(--glass-btn-bg, rgba(18, 18, 18, 0.75)); 1525 border: 1px solid var(--glass-btn-border, rgba(255, 255, 255, 0.1)); 1526 - border-radius: 6px; 1527 color: var(--text-secondary); 1528 cursor: pointer; 1529 transition: all 0.15s; ··· 1557 .play-button, 1558 .queue-button { 1559 padding: 0.75rem 1.5rem; 1560 - border-radius: 24px; 1561 font-weight: 600; 1562 - font-size: 0.95rem; 1563 font-family: inherit; 1564 cursor: pointer; 1565 transition: all 0.2s; ··· 1578 transform: scale(1.05); 1579 } 1580 1581 .queue-button { 1582 background: var(--glass-btn-bg, transparent); 1583 color: var(--text-primary); ··· 1612 } 1613 1614 .section-heading { 1615 - font-size: 1.25rem; 1616 font-weight: 600; 1617 color: var(--text-primary); 1618 margin-bottom: 1rem; ··· 1631 display: flex; 1632 align-items: center; 1633 gap: 0.5rem; 1634 - border-radius: 8px; 1635 transition: all 0.2s; 1636 position: relative; 1637 } ··· 1663 color: var(--text-muted); 1664 cursor: grab; 1665 touch-action: none; 1666 - border-radius: 4px; 1667 transition: all 0.2s; 1668 flex-shrink: 0; 1669 } ··· 1696 padding: 0.5rem; 1697 background: transparent; 1698 border: 1px solid var(--border-default); 1699 - border-radius: 4px; 1700 color: var(--text-muted); 1701 cursor: pointer; 1702 transition: all 0.2s; ··· 1729 margin-top: 0.5rem; 1730 background: transparent; 1731 border: 1px dashed var(--border-default); 1732 - border-radius: 8px; 1733 color: var(--text-tertiary); 1734 font-family: inherit; 1735 - font-size: 0.9rem; 1736 cursor: pointer; 1737 transition: all 0.2s; 1738 } ··· 1756 .empty-icon { 1757 width: 64px; 1758 height: 64px; 1759 - border-radius: 16px; 1760 display: flex; 1761 align-items: center; 1762 justify-content: center; ··· 1766 } 1767 1768 .empty-state p { 1769 - font-size: 1rem; 1770 font-weight: 500; 1771 color: var(--text-secondary); 1772 margin: 0 0 0.25rem 0; 1773 } 1774 1775 .empty-state span { 1776 - font-size: 0.85rem; 1777 color: var(--text-muted); 1778 margin-bottom: 1.5rem; 1779 } ··· 1783 background: var(--accent); 1784 color: white; 1785 border: none; 1786 - border-radius: 8px; 1787 font-family: inherit; 1788 - font-size: 0.9rem; 1789 font-weight: 500; 1790 cursor: pointer; 1791 transition: all 0.15s; ··· 1813 .modal { 1814 background: var(--bg-primary); 1815 border: 1px solid var(--border-default); 1816 - border-radius: 16px; 1817 width: 100%; 1818 max-width: 400px; 1819 box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); ··· 1835 } 1836 1837 .modal-header h3 { 1838 - font-size: 1.1rem; 1839 font-weight: 600; 1840 color: var(--text-primary); 1841 margin: 0; ··· 1849 height: 32px; 1850 background: transparent; 1851 border: none; 1852 - border-radius: 8px; 1853 color: var(--text-secondary); 1854 cursor: pointer; 1855 transition: all 0.15s; ··· 1874 background: transparent; 1875 border: none; 1876 font-family: inherit; 1877 - font-size: 1rem; 1878 color: var(--text-primary); 1879 outline: none; 1880 } ··· 1895 padding: 2rem 1.5rem; 1896 text-align: center; 1897 color: var(--text-muted); 1898 - font-size: 0.9rem; 1899 margin: 0; 1900 } 1901 ··· 1919 .result-image-placeholder { 1920 width: 40px; 1921 height: 40px; 1922 - border-radius: 6px; 1923 flex-shrink: 0; 1924 } 1925 ··· 1944 } 1945 1946 .result-title { 1947 - font-size: 0.9rem; 1948 font-weight: 500; 1949 color: var(--text-primary); 1950 white-space: nowrap; ··· 1953 } 1954 1955 .result-artist { 1956 - font-size: 0.8rem; 1957 color: var(--text-tertiary); 1958 white-space: nowrap; 1959 overflow: hidden; ··· 1968 height: 36px; 1969 background: var(--accent); 1970 border: none; 1971 - border-radius: 8px; 1972 color: white; 1973 cursor: pointer; 1974 transition: all 0.15s; ··· 1991 .modal-body p { 1992 margin: 0; 1993 color: var(--text-secondary); 1994 - font-size: 0.95rem; 1995 line-height: 1.5; 1996 } 1997 ··· 2005 .cancel-btn, 2006 .confirm-btn { 2007 padding: 0.625rem 1.25rem; 2008 - border-radius: 8px; 2009 font-family: inherit; 2010 - font-size: 0.9rem; 2011 font-weight: 500; 2012 cursor: pointer; 2013 transition: all 0.15s; ··· 2050 height: 16px; 2051 border: 2px solid currentColor; 2052 border-top-color: transparent; 2053 - border-radius: 50%; 2054 animation: spin 0.6s linear infinite; 2055 } 2056 ··· 2096 } 2097 2098 .playlist-meta { 2099 - font-size: 0.85rem; 2100 } 2101 2102 .playlist-actions { ··· 2139 } 2140 2141 .playlist-meta { 2142 - font-size: 0.8rem; 2143 flex-wrap: wrap; 2144 } 2145 }
··· 12 import { toast } from "$lib/toast.svelte"; 13 import { player } from "$lib/player.svelte"; 14 import { queue } from "$lib/queue.svelte"; 15 + import { playQueue } from "$lib/playback.svelte"; 16 import { fetchLikedTracks } from "$lib/tracks.svelte"; 17 import type { PageData } from "./$types"; 18 import type { PlaylistWithTracks, Track } from "$lib/types"; ··· 144 queue.playNow(track); 145 } 146 147 + async function playNow() { 148 if (tracks.length > 0) { 149 + // use playQueue to check gated access on first track before modifying queue 150 + const played = await playQueue(tracks); 151 + if (played) { 152 + toast.success(`playing ${playlist.name}`, 1800); 153 + } 154 } 155 } 156 ··· 607 608 // check if user owns this playlist 609 const isOwner = $derived(auth.user?.did === playlist.owner_did); 610 + 611 + // check if current track is from this playlist (active, regardless of paused state) 612 + const isPlaylistActive = $derived( 613 + player.currentTrack !== null && 614 + tracks.some(t => t.id === player.currentTrack?.id) 615 + ); 616 + 617 + // check if actively playing (not paused) 618 + const isPlaylistPlaying = $derived(isPlaylistActive && !player.paused); 619 </script> 620 621 <svelte:window on:keydown={handleKeydown} /> ··· 874 </div> 875 876 <div class="playlist-actions"> 877 + <button 878 + class="play-button" 879 + class:is-playing={isPlaylistPlaying} 880 + onclick={() => isPlaylistActive ? player.togglePlayPause() : playNow()} 881 + > 882 + {#if isPlaylistPlaying} 883 + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> 884 + <path d="M6 4h4v16H6zM14 4h4v16h-4z"/> 885 + </svg> 886 + pause 887 + {:else} 888 + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> 889 + <path d="M8 5v14l11-7z" /> 890 + </svg> 891 + play 892 + {/if} 893 </button> 894 <button class="queue-button" onclick={addToQueue}> 895 <svg ··· 1356 .playlist-art { 1357 width: 200px; 1358 height: 200px; 1359 + border-radius: var(--radius-md); 1360 object-fit: cover; 1361 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 1362 } ··· 1364 .playlist-art-placeholder { 1365 width: 200px; 1366 height: 200px; 1367 + border-radius: var(--radius-md); 1368 background: var(--bg-tertiary); 1369 border: 1px solid var(--border-subtle); 1370 display: flex; ··· 1409 opacity: 0; 1410 transition: opacity 0.2s; 1411 pointer-events: none; 1412 + border-radius: var(--radius-md); 1413 font-family: inherit; 1414 } 1415 1416 .art-edit-overlay span { 1417 font-family: inherit; 1418 + font-size: var(--text-sm); 1419 font-weight: 500; 1420 } 1421 ··· 1447 1448 .playlist-type { 1449 text-transform: uppercase; 1450 + font-size: var(--text-xs); 1451 font-weight: 600; 1452 letter-spacing: 0.1em; 1453 color: var(--text-tertiary); ··· 1488 display: flex; 1489 align-items: center; 1490 gap: 0.75rem; 1491 + font-size: var(--text-base); 1492 color: var(--text-secondary); 1493 } 1494 ··· 1505 1506 .meta-separator { 1507 color: var(--text-muted); 1508 + font-size: var(--text-xs); 1509 } 1510 1511 .show-on-profile-toggle { ··· 1514 gap: 0.5rem; 1515 margin-top: 0.75rem; 1516 cursor: pointer; 1517 + font-size: var(--text-sm); 1518 color: var(--text-secondary); 1519 } 1520 ··· 1541 height: 32px; 1542 background: var(--glass-btn-bg, rgba(18, 18, 18, 0.75)); 1543 border: 1px solid var(--glass-btn-border, rgba(255, 255, 255, 0.1)); 1544 + border-radius: var(--radius-base); 1545 color: var(--text-secondary); 1546 cursor: pointer; 1547 transition: all 0.15s; ··· 1575 .play-button, 1576 .queue-button { 1577 padding: 0.75rem 1.5rem; 1578 + border-radius: var(--radius-2xl); 1579 font-weight: 600; 1580 + font-size: var(--text-base); 1581 font-family: inherit; 1582 cursor: pointer; 1583 transition: all 0.2s; ··· 1596 transform: scale(1.05); 1597 } 1598 1599 + .play-button.is-playing { 1600 + animation: ethereal-glow 3s ease-in-out infinite; 1601 + } 1602 + 1603 .queue-button { 1604 background: var(--glass-btn-bg, transparent); 1605 color: var(--text-primary); ··· 1634 } 1635 1636 .section-heading { 1637 + font-size: var(--text-2xl); 1638 font-weight: 600; 1639 color: var(--text-primary); 1640 margin-bottom: 1rem; ··· 1653 display: flex; 1654 align-items: center; 1655 gap: 0.5rem; 1656 + border-radius: var(--radius-md); 1657 transition: all 0.2s; 1658 position: relative; 1659 } ··· 1685 color: var(--text-muted); 1686 cursor: grab; 1687 touch-action: none; 1688 + border-radius: var(--radius-sm); 1689 transition: all 0.2s; 1690 flex-shrink: 0; 1691 } ··· 1718 padding: 0.5rem; 1719 background: transparent; 1720 border: 1px solid var(--border-default); 1721 + border-radius: var(--radius-sm); 1722 color: var(--text-muted); 1723 cursor: pointer; 1724 transition: all 0.2s; ··· 1751 margin-top: 0.5rem; 1752 background: transparent; 1753 border: 1px dashed var(--border-default); 1754 + border-radius: var(--radius-md); 1755 color: var(--text-tertiary); 1756 font-family: inherit; 1757 + font-size: var(--text-base); 1758 cursor: pointer; 1759 transition: all 0.2s; 1760 } ··· 1778 .empty-icon { 1779 width: 64px; 1780 height: 64px; 1781 + border-radius: var(--radius-xl); 1782 display: flex; 1783 align-items: center; 1784 justify-content: center; ··· 1788 } 1789 1790 .empty-state p { 1791 + font-size: var(--text-lg); 1792 font-weight: 500; 1793 color: var(--text-secondary); 1794 margin: 0 0 0.25rem 0; 1795 } 1796 1797 .empty-state span { 1798 + font-size: var(--text-sm); 1799 color: var(--text-muted); 1800 margin-bottom: 1.5rem; 1801 } ··· 1805 background: var(--accent); 1806 color: white; 1807 border: none; 1808 + border-radius: var(--radius-md); 1809 font-family: inherit; 1810 + font-size: var(--text-base); 1811 font-weight: 500; 1812 cursor: pointer; 1813 transition: all 0.15s; ··· 1835 .modal { 1836 background: var(--bg-primary); 1837 border: 1px solid var(--border-default); 1838 + border-radius: var(--radius-xl); 1839 width: 100%; 1840 max-width: 400px; 1841 box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); ··· 1857 } 1858 1859 .modal-header h3 { 1860 + font-size: var(--text-xl); 1861 font-weight: 600; 1862 color: var(--text-primary); 1863 margin: 0; ··· 1871 height: 32px; 1872 background: transparent; 1873 border: none; 1874 + border-radius: var(--radius-md); 1875 color: var(--text-secondary); 1876 cursor: pointer; 1877 transition: all 0.15s; ··· 1896 background: transparent; 1897 border: none; 1898 font-family: inherit; 1899 + font-size: var(--text-lg); 1900 color: var(--text-primary); 1901 outline: none; 1902 } ··· 1917 padding: 2rem 1.5rem; 1918 text-align: center; 1919 color: var(--text-muted); 1920 + font-size: var(--text-base); 1921 margin: 0; 1922 } 1923 ··· 1941 .result-image-placeholder { 1942 width: 40px; 1943 height: 40px; 1944 + border-radius: var(--radius-base); 1945 flex-shrink: 0; 1946 } 1947 ··· 1966 } 1967 1968 .result-title { 1969 + font-size: var(--text-base); 1970 font-weight: 500; 1971 color: var(--text-primary); 1972 white-space: nowrap; ··· 1975 } 1976 1977 .result-artist { 1978 + font-size: var(--text-sm); 1979 color: var(--text-tertiary); 1980 white-space: nowrap; 1981 overflow: hidden; ··· 1990 height: 36px; 1991 background: var(--accent); 1992 border: none; 1993 + border-radius: var(--radius-md); 1994 color: white; 1995 cursor: pointer; 1996 transition: all 0.15s; ··· 2013 .modal-body p { 2014 margin: 0; 2015 color: var(--text-secondary); 2016 + font-size: var(--text-base); 2017 line-height: 1.5; 2018 } 2019 ··· 2027 .cancel-btn, 2028 .confirm-btn { 2029 padding: 0.625rem 1.25rem; 2030 + border-radius: var(--radius-md); 2031 font-family: inherit; 2032 + font-size: var(--text-base); 2033 font-weight: 500; 2034 cursor: pointer; 2035 transition: all 0.15s; ··· 2072 height: 16px; 2073 border: 2px solid currentColor; 2074 border-top-color: transparent; 2075 + border-radius: var(--radius-full); 2076 animation: spin 0.6s linear infinite; 2077 } 2078 ··· 2118 } 2119 2120 .playlist-meta { 2121 + font-size: var(--text-sm); 2122 } 2123 2124 .playlist-actions { ··· 2161 } 2162 2163 .playlist-meta { 2164 + font-size: var(--text-sm); 2165 flex-wrap: wrap; 2166 } 2167 }
+181 -122
frontend/src/routes/portal/+page.svelte
··· 28 let editFeaturedArtists = $state<FeaturedArtist[]>([]); 29 let editTags = $state<string[]>([]); 30 let editImageFile = $state<File | null>(null); 31 let hasUnresolvedEditFeaturesInput = $state(false); 32 33 // profile editing state ··· 105 } 106 107 try { 108 - await loadMyTracks(); 109 - await loadArtistProfile(); 110 - await loadMyAlbums(); 111 - await loadMyPlaylists(); 112 } catch (_e) { 113 console.error('error loading portal data:', _e); 114 error = 'failed to load portal data'; ··· 315 editAlbum = track.album?.title || ''; 316 editFeaturedArtists = track.features || []; 317 editTags = track.tags || []; 318 } 319 320 function cancelEdit() { ··· 324 editFeaturedArtists = []; 325 editTags = []; 326 editImageFile = null; 327 } 328 329 ··· 340 } 341 // always send tags (empty array clears them) 342 formData.append('tags', JSON.stringify(editTags)); 343 if (editImageFile) { 344 formData.append('image', editImageFile); 345 } ··· 740 <p class="file-info">{editImageFile.name} (will replace current)</p> 741 {/if} 742 </div> 743 </div> 744 <div class="edit-actions"> 745 <button ··· 784 <div class="track-info"> 785 <div class="track-title"> 786 {track.title} 787 {#if track.copyright_flagged} 788 {@const matchText = track.copyright_match ? `potential copyright violation: ${track.copyright_match}` : 'potential copyright violation'} 789 {#if track.atproto_record_url} ··· 1109 .view-profile-link { 1110 color: var(--text-secondary); 1111 text-decoration: none; 1112 - font-size: 0.8rem; 1113 padding: 0.35rem 0.6rem; 1114 background: var(--bg-tertiary); 1115 - border-radius: 5px; 1116 border: 1px solid var(--border-default); 1117 transition: all 0.15s; 1118 white-space: nowrap; ··· 1127 .settings-link { 1128 color: var(--text-secondary); 1129 text-decoration: none; 1130 - font-size: 0.8rem; 1131 padding: 0.35rem 0.6rem; 1132 background: var(--bg-tertiary); 1133 - border-radius: 5px; 1134 border: 1px solid var(--border-default); 1135 transition: all 0.15s; 1136 white-space: nowrap; ··· 1150 padding: 1rem 1.25rem; 1151 background: var(--bg-tertiary); 1152 border: 1px solid var(--border-default); 1153 - border-radius: 8px; 1154 text-decoration: none; 1155 color: var(--text-primary); 1156 transition: all 0.15s; ··· 1173 width: 44px; 1174 height: 44px; 1175 background: color-mix(in srgb, var(--accent) 15%, transparent); 1176 - border-radius: 10px; 1177 color: var(--accent); 1178 flex-shrink: 0; 1179 } ··· 1186 .upload-card-title { 1187 display: block; 1188 font-weight: 600; 1189 - font-size: 0.95rem; 1190 color: var(--text-primary); 1191 } 1192 1193 .upload-card-subtitle { 1194 display: block; 1195 - font-size: 0.8rem; 1196 color: var(--text-tertiary); 1197 } 1198 ··· 1210 form { 1211 background: var(--bg-tertiary); 1212 padding: 1.25rem; 1213 - border-radius: 8px; 1214 border: 1px solid var(--border-subtle); 1215 } 1216 ··· 1226 display: block; 1227 color: var(--text-secondary); 1228 margin-bottom: 0.4rem; 1229 - font-size: 0.85rem; 1230 } 1231 1232 - input[type='text'] { 1233 width: 100%; 1234 padding: 0.6rem 0.75rem; 1235 background: var(--bg-primary); 1236 border: 1px solid var(--border-default); 1237 - border-radius: 4px; 1238 color: var(--text-primary); 1239 - font-size: 0.95rem; 1240 font-family: inherit; 1241 transition: all 0.15s; 1242 } 1243 1244 - input[type='text']:focus { 1245 outline: none; 1246 border-color: var(--accent); 1247 } 1248 1249 - input[type='text']:disabled { 1250 opacity: 0.5; 1251 cursor: not-allowed; 1252 } 1253 1254 textarea { 1255 - width: 100%; 1256 - padding: 0.6rem 0.75rem; 1257 - background: var(--bg-primary); 1258 - border: 1px solid var(--border-default); 1259 - border-radius: 4px; 1260 - color: var(--text-primary); 1261 - font-size: 0.95rem; 1262 - font-family: inherit; 1263 - transition: all 0.15s; 1264 resize: vertical; 1265 min-height: 80px; 1266 } 1267 1268 - textarea:focus { 1269 - outline: none; 1270 - border-color: var(--accent); 1271 - } 1272 - 1273 - textarea:disabled { 1274 - opacity: 0.5; 1275 - cursor: not-allowed; 1276 - } 1277 - 1278 .hint { 1279 margin-top: 0.35rem; 1280 - font-size: 0.75rem; 1281 color: var(--text-muted); 1282 } 1283 ··· 1295 display: block; 1296 color: var(--text-secondary); 1297 margin-bottom: 0.6rem; 1298 - font-size: 0.85rem; 1299 } 1300 1301 .support-options { ··· 1312 padding: 0.6rem 0.75rem; 1313 background: var(--bg-primary); 1314 border: 1px solid var(--border-default); 1315 - border-radius: 6px; 1316 cursor: pointer; 1317 transition: all 0.15s; 1318 margin-bottom: 0; ··· 1335 } 1336 1337 .support-option span { 1338 - font-size: 0.9rem; 1339 color: var(--text-primary); 1340 } 1341 1342 .support-status { 1343 margin-left: auto; 1344 - font-size: 0.75rem; 1345 color: var(--text-tertiary); 1346 } 1347 1348 .support-setup-link, 1349 .support-status-link { 1350 margin-left: auto; 1351 - font-size: 0.75rem; 1352 text-decoration: none; 1353 } 1354 ··· 1379 padding: 0.6rem 0.75rem; 1380 background: var(--bg-primary); 1381 border: 1px solid var(--border-default); 1382 - border-radius: 4px; 1383 color: var(--text-primary); 1384 - font-size: 0.95rem; 1385 font-family: inherit; 1386 transition: all 0.15s; 1387 margin-bottom: 0.5rem; ··· 1407 .avatar-preview img { 1408 width: 64px; 1409 height: 64px; 1410 - border-radius: 50%; 1411 object-fit: cover; 1412 border: 2px solid var(--border-default); 1413 } ··· 1417 padding: 0.75rem; 1418 background: var(--bg-primary); 1419 border: 1px solid var(--border-default); 1420 - border-radius: 4px; 1421 color: var(--text-primary); 1422 - font-size: 0.9rem; 1423 font-family: inherit; 1424 cursor: pointer; 1425 } ··· 1431 1432 .file-info { 1433 margin-top: 0.5rem; 1434 - font-size: 0.85rem; 1435 color: var(--text-muted); 1436 } 1437 ··· 1441 background: var(--accent); 1442 color: var(--text-primary); 1443 border: none; 1444 - border-radius: 4px; 1445 - font-size: 1rem; 1446 font-weight: 600; 1447 font-family: inherit; 1448 cursor: pointer; ··· 1479 padding: 2rem; 1480 text-align: center; 1481 background: var(--bg-tertiary); 1482 - border-radius: 8px; 1483 border: 1px solid var(--border-subtle); 1484 } 1485 ··· 1496 gap: 1rem; 1497 background: var(--bg-tertiary); 1498 border: 1px solid var(--border-subtle); 1499 - border-radius: 6px; 1500 padding: 1rem; 1501 transition: all 0.2s; 1502 } ··· 1531 .track-artwork { 1532 width: 48px; 1533 height: 48px; 1534 - border-radius: 4px; 1535 overflow: hidden; 1536 background: var(--bg-primary); 1537 border: 1px solid var(--border-subtle); ··· 1553 } 1554 1555 .track-view-link { 1556 - font-size: 0.7rem; 1557 color: var(--text-muted); 1558 text-decoration: none; 1559 transition: color 0.15s; ··· 1600 } 1601 1602 .edit-label { 1603 - font-size: 0.85rem; 1604 color: var(--text-secondary); 1605 } 1606 1607 .track-title { 1608 font-weight: 600; 1609 - font-size: 1rem; 1610 margin-bottom: 0.25rem; 1611 color: var(--text-primary); 1612 display: flex; ··· 1614 gap: 0.5rem; 1615 } 1616 1617 .copyright-flag { 1618 display: inline-flex; 1619 align-items: center; ··· 1635 } 1636 1637 .track-meta { 1638 - font-size: 0.9rem; 1639 color: var(--text-secondary); 1640 margin-bottom: 0.25rem; 1641 display: flex; ··· 1703 padding: 0.1rem 0.4rem; 1704 background: color-mix(in srgb, var(--accent) 15%, transparent); 1705 color: var(--accent-hover); 1706 - border-radius: 3px; 1707 - font-size: 0.8rem; 1708 font-weight: 500; 1709 text-decoration: none; 1710 transition: all 0.15s; ··· 1716 } 1717 1718 .track-date { 1719 - font-size: 0.85rem; 1720 color: var(--text-muted); 1721 } 1722 ··· 1737 padding: 0; 1738 background: transparent; 1739 border: 1px solid var(--border-default); 1740 - border-radius: 6px; 1741 color: var(--text-tertiary); 1742 cursor: pointer; 1743 transition: all 0.15s; ··· 1782 padding: 0.5rem; 1783 background: var(--bg-primary); 1784 border: 1px solid var(--border-default); 1785 - border-radius: 4px; 1786 color: var(--text-primary); 1787 - font-size: 0.9rem; 1788 font-family: inherit; 1789 } 1790 ··· 1795 padding: 0.5rem; 1796 background: var(--bg-primary); 1797 border: 1px solid var(--border-default); 1798 - border-radius: 4px; 1799 margin-bottom: 0.5rem; 1800 } 1801 1802 .current-image-preview img { 1803 width: 48px; 1804 height: 48px; 1805 - border-radius: 4px; 1806 object-fit: cover; 1807 } 1808 1809 .current-image-label { 1810 color: var(--text-tertiary); 1811 - font-size: 0.85rem; 1812 } 1813 1814 .edit-input:focus { ··· 1840 .album-card { 1841 background: var(--bg-tertiary); 1842 border: 1px solid var(--border-subtle); 1843 - border-radius: 8px; 1844 padding: 1rem; 1845 transition: all 0.2s; 1846 display: flex; ··· 1858 .album-cover { 1859 width: 100%; 1860 aspect-ratio: 1; 1861 - border-radius: 6px; 1862 object-fit: cover; 1863 } 1864 1865 .album-cover-placeholder { 1866 width: 100%; 1867 aspect-ratio: 1; 1868 - border-radius: 6px; 1869 background: linear-gradient(135deg, rgba(var(--accent-rgb, 139, 92, 246), 0.15), rgba(var(--accent-rgb, 139, 92, 246), 0.05)); 1870 display: flex; 1871 align-items: center; ··· 1879 } 1880 1881 .album-title { 1882 - font-size: 1rem; 1883 font-weight: 600; 1884 color: var(--text-primary); 1885 margin: 0 0 0.25rem 0; ··· 1889 } 1890 1891 .album-stats { 1892 - font-size: 0.85rem; 1893 color: var(--text-tertiary); 1894 margin: 0; 1895 } ··· 1907 .view-playlists-link { 1908 color: var(--text-secondary); 1909 text-decoration: none; 1910 - font-size: 0.8rem; 1911 padding: 0.35rem 0.6rem; 1912 background: var(--bg-tertiary); 1913 - border-radius: 5px; 1914 border: 1px solid var(--border-default); 1915 transition: all 0.15s; 1916 white-space: nowrap; ··· 1931 .playlist-card { 1932 background: var(--bg-tertiary); 1933 border: 1px solid var(--border-subtle); 1934 - border-radius: 8px; 1935 padding: 1rem; 1936 transition: all 0.2s; 1937 display: flex; ··· 1949 .playlist-cover { 1950 width: 100%; 1951 aspect-ratio: 1; 1952 - border-radius: 6px; 1953 object-fit: cover; 1954 } 1955 1956 .playlist-cover-placeholder { 1957 width: 100%; 1958 aspect-ratio: 1; 1959 - border-radius: 6px; 1960 background: linear-gradient(135deg, rgba(var(--accent-rgb, 139, 92, 246), 0.15), rgba(var(--accent-rgb, 139, 92, 246), 0.05)); 1961 display: flex; 1962 align-items: center; ··· 1970 } 1971 1972 .playlist-title { 1973 - font-size: 1rem; 1974 font-weight: 600; 1975 color: var(--text-primary); 1976 margin: 0 0 0.25rem 0; ··· 1980 } 1981 1982 .playlist-stats { 1983 - font-size: 0.85rem; 1984 color: var(--text-tertiary); 1985 margin: 0; 1986 } ··· 1999 padding: 1rem 1.25rem; 2000 background: var(--bg-tertiary); 2001 border: 1px solid var(--border-subtle); 2002 - border-radius: 8px; 2003 display: flex; 2004 justify-content: space-between; 2005 align-items: center; ··· 2017 } 2018 2019 .control-info h3 { 2020 - font-size: 0.9rem; 2021 font-weight: 600; 2022 margin: 0 0 0.15rem 0; 2023 color: var(--text-primary); 2024 } 2025 2026 .control-description { 2027 - font-size: 0.75rem; 2028 color: var(--text-tertiary); 2029 margin: 0; 2030 line-height: 1.4; ··· 2035 background: var(--accent); 2036 color: var(--text-primary); 2037 border: none; 2038 - border-radius: 6px; 2039 - font-size: 0.9rem; 2040 font-weight: 600; 2041 cursor: pointer; 2042 transition: all 0.2s; ··· 2080 background: transparent; 2081 color: var(--error); 2082 border: 1px solid var(--error); 2083 - border-radius: 6px; 2084 font-family: inherit; 2085 - font-size: 0.9rem; 2086 font-weight: 600; 2087 cursor: pointer; 2088 transition: all 0.2s; ··· 2098 padding: 1rem; 2099 background: var(--bg-primary); 2100 border: 1px solid var(--border-default); 2101 - border-radius: 8px; 2102 } 2103 2104 .delete-warning { 2105 margin: 0 0 1rem; 2106 color: var(--error); 2107 - font-size: 0.9rem; 2108 line-height: 1.5; 2109 } 2110 ··· 2112 margin-bottom: 1rem; 2113 padding: 0.75rem; 2114 background: var(--bg-tertiary); 2115 - border-radius: 6px; 2116 } 2117 2118 .atproto-option { 2119 display: flex; 2120 align-items: center; 2121 gap: 0.5rem; 2122 - font-size: 0.9rem; 2123 color: var(--text-primary); 2124 cursor: pointer; 2125 } ··· 2132 2133 .atproto-note { 2134 margin: 0.5rem 0 0; 2135 - font-size: 0.8rem; 2136 color: var(--text-tertiary); 2137 } 2138 ··· 2149 margin: 0.5rem 0 0; 2150 padding: 0.5rem; 2151 background: color-mix(in srgb, var(--warning) 10%, transparent); 2152 - border-radius: 4px; 2153 - font-size: 0.8rem; 2154 color: var(--warning); 2155 } 2156 2157 .confirm-prompt { 2158 margin: 0 0 0.5rem; 2159 - font-size: 0.9rem; 2160 color: var(--text-secondary); 2161 } 2162 ··· 2165 padding: 0.6rem 0.75rem; 2166 background: var(--bg-tertiary); 2167 border: 1px solid var(--border-default); 2168 - border-radius: 6px; 2169 color: var(--text-primary); 2170 - font-size: 0.9rem; 2171 font-family: inherit; 2172 margin-bottom: 1rem; 2173 } ··· 2187 padding: 0.6rem; 2188 background: transparent; 2189 border: 1px solid var(--border-default); 2190 - border-radius: 6px; 2191 color: var(--text-secondary); 2192 font-family: inherit; 2193 - font-size: 0.9rem; 2194 cursor: pointer; 2195 transition: all 0.15s; 2196 } ··· 2209 padding: 0.6rem; 2210 background: var(--error); 2211 border: none; 2212 - border-radius: 6px; 2213 color: white; 2214 font-family: inherit; 2215 - font-size: 0.9rem; 2216 font-weight: 600; 2217 cursor: pointer; 2218 transition: all 0.15s; ··· 2238 } 2239 2240 .portal-header h2 { 2241 - font-size: 1.25rem; 2242 } 2243 2244 .profile-section h2, ··· 2246 .albums-section h2, 2247 .playlists-section h2, 2248 .data-section h2 { 2249 - font-size: 1.1rem; 2250 } 2251 2252 .section-header { ··· 2254 } 2255 2256 .view-profile-link { 2257 - font-size: 0.75rem; 2258 padding: 0.3rem 0.5rem; 2259 } 2260 ··· 2267 } 2268 2269 label { 2270 - font-size: 0.8rem; 2271 margin-bottom: 0.3rem; 2272 } 2273 ··· 2275 input[type='url'], 2276 textarea { 2277 padding: 0.5rem 0.6rem; 2278 - font-size: 0.9rem; 2279 } 2280 2281 textarea { ··· 2283 } 2284 2285 .hint { 2286 - font-size: 0.7rem; 2287 } 2288 2289 .avatar-preview img { ··· 2293 2294 button[type="submit"] { 2295 padding: 0.6rem; 2296 - font-size: 0.9rem; 2297 } 2298 2299 /* upload card mobile */ ··· 2313 } 2314 2315 .upload-card-title { 2316 - font-size: 0.9rem; 2317 } 2318 2319 .upload-card-subtitle { 2320 - font-size: 0.75rem; 2321 } 2322 2323 /* tracks mobile */ ··· 2351 } 2352 2353 .track-title { 2354 - font-size: 0.9rem; 2355 } 2356 2357 .track-meta { 2358 - font-size: 0.8rem; 2359 } 2360 2361 .track-date { 2362 - font-size: 0.75rem; 2363 } 2364 2365 .track-actions { ··· 2387 } 2388 2389 .edit-label { 2390 - font-size: 0.8rem; 2391 } 2392 2393 .edit-input { 2394 padding: 0.45rem 0.5rem; 2395 - font-size: 0.85rem; 2396 } 2397 2398 .edit-actions { ··· 2406 } 2407 2408 .control-info h3 { 2409 - font-size: 0.85rem; 2410 } 2411 2412 .control-description { 2413 - font-size: 0.7rem; 2414 } 2415 2416 .export-btn { 2417 padding: 0.5rem 0.85rem; 2418 - font-size: 0.8rem; 2419 } 2420 2421 /* albums mobile */ ··· 2430 } 2431 2432 .album-title { 2433 - font-size: 0.85rem; 2434 } 2435 2436 /* playlists mobile */ ··· 2445 } 2446 2447 .playlist-title { 2448 - font-size: 0.85rem; 2449 } 2450 2451 .playlist-stats { 2452 - font-size: 0.75rem; 2453 } 2454 2455 .view-playlists-link { 2456 - font-size: 0.75rem; 2457 padding: 0.3rem 0.5rem; 2458 } 2459 }
··· 28 let editFeaturedArtists = $state<FeaturedArtist[]>([]); 29 let editTags = $state<string[]>([]); 30 let editImageFile = $state<File | null>(null); 31 + let editSupportGate = $state(false); 32 let hasUnresolvedEditFeaturesInput = $state(false); 33 34 // profile editing state ··· 106 } 107 108 try { 109 + await Promise.all([ 110 + loadMyTracks(), 111 + loadArtistProfile(), 112 + loadMyAlbums(), 113 + loadMyPlaylists() 114 + ]); 115 } catch (_e) { 116 console.error('error loading portal data:', _e); 117 error = 'failed to load portal data'; ··· 318 editAlbum = track.album?.title || ''; 319 editFeaturedArtists = track.features || []; 320 editTags = track.tags || []; 321 + editSupportGate = track.support_gate !== null && track.support_gate !== undefined; 322 } 323 324 function cancelEdit() { ··· 328 editFeaturedArtists = []; 329 editTags = []; 330 editImageFile = null; 331 + editSupportGate = false; 332 } 333 334 ··· 345 } 346 // always send tags (empty array clears them) 347 formData.append('tags', JSON.stringify(editTags)); 348 + // send support_gate - null to remove, or {type: "any"} to enable 349 + if (editSupportGate) { 350 + formData.append('support_gate', JSON.stringify({ type: 'any' })); 351 + } else { 352 + formData.append('support_gate', 'null'); 353 + } 354 if (editImageFile) { 355 formData.append('image', editImageFile); 356 } ··· 751 <p class="file-info">{editImageFile.name} (will replace current)</p> 752 {/if} 753 </div> 754 + {#if atprotofansEligible || track.support_gate} 755 + <div class="edit-field-group"> 756 + <label class="edit-label">supporter access</label> 757 + <label class="toggle-row"> 758 + <input 759 + type="checkbox" 760 + bind:checked={editSupportGate} 761 + /> 762 + <span>only supporters can play this track</span> 763 + </label> 764 + {#if editSupportGate} 765 + <p class="field-hint"> 766 + only users who support you via <a href="https://atprotofans.com" target="_blank" rel="noopener">atprotofans</a> can play this track 767 + </p> 768 + {/if} 769 + </div> 770 + {/if} 771 </div> 772 <div class="edit-actions"> 773 <button ··· 812 <div class="track-info"> 813 <div class="track-title"> 814 {track.title} 815 + {#if track.support_gate} 816 + <span class="support-gate-badge" title="supporters only"> 817 + <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> 818 + <path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/> 819 + </svg> 820 + </span> 821 + {/if} 822 {#if track.copyright_flagged} 823 {@const matchText = track.copyright_match ? `potential copyright violation: ${track.copyright_match}` : 'potential copyright violation'} 824 {#if track.atproto_record_url} ··· 1144 .view-profile-link { 1145 color: var(--text-secondary); 1146 text-decoration: none; 1147 + font-size: var(--text-sm); 1148 padding: 0.35rem 0.6rem; 1149 background: var(--bg-tertiary); 1150 + border-radius: var(--radius-sm); 1151 border: 1px solid var(--border-default); 1152 transition: all 0.15s; 1153 white-space: nowrap; ··· 1162 .settings-link { 1163 color: var(--text-secondary); 1164 text-decoration: none; 1165 + font-size: var(--text-sm); 1166 padding: 0.35rem 0.6rem; 1167 background: var(--bg-tertiary); 1168 + border-radius: var(--radius-sm); 1169 border: 1px solid var(--border-default); 1170 transition: all 0.15s; 1171 white-space: nowrap; ··· 1185 padding: 1rem 1.25rem; 1186 background: var(--bg-tertiary); 1187 border: 1px solid var(--border-default); 1188 + border-radius: var(--radius-md); 1189 text-decoration: none; 1190 color: var(--text-primary); 1191 transition: all 0.15s; ··· 1208 width: 44px; 1209 height: 44px; 1210 background: color-mix(in srgb, var(--accent) 15%, transparent); 1211 + border-radius: var(--radius-md); 1212 color: var(--accent); 1213 flex-shrink: 0; 1214 } ··· 1221 .upload-card-title { 1222 display: block; 1223 font-weight: 600; 1224 + font-size: var(--text-base); 1225 color: var(--text-primary); 1226 } 1227 1228 .upload-card-subtitle { 1229 display: block; 1230 + font-size: var(--text-sm); 1231 color: var(--text-tertiary); 1232 } 1233 ··· 1245 form { 1246 background: var(--bg-tertiary); 1247 padding: 1.25rem; 1248 + border-radius: var(--radius-md); 1249 border: 1px solid var(--border-subtle); 1250 } 1251 ··· 1261 display: block; 1262 color: var(--text-secondary); 1263 margin-bottom: 0.4rem; 1264 + font-size: var(--text-sm); 1265 } 1266 1267 + input[type='text'], 1268 + input[type='url'], 1269 + textarea { 1270 width: 100%; 1271 padding: 0.6rem 0.75rem; 1272 background: var(--bg-primary); 1273 border: 1px solid var(--border-default); 1274 + border-radius: var(--radius-sm); 1275 color: var(--text-primary); 1276 + font-size: var(--text-base); 1277 font-family: inherit; 1278 transition: all 0.15s; 1279 } 1280 1281 + input[type='text']:focus, 1282 + input[type='url']:focus, 1283 + textarea:focus { 1284 outline: none; 1285 border-color: var(--accent); 1286 } 1287 1288 + input[type='text']:disabled, 1289 + input[type='url']:disabled, 1290 + textarea:disabled { 1291 opacity: 0.5; 1292 cursor: not-allowed; 1293 } 1294 1295 textarea { 1296 resize: vertical; 1297 min-height: 80px; 1298 } 1299 1300 .hint { 1301 margin-top: 0.35rem; 1302 + font-size: var(--text-xs); 1303 color: var(--text-muted); 1304 } 1305 ··· 1317 display: block; 1318 color: var(--text-secondary); 1319 margin-bottom: 0.6rem; 1320 + font-size: var(--text-sm); 1321 } 1322 1323 .support-options { ··· 1334 padding: 0.6rem 0.75rem; 1335 background: var(--bg-primary); 1336 border: 1px solid var(--border-default); 1337 + border-radius: var(--radius-base); 1338 cursor: pointer; 1339 transition: all 0.15s; 1340 margin-bottom: 0; ··· 1357 } 1358 1359 .support-option span { 1360 + font-size: var(--text-base); 1361 color: var(--text-primary); 1362 } 1363 1364 .support-status { 1365 margin-left: auto; 1366 + font-size: var(--text-xs); 1367 color: var(--text-tertiary); 1368 } 1369 1370 .support-setup-link, 1371 .support-status-link { 1372 margin-left: auto; 1373 + font-size: var(--text-xs); 1374 text-decoration: none; 1375 } 1376 ··· 1401 padding: 0.6rem 0.75rem; 1402 background: var(--bg-primary); 1403 border: 1px solid var(--border-default); 1404 + border-radius: var(--radius-sm); 1405 color: var(--text-primary); 1406 + font-size: var(--text-base); 1407 font-family: inherit; 1408 transition: all 0.15s; 1409 margin-bottom: 0.5rem; ··· 1429 .avatar-preview img { 1430 width: 64px; 1431 height: 64px; 1432 + border-radius: var(--radius-full); 1433 object-fit: cover; 1434 border: 2px solid var(--border-default); 1435 } ··· 1439 padding: 0.75rem; 1440 background: var(--bg-primary); 1441 border: 1px solid var(--border-default); 1442 + border-radius: var(--radius-sm); 1443 color: var(--text-primary); 1444 + font-size: var(--text-base); 1445 font-family: inherit; 1446 cursor: pointer; 1447 } ··· 1453 1454 .file-info { 1455 margin-top: 0.5rem; 1456 + font-size: var(--text-sm); 1457 color: var(--text-muted); 1458 } 1459 ··· 1463 background: var(--accent); 1464 color: var(--text-primary); 1465 border: none; 1466 + border-radius: var(--radius-sm); 1467 + font-size: var(--text-lg); 1468 font-weight: 600; 1469 font-family: inherit; 1470 cursor: pointer; ··· 1501 padding: 2rem; 1502 text-align: center; 1503 background: var(--bg-tertiary); 1504 + border-radius: var(--radius-md); 1505 border: 1px solid var(--border-subtle); 1506 } 1507 ··· 1518 gap: 1rem; 1519 background: var(--bg-tertiary); 1520 border: 1px solid var(--border-subtle); 1521 + border-radius: var(--radius-base); 1522 padding: 1rem; 1523 transition: all 0.2s; 1524 } ··· 1553 .track-artwork { 1554 width: 48px; 1555 height: 48px; 1556 + border-radius: var(--radius-sm); 1557 overflow: hidden; 1558 background: var(--bg-primary); 1559 border: 1px solid var(--border-subtle); ··· 1575 } 1576 1577 .track-view-link { 1578 + font-size: var(--text-xs); 1579 color: var(--text-muted); 1580 text-decoration: none; 1581 transition: color 0.15s; ··· 1622 } 1623 1624 .edit-label { 1625 + font-size: var(--text-sm); 1626 color: var(--text-secondary); 1627 } 1628 1629 + .toggle-row { 1630 + display: flex; 1631 + align-items: center; 1632 + gap: 0.5rem; 1633 + cursor: pointer; 1634 + font-size: var(--text-base); 1635 + color: var(--text-primary); 1636 + } 1637 + 1638 + .toggle-row input[type="checkbox"] { 1639 + width: 16px; 1640 + height: 16px; 1641 + accent-color: var(--accent); 1642 + } 1643 + 1644 + .field-hint { 1645 + font-size: var(--text-sm); 1646 + color: var(--text-tertiary); 1647 + margin-top: 0.25rem; 1648 + } 1649 + 1650 + .field-hint a { 1651 + color: var(--accent); 1652 + text-decoration: none; 1653 + } 1654 + 1655 + .field-hint a:hover { 1656 + text-decoration: underline; 1657 + } 1658 + 1659 .track-title { 1660 font-weight: 600; 1661 + font-size: var(--text-lg); 1662 margin-bottom: 0.25rem; 1663 color: var(--text-primary); 1664 display: flex; ··· 1666 gap: 0.5rem; 1667 } 1668 1669 + .support-gate-badge { 1670 + display: inline-flex; 1671 + align-items: center; 1672 + color: var(--accent); 1673 + flex-shrink: 0; 1674 + } 1675 + 1676 .copyright-flag { 1677 display: inline-flex; 1678 align-items: center; ··· 1694 } 1695 1696 .track-meta { 1697 + font-size: var(--text-base); 1698 color: var(--text-secondary); 1699 margin-bottom: 0.25rem; 1700 display: flex; ··· 1762 padding: 0.1rem 0.4rem; 1763 background: color-mix(in srgb, var(--accent) 15%, transparent); 1764 color: var(--accent-hover); 1765 + border-radius: var(--radius-sm); 1766 + font-size: var(--text-sm); 1767 font-weight: 500; 1768 text-decoration: none; 1769 transition: all 0.15s; ··· 1775 } 1776 1777 .track-date { 1778 + font-size: var(--text-sm); 1779 color: var(--text-muted); 1780 } 1781 ··· 1796 padding: 0; 1797 background: transparent; 1798 border: 1px solid var(--border-default); 1799 + border-radius: var(--radius-base); 1800 color: var(--text-tertiary); 1801 cursor: pointer; 1802 transition: all 0.15s; ··· 1841 padding: 0.5rem; 1842 background: var(--bg-primary); 1843 border: 1px solid var(--border-default); 1844 + border-radius: var(--radius-sm); 1845 color: var(--text-primary); 1846 + font-size: var(--text-base); 1847 font-family: inherit; 1848 } 1849 ··· 1854 padding: 0.5rem; 1855 background: var(--bg-primary); 1856 border: 1px solid var(--border-default); 1857 + border-radius: var(--radius-sm); 1858 margin-bottom: 0.5rem; 1859 } 1860 1861 .current-image-preview img { 1862 width: 48px; 1863 height: 48px; 1864 + border-radius: var(--radius-sm); 1865 object-fit: cover; 1866 } 1867 1868 .current-image-label { 1869 color: var(--text-tertiary); 1870 + font-size: var(--text-sm); 1871 } 1872 1873 .edit-input:focus { ··· 1899 .album-card { 1900 background: var(--bg-tertiary); 1901 border: 1px solid var(--border-subtle); 1902 + border-radius: var(--radius-md); 1903 padding: 1rem; 1904 transition: all 0.2s; 1905 display: flex; ··· 1917 .album-cover { 1918 width: 100%; 1919 aspect-ratio: 1; 1920 + border-radius: var(--radius-base); 1921 object-fit: cover; 1922 } 1923 1924 .album-cover-placeholder { 1925 width: 100%; 1926 aspect-ratio: 1; 1927 + border-radius: var(--radius-base); 1928 background: linear-gradient(135deg, rgba(var(--accent-rgb, 139, 92, 246), 0.15), rgba(var(--accent-rgb, 139, 92, 246), 0.05)); 1929 display: flex; 1930 align-items: center; ··· 1938 } 1939 1940 .album-title { 1941 + font-size: var(--text-lg); 1942 font-weight: 600; 1943 color: var(--text-primary); 1944 margin: 0 0 0.25rem 0; ··· 1948 } 1949 1950 .album-stats { 1951 + font-size: var(--text-sm); 1952 color: var(--text-tertiary); 1953 margin: 0; 1954 } ··· 1966 .view-playlists-link { 1967 color: var(--text-secondary); 1968 text-decoration: none; 1969 + font-size: var(--text-sm); 1970 padding: 0.35rem 0.6rem; 1971 background: var(--bg-tertiary); 1972 + border-radius: var(--radius-sm); 1973 border: 1px solid var(--border-default); 1974 transition: all 0.15s; 1975 white-space: nowrap; ··· 1990 .playlist-card { 1991 background: var(--bg-tertiary); 1992 border: 1px solid var(--border-subtle); 1993 + border-radius: var(--radius-md); 1994 padding: 1rem; 1995 transition: all 0.2s; 1996 display: flex; ··· 2008 .playlist-cover { 2009 width: 100%; 2010 aspect-ratio: 1; 2011 + border-radius: var(--radius-base); 2012 object-fit: cover; 2013 } 2014 2015 .playlist-cover-placeholder { 2016 width: 100%; 2017 aspect-ratio: 1; 2018 + border-radius: var(--radius-base); 2019 background: linear-gradient(135deg, rgba(var(--accent-rgb, 139, 92, 246), 0.15), rgba(var(--accent-rgb, 139, 92, 246), 0.05)); 2020 display: flex; 2021 align-items: center; ··· 2029 } 2030 2031 .playlist-title { 2032 + font-size: var(--text-lg); 2033 font-weight: 600; 2034 color: var(--text-primary); 2035 margin: 0 0 0.25rem 0; ··· 2039 } 2040 2041 .playlist-stats { 2042 + font-size: var(--text-sm); 2043 color: var(--text-tertiary); 2044 margin: 0; 2045 } ··· 2058 padding: 1rem 1.25rem; 2059 background: var(--bg-tertiary); 2060 border: 1px solid var(--border-subtle); 2061 + border-radius: var(--radius-md); 2062 display: flex; 2063 justify-content: space-between; 2064 align-items: center; ··· 2076 } 2077 2078 .control-info h3 { 2079 + font-size: var(--text-base); 2080 font-weight: 600; 2081 margin: 0 0 0.15rem 0; 2082 color: var(--text-primary); 2083 } 2084 2085 .control-description { 2086 + font-size: var(--text-xs); 2087 color: var(--text-tertiary); 2088 margin: 0; 2089 line-height: 1.4; ··· 2094 background: var(--accent); 2095 color: var(--text-primary); 2096 border: none; 2097 + border-radius: var(--radius-base); 2098 + font-size: var(--text-base); 2099 font-weight: 600; 2100 cursor: pointer; 2101 transition: all 0.2s; ··· 2139 background: transparent; 2140 color: var(--error); 2141 border: 1px solid var(--error); 2142 + border-radius: var(--radius-base); 2143 font-family: inherit; 2144 + font-size: var(--text-base); 2145 font-weight: 600; 2146 cursor: pointer; 2147 transition: all 0.2s; ··· 2157 padding: 1rem; 2158 background: var(--bg-primary); 2159 border: 1px solid var(--border-default); 2160 + border-radius: var(--radius-md); 2161 } 2162 2163 .delete-warning { 2164 margin: 0 0 1rem; 2165 color: var(--error); 2166 + font-size: var(--text-base); 2167 line-height: 1.5; 2168 } 2169 ··· 2171 margin-bottom: 1rem; 2172 padding: 0.75rem; 2173 background: var(--bg-tertiary); 2174 + border-radius: var(--radius-base); 2175 } 2176 2177 .atproto-option { 2178 display: flex; 2179 align-items: center; 2180 gap: 0.5rem; 2181 + font-size: var(--text-base); 2182 color: var(--text-primary); 2183 cursor: pointer; 2184 } ··· 2191 2192 .atproto-note { 2193 margin: 0.5rem 0 0; 2194 + font-size: var(--text-sm); 2195 color: var(--text-tertiary); 2196 } 2197 ··· 2208 margin: 0.5rem 0 0; 2209 padding: 0.5rem; 2210 background: color-mix(in srgb, var(--warning) 10%, transparent); 2211 + border-radius: var(--radius-sm); 2212 + font-size: var(--text-sm); 2213 color: var(--warning); 2214 } 2215 2216 .confirm-prompt { 2217 margin: 0 0 0.5rem; 2218 + font-size: var(--text-base); 2219 color: var(--text-secondary); 2220 } 2221 ··· 2224 padding: 0.6rem 0.75rem; 2225 background: var(--bg-tertiary); 2226 border: 1px solid var(--border-default); 2227 + border-radius: var(--radius-base); 2228 color: var(--text-primary); 2229 + font-size: var(--text-base); 2230 font-family: inherit; 2231 margin-bottom: 1rem; 2232 } ··· 2246 padding: 0.6rem; 2247 background: transparent; 2248 border: 1px solid var(--border-default); 2249 + border-radius: var(--radius-base); 2250 color: var(--text-secondary); 2251 font-family: inherit; 2252 + font-size: var(--text-base); 2253 cursor: pointer; 2254 transition: all 0.15s; 2255 } ··· 2268 padding: 0.6rem; 2269 background: var(--error); 2270 border: none; 2271 + border-radius: var(--radius-base); 2272 color: white; 2273 font-family: inherit; 2274 + font-size: var(--text-base); 2275 font-weight: 600; 2276 cursor: pointer; 2277 transition: all 0.15s; ··· 2297 } 2298 2299 .portal-header h2 { 2300 + font-size: var(--text-2xl); 2301 } 2302 2303 .profile-section h2, ··· 2305 .albums-section h2, 2306 .playlists-section h2, 2307 .data-section h2 { 2308 + font-size: var(--text-xl); 2309 } 2310 2311 .section-header { ··· 2313 } 2314 2315 .view-profile-link { 2316 + font-size: var(--text-xs); 2317 padding: 0.3rem 0.5rem; 2318 } 2319 ··· 2326 } 2327 2328 label { 2329 + font-size: var(--text-sm); 2330 margin-bottom: 0.3rem; 2331 } 2332 ··· 2334 input[type='url'], 2335 textarea { 2336 padding: 0.5rem 0.6rem; 2337 + font-size: var(--text-base); 2338 } 2339 2340 textarea { ··· 2342 } 2343 2344 .hint { 2345 + font-size: var(--text-xs); 2346 } 2347 2348 .avatar-preview img { ··· 2352 2353 button[type="submit"] { 2354 padding: 0.6rem; 2355 + font-size: var(--text-base); 2356 } 2357 2358 /* upload card mobile */ ··· 2372 } 2373 2374 .upload-card-title { 2375 + font-size: var(--text-base); 2376 } 2377 2378 .upload-card-subtitle { 2379 + font-size: var(--text-xs); 2380 } 2381 2382 /* tracks mobile */ ··· 2410 } 2411 2412 .track-title { 2413 + font-size: var(--text-base); 2414 } 2415 2416 .track-meta { 2417 + font-size: var(--text-sm); 2418 } 2419 2420 .track-date { 2421 + font-size: var(--text-xs); 2422 } 2423 2424 .track-actions { ··· 2446 } 2447 2448 .edit-label { 2449 + font-size: var(--text-sm); 2450 } 2451 2452 .edit-input { 2453 padding: 0.45rem 0.5rem; 2454 + font-size: var(--text-sm); 2455 } 2456 2457 .edit-actions { ··· 2465 } 2466 2467 .control-info h3 { 2468 + font-size: var(--text-sm); 2469 } 2470 2471 .control-description { 2472 + font-size: var(--text-xs); 2473 } 2474 2475 .export-btn { 2476 padding: 0.5rem 0.85rem; 2477 + font-size: var(--text-sm); 2478 } 2479 2480 /* albums mobile */ ··· 2489 } 2490 2491 .album-title { 2492 + font-size: var(--text-sm); 2493 } 2494 2495 /* playlists mobile */ ··· 2504 } 2505 2506 .playlist-title { 2507 + font-size: var(--text-sm); 2508 } 2509 2510 .playlist-stats { 2511 + font-size: var(--text-xs); 2512 } 2513 2514 .view-playlists-link { 2515 + font-size: var(--text-xs); 2516 padding: 0.3rem 0.5rem; 2517 } 2518 }
+8 -8
frontend/src/routes/profile/setup/+page.svelte
··· 222 223 .error { 224 padding: 1rem; 225 - border-radius: 4px; 226 margin-bottom: 1.5rem; 227 background: color-mix(in srgb, var(--error) 10%, transparent); 228 border: 1px solid color-mix(in srgb, var(--error) 30%, transparent); ··· 232 form { 233 background: var(--bg-tertiary); 234 padding: 2rem; 235 - border-radius: 8px; 236 border: 1px solid var(--border-subtle); 237 } 238 ··· 248 display: block; 249 color: var(--text-secondary); 250 margin-bottom: 0.5rem; 251 - font-size: 0.9rem; 252 font-weight: 500; 253 } 254 ··· 259 padding: 0.75rem; 260 background: var(--bg-primary); 261 border: 1px solid var(--border-default); 262 - border-radius: 4px; 263 color: var(--text-primary); 264 - font-size: 1rem; 265 font-family: inherit; 266 transition: all 0.2s; 267 } ··· 287 288 .hint { 289 margin-top: 0.5rem; 290 - font-size: 0.85rem; 291 color: var(--text-muted); 292 } 293 ··· 297 background: var(--accent); 298 color: white; 299 border: none; 300 - border-radius: 4px; 301 - font-size: 1rem; 302 font-weight: 600; 303 cursor: pointer; 304 transition: all 0.2s;
··· 222 223 .error { 224 padding: 1rem; 225 + border-radius: var(--radius-sm); 226 margin-bottom: 1.5rem; 227 background: color-mix(in srgb, var(--error) 10%, transparent); 228 border: 1px solid color-mix(in srgb, var(--error) 30%, transparent); ··· 232 form { 233 background: var(--bg-tertiary); 234 padding: 2rem; 235 + border-radius: var(--radius-md); 236 border: 1px solid var(--border-subtle); 237 } 238 ··· 248 display: block; 249 color: var(--text-secondary); 250 margin-bottom: 0.5rem; 251 + font-size: var(--text-base); 252 font-weight: 500; 253 } 254 ··· 259 padding: 0.75rem; 260 background: var(--bg-primary); 261 border: 1px solid var(--border-default); 262 + border-radius: var(--radius-sm); 263 color: var(--text-primary); 264 + font-size: var(--text-lg); 265 font-family: inherit; 266 transition: all 0.2s; 267 } ··· 287 288 .hint { 289 margin-top: 0.5rem; 290 + font-size: var(--text-sm); 291 color: var(--text-muted); 292 } 293 ··· 297 background: var(--accent); 298 color: white; 299 border: none; 300 + border-radius: var(--radius-sm); 301 + font-size: var(--text-lg); 302 font-weight: 600; 303 cursor: pointer; 304 transition: all 0.2s;
+46 -46
frontend/src/routes/settings/+page.svelte
··· 774 .token-overlay-content { 775 background: var(--bg-secondary); 776 border: 1px solid var(--border-default); 777 - border-radius: 16px; 778 padding: 2rem; 779 max-width: 500px; 780 width: 100%; ··· 788 789 .token-overlay-content h2 { 790 margin: 0 0 0.75rem; 791 - font-size: 1.5rem; 792 color: var(--text-primary); 793 } 794 795 .token-overlay-warning { 796 color: var(--warning); 797 - font-size: 0.9rem; 798 margin: 0 0 1.5rem; 799 line-height: 1.5; 800 } ··· 804 gap: 0.5rem; 805 background: var(--bg-primary); 806 border: 1px solid var(--border-default); 807 - border-radius: 8px; 808 padding: 1rem; 809 margin-bottom: 1rem; 810 } 811 812 .token-overlay-display code { 813 flex: 1; 814 - font-size: 0.85rem; 815 word-break: break-all; 816 color: var(--accent); 817 text-align: left; ··· 822 padding: 0.5rem 1rem; 823 background: var(--accent); 824 border: none; 825 - border-radius: 6px; 826 color: var(--text-primary); 827 font-family: inherit; 828 - font-size: 0.85rem; 829 font-weight: 600; 830 cursor: pointer; 831 white-space: nowrap; ··· 837 } 838 839 .token-overlay-hint { 840 - font-size: 0.8rem; 841 color: var(--text-tertiary); 842 margin: 0 0 1.5rem; 843 } ··· 855 padding: 0.75rem 2rem; 856 background: var(--bg-tertiary); 857 border: 1px solid var(--border-default); 858 - border-radius: 8px; 859 color: var(--text-secondary); 860 font-family: inherit; 861 - font-size: 0.9rem; 862 cursor: pointer; 863 transition: all 0.15s; 864 } ··· 901 .portal-link { 902 color: var(--text-secondary); 903 text-decoration: none; 904 - font-size: 0.85rem; 905 padding: 0.4rem 0.75rem; 906 background: var(--bg-tertiary); 907 - border-radius: 6px; 908 border: 1px solid var(--border-default); 909 transition: all 0.15s; 910 } ··· 919 } 920 921 .settings-section h2 { 922 - font-size: 0.8rem; 923 text-transform: uppercase; 924 letter-spacing: 0.08em; 925 color: var(--text-tertiary); ··· 930 .settings-card { 931 background: var(--bg-tertiary); 932 border: 1px solid var(--border-subtle); 933 - border-radius: 10px; 934 padding: 1rem 1.25rem; 935 } 936 ··· 957 958 .setting-info h3 { 959 margin: 0 0 0.25rem; 960 - font-size: 0.95rem; 961 font-weight: 600; 962 color: var(--text-primary); 963 } 964 965 .setting-info p { 966 margin: 0; 967 - font-size: 0.8rem; 968 color: var(--text-tertiary); 969 line-height: 1.4; 970 } ··· 993 padding: 0.6rem 0.75rem; 994 background: var(--bg-primary); 995 border: 1px solid var(--border-default); 996 - border-radius: 8px; 997 color: var(--text-secondary); 998 cursor: pointer; 999 transition: all 0.15s; ··· 1034 width: 40px; 1035 height: 40px; 1036 border: 1px solid var(--border-default); 1037 - border-radius: 8px; 1038 cursor: pointer; 1039 background: transparent; 1040 } ··· 1044 } 1045 1046 .color-input::-webkit-color-swatch { 1047 - border-radius: 4px; 1048 border: none; 1049 } 1050 ··· 1056 .preset-btn { 1057 width: 32px; 1058 height: 32px; 1059 - border-radius: 6px; 1060 border: 2px solid transparent; 1061 cursor: pointer; 1062 transition: all 0.15s; ··· 1085 padding: 0.5rem 0.75rem; 1086 background: var(--bg-primary); 1087 border: 1px solid var(--border-default); 1088 - border-radius: 6px; 1089 color: var(--text-primary); 1090 - font-size: 0.85rem; 1091 font-family: inherit; 1092 } 1093 ··· 1104 display: flex; 1105 align-items: center; 1106 gap: 0.4rem; 1107 - font-size: 0.8rem; 1108 color: var(--text-secondary); 1109 cursor: pointer; 1110 } ··· 1132 width: 48px; 1133 height: 28px; 1134 background: var(--border-default); 1135 - border-radius: 999px; 1136 position: relative; 1137 cursor: pointer; 1138 transition: background 0.2s; ··· 1145 left: 4px; 1146 width: 20px; 1147 height: 20px; 1148 - border-radius: 50%; 1149 background: var(--text-secondary); 1150 transition: transform 0.2s, background 0.2s; 1151 } ··· 1167 padding: 0.75rem; 1168 background: color-mix(in srgb, var(--warning) 10%, transparent); 1169 border: 1px solid color-mix(in srgb, var(--warning) 30%, transparent); 1170 - border-radius: 6px; 1171 margin-top: 0.75rem; 1172 - font-size: 0.8rem; 1173 color: var(--warning); 1174 } 1175 ··· 1191 /* developer tokens */ 1192 .loading-tokens { 1193 color: var(--text-tertiary); 1194 - font-size: 0.85rem; 1195 } 1196 1197 .existing-tokens { ··· 1199 } 1200 1201 .tokens-header { 1202 - font-size: 0.75rem; 1203 text-transform: uppercase; 1204 letter-spacing: 0.05em; 1205 color: var(--text-tertiary); ··· 1220 padding: 0.75rem; 1221 background: var(--bg-primary); 1222 border: 1px solid var(--border-default); 1223 - border-radius: 6px; 1224 } 1225 1226 .token-info { ··· 1233 .token-name { 1234 font-weight: 500; 1235 color: var(--text-primary); 1236 - font-size: 0.9rem; 1237 } 1238 1239 .token-meta { 1240 - font-size: 0.75rem; 1241 color: var(--text-tertiary); 1242 } 1243 ··· 1245 padding: 0.4rem 0.75rem; 1246 background: transparent; 1247 border: 1px solid var(--border-emphasis); 1248 - border-radius: 4px; 1249 color: var(--text-secondary); 1250 font-family: inherit; 1251 - font-size: 0.8rem; 1252 cursor: pointer; 1253 transition: all 0.15s; 1254 white-space: nowrap; ··· 1272 padding: 0.75rem; 1273 background: var(--bg-primary); 1274 border: 1px solid var(--border-default); 1275 - border-radius: 6px; 1276 } 1277 1278 .token-value { 1279 flex: 1; 1280 - font-size: 0.8rem; 1281 word-break: break-all; 1282 color: var(--accent); 1283 } ··· 1287 padding: 0.4rem 0.6rem; 1288 background: var(--bg-tertiary); 1289 border: 1px solid var(--border-default); 1290 - border-radius: 4px; 1291 color: var(--text-secondary); 1292 font-family: inherit; 1293 - font-size: 0.8rem; 1294 cursor: pointer; 1295 transition: all 0.15s; 1296 } ··· 1303 1304 .token-warning { 1305 margin-top: 0.5rem; 1306 - font-size: 0.8rem; 1307 color: var(--warning); 1308 } 1309 ··· 1320 padding: 0.6rem 0.75rem; 1321 background: var(--bg-primary); 1322 border: 1px solid var(--border-default); 1323 - border-radius: 6px; 1324 color: var(--text-primary); 1325 - font-size: 0.9rem; 1326 font-family: inherit; 1327 } 1328 ··· 1335 display: flex; 1336 align-items: center; 1337 gap: 0.5rem; 1338 - font-size: 0.85rem; 1339 color: var(--text-secondary); 1340 } 1341 ··· 1343 padding: 0.5rem 0.75rem; 1344 background: var(--bg-primary); 1345 border: 1px solid var(--border-default); 1346 - border-radius: 6px; 1347 color: var(--text-primary); 1348 - font-size: 0.85rem; 1349 font-family: inherit; 1350 cursor: pointer; 1351 } ··· 1359 padding: 0.6rem 1rem; 1360 background: var(--accent); 1361 border: none; 1362 - border-radius: 6px; 1363 color: var(--text-primary); 1364 font-family: inherit; 1365 - font-size: 0.9rem; 1366 font-weight: 600; 1367 cursor: pointer; 1368 transition: all 0.15s;
··· 774 .token-overlay-content { 775 background: var(--bg-secondary); 776 border: 1px solid var(--border-default); 777 + border-radius: var(--radius-xl); 778 padding: 2rem; 779 max-width: 500px; 780 width: 100%; ··· 788 789 .token-overlay-content h2 { 790 margin: 0 0 0.75rem; 791 + font-size: var(--text-3xl); 792 color: var(--text-primary); 793 } 794 795 .token-overlay-warning { 796 color: var(--warning); 797 + font-size: var(--text-base); 798 margin: 0 0 1.5rem; 799 line-height: 1.5; 800 } ··· 804 gap: 0.5rem; 805 background: var(--bg-primary); 806 border: 1px solid var(--border-default); 807 + border-radius: var(--radius-md); 808 padding: 1rem; 809 margin-bottom: 1rem; 810 } 811 812 .token-overlay-display code { 813 flex: 1; 814 + font-size: var(--text-sm); 815 word-break: break-all; 816 color: var(--accent); 817 text-align: left; ··· 822 padding: 0.5rem 1rem; 823 background: var(--accent); 824 border: none; 825 + border-radius: var(--radius-base); 826 color: var(--text-primary); 827 font-family: inherit; 828 + font-size: var(--text-sm); 829 font-weight: 600; 830 cursor: pointer; 831 white-space: nowrap; ··· 837 } 838 839 .token-overlay-hint { 840 + font-size: var(--text-sm); 841 color: var(--text-tertiary); 842 margin: 0 0 1.5rem; 843 } ··· 855 padding: 0.75rem 2rem; 856 background: var(--bg-tertiary); 857 border: 1px solid var(--border-default); 858 + border-radius: var(--radius-md); 859 color: var(--text-secondary); 860 font-family: inherit; 861 + font-size: var(--text-base); 862 cursor: pointer; 863 transition: all 0.15s; 864 } ··· 901 .portal-link { 902 color: var(--text-secondary); 903 text-decoration: none; 904 + font-size: var(--text-sm); 905 padding: 0.4rem 0.75rem; 906 background: var(--bg-tertiary); 907 + border-radius: var(--radius-base); 908 border: 1px solid var(--border-default); 909 transition: all 0.15s; 910 } ··· 919 } 920 921 .settings-section h2 { 922 + font-size: var(--text-sm); 923 text-transform: uppercase; 924 letter-spacing: 0.08em; 925 color: var(--text-tertiary); ··· 930 .settings-card { 931 background: var(--bg-tertiary); 932 border: 1px solid var(--border-subtle); 933 + border-radius: var(--radius-md); 934 padding: 1rem 1.25rem; 935 } 936 ··· 957 958 .setting-info h3 { 959 margin: 0 0 0.25rem; 960 + font-size: var(--text-base); 961 font-weight: 600; 962 color: var(--text-primary); 963 } 964 965 .setting-info p { 966 margin: 0; 967 + font-size: var(--text-sm); 968 color: var(--text-tertiary); 969 line-height: 1.4; 970 } ··· 993 padding: 0.6rem 0.75rem; 994 background: var(--bg-primary); 995 border: 1px solid var(--border-default); 996 + border-radius: var(--radius-md); 997 color: var(--text-secondary); 998 cursor: pointer; 999 transition: all 0.15s; ··· 1034 width: 40px; 1035 height: 40px; 1036 border: 1px solid var(--border-default); 1037 + border-radius: var(--radius-md); 1038 cursor: pointer; 1039 background: transparent; 1040 } ··· 1044 } 1045 1046 .color-input::-webkit-color-swatch { 1047 + border-radius: var(--radius-sm); 1048 border: none; 1049 } 1050 ··· 1056 .preset-btn { 1057 width: 32px; 1058 height: 32px; 1059 + border-radius: var(--radius-base); 1060 border: 2px solid transparent; 1061 cursor: pointer; 1062 transition: all 0.15s; ··· 1085 padding: 0.5rem 0.75rem; 1086 background: var(--bg-primary); 1087 border: 1px solid var(--border-default); 1088 + border-radius: var(--radius-base); 1089 color: var(--text-primary); 1090 + font-size: var(--text-sm); 1091 font-family: inherit; 1092 } 1093 ··· 1104 display: flex; 1105 align-items: center; 1106 gap: 0.4rem; 1107 + font-size: var(--text-sm); 1108 color: var(--text-secondary); 1109 cursor: pointer; 1110 } ··· 1132 width: 48px; 1133 height: 28px; 1134 background: var(--border-default); 1135 + border-radius: var(--radius-full); 1136 position: relative; 1137 cursor: pointer; 1138 transition: background 0.2s; ··· 1145 left: 4px; 1146 width: 20px; 1147 height: 20px; 1148 + border-radius: var(--radius-full); 1149 background: var(--text-secondary); 1150 transition: transform 0.2s, background 0.2s; 1151 } ··· 1167 padding: 0.75rem; 1168 background: color-mix(in srgb, var(--warning) 10%, transparent); 1169 border: 1px solid color-mix(in srgb, var(--warning) 30%, transparent); 1170 + border-radius: var(--radius-base); 1171 margin-top: 0.75rem; 1172 + font-size: var(--text-sm); 1173 color: var(--warning); 1174 } 1175 ··· 1191 /* developer tokens */ 1192 .loading-tokens { 1193 color: var(--text-tertiary); 1194 + font-size: var(--text-sm); 1195 } 1196 1197 .existing-tokens { ··· 1199 } 1200 1201 .tokens-header { 1202 + font-size: var(--text-xs); 1203 text-transform: uppercase; 1204 letter-spacing: 0.05em; 1205 color: var(--text-tertiary); ··· 1220 padding: 0.75rem; 1221 background: var(--bg-primary); 1222 border: 1px solid var(--border-default); 1223 + border-radius: var(--radius-base); 1224 } 1225 1226 .token-info { ··· 1233 .token-name { 1234 font-weight: 500; 1235 color: var(--text-primary); 1236 + font-size: var(--text-base); 1237 } 1238 1239 .token-meta { 1240 + font-size: var(--text-xs); 1241 color: var(--text-tertiary); 1242 } 1243 ··· 1245 padding: 0.4rem 0.75rem; 1246 background: transparent; 1247 border: 1px solid var(--border-emphasis); 1248 + border-radius: var(--radius-sm); 1249 color: var(--text-secondary); 1250 font-family: inherit; 1251 + font-size: var(--text-sm); 1252 cursor: pointer; 1253 transition: all 0.15s; 1254 white-space: nowrap; ··· 1272 padding: 0.75rem; 1273 background: var(--bg-primary); 1274 border: 1px solid var(--border-default); 1275 + border-radius: var(--radius-base); 1276 } 1277 1278 .token-value { 1279 flex: 1; 1280 + font-size: var(--text-sm); 1281 word-break: break-all; 1282 color: var(--accent); 1283 } ··· 1287 padding: 0.4rem 0.6rem; 1288 background: var(--bg-tertiary); 1289 border: 1px solid var(--border-default); 1290 + border-radius: var(--radius-sm); 1291 color: var(--text-secondary); 1292 font-family: inherit; 1293 + font-size: var(--text-sm); 1294 cursor: pointer; 1295 transition: all 0.15s; 1296 } ··· 1303 1304 .token-warning { 1305 margin-top: 0.5rem; 1306 + font-size: var(--text-sm); 1307 color: var(--warning); 1308 } 1309 ··· 1320 padding: 0.6rem 0.75rem; 1321 background: var(--bg-primary); 1322 border: 1px solid var(--border-default); 1323 + border-radius: var(--radius-base); 1324 color: var(--text-primary); 1325 + font-size: var(--text-base); 1326 font-family: inherit; 1327 } 1328 ··· 1335 display: flex; 1336 align-items: center; 1337 gap: 0.5rem; 1338 + font-size: var(--text-sm); 1339 color: var(--text-secondary); 1340 } 1341 ··· 1343 padding: 0.5rem 0.75rem; 1344 background: var(--bg-primary); 1345 border: 1px solid var(--border-default); 1346 + border-radius: var(--radius-base); 1347 color: var(--text-primary); 1348 + font-size: var(--text-sm); 1349 font-family: inherit; 1350 cursor: pointer; 1351 } ··· 1359 padding: 0.6rem 1rem; 1360 background: var(--accent); 1361 border: none; 1362 + border-radius: var(--radius-base); 1363 color: var(--text-primary); 1364 font-family: inherit; 1365 + font-size: var(--text-base); 1366 font-weight: 600; 1367 cursor: pointer; 1368 transition: all 0.15s;
+10 -10
frontend/src/routes/tag/[name]/+page.svelte
··· 197 } 198 199 .subtitle { 200 - font-size: 0.95rem; 201 color: var(--text-tertiary); 202 margin: 0; 203 text-shadow: var(--text-shadow, none); ··· 211 background: var(--glass-btn-bg, transparent); 212 border: 1px solid var(--glass-btn-border, var(--accent)); 213 color: var(--accent); 214 - border-radius: 6px; 215 - font-size: 0.9rem; 216 font-family: inherit; 217 cursor: pointer; 218 transition: all 0.2s; ··· 240 } 241 242 .empty-state h2 { 243 - font-size: 1.5rem; 244 font-weight: 600; 245 color: var(--text-secondary); 246 margin: 0 0 0.5rem 0; 247 } 248 249 .empty-state p { 250 - font-size: 0.95rem; 251 margin: 0; 252 } 253 ··· 264 text-align: center; 265 padding: 4rem 1rem; 266 color: var(--text-tertiary); 267 - font-size: 0.95rem; 268 } 269 270 .tracks-list { ··· 292 } 293 294 .empty-state h2 { 295 - font-size: 1.25rem; 296 } 297 298 .btn-queue-all { 299 padding: 0.5rem 0.75rem; 300 - font-size: 0.85rem; 301 } 302 303 .btn-queue-all svg { ··· 330 } 331 332 .subtitle { 333 - font-size: 0.85rem; 334 } 335 336 .btn-queue-all { 337 padding: 0.45rem 0.65rem; 338 - font-size: 0.8rem; 339 } 340 341 .btn-queue-all svg {
··· 197 } 198 199 .subtitle { 200 + font-size: var(--text-base); 201 color: var(--text-tertiary); 202 margin: 0; 203 text-shadow: var(--text-shadow, none); ··· 211 background: var(--glass-btn-bg, transparent); 212 border: 1px solid var(--glass-btn-border, var(--accent)); 213 color: var(--accent); 214 + border-radius: var(--radius-base); 215 + font-size: var(--text-base); 216 font-family: inherit; 217 cursor: pointer; 218 transition: all 0.2s; ··· 240 } 241 242 .empty-state h2 { 243 + font-size: var(--text-3xl); 244 font-weight: 600; 245 color: var(--text-secondary); 246 margin: 0 0 0.5rem 0; 247 } 248 249 .empty-state p { 250 + font-size: var(--text-base); 251 margin: 0; 252 } 253 ··· 264 text-align: center; 265 padding: 4rem 1rem; 266 color: var(--text-tertiary); 267 + font-size: var(--text-base); 268 } 269 270 .tracks-list { ··· 292 } 293 294 .empty-state h2 { 295 + font-size: var(--text-2xl); 296 } 297 298 .btn-queue-all { 299 padding: 0.5rem 0.75rem; 300 + font-size: var(--text-sm); 301 } 302 303 .btn-queue-all svg { ··· 330 } 331 332 .subtitle { 333 + font-size: var(--text-sm); 334 } 335 336 .btn-queue-all { 337 padding: 0.45rem 0.65rem; 338 + font-size: var(--text-sm); 339 } 340 341 .btn-queue-all svg {
+108 -51
frontend/src/routes/track/[id]/+page.svelte
··· 12 import { checkImageSensitive } from '$lib/moderation.svelte'; 13 import { player } from '$lib/player.svelte'; 14 import { queue } from '$lib/queue.svelte'; 15 import { auth } from '$lib/auth.svelte'; 16 import { toast } from '$lib/toast.svelte'; 17 import type { Track } from '$lib/types'; ··· 103 window.location.href = '/'; 104 } 105 106 - function handlePlay() { 107 if (player.currentTrack?.id === track.id) { 108 // this track is already loaded - just toggle play/pause 109 player.togglePlayPause(); 110 } else { 111 // different track or no track - start this one 112 - queue.playNow(track); 113 } 114 } 115 116 function addToQueue() { 117 queue.addTracks([track]); 118 toast.success(`queued ${track.title}`, 1800); 119 } ··· 187 return `${minutes}:${seconds.toString().padStart(2, '0')}`; 188 } 189 190 - function seekToTimestamp(ms: number) { 191 const doSeek = () => { 192 if (player.audioElement) { 193 player.audioElement.currentTime = ms / 1000; ··· 201 } 202 203 // otherwise start playing and wait for audio to be ready 204 - queue.playNow(track); 205 if (player.audioElement && player.audioElement.readyState >= 1) { 206 doSeek(); 207 } else { ··· 288 289 // track which track we've loaded data for to detect navigation 290 let loadedForTrackId = $state<number | null>(null); 291 292 // reload data when navigating between track pages 293 // watch data.track.id (from server) not track.id (local state) ··· 304 newCommentText = ''; 305 editingCommentId = null; 306 editingCommentText = ''; 307 308 // sync track from server data 309 track = data.track; ··· 311 // mark as loaded for this track 312 loadedForTrackId = currentId; 313 314 - // load fresh data 315 - if (auth.isAuthenticated) { 316 - void loadLikedState(); 317 - } 318 void loadComments(); 319 } 320 }); 321 322 let shareUrl = $state(''); 323 324 $effect(() => { ··· 413 <!-- track info wrapper --> 414 <div class="track-info-wrapper"> 415 <div class="track-info"> 416 - <h1 class="track-title">{track.title}</h1> 417 <div class="track-metadata"> 418 <a href="/u/{track.artist_handle}" class="artist-link"> 419 {track.artist} ··· 465 trackTitle={track.title} 466 trackUri={track.atproto_record_uri} 467 trackCid={track.atproto_record_cid} 468 initialLiked={track.is_liked || false} 469 shareUrl={shareUrl} 470 onQueue={addToQueue} ··· 657 width: 100%; 658 max-width: 300px; 659 aspect-ratio: 1; 660 - border-radius: 8px; 661 overflow: hidden; 662 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); 663 } ··· 703 margin: 0; 704 line-height: 1.2; 705 text-align: center; 706 } 707 708 .track-metadata { ··· 712 gap: 0.75rem; 713 flex-wrap: wrap; 714 color: var(--text-secondary); 715 - font-size: 1.1rem; 716 } 717 718 .separator { 719 color: var(--text-muted); 720 - font-size: 0.8rem; 721 } 722 723 .artist-link { ··· 798 799 .track-stats { 800 color: var(--text-tertiary); 801 - font-size: 0.95rem; 802 display: flex; 803 align-items: center; 804 gap: 0.5rem; ··· 806 } 807 808 .track-stats .separator { 809 - font-size: 0.7rem; 810 } 811 812 .track-tags { ··· 821 padding: 0.25rem 0.6rem; 822 background: color-mix(in srgb, var(--accent) 15%, transparent); 823 color: var(--accent-hover); 824 - border-radius: 4px; 825 - font-size: 0.85rem; 826 font-weight: 500; 827 text-decoration: none; 828 transition: all 0.15s; ··· 857 background: var(--accent); 858 color: var(--bg-primary); 859 border: none; 860 - border-radius: 24px; 861 - font-size: 0.95rem; 862 font-weight: 600; 863 font-family: inherit; 864 cursor: pointer; ··· 871 } 872 873 .btn-play.playing { 874 - opacity: 0.8; 875 } 876 877 .btn-queue { ··· 882 background: transparent; 883 color: var(--text-primary); 884 border: 1px solid var(--border-emphasis); 885 - border-radius: 24px; 886 - font-size: 0.95rem; 887 font-weight: 500; 888 font-family: inherit; 889 cursor: pointer; ··· 927 } 928 929 .track-title { 930 - font-size: 1.5rem; 931 } 932 933 .track-metadata { 934 - font-size: 0.9rem; 935 gap: 0.5rem; 936 } 937 938 .track-stats { 939 - font-size: 0.85rem; 940 } 941 942 .track-actions { ··· 953 min-width: calc(50% - 0.25rem); 954 justify-content: center; 955 padding: 0.6rem 1rem; 956 - font-size: 0.9rem; 957 } 958 959 .btn-play svg { ··· 966 min-width: calc(50% - 0.25rem); 967 justify-content: center; 968 padding: 0.6rem 1rem; 969 - font-size: 0.9rem; 970 } 971 972 .btn-queue svg { ··· 985 } 986 987 .comments-title { 988 - font-size: 1rem; 989 font-weight: 600; 990 color: var(--text-primary); 991 margin: 0 0 0.75rem 0; ··· 1010 padding: 0.6rem 0.8rem; 1011 background: var(--bg-tertiary); 1012 border: 1px solid var(--border-default); 1013 - border-radius: 6px; 1014 color: var(--text-primary); 1015 - font-size: 0.9rem; 1016 font-family: inherit; 1017 } 1018 ··· 1030 background: var(--accent); 1031 color: var(--bg-primary); 1032 border: none; 1033 - border-radius: 6px; 1034 - font-size: 0.9rem; 1035 font-weight: 600; 1036 font-family: inherit; 1037 cursor: pointer; ··· 1049 1050 .login-prompt { 1051 color: var(--text-tertiary); 1052 - font-size: 0.9rem; 1053 margin-bottom: 1rem; 1054 } 1055 ··· 1064 1065 .no-comments { 1066 color: var(--text-muted); 1067 - font-size: 0.9rem; 1068 text-align: center; 1069 padding: 1rem; 1070 } ··· 1085 1086 .comments-list::-webkit-scrollbar-track { 1087 background: var(--bg-primary); 1088 - border-radius: 4px; 1089 } 1090 1091 .comments-list::-webkit-scrollbar-thumb { 1092 background: var(--border-default); 1093 - border-radius: 4px; 1094 } 1095 1096 .comments-list::-webkit-scrollbar-thumb:hover { ··· 1103 gap: 0.6rem; 1104 padding: 0.5rem 0.6rem; 1105 background: var(--bg-tertiary); 1106 - border-radius: 6px; 1107 transition: background 0.15s; 1108 } 1109 ··· 1112 } 1113 1114 .comment-timestamp { 1115 - font-size: 0.8rem; 1116 font-weight: 600; 1117 color: var(--accent); 1118 background: color-mix(in srgb, var(--accent) 10%, transparent); 1119 padding: 0.2rem 0.5rem; 1120 - border-radius: 4px; 1121 white-space: nowrap; 1122 height: fit-content; 1123 border: none; ··· 1150 } 1151 1152 .comment-time { 1153 - font-size: 0.75rem; 1154 color: var(--text-muted); 1155 } 1156 1157 .comment-avatar { 1158 width: 20px; 1159 height: 20px; 1160 - border-radius: 50%; 1161 object-fit: cover; 1162 } 1163 1164 .comment-avatar-placeholder { 1165 width: 20px; 1166 height: 20px; 1167 - border-radius: 50%; 1168 background: var(--border-default); 1169 } 1170 1171 .comment-author { 1172 - font-size: 0.85rem; 1173 font-weight: 500; 1174 color: var(--text-secondary); 1175 text-decoration: none; ··· 1180 } 1181 1182 .comment-text { 1183 - font-size: 0.9rem; 1184 color: var(--text-primary); 1185 margin: 0; 1186 line-height: 1.4; ··· 1220 border: none; 1221 padding: 0; 1222 color: var(--text-muted); 1223 - font-size: 0.8rem; 1224 cursor: pointer; 1225 transition: color 0.15s; 1226 font-family: inherit; ··· 1253 padding: 0.5rem; 1254 background: var(--bg-primary); 1255 border: 1px solid var(--border-default); 1256 - border-radius: 4px; 1257 color: var(--text-primary); 1258 - font-size: 0.9rem; 1259 font-family: inherit; 1260 } 1261 ··· 1272 1273 .edit-form-btn { 1274 padding: 0.25rem 0.6rem; 1275 - font-size: 0.8rem; 1276 font-family: inherit; 1277 - border-radius: 4px; 1278 cursor: pointer; 1279 transition: all 0.15s; 1280 } ··· 1324 ); 1325 background-size: 200% 100%; 1326 animation: shimmer 1.5s ease-in-out infinite; 1327 - border-radius: 4px; 1328 } 1329 1330 .comment-timestamp-skeleton { ··· 1336 .comment-avatar-skeleton { 1337 width: 20px; 1338 height: 20px; 1339 - border-radius: 50%; 1340 } 1341 1342 .comment-author-skeleton { ··· 1386 } 1387 1388 .comment-timestamp { 1389 - font-size: 0.75rem; 1390 padding: 0.15rem 0.4rem; 1391 } 1392 }
··· 12 import { checkImageSensitive } from '$lib/moderation.svelte'; 13 import { player } from '$lib/player.svelte'; 14 import { queue } from '$lib/queue.svelte'; 15 + import { playTrack, guardGatedTrack } from '$lib/playback.svelte'; 16 import { auth } from '$lib/auth.svelte'; 17 import { toast } from '$lib/toast.svelte'; 18 import type { Track } from '$lib/types'; ··· 104 window.location.href = '/'; 105 } 106 107 + async function handlePlay() { 108 if (player.currentTrack?.id === track.id) { 109 // this track is already loaded - just toggle play/pause 110 player.togglePlayPause(); 111 } else { 112 // different track or no track - start this one 113 + // use playTrack for gated content checks 114 + if (track.gated) { 115 + await playTrack(track); 116 + } else { 117 + queue.playNow(track); 118 + } 119 } 120 } 121 122 function addToQueue() { 123 + if (!guardGatedTrack(track, auth.isAuthenticated)) return; 124 queue.addTracks([track]); 125 toast.success(`queued ${track.title}`, 1800); 126 } ··· 194 return `${minutes}:${seconds.toString().padStart(2, '0')}`; 195 } 196 197 + async function seekToTimestamp(ms: number) { 198 const doSeek = () => { 199 if (player.audioElement) { 200 player.audioElement.currentTime = ms / 1000; ··· 208 } 209 210 // otherwise start playing and wait for audio to be ready 211 + // use playTrack for gated content checks 212 + let played = false; 213 + if (track.gated) { 214 + played = await playTrack(track); 215 + } else { 216 + queue.playNow(track); 217 + played = true; 218 + } 219 + 220 + if (!played) return; // gated - can't seek 221 + 222 if (player.audioElement && player.audioElement.readyState >= 1) { 223 doSeek(); 224 } else { ··· 305 306 // track which track we've loaded data for to detect navigation 307 let loadedForTrackId = $state<number | null>(null); 308 + // track if we've loaded liked state for this track (separate from general load) 309 + let likedStateLoadedForTrackId = $state<number | null>(null); 310 311 // reload data when navigating between track pages 312 // watch data.track.id (from server) not track.id (local state) ··· 323 newCommentText = ''; 324 editingCommentId = null; 325 editingCommentText = ''; 326 + likedStateLoadedForTrackId = null; // reset liked state tracking 327 328 // sync track from server data 329 track = data.track; ··· 331 // mark as loaded for this track 332 loadedForTrackId = currentId; 333 334 + // load comments (doesn't require auth) 335 void loadComments(); 336 } 337 }); 338 339 + // separate effect to load liked state when auth becomes available 340 + $effect(() => { 341 + const currentId = data.track?.id; 342 + if (!currentId || !browser) return; 343 + 344 + // load liked state when authenticated and haven't loaded for this track yet 345 + if (auth.isAuthenticated && likedStateLoadedForTrackId !== currentId) { 346 + likedStateLoadedForTrackId = currentId; 347 + void loadLikedState(); 348 + } 349 + }); 350 + 351 let shareUrl = $state(''); 352 353 $effect(() => { ··· 442 <!-- track info wrapper --> 443 <div class="track-info-wrapper"> 444 <div class="track-info"> 445 + <h1 class="track-title"> 446 + {track.title} 447 + {#if track.gated} 448 + <span class="gated-badge" title="supporters only"> 449 + <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"> 450 + <path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/> 451 + </svg> 452 + </span> 453 + {/if} 454 + </h1> 455 <div class="track-metadata"> 456 <a href="/u/{track.artist_handle}" class="artist-link"> 457 {track.artist} ··· 503 trackTitle={track.title} 504 trackUri={track.atproto_record_uri} 505 trackCid={track.atproto_record_cid} 506 + fileId={track.file_id} 507 + gated={track.gated} 508 initialLiked={track.is_liked || false} 509 shareUrl={shareUrl} 510 onQueue={addToQueue} ··· 697 width: 100%; 698 max-width: 300px; 699 aspect-ratio: 1; 700 + border-radius: var(--radius-md); 701 overflow: hidden; 702 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); 703 } ··· 743 margin: 0; 744 line-height: 1.2; 745 text-align: center; 746 + display: inline-flex; 747 + align-items: center; 748 + justify-content: center; 749 + gap: 0.5rem; 750 + flex-wrap: wrap; 751 + } 752 + 753 + .gated-badge { 754 + display: inline-flex; 755 + align-items: center; 756 + justify-content: center; 757 + color: var(--accent); 758 + opacity: 0.8; 759 + } 760 + 761 + .gated-badge svg { 762 + display: block; 763 } 764 765 .track-metadata { ··· 769 gap: 0.75rem; 770 flex-wrap: wrap; 771 color: var(--text-secondary); 772 + font-size: var(--text-xl); 773 } 774 775 .separator { 776 color: var(--text-muted); 777 + font-size: var(--text-sm); 778 } 779 780 .artist-link { ··· 855 856 .track-stats { 857 color: var(--text-tertiary); 858 + font-size: var(--text-base); 859 display: flex; 860 align-items: center; 861 gap: 0.5rem; ··· 863 } 864 865 .track-stats .separator { 866 + font-size: var(--text-xs); 867 } 868 869 .track-tags { ··· 878 padding: 0.25rem 0.6rem; 879 background: color-mix(in srgb, var(--accent) 15%, transparent); 880 color: var(--accent-hover); 881 + border-radius: var(--radius-sm); 882 + font-size: var(--text-sm); 883 font-weight: 500; 884 text-decoration: none; 885 transition: all 0.15s; ··· 914 background: var(--accent); 915 color: var(--bg-primary); 916 border: none; 917 + border-radius: var(--radius-2xl); 918 + font-size: var(--text-base); 919 font-weight: 600; 920 font-family: inherit; 921 cursor: pointer; ··· 928 } 929 930 .btn-play.playing { 931 + animation: ethereal-glow 3s ease-in-out infinite; 932 } 933 934 .btn-queue { ··· 939 background: transparent; 940 color: var(--text-primary); 941 border: 1px solid var(--border-emphasis); 942 + border-radius: var(--radius-2xl); 943 + font-size: var(--text-base); 944 font-weight: 500; 945 font-family: inherit; 946 cursor: pointer; ··· 984 } 985 986 .track-title { 987 + font-size: var(--text-3xl); 988 } 989 990 .track-metadata { 991 + font-size: var(--text-base); 992 gap: 0.5rem; 993 } 994 995 .track-stats { 996 + font-size: var(--text-sm); 997 } 998 999 .track-actions { ··· 1010 min-width: calc(50% - 0.25rem); 1011 justify-content: center; 1012 padding: 0.6rem 1rem; 1013 + font-size: var(--text-base); 1014 } 1015 1016 .btn-play svg { ··· 1023 min-width: calc(50% - 0.25rem); 1024 justify-content: center; 1025 padding: 0.6rem 1rem; 1026 + font-size: var(--text-base); 1027 } 1028 1029 .btn-queue svg { ··· 1042 } 1043 1044 .comments-title { 1045 + font-size: var(--text-lg); 1046 font-weight: 600; 1047 color: var(--text-primary); 1048 margin: 0 0 0.75rem 0; ··· 1067 padding: 0.6rem 0.8rem; 1068 background: var(--bg-tertiary); 1069 border: 1px solid var(--border-default); 1070 + border-radius: var(--radius-base); 1071 color: var(--text-primary); 1072 + font-size: var(--text-base); 1073 font-family: inherit; 1074 } 1075 ··· 1087 background: var(--accent); 1088 color: var(--bg-primary); 1089 border: none; 1090 + border-radius: var(--radius-base); 1091 + font-size: var(--text-base); 1092 font-weight: 600; 1093 font-family: inherit; 1094 cursor: pointer; ··· 1106 1107 .login-prompt { 1108 color: var(--text-tertiary); 1109 + font-size: var(--text-base); 1110 margin-bottom: 1rem; 1111 } 1112 ··· 1121 1122 .no-comments { 1123 color: var(--text-muted); 1124 + font-size: var(--text-base); 1125 text-align: center; 1126 padding: 1rem; 1127 } ··· 1142 1143 .comments-list::-webkit-scrollbar-track { 1144 background: var(--bg-primary); 1145 + border-radius: var(--radius-sm); 1146 } 1147 1148 .comments-list::-webkit-scrollbar-thumb { 1149 background: var(--border-default); 1150 + border-radius: var(--radius-sm); 1151 } 1152 1153 .comments-list::-webkit-scrollbar-thumb:hover { ··· 1160 gap: 0.6rem; 1161 padding: 0.5rem 0.6rem; 1162 background: var(--bg-tertiary); 1163 + border-radius: var(--radius-base); 1164 transition: background 0.15s; 1165 } 1166 ··· 1169 } 1170 1171 .comment-timestamp { 1172 + font-size: var(--text-sm); 1173 font-weight: 600; 1174 color: var(--accent); 1175 background: color-mix(in srgb, var(--accent) 10%, transparent); 1176 padding: 0.2rem 0.5rem; 1177 + border-radius: var(--radius-sm); 1178 white-space: nowrap; 1179 height: fit-content; 1180 border: none; ··· 1207 } 1208 1209 .comment-time { 1210 + font-size: var(--text-xs); 1211 color: var(--text-muted); 1212 } 1213 1214 .comment-avatar { 1215 width: 20px; 1216 height: 20px; 1217 + border-radius: var(--radius-full); 1218 object-fit: cover; 1219 } 1220 1221 .comment-avatar-placeholder { 1222 width: 20px; 1223 height: 20px; 1224 + border-radius: var(--radius-full); 1225 background: var(--border-default); 1226 } 1227 1228 .comment-author { 1229 + font-size: var(--text-sm); 1230 font-weight: 500; 1231 color: var(--text-secondary); 1232 text-decoration: none; ··· 1237 } 1238 1239 .comment-text { 1240 + font-size: var(--text-base); 1241 color: var(--text-primary); 1242 margin: 0; 1243 line-height: 1.4; ··· 1277 border: none; 1278 padding: 0; 1279 color: var(--text-muted); 1280 + font-size: var(--text-sm); 1281 cursor: pointer; 1282 transition: color 0.15s; 1283 font-family: inherit; ··· 1310 padding: 0.5rem; 1311 background: var(--bg-primary); 1312 border: 1px solid var(--border-default); 1313 + border-radius: var(--radius-sm); 1314 color: var(--text-primary); 1315 + font-size: var(--text-base); 1316 font-family: inherit; 1317 } 1318 ··· 1329 1330 .edit-form-btn { 1331 padding: 0.25rem 0.6rem; 1332 + font-size: var(--text-sm); 1333 font-family: inherit; 1334 + border-radius: var(--radius-sm); 1335 cursor: pointer; 1336 transition: all 0.15s; 1337 } ··· 1381 ); 1382 background-size: 200% 100%; 1383 animation: shimmer 1.5s ease-in-out infinite; 1384 + border-radius: var(--radius-sm); 1385 } 1386 1387 .comment-timestamp-skeleton { ··· 1393 .comment-avatar-skeleton { 1394 width: 20px; 1395 height: 20px; 1396 + border-radius: var(--radius-full); 1397 } 1398 1399 .comment-author-skeleton { ··· 1443 } 1444 1445 .comment-timestamp { 1446 + font-size: var(--text-xs); 1447 padding: 0.15rem 0.4rem; 1448 } 1449 }
+5 -5
frontend/src/routes/u/[handle]/+error.svelte
··· 96 } 97 98 .error-message { 99 - font-size: 1.25rem; 100 color: var(--text-secondary); 101 margin: 0 0 0.5rem 0; 102 } 103 104 .error-detail { 105 - font-size: 1rem; 106 color: var(--text-tertiary); 107 margin: 0 0 2rem 0; 108 } ··· 118 .bsky-link { 119 color: var(--accent); 120 text-decoration: none; 121 - font-size: 1.1rem; 122 padding: 0.75rem 1.5rem; 123 border: 1px solid var(--accent); 124 - border-radius: 6px; 125 transition: all 0.2s; 126 display: inline-block; 127 } ··· 151 } 152 153 .error-message { 154 - font-size: 1.1rem; 155 } 156 157 .actions {
··· 96 } 97 98 .error-message { 99 + font-size: var(--text-2xl); 100 color: var(--text-secondary); 101 margin: 0 0 0.5rem 0; 102 } 103 104 .error-detail { 105 + font-size: var(--text-lg); 106 color: var(--text-tertiary); 107 margin: 0 0 2rem 0; 108 } ··· 118 .bsky-link { 119 color: var(--accent); 120 text-decoration: none; 121 + font-size: var(--text-xl); 122 padding: 0.75rem 1.5rem; 123 border: 1px solid var(--accent); 124 + border-radius: var(--radius-base); 125 transition: all 0.2s; 126 display: inline-block; 127 } ··· 151 } 152 153 .error-message { 154 + font-size: var(--text-xl); 155 } 156 157 .actions {
+95 -36
frontend/src/routes/u/[handle]/+page.svelte
··· 1 <script lang="ts"> 2 import { fade } from 'svelte/transition'; 3 - import { API_URL } from '$lib/config'; 4 import { browser } from '$app/environment'; 5 import type { Analytics, Track, Playlist } from '$lib/types'; 6 import { formatDuration } from '$lib/stats.svelte'; ··· 8 import ShareButton from '$lib/components/ShareButton.svelte'; 9 import Header from '$lib/components/Header.svelte'; 10 import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 11 import { checkImageSensitive } from '$lib/moderation.svelte'; 12 import { player } from '$lib/player.svelte'; 13 import { queue } from '$lib/queue.svelte'; ··· 15 import { fetchLikedTracks, fetchUserLikes } from '$lib/tracks.svelte'; 16 import { APP_NAME, APP_CANONICAL_URL } from '$lib/branding'; 17 import type { PageData } from './$types'; 18 19 // receive server-loaded data 20 let { data }: { data: PageData } = $props(); ··· 38 const supportUrl = $derived(() => { 39 if (!artist?.support_url) return null; 40 if (artist.support_url === 'atprotofans') { 41 - return `https://atprotofans.com/u/${artist.did}`; 42 } 43 return artist.support_url; 44 }); ··· 66 67 // public playlists for collections section 68 let publicPlaylists = $state<Playlist[]>([]); 69 70 // track which artist we've loaded data for to detect navigation 71 let loadedForDid = $state<string | null>(null); ··· 130 } 131 } 132 133 // reload data when navigating between artist pages 134 // watch data.artist?.did (from server) not artist?.did (local derived) 135 $effect(() => { ··· 143 tracksHydrated = false; 144 likedTracksCount = null; 145 publicPlaylists = []; 146 147 // sync tracks and pagination from server data 148 tracks = data.tracks ?? []; ··· 158 void hydrateTracksWithLikes(); 159 void loadLikedTracksCount(); 160 void loadPublicPlaylists(); 161 } 162 }); 163 ··· 310 <div class="artist-details"> 311 <div class="artist-info"> 312 <h1>{artist.display_name}</h1> 313 - <a href="https://bsky.app/profile/{artist.handle}" target="_blank" rel="noopener" class="handle"> 314 - @{artist.handle} 315 - </a> 316 {#if artist.bio} 317 <p class="bio">{artist.bio}</p> 318 {/if} ··· 564 padding: 2rem; 565 background: var(--bg-secondary); 566 border: 1px solid var(--border-subtle); 567 - border-radius: 8px; 568 } 569 570 .artist-details { ··· 602 padding: 0 0.75rem; 603 background: color-mix(in srgb, var(--accent) 15%, transparent); 604 border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); 605 - border-radius: 4px; 606 color: var(--accent); 607 - font-size: 0.85rem; 608 text-decoration: none; 609 transition: all 0.2s ease; 610 } ··· 622 .artist-avatar { 623 width: 120px; 624 height: 120px; 625 - border-radius: 50%; 626 object-fit: cover; 627 border: 3px solid var(--border-default); 628 } ··· 634 word-wrap: break-word; 635 overflow-wrap: break-word; 636 hyphens: auto; 637 } 638 639 .handle { 640 color: var(--text-tertiary); 641 - font-size: 1.1rem; 642 - margin: 0 0 1rem 0; 643 text-decoration: none; 644 transition: color 0.2s; 645 - display: inline-block; 646 } 647 648 .handle:hover { ··· 683 684 .section-header span { 685 color: var(--text-tertiary); 686 - font-size: 0.9rem; 687 text-transform: uppercase; 688 letter-spacing: 0.1em; 689 } ··· 701 padding: 1rem; 702 background: var(--bg-secondary); 703 border: 1px solid var(--border-subtle); 704 - border-radius: 10px; 705 color: inherit; 706 text-decoration: none; 707 transition: transform 0.15s ease, border-color 0.15s ease; ··· 717 .album-cover-wrapper { 718 width: 72px; 719 height: 72px; 720 - border-radius: 6px; 721 overflow: hidden; 722 flex-shrink: 0; 723 background: var(--bg-tertiary); ··· 765 .album-card-meta p { 766 margin: 0; 767 color: var(--text-tertiary); 768 - font-size: 0.9rem; 769 display: flex; 770 align-items: center; 771 gap: 0.4rem; ··· 779 .stat-card { 780 background: var(--bg-secondary); 781 border: 1px solid var(--border-subtle); 782 - border-radius: 8px; 783 padding: 1.5rem; 784 transition: border-color 0.2s; 785 } ··· 798 799 .stat-label { 800 color: var(--text-tertiary); 801 - font-size: 0.9rem; 802 text-transform: lowercase; 803 line-height: 1; 804 } 805 806 .stat-duration { 807 margin-top: 0.5rem; 808 - font-size: 0.85rem; 809 color: var(--text-secondary); 810 font-variant-numeric: tabular-nums; 811 } ··· 833 834 .top-item-plays { 835 color: var(--accent); 836 - font-size: 1rem; 837 line-height: 1; 838 } 839 ··· 853 ); 854 background-size: 200% 100%; 855 animation: shimmer 1.5s ease-in-out infinite; 856 - border-radius: 4px; 857 } 858 859 /* match .stat-value dimensions: 2.5rem font + 0.5rem margin-bottom */ ··· 905 padding: 0.75rem 1.5rem; 906 background: var(--bg-secondary); 907 border: 1px solid var(--border-subtle); 908 - border-radius: 8px; 909 color: var(--text-secondary); 910 font-family: inherit; 911 - font-size: 0.95rem; 912 cursor: pointer; 913 transition: all 0.2s ease; 914 } ··· 926 927 .tracks-loading { 928 margin-left: 0.75rem; 929 - font-size: 0.95rem; 930 color: var(--text-secondary); 931 font-weight: 400; 932 text-transform: lowercase; ··· 943 padding: 3rem; 944 background: var(--bg-secondary); 945 border: 1px solid var(--border-subtle); 946 - border-radius: 8px; 947 } 948 949 .empty-message { 950 color: var(--text-secondary); 951 - font-size: 1.25rem; 952 margin: 0 0 0.5rem 0; 953 } 954 ··· 960 .bsky-link { 961 color: var(--accent); 962 text-decoration: none; 963 - font-size: 1rem; 964 padding: 0.75rem 1.5rem; 965 border: 1px solid var(--accent); 966 - border-radius: 6px; 967 transition: all 0.2s; 968 display: inline-block; 969 } ··· 1001 1002 .artist-info { 1003 text-align: center; 1004 } 1005 1006 .artist-actions-desktop { ··· 1013 1014 .support-btn { 1015 height: 28px; 1016 - font-size: 0.8rem; 1017 padding: 0 0.6rem; 1018 } 1019 ··· 1053 .album-cover-wrapper { 1054 width: 56px; 1055 height: 56px; 1056 - border-radius: 4px; 1057 } 1058 1059 .album-card-meta h3 { 1060 - font-size: 0.95rem; 1061 margin-bottom: 0.25rem; 1062 } 1063 1064 .album-card-meta p { 1065 - font-size: 0.8rem; 1066 } 1067 } 1068 ··· 1089 padding: 1.25rem 1.5rem; 1090 background: var(--bg-secondary); 1091 border: 1px solid var(--border-subtle); 1092 - border-radius: 10px; 1093 color: inherit; 1094 text-decoration: none; 1095 transition: transform 0.15s ease, border-color 0.15s ease; ··· 1103 .collection-icon { 1104 width: 48px; 1105 height: 48px; 1106 - border-radius: 8px; 1107 display: flex; 1108 align-items: center; 1109 justify-content: center; ··· 1134 1135 .collection-info h3 { 1136 margin: 0 0 0.25rem 0; 1137 - font-size: 1.1rem; 1138 color: var(--text-primary); 1139 } 1140 1141 .collection-info p { 1142 margin: 0; 1143 - font-size: 0.9rem; 1144 color: var(--text-tertiary); 1145 } 1146
··· 1 <script lang="ts"> 2 import { fade } from 'svelte/transition'; 3 + import { API_URL, getAtprotofansSupportUrl } from '$lib/config'; 4 import { browser } from '$app/environment'; 5 import type { Analytics, Track, Playlist } from '$lib/types'; 6 import { formatDuration } from '$lib/stats.svelte'; ··· 8 import ShareButton from '$lib/components/ShareButton.svelte'; 9 import Header from '$lib/components/Header.svelte'; 10 import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 11 + import SupporterBadge from '$lib/components/SupporterBadge.svelte'; 12 import { checkImageSensitive } from '$lib/moderation.svelte'; 13 import { player } from '$lib/player.svelte'; 14 import { queue } from '$lib/queue.svelte'; ··· 16 import { fetchLikedTracks, fetchUserLikes } from '$lib/tracks.svelte'; 17 import { APP_NAME, APP_CANONICAL_URL } from '$lib/branding'; 18 import type { PageData } from './$types'; 19 + 20 21 // receive server-loaded data 22 let { data }: { data: PageData } = $props(); ··· 40 const supportUrl = $derived(() => { 41 if (!artist?.support_url) return null; 42 if (artist.support_url === 'atprotofans') { 43 + return getAtprotofansSupportUrl(artist.did); 44 } 45 return artist.support_url; 46 }); ··· 68 69 // public playlists for collections section 70 let publicPlaylists = $state<Playlist[]>([]); 71 + 72 + // supporter status - true if logged-in viewer supports this artist via atprotofans 73 + let isSupporter = $state(false); 74 75 // track which artist we've loaded data for to detect navigation 76 let loadedForDid = $state<string | null>(null); ··· 135 } 136 } 137 138 + /** 139 + * check if the logged-in viewer supports this artist via atprotofans. 140 + * only called when: 141 + * 1. viewer is authenticated 142 + * 2. artist has atprotofans support enabled 143 + * 3. viewer is not the artist themselves 144 + */ 145 + async function checkSupporterStatus() { 146 + // reset state 147 + isSupporter = false; 148 + 149 + // only check if viewer is logged in 150 + if (!auth.isAuthenticated || !auth.user?.did) return; 151 + 152 + // only check if artist has atprotofans enabled 153 + if (artist?.support_url !== 'atprotofans') return; 154 + 155 + // don't show badge on your own profile 156 + if (auth.user.did === artist.did) return; 157 + 158 + try { 159 + const url = new URL('https://atprotofans.com/xrpc/com.atprotofans.validateSupporter'); 160 + url.searchParams.set('supporter', auth.user.did); 161 + url.searchParams.set('subject', artist.did); 162 + url.searchParams.set('signer', artist.did); 163 + 164 + const response = await fetch(url.toString()); 165 + if (response.ok) { 166 + const data = await response.json(); 167 + isSupporter = data.valid === true; 168 + } 169 + } catch (_e) { 170 + // silently fail - supporter badge is optional enhancement 171 + console.error('failed to check supporter status:', _e); 172 + } 173 + } 174 + 175 // reload data when navigating between artist pages 176 // watch data.artist?.did (from server) not artist?.did (local derived) 177 $effect(() => { ··· 185 tracksHydrated = false; 186 likedTracksCount = null; 187 publicPlaylists = []; 188 + isSupporter = false; 189 190 // sync tracks and pagination from server data 191 tracks = data.tracks ?? []; ··· 201 void hydrateTracksWithLikes(); 202 void loadLikedTracksCount(); 203 void loadPublicPlaylists(); 204 + void checkSupporterStatus(); 205 } 206 }); 207 ··· 354 <div class="artist-details"> 355 <div class="artist-info"> 356 <h1>{artist.display_name}</h1> 357 + <div class="handle-row"> 358 + <a href="https://bsky.app/profile/{artist.handle}" target="_blank" rel="noopener" class="handle"> 359 + @{artist.handle} 360 + </a> 361 + {#if isSupporter} 362 + <SupporterBadge /> 363 + {/if} 364 + </div> 365 {#if artist.bio} 366 <p class="bio">{artist.bio}</p> 367 {/if} ··· 613 padding: 2rem; 614 background: var(--bg-secondary); 615 border: 1px solid var(--border-subtle); 616 + border-radius: var(--radius-md); 617 } 618 619 .artist-details { ··· 651 padding: 0 0.75rem; 652 background: color-mix(in srgb, var(--accent) 15%, transparent); 653 border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); 654 + border-radius: var(--radius-sm); 655 color: var(--accent); 656 + font-size: var(--text-sm); 657 text-decoration: none; 658 transition: all 0.2s ease; 659 } ··· 671 .artist-avatar { 672 width: 120px; 673 height: 120px; 674 + border-radius: var(--radius-full); 675 object-fit: cover; 676 border: 3px solid var(--border-default); 677 } ··· 683 word-wrap: break-word; 684 overflow-wrap: break-word; 685 hyphens: auto; 686 + } 687 + 688 + .handle-row { 689 + display: flex; 690 + align-items: center; 691 + gap: 0.75rem; 692 + flex-wrap: wrap; 693 + margin-bottom: 1rem; 694 } 695 696 .handle { 697 color: var(--text-tertiary); 698 + font-size: var(--text-xl); 699 text-decoration: none; 700 transition: color 0.2s; 701 } 702 703 .handle:hover { ··· 738 739 .section-header span { 740 color: var(--text-tertiary); 741 + font-size: var(--text-base); 742 text-transform: uppercase; 743 letter-spacing: 0.1em; 744 } ··· 756 padding: 1rem; 757 background: var(--bg-secondary); 758 border: 1px solid var(--border-subtle); 759 + border-radius: var(--radius-md); 760 color: inherit; 761 text-decoration: none; 762 transition: transform 0.15s ease, border-color 0.15s ease; ··· 772 .album-cover-wrapper { 773 width: 72px; 774 height: 72px; 775 + border-radius: var(--radius-base); 776 overflow: hidden; 777 flex-shrink: 0; 778 background: var(--bg-tertiary); ··· 820 .album-card-meta p { 821 margin: 0; 822 color: var(--text-tertiary); 823 + font-size: var(--text-base); 824 display: flex; 825 align-items: center; 826 gap: 0.4rem; ··· 834 .stat-card { 835 background: var(--bg-secondary); 836 border: 1px solid var(--border-subtle); 837 + border-radius: var(--radius-md); 838 padding: 1.5rem; 839 transition: border-color 0.2s; 840 } ··· 853 854 .stat-label { 855 color: var(--text-tertiary); 856 + font-size: var(--text-base); 857 text-transform: lowercase; 858 line-height: 1; 859 } 860 861 .stat-duration { 862 margin-top: 0.5rem; 863 + font-size: var(--text-sm); 864 color: var(--text-secondary); 865 font-variant-numeric: tabular-nums; 866 } ··· 888 889 .top-item-plays { 890 color: var(--accent); 891 + font-size: var(--text-lg); 892 line-height: 1; 893 } 894 ··· 908 ); 909 background-size: 200% 100%; 910 animation: shimmer 1.5s ease-in-out infinite; 911 + border-radius: var(--radius-sm); 912 } 913 914 /* match .stat-value dimensions: 2.5rem font + 0.5rem margin-bottom */ ··· 960 padding: 0.75rem 1.5rem; 961 background: var(--bg-secondary); 962 border: 1px solid var(--border-subtle); 963 + border-radius: var(--radius-md); 964 color: var(--text-secondary); 965 font-family: inherit; 966 + font-size: var(--text-base); 967 cursor: pointer; 968 transition: all 0.2s ease; 969 } ··· 981 982 .tracks-loading { 983 margin-left: 0.75rem; 984 + font-size: var(--text-base); 985 color: var(--text-secondary); 986 font-weight: 400; 987 text-transform: lowercase; ··· 998 padding: 3rem; 999 background: var(--bg-secondary); 1000 border: 1px solid var(--border-subtle); 1001 + border-radius: var(--radius-md); 1002 } 1003 1004 .empty-message { 1005 color: var(--text-secondary); 1006 + font-size: var(--text-2xl); 1007 margin: 0 0 0.5rem 0; 1008 } 1009 ··· 1015 .bsky-link { 1016 color: var(--accent); 1017 text-decoration: none; 1018 + font-size: var(--text-lg); 1019 padding: 0.75rem 1.5rem; 1020 border: 1px solid var(--accent); 1021 + border-radius: var(--radius-base); 1022 transition: all 0.2s; 1023 display: inline-block; 1024 } ··· 1056 1057 .artist-info { 1058 text-align: center; 1059 + } 1060 + 1061 + .handle-row { 1062 + justify-content: center; 1063 } 1064 1065 .artist-actions-desktop { ··· 1072 1073 .support-btn { 1074 height: 28px; 1075 + font-size: var(--text-sm); 1076 padding: 0 0.6rem; 1077 } 1078 ··· 1112 .album-cover-wrapper { 1113 width: 56px; 1114 height: 56px; 1115 + border-radius: var(--radius-sm); 1116 } 1117 1118 .album-card-meta h3 { 1119 + font-size: var(--text-base); 1120 margin-bottom: 0.25rem; 1121 } 1122 1123 .album-card-meta p { 1124 + font-size: var(--text-sm); 1125 } 1126 } 1127 ··· 1148 padding: 1.25rem 1.5rem; 1149 background: var(--bg-secondary); 1150 border: 1px solid var(--border-subtle); 1151 + border-radius: var(--radius-md); 1152 color: inherit; 1153 text-decoration: none; 1154 transition: transform 0.15s ease, border-color 0.15s ease; ··· 1162 .collection-icon { 1163 width: 48px; 1164 height: 48px; 1165 + border-radius: var(--radius-md); 1166 display: flex; 1167 align-items: center; 1168 justify-content: center; ··· 1193 1194 .collection-info h3 { 1195 margin: 0 0 0.25rem 0; 1196 + font-size: var(--text-xl); 1197 color: var(--text-primary); 1198 } 1199 1200 .collection-info p { 1201 margin: 0; 1202 + font-size: var(--text-base); 1203 color: var(--text-tertiary); 1204 } 1205
+58 -31
frontend/src/routes/u/[handle]/album/[slug]/+page.svelte
··· 7 import { checkImageSensitive } from '$lib/moderation.svelte'; 8 import { player } from '$lib/player.svelte'; 9 import { queue } from '$lib/queue.svelte'; 10 import { toast } from '$lib/toast.svelte'; 11 import { auth } from '$lib/auth.svelte'; 12 import { API_URL } from '$lib/config'; ··· 26 tracks = [...data.album.tracks]; 27 }); 28 29 // check if current user owns this album 30 const isOwner = $derived(auth.user?.did === albumMetadata.artist_did); 31 // can only reorder if owner and album has an ATProto list 32 const canReorder = $derived(isOwner && !!albumMetadata.list_uri); 33 34 - // local mutable copy of tracks for reordering 35 - let tracks = $state<Track[]>([...data.album.tracks]); 36 37 // edit mode state 38 let isEditMode = $state(false); ··· 70 queue.playNow(track); 71 } 72 73 - function playNow() { 74 if (tracks.length > 0) { 75 - queue.setQueue(tracks); 76 - queue.playNow(tracks[0]); 77 - toast.success(`playing ${albumMetadata.title}`, 1800); 78 } 79 } 80 ··· 542 </div> 543 544 <div class="album-actions"> 545 - <button class="play-button" onclick={playNow}> 546 - <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> 547 - <path d="M8 5v14l11-7z"/> 548 - </svg> 549 - play now 550 </button> 551 <button class="queue-button" onclick={addToQueue}> 552 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> ··· 763 .album-art { 764 width: 200px; 765 height: 200px; 766 - border-radius: 8px; 767 object-fit: cover; 768 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 769 } ··· 771 .album-art-placeholder { 772 width: 200px; 773 height: 200px; 774 - border-radius: 8px; 775 background: var(--bg-tertiary); 776 border: 1px solid var(--border-subtle); 777 display: flex; ··· 814 height: 32px; 815 background: transparent; 816 border: 1px solid var(--border-default); 817 - border-radius: 4px; 818 color: var(--text-tertiary); 819 cursor: pointer; 820 transition: all 0.15s; ··· 855 position: absolute; 856 inset: 0; 857 background: rgba(0, 0, 0, 0.6); 858 - border-radius: 8px; 859 display: flex; 860 flex-direction: column; 861 align-items: center; 862 justify-content: center; 863 gap: 0.5rem; 864 color: white; 865 - font-size: 0.85rem; 866 opacity: 0; 867 transition: opacity 0.2s; 868 } ··· 890 891 .album-type { 892 text-transform: uppercase; 893 - font-size: 0.75rem; 894 font-weight: 600; 895 letter-spacing: 0.1em; 896 color: var(--text-tertiary); ··· 914 display: flex; 915 align-items: center; 916 gap: 0.75rem; 917 - font-size: 0.95rem; 918 color: var(--text-secondary); 919 text-shadow: var(--text-shadow, none); 920 } ··· 933 934 .meta-separator { 935 color: var(--text-muted); 936 - font-size: 0.7rem; 937 } 938 939 .album-actions { ··· 945 .play-button, 946 .queue-button { 947 padding: 0.75rem 1.5rem; 948 - border-radius: 24px; 949 font-weight: 600; 950 - font-size: 0.95rem; 951 font-family: inherit; 952 cursor: pointer; 953 transition: all 0.2s; ··· 966 transform: scale(1.05); 967 } 968 969 .queue-button { 970 background: var(--glass-btn-bg, transparent); 971 color: var(--text-primary); ··· 997 } 998 999 .section-heading { 1000 - font-size: 1.25rem; 1001 font-weight: 600; 1002 color: var(--text-primary); 1003 margin-bottom: 1rem; ··· 1015 display: flex; 1016 align-items: center; 1017 gap: 0.5rem; 1018 - border-radius: 8px; 1019 transition: all 0.2s; 1020 position: relative; 1021 } ··· 1047 color: var(--text-muted); 1048 cursor: grab; 1049 touch-action: none; 1050 - border-radius: 4px; 1051 transition: all 0.2s; 1052 flex-shrink: 0; 1053 } ··· 1108 } 1109 1110 .album-meta { 1111 - font-size: 0.85rem; 1112 } 1113 1114 .album-actions { ··· 1140 } 1141 1142 .album-meta { 1143 - font-size: 0.8rem; 1144 flex-wrap: wrap; 1145 } 1146 } ··· 1152 justify-content: center; 1153 width: 32px; 1154 height: 32px; 1155 - border-radius: 50%; 1156 background: transparent; 1157 border: none; 1158 color: var(--text-muted); ··· 1185 1186 .modal { 1187 background: var(--bg-secondary); 1188 - border-radius: 12px; 1189 padding: 1.5rem; 1190 max-width: 400px; 1191 width: calc(100% - 2rem); ··· 1199 1200 .modal-header h3 { 1201 margin: 0; 1202 - font-size: 1.25rem; 1203 font-weight: 600; 1204 color: var(--text-primary); 1205 } ··· 1223 .cancel-btn, 1224 .confirm-btn { 1225 padding: 0.625rem 1.25rem; 1226 - border-radius: 8px; 1227 font-weight: 500; 1228 - font-size: 0.9rem; 1229 font-family: inherit; 1230 cursor: pointer; 1231 transition: all 0.2s;
··· 7 import { checkImageSensitive } from '$lib/moderation.svelte'; 8 import { player } from '$lib/player.svelte'; 9 import { queue } from '$lib/queue.svelte'; 10 + import { playQueue } from '$lib/playback.svelte'; 11 import { toast } from '$lib/toast.svelte'; 12 import { auth } from '$lib/auth.svelte'; 13 import { API_URL } from '$lib/config'; ··· 27 tracks = [...data.album.tracks]; 28 }); 29 30 + // local mutable copy of tracks for reordering 31 + let tracks = $state<Track[]>([...data.album.tracks]); 32 + 33 // check if current user owns this album 34 const isOwner = $derived(auth.user?.did === albumMetadata.artist_did); 35 // can only reorder if owner and album has an ATProto list 36 const canReorder = $derived(isOwner && !!albumMetadata.list_uri); 37 38 + // check if current track is from this album (active, regardless of paused state) 39 + const isAlbumActive = $derived( 40 + player.currentTrack !== null && 41 + tracks.some(t => t.id === player.currentTrack?.id) 42 + ); 43 + 44 + // check if actively playing (not paused) 45 + const isAlbumPlaying = $derived(isAlbumActive && !player.paused); 46 47 // edit mode state 48 let isEditMode = $state(false); ··· 80 queue.playNow(track); 81 } 82 83 + async function playNow() { 84 if (tracks.length > 0) { 85 + // use playQueue to check gated access on first track before modifying queue 86 + const played = await playQueue(tracks); 87 + if (played) { 88 + toast.success(`playing ${albumMetadata.title}`, 1800); 89 + } 90 } 91 } 92 ··· 554 </div> 555 556 <div class="album-actions"> 557 + <button 558 + class="play-button" 559 + class:is-playing={isAlbumPlaying} 560 + onclick={() => isAlbumActive ? player.togglePlayPause() : playNow()} 561 + > 562 + {#if isAlbumPlaying} 563 + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> 564 + <path d="M6 4h4v16H6zM14 4h4v16h-4z"/> 565 + </svg> 566 + pause 567 + {:else} 568 + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> 569 + <path d="M8 5v14l11-7z"/> 570 + </svg> 571 + play 572 + {/if} 573 </button> 574 <button class="queue-button" onclick={addToQueue}> 575 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> ··· 786 .album-art { 787 width: 200px; 788 height: 200px; 789 + border-radius: var(--radius-md); 790 object-fit: cover; 791 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 792 } ··· 794 .album-art-placeholder { 795 width: 200px; 796 height: 200px; 797 + border-radius: var(--radius-md); 798 background: var(--bg-tertiary); 799 border: 1px solid var(--border-subtle); 800 display: flex; ··· 837 height: 32px; 838 background: transparent; 839 border: 1px solid var(--border-default); 840 + border-radius: var(--radius-sm); 841 color: var(--text-tertiary); 842 cursor: pointer; 843 transition: all 0.15s; ··· 878 position: absolute; 879 inset: 0; 880 background: rgba(0, 0, 0, 0.6); 881 + border-radius: var(--radius-md); 882 display: flex; 883 flex-direction: column; 884 align-items: center; 885 justify-content: center; 886 gap: 0.5rem; 887 color: white; 888 + font-size: var(--text-sm); 889 opacity: 0; 890 transition: opacity 0.2s; 891 } ··· 913 914 .album-type { 915 text-transform: uppercase; 916 + font-size: var(--text-xs); 917 font-weight: 600; 918 letter-spacing: 0.1em; 919 color: var(--text-tertiary); ··· 937 display: flex; 938 align-items: center; 939 gap: 0.75rem; 940 + font-size: var(--text-base); 941 color: var(--text-secondary); 942 text-shadow: var(--text-shadow, none); 943 } ··· 956 957 .meta-separator { 958 color: var(--text-muted); 959 + font-size: var(--text-xs); 960 } 961 962 .album-actions { ··· 968 .play-button, 969 .queue-button { 970 padding: 0.75rem 1.5rem; 971 + border-radius: var(--radius-2xl); 972 font-weight: 600; 973 + font-size: var(--text-base); 974 font-family: inherit; 975 cursor: pointer; 976 transition: all 0.2s; ··· 989 transform: scale(1.05); 990 } 991 992 + .play-button.is-playing { 993 + animation: ethereal-glow 3s ease-in-out infinite; 994 + } 995 + 996 .queue-button { 997 background: var(--glass-btn-bg, transparent); 998 color: var(--text-primary); ··· 1024 } 1025 1026 .section-heading { 1027 + font-size: var(--text-2xl); 1028 font-weight: 600; 1029 color: var(--text-primary); 1030 margin-bottom: 1rem; ··· 1042 display: flex; 1043 align-items: center; 1044 gap: 0.5rem; 1045 + border-radius: var(--radius-md); 1046 transition: all 0.2s; 1047 position: relative; 1048 } ··· 1074 color: var(--text-muted); 1075 cursor: grab; 1076 touch-action: none; 1077 + border-radius: var(--radius-sm); 1078 transition: all 0.2s; 1079 flex-shrink: 0; 1080 } ··· 1135 } 1136 1137 .album-meta { 1138 + font-size: var(--text-sm); 1139 } 1140 1141 .album-actions { ··· 1167 } 1168 1169 .album-meta { 1170 + font-size: var(--text-sm); 1171 flex-wrap: wrap; 1172 } 1173 } ··· 1179 justify-content: center; 1180 width: 32px; 1181 height: 32px; 1182 + border-radius: var(--radius-full); 1183 background: transparent; 1184 border: none; 1185 color: var(--text-muted); ··· 1212 1213 .modal { 1214 background: var(--bg-secondary); 1215 + border-radius: var(--radius-lg); 1216 padding: 1.5rem; 1217 max-width: 400px; 1218 width: calc(100% - 2rem); ··· 1226 1227 .modal-header h3 { 1228 margin: 0; 1229 + font-size: var(--text-2xl); 1230 font-weight: 600; 1231 color: var(--text-primary); 1232 } ··· 1250 .cancel-btn, 1251 .confirm-btn { 1252 padding: 0.625rem 1.25rem; 1253 + border-radius: var(--radius-md); 1254 font-weight: 500; 1255 + font-size: var(--text-base); 1256 font-family: inherit; 1257 cursor: pointer; 1258 transition: all 0.2s;
+129 -16
frontend/src/routes/upload/+page.svelte
··· 6 import AlbumSelect from "$lib/components/AlbumSelect.svelte"; 7 import WaveLoading from "$lib/components/WaveLoading.svelte"; 8 import TagInput from "$lib/components/TagInput.svelte"; 9 - import type { FeaturedArtist, AlbumSummary } from "$lib/types"; 10 import { API_URL, getServerConfig } from "$lib/config"; 11 import { uploader } from "$lib/uploader.svelte"; 12 import { toast } from "$lib/toast.svelte"; ··· 38 let uploadTags = $state<string[]>([]); 39 let hasUnresolvedFeaturesInput = $state(false); 40 let attestedRights = $state(false); 41 42 // albums for selection 43 let albums = $state<AlbumSummary[]>([]); 44 45 onMount(async () => { 46 // wait for auth to finish loading ··· 53 return; 54 } 55 56 - await loadMyAlbums(); 57 loading = false; 58 }); 59 60 async function loadMyAlbums() { 61 if (!auth.user) return; 62 try { ··· 82 const uploadFeatures = [...featuredArtists]; 83 const uploadImage = imageFile; 84 const tagsToUpload = [...uploadTags]; 85 86 const clearForm = () => { 87 title = ""; ··· 91 featuredArtists = []; 92 uploadTags = []; 93 attestedRights = false; 94 95 const fileInput = document.getElementById( 96 "file-input", ··· 109 uploadFeatures, 110 uploadImage, 111 tagsToUpload, 112 async () => { 113 await loadMyAlbums(); 114 }, ··· 286 {/if} 287 </div> 288 289 <div class="form-group attestation"> 290 <label class="checkbox-label"> 291 <input ··· 327 > 328 <span>upload track</span> 329 </button> 330 </form> 331 </main> 332 {/if} ··· 370 form { 371 background: var(--bg-tertiary); 372 padding: 2rem; 373 - border-radius: 8px; 374 border: 1px solid var(--border-subtle); 375 } 376 ··· 382 display: block; 383 color: var(--text-secondary); 384 margin-bottom: 0.5rem; 385 - font-size: 0.9rem; 386 } 387 388 input[type="text"] { ··· 390 padding: 0.75rem; 391 background: var(--bg-primary); 392 border: 1px solid var(--border-default); 393 - border-radius: 4px; 394 color: var(--text-primary); 395 - font-size: 1rem; 396 font-family: inherit; 397 transition: all 0.2s; 398 } ··· 407 padding: 0.75rem; 408 background: var(--bg-primary); 409 border: 1px solid var(--border-default); 410 - border-radius: 4px; 411 color: var(--text-primary); 412 - font-size: 0.9rem; 413 font-family: inherit; 414 cursor: pointer; 415 } 416 417 .format-hint { 418 margin-top: 0.25rem; 419 - font-size: 0.8rem; 420 color: var(--text-tertiary); 421 } 422 423 .file-info { 424 margin-top: 0.5rem; 425 - font-size: 0.85rem; 426 color: var(--text-muted); 427 } 428 ··· 432 background: var(--accent); 433 color: var(--text-primary); 434 border: none; 435 - border-radius: 4px; 436 - font-size: 1rem; 437 font-weight: 600; 438 font-family: inherit; 439 cursor: pointer; ··· 467 .attestation { 468 background: var(--bg-primary); 469 padding: 1rem; 470 - border-radius: 4px; 471 border: 1px solid var(--border-default); 472 } 473 ··· 489 } 490 491 .checkbox-text { 492 - font-size: 0.95rem; 493 color: var(--text-primary); 494 line-height: 1.4; 495 } ··· 497 .attestation-note { 498 margin-top: 0.75rem; 499 margin-left: 2rem; 500 - font-size: 0.8rem; 501 color: var(--text-tertiary); 502 line-height: 1.4; 503 } ··· 511 text-decoration: underline; 512 } 513 514 @media (max-width: 768px) { 515 main { 516 padding: 0 0.75rem ··· 525 } 526 527 .section-header h2 { 528 - font-size: 1.25rem; 529 } 530 } 531 </style>
··· 6 import AlbumSelect from "$lib/components/AlbumSelect.svelte"; 7 import WaveLoading from "$lib/components/WaveLoading.svelte"; 8 import TagInput from "$lib/components/TagInput.svelte"; 9 + import type { FeaturedArtist, AlbumSummary, Artist } from "$lib/types"; 10 import { API_URL, getServerConfig } from "$lib/config"; 11 import { uploader } from "$lib/uploader.svelte"; 12 import { toast } from "$lib/toast.svelte"; ··· 38 let uploadTags = $state<string[]>([]); 39 let hasUnresolvedFeaturesInput = $state(false); 40 let attestedRights = $state(false); 41 + let supportGated = $state(false); 42 43 // albums for selection 44 let albums = $state<AlbumSummary[]>([]); 45 + 46 + // artist profile for checking atprotofans eligibility 47 + let artistProfile = $state<Artist | null>(null); 48 49 onMount(async () => { 50 // wait for auth to finish loading ··· 57 return; 58 } 59 60 + await Promise.all([loadMyAlbums(), loadArtistProfile()]); 61 loading = false; 62 }); 63 64 + async function loadArtistProfile() { 65 + if (!auth.user) return; 66 + try { 67 + const response = await fetch( 68 + `${API_URL}/artists/by-handle/${auth.user.handle}`, 69 + ); 70 + if (response.ok) { 71 + artistProfile = await response.json(); 72 + } 73 + } catch (_e) { 74 + console.error("failed to load artist profile:", _e); 75 + } 76 + } 77 + 78 async function loadMyAlbums() { 79 if (!auth.user) return; 80 try { ··· 100 const uploadFeatures = [...featuredArtists]; 101 const uploadImage = imageFile; 102 const tagsToUpload = [...uploadTags]; 103 + const isGated = supportGated; 104 105 const clearForm = () => { 106 title = ""; ··· 110 featuredArtists = []; 111 uploadTags = []; 112 attestedRights = false; 113 + supportGated = false; 114 115 const fileInput = document.getElementById( 116 "file-input", ··· 129 uploadFeatures, 130 uploadImage, 131 tagsToUpload, 132 + isGated, 133 async () => { 134 await loadMyAlbums(); 135 }, ··· 307 {/if} 308 </div> 309 310 + <div class="form-group supporter-gating"> 311 + {#if artistProfile?.support_url} 312 + <label class="checkbox-label"> 313 + <input 314 + type="checkbox" 315 + bind:checked={supportGated} 316 + /> 317 + <span class="checkbox-text"> 318 + <svg class="heart-icon" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> 319 + <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/> 320 + </svg> 321 + supporters only 322 + </span> 323 + </label> 324 + <p class="gating-note"> 325 + only users who support you via <a href={artistProfile.support_url} target="_blank" rel="noopener">atprotofans</a> can play this track 326 + </p> 327 + {:else} 328 + <div class="gating-disabled"> 329 + <span class="gating-disabled-icon"> 330 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 331 + <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/> 332 + </svg> 333 + </span> 334 + <span class="gating-disabled-text"> 335 + want to offer exclusive tracks to supporters? <a href="https://atprotofans.com" target="_blank" rel="noopener">set up atprotofans</a>, then enable it in your <a href="/portal">portal</a> 336 + </span> 337 + </div> 338 + {/if} 339 + </div> 340 + 341 <div class="form-group attestation"> 342 <label class="checkbox-label"> 343 <input ··· 379 > 380 <span>upload track</span> 381 </button> 382 + 383 </form> 384 </main> 385 {/if} ··· 423 form { 424 background: var(--bg-tertiary); 425 padding: 2rem; 426 + border-radius: var(--radius-md); 427 border: 1px solid var(--border-subtle); 428 } 429 ··· 435 display: block; 436 color: var(--text-secondary); 437 margin-bottom: 0.5rem; 438 + font-size: var(--text-base); 439 } 440 441 input[type="text"] { ··· 443 padding: 0.75rem; 444 background: var(--bg-primary); 445 border: 1px solid var(--border-default); 446 + border-radius: var(--radius-sm); 447 color: var(--text-primary); 448 + font-size: var(--text-lg); 449 font-family: inherit; 450 transition: all 0.2s; 451 } ··· 460 padding: 0.75rem; 461 background: var(--bg-primary); 462 border: 1px solid var(--border-default); 463 + border-radius: var(--radius-sm); 464 color: var(--text-primary); 465 + font-size: var(--text-base); 466 font-family: inherit; 467 cursor: pointer; 468 } 469 470 .format-hint { 471 margin-top: 0.25rem; 472 + font-size: var(--text-sm); 473 color: var(--text-tertiary); 474 } 475 476 .file-info { 477 margin-top: 0.5rem; 478 + font-size: var(--text-sm); 479 color: var(--text-muted); 480 } 481 ··· 485 background: var(--accent); 486 color: var(--text-primary); 487 border: none; 488 + border-radius: var(--radius-sm); 489 + font-size: var(--text-lg); 490 font-weight: 600; 491 font-family: inherit; 492 cursor: pointer; ··· 520 .attestation { 521 background: var(--bg-primary); 522 padding: 1rem; 523 + border-radius: var(--radius-sm); 524 border: 1px solid var(--border-default); 525 } 526 ··· 542 } 543 544 .checkbox-text { 545 + font-size: var(--text-base); 546 color: var(--text-primary); 547 line-height: 1.4; 548 } ··· 550 .attestation-note { 551 margin-top: 0.75rem; 552 margin-left: 2rem; 553 + font-size: var(--text-sm); 554 color: var(--text-tertiary); 555 line-height: 1.4; 556 } ··· 564 text-decoration: underline; 565 } 566 567 + .supporter-gating { 568 + background: color-mix(in srgb, var(--accent) 8%, var(--bg-primary)); 569 + padding: 1rem; 570 + border-radius: var(--radius-sm); 571 + border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border-default)); 572 + } 573 + 574 + .supporter-gating .checkbox-text { 575 + display: inline-flex; 576 + align-items: center; 577 + gap: 0.4rem; 578 + } 579 + 580 + .supporter-gating .heart-icon { 581 + color: var(--accent); 582 + } 583 + 584 + .gating-note { 585 + margin-top: 0.5rem; 586 + margin-left: 2rem; 587 + font-size: var(--text-sm); 588 + color: var(--text-tertiary); 589 + line-height: 1.4; 590 + } 591 + 592 + .gating-note a { 593 + color: var(--accent); 594 + text-decoration: none; 595 + } 596 + 597 + .gating-note a:hover { 598 + text-decoration: underline; 599 + } 600 + 601 + .gating-disabled { 602 + display: flex; 603 + align-items: flex-start; 604 + gap: 0.75rem; 605 + color: var(--text-muted); 606 + } 607 + 608 + .gating-disabled-icon { 609 + flex-shrink: 0; 610 + margin-top: 0.1rem; 611 + } 612 + 613 + .gating-disabled-text { 614 + font-size: var(--text-sm); 615 + line-height: 1.4; 616 + } 617 + 618 + .gating-disabled-text a { 619 + color: var(--accent); 620 + text-decoration: none; 621 + } 622 + 623 + .gating-disabled-text a:hover { 624 + text-decoration: underline; 625 + } 626 + 627 @media (max-width: 768px) { 628 main { 629 padding: 0 0.75rem ··· 638 } 639 640 .section-header h2 { 641 + font-size: var(--text-2xl); 642 } 643 } 644 </style>
+17
lexicons/track.json
··· 61 "type": "string", 62 "format": "datetime", 63 "description": "Timestamp when the track was uploaded." 64 } 65 } 66 } 67 },
··· 61 "type": "string", 62 "format": "datetime", 63 "description": "Timestamp when the track was uploaded." 64 + }, 65 + "supportGate": { 66 + "type": "ref", 67 + "ref": "#supportGate", 68 + "description": "If set, this track requires viewer to be a supporter of the artist via atprotofans." 69 } 70 + } 71 + } 72 + }, 73 + "supportGate": { 74 + "type": "object", 75 + "description": "Configuration for supporter-gated content.", 76 + "required": ["type"], 77 + "properties": { 78 + "type": { 79 + "type": "string", 80 + "description": "The type of support required to access this content.", 81 + "knownValues": ["any"] 82 } 83 } 84 },
+1 -1
moderation/Cargo.toml
··· 6 [dependencies] 7 anyhow = "1.0" 8 axum = { version = "0.7", features = ["macros", "json", "ws"] } 9 bytes = "1.0" 10 chrono = { version = "0.4", features = ["serde"] } 11 futures = "0.3" ··· 25 tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } 26 27 [dev-dependencies] 28 - rand = "0.8"
··· 6 [dependencies] 7 anyhow = "1.0" 8 axum = { version = "0.7", features = ["macros", "json", "ws"] } 9 + rand = "0.8" 10 bytes = "1.0" 11 chrono = { version = "0.4", features = ["serde"] } 12 futures = "0.3" ··· 26 tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } 27 28 [dev-dependencies]
+159
moderation/src/admin.rs
··· 104 pub active_uris: Vec<String>, 105 } 106 107 /// List all flagged tracks - returns JSON for API, HTML for htmx. 108 pub async fn list_flagged( 109 State(state): State<AppState>, ··· 292 Ok(Json(StoreContextResponse { 293 message: format!("context stored for {}", request.uri), 294 })) 295 } 296 297 /// Serve the admin UI HTML from static file.
··· 104 pub active_uris: Vec<String>, 105 } 106 107 + /// Request to add a sensitive image. 108 + #[derive(Debug, Deserialize)] 109 + pub struct AddSensitiveImageRequest { 110 + /// R2 storage ID (for track/album artwork) 111 + pub image_id: Option<String>, 112 + /// Full URL (for external images like avatars) 113 + pub url: Option<String>, 114 + /// Why this image was flagged 115 + pub reason: Option<String>, 116 + /// Admin who flagged it 117 + pub flagged_by: Option<String>, 118 + } 119 + 120 + /// Response after adding a sensitive image. 121 + #[derive(Debug, Serialize)] 122 + pub struct AddSensitiveImageResponse { 123 + pub id: i64, 124 + pub message: String, 125 + } 126 + 127 + /// Request to remove a sensitive image. 128 + #[derive(Debug, Deserialize)] 129 + pub struct RemoveSensitiveImageRequest { 130 + pub id: i64, 131 + } 132 + 133 + /// Response after removing a sensitive image. 134 + #[derive(Debug, Serialize)] 135 + pub struct RemoveSensitiveImageResponse { 136 + pub removed: bool, 137 + pub message: String, 138 + } 139 + 140 + /// Request to create a review batch. 141 + #[derive(Debug, Deserialize)] 142 + pub struct CreateBatchRequest { 143 + /// URIs to include. If empty, uses all pending flags. 144 + #[serde(default)] 145 + pub uris: Vec<String>, 146 + /// Who created this batch. 147 + pub created_by: Option<String>, 148 + } 149 + 150 + /// Response after creating a review batch. 151 + #[derive(Debug, Serialize)] 152 + pub struct CreateBatchResponse { 153 + pub id: String, 154 + pub url: String, 155 + pub flag_count: usize, 156 + } 157 + 158 /// List all flagged tracks - returns JSON for API, HTML for htmx. 159 pub async fn list_flagged( 160 State(state): State<AppState>, ··· 343 Ok(Json(StoreContextResponse { 344 message: format!("context stored for {}", request.uri), 345 })) 346 + } 347 + 348 + /// Create a review batch from pending flags. 349 + pub async fn create_batch( 350 + State(state): State<AppState>, 351 + Json(request): Json<CreateBatchRequest>, 352 + ) -> Result<Json<CreateBatchResponse>, AppError> { 353 + let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 354 + 355 + // Get URIs to include 356 + let uris = if request.uris.is_empty() { 357 + let pending = db.get_pending_flags().await?; 358 + pending 359 + .into_iter() 360 + .filter(|t| !t.resolved) 361 + .map(|t| t.uri) 362 + .collect() 363 + } else { 364 + request.uris 365 + }; 366 + 367 + if uris.is_empty() { 368 + return Err(AppError::BadRequest("no flags to review".to_string())); 369 + } 370 + 371 + let id = generate_batch_id(); 372 + let flag_count = uris.len(); 373 + 374 + tracing::info!( 375 + batch_id = %id, 376 + flag_count = flag_count, 377 + "creating review batch" 378 + ); 379 + 380 + db.create_batch(&id, &uris, request.created_by.as_deref()) 381 + .await?; 382 + 383 + let url = format!("/admin/review/{}", id); 384 + 385 + Ok(Json(CreateBatchResponse { id, url, flag_count })) 386 + } 387 + 388 + /// Generate a short, URL-safe batch ID. 389 + fn generate_batch_id() -> String { 390 + use std::time::{SystemTime, UNIX_EPOCH}; 391 + let now = SystemTime::now() 392 + .duration_since(UNIX_EPOCH) 393 + .unwrap() 394 + .as_millis(); 395 + let rand_part: u32 = rand::random(); 396 + format!("{:x}{:x}", (now as u64) & 0xFFFFFFFF, rand_part & 0xFFFF) 397 + } 398 + 399 + /// Add a sensitive image entry. 400 + pub async fn add_sensitive_image( 401 + State(state): State<AppState>, 402 + Json(request): Json<AddSensitiveImageRequest>, 403 + ) -> Result<Json<AddSensitiveImageResponse>, AppError> { 404 + let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 405 + 406 + // Validate: at least one of image_id or url must be provided 407 + if request.image_id.is_none() && request.url.is_none() { 408 + return Err(AppError::BadRequest( 409 + "at least one of image_id or url must be provided".to_string(), 410 + )); 411 + } 412 + 413 + tracing::info!( 414 + image_id = ?request.image_id, 415 + url = ?request.url, 416 + reason = ?request.reason, 417 + flagged_by = ?request.flagged_by, 418 + "adding sensitive image" 419 + ); 420 + 421 + let id = db 422 + .add_sensitive_image( 423 + request.image_id.as_deref(), 424 + request.url.as_deref(), 425 + request.reason.as_deref(), 426 + request.flagged_by.as_deref(), 427 + ) 428 + .await?; 429 + 430 + Ok(Json(AddSensitiveImageResponse { 431 + id, 432 + message: "sensitive image added".to_string(), 433 + })) 434 + } 435 + 436 + /// Remove a sensitive image entry. 437 + pub async fn remove_sensitive_image( 438 + State(state): State<AppState>, 439 + Json(request): Json<RemoveSensitiveImageRequest>, 440 + ) -> Result<Json<RemoveSensitiveImageResponse>, AppError> { 441 + let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 442 + 443 + tracing::info!(id = request.id, "removing sensitive image"); 444 + 445 + let removed = db.remove_sensitive_image(request.id).await?; 446 + 447 + let message = if removed { 448 + format!("sensitive image {} removed", request.id) 449 + } else { 450 + format!("sensitive image {} not found", request.id) 451 + }; 452 + 453 + Ok(Json(RemoveSensitiveImageResponse { removed, message })) 454 } 455 456 /// Serve the admin UI HTML from static file.
+6 -1
moderation/src/auth.rs
··· 12 let path = req.uri().path(); 13 14 // Public endpoints - no auth required 15 - // Note: /admin serves HTML, auth is handled client-side for API calls 16 // Static files must be public for admin UI CSS/JS to load 17 if path == "/" 18 || path == "/health" 19 || path == "/admin" 20 || path.starts_with("/static/") 21 || path.starts_with("/xrpc/com.atproto.label.") 22 {
··· 12 let path = req.uri().path(); 13 14 // Public endpoints - no auth required 15 + // Note: /admin and /admin/review/:id serve HTML, auth is handled client-side for API calls 16 // Static files must be public for admin UI CSS/JS to load 17 + let is_review_page = path.starts_with("/admin/review/") 18 + && !path.ends_with("/data") 19 + && !path.ends_with("/submit"); 20 if path == "/" 21 || path == "/health" 22 + || path == "/sensitive-images" 23 || path == "/admin" 24 + || is_review_page 25 || path.starts_with("/static/") 26 || path.starts_with("/xrpc/com.atproto.label.") 27 {
+336 -1
moderation/src/db.rs
··· 2 3 use chrono::{DateTime, Utc}; 4 use serde::{Deserialize, Serialize}; 5 - use sqlx::{postgres::PgPoolOptions, PgPool}; 6 7 use crate::admin::FlaggedTrack; 8 use crate::labels::Label; 9 10 /// Type alias for context row from database query. 11 type ContextRow = ( 12 Option<i64>, // track_id ··· 55 FingerprintNoise, 56 /// Legal cover version or remix 57 CoverVersion, 58 /// Other reason (see resolution_notes) 59 Other, 60 } ··· 67 Self::Licensed => "licensed", 68 Self::FingerprintNoise => "fingerprint noise", 69 Self::CoverVersion => "cover/remix", 70 Self::Other => "other", 71 } 72 } ··· 78 "licensed" => Some(Self::Licensed), 79 "fingerprint_noise" => Some(Self::FingerprintNoise), 80 "cover_version" => Some(Self::CoverVersion), 81 "other" => Some(Self::Other), 82 _ => None, 83 } ··· 193 .execute(&self.pool) 194 .await?; 195 sqlx::query("ALTER TABLE label_context ADD COLUMN IF NOT EXISTS resolution_notes TEXT") 196 .execute(&self.pool) 197 .await?; 198 ··· 592 .collect(); 593 594 Ok(tracks) 595 } 596 } 597
··· 2 3 use chrono::{DateTime, Utc}; 4 use serde::{Deserialize, Serialize}; 5 + use sqlx::{postgres::PgPoolOptions, FromRow, PgPool}; 6 7 use crate::admin::FlaggedTrack; 8 use crate::labels::Label; 9 10 + /// Sensitive image record from the database. 11 + #[derive(Debug, Clone, FromRow, Serialize, Deserialize)] 12 + pub struct SensitiveImageRow { 13 + pub id: i64, 14 + /// R2 storage ID (for track/album artwork) 15 + pub image_id: Option<String>, 16 + /// Full URL (for external images like avatars) 17 + pub url: Option<String>, 18 + /// Why this image was flagged 19 + pub reason: Option<String>, 20 + /// When the image was flagged 21 + pub flagged_at: DateTime<Utc>, 22 + /// Admin who flagged it 23 + pub flagged_by: Option<String>, 24 + } 25 + 26 + /// Review batch for mobile-friendly flag review. 27 + #[derive(Debug, Clone, FromRow, Serialize, Deserialize)] 28 + pub struct ReviewBatch { 29 + pub id: String, 30 + pub created_at: DateTime<Utc>, 31 + pub expires_at: Option<DateTime<Utc>>, 32 + /// Status: pending, completed. 33 + pub status: String, 34 + /// Who created this batch. 35 + pub created_by: Option<String>, 36 + } 37 + 38 + /// A flag within a review batch. 39 + #[derive(Debug, Clone, FromRow, Serialize, Deserialize)] 40 + pub struct BatchFlag { 41 + pub id: i64, 42 + pub batch_id: String, 43 + pub uri: String, 44 + pub reviewed: bool, 45 + pub reviewed_at: Option<DateTime<Utc>>, 46 + /// Decision: approved, rejected, or null. 47 + pub decision: Option<String>, 48 + } 49 + 50 /// Type alias for context row from database query. 51 type ContextRow = ( 52 Option<i64>, // track_id ··· 95 FingerprintNoise, 96 /// Legal cover version or remix 97 CoverVersion, 98 + /// Content was deleted from plyr.fm 99 + ContentDeleted, 100 /// Other reason (see resolution_notes) 101 Other, 102 } ··· 109 Self::Licensed => "licensed", 110 Self::FingerprintNoise => "fingerprint noise", 111 Self::CoverVersion => "cover/remix", 112 + Self::ContentDeleted => "content deleted", 113 Self::Other => "other", 114 } 115 } ··· 121 "licensed" => Some(Self::Licensed), 122 "fingerprint_noise" => Some(Self::FingerprintNoise), 123 "cover_version" => Some(Self::CoverVersion), 124 + "content_deleted" => Some(Self::ContentDeleted), 125 "other" => Some(Self::Other), 126 _ => None, 127 } ··· 237 .execute(&self.pool) 238 .await?; 239 sqlx::query("ALTER TABLE label_context ADD COLUMN IF NOT EXISTS resolution_notes TEXT") 240 + .execute(&self.pool) 241 + .await?; 242 + 243 + // Sensitive images table for content moderation 244 + sqlx::query( 245 + r#" 246 + CREATE TABLE IF NOT EXISTS sensitive_images ( 247 + id BIGSERIAL PRIMARY KEY, 248 + image_id TEXT, 249 + url TEXT, 250 + reason TEXT, 251 + flagged_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 252 + flagged_by TEXT 253 + ) 254 + "#, 255 + ) 256 + .execute(&self.pool) 257 + .await?; 258 + 259 + sqlx::query("CREATE INDEX IF NOT EXISTS idx_sensitive_images_image_id ON sensitive_images(image_id)") 260 + .execute(&self.pool) 261 + .await?; 262 + sqlx::query("CREATE INDEX IF NOT EXISTS idx_sensitive_images_url ON sensitive_images(url)") 263 + .execute(&self.pool) 264 + .await?; 265 + 266 + // Review batches for mobile-friendly flag review 267 + sqlx::query( 268 + r#" 269 + CREATE TABLE IF NOT EXISTS review_batches ( 270 + id TEXT PRIMARY KEY, 271 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), 272 + expires_at TIMESTAMPTZ, 273 + status TEXT NOT NULL DEFAULT 'pending', 274 + created_by TEXT 275 + ) 276 + "#, 277 + ) 278 + .execute(&self.pool) 279 + .await?; 280 + 281 + // Flags within review batches 282 + sqlx::query( 283 + r#" 284 + CREATE TABLE IF NOT EXISTS batch_flags ( 285 + id BIGSERIAL PRIMARY KEY, 286 + batch_id TEXT NOT NULL REFERENCES review_batches(id) ON DELETE CASCADE, 287 + uri TEXT NOT NULL, 288 + reviewed BOOLEAN NOT NULL DEFAULT FALSE, 289 + reviewed_at TIMESTAMPTZ, 290 + decision TEXT, 291 + UNIQUE(batch_id, uri) 292 + ) 293 + "#, 294 + ) 295 + .execute(&self.pool) 296 + .await?; 297 + 298 + sqlx::query("CREATE INDEX IF NOT EXISTS idx_batch_flags_batch_id ON batch_flags(batch_id)") 299 .execute(&self.pool) 300 .await?; 301 ··· 695 .collect(); 696 697 Ok(tracks) 698 + } 699 + 700 + // ------------------------------------------------------------------------- 701 + // Review batches 702 + // ------------------------------------------------------------------------- 703 + 704 + /// Create a review batch with the given flags. 705 + pub async fn create_batch( 706 + &self, 707 + id: &str, 708 + uris: &[String], 709 + created_by: Option<&str>, 710 + ) -> Result<ReviewBatch, sqlx::Error> { 711 + let batch = sqlx::query_as::<_, ReviewBatch>( 712 + r#" 713 + INSERT INTO review_batches (id, created_by) 714 + VALUES ($1, $2) 715 + RETURNING id, created_at, expires_at, status, created_by 716 + "#, 717 + ) 718 + .bind(id) 719 + .bind(created_by) 720 + .fetch_one(&self.pool) 721 + .await?; 722 + 723 + for uri in uris { 724 + sqlx::query( 725 + r#" 726 + INSERT INTO batch_flags (batch_id, uri) 727 + VALUES ($1, $2) 728 + ON CONFLICT (batch_id, uri) DO NOTHING 729 + "#, 730 + ) 731 + .bind(id) 732 + .bind(uri) 733 + .execute(&self.pool) 734 + .await?; 735 + } 736 + 737 + Ok(batch) 738 + } 739 + 740 + /// Get a batch by ID. 741 + pub async fn get_batch(&self, id: &str) -> Result<Option<ReviewBatch>, sqlx::Error> { 742 + sqlx::query_as::<_, ReviewBatch>( 743 + r#" 744 + SELECT id, created_at, expires_at, status, created_by 745 + FROM review_batches 746 + WHERE id = $1 747 + "#, 748 + ) 749 + .bind(id) 750 + .fetch_optional(&self.pool) 751 + .await 752 + } 753 + 754 + /// Get all flags in a batch with their context. 755 + pub async fn get_batch_flags(&self, batch_id: &str) -> Result<Vec<FlaggedTrack>, sqlx::Error> { 756 + let rows: Vec<FlaggedRow> = sqlx::query_as( 757 + r#" 758 + SELECT l.seq, l.uri, l.val, l.cts, 759 + c.track_id, c.track_title, c.artist_handle, c.artist_did, c.highest_score, c.matches, 760 + c.resolution_reason, c.resolution_notes 761 + FROM batch_flags bf 762 + JOIN labels l ON l.uri = bf.uri AND l.val = 'copyright-violation' AND l.neg = false 763 + LEFT JOIN label_context c ON l.uri = c.uri 764 + WHERE bf.batch_id = $1 765 + ORDER BY l.seq DESC 766 + "#, 767 + ) 768 + .bind(batch_id) 769 + .fetch_all(&self.pool) 770 + .await?; 771 + 772 + let batch_uris: Vec<String> = rows.iter().map(|r| r.1.clone()).collect(); 773 + let negated_uris: std::collections::HashSet<String> = if !batch_uris.is_empty() { 774 + sqlx::query_scalar::<_, String>( 775 + r#" 776 + SELECT DISTINCT uri 777 + FROM labels 778 + WHERE val = 'copyright-violation' AND neg = true AND uri = ANY($1) 779 + "#, 780 + ) 781 + .bind(&batch_uris) 782 + .fetch_all(&self.pool) 783 + .await? 784 + .into_iter() 785 + .collect() 786 + } else { 787 + std::collections::HashSet::new() 788 + }; 789 + 790 + let tracks = rows 791 + .into_iter() 792 + .map( 793 + |( 794 + seq, 795 + uri, 796 + val, 797 + cts, 798 + track_id, 799 + track_title, 800 + artist_handle, 801 + artist_did, 802 + highest_score, 803 + matches, 804 + resolution_reason, 805 + resolution_notes, 806 + )| { 807 + let context = if track_id.is_some() 808 + || track_title.is_some() 809 + || artist_handle.is_some() 810 + || resolution_reason.is_some() 811 + { 812 + Some(LabelContext { 813 + track_id, 814 + track_title, 815 + artist_handle, 816 + artist_did, 817 + highest_score, 818 + matches: matches.and_then(|v| serde_json::from_value(v).ok()), 819 + resolution_reason: resolution_reason 820 + .and_then(|s| ResolutionReason::from_str(&s)), 821 + resolution_notes, 822 + }) 823 + } else { 824 + None 825 + }; 826 + 827 + FlaggedTrack { 828 + seq, 829 + uri: uri.clone(), 830 + val, 831 + created_at: cts.format("%Y-%m-%d %H:%M:%S").to_string(), 832 + resolved: negated_uris.contains(&uri), 833 + context, 834 + } 835 + }, 836 + ) 837 + .collect(); 838 + 839 + Ok(tracks) 840 + } 841 + 842 + /// Update batch status. 843 + pub async fn update_batch_status(&self, id: &str, status: &str) -> Result<bool, sqlx::Error> { 844 + let result = sqlx::query("UPDATE review_batches SET status = $1 WHERE id = $2") 845 + .bind(status) 846 + .bind(id) 847 + .execute(&self.pool) 848 + .await?; 849 + Ok(result.rows_affected() > 0) 850 + } 851 + 852 + /// Mark a flag in a batch as reviewed. 853 + pub async fn mark_flag_reviewed( 854 + &self, 855 + batch_id: &str, 856 + uri: &str, 857 + decision: &str, 858 + ) -> Result<bool, sqlx::Error> { 859 + let result = sqlx::query( 860 + r#" 861 + UPDATE batch_flags 862 + SET reviewed = true, reviewed_at = NOW(), decision = $1 863 + WHERE batch_id = $2 AND uri = $3 864 + "#, 865 + ) 866 + .bind(decision) 867 + .bind(batch_id) 868 + .bind(uri) 869 + .execute(&self.pool) 870 + .await?; 871 + Ok(result.rows_affected() > 0) 872 + } 873 + 874 + /// Get pending (non-reviewed) flags from a batch. 875 + pub async fn get_batch_pending_uris(&self, batch_id: &str) -> Result<Vec<String>, sqlx::Error> { 876 + sqlx::query_scalar::<_, String>( 877 + r#" 878 + SELECT uri FROM batch_flags 879 + WHERE batch_id = $1 AND reviewed = false 880 + "#, 881 + ) 882 + .bind(batch_id) 883 + .fetch_all(&self.pool) 884 + .await 885 + } 886 + 887 + // ------------------------------------------------------------------------- 888 + // Sensitive images 889 + // ------------------------------------------------------------------------- 890 + 891 + /// Get all sensitive images. 892 + pub async fn get_sensitive_images(&self) -> Result<Vec<SensitiveImageRow>, sqlx::Error> { 893 + sqlx::query_as::<_, SensitiveImageRow>( 894 + "SELECT id, image_id, url, reason, flagged_at, flagged_by FROM sensitive_images ORDER BY flagged_at DESC", 895 + ) 896 + .fetch_all(&self.pool) 897 + .await 898 + } 899 + 900 + /// Add a sensitive image entry. 901 + pub async fn add_sensitive_image( 902 + &self, 903 + image_id: Option<&str>, 904 + url: Option<&str>, 905 + reason: Option<&str>, 906 + flagged_by: Option<&str>, 907 + ) -> Result<i64, sqlx::Error> { 908 + sqlx::query_scalar::<_, i64>( 909 + r#" 910 + INSERT INTO sensitive_images (image_id, url, reason, flagged_by) 911 + VALUES ($1, $2, $3, $4) 912 + RETURNING id 913 + "#, 914 + ) 915 + .bind(image_id) 916 + .bind(url) 917 + .bind(reason) 918 + .bind(flagged_by) 919 + .fetch_one(&self.pool) 920 + .await 921 + } 922 + 923 + /// Remove a sensitive image entry by ID. 924 + pub async fn remove_sensitive_image(&self, id: i64) -> Result<bool, sqlx::Error> { 925 + let result = sqlx::query("DELETE FROM sensitive_images WHERE id = $1") 926 + .bind(id) 927 + .execute(&self.pool) 928 + .await?; 929 + Ok(result.rows_affected() > 0) 930 } 931 } 932
+26
moderation/src/handlers.rs
··· 63 pub label: Label, 64 } 65 66 // --- handlers --- 67 68 /// Health check endpoint. ··· 206 } 207 208 Ok(Json(EmitLabelResponse { seq, label })) 209 } 210 211 #[cfg(test)]
··· 63 pub label: Label, 64 } 65 66 + /// Response for sensitive images endpoint. 67 + #[derive(Debug, Serialize)] 68 + pub struct SensitiveImagesResponse { 69 + /// R2 image IDs (for track/album artwork) 70 + pub image_ids: Vec<String>, 71 + /// Full URLs (for external images like avatars) 72 + pub urls: Vec<String>, 73 + } 74 + 75 // --- handlers --- 76 77 /// Health check endpoint. ··· 215 } 216 217 Ok(Json(EmitLabelResponse { seq, label })) 218 + } 219 + 220 + /// Get all sensitive images (public endpoint). 221 + /// 222 + /// Returns image_ids (R2 storage IDs) and urls (full URLs) for all flagged images. 223 + /// Clients should check both lists when determining if an image is sensitive. 224 + pub async fn get_sensitive_images( 225 + State(state): State<AppState>, 226 + ) -> Result<Json<SensitiveImagesResponse>, AppError> { 227 + let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 228 + 229 + let images = db.get_sensitive_images().await?; 230 + 231 + let image_ids: Vec<String> = images.iter().filter_map(|i| i.image_id.clone()).collect(); 232 + let urls: Vec<String> = images.iter().filter_map(|i| i.url.clone()).collect(); 233 + 234 + Ok(Json(SensitiveImagesResponse { image_ids, urls })) 235 } 236 237 #[cfg(test)]
+13
moderation/src/main.rs
··· 25 mod db; 26 mod handlers; 27 mod labels; 28 mod state; 29 mod xrpc; 30 ··· 72 .route("/", get(handlers::landing)) 73 // Health check 74 .route("/health", get(handlers::health)) 75 // AuDD scanning 76 .route("/scan", post(audd::scan)) 77 // Label emission (internal API) ··· 84 .route("/admin/resolve-htmx", post(admin::resolve_flag_htmx)) 85 .route("/admin/context", post(admin::store_context)) 86 .route("/admin/active-labels", post(admin::get_active_labels)) 87 // Static files (CSS, JS for admin UI) 88 .nest_service("/static", ServeDir::new("static")) 89 // ATProto XRPC endpoints (public)
··· 25 mod db; 26 mod handlers; 27 mod labels; 28 + mod review; 29 mod state; 30 mod xrpc; 31 ··· 73 .route("/", get(handlers::landing)) 74 // Health check 75 .route("/health", get(handlers::health)) 76 + // Sensitive images (public) 77 + .route("/sensitive-images", get(handlers::get_sensitive_images)) 78 // AuDD scanning 79 .route("/scan", post(audd::scan)) 80 // Label emission (internal API) ··· 87 .route("/admin/resolve-htmx", post(admin::resolve_flag_htmx)) 88 .route("/admin/context", post(admin::store_context)) 89 .route("/admin/active-labels", post(admin::get_active_labels)) 90 + .route("/admin/sensitive-images", post(admin::add_sensitive_image)) 91 + .route( 92 + "/admin/sensitive-images/remove", 93 + post(admin::remove_sensitive_image), 94 + ) 95 + .route("/admin/batches", post(admin::create_batch)) 96 + // Review endpoints (under admin, auth protected) 97 + .route("/admin/review/:id", get(review::review_page)) 98 + .route("/admin/review/:id/data", get(review::review_data)) 99 + .route("/admin/review/:id/submit", post(review::submit_review)) 100 // Static files (CSS, JS for admin UI) 101 .nest_service("/static", ServeDir::new("static")) 102 // ATProto XRPC endpoints (public)
+526
moderation/src/review.rs
···
··· 1 + //! Review endpoints for batch flag review. 2 + //! 3 + //! These endpoints are behind the same auth as admin endpoints. 4 + 5 + use axum::{ 6 + extract::{Path, State}, 7 + http::header::CONTENT_TYPE, 8 + response::{IntoResponse, Response}, 9 + Json, 10 + }; 11 + use serde::{Deserialize, Serialize}; 12 + 13 + use crate::admin::FlaggedTrack; 14 + use crate::state::{AppError, AppState}; 15 + 16 + /// Response for review page data. 17 + #[derive(Debug, Serialize)] 18 + pub struct ReviewPageData { 19 + pub batch_id: String, 20 + pub flags: Vec<FlaggedTrack>, 21 + pub status: String, 22 + } 23 + 24 + /// Request to submit review decisions. 25 + #[derive(Debug, Deserialize)] 26 + pub struct SubmitReviewRequest { 27 + pub decisions: Vec<ReviewDecision>, 28 + } 29 + 30 + /// A single review decision. 31 + #[derive(Debug, Deserialize)] 32 + pub struct ReviewDecision { 33 + pub uri: String, 34 + /// "clear" (false positive), "defer" (acknowledge, no action), "confirm" (real violation) 35 + pub decision: String, 36 + } 37 + 38 + /// Response after submitting review. 39 + #[derive(Debug, Serialize)] 40 + pub struct SubmitReviewResponse { 41 + pub resolved_count: usize, 42 + pub message: String, 43 + } 44 + 45 + /// Get review page HTML. 46 + pub async fn review_page( 47 + State(state): State<AppState>, 48 + Path(batch_id): Path<String>, 49 + ) -> Result<Response, AppError> { 50 + let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 51 + 52 + let batch = db 53 + .get_batch(&batch_id) 54 + .await? 55 + .ok_or(AppError::NotFound("batch not found".to_string()))?; 56 + 57 + let flags = db.get_batch_flags(&batch_id).await?; 58 + let html = render_review_page(&batch_id, &flags, &batch.status); 59 + 60 + Ok(([(CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()) 61 + } 62 + 63 + /// Get review data as JSON. 64 + pub async fn review_data( 65 + State(state): State<AppState>, 66 + Path(batch_id): Path<String>, 67 + ) -> Result<Json<ReviewPageData>, AppError> { 68 + let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 69 + 70 + let batch = db 71 + .get_batch(&batch_id) 72 + .await? 73 + .ok_or(AppError::NotFound("batch not found".to_string()))?; 74 + 75 + let flags = db.get_batch_flags(&batch_id).await?; 76 + 77 + Ok(Json(ReviewPageData { 78 + batch_id, 79 + flags, 80 + status: batch.status, 81 + })) 82 + } 83 + 84 + /// Submit review decisions. 85 + pub async fn submit_review( 86 + State(state): State<AppState>, 87 + Path(batch_id): Path<String>, 88 + Json(request): Json<SubmitReviewRequest>, 89 + ) -> Result<Json<SubmitReviewResponse>, AppError> { 90 + let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?; 91 + let signer = state 92 + .signer 93 + .as_ref() 94 + .ok_or(AppError::LabelerNotConfigured)?; 95 + 96 + let _batch = db 97 + .get_batch(&batch_id) 98 + .await? 99 + .ok_or(AppError::NotFound("batch not found".to_string()))?; 100 + 101 + let mut resolved_count = 0; 102 + 103 + for decision in &request.decisions { 104 + tracing::info!( 105 + batch_id = %batch_id, 106 + uri = %decision.uri, 107 + decision = %decision.decision, 108 + "processing review decision" 109 + ); 110 + 111 + db.mark_flag_reviewed(&batch_id, &decision.uri, &decision.decision) 112 + .await?; 113 + 114 + match decision.decision.as_str() { 115 + "clear" => { 116 + // False positive - emit negation label to clear the flag 117 + let label = 118 + crate::labels::Label::new(signer.did(), &decision.uri, "copyright-violation") 119 + .negated(); 120 + let label = signer.sign_label(label)?; 121 + let seq = db.store_label(&label).await?; 122 + 123 + db.store_resolution( 124 + &decision.uri, 125 + crate::db::ResolutionReason::FingerprintNoise, 126 + Some("batch review: cleared"), 127 + ) 128 + .await?; 129 + 130 + if let Some(tx) = &state.label_tx { 131 + let _ = tx.send((seq, label)); 132 + } 133 + 134 + resolved_count += 1; 135 + } 136 + "defer" => { 137 + // Acknowledge but take no action - flag stays active 138 + // Just mark as reviewed in the batch, no label changes 139 + tracing::info!(uri = %decision.uri, "deferred - no action taken"); 140 + } 141 + "confirm" => { 142 + // Real violation - flag stays active, could add enforcement later 143 + tracing::info!(uri = %decision.uri, "confirmed as violation"); 144 + } 145 + _ => { 146 + tracing::warn!(uri = %decision.uri, decision = %decision.decision, "unknown decision type"); 147 + } 148 + } 149 + } 150 + 151 + let pending = db.get_batch_pending_uris(&batch_id).await?; 152 + if pending.is_empty() { 153 + db.update_batch_status(&batch_id, "completed").await?; 154 + } 155 + 156 + Ok(Json(SubmitReviewResponse { 157 + resolved_count, 158 + message: format!( 159 + "processed {} decisions, resolved {} flags", 160 + request.decisions.len(), 161 + resolved_count 162 + ), 163 + })) 164 + } 165 + 166 + /// Render the review page. 167 + fn render_review_page(batch_id: &str, flags: &[FlaggedTrack], status: &str) -> String { 168 + let pending: Vec<_> = flags.iter().filter(|f| !f.resolved).collect(); 169 + let resolved: Vec<_> = flags.iter().filter(|f| f.resolved).collect(); 170 + 171 + let pending_cards: Vec<String> = pending.iter().map(|f| render_review_card(f)).collect(); 172 + let resolved_cards: Vec<String> = resolved.iter().map(|f| render_review_card(f)).collect(); 173 + 174 + let pending_html = if pending_cards.is_empty() { 175 + "<div class=\"empty\">all flags reviewed!</div>".to_string() 176 + } else { 177 + pending_cards.join("\n") 178 + }; 179 + 180 + let resolved_html = if resolved_cards.is_empty() { 181 + String::new() 182 + } else { 183 + format!( 184 + r#"<details class="resolved-section"> 185 + <summary>{} resolved</summary> 186 + {} 187 + </details>"#, 188 + resolved_cards.len(), 189 + resolved_cards.join("\n") 190 + ) 191 + }; 192 + 193 + let status_badge = if status == "completed" { 194 + r#"<span class="badge resolved">completed</span>"# 195 + } else { 196 + "" 197 + }; 198 + 199 + format!( 200 + r#"<!DOCTYPE html> 201 + <html lang="en"> 202 + <head> 203 + <meta charset="utf-8"> 204 + <meta name="viewport" content="width=device-width, initial-scale=1"> 205 + <title>review batch - plyr.fm</title> 206 + <link rel="stylesheet" href="/static/admin.css"> 207 + <style>{}</style> 208 + </head> 209 + <body> 210 + <h1>plyr.fm moderation</h1> 211 + <p class="subtitle"> 212 + <a href="/admin">← back to dashboard</a> 213 + <span style="margin: 0 12px; color: var(--text-muted);">|</span> 214 + batch review: {} pending {} 215 + </p> 216 + 217 + <div class="auth-section" id="auth-section"> 218 + <input type="password" id="auth-token" placeholder="auth token" 219 + onkeyup="if(event.key==='Enter')authenticate()"> 220 + <button class="btn btn-primary" onclick="authenticate()">authenticate</button> 221 + </div> 222 + 223 + <form id="review-form" style="display: none;"> 224 + <div class="flags-list"> 225 + {} 226 + </div> 227 + 228 + {} 229 + 230 + <div class="submit-bar"> 231 + <button type="submit" class="btn btn-primary" id="submit-btn" disabled> 232 + submit decisions 233 + </button> 234 + </div> 235 + </form> 236 + 237 + <script> 238 + const form = document.getElementById('review-form'); 239 + const submitBtn = document.getElementById('submit-btn'); 240 + const authSection = document.getElementById('auth-section'); 241 + const batchId = '{}'; 242 + 243 + let currentToken = ''; 244 + const decisions = {{}}; 245 + 246 + function authenticate() {{ 247 + const token = document.getElementById('auth-token').value; 248 + if (token && token !== '••••••••') {{ 249 + localStorage.setItem('mod_token', token); 250 + currentToken = token; 251 + showReviewForm(); 252 + }} 253 + }} 254 + 255 + function showReviewForm() {{ 256 + authSection.style.display = 'none'; 257 + form.style.display = 'block'; 258 + }} 259 + 260 + // Check for saved token on load 261 + const savedToken = localStorage.getItem('mod_token'); 262 + if (savedToken) {{ 263 + currentToken = savedToken; 264 + document.getElementById('auth-token').value = '••••••••'; 265 + showReviewForm(); 266 + }} 267 + 268 + function updateSubmitBtn() {{ 269 + const count = Object.keys(decisions).length; 270 + submitBtn.disabled = count === 0; 271 + submitBtn.textContent = count > 0 ? `submit ${{count}} decision${{count > 1 ? 's' : ''}}` : 'submit decisions'; 272 + }} 273 + 274 + function setDecision(uri, decision) {{ 275 + // Toggle off if clicking the same decision 276 + if (decisions[uri] === decision) {{ 277 + delete decisions[uri]; 278 + const card = document.querySelector(`[data-uri="${{CSS.escape(uri)}}"]`); 279 + if (card) card.classList.remove('decision-clear', 'decision-defer', 'decision-confirm'); 280 + }} else {{ 281 + decisions[uri] = decision; 282 + const card = document.querySelector(`[data-uri="${{CSS.escape(uri)}}"]`); 283 + if (card) {{ 284 + card.classList.remove('decision-clear', 'decision-defer', 'decision-confirm'); 285 + card.classList.add('decision-' + decision); 286 + }} 287 + }} 288 + updateSubmitBtn(); 289 + }} 290 + 291 + form.addEventListener('submit', async (e) => {{ 292 + e.preventDefault(); 293 + submitBtn.disabled = true; 294 + submitBtn.textContent = 'submitting...'; 295 + 296 + try {{ 297 + const response = await fetch(`/admin/review/${{batchId}}/submit`, {{ 298 + method: 'POST', 299 + headers: {{ 300 + 'Content-Type': 'application/json', 301 + 'X-Moderation-Key': currentToken 302 + }}, 303 + body: JSON.stringify({{ 304 + decisions: Object.entries(decisions).map(([uri, decision]) => ({{ uri, decision }})) 305 + }}) 306 + }}); 307 + 308 + if (response.status === 401) {{ 309 + localStorage.removeItem('mod_token'); 310 + currentToken = ''; 311 + authSection.style.display = 'block'; 312 + form.style.display = 'none'; 313 + document.getElementById('auth-token').value = ''; 314 + alert('invalid token'); 315 + return; 316 + }} 317 + 318 + if (response.ok) {{ 319 + const result = await response.json(); 320 + alert(result.message); 321 + location.reload(); 322 + }} else {{ 323 + const err = await response.json(); 324 + alert('error: ' + (err.message || 'unknown error')); 325 + submitBtn.disabled = false; 326 + updateSubmitBtn(); 327 + }} 328 + }} catch (err) {{ 329 + alert('network error: ' + err.message); 330 + submitBtn.disabled = false; 331 + updateSubmitBtn(); 332 + }} 333 + }}); 334 + </script> 335 + </body> 336 + </html>"#, 337 + REVIEW_CSS, 338 + pending.len(), 339 + status_badge, 340 + pending_html, 341 + resolved_html, 342 + html_escape(batch_id) 343 + ) 344 + } 345 + 346 + /// Render a single review card. 347 + fn render_review_card(track: &FlaggedTrack) -> String { 348 + let ctx = track.context.as_ref(); 349 + 350 + let title = ctx 351 + .and_then(|c| c.track_title.as_deref()) 352 + .unwrap_or("unknown track"); 353 + let artist = ctx 354 + .and_then(|c| c.artist_handle.as_deref()) 355 + .unwrap_or("unknown"); 356 + let track_id = ctx.and_then(|c| c.track_id); 357 + 358 + let title_html = if let Some(id) = track_id { 359 + format!( 360 + r#"<a href="https://plyr.fm/track/{}" target="_blank">{}</a>"#, 361 + id, 362 + html_escape(title) 363 + ) 364 + } else { 365 + html_escape(title) 366 + }; 367 + 368 + let matches_html = ctx 369 + .and_then(|c| c.matches.as_ref()) 370 + .filter(|m| !m.is_empty()) 371 + .map(|matches| { 372 + let items: Vec<String> = matches 373 + .iter() 374 + .take(3) 375 + .map(|m| { 376 + format!( 377 + r#"<div class="match-item"><span class="title">{}</span> <span class="artist">by {}</span></div>"#, 378 + html_escape(&m.title), 379 + html_escape(&m.artist) 380 + ) 381 + }) 382 + .collect(); 383 + format!( 384 + r#"<div class="matches"><h4>potential matches</h4>{}</div>"#, 385 + items.join("\n") 386 + ) 387 + }) 388 + .unwrap_or_default(); 389 + 390 + let resolved_badge = if track.resolved { 391 + r#"<span class="badge resolved">resolved</span>"# 392 + } else { 393 + r#"<span class="badge pending">pending</span>"# 394 + }; 395 + 396 + let action_buttons = if !track.resolved { 397 + format!( 398 + r#"<div class="flag-actions"> 399 + <button type="button" class="btn btn-clear" onclick="setDecision('{}', 'clear')">clear</button> 400 + <button type="button" class="btn btn-defer" onclick="setDecision('{}', 'defer')">defer</button> 401 + <button type="button" class="btn btn-confirm" onclick="setDecision('{}', 'confirm')">confirm</button> 402 + </div>"#, 403 + html_escape(&track.uri), 404 + html_escape(&track.uri), 405 + html_escape(&track.uri) 406 + ) 407 + } else { 408 + String::new() 409 + }; 410 + 411 + format!( 412 + r#"<div class="flag-card{}" data-uri="{}"> 413 + <div class="flag-header"> 414 + <div class="track-info"> 415 + <h3>{}</h3> 416 + <div class="artist">@{}</div> 417 + </div> 418 + <div class="flag-badges"> 419 + {} 420 + </div> 421 + </div> 422 + {} 423 + {} 424 + </div>"#, 425 + if track.resolved { " resolved" } else { "" }, 426 + html_escape(&track.uri), 427 + title_html, 428 + html_escape(artist), 429 + resolved_badge, 430 + matches_html, 431 + action_buttons 432 + ) 433 + } 434 + 435 + fn html_escape(s: &str) -> String { 436 + s.replace('&', "&amp;") 437 + .replace('<', "&lt;") 438 + .replace('>', "&gt;") 439 + .replace('"', "&quot;") 440 + .replace('\'', "&#039;") 441 + } 442 + 443 + /// Additional CSS for review page (supplements admin.css) 444 + const REVIEW_CSS: &str = r#" 445 + /* review page specific styles */ 446 + body { padding-bottom: 80px; } 447 + 448 + .subtitle a { 449 + color: var(--accent); 450 + text-decoration: none; 451 + } 452 + .subtitle a:hover { text-decoration: underline; } 453 + 454 + /* action buttons */ 455 + .btn-clear { 456 + background: rgba(74, 222, 128, 0.15); 457 + color: var(--success); 458 + border: 1px solid rgba(74, 222, 128, 0.3); 459 + } 460 + .btn-clear:hover { 461 + background: rgba(74, 222, 128, 0.25); 462 + } 463 + 464 + .btn-defer { 465 + background: rgba(251, 191, 36, 0.15); 466 + color: var(--warning); 467 + border: 1px solid rgba(251, 191, 36, 0.3); 468 + } 469 + .btn-defer:hover { 470 + background: rgba(251, 191, 36, 0.25); 471 + } 472 + 473 + .btn-confirm { 474 + background: rgba(239, 68, 68, 0.15); 475 + color: var(--error); 476 + border: 1px solid rgba(239, 68, 68, 0.3); 477 + } 478 + .btn-confirm:hover { 479 + background: rgba(239, 68, 68, 0.25); 480 + } 481 + 482 + /* card selection states */ 483 + .flag-card.decision-clear { 484 + border-color: var(--success); 485 + background: rgba(74, 222, 128, 0.05); 486 + } 487 + .flag-card.decision-defer { 488 + border-color: var(--warning); 489 + background: rgba(251, 191, 36, 0.05); 490 + } 491 + .flag-card.decision-confirm { 492 + border-color: var(--error); 493 + background: rgba(239, 68, 68, 0.05); 494 + } 495 + 496 + /* submit bar */ 497 + .submit-bar { 498 + position: fixed; 499 + bottom: 0; 500 + left: 0; 501 + right: 0; 502 + padding: 16px 24px; 503 + background: var(--bg-secondary); 504 + border-top: 1px solid var(--border-subtle); 505 + } 506 + .submit-bar .btn { 507 + width: 100%; 508 + max-width: 900px; 509 + margin: 0 auto; 510 + display: block; 511 + padding: 14px; 512 + } 513 + 514 + /* resolved section */ 515 + .resolved-section { 516 + margin-top: 24px; 517 + padding-top: 16px; 518 + border-top: 1px solid var(--border-subtle); 519 + } 520 + .resolved-section summary { 521 + cursor: pointer; 522 + color: var(--text-tertiary); 523 + font-size: 0.85rem; 524 + margin-bottom: 12px; 525 + } 526 + "#;
+8
moderation/src/state.rs
··· 32 #[error("labeler not configured")] 33 LabelerNotConfigured, 34 35 #[error("label error: {0}")] 36 Label(#[from] LabelError), 37 ··· 50 AppError::LabelerNotConfigured => { 51 (StatusCode::SERVICE_UNAVAILABLE, "LabelerNotConfigured") 52 } 53 AppError::Label(_) => (StatusCode::INTERNAL_SERVER_ERROR, "LabelError"), 54 AppError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "DatabaseError"), 55 AppError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, "IoError"),
··· 32 #[error("labeler not configured")] 33 LabelerNotConfigured, 34 35 + #[error("bad request: {0}")] 36 + BadRequest(String), 37 + 38 + #[error("not found: {0}")] 39 + NotFound(String), 40 + 41 #[error("label error: {0}")] 42 Label(#[from] LabelError), 43 ··· 56 AppError::LabelerNotConfigured => { 57 (StatusCode::SERVICE_UNAVAILABLE, "LabelerNotConfigured") 58 } 59 + AppError::BadRequest(_) => (StatusCode::BAD_REQUEST, "BadRequest"), 60 + AppError::NotFound(_) => (StatusCode::NOT_FOUND, "NotFound"), 61 AppError::Label(_) => (StatusCode::INTERNAL_SERVER_ERROR, "LabelError"), 62 AppError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "DatabaseError"), 63 AppError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, "IoError"),
+41
redis/README.md
···
··· 1 + # plyr-redis 2 + 3 + self-hosted Redis on Fly.io for docket background tasks. 4 + 5 + ## deployment 6 + 7 + ```bash 8 + # first time: create app and volume 9 + fly apps create plyr-redis 10 + fly volumes create redis_data --region iad --size 1 -a plyr-redis 11 + 12 + # deploy 13 + fly deploy -a plyr-redis 14 + ``` 15 + 16 + ## connecting from other fly apps 17 + 18 + Redis is accessible via Fly's private network: 19 + 20 + ``` 21 + redis://plyr-redis.internal:6379 22 + ``` 23 + 24 + Update `DOCKET_URL` secret on backend apps: 25 + 26 + ```bash 27 + fly secrets set DOCKET_URL=redis://plyr-redis.internal:6379 -a relay-api 28 + fly secrets set DOCKET_URL=redis://plyr-redis.internal:6379 -a relay-api-staging 29 + ``` 30 + 31 + ## configuration 32 + 33 + - **persistence**: AOF (append-only file) enabled for durability 34 + - **memory**: 200MB max with LRU eviction 35 + - **storage**: 1GB volume mounted at /data 36 + 37 + ## cost 38 + 39 + ~$1.94/month (256MB shared-cpu VM) + $0.15/month (1GB volume) = ~$2.09/month 40 + 41 + vs. Upstash pay-as-you-go which was costing ~$75/month at 37M commands.
+29
redis/fly.staging.toml
···
··· 1 + app = "plyr-redis-stg" 2 + primary_region = "iad" 3 + 4 + [build] 5 + image = "redis:7-alpine" 6 + 7 + [mounts] 8 + source = "redis_data" 9 + destination = "/data" 10 + 11 + [env] 12 + # redis config via command line args in [processes] 13 + 14 + [processes] 15 + app = "--appendonly yes --maxmemory 200mb --maxmemory-policy allkeys-lru" 16 + 17 + [[services]] 18 + protocol = "tcp" 19 + internal_port = 6379 20 + processes = ["app"] 21 + 22 + # only accessible within private network 23 + [[services.ports]] 24 + port = 6379 25 + 26 + [[vm]] 27 + memory = "256mb" 28 + cpu_kind = "shared" 29 + cpus = 1
+29
redis/fly.toml
···
··· 1 + app = "plyr-redis" 2 + primary_region = "iad" 3 + 4 + [build] 5 + image = "redis:7-alpine" 6 + 7 + [mounts] 8 + source = "redis_data" 9 + destination = "/data" 10 + 11 + [env] 12 + # redis config via command line args in [processes] 13 + 14 + [processes] 15 + app = "--appendonly yes --maxmemory 200mb --maxmemory-policy allkeys-lru" 16 + 17 + [[services]] 18 + protocol = "tcp" 19 + internal_port = 6379 20 + processes = ["app"] 21 + 22 + # only accessible within private network 23 + [[services.ports]] 24 + port = 6379 25 + 26 + [[vm]] 27 + memory = "256mb" 28 + cpu_kind = "shared" 29 + cpus = 1
+7 -3
scripts/costs/export_costs.py
··· 35 AUDD_COST_PER_1000 = 5.00 # $5 per 1000 requests 36 AUDD_BASE_COST = 5.00 # $5/month base 37 38 - # fixed monthly costs (updated 2025-12-16) 39 # fly.io: manually updated from cost explorer (TODO: use fly billing API) 40 # neon: fixed $5/month 41 # cloudflare: mostly free tier 42 FIXED_COSTS = { 43 "fly_io": { 44 "breakdown": { ··· 116 import asyncpg 117 118 billing_start = get_billing_period_start() 119 120 conn = await asyncpg.connect(db_url) 121 try: 122 # get totals: scans, flagged, and derived API requests from duration 123 row = await conn.fetchrow( 124 """ 125 SELECT ··· 139 total_requests = row["total_requests"] 140 total_seconds = row["total_seconds"] 141 142 - # daily breakdown for chart - now includes requests derived from duration 143 daily = await conn.fetch( 144 """ 145 SELECT ··· 153 GROUP BY DATE(cs.scanned_at) 154 ORDER BY date 155 """, 156 - billing_start, 157 AUDD_SECONDS_PER_REQUEST, 158 ) 159
··· 35 AUDD_COST_PER_1000 = 5.00 # $5 per 1000 requests 36 AUDD_BASE_COST = 5.00 # $5/month base 37 38 + # fixed monthly costs (updated 2025-12-26) 39 # fly.io: manually updated from cost explorer (TODO: use fly billing API) 40 # neon: fixed $5/month 41 # cloudflare: mostly free tier 42 + # redis: self-hosted on fly (included in fly_io costs) 43 FIXED_COSTS = { 44 "fly_io": { 45 "breakdown": { ··· 117 import asyncpg 118 119 billing_start = get_billing_period_start() 120 + # 30 days of history for the daily chart (independent of billing cycle) 121 + history_start = datetime.now() - timedelta(days=30) 122 123 conn = await asyncpg.connect(db_url) 124 try: 125 # get totals: scans, flagged, and derived API requests from duration 126 + # uses billing period for accurate cost calculation 127 row = await conn.fetchrow( 128 """ 129 SELECT ··· 143 total_requests = row["total_requests"] 144 total_seconds = row["total_seconds"] 145 146 + # daily breakdown for chart - 30 days of history for flexible views 147 daily = await conn.fetch( 148 """ 149 SELECT ··· 157 GROUP BY DATE(cs.scanned_at) 158 ORDER BY date 159 """, 160 + history_start, 161 AUDD_SECONDS_PER_REQUEST, 162 ) 163
+4 -6
scripts/docket_runs.py
··· 38 url = os.environ.get("DOCKET_URL_STAGING") 39 if not url: 40 print("error: DOCKET_URL_STAGING not set") 41 - print( 42 - "hint: export DOCKET_URL_STAGING=rediss://default:xxx@xxx.upstash.io:6379" 43 - ) 44 return 1 45 elif args.env == "production": 46 url = os.environ.get("DOCKET_URL_PRODUCTION") 47 if not url: 48 print("error: DOCKET_URL_PRODUCTION not set") 49 - print( 50 - "hint: export DOCKET_URL_PRODUCTION=rediss://default:xxx@xxx.upstash.io:6379" 51 - ) 52 return 1 53 54 print(f"connecting to {args.env}...")
··· 38 url = os.environ.get("DOCKET_URL_STAGING") 39 if not url: 40 print("error: DOCKET_URL_STAGING not set") 41 + print("hint: flyctl proxy 6380:6379 -a plyr-redis-stg") 42 + print(" export DOCKET_URL_STAGING=redis://localhost:6380") 43 return 1 44 elif args.env == "production": 45 url = os.environ.get("DOCKET_URL_PRODUCTION") 46 if not url: 47 print("error: DOCKET_URL_PRODUCTION not set") 48 + print("hint: flyctl proxy 6381:6379 -a plyr-redis") 49 + print(" export DOCKET_URL_PRODUCTION=redis://localhost:6381") 50 return 1 51 52 print(f"connecting to {args.env}...")
+229
scripts/migrate_sensitive_images.py
···
··· 1 + #!/usr/bin/env -S uv run --script --quiet --with-editable=backend 2 + # /// script 3 + # requires-python = ">=3.12" 4 + # dependencies = [ 5 + # "httpx", 6 + # "pydantic-settings", 7 + # "asyncpg", 8 + # "sqlalchemy[asyncio]", 9 + # ] 10 + # /// 11 + """migrate sensitive images from backend database to moderation service. 12 + 13 + this script reads sensitive images from the backend database and creates 14 + them in the moderation service. after migration, the backend will proxy 15 + sensitive image requests to the moderation service. 16 + 17 + usage: 18 + uv run scripts/migrate_sensitive_images.py --env prod --dry-run 19 + uv run scripts/migrate_sensitive_images.py --env prod 20 + 21 + environment variables (set in .env or export): 22 + PROD_DATABASE_URL - production database connection string 23 + STAGING_DATABASE_URL - staging database connection string 24 + DEV_DATABASE_URL - development database connection string 25 + MODERATION_SERVICE_URL - URL of moderation service 26 + MODERATION_AUTH_TOKEN - auth token for moderation service 27 + """ 28 + 29 + import argparse 30 + import asyncio 31 + import os 32 + from typing import Literal 33 + 34 + import httpx 35 + from pydantic import Field 36 + from pydantic_settings import BaseSettings, SettingsConfigDict 37 + from sqlalchemy import text 38 + from sqlalchemy.ext.asyncio import create_async_engine 39 + 40 + Environment = Literal["dev", "staging", "prod"] 41 + 42 + 43 + class MigrationSettings(BaseSettings): 44 + """settings for migration script.""" 45 + 46 + model_config = SettingsConfigDict( 47 + env_file=".env", 48 + case_sensitive=False, 49 + extra="ignore", 50 + ) 51 + 52 + dev_database_url: str = Field(default="", validation_alias="DEV_DATABASE_URL") 53 + staging_database_url: str = Field( 54 + default="", validation_alias="STAGING_DATABASE_URL" 55 + ) 56 + prod_database_url: str = Field(default="", validation_alias="PROD_DATABASE_URL") 57 + 58 + moderation_service_url: str = Field( 59 + default="https://moderation.plyr.fm", 60 + validation_alias="MODERATION_SERVICE_URL", 61 + ) 62 + moderation_auth_token: str = Field( 63 + default="", validation_alias="MODERATION_AUTH_TOKEN" 64 + ) 65 + 66 + def get_database_url(self, env: Environment) -> str: 67 + """get database URL for environment.""" 68 + urls = { 69 + "dev": self.dev_database_url, 70 + "staging": self.staging_database_url, 71 + "prod": self.prod_database_url, 72 + } 73 + url = urls.get(env, "") 74 + if not url: 75 + raise ValueError(f"no database URL configured for {env}") 76 + # ensure asyncpg driver is used 77 + if url.startswith("postgresql://"): 78 + url = url.replace("postgresql://", "postgresql+asyncpg://", 1) 79 + return url 80 + 81 + def get_moderation_url(self, env: Environment) -> str: 82 + """get moderation service URL for environment.""" 83 + if env == "dev": 84 + return os.getenv("DEV_MODERATION_URL", "http://localhost:8002") 85 + elif env == "staging": 86 + return os.getenv("STAGING_MODERATION_URL", "https://moderation-stg.plyr.fm") 87 + else: 88 + return self.moderation_service_url 89 + 90 + 91 + async def fetch_sensitive_images(db_url: str) -> list[dict]: 92 + """fetch all sensitive images from backend database.""" 93 + engine = create_async_engine(db_url) 94 + 95 + async with engine.begin() as conn: 96 + result = await conn.execute( 97 + text( 98 + """ 99 + SELECT id, image_id, url, reason, flagged_at, flagged_by 100 + FROM sensitive_images 101 + ORDER BY id 102 + """ 103 + ) 104 + ) 105 + rows = result.fetchall() 106 + 107 + await engine.dispose() 108 + 109 + return [ 110 + { 111 + "id": row[0], 112 + "image_id": row[1], 113 + "url": row[2], 114 + "reason": row[3], 115 + "flagged_at": row[4].isoformat() if row[4] else None, 116 + "flagged_by": row[5], 117 + } 118 + for row in rows 119 + ] 120 + 121 + 122 + async def migrate_to_moderation_service( 123 + images: list[dict], 124 + moderation_url: str, 125 + auth_token: str, 126 + dry_run: bool = False, 127 + ) -> tuple[int, int]: 128 + """migrate images to moderation service. 129 + 130 + returns: 131 + tuple of (success_count, error_count) 132 + """ 133 + success_count = 0 134 + error_count = 0 135 + 136 + headers = {"X-Moderation-Key": auth_token} 137 + 138 + async with httpx.AsyncClient(timeout=30.0) as client: 139 + for image in images: 140 + payload = { 141 + "image_id": image["image_id"], 142 + "url": image["url"], 143 + "reason": image["reason"], 144 + "flagged_by": image["flagged_by"], 145 + } 146 + 147 + if dry_run: 148 + print(f" [dry-run] would migrate: {payload}") 149 + success_count += 1 150 + continue 151 + 152 + try: 153 + response = await client.post( 154 + f"{moderation_url}/admin/sensitive-images", 155 + json=payload, 156 + headers=headers, 157 + ) 158 + response.raise_for_status() 159 + result = response.json() 160 + print(f" migrated id={image['id']} -> moderation id={result['id']}") 161 + success_count += 1 162 + except Exception as e: 163 + print(f" ERROR migrating id={image['id']}: {e}") 164 + error_count += 1 165 + 166 + return success_count, error_count 167 + 168 + 169 + async def main() -> None: 170 + parser = argparse.ArgumentParser( 171 + description="migrate sensitive images to moderation service" 172 + ) 173 + parser.add_argument( 174 + "--env", 175 + choices=["dev", "staging", "prod"], 176 + required=True, 177 + help="environment to migrate", 178 + ) 179 + parser.add_argument( 180 + "--dry-run", 181 + action="store_true", 182 + help="show what would be migrated without making changes", 183 + ) 184 + args = parser.parse_args() 185 + 186 + settings = MigrationSettings() 187 + 188 + print(f"migrating sensitive images for {args.env}") 189 + if args.dry_run: 190 + print("(dry run - no changes will be made)") 191 + 192 + # fetch from backend database 193 + db_url = settings.get_database_url(args.env) 194 + print("\nfetching from backend database...") 195 + images = await fetch_sensitive_images(db_url) 196 + print(f"found {len(images)} sensitive images") 197 + 198 + if not images: 199 + print("nothing to migrate") 200 + return 201 + 202 + # migrate to moderation service 203 + moderation_url = settings.get_moderation_url(args.env) 204 + print(f"\nmigrating to moderation service at {moderation_url}...") 205 + 206 + if not settings.moderation_auth_token and not args.dry_run: 207 + print("ERROR: MODERATION_AUTH_TOKEN not set") 208 + return 209 + 210 + success, errors = await migrate_to_moderation_service( 211 + images, 212 + moderation_url, 213 + settings.moderation_auth_token, 214 + dry_run=args.dry_run, 215 + ) 216 + 217 + print(f"\ndone: {success} migrated, {errors} errors") 218 + 219 + if not args.dry_run and errors == 0: 220 + print( 221 + "\nnext steps:\n" 222 + " 1. verify data in moderation service: GET /sensitive-images\n" 223 + " 2. update backend to proxy to moderation service\n" 224 + " 3. optionally drop sensitive_images table from backend db" 225 + ) 226 + 227 + 228 + if __name__ == "__main__": 229 + asyncio.run(main())
+348
scripts/moderation_loop.py
···
··· 1 + #!/usr/bin/env -S uv run --script --quiet 2 + # /// script 3 + # requires-python = ">=3.12" 4 + # dependencies = [ 5 + # "pydantic-ai>=0.1.0", 6 + # "anthropic", 7 + # "httpx", 8 + # "pydantic>=2.0", 9 + # "pydantic-settings", 10 + # "atproto>=0.0.55", 11 + # "rich", 12 + # ] 13 + # /// 14 + """autonomous moderation loop for plyr.fm. 15 + 16 + workflow: 17 + 1. fetch pending flags from moderation service 18 + 2. analyze each flag with LLM (FALSE_POSITIVE, VIOLATION, NEEDS_HUMAN) 19 + 3. auto-resolve false positives 20 + 4. create review batch for needs_human flags 21 + 5. send DM with link to review UI 22 + 23 + the review UI handles human decisions - DM is just a notification channel. 24 + """ 25 + 26 + import argparse 27 + import asyncio 28 + from dataclasses import dataclass, field 29 + from pathlib import Path 30 + 31 + import httpx 32 + from atproto import AsyncClient, models 33 + from pydantic import BaseModel, Field 34 + from pydantic_ai import Agent 35 + from pydantic_ai.models.anthropic import AnthropicModel 36 + from pydantic_settings import BaseSettings, SettingsConfigDict 37 + from rich.console import Console 38 + 39 + console = Console() 40 + 41 + 42 + class LoopSettings(BaseSettings): 43 + model_config = SettingsConfigDict( 44 + env_file=Path(__file__).parent.parent / ".env", 45 + case_sensitive=False, 46 + extra="ignore", 47 + ) 48 + moderation_service_url: str = Field( 49 + default="https://moderation.plyr.fm", validation_alias="MODERATION_SERVICE_URL" 50 + ) 51 + moderation_auth_token: str = Field( 52 + default="", validation_alias="MODERATION_AUTH_TOKEN" 53 + ) 54 + anthropic_api_key: str = Field(default="", validation_alias="ANTHROPIC_API_KEY") 55 + anthropic_model: str = Field( 56 + default="claude-sonnet-4-20250514", validation_alias="ANTHROPIC_MODEL" 57 + ) 58 + bot_handle: str = Field(default="", validation_alias="NOTIFY_BOT_HANDLE") 59 + bot_password: str = Field(default="", validation_alias="NOTIFY_BOT_PASSWORD") 60 + recipient_handle: str = Field( 61 + default="", validation_alias="NOTIFY_RECIPIENT_HANDLE" 62 + ) 63 + 64 + 65 + class FlagAnalysis(BaseModel): 66 + """result of analyzing a single flag.""" 67 + 68 + uri: str 69 + category: str = Field(description="FALSE_POSITIVE, VIOLATION, or NEEDS_HUMAN") 70 + reason: str 71 + 72 + 73 + @dataclass 74 + class DMClient: 75 + handle: str 76 + password: str 77 + recipient_handle: str 78 + _client: AsyncClient = field(init=False, repr=False) 79 + _dm_client: AsyncClient = field(init=False, repr=False) 80 + _recipient_did: str = field(init=False, repr=False) 81 + _convo_id: str = field(init=False, repr=False) 82 + 83 + async def setup(self) -> None: 84 + self._client = AsyncClient() 85 + await self._client.login(self.handle, self.password) 86 + self._dm_client = self._client.with_bsky_chat_proxy() 87 + profile = await self._client.app.bsky.actor.get_profile( 88 + {"actor": self.recipient_handle} 89 + ) 90 + self._recipient_did = profile.did 91 + convo = await self._dm_client.chat.bsky.convo.get_convo_for_members( 92 + models.ChatBskyConvoGetConvoForMembers.Params(members=[self._recipient_did]) 93 + ) 94 + self._convo_id = convo.convo.id 95 + 96 + async def get_messages(self, limit: int = 30) -> list[dict]: 97 + response = await self._dm_client.chat.bsky.convo.get_messages( 98 + models.ChatBskyConvoGetMessages.Params(convo_id=self._convo_id, limit=limit) 99 + ) 100 + return [ 101 + { 102 + "text": m.text, 103 + "is_bot": m.sender.did != self._recipient_did, 104 + "sent_at": getattr(m, "sent_at", None), 105 + } 106 + for m in response.messages 107 + if hasattr(m, "text") and hasattr(m, "sender") 108 + ] 109 + 110 + async def send(self, text: str) -> None: 111 + await self._dm_client.chat.bsky.convo.send_message( 112 + models.ChatBskyConvoSendMessage.Data( 113 + convo_id=self._convo_id, 114 + message=models.ChatBskyConvoDefs.MessageInput(text=text), 115 + ) 116 + ) 117 + 118 + 119 + @dataclass 120 + class PlyrClient: 121 + """client for checking track existence in plyr.fm.""" 122 + 123 + env: str = "prod" 124 + _client: httpx.AsyncClient = field(init=False, repr=False) 125 + 126 + def __post_init__(self) -> None: 127 + base_url = { 128 + "prod": "https://api.plyr.fm", 129 + "staging": "https://api-stg.plyr.fm", 130 + "dev": "http://localhost:8001", 131 + }.get(self.env, "https://api.plyr.fm") 132 + self._client = httpx.AsyncClient(base_url=base_url, timeout=10.0) 133 + 134 + async def close(self) -> None: 135 + await self._client.aclose() 136 + 137 + async def track_exists(self, track_id: int) -> bool: 138 + """check if a track exists (returns False if 404).""" 139 + try: 140 + r = await self._client.get(f"/tracks/{track_id}") 141 + return r.status_code == 200 142 + except Exception: 143 + return True # assume exists on error (don't accidentally delete labels) 144 + 145 + 146 + @dataclass 147 + class ModClient: 148 + base_url: str 149 + auth_token: str 150 + _client: httpx.AsyncClient = field(init=False, repr=False) 151 + 152 + def __post_init__(self) -> None: 153 + self._client = httpx.AsyncClient( 154 + base_url=self.base_url, 155 + headers={"X-Moderation-Key": self.auth_token}, 156 + timeout=30.0, 157 + ) 158 + 159 + async def close(self) -> None: 160 + await self._client.aclose() 161 + 162 + async def list_pending(self) -> list[dict]: 163 + r = await self._client.get("/admin/flags", params={"filter": "pending"}) 164 + r.raise_for_status() 165 + return r.json().get("tracks", []) 166 + 167 + async def resolve(self, uri: str, reason: str, notes: str = "") -> None: 168 + r = await self._client.post( 169 + "/admin/resolve", 170 + json={ 171 + "uri": uri, 172 + "val": "copyright-violation", 173 + "reason": reason, 174 + "notes": notes, 175 + }, 176 + ) 177 + r.raise_for_status() 178 + 179 + async def create_batch( 180 + self, uris: list[str], created_by: str | None = None 181 + ) -> dict: 182 + """create a review batch and return {id, url, flag_count}.""" 183 + r = await self._client.post( 184 + "/admin/batches", 185 + json={"uris": uris, "created_by": created_by}, 186 + ) 187 + r.raise_for_status() 188 + return r.json() 189 + 190 + 191 + def get_header(env: str) -> str: 192 + return f"[PLYR-MOD:{env.upper()}]" 193 + 194 + 195 + def create_flag_analyzer(api_key: str, model: str) -> Agent[None, list[FlagAnalysis]]: 196 + from pydantic_ai.providers.anthropic import AnthropicProvider 197 + 198 + return Agent( 199 + model=AnthropicModel(model, provider=AnthropicProvider(api_key=api_key)), 200 + output_type=list[FlagAnalysis], 201 + system_prompt="""\ 202 + analyze each copyright flag. categorize as: 203 + - FALSE_POSITIVE: fingerprint noise, uploader is the artist, unrelated matches 204 + - VIOLATION: clearly copyrighted commercial content 205 + - NEEDS_HUMAN: ambiguous, need human review 206 + 207 + return a FlagAnalysis for each flag with uri, category, and brief reason. 208 + """, 209 + ) 210 + 211 + 212 + async def run_loop( 213 + dry_run: bool = False, limit: int | None = None, env: str = "prod" 214 + ) -> None: 215 + settings = LoopSettings() 216 + for attr in [ 217 + "moderation_auth_token", 218 + "anthropic_api_key", 219 + "bot_handle", 220 + "bot_password", 221 + "recipient_handle", 222 + ]: 223 + if not getattr(settings, attr): 224 + console.print(f"[red]missing {attr}[/red]") 225 + return 226 + 227 + console.print(f"[bold]moderation loop[/bold] ({settings.anthropic_model})") 228 + if dry_run: 229 + console.print("[yellow]DRY RUN[/yellow]") 230 + 231 + dm = DMClient(settings.bot_handle, settings.bot_password, settings.recipient_handle) 232 + mod = ModClient(settings.moderation_service_url, settings.moderation_auth_token) 233 + plyr = PlyrClient(env=env) 234 + 235 + try: 236 + await dm.setup() 237 + 238 + # get pending flags 239 + pending = await mod.list_pending() 240 + if not pending: 241 + console.print("[green]no pending flags[/green]") 242 + return 243 + 244 + console.print(f"[bold]{len(pending)} pending flags[/bold]") 245 + 246 + # check for deleted tracks and auto-resolve them 247 + console.print("[dim]checking for deleted tracks...[/dim]") 248 + active_flags = [] 249 + deleted_count = 0 250 + for flag in pending: 251 + track_id = flag.get("context", {}).get("track_id") 252 + if track_id and not await plyr.track_exists(track_id): 253 + # track was deleted - resolve the flag 254 + if not dry_run: 255 + try: 256 + await mod.resolve( 257 + flag["uri"], "content_deleted", "track no longer exists" 258 + ) 259 + console.print( 260 + f" [yellow]⌫[/yellow] deleted: {flag['uri'][-40:]}" 261 + ) 262 + deleted_count += 1 263 + except Exception as e: 264 + console.print(f" [red]✗[/red] {e}") 265 + active_flags.append(flag) 266 + else: 267 + console.print( 268 + f" [yellow]would resolve deleted:[/yellow] {flag['uri'][-40:]}" 269 + ) 270 + deleted_count += 1 271 + else: 272 + active_flags.append(flag) 273 + 274 + if deleted_count > 0: 275 + console.print(f"[yellow]{deleted_count} deleted tracks resolved[/yellow]") 276 + 277 + pending = active_flags 278 + if not pending: 279 + console.print("[green]all flags were for deleted tracks[/green]") 280 + return 281 + 282 + # analyze remaining flags 283 + if limit: 284 + pending = pending[:limit] 285 + 286 + analyzer = create_flag_analyzer( 287 + settings.anthropic_api_key, settings.anthropic_model 288 + ) 289 + desc = "\n---\n".join( 290 + f"URI: {f['uri']}\nTrack: {f.get('context', {}).get('track_title', '?')}\n" 291 + f"Uploader: @{f.get('context', {}).get('artist_handle', '?')}\n" 292 + f"Matches: {', '.join(m['artist'] for m in f.get('context', {}).get('matches', [])[:3])}" 293 + for f in pending 294 + ) 295 + result = await analyzer.run(f"analyze {len(pending)} flags:\n\n{desc}") 296 + analyses = result.output 297 + 298 + # auto-resolve false positives 299 + auto = [a for a in analyses if a.category == "FALSE_POSITIVE"] 300 + human = [a for a in analyses if a.category == "NEEDS_HUMAN"] 301 + console.print(f"analysis: {len(auto)} auto-resolve, {len(human)} need human") 302 + 303 + for a in auto: 304 + if not dry_run: 305 + try: 306 + await mod.resolve( 307 + a.uri, "fingerprint_noise", f"auto: {a.reason[:50]}" 308 + ) 309 + console.print(f" [green]✓[/green] {a.uri[-40:]}") 310 + except Exception as e: 311 + console.print(f" [red]✗[/red] {e}") 312 + 313 + # create batch and send link for needs_human (if any) 314 + if human: 315 + human_uris = [h.uri for h in human] 316 + console.print(f"[dim]creating batch for {len(human_uris)} flags...[/dim]") 317 + 318 + if not dry_run: 319 + batch = await mod.create_batch(human_uris, created_by="moderation_loop") 320 + full_url = f"{mod.base_url.rstrip('/')}{batch['url']}" 321 + msg = ( 322 + f"{get_header(env)} {batch['flag_count']} need review:\n{full_url}" 323 + ) 324 + await dm.send(msg) 325 + console.print(f"[green]sent batch {batch['id']}[/green]") 326 + else: 327 + console.print( 328 + f"[yellow]would create batch with {len(human_uris)} flags[/yellow]" 329 + ) 330 + 331 + console.print("[bold]done[/bold]") 332 + 333 + finally: 334 + await mod.close() 335 + await plyr.close() 336 + 337 + 338 + def main() -> None: 339 + parser = argparse.ArgumentParser() 340 + parser.add_argument("--dry-run", action="store_true") 341 + parser.add_argument("--limit", type=int, default=None) 342 + parser.add_argument("--env", default="prod", choices=["dev", "staging", "prod"]) 343 + args = parser.parse_args() 344 + asyncio.run(run_loop(dry_run=args.dry_run, limit=args.limit, env=args.env)) 345 + 346 + 347 + if __name__ == "__main__": 348 + main()
update.wav

This is a binary file and will not be displayed.