+36
-5
.claude/commands/consider-review.md
+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
+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
+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
+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
+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
+2
.claude/commands/status-update.md
+47
-3
.github/workflows/check-rust.yml
+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
+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
+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
+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
+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
-211
STATUS.md
+104
-211
STATUS.md
···
47
47
48
48
### December 2025
49
49
50
-
#### visual customization (PRs #595-596, Dec 16)
50
+
#### self-hosted redis (PR #674-675, Dec 30)
51
51
52
-
**custom backgrounds** (PR #595):
53
-
- users can set a custom background image URL in settings with optional tiling
54
-
- new "playing artwork as background" toggle - uses current track's artwork as blurred page background
55
-
- glass effect styling for track items (translucent backgrounds, subtle shadows)
56
-
- 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
57
57
58
-
**bug fix** (PR #596):
59
-
- removed 3D wheel scroll effect that was blocking like/share button clicks
60
-
- 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.
61
59
62
60
---
63
61
64
-
#### performance & UX polish (PRs #586-593, Dec 14-15)
62
+
#### supporter-gated content (PR #637, Dec 22-23)
65
63
66
-
**performance improvements** (PRs #590-591):
67
-
- removed moderation service call from `/tracks/` listing endpoint
68
-
- removed copyright check from tag listing endpoint
69
-
- faster page loads for track feeds
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
70
68
71
-
**moderation agent** (PRs #586, #588):
72
-
- added moderation agent script with audit trail support
73
-
- improved moderation prompt and UI layout
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`)
74
75
75
-
**bug fixes** (PRs #589, #592, #593):
76
-
- fixed liked state display on playlist detail page
77
-
- preserved album track order during ATProto sync
78
-
- made header sticky on scroll for better mobile navigation
79
-
80
-
**iOS Safari fixes** (PRs #573-576):
81
-
- fixed AddToMenu visibility issue on iOS Safari
82
-
- menu now correctly opens upward when near viewport bottom
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
83
80
84
81
---
85
82
86
-
#### mobile UI polish & background task expansion (PRs #558-572, Dec 10-12)
83
+
#### supporter badges (PR #627, Dec 21-22)
87
84
88
-
**background task expansion** (PRs #558, #561):
89
-
- moved like/unlike and comment PDS writes to docket background tasks
90
-
- API responses now immediate; PDS sync happens asynchronously
91
-
- added targeted album list sync background task for ATProto record updates
92
-
93
-
**performance caching** (PR #566):
94
-
- added Redis cache for copyright label lookups (5-minute TTL)
95
-
- fixed 2-3s latency spikes on `/tracks/` endpoint
96
-
- batch operations via `mget`/pipeline for efficiency
97
-
98
-
**mobile UX improvements** (PRs #569, #572):
99
-
- mobile action menus now open from top with all actions visible
100
-
- UI polish for album and artist pages on small screens
101
-
102
-
**misc** (PRs #559, #562, #563, #570):
103
-
- reduced docket Redis polling from 250ms to 5s (lower resource usage)
104
-
- added atprotofans support link mode for ko-fi integration
105
-
- added alpha badge to header branding
106
-
- fixed web manifest ID for PWA stability
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
107
89
108
90
---
109
91
110
-
#### confidential OAuth client (PRs #578, #580-582, Dec 12-13)
111
-
112
-
**confidential client support** (PR #578):
113
-
- implemented ATProto OAuth confidential client using `private_key_jwt` authentication
114
-
- when `OAUTH_JWK` is configured, plyr.fm authenticates with a cryptographic key
115
-
- confidential clients earn 180-day refresh tokens (vs 2-week for public clients)
116
-
- added `/.well-known/jwks.json` endpoint for public key discovery
117
-
- updated `/oauth-client-metadata.json` with confidential client fields
118
-
119
-
**bug fixes** (PRs #580-582):
120
-
- fixed client assertion JWT to use Authorization Server's issuer as `aud` claim (not token endpoint URL)
121
-
- fixed JWKS endpoint to preserve `kid` field from original JWK
122
-
- fixed `OAuthClient` to pass `client_secret_kid` for JWT header
123
-
124
-
**atproto fork updates** (zzstoatzz/atproto#6, #7):
125
-
- added `issuer` parameter to `_make_token_request()` for correct `aud` claim
126
-
- added `client_secret_kid` parameter to include `kid` in client assertion JWT header
92
+
#### rate limit moderation endpoint (PR #629, Dec 21)
127
93
128
-
**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.
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.
129
95
130
96
---
131
97
132
-
#### pagination & album management (PRs #550-554, Dec 9-10)
98
+
#### end-of-year sprint planning (PR #626, Dec 20)
133
99
134
-
**tracks list pagination** (PR #554):
135
-
- cursor-based pagination on `/tracks/` endpoint (default 50 per page)
136
-
- infinite scroll on homepage using native IntersectionObserver
137
-
- zero new dependencies - uses browser APIs only
138
-
- pagination state persisted to localStorage for fast subsequent loads
100
+
**focus**: two foundational systems need solid experimental implementations by 2026.
139
101
140
-
**album management improvements** (PRs #550-552, #557):
141
-
- album delete and track reorder fixes
142
-
- album page edit mode matching playlist UX (inline title editing, cover upload)
143
-
- optimistic UI updates for album title changes (instant feedback)
144
-
- ATProto record sync when album title changes (updates all track records + list record)
145
-
- fixed album slug sync on rename (prevented duplicate albums when adding tracks)
102
+
| track | focus | status |
103
+
|-------|-------|--------|
104
+
| moderation | consolidate architecture, add rules engine | in progress |
105
+
| atprotofans | supporter validation, content gating | shipped (phase 1-3) |
146
106
147
-
**playlist show on profile** (PR #553):
148
-
- restored "show on profile" toggle that was lost during inline editing refactor
149
-
- users can now control whether playlists appear on their public profile
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)
150
110
151
111
---
152
112
153
-
#### public cost dashboard (PRs #548-549, Dec 9)
113
+
#### beartype + moderation cleanup (PRs #617-619, Dec 19)
154
114
155
-
- `/costs` page showing live platform infrastructure costs
156
-
- daily export to R2 via GitHub Action, proxied through `/stats/costs` endpoint
157
-
- dedicated `plyr-stats` R2 bucket with public access (shared across environments)
158
-
- includes fly.io, neon, cloudflare, and audd API costs
159
-
- ko-fi integration for community support
160
-
161
-
#### docket background tasks & concurrent exports (PRs #534-546, Dec 9)
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)
162
119
163
-
**docket integration** (PRs #534, #536, #539):
164
-
- migrated background tasks from inline asyncio to docket (Redis-backed task queue)
165
-
- copyright scanning, media export, ATProto sync, and teal scrobbling now run via docket
166
-
- graceful fallback to asyncio for local development without Redis
167
-
- parallel test execution with xdist template databases (#540)
168
-
169
-
**concurrent export downloads** (PR #545):
170
-
- exports now download tracks in parallel (up to 4 concurrent) instead of sequentially
171
-
- significantly faster for users with many tracks or large files
172
-
- zip creation remains sequential (zipfile constraint)
173
-
174
-
**ATProto refactor** (PR #534):
175
-
- reorganized ATProto record code into `_internal/atproto/records/` by lexicon namespace
176
-
- extracted `client.py` for low-level PDS operations
177
-
- cleaner separation between plyr.fm and teal.fm lexicons
178
-
179
-
**documentation & observability**:
180
-
- AudD API cost tracking dashboard (#546)
181
-
- promoted runbooks from sandbox to `docs/runbooks/`
182
-
- updated CLAUDE.md files across the codebase
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)
183
124
184
125
---
185
126
186
-
#### artist support links & inline playlist editing (PRs #520-532, Dec 8)
127
+
#### UX polish (PRs #604-607, #613, #615, Dec 16-18)
187
128
188
-
**artist support link** (PR #532):
189
-
- artists can set a support URL (Ko-fi, Patreon, etc.) in their portal profile
190
-
- support link displays as a button on artist profile pages next to the share button
191
-
- URLs validated to require https:// prefix
129
+
**login improvements** (PRs #604, #613):
130
+
- login page now uses "internet handle" terminology for clarity
131
+
- input normalization: strips `@` and `at://` prefixes automatically
192
132
193
-
**inline playlist editing** (PR #531):
194
-
- edit playlist name and description directly on playlist detail page
195
-
- click-to-upload cover art replacement without modal
196
-
- cleaner UX - no more edit modal popup
197
-
198
-
**platform stats enhancements** (PRs #522, #528):
199
-
- total duration displayed in platform stats (e.g., "42h 15m of music")
200
-
- duration shown per artist in analytics section
201
-
- combined stats and search into single centered container for cleaner layout
202
-
203
-
**navigation & data loading fixes** (PR #527):
204
-
- fixed stale data when navigating between detail pages of the same type
205
-
- e.g., clicking from one artist to another now properly reloads data
206
-
207
-
**copyright moderation improvements** (PR #480):
208
-
- enhanced moderation workflow for copyright claims
209
-
- improved labeler integration
133
+
**artist page fixes** (PR #615):
134
+
- track pagination on artist pages now works correctly
135
+
- fixed mobile album card overflow
210
136
211
-
**status maintenance workflow** (PR #529):
212
-
- automated status maintenance using claude-code-action
213
-
- reviews merged PRs and updates STATUS.md narratively
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
214
141
215
142
---
216
143
217
-
#### playlist fast-follow fixes (PRs #507-519, Dec 7-8)
144
+
#### offline mode foundation (PRs #610-611, Dec 17)
218
145
219
-
**public playlist viewing** (PR #519):
220
-
- playlists now publicly viewable without authentication
221
-
- ATProto records are public by design - auth was unnecessary for read access
222
-
- shared playlist URLs no longer redirect unauthenticated users to homepage
223
-
224
-
**inline playlist creation** (PR #510):
225
-
- clicking "create new playlist" from AddToMenu previously navigated to `/library?create=playlist`
226
-
- this caused SvelteKit to reinitialize the layout, destroying the audio element and stopping playback
227
-
- fix: added inline create form that creates playlist and adds track in one action without navigation
228
-
229
-
**UI polish** (PRs #507-509, #515):
230
-
- include `image_url` in playlist SSR data for og:image link previews
231
-
- invalidate layout data after token exchange - fixes stale auth state after login
232
-
- fixed stopPropagation blocking "create new playlist" link clicks
233
-
- detail page button layouts: all buttons visible on mobile, centered AddToMenu on track detail
234
-
- AddToMenu smart positioning: menu opens upward when near viewport bottom
235
-
236
-
**documentation** (PR #514):
237
-
- added lexicons overview documentation at `docs/lexicons/overview.md`
238
-
- covers `fm.plyr.track`, `fm.plyr.like`, `fm.plyr.comment`, `fm.plyr.list`, `fm.plyr.actor.profile`
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
239
151
240
152
---
241
153
242
-
#### playlists, ATProto sync, and library hub (PR #499, Dec 6-7)
154
+
### Earlier December 2025
243
155
244
-
**playlists** (full CRUD):
245
-
- create, rename, delete playlists with cover art upload
246
-
- add/remove/reorder tracks with drag-and-drop
247
-
- playlist detail page with edit modal
248
-
- "add to playlist" menu on tracks with inline create
249
-
- playlist sharing with OpenGraph link previews
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)
250
172
251
-
**ATProto integration**:
252
-
- `fm.plyr.list` lexicon for syncing playlists/albums to user PDSes
253
-
- `fm.plyr.actor.profile` lexicon for artist profiles
254
-
- automatic sync of albums, liked tracks, profile on login
255
-
256
-
**library hub** (`/library`):
257
-
- unified page with tabs: liked, playlists, albums
258
-
- nav changed from "liked" โ "library"
259
-
260
-
**related**: scope upgrade OAuth flow (PR #503), settings consolidation (PR #496)
261
-
262
-
---
263
-
264
-
#### sensitive image moderation (PRs #471-488, Dec 5-6)
173
+
### November 2025
265
174
266
-
- `sensitive_images` table flags problematic images
267
-
- `show_sensitive_artwork` user preference
268
-
- flagged images blurred everywhere: track lists, player, artist pages, search, embeds
269
-
- Media Session API respects sensitive preference
270
-
- SSR-safe filtering for og:image link previews
271
-
272
-
---
273
-
274
-
#### teal.fm scrobbling (PR #467, Dec 4)
275
-
276
-
- native scrobbling to user's PDS using teal's ATProto lexicons
277
-
- scrobble at 30% or 30 seconds (same threshold as play counts)
278
-
- toggle in settings, link to pdsls.dev to view records
279
-
280
-
---
281
-
282
-
### Earlier December / November 2025
283
-
284
-
See `.status_history/2025-12.md` and `.status_history/2025-11.md` for detailed history including:
285
-
- unified search with Cmd+K (PR #447)
286
-
- light/dark theme system (PR #441)
287
-
- tag filtering and bufo easter egg (PRs #431-438)
175
+
See `.status_history/2025-11.md` for detailed history including:
288
176
- developer tokens (PR #367)
289
177
- copyright moderation system (PRs #382-395)
290
178
- export & upload reliability (PRs #337-344)
···
292
180
293
181
## immediate priorities
294
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
+
295
195
### known issues
296
196
- playback auto-start on refresh (#225)
297
197
- iOS PWA audio may hang on first play after backgrounding
298
198
299
-
### immediate focus
300
-
- **moderation cleanup**: consolidate copyright detection, reduce AudD API costs, streamline labeler integration (issues #541-544)
301
-
302
-
### feature ideas
303
-
- issue #334: add 'share to bluesky' option for tracks
304
-
- issue #373: lyrics field and Genius-style annotations
305
-
306
199
### backlog
307
200
- audio transcoding pipeline integration (#153) - transcoder service deployed, integration deferred
201
+
- share to bluesky (#334)
202
+
- lyrics and annotations (#373)
308
203
309
204
## technical state
310
205
···
350
245
- โ
copyright moderation with ATProto labeler
351
246
- โ
docket background tasks (copyright scan, export, atproto sync, scrobble)
352
247
- โ
media export with concurrent downloads
248
+
- โ
supporter-gated content via atprotofans
353
249
354
250
**albums**
355
251
- โ
album CRUD with cover art
···
381
277
382
278
## cost structure
383
279
384
-
current monthly costs: ~$18/month (plyr.fm specific)
280
+
current monthly costs: ~$20/month (plyr.fm specific)
385
281
386
282
see live dashboard: [plyr.fm/costs](https://plyr.fm/costs)
387
283
388
-
- fly.io (plyr apps only): ~$12/month
389
-
- relay-api (prod): $5.80
390
-
- relay-api-staging: $5.60
391
-
- plyr-moderation: $0.24
392
-
- plyr-transcoder: $0.02
284
+
- fly.io (backend + redis + moderation): ~$14/month
393
285
- neon postgres: $5/month
394
-
- cloudflare (R2 + pages + domain): ~$1.16/month
395
-
- 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)
396
288
- logfire: $0 (free tier)
397
289
398
290
## admin tooling
···
443
335
โ โโโ src/routes/ # pages
444
336
โโโ moderation/ # Rust moderation service (ATProto labeler)
445
337
โโโ transcoder/ # Rust audio transcoding service
338
+
โโโ redis/ # self-hosted Redis config
446
339
โโโ docs/ # documentation
447
340
โโโ justfile # task runner
448
341
```
···
458
351
459
352
---
460
353
461
-
this is a living document. last updated 2025-12-16.
354
+
this is a living document. last updated 2025-12-30.
+1
backend/.env.example
+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
+58
backend/alembic/versions/2025_12_19_000905_f2380236c97b_drop_copyright_scan_resolution_columns.py
+58
backend/alembic/versions/2025_12_19_000905_f2380236c97b_drop_copyright_scan_resolution_columns.py
···
1
+
"""drop copyright scan resolution columns
2
+
3
+
Revision ID: f2380236c97b
4
+
Revises: a1b2c3d4e5f6
5
+
Create Date: 2025-12-19 00:09:05.006236
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 = "f2380236c97b"
18
+
down_revision: str | Sequence[str] | None = "a1b2c3d4e5f6"
19
+
branch_labels: str | Sequence[str] | None = None
20
+
depends_on: str | Sequence[str] | None = None
21
+
22
+
23
+
def upgrade() -> None:
24
+
"""Upgrade schema.
25
+
26
+
Remove resolution-related columns from copyright_scans.
27
+
The labeler service is now the source of truth for resolution status,
28
+
and the sync_copyright_resolutions background task updates is_flagged.
29
+
"""
30
+
op.drop_column("copyright_scans", "review_notes")
31
+
op.drop_column("copyright_scans", "reviewed_at")
32
+
op.drop_column("copyright_scans", "resolution")
33
+
op.drop_column("copyright_scans", "reviewed_by")
34
+
35
+
36
+
def downgrade() -> None:
37
+
"""Downgrade schema."""
38
+
op.add_column(
39
+
"copyright_scans",
40
+
sa.Column("reviewed_by", sa.VARCHAR(), autoincrement=False, nullable=True),
41
+
)
42
+
op.add_column(
43
+
"copyright_scans",
44
+
sa.Column("resolution", sa.VARCHAR(), autoincrement=False, nullable=True),
45
+
)
46
+
op.add_column(
47
+
"copyright_scans",
48
+
sa.Column(
49
+
"reviewed_at",
50
+
postgresql.TIMESTAMP(timezone=True),
51
+
autoincrement=False,
52
+
nullable=True,
53
+
),
54
+
)
55
+
op.add_column(
56
+
"copyright_scans",
57
+
sa.Column("review_notes", sa.VARCHAR(), autoincrement=False, nullable=True),
58
+
)
+37
backend/alembic/versions/2025_12_22_190115_9ee155c078ed_add_support_gate_to_tracks.py
+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
+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
+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
+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
+5
backend/src/backend/__init__.py
+3
backend/src/backend/_internal/__init__.py
+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
+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
+1
-1
backend/src/backend/_internal/atproto/sync.py
+121
backend/src/backend/_internal/atprotofans.py
+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
+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
+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
+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
+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
+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
+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
+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
+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:
+166
-9
backend/src/backend/api/audio.py
+166
-9
backend/src/backend/api/audio.py
···
1
1
"""audio streaming endpoint."""
2
2
3
3
import logfire
4
-
from fastapi import APIRouter, HTTPException
4
+
from fastapi import APIRouter, Depends, HTTPException, Request, Response
5
5
from fastapi.responses import RedirectResponse
6
+
from pydantic import BaseModel
6
7
from sqlalchemy import func, select
7
8
9
+
from backend._internal import Session, get_optional_session, validate_supporter
8
10
from backend.models import Track
9
11
from backend.storage import storage
10
12
from backend.utilities.database import db_session
···
12
14
router = APIRouter(prefix="/audio", tags=["audio"])
13
15
14
16
17
+
class AudioUrlResponse(BaseModel):
18
+
"""response containing direct R2 URL for offline caching."""
19
+
20
+
url: str
21
+
file_id: str
22
+
file_type: str | None
23
+
24
+
25
+
@router.head("/{file_id}")
15
26
@router.get("/{file_id}")
16
-
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
+
):
17
32
"""stream audio file by redirecting to R2 CDN URL.
18
33
19
-
looks up track to get cached r2_url and file extension,
20
-
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.
21
39
22
40
images are served directly via R2 URLs stored in the image_url field,
23
41
not through this endpoint.
24
42
"""
25
-
# 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
26
45
async with db_session() as db:
27
46
# check for duplicates (multiple tracks with same file_id)
28
47
count_result = await db.execute(
···
40
59
count=count,
41
60
)
42
61
43
-
# get the best track: prefer non-null r2_url, then newest
62
+
# get the track with gating info
44
63
result = await db.execute(
45
-
select(Track.r2_url, Track.file_type)
64
+
select(Track.r2_url, Track.file_type, Track.support_gate, Track.artist_did)
46
65
.where(Track.file_id == file_id)
47
66
.order_by(Track.r2_url.is_not(None).desc(), Track.created_at.desc())
48
67
.limit(1)
49
68
)
50
69
track_data = result.first()
70
+
r2_url, file_type, support_gate, artist_did = track_data
51
71
52
-
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
+
)
53
81
54
-
# if we have a valid r2_url cached, use it directly (zero HEADs)
82
+
# public track - use cached r2_url if available
55
83
if r2_url and r2_url.startswith("http"):
56
84
return RedirectResponse(url=r2_url)
57
85
···
60
88
if not url:
61
89
raise HTTPException(status_code=404, detail="audio file not found")
62
90
return RedirectResponse(url=url)
91
+
92
+
93
+
async def _handle_gated_audio(
94
+
file_id: str,
95
+
file_type: str,
96
+
artist_did: str,
97
+
session: Session | None,
98
+
is_head_request: bool = False,
99
+
) -> RedirectResponse | Response:
100
+
"""handle streaming for supporter-gated content.
101
+
102
+
validates that the user is authenticated and either:
103
+
- is the artist who uploaded the track, OR
104
+
- supports the artist via atprotofans
105
+
before returning a presigned URL for the private bucket.
106
+
107
+
for HEAD requests (used for pre-flight auth checks), returns 200 status
108
+
without redirecting to avoid CORS issues with cross-origin redirects.
109
+
"""
110
+
# must be authenticated to access gated content
111
+
if not session:
112
+
raise HTTPException(
113
+
status_code=401,
114
+
detail="authentication required for supporter-gated content",
115
+
)
116
+
117
+
# artist can always play their own gated tracks
118
+
if session.did == artist_did:
119
+
logfire.info(
120
+
"serving gated content to owner",
121
+
file_id=file_id,
122
+
artist_did=artist_did,
123
+
)
124
+
else:
125
+
# validate supporter status via atprotofans
126
+
validation = await validate_supporter(
127
+
supporter_did=session.did,
128
+
artist_did=artist_did,
129
+
)
130
+
131
+
if not validation.valid:
132
+
raise HTTPException(
133
+
status_code=402,
134
+
detail="this track requires supporter access",
135
+
headers={"X-Support-Required": "true"},
136
+
)
137
+
138
+
# for HEAD requests, just return 200 to confirm access
139
+
# (avoids CORS issues with cross-origin redirects)
140
+
if is_head_request:
141
+
return Response(status_code=200)
142
+
143
+
# authorized - generate presigned URL for private bucket
144
+
if session.did != artist_did:
145
+
logfire.info(
146
+
"serving gated content to supporter",
147
+
file_id=file_id,
148
+
supporter_did=session.did,
149
+
artist_did=artist_did,
150
+
)
151
+
152
+
url = await storage.generate_presigned_url(file_id=file_id, extension=file_type)
153
+
return RedirectResponse(url=url)
154
+
155
+
156
+
@router.get("/{file_id}/url")
157
+
async def get_audio_url(
158
+
file_id: str,
159
+
session: Session | None = Depends(get_optional_session),
160
+
) -> AudioUrlResponse:
161
+
"""return direct URL for audio file.
162
+
163
+
for public tracks: returns R2 CDN URL for offline caching.
164
+
for gated tracks: returns presigned URL after supporter validation.
165
+
166
+
used for offline mode - frontend fetches and caches locally.
167
+
"""
168
+
async with db_session() as db:
169
+
result = await db.execute(
170
+
select(Track.r2_url, Track.file_type, Track.support_gate, Track.artist_did)
171
+
.where(Track.file_id == file_id)
172
+
.order_by(Track.r2_url.is_not(None).desc(), Track.created_at.desc())
173
+
.limit(1)
174
+
)
175
+
track_data = result.first()
176
+
177
+
if not track_data:
178
+
raise HTTPException(status_code=404, detail="audio file not found")
179
+
180
+
r2_url, file_type, support_gate, artist_did = track_data
181
+
182
+
# check if track is gated
183
+
if support_gate is not None:
184
+
# must be authenticated
185
+
if not session:
186
+
raise HTTPException(
187
+
status_code=401,
188
+
detail="authentication required for supporter-gated content",
189
+
)
190
+
191
+
# artist can always access their own gated tracks
192
+
if session.did != artist_did:
193
+
# validate supporter status
194
+
validation = await validate_supporter(
195
+
supporter_did=session.did,
196
+
artist_did=artist_did,
197
+
)
198
+
199
+
if not validation.valid:
200
+
raise HTTPException(
201
+
status_code=402,
202
+
detail="this track requires supporter access",
203
+
headers={"X-Support-Required": "true"},
204
+
)
205
+
206
+
# return presigned URL
207
+
url = await storage.generate_presigned_url(file_id=file_id, extension=file_type)
208
+
return AudioUrlResponse(url=url, file_id=file_id, file_type=file_type)
209
+
210
+
# public track - return cached r2_url if available
211
+
if r2_url and r2_url.startswith("http"):
212
+
return AudioUrlResponse(url=r2_url, file_id=file_id, file_type=file_type)
213
+
214
+
# otherwise, resolve it
215
+
url = await storage.get_url(file_id, file_type="audio", extension=file_type)
216
+
if not url:
217
+
raise HTTPException(status_code=404, detail="audio file not found")
218
+
219
+
return AudioUrlResponse(url=url, file_id=file_id, file_type=file_type)
+17
-12
backend/src/backend/api/moderation.py
+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
+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
+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
+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
+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
-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
-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
+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
+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
+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
+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
+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
+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
-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
+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
+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
)
+351
backend/tests/api/test_audio.py
+351
backend/tests/api/test_audio.py
···
7
7
from httpx import ASGITransport, AsyncClient
8
8
from sqlalchemy.ext.asyncio import AsyncSession
9
9
10
+
from backend._internal import Session, require_auth
10
11
from backend.main import app
11
12
from backend.models import Artist, Track
13
+
14
+
15
+
@pytest.fixture
16
+
def mock_session() -> Session:
17
+
"""create a mock session for authenticated endpoints."""
18
+
return Session(
19
+
session_id="test-session-id",
20
+
did="did:plc:testuser123",
21
+
handle="testuser.bsky.social",
22
+
oauth_session={
23
+
"access_token": "test-access-token",
24
+
"refresh_token": "test-refresh-token",
25
+
"dpop_key": {},
26
+
},
27
+
)
12
28
13
29
14
30
@pytest.fixture
···
146
162
147
163
assert response.status_code == 404
148
164
assert response.json()["detail"] == "audio file not found"
165
+
166
+
167
+
# tests for /audio/{file_id}/url endpoint (offline caching, requires auth)
168
+
169
+
170
+
async def test_get_audio_url_with_cached_url(
171
+
test_app: FastAPI, test_track_with_r2_url: Track, mock_session: Session
172
+
):
173
+
"""test that /url endpoint returns cached r2_url as JSON."""
174
+
mock_storage = MagicMock()
175
+
mock_storage.get_url = AsyncMock()
176
+
177
+
test_app.dependency_overrides[require_auth] = lambda: mock_session
178
+
179
+
try:
180
+
with patch("backend.api.audio.storage", mock_storage):
181
+
async with AsyncClient(
182
+
transport=ASGITransport(app=test_app), base_url="http://test"
183
+
) as client:
184
+
response = await client.get(
185
+
f"/audio/{test_track_with_r2_url.file_id}/url"
186
+
)
187
+
188
+
assert response.status_code == 200
189
+
data = response.json()
190
+
assert data["url"] == test_track_with_r2_url.r2_url
191
+
assert data["file_id"] == test_track_with_r2_url.file_id
192
+
assert data["file_type"] == test_track_with_r2_url.file_type
193
+
# should NOT call get_url when r2_url is cached
194
+
mock_storage.get_url.assert_not_called()
195
+
finally:
196
+
test_app.dependency_overrides.pop(require_auth, None)
197
+
198
+
199
+
async def test_get_audio_url_without_cached_url(
200
+
test_app: FastAPI, test_track_without_r2_url: Track, mock_session: Session
201
+
):
202
+
"""test that /url endpoint calls storage.get_url when r2_url is None."""
203
+
expected_url = "https://cdn.example.com/audio/test456.flac"
204
+
205
+
mock_storage = MagicMock()
206
+
mock_storage.get_url = AsyncMock(return_value=expected_url)
207
+
208
+
test_app.dependency_overrides[require_auth] = lambda: mock_session
209
+
210
+
try:
211
+
with patch("backend.api.audio.storage", mock_storage):
212
+
async with AsyncClient(
213
+
transport=ASGITransport(app=test_app), base_url="http://test"
214
+
) as client:
215
+
response = await client.get(
216
+
f"/audio/{test_track_without_r2_url.file_id}/url"
217
+
)
218
+
219
+
assert response.status_code == 200
220
+
data = response.json()
221
+
assert data["url"] == expected_url
222
+
assert data["file_id"] == test_track_without_r2_url.file_id
223
+
assert data["file_type"] == test_track_without_r2_url.file_type
224
+
225
+
mock_storage.get_url.assert_called_once_with(
226
+
test_track_without_r2_url.file_id,
227
+
file_type="audio",
228
+
extension=test_track_without_r2_url.file_type,
229
+
)
230
+
finally:
231
+
test_app.dependency_overrides.pop(require_auth, None)
232
+
233
+
234
+
async def test_get_audio_url_not_found(test_app: FastAPI, mock_session: Session):
235
+
"""test that /url endpoint returns 404 for nonexistent track."""
236
+
test_app.dependency_overrides[require_auth] = lambda: mock_session
237
+
238
+
try:
239
+
async with AsyncClient(
240
+
transport=ASGITransport(app=test_app), base_url="http://test"
241
+
) as client:
242
+
response = await client.get("/audio/nonexistent/url")
243
+
244
+
assert response.status_code == 404
245
+
assert response.json()["detail"] == "audio file not found"
246
+
finally:
247
+
test_app.dependency_overrides.pop(require_auth, None)
248
+
249
+
250
+
async def test_get_audio_url_storage_returns_none(
251
+
test_app: FastAPI, test_track_without_r2_url: Track, mock_session: Session
252
+
):
253
+
"""test that /url endpoint returns 404 when storage.get_url returns None."""
254
+
mock_storage = MagicMock()
255
+
mock_storage.get_url = AsyncMock(return_value=None)
256
+
257
+
test_app.dependency_overrides[require_auth] = lambda: mock_session
258
+
259
+
try:
260
+
with patch("backend.api.audio.storage", mock_storage):
261
+
async with AsyncClient(
262
+
transport=ASGITransport(app=test_app), base_url="http://test"
263
+
) as client:
264
+
response = await client.get(
265
+
f"/audio/{test_track_without_r2_url.file_id}/url"
266
+
)
267
+
268
+
assert response.status_code == 404
269
+
assert response.json()["detail"] == "audio file not found"
270
+
finally:
271
+
test_app.dependency_overrides.pop(require_auth, None)
272
+
273
+
274
+
async def test_get_audio_url_gated_requires_auth(
275
+
test_app: FastAPI, db_session: AsyncSession
276
+
):
277
+
"""test that /url endpoint returns 401 for gated content without authentication."""
278
+
# create a gated track
279
+
artist = Artist(
280
+
did="did:plc:gatedartist",
281
+
handle="gatedartist.bsky.social",
282
+
display_name="Gated Artist",
283
+
)
284
+
db_session.add(artist)
285
+
await db_session.flush()
286
+
287
+
track = Track(
288
+
title="Gated Track",
289
+
artist_did=artist.did,
290
+
file_id="gated-test-file",
291
+
file_type="mp3",
292
+
r2_url="https://cdn.example.com/audio/gated.mp3",
293
+
support_gate={"type": "any"},
294
+
)
295
+
db_session.add(track)
296
+
await db_session.commit()
297
+
298
+
# ensure no auth override
299
+
test_app.dependency_overrides.pop(require_auth, None)
300
+
301
+
async with AsyncClient(
302
+
transport=ASGITransport(app=test_app), base_url="http://test"
303
+
) as client:
304
+
response = await client.get(f"/audio/{track.file_id}/url")
305
+
306
+
assert response.status_code == 401
307
+
assert "authentication required" in response.json()["detail"]
308
+
309
+
310
+
# gated content regression tests
311
+
312
+
313
+
@pytest.fixture
314
+
async def gated_track(db_session: AsyncSession) -> Track:
315
+
"""create a gated track for testing supporter access."""
316
+
artist = Artist(
317
+
did="did:plc:gatedowner",
318
+
handle="gatedowner.bsky.social",
319
+
display_name="Gated Owner",
320
+
)
321
+
db_session.add(artist)
322
+
await db_session.flush()
323
+
324
+
track = Track(
325
+
title="Supporters Only Track",
326
+
artist_did=artist.did,
327
+
file_id="gated-regression-test",
328
+
file_type="mp3",
329
+
r2_url=None, # no cached URL - forces presigned URL generation
330
+
support_gate={"type": "any"},
331
+
)
332
+
db_session.add(track)
333
+
await db_session.commit()
334
+
await db_session.refresh(track)
335
+
336
+
return track
337
+
338
+
339
+
@pytest.fixture
340
+
def owner_session() -> Session:
341
+
"""session for the track owner."""
342
+
return Session(
343
+
session_id="owner-session-id",
344
+
did="did:plc:gatedowner",
345
+
handle="gatedowner.bsky.social",
346
+
oauth_session={
347
+
"access_token": "owner-access-token",
348
+
"refresh_token": "owner-refresh-token",
349
+
"dpop_key": {},
350
+
},
351
+
)
352
+
353
+
354
+
@pytest.fixture
355
+
def non_supporter_session() -> Session:
356
+
"""session for a user who is not a supporter."""
357
+
return Session(
358
+
session_id="non-supporter-session-id",
359
+
did="did:plc:randomuser",
360
+
handle="randomuser.bsky.social",
361
+
oauth_session={
362
+
"access_token": "random-access-token",
363
+
"refresh_token": "random-refresh-token",
364
+
"dpop_key": {},
365
+
},
366
+
)
367
+
368
+
369
+
async def test_gated_stream_requires_auth(test_app: FastAPI, gated_track: Track):
370
+
"""regression: GET /audio/{file_id} returns 401 for gated content without auth."""
371
+
test_app.dependency_overrides.pop(require_auth, None)
372
+
373
+
async with AsyncClient(
374
+
transport=ASGITransport(app=test_app), base_url="http://test"
375
+
) as client:
376
+
response = await client.get(
377
+
f"/audio/{gated_track.file_id}", follow_redirects=False
378
+
)
379
+
380
+
assert response.status_code == 401
381
+
assert "authentication required" in response.json()["detail"]
382
+
383
+
384
+
async def test_gated_head_requires_auth(test_app: FastAPI, gated_track: Track):
385
+
"""regression: HEAD /audio/{file_id} returns 401 for gated content without auth."""
386
+
test_app.dependency_overrides.pop(require_auth, None)
387
+
388
+
async with AsyncClient(
389
+
transport=ASGITransport(app=test_app), base_url="http://test"
390
+
) as client:
391
+
response = await client.head(f"/audio/{gated_track.file_id}")
392
+
393
+
assert response.status_code == 401
394
+
395
+
396
+
async def test_gated_head_owner_allowed(
397
+
test_app: FastAPI, gated_track: Track, owner_session: Session
398
+
):
399
+
"""regression: HEAD /audio/{file_id} returns 200 for track owner."""
400
+
from backend._internal import get_optional_session
401
+
402
+
test_app.dependency_overrides[get_optional_session] = lambda: owner_session
403
+
404
+
try:
405
+
async with AsyncClient(
406
+
transport=ASGITransport(app=test_app), base_url="http://test"
407
+
) as client:
408
+
response = await client.head(f"/audio/{gated_track.file_id}")
409
+
410
+
assert response.status_code == 200
411
+
finally:
412
+
test_app.dependency_overrides.pop(get_optional_session, None)
413
+
414
+
415
+
async def test_gated_stream_owner_redirects(
416
+
test_app: FastAPI, gated_track: Track, owner_session: Session
417
+
):
418
+
"""regression: GET /audio/{file_id} returns 307 redirect for track owner."""
419
+
from backend._internal import get_optional_session
420
+
421
+
mock_storage = MagicMock()
422
+
mock_storage.generate_presigned_url = AsyncMock(
423
+
return_value="https://presigned.example.com/audio/gated.mp3"
424
+
)
425
+
426
+
test_app.dependency_overrides[get_optional_session] = lambda: owner_session
427
+
428
+
try:
429
+
with patch("backend.api.audio.storage", mock_storage):
430
+
async with AsyncClient(
431
+
transport=ASGITransport(app=test_app), base_url="http://test"
432
+
) as client:
433
+
response = await client.get(
434
+
f"/audio/{gated_track.file_id}", follow_redirects=False
435
+
)
436
+
437
+
assert response.status_code == 307
438
+
assert "presigned.example.com" in response.headers["location"]
439
+
mock_storage.generate_presigned_url.assert_called_once()
440
+
finally:
441
+
test_app.dependency_overrides.pop(get_optional_session, None)
442
+
443
+
444
+
async def test_gated_head_non_supporter_denied(
445
+
test_app: FastAPI, gated_track: Track, non_supporter_session: Session
446
+
):
447
+
"""regression: HEAD /audio/{file_id} returns 402 for non-supporter."""
448
+
from backend._internal import get_optional_session
449
+
450
+
test_app.dependency_overrides[get_optional_session] = lambda: non_supporter_session
451
+
452
+
# mock validate_supporter to return invalid
453
+
mock_validation = MagicMock()
454
+
mock_validation.valid = False
455
+
456
+
try:
457
+
with patch(
458
+
"backend.api.audio.validate_supporter",
459
+
AsyncMock(return_value=mock_validation),
460
+
):
461
+
async with AsyncClient(
462
+
transport=ASGITransport(app=test_app), base_url="http://test"
463
+
) as client:
464
+
response = await client.head(f"/audio/{gated_track.file_id}")
465
+
466
+
assert response.status_code == 402
467
+
assert response.headers.get("x-support-required") == "true"
468
+
finally:
469
+
test_app.dependency_overrides.pop(get_optional_session, None)
470
+
471
+
472
+
async def test_gated_stream_non_supporter_denied(
473
+
test_app: FastAPI, gated_track: Track, non_supporter_session: Session
474
+
):
475
+
"""regression: GET /audio/{file_id} returns 402 for non-supporter."""
476
+
from backend._internal import get_optional_session
477
+
478
+
test_app.dependency_overrides[get_optional_session] = lambda: non_supporter_session
479
+
480
+
# mock validate_supporter to return invalid
481
+
mock_validation = MagicMock()
482
+
mock_validation.valid = False
483
+
484
+
try:
485
+
with patch(
486
+
"backend.api.audio.validate_supporter",
487
+
AsyncMock(return_value=mock_validation),
488
+
):
489
+
async with AsyncClient(
490
+
transport=ASGITransport(app=test_app), base_url="http://test"
491
+
) as client:
492
+
response = await client.get(
493
+
f"/audio/{gated_track.file_id}", follow_redirects=False
494
+
)
495
+
496
+
assert response.status_code == 402
497
+
assert "supporter access" in response.json()["detail"]
498
+
finally:
499
+
test_app.dependency_overrides.pop(get_optional_session, None)
+22
-8
backend/tests/conftest.py
+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
+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
+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
+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
+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
+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
+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
+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
+
```
+40
docs/frontend/portals.md
+40
docs/frontend/portals.md
···
1
+
# portals (rendering modals outside parent DOM)
2
+
3
+
## the problem
4
+
5
+
when a modal is rendered inside an element with `backdrop-filter`, `transform`, or `filter`, the modal's `position: fixed` becomes relative to that ancestor instead of the viewport. this causes modals to be positioned incorrectly (e.g., appearing off-screen).
6
+
7
+
the header uses `backdrop-filter` for the glass blur effect, so any modal rendered inside the header will not center properly on the viewport.
8
+
9
+
## the solution
10
+
11
+
use `svelte-portal` to render modal content directly on `<body>`, outside the parent DOM hierarchy.
12
+
13
+
```bash
14
+
bun add svelte-portal
15
+
```
16
+
17
+
```svelte
18
+
<script>
19
+
import { portal } from 'svelte-portal';
20
+
</script>
21
+
22
+
<div class="menu-backdrop" use:portal={'body'} onclick={close}></div>
23
+
<div class="menu-popover" use:portal={'body'}>
24
+
<!-- modal content -->
25
+
</div>
26
+
```
27
+
28
+
the `use:portal={'body'}` action moves the element to `document.body` while preserving all svelte reactivity, bindings, and event handlers.
29
+
30
+
## when to use
31
+
32
+
use portals for any fixed-position overlay (modals, dropdowns, tooltips) that might be rendered inside:
33
+
- elements with `backdrop-filter` (glass effects)
34
+
- elements with `transform`
35
+
- elements with `filter`
36
+
- `position: sticky` containers (in some browsers)
37
+
38
+
## reference
39
+
40
+
- [svelte-portal on GitHub](https://github.com/romkor/svelte-portal)
+34
docs/frontend/state-management.md
+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
+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
docs/plans/.gitkeep
This is a binary file and will not be displayed.
docs/research/.gitkeep
docs/research/.gitkeep
This is a binary file and will not be displayed.
+122
docs/research/2025-12-18-moderation-cleanup.md
+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
+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?
+259
docs/research/2025-12-20-atprotofans-paywall-integration.md
+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
+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
+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
+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
+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`)
frontend/bun.lockb
frontend/bun.lockb
This is a binary file and will not be displayed.
+2
-1
frontend/package.json
+2
-1
frontend/package.json
+29
frontend/src/lib/breakpoints.ts
+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;
+21
-17
frontend/src/lib/components/AddToMenu.svelte
+21
-17
frontend/src/lib/components/AddToMenu.svelte
···
9
9
trackTitle: string;
10
10
trackUri?: string;
11
11
trackCid?: string;
12
+
fileId?: string;
13
+
gated?: boolean;
12
14
initialLiked?: boolean;
13
15
disabled?: boolean;
14
16
disabledReason?: string;
···
23
25
trackTitle,
24
26
trackUri,
25
27
trackCid,
28
+
fileId,
29
+
gated,
26
30
initialLiked = false,
27
31
disabled = false,
28
32
disabledReason,
···
100
104
101
105
try {
102
106
const success = liked
103
-
? await likeTrack(trackId)
107
+
? await likeTrack(trackId, fileId, gated)
104
108
: await unlikeTrack(trackId);
105
109
106
110
if (!success) {
···
435
439
justify-content: center;
436
440
background: transparent;
437
441
border: 1px solid var(--border-default);
438
-
border-radius: 4px;
442
+
border-radius: var(--radius-sm);
439
443
color: var(--text-tertiary);
440
444
cursor: pointer;
441
445
transition: all 0.2s;
···
486
490
min-width: 200px;
487
491
background: var(--bg-secondary);
488
492
border: 1px solid var(--border-default);
489
-
border-radius: 8px;
493
+
border-radius: var(--radius-md);
490
494
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
491
495
overflow: hidden;
492
496
z-index: 10;
···
506
510
background: transparent;
507
511
border: none;
508
512
color: var(--text-primary);
509
-
font-size: 0.9rem;
513
+
font-size: var(--text-base);
510
514
font-family: inherit;
511
515
cursor: pointer;
512
516
transition: background 0.15s;
···
540
544
border: none;
541
545
border-bottom: 1px solid var(--border-subtle);
542
546
color: var(--text-secondary);
543
-
font-size: 0.85rem;
547
+
font-size: var(--text-sm);
544
548
font-family: inherit;
545
549
cursor: pointer;
546
550
transition: background 0.15s;
···
563
567
564
568
.playlist-list::-webkit-scrollbar-track {
565
569
background: transparent;
566
-
border-radius: 4px;
570
+
border-radius: var(--radius-sm);
567
571
}
568
572
569
573
.playlist-list::-webkit-scrollbar-thumb {
570
574
background: var(--border-default);
571
-
border-radius: 4px;
575
+
border-radius: var(--radius-sm);
572
576
}
573
577
574
578
.playlist-list::-webkit-scrollbar-thumb:hover {
···
584
588
background: transparent;
585
589
border: none;
586
590
color: var(--text-primary);
587
-
font-size: 0.9rem;
591
+
font-size: var(--text-base);
588
592
font-family: inherit;
589
593
cursor: pointer;
590
594
transition: background 0.15s;
···
603
607
.playlist-thumb-placeholder {
604
608
width: 32px;
605
609
height: 32px;
606
-
border-radius: 4px;
610
+
border-radius: var(--radius-sm);
607
611
flex-shrink: 0;
608
612
}
609
613
···
635
639
gap: 0.5rem;
636
640
padding: 1.5rem 1rem;
637
641
color: var(--text-tertiary);
638
-
font-size: 0.85rem;
642
+
font-size: var(--text-sm);
639
643
}
640
644
641
645
.create-playlist-btn {
···
648
652
border: none;
649
653
border-top: 1px solid var(--border-subtle);
650
654
color: var(--accent);
651
-
font-size: 0.9rem;
655
+
font-size: var(--text-base);
652
656
font-family: inherit;
653
657
cursor: pointer;
654
658
transition: background 0.15s;
···
671
675
padding: 0.625rem 0.75rem;
672
676
background: var(--bg-tertiary);
673
677
border: 1px solid var(--border-default);
674
-
border-radius: 6px;
678
+
border-radius: var(--radius-base);
675
679
color: var(--text-primary);
676
680
font-family: inherit;
677
-
font-size: 0.9rem;
681
+
font-size: var(--text-base);
678
682
}
679
683
680
684
.create-form input:focus {
···
694
698
padding: 0.625rem 1rem;
695
699
background: var(--accent);
696
700
border: none;
697
-
border-radius: 6px;
701
+
border-radius: var(--radius-base);
698
702
color: white;
699
703
font-family: inherit;
700
-
font-size: 0.9rem;
704
+
font-size: var(--text-base);
701
705
font-weight: 500;
702
706
cursor: pointer;
703
707
transition: opacity 0.15s;
···
717
721
height: 16px;
718
722
border: 2px solid var(--border-default);
719
723
border-top-color: var(--accent);
720
-
border-radius: 50%;
724
+
border-radius: var(--radius-full);
721
725
animation: spin 0.8s linear infinite;
722
726
}
723
727
···
779
783
780
784
.menu-item {
781
785
padding: 1rem 1.25rem;
782
-
font-size: 1rem;
786
+
font-size: var(--text-lg);
783
787
}
784
788
785
789
.back-button {
+7
-7
frontend/src/lib/components/AlbumSelect.svelte
+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
+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
+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
+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
+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
+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
+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 {
+8
-9
frontend/src/lib/components/LikeButton.svelte
+8
-9
frontend/src/lib/components/LikeButton.svelte
···
5
5
interface Props {
6
6
trackId: number;
7
7
trackTitle: string;
8
+
fileId?: string;
9
+
gated?: boolean;
8
10
initialLiked?: boolean;
9
11
disabled?: boolean;
10
12
disabledReason?: string;
11
13
onLikeChange?: (_liked: boolean) => void;
12
14
}
13
15
14
-
let { trackId, trackTitle, initialLiked = false, disabled = false, disabledReason, onLikeChange }: Props = $props();
16
+
let { trackId, trackTitle, fileId, gated, initialLiked = false, disabled = false, disabledReason, onLikeChange }: Props = $props();
15
17
16
-
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);
17
20
let loading = $state(false);
18
21
19
-
// update liked state when initialLiked changes
20
-
$effect(() => {
21
-
liked = initialLiked;
22
-
});
23
-
24
22
async function toggleLike(e: Event) {
25
23
e.stopPropagation();
26
24
···
34
32
35
33
try {
36
34
const success = liked
37
-
? await likeTrack(trackId)
35
+
? await likeTrack(trackId, fileId, gated)
38
36
: await unlikeTrack(trackId);
39
37
40
38
if (!success) {
···
69
67
class:disabled-state={disabled}
70
68
onclick={toggleLike}
71
69
title={disabled && disabledReason ? disabledReason : (liked ? 'unlike' : 'like')}
70
+
aria-label={disabled && disabledReason ? disabledReason : (liked ? 'unlike' : 'like')}
72
71
disabled={loading || disabled}
73
72
>
74
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">
···
85
84
justify-content: center;
86
85
background: transparent;
87
86
border: 1px solid var(--border-default);
88
-
border-radius: 4px;
87
+
border-radius: var(--radius-sm);
89
88
color: var(--text-tertiary);
90
89
cursor: pointer;
91
90
transition: all 0.2s;
+9
-9
frontend/src/lib/components/LikersTooltip.svelte
+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 {
+11
-10
frontend/src/lib/components/LinksMenu.svelte
+11
-10
frontend/src/lib/components/LinksMenu.svelte
···
1
1
<script lang="ts">
2
+
import { portal } from 'svelte-portal';
2
3
import PlatformStats from './PlatformStats.svelte';
3
4
4
5
let showMenu = $state(false);
···
33
34
{#if showMenu}
34
35
<!-- svelte-ignore a11y_click_events_have_key_events -->
35
36
<!-- svelte-ignore a11y_no_static_element_interactions -->
36
-
<div class="menu-backdrop" onclick={closeMenu}></div>
37
-
<div class="menu-popover">
37
+
<div class="menu-backdrop" use:portal={'body'} onclick={closeMenu}></div>
38
+
<div class="menu-popover" use:portal={'body'}>
38
39
<div class="menu-header">
39
40
<span>links</span>
40
41
<button
···
139
140
height: 32px;
140
141
background: transparent;
141
142
border: 1px solid var(--border-default);
142
-
border-radius: 6px;
143
+
border-radius: var(--radius-base);
143
144
color: var(--text-secondary);
144
145
cursor: pointer;
145
146
transition: all 0.2s;
···
170
171
width: min(320px, calc(100vw - 2rem));
171
172
background: var(--bg-secondary);
172
173
border: 1px solid var(--border-default);
173
-
border-radius: 12px;
174
+
border-radius: var(--radius-lg);
174
175
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
175
176
z-index: 101;
176
177
animation: slideIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);
···
185
186
}
186
187
187
188
.menu-header span {
188
-
font-size: 0.9rem;
189
+
font-size: var(--text-base);
189
190
font-weight: 600;
190
191
color: var(--text-primary);
191
192
text-transform: uppercase;
···
200
201
height: 28px;
201
202
background: transparent;
202
203
border: none;
203
-
border-radius: 4px;
204
+
border-radius: var(--radius-sm);
204
205
color: var(--text-secondary);
205
206
cursor: pointer;
206
207
transition: all 0.2s;
···
223
224
gap: 1rem;
224
225
padding: 1rem;
225
226
background: transparent;
226
-
border-radius: 8px;
227
+
border-radius: var(--radius-md);
227
228
text-decoration: none;
228
229
color: var(--text-primary);
229
230
transition: all 0.2s;
···
244
245
}
245
246
246
247
.tangled-menu-icon {
247
-
border-radius: 4px;
248
+
border-radius: var(--radius-sm);
248
249
opacity: 0.7;
249
250
transition: opacity 0.2s, box-shadow 0.2s;
250
251
}
···
262
263
}
263
264
264
265
.link-title {
265
-
font-size: 0.95rem;
266
+
font-size: var(--text-base);
266
267
font-weight: 500;
267
268
color: var(--text-primary);
268
269
}
269
270
270
271
.link-subtitle {
271
-
font-size: 0.8rem;
272
+
font-size: var(--text-sm);
272
273
color: var(--text-tertiary);
273
274
}
274
275
+4
-4
frontend/src/lib/components/MigrationBanner.svelte
+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
+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;
+22
-21
frontend/src/lib/components/ProfileMenu.svelte
+22
-21
frontend/src/lib/components/ProfileMenu.svelte
···
1
1
<script lang="ts">
2
+
import { portal } from 'svelte-portal';
2
3
import { onMount } from 'svelte';
3
4
import { page } from '$app/stores';
4
5
import { queue } from '$lib/queue.svelte';
···
118
119
{#if showMenu}
119
120
<!-- svelte-ignore a11y_click_events_have_key_events -->
120
121
<!-- svelte-ignore a11y_no_static_element_interactions -->
121
-
<div class="menu-backdrop" onclick={closeMenu}></div>
122
-
<div class="menu-popover">
122
+
<div class="menu-backdrop" use:portal={'body'} onclick={closeMenu}></div>
123
+
<div class="menu-popover" use:portal={'body'}>
123
124
<div class="menu-header">
124
125
<span>{showSettings ? 'settings' : 'menu'}</span>
125
126
<button class="close-btn" onclick={closeMenu} aria-label="close">
···
275
276
height: 44px;
276
277
background: transparent;
277
278
border: 1px solid var(--border-default);
278
-
border-radius: 8px;
279
+
border-radius: var(--radius-md);
279
280
color: var(--text-secondary);
280
281
cursor: pointer;
281
282
transition: all 0.15s;
···
310
311
overflow-y: auto;
311
312
background: var(--bg-secondary);
312
313
border: 1px solid var(--border-default);
313
-
border-radius: 16px;
314
+
border-radius: var(--radius-xl);
314
315
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
315
316
z-index: 101;
316
317
animation: slideIn 0.18s cubic-bezier(0.16, 1, 0.3, 1);
···
325
326
}
326
327
327
328
.menu-header span {
328
-
font-size: 0.9rem;
329
+
font-size: var(--text-base);
329
330
font-weight: 600;
330
331
color: var(--text-primary);
331
332
text-transform: uppercase;
···
340
341
height: 36px;
341
342
background: transparent;
342
343
border: none;
343
-
border-radius: 8px;
344
+
border-radius: var(--radius-md);
344
345
color: var(--text-secondary);
345
346
cursor: pointer;
346
347
transition: all 0.15s;
···
370
371
min-height: 56px;
371
372
background: transparent;
372
373
border: none;
373
-
border-radius: 12px;
374
+
border-radius: var(--radius-lg);
374
375
text-decoration: none;
375
376
color: var(--text-primary);
376
377
font-family: inherit;
···
413
414
}
414
415
415
416
.item-title {
416
-
font-size: 0.95rem;
417
+
font-size: var(--text-base);
417
418
font-weight: 500;
418
419
color: var(--text-primary);
419
420
}
420
421
421
422
.item-subtitle {
422
-
font-size: 0.8rem;
423
+
font-size: var(--text-sm);
423
424
color: var(--text-tertiary);
424
425
overflow: hidden;
425
426
text-overflow: ellipsis;
···
439
440
padding: 0.5rem 0.75rem;
440
441
background: transparent;
441
442
border: none;
442
-
border-radius: 6px;
443
+
border-radius: var(--radius-base);
443
444
color: var(--text-secondary);
444
445
font-family: inherit;
445
-
font-size: 0.85rem;
446
+
font-size: var(--text-sm);
446
447
cursor: pointer;
447
448
transition: all 0.15s;
448
449
-webkit-tap-highlight-color: transparent;
···
468
469
469
470
.settings-section h3 {
470
471
margin: 0;
471
-
font-size: 0.75rem;
472
+
font-size: var(--text-xs);
472
473
text-transform: uppercase;
473
474
letter-spacing: 0.08em;
474
475
color: var(--text-tertiary);
···
489
490
min-height: 54px;
490
491
background: var(--bg-tertiary);
491
492
border: 1px solid var(--border-default);
492
-
border-radius: 8px;
493
+
border-radius: var(--radius-md);
493
494
color: var(--text-secondary);
494
495
cursor: pointer;
495
496
transition: all 0.15s;
···
517
518
}
518
519
519
520
.theme-btn span {
520
-
font-size: 0.7rem;
521
+
font-size: var(--text-xs);
521
522
text-transform: uppercase;
522
523
letter-spacing: 0.05em;
523
524
}
···
532
533
width: 44px;
533
534
height: 44px;
534
535
border: 1px solid var(--border-default);
535
-
border-radius: 8px;
536
+
border-radius: var(--radius-md);
536
537
cursor: pointer;
537
538
background: transparent;
538
539
flex-shrink: 0;
···
543
544
}
544
545
545
546
.color-input::-webkit-color-swatch {
546
-
border-radius: 4px;
547
+
border-radius: var(--radius-sm);
547
548
border: none;
548
549
}
549
550
···
556
557
.preset-btn {
557
558
width: 36px;
558
559
height: 36px;
559
-
border-radius: 6px;
560
+
border-radius: var(--radius-base);
560
561
border: 2px solid transparent;
561
562
cursor: pointer;
562
563
transition: all 0.15s;
···
582
583
align-items: center;
583
584
gap: 0.75rem;
584
585
color: var(--text-primary);
585
-
font-size: 0.9rem;
586
+
font-size: var(--text-base);
586
587
cursor: pointer;
587
588
padding: 0.5rem 0;
588
589
}
···
591
592
appearance: none;
592
593
width: 48px;
593
594
height: 28px;
594
-
border-radius: 999px;
595
+
border-radius: var(--radius-full);
595
596
background: var(--border-default);
596
597
position: relative;
597
598
cursor: pointer;
···
607
608
left: 3px;
608
609
width: 20px;
609
610
height: 20px;
610
-
border-radius: 50%;
611
+
border-radius: var(--radius-full);
611
612
background: var(--text-secondary);
612
613
transition: transform 0.15s, background 0.15s;
613
614
}
···
634
635
border-top: 1px solid var(--border-subtle);
635
636
color: var(--text-secondary);
636
637
text-decoration: none;
637
-
font-size: 0.9rem;
638
+
font-size: var(--text-base);
638
639
transition: color 0.15s;
639
640
}
640
641
+19
-18
frontend/src/lib/components/Queue.svelte
+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
+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
+1
-1
frontend/src/lib/components/SearchTrigger.svelte
+3
-3
frontend/src/lib/components/SensitiveImage.svelte
+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
+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
+40
frontend/src/lib/components/SupporterBadge.svelte
+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
+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
+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 {
+20
-16
frontend/src/lib/components/TrackActionsMenu.svelte
+20
-16
frontend/src/lib/components/TrackActionsMenu.svelte
···
9
9
trackTitle: string;
10
10
trackUri?: string;
11
11
trackCid?: string;
12
+
fileId?: string;
13
+
gated?: boolean;
12
14
initialLiked: boolean;
13
15
shareUrl: string;
14
16
onQueue: () => void;
···
22
24
trackTitle,
23
25
trackUri,
24
26
trackCid,
27
+
fileId,
28
+
gated,
25
29
initialLiked,
26
30
shareUrl,
27
31
onQueue,
···
99
103
100
104
try {
101
105
const success = liked
102
-
? await likeTrack(trackId)
106
+
? await likeTrack(trackId, fileId, gated)
103
107
: await unlikeTrack(trackId);
104
108
105
109
if (!success) {
···
398
402
justify-content: center;
399
403
background: transparent;
400
404
border: 1px solid var(--border-default);
401
-
border-radius: 4px;
405
+
border-radius: var(--radius-sm);
402
406
color: var(--text-tertiary);
403
407
cursor: pointer;
404
408
transition: all 0.2s;
···
470
474
}
471
475
472
476
.menu-item span {
473
-
font-size: 1rem;
477
+
font-size: var(--text-lg);
474
478
font-weight: 400;
475
479
flex: 1;
476
480
}
···
504
508
border: none;
505
509
border-bottom: 1px solid var(--border-default);
506
510
color: var(--text-secondary);
507
-
font-size: 0.9rem;
511
+
font-size: var(--text-base);
508
512
font-family: inherit;
509
513
cursor: pointer;
510
514
transition: background 0.15s;
···
530
534
border: none;
531
535
border-bottom: 1px solid var(--border-subtle);
532
536
color: var(--text-primary);
533
-
font-size: 1rem;
537
+
font-size: var(--text-lg);
534
538
font-family: inherit;
535
539
cursor: pointer;
536
540
transition: background 0.15s;
···
554
558
.playlist-thumb-placeholder {
555
559
width: 36px;
556
560
height: 36px;
557
-
border-radius: 4px;
561
+
border-radius: var(--radius-sm);
558
562
flex-shrink: 0;
559
563
}
560
564
···
586
590
gap: 0.5rem;
587
591
padding: 2rem 1rem;
588
592
color: var(--text-tertiary);
589
-
font-size: 0.9rem;
593
+
font-size: var(--text-base);
590
594
}
591
595
592
596
.create-playlist-btn {
···
599
603
border: none;
600
604
border-top: 1px solid var(--border-subtle);
601
605
color: var(--accent);
602
-
font-size: 1rem;
606
+
font-size: var(--text-lg);
603
607
font-family: inherit;
604
608
cursor: pointer;
605
609
transition: background 0.15s;
···
623
627
padding: 0.75rem 1rem;
624
628
background: var(--bg-tertiary);
625
629
border: 1px solid var(--border-default);
626
-
border-radius: 8px;
630
+
border-radius: var(--radius-md);
627
631
color: var(--text-primary);
628
632
font-family: inherit;
629
-
font-size: 1rem;
633
+
font-size: var(--text-lg);
630
634
}
631
635
632
636
.create-form input:focus {
···
646
650
padding: 0.75rem 1rem;
647
651
background: var(--accent);
648
652
border: none;
649
-
border-radius: 8px;
653
+
border-radius: var(--radius-md);
650
654
color: white;
651
655
font-family: inherit;
652
-
font-size: 1rem;
656
+
font-size: var(--text-lg);
653
657
font-weight: 500;
654
658
cursor: pointer;
655
659
transition: opacity 0.15s;
···
669
673
height: 18px;
670
674
border: 2px solid var(--border-default);
671
675
border-top-color: var(--accent);
672
-
border-radius: 50%;
676
+
border-radius: var(--radius-full);
673
677
animation: spin 0.8s linear infinite;
674
678
}
675
679
···
696
700
top: 50%;
697
701
transform: translateY(-50%);
698
702
margin-right: 0.5rem;
699
-
border-radius: 8px;
703
+
border-radius: var(--radius-md);
700
704
min-width: 180px;
701
705
max-height: none;
702
706
animation: slideIn 0.15s cubic-bezier(0.16, 1, 0.3, 1);
···
719
723
}
720
724
721
725
.menu-item span {
722
-
font-size: 0.9rem;
726
+
font-size: var(--text-base);
723
727
}
724
728
725
729
.menu-item svg {
···
733
737
734
738
.playlist-item {
735
739
padding: 0.625rem 1rem;
736
-
font-size: 0.9rem;
740
+
font-size: var(--text-base);
737
741
}
738
742
739
743
.playlist-thumb,
+158
-70
frontend/src/lib/components/TrackItem.svelte
+158
-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;
···
51
66
let hiddenTagCount = $derived(
52
67
(track.tags?.length || 0) - MAX_VISIBLE_TAGS
53
68
);
54
-
55
-
// sync counts when track changes
56
-
$effect(() => {
57
-
likeCount = track.like_count || 0;
58
-
commentCount = track.comment_count || 0;
59
-
// reset error states when track changes (e.g. recycled component)
60
-
trackImageError = false;
61
-
avatarError = false;
62
-
tagsExpanded = false;
63
-
});
64
69
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
···
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>
149
187
</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}
188
+
{/if}
189
+
{#if track.gated}
190
+
<div class="gated-badge" title="supporters only">
191
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
192
+
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/>
193
+
</svg>
194
+
</div>
195
+
{/if}
196
+
</div>
176
197
<div class="track-info">
177
198
<div class="track-title">{track.title}</div>
178
199
<div class="track-metadata">
···
284
305
trackTitle={track.title}
285
306
trackUri={track.atproto_record_uri}
286
307
trackCid={track.atproto_record_cid}
308
+
fileId={track.file_id}
309
+
gated={track.gated}
287
310
initialLiked={track.is_liked || false}
288
311
disabled={!track.atproto_record_uri}
289
312
disabledReason={!track.atproto_record_uri ? "track's record is unavailable" : undefined}
···
316
339
trackTitle={track.title}
317
340
trackUri={track.atproto_record_uri}
318
341
trackCid={track.atproto_record_cid}
342
+
fileId={track.file_id}
343
+
gated={track.gated}
319
344
initialLiked={track.is_liked || false}
320
345
shareUrl={shareUrl}
321
346
onQueue={handleQueue}
···
334
359
gap: 0.75rem;
335
360
background: var(--track-bg, var(--bg-secondary));
336
361
border: 1px solid var(--track-border, var(--border-subtle));
337
-
border-radius: 8px;
362
+
border-radius: var(--radius-md);
338
363
padding: 1rem;
339
364
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
340
365
transition:
···
345
370
346
371
.track-index {
347
372
width: 24px;
348
-
font-size: 0.85rem;
373
+
font-size: var(--text-sm);
349
374
color: var(--text-muted);
350
375
text-align: center;
351
376
flex-shrink: 0;
···
391
416
font-family: inherit;
392
417
}
393
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
+
394
451
.track-image,
395
452
.track-image-placeholder {
396
453
flex-shrink: 0;
···
399
456
display: flex;
400
457
align-items: center;
401
458
justify-content: center;
402
-
border-radius: 4px;
459
+
border-radius: var(--radius-sm);
403
460
overflow: hidden;
404
461
background: var(--bg-tertiary);
405
462
border: 1px solid var(--border-subtle);
···
433
490
}
434
491
435
492
.track-avatar img {
436
-
border-radius: 50%;
493
+
border-radius: var(--radius-full);
437
494
border: 2px solid var(--border-default);
438
495
transition: border-color 0.2s;
439
496
}
···
483
540
align-items: flex-start;
484
541
gap: 0.15rem;
485
542
color: var(--text-secondary);
486
-
font-size: 0.9rem;
543
+
font-size: var(--text-base);
487
544
font-family: inherit;
488
545
min-width: 0;
489
546
width: 100%;
···
503
560
504
561
.metadata-separator {
505
562
display: none;
506
-
font-size: 0.7rem;
563
+
font-size: var(--text-xs);
507
564
}
508
565
509
566
.artist-link {
···
602
659
padding: 0.1rem 0.4rem;
603
660
background: color-mix(in srgb, var(--accent) 15%, transparent);
604
661
color: var(--accent-hover);
605
-
border-radius: 3px;
606
-
font-size: 0.75rem;
662
+
border-radius: var(--radius-sm);
663
+
font-size: var(--text-xs);
607
664
font-weight: 500;
608
665
text-decoration: none;
609
666
transition: all 0.15s;
···
622
679
background: var(--bg-tertiary);
623
680
color: var(--text-muted);
624
681
border: none;
625
-
border-radius: 3px;
626
-
font-size: 0.75rem;
682
+
border-radius: var(--radius-sm);
683
+
font-size: var(--text-xs);
627
684
font-weight: 500;
628
685
font-family: inherit;
629
686
cursor: pointer;
···
638
695
}
639
696
640
697
.track-meta {
641
-
font-size: 0.8rem;
698
+
font-size: var(--text-sm);
642
699
color: var(--text-tertiary);
643
700
display: flex;
644
701
align-items: center;
···
652
709
653
710
.meta-separator {
654
711
color: var(--text-muted);
655
-
font-size: 0.7rem;
712
+
font-size: var(--text-xs);
656
713
}
657
714
658
715
.likes {
···
699
756
justify-content: center;
700
757
background: transparent;
701
758
border: 1px solid var(--border-default);
702
-
border-radius: 4px;
759
+
border-radius: var(--radius-sm);
703
760
color: var(--text-tertiary);
704
761
cursor: pointer;
705
762
transition: all 0.2s;
···
710
767
background: var(--bg-tertiary);
711
768
border-color: var(--accent);
712
769
color: var(--accent);
770
+
}
771
+
772
+
.action-button:disabled {
773
+
opacity: 0.6;
774
+
cursor: not-allowed;
713
775
}
714
776
715
777
.action-button svg {
···
739
801
gap: 0.5rem;
740
802
}
741
803
804
+
.track-image-wrapper,
742
805
.track-image,
743
806
.track-image-placeholder,
744
807
.track-avatar {
···
746
809
height: 40px;
747
810
}
748
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
+
749
824
.track-title {
750
-
font-size: 0.9rem;
825
+
font-size: var(--text-base);
751
826
}
752
827
753
828
.track-metadata {
754
-
font-size: 0.8rem;
829
+
font-size: var(--text-sm);
755
830
gap: 0.35rem;
756
831
}
757
832
758
833
.track-meta {
759
-
font-size: 0.7rem;
834
+
font-size: var(--text-xs);
760
835
}
761
836
762
837
.track-actions {
···
779
854
padding: 0.5rem 0.65rem;
780
855
}
781
856
857
+
.track-image-wrapper,
782
858
.track-image,
783
859
.track-image-placeholder,
784
860
.track-avatar {
···
786
862
height: 36px;
787
863
}
788
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
+
789
877
.track-title {
790
-
font-size: 0.85rem;
878
+
font-size: var(--text-sm);
791
879
}
792
880
793
881
.track-metadata {
794
-
font-size: 0.75rem;
882
+
font-size: var(--text-xs);
795
883
}
796
884
797
885
.metadata-separator {
+1
-1
frontend/src/lib/components/WaveLoading.svelte
+1
-1
frontend/src/lib/components/WaveLoading.svelte
+6
-6
frontend/src/lib/components/player/PlaybackControls.svelte
+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
+171
-7
frontend/src/lib/components/player/Player.svelte
+171
-7
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';
9
+
import { getCachedAudioUrl } from '$lib/storage';
8
10
import { onMount } from 'svelte';
9
11
import { page } from '$app/stores';
10
12
import TrackInfo from './TrackInfo.svelte';
11
13
import PlaybackControls from './PlaybackControls.svelte';
12
14
import type { Track } from '$lib/types';
15
+
16
+
// atprotofans base URL for supporter CTAs
17
+
const ATPROTOFANS_URL = 'https://atprotofans.com';
13
18
14
19
// check if artwork should be shown in media session (respects sensitive content settings)
15
20
function shouldShowArtwork(url: string | null | undefined): boolean {
···
238
243
);
239
244
});
240
245
246
+
// gated content error types
247
+
interface GatedError {
248
+
type: 'gated';
249
+
artistDid: string;
250
+
artistHandle: string;
251
+
requiresAuth: boolean;
252
+
}
253
+
254
+
// get audio source URL - checks local cache first, falls back to network
255
+
// throws GatedError if the track requires supporter access
256
+
async function getAudioSource(file_id: string, track: Track): Promise<string> {
257
+
try {
258
+
const cachedUrl = await getCachedAudioUrl(file_id);
259
+
if (cachedUrl) {
260
+
return cachedUrl;
261
+
}
262
+
} catch (err) {
263
+
console.error('failed to check audio cache:', err);
264
+
}
265
+
266
+
// for gated tracks, check authorization first
267
+
if (track.gated) {
268
+
const response = await fetch(`${API_URL}/audio/${file_id}`, {
269
+
method: 'HEAD',
270
+
credentials: 'include'
271
+
});
272
+
273
+
if (response.status === 401) {
274
+
throw {
275
+
type: 'gated',
276
+
artistDid: track.artist_did,
277
+
artistHandle: track.artist_handle,
278
+
requiresAuth: true
279
+
} as GatedError;
280
+
}
281
+
282
+
if (response.status === 402) {
283
+
throw {
284
+
type: 'gated',
285
+
artistDid: track.artist_did,
286
+
artistHandle: track.artist_handle,
287
+
requiresAuth: false
288
+
} as GatedError;
289
+
}
290
+
}
291
+
292
+
return `${API_URL}/audio/${file_id}`;
293
+
}
294
+
295
+
// track blob URLs we've created so we can revoke them
296
+
let currentBlobUrl: string | null = null;
297
+
298
+
function cleanupBlobUrl() {
299
+
if (currentBlobUrl) {
300
+
URL.revokeObjectURL(currentBlobUrl);
301
+
currentBlobUrl = null;
302
+
}
303
+
}
304
+
241
305
// handle track changes - load new audio when track changes
242
306
let previousTrackId = $state<number | null>(null);
243
307
let isLoadingTrack = $state(false);
244
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
+
245
317
$effect(() => {
246
318
if (!player.currentTrack || !player.audioElement) return;
247
319
248
320
// only load new track if it actually changed
249
321
if (player.currentTrack.id !== previousTrackId) {
250
-
previousTrackId = player.currentTrack.id;
322
+
const trackToLoad = player.currentTrack;
323
+
const audioElement = player.audioElement;
324
+
325
+
// save current playback state BEFORE changing anything
326
+
// (only if we have a playing/paused track to restore to)
327
+
if (previousTrackId !== null && audioElement.src && !audioElement.src.startsWith('blob:')) {
328
+
const prevTrack = queue.tracks.find((t) => t.id === previousTrackId);
329
+
if (prevTrack) {
330
+
savedPlaybackState = {
331
+
track: prevTrack,
332
+
src: audioElement.src,
333
+
currentTime: audioElement.currentTime,
334
+
paused: audioElement.paused
335
+
};
336
+
}
337
+
}
338
+
339
+
// update tracking state
340
+
previousTrackId = trackToLoad.id;
251
341
player.resetPlayCount();
252
342
isLoadingTrack = true;
253
343
254
-
player.audioElement.src = `${API_URL}/audio/${player.currentTrack.file_id}`;
255
-
player.audioElement.load();
344
+
// cleanup previous blob URL before loading new track
345
+
cleanupBlobUrl();
346
+
347
+
// async: get audio source (cached or network)
348
+
getAudioSource(trackToLoad.file_id, trackToLoad)
349
+
.then((src) => {
350
+
// check if track is still current (user may have changed tracks during await)
351
+
if (player.currentTrack?.id !== trackToLoad.id || !player.audioElement) {
352
+
// track changed, cleanup if we created a blob URL
353
+
if (src.startsWith('blob:')) {
354
+
URL.revokeObjectURL(src);
355
+
}
356
+
return;
357
+
}
358
+
359
+
// successfully got source - clear saved state
360
+
savedPlaybackState = null;
361
+
362
+
// track if this is a blob URL so we can revoke it later
363
+
if (src.startsWith('blob:')) {
364
+
currentBlobUrl = src;
365
+
}
366
+
367
+
player.audioElement.src = src;
368
+
player.audioElement.load();
369
+
370
+
// wait for audio to be ready before allowing playback
371
+
player.audioElement.addEventListener(
372
+
'loadeddata',
373
+
() => {
374
+
isLoadingTrack = false;
375
+
},
376
+
{ once: true }
377
+
);
378
+
})
379
+
.catch((err) => {
380
+
isLoadingTrack = false;
256
381
257
-
// wait for audio to be ready before allowing playback
258
-
player.audioElement.addEventListener('loadeddata', () => {
259
-
isLoadingTrack = false;
260
-
}, { once: true });
382
+
// handle gated content errors with supporter CTA
383
+
if (err && err.type === 'gated') {
384
+
const gatedErr = err as GatedError;
385
+
386
+
if (gatedErr.requiresAuth) {
387
+
toast.info('sign in to play supporter-only tracks');
388
+
} else {
389
+
// show toast with supporter CTA
390
+
const supportUrl = gatedErr.artistDid
391
+
? `${ATPROTOFANS_URL}/${gatedErr.artistDid}`
392
+
: `${ATPROTOFANS_URL}/${gatedErr.artistHandle}`;
393
+
394
+
toast.info('this track is for supporters only', 5000, {
395
+
label: 'become a supporter',
396
+
href: supportUrl
397
+
});
398
+
}
399
+
400
+
// restore previous playback if we had something playing
401
+
if (savedPlaybackState && player.audioElement) {
402
+
player.currentTrack = savedPlaybackState.track;
403
+
previousTrackId = savedPlaybackState.track.id;
404
+
player.audioElement.src = savedPlaybackState.src;
405
+
player.audioElement.currentTime = savedPlaybackState.currentTime;
406
+
if (!savedPlaybackState.paused) {
407
+
player.audioElement.play().catch(() => {});
408
+
}
409
+
savedPlaybackState = null;
410
+
return;
411
+
}
412
+
413
+
// no previous state to restore - skip to next or stop
414
+
if (queue.hasNext) {
415
+
queue.next();
416
+
} else {
417
+
player.currentTrack = null;
418
+
player.paused = true;
419
+
}
420
+
return;
421
+
}
422
+
423
+
console.error('failed to load audio:', err);
424
+
});
261
425
}
262
426
});
263
427
+4
-4
frontend/src/lib/components/player/TrackInfo.svelte
+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
+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
+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
+
}
+22
-3
frontend/src/lib/preferences.svelte.ts
+22
-3
frontend/src/lib/preferences.svelte.ts
···
23
23
show_liked_on_profile: boolean;
24
24
support_url: string | null;
25
25
ui_settings: UiSettings;
26
+
auto_download_liked: boolean;
26
27
}
27
28
28
29
const DEFAULT_PREFERENCES: Preferences = {
···
36
37
show_sensitive_artwork: false,
37
38
show_liked_on_profile: false,
38
39
support_url: null,
39
-
ui_settings: {}
40
+
ui_settings: {},
41
+
auto_download_liked: false
40
42
};
41
43
42
44
class PreferencesManager {
···
92
94
return this.data?.ui_settings ?? DEFAULT_PREFERENCES.ui_settings;
93
95
}
94
96
97
+
get autoDownloadLiked(): boolean {
98
+
return this.data?.auto_download_liked ?? DEFAULT_PREFERENCES.auto_download_liked;
99
+
}
100
+
101
+
setAutoDownloadLiked(enabled: boolean): void {
102
+
if (browser) {
103
+
localStorage.setItem('autoDownloadLiked', enabled ? '1' : '0');
104
+
}
105
+
if (this.data) {
106
+
this.data = { ...this.data, auto_download_liked: enabled };
107
+
}
108
+
}
109
+
95
110
setTheme(theme: Theme): void {
96
111
if (browser) {
97
112
localStorage.setItem('theme', theme);
···
134
149
});
135
150
if (response.ok) {
136
151
const data = await response.json();
152
+
// auto_download_liked is stored locally since it's device-specific
153
+
const storedAutoDownload = localStorage.getItem('autoDownloadLiked') === '1';
137
154
this.data = {
138
155
accent_color: data.accent_color ?? null,
139
156
auto_advance: data.auto_advance ?? DEFAULT_PREFERENCES.auto_advance,
···
145
162
show_sensitive_artwork: data.show_sensitive_artwork ?? DEFAULT_PREFERENCES.show_sensitive_artwork,
146
163
show_liked_on_profile: data.show_liked_on_profile ?? DEFAULT_PREFERENCES.show_liked_on_profile,
147
164
support_url: data.support_url ?? DEFAULT_PREFERENCES.support_url,
148
-
ui_settings: data.ui_settings ?? DEFAULT_PREFERENCES.ui_settings
165
+
ui_settings: data.ui_settings ?? DEFAULT_PREFERENCES.ui_settings,
166
+
auto_download_liked: storedAutoDownload
149
167
};
150
168
} else {
151
-
this.data = { ...DEFAULT_PREFERENCES, theme: currentTheme };
169
+
const storedAutoDownload = localStorage.getItem('autoDownloadLiked') === '1';
170
+
this.data = { ...DEFAULT_PREFERENCES, theme: currentTheme, auto_download_liked: storedAutoDownload };
152
171
}
153
172
// apply theme after fetching
154
173
if (browser) {
+328
frontend/src/lib/storage.ts
+328
frontend/src/lib/storage.ts
···
1
+
/**
2
+
* offline storage layer for plyr.fm
3
+
*
4
+
* uses Cache API for audio bytes (large binary files, quota-managed)
5
+
* uses IndexedDB for download metadata (queryable, persistent)
6
+
*
7
+
* this module bypasses the service worker entirely - we fetch directly
8
+
* from R2 and store in Cache API from the main thread. this avoids
9
+
* iOS PWA issues with service worker + redirects + range requests.
10
+
*/
11
+
12
+
/* eslint-disable no-undef */
13
+
14
+
import { API_URL } from './config';
15
+
16
+
// cache name for audio files
17
+
const AUDIO_CACHE_NAME = 'plyr-audio-v1';
18
+
19
+
// IndexedDB config
20
+
const DB_NAME = 'plyr-offline';
21
+
const DB_VERSION = 1;
22
+
const DOWNLOADS_STORE = 'downloads';
23
+
24
+
// in-flight downloads (prevents duplicate concurrent downloads)
25
+
const activeDownloads = new Map<string, Promise<void>>();
26
+
27
+
export interface DownloadRecord {
28
+
file_id: string;
29
+
size: number;
30
+
downloaded_at: number;
31
+
file_type: string | null;
32
+
}
33
+
34
+
// IndexedDB helpers
35
+
36
+
function openDatabase(): Promise<IDBDatabase> {
37
+
return new Promise((resolve, reject) => {
38
+
const request = indexedDB.open(DB_NAME, DB_VERSION);
39
+
40
+
request.onerror = () => reject(request.error);
41
+
request.onsuccess = () => resolve(request.result);
42
+
43
+
request.onupgradeneeded = (event) => {
44
+
const db = (event.target as IDBOpenDBRequest).result;
45
+
46
+
if (!db.objectStoreNames.contains(DOWNLOADS_STORE)) {
47
+
const store = db.createObjectStore(DOWNLOADS_STORE, { keyPath: 'file_id' });
48
+
store.createIndex('downloaded_at', 'downloaded_at', { unique: false });
49
+
}
50
+
};
51
+
});
52
+
}
53
+
54
+
/**
55
+
* run a transaction against IndexedDB, properly closing the connection after
56
+
*/
57
+
async function withDatabase<T>(
58
+
mode: IDBTransactionMode,
59
+
operation: (store: IDBObjectStore) => IDBRequest<T>
60
+
): Promise<T> {
61
+
const db = await openDatabase();
62
+
try {
63
+
return await new Promise<T>((resolve, reject) => {
64
+
const tx = db.transaction(DOWNLOADS_STORE, mode);
65
+
const store = tx.objectStore(DOWNLOADS_STORE);
66
+
const request = operation(store);
67
+
68
+
request.onerror = () => reject(request.error);
69
+
request.onsuccess = () => resolve(request.result);
70
+
});
71
+
} finally {
72
+
db.close();
73
+
}
74
+
}
75
+
76
+
async function getDownloadRecord(file_id: string): Promise<DownloadRecord | null> {
77
+
const result = await withDatabase('readonly', (store) => store.get(file_id));
78
+
return result || null;
79
+
}
80
+
81
+
async function setDownloadRecord(record: DownloadRecord): Promise<void> {
82
+
await withDatabase('readwrite', (store) => store.put(record));
83
+
}
84
+
85
+
async function deleteDownloadRecord(file_id: string): Promise<void> {
86
+
await withDatabase('readwrite', (store) => store.delete(file_id));
87
+
}
88
+
89
+
/**
90
+
* get all download records from IndexedDB
91
+
*/
92
+
export async function getAllDownloads(): Promise<DownloadRecord[]> {
93
+
return withDatabase('readonly', (store) => store.getAll());
94
+
}
95
+
96
+
// Cache API helpers
97
+
98
+
/**
99
+
* get the cache key for an audio file
100
+
* Cache API requires http/https URLs, so we use a fake but valid URL
101
+
*/
102
+
function getCacheKey(file_id: string): string {
103
+
return `https://plyr.fm/_offline/${file_id}`;
104
+
}
105
+
106
+
/**
107
+
* check if audio is cached locally
108
+
* verifies both metadata (IndexedDB) and actual audio (Cache API) exist
109
+
*/
110
+
export async function isDownloaded(file_id: string): Promise<boolean> {
111
+
const record = await getDownloadRecord(file_id);
112
+
if (!record) return false;
113
+
114
+
// also verify the cache entry exists (handles edge case where cache was
115
+
// cleared but IndexedDB wasn't)
116
+
try {
117
+
const cache = await caches.open(AUDIO_CACHE_NAME);
118
+
const cached = await cache.match(getCacheKey(file_id));
119
+
if (!cached) {
120
+
// cache is gone but metadata exists - clean up the stale record
121
+
await deleteDownloadRecord(file_id);
122
+
return false;
123
+
}
124
+
return true;
125
+
} catch {
126
+
return false;
127
+
}
128
+
}
129
+
130
+
/**
131
+
* get cached audio as a blob URL for playback
132
+
* returns null if not cached
133
+
*/
134
+
export async function getCachedAudioUrl(file_id: string): Promise<string | null> {
135
+
try {
136
+
const cache = await caches.open(AUDIO_CACHE_NAME);
137
+
const response = await cache.match(getCacheKey(file_id));
138
+
139
+
if (!response) {
140
+
return null;
141
+
}
142
+
143
+
const blob = await response.blob();
144
+
return URL.createObjectURL(blob);
145
+
} catch (error) {
146
+
console.error('failed to get cached audio:', error);
147
+
return null;
148
+
}
149
+
}
150
+
151
+
/**
152
+
* download audio file and cache it locally
153
+
*
154
+
* 1. fetch presigned URL from backend
155
+
* 2. fetch audio from R2 directly
156
+
* 3. store in Cache API
157
+
* 4. record in IndexedDB
158
+
*
159
+
* if a download for this file_id is already in progress, returns
160
+
* the existing promise (prevents duplicate downloads from concurrent calls)
161
+
*/
162
+
export async function downloadAudio(
163
+
file_id: string,
164
+
onProgress?: (loaded: number, total: number) => void
165
+
): Promise<void> {
166
+
// if already downloading this file, return existing promise
167
+
const existing = activeDownloads.get(file_id);
168
+
if (existing) return existing;
169
+
170
+
const downloadPromise = (async () => {
171
+
try {
172
+
await performDownload(file_id, onProgress);
173
+
} finally {
174
+
activeDownloads.delete(file_id);
175
+
}
176
+
})();
177
+
178
+
activeDownloads.set(file_id, downloadPromise);
179
+
return downloadPromise;
180
+
}
181
+
182
+
async function performDownload(
183
+
file_id: string,
184
+
onProgress?: (loaded: number, total: number) => void
185
+
): Promise<void> {
186
+
// 1. get presigned URL from backend
187
+
const urlResponse = await fetch(`${API_URL}/audio/${file_id}/url`, {
188
+
credentials: 'include'
189
+
});
190
+
191
+
if (!urlResponse.ok) {
192
+
throw new Error(`failed to get audio URL: ${urlResponse.status}`);
193
+
}
194
+
195
+
const { url, file_type } = await urlResponse.json();
196
+
197
+
// 2. fetch audio from R2
198
+
const audioResponse = await fetch(url);
199
+
200
+
if (!audioResponse.ok) {
201
+
throw new Error(`failed to fetch audio: ${audioResponse.status}`);
202
+
}
203
+
204
+
// get total size for progress tracking
205
+
const contentLength = audioResponse.headers.get('content-length');
206
+
const total = contentLength ? parseInt(contentLength, 10) : 0;
207
+
208
+
// read the response as a blob, tracking progress if callback provided
209
+
let blob: Blob;
210
+
211
+
if (onProgress && audioResponse.body && total > 0) {
212
+
const reader = audioResponse.body.getReader();
213
+
const chunks: BlobPart[] = [];
214
+
let loaded = 0;
215
+
216
+
while (true) {
217
+
const { done, value } = await reader.read();
218
+
if (done) break;
219
+
220
+
chunks.push(value);
221
+
loaded += value.length;
222
+
onProgress(loaded, total);
223
+
}
224
+
225
+
blob = new Blob(chunks, {
226
+
type: audioResponse.headers.get('content-type') || 'audio/mpeg'
227
+
});
228
+
} else {
229
+
blob = await audioResponse.blob();
230
+
}
231
+
232
+
// 3. store in Cache API
233
+
const cache = await caches.open(AUDIO_CACHE_NAME);
234
+
const cacheResponse = new Response(blob, {
235
+
headers: {
236
+
'content-type': blob.type,
237
+
'content-length': blob.size.toString()
238
+
}
239
+
});
240
+
await cache.put(getCacheKey(file_id), cacheResponse);
241
+
242
+
// 4. record in IndexedDB
243
+
await setDownloadRecord({
244
+
file_id,
245
+
size: blob.size,
246
+
downloaded_at: Date.now(),
247
+
file_type
248
+
});
249
+
}
250
+
251
+
/**
252
+
* remove downloaded audio from cache and IndexedDB
253
+
*/
254
+
export async function removeDownload(file_id: string): Promise<void> {
255
+
// remove from Cache API
256
+
const cache = await caches.open(AUDIO_CACHE_NAME);
257
+
await cache.delete(getCacheKey(file_id));
258
+
259
+
// remove from IndexedDB
260
+
await deleteDownloadRecord(file_id);
261
+
}
262
+
263
+
/**
264
+
* get storage usage estimate
265
+
* returns bytes used and quota
266
+
*/
267
+
export async function getStorageUsage(): Promise<{ used: number; quota: number }> {
268
+
if ('storage' in navigator && 'estimate' in navigator.storage) {
269
+
const estimate = await navigator.storage.estimate();
270
+
return {
271
+
used: estimate.usage || 0,
272
+
quota: estimate.quota || 0
273
+
};
274
+
}
275
+
276
+
// fallback: sum up our download records
277
+
const downloads = await getAllDownloads();
278
+
const used = downloads.reduce((sum, d) => sum + d.size, 0);
279
+
280
+
return { used, quota: 0 };
281
+
}
282
+
283
+
/**
284
+
* clear all downloaded audio
285
+
*/
286
+
export async function clearAllDownloads(): Promise<void> {
287
+
// clear Cache API
288
+
await caches.delete(AUDIO_CACHE_NAME);
289
+
290
+
// clear IndexedDB
291
+
await withDatabase('readwrite', (store) => store.clear());
292
+
}
293
+
294
+
/**
295
+
* download all liked tracks that aren't already cached
296
+
* returns the number of tracks that were downloaded
297
+
*/
298
+
export async function downloadAllLikedTracks(): Promise<number> {
299
+
// fetch liked tracks from API
300
+
const response = await fetch(`${API_URL}/tracks/liked`, {
301
+
credentials: 'include'
302
+
});
303
+
304
+
if (!response.ok) {
305
+
throw new Error(`failed to fetch liked tracks: ${response.status}`);
306
+
}
307
+
308
+
const data = await response.json();
309
+
const tracks = data.tracks as { file_id: string }[];
310
+
311
+
let downloadedCount = 0;
312
+
313
+
// download each track that isn't already cached
314
+
for (const track of tracks) {
315
+
try {
316
+
const alreadyDownloaded = await isDownloaded(track.file_id);
317
+
if (!alreadyDownloaded) {
318
+
await downloadAudio(track.file_id);
319
+
downloadedCount++;
320
+
}
321
+
} catch (err) {
322
+
console.error(`failed to download track ${track.file_id}:`, err);
323
+
// continue with other tracks
324
+
}
325
+
}
326
+
327
+
return downloadedCount;
328
+
}
+20
-1
frontend/src/lib/tracks.svelte.ts
+20
-1
frontend/src/lib/tracks.svelte.ts
···
1
1
import { API_URL } from './config';
2
2
import type { Track } from './types';
3
+
import { preferences } from './preferences.svelte';
4
+
import { downloadAudio, isDownloaded } from './storage';
3
5
4
6
interface TracksApiResponse {
5
7
tracks: Track[];
···
131
133
export const tracksCache = new TracksCache();
132
134
133
135
// like/unlike track functions
134
-
export async function likeTrack(trackId: number): 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> {
135
138
try {
136
139
const response = await fetch(`${API_URL}/tracks/${trackId}/like`, {
137
140
method: 'POST',
···
144
147
145
148
// invalidate cache so next fetch gets updated like status
146
149
tracksCache.invalidate();
150
+
151
+
// auto-download if preference is enabled and file_id provided
152
+
// skip download only if track is gated AND viewer lacks access (gated === true)
153
+
if (fileId && preferences.autoDownloadLiked && gated !== true) {
154
+
try {
155
+
const alreadyDownloaded = await isDownloaded(fileId);
156
+
if (!alreadyDownloaded) {
157
+
// download in background, don't await
158
+
downloadAudio(fileId).catch((err) => {
159
+
console.error('auto-download failed:', err);
160
+
});
161
+
}
162
+
} catch (err) {
163
+
console.error('failed to check/download:', err);
164
+
}
165
+
}
147
166
148
167
return true;
149
168
} catch (e) {
+7
frontend/src/lib/types.ts
+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
+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
+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>
+33
-4
frontend/src/routes/+layout.svelte
+33
-4
frontend/src/routes/+layout.svelte
···
29
29
$page.url.pathname === '/' || // homepage
30
30
$page.url.pathname.startsWith('/track/') || // track detail
31
31
$page.url.pathname.startsWith('/playlist/') || // playlist detail
32
+
$page.url.pathname.startsWith('/tag/') || // tag detail
32
33
$page.url.pathname === '/liked' || // liked tracks
33
34
$page.url.pathname.match(/^\/u\/[^/]+$/) || // artist detail
34
35
$page.url.pathname.match(/^\/u\/[^/]+\/album\/[^/]+/) // album detail
···
449
450
--text-muted: #666666;
450
451
451
452
/* typography scale */
452
-
--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);
453
463
--text-section-heading: 1.2rem;
454
-
--text-body: 1rem;
455
-
--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;
456
475
457
476
/* semantic */
458
477
--success: #4ade80;
···
515
534
color: var(--accent-muted);
516
535
}
517
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
+
518
547
:global(body) {
519
548
margin: 0;
520
549
padding: 0;
···
588
617
right: 20px;
589
618
width: 48px;
590
619
height: 48px;
591
-
border-radius: 50%;
620
+
border-radius: var(--radius-full);
592
621
background: var(--bg-secondary);
593
622
border: 1px solid var(--border-default);
594
623
color: var(--text-secondary);
+8
-2
frontend/src/routes/+layout.ts
+8
-2
frontend/src/routes/+layout.ts
···
23
23
show_sensitive_artwork: false,
24
24
show_liked_on_profile: false,
25
25
support_url: null,
26
-
ui_settings: {}
26
+
ui_settings: {},
27
+
auto_download_liked: false
27
28
};
28
29
29
30
export async function load({ fetch, data }: LoadEvent): Promise<LayoutData> {
···
54
55
});
55
56
if (prefsResponse.ok) {
56
57
const prefsData = await prefsResponse.json();
58
+
// auto_download_liked is stored locally, not on server
59
+
const storedAutoDownload = typeof localStorage !== 'undefined'
60
+
? localStorage.getItem('autoDownloadLiked') === '1'
61
+
: false;
57
62
preferences = {
58
63
accent_color: prefsData.accent_color ?? null,
59
64
auto_advance: prefsData.auto_advance ?? true,
···
65
70
show_sensitive_artwork: prefsData.show_sensitive_artwork ?? false,
66
71
show_liked_on_profile: prefsData.show_liked_on_profile ?? false,
67
72
support_url: prefsData.support_url ?? null,
68
-
ui_settings: prefsData.ui_settings ?? {}
73
+
ui_settings: prefsData.ui_settings ?? {},
74
+
auto_download_liked: storedAutoDownload
69
75
};
70
76
}
71
77
} catch (e) {
+1
-1
frontend/src/routes/+page.svelte
+1
-1
frontend/src/routes/+page.svelte
+146
-38
frontend/src/routes/costs/+page.svelte
+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
+1
-1
frontend/src/routes/embed/track/[id]/+page.svelte
+64
-26
frontend/src/routes/library/+page.svelte
+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
+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
+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
+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
+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
+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
+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;
+106
-47
frontend/src/routes/settings/+page.svelte
+106
-47
frontend/src/routes/settings/+page.svelte
···
24
24
let backgroundImageUrl = $derived(preferences.uiSettings.background_image_url ?? '');
25
25
let backgroundTile = $derived(preferences.uiSettings.background_tile ?? false);
26
26
let usePlayingArtwork = $derived(preferences.uiSettings.use_playing_artwork_as_background ?? false);
27
+
let autoDownloadLiked = $derived(preferences.autoDownloadLiked);
27
28
// developer token state
28
29
let creatingToken = $state(false);
29
30
let developerToken = $state<string | null>(null);
···
190
191
queue.setAutoAdvance(value);
191
192
localStorage.setItem('autoAdvance', value ? '1' : '0');
192
193
await preferences.update({ auto_advance: value });
194
+
}
195
+
196
+
function handleAutoDownloadToggle(enabled: boolean) {
197
+
preferences.setAutoDownloadLiked(enabled);
198
+
199
+
if (enabled) {
200
+
// start downloading existing liked tracks in background (non-blocking)
201
+
toast.success('downloading liked tracks in background...');
202
+
import('$lib/storage').then(({ downloadAllLikedTracks }) => {
203
+
downloadAllLikedTracks().then((count) => {
204
+
if (count > 0) {
205
+
toast.success(`downloaded ${count} track${count === 1 ? '' : 's'} for offline`);
206
+
} else {
207
+
toast.success('all liked tracks already downloaded');
208
+
}
209
+
}).catch((err) => {
210
+
console.error('failed to download liked tracks:', err);
211
+
toast.error('failed to download some tracks');
212
+
});
213
+
});
214
+
} else {
215
+
toast.success('auto-download disabled');
216
+
}
193
217
}
194
218
195
219
// preferences
···
511
535
type="checkbox"
512
536
checked={autoAdvance}
513
537
onchange={handleAutoAdvanceToggle}
538
+
/>
539
+
<span class="toggle-slider"></span>
540
+
</label>
541
+
</div>
542
+
543
+
<div class="setting-row">
544
+
<div class="setting-info">
545
+
<h3>auto-download liked</h3>
546
+
<p>automatically download tracks for offline playback when you like them</p>
547
+
</div>
548
+
<label class="toggle-switch">
549
+
<input
550
+
type="checkbox"
551
+
checked={autoDownloadLiked}
552
+
onchange={(e) => handleAutoDownloadToggle((e.target as HTMLInputElement).checked)}
514
553
/>
515
554
<span class="toggle-slider"></span>
516
555
</label>
···
603
642
<circle cx="12" cy="12" r="10" />
604
643
<path d="M12 16v-4M12 8h.01" />
605
644
</svg>
606
-
<span>toggle on to connect teal.fm scrobbling</span>
645
+
<span>authorization expired โ disable and re-enable to reconnect</span>
607
646
</div>
608
647
{/if}
609
648
</div>
···
695
734
{/if}
696
735
</div>
697
736
</section>
737
+
738
+
<section class="settings-section">
739
+
<h2>experimental</h2>
740
+
<div class="settings-card">
741
+
<div class="setting-row">
742
+
<div class="setting-info">
743
+
<h3>auto-download liked</h3>
744
+
<p>automatically download tracks for offline playback when you like them</p>
745
+
</div>
746
+
<label class="toggle-switch">
747
+
<input
748
+
type="checkbox"
749
+
checked={autoDownloadLiked}
750
+
onchange={(e) => handleAutoDownloadToggle((e.target as HTMLInputElement).checked)}
751
+
/>
752
+
<span class="toggle-slider"></span>
753
+
</label>
754
+
</div>
755
+
</div>
756
+
</section>
698
757
</main>
699
758
{/if}
700
759
···
715
774
.token-overlay-content {
716
775
background: var(--bg-secondary);
717
776
border: 1px solid var(--border-default);
718
-
border-radius: 16px;
777
+
border-radius: var(--radius-xl);
719
778
padding: 2rem;
720
779
max-width: 500px;
721
780
width: 100%;
···
729
788
730
789
.token-overlay-content h2 {
731
790
margin: 0 0 0.75rem;
732
-
font-size: 1.5rem;
791
+
font-size: var(--text-3xl);
733
792
color: var(--text-primary);
734
793
}
735
794
736
795
.token-overlay-warning {
737
796
color: var(--warning);
738
-
font-size: 0.9rem;
797
+
font-size: var(--text-base);
739
798
margin: 0 0 1.5rem;
740
799
line-height: 1.5;
741
800
}
···
745
804
gap: 0.5rem;
746
805
background: var(--bg-primary);
747
806
border: 1px solid var(--border-default);
748
-
border-radius: 8px;
807
+
border-radius: var(--radius-md);
749
808
padding: 1rem;
750
809
margin-bottom: 1rem;
751
810
}
752
811
753
812
.token-overlay-display code {
754
813
flex: 1;
755
-
font-size: 0.85rem;
814
+
font-size: var(--text-sm);
756
815
word-break: break-all;
757
816
color: var(--accent);
758
817
text-align: left;
···
763
822
padding: 0.5rem 1rem;
764
823
background: var(--accent);
765
824
border: none;
766
-
border-radius: 6px;
825
+
border-radius: var(--radius-base);
767
826
color: var(--text-primary);
768
827
font-family: inherit;
769
-
font-size: 0.85rem;
828
+
font-size: var(--text-sm);
770
829
font-weight: 600;
771
830
cursor: pointer;
772
831
white-space: nowrap;
···
778
837
}
779
838
780
839
.token-overlay-hint {
781
-
font-size: 0.8rem;
840
+
font-size: var(--text-sm);
782
841
color: var(--text-tertiary);
783
842
margin: 0 0 1.5rem;
784
843
}
···
796
855
padding: 0.75rem 2rem;
797
856
background: var(--bg-tertiary);
798
857
border: 1px solid var(--border-default);
799
-
border-radius: 8px;
858
+
border-radius: var(--radius-md);
800
859
color: var(--text-secondary);
801
860
font-family: inherit;
802
-
font-size: 0.9rem;
861
+
font-size: var(--text-base);
803
862
cursor: pointer;
804
863
transition: all 0.15s;
805
864
}
···
842
901
.portal-link {
843
902
color: var(--text-secondary);
844
903
text-decoration: none;
845
-
font-size: 0.85rem;
904
+
font-size: var(--text-sm);
846
905
padding: 0.4rem 0.75rem;
847
906
background: var(--bg-tertiary);
848
-
border-radius: 6px;
907
+
border-radius: var(--radius-base);
849
908
border: 1px solid var(--border-default);
850
909
transition: all 0.15s;
851
910
}
···
860
919
}
861
920
862
921
.settings-section h2 {
863
-
font-size: 0.8rem;
922
+
font-size: var(--text-sm);
864
923
text-transform: uppercase;
865
924
letter-spacing: 0.08em;
866
925
color: var(--text-tertiary);
···
871
930
.settings-card {
872
931
background: var(--bg-tertiary);
873
932
border: 1px solid var(--border-subtle);
874
-
border-radius: 10px;
933
+
border-radius: var(--radius-md);
875
934
padding: 1rem 1.25rem;
876
935
}
877
936
···
898
957
899
958
.setting-info h3 {
900
959
margin: 0 0 0.25rem;
901
-
font-size: 0.95rem;
960
+
font-size: var(--text-base);
902
961
font-weight: 600;
903
962
color: var(--text-primary);
904
963
}
905
964
906
965
.setting-info p {
907
966
margin: 0;
908
-
font-size: 0.8rem;
967
+
font-size: var(--text-sm);
909
968
color: var(--text-tertiary);
910
969
line-height: 1.4;
911
970
}
···
934
993
padding: 0.6rem 0.75rem;
935
994
background: var(--bg-primary);
936
995
border: 1px solid var(--border-default);
937
-
border-radius: 8px;
996
+
border-radius: var(--radius-md);
938
997
color: var(--text-secondary);
939
998
cursor: pointer;
940
999
transition: all 0.15s;
···
975
1034
width: 40px;
976
1035
height: 40px;
977
1036
border: 1px solid var(--border-default);
978
-
border-radius: 8px;
1037
+
border-radius: var(--radius-md);
979
1038
cursor: pointer;
980
1039
background: transparent;
981
1040
}
···
985
1044
}
986
1045
987
1046
.color-input::-webkit-color-swatch {
988
-
border-radius: 4px;
1047
+
border-radius: var(--radius-sm);
989
1048
border: none;
990
1049
}
991
1050
···
997
1056
.preset-btn {
998
1057
width: 32px;
999
1058
height: 32px;
1000
-
border-radius: 6px;
1059
+
border-radius: var(--radius-base);
1001
1060
border: 2px solid transparent;
1002
1061
cursor: pointer;
1003
1062
transition: all 0.15s;
···
1026
1085
padding: 0.5rem 0.75rem;
1027
1086
background: var(--bg-primary);
1028
1087
border: 1px solid var(--border-default);
1029
-
border-radius: 6px;
1088
+
border-radius: var(--radius-base);
1030
1089
color: var(--text-primary);
1031
-
font-size: 0.85rem;
1090
+
font-size: var(--text-sm);
1032
1091
font-family: inherit;
1033
1092
}
1034
1093
···
1045
1104
display: flex;
1046
1105
align-items: center;
1047
1106
gap: 0.4rem;
1048
-
font-size: 0.8rem;
1107
+
font-size: var(--text-sm);
1049
1108
color: var(--text-secondary);
1050
1109
cursor: pointer;
1051
1110
}
···
1073
1132
width: 48px;
1074
1133
height: 28px;
1075
1134
background: var(--border-default);
1076
-
border-radius: 999px;
1135
+
border-radius: var(--radius-full);
1077
1136
position: relative;
1078
1137
cursor: pointer;
1079
1138
transition: background 0.2s;
···
1086
1145
left: 4px;
1087
1146
width: 20px;
1088
1147
height: 20px;
1089
-
border-radius: 50%;
1148
+
border-radius: var(--radius-full);
1090
1149
background: var(--text-secondary);
1091
1150
transition: transform 0.2s, background 0.2s;
1092
1151
}
···
1108
1167
padding: 0.75rem;
1109
1168
background: color-mix(in srgb, var(--warning) 10%, transparent);
1110
1169
border: 1px solid color-mix(in srgb, var(--warning) 30%, transparent);
1111
-
border-radius: 6px;
1170
+
border-radius: var(--radius-base);
1112
1171
margin-top: 0.75rem;
1113
-
font-size: 0.8rem;
1172
+
font-size: var(--text-sm);
1114
1173
color: var(--warning);
1115
1174
}
1116
1175
···
1132
1191
/* developer tokens */
1133
1192
.loading-tokens {
1134
1193
color: var(--text-tertiary);
1135
-
font-size: 0.85rem;
1194
+
font-size: var(--text-sm);
1136
1195
}
1137
1196
1138
1197
.existing-tokens {
···
1140
1199
}
1141
1200
1142
1201
.tokens-header {
1143
-
font-size: 0.75rem;
1202
+
font-size: var(--text-xs);
1144
1203
text-transform: uppercase;
1145
1204
letter-spacing: 0.05em;
1146
1205
color: var(--text-tertiary);
···
1161
1220
padding: 0.75rem;
1162
1221
background: var(--bg-primary);
1163
1222
border: 1px solid var(--border-default);
1164
-
border-radius: 6px;
1223
+
border-radius: var(--radius-base);
1165
1224
}
1166
1225
1167
1226
.token-info {
···
1174
1233
.token-name {
1175
1234
font-weight: 500;
1176
1235
color: var(--text-primary);
1177
-
font-size: 0.9rem;
1236
+
font-size: var(--text-base);
1178
1237
}
1179
1238
1180
1239
.token-meta {
1181
-
font-size: 0.75rem;
1240
+
font-size: var(--text-xs);
1182
1241
color: var(--text-tertiary);
1183
1242
}
1184
1243
···
1186
1245
padding: 0.4rem 0.75rem;
1187
1246
background: transparent;
1188
1247
border: 1px solid var(--border-emphasis);
1189
-
border-radius: 4px;
1248
+
border-radius: var(--radius-sm);
1190
1249
color: var(--text-secondary);
1191
1250
font-family: inherit;
1192
-
font-size: 0.8rem;
1251
+
font-size: var(--text-sm);
1193
1252
cursor: pointer;
1194
1253
transition: all 0.15s;
1195
1254
white-space: nowrap;
···
1213
1272
padding: 0.75rem;
1214
1273
background: var(--bg-primary);
1215
1274
border: 1px solid var(--border-default);
1216
-
border-radius: 6px;
1275
+
border-radius: var(--radius-base);
1217
1276
}
1218
1277
1219
1278
.token-value {
1220
1279
flex: 1;
1221
-
font-size: 0.8rem;
1280
+
font-size: var(--text-sm);
1222
1281
word-break: break-all;
1223
1282
color: var(--accent);
1224
1283
}
···
1228
1287
padding: 0.4rem 0.6rem;
1229
1288
background: var(--bg-tertiary);
1230
1289
border: 1px solid var(--border-default);
1231
-
border-radius: 4px;
1290
+
border-radius: var(--radius-sm);
1232
1291
color: var(--text-secondary);
1233
1292
font-family: inherit;
1234
-
font-size: 0.8rem;
1293
+
font-size: var(--text-sm);
1235
1294
cursor: pointer;
1236
1295
transition: all 0.15s;
1237
1296
}
···
1244
1303
1245
1304
.token-warning {
1246
1305
margin-top: 0.5rem;
1247
-
font-size: 0.8rem;
1306
+
font-size: var(--text-sm);
1248
1307
color: var(--warning);
1249
1308
}
1250
1309
···
1261
1320
padding: 0.6rem 0.75rem;
1262
1321
background: var(--bg-primary);
1263
1322
border: 1px solid var(--border-default);
1264
-
border-radius: 6px;
1323
+
border-radius: var(--radius-base);
1265
1324
color: var(--text-primary);
1266
-
font-size: 0.9rem;
1325
+
font-size: var(--text-base);
1267
1326
font-family: inherit;
1268
1327
}
1269
1328
···
1276
1335
display: flex;
1277
1336
align-items: center;
1278
1337
gap: 0.5rem;
1279
-
font-size: 0.85rem;
1338
+
font-size: var(--text-sm);
1280
1339
color: var(--text-secondary);
1281
1340
}
1282
1341
···
1284
1343
padding: 0.5rem 0.75rem;
1285
1344
background: var(--bg-primary);
1286
1345
border: 1px solid var(--border-default);
1287
-
border-radius: 6px;
1346
+
border-radius: var(--radius-base);
1288
1347
color: var(--text-primary);
1289
-
font-size: 0.85rem;
1348
+
font-size: var(--text-sm);
1290
1349
font-family: inherit;
1291
1350
cursor: pointer;
1292
1351
}
···
1300
1359
padding: 0.6rem 1rem;
1301
1360
background: var(--accent);
1302
1361
border: none;
1303
-
border-radius: 6px;
1362
+
border-radius: var(--radius-base);
1304
1363
color: var(--text-primary);
1305
1364
font-family: inherit;
1306
-
font-size: 0.9rem;
1365
+
font-size: var(--text-base);
1307
1366
font-weight: 600;
1308
1367
cursor: pointer;
1309
1368
transition: all 0.15s;
+42
frontend/src/routes/tag/[name]/+page.server.ts
+42
frontend/src/routes/tag/[name]/+page.server.ts
···
1
+
import { API_URL } from '$lib/config';
2
+
import { error } from '@sveltejs/kit';
3
+
import type { PageServerLoad } from './$types';
4
+
5
+
interface TagDetail {
6
+
name: string;
7
+
track_count: number;
8
+
created_by_handle: string | null;
9
+
}
10
+
11
+
export const load: PageServerLoad = async ({ params, fetch }) => {
12
+
try {
13
+
const response = await fetch(`${API_URL}/tracks/tags/${encodeURIComponent(params.name)}`);
14
+
15
+
if (!response.ok) {
16
+
if (response.status === 404) {
17
+
return {
18
+
tag: null,
19
+
trackCount: 0,
20
+
error: `tag "${params.name}" not found`
21
+
};
22
+
}
23
+
throw error(500, 'failed to load tag');
24
+
}
25
+
26
+
const data = await response.json();
27
+
const tag = data.tag as TagDetail;
28
+
29
+
return {
30
+
tag,
31
+
trackCount: tag.track_count,
32
+
error: null
33
+
};
34
+
} catch (e) {
35
+
console.error('failed to load tag:', e);
36
+
return {
37
+
tag: null,
38
+
trackCount: 0,
39
+
error: 'failed to load tag'
40
+
};
41
+
}
42
+
};
+90
-19
frontend/src/routes/tag/[name]/+page.svelte
+90
-19
frontend/src/routes/tag/[name]/+page.svelte
···
1
1
<script lang="ts">
2
+
import { browser } from '$app/environment';
3
+
import { APP_NAME, APP_CANONICAL_URL } from '$lib/branding';
4
+
import { API_URL } from '$lib/config';
2
5
import Header from '$lib/components/Header.svelte';
3
6
import TrackItem from '$lib/components/TrackItem.svelte';
4
7
import { player } from '$lib/player.svelte';
···
10
13
11
14
let { data }: { data: PageData } = $props();
12
15
16
+
// server provides tag metadata for OG tags, client fetches full track list
17
+
let tracks = $state<Track[]>([]);
18
+
let loading = $state(true);
19
+
let error = $state<string | null>(data.error);
20
+
21
+
async function loadTracks() {
22
+
if (!browser || !data.tag) return;
23
+
24
+
loading = true;
25
+
try {
26
+
const response = await fetch(`${API_URL}/tracks/tags/${encodeURIComponent(data.tag.name)}`, {
27
+
credentials: 'include'
28
+
});
29
+
30
+
if (!response.ok) {
31
+
error = 'failed to load tracks';
32
+
return;
33
+
}
34
+
35
+
const result = await response.json();
36
+
tracks = result.tracks;
37
+
} catch (e) {
38
+
console.error('failed to load tracks:', e);
39
+
error = 'failed to load tracks';
40
+
} finally {
41
+
loading = false;
42
+
}
43
+
}
44
+
45
+
$effect(() => {
46
+
if (browser && data.tag) {
47
+
loadTracks();
48
+
}
49
+
});
50
+
13
51
async function handleLogout() {
14
52
await auth.logout();
15
53
window.location.href = '/';
···
20
58
}
21
59
22
60
function queueAll() {
23
-
if (data.tracks.length === 0) return;
24
-
queue.addTracks(data.tracks);
25
-
toast.success(`queued ${data.tracks.length} ${data.tracks.length === 1 ? 'track' : 'tracks'}`);
61
+
if (tracks.length === 0) return;
62
+
queue.addTracks(tracks);
63
+
toast.success(`queued ${tracks.length} ${tracks.length === 1 ? 'track' : 'tracks'}`);
26
64
}
27
65
</script>
28
66
29
67
<svelte:head>
30
-
<title>{data.tag?.name ?? 'tag'} โข plyr</title>
68
+
<title>#{data.tag?.name ?? 'tag'} โข {APP_NAME}</title>
69
+
<meta
70
+
name="description"
71
+
content="{data.trackCount} {data.trackCount === 1 ? 'track' : 'tracks'} tagged #{data.tag?.name ?? 'tag'} on {APP_NAME}"
72
+
/>
73
+
74
+
<!-- Open Graph -->
75
+
<meta property="og:type" content="website" />
76
+
<meta property="og:title" content="#{data.tag?.name ?? 'tag'} โข {APP_NAME}" />
77
+
<meta
78
+
property="og:description"
79
+
content="{data.trackCount} {data.trackCount === 1 ? 'track' : 'tracks'} tagged #{data.tag?.name ?? 'tag'}"
80
+
/>
81
+
<meta property="og:url" content="{APP_CANONICAL_URL}/tag/{data.tag?.name ?? ''}" />
82
+
<meta property="og:site_name" content={APP_NAME} />
83
+
84
+
<!-- Twitter -->
85
+
<meta name="twitter:card" content="summary" />
86
+
<meta name="twitter:title" content="#{data.tag?.name ?? 'tag'} โข {APP_NAME}" />
87
+
<meta
88
+
name="twitter:description"
89
+
content="{data.trackCount} {data.trackCount === 1 ? 'track' : 'tracks'} tagged #{data.tag?.name ?? 'tag'}"
90
+
/>
31
91
</svelte:head>
32
92
33
93
<Header user={auth.user} isAuthenticated={auth.isAuthenticated} onLogout={handleLogout} />
34
94
35
95
<div class="page">
36
-
{#if data.error}
96
+
{#if error}
37
97
<div class="empty-state">
38
98
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
39
99
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path>
40
100
<line x1="7" y1="7" x2="7.01" y2="7"></line>
41
101
</svg>
42
-
<h2>{data.error}</h2>
102
+
<h2>{error}</h2>
43
103
<p><a href="/">back to home</a></p>
44
104
</div>
45
105
{:else if data.tag}
···
54
114
{data.tag.name}
55
115
</h1>
56
116
<p class="subtitle">
57
-
{data.tag.track_count} {data.tag.track_count === 1 ? 'track' : 'tracks'}
117
+
{data.trackCount} {data.trackCount === 1 ? 'track' : 'tracks'}
58
118
</p>
59
119
</div>
60
-
{#if data.tracks.length > 0}
120
+
{#if tracks.length > 0}
61
121
<button class="btn-queue-all" onclick={queueAll} title="queue all tracks">
62
122
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
63
123
<line x1="8" y1="6" x2="21" y2="6"></line>
···
73
133
</div>
74
134
</header>
75
135
76
-
{#if data.tracks.length === 0}
136
+
{#if loading}
137
+
<div class="loading-state">
138
+
<p>loading tracks...</p>
139
+
</div>
140
+
{:else if tracks.length === 0}
77
141
<div class="empty-state">
78
142
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
79
143
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"></path>
···
84
148
</div>
85
149
{:else}
86
150
<div class="tracks-list">
87
-
{#each data.tracks as track, i (track.id)}
151
+
{#each tracks as track, i (track.id)}
88
152
<TrackItem
89
153
{track}
90
154
index={i}
···
133
197
}
134
198
135
199
.subtitle {
136
-
font-size: 0.95rem;
200
+
font-size: var(--text-base);
137
201
color: var(--text-tertiary);
138
202
margin: 0;
139
203
text-shadow: var(--text-shadow, none);
···
147
211
background: var(--glass-btn-bg, transparent);
148
212
border: 1px solid var(--glass-btn-border, var(--accent));
149
213
color: var(--accent);
150
-
border-radius: 6px;
151
-
font-size: 0.9rem;
214
+
border-radius: var(--radius-base);
215
+
font-size: var(--text-base);
152
216
font-family: inherit;
153
217
cursor: pointer;
154
218
transition: all 0.2s;
···
176
240
}
177
241
178
242
.empty-state h2 {
179
-
font-size: 1.5rem;
243
+
font-size: var(--text-3xl);
180
244
font-weight: 600;
181
245
color: var(--text-secondary);
182
246
margin: 0 0 0.5rem 0;
183
247
}
184
248
185
249
.empty-state p {
186
-
font-size: 0.95rem;
250
+
font-size: var(--text-base);
187
251
margin: 0;
188
252
}
189
253
···
196
260
text-decoration: underline;
197
261
}
198
262
263
+
.loading-state {
264
+
text-align: center;
265
+
padding: 4rem 1rem;
266
+
color: var(--text-tertiary);
267
+
font-size: var(--text-base);
268
+
}
269
+
199
270
.tracks-list {
200
271
display: flex;
201
272
flex-direction: column;
···
221
292
}
222
293
223
294
.empty-state h2 {
224
-
font-size: 1.25rem;
295
+
font-size: var(--text-2xl);
225
296
}
226
297
227
298
.btn-queue-all {
228
299
padding: 0.5rem 0.75rem;
229
-
font-size: 0.85rem;
300
+
font-size: var(--text-sm);
230
301
}
231
302
232
303
.btn-queue-all svg {
···
259
330
}
260
331
261
332
.subtitle {
262
-
font-size: 0.85rem;
333
+
font-size: var(--text-sm);
263
334
}
264
335
265
336
.btn-queue-all {
266
337
padding: 0.45rem 0.65rem;
267
-
font-size: 0.8rem;
338
+
font-size: var(--text-sm);
268
339
}
269
340
270
341
.btn-queue-all svg {
-46
frontend/src/routes/tag/[name]/+page.ts
-46
frontend/src/routes/tag/[name]/+page.ts
···
1
-
import { browser } from '$app/environment';
2
-
import { API_URL } from '$lib/config';
3
-
import type { Track } from '$lib/types';
4
-
5
-
interface TagDetail {
6
-
name: string;
7
-
track_count: number;
8
-
created_by_handle: string | null;
9
-
}
10
-
11
-
export interface PageData {
12
-
tag: TagDetail | null;
13
-
tracks: Track[];
14
-
error: string | null;
15
-
}
16
-
17
-
export const ssr = false;
18
-
19
-
export async function load({ params }: { params: { name: string } }): Promise<PageData> {
20
-
if (!browser) {
21
-
return { tag: null, tracks: [], error: null };
22
-
}
23
-
24
-
try {
25
-
const response = await fetch(`${API_URL}/tracks/tags/${encodeURIComponent(params.name)}`, {
26
-
credentials: 'include'
27
-
});
28
-
29
-
if (!response.ok) {
30
-
if (response.status === 404) {
31
-
return { tag: null, tracks: [], error: `tag "${params.name}" not found` };
32
-
}
33
-
throw new Error(`failed to load tag: ${response.statusText}`);
34
-
}
35
-
36
-
const data = await response.json();
37
-
return {
38
-
tag: data.tag,
39
-
tracks: data.tracks,
40
-
error: null
41
-
};
42
-
} catch (e) {
43
-
console.error('failed to load tag:', e);
44
-
return { tag: null, tracks: [], error: 'failed to load tag' };
45
-
}
46
-
}
+108
-51
frontend/src/routes/track/[id]/+page.svelte
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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('&', "&")
437
+
.replace('<', "<")
438
+
.replace('>', ">")
439
+
.replace('"', """)
440
+
.replace('\'', "'")
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
+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
+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
+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
+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
+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
+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
+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
+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
update.wav
This is a binary file and will not be displayed.