Compare changes

Choose any two refs to compare.

Changed files
+7985 -1982
.claude
.github
.status_history
backend
docs
frontend
lexicons
moderation
redis
scripts
+36 -5
.claude/commands/consider-review.md
··· 1 - Check the current PR for comments, reviews, AND inline review comments via the gh cli. 1 + --- 2 + description: Review PR feedback and address comments 3 + argument-hint: [PR number, optional] 4 + --- 5 + 6 + # consider review 7 + 8 + check PR feedback and address it. 9 + 10 + ## process 11 + 12 + 1. **find the PR** 13 + - if number provided: use it 14 + - otherwise: `gh pr view --json number,title,url` 15 + 16 + 2. **gather all feedback** in parallel: 17 + ```bash 18 + # top-level review comments 19 + gh pr view NNN --comments 20 + 21 + # inline code review comments 22 + gh api repos/zzstoatzz/plyr.fm/pulls/NNN/comments --jq '.[] | {path: .path, line: .line, body: .body, author: .user.login}' 2 23 3 - For example, to get the review comments for PR #246: 24 + # review status 25 + gh pr view NNN --json reviews --jq '.reviews[] | {author: .author.login, state: .state, body: .body}' 26 + ``` 4 27 5 - ```bash 6 - gh api repos/zzstoatzz/plyr.fm/pulls/246/comments --jq '.[] | {path: .path, line: .line, body: .body}' 7 - ``` 28 + 3. **summarize feedback**: 29 + - blocking issues (changes requested) 30 + - suggestions (nice to have) 31 + - questions needing response 32 + 33 + 4. **for each item**: 34 + - if code change needed: make the fix 35 + - if clarification needed: draft a response 36 + - if disagreement: explain your reasoning 37 + 38 + 5. **report** what you addressed and what needs discussion
+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 + ```
+54
.claude/commands/implement.md
··· 1 + --- 2 + description: Execute an implementation plan phase by phase 3 + argument-hint: [path to plan in docs/plans/] 4 + --- 5 + 6 + # implement 7 + 8 + execute a plan systematically, phase by phase. 9 + 10 + ## process 11 + 12 + 1. **read the plan**: $ARGUMENTS 13 + - read it fully 14 + - check for existing checkmarks (prior progress) 15 + - read all files mentioned in the plan 16 + 17 + 2. **pick up from first unchecked item** 18 + - if resuming, trust that completed work is done 19 + - start with the next pending phase 20 + 21 + 3. **implement each phase**: 22 + - make the changes described 23 + - run the success criteria checks 24 + - fix any issues before proceeding 25 + - check off completed items in the plan file 26 + 27 + 4. **pause for verification** after each phase: 28 + ``` 29 + phase N complete. 30 + 31 + automated checks passed: 32 + - [list what passed] 33 + 34 + ready for manual verification: 35 + - [list manual checks from plan] 36 + 37 + continue to phase N+1? 38 + ``` 39 + 40 + 5. **continue or stop** based on user feedback 41 + 42 + ## guidelines 43 + 44 + - follow the plan's intent, adapt to what you find 45 + - if something doesn't match the plan, stop and explain: 46 + ``` 47 + issue in phase N: 48 + expected: [what plan says] 49 + found: [actual situation] 50 + how should I proceed? 51 + ``` 52 + - run `just backend test` and `just backend lint` frequently 53 + - commit after each phase if changes are substantial 54 + - update the plan file checkboxes as you complete items
+87
.claude/commands/plan.md
··· 1 + --- 2 + description: Create an implementation plan before coding 3 + argument-hint: [issue number, description, or path to research doc] 4 + --- 5 + 6 + # plan 7 + 8 + think before coding. create an implementation plan and get alignment. 9 + 10 + ## process 11 + 12 + 1. **understand the task**: $ARGUMENTS 13 + - if issue number given, fetch it with `gh issue view` 14 + - if research doc referenced, read it fully 15 + - read any related code 16 + 17 + 2. **research if needed** - if you don't understand the problem space: 18 + - spawn sub-tasks to explore the codebase 19 + - find similar patterns we can follow 20 + - identify integration points and constraints 21 + 22 + 3. **propose approach** - present to user: 23 + ``` 24 + based on [context], I understand we need to [goal]. 25 + 26 + current state: 27 + - [what exists now] 28 + 29 + proposed approach: 30 + - [high-level strategy] 31 + 32 + questions: 33 + - [anything unclear] 34 + ``` 35 + 36 + 4. **resolve all questions** - don't proceed with open questions 37 + 38 + 5. **write the plan** to `docs/plans/YYYY-MM-DD-description.md`: 39 + 40 + ```markdown 41 + # plan: [feature/task name] 42 + 43 + **date**: YYYY-MM-DD 44 + **issue**: #NNN (if applicable) 45 + 46 + ## goal 47 + 48 + [what we're trying to accomplish] 49 + 50 + ## current state 51 + 52 + [what exists now, constraints discovered] 53 + 54 + ## not doing 55 + 56 + [explicitly out of scope] 57 + 58 + ## phases 59 + 60 + ### phase 1: [name] 61 + 62 + **changes**: 63 + - `path/to/file.py` - [what to change] 64 + - `another/file.ts` - [what to change] 65 + 66 + **success criteria**: 67 + - [ ] tests pass: `just backend test` 68 + - [ ] [specific behavior to verify] 69 + 70 + ### phase 2: [name] 71 + ... 72 + 73 + ## testing 74 + 75 + - [key scenarios to test] 76 + - [edge cases] 77 + ``` 78 + 79 + 6. **ask for confirmation** before finalizing 80 + 81 + ## guidelines 82 + 83 + - no open questions in the final plan - resolve everything first 84 + - keep phases small and testable 85 + - include specific file paths 86 + - success criteria should be verifiable 87 + - if the task is small, skip the formal plan and just do it
+63
.claude/commands/research.md
··· 1 + --- 2 + description: Research a topic thoroughly and persist findings 3 + argument-hint: [topic or question to research] 4 + --- 5 + 6 + # research 7 + 8 + deep dive on a topic, persist findings to `docs/research/`. 9 + 10 + ## process 11 + 12 + 1. **understand the question**: $ARGUMENTS 13 + 14 + 2. **gather context** - spawn sub-tasks in parallel to: 15 + - grep for relevant keywords 16 + - find related files and directories 17 + - read key implementation files 18 + - check git history if relevant 19 + 20 + 3. **synthesize findings** - after sub-tasks complete: 21 + - summarize what you learned 22 + - include file:line references for key discoveries 23 + - note any open questions or uncertainties 24 + 25 + 4. **persist to docs/research/** - write findings to `docs/research/YYYY-MM-DD-topic.md`: 26 + 27 + ```markdown 28 + # research: [topic] 29 + 30 + **date**: YYYY-MM-DD 31 + **question**: [the original question] 32 + 33 + ## summary 34 + 35 + [2-3 sentences on what you found] 36 + 37 + ## findings 38 + 39 + ### [area 1] 40 + - finding with reference (`file.py:123`) 41 + - another finding 42 + 43 + ### [area 2] 44 + ... 45 + 46 + ## code references 47 + 48 + - `path/to/file.py:45` - description 49 + - `another/file.ts:12-30` - description 50 + 51 + ## open questions 52 + 53 + - [anything unresolved] 54 + ``` 55 + 56 + 5. **present summary** to the user with key takeaways 57 + 58 + ## guidelines 59 + 60 + - spawn sub-tasks for broad searches, read files yourself for focused analysis 61 + - always include file:line references - make findings actionable 62 + - be honest about what you don't know 63 + - keep the output concise - this is a working document, not a thesis
+2
.claude/commands/status-update.md
··· 11 11 - deployment/infrastructure changes 12 12 - incidents and their resolutions 13 13 14 + **tip**: after running `/deploy`, consider running `/status-update` to document what shipped. 15 + 14 16 ## how to update 15 17 16 18 1. add a new subsection under `## recent work` with today's date
+47 -3
.github/workflows/check-rust.yml
··· 11 11 contents: read 12 12 13 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 + 14 33 check: 15 34 name: cargo check 16 35 runs-on: ubuntu-latest 17 36 timeout-minutes: 15 37 + needs: changes 18 38 19 39 strategy: 40 + fail-fast: false 20 41 matrix: 21 - service: [moderation, transcoder] 42 + include: 43 + - service: moderation 44 + changed: ${{ needs.changes.outputs.moderation }} 45 + - service: transcoder 46 + changed: ${{ needs.changes.outputs.transcoder }} 22 47 23 48 steps: 24 49 - uses: actions/checkout@v4 50 + if: matrix.changed == 'true' 25 51 26 52 - name: install rust toolchain 53 + if: matrix.changed == 'true' 27 54 uses: dtolnay/rust-toolchain@stable 28 55 29 56 - name: cache cargo 57 + if: matrix.changed == 'true' 30 58 uses: Swatinem/rust-cache@v2 31 59 with: 32 60 workspaces: ${{ matrix.service }} 33 61 34 62 - name: cargo check 63 + if: matrix.changed == 'true' 35 64 working-directory: ${{ matrix.service }} 36 65 run: cargo check --release 37 66 67 + - name: skip (no changes) 68 + if: matrix.changed != 'true' 69 + run: echo "skipping ${{ matrix.service }} - no changes" 70 + 38 71 docker-build: 39 72 name: docker build 40 73 runs-on: ubuntu-latest 41 74 timeout-minutes: 10 42 - needs: check 75 + needs: [changes, check] 43 76 44 77 strategy: 78 + fail-fast: false 45 79 matrix: 46 - service: [moderation, transcoder] 80 + include: 81 + - service: moderation 82 + changed: ${{ needs.changes.outputs.moderation }} 83 + - service: transcoder 84 + changed: ${{ needs.changes.outputs.transcoder }} 47 85 48 86 steps: 49 87 - uses: actions/checkout@v4 88 + if: matrix.changed == 'true' 50 89 51 90 - name: build docker image 91 + if: matrix.changed == 'true' 52 92 working-directory: ${{ matrix.service }} 53 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 412 - 6 original artists (people uploading their own distributed music) 413 413 414 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 119 โ”‚ โ””โ”€โ”€ src/routes/ # pages 120 120 โ”œโ”€โ”€ moderation/ # Rust labeler service 121 121 โ”œโ”€โ”€ transcoder/ # Rust audio service 122 + โ”œโ”€โ”€ redis/ # self-hosted Redis config 122 123 โ”œโ”€โ”€ docs/ # documentation 123 124 โ””โ”€โ”€ justfile # task runner 124 125 ``` ··· 128 129 <details> 129 130 <summary>costs</summary> 130 131 131 - ~$35-40/month: 132 - - fly.io backend (prod + staging): ~$10/month 133 - - fly.io transcoder: ~$0-5/month (auto-scales to zero) 132 + ~$20/month: 133 + - fly.io (backend + redis + moderation): ~$14/month 134 134 - neon postgres: $5/month 135 - - audd audio fingerprinting: ~$10/month 136 - - cloudflare (pages + r2): ~$0.16/month 135 + - cloudflare (pages + r2): ~$1/month 136 + - audd audio fingerprinting: $5-10/month (usage-based) 137 + 138 + live dashboard: https://plyr.fm/costs 137 139 138 140 </details> 139 141
+104 -228
STATUS.md
··· 47 47 48 48 ### December 2025 49 49 50 - #### offline mode foundation (PRs #610-611, Dec 17) 50 + #### self-hosted redis (PR #674-675, Dec 30) 51 51 52 - **experimental offline playback**: 53 - - new storage layer using Cache API for audio bytes + IndexedDB for metadata 54 - - `GET /audio/{file_id}/url` backend endpoint returns direct R2 URLs for client-side caching 55 - - "auto-download liked" toggle in experimental settings section 56 - - when enabled, bulk-downloads all liked tracks and auto-downloads future likes 57 - - Player checks for cached audio before streaming from R2 58 - - works offline once tracks are downloaded 59 - 60 - **robustness improvements**: 61 - - IndexedDB connections properly closed after each operation 62 - - concurrent downloads deduplicated via in-flight promise tracking 63 - - stale metadata cleanup when cache entries are missing 64 - 65 - --- 66 - 67 - #### visual customization (PRs #595-596, Dec 16) 68 - 69 - **custom backgrounds** (PR #595): 70 - - users can set a custom background image URL in settings with optional tiling 71 - - new "playing artwork as background" toggle - uses current track's artwork as blurred page background 72 - - glass effect styling for track items (translucent backgrounds, subtle shadows) 73 - - new `ui_settings` JSONB column in preferences for extensible UI settings 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 74 57 75 - **bug fix** (PR #596): 76 - - removed 3D wheel scroll effect that was blocking like/share button clicks 77 - - root cause: `translateZ` transforms created z-index stacking that intercepted pointer events 58 + **no state migration needed** - docket stores ephemeral task queue data, job progress lives in postgres. 78 59 79 60 --- 80 61 81 - #### performance & UX polish (PRs #586-593, Dec 14-15) 82 - 83 - **performance improvements** (PRs #590-591): 84 - - removed moderation service call from `/tracks/` listing endpoint 85 - - removed copyright check from tag listing endpoint 86 - - faster page loads for track feeds 87 - 88 - **moderation agent** (PRs #586, #588): 89 - - added moderation agent script with audit trail support 90 - - improved moderation prompt and UI layout 91 - 92 - **bug fixes** (PRs #589, #592, #593): 93 - - fixed liked state display on playlist detail page 94 - - preserved album track order during ATProto sync 95 - - made header sticky on scroll for better mobile navigation 96 - 97 - **iOS Safari fixes** (PRs #573-576): 98 - - fixed AddToMenu visibility issue on iOS Safari 99 - - menu now correctly opens upward when near viewport bottom 100 - 101 - --- 102 - 103 - #### mobile UI polish & background task expansion (PRs #558-572, Dec 10-12) 104 - 105 - **background task expansion** (PRs #558, #561): 106 - - moved like/unlike and comment PDS writes to docket background tasks 107 - - API responses now immediate; PDS sync happens asynchronously 108 - - added targeted album list sync background task for ATProto record updates 109 - 110 - **performance caching** (PR #566): 111 - - added Redis cache for copyright label lookups (5-minute TTL) 112 - - fixed 2-3s latency spikes on `/tracks/` endpoint 113 - - batch operations via `mget`/pipeline for efficiency 114 - 115 - **mobile UX improvements** (PRs #569, #572): 116 - - mobile action menus now open from top with all actions visible 117 - - UI polish for album and artist pages on small screens 118 - 119 - **misc** (PRs #559, #562, #563, #570): 120 - - reduced docket Redis polling from 250ms to 5s (lower resource usage) 121 - - added atprotofans support link mode for ko-fi integration 122 - - added alpha badge to header branding 123 - - fixed web manifest ID for PWA stability 124 - 125 - --- 126 - 127 - #### confidential OAuth client (PRs #578, #580-582, Dec 12-13) 128 - 129 - **confidential client support** (PR #578): 130 - - implemented ATProto OAuth confidential client using `private_key_jwt` authentication 131 - - when `OAUTH_JWK` is configured, plyr.fm authenticates with a cryptographic key 132 - - confidential clients earn 180-day refresh tokens (vs 2-week for public clients) 133 - - added `/.well-known/jwks.json` endpoint for public key discovery 134 - - updated `/oauth-client-metadata.json` with confidential client fields 62 + #### supporter-gated content (PR #637, Dec 22-23) 135 63 136 - **bug fixes** (PRs #580-582): 137 - - fixed client assertion JWT to use Authorization Server's issuer as `aud` claim (not token endpoint URL) 138 - - fixed JWKS endpoint to preserve `kid` field from original JWK 139 - - fixed `OAuthClient` to pass `client_secret_kid` for JWT header 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 140 68 141 - **atproto fork updates** (zzstoatzz/atproto#6, #7): 142 - - added `issuer` parameter to `_make_token_request()` for correct `aud` claim 143 - - added `client_secret_kid` parameter to include `kid` in client assertion JWT header 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`) 144 75 145 - **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. 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 146 80 147 81 --- 148 82 149 - #### pagination & album management (PRs #550-554, Dec 9-10) 83 + #### supporter badges (PR #627, Dec 21-22) 150 84 151 - **tracks list pagination** (PR #554): 152 - - cursor-based pagination on `/tracks/` endpoint (default 50 per page) 153 - - infinite scroll on homepage using native IntersectionObserver 154 - - zero new dependencies - uses browser APIs only 155 - - pagination state persisted to localStorage for fast subsequent loads 156 - 157 - **album management improvements** (PRs #550-552, #557): 158 - - album delete and track reorder fixes 159 - - album page edit mode matching playlist UX (inline title editing, cover upload) 160 - - optimistic UI updates for album title changes (instant feedback) 161 - - ATProto record sync when album title changes (updates all track records + list record) 162 - - fixed album slug sync on rename (prevented duplicate albums when adding tracks) 163 - 164 - **playlist show on profile** (PR #553): 165 - - restored "show on profile" toggle that was lost during inline editing refactor 166 - - users can now control whether playlists appear on their public profile 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 167 89 168 90 --- 169 91 170 - #### public cost dashboard (PRs #548-549, Dec 9) 92 + #### rate limit moderation endpoint (PR #629, Dec 21) 171 93 172 - - `/costs` page showing live platform infrastructure costs 173 - - daily export to R2 via GitHub Action, proxied through `/stats/costs` endpoint 174 - - dedicated `plyr-stats` R2 bucket with public access (shared across environments) 175 - - includes fly.io, neon, cloudflare, and audd API costs 176 - - ko-fi integration for community support 177 - 178 - #### docket background tasks & concurrent exports (PRs #534-546, Dec 9) 179 - 180 - **docket integration** (PRs #534, #536, #539): 181 - - migrated background tasks from inline asyncio to docket (Redis-backed task queue) 182 - - copyright scanning, media export, ATProto sync, and teal scrobbling now run via docket 183 - - graceful fallback to asyncio for local development without Redis 184 - - parallel test execution with xdist template databases (#540) 185 - 186 - **concurrent export downloads** (PR #545): 187 - - exports now download tracks in parallel (up to 4 concurrent) instead of sequentially 188 - - significantly faster for users with many tracks or large files 189 - - zip creation remains sequential (zipfile constraint) 190 - 191 - **ATProto refactor** (PR #534): 192 - - reorganized ATProto record code into `_internal/atproto/records/` by lexicon namespace 193 - - extracted `client.py` for low-level PDS operations 194 - - cleaner separation between plyr.fm and teal.fm lexicons 195 - 196 - **documentation & observability**: 197 - - AudD API cost tracking dashboard (#546) 198 - - promoted runbooks from sandbox to `docs/runbooks/` 199 - - updated CLAUDE.md files across the codebase 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. 200 95 201 96 --- 202 97 203 - #### artist support links & inline playlist editing (PRs #520-532, Dec 8) 204 - 205 - **artist support link** (PR #532): 206 - - artists can set a support URL (Ko-fi, Patreon, etc.) in their portal profile 207 - - support link displays as a button on artist profile pages next to the share button 208 - - URLs validated to require https:// prefix 98 + #### end-of-year sprint planning (PR #626, Dec 20) 209 99 210 - **inline playlist editing** (PR #531): 211 - - edit playlist name and description directly on playlist detail page 212 - - click-to-upload cover art replacement without modal 213 - - cleaner UX - no more edit modal popup 100 + **focus**: two foundational systems need solid experimental implementations by 2026. 214 101 215 - **platform stats enhancements** (PRs #522, #528): 216 - - total duration displayed in platform stats (e.g., "42h 15m of music") 217 - - duration shown per artist in analytics section 218 - - combined stats and search into single centered container for cleaner layout 219 - 220 - **navigation & data loading fixes** (PR #527): 221 - - fixed stale data when navigating between detail pages of the same type 222 - - e.g., clicking from one artist to another now properly reloads data 223 - 224 - **copyright moderation improvements** (PR #480): 225 - - enhanced moderation workflow for copyright claims 226 - - improved labeler integration 102 + | track | focus | status | 103 + |-------|-------|--------| 104 + | moderation | consolidate architecture, add rules engine | in progress | 105 + | atprotofans | supporter validation, content gating | shipped (phase 1-3) | 227 106 228 - **status maintenance workflow** (PR #529): 229 - - automated status maintenance using claude-code-action 230 - - reviews merged PRs and updates STATUS.md narratively 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) 231 110 232 111 --- 233 112 234 - #### playlist fast-follow fixes (PRs #507-519, Dec 7-8) 113 + #### beartype + moderation cleanup (PRs #617-619, Dec 19) 235 114 236 - **public playlist viewing** (PR #519): 237 - - playlists now publicly viewable without authentication 238 - - ATProto records are public by design - auth was unnecessary for read access 239 - - shared playlist URLs no longer redirect unauthenticated users to homepage 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) 240 119 241 - **inline playlist creation** (PR #510): 242 - - clicking "create new playlist" from AddToMenu previously navigated to `/library?create=playlist` 243 - - this caused SvelteKit to reinitialize the layout, destroying the audio element and stopping playback 244 - - fix: added inline create form that creates playlist and adds track in one action without navigation 245 - 246 - **UI polish** (PRs #507-509, #515): 247 - - include `image_url` in playlist SSR data for og:image link previews 248 - - invalidate layout data after token exchange - fixes stale auth state after login 249 - - fixed stopPropagation blocking "create new playlist" link clicks 250 - - detail page button layouts: all buttons visible on mobile, centered AddToMenu on track detail 251 - - AddToMenu smart positioning: menu opens upward when near viewport bottom 252 - 253 - **documentation** (PR #514): 254 - - added lexicons overview documentation at `docs/lexicons/overview.md` 255 - - covers `fm.plyr.track`, `fm.plyr.like`, `fm.plyr.comment`, `fm.plyr.list`, `fm.plyr.actor.profile` 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) 256 124 257 125 --- 258 126 259 - #### playlists, ATProto sync, and library hub (PR #499, Dec 6-7) 127 + #### UX polish (PRs #604-607, #613, #615, Dec 16-18) 260 128 261 - **playlists** (full CRUD): 262 - - create, rename, delete playlists with cover art upload 263 - - add/remove/reorder tracks with drag-and-drop 264 - - playlist detail page with edit modal 265 - - "add to playlist" menu on tracks with inline create 266 - - playlist sharing with OpenGraph link previews 267 - 268 - **ATProto integration**: 269 - - `fm.plyr.list` lexicon for syncing playlists/albums to user PDSes 270 - - `fm.plyr.actor.profile` lexicon for artist profiles 271 - - automatic sync of albums, liked tracks, profile on login 129 + **login improvements** (PRs #604, #613): 130 + - login page now uses "internet handle" terminology for clarity 131 + - input normalization: strips `@` and `at://` prefixes automatically 272 132 273 - **library hub** (`/library`): 274 - - unified page with tabs: liked, playlists, albums 275 - - nav changed from "liked" โ†’ "library" 133 + **artist page fixes** (PR #615): 134 + - track pagination on artist pages now works correctly 135 + - fixed mobile album card overflow 276 136 277 - **related**: scope upgrade OAuth flow (PR #503), settings consolidation (PR #496) 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 278 141 279 142 --- 280 143 281 - #### sensitive image moderation (PRs #471-488, Dec 5-6) 144 + #### offline mode foundation (PRs #610-611, Dec 17) 282 145 283 - - `sensitive_images` table flags problematic images 284 - - `show_sensitive_artwork` user preference 285 - - flagged images blurred everywhere: track lists, player, artist pages, search, embeds 286 - - Media Session API respects sensitive preference 287 - - SSR-safe filtering for og:image link previews 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 288 151 289 152 --- 290 153 291 - #### teal.fm scrobbling (PR #467, Dec 4) 292 - 293 - - native scrobbling to user's PDS using teal's ATProto lexicons 294 - - scrobble at 30% or 30 seconds (same threshold as play counts) 295 - - toggle in settings, link to pdsls.dev to view records 154 + ### Earlier December 2025 296 155 297 - --- 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) 298 172 299 - ### Earlier December / November 2025 173 + ### November 2025 300 174 301 - See `.status_history/2025-12.md` and `.status_history/2025-11.md` for detailed history including: 302 - - unified search with Cmd+K (PR #447) 303 - - light/dark theme system (PR #441) 304 - - tag filtering and bufo easter egg (PRs #431-438) 175 + See `.status_history/2025-11.md` for detailed history including: 305 176 - developer tokens (PR #367) 306 177 - copyright moderation system (PRs #382-395) 307 178 - export & upload reliability (PRs #337-344) ··· 309 180 310 181 ## immediate priorities 311 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 + 312 195 ### known issues 313 196 - playback auto-start on refresh (#225) 314 197 - iOS PWA audio may hang on first play after backgrounding 315 198 316 - ### immediate focus 317 - - **moderation cleanup**: consolidate copyright detection, reduce AudD API costs, streamline labeler integration (issues #541-544) 318 - 319 - ### feature ideas 320 - - issue #334: add 'share to bluesky' option for tracks 321 - - issue #373: lyrics field and Genius-style annotations 322 - 323 199 ### backlog 324 200 - audio transcoding pipeline integration (#153) - transcoder service deployed, integration deferred 201 + - share to bluesky (#334) 202 + - lyrics and annotations (#373) 325 203 326 204 ## technical state 327 205 ··· 367 245 - โœ… copyright moderation with ATProto labeler 368 246 - โœ… docket background tasks (copyright scan, export, atproto sync, scrobble) 369 247 - โœ… media export with concurrent downloads 248 + - โœ… supporter-gated content via atprotofans 370 249 371 250 **albums** 372 251 - โœ… album CRUD with cover art ··· 398 277 399 278 ## cost structure 400 279 401 - current monthly costs: ~$18/month (plyr.fm specific) 280 + current monthly costs: ~$20/month (plyr.fm specific) 402 281 403 282 see live dashboard: [plyr.fm/costs](https://plyr.fm/costs) 404 283 405 - - fly.io (plyr apps only): ~$12/month 406 - - relay-api (prod): $5.80 407 - - relay-api-staging: $5.60 408 - - plyr-moderation: $0.24 409 - - plyr-transcoder: $0.02 284 + - fly.io (backend + redis + moderation): ~$14/month 410 285 - neon postgres: $5/month 411 - - cloudflare (R2 + pages + domain): ~$1.16/month 412 - - audd audio fingerprinting: $0-10/month (6000 free/month) 286 + - cloudflare (R2 + pages + domain): ~$1/month 287 + - audd audio fingerprinting: $5-10/month (usage-based) 413 288 - logfire: $0 (free tier) 414 289 415 290 ## admin tooling ··· 460 335 โ”‚ โ””โ”€โ”€ src/routes/ # pages 461 336 โ”œโ”€โ”€ moderation/ # Rust moderation service (ATProto labeler) 462 337 โ”œโ”€โ”€ transcoder/ # Rust audio transcoding service 338 + โ”œโ”€โ”€ redis/ # self-hosted Redis config 463 339 โ”œโ”€โ”€ docs/ # documentation 464 340 โ””โ”€โ”€ justfile # task runner 465 341 ``` ··· 475 351 476 352 --- 477 353 478 - this is a living document. last updated 2025-12-17. 354 + this is a living document. last updated 2025-12-30.
+1
backend/.env.example
··· 32 32 R2_ENDPOINT_URL=https://8feb33b5fb57ce2bc093bc6f4141f40a.r2.cloudflarestorage.com 33 33 R2_PUBLIC_BUCKET_URL=https://pub-154b70b3121149eda0cf1ccbae78cb33.r2.dev 34 34 R2_PUBLIC_IMAGE_BUCKET_URL=https://pub-154b70b3121149eda0cf1ccbae78cb33.r2.dev 35 + R2_PRIVATE_BUCKET=audio-private-dev # private bucket for supporter-gated audio 35 36 MAX_UPLOAD_SIZE_MB=1536 # max audio upload size (default: 1536MB / 1.5GB - supports 2-hour WAV) 36 37 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 44 # - AWS_ACCESS_KEY_ID (cloudflare R2) 45 45 # - AWS_SECRET_ACCESS_KEY (cloudflare R2) 46 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) 47 + # - DOCKET_URL (self-hosted redis: redis://plyr-redis-stg.internal:6379)
+1 -1
backend/fly.toml
··· 39 39 # - AWS_ACCESS_KEY_ID (cloudflare R2) 40 40 # - AWS_SECRET_ACCESS_KEY (cloudflare R2) 41 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) 42 + # - DOCKET_URL (self-hosted redis: redis://plyr-redis.internal:6379)
+3
backend/pyproject.toml
··· 29 29 "mutagen>=1.47.0", 30 30 "pydocket>=0.15.2", 31 31 "redis>=7.1.0", 32 + "beartype>=0.22.8", 32 33 ] 33 34 34 35 requires-python = ">=3.11" ··· 84 85 # redis URL for cache tests (uses test-redis from docker-compose) 85 86 # D: prefix means don't override if already set (e.g., by CI workflow) 86 87 "D:DOCKET_URL=redis://localhost:6380/0", 88 + # disable automatic perpetual task scheduling in tests to avoid event loop issues 89 + "DOCKET_SCHEDULE_AUTOMATIC_TASKS=false", 87 90 ] 88 91 markers = [ 89 92 "integration: marks tests as integration tests (deselect with '-m \"not integration\"')",
+5
backend/src/backend/__init__.py
··· 1 + from beartype.claw import beartype_this_package 2 + 3 + beartype_this_package() 4 + 5 + 1 6 def hello() -> str: 2 7 return "Hello from backend!"
+3
backend/src/backend/_internal/__init__.py
··· 32 32 from backend._internal.notifications import notification_service 33 33 from backend._internal.now_playing import now_playing_service 34 34 from backend._internal.queue import queue_service 35 + from backend._internal.atprotofans import get_supported_artists, validate_supporter 35 36 36 37 __all__ = [ 37 38 "DeveloperToken", ··· 51 52 "get_pending_dev_token", 52 53 "get_pending_scope_upgrade", 53 54 "get_session", 55 + "get_supported_artists", 54 56 "handle_oauth_callback", 55 57 "list_developer_tokens", 56 58 "notification_service", ··· 64 66 "start_oauth_flow", 65 67 "start_oauth_flow_with_scopes", 66 68 "update_session_tokens", 69 + "validate_supporter", 67 70 ]
+9 -2
backend/src/backend/_internal/atproto/records/fm_plyr/track.py
··· 20 20 duration: int | None = None, 21 21 features: list[dict] | None = None, 22 22 image_url: str | None = None, 23 + support_gate: dict | None = None, 23 24 ) -> dict[str, Any]: 24 25 """Build a track record dict for ATProto. 25 26 26 27 args: 27 28 title: track title 28 29 artist: artist name 29 - audio_url: R2 URL for audio file 30 + audio_url: R2 URL for audio file (placeholder for gated tracks) 30 31 file_type: file extension (mp3, wav, etc) 31 32 album: optional album name 32 33 duration: optional duration in seconds 33 34 features: optional list of featured artists [{did, handle, display_name, avatar_url}] 34 35 image_url: optional cover art image URL 36 + support_gate: optional gating config (e.g., {"type": "any"}) 35 37 36 38 returns: 37 39 record dict ready for ATProto ··· 64 66 # validate image URL comes from allowed origin 65 67 settings.storage.validate_image_url(image_url) 66 68 record["imageUrl"] = image_url 69 + if support_gate: 70 + record["supportGate"] = support_gate 67 71 68 72 return record 69 73 ··· 78 82 duration: int | None = None, 79 83 features: list[dict] | None = None, 80 84 image_url: str | None = None, 85 + support_gate: dict | None = None, 81 86 ) -> tuple[str, str]: 82 87 """Create a track record on the user's PDS using the configured collection. 83 88 ··· 85 90 auth_session: authenticated user session 86 91 title: track title 87 92 artist: artist name 88 - audio_url: R2 URL for audio file 93 + audio_url: R2 URL for audio file (placeholder URL for gated tracks) 89 94 file_type: file extension (mp3, wav, etc) 90 95 album: optional album name 91 96 duration: optional duration in seconds 92 97 features: optional list of featured artists [{did, handle, display_name, avatar_url}] 93 98 image_url: optional cover art image URL 99 + support_gate: optional gating config (e.g., {"type": "any"}) 94 100 95 101 returns: 96 102 tuple of (record_uri, record_cid) ··· 108 114 duration=duration, 109 115 features=features, 110 116 image_url=image_url, 117 + support_gate=support_gate, 111 118 ) 112 119 113 120 payload = {
+1 -1
backend/src/backend/_internal/atproto/sync.py
··· 17 17 18 18 19 19 async def _get_existing_track_order( 20 - album_atproto_uri: str, 20 + album_atproto_uri: str | None, 21 21 artist_pds_url: str | None, 22 22 ) -> list[str]: 23 23 """fetch existing track URIs from ATProto list record.
+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 -1
backend/src/backend/_internal/audio.py
··· 1 1 """audio file type definitions.""" 2 2 3 3 from enum import Enum 4 + from typing import Self 4 5 5 6 6 7 class AudioFormat(str, Enum): ··· 26 27 return media_types[self] 27 28 28 29 @classmethod 29 - def from_extension(cls, ext: str) -> "AudioFormat | None": 30 + def from_extension(cls, ext: str) -> Self | None: 30 31 """get format from file extension (with or without dot).""" 31 32 ext = ext.lower().lstrip(".") 32 33 for format in cls:
+4 -6
backend/src/backend/_internal/auth.py
··· 5 5 import secrets 6 6 from dataclasses import dataclass 7 7 from datetime import UTC, datetime, timedelta 8 - from typing import TYPE_CHECKING, Annotated, Any 8 + from typing import Annotated, Any 9 9 10 10 from atproto_oauth import OAuthClient 11 11 from atproto_oauth.stores.memory import MemorySessionStore 12 12 from cryptography.fernet import Fernet 13 13 from cryptography.hazmat.primitives.asymmetric import ec 14 + from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey 14 15 from cryptography.hazmat.primitives.serialization import load_pem_private_key 15 16 from fastapi import Cookie, Header, HTTPException 16 17 from jose import jwk ··· 20 21 from backend.config import settings 21 22 from backend.models import ExchangeToken, PendingDevToken, UserPreferences, UserSession 22 23 from backend.utilities.database import db_session 23 - 24 - if TYPE_CHECKING: 25 - from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey 26 24 27 25 logger = logging.getLogger(__name__) 28 26 ··· 76 74 _session_store = MemorySessionStore() 77 75 78 76 # confidential client key (loaded lazily) 79 - _client_secret_key: "EllipticCurvePrivateKey | None" = None 77 + _client_secret_key: EllipticCurvePrivateKey | None = None 80 78 _client_secret_kid: str | None = None 81 79 _client_secret_key_loaded = False 82 80 83 81 84 - def _load_client_secret() -> tuple["EllipticCurvePrivateKey | None", str | None]: 82 + def _load_client_secret() -> tuple[EllipticCurvePrivateKey | None, str | None]: 85 83 """load EC private key and kid from OAUTH_JWK setting for confidential client. 86 84 87 85 the key is expected to be a JSON-serialized JWK with ES256 (P-256) key.
+4
backend/src/backend/_internal/background.py
··· 55 55 extra={"docket_name": settings.docket.name, "url": settings.docket.url}, 56 56 ) 57 57 58 + # WARNING: do not modify Docket() or Worker() constructor args without 59 + # reading docs/backend/background-tasks.md - see 2025-12-30 incident 58 60 async with Docket( 59 61 name=settings.docket.name, 60 62 url=settings.docket.url, ··· 77 79 scheduling_resolution=timedelta( 78 80 seconds=settings.docket.scheduling_resolution_seconds 79 81 ), 82 + # disable automatic perpetual tasks in tests to avoid event loop issues 83 + schedule_automatic_tasks=settings.docket.schedule_automatic_tasks, 80 84 ) as worker: 81 85 worker_task = asyncio.create_task( 82 86 worker.run_forever(),
+132 -4
backend/src/backend/_internal/background_tasks.py
··· 11 11 import os 12 12 import tempfile 13 13 import zipfile 14 - from datetime import UTC, datetime 14 + from datetime import UTC, datetime, timedelta 15 15 from pathlib import Path 16 16 17 17 import aioboto3 18 18 import aiofiles 19 19 import logfire 20 + from docket import Perpetual 20 21 from sqlalchemy import select 21 22 22 23 from backend._internal.atproto.records import ( ··· 27 28 ) 28 29 from backend._internal.auth import get_session 29 30 from backend._internal.background import get_docket 30 - from backend.models import TrackComment, TrackLike 31 + from backend.models import CopyrightScan, Track, TrackComment, TrackLike 31 32 from backend.utilities.database import db_session 32 33 33 34 logger = logging.getLogger(__name__) ··· 50 51 docket = get_docket() 51 52 await docket.add(scan_copyright)(track_id, audio_url) 52 53 logfire.info("scheduled copyright scan", track_id=track_id) 54 + 55 + 56 + async def sync_copyright_resolutions( 57 + perpetual: Perpetual = Perpetual(every=timedelta(minutes=5), automatic=True), # noqa: B008 58 + ) -> None: 59 + """sync resolution status from labeler to backend database. 60 + 61 + finds tracks that are flagged but have no resolution, checks the labeler 62 + to see if the labels were negated (dismissed), and marks them as resolved. 63 + 64 + this replaces the lazy reconciliation that was happening on read paths. 65 + runs automatically every 5 minutes via docket's Perpetual. 66 + """ 67 + from backend._internal.moderation_client import get_moderation_client 68 + 69 + async with db_session() as db: 70 + # find flagged scans with AT URIs that haven't been resolved 71 + result = await db.execute( 72 + select(CopyrightScan, Track.atproto_record_uri) 73 + .join(Track, CopyrightScan.track_id == Track.id) 74 + .where( 75 + CopyrightScan.is_flagged == True, # noqa: E712 76 + Track.atproto_record_uri.isnot(None), 77 + ) 78 + ) 79 + rows = result.all() 80 + 81 + if not rows: 82 + logfire.debug("sync_copyright_resolutions: no flagged scans to check") 83 + return 84 + 85 + # batch check with labeler 86 + scan_by_uri: dict[str, CopyrightScan] = {} 87 + for scan, uri in rows: 88 + if uri: 89 + scan_by_uri[uri] = scan 90 + 91 + if not scan_by_uri: 92 + return 93 + 94 + client = get_moderation_client() 95 + active_uris = await client.get_active_labels(list(scan_by_uri.keys())) 96 + 97 + # find scans that are no longer active (label was negated) 98 + resolved_count = 0 99 + for uri, scan in scan_by_uri.items(): 100 + if uri not in active_uris: 101 + # label was negated - track is no longer flagged 102 + scan.is_flagged = False 103 + resolved_count += 1 104 + 105 + if resolved_count > 0: 106 + await db.commit() 107 + logfire.info( 108 + "sync_copyright_resolutions: resolved {count} scans", 109 + count=resolved_count, 110 + ) 111 + else: 112 + logfire.debug( 113 + "sync_copyright_resolutions: checked {count} scans, none resolved", 114 + count=len(scan_by_uri), 115 + ) 116 + 117 + 118 + async def schedule_copyright_resolution_sync() -> None: 119 + """schedule a copyright resolution sync via docket.""" 120 + docket = get_docket() 121 + await docket.add(sync_copyright_resolutions)() 122 + logfire.info("scheduled copyright resolution sync") 53 123 54 124 55 125 async def process_export(export_id: str, artist_did: str) -> None: ··· 211 281 export_id, 212 282 JobStatus.PROCESSING, 213 283 f"downloading {total} tracks...", 214 - progress_pct=0, 284 + progress_pct=0.0, 215 285 result={"processed_count": 0, "total_count": total}, 216 286 ) 217 287 ··· 234 304 export_id, 235 305 JobStatus.PROCESSING, 236 306 "creating zip archive...", 237 - progress_pct=100, 307 + progress_pct=100.0, 238 308 result={ 239 309 "processed_count": len(successful_downloads), 240 310 "total_count": total, ··· 795 865 logfire.info("scheduled pds comment update", comment_id=comment_id) 796 866 797 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 + 798 924 # collection of all background task functions for docket registration 799 925 background_tasks = [ 800 926 scan_copyright, 927 + sync_copyright_resolutions, 801 928 process_export, 802 929 sync_atproto, 803 930 scrobble_to_teal, ··· 807 934 pds_create_comment, 808 935 pds_delete_comment, 809 936 pds_update_comment, 937 + move_track_audio, 810 938 ]
+4 -3
backend/src/backend/_internal/image.py
··· 1 1 """image format handling for media storage.""" 2 2 3 3 from enum import Enum 4 + from typing import Self 4 5 5 6 6 7 class ImageFormat(str, Enum): ··· 24 25 }[self.value] 25 26 26 27 @classmethod 27 - def from_filename(cls, filename: str) -> "ImageFormat | None": 28 + def from_filename(cls, filename: str) -> Self | None: 28 29 """extract image format from filename extension.""" 29 30 ext = filename.lower().split(".")[-1] 30 31 if ext in ["jpg", "jpeg"]: ··· 34 35 return None 35 36 36 37 @classmethod 37 - def from_content_type(cls, content_type: str | None) -> "ImageFormat | None": 38 + def from_content_type(cls, content_type: str | None) -> Self | None: 38 39 """extract image format from MIME content type. 39 40 40 41 this is more reliable than filename extension, especially on iOS ··· 56 57 @classmethod 57 58 def validate_and_extract( 58 59 cls, filename: str | None, content_type: str | None = None 59 - ) -> tuple["ImageFormat | None", bool]: 60 + ) -> tuple[Self | None, bool]: 60 61 """validate image format from filename or content type. 61 62 62 63 prefers content_type over filename extension when available, since
+54 -235
backend/src/backend/_internal/moderation.py
··· 1 - """moderation service client for copyright scanning.""" 1 + """moderation service integration for copyright scanning.""" 2 2 3 3 import logging 4 4 from typing import Any 5 5 6 - import httpx 7 6 import logfire 8 7 from sqlalchemy import select 8 + from sqlalchemy.orm import joinedload 9 9 10 + from backend._internal.moderation_client import get_moderation_client 10 11 from backend.config import settings 11 12 from backend.models import CopyrightScan, Track 12 13 from backend.utilities.database import db_session 13 - from backend.utilities.redis import get_async_redis_client 14 14 15 15 logger = logging.getLogger(__name__) 16 16 ··· 42 42 audio_url=audio_url, 43 43 ): 44 44 try: 45 - result = await _call_moderation_service(audio_url) 45 + client = get_moderation_client() 46 + result = await client.scan(audio_url) 46 47 await _store_scan_result(track_id, result) 47 48 except Exception as e: 48 49 logger.warning( ··· 50 51 track_id, 51 52 e, 52 53 ) 53 - # store as "clear" with error info so track doesn't stay unscanned 54 - # this handles cases like: audio too short, unreadable format, etc. 55 54 await _store_scan_error(track_id, str(e)) 56 - # don't re-raise - this is fire-and-forget 57 55 58 56 59 - async def _call_moderation_service(audio_url: str) -> dict[str, Any]: 60 - """call the moderation service /scan endpoint. 61 - 62 - args: 63 - audio_url: public URL of the audio file 64 - 65 - returns: 66 - scan result from moderation service 67 - 68 - raises: 69 - httpx.HTTPStatusError: on non-2xx response 70 - httpx.TimeoutException: on timeout 71 - """ 72 - async with httpx.AsyncClient( 73 - timeout=httpx.Timeout(settings.moderation.timeout_seconds) 74 - ) as client: 75 - response = await client.post( 76 - f"{settings.moderation.service_url}/scan", 77 - json={"audio_url": audio_url}, 78 - headers={"X-Moderation-Key": settings.moderation.auth_token}, 79 - ) 80 - response.raise_for_status() 81 - return response.json() 82 - 83 - 84 - async def _store_scan_result(track_id: int, result: dict[str, Any]) -> None: 57 + async def _store_scan_result(track_id: int, result: Any) -> None: 85 58 """store scan result in the database. 86 59 87 60 args: 88 61 track_id: database ID of the track 89 - result: scan result from moderation service 62 + result: ScanResult from moderation client 90 63 """ 91 - from sqlalchemy.orm import joinedload 92 - 93 64 async with db_session() as db: 94 - is_flagged = result.get("is_flagged", False) 95 - 96 65 scan = CopyrightScan( 97 66 track_id=track_id, 98 - is_flagged=is_flagged, 99 - highest_score=result.get("highest_score", 0), 100 - matches=result.get("matches", []), 101 - raw_response=result.get("raw_response", {}), 67 + is_flagged=result.is_flagged, 68 + highest_score=result.highest_score, 69 + matches=result.matches, 70 + raw_response=result.raw_response, 102 71 ) 103 72 db.add(scan) 104 73 await db.commit() ··· 112 81 ) 113 82 114 83 # emit ATProto label if flagged 115 - if is_flagged: 84 + if result.is_flagged: 116 85 track = await db.scalar( 117 86 select(Track) 118 87 .options(joinedload(Track.artist)) ··· 138 107 track_title: str | None = None, 139 108 artist_handle: str | None = None, 140 109 artist_did: str | None = None, 141 - highest_score: float | None = None, 110 + highest_score: int | None = None, 142 111 matches: list[dict[str, Any]] | None = None, 143 112 ) -> None: 144 - """emit a copyright-violation label to the ATProto labeler service. 113 + """emit a copyright-violation label to the ATProto labeler service.""" 114 + context: dict[str, Any] | None = None 115 + if track_id or track_title or artist_handle or matches: 116 + context = { 117 + "track_id": track_id, 118 + "track_title": track_title, 119 + "artist_handle": artist_handle, 120 + "artist_did": artist_did, 121 + "highest_score": highest_score, 122 + "matches": matches, 123 + } 145 124 146 - this is fire-and-forget - failures are logged but don't affect the scan result. 125 + client = get_moderation_client() 126 + await client.emit_label(uri=uri, cid=cid, context=context) 147 127 148 - args: 149 - uri: AT URI of the track record 150 - cid: optional CID of the record 151 - track_id: database ID of the track (for admin UI links) 152 - track_title: title of the track (for admin UI context) 153 - artist_handle: handle of the artist (for admin UI context) 154 - artist_did: DID of the artist (for admin UI context) 155 - highest_score: highest match score (for admin UI context) 156 - matches: list of copyright matches (for admin UI context) 157 - """ 158 - try: 159 - # build context for admin UI display 160 - context: dict[str, Any] | None = None 161 - if track_id or track_title or artist_handle or matches: 162 - context = { 163 - "track_id": track_id, 164 - "track_title": track_title, 165 - "artist_handle": artist_handle, 166 - "artist_did": artist_did, 167 - "highest_score": highest_score, 168 - "matches": matches, 169 - } 170 128 171 - payload: dict[str, Any] = { 172 - "uri": uri, 173 - "val": "copyright-violation", 174 - "cid": cid, 175 - } 176 - if context: 177 - payload["context"] = context 178 - 179 - async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client: 180 - response = await client.post( 181 - f"{settings.moderation.labeler_url}/emit-label", 182 - json=payload, 183 - headers={"X-Moderation-Key": settings.moderation.auth_token}, 184 - ) 185 - response.raise_for_status() 186 - 187 - # invalidate cache since label status changed 188 - await invalidate_label_cache(uri) 129 + async def _store_scan_error(track_id: int, error: str) -> None: 130 + """store a scan error as a clear result.""" 131 + async with db_session() as db: 132 + scan = CopyrightScan( 133 + track_id=track_id, 134 + is_flagged=False, 135 + highest_score=0, 136 + matches=[], 137 + raw_response={"error": error, "status": "scan_failed"}, 138 + ) 139 + db.add(scan) 140 + await db.commit() 189 141 190 - logfire.info( 191 - "copyright label emitted", 192 - uri=uri, 193 - cid=cid, 194 - ) 195 - except Exception as e: 196 - logger.warning("failed to emit copyright label for %s: %s", uri, e) 142 + logfire.info( 143 + "copyright scan error stored as clear", 144 + track_id=track_id, 145 + error=error, 146 + ) 197 147 198 148 149 + # re-export for backwards compatibility 199 150 async def get_active_copyright_labels(uris: list[str]) -> set[str]: 200 - """check which URIs have active (non-negated) copyright-violation labels. 151 + """check which URIs have active copyright-violation labels. 201 152 202 - uses redis cache (shared across instances) to avoid repeated calls 203 - to the moderation service. only URIs not in cache are fetched. 204 - 205 - args: 206 - uris: list of AT URIs to check 207 - 208 - returns: 209 - set of URIs that are still actively flagged 210 - 211 - note: 212 - fails closed (returns all URIs as active) if moderation service is unreachable 213 - to avoid accidentally hiding real violations. 153 + this is a convenience wrapper around the moderation client. 214 154 """ 215 - if not uris: 216 - return set() 217 - 218 155 if not settings.moderation.enabled: 219 156 logger.debug("moderation disabled, treating all as active") 220 157 return set(uris) ··· 223 160 logger.warning("MODERATION_AUTH_TOKEN not set, treating all as active") 224 161 return set(uris) 225 162 226 - # check redis cache first - partition into cached vs uncached 227 - active_from_cache: set[str] = set() 228 - uris_to_fetch: list[str] = [] 229 - 230 - try: 231 - redis = get_async_redis_client() 232 - prefix = settings.moderation.label_cache_prefix 233 - cache_keys = [f"{prefix}{uri}" for uri in uris] 234 - cached_values = await redis.mget(cache_keys) 235 - 236 - for uri, cached_value in zip(uris, cached_values, strict=True): 237 - if cached_value is not None: 238 - if cached_value == "1": 239 - active_from_cache.add(uri) 240 - # else: cached as "0" (not active), skip 241 - else: 242 - uris_to_fetch.append(uri) 243 - except Exception as e: 244 - # redis unavailable - fall through to fetch all 245 - logger.warning("redis cache unavailable: %s", e) 246 - uris_to_fetch = list(uris) 247 - 248 - # if everything was cached, return early 249 - if not uris_to_fetch: 250 - logfire.debug( 251 - "checked active copyright labels (all cached)", 252 - total_uris=len(uris), 253 - active_count=len(active_from_cache), 254 - ) 255 - return active_from_cache 256 - 257 - # fetch uncached URIs from moderation service 258 - try: 259 - async with httpx.AsyncClient( 260 - timeout=httpx.Timeout(settings.moderation.timeout_seconds) 261 - ) as client: 262 - response = await client.post( 263 - f"{settings.moderation.labeler_url}/admin/active-labels", 264 - json={"uris": uris_to_fetch}, 265 - headers={"X-Moderation-Key": settings.moderation.auth_token}, 266 - ) 267 - response.raise_for_status() 268 - data = response.json() 269 - active_from_service = set(data.get("active_uris", [])) 270 - 271 - # update redis cache with results 272 - try: 273 - redis = get_async_redis_client() 274 - prefix = settings.moderation.label_cache_prefix 275 - ttl = settings.moderation.label_cache_ttl_seconds 276 - async with redis.pipeline() as pipe: 277 - for uri in uris_to_fetch: 278 - cache_key = f"{prefix}{uri}" 279 - value = "1" if uri in active_from_service else "0" 280 - await pipe.set(cache_key, value, ex=ttl) 281 - await pipe.execute() 282 - except Exception as e: 283 - # cache update failed - not critical, just log 284 - logger.warning("failed to update redis cache: %s", e) 285 - 286 - logfire.info( 287 - "checked active copyright labels", 288 - total_uris=len(uris), 289 - cached_count=len(uris) - len(uris_to_fetch), 290 - fetched_count=len(uris_to_fetch), 291 - active_count=len(active_from_cache) + len(active_from_service), 292 - ) 293 - return active_from_cache | active_from_service 294 - 295 - except Exception as e: 296 - # fail closed: if we can't confirm resolution, treat as active 297 - # don't cache failures - we want to retry next time 298 - logger.warning("failed to check active labels, treating all as active: %s", e) 299 - return set(uris) 163 + client = get_moderation_client() 164 + return await client.get_active_labels(uris) 300 165 301 166 302 167 async def invalidate_label_cache(uri: str) -> None: 303 - """invalidate cache entry for a URI when its label status changes. 304 - 305 - call this when emitting or negating labels to ensure fresh data. 306 - """ 307 - try: 308 - redis = get_async_redis_client() 309 - prefix = settings.moderation.label_cache_prefix 310 - await redis.delete(f"{prefix}{uri}") 311 - except Exception as e: 312 - logger.warning("failed to invalidate label cache for %s: %s", uri, e) 168 + """invalidate cache entry for a URI.""" 169 + client = get_moderation_client() 170 + await client.invalidate_cache(uri) 313 171 314 172 315 173 async def clear_label_cache() -> None: 316 - """clear all label cache entries. primarily for testing.""" 317 - try: 318 - redis = get_async_redis_client() 319 - prefix = settings.moderation.label_cache_prefix 320 - # scan and delete all keys with our prefix 321 - cursor = 0 322 - while True: 323 - cursor, keys = await redis.scan(cursor, match=f"{prefix}*", count=100) 324 - if keys: 325 - await redis.delete(*keys) 326 - if cursor == 0: 327 - break 328 - except Exception as e: 329 - logger.warning("failed to clear label cache: %s", e) 330 - 331 - 332 - async def _store_scan_error(track_id: int, error: str) -> None: 333 - """store a scan error as a clear result. 334 - 335 - when the moderation service can't process a file (too short, bad format, etc.), 336 - we still want to record that we tried so the track isn't stuck in limbo. 337 - 338 - args: 339 - track_id: database ID of the track 340 - error: error message from the failed scan 341 - """ 342 - async with db_session() as db: 343 - scan = CopyrightScan( 344 - track_id=track_id, 345 - is_flagged=False, 346 - highest_score=0, 347 - matches=[], 348 - raw_response={"error": error, "status": "scan_failed"}, 349 - ) 350 - db.add(scan) 351 - await db.commit() 352 - 353 - logfire.info( 354 - "copyright scan error stored as clear", 355 - track_id=track_id, 356 - error=error, 357 - ) 174 + """clear all label cache entries.""" 175 + client = get_moderation_client() 176 + await client.clear_cache()
+312
backend/src/backend/_internal/moderation_client.py
··· 1 + """moderation service client. 2 + 3 + centralized client for all moderation service interactions. 4 + replaces scattered httpx calls with a single, testable interface. 5 + """ 6 + 7 + import logging 8 + from dataclasses import dataclass 9 + from typing import Any 10 + 11 + import httpx 12 + import logfire 13 + 14 + from backend.config import settings 15 + from backend.utilities.redis import get_async_redis_client 16 + 17 + logger = logging.getLogger(__name__) 18 + 19 + 20 + @dataclass 21 + class ScanResult: 22 + """result from a copyright scan.""" 23 + 24 + is_flagged: bool 25 + highest_score: int 26 + matches: list[dict[str, Any]] 27 + raw_response: dict[str, Any] 28 + 29 + 30 + @dataclass 31 + class EmitLabelResult: 32 + """result from emitting a label.""" 33 + 34 + success: bool 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 + 49 + provides a clean interface for: 50 + - scanning audio for copyright matches 51 + - emitting ATProto labels 52 + - checking active labels 53 + - caching label status in redis 54 + 55 + usage: 56 + client = ModerationClient.from_settings() 57 + result = await client.scan(audio_url) 58 + """ 59 + 60 + def __init__( 61 + self, 62 + service_url: str, 63 + labeler_url: str, 64 + auth_token: str, 65 + timeout_seconds: float | int, 66 + label_cache_prefix: str, 67 + label_cache_ttl_seconds: int, 68 + ) -> None: 69 + self.service_url = service_url 70 + self.labeler_url = labeler_url 71 + self.auth_token = auth_token 72 + self.timeout = httpx.Timeout(timeout_seconds) 73 + self.label_cache_prefix = label_cache_prefix 74 + self.label_cache_ttl_seconds = label_cache_ttl_seconds 75 + 76 + @classmethod 77 + def from_settings(cls) -> "ModerationClient": 78 + """create a client from application settings.""" 79 + return cls( 80 + service_url=settings.moderation.service_url, 81 + labeler_url=settings.moderation.labeler_url, 82 + auth_token=settings.moderation.auth_token, 83 + timeout_seconds=settings.moderation.timeout_seconds, 84 + label_cache_prefix=settings.moderation.label_cache_prefix, 85 + label_cache_ttl_seconds=settings.moderation.label_cache_ttl_seconds, 86 + ) 87 + 88 + def _headers(self) -> dict[str, str]: 89 + """common auth headers.""" 90 + return {"X-Moderation-Key": self.auth_token} 91 + 92 + async def scan(self, audio_url: str) -> ScanResult: 93 + """scan audio for potential copyright matches. 94 + 95 + args: 96 + audio_url: public URL of the audio file 97 + 98 + returns: 99 + ScanResult with match details 100 + 101 + raises: 102 + httpx.HTTPStatusError: on non-2xx response 103 + httpx.TimeoutException: on timeout 104 + """ 105 + async with httpx.AsyncClient(timeout=self.timeout) as client: 106 + response = await client.post( 107 + f"{self.service_url}/scan", 108 + json={"audio_url": audio_url}, 109 + headers=self._headers(), 110 + ) 111 + response.raise_for_status() 112 + data = response.json() 113 + 114 + return ScanResult( 115 + is_flagged=data.get("is_flagged", False), 116 + highest_score=data.get("highest_score", 0), 117 + matches=data.get("matches", []), 118 + raw_response=data.get("raw_response", {}), 119 + ) 120 + 121 + async def emit_label( 122 + self, 123 + uri: str, 124 + cid: str | None = None, 125 + val: str = "copyright-violation", 126 + context: dict[str, Any] | None = None, 127 + ) -> EmitLabelResult: 128 + """emit an ATProto label to the labeler service. 129 + 130 + args: 131 + uri: AT URI of the record to label 132 + cid: optional CID of the record 133 + val: label value (default: copyright-violation) 134 + context: optional metadata for admin UI display 135 + 136 + returns: 137 + EmitLabelResult indicating success/failure 138 + """ 139 + payload: dict[str, Any] = {"uri": uri, "val": val} 140 + if cid: 141 + payload["cid"] = cid 142 + if context: 143 + payload["context"] = context 144 + 145 + try: 146 + async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client: 147 + response = await client.post( 148 + f"{self.labeler_url}/emit-label", 149 + json=payload, 150 + headers=self._headers(), 151 + ) 152 + response.raise_for_status() 153 + 154 + # invalidate cache since label status changed 155 + await self.invalidate_cache(uri) 156 + 157 + logfire.info("copyright label emitted", uri=uri, cid=cid) 158 + return EmitLabelResult(success=True) 159 + 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. 189 + 190 + uses redis cache to avoid repeated calls to the labeler service. 191 + fails closed (returns all URIs as active) if labeler is unreachable. 192 + 193 + args: 194 + uris: list of AT URIs to check 195 + 196 + returns: 197 + set of URIs that are still actively flagged 198 + """ 199 + if not uris: 200 + return set() 201 + 202 + # check redis cache first 203 + active_from_cache: set[str] = set() 204 + uris_to_fetch: list[str] = [] 205 + 206 + try: 207 + redis = get_async_redis_client() 208 + cache_keys = [f"{self.label_cache_prefix}{uri}" for uri in uris] 209 + cached_values = await redis.mget(cache_keys) 210 + 211 + for uri, cached_value in zip(uris, cached_values, strict=True): 212 + if cached_value is not None: 213 + if cached_value == "1": 214 + active_from_cache.add(uri) 215 + # else: cached as "0" (not active), skip 216 + else: 217 + uris_to_fetch.append(uri) 218 + except Exception as e: 219 + logger.warning("redis cache unavailable: %s", e) 220 + uris_to_fetch = list(uris) 221 + 222 + # if everything was cached, return early 223 + if not uris_to_fetch: 224 + logfire.debug( 225 + "checked active copyright labels (all cached)", 226 + total_uris=len(uris), 227 + active_count=len(active_from_cache), 228 + ) 229 + return active_from_cache 230 + 231 + # fetch uncached URIs from labeler 232 + try: 233 + async with httpx.AsyncClient(timeout=self.timeout) as client: 234 + response = await client.post( 235 + f"{self.labeler_url}/admin/active-labels", 236 + json={"uris": uris_to_fetch}, 237 + headers=self._headers(), 238 + ) 239 + response.raise_for_status() 240 + data = response.json() 241 + active_from_service = set(data.get("active_uris", [])) 242 + 243 + # update redis cache 244 + await self._cache_label_status(uris_to_fetch, active_from_service) 245 + 246 + logfire.info( 247 + "checked active copyright labels", 248 + total_uris=len(uris), 249 + cached_count=len(uris) - len(uris_to_fetch), 250 + fetched_count=len(uris_to_fetch), 251 + active_count=len(active_from_cache) + len(active_from_service), 252 + ) 253 + return active_from_cache | active_from_service 254 + 255 + except Exception as e: 256 + # fail closed: if we can't confirm resolution, treat as active 257 + logger.warning( 258 + "failed to check active labels, treating all as active: %s", e 259 + ) 260 + return set(uris) 261 + 262 + async def _cache_label_status(self, uris: list[str], active_uris: set[str]) -> None: 263 + """cache label status in redis.""" 264 + try: 265 + redis = get_async_redis_client() 266 + async with redis.pipeline() as pipe: 267 + for uri in uris: 268 + cache_key = f"{self.label_cache_prefix}{uri}" 269 + value = "1" if uri in active_uris else "0" 270 + await pipe.set(cache_key, value, ex=self.label_cache_ttl_seconds) 271 + await pipe.execute() 272 + except Exception as e: 273 + logger.warning("failed to update redis cache: %s", e) 274 + 275 + async def invalidate_cache(self, uri: str) -> None: 276 + """invalidate cache entry for a URI when its label status changes.""" 277 + try: 278 + redis = get_async_redis_client() 279 + await redis.delete(f"{self.label_cache_prefix}{uri}") 280 + except Exception as e: 281 + logger.warning("failed to invalidate label cache for %s: %s", uri, e) 282 + 283 + async def clear_cache(self) -> None: 284 + """clear all label cache entries. primarily for testing.""" 285 + try: 286 + redis = get_async_redis_client() 287 + cursor = 0 288 + while True: 289 + cursor, keys = await redis.scan( 290 + cursor, match=f"{self.label_cache_prefix}*", count=100 291 + ) 292 + if keys: 293 + await redis.delete(*keys) 294 + if cursor == 0: 295 + break 296 + except Exception as e: 297 + logger.warning("failed to clear label cache: %s", e) 298 + 299 + 300 + # module-level singleton 301 + _client: ModerationClient | None = None 302 + 303 + 304 + def get_moderation_client() -> ModerationClient: 305 + """get the moderation client singleton. 306 + 307 + creates the client on first call, reuses on subsequent calls. 308 + """ 309 + global _client 310 + if _client is None: 311 + _client = ModerationClient.from_settings() 312 + return _client
+18 -17
backend/src/backend/api/albums.py
··· 517 517 track.extra = {} 518 518 track.extra = {**track.extra, "album": new_title} 519 519 520 - # update ATProto record 521 - updated_record = build_track_record( 522 - title=track.title, 523 - artist=track.artist.display_name, 524 - audio_url=track.r2_url, 525 - file_type=track.file_type, 526 - album=new_title, 527 - duration=track.duration, 528 - features=track.features if track.features else None, 529 - image_url=await track.get_image_url(), 530 - ) 520 + # update ATProto record if track has one 521 + if track.atproto_record_uri and track.r2_url and track.file_type: 522 + updated_record = build_track_record( 523 + title=track.title, 524 + artist=track.artist.display_name, 525 + audio_url=track.r2_url, 526 + file_type=track.file_type, 527 + album=new_title, 528 + duration=track.duration, 529 + features=track.features if track.features else None, 530 + image_url=await track.get_image_url(), 531 + ) 531 532 532 - _, new_cid = await update_record( 533 - auth_session=auth_session, 534 - record_uri=track.atproto_record_uri, 535 - record=updated_record, 536 - ) 537 - track.atproto_record_cid = new_cid 533 + _, new_cid = await update_record( 534 + auth_session=auth_session, 535 + record_uri=track.atproto_record_uri, 536 + record=updated_record, 537 + ) 538 + track.atproto_record_cid = new_cid 538 539 539 540 # update the album's ATProto list record name 540 541 if album.atproto_record_uri:
+127 -19
backend/src/backend/api/audio.py
··· 1 1 """audio streaming endpoint.""" 2 2 3 3 import logfire 4 - from fastapi import APIRouter, Depends, HTTPException 4 + from fastapi import APIRouter, Depends, HTTPException, Request, Response 5 5 from fastapi.responses import RedirectResponse 6 6 from pydantic import BaseModel 7 7 from sqlalchemy import func, select 8 8 9 - from backend._internal import Session, require_auth 9 + from backend._internal import Session, get_optional_session, validate_supporter 10 10 from backend.models import Track 11 11 from backend.storage import storage 12 12 from backend.utilities.database import db_session ··· 22 22 file_type: str | None 23 23 24 24 25 + @router.head("/{file_id}") 25 26 @router.get("/{file_id}") 26 - async def stream_audio(file_id: str): 27 + async def stream_audio( 28 + file_id: str, 29 + request: Request, 30 + session: Session | None = Depends(get_optional_session), 31 + ): 27 32 """stream audio file by redirecting to R2 CDN URL. 28 33 29 - looks up track to get cached r2_url and file extension, 30 - eliminating the need to probe multiple formats. 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. 31 39 32 40 images are served directly via R2 URLs stored in the image_url field, 33 41 not through this endpoint. 34 42 """ 35 - # look up track to get r2_url and file_type 43 + is_head_request = request.method == "HEAD" 44 + # look up track to get r2_url, file_type, support_gate, and artist_did 36 45 async with db_session() as db: 37 46 # check for duplicates (multiple tracks with same file_id) 38 47 count_result = await db.execute( ··· 50 59 count=count, 51 60 ) 52 61 53 - # get the best track: prefer non-null r2_url, then newest 62 + # get the track with gating info 54 63 result = await db.execute( 55 - select(Track.r2_url, Track.file_type) 64 + select(Track.r2_url, Track.file_type, Track.support_gate, Track.artist_did) 56 65 .where(Track.file_id == file_id) 57 66 .order_by(Track.r2_url.is_not(None).desc(), Track.created_at.desc()) 58 67 .limit(1) 59 68 ) 60 69 track_data = result.first() 70 + r2_url, file_type, support_gate, artist_did = track_data 61 71 62 - r2_url, file_type = track_data 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 + ) 63 81 64 - # if we have a valid r2_url cached, use it directly (zero HEADs) 82 + # public track - use cached r2_url if available 65 83 if r2_url and r2_url.startswith("http"): 66 84 return RedirectResponse(url=r2_url) 67 85 ··· 72 90 return RedirectResponse(url=url) 73 91 74 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 + 75 156 @router.get("/{file_id}/url") 76 157 async def get_audio_url( 77 158 file_id: str, 78 - session: Session = Depends(require_auth), 159 + session: Session | None = Depends(get_optional_session), 79 160 ) -> AudioUrlResponse: 80 - """return direct R2 URL for offline caching. 161 + """return direct URL for audio file. 81 162 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. 163 + for public tracks: returns R2 CDN URL for offline caching. 164 + for gated tracks: returns presigned URL after supporter validation. 85 165 86 - used for offline mode - frontend fetches from R2 and stores locally. 166 + used for offline mode - frontend fetches and caches locally. 87 167 """ 88 168 async with db_session() as db: 89 169 result = await db.execute( 90 - select(Track.r2_url, Track.file_type) 170 + select(Track.r2_url, Track.file_type, Track.support_gate, Track.artist_did) 91 171 .where(Track.file_id == file_id) 92 172 .order_by(Track.r2_url.is_not(None).desc(), Track.created_at.desc()) 93 173 .limit(1) ··· 97 177 if not track_data: 98 178 raise HTTPException(status_code=404, detail="audio file not found") 99 179 100 - r2_url, file_type = track_data 180 + r2_url, file_type, support_gate, artist_did = track_data 101 181 102 - # if we have a cached r2_url, return it 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 103 211 if r2_url and r2_url.startswith("http"): 104 212 return AudioUrlResponse(url=r2_url, file_id=file_id, file_type=file_type) 105 213
+17 -12
backend/src/backend/api/moderation.py
··· 1 1 """content moderation api endpoints.""" 2 2 3 - from typing import Annotated 3 + import logging 4 4 5 - from fastapi import APIRouter, Depends 5 + from fastapi import APIRouter, Request 6 6 from pydantic import BaseModel 7 - from sqlalchemy import select 8 - from sqlalchemy.ext.asyncio import AsyncSession 7 + 8 + from backend._internal.moderation_client import get_moderation_client 9 + from backend.utilities.rate_limit import limiter 9 10 10 - from backend.models import SensitiveImage, get_db 11 + logger = logging.getLogger(__name__) 11 12 12 13 router = APIRouter(prefix="/moderation", tags=["moderation"]) 13 14 ··· 22 23 23 24 24 25 @router.get("/sensitive-images") 26 + @limiter.limit("10/minute") 25 27 async def get_sensitive_images( 26 - db: Annotated[AsyncSession, Depends(get_db)], 28 + request: Request, 27 29 ) -> SensitiveImagesResponse: 28 30 """get all flagged sensitive images. 29 31 32 + proxies to the moderation service which is the source of truth 33 + for sensitive image data. 34 + 30 35 returns both image_ids (for R2-stored images) and full URLs 31 36 (for external images like avatars). clients should check both. 32 37 """ 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 + client = get_moderation_client() 39 + result = await client.get_sensitive_images() 38 40 39 - return SensitiveImagesResponse(image_ids=image_ids, urls=urls) 41 + return SensitiveImagesResponse( 42 + image_ids=result.image_ids, 43 + urls=result.urls, 44 + )
+18 -1
backend/src/backend/api/tracks/listing.py
··· 12 12 from sqlalchemy.orm import selectinload 13 13 14 14 from backend._internal import Session as AuthSession 15 - from backend._internal import get_optional_session, require_auth 15 + from backend._internal import get_optional_session, get_supported_artists, require_auth 16 16 from backend.config import settings 17 17 from backend.models import ( 18 18 Artist, ··· 233 233 await asyncio.gather(*[resolve_image(t) for t in tracks_needing_images]) 234 234 await db.commit() 235 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 + 236 251 # fetch all track responses concurrently with like status and counts 237 252 track_responses = await asyncio.gather( 238 253 *[ ··· 243 258 like_counts, 244 259 comment_counts, 245 260 track_tags=track_tags, 261 + viewer_did=viewer_did, 262 + supported_artist_dids=supported_artist_dids, 246 263 ) 247 264 for track in tracks 248 265 ]
+64 -4
backend/src/backend/api/tracks/mutations.py
··· 1 1 """Track mutation endpoints (delete/update/restore).""" 2 2 3 3 import contextlib 4 + import json 4 5 import logging 5 6 from datetime import UTC, datetime 6 7 from typing import Annotated 8 + from urllib.parse import urljoin 7 9 8 10 import logfire 9 11 from fastapi import Depends, File, Form, HTTPException, UploadFile ··· 23 25 update_record, 24 26 ) 25 27 from backend._internal.atproto.tid import datetime_to_tid 26 - from backend._internal.background_tasks import schedule_album_list_sync 28 + from backend._internal.background_tasks import ( 29 + schedule_album_list_sync, 30 + schedule_move_track_audio, 31 + ) 27 32 from backend.config import settings 28 33 from backend.models import Artist, Tag, Track, TrackTag, get_db 29 34 from backend.schemas import TrackResponse ··· 170 175 album: Annotated[str | None, Form()] = None, 171 176 features: Annotated[str | None, Form()] = None, 172 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, 173 182 image: UploadFile | None = File(None), 174 183 ) -> TrackResponse: 175 184 """Update track metadata (only by owner).""" ··· 196 205 track.title = title 197 206 title_changed = True 198 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 + 199 240 # track album changes for list sync 200 241 old_album_id = track.album_id 201 242 await apply_album_update(db, track, album) ··· 252 293 updated_tags.add(tag_name) 253 294 254 295 # always update ATProto record if any metadata changed 296 + support_gate_changed = move_to_private is not None 255 297 metadata_changed = ( 256 - title_changed or album is not None or features is not None or image_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 257 303 ) 258 304 if track.atproto_record_uri and metadata_changed: 259 305 try: ··· 281 327 if new_album_id: 282 328 await schedule_album_list_sync(auth_session.session_id, new_album_id) 283 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 + 284 334 # build track_tags dict for response 285 335 # if tags were updated, use updated_tags; otherwise query for existing 286 336 if tags is not None: ··· 304 354 Exception: if ATProto record update fails 305 355 """ 306 356 record_uri = track.atproto_record_uri 307 - audio_url = track.r2_url 308 - if not record_uri or not audio_url: 357 + if not record_uri: 309 358 return 310 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 + 311 370 updated_record = build_track_record( 312 371 title=track.title, 313 372 artist=track.artist.display_name, ··· 317 376 duration=track.duration, 318 377 features=track.features if track.features else None, 319 378 image_url=image_url_override or await track.get_image_url(), 379 + support_gate=track.support_gate, 320 380 ) 321 381 322 382 result = await update_record(
+108 -24
backend/src/backend/api/tracks/uploads.py
··· 37 37 from backend._internal.image import ImageFormat 38 38 from backend._internal.jobs import job_service 39 39 from backend.config import settings 40 - from backend.models import Artist, Tag, Track, TrackTag 40 + from backend.models import Artist, Tag, Track, TrackTag, UserPreferences 41 41 from backend.models.job import JobStatus, JobType 42 42 from backend.storage import storage 43 43 from backend.utilities.audio import extract_duration ··· 75 75 image_path: str | None = None 76 76 image_filename: str | None = None 77 77 image_content_type: str | None = None 78 + 79 + # supporter-gated content (e.g., {"type": "any"}) 80 + support_gate: dict | None = None 78 81 79 82 80 83 async def _get_or_create_tag( ··· 119 122 upload_id: str, 120 123 file_path: str, 121 124 filename: str, 125 + *, 126 + gated: bool = False, 122 127 ) -> str | None: 123 - """save audio file to storage, returning file_id or None on failure.""" 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..." 124 137 await job_service.update_progress( 125 138 upload_id, 126 139 JobStatus.PROCESSING, 127 - "uploading to storage...", 140 + message, 128 141 phase="upload", 129 - progress_pct=0, 142 + progress_pct=0.0, 130 143 ) 131 144 try: 132 145 async with R2ProgressTracker( 133 146 job_id=upload_id, 134 - message="uploading to storage...", 147 + message=message, 135 148 phase="upload", 136 149 ) as tracker: 137 150 with open(file_path, "rb") as file_obj: 138 - file_id = await storage.save( 139 - file_obj, filename, progress_callback=tracker.on_progress 140 - ) 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 + ) 141 159 142 160 await job_service.update_progress( 143 161 upload_id, 144 162 JobStatus.PROCESSING, 145 - "uploading to storage...", 163 + message, 146 164 phase="upload", 147 165 progress_pct=100.0, 148 166 ) 149 - logfire.info("storage.save completed", file_id=file_id) 167 + logfire.info("storage.save completed", file_id=file_id, gated=gated) 150 168 return file_id 151 169 152 170 except Exception as e: ··· 255 273 with open(ctx.file_path, "rb") as f: 256 274 duration = extract_duration(f) 257 275 258 - # save audio to storage 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) 259 296 file_id = await _save_audio_to_storage( 260 - ctx.upload_id, ctx.file_path, ctx.filename 297 + ctx.upload_id, ctx.file_path, ctx.filename, gated=is_gated 261 298 ) 262 299 if not file_id: 263 300 return ··· 279 316 ) 280 317 return 281 318 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", 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:] 292 324 ) 293 - return 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 294 333 295 334 # save image if provided 296 335 image_url = None ··· 338 377 phase="atproto", 339 378 ) 340 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 + 341 395 atproto_result = await create_track_record( 342 396 auth_session=ctx.auth_session, 343 397 title=ctx.title, 344 398 artist=artist.display_name, 345 - audio_url=r2_url, 399 + audio_url=audio_url_for_record, 346 400 file_type=ext[1:], 347 401 album=ctx.album, 348 402 duration=duration, 349 403 features=featured_artists or None, 350 404 image_url=image_url, 405 + support_gate=ctx.support_gate, 351 406 ) 352 407 if not atproto_result: 353 408 raise ValueError("PDS returned no record data") ··· 403 458 atproto_record_cid=atproto_cid, 404 459 image_id=image_id, 405 460 image_url=image_url, 461 + support_gate=ctx.support_gate, 406 462 ) 407 463 408 464 db.add(track) ··· 467 523 album: Annotated[str | None, Form()] = None, 468 524 features: Annotated[str | None, Form()] = None, 469 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, 470 530 file: UploadFile = File(...), 471 531 image: UploadFile | None = File(None), 472 532 ) -> dict: ··· 477 537 album: Optional album name/ID to associate with the track. 478 538 features: Optional JSON array of ATProto handles, e.g., 479 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. 480 543 file: Audio file to upload (required). 481 544 image: Optional image file for track artwork. 482 545 background_tasks: FastAPI background-task runner. ··· 491 554 except ValueError as e: 492 555 raise HTTPException(status_code=400, detail=str(e)) from e 493 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 + 494 577 # validate audio file type upfront 495 578 if not file.filename: 496 579 raise HTTPException(status_code=400, detail="no filename provided") ··· 577 660 image_path=image_path, 578 661 image_filename=image_filename, 579 662 image_content_type=image_content_type, 663 + support_gate=parsed_support_gate, 580 664 ) 581 665 background_tasks.add_task(_process_upload_background, ctx) 582 666 except Exception:
+14
backend/src/backend/config.py
··· 256 256 validation_alias="R2_IMAGE_BUCKET", 257 257 description="R2 bucket name for image files", 258 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 + ) 259 269 r2_endpoint_url: str = Field( 260 270 default="", 261 271 validation_alias="R2_ENDPOINT_URL", ··· 596 606 scheduling_resolution_seconds: float = Field( 597 607 default=5.0, 598 608 description="How often to run the scheduler loop (seconds). Default 5s reduces Redis costs vs docket's 250ms default.", 609 + ) 610 + schedule_automatic_tasks: bool = Field( 611 + default=True, 612 + description="Schedule automatic perpetual tasks at worker startup. Disable in tests to avoid event loop issues.", 599 613 ) 600 614 601 615
-7
backend/src/backend/main.py
··· 46 46 from backend.api.lists import router as lists_router 47 47 from backend.api.migration import router as migration_router 48 48 from backend.config import settings 49 - from backend.models import init_db 50 49 from backend.utilities.rate_limit import limiter 51 50 52 51 # configure logfire if enabled ··· 148 147 @asynccontextmanager 149 148 async def lifespan(app: FastAPI) -> AsyncIterator[None]: 150 149 """handle application lifespan events.""" 151 - # startup: initialize database 152 - # NOTE: init_db() is still needed because base tables (artists, tracks, user_sessions) 153 - # don't have migrations - they were created before migrations were introduced. 154 - # See issue #46 for removing this in favor of a proper initial migration. 155 - await init_db() 156 - 157 150 # setup services 158 151 await notification_service.setup() 159 152 await queue_service.setup()
+2 -4
backend/src/backend/models/__init__.py
··· 2 2 3 3 from backend.models.album import Album 4 4 from backend.models.artist import Artist 5 - from backend.models.copyright_scan import CopyrightScan, ScanResolution 5 + from backend.models.copyright_scan import CopyrightScan 6 6 from backend.models.database import Base 7 7 from backend.models.sensitive_image import SensitiveImage 8 8 from backend.models.exchange_token import ExchangeToken ··· 18 18 from backend.models.track import Track 19 19 from backend.models.track_comment import TrackComment 20 20 from backend.models.track_like import TrackLike 21 - from backend.utilities.database import db_session, get_db, init_db 21 + from backend.utilities.database import db_session, get_db 22 22 23 23 __all__ = [ 24 24 "Album", ··· 32 32 "PendingScopeUpgrade", 33 33 "Playlist", 34 34 "QueueState", 35 - "ScanResolution", 36 35 "SensitiveImage", 37 36 "Tag", 38 37 "Track", ··· 43 42 "UserSession", 44 43 "db_session", 45 44 "get_db", 46 - "init_db", 47 45 ]
+7 -21
backend/src/backend/models/copyright_scan.py
··· 1 1 """copyright scan model for tracking moderation results.""" 2 2 3 3 from datetime import UTC, datetime 4 - from enum import Enum 5 4 from typing import Any 6 5 7 - from sqlalchemy import DateTime, ForeignKey, Index, Integer, String 6 + from sqlalchemy import DateTime, ForeignKey, Index, Integer 8 7 from sqlalchemy.dialects.postgresql import JSONB 9 8 from sqlalchemy.orm import Mapped, mapped_column 10 9 11 10 from backend.models.database import Base 12 11 13 12 14 - class ScanResolution(str, Enum): 15 - """resolution status for a flagged scan.""" 16 - 17 - PENDING = "pending" # awaiting review 18 - DISMISSED = "dismissed" # false positive, no action needed 19 - REMOVED = "removed" # track was removed 20 - LICENSED = "licensed" # verified to be properly licensed 21 - 22 - 23 13 class CopyrightScan(Base): 24 14 """copyright scan result from moderation service. 25 15 26 - stores scan results from AuDD API for tracking potential 27 - copyright issues without immediate enforcement (ozone pattern). 16 + stores scan results from AuDD API. the labeler service is the source 17 + of truth for whether a track is actively flagged (label not negated). 18 + 19 + the is_flagged field here indicates the initial scan result. the 20 + sync_copyright_resolutions background task updates it when labels 21 + are negated in the labeler. 28 22 """ 29 23 30 24 __tablename__ = "copyright_scans" ··· 61 55 default=dict, 62 56 server_default="{}", 63 57 ) 64 - 65 - # review tracking (for later human review) 66 - resolution: Mapped[str | None] = mapped_column(String, nullable=True) 67 - reviewed_at: Mapped[datetime | None] = mapped_column( 68 - DateTime(timezone=True), nullable=True 69 - ) 70 - reviewed_by: Mapped[str | None] = mapped_column(String, nullable=True) # DID 71 - review_notes: Mapped[str | None] = mapped_column(String, nullable=True) 72 58 73 59 __table_args__ = ( 74 60 Index("idx_copyright_scans_flagged", "is_flagged"),
+10
backend/src/backend/models/track.py
··· 87 87 nullable=False, default=False, server_default="false" 88 88 ) 89 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 + 90 100 @property 91 101 def album(self) -> str | None: 92 102 """get album name from extra (for ATProto compatibility)."""
+20
backend/src/backend/schemas.py
··· 50 50 title: str 51 51 artist: str 52 52 artist_handle: str 53 + artist_did: str 53 54 artist_avatar_url: str | None 54 55 file_id: str 55 56 file_type: str ··· 70 71 None # None = not scanned, False = clear, True = flagged 71 72 ) 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 73 76 74 77 @classmethod 75 78 async def from_track( ··· 81 84 comment_counts: dict[int, int] | None = None, 82 85 copyright_info: dict[int, CopyrightInfo] | None = None, 83 86 track_tags: dict[int, set[str]] | None = None, 87 + viewer_did: str | None = None, 88 + supported_artist_dids: set[str] | None = None, 84 89 ) -> "TrackResponse": 85 90 """build track response from Track model. 86 91 ··· 92 97 comment_counts: optional dict of track_id -> comment_count 93 98 copyright_info: optional dict of track_id -> CopyrightInfo 94 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 95 102 """ 96 103 # check if user has liked this track 97 104 is_liked = liked_track_ids is not None and track.id in liked_track_ids ··· 135 142 # get tags for this track 136 143 tags = track_tags.get(track.id, set()) if track_tags else set() 137 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 + 138 155 return cls( 139 156 id=track.id, 140 157 title=track.title, 141 158 artist=track.artist.display_name, 142 159 artist_handle=track.artist.handle, 160 + artist_did=track.artist_did, 143 161 artist_avatar_url=track.artist.avatar_url, 144 162 file_id=track.file_id, 145 163 file_type=track.file_type, ··· 158 176 tags=tags, 159 177 copyright_flagged=copyright_flagged, 160 178 copyright_match=copyright_match, 179 + support_gate=track.support_gate, 180 + gated=gated, 161 181 )
+3 -8
backend/src/backend/storage/__init__.py
··· 1 1 """storage implementations.""" 2 2 3 - from typing import TYPE_CHECKING 3 + from backend.storage.r2 import R2Storage 4 4 5 - if TYPE_CHECKING: 6 - from backend.storage.r2 import R2Storage 5 + _storage: R2Storage | None = None 7 6 8 - _storage: "R2Storage | None" = None 9 7 10 - 11 - def _get_storage() -> "R2Storage": 8 + def _get_storage() -> R2Storage: 12 9 """lazily initialize storage on first access.""" 13 10 global _storage 14 11 if _storage is None: 15 - from backend.storage.r2 import R2Storage 16 - 17 12 _storage = R2Storage() 18 13 return _storage 19 14
+220 -1
backend/src/backend/storage/r2.py
··· 32 32 total_size: int, 33 33 callback: Callable[[float], None], 34 34 min_bytes_between_updates: int = 5 * 1024 * 1024, # 5MB 35 - min_time_between_updates: float = 0.25, # 250ms 35 + min_time_between_updates: float | int = 0.25, # 250ms 36 36 ): 37 37 """initialize progress tracker. 38 38 ··· 95 95 96 96 self.audio_bucket_name = settings.storage.r2_bucket 97 97 self.image_bucket_name = settings.storage.r2_image_bucket 98 + self.private_audio_bucket_name = settings.storage.r2_private_bucket 98 99 self.public_audio_bucket_url = settings.storage.r2_public_bucket_url 99 100 self.public_image_bucket_url = settings.storage.r2_public_image_bucket_url 101 + self.presigned_url_expiry = settings.storage.presigned_url_expiry_seconds 100 102 101 103 # sync client for upload (used in background tasks) 102 104 self.client = boto3.client( ··· 439 441 image_bucket=self.image_bucket_name, 440 442 ) 441 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
+14 -83
backend/src/backend/utilities/aggregations.py
··· 3 3 import logging 4 4 from collections import Counter 5 5 from dataclasses import dataclass 6 - from datetime import UTC, datetime 7 6 from typing import Any 8 7 9 - from sqlalchemy import select, update 8 + from sqlalchemy import select 10 9 from sqlalchemy.ext.asyncio import AsyncSession 11 10 from sqlalchemy.sql import func 12 11 13 - from backend.models import CopyrightScan, Tag, Track, TrackComment, TrackLike, TrackTag 12 + from backend.models import CopyrightScan, Tag, TrackComment, TrackLike, TrackTag 14 13 15 14 logger = logging.getLogger(__name__) 16 15 ··· 75 74 ) -> dict[int, CopyrightInfo]: 76 75 """get copyright scan info for multiple tracks in a single query. 77 76 78 - checks the moderation service's labeler for the true resolution status. 79 - if a track was resolved (negation label exists), treats it as not flagged 80 - and lazily updates the backend's resolution field. 77 + this is a pure read - no reconciliation with the labeler. 78 + resolution sync happens via background task (sync_copyright_resolutions). 81 79 82 80 args: 83 81 db: database session ··· 89 87 if not track_ids: 90 88 return {} 91 89 92 - # get scans with track AT URIs for labeler lookup 93 - stmt = ( 94 - select( 95 - CopyrightScan.id, 96 - CopyrightScan.track_id, 97 - CopyrightScan.is_flagged, 98 - CopyrightScan.matches, 99 - CopyrightScan.resolution, 100 - Track.atproto_record_uri, 101 - ) 102 - .join(Track, CopyrightScan.track_id == Track.id) 103 - .where(CopyrightScan.track_id.in_(track_ids)) 104 - ) 90 + stmt = select( 91 + CopyrightScan.track_id, 92 + CopyrightScan.is_flagged, 93 + CopyrightScan.matches, 94 + ).where(CopyrightScan.track_id.in_(track_ids)) 105 95 106 96 result = await db.execute(stmt) 107 97 rows = result.all() 108 98 109 - # separate flagged scans that need labeler check vs already resolved 110 - needs_labeler_check: list[ 111 - tuple[int, int, str, list] 112 - ] = [] # scan_id, track_id, uri, matches 113 99 copyright_info: dict[int, CopyrightInfo] = {} 114 - 115 - for scan_id, track_id, is_flagged, matches, resolution, uri in rows: 116 - if not is_flagged or resolution is not None: 117 - # not flagged or already resolved - no need to check labeler 118 - copyright_info[track_id] = CopyrightInfo( 119 - is_flagged=False if resolution else is_flagged, 120 - primary_match=_extract_primary_match(matches) 121 - if is_flagged and not resolution 122 - else None, 123 - ) 124 - elif uri: 125 - # flagged with no resolution - need to check labeler 126 - needs_labeler_check.append((scan_id, track_id, uri, matches)) 127 - else: 128 - # flagged but no AT URI - can't check labeler, treat as flagged 129 - copyright_info[track_id] = CopyrightInfo( 130 - is_flagged=True, 131 - primary_match=_extract_primary_match(matches), 132 - ) 133 - 134 - # check labeler for tracks that need it 135 - if needs_labeler_check: 136 - from backend._internal.moderation import get_active_copyright_labels 137 - 138 - uris = [uri for _, _, uri, _ in needs_labeler_check] 139 - active_uris = await get_active_copyright_labels(uris) 140 - 141 - # process results and lazily update DB for resolved tracks 142 - resolved_scan_ids: list[int] = [] 143 - for scan_id, track_id, uri, matches in needs_labeler_check: 144 - if uri in active_uris: 145 - # still actively flagged 146 - copyright_info[track_id] = CopyrightInfo( 147 - is_flagged=True, 148 - primary_match=_extract_primary_match(matches), 149 - ) 150 - else: 151 - # resolved in labeler - treat as not flagged 152 - copyright_info[track_id] = CopyrightInfo( 153 - is_flagged=False, 154 - primary_match=None, 155 - ) 156 - resolved_scan_ids.append(scan_id) 157 - 158 - # lazily update resolution for newly discovered resolved scans 159 - if resolved_scan_ids: 160 - try: 161 - await db.execute( 162 - update(CopyrightScan) 163 - .where(CopyrightScan.id.in_(resolved_scan_ids)) 164 - .values(resolution="dismissed", reviewed_at=datetime.now(UTC)) 165 - ) 166 - await db.commit() 167 - logger.info( 168 - "lazily updated %d copyright scans as dismissed", 169 - len(resolved_scan_ids), 170 - ) 171 - except Exception as e: 172 - logger.warning("failed to lazily update copyright resolutions: %s", e) 173 - await db.rollback() 100 + for track_id, is_flagged, matches in rows: 101 + copyright_info[track_id] = CopyrightInfo( 102 + is_flagged=is_flagged, 103 + primary_match=_extract_primary_match(matches) if is_flagged else None, 104 + ) 174 105 175 106 return copyright_info 176 107
-9
backend/src/backend/utilities/database.py
··· 90 90 """get async database session (for FastAPI dependency injection).""" 91 91 async with db_session() as session: 92 92 yield session 93 - 94 - 95 - async def init_db(): 96 - """initialize database tables.""" 97 - from backend.models.database import Base 98 - 99 - engine = get_engine() 100 - async with engine.begin() as conn: 101 - await conn.run_sync(Base.metadata.create_all)
+2 -1
backend/src/backend/utilities/hashing.py
··· 1 1 """streaming hash calculation utilities.""" 2 2 3 3 import hashlib 4 + from io import IOBase 4 5 from typing import BinaryIO 5 6 6 7 # 8MB chunks balances memory usage and performance 7 8 CHUNK_SIZE = 8 * 1024 * 1024 8 9 9 10 10 - def hash_file_chunked(file_obj: BinaryIO, algorithm: str = "sha256") -> str: 11 + def hash_file_chunked(file_obj: BinaryIO | IOBase, algorithm: str = "sha256") -> str: 11 12 """compute hash by reading file in chunks. 12 13 13 14 this prevents loading entire file into memory, enabling constant
+2 -1
backend/tests/api/test_albums.py
··· 657 657 track = Track( 658 658 title="Test Track", 659 659 file_id="test-file-update", 660 - file_type="audio/mpeg", 660 + file_type="mp3", 661 661 artist_did=artist.did, 662 662 album_id=album.id, 663 663 extra={"album": "Original Title"}, 664 + r2_url="https://r2.example.com/audio/test-file-update.mp3", 664 665 atproto_record_uri="at://did:test:user123/fm.plyr.track/track123", 665 666 atproto_record_cid="original_cid", 666 667 )
+218 -3
backend/tests/api/test_audio.py
··· 271 271 test_app.dependency_overrides.pop(require_auth, None) 272 272 273 273 274 - async def test_get_audio_url_requires_auth(test_app: FastAPI): 275 - """test that /url endpoint returns 401 without authentication.""" 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 + 276 298 # ensure no auth override 277 299 test_app.dependency_overrides.pop(require_auth, None) 278 300 279 301 async with AsyncClient( 280 302 transport=ASGITransport(app=test_app), base_url="http://test" 281 303 ) as client: 282 - response = await client.get("/audio/somefile/url") 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}") 283 392 284 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)
+22 -8
backend/tests/conftest.py
··· 22 22 23 23 from backend.config import settings 24 24 from backend.models import Base 25 + from backend.storage.r2 import R2Storage 25 26 from backend.utilities.redis import clear_client_cache 26 27 27 28 28 - class MockStorage: 29 + class MockStorage(R2Storage): 29 30 """Mock storage for tests - no R2 credentials needed.""" 30 31 32 + def __init__(self): 33 + # skip R2Storage.__init__ which requires credentials 34 + pass 35 + 31 36 async def save(self, file_obj, filename: str, progress_callback=None) -> str: 32 37 """Mock save - returns a fake file_id.""" 33 38 return "mock_file_id_123" 34 39 35 40 async def get_url( 36 - self, file_id: str, file_type: str | None = None, extension: str | None = None 37 - ) -> str: 41 + self, 42 + file_id: str, 43 + *, 44 + file_type: str | None = None, 45 + extension: str | None = None, 46 + ) -> str | None: 38 47 """Mock get_url - returns a fake URL.""" 39 48 return f"https://mock.r2.dev/{file_id}" 40 49 41 - async def delete(self, file_id: str, extension: str | None = None) -> None: 50 + async def delete(self, file_id: str, file_type: str | None = None) -> bool: 42 51 """Mock delete.""" 52 + return True 43 53 44 54 45 55 def pytest_configure(config): ··· 359 369 yield session 360 370 361 371 362 - @pytest.fixture 372 + @pytest.fixture(scope="session") 363 373 def fastapi_app() -> FastAPI: 364 - """provides the FastAPI app instance.""" 374 + """provides the FastAPI app instance (session-scoped for performance).""" 365 375 from backend.main import app as main_app 366 376 367 377 return main_app 368 378 369 379 370 - @pytest.fixture 380 + @pytest.fixture(scope="session") 371 381 def client(fastapi_app: FastAPI) -> Generator[TestClient, None, None]: 372 - """provides a TestClient for testing the FastAPI application.""" 382 + """provides a TestClient for testing the FastAPI application. 383 + 384 + session-scoped to avoid the overhead of starting the full lifespan 385 + (database init, services, docket worker) for each test. 386 + """ 373 387 with TestClient(fastapi_app) as tc: 374 388 yield tc 375 389
+276 -305
backend/tests/test_moderation.py
··· 4 4 5 5 import httpx 6 6 import pytest 7 + from fastapi.testclient import TestClient 7 8 from sqlalchemy import select 8 9 from sqlalchemy.ext.asyncio import AsyncSession 9 10 10 11 from backend._internal.moderation import ( 11 - _call_moderation_service, 12 - _store_scan_result, 13 12 get_active_copyright_labels, 14 13 scan_track_for_copyright, 15 14 ) 15 + from backend._internal.moderation_client import ( 16 + ModerationClient, 17 + ScanResult, 18 + SensitiveImagesResult, 19 + ) 16 20 from backend.models import Artist, CopyrightScan, Track 17 21 18 22 19 23 @pytest.fixture 20 - def mock_moderation_response() -> dict: 21 - """typical response from moderation service.""" 22 - return { 23 - "matches": [ 24 + def mock_scan_result() -> ScanResult: 25 + """typical scan result from moderation client.""" 26 + return ScanResult( 27 + is_flagged=True, 28 + highest_score=85, 29 + matches=[ 24 30 { 25 31 "artist": "Test Artist", 26 32 "title": "Test Song", ··· 28 34 "isrc": "USRC12345678", 29 35 } 30 36 ], 31 - "is_flagged": True, 32 - "highest_score": 85, 33 - "raw_response": {"status": "success", "result": []}, 34 - } 37 + raw_response={"status": "success", "result": []}, 38 + ) 35 39 36 40 37 41 @pytest.fixture 38 - def mock_clear_response() -> dict: 39 - """response when no copyright matches found.""" 40 - return { 41 - "matches": [], 42 - "is_flagged": False, 43 - "highest_score": 0, 44 - "raw_response": {"status": "success", "result": None}, 45 - } 42 + def mock_clear_result() -> ScanResult: 43 + """scan result when no copyright matches found.""" 44 + return ScanResult( 45 + is_flagged=False, 46 + highest_score=0, 47 + matches=[], 48 + raw_response={"status": "success", "result": None}, 49 + ) 46 50 47 51 48 - async def test_call_moderation_service_success( 49 - mock_moderation_response: dict, 50 - ) -> None: 51 - """test successful call to moderation service.""" 52 - # use regular Mock for response since httpx Response methods are sync 52 + async def test_moderation_client_scan_success() -> None: 53 + """test ModerationClient.scan() with successful response.""" 53 54 mock_response = Mock() 54 - mock_response.json.return_value = mock_moderation_response 55 + mock_response.json.return_value = { 56 + "is_flagged": True, 57 + "highest_score": 85, 58 + "matches": [{"artist": "Test", "title": "Song", "score": 85}], 59 + "raw_response": {"status": "success"}, 60 + } 55 61 mock_response.raise_for_status.return_value = None 56 62 63 + client = ModerationClient( 64 + service_url="https://test.example.com", 65 + labeler_url="https://labeler.example.com", 66 + auth_token="test-token", 67 + timeout_seconds=30, 68 + label_cache_prefix="test:label:", 69 + label_cache_ttl_seconds=300, 70 + ) 71 + 57 72 with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: 58 73 mock_post.return_value = mock_response 59 74 60 - with patch("backend._internal.moderation.settings") as mock_settings: 61 - mock_settings.moderation.service_url = "https://test.example.com" 62 - mock_settings.moderation.auth_token = "test-token" 63 - mock_settings.moderation.timeout_seconds = 30 75 + result = await client.scan("https://example.com/audio.mp3") 64 76 65 - result = await _call_moderation_service("https://example.com/audio.mp3") 66 - 67 - assert result == mock_moderation_response 77 + assert result.is_flagged is True 78 + assert result.highest_score == 85 79 + assert len(result.matches) == 1 68 80 mock_post.assert_called_once() 69 - call_kwargs = mock_post.call_args 70 - assert call_kwargs.kwargs["json"] == {"audio_url": "https://example.com/audio.mp3"} 71 - assert call_kwargs.kwargs["headers"] == {"X-Moderation-Key": "test-token"} 72 81 73 82 74 - async def test_call_moderation_service_timeout() -> None: 75 - """test timeout handling.""" 83 + async def test_moderation_client_scan_timeout() -> None: 84 + """test ModerationClient.scan() timeout handling.""" 85 + client = ModerationClient( 86 + service_url="https://test.example.com", 87 + labeler_url="https://labeler.example.com", 88 + auth_token="test-token", 89 + timeout_seconds=30, 90 + label_cache_prefix="test:label:", 91 + label_cache_ttl_seconds=300, 92 + ) 93 + 76 94 with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: 77 95 mock_post.side_effect = httpx.TimeoutException("timeout") 78 96 79 - with patch("backend._internal.moderation.settings") as mock_settings: 80 - mock_settings.moderation.service_url = "https://test.example.com" 81 - mock_settings.moderation.auth_token = "test-token" 82 - mock_settings.moderation.timeout_seconds = 30 97 + with pytest.raises(httpx.TimeoutException): 98 + await client.scan("https://example.com/audio.mp3") 83 99 84 - with pytest.raises(httpx.TimeoutException): 85 - await _call_moderation_service("https://example.com/audio.mp3") 86 100 87 - 88 - async def test_store_scan_result_flagged( 101 + async def test_scan_track_stores_flagged_result( 89 102 db_session: AsyncSession, 90 - mock_moderation_response: dict, 103 + mock_scan_result: ScanResult, 91 104 ) -> None: 92 105 """test storing a flagged scan result.""" 93 - # create test artist and track 94 106 artist = Artist( 95 107 did="did:plc:test123", 96 108 handle="test.bsky.social", ··· 109 121 db_session.add(track) 110 122 await db_session.commit() 111 123 112 - await _store_scan_result(track.id, mock_moderation_response) 124 + with patch("backend._internal.moderation.settings") as mock_settings: 125 + mock_settings.moderation.enabled = True 126 + mock_settings.moderation.auth_token = "test-token" 127 + 128 + with patch( 129 + "backend._internal.moderation.get_moderation_client" 130 + ) as mock_get_client: 131 + mock_client = AsyncMock() 132 + mock_client.scan.return_value = mock_scan_result 133 + mock_get_client.return_value = mock_client 134 + 135 + assert track.r2_url is not None 136 + await scan_track_for_copyright(track.id, track.r2_url) 113 137 114 - # verify scan was stored 115 138 result = await db_session.execute( 116 139 select(CopyrightScan).where(CopyrightScan.track_id == track.id) 117 140 ) ··· 123 146 assert scan.matches[0]["artist"] == "Test Artist" 124 147 125 148 126 - async def test_store_scan_result_flagged_emits_label( 149 + async def test_scan_track_emits_label_when_flagged( 127 150 db_session: AsyncSession, 128 - mock_moderation_response: dict, 151 + mock_scan_result: ScanResult, 129 152 ) -> None: 130 153 """test that flagged scan result emits ATProto label.""" 131 - # create test artist and track with ATProto URI 132 154 artist = Artist( 133 155 did="did:plc:labelertest", 134 156 handle="labeler.bsky.social", ··· 149 171 db_session.add(track) 150 172 await db_session.commit() 151 173 152 - with patch( 153 - "backend._internal.moderation._emit_copyright_label", 154 - new_callable=AsyncMock, 155 - ) as mock_emit: 156 - await _store_scan_result(track.id, mock_moderation_response) 174 + with patch("backend._internal.moderation.settings") as mock_settings: 175 + mock_settings.moderation.enabled = True 176 + mock_settings.moderation.auth_token = "test-token" 157 177 158 - # verify label emission was called with full context 159 - mock_emit.assert_called_once_with( 160 - uri="at://did:plc:labelertest/fm.plyr.track/abc123", 161 - cid="bafyreiabc123", 162 - track_id=track.id, 163 - track_title="Labeler Test Track", 164 - artist_handle="labeler.bsky.social", 165 - artist_did="did:plc:labelertest", 166 - highest_score=85, 167 - matches=[ 168 - { 169 - "artist": "Test Artist", 170 - "title": "Test Song", 171 - "score": 85, 172 - "isrc": "USRC12345678", 173 - } 174 - ], 175 - ) 178 + with patch( 179 + "backend._internal.moderation.get_moderation_client" 180 + ) as mock_get_client: 181 + mock_client = AsyncMock() 182 + mock_client.scan.return_value = mock_scan_result 183 + mock_client.emit_label = AsyncMock() 184 + mock_get_client.return_value = mock_client 185 + 186 + assert track.r2_url is not None 187 + await scan_track_for_copyright(track.id, track.r2_url) 188 + 189 + # verify label was emitted 190 + mock_client.emit_label.assert_called_once() 191 + call_kwargs = mock_client.emit_label.call_args.kwargs 192 + assert call_kwargs["uri"] == "at://did:plc:labelertest/fm.plyr.track/abc123" 193 + assert call_kwargs["cid"] == "bafyreiabc123" 176 194 177 195 178 - async def test_store_scan_result_flagged_no_atproto_uri_skips_label( 196 + async def test_scan_track_no_label_without_atproto_uri( 179 197 db_session: AsyncSession, 180 - mock_moderation_response: dict, 198 + mock_scan_result: ScanResult, 181 199 ) -> None: 182 200 """test that flagged scan without ATProto URI skips label emission.""" 183 - # create test artist and track without ATProto URI 184 201 artist = Artist( 185 202 did="did:plc:nouri", 186 203 handle="nouri.bsky.social", ··· 200 217 db_session.add(track) 201 218 await db_session.commit() 202 219 203 - with patch( 204 - "backend._internal.moderation._emit_copyright_label", 205 - new_callable=AsyncMock, 206 - ) as mock_emit: 207 - await _store_scan_result(track.id, mock_moderation_response) 220 + with patch("backend._internal.moderation.settings") as mock_settings: 221 + mock_settings.moderation.enabled = True 222 + mock_settings.moderation.auth_token = "test-token" 223 + 224 + with patch( 225 + "backend._internal.moderation.get_moderation_client" 226 + ) as mock_get_client: 227 + mock_client = AsyncMock() 228 + mock_client.scan.return_value = mock_scan_result 229 + mock_client.emit_label = AsyncMock() 230 + mock_get_client.return_value = mock_client 231 + 232 + assert track.r2_url is not None 233 + await scan_track_for_copyright(track.id, track.r2_url) 208 234 209 - # label emission should not be called 210 - mock_emit.assert_not_called() 235 + # label emission should not be called 236 + mock_client.emit_label.assert_not_called() 211 237 212 238 213 - async def test_store_scan_result_clear( 239 + async def test_scan_track_stores_clear_result( 214 240 db_session: AsyncSession, 215 - mock_clear_response: dict, 241 + mock_clear_result: ScanResult, 216 242 ) -> None: 217 243 """test storing a clear (no matches) scan result.""" 218 - # create test artist and track 219 244 artist = Artist( 220 245 did="did:plc:test456", 221 246 handle="clear.bsky.social", ··· 234 259 db_session.add(track) 235 260 await db_session.commit() 236 261 237 - await _store_scan_result(track.id, mock_clear_response) 262 + with patch("backend._internal.moderation.settings") as mock_settings: 263 + mock_settings.moderation.enabled = True 264 + mock_settings.moderation.auth_token = "test-token" 238 265 239 - # verify scan was stored 266 + with patch( 267 + "backend._internal.moderation.get_moderation_client" 268 + ) as mock_get_client: 269 + mock_client = AsyncMock() 270 + mock_client.scan.return_value = mock_clear_result 271 + mock_get_client.return_value = mock_client 272 + 273 + assert track.r2_url is not None 274 + await scan_track_for_copyright(track.id, track.r2_url) 275 + 240 276 result = await db_session.execute( 241 277 select(CopyrightScan).where(CopyrightScan.track_id == track.id) 242 278 ) ··· 253 289 mock_settings.moderation.enabled = False 254 290 255 291 with patch( 256 - "backend._internal.moderation._call_moderation_service" 257 - ) as mock_call: 292 + "backend._internal.moderation.get_moderation_client" 293 + ) as mock_get_client: 258 294 await scan_track_for_copyright(1, "https://example.com/audio.mp3") 259 295 260 - # should not call the service when disabled 261 - mock_call.assert_not_called() 296 + # should not even get the client when disabled 297 + mock_get_client.assert_not_called() 262 298 263 299 264 300 async def test_scan_track_no_auth_token() -> None: ··· 268 304 mock_settings.moderation.auth_token = "" 269 305 270 306 with patch( 271 - "backend._internal.moderation._call_moderation_service" 272 - ) as mock_call: 307 + "backend._internal.moderation.get_moderation_client" 308 + ) as mock_get_client: 273 309 await scan_track_for_copyright(1, "https://example.com/audio.mp3") 274 310 275 - # should not call the service without auth token 276 - mock_call.assert_not_called() 311 + # should not even get the client without auth token 312 + mock_get_client.assert_not_called() 277 313 278 314 279 315 async def test_scan_track_service_error_stores_as_clear( 280 316 db_session: AsyncSession, 281 317 ) -> None: 282 318 """test that service errors are stored as clear results.""" 283 - # create test artist and track 284 319 artist = Artist( 285 320 did="did:plc:errortest", 286 321 handle="errortest.bsky.social", ··· 304 339 mock_settings.moderation.auth_token = "test-token" 305 340 306 341 with patch( 307 - "backend._internal.moderation._call_moderation_service", 308 - new_callable=AsyncMock, 309 - ) as mock_call: 310 - mock_call.side_effect = httpx.HTTPStatusError( 342 + "backend._internal.moderation.get_moderation_client" 343 + ) as mock_get_client: 344 + mock_client = AsyncMock() 345 + mock_client.scan.side_effect = httpx.HTTPStatusError( 311 346 "502 error", 312 347 request=AsyncMock(), 313 348 response=AsyncMock(status_code=502), 314 349 ) 350 + mock_get_client.return_value = mock_client 315 351 316 352 # should not raise - stores error as clear 317 353 await scan_track_for_copyright(track.id, "https://example.com/short.mp3") 318 354 319 - # verify scan was stored as clear with error info 320 355 result = await db_session.execute( 321 356 select(CopyrightScan).where(CopyrightScan.track_id == track.id) 322 357 ) ··· 329 364 assert scan.raw_response["status"] == "scan_failed" 330 365 331 366 332 - async def test_scan_track_full_flow( 333 - db_session: AsyncSession, 334 - mock_moderation_response: dict, 335 - ) -> None: 336 - """test complete scan flow from track to stored result.""" 337 - # create test artist and track 338 - artist = Artist( 339 - did="did:plc:fullflow", 340 - handle="fullflow.bsky.social", 341 - display_name="Full Flow User", 342 - ) 343 - db_session.add(artist) 344 - await db_session.commit() 345 - 346 - track = Track( 347 - title="Full Flow Track", 348 - file_id="fullflow_file", 349 - file_type="flac", 350 - artist_did=artist.did, 351 - r2_url="https://example.com/fullflow.flac", 352 - ) 353 - db_session.add(track) 354 - await db_session.commit() 355 - 356 - with patch("backend._internal.moderation.settings") as mock_settings: 357 - mock_settings.moderation.enabled = True 358 - mock_settings.moderation.auth_token = "test-token" 359 - mock_settings.moderation.service_url = "https://test.example.com" 360 - mock_settings.moderation.timeout_seconds = 30 361 - 362 - with patch( 363 - "backend._internal.moderation._call_moderation_service", 364 - new_callable=AsyncMock, 365 - ) as mock_call: 366 - mock_call.return_value = mock_moderation_response 367 - 368 - assert track.r2_url is not None 369 - await scan_track_for_copyright(track.id, track.r2_url) 370 - 371 - # verify scan was stored (need fresh session query) 372 - result = await db_session.execute( 373 - select(CopyrightScan).where(CopyrightScan.track_id == track.id) 374 - ) 375 - scan = result.scalar_one() 376 - 377 - assert scan.is_flagged is True 378 - assert scan.highest_score == 85 379 - 380 - 381 367 # tests for get_active_copyright_labels 382 368 383 369 ··· 420 406 "at://did:plc:success/fm.plyr.track/3", 421 407 ] 422 408 423 - mock_response = Mock() 424 - mock_response.json.return_value = { 425 - "active_uris": [uris[0]] # only first is active 426 - } 427 - mock_response.raise_for_status.return_value = None 428 - 429 409 with patch("backend._internal.moderation.settings") as mock_settings: 430 410 mock_settings.moderation.enabled = True 431 411 mock_settings.moderation.auth_token = "test-token" 432 - mock_settings.moderation.labeler_url = "https://test.example.com" 433 - mock_settings.moderation.timeout_seconds = 30 434 - mock_settings.moderation.label_cache_prefix = "test:label:" 435 - mock_settings.moderation.label_cache_ttl_seconds = 300 436 412 437 - with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: 438 - mock_post.return_value = mock_response 413 + with patch( 414 + "backend._internal.moderation.get_moderation_client" 415 + ) as mock_get_client: 416 + mock_client = AsyncMock() 417 + mock_client.get_active_labels.return_value = {uris[0]} # only first active 418 + mock_get_client.return_value = mock_client 439 419 440 420 result = await get_active_copyright_labels(uris) 441 421 442 - # only the active URI should be in the result 443 - assert result == {uris[0]} 444 - 445 - # verify correct endpoint was called 446 - call_kwargs = mock_post.call_args 447 - assert "/admin/active-labels" in str(call_kwargs) 448 - assert call_kwargs.kwargs["json"] == {"uris": uris} 422 + assert result == {uris[0]} 449 423 450 424 451 425 async def test_get_active_copyright_labels_service_error() -> None: ··· 458 432 with patch("backend._internal.moderation.settings") as mock_settings: 459 433 mock_settings.moderation.enabled = True 460 434 mock_settings.moderation.auth_token = "test-token" 461 - mock_settings.moderation.labeler_url = "https://test.example.com" 462 - mock_settings.moderation.timeout_seconds = 30 463 - mock_settings.moderation.label_cache_prefix = "test:label:" 464 - mock_settings.moderation.label_cache_ttl_seconds = 300 465 435 466 - with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: 467 - mock_post.side_effect = httpx.ConnectError("connection failed") 436 + with patch( 437 + "backend._internal.moderation.get_moderation_client" 438 + ) as mock_get_client: 439 + mock_client = AsyncMock() 440 + # client's get_active_labels fails closed internally 441 + mock_client.get_active_labels.return_value = set(uris) 442 + mock_get_client.return_value = mock_client 468 443 469 444 result = await get_active_copyright_labels(uris) 470 445 471 - # should fail closed - all URIs treated as active 472 - assert result == set(uris) 446 + assert result == set(uris) 473 447 474 448 475 - # tests for active labels caching (using real redis from test docker-compose) 449 + # tests for background task 476 450 477 451 478 - async def test_get_active_copyright_labels_caching() -> None: 479 - """test that repeated calls use cache instead of calling service.""" 480 - uris = [ 481 - "at://did:plc:caching/fm.plyr.track/1", 482 - "at://did:plc:caching/fm.plyr.track/2", 483 - ] 452 + async def test_sync_copyright_resolutions(db_session: AsyncSession) -> None: 453 + """test that sync_copyright_resolutions updates flagged scans.""" 454 + from backend._internal.background_tasks import sync_copyright_resolutions 484 455 485 - mock_response = Mock() 486 - mock_response.json.return_value = { 487 - "active_uris": [uris[0]] # only first is active 488 - } 489 - mock_response.raise_for_status.return_value = None 456 + # create test artist and tracks 457 + artist = Artist( 458 + did="did:plc:synctest", 459 + handle="synctest.bsky.social", 460 + display_name="Sync Test User", 461 + ) 462 + db_session.add(artist) 463 + await db_session.commit() 490 464 491 - with patch("backend._internal.moderation.settings") as mock_settings: 492 - mock_settings.moderation.enabled = True 493 - mock_settings.moderation.auth_token = "test-token" 494 - mock_settings.moderation.labeler_url = "https://test.example.com" 495 - mock_settings.moderation.timeout_seconds = 30 496 - mock_settings.moderation.label_cache_prefix = "test:label:" 497 - mock_settings.moderation.label_cache_ttl_seconds = 300 498 - 499 - with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: 500 - mock_post.return_value = mock_response 501 - 502 - # first call - should hit service 503 - result1 = await get_active_copyright_labels(uris) 504 - assert result1 == {uris[0]} 505 - assert mock_post.call_count == 1 506 - 507 - # second call with same URIs - should use cache, not call service 508 - result2 = await get_active_copyright_labels(uris) 509 - assert result2 == {uris[0]} 510 - assert mock_post.call_count == 1 # still 1, no new call 511 - 512 - 513 - async def test_get_active_copyright_labels_partial_cache() -> None: 514 - """test that cache hits are combined with service calls for new URIs.""" 515 - uris_batch1 = ["at://did:plc:partial/fm.plyr.track/1"] 516 - uris_batch2 = [ 517 - "at://did:plc:partial/fm.plyr.track/1", # cached 518 - "at://did:plc:partial/fm.plyr.track/2", # new 519 - ] 465 + # track 1: flagged, will be resolved 466 + track1 = Track( 467 + title="Flagged Track 1", 468 + file_id="flagged_1", 469 + file_type="mp3", 470 + artist_did=artist.did, 471 + r2_url="https://example.com/flagged1.mp3", 472 + atproto_record_uri="at://did:plc:synctest/fm.plyr.track/1", 473 + ) 474 + db_session.add(track1) 520 475 521 - mock_response1 = Mock() 522 - mock_response1.json.return_value = { 523 - "active_uris": ["at://did:plc:partial/fm.plyr.track/1"] 524 - } 525 - mock_response1.raise_for_status.return_value = None 476 + # track 2: flagged, will stay flagged 477 + track2 = Track( 478 + title="Flagged Track 2", 479 + file_id="flagged_2", 480 + file_type="mp3", 481 + artist_did=artist.did, 482 + r2_url="https://example.com/flagged2.mp3", 483 + atproto_record_uri="at://did:plc:synctest/fm.plyr.track/2", 484 + ) 485 + db_session.add(track2) 486 + await db_session.commit() 526 487 527 - mock_response2 = Mock() 528 - mock_response2.json.return_value = { 529 - "active_uris": [] # uri/2 is not active 530 - } 531 - mock_response2.raise_for_status.return_value = None 488 + # create flagged scans 489 + scan1 = CopyrightScan( 490 + track_id=track1.id, 491 + is_flagged=True, 492 + highest_score=85, 493 + matches=[{"artist": "Test", "title": "Song"}], 494 + raw_response={}, 495 + ) 496 + scan2 = CopyrightScan( 497 + track_id=track2.id, 498 + is_flagged=True, 499 + highest_score=90, 500 + matches=[{"artist": "Test", "title": "Song2"}], 501 + raw_response={}, 502 + ) 503 + db_session.add_all([scan1, scan2]) 504 + await db_session.commit() 532 505 533 - with patch("backend._internal.moderation.settings") as mock_settings: 534 - mock_settings.moderation.enabled = True 535 - mock_settings.moderation.auth_token = "test-token" 536 - mock_settings.moderation.labeler_url = "https://test.example.com" 537 - mock_settings.moderation.timeout_seconds = 30 538 - mock_settings.moderation.label_cache_prefix = "test:label:" 539 - mock_settings.moderation.label_cache_ttl_seconds = 300 506 + with patch( 507 + "backend._internal.moderation_client.get_moderation_client" 508 + ) as mock_get_client: 509 + mock_client = AsyncMock() 510 + # only track2's URI is still active 511 + mock_client.get_active_labels.return_value = { 512 + "at://did:plc:synctest/fm.plyr.track/2" 513 + } 514 + mock_get_client.return_value = mock_client 540 515 541 - with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: 542 - mock_post.side_effect = [mock_response1, mock_response2] 516 + await sync_copyright_resolutions() 543 517 544 - # first call - cache uri/1 as active 545 - result1 = await get_active_copyright_labels(uris_batch1) 546 - assert result1 == {"at://did:plc:partial/fm.plyr.track/1"} 547 - assert mock_post.call_count == 1 518 + # refresh from db 519 + await db_session.refresh(scan1) 520 + await db_session.refresh(scan2) 548 521 549 - # second call - uri/1 from cache, only uri/2 fetched 550 - result2 = await get_active_copyright_labels(uris_batch2) 551 - # uri/1 is active (from cache), uri/2 is not active (from service) 552 - assert result2 == {"at://did:plc:partial/fm.plyr.track/1"} 553 - assert mock_post.call_count == 2 522 + # scan1 should no longer be flagged (label was negated) 523 + assert scan1.is_flagged is False 554 524 555 - # verify second call only requested uri/2 556 - second_call_args = mock_post.call_args_list[1] 557 - assert second_call_args.kwargs["json"] == { 558 - "uris": ["at://did:plc:partial/fm.plyr.track/2"] 559 - } 525 + # scan2 should still be flagged 526 + assert scan2.is_flagged is True 560 527 561 528 562 - async def test_get_active_copyright_labels_cache_invalidation() -> None: 563 - """test that invalidate_label_cache clears specific entry.""" 564 - from backend._internal.moderation import invalidate_label_cache 529 + # tests for sensitive images 565 530 566 - uris = ["at://did:plc:invalidate/fm.plyr.track/1"] 567 531 532 + async def test_moderation_client_get_sensitive_images() -> None: 533 + """test ModerationClient.get_sensitive_images() with successful response.""" 568 534 mock_response = Mock() 569 535 mock_response.json.return_value = { 570 - "active_uris": ["at://did:plc:invalidate/fm.plyr.track/1"] 536 + "image_ids": ["abc123", "def456"], 537 + "urls": ["https://example.com/image.jpg"], 571 538 } 572 539 mock_response.raise_for_status.return_value = None 573 540 574 - with patch("backend._internal.moderation.settings") as mock_settings: 575 - mock_settings.moderation.enabled = True 576 - mock_settings.moderation.auth_token = "test-token" 577 - mock_settings.moderation.labeler_url = "https://test.example.com" 578 - mock_settings.moderation.timeout_seconds = 30 579 - mock_settings.moderation.label_cache_prefix = "test:label:" 580 - mock_settings.moderation.label_cache_ttl_seconds = 300 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 + ) 581 549 582 - with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: 583 - mock_post.return_value = mock_response 550 + with patch("httpx.AsyncClient.get", new_callable=AsyncMock) as mock_get: 551 + mock_get.return_value = mock_response 584 552 585 - # first call - populate cache 586 - result1 = await get_active_copyright_labels(uris) 587 - assert result1 == {"at://did:plc:invalidate/fm.plyr.track/1"} 588 - assert mock_post.call_count == 1 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 + 589 559 590 - # invalidate the cache entry 591 - await invalidate_label_cache("at://did:plc:invalidate/fm.plyr.track/1") 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 592 565 593 - # next call - should hit service again since cache was invalidated 594 - result2 = await get_active_copyright_labels(uris) 595 - assert result2 == {"at://did:plc:invalidate/fm.plyr.track/1"} 596 - assert mock_post.call_count == 2 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 + ) 597 574 575 + with patch("httpx.AsyncClient.get", new_callable=AsyncMock) as mock_get: 576 + mock_get.return_value = mock_response 598 577 599 - async def test_service_error_does_not_cache() -> None: 600 - """test that service errors don't pollute the cache.""" 601 - # use unique URIs for this test to avoid cache pollution from other tests 602 - uris = ["at://did:plc:errnocache/fm.plyr.track/1"] 578 + result = await client.get_sensitive_images() 603 579 604 - mock_success_response = Mock() 605 - mock_success_response.json.return_value = {"active_uris": []} 606 - mock_success_response.raise_for_status.return_value = None 580 + assert result.image_ids == [] 581 + assert result.urls == [] 607 582 608 - with patch("backend._internal.moderation.settings") as mock_settings: 609 - mock_settings.moderation.enabled = True 610 - mock_settings.moderation.auth_token = "test-token" 611 - mock_settings.moderation.labeler_url = "https://test.example.com" 612 - mock_settings.moderation.timeout_seconds = 30 613 - mock_settings.moderation.label_cache_prefix = "test:label:" 614 - mock_settings.moderation.label_cache_ttl_seconds = 300 615 583 616 - with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: 617 - # first call fails 618 - mock_post.side_effect = httpx.ConnectError("connection failed") 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 + ) 619 592 620 - # first call - fails, returns all URIs as active (fail closed) 621 - result1 = await get_active_copyright_labels(uris) 622 - assert result1 == set(uris) 623 - assert mock_post.call_count == 1 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 624 597 625 - # reset mock to succeed 626 - mock_post.side_effect = None 627 - mock_post.return_value = mock_success_response 598 + response = client.get("/moderation/sensitive-images") 628 599 629 - # second call - should try service again (error wasn't cached) 630 - result2 = await get_active_copyright_labels(uris) 631 - assert result2 == set() # now correctly shows not active 632 - assert mock_post.call_count == 2 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"]
+20 -70
backend/tests/utilities/test_aggregations.py
··· 1 1 """tests for aggregation utilities.""" 2 2 3 - from unittest.mock import AsyncMock, patch 4 - 5 3 import pytest 6 - from sqlalchemy import select 7 4 from sqlalchemy.ext.asyncio import AsyncSession 8 5 9 6 from backend.models import Artist, CopyrightScan, Track, TrackLike ··· 146 143 return track 147 144 148 145 149 - async def test_get_copyright_info_already_resolved( 146 + async def test_get_copyright_info_flagged( 150 147 db_session: AsyncSession, flagged_track: Track 151 148 ) -> None: 152 - """test that already resolved scans are treated as not flagged.""" 153 - # update scan to have resolution set 154 - scan = await db_session.scalar( 155 - select(CopyrightScan).where(CopyrightScan.track_id == flagged_track.id) 156 - ) 157 - assert scan is not None 158 - scan.resolution = "dismissed" 159 - await db_session.commit() 160 - 161 - # should NOT call labeler since resolution is already set 162 - with patch( 163 - "backend._internal.moderation.get_active_copyright_labels", 164 - new_callable=AsyncMock, 165 - ) as mock_labeler: 166 - result = await get_copyright_info(db_session, [flagged_track.id]) 149 + """test that flagged scans are returned as flagged. 167 150 168 - # labeler should not be called for already-resolved scans 169 - mock_labeler.assert_not_called() 151 + get_copyright_info is now a pure read - it reads the is_flagged state 152 + directly from the database. the sync_copyright_resolutions background 153 + task is responsible for updating is_flagged based on labeler state. 154 + """ 155 + result = await get_copyright_info(db_session, [flagged_track.id]) 170 156 171 - # track should show as not flagged 172 - assert flagged_track.id in result 173 - assert result[flagged_track.id].is_flagged is False 174 - 175 - 176 - async def test_get_copyright_info_checks_labeler_for_pending( 177 - db_session: AsyncSession, flagged_track: Track 178 - ) -> None: 179 - """test that pending flagged scans query the labeler.""" 180 - # mock labeler returning this URI as active (still flagged) 181 - with patch( 182 - "backend._internal.moderation.get_active_copyright_labels", 183 - new_callable=AsyncMock, 184 - ) as mock_labeler: 185 - mock_labeler.return_value = {flagged_track.atproto_record_uri} 186 - 187 - result = await get_copyright_info(db_session, [flagged_track.id]) 188 - 189 - # labeler should be called 190 - mock_labeler.assert_called_once() 191 - call_args = mock_labeler.call_args[0][0] 192 - assert flagged_track.atproto_record_uri in call_args 193 - 194 - # track should still show as flagged 195 157 assert flagged_track.id in result 196 158 assert result[flagged_track.id].is_flagged is True 197 159 assert result[flagged_track.id].primary_match == "Copyrighted Song by Famous Artist" 198 160 199 161 200 - async def test_get_copyright_info_resolved_in_labeler( 162 + async def test_get_copyright_info_not_flagged( 201 163 db_session: AsyncSession, flagged_track: Track 202 164 ) -> None: 203 - """test that labeler resolution clears the flag and updates DB.""" 204 - # mock labeler returning empty set (all resolved) 205 - with patch( 206 - "backend._internal.moderation.get_active_copyright_labels", 207 - new_callable=AsyncMock, 208 - ) as mock_labeler: 209 - mock_labeler.return_value = set() # not active = resolved 165 + """test that resolved scans (is_flagged=False) are returned as not flagged.""" 166 + from sqlalchemy import select 210 167 211 - result = await get_copyright_info(db_session, [flagged_track.id]) 212 - 213 - # track should show as not flagged 214 - assert flagged_track.id in result 215 - assert result[flagged_track.id].is_flagged is False 216 - 217 - # verify lazy update: resolution should be set in DB 168 + # update scan to be not flagged (simulates sync_copyright_resolutions running) 218 169 scan = await db_session.scalar( 219 170 select(CopyrightScan).where(CopyrightScan.track_id == flagged_track.id) 220 171 ) 221 172 assert scan is not None 222 - assert scan.resolution == "dismissed" 223 - assert scan.reviewed_at is not None 173 + scan.is_flagged = False 174 + await db_session.commit() 175 + 176 + result = await get_copyright_info(db_session, [flagged_track.id]) 177 + 178 + assert flagged_track.id in result 179 + assert result[flagged_track.id].is_flagged is False 180 + assert result[flagged_track.id].primary_match is None 224 181 225 182 226 183 async def test_get_copyright_info_empty_list(db_session: AsyncSession) -> None: ··· 236 193 # test_tracks fixture doesn't create copyright scans 237 194 track_ids = [track.id for track in test_tracks] 238 195 239 - with patch( 240 - "backend._internal.moderation.get_active_copyright_labels", 241 - new_callable=AsyncMock, 242 - ) as mock_labeler: 243 - result = await get_copyright_info(db_session, track_ids) 244 - 245 - # labeler should not be called since no flagged tracks 246 - mock_labeler.assert_not_called() 196 + result = await get_copyright_info(db_session, track_ids) 247 197 248 198 # no tracks should be in result since none have scans 249 199 assert result == {}
+2
backend/uv.lock
··· 317 317 { name = "alembic" }, 318 318 { name = "asyncpg" }, 319 319 { name = "atproto" }, 320 + { name = "beartype" }, 320 321 { name = "boto3" }, 321 322 { name = "cachetools" }, 322 323 { name = "fastapi" }, ··· 363 364 { name = "alembic", specifier = ">=1.14.0" }, 364 365 { name = "asyncpg", specifier = ">=0.30.0" }, 365 366 { name = "atproto", git = "https://github.com/zzstoatzz/atproto?rev=main" }, 367 + { name = "beartype", specifier = ">=0.22.8" }, 366 368 { name = "boto3", specifier = ">=1.37.0" }, 367 369 { name = "cachetools", specifier = ">=6.2.1" }, 368 370 { name = "fastapi", specifier = ">=0.115.0" },
+29 -17
docs/backend/background-tasks.md
··· 39 39 DOCKET_WORKER_CONCURRENCY=10 # concurrent task limit 40 40 ``` 41 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 + 42 59 when `DOCKET_URL` is not set, docket is disabled and tasks fall back to `asyncio.create_task()` (fire-and-forget). 43 60 44 61 ### local development ··· 54 71 55 72 ### production/staging 56 73 57 - Redis instances are provisioned via Upstash (managed Redis): 74 + Redis instances are self-hosted on Fly.io (redis:7-alpine): 58 75 59 - | environment | instance | region | 60 - |-------------|----------|--------| 61 - | production | `plyr-redis-prd` | us-east-1 (near fly.io) | 62 - | staging | `plyr-redis-stg` | us-east-1 | 76 + | environment | fly app | region | 77 + |-------------|---------|--------| 78 + | production | `plyr-redis` | iad | 79 + | staging | `plyr-redis-stg` | iad | 63 80 64 81 set `DOCKET_URL` in fly.io secrets: 65 82 ```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 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 68 85 ``` 69 86 70 - note: use `rediss://` (with double 's') for TLS connections to Upstash. 87 + note: uses Fly internal networking (`.internal` domain), no TLS needed within private network. 71 88 72 89 ## usage 73 90 ··· 117 134 118 135 ## costs 119 136 120 - **Upstash pricing** (pay-per-request): 121 - - free tier: 10k commands/day 122 - - pro: $0.2 per 100k commands + $0.25/GB storage 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 123 140 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 141 + this replaced Upstash pay-per-command pricing which was costing ~$75/month at scale (37M commands/month). 130 142 131 143 ## fallback behavior 132 144
+3 -1
docs/backend/configuration.md
··· 27 27 28 28 # storage settings (cloudflare r2) 29 29 settings.storage.backend # from STORAGE_BACKEND 30 - settings.storage.r2_bucket # from R2_BUCKET (audio files) 30 + settings.storage.r2_bucket # from R2_BUCKET (public audio files) 31 + settings.storage.r2_private_bucket # from R2_PRIVATE_BUCKET (gated audio files) 31 32 settings.storage.r2_image_bucket # from R2_IMAGE_BUCKET (image files) 32 33 settings.storage.r2_endpoint_url # from R2_ENDPOINT_URL 33 34 settings.storage.r2_public_bucket_url # from R2_PUBLIC_BUCKET_URL (audio files) ··· 84 85 # storage 85 86 STORAGE_BACKEND=r2 # or "filesystem" 86 87 R2_BUCKET=your-audio-bucket 88 + R2_PRIVATE_BUCKET=your-private-audio-bucket # for supporter-gated content 87 89 R2_IMAGE_BUCKET=your-image-bucket 88 90 R2_ENDPOINT_URL=https://xxx.r2.cloudflarestorage.com 89 91 R2_PUBLIC_BUCKET_URL=https://pub-xxx.r2.dev # for audio files
+2 -2
docs/deployment/environments.md
··· 7 7 | environment | trigger | backend URL | database | redis | frontend | storage | 8 8 |-------------|---------|-------------|----------|-------|----------|---------| 9 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) | 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 12 13 13 ## workflow 14 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 38 - is bound to form inputs (`bind:value={title}`) 39 39 - is checked in reactive blocks (`$effect(() => { ... })`) 40 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 + 41 75 use plain `let` for: 42 76 - constants that never change 43 77 - variables only used in functions/callbacks (not template)
+1
docs/local-development/setup.md
··· 53 53 # storage (r2 or filesystem) 54 54 STORAGE_BACKEND=filesystem # or "r2" for cloudflare r2 55 55 R2_BUCKET=audio-dev 56 + R2_PRIVATE_BUCKET=audio-private-dev # for supporter-gated content 56 57 R2_IMAGE_BUCKET=images-dev 57 58 R2_ENDPOINT_URL=<your-r2-endpoint> 58 59 R2_PUBLIC_BUCKET_URL=<your-r2-public-url>
docs/plans/.gitkeep

This is a binary file and will not be displayed.

docs/research/.gitkeep

This is a binary file and will not be displayed.

+122
docs/research/2025-12-18-moderation-cleanup.md
··· 1 + # research: moderation cleanup 2 + 3 + **date**: 2025-12-18 4 + **question**: understand issues #541-544 and how the moderation system works to inform cleanup 5 + 6 + ## summary 7 + 8 + the moderation system is split between backend (Python/FastAPI) and moderation service (Rust). copyright scanning uses AudD API, stores results in backend's `copyright_scans` table, and emits ATProto labels via the moderation service. there's a "lazy reconciliation" pattern on read paths that adds complexity. sensitive images are entirely in backend. the 4 issues propose consolidating this into a cleaner architecture. 9 + 10 + ## findings 11 + 12 + ### current architecture 13 + 14 + ``` 15 + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” 16 + โ”‚ BACKEND (Python) โ”‚ 17 + โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค 18 + โ”‚ _internal/moderation.py โ”‚ 19 + โ”‚ - scan_track_for_copyright() โ†’ calls moderation service /scan โ”‚ 20 + โ”‚ - _emit_copyright_label() โ†’ POST /emit-label โ”‚ 21 + โ”‚ - get_active_copyright_labels() โ†’ POST /admin/active-labels โ”‚ 22 + โ”‚ (each creates its own httpx.AsyncClient) โ”‚ 23 + โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค 24 + โ”‚ models/copyright_scan.py โ”‚ 25 + โ”‚ - is_flagged, resolution, matches, raw_response โ”‚ 26 + โ”‚ - resolution field tries to mirror labeler state โ”‚ 27 + โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค 28 + โ”‚ models/sensitive_image.py โ”‚ 29 + โ”‚ - image_id or url, reason, flagged_at, flagged_by โ”‚ 30 + โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค 31 + โ”‚ utilities/aggregations.py:73-175 โ”‚ 32 + โ”‚ - get_copyright_info() does lazy reconciliation โ”‚ 33 + โ”‚ - read path calls labeler, then WRITES to DB if resolved โ”‚ 34 + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ 35 + โ”‚ 36 + โ–ผ 37 + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” 38 + โ”‚ MODERATION SERVICE (Rust) โ”‚ 39 + โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค 40 + โ”‚ /scan - calls AudD API, returns scan result โ”‚ 41 + โ”‚ /emit-label - creates ATProto label in labeler DB โ”‚ 42 + โ”‚ /admin/active-labels - returns URIs with non-negated labels โ”‚ 43 + โ”‚ /admin/* - htmx admin UI for reviewing flags โ”‚ 44 + โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค 45 + โ”‚ labels table - ATProto labels with negation support โ”‚ 46 + โ”‚ label_context table - track metadata for admin UI display โ”‚ 47 + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ 48 + ``` 49 + 50 + ### issue #541: ModerationClient class 51 + 52 + **problem**: 3 functions in `moderation.py` each create their own `httpx.AsyncClient`: 53 + - `_call_moderation_service()` (line 72-81) 54 + - `_emit_copyright_label()` (line 179-185) 55 + - `get_active_copyright_labels()` (line 259-268) 56 + 57 + **solution**: extract `ModerationClient` class with shared client, auth, timeout handling. could use singleton pattern like `get_docket()` or store on `app.state`. 58 + 59 + ### issue #542: lazy resolution sync 60 + 61 + **problem**: `get_copyright_info()` in `aggregations.py:73-175` does: 62 + 1. fetch scans from backend DB 63 + 2. for flagged tracks without resolution, call labeler 64 + 3. if label was negated, UPDATE the backend DB inline 65 + 66 + this means read paths do writes, adding latency and complexity. 67 + 68 + **solution**: move to docket background task that periodically syncs resolutions. read path becomes pure read. 69 + 70 + ### issue #543: dual storage source of truth 71 + 72 + **problem**: copyright flag status stored in TWO places: 73 + 1. backend `copyright_scans.resolution` field 74 + 2. moderation service labeler (negation labels) 75 + 76 + they can get out of sync, requiring reconciliation logic. 77 + 78 + **options proposed**: 79 + - A: labeler is source of truth (remove `resolution` from backend) 80 + - B: backend is source of truth (labeler just signs labels) 81 + - C: webhook sync (labeler notifies backend on changes) 82 + 83 + ### issue #544: SensitiveImage in wrong place 84 + 85 + **problem**: `SensitiveImage` model and `/moderation/sensitive-images` endpoint are in backend, but all other moderation (copyright) is in moderation service. 86 + 87 + **solution**: move to moderation service for consistency. frontend just changes the URL it fetches from. 88 + 89 + ## code references 90 + 91 + - `backend/src/backend/_internal/moderation.py:59-81` - `_call_moderation_service()` with inline httpx client 92 + - `backend/src/backend/_internal/moderation.py:134-196` - `_emit_copyright_label()` with inline httpx client 93 + - `backend/src/backend/_internal/moderation.py:199-299` - `get_active_copyright_labels()` with redis caching 94 + - `backend/src/backend/utilities/aggregations.py:73-175` - `get_copyright_info()` with lazy reconciliation 95 + - `backend/src/backend/models/copyright_scan.py:23-76` - `CopyrightScan` model with `resolution` field 96 + - `backend/src/backend/models/sensitive_image.py:11-38` - `SensitiveImage` model 97 + - `backend/src/backend/api/moderation.py:24-39` - `/moderation/sensitive-images` endpoint 98 + 99 + ## dependencies between issues 100 + 101 + ``` 102 + #541 (ModerationClient) 103 + โ†“ 104 + #542 (background sync) - uses ModerationClient 105 + โ†“ 106 + #543 (source of truth) - depends on sync strategy 107 + 108 + #544 (SensitiveImage) - independent, can be done anytime 109 + ``` 110 + 111 + ## recommended order 112 + 113 + 1. **#541 first** - extract ModerationClient, improves testability, no behavior change 114 + 2. **#542 next** - move lazy sync to background task using new client 115 + 3. **#543 then** - once sync is background, decide source of truth (likely option A: labeler owns resolution) 116 + 4. **#544 anytime** - independent refactor, lower priority 117 + 118 + ## open questions 119 + 120 + - should moderation service expose webhook for label changes? (would eliminate need for polling in #542) 121 + - is the 5-minute redis cache TTL for labels appropriate? (currently in settings) 122 + - does the admin UI need to stay in moderation service or could it move to main frontend `/admin` routes?
+112
docs/research/2025-12-19-beartype.md
··· 1 + # research: beartype runtime type checking 2 + 3 + **date**: 2025-12-19 4 + **question**: investigate beartype for runtime type checking, determine how to integrate into plyr.fm 5 + 6 + ## summary 7 + 8 + beartype is a runtime type checker that validates Python type hints at execution time with O(1) worst-case performance. it's already a transitive dependency via `py-key-value-aio`. FastMCP does **not** use beartype. integration would require adding `beartype_this_package()` to `backend/src/backend/__init__.py`. 9 + 10 + ## findings 11 + 12 + ### what beartype does 13 + 14 + - validates type hints at runtime when functions are called 15 + - O(1) non-amortized worst-case time (constant time regardless of data structure size) 16 + - zero runtime dependencies, pure Python 17 + - MIT license 18 + 19 + ### key integration patterns 20 + 21 + **package-wide (recommended)**: 22 + ```python 23 + # At the very top of backend/src/backend/__init__.py 24 + from beartype.claw import beartype_this_package 25 + beartype_this_package() # enables type-checking for all submodules 26 + ``` 27 + 28 + **per-function**: 29 + ```python 30 + from beartype import beartype 31 + 32 + @beartype 33 + def my_function(x: int) -> str: 34 + return str(x) 35 + ``` 36 + 37 + ### configuration options (`BeartypeConf`) 38 + 39 + key parameters: 40 + - `violation_type` - exception class to raise (default: `BeartypeCallHintViolation`) 41 + - `violation_param_type` - exception for parameter violations 42 + - `violation_return_type` - exception for return type violations 43 + - `strategy` - checking strategy (default: `O1` for O(1) time) 44 + - `is_debug` - enable debugging output 45 + - `claw_skip_package_names` - packages to exclude from type checking 46 + 47 + **example with warnings for third-party code**: 48 + ```python 49 + from beartype import BeartypeConf 50 + from beartype.claw import beartype_all, beartype_this_package 51 + 52 + beartype_this_package() # strict for our code 53 + beartype_all(conf=BeartypeConf(violation_type=UserWarning)) # warn for third-party 54 + ``` 55 + 56 + ### current state in plyr.fm 57 + 58 + beartype is already installed as a transitive dependency: 59 + - `backend/uv.lock:477-482` - beartype 0.22.8 present 60 + - pulled in by `py-key-value-aio` and `py-key-value-shared` 61 + 62 + ### FastMCP status 63 + 64 + FastMCP does **not** use beartype: 65 + - not in FastMCP's dependencies 66 + - FastMCP uses type hints for schema generation/documentation, not runtime validation 67 + 68 + ### integration approach for plyr.fm 69 + 70 + 1. **add explicit dependency** (optional but good for clarity): 71 + ```toml 72 + # pyproject.toml 73 + dependencies = [ 74 + "beartype>=0.22.0", 75 + # ... existing deps 76 + ] 77 + ``` 78 + 79 + 2. **enable in `__init__.py`**: 80 + ```python 81 + # backend/src/backend/__init__.py 82 + from beartype.claw import beartype_this_package 83 + beartype_this_package() 84 + 85 + def hello() -> str: 86 + return "Hello from backend!" 87 + ``` 88 + 89 + 3. **considerations**: 90 + - must be called before importing any submodules 91 + - main.py currently imports warnings before filtering, then imports submodules 92 + - beartype should be activated in `__init__.py`, not `main.py` 93 + 94 + ### potential concerns 95 + 96 + 1. **performance**: O(1) guarantees should be fine, but worth benchmarking 97 + 2. **third-party compatibility**: some libraries may have inaccurate type hints; use `claw_skip_package_names` or warn mode 98 + 3. **FastAPI**: pydantic already validates request/response types; beartype adds internal function validation 99 + 100 + ## code references 101 + 102 + - `backend/uv.lock:477-482` - beartype 0.22.8 in lockfile 103 + - `backend/uv.lock:2240` - py-key-value-aio depends on beartype 104 + - `backend/uv.lock:2261` - py-key-value-shared depends on beartype 105 + - `backend/src/backend/__init__.py:1-2` - current init (needs modification) 106 + - `backend/src/backend/main.py:1-50` - app initialization (imports after warnings filter) 107 + 108 + ## open questions 109 + 110 + - should we enable strict mode (exceptions) or warning mode initially? 111 + - which third-party packages might have problematic type hints to skip? 112 + - should we benchmark API response times before/after enabling?
+157
docs/research/2025-12-19-ios-share-extension-minimal-app.md
··· 1 + # research: minimal iOS app with share extension 2 + 3 + **date**: 2025-12-19 4 + **question**: how to build a minimal iOS app with share extension for uploading audio to plyr.fm API, for someone with no iOS experience 5 + 6 + ## summary 7 + 8 + **yes, it's doable.** native Swift is the best approach - simpler than cross-platform for this use case. you need a minimal "host app" (Apple requires it) but it can just be login + settings. the share extension does the real work. estimated 4-8 weeks for someone learning Swift as they go. 9 + 10 + ## the reality check 11 + 12 + | requirement | detail | 13 + |-------------|--------| 14 + | cost | $99/year Apple Developer account + Mac | 15 + | timeline | 4-8 weeks (learning + building) | 16 + | complexity | medium - Swift is approachable | 17 + | app store approval | achievable if main app has real UI | 18 + 19 + ## architecture 20 + 21 + ``` 22 + plyr-ios/ 23 + โ”œโ”€โ”€ PlyrApp/ # main app target (~200 lines) 24 + โ”‚ โ”œโ”€โ”€ AuthView.swift # OAuth login 25 + โ”‚ โ”œโ”€โ”€ SettingsView.swift # preferences 26 + โ”‚ โ””โ”€โ”€ ProfileView.swift # account info 27 + โ”‚ 28 + โ”œโ”€โ”€ PlyrShareExtension/ # share extension (~300 lines) 29 + โ”‚ โ”œโ”€โ”€ ShareViewController.swift 30 + โ”‚ โ””โ”€โ”€ AudioUploadManager.swift 31 + โ”‚ 32 + โ””โ”€โ”€ PlyrShared/ # shared code 33 + โ”œโ”€โ”€ APIClient.swift # HTTP requests 34 + โ”œโ”€โ”€ KeychainManager.swift # token storage 35 + โ””โ”€โ”€ Constants.swift 36 + ``` 37 + 38 + ## how it works 39 + 40 + 1. **user installs app** โ†’ opens once โ†’ logs in via OAuth 41 + 2. **token stored** in iOS Keychain (shared between app + extension) 42 + 3. **user records voice memo** โ†’ taps share โ†’ sees "plyr.fm" 43 + 4. **share extension** reads token from Keychain, uploads audio to API 44 + 5. **done** - no need to open main app again 45 + 46 + ## key constraints 47 + 48 + | constraint | value | implication | 49 + |------------|-------|-------------| 50 + | memory limit | ~120 MB | stream file, don't load into memory | 51 + | time limit | ~30 seconds | fine for most audio, use background upload for large files | 52 + | extension can't do OAuth | - | main app handles login, extension reads stored token | 53 + 54 + ## authentication flow 55 + 56 + ``` 57 + Main App: 58 + 1. User taps "Log in with Bluesky" 59 + 2. OAuth flow via browser/webview 60 + 3. Receive token, store in shared Keychain 61 + 62 + Share Extension: 63 + 1. Check Keychain for token 64 + 2. If missing โ†’ "Open app to log in" button 65 + 3. If present โ†’ upload directly to plyr.fm API 66 + ``` 67 + 68 + ## two implementation paths 69 + 70 + ### path A: direct upload (simpler, 4-6 weeks) 71 + 72 + - extension uploads file directly via URLSession 73 + - shows progress UI in extension 74 + - good for files under ~20MB 75 + - risk: 30-second timeout on slow connections 76 + 77 + ### path B: background upload (robust, 6-8 weeks) 78 + 79 + - extension saves file to shared container 80 + - hands off to background URLSession 81 + - main app can show upload queue 82 + - handles large files, retries on failure 83 + - professional quality 84 + 85 + **recommendation**: start with path A, upgrade to B if needed. 86 + 87 + ## app store approval 88 + 89 + Apple requires the main app to have "independent value" - can't be empty shell. minimum viable: 90 + - login/logout UI 91 + - settings screen 92 + - profile/account view 93 + - maybe upload history 94 + 95 + this is enough to pass review. many apps do exactly this pattern. 96 + 97 + ## what you need to learn 98 + 99 + 1. **Swift basics** - 1-2 weeks of tutorials 100 + 2. **SwiftUI** - for building UI (modern, easier than UIKit) 101 + 3. **URLSession** - for HTTP requests 102 + 4. **Keychain** - for secure token storage 103 + 5. **App Groups** - for sharing data between app + extension 104 + 105 + ## example code: share extension upload 106 + 107 + ```swift 108 + class ShareViewController: SLComposeServiceViewController { 109 + override func didSelectPost() { 110 + guard let item = extensionContext?.inputItems.first as? NSExtensionItem, 111 + let attachment = item.attachments?.first else { return } 112 + 113 + attachment.loadFileRepresentation(forTypeIdentifier: "public.audio") { url, error in 114 + guard let url = url else { return } 115 + 116 + // Get auth token from shared Keychain 117 + let token = KeychainManager.shared.getToken() 118 + 119 + // Upload to plyr.fm API 120 + APIClient.shared.uploadTrack(fileURL: url, token: token) { result in 121 + self.extensionContext?.completeRequest(returningItems: nil) 122 + } 123 + } 124 + } 125 + } 126 + ``` 127 + 128 + ## plyr.fm specific considerations 129 + 130 + - **OAuth**: plyr.fm uses ATProto OAuth - need to handle in main app 131 + - **API endpoint**: `POST /api/tracks/upload` (or similar) 132 + - **token refresh**: share extension should handle expired tokens gracefully 133 + - **metadata**: could add title/artist fields in share extension UI 134 + 135 + ## alternative: iOS Shortcuts 136 + 137 + if native app feels too heavy, you could create a Shortcut that: 138 + 1. user selects audio file 139 + 2. shortcut calls plyr.fm upload API 140 + 3. user shares shortcut via iCloud link 141 + 142 + **pros**: no app store, no $99/year 143 + **cons**: users must manually install shortcut, less discoverable, clunkier UX 144 + 145 + ## open questions 146 + 147 + - does plyr.fm API support multipart file upload from iOS? 148 + - what metadata should share extension collect? (title, tags, etc.) 149 + - should extension show upload progress or dismiss immediately? 150 + - worth building Android version too? (share target works there) 151 + 152 + ## learning resources 153 + 154 + - [Apple: App Extension Programming Guide](https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/Share.html) 155 + - [AppCoda: Building Share Extension](https://www.appcoda.com/ios8-share-extension-swift/) 156 + - [Hacking with Swift: 100 Days of SwiftUI](https://www.hackingwithswift.com/100/swiftui) (free) 157 + - [Stanford CS193p: iOS Development](https://cs193p.sites.stanford.edu/) (free)
+63
docs/research/2025-12-19-web-share-target-ios-pwa.md
··· 1 + # research: Web Share Target API for iOS PWAs 2 + 3 + **date**: 2025-12-19 4 + **question**: can a PWA receive shared audio files from the iOS share sheet (like SoundCloud does)? 5 + 6 + ## summary 7 + 8 + **No, iOS Safari does not support Web Share Target API.** This is a known limitation with an open WebKit bug (#194593) since February 2019 - no timeline for implementation. Android Chrome fully supports it. The SoundCloud iOS share integration you saw works because SoundCloud has a native iOS app, not a PWA. 9 + 10 + ## findings 11 + 12 + ### iOS Safari limitations 13 + 14 + - Web Share Target API (receiving files) - **NOT supported** 15 + - Web Share API (sending/sharing out) - supported 16 + - WebKit bug #194593 tracks this, last activity May 2025, no assignee 17 + - Apple mandates WebKit for all iOS browsers, so no workaround via Chrome/Firefox 18 + - This is why plyr.fm can't appear in the iOS share sheet as a destination 19 + 20 + ### Android support 21 + 22 + - Chrome fully supports Web Share Target since ~2019 23 + - manifest.json `share_target` field enables receiving shared files 24 + - Would work for Android users installing plyr.fm PWA 25 + 26 + ### current plyr.fm state 27 + 28 + - `frontend/static/manifest.webmanifest` - basic PWA config, no share_target 29 + - `frontend/src/lib/components/ShareButton.svelte` - clipboard copy only 30 + - no `navigator.share()` usage (even though iOS supports sharing OUT) 31 + 32 + ### workaround options 33 + 34 + 1. **native iOS app** - only real solution for iOS share sheet integration 35 + 2. **Web Share API for outbound** - can add "share to..." button that opens iOS share sheet 36 + 3. **improved in-app UX** - better upload flow, drag-drop on desktop 37 + 4. **PWABuilder wrapper** - publish to App Store, gains URL scheme support 38 + 39 + ## code references 40 + 41 + - `frontend/static/manifest.webmanifest` - would add `share_target` here for Android 42 + - `frontend/src/lib/components/ShareButton.svelte:8-15` - current clipboard-only implementation 43 + 44 + ## potential quick wins 45 + 46 + even without iOS share target support: 47 + 48 + 1. **add navigator.share() for outbound sharing** - let users share tracks TO other apps via native share sheet 49 + 2. **add share_target for Android users** - Android PWA installs would get share sheet integration 50 + 3. **improve upload UX** - streamline the in-app upload flow for mobile 51 + 52 + ## open questions 53 + 54 + - is Android share target support worth implementing given iOS is primary user base? 55 + - would a lightweight native iOS app (just for share extension) be worth maintaining? 56 + - any appetite for PWABuilder/App Store distribution? 57 + 58 + ## sources 59 + 60 + - [WebKit Bug #194593](https://bugs.webkit.org/show_bug.cgi?id=194593) 61 + - [MDN: share_target](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/share_target) 62 + - [web.dev: OS Integration](https://web.dev/learn/pwa/os-integration) 63 + - [firt.dev: iOS PWA Compatibility](https://firt.dev/notes/pwa-ios/)
+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 24 * **`X-XSS-Protection: 1; mode=block`:** Enables browser cross-site scripting filters. 25 25 * **`Referrer-Policy: strict-origin-when-cross-origin`:** Controls how much referrer information is included with requests. 26 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 + 27 80 ## CORS 28 81 29 82 Cross-Origin Resource Sharing (CORS) is configured to allow:
+1
frontend/CLAUDE.md
··· 6 6 - **state**: global managers in `lib/*.svelte.ts` using `$state` runes (player, queue, uploader, tracks cache) 7 7 - **components**: reusable ui in `lib/components/` (LikeButton, Toast, Player, etc) 8 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`) 9 10 10 11 gotchas: 11 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 10 trackUri?: string; 11 11 trackCid?: string; 12 12 fileId?: string; 13 + gated?: boolean; 13 14 initialLiked?: boolean; 14 15 disabled?: boolean; 15 16 disabledReason?: string; ··· 25 26 trackUri, 26 27 trackCid, 27 28 fileId, 29 + gated, 28 30 initialLiked = false, 29 31 disabled = false, 30 32 disabledReason, ··· 102 104 103 105 try { 104 106 const success = liked 105 - ? await likeTrack(trackId, fileId) 107 + ? await likeTrack(trackId, fileId, gated) 106 108 : await unlikeTrack(trackId); 107 109 108 110 if (!success) { ··· 437 439 justify-content: center; 438 440 background: transparent; 439 441 border: 1px solid var(--border-default); 440 - border-radius: 4px; 442 + border-radius: var(--radius-sm); 441 443 color: var(--text-tertiary); 442 444 cursor: pointer; 443 445 transition: all 0.2s; ··· 488 490 min-width: 200px; 489 491 background: var(--bg-secondary); 490 492 border: 1px solid var(--border-default); 491 - border-radius: 8px; 493 + border-radius: var(--radius-md); 492 494 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); 493 495 overflow: hidden; 494 496 z-index: 10; ··· 508 510 background: transparent; 509 511 border: none; 510 512 color: var(--text-primary); 511 - font-size: 0.9rem; 513 + font-size: var(--text-base); 512 514 font-family: inherit; 513 515 cursor: pointer; 514 516 transition: background 0.15s; ··· 542 544 border: none; 543 545 border-bottom: 1px solid var(--border-subtle); 544 546 color: var(--text-secondary); 545 - font-size: 0.85rem; 547 + font-size: var(--text-sm); 546 548 font-family: inherit; 547 549 cursor: pointer; 548 550 transition: background 0.15s; ··· 565 567 566 568 .playlist-list::-webkit-scrollbar-track { 567 569 background: transparent; 568 - border-radius: 4px; 570 + border-radius: var(--radius-sm); 569 571 } 570 572 571 573 .playlist-list::-webkit-scrollbar-thumb { 572 574 background: var(--border-default); 573 - border-radius: 4px; 575 + border-radius: var(--radius-sm); 574 576 } 575 577 576 578 .playlist-list::-webkit-scrollbar-thumb:hover { ··· 586 588 background: transparent; 587 589 border: none; 588 590 color: var(--text-primary); 589 - font-size: 0.9rem; 591 + font-size: var(--text-base); 590 592 font-family: inherit; 591 593 cursor: pointer; 592 594 transition: background 0.15s; ··· 605 607 .playlist-thumb-placeholder { 606 608 width: 32px; 607 609 height: 32px; 608 - border-radius: 4px; 610 + border-radius: var(--radius-sm); 609 611 flex-shrink: 0; 610 612 } 611 613 ··· 637 639 gap: 0.5rem; 638 640 padding: 1.5rem 1rem; 639 641 color: var(--text-tertiary); 640 - font-size: 0.85rem; 642 + font-size: var(--text-sm); 641 643 } 642 644 643 645 .create-playlist-btn { ··· 650 652 border: none; 651 653 border-top: 1px solid var(--border-subtle); 652 654 color: var(--accent); 653 - font-size: 0.9rem; 655 + font-size: var(--text-base); 654 656 font-family: inherit; 655 657 cursor: pointer; 656 658 transition: background 0.15s; ··· 673 675 padding: 0.625rem 0.75rem; 674 676 background: var(--bg-tertiary); 675 677 border: 1px solid var(--border-default); 676 - border-radius: 6px; 678 + border-radius: var(--radius-base); 677 679 color: var(--text-primary); 678 680 font-family: inherit; 679 - font-size: 0.9rem; 681 + font-size: var(--text-base); 680 682 } 681 683 682 684 .create-form input:focus { ··· 696 698 padding: 0.625rem 1rem; 697 699 background: var(--accent); 698 700 border: none; 699 - border-radius: 6px; 701 + border-radius: var(--radius-base); 700 702 color: white; 701 703 font-family: inherit; 702 - font-size: 0.9rem; 704 + font-size: var(--text-base); 703 705 font-weight: 500; 704 706 cursor: pointer; 705 707 transition: opacity 0.15s; ··· 719 721 height: 16px; 720 722 border: 2px solid var(--border-default); 721 723 border-top-color: var(--accent); 722 - border-radius: 50%; 724 + border-radius: var(--radius-full); 723 725 animation: spin 0.8s linear infinite; 724 726 } 725 727 ··· 781 783 782 784 .menu-item { 783 785 padding: 1rem 1.25rem; 784 - font-size: 1rem; 786 + font-size: var(--text-lg); 785 787 } 786 788 787 789 .back-button {
+7 -7
frontend/src/lib/components/AlbumSelect.svelte
··· 102 102 padding: 0.75rem; 103 103 background: var(--bg-primary); 104 104 border: 1px solid var(--border-default); 105 - border-radius: 4px; 105 + border-radius: var(--radius-sm); 106 106 color: var(--text-primary); 107 - font-size: 1rem; 107 + font-size: var(--text-lg); 108 108 font-family: inherit; 109 109 transition: all 0.2s; 110 110 } ··· 127 127 overflow-y: auto; 128 128 background: var(--bg-tertiary); 129 129 border: 1px solid var(--border-default); 130 - border-radius: 4px; 130 + border-radius: var(--radius-sm); 131 131 margin-top: 0.25rem; 132 132 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 133 133 } ··· 139 139 140 140 .album-results::-webkit-scrollbar-track { 141 141 background: var(--bg-primary); 142 - border-radius: 4px; 142 + border-radius: var(--radius-sm); 143 143 } 144 144 145 145 .album-results::-webkit-scrollbar-thumb { 146 146 background: var(--border-default); 147 - border-radius: 4px; 147 + border-radius: var(--radius-sm); 148 148 } 149 149 150 150 .album-results::-webkit-scrollbar-thumb:hover { ··· 203 203 } 204 204 205 205 .album-stats { 206 - font-size: 0.85rem; 206 + font-size: var(--text-sm); 207 207 color: var(--text-tertiary); 208 208 overflow: hidden; 209 209 text-overflow: ellipsis; ··· 212 212 213 213 .similar-hint { 214 214 margin-top: 0.5rem; 215 - font-size: 0.85rem; 215 + font-size: var(--text-sm); 216 216 color: var(--warning); 217 217 font-style: italic; 218 218 margin-bottom: 0;
+15 -15
frontend/src/lib/components/BrokenTracks.svelte
··· 194 194 margin-bottom: 3rem; 195 195 background: color-mix(in srgb, var(--warning) 5%, transparent); 196 196 border: 1px solid color-mix(in srgb, var(--warning) 20%, transparent); 197 - border-radius: 8px; 197 + border-radius: var(--radius-md); 198 198 padding: 1.5rem; 199 199 } 200 200 ··· 213 213 } 214 214 215 215 .section-header h2 { 216 - font-size: 1.5rem; 216 + font-size: var(--text-3xl); 217 217 margin: 0; 218 218 color: var(--warning); 219 219 } ··· 222 222 padding: 0.5rem 1rem; 223 223 background: color-mix(in srgb, var(--warning) 20%, transparent); 224 224 border: 1px solid color-mix(in srgb, var(--warning) 50%, transparent); 225 - border-radius: 4px; 225 + border-radius: var(--radius-sm); 226 226 color: var(--warning); 227 227 font-family: inherit; 228 - font-size: 0.9rem; 228 + font-size: var(--text-base); 229 229 font-weight: 600; 230 230 cursor: pointer; 231 231 transition: all 0.2s; ··· 248 248 background: color-mix(in srgb, var(--warning) 20%, transparent); 249 249 color: var(--warning); 250 250 padding: 0.25rem 0.6rem; 251 - border-radius: 12px; 252 - font-size: 0.85rem; 251 + border-radius: var(--radius-lg); 252 + font-size: var(--text-sm); 253 253 font-weight: 600; 254 254 } 255 255 ··· 263 263 .broken-track-item { 264 264 background: var(--bg-tertiary); 265 265 border: 1px solid color-mix(in srgb, var(--warning) 30%, transparent); 266 - border-radius: 6px; 266 + border-radius: var(--radius-base); 267 267 padding: 1rem; 268 268 display: flex; 269 269 align-items: center; ··· 280 280 } 281 281 282 282 .warning-icon { 283 - font-size: 1.25rem; 283 + font-size: var(--text-2xl); 284 284 flex-shrink: 0; 285 285 } 286 286 ··· 291 291 292 292 .track-title { 293 293 font-weight: 600; 294 - font-size: 1rem; 294 + font-size: var(--text-lg); 295 295 margin-bottom: 0.25rem; 296 296 color: var(--text-primary); 297 297 } 298 298 299 299 .track-meta { 300 - font-size: 0.9rem; 300 + font-size: var(--text-base); 301 301 color: var(--text-secondary); 302 302 margin-bottom: 0.5rem; 303 303 } 304 304 305 305 .issue-description { 306 - font-size: 0.85rem; 306 + font-size: var(--text-sm); 307 307 color: var(--warning); 308 308 } 309 309 ··· 311 311 padding: 0.5rem 1rem; 312 312 background: color-mix(in srgb, var(--warning) 15%, transparent); 313 313 border: 1px solid color-mix(in srgb, var(--warning) 40%, transparent); 314 - border-radius: 4px; 314 + border-radius: var(--radius-sm); 315 315 color: var(--warning); 316 316 font-family: inherit; 317 - font-size: 0.9rem; 317 + font-size: var(--text-base); 318 318 font-weight: 500; 319 319 cursor: pointer; 320 320 transition: all 0.2s; ··· 337 337 .info-box { 338 338 background: var(--bg-primary); 339 339 border: 1px solid var(--border-subtle); 340 - border-radius: 6px; 340 + border-radius: var(--radius-base); 341 341 padding: 1rem; 342 - font-size: 0.9rem; 342 + font-size: var(--text-base); 343 343 color: var(--text-secondary); 344 344 } 345 345
+8 -8
frontend/src/lib/components/ColorSettings.svelte
··· 115 115 border: 1px solid var(--border-default); 116 116 color: var(--text-secondary); 117 117 padding: 0.5rem; 118 - border-radius: 4px; 118 + border-radius: var(--radius-sm); 119 119 cursor: pointer; 120 120 transition: all 0.2s; 121 121 display: flex; ··· 134 134 right: 0; 135 135 background: var(--bg-secondary); 136 136 border: 1px solid var(--border-default); 137 - border-radius: 6px; 137 + border-radius: var(--radius-base); 138 138 padding: 1rem; 139 139 min-width: 240px; 140 140 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); ··· 147 147 align-items: center; 148 148 margin-bottom: 1rem; 149 149 color: var(--text-primary); 150 - font-size: 0.9rem; 150 + font-size: var(--text-base); 151 151 } 152 152 153 153 .close-btn { 154 154 background: transparent; 155 155 border: none; 156 156 color: var(--text-secondary); 157 - font-size: 1.5rem; 157 + font-size: var(--text-3xl); 158 158 cursor: pointer; 159 159 padding: 0; 160 160 width: 24px; ··· 182 182 width: 48px; 183 183 height: 32px; 184 184 border: 1px solid var(--border-default); 185 - border-radius: 4px; 185 + border-radius: var(--radius-sm); 186 186 cursor: pointer; 187 187 background: transparent; 188 188 } ··· 198 198 199 199 .color-value { 200 200 font-family: monospace; 201 - font-size: 0.85rem; 201 + font-size: var(--text-sm); 202 202 color: var(--text-secondary); 203 203 } 204 204 ··· 209 209 } 210 210 211 211 .presets-label { 212 - font-size: 0.85rem; 212 + font-size: var(--text-sm); 213 213 color: var(--text-tertiary); 214 214 } 215 215 ··· 222 222 .preset-btn { 223 223 width: 32px; 224 224 height: 32px; 225 - border-radius: 4px; 225 + border-radius: var(--radius-sm); 226 226 border: 2px solid transparent; 227 227 cursor: pointer; 228 228 transition: all 0.2s;
+9 -9
frontend/src/lib/components/HandleAutocomplete.svelte
··· 131 131 padding: 0.75rem; 132 132 background: var(--bg-primary); 133 133 border: 1px solid var(--border-default); 134 - border-radius: 4px; 134 + border-radius: var(--radius-sm); 135 135 color: var(--text-primary); 136 - font-size: 1rem; 136 + font-size: var(--text-lg); 137 137 font-family: inherit; 138 138 transition: border-color 0.2s; 139 139 box-sizing: border-box; ··· 159 159 top: 50%; 160 160 transform: translateY(-50%); 161 161 color: var(--text-muted); 162 - font-size: 0.85rem; 162 + font-size: var(--text-sm); 163 163 } 164 164 165 165 .results { ··· 170 170 overflow-y: auto; 171 171 background: var(--bg-tertiary); 172 172 border: 1px solid var(--border-default); 173 - border-radius: 4px; 173 + border-radius: var(--radius-sm); 174 174 margin-top: 0.25rem; 175 175 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 176 176 scrollbar-width: thin; ··· 183 183 184 184 .results::-webkit-scrollbar-track { 185 185 background: var(--bg-primary); 186 - border-radius: 4px; 186 + border-radius: var(--radius-sm); 187 187 } 188 188 189 189 .results::-webkit-scrollbar-thumb { 190 190 background: var(--border-default); 191 - border-radius: 4px; 191 + border-radius: var(--radius-sm); 192 192 } 193 193 194 194 .results::-webkit-scrollbar-thumb:hover { ··· 222 222 .avatar { 223 223 width: 36px; 224 224 height: 36px; 225 - border-radius: 50%; 225 + border-radius: var(--radius-full); 226 226 object-fit: cover; 227 227 border: 2px solid var(--border-default); 228 228 flex-shrink: 0; ··· 231 231 .avatar-placeholder { 232 232 width: 36px; 233 233 height: 36px; 234 - border-radius: 50%; 234 + border-radius: var(--radius-full); 235 235 background: var(--border-default); 236 236 flex-shrink: 0; 237 237 } ··· 252 252 } 253 253 254 254 .handle { 255 - font-size: 0.85rem; 255 + font-size: var(--text-sm); 256 256 color: var(--text-tertiary); 257 257 overflow: hidden; 258 258 text-overflow: ellipsis;
+15 -15
frontend/src/lib/components/HandleSearch.svelte
··· 179 179 padding: 0.75rem; 180 180 background: var(--bg-primary); 181 181 border: 1px solid var(--border-default); 182 - border-radius: 4px; 182 + border-radius: var(--radius-sm); 183 183 color: var(--text-primary); 184 - font-size: 1rem; 184 + font-size: var(--text-lg); 185 185 font-family: inherit; 186 186 transition: all 0.2s; 187 187 } ··· 201 201 right: 0.75rem; 202 202 top: 50%; 203 203 transform: translateY(-50%); 204 - font-size: 0.85rem; 204 + font-size: var(--text-sm); 205 205 color: var(--text-muted); 206 206 } 207 207 ··· 213 213 overflow-y: auto; 214 214 background: var(--bg-tertiary); 215 215 border: 1px solid var(--border-default); 216 - border-radius: 4px; 216 + border-radius: var(--radius-sm); 217 217 margin-top: 0.25rem; 218 218 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 219 219 } ··· 225 225 226 226 .search-results::-webkit-scrollbar-track { 227 227 background: var(--bg-primary); 228 - border-radius: 4px; 228 + border-radius: var(--radius-sm); 229 229 } 230 230 231 231 .search-results::-webkit-scrollbar-thumb { 232 232 background: var(--border-default); 233 - border-radius: 4px; 233 + border-radius: var(--radius-sm); 234 234 } 235 235 236 236 .search-results::-webkit-scrollbar-thumb:hover { ··· 277 277 .result-avatar { 278 278 width: 36px; 279 279 height: 36px; 280 - border-radius: 50%; 280 + border-radius: var(--radius-full); 281 281 object-fit: cover; 282 282 border: 2px solid var(--border-default); 283 283 flex-shrink: 0; ··· 299 299 } 300 300 301 301 .result-handle { 302 - font-size: 0.85rem; 302 + font-size: var(--text-sm); 303 303 color: var(--text-tertiary); 304 304 overflow: hidden; 305 305 text-overflow: ellipsis; ··· 320 320 padding: 0.5rem 0.75rem; 321 321 background: color-mix(in srgb, var(--accent) 10%, var(--bg-tertiary)); 322 322 border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border-subtle)); 323 - border-radius: 20px; 323 + border-radius: var(--radius-xl); 324 324 color: var(--text-primary); 325 - font-size: 0.9rem; 325 + font-size: var(--text-base); 326 326 } 327 327 328 328 .chip-avatar { 329 329 width: 24px; 330 330 height: 24px; 331 - border-radius: 50%; 331 + border-radius: var(--radius-full); 332 332 object-fit: cover; 333 333 border: 1px solid var(--border-default); 334 334 } ··· 365 365 366 366 .max-features-message { 367 367 margin-top: 0.5rem; 368 - font-size: 0.85rem; 368 + font-size: var(--text-sm); 369 369 color: var(--warning); 370 370 } 371 371 ··· 374 374 padding: 0.75rem; 375 375 background: color-mix(in srgb, var(--warning) 10%, var(--bg-primary)); 376 376 border: 1px solid color-mix(in srgb, var(--warning) 20%, var(--border-subtle)); 377 - border-radius: 4px; 377 + border-radius: var(--radius-sm); 378 378 color: var(--warning); 379 - font-size: 0.9rem; 379 + font-size: var(--text-base); 380 380 text-align: center; 381 381 } 382 382 ··· 401 401 402 402 .selected-artist-chip { 403 403 padding: 0.4rem 0.6rem; 404 - font-size: 0.85rem; 404 + font-size: var(--text-sm); 405 405 } 406 406 407 407 .chip-avatar {
+18 -18
frontend/src/lib/components/Header.svelte
··· 228 228 justify-content: center; 229 229 width: 44px; 230 230 height: 44px; 231 - border-radius: 10px; 231 + border-radius: var(--radius-md); 232 232 background: transparent; 233 233 border: none; 234 234 color: var(--text-secondary); ··· 275 275 border: 1px solid var(--border-emphasis); 276 276 color: var(--text-secondary); 277 277 padding: 0.5rem 1rem; 278 - border-radius: 6px; 279 - font-size: 0.9rem; 278 + border-radius: var(--radius-base); 279 + font-size: var(--text-base); 280 280 font-family: inherit; 281 281 cursor: pointer; 282 282 transition: all 0.2s; ··· 309 309 } 310 310 311 311 .tangled-icon { 312 - border-radius: 4px; 312 + border-radius: var(--radius-sm); 313 313 opacity: 0.7; 314 314 transition: opacity 0.2s, box-shadow 0.2s; 315 315 } ··· 320 320 } 321 321 322 322 h1 { 323 - font-size: 1.5rem; 323 + font-size: var(--text-3xl); 324 324 margin: 0; 325 325 color: var(--text-primary); 326 326 transition: color 0.2s; ··· 353 353 .nav-link { 354 354 color: var(--text-secondary); 355 355 text-decoration: none; 356 - font-size: 0.9rem; 356 + font-size: var(--text-base); 357 357 transition: all 0.2s; 358 358 white-space: nowrap; 359 359 display: flex; 360 360 align-items: center; 361 361 gap: 0.4rem; 362 362 padding: 0.4rem 0.75rem; 363 - border-radius: 6px; 363 + border-radius: var(--radius-base); 364 364 border: 1px solid transparent; 365 365 } 366 366 ··· 388 388 .user-handle { 389 389 color: var(--text-secondary); 390 390 text-decoration: none; 391 - font-size: 0.9rem; 391 + font-size: var(--text-base); 392 392 padding: 0.4rem 0.75rem; 393 393 background: var(--bg-tertiary); 394 - border-radius: 6px; 394 + border-radius: var(--radius-base); 395 395 border: 1px solid var(--border-default); 396 396 transition: all 0.2s; 397 397 white-space: nowrap; ··· 408 408 border: 1px solid var(--accent); 409 409 color: var(--accent); 410 410 padding: 0.5rem 1rem; 411 - border-radius: 6px; 412 - font-size: 0.9rem; 411 + border-radius: var(--radius-base); 412 + font-size: var(--text-base); 413 413 text-decoration: none; 414 414 transition: all 0.2s; 415 415 cursor: pointer; ··· 421 421 color: var(--bg-primary); 422 422 } 423 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) { 424 + /* header mobile breakpoint - see $lib/breakpoints.ts 425 + switch to mobile before margin elements crowd each other */ 426 + @media (max-width: 1300px) { 427 427 .margin-left, 428 428 .logout-right { 429 429 display: none !important; ··· 442 442 } 443 443 } 444 444 445 - /* Smaller screens: compact header */ 445 + /* mobile breakpoint - see $lib/breakpoints.ts */ 446 446 @media (max-width: 768px) { 447 447 .header-content { 448 448 padding: 0.75rem 0.75rem; ··· 467 467 468 468 .nav-link { 469 469 padding: 0.3rem 0.5rem; 470 - font-size: 0.8rem; 470 + font-size: var(--text-sm); 471 471 } 472 472 473 473 .nav-link span { ··· 475 475 } 476 476 477 477 .user-handle { 478 - font-size: 0.8rem; 478 + font-size: var(--text-sm); 479 479 padding: 0.3rem 0.5rem; 480 480 } 481 481 482 482 .btn-primary { 483 - font-size: 0.8rem; 483 + font-size: var(--text-sm); 484 484 padding: 0.3rem 0.65rem; 485 485 } 486 486 }
+11 -11
frontend/src/lib/components/HiddenTagsFilter.svelte
··· 126 126 align-items: center; 127 127 gap: 0.5rem; 128 128 flex-wrap: wrap; 129 - font-size: 0.8rem; 129 + font-size: var(--text-sm); 130 130 } 131 131 132 132 .filter-toggle { ··· 139 139 color: var(--text-tertiary); 140 140 cursor: pointer; 141 141 transition: all 0.15s; 142 - border-radius: 6px; 142 + border-radius: var(--radius-base); 143 143 } 144 144 145 145 .filter-toggle:hover { ··· 157 157 } 158 158 159 159 .filter-count { 160 - font-size: 0.7rem; 160 + font-size: var(--text-xs); 161 161 color: var(--text-tertiary); 162 162 } 163 163 164 164 .filter-label { 165 165 color: var(--text-tertiary); 166 166 white-space: nowrap; 167 - font-size: 0.75rem; 167 + font-size: var(--text-xs); 168 168 font-family: inherit; 169 169 } 170 170 ··· 183 183 background: transparent; 184 184 border: 1px solid var(--border-default); 185 185 color: var(--text-secondary); 186 - border-radius: 3px; 187 - font-size: 0.75rem; 186 + border-radius: var(--radius-sm); 187 + font-size: var(--text-xs); 188 188 font-family: inherit; 189 189 cursor: pointer; 190 190 transition: all 0.15s; ··· 201 201 } 202 202 203 203 .remove-icon { 204 - font-size: 0.8rem; 204 + font-size: var(--text-sm); 205 205 line-height: 1; 206 206 opacity: 0.5; 207 207 } ··· 219 219 padding: 0; 220 220 background: transparent; 221 221 border: 1px dashed var(--border-default); 222 - border-radius: 3px; 222 + border-radius: var(--radius-sm); 223 223 color: var(--text-tertiary); 224 - font-size: 0.8rem; 224 + font-size: var(--text-sm); 225 225 cursor: pointer; 226 226 transition: all 0.15s; 227 227 } ··· 236 236 background: transparent; 237 237 border: 1px solid var(--border-default); 238 238 color: var(--text-primary); 239 - font-size: 0.75rem; 239 + font-size: var(--text-xs); 240 240 font-family: inherit; 241 241 min-height: 24px; 242 242 width: 70px; 243 243 outline: none; 244 - border-radius: 3px; 244 + border-radius: var(--radius-sm); 245 245 } 246 246 247 247 .add-input:focus {
+7 -9
frontend/src/lib/components/LikeButton.svelte
··· 6 6 trackId: number; 7 7 trackTitle: string; 8 8 fileId?: string; 9 + gated?: boolean; 9 10 initialLiked?: boolean; 10 11 disabled?: boolean; 11 12 disabledReason?: string; 12 13 onLikeChange?: (_liked: boolean) => void; 13 14 } 14 15 15 - let { trackId, trackTitle, fileId, initialLiked = false, disabled = false, disabledReason, onLikeChange }: Props = $props(); 16 + let { trackId, trackTitle, fileId, gated, initialLiked = false, disabled = false, disabledReason, onLikeChange }: Props = $props(); 16 17 17 - let liked = $state(initialLiked); 18 + // use overridable $derived (Svelte 5.25+) - syncs with prop but can be overridden for optimistic UI 19 + let liked = $derived(initialLiked); 18 20 let loading = $state(false); 19 21 20 - // update liked state when initialLiked changes 21 - $effect(() => { 22 - liked = initialLiked; 23 - }); 24 - 25 22 async function toggleLike(e: Event) { 26 23 e.stopPropagation(); 27 24 ··· 35 32 36 33 try { 37 34 const success = liked 38 - ? await likeTrack(trackId, fileId) 35 + ? await likeTrack(trackId, fileId, gated) 39 36 : await unlikeTrack(trackId); 40 37 41 38 if (!success) { ··· 70 67 class:disabled-state={disabled} 71 68 onclick={toggleLike} 72 69 title={disabled && disabledReason ? disabledReason : (liked ? 'unlike' : 'like')} 70 + aria-label={disabled && disabledReason ? disabledReason : (liked ? 'unlike' : 'like')} 73 71 disabled={loading || disabled} 74 72 > 75 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"> ··· 86 84 justify-content: center; 87 85 background: transparent; 88 86 border: 1px solid var(--border-default); 89 - border-radius: 4px; 87 + border-radius: var(--radius-sm); 90 88 color: var(--text-tertiary); 91 89 cursor: pointer; 92 90 transition: all 0.2s;
+9 -9
frontend/src/lib/components/LikersTooltip.svelte
··· 134 134 margin-bottom: 0.5rem; 135 135 background: var(--bg-secondary); 136 136 border: 1px solid var(--border-default); 137 - border-radius: 8px; 137 + border-radius: var(--radius-md); 138 138 padding: 0.75rem; 139 139 min-width: 240px; 140 140 max-width: 320px; ··· 155 155 .error, 156 156 .empty { 157 157 color: var(--text-tertiary); 158 - font-size: 0.85rem; 158 + font-size: var(--text-sm); 159 159 text-align: center; 160 160 padding: 0.5rem; 161 161 } ··· 177 177 align-items: center; 178 178 gap: 0.75rem; 179 179 padding: 0.5rem; 180 - border-radius: 6px; 180 + border-radius: var(--radius-base); 181 181 text-decoration: none; 182 182 transition: background 0.2s; 183 183 } ··· 190 190 .avatar-placeholder { 191 191 width: 32px; 192 192 height: 32px; 193 - border-radius: 50%; 193 + border-radius: var(--radius-full); 194 194 flex-shrink: 0; 195 195 } 196 196 ··· 206 206 justify-content: center; 207 207 color: var(--text-tertiary); 208 208 font-weight: 600; 209 - font-size: 0.9rem; 209 + font-size: var(--text-base); 210 210 } 211 211 212 212 .liker-info { ··· 217 217 .display-name { 218 218 color: var(--text-primary); 219 219 font-weight: 500; 220 - font-size: 0.9rem; 220 + font-size: var(--text-base); 221 221 white-space: nowrap; 222 222 overflow: hidden; 223 223 text-overflow: ellipsis; ··· 225 225 226 226 .handle { 227 227 color: var(--text-tertiary); 228 - font-size: 0.8rem; 228 + font-size: var(--text-sm); 229 229 white-space: nowrap; 230 230 overflow: hidden; 231 231 text-overflow: ellipsis; ··· 233 233 234 234 .liked-time { 235 235 color: var(--text-muted); 236 - font-size: 0.75rem; 236 + font-size: var(--text-xs); 237 237 flex-shrink: 0; 238 238 } 239 239 ··· 248 248 249 249 .likers-list::-webkit-scrollbar-thumb { 250 250 background: var(--border-default); 251 - border-radius: 3px; 251 + border-radius: var(--radius-sm); 252 252 } 253 253 254 254 .likers-list::-webkit-scrollbar-thumb:hover {
+8 -8
frontend/src/lib/components/LinksMenu.svelte
··· 140 140 height: 32px; 141 141 background: transparent; 142 142 border: 1px solid var(--border-default); 143 - border-radius: 6px; 143 + border-radius: var(--radius-base); 144 144 color: var(--text-secondary); 145 145 cursor: pointer; 146 146 transition: all 0.2s; ··· 171 171 width: min(320px, calc(100vw - 2rem)); 172 172 background: var(--bg-secondary); 173 173 border: 1px solid var(--border-default); 174 - border-radius: 12px; 174 + border-radius: var(--radius-lg); 175 175 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); 176 176 z-index: 101; 177 177 animation: slideIn 0.2s cubic-bezier(0.16, 1, 0.3, 1); ··· 186 186 } 187 187 188 188 .menu-header span { 189 - font-size: 0.9rem; 189 + font-size: var(--text-base); 190 190 font-weight: 600; 191 191 color: var(--text-primary); 192 192 text-transform: uppercase; ··· 201 201 height: 28px; 202 202 background: transparent; 203 203 border: none; 204 - border-radius: 4px; 204 + border-radius: var(--radius-sm); 205 205 color: var(--text-secondary); 206 206 cursor: pointer; 207 207 transition: all 0.2s; ··· 224 224 gap: 1rem; 225 225 padding: 1rem; 226 226 background: transparent; 227 - border-radius: 8px; 227 + border-radius: var(--radius-md); 228 228 text-decoration: none; 229 229 color: var(--text-primary); 230 230 transition: all 0.2s; ··· 245 245 } 246 246 247 247 .tangled-menu-icon { 248 - border-radius: 4px; 248 + border-radius: var(--radius-sm); 249 249 opacity: 0.7; 250 250 transition: opacity 0.2s, box-shadow 0.2s; 251 251 } ··· 263 263 } 264 264 265 265 .link-title { 266 - font-size: 0.95rem; 266 + font-size: var(--text-base); 267 267 font-weight: 500; 268 268 color: var(--text-primary); 269 269 } 270 270 271 271 .link-subtitle { 272 - font-size: 0.8rem; 272 + font-size: var(--text-sm); 273 273 color: var(--text-tertiary); 274 274 } 275 275
+4 -4
frontend/src/lib/components/MigrationBanner.svelte
··· 152 152 .migration-banner { 153 153 background: var(--bg-tertiary); 154 154 border: 1px solid var(--border-default); 155 - border-radius: 8px; 155 + border-radius: var(--radius-md); 156 156 padding: 1rem; 157 157 margin-bottom: 1.5rem; 158 158 } ··· 190 190 gap: 1rem; 191 191 background: color-mix(in srgb, var(--success) 10%, transparent); 192 192 border: 1px solid color-mix(in srgb, var(--success) 30%, transparent); 193 - border-radius: 6px; 193 + border-radius: var(--radius-base); 194 194 padding: 1rem; 195 195 animation: slideIn 0.3s ease-out; 196 196 } ··· 239 239 .collection-name { 240 240 background: color-mix(in srgb, var(--text-primary) 5%, transparent); 241 241 padding: 0.15em 0.4em; 242 - border-radius: 3px; 242 + border-radius: var(--radius-sm); 243 243 font-family: monospace; 244 244 font-size: 0.95em; 245 245 color: var(--text-primary); ··· 265 265 .migrate-button, 266 266 .dismiss-button { 267 267 padding: 0.5rem 1rem; 268 - border-radius: 4px; 268 + border-radius: var(--radius-sm); 269 269 font-size: 0.9em; 270 270 font-family: inherit; 271 271 cursor: pointer;
+4 -4
frontend/src/lib/components/PlatformStats.svelte
··· 182 182 gap: 0.5rem; 183 183 margin-bottom: 0.75rem; 184 184 color: var(--text-secondary); 185 - font-size: 0.7rem; 185 + font-size: var(--text-xs); 186 186 font-weight: 600; 187 187 text-transform: uppercase; 188 188 letter-spacing: 0.05em; ··· 203 203 background: linear-gradient(90deg, var(--bg-tertiary) 0%, var(--bg-hover) 50%, var(--bg-tertiary) 100%); 204 204 background-size: 200% 100%; 205 205 animation: shimmer 1.5s ease-in-out infinite; 206 - border-radius: 6px; 206 + border-radius: var(--radius-base); 207 207 } 208 208 209 209 .stats-menu-grid { ··· 219 219 gap: 0.15rem; 220 220 padding: 0.6rem 0.4rem; 221 221 background: var(--bg-tertiary, #1a1a1a); 222 - border-radius: 6px; 222 + border-radius: var(--radius-base); 223 223 } 224 224 225 225 .menu-stat-icon { ··· 229 229 } 230 230 231 231 .stats-menu-value { 232 - font-size: 0.95rem; 232 + font-size: var(--text-base); 233 233 font-weight: 600; 234 234 color: var(--text-primary); 235 235 font-variant-numeric: tabular-nums;
+19 -19
frontend/src/lib/components/ProfileMenu.svelte
··· 276 276 height: 44px; 277 277 background: transparent; 278 278 border: 1px solid var(--border-default); 279 - border-radius: 8px; 279 + border-radius: var(--radius-md); 280 280 color: var(--text-secondary); 281 281 cursor: pointer; 282 282 transition: all 0.15s; ··· 311 311 overflow-y: auto; 312 312 background: var(--bg-secondary); 313 313 border: 1px solid var(--border-default); 314 - border-radius: 16px; 314 + border-radius: var(--radius-xl); 315 315 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); 316 316 z-index: 101; 317 317 animation: slideIn 0.18s cubic-bezier(0.16, 1, 0.3, 1); ··· 326 326 } 327 327 328 328 .menu-header span { 329 - font-size: 0.9rem; 329 + font-size: var(--text-base); 330 330 font-weight: 600; 331 331 color: var(--text-primary); 332 332 text-transform: uppercase; ··· 341 341 height: 36px; 342 342 background: transparent; 343 343 border: none; 344 - border-radius: 8px; 344 + border-radius: var(--radius-md); 345 345 color: var(--text-secondary); 346 346 cursor: pointer; 347 347 transition: all 0.15s; ··· 371 371 min-height: 56px; 372 372 background: transparent; 373 373 border: none; 374 - border-radius: 12px; 374 + border-radius: var(--radius-lg); 375 375 text-decoration: none; 376 376 color: var(--text-primary); 377 377 font-family: inherit; ··· 414 414 } 415 415 416 416 .item-title { 417 - font-size: 0.95rem; 417 + font-size: var(--text-base); 418 418 font-weight: 500; 419 419 color: var(--text-primary); 420 420 } 421 421 422 422 .item-subtitle { 423 - font-size: 0.8rem; 423 + font-size: var(--text-sm); 424 424 color: var(--text-tertiary); 425 425 overflow: hidden; 426 426 text-overflow: ellipsis; ··· 440 440 padding: 0.5rem 0.75rem; 441 441 background: transparent; 442 442 border: none; 443 - border-radius: 6px; 443 + border-radius: var(--radius-base); 444 444 color: var(--text-secondary); 445 445 font-family: inherit; 446 - font-size: 0.85rem; 446 + font-size: var(--text-sm); 447 447 cursor: pointer; 448 448 transition: all 0.15s; 449 449 -webkit-tap-highlight-color: transparent; ··· 469 469 470 470 .settings-section h3 { 471 471 margin: 0; 472 - font-size: 0.75rem; 472 + font-size: var(--text-xs); 473 473 text-transform: uppercase; 474 474 letter-spacing: 0.08em; 475 475 color: var(--text-tertiary); ··· 490 490 min-height: 54px; 491 491 background: var(--bg-tertiary); 492 492 border: 1px solid var(--border-default); 493 - border-radius: 8px; 493 + border-radius: var(--radius-md); 494 494 color: var(--text-secondary); 495 495 cursor: pointer; 496 496 transition: all 0.15s; ··· 518 518 } 519 519 520 520 .theme-btn span { 521 - font-size: 0.7rem; 521 + font-size: var(--text-xs); 522 522 text-transform: uppercase; 523 523 letter-spacing: 0.05em; 524 524 } ··· 533 533 width: 44px; 534 534 height: 44px; 535 535 border: 1px solid var(--border-default); 536 - border-radius: 8px; 536 + border-radius: var(--radius-md); 537 537 cursor: pointer; 538 538 background: transparent; 539 539 flex-shrink: 0; ··· 544 544 } 545 545 546 546 .color-input::-webkit-color-swatch { 547 - border-radius: 4px; 547 + border-radius: var(--radius-sm); 548 548 border: none; 549 549 } 550 550 ··· 557 557 .preset-btn { 558 558 width: 36px; 559 559 height: 36px; 560 - border-radius: 6px; 560 + border-radius: var(--radius-base); 561 561 border: 2px solid transparent; 562 562 cursor: pointer; 563 563 transition: all 0.15s; ··· 583 583 align-items: center; 584 584 gap: 0.75rem; 585 585 color: var(--text-primary); 586 - font-size: 0.9rem; 586 + font-size: var(--text-base); 587 587 cursor: pointer; 588 588 padding: 0.5rem 0; 589 589 } ··· 592 592 appearance: none; 593 593 width: 48px; 594 594 height: 28px; 595 - border-radius: 999px; 595 + border-radius: var(--radius-full); 596 596 background: var(--border-default); 597 597 position: relative; 598 598 cursor: pointer; ··· 608 608 left: 3px; 609 609 width: 20px; 610 610 height: 20px; 611 - border-radius: 50%; 611 + border-radius: var(--radius-full); 612 612 background: var(--text-secondary); 613 613 transition: transform 0.15s, background 0.15s; 614 614 } ··· 635 635 border-top: 1px solid var(--border-subtle); 636 636 color: var(--text-secondary); 637 637 text-decoration: none; 638 - font-size: 0.9rem; 638 + font-size: var(--text-base); 639 639 transition: color 0.15s; 640 640 } 641 641
+19 -18
frontend/src/lib/components/Queue.svelte
··· 1 1 <script lang="ts"> 2 2 import { queue } from '$lib/queue.svelte'; 3 + import { goToIndex } from '$lib/playback.svelte'; 3 4 import type { Track } from '$lib/types'; 4 5 5 6 let draggedIndex = $state<number | null>(null); ··· 167 168 ondragover={(e) => handleDragOver(e, index)} 168 169 ondrop={(e) => handleDrop(e, index)} 169 170 ondragend={handleDragEnd} 170 - onclick={() => queue.goTo(index)} 171 - onkeydown={(e) => e.key === 'Enter' && queue.goTo(index)} 171 + onclick={() => goToIndex(index)} 172 + onkeydown={(e) => e.key === 'Enter' && goToIndex(index)} 172 173 > 173 174 <!-- drag handle for reordering --> 174 175 <button ··· 248 249 249 250 .queue-header h2 { 250 251 margin: 0; 251 - font-size: 1rem; 252 + font-size: var(--text-lg); 252 253 text-transform: uppercase; 253 254 letter-spacing: 0.12em; 254 255 color: var(--text-tertiary); ··· 262 263 263 264 .clear-btn { 264 265 padding: 0.25rem 0.75rem; 265 - font-size: 0.75rem; 266 + font-size: var(--text-xs); 266 267 font-family: inherit; 267 268 text-transform: uppercase; 268 269 letter-spacing: 0.08em; 269 270 background: transparent; 270 271 border: 1px solid var(--border-subtle); 271 272 color: var(--text-tertiary); 272 - border-radius: 4px; 273 + border-radius: var(--radius-sm); 273 274 cursor: pointer; 274 275 transition: all 0.15s ease; 275 276 } ··· 289 290 } 290 291 291 292 .section-label { 292 - font-size: 0.75rem; 293 + font-size: var(--text-xs); 293 294 text-transform: uppercase; 294 295 letter-spacing: 0.08em; 295 296 color: var(--text-tertiary); ··· 301 302 align-items: center; 302 303 justify-content: space-between; 303 304 padding: 1rem 1.1rem; 304 - border-radius: 10px; 305 + border-radius: var(--radius-md); 305 306 background: var(--bg-secondary); 306 307 border: 1px solid var(--border-default); 307 308 gap: 1rem; ··· 315 316 } 316 317 317 318 .now-playing-card .track-artist { 318 - font-size: 0.9rem; 319 + font-size: var(--text-base); 319 320 color: var(--text-secondary); 320 321 } 321 322 ··· 343 344 justify-content: space-between; 344 345 align-items: center; 345 346 color: var(--text-tertiary); 346 - font-size: 0.85rem; 347 + font-size: var(--text-sm); 347 348 text-transform: uppercase; 348 349 letter-spacing: 0.08em; 349 350 } 350 351 351 352 .section-header h3 { 352 353 margin: 0; 353 - font-size: 0.85rem; 354 + font-size: var(--text-sm); 354 355 font-weight: 600; 355 356 color: var(--text-secondary); 356 357 text-transform: uppercase; ··· 371 372 align-items: center; 372 373 gap: 0.5rem; 373 374 padding: 0.85rem 0.9rem; 374 - border-radius: 8px; 375 + border-radius: var(--radius-md); 375 376 cursor: pointer; 376 377 transition: all 0.2s; 377 378 border: 1px solid var(--border-subtle); ··· 411 412 color: var(--text-muted); 412 413 cursor: grab; 413 414 touch-action: none; 414 - border-radius: 4px; 415 + border-radius: var(--radius-sm); 415 416 transition: all 0.2s; 416 417 flex-shrink: 0; 417 418 } ··· 448 449 } 449 450 450 451 .track-artist { 451 - font-size: 0.85rem; 452 + font-size: var(--text-sm); 452 453 color: var(--text-tertiary); 453 454 white-space: nowrap; 454 455 overflow: hidden; ··· 475 476 align-items: center; 476 477 justify-content: center; 477 478 transition: all 0.2s; 478 - border-radius: 4px; 479 + border-radius: var(--radius-sm); 479 480 opacity: 0; 480 481 flex-shrink: 0; 481 482 } ··· 498 499 499 500 .empty-up-next { 500 501 border: 1px dashed var(--border-subtle); 501 - border-radius: 6px; 502 + border-radius: var(--radius-base); 502 503 padding: 1.25rem; 503 504 text-align: center; 504 505 color: var(--text-tertiary); ··· 523 524 524 525 .empty-state p { 525 526 margin: 0.5rem 0 0.25rem; 526 - font-size: 1.1rem; 527 + font-size: var(--text-xl); 527 528 color: var(--text-secondary); 528 529 } 529 530 530 531 .empty-state span { 531 - font-size: 0.9rem; 532 + font-size: var(--text-base); 532 533 } 533 534 534 535 .queue-tracks::-webkit-scrollbar { ··· 541 542 542 543 .queue-tracks::-webkit-scrollbar-thumb { 543 544 background: var(--border-default); 544 - border-radius: 4px; 545 + border-radius: var(--radius-sm); 545 546 } 546 547 547 548 .queue-tracks::-webkit-scrollbar-thumb:hover {
+20 -20
frontend/src/lib/components/SearchModal.svelte
··· 276 276 backdrop-filter: blur(20px) saturate(180%); 277 277 -webkit-backdrop-filter: blur(20px) saturate(180%); 278 278 border: 1px solid var(--border-subtle); 279 - border-radius: 16px; 279 + border-radius: var(--radius-xl); 280 280 box-shadow: 281 281 0 24px 80px color-mix(in srgb, var(--bg-primary) 50%, transparent), 282 282 0 0 1px var(--border-subtle) inset; ··· 303 303 background: transparent; 304 304 border: none; 305 305 outline: none; 306 - font-size: 1rem; 306 + font-size: var(--text-lg); 307 307 font-family: inherit; 308 308 color: var(--text-primary); 309 309 } ··· 313 313 } 314 314 315 315 .search-shortcut { 316 - font-size: 0.7rem; 316 + font-size: var(--text-xs); 317 317 padding: 0.25rem 0.5rem; 318 318 background: var(--bg-tertiary); 319 319 border: 1px solid var(--border-default); 320 - border-radius: 5px; 320 + border-radius: var(--radius-sm); 321 321 color: var(--text-muted); 322 322 font-family: inherit; 323 323 } ··· 327 327 height: 16px; 328 328 border: 2px solid var(--border-default); 329 329 border-top-color: var(--accent); 330 - border-radius: 50%; 330 + border-radius: var(--radius-full); 331 331 animation: spin 0.6s linear infinite; 332 332 } 333 333 ··· 351 351 352 352 .search-results::-webkit-scrollbar-track { 353 353 background: transparent; 354 - border-radius: 4px; 354 + border-radius: var(--radius-sm); 355 355 } 356 356 357 357 .search-results::-webkit-scrollbar-thumb { 358 358 background: var(--border-default); 359 - border-radius: 4px; 359 + border-radius: var(--radius-sm); 360 360 } 361 361 362 362 .search-results::-webkit-scrollbar-thumb:hover { ··· 371 371 padding: 0.75rem; 372 372 background: transparent; 373 373 border: none; 374 - border-radius: 8px; 374 + border-radius: var(--radius-md); 375 375 cursor: pointer; 376 376 text-align: left; 377 377 font-family: inherit; ··· 396 396 align-items: center; 397 397 justify-content: center; 398 398 background: var(--bg-tertiary); 399 - border-radius: 8px; 400 - font-size: 0.9rem; 399 + border-radius: var(--radius-md); 400 + font-size: var(--text-base); 401 401 flex-shrink: 0; 402 402 position: relative; 403 403 overflow: hidden; ··· 409 409 width: 100%; 410 410 height: 100%; 411 411 object-fit: cover; 412 - border-radius: 8px; 412 + border-radius: var(--radius-md); 413 413 } 414 414 415 415 .result-icon[data-type='track'] { ··· 441 441 } 442 442 443 443 .result-title { 444 - font-size: 0.9rem; 444 + font-size: var(--text-base); 445 445 font-weight: 500; 446 446 white-space: nowrap; 447 447 overflow: hidden; ··· 449 449 } 450 450 451 451 .result-subtitle { 452 - font-size: 0.75rem; 452 + font-size: var(--text-xs); 453 453 color: var(--text-secondary); 454 454 white-space: nowrap; 455 455 overflow: hidden; ··· 463 463 color: var(--text-muted); 464 464 padding: 0.2rem 0.45rem; 465 465 background: var(--bg-tertiary); 466 - border-radius: 4px; 466 + border-radius: var(--radius-sm); 467 467 flex-shrink: 0; 468 468 } 469 469 ··· 471 471 padding: 2rem; 472 472 text-align: center; 473 473 color: var(--text-secondary); 474 - font-size: 0.9rem; 474 + font-size: var(--text-base); 475 475 } 476 476 477 477 .search-hints { ··· 482 482 .search-hints p { 483 483 margin: 0 0 1rem 0; 484 484 color: var(--text-secondary); 485 - font-size: 0.85rem; 485 + font-size: var(--text-sm); 486 486 } 487 487 488 488 .hint-shortcuts { ··· 490 490 justify-content: center; 491 491 gap: 1.5rem; 492 492 color: var(--text-muted); 493 - font-size: 0.75rem; 493 + font-size: var(--text-xs); 494 494 } 495 495 496 496 .hint-shortcuts span { ··· 504 504 padding: 0.15rem 0.35rem; 505 505 background: var(--bg-tertiary); 506 506 border: 1px solid var(--border-default); 507 - border-radius: 4px; 507 + border-radius: var(--radius-sm); 508 508 font-family: inherit; 509 509 } 510 510 ··· 512 512 padding: 1rem; 513 513 text-align: center; 514 514 color: var(--error); 515 - font-size: 0.85rem; 515 + font-size: var(--text-sm); 516 516 } 517 517 518 518 /* mobile optimizations */ ··· 535 535 } 536 536 537 537 .search-input::placeholder { 538 - font-size: 0.85rem; 538 + font-size: var(--text-sm); 539 539 } 540 540 541 541 .search-results {
+1 -1
frontend/src/lib/components/SearchTrigger.svelte
··· 24 24 border: 1px solid var(--border-default); 25 25 color: var(--text-secondary); 26 26 padding: 0.5rem; 27 - border-radius: 4px; 27 + border-radius: var(--radius-sm); 28 28 cursor: pointer; 29 29 transition: all 0.2s; 30 30 display: flex;
+3 -3
frontend/src/lib/components/SensitiveImage.svelte
··· 70 70 margin-bottom: 4px; 71 71 background: var(--bg-primary); 72 72 border: 1px solid var(--border-default); 73 - border-radius: 4px; 73 + border-radius: var(--radius-sm); 74 74 padding: 0.25rem 0.5rem; 75 - font-size: 0.7rem; 75 + font-size: var(--text-xs); 76 76 color: var(--text-tertiary); 77 77 white-space: nowrap; 78 78 opacity: 0; ··· 89 89 transform: translate(-50%, -50%); 90 90 margin-bottom: 0; 91 91 padding: 0.5rem 0.75rem; 92 - font-size: 0.8rem; 92 + font-size: var(--text-sm); 93 93 } 94 94 95 95 .sensitive-wrapper.blur:hover .sensitive-tooltip {
+14 -14
frontend/src/lib/components/SettingsMenu.svelte
··· 181 181 border: 1px solid var(--border-default); 182 182 color: var(--text-secondary); 183 183 padding: 0.5rem; 184 - border-radius: 4px; 184 + border-radius: var(--radius-sm); 185 185 cursor: pointer; 186 186 transition: all 0.2s; 187 187 display: flex; ··· 200 200 right: 0; 201 201 background: var(--bg-secondary); 202 202 border: 1px solid var(--border-default); 203 - border-radius: 8px; 203 + border-radius: var(--radius-md); 204 204 padding: 1.25rem; 205 205 min-width: 280px; 206 206 box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45); ··· 216 216 align-items: center; 217 217 color: var(--text-primary); 218 218 font-weight: 600; 219 - font-size: 0.95rem; 219 + font-size: var(--text-base); 220 220 } 221 221 222 222 .close-btn { ··· 245 245 246 246 .settings-section h3 { 247 247 margin: 0; 248 - font-size: 0.85rem; 248 + font-size: var(--text-sm); 249 249 text-transform: uppercase; 250 250 letter-spacing: 0.08em; 251 251 color: var(--text-tertiary); ··· 265 265 padding: 0.6rem 0.5rem; 266 266 background: var(--bg-tertiary); 267 267 border: 1px solid var(--border-default); 268 - border-radius: 6px; 268 + border-radius: var(--radius-base); 269 269 color: var(--text-secondary); 270 270 cursor: pointer; 271 271 transition: all 0.2s; ··· 288 288 } 289 289 290 290 .theme-btn span { 291 - font-size: 0.7rem; 291 + font-size: var(--text-xs); 292 292 text-transform: uppercase; 293 293 letter-spacing: 0.05em; 294 294 } ··· 303 303 width: 48px; 304 304 height: 32px; 305 305 border: 1px solid var(--border-default); 306 - border-radius: 4px; 306 + border-radius: var(--radius-sm); 307 307 cursor: pointer; 308 308 background: transparent; 309 309 } ··· 319 319 320 320 .color-value { 321 321 font-family: monospace; 322 - font-size: 0.85rem; 322 + font-size: var(--text-sm); 323 323 color: var(--text-secondary); 324 324 } 325 325 ··· 332 332 .preset-btn { 333 333 width: 32px; 334 334 height: 32px; 335 - border-radius: 4px; 335 + border-radius: var(--radius-sm); 336 336 border: 2px solid transparent; 337 337 cursor: pointer; 338 338 transition: all 0.2s; ··· 354 354 align-items: center; 355 355 gap: 0.65rem; 356 356 color: var(--text-primary); 357 - font-size: 0.9rem; 357 + font-size: var(--text-base); 358 358 } 359 359 360 360 .toggle input { 361 361 appearance: none; 362 362 width: 42px; 363 363 height: 22px; 364 - border-radius: 999px; 364 + border-radius: var(--radius-full); 365 365 background: var(--border-default); 366 366 position: relative; 367 367 cursor: pointer; ··· 377 377 left: 2px; 378 378 width: 16px; 379 379 height: 16px; 380 - border-radius: 50%; 380 + border-radius: var(--radius-full); 381 381 background: var(--text-secondary); 382 382 transition: transform 0.2s, background 0.2s; 383 383 } ··· 403 403 .toggle-hint { 404 404 margin: 0; 405 405 color: var(--text-tertiary); 406 - font-size: 0.8rem; 406 + font-size: var(--text-sm); 407 407 line-height: 1.3; 408 408 } 409 409 ··· 415 415 border-top: 1px solid var(--border-subtle); 416 416 color: var(--text-secondary); 417 417 text-decoration: none; 418 - font-size: 0.85rem; 418 + font-size: var(--text-sm); 419 419 transition: color 0.15s; 420 420 } 421 421
+3 -3
frontend/src/lib/components/ShareButton.svelte
··· 38 38 .share-btn { 39 39 background: var(--glass-btn-bg, transparent); 40 40 border: 1px solid var(--glass-btn-border, var(--border-default)); 41 - border-radius: 6px; 41 + border-radius: var(--radius-base); 42 42 width: 32px; 43 43 height: 32px; 44 44 padding: 0; ··· 67 67 border: 1px solid var(--accent); 68 68 color: var(--accent); 69 69 padding: 0.25rem 0.75rem; 70 - border-radius: 4px; 71 - font-size: 0.75rem; 70 + border-radius: var(--radius-sm); 71 + font-size: var(--text-xs); 72 72 white-space: nowrap; 73 73 pointer-events: none; 74 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 178 padding: 0.75rem; 179 179 background: var(--bg-primary); 180 180 border: 1px solid var(--border-default); 181 - border-radius: 4px; 181 + border-radius: var(--radius-sm); 182 182 min-height: 48px; 183 183 transition: all 0.2s; 184 184 } ··· 195 195 background: color-mix(in srgb, var(--accent) 10%, transparent); 196 196 border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent); 197 197 color: var(--accent-hover); 198 - border-radius: 20px; 199 - font-size: 0.9rem; 198 + border-radius: var(--radius-xl); 199 + font-size: var(--text-base); 200 200 font-weight: 500; 201 201 } 202 202 ··· 233 233 background: transparent; 234 234 border: none; 235 235 color: var(--text-primary); 236 - font-size: 1rem; 236 + font-size: var(--text-lg); 237 237 font-family: inherit; 238 238 outline: none; 239 239 } ··· 249 249 250 250 .spinner { 251 251 color: var(--text-muted); 252 - font-size: 0.85rem; 252 + font-size: var(--text-sm); 253 253 margin-left: auto; 254 254 } 255 255 ··· 261 261 overflow-y: auto; 262 262 background: var(--bg-secondary); 263 263 border: 1px solid var(--border-default); 264 - border-radius: 4px; 264 + border-radius: var(--radius-sm); 265 265 margin-top: 0.25rem; 266 266 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); 267 267 scrollbar-width: thin; ··· 274 274 275 275 .suggestions::-webkit-scrollbar-track { 276 276 background: var(--bg-primary); 277 - border-radius: 4px; 277 + border-radius: var(--radius-sm); 278 278 } 279 279 280 280 .suggestions::-webkit-scrollbar-thumb { 281 281 background: var(--border-default); 282 - border-radius: 4px; 282 + border-radius: var(--radius-sm); 283 283 } 284 284 285 285 .suggestions::-webkit-scrollbar-thumb:hover { ··· 317 317 } 318 318 319 319 .tag-count { 320 - font-size: 0.85rem; 320 + font-size: var(--text-sm); 321 321 color: var(--text-tertiary); 322 322 } 323 323 ··· 332 332 333 333 .tag-chip { 334 334 padding: 0.3rem 0.5rem; 335 - font-size: 0.85rem; 335 + font-size: var(--text-sm); 336 336 } 337 337 } 338 338 </style>
+5 -5
frontend/src/lib/components/Toast.svelte
··· 61 61 backdrop-filter: blur(12px); 62 62 -webkit-backdrop-filter: blur(12px); 63 63 border: 1px solid rgba(255, 255, 255, 0.06); 64 - border-radius: 8px; 64 + border-radius: var(--radius-md); 65 65 pointer-events: none; 66 - font-size: 0.85rem; 66 + font-size: var(--text-sm); 67 67 max-width: 450px; 68 68 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); 69 69 } 70 70 71 71 .toast-icon { 72 - font-size: 0.8rem; 72 + font-size: var(--text-sm); 73 73 flex-shrink: 0; 74 74 opacity: 0.7; 75 75 margin-top: 0.1rem; ··· 135 135 136 136 .toast { 137 137 padding: 0.35rem 0.7rem; 138 - font-size: 0.8rem; 138 + font-size: var(--text-sm); 139 139 max-width: 90vw; 140 140 } 141 141 142 142 .toast-icon { 143 - font-size: 0.75rem; 143 + font-size: var(--text-xs); 144 144 } 145 145 146 146 .toast-message {
+18 -16
frontend/src/lib/components/TrackActionsMenu.svelte
··· 10 10 trackUri?: string; 11 11 trackCid?: string; 12 12 fileId?: string; 13 + gated?: boolean; 13 14 initialLiked: boolean; 14 15 shareUrl: string; 15 16 onQueue: () => void; ··· 24 25 trackUri, 25 26 trackCid, 26 27 fileId, 28 + gated, 27 29 initialLiked, 28 30 shareUrl, 29 31 onQueue, ··· 101 103 102 104 try { 103 105 const success = liked 104 - ? await likeTrack(trackId, fileId) 106 + ? await likeTrack(trackId, fileId, gated) 105 107 : await unlikeTrack(trackId); 106 108 107 109 if (!success) { ··· 400 402 justify-content: center; 401 403 background: transparent; 402 404 border: 1px solid var(--border-default); 403 - border-radius: 4px; 405 + border-radius: var(--radius-sm); 404 406 color: var(--text-tertiary); 405 407 cursor: pointer; 406 408 transition: all 0.2s; ··· 472 474 } 473 475 474 476 .menu-item span { 475 - font-size: 1rem; 477 + font-size: var(--text-lg); 476 478 font-weight: 400; 477 479 flex: 1; 478 480 } ··· 506 508 border: none; 507 509 border-bottom: 1px solid var(--border-default); 508 510 color: var(--text-secondary); 509 - font-size: 0.9rem; 511 + font-size: var(--text-base); 510 512 font-family: inherit; 511 513 cursor: pointer; 512 514 transition: background 0.15s; ··· 532 534 border: none; 533 535 border-bottom: 1px solid var(--border-subtle); 534 536 color: var(--text-primary); 535 - font-size: 1rem; 537 + font-size: var(--text-lg); 536 538 font-family: inherit; 537 539 cursor: pointer; 538 540 transition: background 0.15s; ··· 556 558 .playlist-thumb-placeholder { 557 559 width: 36px; 558 560 height: 36px; 559 - border-radius: 4px; 561 + border-radius: var(--radius-sm); 560 562 flex-shrink: 0; 561 563 } 562 564 ··· 588 590 gap: 0.5rem; 589 591 padding: 2rem 1rem; 590 592 color: var(--text-tertiary); 591 - font-size: 0.9rem; 593 + font-size: var(--text-base); 592 594 } 593 595 594 596 .create-playlist-btn { ··· 601 603 border: none; 602 604 border-top: 1px solid var(--border-subtle); 603 605 color: var(--accent); 604 - font-size: 1rem; 606 + font-size: var(--text-lg); 605 607 font-family: inherit; 606 608 cursor: pointer; 607 609 transition: background 0.15s; ··· 625 627 padding: 0.75rem 1rem; 626 628 background: var(--bg-tertiary); 627 629 border: 1px solid var(--border-default); 628 - border-radius: 8px; 630 + border-radius: var(--radius-md); 629 631 color: var(--text-primary); 630 632 font-family: inherit; 631 - font-size: 1rem; 633 + font-size: var(--text-lg); 632 634 } 633 635 634 636 .create-form input:focus { ··· 648 650 padding: 0.75rem 1rem; 649 651 background: var(--accent); 650 652 border: none; 651 - border-radius: 8px; 653 + border-radius: var(--radius-md); 652 654 color: white; 653 655 font-family: inherit; 654 - font-size: 1rem; 656 + font-size: var(--text-lg); 655 657 font-weight: 500; 656 658 cursor: pointer; 657 659 transition: opacity 0.15s; ··· 671 673 height: 18px; 672 674 border: 2px solid var(--border-default); 673 675 border-top-color: var(--accent); 674 - border-radius: 50%; 676 + border-radius: var(--radius-full); 675 677 animation: spin 0.8s linear infinite; 676 678 } 677 679 ··· 698 700 top: 50%; 699 701 transform: translateY(-50%); 700 702 margin-right: 0.5rem; 701 - border-radius: 8px; 703 + border-radius: var(--radius-md); 702 704 min-width: 180px; 703 705 max-height: none; 704 706 animation: slideIn 0.15s cubic-bezier(0.16, 1, 0.3, 1); ··· 721 723 } 722 724 723 725 .menu-item span { 724 - font-size: 0.9rem; 726 + font-size: var(--text-base); 725 727 } 726 728 727 729 .menu-item svg { ··· 735 737 736 738 .playlist-item { 737 739 padding: 0.625rem 1rem; 738 - font-size: 0.9rem; 740 + font-size: var(--text-base); 739 741 } 740 742 741 743 .playlist-thumb,
+151 -70
frontend/src/lib/components/TrackItem.svelte
··· 7 7 import type { Track } from '$lib/types'; 8 8 import { queue } from '$lib/queue.svelte'; 9 9 import { toast } from '$lib/toast.svelte'; 10 + import { playTrack, guardGatedTrack } from '$lib/playback.svelte'; 10 11 11 12 interface Props { 12 13 track: Track; ··· 37 38 const imageFetchPriority = index < 2 ? 'high' : undefined; 38 39 39 40 let showLikersTooltip = $state(false); 40 - let likeCount = $state(track.like_count || 0); 41 - let commentCount = $state(track.comment_count || 0); 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) 42 45 let trackImageError = $state(false); 43 46 let avatarError = $state(false); 44 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 + }); 45 60 46 61 // limit visible tags to prevent vertical sprawl (max 2 shown) 47 62 const MAX_VISIBLE_TAGS = 2; ··· 52 67 (track.tags?.length || 0) - MAX_VISIBLE_TAGS 53 68 ); 54 69 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 70 // construct shareable URL - use /track/[id] for link previews 66 71 // the track page will redirect to home with query param for actual playback 67 72 const shareUrl = typeof window !== 'undefined' ··· 70 75 71 76 function addToQueue(e: Event) { 72 77 e.stopPropagation(); 78 + if (!guardGatedTrack(track, isAuthenticated)) return; 73 79 queue.addTracks([track]); 74 80 toast.success(`queued ${track.title}`, 1800); 75 81 } 76 82 77 83 function handleQueue() { 84 + if (!guardGatedTrack(track, isAuthenticated)) return; 78 85 queue.addTracks([track]); 79 86 toast.success(`queued ${track.title}`, 1800); 80 87 } ··· 126 133 {/if} 127 134 <button 128 135 class="track" 129 - onclick={(e) => { 136 + onclick={async (e) => { 130 137 // only play if clicking the track itself, not a link inside 131 138 if (e.target instanceof HTMLAnchorElement || (e.target as HTMLElement).closest('a')) { 132 139 return; 133 140 } 134 - onPlay(track); 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 + } 135 147 }} 136 148 > 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 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> 149 194 </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} 195 + {/if} 196 + </div> 176 197 <div class="track-info"> 177 198 <div class="track-title">{track.title}</div> 178 199 <div class="track-metadata"> ··· 285 306 trackUri={track.atproto_record_uri} 286 307 trackCid={track.atproto_record_cid} 287 308 fileId={track.file_id} 309 + gated={track.gated} 288 310 initialLiked={track.is_liked || false} 289 311 disabled={!track.atproto_record_uri} 290 312 disabledReason={!track.atproto_record_uri ? "track's record is unavailable" : undefined} ··· 318 340 trackUri={track.atproto_record_uri} 319 341 trackCid={track.atproto_record_cid} 320 342 fileId={track.file_id} 343 + gated={track.gated} 321 344 initialLiked={track.is_liked || false} 322 345 shareUrl={shareUrl} 323 346 onQueue={handleQueue} ··· 336 359 gap: 0.75rem; 337 360 background: var(--track-bg, var(--bg-secondary)); 338 361 border: 1px solid var(--track-border, var(--border-subtle)); 339 - border-radius: 8px; 362 + border-radius: var(--radius-md); 340 363 padding: 1rem; 341 364 box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); 342 365 transition: ··· 347 370 348 371 .track-index { 349 372 width: 24px; 350 - font-size: 0.85rem; 373 + font-size: var(--text-sm); 351 374 color: var(--text-muted); 352 375 text-align: center; 353 376 flex-shrink: 0; ··· 393 416 font-family: inherit; 394 417 } 395 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 + 396 451 .track-image, 397 452 .track-image-placeholder { 398 453 flex-shrink: 0; ··· 401 456 display: flex; 402 457 align-items: center; 403 458 justify-content: center; 404 - border-radius: 4px; 459 + border-radius: var(--radius-sm); 405 460 overflow: hidden; 406 461 background: var(--bg-tertiary); 407 462 border: 1px solid var(--border-subtle); ··· 435 490 } 436 491 437 492 .track-avatar img { 438 - border-radius: 50%; 493 + border-radius: var(--radius-full); 439 494 border: 2px solid var(--border-default); 440 495 transition: border-color 0.2s; 441 496 } ··· 485 540 align-items: flex-start; 486 541 gap: 0.15rem; 487 542 color: var(--text-secondary); 488 - font-size: 0.9rem; 543 + font-size: var(--text-base); 489 544 font-family: inherit; 490 545 min-width: 0; 491 546 width: 100%; ··· 505 560 506 561 .metadata-separator { 507 562 display: none; 508 - font-size: 0.7rem; 563 + font-size: var(--text-xs); 509 564 } 510 565 511 566 .artist-link { ··· 604 659 padding: 0.1rem 0.4rem; 605 660 background: color-mix(in srgb, var(--accent) 15%, transparent); 606 661 color: var(--accent-hover); 607 - border-radius: 3px; 608 - font-size: 0.75rem; 662 + border-radius: var(--radius-sm); 663 + font-size: var(--text-xs); 609 664 font-weight: 500; 610 665 text-decoration: none; 611 666 transition: all 0.15s; ··· 624 679 background: var(--bg-tertiary); 625 680 color: var(--text-muted); 626 681 border: none; 627 - border-radius: 3px; 628 - font-size: 0.75rem; 682 + border-radius: var(--radius-sm); 683 + font-size: var(--text-xs); 629 684 font-weight: 500; 630 685 font-family: inherit; 631 686 cursor: pointer; ··· 640 695 } 641 696 642 697 .track-meta { 643 - font-size: 0.8rem; 698 + font-size: var(--text-sm); 644 699 color: var(--text-tertiary); 645 700 display: flex; 646 701 align-items: center; ··· 654 709 655 710 .meta-separator { 656 711 color: var(--text-muted); 657 - font-size: 0.7rem; 712 + font-size: var(--text-xs); 658 713 } 659 714 660 715 .likes { ··· 701 756 justify-content: center; 702 757 background: transparent; 703 758 border: 1px solid var(--border-default); 704 - border-radius: 4px; 759 + border-radius: var(--radius-sm); 705 760 color: var(--text-tertiary); 706 761 cursor: pointer; 707 762 transition: all 0.2s; ··· 746 801 gap: 0.5rem; 747 802 } 748 803 804 + .track-image-wrapper, 749 805 .track-image, 750 806 .track-image-placeholder, 751 807 .track-avatar { ··· 753 809 height: 40px; 754 810 } 755 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 + 756 824 .track-title { 757 - font-size: 0.9rem; 825 + font-size: var(--text-base); 758 826 } 759 827 760 828 .track-metadata { 761 - font-size: 0.8rem; 829 + font-size: var(--text-sm); 762 830 gap: 0.35rem; 763 831 } 764 832 765 833 .track-meta { 766 - font-size: 0.7rem; 834 + font-size: var(--text-xs); 767 835 } 768 836 769 837 .track-actions { ··· 786 854 padding: 0.5rem 0.65rem; 787 855 } 788 856 857 + .track-image-wrapper, 789 858 .track-image, 790 859 .track-image-placeholder, 791 860 .track-avatar { ··· 793 862 height: 36px; 794 863 } 795 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 + 796 877 .track-title { 797 - font-size: 0.85rem; 878 + font-size: var(--text-sm); 798 879 } 799 880 800 881 .track-metadata { 801 - font-size: 0.75rem; 882 + font-size: var(--text-xs); 802 883 } 803 884 804 885 .metadata-separator {
+1 -1
frontend/src/lib/components/WaveLoading.svelte
··· 67 67 .message { 68 68 margin: 0; 69 69 color: var(--text-secondary); 70 - font-size: 0.9rem; 70 + font-size: var(--text-base); 71 71 text-align: center; 72 72 } 73 73 </style>
+6 -6
frontend/src/lib/components/player/PlaybackControls.svelte
··· 244 244 align-items: center; 245 245 justify-content: center; 246 246 transition: all 0.2s; 247 - border-radius: 50%; 247 + border-radius: var(--radius-full); 248 248 } 249 249 250 250 .control-btn svg { ··· 282 282 display: flex; 283 283 align-items: center; 284 284 justify-content: center; 285 - border-radius: 6px; 285 + border-radius: var(--radius-base); 286 286 transition: all 0.2s; 287 287 position: relative; 288 288 } ··· 310 310 } 311 311 312 312 .time { 313 - font-size: 0.85rem; 313 + font-size: var(--text-sm); 314 314 color: var(--text-tertiary); 315 315 min-width: 45px; 316 316 font-variant-numeric: tabular-nums; ··· 382 382 background: var(--accent); 383 383 height: 14px; 384 384 width: 14px; 385 - border-radius: 50%; 385 + border-radius: var(--radius-full); 386 386 margin-top: -5px; 387 387 transition: all 0.2s; 388 388 box-shadow: 0 0 0 8px transparent; ··· 419 419 background: var(--accent); 420 420 height: 14px; 421 421 width: 14px; 422 - border-radius: 50%; 422 + border-radius: var(--radius-full); 423 423 border: none; 424 424 transition: all 0.2s; 425 425 box-shadow: 0 0 0 8px transparent; ··· 493 493 } 494 494 495 495 .time { 496 - font-size: 0.75rem; 496 + font-size: var(--text-xs); 497 497 min-width: 38px; 498 498 } 499 499
+138 -23
frontend/src/lib/components/player/Player.svelte
··· 4 4 import { nowPlaying } from '$lib/now-playing.svelte'; 5 5 import { moderation } from '$lib/moderation.svelte'; 6 6 import { preferences } from '$lib/preferences.svelte'; 7 + import { toast } from '$lib/toast.svelte'; 7 8 import { API_URL } from '$lib/config'; 8 9 import { getCachedAudioUrl } from '$lib/storage'; 9 10 import { onMount } from 'svelte'; ··· 11 12 import TrackInfo from './TrackInfo.svelte'; 12 13 import PlaybackControls from './PlaybackControls.svelte'; 13 14 import type { Track } from '$lib/types'; 15 + 16 + // atprotofans base URL for supporter CTAs 17 + const ATPROTOFANS_URL = 'https://atprotofans.com'; 14 18 15 19 // check if artwork should be shown in media session (respects sensitive content settings) 16 20 function shouldShowArtwork(url: string | null | undefined): boolean { ··· 239 243 ); 240 244 }); 241 245 246 + // gated content error types 247 + interface GatedError { 248 + type: 'gated'; 249 + artistDid: string; 250 + artistHandle: string; 251 + requiresAuth: boolean; 252 + } 253 + 242 254 // get audio source URL - checks local cache first, falls back to network 243 - async function getAudioSource(file_id: string): Promise<string> { 255 + // throws GatedError if the track requires supporter access 256 + async function getAudioSource(file_id: string, track: Track): Promise<string> { 244 257 try { 245 258 const cachedUrl = await getCachedAudioUrl(file_id); 246 259 if (cachedUrl) { ··· 249 262 } catch (err) { 250 263 console.error('failed to check audio cache:', err); 251 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 + 252 292 return `${API_URL}/audio/${file_id}`; 253 293 } 254 294 ··· 266 306 let previousTrackId = $state<number | null>(null); 267 307 let isLoadingTrack = $state(false); 268 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 + 269 317 $effect(() => { 270 318 if (!player.currentTrack || !player.audioElement) return; 271 319 272 320 // only load new track if it actually changed 273 321 if (player.currentTrack.id !== previousTrackId) { 274 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 275 340 previousTrackId = trackToLoad.id; 276 341 player.resetPlayCount(); 277 342 isLoadingTrack = true; ··· 280 345 cleanupBlobUrl(); 281 346 282 347 // 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 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 287 363 if (src.startsWith('blob:')) { 288 - URL.revokeObjectURL(src); 364 + currentBlobUrl = src; 289 365 } 290 - return; 291 - } 366 + 367 + player.audioElement.src = src; 368 + player.audioElement.load(); 292 369 293 - // track if this is a blob URL so we can revoke it later 294 - if (src.startsWith('blob:')) { 295 - currentBlobUrl = src; 296 - } 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; 297 381 298 - player.audioElement.src = src; 299 - player.audioElement.load(); 382 + // handle gated content errors with supporter CTA 383 + if (err && err.type === 'gated') { 384 + const gatedErr = err as GatedError; 300 385 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 - }); 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 + }); 310 425 } 311 426 }); 312 427
+4 -4
frontend/src/lib/components/player/TrackInfo.svelte
··· 156 156 flex-shrink: 0; 157 157 width: 56px; 158 158 height: 56px; 159 - border-radius: 4px; 159 + border-radius: var(--radius-sm); 160 160 overflow: hidden; 161 161 background: var(--bg-tertiary); 162 162 border: 1px solid var(--border-default); ··· 197 197 198 198 .player-title, 199 199 .player-title-link { 200 - font-size: 0.95rem; 200 + font-size: var(--text-base); 201 201 font-weight: 600; 202 202 color: var(--text-primary); 203 203 margin-bottom: 0; ··· 384 384 385 385 .player-title, 386 386 .player-title-link { 387 - font-size: 0.9rem; 387 + font-size: var(--text-base); 388 388 margin-bottom: 0; 389 389 } 390 390 391 391 .player-metadata { 392 - font-size: 0.8rem; 392 + font-size: var(--text-sm); 393 393 } 394 394 395 395 .player-title.scrolling,
+8
frontend/src/lib/config.ts
··· 2 2 3 3 export const API_URL = PUBLIC_API_URL || 'http://localhost:8001'; 4 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 + 5 13 interface ServerConfig { 6 14 max_upload_size_mb: number; 7 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 133 export const tracksCache = new TracksCache(); 134 134 135 135 // like/unlike track functions 136 - export async function likeTrack(trackId: number, fileId?: string): Promise<boolean> { 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> { 137 138 try { 138 139 const response = await fetch(`${API_URL}/tracks/${trackId}/like`, { 139 140 method: 'POST', ··· 148 149 tracksCache.invalidate(); 149 150 150 151 // auto-download if preference is enabled and file_id provided 151 - if (fileId && preferences.autoDownloadLiked) { 152 + // skip download only if track is gated AND viewer lacks access (gated === true) 153 + if (fileId && preferences.autoDownloadLiked && gated !== true) { 152 154 try { 153 155 const alreadyDownloaded = await isDownloaded(fileId); 154 156 if (!alreadyDownloaded) {
+7
frontend/src/lib/types.ts
··· 27 27 tracks: Track[]; 28 28 } 29 29 30 + export interface SupportGate { 31 + type: 'any' | string; 32 + } 33 + 30 34 export interface Track { 31 35 id: number; 32 36 title: string; ··· 36 40 file_type: string; 37 41 artist_handle: string; 38 42 artist_avatar_url?: string; 43 + artist_did?: string; 39 44 r2_url?: string; 40 45 atproto_record_uri?: string; 41 46 atproto_record_cid?: string; ··· 50 55 is_liked?: boolean; 51 56 copyright_flagged?: boolean | null; // null = not scanned, false = clear, true = flagged 52 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 53 60 } 54 61 55 62 export interface User {
+54 -4
frontend/src/lib/uploader.svelte.ts
··· 23 23 onError?: (_error: string) => void; 24 24 } 25 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 + 26 61 // global upload manager using Svelte 5 runes 27 62 class UploaderState { 28 63 activeUploads = $state<Map<string, UploadTask>>(new Map()); ··· 34 69 features: FeaturedArtist[], 35 70 image: File | null | undefined, 36 71 tags: string[], 72 + supportGated: boolean, 37 73 onSuccess?: () => void, 38 74 callbacks?: UploadProgressCallback 39 75 ): void { 40 76 const taskId = crypto.randomUUID(); 41 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 + 42 85 const uploadMessage = fileSizeMB > 10 43 86 ? 'uploading track... (large file, this may take a moment)' 44 87 : 'uploading track...'; 45 88 // 0 means infinite/persist until dismissed 46 89 const toastId = toast.info(uploadMessage, 0); 47 90 91 + // track upload progress for error messages 92 + let lastProgressPercent = 0; 93 + 48 94 if (!browser) return; 49 95 const formData = new FormData(); 50 96 formData.append('file', file); ··· 60 106 if (image) { 61 107 formData.append('image', image); 62 108 } 109 + if (supportGated) { 110 + formData.append('support_gate', JSON.stringify({ type: 'any' })); 111 + } 63 112 64 113 const xhr = new XMLHttpRequest(); 65 114 xhr.open('POST', `${API_URL}/tracks/`); ··· 70 119 xhr.upload.addEventListener('progress', (e) => { 71 120 if (e.lengthComputable && !uploadComplete) { 72 121 const percent = Math.round((e.loaded / e.total) * 100); 122 + lastProgressPercent = percent; 73 123 const progressMsg = `retrieving your file... ${percent}%`; 74 124 toast.update(toastId, progressMsg); 75 125 if (callbacks?.onProgress) { ··· 168 218 errorMsg = error.detail || errorMsg; 169 219 } catch { 170 220 if (xhr.status === 0) { 171 - errorMsg = 'network error: connection failed. check your internet connection and try again'; 221 + errorMsg = buildNetworkErrorMessage(lastProgressPercent, fileSizeMB, isMobile); 172 222 } else if (xhr.status >= 500) { 173 223 errorMsg = 'server error: please try again in a moment'; 174 224 } else if (xhr.status === 413) { 175 225 errorMsg = 'file too large: please use a smaller file'; 176 226 } else if (xhr.status === 408 || xhr.status === 504) { 177 - errorMsg = 'upload timed out: please try again with a better connection'; 227 + errorMsg = buildTimeoutErrorMessage(lastProgressPercent, fileSizeMB, isMobile); 178 228 } 179 229 } 180 230 toast.error(errorMsg); ··· 186 236 187 237 xhr.addEventListener('error', () => { 188 238 toast.dismiss(toastId); 189 - const errorMsg = 'network error: connection failed. check your internet connection and try again'; 239 + const errorMsg = buildNetworkErrorMessage(lastProgressPercent, fileSizeMB, isMobile); 190 240 toast.error(errorMsg); 191 241 if (callbacks?.onError) { 192 242 callbacks.onError(errorMsg); ··· 195 245 196 246 xhr.addEventListener('timeout', () => { 197 247 toast.dismiss(toastId); 198 - const errorMsg = 'upload timed out: please try again with a better connection'; 248 + const errorMsg = buildTimeoutErrorMessage(lastProgressPercent, fileSizeMB, isMobile); 199 249 toast.error(errorMsg); 200 250 if (callbacks?.onError) { 201 251 callbacks.onError(errorMsg);
+5 -5
frontend/src/routes/+error.svelte
··· 62 62 } 63 63 64 64 .error-message { 65 - font-size: 1.25rem; 65 + font-size: var(--text-2xl); 66 66 color: var(--text-secondary); 67 67 margin: 0 0 0.5rem 0; 68 68 } 69 69 70 70 .error-detail { 71 - font-size: 1rem; 71 + font-size: var(--text-lg); 72 72 color: var(--text-tertiary); 73 73 margin: 0 0 2rem 0; 74 74 } ··· 76 76 .home-link { 77 77 color: var(--accent); 78 78 text-decoration: none; 79 - font-size: 1.1rem; 79 + font-size: var(--text-xl); 80 80 padding: 0.75rem 1.5rem; 81 81 border: 1px solid var(--accent); 82 - border-radius: 6px; 82 + border-radius: var(--radius-base); 83 83 transition: all 0.2s; 84 84 } 85 85 ··· 98 98 } 99 99 100 100 .error-message { 101 - font-size: 1.1rem; 101 + font-size: var(--text-xl); 102 102 } 103 103 } 104 104 </style>
+32 -4
frontend/src/routes/+layout.svelte
··· 450 450 --text-muted: #666666; 451 451 452 452 /* typography scale */ 453 - --text-page-heading: 1.5rem; 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); 454 463 --text-section-heading: 1.2rem; 455 - --text-body: 1rem; 456 - --text-small: 0.9rem; 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; 457 475 458 476 /* semantic */ 459 477 --success: #4ade80; ··· 516 534 color: var(--accent-muted); 517 535 } 518 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 + 519 547 :global(body) { 520 548 margin: 0; 521 549 padding: 0; ··· 589 617 right: 20px; 590 618 width: 48px; 591 619 height: 48px; 592 - border-radius: 50%; 620 + border-radius: var(--radius-full); 593 621 background: var(--bg-secondary); 594 622 border: 1px solid var(--border-default); 595 623 color: var(--text-secondary);
+1 -1
frontend/src/routes/+page.svelte
··· 227 227 } 228 228 229 229 .section-header h2 { 230 - font-size: 1.25rem; 230 + font-size: var(--text-2xl); 231 231 } 232 232 } 233 233 </style>
+146 -38
frontend/src/routes/costs/+page.svelte
··· 59 59 let loading = $state(true); 60 60 let error = $state<string | null>(null); 61 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 + }); 62 88 63 89 // derived values for bar chart scaling 64 90 let maxCost = $derived( ··· 72 98 : 1 73 99 ); 74 100 75 - let maxRequests = $derived( 76 - data?.costs.audd.daily.length 77 - ? Math.max(...data.costs.audd.daily.map((d) => d.requests)) 78 - : 1 79 - ); 101 + let maxRequests = $derived.by(() => { 102 + return filteredDaily.length ? Math.max(...filteredDaily.map((d) => d.requests)) : 1; 103 + }); 80 104 81 105 onMount(async () => { 82 106 try { ··· 216 240 217 241 <!-- audd details --> 218 242 <section class="audd-section"> 219 - <h2>copyright scanning (audd)</h2> 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 + 220 267 <div class="audd-stats"> 221 268 <div class="stat"> 222 - <span class="stat-value">{data.costs.audd.requests_this_period.toLocaleString()}</span> 223 - <span class="stat-label">API requests</span> 269 + <span class="stat-value">{filteredTotals.requests.toLocaleString()}</span> 270 + <span class="stat-label">requests ({timeRange === 'day' ? '24h' : timeRange === 'week' ? '7d' : '30d'})</span> 224 271 </div> 225 272 <div class="stat"> 226 273 <span class="stat-value">{data.costs.audd.remaining_free.toLocaleString()}</span> 227 274 <span class="stat-label">free remaining</span> 228 275 </div> 229 276 <div class="stat"> 230 - <span class="stat-value">{data.costs.audd.scans_this_period.toLocaleString()}</span> 277 + <span class="stat-value">{filteredTotals.scans.toLocaleString()}</span> 231 278 <span class="stat-label">tracks scanned</span> 232 279 </div> 233 280 </div> ··· 236 283 1 request = 12s of audio. {data.costs.audd.free_requests.toLocaleString()} free/month, 237 284 then ${(5).toFixed(2)}/1k requests. 238 285 {#if data.costs.audd.billable_requests > 0} 239 - <strong>{data.costs.audd.billable_requests.toLocaleString()} billable</strong> this period. 286 + <strong>{data.costs.audd.billable_requests.toLocaleString()} billable</strong> this billing period. 240 287 {/if} 241 288 </p> 242 289 243 - {#if data.costs.audd.daily.length > 0} 290 + {#if filteredDaily.length > 0} 244 291 <div class="daily-chart"> 245 292 <h3>daily requests</h3> 246 293 <div class="chart-bars"> 247 - {#each data.costs.audd.daily as day} 294 + {#each filteredDaily as day} 248 295 <div class="chart-bar-container"> 249 296 <div 250 297 class="chart-bar" ··· 256 303 {/each} 257 304 </div> 258 305 </div> 306 + {:else} 307 + <p class="no-data">no requests in this time range</p> 259 308 {/if} 260 309 </section> 261 310 ··· 303 352 304 353 .subtitle { 305 354 color: var(--text-tertiary); 306 - font-size: 0.9rem; 355 + font-size: var(--text-base); 307 356 margin: 0; 308 357 } 309 358 ··· 321 370 322 371 .error-state .hint { 323 372 color: var(--text-tertiary); 324 - font-size: 0.85rem; 373 + font-size: var(--text-sm); 325 374 margin-top: 0.5rem; 326 375 } 327 376 ··· 337 386 padding: 2rem; 338 387 background: var(--bg-tertiary); 339 388 border: 1px solid var(--border-subtle); 340 - border-radius: 12px; 389 + border-radius: var(--radius-lg); 341 390 } 342 391 343 392 .total-label { 344 - font-size: 0.8rem; 393 + font-size: var(--text-sm); 345 394 text-transform: uppercase; 346 395 letter-spacing: 0.08em; 347 396 color: var(--text-tertiary); ··· 356 405 357 406 .updated { 358 407 text-align: center; 359 - font-size: 0.75rem; 408 + font-size: var(--text-xs); 360 409 color: var(--text-tertiary); 361 410 margin-top: 0.75rem; 362 411 } ··· 368 417 369 418 .breakdown-section h2, 370 419 .audd-section h2 { 371 - font-size: 0.8rem; 420 + font-size: var(--text-sm); 372 421 text-transform: uppercase; 373 422 letter-spacing: 0.08em; 374 423 color: var(--text-tertiary); ··· 384 433 .cost-item { 385 434 background: var(--bg-tertiary); 386 435 border: 1px solid var(--border-subtle); 387 - border-radius: 8px; 436 + border-radius: var(--radius-md); 388 437 padding: 1rem; 389 438 } 390 439 ··· 409 458 .cost-bar-bg { 410 459 height: 8px; 411 460 background: var(--bg-primary); 412 - border-radius: 4px; 461 + border-radius: var(--radius-sm); 413 462 overflow: hidden; 414 463 margin-bottom: 0.5rem; 415 464 } ··· 417 466 .cost-bar { 418 467 height: 100%; 419 468 background: var(--accent); 420 - border-radius: 4px; 469 + border-radius: var(--radius-sm); 421 470 transition: width 0.3s ease; 422 471 } 423 472 ··· 426 475 } 427 476 428 477 .cost-note { 429 - font-size: 0.75rem; 478 + font-size: var(--text-xs); 430 479 color: var(--text-tertiary); 431 480 } 432 481 ··· 435 484 margin-bottom: 2rem; 436 485 } 437 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 + 438 540 .audd-stats { 439 541 display: grid; 440 542 grid-template-columns: repeat(3, 1fr); ··· 443 545 } 444 546 445 547 .audd-explainer { 446 - font-size: 0.8rem; 548 + font-size: var(--text-sm); 447 549 color: var(--text-secondary); 448 550 margin-bottom: 1.5rem; 449 551 line-height: 1.5; ··· 460 562 padding: 1rem; 461 563 background: var(--bg-tertiary); 462 564 border: 1px solid var(--border-subtle); 463 - border-radius: 8px; 565 + border-radius: var(--radius-md); 464 566 } 465 567 466 568 .stat-value { 467 - font-size: 1.25rem; 569 + font-size: var(--text-2xl); 468 570 font-weight: 700; 469 571 color: var(--text-primary); 470 572 font-variant-numeric: tabular-nums; 471 573 } 472 574 473 575 .stat-label { 474 - font-size: 0.7rem; 576 + font-size: var(--text-xs); 475 577 color: var(--text-tertiary); 476 578 text-align: center; 477 579 margin-top: 0.25rem; ··· 481 583 .daily-chart { 482 584 background: var(--bg-tertiary); 483 585 border: 1px solid var(--border-subtle); 484 - border-radius: 8px; 586 + border-radius: var(--radius-md); 485 587 padding: 1rem; 588 + overflow: hidden; 486 589 } 487 590 488 591 .daily-chart h3 { 489 - font-size: 0.75rem; 592 + font-size: var(--text-xs); 490 593 text-transform: uppercase; 491 594 letter-spacing: 0.05em; 492 595 color: var(--text-tertiary); ··· 496 599 .chart-bars { 497 600 display: flex; 498 601 align-items: flex-end; 499 - gap: 4px; 602 + gap: 2px; 500 603 height: 100px; 604 + width: 100%; 501 605 } 502 606 503 607 .chart-bar-container { 504 - flex: 1; 608 + flex: 1 1 0; 609 + min-width: 0; 505 610 display: flex; 506 611 flex-direction: column; 507 612 align-items: center; ··· 522 627 } 523 628 524 629 .chart-label { 525 - font-size: 0.6rem; 630 + font-size: 0.55rem; 526 631 color: var(--text-tertiary); 527 - margin-top: 0.5rem; 632 + margin-top: 0.25rem; 528 633 white-space: nowrap; 634 + overflow: hidden; 635 + text-overflow: ellipsis; 636 + max-width: 100%; 529 637 } 530 638 531 639 /* support section */ ··· 544 652 var(--bg-tertiary) 545 653 ); 546 654 border: 1px solid var(--border-subtle); 547 - border-radius: 12px; 655 + border-radius: var(--radius-lg); 548 656 } 549 657 550 658 .support-icon { ··· 554 662 555 663 .support-text h3 { 556 664 margin: 0 0 0.5rem; 557 - font-size: 1.1rem; 665 + font-size: var(--text-xl); 558 666 color: var(--text-primary); 559 667 } 560 668 561 669 .support-text p { 562 670 margin: 0 0 1.5rem; 563 671 color: var(--text-secondary); 564 - font-size: 0.9rem; 672 + font-size: var(--text-base); 565 673 } 566 674 567 675 .support-button { ··· 571 679 padding: 0.75rem 1.5rem; 572 680 background: var(--accent); 573 681 color: white; 574 - border-radius: 8px; 682 + border-radius: var(--radius-md); 575 683 text-decoration: none; 576 684 font-weight: 600; 577 - font-size: 0.9rem; 685 + font-size: var(--text-base); 578 686 transition: transform 0.15s, box-shadow 0.15s; 579 687 } 580 688 ··· 586 694 /* footer */ 587 695 .footer-note { 588 696 text-align: center; 589 - font-size: 0.8rem; 697 + font-size: var(--text-sm); 590 698 color: var(--text-tertiary); 591 699 padding-bottom: 1rem; 592 700 }
+1 -1
frontend/src/routes/embed/track/[id]/+page.svelte
··· 184 184 .play-btn { 185 185 width: 48px; 186 186 height: 48px; 187 - border-radius: 50%; 187 + border-radius: var(--radius-full); 188 188 background: #fff; 189 189 color: #000; 190 190 border: none;
+64 -26
frontend/src/routes/library/+page.svelte
··· 1 1 <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { replaceState, invalidateAll, goto } from '$app/navigation'; 2 4 import Header from '$lib/components/Header.svelte'; 3 5 import { auth } from '$lib/auth.svelte'; 4 - import { goto } from '$app/navigation'; 6 + import { preferences } from '$lib/preferences.svelte'; 5 7 import { API_URL } from '$lib/config'; 6 8 import type { PageData } from './$types'; 7 9 import type { Playlist } from '$lib/types'; ··· 13 15 let newPlaylistName = $state(''); 14 16 let creating = $state(false); 15 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 + }); 16 54 17 55 async function handleLogout() { 18 56 await auth.logout(); ··· 226 264 } 227 265 228 266 .page-header p { 229 - font-size: 0.9rem; 267 + font-size: var(--text-base); 230 268 color: var(--text-tertiary); 231 269 margin: 0; 232 270 } ··· 244 282 padding: 1rem 1.25rem; 245 283 background: var(--bg-secondary); 246 284 border: 1px solid var(--border-default); 247 - border-radius: 12px; 285 + border-radius: var(--radius-lg); 248 286 text-decoration: none; 249 287 color: inherit; 250 288 transition: all 0.15s; ··· 262 300 .collection-icon { 263 301 width: 48px; 264 302 height: 48px; 265 - border-radius: 10px; 303 + border-radius: var(--radius-md); 266 304 display: flex; 267 305 align-items: center; 268 306 justify-content: center; ··· 282 320 .playlist-artwork { 283 321 width: 48px; 284 322 height: 48px; 285 - border-radius: 10px; 323 + border-radius: var(--radius-md); 286 324 object-fit: cover; 287 325 flex-shrink: 0; 288 326 } ··· 293 331 } 294 332 295 333 .collection-info h3 { 296 - font-size: 1rem; 334 + font-size: var(--text-lg); 297 335 font-weight: 600; 298 336 color: var(--text-primary); 299 337 margin: 0 0 0.15rem 0; ··· 303 341 } 304 342 305 343 .collection-info p { 306 - font-size: 0.85rem; 344 + font-size: var(--text-sm); 307 345 color: var(--text-tertiary); 308 346 margin: 0; 309 347 } ··· 332 370 } 333 371 334 372 .section-header h2 { 335 - font-size: 1.1rem; 373 + font-size: var(--text-xl); 336 374 font-weight: 600; 337 375 color: var(--text-primary); 338 376 margin: 0; ··· 346 384 background: var(--accent); 347 385 color: white; 348 386 border: none; 349 - border-radius: 8px; 387 + border-radius: var(--radius-md); 350 388 font-family: inherit; 351 389 font-size: 0.875rem; 352 390 font-weight: 500; ··· 377 415 padding: 3rem 2rem; 378 416 background: var(--bg-secondary); 379 417 border: 1px dashed var(--border-default); 380 - border-radius: 12px; 418 + border-radius: var(--radius-lg); 381 419 text-align: center; 382 420 } 383 421 384 422 .empty-icon { 385 423 width: 64px; 386 424 height: 64px; 387 - border-radius: 16px; 425 + border-radius: var(--radius-xl); 388 426 display: flex; 389 427 align-items: center; 390 428 justify-content: center; ··· 394 432 } 395 433 396 434 .empty-state p { 397 - font-size: 1rem; 435 + font-size: var(--text-lg); 398 436 font-weight: 500; 399 437 color: var(--text-secondary); 400 438 margin: 0 0 0.25rem 0; 401 439 } 402 440 403 441 .empty-state span { 404 - font-size: 0.85rem; 442 + font-size: var(--text-sm); 405 443 color: var(--text-muted); 406 444 } 407 445 ··· 423 461 .modal { 424 462 background: var(--bg-primary); 425 463 border: 1px solid var(--border-default); 426 - border-radius: 16px; 464 + border-radius: var(--radius-xl); 427 465 width: 100%; 428 466 max-width: 400px; 429 467 box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); ··· 438 476 } 439 477 440 478 .modal-header h3 { 441 - font-size: 1.1rem; 479 + font-size: var(--text-xl); 442 480 font-weight: 600; 443 481 color: var(--text-primary); 444 482 margin: 0; ··· 452 490 height: 32px; 453 491 background: transparent; 454 492 border: none; 455 - border-radius: 8px; 493 + border-radius: var(--radius-md); 456 494 color: var(--text-secondary); 457 495 cursor: pointer; 458 496 transition: all 0.15s; ··· 469 507 470 508 .modal-body label { 471 509 display: block; 472 - font-size: 0.85rem; 510 + font-size: var(--text-sm); 473 511 font-weight: 500; 474 512 color: var(--text-secondary); 475 513 margin-bottom: 0.5rem; ··· 480 518 padding: 0.75rem 1rem; 481 519 background: var(--bg-secondary); 482 520 border: 1px solid var(--border-default); 483 - border-radius: 8px; 521 + border-radius: var(--radius-md); 484 522 font-family: inherit; 485 - font-size: 1rem; 523 + font-size: var(--text-lg); 486 524 color: var(--text-primary); 487 525 transition: border-color 0.15s; 488 526 } ··· 498 536 499 537 .modal-body .error { 500 538 margin: 0.5rem 0 0 0; 501 - font-size: 0.85rem; 539 + font-size: var(--text-sm); 502 540 color: #ef4444; 503 541 } 504 542 ··· 512 550 .cancel-btn, 513 551 .confirm-btn { 514 552 padding: 0.625rem 1.25rem; 515 - border-radius: 8px; 553 + border-radius: var(--radius-md); 516 554 font-family: inherit; 517 - font-size: 0.9rem; 555 + font-size: var(--text-base); 518 556 font-weight: 500; 519 557 cursor: pointer; 520 558 transition: all 0.15s; ··· 558 596 } 559 597 560 598 .page-header h1 { 561 - font-size: 1.5rem; 599 + font-size: var(--text-3xl); 562 600 } 563 601 564 602 .collection-card { ··· 571 609 } 572 610 573 611 .collection-info h3 { 574 - font-size: 0.95rem; 612 + font-size: var(--text-base); 575 613 } 576 614 577 615 .section-header h2 { 578 - font-size: 1rem; 616 + font-size: var(--text-lg); 579 617 } 580 618 581 619 .create-btn { 582 620 padding: 0.5rem 0.875rem; 583 - font-size: 0.85rem; 621 + font-size: var(--text-sm); 584 622 } 585 623 586 624 .empty-state {
+12 -12
frontend/src/routes/liked/+page.svelte
··· 345 345 } 346 346 347 347 .count { 348 - font-size: 0.85rem; 348 + font-size: var(--text-sm); 349 349 font-weight: 500; 350 350 color: var(--text-tertiary); 351 351 background: var(--bg-tertiary); 352 352 padding: 0.2rem 0.55rem; 353 - border-radius: 4px; 353 + border-radius: var(--radius-sm); 354 354 } 355 355 356 356 .header-actions { ··· 362 362 .queue-button, 363 363 .reorder-button { 364 364 padding: 0.75rem 1.5rem; 365 - border-radius: 24px; 365 + border-radius: var(--radius-2xl); 366 366 font-weight: 600; 367 - font-size: 0.95rem; 367 + font-size: var(--text-base); 368 368 font-family: inherit; 369 369 cursor: pointer; 370 370 transition: all 0.2s; ··· 419 419 } 420 420 421 421 .empty-state h2 { 422 - font-size: 1.5rem; 422 + font-size: var(--text-3xl); 423 423 font-weight: 600; 424 424 color: var(--text-secondary); 425 425 margin: 0 0 0.5rem 0; 426 426 } 427 427 428 428 .empty-state p { 429 - font-size: 0.95rem; 429 + font-size: var(--text-base); 430 430 margin: 0; 431 431 } 432 432 ··· 441 441 display: flex; 442 442 align-items: center; 443 443 gap: 0.5rem; 444 - border-radius: 8px; 444 + border-radius: var(--radius-md); 445 445 transition: all 0.2s; 446 446 position: relative; 447 447 } ··· 473 473 color: var(--text-muted); 474 474 cursor: grab; 475 475 touch-action: none; 476 - border-radius: 4px; 476 + border-radius: var(--radius-sm); 477 477 transition: all 0.2s; 478 478 flex-shrink: 0; 479 479 } ··· 505 505 } 506 506 507 507 .section-header h2 { 508 - font-size: 1.25rem; 508 + font-size: var(--text-2xl); 509 509 } 510 510 511 511 .count { 512 - font-size: 0.8rem; 512 + font-size: var(--text-sm); 513 513 padding: 0.15rem 0.45rem; 514 514 } 515 515 ··· 518 518 } 519 519 520 520 .empty-state h2 { 521 - font-size: 1.25rem; 521 + font-size: var(--text-2xl); 522 522 } 523 523 524 524 .header-actions { ··· 528 528 .queue-button, 529 529 .reorder-button { 530 530 padding: 0.6rem 1rem; 531 - font-size: 0.85rem; 531 + font-size: var(--text-sm); 532 532 } 533 533 534 534 .queue-button svg,
+16 -16
frontend/src/routes/liked/[handle]/+page.svelte
··· 126 126 .avatar { 127 127 width: 64px; 128 128 height: 64px; 129 - border-radius: 50%; 129 + border-radius: var(--radius-full); 130 130 object-fit: cover; 131 131 flex-shrink: 0; 132 132 } ··· 137 137 justify-content: center; 138 138 background: var(--bg-tertiary); 139 139 color: var(--text-secondary); 140 - font-size: 1.5rem; 140 + font-size: var(--text-3xl); 141 141 font-weight: 600; 142 142 } 143 143 ··· 149 149 } 150 150 151 151 .user-info h1 { 152 - font-size: 1.5rem; 152 + font-size: var(--text-3xl); 153 153 font-weight: 700; 154 154 color: var(--text-primary); 155 155 margin: 0; ··· 159 159 } 160 160 161 161 .handle { 162 - font-size: 0.9rem; 162 + font-size: var(--text-base); 163 163 color: var(--text-tertiary); 164 164 text-decoration: none; 165 165 transition: color 0.15s; ··· 189 189 } 190 190 191 191 .count { 192 - font-size: 0.95rem; 192 + font-size: var(--text-base); 193 193 font-weight: 500; 194 194 color: var(--text-secondary); 195 195 } ··· 208 208 background: transparent; 209 209 border: 1px solid var(--border-default); 210 210 color: var(--text-secondary); 211 - border-radius: 6px; 212 - font-size: 0.85rem; 211 + border-radius: var(--radius-base); 212 + font-size: var(--text-sm); 213 213 font-family: inherit; 214 214 cursor: pointer; 215 215 transition: all 0.15s; ··· 241 241 } 242 242 243 243 .empty-state h2 { 244 - font-size: 1.5rem; 244 + font-size: var(--text-3xl); 245 245 font-weight: 600; 246 246 color: var(--text-secondary); 247 247 margin: 0 0 0.5rem 0; 248 248 } 249 249 250 250 .empty-state p { 251 - font-size: 0.95rem; 251 + font-size: var(--text-base); 252 252 margin: 0; 253 253 } 254 254 ··· 275 275 } 276 276 277 277 .avatar-placeholder { 278 - font-size: 1.25rem; 278 + font-size: var(--text-2xl); 279 279 } 280 280 281 281 .user-info h1 { 282 - font-size: 1.25rem; 282 + font-size: var(--text-2xl); 283 283 } 284 284 285 285 .handle { 286 - font-size: 0.85rem; 286 + font-size: var(--text-sm); 287 287 } 288 288 289 289 .section-header h2 { 290 - font-size: 1.25rem; 290 + font-size: var(--text-2xl); 291 291 } 292 292 293 293 .count { 294 - font-size: 0.85rem; 294 + font-size: var(--text-sm); 295 295 } 296 296 297 297 .empty-state { ··· 299 299 } 300 300 301 301 .empty-state h2 { 302 - font-size: 1.25rem; 302 + font-size: var(--text-2xl); 303 303 } 304 304 305 305 .btn-action { 306 306 padding: 0.45rem 0.7rem; 307 - font-size: 0.8rem; 307 + font-size: var(--text-sm); 308 308 } 309 309 310 310 .btn-action svg {
+33 -9
frontend/src/routes/login/+page.svelte
··· 8 8 let showHandleInfo = $state(false); 9 9 let showPdsInfo = $state(false); 10 10 11 + /** 12 + * normalize user input to a valid identifier for OAuth 13 + * 14 + * accepts: 15 + * - handles: "user.bsky.social", "@user.bsky.social", "at://user.bsky.social" 16 + * - DIDs: "did:plc:abc123", "at://did:plc:abc123" 17 + */ 18 + function normalizeInput(input: string): string { 19 + let value = input.trim(); 20 + 21 + // strip at:// prefix (valid for both handles and DIDs per AT-URI spec) 22 + if (value.startsWith('at://')) { 23 + value = value.slice(5); 24 + } 25 + 26 + // strip @ prefix from handles 27 + if (value.startsWith('@')) { 28 + value = value.slice(1); 29 + } 30 + 31 + return value; 32 + } 33 + 11 34 function startOAuth(e: SubmitEvent) { 12 35 e.preventDefault(); 13 36 if (!handle.trim()) return; 14 37 loading = true; 15 - window.location.href = `${API_URL}/auth/start?handle=${encodeURIComponent(handle)}`; 38 + const normalized = normalizeInput(handle); 39 + window.location.href = `${API_URL}/auth/start?handle=${encodeURIComponent(normalized)}`; 16 40 } 17 41 18 42 function handleSelect(selected: string) { ··· 118 142 .login-card { 119 143 background: var(--bg-tertiary); 120 144 border: 1px solid var(--border-subtle); 121 - border-radius: 12px; 145 + border-radius: var(--radius-lg); 122 146 padding: 2.5rem; 123 147 max-width: 420px; 124 148 width: 100%; ··· 147 171 148 172 label { 149 173 color: var(--text-secondary); 150 - font-size: 0.9rem; 174 + font-size: var(--text-base); 151 175 } 152 176 153 177 button.primary { ··· 156 180 background: var(--accent); 157 181 color: white; 158 182 border: none; 159 - border-radius: 8px; 160 - font-size: 0.95rem; 183 + border-radius: var(--radius-md); 184 + font-size: var(--text-base); 161 185 font-weight: 500; 162 186 font-family: inherit; 163 187 cursor: pointer; ··· 189 213 border: none; 190 214 color: var(--text-secondary); 191 215 font-family: inherit; 192 - font-size: 0.9rem; 216 + font-size: var(--text-base); 193 217 cursor: pointer; 194 218 text-align: left; 195 219 } ··· 210 234 .faq-content { 211 235 padding: 0 0 1rem 0; 212 236 color: var(--text-tertiary); 213 - font-size: 0.85rem; 237 + font-size: var(--text-sm); 214 238 line-height: 1.6; 215 239 } 216 240 ··· 235 259 .faq-content code { 236 260 background: var(--bg-secondary); 237 261 padding: 0.15rem 0.4rem; 238 - border-radius: 4px; 262 + border-radius: var(--radius-sm); 239 263 font-size: 0.85em; 240 264 } 241 265 ··· 245 269 } 246 270 247 271 h1 { 248 - font-size: 1.5rem; 272 + font-size: var(--text-3xl); 249 273 } 250 274 } 251 275 </style>
+73 -51
frontend/src/routes/playlist/[id]/+page.svelte
··· 12 12 import { toast } from "$lib/toast.svelte"; 13 13 import { player } from "$lib/player.svelte"; 14 14 import { queue } from "$lib/queue.svelte"; 15 + import { playQueue } from "$lib/playback.svelte"; 15 16 import { fetchLikedTracks } from "$lib/tracks.svelte"; 16 17 import type { PageData } from "./$types"; 17 18 import type { PlaylistWithTracks, Track } from "$lib/types"; ··· 143 144 queue.playNow(track); 144 145 } 145 146 146 - function playNow() { 147 + async function playNow() { 147 148 if (tracks.length > 0) { 148 - queue.setQueue(tracks); 149 - queue.playNow(tracks[0]); 150 - toast.success(`playing ${playlist.name}`, 1800); 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 + } 151 154 } 152 155 } 153 156 ··· 604 607 605 608 // check if user owns this playlist 606 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); 607 619 </script> 608 620 609 621 <svelte:window on:keydown={handleKeydown} /> ··· 862 874 </div> 863 875 864 876 <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 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} 875 893 </button> 876 894 <button class="queue-button" onclick={addToQueue}> 877 895 <svg ··· 1338 1356 .playlist-art { 1339 1357 width: 200px; 1340 1358 height: 200px; 1341 - border-radius: 8px; 1359 + border-radius: var(--radius-md); 1342 1360 object-fit: cover; 1343 1361 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 1344 1362 } ··· 1346 1364 .playlist-art-placeholder { 1347 1365 width: 200px; 1348 1366 height: 200px; 1349 - border-radius: 8px; 1367 + border-radius: var(--radius-md); 1350 1368 background: var(--bg-tertiary); 1351 1369 border: 1px solid var(--border-subtle); 1352 1370 display: flex; ··· 1391 1409 opacity: 0; 1392 1410 transition: opacity 0.2s; 1393 1411 pointer-events: none; 1394 - border-radius: 8px; 1412 + border-radius: var(--radius-md); 1395 1413 font-family: inherit; 1396 1414 } 1397 1415 1398 1416 .art-edit-overlay span { 1399 1417 font-family: inherit; 1400 - font-size: 0.85rem; 1418 + font-size: var(--text-sm); 1401 1419 font-weight: 500; 1402 1420 } 1403 1421 ··· 1429 1447 1430 1448 .playlist-type { 1431 1449 text-transform: uppercase; 1432 - font-size: 0.75rem; 1450 + font-size: var(--text-xs); 1433 1451 font-weight: 600; 1434 1452 letter-spacing: 0.1em; 1435 1453 color: var(--text-tertiary); ··· 1470 1488 display: flex; 1471 1489 align-items: center; 1472 1490 gap: 0.75rem; 1473 - font-size: 0.95rem; 1491 + font-size: var(--text-base); 1474 1492 color: var(--text-secondary); 1475 1493 } 1476 1494 ··· 1487 1505 1488 1506 .meta-separator { 1489 1507 color: var(--text-muted); 1490 - font-size: 0.7rem; 1508 + font-size: var(--text-xs); 1491 1509 } 1492 1510 1493 1511 .show-on-profile-toggle { ··· 1496 1514 gap: 0.5rem; 1497 1515 margin-top: 0.75rem; 1498 1516 cursor: pointer; 1499 - font-size: 0.85rem; 1517 + font-size: var(--text-sm); 1500 1518 color: var(--text-secondary); 1501 1519 } 1502 1520 ··· 1523 1541 height: 32px; 1524 1542 background: var(--glass-btn-bg, rgba(18, 18, 18, 0.75)); 1525 1543 border: 1px solid var(--glass-btn-border, rgba(255, 255, 255, 0.1)); 1526 - border-radius: 6px; 1544 + border-radius: var(--radius-base); 1527 1545 color: var(--text-secondary); 1528 1546 cursor: pointer; 1529 1547 transition: all 0.15s; ··· 1557 1575 .play-button, 1558 1576 .queue-button { 1559 1577 padding: 0.75rem 1.5rem; 1560 - border-radius: 24px; 1578 + border-radius: var(--radius-2xl); 1561 1579 font-weight: 600; 1562 - font-size: 0.95rem; 1580 + font-size: var(--text-base); 1563 1581 font-family: inherit; 1564 1582 cursor: pointer; 1565 1583 transition: all 0.2s; ··· 1578 1596 transform: scale(1.05); 1579 1597 } 1580 1598 1599 + .play-button.is-playing { 1600 + animation: ethereal-glow 3s ease-in-out infinite; 1601 + } 1602 + 1581 1603 .queue-button { 1582 1604 background: var(--glass-btn-bg, transparent); 1583 1605 color: var(--text-primary); ··· 1612 1634 } 1613 1635 1614 1636 .section-heading { 1615 - font-size: 1.25rem; 1637 + font-size: var(--text-2xl); 1616 1638 font-weight: 600; 1617 1639 color: var(--text-primary); 1618 1640 margin-bottom: 1rem; ··· 1631 1653 display: flex; 1632 1654 align-items: center; 1633 1655 gap: 0.5rem; 1634 - border-radius: 8px; 1656 + border-radius: var(--radius-md); 1635 1657 transition: all 0.2s; 1636 1658 position: relative; 1637 1659 } ··· 1663 1685 color: var(--text-muted); 1664 1686 cursor: grab; 1665 1687 touch-action: none; 1666 - border-radius: 4px; 1688 + border-radius: var(--radius-sm); 1667 1689 transition: all 0.2s; 1668 1690 flex-shrink: 0; 1669 1691 } ··· 1696 1718 padding: 0.5rem; 1697 1719 background: transparent; 1698 1720 border: 1px solid var(--border-default); 1699 - border-radius: 4px; 1721 + border-radius: var(--radius-sm); 1700 1722 color: var(--text-muted); 1701 1723 cursor: pointer; 1702 1724 transition: all 0.2s; ··· 1729 1751 margin-top: 0.5rem; 1730 1752 background: transparent; 1731 1753 border: 1px dashed var(--border-default); 1732 - border-radius: 8px; 1754 + border-radius: var(--radius-md); 1733 1755 color: var(--text-tertiary); 1734 1756 font-family: inherit; 1735 - font-size: 0.9rem; 1757 + font-size: var(--text-base); 1736 1758 cursor: pointer; 1737 1759 transition: all 0.2s; 1738 1760 } ··· 1756 1778 .empty-icon { 1757 1779 width: 64px; 1758 1780 height: 64px; 1759 - border-radius: 16px; 1781 + border-radius: var(--radius-xl); 1760 1782 display: flex; 1761 1783 align-items: center; 1762 1784 justify-content: center; ··· 1766 1788 } 1767 1789 1768 1790 .empty-state p { 1769 - font-size: 1rem; 1791 + font-size: var(--text-lg); 1770 1792 font-weight: 500; 1771 1793 color: var(--text-secondary); 1772 1794 margin: 0 0 0.25rem 0; 1773 1795 } 1774 1796 1775 1797 .empty-state span { 1776 - font-size: 0.85rem; 1798 + font-size: var(--text-sm); 1777 1799 color: var(--text-muted); 1778 1800 margin-bottom: 1.5rem; 1779 1801 } ··· 1783 1805 background: var(--accent); 1784 1806 color: white; 1785 1807 border: none; 1786 - border-radius: 8px; 1808 + border-radius: var(--radius-md); 1787 1809 font-family: inherit; 1788 - font-size: 0.9rem; 1810 + font-size: var(--text-base); 1789 1811 font-weight: 500; 1790 1812 cursor: pointer; 1791 1813 transition: all 0.15s; ··· 1813 1835 .modal { 1814 1836 background: var(--bg-primary); 1815 1837 border: 1px solid var(--border-default); 1816 - border-radius: 16px; 1838 + border-radius: var(--radius-xl); 1817 1839 width: 100%; 1818 1840 max-width: 400px; 1819 1841 box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3); ··· 1835 1857 } 1836 1858 1837 1859 .modal-header h3 { 1838 - font-size: 1.1rem; 1860 + font-size: var(--text-xl); 1839 1861 font-weight: 600; 1840 1862 color: var(--text-primary); 1841 1863 margin: 0; ··· 1849 1871 height: 32px; 1850 1872 background: transparent; 1851 1873 border: none; 1852 - border-radius: 8px; 1874 + border-radius: var(--radius-md); 1853 1875 color: var(--text-secondary); 1854 1876 cursor: pointer; 1855 1877 transition: all 0.15s; ··· 1874 1896 background: transparent; 1875 1897 border: none; 1876 1898 font-family: inherit; 1877 - font-size: 1rem; 1899 + font-size: var(--text-lg); 1878 1900 color: var(--text-primary); 1879 1901 outline: none; 1880 1902 } ··· 1895 1917 padding: 2rem 1.5rem; 1896 1918 text-align: center; 1897 1919 color: var(--text-muted); 1898 - font-size: 0.9rem; 1920 + font-size: var(--text-base); 1899 1921 margin: 0; 1900 1922 } 1901 1923 ··· 1919 1941 .result-image-placeholder { 1920 1942 width: 40px; 1921 1943 height: 40px; 1922 - border-radius: 6px; 1944 + border-radius: var(--radius-base); 1923 1945 flex-shrink: 0; 1924 1946 } 1925 1947 ··· 1944 1966 } 1945 1967 1946 1968 .result-title { 1947 - font-size: 0.9rem; 1969 + font-size: var(--text-base); 1948 1970 font-weight: 500; 1949 1971 color: var(--text-primary); 1950 1972 white-space: nowrap; ··· 1953 1975 } 1954 1976 1955 1977 .result-artist { 1956 - font-size: 0.8rem; 1978 + font-size: var(--text-sm); 1957 1979 color: var(--text-tertiary); 1958 1980 white-space: nowrap; 1959 1981 overflow: hidden; ··· 1968 1990 height: 36px; 1969 1991 background: var(--accent); 1970 1992 border: none; 1971 - border-radius: 8px; 1993 + border-radius: var(--radius-md); 1972 1994 color: white; 1973 1995 cursor: pointer; 1974 1996 transition: all 0.15s; ··· 1991 2013 .modal-body p { 1992 2014 margin: 0; 1993 2015 color: var(--text-secondary); 1994 - font-size: 0.95rem; 2016 + font-size: var(--text-base); 1995 2017 line-height: 1.5; 1996 2018 } 1997 2019 ··· 2005 2027 .cancel-btn, 2006 2028 .confirm-btn { 2007 2029 padding: 0.625rem 1.25rem; 2008 - border-radius: 8px; 2030 + border-radius: var(--radius-md); 2009 2031 font-family: inherit; 2010 - font-size: 0.9rem; 2032 + font-size: var(--text-base); 2011 2033 font-weight: 500; 2012 2034 cursor: pointer; 2013 2035 transition: all 0.15s; ··· 2050 2072 height: 16px; 2051 2073 border: 2px solid currentColor; 2052 2074 border-top-color: transparent; 2053 - border-radius: 50%; 2075 + border-radius: var(--radius-full); 2054 2076 animation: spin 0.6s linear infinite; 2055 2077 } 2056 2078 ··· 2096 2118 } 2097 2119 2098 2120 .playlist-meta { 2099 - font-size: 0.85rem; 2121 + font-size: var(--text-sm); 2100 2122 } 2101 2123 2102 2124 .playlist-actions { ··· 2139 2161 } 2140 2162 2141 2163 .playlist-meta { 2142 - font-size: 0.8rem; 2164 + font-size: var(--text-sm); 2143 2165 flex-wrap: wrap; 2144 2166 } 2145 2167 }
+181 -122
frontend/src/routes/portal/+page.svelte
··· 28 28 let editFeaturedArtists = $state<FeaturedArtist[]>([]); 29 29 let editTags = $state<string[]>([]); 30 30 let editImageFile = $state<File | null>(null); 31 + let editSupportGate = $state(false); 31 32 let hasUnresolvedEditFeaturesInput = $state(false); 32 33 33 34 // profile editing state ··· 105 106 } 106 107 107 108 try { 108 - await loadMyTracks(); 109 - await loadArtistProfile(); 110 - await loadMyAlbums(); 111 - await loadMyPlaylists(); 109 + await Promise.all([ 110 + loadMyTracks(), 111 + loadArtistProfile(), 112 + loadMyAlbums(), 113 + loadMyPlaylists() 114 + ]); 112 115 } catch (_e) { 113 116 console.error('error loading portal data:', _e); 114 117 error = 'failed to load portal data'; ··· 315 318 editAlbum = track.album?.title || ''; 316 319 editFeaturedArtists = track.features || []; 317 320 editTags = track.tags || []; 321 + editSupportGate = track.support_gate !== null && track.support_gate !== undefined; 318 322 } 319 323 320 324 function cancelEdit() { ··· 324 328 editFeaturedArtists = []; 325 329 editTags = []; 326 330 editImageFile = null; 331 + editSupportGate = false; 327 332 } 328 333 329 334 ··· 340 345 } 341 346 // always send tags (empty array clears them) 342 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 + } 343 354 if (editImageFile) { 344 355 formData.append('image', editImageFile); 345 356 } ··· 740 751 <p class="file-info">{editImageFile.name} (will replace current)</p> 741 752 {/if} 742 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} 743 771 </div> 744 772 <div class="edit-actions"> 745 773 <button ··· 784 812 <div class="track-info"> 785 813 <div class="track-title"> 786 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} 787 822 {#if track.copyright_flagged} 788 823 {@const matchText = track.copyright_match ? `potential copyright violation: ${track.copyright_match}` : 'potential copyright violation'} 789 824 {#if track.atproto_record_url} ··· 1109 1144 .view-profile-link { 1110 1145 color: var(--text-secondary); 1111 1146 text-decoration: none; 1112 - font-size: 0.8rem; 1147 + font-size: var(--text-sm); 1113 1148 padding: 0.35rem 0.6rem; 1114 1149 background: var(--bg-tertiary); 1115 - border-radius: 5px; 1150 + border-radius: var(--radius-sm); 1116 1151 border: 1px solid var(--border-default); 1117 1152 transition: all 0.15s; 1118 1153 white-space: nowrap; ··· 1127 1162 .settings-link { 1128 1163 color: var(--text-secondary); 1129 1164 text-decoration: none; 1130 - font-size: 0.8rem; 1165 + font-size: var(--text-sm); 1131 1166 padding: 0.35rem 0.6rem; 1132 1167 background: var(--bg-tertiary); 1133 - border-radius: 5px; 1168 + border-radius: var(--radius-sm); 1134 1169 border: 1px solid var(--border-default); 1135 1170 transition: all 0.15s; 1136 1171 white-space: nowrap; ··· 1150 1185 padding: 1rem 1.25rem; 1151 1186 background: var(--bg-tertiary); 1152 1187 border: 1px solid var(--border-default); 1153 - border-radius: 8px; 1188 + border-radius: var(--radius-md); 1154 1189 text-decoration: none; 1155 1190 color: var(--text-primary); 1156 1191 transition: all 0.15s; ··· 1173 1208 width: 44px; 1174 1209 height: 44px; 1175 1210 background: color-mix(in srgb, var(--accent) 15%, transparent); 1176 - border-radius: 10px; 1211 + border-radius: var(--radius-md); 1177 1212 color: var(--accent); 1178 1213 flex-shrink: 0; 1179 1214 } ··· 1186 1221 .upload-card-title { 1187 1222 display: block; 1188 1223 font-weight: 600; 1189 - font-size: 0.95rem; 1224 + font-size: var(--text-base); 1190 1225 color: var(--text-primary); 1191 1226 } 1192 1227 1193 1228 .upload-card-subtitle { 1194 1229 display: block; 1195 - font-size: 0.8rem; 1230 + font-size: var(--text-sm); 1196 1231 color: var(--text-tertiary); 1197 1232 } 1198 1233 ··· 1210 1245 form { 1211 1246 background: var(--bg-tertiary); 1212 1247 padding: 1.25rem; 1213 - border-radius: 8px; 1248 + border-radius: var(--radius-md); 1214 1249 border: 1px solid var(--border-subtle); 1215 1250 } 1216 1251 ··· 1226 1261 display: block; 1227 1262 color: var(--text-secondary); 1228 1263 margin-bottom: 0.4rem; 1229 - font-size: 0.85rem; 1264 + font-size: var(--text-sm); 1230 1265 } 1231 1266 1232 - input[type='text'] { 1267 + input[type='text'], 1268 + input[type='url'], 1269 + textarea { 1233 1270 width: 100%; 1234 1271 padding: 0.6rem 0.75rem; 1235 1272 background: var(--bg-primary); 1236 1273 border: 1px solid var(--border-default); 1237 - border-radius: 4px; 1274 + border-radius: var(--radius-sm); 1238 1275 color: var(--text-primary); 1239 - font-size: 0.95rem; 1276 + font-size: var(--text-base); 1240 1277 font-family: inherit; 1241 1278 transition: all 0.15s; 1242 1279 } 1243 1280 1244 - input[type='text']:focus { 1281 + input[type='text']:focus, 1282 + input[type='url']:focus, 1283 + textarea:focus { 1245 1284 outline: none; 1246 1285 border-color: var(--accent); 1247 1286 } 1248 1287 1249 - input[type='text']:disabled { 1288 + input[type='text']:disabled, 1289 + input[type='url']:disabled, 1290 + textarea:disabled { 1250 1291 opacity: 0.5; 1251 1292 cursor: not-allowed; 1252 1293 } 1253 1294 1254 1295 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 1296 resize: vertical; 1265 1297 min-height: 80px; 1266 1298 } 1267 1299 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 1300 .hint { 1279 1301 margin-top: 0.35rem; 1280 - font-size: 0.75rem; 1302 + font-size: var(--text-xs); 1281 1303 color: var(--text-muted); 1282 1304 } 1283 1305 ··· 1295 1317 display: block; 1296 1318 color: var(--text-secondary); 1297 1319 margin-bottom: 0.6rem; 1298 - font-size: 0.85rem; 1320 + font-size: var(--text-sm); 1299 1321 } 1300 1322 1301 1323 .support-options { ··· 1312 1334 padding: 0.6rem 0.75rem; 1313 1335 background: var(--bg-primary); 1314 1336 border: 1px solid var(--border-default); 1315 - border-radius: 6px; 1337 + border-radius: var(--radius-base); 1316 1338 cursor: pointer; 1317 1339 transition: all 0.15s; 1318 1340 margin-bottom: 0; ··· 1335 1357 } 1336 1358 1337 1359 .support-option span { 1338 - font-size: 0.9rem; 1360 + font-size: var(--text-base); 1339 1361 color: var(--text-primary); 1340 1362 } 1341 1363 1342 1364 .support-status { 1343 1365 margin-left: auto; 1344 - font-size: 0.75rem; 1366 + font-size: var(--text-xs); 1345 1367 color: var(--text-tertiary); 1346 1368 } 1347 1369 1348 1370 .support-setup-link, 1349 1371 .support-status-link { 1350 1372 margin-left: auto; 1351 - font-size: 0.75rem; 1373 + font-size: var(--text-xs); 1352 1374 text-decoration: none; 1353 1375 } 1354 1376 ··· 1379 1401 padding: 0.6rem 0.75rem; 1380 1402 background: var(--bg-primary); 1381 1403 border: 1px solid var(--border-default); 1382 - border-radius: 4px; 1404 + border-radius: var(--radius-sm); 1383 1405 color: var(--text-primary); 1384 - font-size: 0.95rem; 1406 + font-size: var(--text-base); 1385 1407 font-family: inherit; 1386 1408 transition: all 0.15s; 1387 1409 margin-bottom: 0.5rem; ··· 1407 1429 .avatar-preview img { 1408 1430 width: 64px; 1409 1431 height: 64px; 1410 - border-radius: 50%; 1432 + border-radius: var(--radius-full); 1411 1433 object-fit: cover; 1412 1434 border: 2px solid var(--border-default); 1413 1435 } ··· 1417 1439 padding: 0.75rem; 1418 1440 background: var(--bg-primary); 1419 1441 border: 1px solid var(--border-default); 1420 - border-radius: 4px; 1442 + border-radius: var(--radius-sm); 1421 1443 color: var(--text-primary); 1422 - font-size: 0.9rem; 1444 + font-size: var(--text-base); 1423 1445 font-family: inherit; 1424 1446 cursor: pointer; 1425 1447 } ··· 1431 1453 1432 1454 .file-info { 1433 1455 margin-top: 0.5rem; 1434 - font-size: 0.85rem; 1456 + font-size: var(--text-sm); 1435 1457 color: var(--text-muted); 1436 1458 } 1437 1459 ··· 1441 1463 background: var(--accent); 1442 1464 color: var(--text-primary); 1443 1465 border: none; 1444 - border-radius: 4px; 1445 - font-size: 1rem; 1466 + border-radius: var(--radius-sm); 1467 + font-size: var(--text-lg); 1446 1468 font-weight: 600; 1447 1469 font-family: inherit; 1448 1470 cursor: pointer; ··· 1479 1501 padding: 2rem; 1480 1502 text-align: center; 1481 1503 background: var(--bg-tertiary); 1482 - border-radius: 8px; 1504 + border-radius: var(--radius-md); 1483 1505 border: 1px solid var(--border-subtle); 1484 1506 } 1485 1507 ··· 1496 1518 gap: 1rem; 1497 1519 background: var(--bg-tertiary); 1498 1520 border: 1px solid var(--border-subtle); 1499 - border-radius: 6px; 1521 + border-radius: var(--radius-base); 1500 1522 padding: 1rem; 1501 1523 transition: all 0.2s; 1502 1524 } ··· 1531 1553 .track-artwork { 1532 1554 width: 48px; 1533 1555 height: 48px; 1534 - border-radius: 4px; 1556 + border-radius: var(--radius-sm); 1535 1557 overflow: hidden; 1536 1558 background: var(--bg-primary); 1537 1559 border: 1px solid var(--border-subtle); ··· 1553 1575 } 1554 1576 1555 1577 .track-view-link { 1556 - font-size: 0.7rem; 1578 + font-size: var(--text-xs); 1557 1579 color: var(--text-muted); 1558 1580 text-decoration: none; 1559 1581 transition: color 0.15s; ··· 1600 1622 } 1601 1623 1602 1624 .edit-label { 1603 - font-size: 0.85rem; 1625 + font-size: var(--text-sm); 1604 1626 color: var(--text-secondary); 1605 1627 } 1606 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 + 1607 1659 .track-title { 1608 1660 font-weight: 600; 1609 - font-size: 1rem; 1661 + font-size: var(--text-lg); 1610 1662 margin-bottom: 0.25rem; 1611 1663 color: var(--text-primary); 1612 1664 display: flex; ··· 1614 1666 gap: 0.5rem; 1615 1667 } 1616 1668 1669 + .support-gate-badge { 1670 + display: inline-flex; 1671 + align-items: center; 1672 + color: var(--accent); 1673 + flex-shrink: 0; 1674 + } 1675 + 1617 1676 .copyright-flag { 1618 1677 display: inline-flex; 1619 1678 align-items: center; ··· 1635 1694 } 1636 1695 1637 1696 .track-meta { 1638 - font-size: 0.9rem; 1697 + font-size: var(--text-base); 1639 1698 color: var(--text-secondary); 1640 1699 margin-bottom: 0.25rem; 1641 1700 display: flex; ··· 1703 1762 padding: 0.1rem 0.4rem; 1704 1763 background: color-mix(in srgb, var(--accent) 15%, transparent); 1705 1764 color: var(--accent-hover); 1706 - border-radius: 3px; 1707 - font-size: 0.8rem; 1765 + border-radius: var(--radius-sm); 1766 + font-size: var(--text-sm); 1708 1767 font-weight: 500; 1709 1768 text-decoration: none; 1710 1769 transition: all 0.15s; ··· 1716 1775 } 1717 1776 1718 1777 .track-date { 1719 - font-size: 0.85rem; 1778 + font-size: var(--text-sm); 1720 1779 color: var(--text-muted); 1721 1780 } 1722 1781 ··· 1737 1796 padding: 0; 1738 1797 background: transparent; 1739 1798 border: 1px solid var(--border-default); 1740 - border-radius: 6px; 1799 + border-radius: var(--radius-base); 1741 1800 color: var(--text-tertiary); 1742 1801 cursor: pointer; 1743 1802 transition: all 0.15s; ··· 1782 1841 padding: 0.5rem; 1783 1842 background: var(--bg-primary); 1784 1843 border: 1px solid var(--border-default); 1785 - border-radius: 4px; 1844 + border-radius: var(--radius-sm); 1786 1845 color: var(--text-primary); 1787 - font-size: 0.9rem; 1846 + font-size: var(--text-base); 1788 1847 font-family: inherit; 1789 1848 } 1790 1849 ··· 1795 1854 padding: 0.5rem; 1796 1855 background: var(--bg-primary); 1797 1856 border: 1px solid var(--border-default); 1798 - border-radius: 4px; 1857 + border-radius: var(--radius-sm); 1799 1858 margin-bottom: 0.5rem; 1800 1859 } 1801 1860 1802 1861 .current-image-preview img { 1803 1862 width: 48px; 1804 1863 height: 48px; 1805 - border-radius: 4px; 1864 + border-radius: var(--radius-sm); 1806 1865 object-fit: cover; 1807 1866 } 1808 1867 1809 1868 .current-image-label { 1810 1869 color: var(--text-tertiary); 1811 - font-size: 0.85rem; 1870 + font-size: var(--text-sm); 1812 1871 } 1813 1872 1814 1873 .edit-input:focus { ··· 1840 1899 .album-card { 1841 1900 background: var(--bg-tertiary); 1842 1901 border: 1px solid var(--border-subtle); 1843 - border-radius: 8px; 1902 + border-radius: var(--radius-md); 1844 1903 padding: 1rem; 1845 1904 transition: all 0.2s; 1846 1905 display: flex; ··· 1858 1917 .album-cover { 1859 1918 width: 100%; 1860 1919 aspect-ratio: 1; 1861 - border-radius: 6px; 1920 + border-radius: var(--radius-base); 1862 1921 object-fit: cover; 1863 1922 } 1864 1923 1865 1924 .album-cover-placeholder { 1866 1925 width: 100%; 1867 1926 aspect-ratio: 1; 1868 - border-radius: 6px; 1927 + border-radius: var(--radius-base); 1869 1928 background: linear-gradient(135deg, rgba(var(--accent-rgb, 139, 92, 246), 0.15), rgba(var(--accent-rgb, 139, 92, 246), 0.05)); 1870 1929 display: flex; 1871 1930 align-items: center; ··· 1879 1938 } 1880 1939 1881 1940 .album-title { 1882 - font-size: 1rem; 1941 + font-size: var(--text-lg); 1883 1942 font-weight: 600; 1884 1943 color: var(--text-primary); 1885 1944 margin: 0 0 0.25rem 0; ··· 1889 1948 } 1890 1949 1891 1950 .album-stats { 1892 - font-size: 0.85rem; 1951 + font-size: var(--text-sm); 1893 1952 color: var(--text-tertiary); 1894 1953 margin: 0; 1895 1954 } ··· 1907 1966 .view-playlists-link { 1908 1967 color: var(--text-secondary); 1909 1968 text-decoration: none; 1910 - font-size: 0.8rem; 1969 + font-size: var(--text-sm); 1911 1970 padding: 0.35rem 0.6rem; 1912 1971 background: var(--bg-tertiary); 1913 - border-radius: 5px; 1972 + border-radius: var(--radius-sm); 1914 1973 border: 1px solid var(--border-default); 1915 1974 transition: all 0.15s; 1916 1975 white-space: nowrap; ··· 1931 1990 .playlist-card { 1932 1991 background: var(--bg-tertiary); 1933 1992 border: 1px solid var(--border-subtle); 1934 - border-radius: 8px; 1993 + border-radius: var(--radius-md); 1935 1994 padding: 1rem; 1936 1995 transition: all 0.2s; 1937 1996 display: flex; ··· 1949 2008 .playlist-cover { 1950 2009 width: 100%; 1951 2010 aspect-ratio: 1; 1952 - border-radius: 6px; 2011 + border-radius: var(--radius-base); 1953 2012 object-fit: cover; 1954 2013 } 1955 2014 1956 2015 .playlist-cover-placeholder { 1957 2016 width: 100%; 1958 2017 aspect-ratio: 1; 1959 - border-radius: 6px; 2018 + border-radius: var(--radius-base); 1960 2019 background: linear-gradient(135deg, rgba(var(--accent-rgb, 139, 92, 246), 0.15), rgba(var(--accent-rgb, 139, 92, 246), 0.05)); 1961 2020 display: flex; 1962 2021 align-items: center; ··· 1970 2029 } 1971 2030 1972 2031 .playlist-title { 1973 - font-size: 1rem; 2032 + font-size: var(--text-lg); 1974 2033 font-weight: 600; 1975 2034 color: var(--text-primary); 1976 2035 margin: 0 0 0.25rem 0; ··· 1980 2039 } 1981 2040 1982 2041 .playlist-stats { 1983 - font-size: 0.85rem; 2042 + font-size: var(--text-sm); 1984 2043 color: var(--text-tertiary); 1985 2044 margin: 0; 1986 2045 } ··· 1999 2058 padding: 1rem 1.25rem; 2000 2059 background: var(--bg-tertiary); 2001 2060 border: 1px solid var(--border-subtle); 2002 - border-radius: 8px; 2061 + border-radius: var(--radius-md); 2003 2062 display: flex; 2004 2063 justify-content: space-between; 2005 2064 align-items: center; ··· 2017 2076 } 2018 2077 2019 2078 .control-info h3 { 2020 - font-size: 0.9rem; 2079 + font-size: var(--text-base); 2021 2080 font-weight: 600; 2022 2081 margin: 0 0 0.15rem 0; 2023 2082 color: var(--text-primary); 2024 2083 } 2025 2084 2026 2085 .control-description { 2027 - font-size: 0.75rem; 2086 + font-size: var(--text-xs); 2028 2087 color: var(--text-tertiary); 2029 2088 margin: 0; 2030 2089 line-height: 1.4; ··· 2035 2094 background: var(--accent); 2036 2095 color: var(--text-primary); 2037 2096 border: none; 2038 - border-radius: 6px; 2039 - font-size: 0.9rem; 2097 + border-radius: var(--radius-base); 2098 + font-size: var(--text-base); 2040 2099 font-weight: 600; 2041 2100 cursor: pointer; 2042 2101 transition: all 0.2s; ··· 2080 2139 background: transparent; 2081 2140 color: var(--error); 2082 2141 border: 1px solid var(--error); 2083 - border-radius: 6px; 2142 + border-radius: var(--radius-base); 2084 2143 font-family: inherit; 2085 - font-size: 0.9rem; 2144 + font-size: var(--text-base); 2086 2145 font-weight: 600; 2087 2146 cursor: pointer; 2088 2147 transition: all 0.2s; ··· 2098 2157 padding: 1rem; 2099 2158 background: var(--bg-primary); 2100 2159 border: 1px solid var(--border-default); 2101 - border-radius: 8px; 2160 + border-radius: var(--radius-md); 2102 2161 } 2103 2162 2104 2163 .delete-warning { 2105 2164 margin: 0 0 1rem; 2106 2165 color: var(--error); 2107 - font-size: 0.9rem; 2166 + font-size: var(--text-base); 2108 2167 line-height: 1.5; 2109 2168 } 2110 2169 ··· 2112 2171 margin-bottom: 1rem; 2113 2172 padding: 0.75rem; 2114 2173 background: var(--bg-tertiary); 2115 - border-radius: 6px; 2174 + border-radius: var(--radius-base); 2116 2175 } 2117 2176 2118 2177 .atproto-option { 2119 2178 display: flex; 2120 2179 align-items: center; 2121 2180 gap: 0.5rem; 2122 - font-size: 0.9rem; 2181 + font-size: var(--text-base); 2123 2182 color: var(--text-primary); 2124 2183 cursor: pointer; 2125 2184 } ··· 2132 2191 2133 2192 .atproto-note { 2134 2193 margin: 0.5rem 0 0; 2135 - font-size: 0.8rem; 2194 + font-size: var(--text-sm); 2136 2195 color: var(--text-tertiary); 2137 2196 } 2138 2197 ··· 2149 2208 margin: 0.5rem 0 0; 2150 2209 padding: 0.5rem; 2151 2210 background: color-mix(in srgb, var(--warning) 10%, transparent); 2152 - border-radius: 4px; 2153 - font-size: 0.8rem; 2211 + border-radius: var(--radius-sm); 2212 + font-size: var(--text-sm); 2154 2213 color: var(--warning); 2155 2214 } 2156 2215 2157 2216 .confirm-prompt { 2158 2217 margin: 0 0 0.5rem; 2159 - font-size: 0.9rem; 2218 + font-size: var(--text-base); 2160 2219 color: var(--text-secondary); 2161 2220 } 2162 2221 ··· 2165 2224 padding: 0.6rem 0.75rem; 2166 2225 background: var(--bg-tertiary); 2167 2226 border: 1px solid var(--border-default); 2168 - border-radius: 6px; 2227 + border-radius: var(--radius-base); 2169 2228 color: var(--text-primary); 2170 - font-size: 0.9rem; 2229 + font-size: var(--text-base); 2171 2230 font-family: inherit; 2172 2231 margin-bottom: 1rem; 2173 2232 } ··· 2187 2246 padding: 0.6rem; 2188 2247 background: transparent; 2189 2248 border: 1px solid var(--border-default); 2190 - border-radius: 6px; 2249 + border-radius: var(--radius-base); 2191 2250 color: var(--text-secondary); 2192 2251 font-family: inherit; 2193 - font-size: 0.9rem; 2252 + font-size: var(--text-base); 2194 2253 cursor: pointer; 2195 2254 transition: all 0.15s; 2196 2255 } ··· 2209 2268 padding: 0.6rem; 2210 2269 background: var(--error); 2211 2270 border: none; 2212 - border-radius: 6px; 2271 + border-radius: var(--radius-base); 2213 2272 color: white; 2214 2273 font-family: inherit; 2215 - font-size: 0.9rem; 2274 + font-size: var(--text-base); 2216 2275 font-weight: 600; 2217 2276 cursor: pointer; 2218 2277 transition: all 0.15s; ··· 2238 2297 } 2239 2298 2240 2299 .portal-header h2 { 2241 - font-size: 1.25rem; 2300 + font-size: var(--text-2xl); 2242 2301 } 2243 2302 2244 2303 .profile-section h2, ··· 2246 2305 .albums-section h2, 2247 2306 .playlists-section h2, 2248 2307 .data-section h2 { 2249 - font-size: 1.1rem; 2308 + font-size: var(--text-xl); 2250 2309 } 2251 2310 2252 2311 .section-header { ··· 2254 2313 } 2255 2314 2256 2315 .view-profile-link { 2257 - font-size: 0.75rem; 2316 + font-size: var(--text-xs); 2258 2317 padding: 0.3rem 0.5rem; 2259 2318 } 2260 2319 ··· 2267 2326 } 2268 2327 2269 2328 label { 2270 - font-size: 0.8rem; 2329 + font-size: var(--text-sm); 2271 2330 margin-bottom: 0.3rem; 2272 2331 } 2273 2332 ··· 2275 2334 input[type='url'], 2276 2335 textarea { 2277 2336 padding: 0.5rem 0.6rem; 2278 - font-size: 0.9rem; 2337 + font-size: var(--text-base); 2279 2338 } 2280 2339 2281 2340 textarea { ··· 2283 2342 } 2284 2343 2285 2344 .hint { 2286 - font-size: 0.7rem; 2345 + font-size: var(--text-xs); 2287 2346 } 2288 2347 2289 2348 .avatar-preview img { ··· 2293 2352 2294 2353 button[type="submit"] { 2295 2354 padding: 0.6rem; 2296 - font-size: 0.9rem; 2355 + font-size: var(--text-base); 2297 2356 } 2298 2357 2299 2358 /* upload card mobile */ ··· 2313 2372 } 2314 2373 2315 2374 .upload-card-title { 2316 - font-size: 0.9rem; 2375 + font-size: var(--text-base); 2317 2376 } 2318 2377 2319 2378 .upload-card-subtitle { 2320 - font-size: 0.75rem; 2379 + font-size: var(--text-xs); 2321 2380 } 2322 2381 2323 2382 /* tracks mobile */ ··· 2351 2410 } 2352 2411 2353 2412 .track-title { 2354 - font-size: 0.9rem; 2413 + font-size: var(--text-base); 2355 2414 } 2356 2415 2357 2416 .track-meta { 2358 - font-size: 0.8rem; 2417 + font-size: var(--text-sm); 2359 2418 } 2360 2419 2361 2420 .track-date { 2362 - font-size: 0.75rem; 2421 + font-size: var(--text-xs); 2363 2422 } 2364 2423 2365 2424 .track-actions { ··· 2387 2446 } 2388 2447 2389 2448 .edit-label { 2390 - font-size: 0.8rem; 2449 + font-size: var(--text-sm); 2391 2450 } 2392 2451 2393 2452 .edit-input { 2394 2453 padding: 0.45rem 0.5rem; 2395 - font-size: 0.85rem; 2454 + font-size: var(--text-sm); 2396 2455 } 2397 2456 2398 2457 .edit-actions { ··· 2406 2465 } 2407 2466 2408 2467 .control-info h3 { 2409 - font-size: 0.85rem; 2468 + font-size: var(--text-sm); 2410 2469 } 2411 2470 2412 2471 .control-description { 2413 - font-size: 0.7rem; 2472 + font-size: var(--text-xs); 2414 2473 } 2415 2474 2416 2475 .export-btn { 2417 2476 padding: 0.5rem 0.85rem; 2418 - font-size: 0.8rem; 2477 + font-size: var(--text-sm); 2419 2478 } 2420 2479 2421 2480 /* albums mobile */ ··· 2430 2489 } 2431 2490 2432 2491 .album-title { 2433 - font-size: 0.85rem; 2492 + font-size: var(--text-sm); 2434 2493 } 2435 2494 2436 2495 /* playlists mobile */ ··· 2445 2504 } 2446 2505 2447 2506 .playlist-title { 2448 - font-size: 0.85rem; 2507 + font-size: var(--text-sm); 2449 2508 } 2450 2509 2451 2510 .playlist-stats { 2452 - font-size: 0.75rem; 2511 + font-size: var(--text-xs); 2453 2512 } 2454 2513 2455 2514 .view-playlists-link { 2456 - font-size: 0.75rem; 2515 + font-size: var(--text-xs); 2457 2516 padding: 0.3rem 0.5rem; 2458 2517 } 2459 2518 }
+8 -8
frontend/src/routes/profile/setup/+page.svelte
··· 222 222 223 223 .error { 224 224 padding: 1rem; 225 - border-radius: 4px; 225 + border-radius: var(--radius-sm); 226 226 margin-bottom: 1.5rem; 227 227 background: color-mix(in srgb, var(--error) 10%, transparent); 228 228 border: 1px solid color-mix(in srgb, var(--error) 30%, transparent); ··· 232 232 form { 233 233 background: var(--bg-tertiary); 234 234 padding: 2rem; 235 - border-radius: 8px; 235 + border-radius: var(--radius-md); 236 236 border: 1px solid var(--border-subtle); 237 237 } 238 238 ··· 248 248 display: block; 249 249 color: var(--text-secondary); 250 250 margin-bottom: 0.5rem; 251 - font-size: 0.9rem; 251 + font-size: var(--text-base); 252 252 font-weight: 500; 253 253 } 254 254 ··· 259 259 padding: 0.75rem; 260 260 background: var(--bg-primary); 261 261 border: 1px solid var(--border-default); 262 - border-radius: 4px; 262 + border-radius: var(--radius-sm); 263 263 color: var(--text-primary); 264 - font-size: 1rem; 264 + font-size: var(--text-lg); 265 265 font-family: inherit; 266 266 transition: all 0.2s; 267 267 } ··· 287 287 288 288 .hint { 289 289 margin-top: 0.5rem; 290 - font-size: 0.85rem; 290 + font-size: var(--text-sm); 291 291 color: var(--text-muted); 292 292 } 293 293 ··· 297 297 background: var(--accent); 298 298 color: white; 299 299 border: none; 300 - border-radius: 4px; 301 - font-size: 1rem; 300 + border-radius: var(--radius-sm); 301 + font-size: var(--text-lg); 302 302 font-weight: 600; 303 303 cursor: pointer; 304 304 transition: all 0.2s;
+46 -46
frontend/src/routes/settings/+page.svelte
··· 774 774 .token-overlay-content { 775 775 background: var(--bg-secondary); 776 776 border: 1px solid var(--border-default); 777 - border-radius: 16px; 777 + border-radius: var(--radius-xl); 778 778 padding: 2rem; 779 779 max-width: 500px; 780 780 width: 100%; ··· 788 788 789 789 .token-overlay-content h2 { 790 790 margin: 0 0 0.75rem; 791 - font-size: 1.5rem; 791 + font-size: var(--text-3xl); 792 792 color: var(--text-primary); 793 793 } 794 794 795 795 .token-overlay-warning { 796 796 color: var(--warning); 797 - font-size: 0.9rem; 797 + font-size: var(--text-base); 798 798 margin: 0 0 1.5rem; 799 799 line-height: 1.5; 800 800 } ··· 804 804 gap: 0.5rem; 805 805 background: var(--bg-primary); 806 806 border: 1px solid var(--border-default); 807 - border-radius: 8px; 807 + border-radius: var(--radius-md); 808 808 padding: 1rem; 809 809 margin-bottom: 1rem; 810 810 } 811 811 812 812 .token-overlay-display code { 813 813 flex: 1; 814 - font-size: 0.85rem; 814 + font-size: var(--text-sm); 815 815 word-break: break-all; 816 816 color: var(--accent); 817 817 text-align: left; ··· 822 822 padding: 0.5rem 1rem; 823 823 background: var(--accent); 824 824 border: none; 825 - border-radius: 6px; 825 + border-radius: var(--radius-base); 826 826 color: var(--text-primary); 827 827 font-family: inherit; 828 - font-size: 0.85rem; 828 + font-size: var(--text-sm); 829 829 font-weight: 600; 830 830 cursor: pointer; 831 831 white-space: nowrap; ··· 837 837 } 838 838 839 839 .token-overlay-hint { 840 - font-size: 0.8rem; 840 + font-size: var(--text-sm); 841 841 color: var(--text-tertiary); 842 842 margin: 0 0 1.5rem; 843 843 } ··· 855 855 padding: 0.75rem 2rem; 856 856 background: var(--bg-tertiary); 857 857 border: 1px solid var(--border-default); 858 - border-radius: 8px; 858 + border-radius: var(--radius-md); 859 859 color: var(--text-secondary); 860 860 font-family: inherit; 861 - font-size: 0.9rem; 861 + font-size: var(--text-base); 862 862 cursor: pointer; 863 863 transition: all 0.15s; 864 864 } ··· 901 901 .portal-link { 902 902 color: var(--text-secondary); 903 903 text-decoration: none; 904 - font-size: 0.85rem; 904 + font-size: var(--text-sm); 905 905 padding: 0.4rem 0.75rem; 906 906 background: var(--bg-tertiary); 907 - border-radius: 6px; 907 + border-radius: var(--radius-base); 908 908 border: 1px solid var(--border-default); 909 909 transition: all 0.15s; 910 910 } ··· 919 919 } 920 920 921 921 .settings-section h2 { 922 - font-size: 0.8rem; 922 + font-size: var(--text-sm); 923 923 text-transform: uppercase; 924 924 letter-spacing: 0.08em; 925 925 color: var(--text-tertiary); ··· 930 930 .settings-card { 931 931 background: var(--bg-tertiary); 932 932 border: 1px solid var(--border-subtle); 933 - border-radius: 10px; 933 + border-radius: var(--radius-md); 934 934 padding: 1rem 1.25rem; 935 935 } 936 936 ··· 957 957 958 958 .setting-info h3 { 959 959 margin: 0 0 0.25rem; 960 - font-size: 0.95rem; 960 + font-size: var(--text-base); 961 961 font-weight: 600; 962 962 color: var(--text-primary); 963 963 } 964 964 965 965 .setting-info p { 966 966 margin: 0; 967 - font-size: 0.8rem; 967 + font-size: var(--text-sm); 968 968 color: var(--text-tertiary); 969 969 line-height: 1.4; 970 970 } ··· 993 993 padding: 0.6rem 0.75rem; 994 994 background: var(--bg-primary); 995 995 border: 1px solid var(--border-default); 996 - border-radius: 8px; 996 + border-radius: var(--radius-md); 997 997 color: var(--text-secondary); 998 998 cursor: pointer; 999 999 transition: all 0.15s; ··· 1034 1034 width: 40px; 1035 1035 height: 40px; 1036 1036 border: 1px solid var(--border-default); 1037 - border-radius: 8px; 1037 + border-radius: var(--radius-md); 1038 1038 cursor: pointer; 1039 1039 background: transparent; 1040 1040 } ··· 1044 1044 } 1045 1045 1046 1046 .color-input::-webkit-color-swatch { 1047 - border-radius: 4px; 1047 + border-radius: var(--radius-sm); 1048 1048 border: none; 1049 1049 } 1050 1050 ··· 1056 1056 .preset-btn { 1057 1057 width: 32px; 1058 1058 height: 32px; 1059 - border-radius: 6px; 1059 + border-radius: var(--radius-base); 1060 1060 border: 2px solid transparent; 1061 1061 cursor: pointer; 1062 1062 transition: all 0.15s; ··· 1085 1085 padding: 0.5rem 0.75rem; 1086 1086 background: var(--bg-primary); 1087 1087 border: 1px solid var(--border-default); 1088 - border-radius: 6px; 1088 + border-radius: var(--radius-base); 1089 1089 color: var(--text-primary); 1090 - font-size: 0.85rem; 1090 + font-size: var(--text-sm); 1091 1091 font-family: inherit; 1092 1092 } 1093 1093 ··· 1104 1104 display: flex; 1105 1105 align-items: center; 1106 1106 gap: 0.4rem; 1107 - font-size: 0.8rem; 1107 + font-size: var(--text-sm); 1108 1108 color: var(--text-secondary); 1109 1109 cursor: pointer; 1110 1110 } ··· 1132 1132 width: 48px; 1133 1133 height: 28px; 1134 1134 background: var(--border-default); 1135 - border-radius: 999px; 1135 + border-radius: var(--radius-full); 1136 1136 position: relative; 1137 1137 cursor: pointer; 1138 1138 transition: background 0.2s; ··· 1145 1145 left: 4px; 1146 1146 width: 20px; 1147 1147 height: 20px; 1148 - border-radius: 50%; 1148 + border-radius: var(--radius-full); 1149 1149 background: var(--text-secondary); 1150 1150 transition: transform 0.2s, background 0.2s; 1151 1151 } ··· 1167 1167 padding: 0.75rem; 1168 1168 background: color-mix(in srgb, var(--warning) 10%, transparent); 1169 1169 border: 1px solid color-mix(in srgb, var(--warning) 30%, transparent); 1170 - border-radius: 6px; 1170 + border-radius: var(--radius-base); 1171 1171 margin-top: 0.75rem; 1172 - font-size: 0.8rem; 1172 + font-size: var(--text-sm); 1173 1173 color: var(--warning); 1174 1174 } 1175 1175 ··· 1191 1191 /* developer tokens */ 1192 1192 .loading-tokens { 1193 1193 color: var(--text-tertiary); 1194 - font-size: 0.85rem; 1194 + font-size: var(--text-sm); 1195 1195 } 1196 1196 1197 1197 .existing-tokens { ··· 1199 1199 } 1200 1200 1201 1201 .tokens-header { 1202 - font-size: 0.75rem; 1202 + font-size: var(--text-xs); 1203 1203 text-transform: uppercase; 1204 1204 letter-spacing: 0.05em; 1205 1205 color: var(--text-tertiary); ··· 1220 1220 padding: 0.75rem; 1221 1221 background: var(--bg-primary); 1222 1222 border: 1px solid var(--border-default); 1223 - border-radius: 6px; 1223 + border-radius: var(--radius-base); 1224 1224 } 1225 1225 1226 1226 .token-info { ··· 1233 1233 .token-name { 1234 1234 font-weight: 500; 1235 1235 color: var(--text-primary); 1236 - font-size: 0.9rem; 1236 + font-size: var(--text-base); 1237 1237 } 1238 1238 1239 1239 .token-meta { 1240 - font-size: 0.75rem; 1240 + font-size: var(--text-xs); 1241 1241 color: var(--text-tertiary); 1242 1242 } 1243 1243 ··· 1245 1245 padding: 0.4rem 0.75rem; 1246 1246 background: transparent; 1247 1247 border: 1px solid var(--border-emphasis); 1248 - border-radius: 4px; 1248 + border-radius: var(--radius-sm); 1249 1249 color: var(--text-secondary); 1250 1250 font-family: inherit; 1251 - font-size: 0.8rem; 1251 + font-size: var(--text-sm); 1252 1252 cursor: pointer; 1253 1253 transition: all 0.15s; 1254 1254 white-space: nowrap; ··· 1272 1272 padding: 0.75rem; 1273 1273 background: var(--bg-primary); 1274 1274 border: 1px solid var(--border-default); 1275 - border-radius: 6px; 1275 + border-radius: var(--radius-base); 1276 1276 } 1277 1277 1278 1278 .token-value { 1279 1279 flex: 1; 1280 - font-size: 0.8rem; 1280 + font-size: var(--text-sm); 1281 1281 word-break: break-all; 1282 1282 color: var(--accent); 1283 1283 } ··· 1287 1287 padding: 0.4rem 0.6rem; 1288 1288 background: var(--bg-tertiary); 1289 1289 border: 1px solid var(--border-default); 1290 - border-radius: 4px; 1290 + border-radius: var(--radius-sm); 1291 1291 color: var(--text-secondary); 1292 1292 font-family: inherit; 1293 - font-size: 0.8rem; 1293 + font-size: var(--text-sm); 1294 1294 cursor: pointer; 1295 1295 transition: all 0.15s; 1296 1296 } ··· 1303 1303 1304 1304 .token-warning { 1305 1305 margin-top: 0.5rem; 1306 - font-size: 0.8rem; 1306 + font-size: var(--text-sm); 1307 1307 color: var(--warning); 1308 1308 } 1309 1309 ··· 1320 1320 padding: 0.6rem 0.75rem; 1321 1321 background: var(--bg-primary); 1322 1322 border: 1px solid var(--border-default); 1323 - border-radius: 6px; 1323 + border-radius: var(--radius-base); 1324 1324 color: var(--text-primary); 1325 - font-size: 0.9rem; 1325 + font-size: var(--text-base); 1326 1326 font-family: inherit; 1327 1327 } 1328 1328 ··· 1335 1335 display: flex; 1336 1336 align-items: center; 1337 1337 gap: 0.5rem; 1338 - font-size: 0.85rem; 1338 + font-size: var(--text-sm); 1339 1339 color: var(--text-secondary); 1340 1340 } 1341 1341 ··· 1343 1343 padding: 0.5rem 0.75rem; 1344 1344 background: var(--bg-primary); 1345 1345 border: 1px solid var(--border-default); 1346 - border-radius: 6px; 1346 + border-radius: var(--radius-base); 1347 1347 color: var(--text-primary); 1348 - font-size: 0.85rem; 1348 + font-size: var(--text-sm); 1349 1349 font-family: inherit; 1350 1350 cursor: pointer; 1351 1351 } ··· 1359 1359 padding: 0.6rem 1rem; 1360 1360 background: var(--accent); 1361 1361 border: none; 1362 - border-radius: 6px; 1362 + border-radius: var(--radius-base); 1363 1363 color: var(--text-primary); 1364 1364 font-family: inherit; 1365 - font-size: 0.9rem; 1365 + font-size: var(--text-base); 1366 1366 font-weight: 600; 1367 1367 cursor: pointer; 1368 1368 transition: all 0.15s;
+10 -10
frontend/src/routes/tag/[name]/+page.svelte
··· 197 197 } 198 198 199 199 .subtitle { 200 - font-size: 0.95rem; 200 + font-size: var(--text-base); 201 201 color: var(--text-tertiary); 202 202 margin: 0; 203 203 text-shadow: var(--text-shadow, none); ··· 211 211 background: var(--glass-btn-bg, transparent); 212 212 border: 1px solid var(--glass-btn-border, var(--accent)); 213 213 color: var(--accent); 214 - border-radius: 6px; 215 - font-size: 0.9rem; 214 + border-radius: var(--radius-base); 215 + font-size: var(--text-base); 216 216 font-family: inherit; 217 217 cursor: pointer; 218 218 transition: all 0.2s; ··· 240 240 } 241 241 242 242 .empty-state h2 { 243 - font-size: 1.5rem; 243 + font-size: var(--text-3xl); 244 244 font-weight: 600; 245 245 color: var(--text-secondary); 246 246 margin: 0 0 0.5rem 0; 247 247 } 248 248 249 249 .empty-state p { 250 - font-size: 0.95rem; 250 + font-size: var(--text-base); 251 251 margin: 0; 252 252 } 253 253 ··· 264 264 text-align: center; 265 265 padding: 4rem 1rem; 266 266 color: var(--text-tertiary); 267 - font-size: 0.95rem; 267 + font-size: var(--text-base); 268 268 } 269 269 270 270 .tracks-list { ··· 292 292 } 293 293 294 294 .empty-state h2 { 295 - font-size: 1.25rem; 295 + font-size: var(--text-2xl); 296 296 } 297 297 298 298 .btn-queue-all { 299 299 padding: 0.5rem 0.75rem; 300 - font-size: 0.85rem; 300 + font-size: var(--text-sm); 301 301 } 302 302 303 303 .btn-queue-all svg { ··· 330 330 } 331 331 332 332 .subtitle { 333 - font-size: 0.85rem; 333 + font-size: var(--text-sm); 334 334 } 335 335 336 336 .btn-queue-all { 337 337 padding: 0.45rem 0.65rem; 338 - font-size: 0.8rem; 338 + font-size: var(--text-sm); 339 339 } 340 340 341 341 .btn-queue-all svg {
+108 -51
frontend/src/routes/track/[id]/+page.svelte
··· 12 12 import { checkImageSensitive } from '$lib/moderation.svelte'; 13 13 import { player } from '$lib/player.svelte'; 14 14 import { queue } from '$lib/queue.svelte'; 15 + import { playTrack, guardGatedTrack } from '$lib/playback.svelte'; 15 16 import { auth } from '$lib/auth.svelte'; 16 17 import { toast } from '$lib/toast.svelte'; 17 18 import type { Track } from '$lib/types'; ··· 103 104 window.location.href = '/'; 104 105 } 105 106 106 - function handlePlay() { 107 + async function handlePlay() { 107 108 if (player.currentTrack?.id === track.id) { 108 109 // this track is already loaded - just toggle play/pause 109 110 player.togglePlayPause(); 110 111 } else { 111 112 // different track or no track - start this one 112 - queue.playNow(track); 113 + // use playTrack for gated content checks 114 + if (track.gated) { 115 + await playTrack(track); 116 + } else { 117 + queue.playNow(track); 118 + } 113 119 } 114 120 } 115 121 116 122 function addToQueue() { 123 + if (!guardGatedTrack(track, auth.isAuthenticated)) return; 117 124 queue.addTracks([track]); 118 125 toast.success(`queued ${track.title}`, 1800); 119 126 } ··· 187 194 return `${minutes}:${seconds.toString().padStart(2, '0')}`; 188 195 } 189 196 190 - function seekToTimestamp(ms: number) { 197 + async function seekToTimestamp(ms: number) { 191 198 const doSeek = () => { 192 199 if (player.audioElement) { 193 200 player.audioElement.currentTime = ms / 1000; ··· 201 208 } 202 209 203 210 // otherwise start playing and wait for audio to be ready 204 - queue.playNow(track); 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 + 205 222 if (player.audioElement && player.audioElement.readyState >= 1) { 206 223 doSeek(); 207 224 } else { ··· 288 305 289 306 // track which track we've loaded data for to detect navigation 290 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); 291 310 292 311 // reload data when navigating between track pages 293 312 // watch data.track.id (from server) not track.id (local state) ··· 304 323 newCommentText = ''; 305 324 editingCommentId = null; 306 325 editingCommentText = ''; 326 + likedStateLoadedForTrackId = null; // reset liked state tracking 307 327 308 328 // sync track from server data 309 329 track = data.track; ··· 311 331 // mark as loaded for this track 312 332 loadedForTrackId = currentId; 313 333 314 - // load fresh data 315 - if (auth.isAuthenticated) { 316 - void loadLikedState(); 317 - } 334 + // load comments (doesn't require auth) 318 335 void loadComments(); 319 336 } 320 337 }); 321 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 + 322 351 let shareUrl = $state(''); 323 352 324 353 $effect(() => { ··· 413 442 <!-- track info wrapper --> 414 443 <div class="track-info-wrapper"> 415 444 <div class="track-info"> 416 - <h1 class="track-title">{track.title}</h1> 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> 417 455 <div class="track-metadata"> 418 456 <a href="/u/{track.artist_handle}" class="artist-link"> 419 457 {track.artist} ··· 465 503 trackTitle={track.title} 466 504 trackUri={track.atproto_record_uri} 467 505 trackCid={track.atproto_record_cid} 506 + fileId={track.file_id} 507 + gated={track.gated} 468 508 initialLiked={track.is_liked || false} 469 509 shareUrl={shareUrl} 470 510 onQueue={addToQueue} ··· 657 697 width: 100%; 658 698 max-width: 300px; 659 699 aspect-ratio: 1; 660 - border-radius: 8px; 700 + border-radius: var(--radius-md); 661 701 overflow: hidden; 662 702 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); 663 703 } ··· 703 743 margin: 0; 704 744 line-height: 1.2; 705 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; 706 763 } 707 764 708 765 .track-metadata { ··· 712 769 gap: 0.75rem; 713 770 flex-wrap: wrap; 714 771 color: var(--text-secondary); 715 - font-size: 1.1rem; 772 + font-size: var(--text-xl); 716 773 } 717 774 718 775 .separator { 719 776 color: var(--text-muted); 720 - font-size: 0.8rem; 777 + font-size: var(--text-sm); 721 778 } 722 779 723 780 .artist-link { ··· 798 855 799 856 .track-stats { 800 857 color: var(--text-tertiary); 801 - font-size: 0.95rem; 858 + font-size: var(--text-base); 802 859 display: flex; 803 860 align-items: center; 804 861 gap: 0.5rem; ··· 806 863 } 807 864 808 865 .track-stats .separator { 809 - font-size: 0.7rem; 866 + font-size: var(--text-xs); 810 867 } 811 868 812 869 .track-tags { ··· 821 878 padding: 0.25rem 0.6rem; 822 879 background: color-mix(in srgb, var(--accent) 15%, transparent); 823 880 color: var(--accent-hover); 824 - border-radius: 4px; 825 - font-size: 0.85rem; 881 + border-radius: var(--radius-sm); 882 + font-size: var(--text-sm); 826 883 font-weight: 500; 827 884 text-decoration: none; 828 885 transition: all 0.15s; ··· 857 914 background: var(--accent); 858 915 color: var(--bg-primary); 859 916 border: none; 860 - border-radius: 24px; 861 - font-size: 0.95rem; 917 + border-radius: var(--radius-2xl); 918 + font-size: var(--text-base); 862 919 font-weight: 600; 863 920 font-family: inherit; 864 921 cursor: pointer; ··· 871 928 } 872 929 873 930 .btn-play.playing { 874 - opacity: 0.8; 931 + animation: ethereal-glow 3s ease-in-out infinite; 875 932 } 876 933 877 934 .btn-queue { ··· 882 939 background: transparent; 883 940 color: var(--text-primary); 884 941 border: 1px solid var(--border-emphasis); 885 - border-radius: 24px; 886 - font-size: 0.95rem; 942 + border-radius: var(--radius-2xl); 943 + font-size: var(--text-base); 887 944 font-weight: 500; 888 945 font-family: inherit; 889 946 cursor: pointer; ··· 927 984 } 928 985 929 986 .track-title { 930 - font-size: 1.5rem; 987 + font-size: var(--text-3xl); 931 988 } 932 989 933 990 .track-metadata { 934 - font-size: 0.9rem; 991 + font-size: var(--text-base); 935 992 gap: 0.5rem; 936 993 } 937 994 938 995 .track-stats { 939 - font-size: 0.85rem; 996 + font-size: var(--text-sm); 940 997 } 941 998 942 999 .track-actions { ··· 953 1010 min-width: calc(50% - 0.25rem); 954 1011 justify-content: center; 955 1012 padding: 0.6rem 1rem; 956 - font-size: 0.9rem; 1013 + font-size: var(--text-base); 957 1014 } 958 1015 959 1016 .btn-play svg { ··· 966 1023 min-width: calc(50% - 0.25rem); 967 1024 justify-content: center; 968 1025 padding: 0.6rem 1rem; 969 - font-size: 0.9rem; 1026 + font-size: var(--text-base); 970 1027 } 971 1028 972 1029 .btn-queue svg { ··· 985 1042 } 986 1043 987 1044 .comments-title { 988 - font-size: 1rem; 1045 + font-size: var(--text-lg); 989 1046 font-weight: 600; 990 1047 color: var(--text-primary); 991 1048 margin: 0 0 0.75rem 0; ··· 1010 1067 padding: 0.6rem 0.8rem; 1011 1068 background: var(--bg-tertiary); 1012 1069 border: 1px solid var(--border-default); 1013 - border-radius: 6px; 1070 + border-radius: var(--radius-base); 1014 1071 color: var(--text-primary); 1015 - font-size: 0.9rem; 1072 + font-size: var(--text-base); 1016 1073 font-family: inherit; 1017 1074 } 1018 1075 ··· 1030 1087 background: var(--accent); 1031 1088 color: var(--bg-primary); 1032 1089 border: none; 1033 - border-radius: 6px; 1034 - font-size: 0.9rem; 1090 + border-radius: var(--radius-base); 1091 + font-size: var(--text-base); 1035 1092 font-weight: 600; 1036 1093 font-family: inherit; 1037 1094 cursor: pointer; ··· 1049 1106 1050 1107 .login-prompt { 1051 1108 color: var(--text-tertiary); 1052 - font-size: 0.9rem; 1109 + font-size: var(--text-base); 1053 1110 margin-bottom: 1rem; 1054 1111 } 1055 1112 ··· 1064 1121 1065 1122 .no-comments { 1066 1123 color: var(--text-muted); 1067 - font-size: 0.9rem; 1124 + font-size: var(--text-base); 1068 1125 text-align: center; 1069 1126 padding: 1rem; 1070 1127 } ··· 1085 1142 1086 1143 .comments-list::-webkit-scrollbar-track { 1087 1144 background: var(--bg-primary); 1088 - border-radius: 4px; 1145 + border-radius: var(--radius-sm); 1089 1146 } 1090 1147 1091 1148 .comments-list::-webkit-scrollbar-thumb { 1092 1149 background: var(--border-default); 1093 - border-radius: 4px; 1150 + border-radius: var(--radius-sm); 1094 1151 } 1095 1152 1096 1153 .comments-list::-webkit-scrollbar-thumb:hover { ··· 1103 1160 gap: 0.6rem; 1104 1161 padding: 0.5rem 0.6rem; 1105 1162 background: var(--bg-tertiary); 1106 - border-radius: 6px; 1163 + border-radius: var(--radius-base); 1107 1164 transition: background 0.15s; 1108 1165 } 1109 1166 ··· 1112 1169 } 1113 1170 1114 1171 .comment-timestamp { 1115 - font-size: 0.8rem; 1172 + font-size: var(--text-sm); 1116 1173 font-weight: 600; 1117 1174 color: var(--accent); 1118 1175 background: color-mix(in srgb, var(--accent) 10%, transparent); 1119 1176 padding: 0.2rem 0.5rem; 1120 - border-radius: 4px; 1177 + border-radius: var(--radius-sm); 1121 1178 white-space: nowrap; 1122 1179 height: fit-content; 1123 1180 border: none; ··· 1150 1207 } 1151 1208 1152 1209 .comment-time { 1153 - font-size: 0.75rem; 1210 + font-size: var(--text-xs); 1154 1211 color: var(--text-muted); 1155 1212 } 1156 1213 1157 1214 .comment-avatar { 1158 1215 width: 20px; 1159 1216 height: 20px; 1160 - border-radius: 50%; 1217 + border-radius: var(--radius-full); 1161 1218 object-fit: cover; 1162 1219 } 1163 1220 1164 1221 .comment-avatar-placeholder { 1165 1222 width: 20px; 1166 1223 height: 20px; 1167 - border-radius: 50%; 1224 + border-radius: var(--radius-full); 1168 1225 background: var(--border-default); 1169 1226 } 1170 1227 1171 1228 .comment-author { 1172 - font-size: 0.85rem; 1229 + font-size: var(--text-sm); 1173 1230 font-weight: 500; 1174 1231 color: var(--text-secondary); 1175 1232 text-decoration: none; ··· 1180 1237 } 1181 1238 1182 1239 .comment-text { 1183 - font-size: 0.9rem; 1240 + font-size: var(--text-base); 1184 1241 color: var(--text-primary); 1185 1242 margin: 0; 1186 1243 line-height: 1.4; ··· 1220 1277 border: none; 1221 1278 padding: 0; 1222 1279 color: var(--text-muted); 1223 - font-size: 0.8rem; 1280 + font-size: var(--text-sm); 1224 1281 cursor: pointer; 1225 1282 transition: color 0.15s; 1226 1283 font-family: inherit; ··· 1253 1310 padding: 0.5rem; 1254 1311 background: var(--bg-primary); 1255 1312 border: 1px solid var(--border-default); 1256 - border-radius: 4px; 1313 + border-radius: var(--radius-sm); 1257 1314 color: var(--text-primary); 1258 - font-size: 0.9rem; 1315 + font-size: var(--text-base); 1259 1316 font-family: inherit; 1260 1317 } 1261 1318 ··· 1272 1329 1273 1330 .edit-form-btn { 1274 1331 padding: 0.25rem 0.6rem; 1275 - font-size: 0.8rem; 1332 + font-size: var(--text-sm); 1276 1333 font-family: inherit; 1277 - border-radius: 4px; 1334 + border-radius: var(--radius-sm); 1278 1335 cursor: pointer; 1279 1336 transition: all 0.15s; 1280 1337 } ··· 1324 1381 ); 1325 1382 background-size: 200% 100%; 1326 1383 animation: shimmer 1.5s ease-in-out infinite; 1327 - border-radius: 4px; 1384 + border-radius: var(--radius-sm); 1328 1385 } 1329 1386 1330 1387 .comment-timestamp-skeleton { ··· 1336 1393 .comment-avatar-skeleton { 1337 1394 width: 20px; 1338 1395 height: 20px; 1339 - border-radius: 50%; 1396 + border-radius: var(--radius-full); 1340 1397 } 1341 1398 1342 1399 .comment-author-skeleton { ··· 1386 1443 } 1387 1444 1388 1445 .comment-timestamp { 1389 - font-size: 0.75rem; 1446 + font-size: var(--text-xs); 1390 1447 padding: 0.15rem 0.4rem; 1391 1448 } 1392 1449 }
+5 -5
frontend/src/routes/u/[handle]/+error.svelte
··· 96 96 } 97 97 98 98 .error-message { 99 - font-size: 1.25rem; 99 + font-size: var(--text-2xl); 100 100 color: var(--text-secondary); 101 101 margin: 0 0 0.5rem 0; 102 102 } 103 103 104 104 .error-detail { 105 - font-size: 1rem; 105 + font-size: var(--text-lg); 106 106 color: var(--text-tertiary); 107 107 margin: 0 0 2rem 0; 108 108 } ··· 118 118 .bsky-link { 119 119 color: var(--accent); 120 120 text-decoration: none; 121 - font-size: 1.1rem; 121 + font-size: var(--text-xl); 122 122 padding: 0.75rem 1.5rem; 123 123 border: 1px solid var(--accent); 124 - border-radius: 6px; 124 + border-radius: var(--radius-base); 125 125 transition: all 0.2s; 126 126 display: inline-block; 127 127 } ··· 151 151 } 152 152 153 153 .error-message { 154 - font-size: 1.1rem; 154 + font-size: var(--text-xl); 155 155 } 156 156 157 157 .actions {
+7 -1
frontend/src/routes/u/[handle]/+page.server.ts
··· 17 17 // fetch artist's tracks server-side (no cookie available on frontend host) 18 18 const tracksResponse = await fetch(`${API_URL}/tracks/?artist_did=${artist.did}`); 19 19 let tracks: Track[] = []; 20 + let hasMoreTracks = false; 21 + let nextCursor: string | null = null; 20 22 21 23 if (tracksResponse.ok) { 22 24 const data = await tracksResponse.json(); 23 25 tracks = data.tracks || []; 26 + hasMoreTracks = data.has_more || false; 27 + nextCursor = data.next_cursor || null; 24 28 } 25 29 26 30 const albumsResponse = await fetch(`${API_URL}/albums/${params.handle}`); ··· 34 38 return { 35 39 artist, 36 40 tracks, 37 - albums 41 + albums, 42 + hasMoreTracks, 43 + nextCursor 38 44 }; 39 45 } catch (e) { 40 46 console.error('failed to load artist:', e);
+213 -51
frontend/src/routes/u/[handle]/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { fade } from 'svelte/transition'; 3 - import { API_URL } from '$lib/config'; 3 + import { API_URL, getAtprotofansSupportUrl } from '$lib/config'; 4 4 import { browser } from '$app/environment'; 5 5 import type { Analytics, Track, Playlist } from '$lib/types'; 6 6 import { formatDuration } from '$lib/stats.svelte'; ··· 8 8 import ShareButton from '$lib/components/ShareButton.svelte'; 9 9 import Header from '$lib/components/Header.svelte'; 10 10 import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 11 + import SupporterBadge from '$lib/components/SupporterBadge.svelte'; 11 12 import { checkImageSensitive } from '$lib/moderation.svelte'; 12 13 import { player } from '$lib/player.svelte'; 13 14 import { queue } from '$lib/queue.svelte'; ··· 16 17 import { APP_NAME, APP_CANONICAL_URL } from '$lib/branding'; 17 18 import type { PageData } from './$types'; 18 19 20 + 19 21 // receive server-loaded data 20 22 let { data }: { data: PageData } = $props(); 21 23 ··· 29 31 const artist = $derived(data.artist); 30 32 let tracks = $state(data.tracks ?? []); 31 33 const albums = $derived(data.albums ?? []); 34 + let hasMoreTracks = $state(data.hasMoreTracks ?? false); 35 + let nextCursor = $state<string | null>(data.nextCursor ?? null); 36 + let loadingMoreTracks = $state(false); 32 37 let shareUrl = $state(''); 33 38 34 39 // compute support URL - handle 'atprotofans' magic value 35 40 const supportUrl = $derived(() => { 36 41 if (!artist?.support_url) return null; 37 42 if (artist.support_url === 'atprotofans') { 38 - return `https://atprotofans.com/u/${artist.did}`; 43 + return getAtprotofansSupportUrl(artist.did); 39 44 } 40 45 return artist.support_url; 41 46 }); ··· 64 69 // public playlists for collections section 65 70 let publicPlaylists = $state<Playlist[]>([]); 66 71 72 + // supporter status - true if logged-in viewer supports this artist via atprotofans 73 + let isSupporter = $state(false); 74 + 67 75 // track which artist we've loaded data for to detect navigation 68 76 let loadedForDid = $state<string | null>(null); 69 77 ··· 127 135 } 128 136 } 129 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 + 130 175 // reload data when navigating between artist pages 131 176 // watch data.artist?.did (from server) not artist?.did (local derived) 132 177 $effect(() => { ··· 140 185 tracksHydrated = false; 141 186 likedTracksCount = null; 142 187 publicPlaylists = []; 188 + isSupporter = false; 143 189 144 - // sync tracks from server data 190 + // sync tracks and pagination from server data 145 191 tracks = data.tracks ?? []; 192 + hasMoreTracks = data.hasMoreTracks ?? false; 193 + nextCursor = data.nextCursor ?? null; 146 194 147 195 // mark as loaded for this artist 148 196 loadedForDid = currentDid; ··· 153 201 void hydrateTracksWithLikes(); 154 202 void loadLikedTracksCount(); 155 203 void loadPublicPlaylists(); 204 + void checkSupporterStatus(); 156 205 } 157 206 }); 158 207 208 + async function loadMoreTracks() { 209 + if (!artist?.did || !nextCursor || loadingMoreTracks) return; 210 + 211 + loadingMoreTracks = true; 212 + try { 213 + const response = await fetch( 214 + `${API_URL}/tracks/?artist_did=${artist.did}&cursor=${encodeURIComponent(nextCursor)}` 215 + ); 216 + if (response.ok) { 217 + const data = await response.json(); 218 + const newTracks = data.tracks || []; 219 + 220 + // hydrate with liked status if authenticated 221 + if (auth.isAuthenticated) { 222 + const likedTracks = await fetchLikedTracks(); 223 + const likedIds = new Set(likedTracks.map(track => track.id)); 224 + for (const track of newTracks) { 225 + track.is_liked = likedIds.has(track.id); 226 + } 227 + } 228 + 229 + tracks = [...tracks, ...newTracks]; 230 + hasMoreTracks = data.has_more || false; 231 + nextCursor = data.next_cursor || null; 232 + } 233 + } catch (_e) { 234 + console.error('failed to load more tracks:', _e); 235 + } finally { 236 + loadingMoreTracks = false; 237 + } 238 + } 239 + 159 240 async function hydrateTracksWithLikes() { 160 241 if (!browser || tracksHydrated) return; 161 242 ··· 273 354 <div class="artist-details"> 274 355 <div class="artist-info"> 275 356 <h1>{artist.display_name}</h1> 276 - <a href="https://bsky.app/profile/{artist.handle}" target="_blank" rel="noopener" class="handle"> 277 - @{artist.handle} 278 - </a> 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> 279 365 {#if artist.bio} 280 366 <p class="bio">{artist.bio}</p> 281 367 {/if} ··· 355 441 </section> 356 442 357 443 <section class="tracks"> 358 - <h2> 359 - tracks 360 - {#if tracksLoading} 361 - <span class="tracks-loading">updatingโ€ฆ</span> 444 + <div class="section-header"> 445 + <h2> 446 + tracks 447 + {#if tracksLoading} 448 + <span class="tracks-loading">updatingโ€ฆ</span> 449 + {/if} 450 + </h2> 451 + {#if analytics?.total_items} 452 + <span>{analytics.total_items} {analytics.total_items === 1 ? 'track' : 'tracks'}</span> 362 453 {/if} 363 - </h2> 454 + </div> 364 455 {#if tracks.length === 0} 365 456 <div class="empty-state"> 366 457 <p class="empty-message">no tracks yet</p> ··· 378 469 </div> 379 470 {:else} 380 471 <div class="track-list"> 381 - {#each tracks as track, i} 382 - <TrackItem 383 - {track} 384 - index={i} 385 - isPlaying={player.currentTrack?.id === track.id} 386 - onPlay={(t) => queue.playNow(t)} 387 - isAuthenticated={auth.isAuthenticated} 388 - hideArtist={true} 389 - /> 390 - {/each} 391 - </div> 392 - {/if} 472 + {#each tracks as track, i} 473 + <TrackItem 474 + {track} 475 + index={i} 476 + isPlaying={player.currentTrack?.id === track.id} 477 + onPlay={(t) => queue.playNow(t)} 478 + isAuthenticated={auth.isAuthenticated} 479 + hideArtist={true} 480 + /> 481 + {/each} 482 + </div> 483 + {#if hasMoreTracks} 484 + <button 485 + class="load-more-btn" 486 + onclick={loadMoreTracks} 487 + disabled={loadingMoreTracks} 488 + > 489 + {#if loadingMoreTracks} 490 + loadingโ€ฆ 491 + {:else} 492 + load more tracks 493 + {/if} 494 + </button> 495 + {/if} 496 + {/if} 393 497 </section> 394 498 395 499 {#if albums.length > 0} ··· 509 613 padding: 2rem; 510 614 background: var(--bg-secondary); 511 615 border: 1px solid var(--border-subtle); 512 - border-radius: 8px; 616 + border-radius: var(--radius-md); 513 617 } 514 618 515 619 .artist-details { ··· 547 651 padding: 0 0.75rem; 548 652 background: color-mix(in srgb, var(--accent) 15%, transparent); 549 653 border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); 550 - border-radius: 4px; 654 + border-radius: var(--radius-sm); 551 655 color: var(--accent); 552 - font-size: 0.85rem; 656 + font-size: var(--text-sm); 553 657 text-decoration: none; 554 658 transition: all 0.2s ease; 555 659 } ··· 567 671 .artist-avatar { 568 672 width: 120px; 569 673 height: 120px; 570 - border-radius: 50%; 674 + border-radius: var(--radius-full); 571 675 object-fit: cover; 572 676 border: 3px solid var(--border-default); 573 677 } ··· 581 685 hyphens: auto; 582 686 } 583 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 + 584 696 .handle { 585 697 color: var(--text-tertiary); 586 - font-size: 1.1rem; 587 - margin: 0 0 1rem 0; 698 + font-size: var(--text-xl); 588 699 text-decoration: none; 589 700 transition: color 0.2s; 590 - display: inline-block; 591 701 } 592 702 593 703 .handle:hover { ··· 628 738 629 739 .section-header span { 630 740 color: var(--text-tertiary); 631 - font-size: 0.9rem; 741 + font-size: var(--text-base); 632 742 text-transform: uppercase; 633 743 letter-spacing: 0.1em; 634 744 } ··· 646 756 padding: 1rem; 647 757 background: var(--bg-secondary); 648 758 border: 1px solid var(--border-subtle); 649 - border-radius: 10px; 759 + border-radius: var(--radius-md); 650 760 color: inherit; 651 761 text-decoration: none; 652 762 transition: transform 0.15s ease, border-color 0.15s ease; 763 + overflow: hidden; 764 + max-width: 100%; 653 765 } 654 766 655 767 .album-card:hover { ··· 660 772 .album-cover-wrapper { 661 773 width: 72px; 662 774 height: 72px; 663 - border-radius: 6px; 775 + border-radius: var(--radius-base); 664 776 overflow: hidden; 665 777 flex-shrink: 0; 666 778 background: var(--bg-tertiary); ··· 708 820 .album-card-meta p { 709 821 margin: 0; 710 822 color: var(--text-tertiary); 711 - font-size: 0.9rem; 823 + font-size: var(--text-base); 712 824 display: flex; 713 825 align-items: center; 714 826 gap: 0.4rem; ··· 722 834 .stat-card { 723 835 background: var(--bg-secondary); 724 836 border: 1px solid var(--border-subtle); 725 - border-radius: 8px; 837 + border-radius: var(--radius-md); 726 838 padding: 1.5rem; 727 839 transition: border-color 0.2s; 728 840 } ··· 741 853 742 854 .stat-label { 743 855 color: var(--text-tertiary); 744 - font-size: 0.9rem; 856 + font-size: var(--text-base); 745 857 text-transform: lowercase; 746 858 line-height: 1; 747 859 } 748 860 749 861 .stat-duration { 750 862 margin-top: 0.5rem; 751 - font-size: 0.85rem; 863 + font-size: var(--text-sm); 752 864 color: var(--text-secondary); 753 865 font-variant-numeric: tabular-nums; 754 866 } ··· 776 888 777 889 .top-item-plays { 778 890 color: var(--accent); 779 - font-size: 1rem; 891 + font-size: var(--text-lg); 780 892 line-height: 1; 781 893 } 782 894 ··· 796 908 ); 797 909 background-size: 200% 100%; 798 910 animation: shimmer 1.5s ease-in-out infinite; 799 - border-radius: 4px; 911 + border-radius: var(--radius-sm); 800 912 } 801 913 802 914 /* match .stat-value dimensions: 2.5rem font + 0.5rem margin-bottom */ ··· 835 947 margin-top: 2rem; 836 948 } 837 949 838 - .tracks h2 { 839 - margin-bottom: 1.5rem; 950 + .tracks .section-header h2 { 951 + margin: 0; 840 952 color: var(--text-primary); 841 953 font-size: 1.8rem; 842 954 } 843 955 956 + .load-more-btn { 957 + display: block; 958 + width: 100%; 959 + margin-top: 1rem; 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 + } 970 + 971 + .load-more-btn:hover:not(:disabled) { 972 + background: var(--bg-tertiary); 973 + border-color: var(--accent); 974 + color: var(--accent); 975 + } 976 + 977 + .load-more-btn:disabled { 978 + opacity: 0.6; 979 + cursor: not-allowed; 980 + } 981 + 844 982 .tracks-loading { 845 983 margin-left: 0.75rem; 846 - font-size: 0.95rem; 984 + font-size: var(--text-base); 847 985 color: var(--text-secondary); 848 986 font-weight: 400; 849 987 text-transform: lowercase; ··· 860 998 padding: 3rem; 861 999 background: var(--bg-secondary); 862 1000 border: 1px solid var(--border-subtle); 863 - border-radius: 8px; 1001 + border-radius: var(--radius-md); 864 1002 } 865 1003 866 1004 .empty-message { 867 1005 color: var(--text-secondary); 868 - font-size: 1.25rem; 1006 + font-size: var(--text-2xl); 869 1007 margin: 0 0 0.5rem 0; 870 1008 } 871 1009 ··· 877 1015 .bsky-link { 878 1016 color: var(--accent); 879 1017 text-decoration: none; 880 - font-size: 1rem; 1018 + font-size: var(--text-lg); 881 1019 padding: 0.75rem 1.5rem; 882 1020 border: 1px solid var(--accent); 883 - border-radius: 6px; 1021 + border-radius: var(--radius-base); 884 1022 transition: all 0.2s; 885 1023 display: inline-block; 886 1024 } ··· 920 1058 text-align: center; 921 1059 } 922 1060 1061 + .handle-row { 1062 + justify-content: center; 1063 + } 1064 + 923 1065 .artist-actions-desktop { 924 1066 display: none; 925 1067 } ··· 930 1072 931 1073 .support-btn { 932 1074 height: 28px; 933 - font-size: 0.8rem; 1075 + font-size: var(--text-sm); 934 1076 padding: 0 0.6rem; 935 1077 } 936 1078 ··· 961 1103 .album-grid { 962 1104 grid-template-columns: 1fr; 963 1105 } 1106 + 1107 + .album-card { 1108 + padding: 0.75rem; 1109 + gap: 0.75rem; 1110 + } 1111 + 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 + } 964 1126 } 965 1127 966 1128 .collections-section { ··· 986 1148 padding: 1.25rem 1.5rem; 987 1149 background: var(--bg-secondary); 988 1150 border: 1px solid var(--border-subtle); 989 - border-radius: 10px; 1151 + border-radius: var(--radius-md); 990 1152 color: inherit; 991 1153 text-decoration: none; 992 1154 transition: transform 0.15s ease, border-color 0.15s ease; ··· 1000 1162 .collection-icon { 1001 1163 width: 48px; 1002 1164 height: 48px; 1003 - border-radius: 8px; 1165 + border-radius: var(--radius-md); 1004 1166 display: flex; 1005 1167 align-items: center; 1006 1168 justify-content: center; ··· 1031 1193 1032 1194 .collection-info h3 { 1033 1195 margin: 0 0 0.25rem 0; 1034 - font-size: 1.1rem; 1196 + font-size: var(--text-xl); 1035 1197 color: var(--text-primary); 1036 1198 } 1037 1199 1038 1200 .collection-info p { 1039 1201 margin: 0; 1040 - font-size: 0.9rem; 1202 + font-size: var(--text-base); 1041 1203 color: var(--text-tertiary); 1042 1204 } 1043 1205
+58 -31
frontend/src/routes/u/[handle]/album/[slug]/+page.svelte
··· 7 7 import { checkImageSensitive } from '$lib/moderation.svelte'; 8 8 import { player } from '$lib/player.svelte'; 9 9 import { queue } from '$lib/queue.svelte'; 10 + import { playQueue } from '$lib/playback.svelte'; 10 11 import { toast } from '$lib/toast.svelte'; 11 12 import { auth } from '$lib/auth.svelte'; 12 13 import { API_URL } from '$lib/config'; ··· 26 27 tracks = [...data.album.tracks]; 27 28 }); 28 29 30 + // local mutable copy of tracks for reordering 31 + let tracks = $state<Track[]>([...data.album.tracks]); 32 + 29 33 // check if current user owns this album 30 34 const isOwner = $derived(auth.user?.did === albumMetadata.artist_did); 31 35 // can only reorder if owner and album has an ATProto list 32 36 const canReorder = $derived(isOwner && !!albumMetadata.list_uri); 33 37 34 - // local mutable copy of tracks for reordering 35 - let tracks = $state<Track[]>([...data.album.tracks]); 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); 36 46 37 47 // edit mode state 38 48 let isEditMode = $state(false); ··· 70 80 queue.playNow(track); 71 81 } 72 82 73 - function playNow() { 83 + async function playNow() { 74 84 if (tracks.length > 0) { 75 - queue.setQueue(tracks); 76 - queue.playNow(tracks[0]); 77 - toast.success(`playing ${albumMetadata.title}`, 1800); 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 + } 78 90 } 79 91 } 80 92 ··· 542 554 </div> 543 555 544 556 <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 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} 550 573 </button> 551 574 <button class="queue-button" onclick={addToQueue}> 552 575 <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"> ··· 763 786 .album-art { 764 787 width: 200px; 765 788 height: 200px; 766 - border-radius: 8px; 789 + border-radius: var(--radius-md); 767 790 object-fit: cover; 768 791 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 769 792 } ··· 771 794 .album-art-placeholder { 772 795 width: 200px; 773 796 height: 200px; 774 - border-radius: 8px; 797 + border-radius: var(--radius-md); 775 798 background: var(--bg-tertiary); 776 799 border: 1px solid var(--border-subtle); 777 800 display: flex; ··· 814 837 height: 32px; 815 838 background: transparent; 816 839 border: 1px solid var(--border-default); 817 - border-radius: 4px; 840 + border-radius: var(--radius-sm); 818 841 color: var(--text-tertiary); 819 842 cursor: pointer; 820 843 transition: all 0.15s; ··· 855 878 position: absolute; 856 879 inset: 0; 857 880 background: rgba(0, 0, 0, 0.6); 858 - border-radius: 8px; 881 + border-radius: var(--radius-md); 859 882 display: flex; 860 883 flex-direction: column; 861 884 align-items: center; 862 885 justify-content: center; 863 886 gap: 0.5rem; 864 887 color: white; 865 - font-size: 0.85rem; 888 + font-size: var(--text-sm); 866 889 opacity: 0; 867 890 transition: opacity 0.2s; 868 891 } ··· 890 913 891 914 .album-type { 892 915 text-transform: uppercase; 893 - font-size: 0.75rem; 916 + font-size: var(--text-xs); 894 917 font-weight: 600; 895 918 letter-spacing: 0.1em; 896 919 color: var(--text-tertiary); ··· 914 937 display: flex; 915 938 align-items: center; 916 939 gap: 0.75rem; 917 - font-size: 0.95rem; 940 + font-size: var(--text-base); 918 941 color: var(--text-secondary); 919 942 text-shadow: var(--text-shadow, none); 920 943 } ··· 933 956 934 957 .meta-separator { 935 958 color: var(--text-muted); 936 - font-size: 0.7rem; 959 + font-size: var(--text-xs); 937 960 } 938 961 939 962 .album-actions { ··· 945 968 .play-button, 946 969 .queue-button { 947 970 padding: 0.75rem 1.5rem; 948 - border-radius: 24px; 971 + border-radius: var(--radius-2xl); 949 972 font-weight: 600; 950 - font-size: 0.95rem; 973 + font-size: var(--text-base); 951 974 font-family: inherit; 952 975 cursor: pointer; 953 976 transition: all 0.2s; ··· 966 989 transform: scale(1.05); 967 990 } 968 991 992 + .play-button.is-playing { 993 + animation: ethereal-glow 3s ease-in-out infinite; 994 + } 995 + 969 996 .queue-button { 970 997 background: var(--glass-btn-bg, transparent); 971 998 color: var(--text-primary); ··· 997 1024 } 998 1025 999 1026 .section-heading { 1000 - font-size: 1.25rem; 1027 + font-size: var(--text-2xl); 1001 1028 font-weight: 600; 1002 1029 color: var(--text-primary); 1003 1030 margin-bottom: 1rem; ··· 1015 1042 display: flex; 1016 1043 align-items: center; 1017 1044 gap: 0.5rem; 1018 - border-radius: 8px; 1045 + border-radius: var(--radius-md); 1019 1046 transition: all 0.2s; 1020 1047 position: relative; 1021 1048 } ··· 1047 1074 color: var(--text-muted); 1048 1075 cursor: grab; 1049 1076 touch-action: none; 1050 - border-radius: 4px; 1077 + border-radius: var(--radius-sm); 1051 1078 transition: all 0.2s; 1052 1079 flex-shrink: 0; 1053 1080 } ··· 1108 1135 } 1109 1136 1110 1137 .album-meta { 1111 - font-size: 0.85rem; 1138 + font-size: var(--text-sm); 1112 1139 } 1113 1140 1114 1141 .album-actions { ··· 1140 1167 } 1141 1168 1142 1169 .album-meta { 1143 - font-size: 0.8rem; 1170 + font-size: var(--text-sm); 1144 1171 flex-wrap: wrap; 1145 1172 } 1146 1173 } ··· 1152 1179 justify-content: center; 1153 1180 width: 32px; 1154 1181 height: 32px; 1155 - border-radius: 50%; 1182 + border-radius: var(--radius-full); 1156 1183 background: transparent; 1157 1184 border: none; 1158 1185 color: var(--text-muted); ··· 1185 1212 1186 1213 .modal { 1187 1214 background: var(--bg-secondary); 1188 - border-radius: 12px; 1215 + border-radius: var(--radius-lg); 1189 1216 padding: 1.5rem; 1190 1217 max-width: 400px; 1191 1218 width: calc(100% - 2rem); ··· 1199 1226 1200 1227 .modal-header h3 { 1201 1228 margin: 0; 1202 - font-size: 1.25rem; 1229 + font-size: var(--text-2xl); 1203 1230 font-weight: 600; 1204 1231 color: var(--text-primary); 1205 1232 } ··· 1223 1250 .cancel-btn, 1224 1251 .confirm-btn { 1225 1252 padding: 0.625rem 1.25rem; 1226 - border-radius: 8px; 1253 + border-radius: var(--radius-md); 1227 1254 font-weight: 500; 1228 - font-size: 0.9rem; 1255 + font-size: var(--text-base); 1229 1256 font-family: inherit; 1230 1257 cursor: pointer; 1231 1258 transition: all 0.2s;
+129 -16
frontend/src/routes/upload/+page.svelte
··· 6 6 import AlbumSelect from "$lib/components/AlbumSelect.svelte"; 7 7 import WaveLoading from "$lib/components/WaveLoading.svelte"; 8 8 import TagInput from "$lib/components/TagInput.svelte"; 9 - import type { FeaturedArtist, AlbumSummary } from "$lib/types"; 9 + import type { FeaturedArtist, AlbumSummary, Artist } from "$lib/types"; 10 10 import { API_URL, getServerConfig } from "$lib/config"; 11 11 import { uploader } from "$lib/uploader.svelte"; 12 12 import { toast } from "$lib/toast.svelte"; ··· 38 38 let uploadTags = $state<string[]>([]); 39 39 let hasUnresolvedFeaturesInput = $state(false); 40 40 let attestedRights = $state(false); 41 + let supportGated = $state(false); 41 42 42 43 // albums for selection 43 44 let albums = $state<AlbumSummary[]>([]); 45 + 46 + // artist profile for checking atprotofans eligibility 47 + let artistProfile = $state<Artist | null>(null); 44 48 45 49 onMount(async () => { 46 50 // wait for auth to finish loading ··· 53 57 return; 54 58 } 55 59 56 - await loadMyAlbums(); 60 + await Promise.all([loadMyAlbums(), loadArtistProfile()]); 57 61 loading = false; 58 62 }); 59 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 + 60 78 async function loadMyAlbums() { 61 79 if (!auth.user) return; 62 80 try { ··· 82 100 const uploadFeatures = [...featuredArtists]; 83 101 const uploadImage = imageFile; 84 102 const tagsToUpload = [...uploadTags]; 103 + const isGated = supportGated; 85 104 86 105 const clearForm = () => { 87 106 title = ""; ··· 91 110 featuredArtists = []; 92 111 uploadTags = []; 93 112 attestedRights = false; 113 + supportGated = false; 94 114 95 115 const fileInput = document.getElementById( 96 116 "file-input", ··· 109 129 uploadFeatures, 110 130 uploadImage, 111 131 tagsToUpload, 132 + isGated, 112 133 async () => { 113 134 await loadMyAlbums(); 114 135 }, ··· 286 307 {/if} 287 308 </div> 288 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 + 289 341 <div class="form-group attestation"> 290 342 <label class="checkbox-label"> 291 343 <input ··· 327 379 > 328 380 <span>upload track</span> 329 381 </button> 382 + 330 383 </form> 331 384 </main> 332 385 {/if} ··· 370 423 form { 371 424 background: var(--bg-tertiary); 372 425 padding: 2rem; 373 - border-radius: 8px; 426 + border-radius: var(--radius-md); 374 427 border: 1px solid var(--border-subtle); 375 428 } 376 429 ··· 382 435 display: block; 383 436 color: var(--text-secondary); 384 437 margin-bottom: 0.5rem; 385 - font-size: 0.9rem; 438 + font-size: var(--text-base); 386 439 } 387 440 388 441 input[type="text"] { ··· 390 443 padding: 0.75rem; 391 444 background: var(--bg-primary); 392 445 border: 1px solid var(--border-default); 393 - border-radius: 4px; 446 + border-radius: var(--radius-sm); 394 447 color: var(--text-primary); 395 - font-size: 1rem; 448 + font-size: var(--text-lg); 396 449 font-family: inherit; 397 450 transition: all 0.2s; 398 451 } ··· 407 460 padding: 0.75rem; 408 461 background: var(--bg-primary); 409 462 border: 1px solid var(--border-default); 410 - border-radius: 4px; 463 + border-radius: var(--radius-sm); 411 464 color: var(--text-primary); 412 - font-size: 0.9rem; 465 + font-size: var(--text-base); 413 466 font-family: inherit; 414 467 cursor: pointer; 415 468 } 416 469 417 470 .format-hint { 418 471 margin-top: 0.25rem; 419 - font-size: 0.8rem; 472 + font-size: var(--text-sm); 420 473 color: var(--text-tertiary); 421 474 } 422 475 423 476 .file-info { 424 477 margin-top: 0.5rem; 425 - font-size: 0.85rem; 478 + font-size: var(--text-sm); 426 479 color: var(--text-muted); 427 480 } 428 481 ··· 432 485 background: var(--accent); 433 486 color: var(--text-primary); 434 487 border: none; 435 - border-radius: 4px; 436 - font-size: 1rem; 488 + border-radius: var(--radius-sm); 489 + font-size: var(--text-lg); 437 490 font-weight: 600; 438 491 font-family: inherit; 439 492 cursor: pointer; ··· 467 520 .attestation { 468 521 background: var(--bg-primary); 469 522 padding: 1rem; 470 - border-radius: 4px; 523 + border-radius: var(--radius-sm); 471 524 border: 1px solid var(--border-default); 472 525 } 473 526 ··· 489 542 } 490 543 491 544 .checkbox-text { 492 - font-size: 0.95rem; 545 + font-size: var(--text-base); 493 546 color: var(--text-primary); 494 547 line-height: 1.4; 495 548 } ··· 497 550 .attestation-note { 498 551 margin-top: 0.75rem; 499 552 margin-left: 2rem; 500 - font-size: 0.8rem; 553 + font-size: var(--text-sm); 501 554 color: var(--text-tertiary); 502 555 line-height: 1.4; 503 556 } ··· 511 564 text-decoration: underline; 512 565 } 513 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 + 514 627 @media (max-width: 768px) { 515 628 main { 516 629 padding: 0 0.75rem ··· 525 638 } 526 639 527 640 .section-header h2 { 528 - font-size: 1.25rem; 641 + font-size: var(--text-2xl); 529 642 } 530 643 } 531 644 </style>
+17
lexicons/track.json
··· 61 61 "type": "string", 62 62 "format": "datetime", 63 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." 64 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"] 65 82 } 66 83 } 67 84 },
+1 -1
moderation/Cargo.toml
··· 6 6 [dependencies] 7 7 anyhow = "1.0" 8 8 axum = { version = "0.7", features = ["macros", "json", "ws"] } 9 + rand = "0.8" 9 10 bytes = "1.0" 10 11 chrono = { version = "0.4", features = ["serde"] } 11 12 futures = "0.3" ··· 25 26 tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } 26 27 27 28 [dev-dependencies] 28 - rand = "0.8"
+159
moderation/src/admin.rs
··· 104 104 pub active_uris: Vec<String>, 105 105 } 106 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 + 107 158 /// List all flagged tracks - returns JSON for API, HTML for htmx. 108 159 pub async fn list_flagged( 109 160 State(state): State<AppState>, ··· 292 343 Ok(Json(StoreContextResponse { 293 344 message: format!("context stored for {}", request.uri), 294 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 })) 295 454 } 296 455 297 456 /// Serve the admin UI HTML from static file.
+6 -1
moderation/src/auth.rs
··· 12 12 let path = req.uri().path(); 13 13 14 14 // Public endpoints - no auth required 15 - // Note: /admin serves HTML, auth is handled client-side for API calls 15 + // Note: /admin and /admin/review/:id serve HTML, auth is handled client-side for API calls 16 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"); 17 20 if path == "/" 18 21 || path == "/health" 22 + || path == "/sensitive-images" 19 23 || path == "/admin" 24 + || is_review_page 20 25 || path.starts_with("/static/") 21 26 || path.starts_with("/xrpc/com.atproto.label.") 22 27 {
+336 -1
moderation/src/db.rs
··· 2 2 3 3 use chrono::{DateTime, Utc}; 4 4 use serde::{Deserialize, Serialize}; 5 - use sqlx::{postgres::PgPoolOptions, PgPool}; 5 + use sqlx::{postgres::PgPoolOptions, FromRow, PgPool}; 6 6 7 7 use crate::admin::FlaggedTrack; 8 8 use crate::labels::Label; 9 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 + 10 50 /// Type alias for context row from database query. 11 51 type ContextRow = ( 12 52 Option<i64>, // track_id ··· 55 95 FingerprintNoise, 56 96 /// Legal cover version or remix 57 97 CoverVersion, 98 + /// Content was deleted from plyr.fm 99 + ContentDeleted, 58 100 /// Other reason (see resolution_notes) 59 101 Other, 60 102 } ··· 67 109 Self::Licensed => "licensed", 68 110 Self::FingerprintNoise => "fingerprint noise", 69 111 Self::CoverVersion => "cover/remix", 112 + Self::ContentDeleted => "content deleted", 70 113 Self::Other => "other", 71 114 } 72 115 } ··· 78 121 "licensed" => Some(Self::Licensed), 79 122 "fingerprint_noise" => Some(Self::FingerprintNoise), 80 123 "cover_version" => Some(Self::CoverVersion), 124 + "content_deleted" => Some(Self::ContentDeleted), 81 125 "other" => Some(Self::Other), 82 126 _ => None, 83 127 } ··· 193 237 .execute(&self.pool) 194 238 .await?; 195 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)") 196 299 .execute(&self.pool) 197 300 .await?; 198 301 ··· 592 695 .collect(); 593 696 594 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) 595 930 } 596 931 } 597 932
+26
moderation/src/handlers.rs
··· 63 63 pub label: Label, 64 64 } 65 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 + 66 75 // --- handlers --- 67 76 68 77 /// Health check endpoint. ··· 206 215 } 207 216 208 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 })) 209 235 } 210 236 211 237 #[cfg(test)]
+13
moderation/src/main.rs
··· 25 25 mod db; 26 26 mod handlers; 27 27 mod labels; 28 + mod review; 28 29 mod state; 29 30 mod xrpc; 30 31 ··· 72 73 .route("/", get(handlers::landing)) 73 74 // Health check 74 75 .route("/health", get(handlers::health)) 76 + // Sensitive images (public) 77 + .route("/sensitive-images", get(handlers::get_sensitive_images)) 75 78 // AuDD scanning 76 79 .route("/scan", post(audd::scan)) 77 80 // Label emission (internal API) ··· 84 87 .route("/admin/resolve-htmx", post(admin::resolve_flag_htmx)) 85 88 .route("/admin/context", post(admin::store_context)) 86 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)) 87 100 // Static files (CSS, JS for admin UI) 88 101 .nest_service("/static", ServeDir::new("static")) 89 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 32 #[error("labeler not configured")] 33 33 LabelerNotConfigured, 34 34 35 + #[error("bad request: {0}")] 36 + BadRequest(String), 37 + 38 + #[error("not found: {0}")] 39 + NotFound(String), 40 + 35 41 #[error("label error: {0}")] 36 42 Label(#[from] LabelError), 37 43 ··· 50 56 AppError::LabelerNotConfigured => { 51 57 (StatusCode::SERVICE_UNAVAILABLE, "LabelerNotConfigured") 52 58 } 59 + AppError::BadRequest(_) => (StatusCode::BAD_REQUEST, "BadRequest"), 60 + AppError::NotFound(_) => (StatusCode::NOT_FOUND, "NotFound"), 53 61 AppError::Label(_) => (StatusCode::INTERNAL_SERVER_ERROR, "LabelError"), 54 62 AppError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "DatabaseError"), 55 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 35 AUDD_COST_PER_1000 = 5.00 # $5 per 1000 requests 36 36 AUDD_BASE_COST = 5.00 # $5/month base 37 37 38 - # fixed monthly costs (updated 2025-12-16) 38 + # fixed monthly costs (updated 2025-12-26) 39 39 # fly.io: manually updated from cost explorer (TODO: use fly billing API) 40 40 # neon: fixed $5/month 41 41 # cloudflare: mostly free tier 42 + # redis: self-hosted on fly (included in fly_io costs) 42 43 FIXED_COSTS = { 43 44 "fly_io": { 44 45 "breakdown": { ··· 116 117 import asyncpg 117 118 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) 119 122 120 123 conn = await asyncpg.connect(db_url) 121 124 try: 122 125 # get totals: scans, flagged, and derived API requests from duration 126 + # uses billing period for accurate cost calculation 123 127 row = await conn.fetchrow( 124 128 """ 125 129 SELECT ··· 139 143 total_requests = row["total_requests"] 140 144 total_seconds = row["total_seconds"] 141 145 142 - # daily breakdown for chart - now includes requests derived from duration 146 + # daily breakdown for chart - 30 days of history for flexible views 143 147 daily = await conn.fetch( 144 148 """ 145 149 SELECT ··· 153 157 GROUP BY DATE(cs.scanned_at) 154 158 ORDER BY date 155 159 """, 156 - billing_start, 160 + history_start, 157 161 AUDD_SECONDS_PER_REQUEST, 158 162 ) 159 163
+4 -6
scripts/docket_runs.py
··· 38 38 url = os.environ.get("DOCKET_URL_STAGING") 39 39 if not url: 40 40 print("error: DOCKET_URL_STAGING not set") 41 - print( 42 - "hint: export DOCKET_URL_STAGING=rediss://default:xxx@xxx.upstash.io:6379" 43 - ) 41 + print("hint: flyctl proxy 6380:6379 -a plyr-redis-stg") 42 + print(" export DOCKET_URL_STAGING=redis://localhost:6380") 44 43 return 1 45 44 elif args.env == "production": 46 45 url = os.environ.get("DOCKET_URL_PRODUCTION") 47 46 if not url: 48 47 print("error: DOCKET_URL_PRODUCTION not set") 49 - print( 50 - "hint: export DOCKET_URL_PRODUCTION=rediss://default:xxx@xxx.upstash.io:6379" 51 - ) 48 + print("hint: flyctl proxy 6381:6379 -a plyr-redis") 49 + print(" export DOCKET_URL_PRODUCTION=redis://localhost:6381") 52 50 return 1 53 51 54 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.