+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
+
```
+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
+90
-279
STATUS.md
+90
-279
STATUS.md
···
47
47
48
48
### December 2025
49
49
50
-
#### end-of-year sprint (Dec 20-31)
50
+
#### self-hosted redis (PR #674-675, Dec 30)
51
+
52
+
**replaced Upstash with self-hosted Redis on Fly.io** - ~$75/month โ ~$4/month:
53
+
- Upstash pay-as-you-go was charging per command (37M commands = $75)
54
+
- self-hosted Redis on 256MB Fly VMs costs fixed ~$2/month per environment
55
+
- deployed `plyr-redis` (prod) and `plyr-redis-stg` (staging)
56
+
- added CI workflow for redis deployments on merge
57
+
58
+
**no state migration needed** - docket stores ephemeral task queue data, job progress lives in postgres.
59
+
60
+
---
61
+
62
+
#### supporter-gated content (PR #637, Dec 22-23)
63
+
64
+
**atprotofans paywall integration** - artists can now mark tracks as "supporters only":
65
+
- tracks with `support_gate` require atprotofans validation before playback
66
+
- non-supporters see lock icon and "become a supporter" CTA linking to atprotofans
67
+
- artists can always play their own gated tracks
68
+
69
+
**backend architecture**:
70
+
- audio endpoint validates supporter status via atprotofans API before serving gated content
71
+
- HEAD requests return 200/401/402 for pre-flight auth checks (avoids CORS issues)
72
+
- `R2Storage.move_audio()` moves files between public/private buckets when toggling gate
73
+
- background task handles bucket migration asynchronously
74
+
- ATProto record syncs when toggling gate (updates `supportGate` field and `audioUrl`)
75
+
76
+
**frontend**:
77
+
- `playback.svelte.ts` guards queue operations with gated checks BEFORE modifying state
78
+
- clicking locked track shows toast with CTA - does NOT interrupt current playback
79
+
- portal shows support gate toggle in track edit UI
80
+
81
+
---
82
+
83
+
#### supporter badges (PR #627, Dec 21-22)
84
+
85
+
**phase 1 of atprotofans integration**:
86
+
- supporter badge displays on artist pages when logged-in viewer supports the artist
87
+
- calls atprotofans `validateSupporter` API directly from frontend (public endpoint)
88
+
- badge only shows when viewer is authenticated and not viewing their own profile
89
+
90
+
---
91
+
92
+
#### rate limit moderation endpoint (PR #629, Dec 21)
93
+
94
+
**incident response**: detected suspicious activity - 72 requests in 17 seconds from a single IP targeting `/moderation/sensitive-images`. added `10/minute` rate limit using existing slowapi infrastructure.
95
+
96
+
---
97
+
98
+
#### end-of-year sprint planning (PR #626, Dec 20)
51
99
52
100
**focus**: two foundational systems need solid experimental implementations by 2026.
53
101
54
-
**track 1: moderation architecture overhaul**
55
-
- consolidate sensitive images into moderation service
56
-
- add event-sourced audit trail
57
-
- implement configurable rules (replace hard-coded thresholds)
58
-
- informed by [Roost Osprey](https://github.com/roostorg/osprey) patterns and [Bluesky Ozone](https://github.com/bluesky-social/ozone) workflows
59
-
60
-
**track 2: atprotofans paywall integration**
61
-
- phase 1: read-only supporter validation (show badges)
62
-
- phase 2: platform registration (artists create support tiers)
63
-
- phase 3: content gating (track-level access control)
102
+
| track | focus | status |
103
+
|-------|-------|--------|
104
+
| moderation | consolidate architecture, add rules engine | in progress |
105
+
| atprotofans | supporter validation, content gating | shipped (phase 1-3) |
64
106
65
107
**research docs**:
66
108
- [moderation architecture overhaul](docs/research/2025-12-20-moderation-architecture-overhaul.md)
67
109
- [atprotofans paywall integration](docs/research/2025-12-20-atprotofans-paywall-integration.md)
68
110
69
-
**tracking**: issue #625
70
-
71
111
---
72
112
73
113
#### beartype + moderation cleanup (PRs #617-619, Dec 19)
···
76
116
- enabled beartype runtime type validation across the backend
77
117
- catches type errors at runtime instead of silently passing bad data
78
118
- test infrastructure improvements: session-scoped TestClient fixture (5x faster tests)
79
-
- disabled automatic perpetual task scheduling in tests
80
119
81
120
**moderation cleanup** (PRs #617-618):
82
121
- consolidated moderation code, addressing issues #541-543
83
122
- `sync_copyright_resolutions` now runs automatically via docket Perpetual task
84
-
- removed `init_db()` from lifespan (handled by alembic migrations)
123
+
- removed dead `init_db()` from lifespan (handled by alembic migrations)
85
124
86
125
---
87
126
···
100
139
- mobile modals now use full screen positioning
101
140
- fixed `/tag/` routes in hasPageMetadata check
102
141
103
-
**misc** (PRs #598-601):
104
-
- upload button added to desktop header nav
105
-
- background settings UX improvements
106
-
- switched support link to atprotofans
107
-
- AudD costs now derived from track duration for accurate billing
108
-
109
142
---
110
143
111
144
#### offline mode foundation (PRs #610-611, Dec 17)
112
145
113
146
**experimental offline playback**:
114
-
- new storage layer using Cache API for audio bytes + IndexedDB for metadata
147
+
- storage layer using Cache API for audio bytes + IndexedDB for metadata
115
148
- `GET /audio/{file_id}/url` backend endpoint returns direct R2 URLs for client-side caching
116
149
- "auto-download liked" toggle in experimental settings section
117
-
- when enabled, bulk-downloads all liked tracks and auto-downloads future likes
118
150
- Player checks for cached audio before streaming from R2
119
-
- works offline once tracks are downloaded
120
-
121
-
**robustness improvements**:
122
-
- IndexedDB connections properly closed after each operation
123
-
- concurrent downloads deduplicated via in-flight promise tracking
124
-
- stale metadata cleanup when cache entries are missing
125
151
126
152
---
127
153
128
-
#### visual customization (PRs #595-596, Dec 16)
154
+
### Earlier December 2025
129
155
130
-
**custom backgrounds** (PR #595):
131
-
- users can set a custom background image URL in settings with optional tiling
132
-
- new "playing artwork as background" toggle - uses current track's artwork as blurred page background
133
-
- glass effect styling for track items (translucent backgrounds, subtle shadows)
134
-
- new `ui_settings` JSONB column in preferences for extensible UI settings
135
-
136
-
**bug fix** (PR #596):
137
-
- removed 3D wheel scroll effect that was blocking like/share button clicks
138
-
- root cause: `translateZ` transforms created z-index stacking that intercepted pointer events
139
-
140
-
---
141
-
142
-
#### performance & UX polish (PRs #586-593, Dec 14-15)
143
-
144
-
**performance improvements** (PRs #590-591):
145
-
- removed moderation service call from `/tracks/` listing endpoint
146
-
- removed copyright check from tag listing endpoint
147
-
- faster page loads for track feeds
148
-
149
-
**moderation agent** (PRs #586, #588):
150
-
- added moderation agent script with audit trail support
151
-
- improved moderation prompt and UI layout
152
-
153
-
**bug fixes** (PRs #589, #592, #593):
154
-
- fixed liked state display on playlist detail page
155
-
- preserved album track order during ATProto sync
156
-
- made header sticky on scroll for better mobile navigation
157
-
158
-
**iOS Safari fixes** (PRs #573-576):
159
-
- fixed AddToMenu visibility issue on iOS Safari
160
-
- menu now correctly opens upward when near viewport bottom
161
-
162
-
---
163
-
164
-
#### mobile UI polish & background task expansion (PRs #558-572, Dec 10-12)
165
-
166
-
**background task expansion** (PRs #558, #561):
167
-
- moved like/unlike and comment PDS writes to docket background tasks
168
-
- API responses now immediate; PDS sync happens asynchronously
169
-
- added targeted album list sync background task for ATProto record updates
170
-
171
-
**performance caching** (PR #566):
172
-
- added Redis cache for copyright label lookups (5-minute TTL)
173
-
- fixed 2-3s latency spikes on `/tracks/` endpoint
174
-
- batch operations via `mget`/pipeline for efficiency
175
-
176
-
**mobile UX improvements** (PRs #569, #572):
177
-
- mobile action menus now open from top with all actions visible
178
-
- UI polish for album and artist pages on small screens
179
-
180
-
**misc** (PRs #559, #562, #563, #570):
181
-
- reduced docket Redis polling from 250ms to 5s (lower resource usage)
182
-
- added atprotofans support link mode for ko-fi integration
183
-
- added alpha badge to header branding
184
-
- fixed web manifest ID for PWA stability
185
-
186
-
---
187
-
188
-
#### confidential OAuth client (PRs #578, #580-582, Dec 12-13)
189
-
190
-
**confidential client support** (PR #578):
191
-
- implemented ATProto OAuth confidential client using `private_key_jwt` authentication
192
-
- when `OAUTH_JWK` is configured, plyr.fm authenticates with a cryptographic key
193
-
- confidential clients earn 180-day refresh tokens (vs 2-week for public clients)
194
-
- added `/.well-known/jwks.json` endpoint for public key discovery
195
-
- updated `/oauth-client-metadata.json` with confidential client fields
196
-
197
-
**bug fixes** (PRs #580-582):
198
-
- fixed client assertion JWT to use Authorization Server's issuer as `aud` claim (not token endpoint URL)
199
-
- fixed JWKS endpoint to preserve `kid` field from original JWK
200
-
- fixed `OAuthClient` to pass `client_secret_kid` for JWT header
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)
201
172
202
-
**atproto fork updates** (zzstoatzz/atproto#6, #7):
203
-
- added `issuer` parameter to `_make_token_request()` for correct `aud` claim
204
-
- added `client_secret_kid` parameter to include `kid` in client assertion JWT header
173
+
### November 2025
205
174
206
-
**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.
207
-
208
-
---
209
-
210
-
#### pagination & album management (PRs #550-554, Dec 9-10)
211
-
212
-
**tracks list pagination** (PR #554):
213
-
- cursor-based pagination on `/tracks/` endpoint (default 50 per page)
214
-
- infinite scroll on homepage using native IntersectionObserver
215
-
- zero new dependencies - uses browser APIs only
216
-
- pagination state persisted to localStorage for fast subsequent loads
217
-
218
-
**album management improvements** (PRs #550-552, #557):
219
-
- album delete and track reorder fixes
220
-
- album page edit mode matching playlist UX (inline title editing, cover upload)
221
-
- optimistic UI updates for album title changes (instant feedback)
222
-
- ATProto record sync when album title changes (updates all track records + list record)
223
-
- fixed album slug sync on rename (prevented duplicate albums when adding tracks)
224
-
225
-
**playlist show on profile** (PR #553):
226
-
- restored "show on profile" toggle that was lost during inline editing refactor
227
-
- users can now control whether playlists appear on their public profile
228
-
229
-
---
230
-
231
-
#### public cost dashboard (PRs #548-549, Dec 9)
232
-
233
-
- `/costs` page showing live platform infrastructure costs
234
-
- daily export to R2 via GitHub Action, proxied through `/stats/costs` endpoint
235
-
- dedicated `plyr-stats` R2 bucket with public access (shared across environments)
236
-
- includes fly.io, neon, cloudflare, and audd API costs
237
-
- ko-fi integration for community support
238
-
239
-
#### docket background tasks & concurrent exports (PRs #534-546, Dec 9)
240
-
241
-
**docket integration** (PRs #534, #536, #539):
242
-
- migrated background tasks from inline asyncio to docket (Redis-backed task queue)
243
-
- copyright scanning, media export, ATProto sync, and teal scrobbling now run via docket
244
-
- graceful fallback to asyncio for local development without Redis
245
-
- parallel test execution with xdist template databases (#540)
246
-
247
-
**concurrent export downloads** (PR #545):
248
-
- exports now download tracks in parallel (up to 4 concurrent) instead of sequentially
249
-
- significantly faster for users with many tracks or large files
250
-
- zip creation remains sequential (zipfile constraint)
251
-
252
-
**ATProto refactor** (PR #534):
253
-
- reorganized ATProto record code into `_internal/atproto/records/` by lexicon namespace
254
-
- extracted `client.py` for low-level PDS operations
255
-
- cleaner separation between plyr.fm and teal.fm lexicons
256
-
257
-
**documentation & observability**:
258
-
- AudD API cost tracking dashboard (#546)
259
-
- promoted runbooks from sandbox to `docs/runbooks/`
260
-
- updated CLAUDE.md files across the codebase
261
-
262
-
---
263
-
264
-
#### artist support links & inline playlist editing (PRs #520-532, Dec 8)
265
-
266
-
**artist support link** (PR #532):
267
-
- artists can set a support URL (Ko-fi, Patreon, etc.) in their portal profile
268
-
- support link displays as a button on artist profile pages next to the share button
269
-
- URLs validated to require https:// prefix
270
-
271
-
**inline playlist editing** (PR #531):
272
-
- edit playlist name and description directly on playlist detail page
273
-
- click-to-upload cover art replacement without modal
274
-
- cleaner UX - no more edit modal popup
275
-
276
-
**platform stats enhancements** (PRs #522, #528):
277
-
- total duration displayed in platform stats (e.g., "42h 15m of music")
278
-
- duration shown per artist in analytics section
279
-
- combined stats and search into single centered container for cleaner layout
280
-
281
-
**navigation & data loading fixes** (PR #527):
282
-
- fixed stale data when navigating between detail pages of the same type
283
-
- e.g., clicking from one artist to another now properly reloads data
284
-
285
-
**copyright moderation improvements** (PR #480):
286
-
- enhanced moderation workflow for copyright claims
287
-
- improved labeler integration
288
-
289
-
**status maintenance workflow** (PR #529):
290
-
- automated status maintenance using claude-code-action
291
-
- reviews merged PRs and updates STATUS.md narratively
292
-
293
-
---
294
-
295
-
#### playlist fast-follow fixes (PRs #507-519, Dec 7-8)
296
-
297
-
**public playlist viewing** (PR #519):
298
-
- playlists now publicly viewable without authentication
299
-
- ATProto records are public by design - auth was unnecessary for read access
300
-
- shared playlist URLs no longer redirect unauthenticated users to homepage
301
-
302
-
**inline playlist creation** (PR #510):
303
-
- clicking "create new playlist" from AddToMenu previously navigated to `/library?create=playlist`
304
-
- this caused SvelteKit to reinitialize the layout, destroying the audio element and stopping playback
305
-
- fix: added inline create form that creates playlist and adds track in one action without navigation
306
-
307
-
**UI polish** (PRs #507-509, #515):
308
-
- include `image_url` in playlist SSR data for og:image link previews
309
-
- invalidate layout data after token exchange - fixes stale auth state after login
310
-
- fixed stopPropagation blocking "create new playlist" link clicks
311
-
- detail page button layouts: all buttons visible on mobile, centered AddToMenu on track detail
312
-
- AddToMenu smart positioning: menu opens upward when near viewport bottom
313
-
314
-
**documentation** (PR #514):
315
-
- added lexicons overview documentation at `docs/lexicons/overview.md`
316
-
- covers `fm.plyr.track`, `fm.plyr.like`, `fm.plyr.comment`, `fm.plyr.list`, `fm.plyr.actor.profile`
317
-
318
-
---
319
-
320
-
#### playlists, ATProto sync, and library hub (PR #499, Dec 6-7)
321
-
322
-
**playlists** (full CRUD):
323
-
- create, rename, delete playlists with cover art upload
324
-
- add/remove/reorder tracks with drag-and-drop
325
-
- playlist detail page with edit modal
326
-
- "add to playlist" menu on tracks with inline create
327
-
- playlist sharing with OpenGraph link previews
328
-
329
-
**ATProto integration**:
330
-
- `fm.plyr.list` lexicon for syncing playlists/albums to user PDSes
331
-
- `fm.plyr.actor.profile` lexicon for artist profiles
332
-
- automatic sync of albums, liked tracks, profile on login
333
-
334
-
**library hub** (`/library`):
335
-
- unified page with tabs: liked, playlists, albums
336
-
- nav changed from "liked" โ "library"
337
-
338
-
**related**: scope upgrade OAuth flow (PR #503), settings consolidation (PR #496)
339
-
340
-
---
341
-
342
-
#### sensitive image moderation (PRs #471-488, Dec 5-6)
343
-
344
-
- `sensitive_images` table flags problematic images
345
-
- `show_sensitive_artwork` user preference
346
-
- flagged images blurred everywhere: track lists, player, artist pages, search, embeds
347
-
- Media Session API respects sensitive preference
348
-
- SSR-safe filtering for og:image link previews
349
-
350
-
---
351
-
352
-
#### teal.fm scrobbling (PR #467, Dec 4)
353
-
354
-
- native scrobbling to user's PDS using teal's ATProto lexicons
355
-
- scrobble at 30% or 30 seconds (same threshold as play counts)
356
-
- toggle in settings, link to pdsls.dev to view records
357
-
358
-
---
359
-
360
-
### Earlier December / November 2025
361
-
362
-
See `.status_history/2025-12.md` and `.status_history/2025-11.md` for detailed history including:
363
-
- unified search with Cmd+K (PR #447)
364
-
- light/dark theme system (PR #441)
365
-
- tag filtering and bufo easter egg (PRs #431-438)
175
+
See `.status_history/2025-11.md` for detailed history including:
366
176
- developer tokens (PR #367)
367
177
- copyright moderation system (PRs #382-395)
368
178
- export & upload reliability (PRs #337-344)
···
370
180
371
181
## immediate priorities
372
182
373
-
### end-of-year sprint (Dec 20-31)
183
+
### quality of life mode (Dec 29-31)
374
184
375
-
see [sprint tracking issue #625](https://github.com/zzstoatzz/plyr.fm/issues/625) for details.
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.
376
186
377
-
| track | focus | status |
378
-
|-------|-------|--------|
379
-
| moderation | consolidate architecture, add rules engine | planning |
380
-
| atprotofans | supporter validation, content gating | planning |
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)
381
194
382
195
### known issues
383
196
- playback auto-start on refresh (#225)
···
432
245
- โ
copyright moderation with ATProto labeler
433
246
- โ
docket background tasks (copyright scan, export, atproto sync, scrobble)
434
247
- โ
media export with concurrent downloads
248
+
- โ
supporter-gated content via atprotofans
435
249
436
250
**albums**
437
251
- โ
album CRUD with cover art
···
463
277
464
278
## cost structure
465
279
466
-
current monthly costs: ~$18/month (plyr.fm specific)
280
+
current monthly costs: ~$20/month (plyr.fm specific)
467
281
468
282
see live dashboard: [plyr.fm/costs](https://plyr.fm/costs)
469
283
470
-
- fly.io (plyr apps only): ~$12/month
471
-
- relay-api (prod): $5.80
472
-
- relay-api-staging: $5.60
473
-
- plyr-moderation: $0.24
474
-
- plyr-transcoder: $0.02
284
+
- fly.io (backend + redis + moderation): ~$14/month
475
285
- neon postgres: $5/month
476
-
- cloudflare (R2 + pages + domain): ~$1.16/month
477
-
- 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)
478
288
- logfire: $0 (free tier)
479
289
480
290
## admin tooling
···
525
335
โ โโโ src/routes/ # pages
526
336
โโโ moderation/ # Rust moderation service (ATProto labeler)
527
337
โโโ transcoder/ # Rust audio transcoding service
338
+
โโโ redis/ # self-hosted Redis config
528
339
โโโ docs/ # documentation
529
340
โโโ justfile # task runner
530
341
```
···
540
351
541
352
---
542
353
543
-
this is a living document. last updated 2025-12-20.
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
+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/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 = {
+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
backend/src/backend/_internal/background.py
+2
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,
+59
-2
backend/src/backend/_internal/background_tasks.py
+59
-2
backend/src/backend/_internal/background_tasks.py
···
281
281
export_id,
282
282
JobStatus.PROCESSING,
283
283
f"downloading {total} tracks...",
284
-
progress_pct=0,
284
+
progress_pct=0.0,
285
285
result={"processed_count": 0, "total_count": total},
286
286
)
287
287
···
304
304
export_id,
305
305
JobStatus.PROCESSING,
306
306
"creating zip archive...",
307
-
progress_pct=100,
307
+
progress_pct=100.0,
308
308
result={
309
309
"processed_count": len(successful_downloads),
310
310
"total_count": total,
···
865
865
logfire.info("scheduled pds comment update", comment_id=comment_id)
866
866
867
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
+
868
924
# collection of all background task functions for docket registration
869
925
background_tasks = [
870
926
scan_copyright,
···
878
934
pds_create_comment,
879
935
pds_delete_comment,
880
936
pds_update_comment,
937
+
move_track_audio,
881
938
]
+31
backend/src/backend/_internal/moderation_client.py
+31
backend/src/backend/_internal/moderation_client.py
···
35
35
error: str | None = None
36
36
37
37
38
+
@dataclass
39
+
class SensitiveImagesResult:
40
+
"""result from fetching sensitive images."""
41
+
42
+
image_ids: list[str]
43
+
urls: list[str]
44
+
45
+
38
46
class ModerationClient:
39
47
"""client for the plyr.fm moderation service.
40
48
···
152
160
except Exception as e:
153
161
logger.warning("failed to emit copyright label for %s: %s", uri, e)
154
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
+
)
155
186
156
187
async def get_active_labels(self, uris: list[str]) -> set[str]:
157
188
"""check which URIs have active (non-negated) copyright-violation labels.
+127
-19
backend/src/backend/api/audio.py
+127
-19
backend/src/backend/api/audio.py
···
1
1
"""audio streaming endpoint."""
2
2
3
3
import logfire
4
-
from fastapi import APIRouter, Depends, HTTPException
4
+
from fastapi import APIRouter, Depends, HTTPException, Request, Response
5
5
from fastapi.responses import RedirectResponse
6
6
from pydantic import BaseModel
7
7
from sqlalchemy import func, select
8
8
9
-
from backend._internal import Session, require_auth
9
+
from backend._internal import Session, get_optional_session, validate_supporter
10
10
from backend.models import Track
11
11
from backend.storage import storage
12
12
from backend.utilities.database import db_session
···
22
22
file_type: str | None
23
23
24
24
25
+
@router.head("/{file_id}")
25
26
@router.get("/{file_id}")
26
-
async def stream_audio(file_id: str):
27
+
async def stream_audio(
28
+
file_id: str,
29
+
request: Request,
30
+
session: Session | None = Depends(get_optional_session),
31
+
):
27
32
"""stream audio file by redirecting to R2 CDN URL.
28
33
29
-
looks up track to get cached r2_url and file extension,
30
-
eliminating the need to probe multiple formats.
34
+
for public tracks: redirects to R2 CDN URL.
35
+
for gated tracks: validates supporter status and returns presigned URL.
36
+
37
+
HEAD requests are used for pre-flight auth checks - they return
38
+
200/401/402 status without redirecting to avoid CORS issues.
31
39
32
40
images are served directly via R2 URLs stored in the image_url field,
33
41
not through this endpoint.
34
42
"""
35
-
# look up track to get r2_url and file_type
43
+
is_head_request = request.method == "HEAD"
44
+
# look up track to get r2_url, file_type, support_gate, and artist_did
36
45
async with db_session() as db:
37
46
# check for duplicates (multiple tracks with same file_id)
38
47
count_result = await db.execute(
···
50
59
count=count,
51
60
)
52
61
53
-
# get the best track: prefer non-null r2_url, then newest
62
+
# get the track with gating info
54
63
result = await db.execute(
55
-
select(Track.r2_url, Track.file_type)
64
+
select(Track.r2_url, Track.file_type, Track.support_gate, Track.artist_did)
56
65
.where(Track.file_id == file_id)
57
66
.order_by(Track.r2_url.is_not(None).desc(), Track.created_at.desc())
58
67
.limit(1)
59
68
)
60
69
track_data = result.first()
70
+
r2_url, file_type, support_gate, artist_did = track_data
61
71
62
-
r2_url, file_type = track_data
72
+
# check if track is gated
73
+
if support_gate is not None:
74
+
return await _handle_gated_audio(
75
+
file_id=file_id,
76
+
file_type=file_type,
77
+
artist_did=artist_did,
78
+
session=session,
79
+
is_head_request=is_head_request,
80
+
)
63
81
64
-
# if we have a valid r2_url cached, use it directly (zero HEADs)
82
+
# public track - use cached r2_url if available
65
83
if r2_url and r2_url.startswith("http"):
66
84
return RedirectResponse(url=r2_url)
67
85
···
72
90
return RedirectResponse(url=url)
73
91
74
92
93
+
async def _handle_gated_audio(
94
+
file_id: str,
95
+
file_type: str,
96
+
artist_did: str,
97
+
session: Session | None,
98
+
is_head_request: bool = False,
99
+
) -> RedirectResponse | Response:
100
+
"""handle streaming for supporter-gated content.
101
+
102
+
validates that the user is authenticated and either:
103
+
- is the artist who uploaded the track, OR
104
+
- supports the artist via atprotofans
105
+
before returning a presigned URL for the private bucket.
106
+
107
+
for HEAD requests (used for pre-flight auth checks), returns 200 status
108
+
without redirecting to avoid CORS issues with cross-origin redirects.
109
+
"""
110
+
# must be authenticated to access gated content
111
+
if not session:
112
+
raise HTTPException(
113
+
status_code=401,
114
+
detail="authentication required for supporter-gated content",
115
+
)
116
+
117
+
# artist can always play their own gated tracks
118
+
if session.did == artist_did:
119
+
logfire.info(
120
+
"serving gated content to owner",
121
+
file_id=file_id,
122
+
artist_did=artist_did,
123
+
)
124
+
else:
125
+
# validate supporter status via atprotofans
126
+
validation = await validate_supporter(
127
+
supporter_did=session.did,
128
+
artist_did=artist_did,
129
+
)
130
+
131
+
if not validation.valid:
132
+
raise HTTPException(
133
+
status_code=402,
134
+
detail="this track requires supporter access",
135
+
headers={"X-Support-Required": "true"},
136
+
)
137
+
138
+
# for HEAD requests, just return 200 to confirm access
139
+
# (avoids CORS issues with cross-origin redirects)
140
+
if is_head_request:
141
+
return Response(status_code=200)
142
+
143
+
# authorized - generate presigned URL for private bucket
144
+
if session.did != artist_did:
145
+
logfire.info(
146
+
"serving gated content to supporter",
147
+
file_id=file_id,
148
+
supporter_did=session.did,
149
+
artist_did=artist_did,
150
+
)
151
+
152
+
url = await storage.generate_presigned_url(file_id=file_id, extension=file_type)
153
+
return RedirectResponse(url=url)
154
+
155
+
75
156
@router.get("/{file_id}/url")
76
157
async def get_audio_url(
77
158
file_id: str,
78
-
session: Session = Depends(require_auth),
159
+
session: Session | None = Depends(get_optional_session),
79
160
) -> AudioUrlResponse:
80
-
"""return direct R2 URL for offline caching.
161
+
"""return direct URL for audio file.
81
162
82
-
unlike the streaming endpoint which returns a 307 redirect,
83
-
this returns the URL as JSON so the frontend can fetch and
84
-
cache the audio directly via the Cache API.
163
+
for public tracks: returns R2 CDN URL for offline caching.
164
+
for gated tracks: returns presigned URL after supporter validation.
85
165
86
-
used for offline mode - frontend fetches from R2 and stores locally.
166
+
used for offline mode - frontend fetches and caches locally.
87
167
"""
88
168
async with db_session() as db:
89
169
result = await db.execute(
90
-
select(Track.r2_url, Track.file_type)
170
+
select(Track.r2_url, Track.file_type, Track.support_gate, Track.artist_did)
91
171
.where(Track.file_id == file_id)
92
172
.order_by(Track.r2_url.is_not(None).desc(), Track.created_at.desc())
93
173
.limit(1)
···
97
177
if not track_data:
98
178
raise HTTPException(status_code=404, detail="audio file not found")
99
179
100
-
r2_url, file_type = track_data
180
+
r2_url, file_type, support_gate, artist_did = track_data
101
181
102
-
# if we have a cached r2_url, return it
182
+
# check if track is gated
183
+
if support_gate is not None:
184
+
# must be authenticated
185
+
if not session:
186
+
raise HTTPException(
187
+
status_code=401,
188
+
detail="authentication required for supporter-gated content",
189
+
)
190
+
191
+
# artist can always access their own gated tracks
192
+
if session.did != artist_did:
193
+
# validate supporter status
194
+
validation = await validate_supporter(
195
+
supporter_did=session.did,
196
+
artist_did=artist_did,
197
+
)
198
+
199
+
if not validation.valid:
200
+
raise HTTPException(
201
+
status_code=402,
202
+
detail="this track requires supporter access",
203
+
headers={"X-Support-Required": "true"},
204
+
)
205
+
206
+
# return presigned URL
207
+
url = await storage.generate_presigned_url(file_id=file_id, extension=file_type)
208
+
return AudioUrlResponse(url=url, file_id=file_id, file_type=file_type)
209
+
210
+
# public track - return cached r2_url if available
103
211
if r2_url and r2_url.startswith("http"):
104
212
return AudioUrlResponse(url=r2_url, file_id=file_id, file_type=file_type)
105
213
+17
-12
backend/src/backend/api/moderation.py
+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:
+10
backend/src/backend/config.py
+10
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",
+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
)
+219
backend/src/backend/storage/r2.py
+219
backend/src/backend/storage/r2.py
···
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
+218
-3
backend/tests/api/test_audio.py
+218
-3
backend/tests/api/test_audio.py
···
271
271
test_app.dependency_overrides.pop(require_auth, None)
272
272
273
273
274
-
async def test_get_audio_url_requires_auth(test_app: FastAPI):
275
-
"""test that /url endpoint returns 401 without authentication."""
274
+
async def test_get_audio_url_gated_requires_auth(
275
+
test_app: FastAPI, db_session: AsyncSession
276
+
):
277
+
"""test that /url endpoint returns 401 for gated content without authentication."""
278
+
# create a gated track
279
+
artist = Artist(
280
+
did="did:plc:gatedartist",
281
+
handle="gatedartist.bsky.social",
282
+
display_name="Gated Artist",
283
+
)
284
+
db_session.add(artist)
285
+
await db_session.flush()
286
+
287
+
track = Track(
288
+
title="Gated Track",
289
+
artist_did=artist.did,
290
+
file_id="gated-test-file",
291
+
file_type="mp3",
292
+
r2_url="https://cdn.example.com/audio/gated.mp3",
293
+
support_gate={"type": "any"},
294
+
)
295
+
db_session.add(track)
296
+
await db_session.commit()
297
+
276
298
# ensure no auth override
277
299
test_app.dependency_overrides.pop(require_auth, None)
278
300
279
301
async with AsyncClient(
280
302
transport=ASGITransport(app=test_app), base_url="http://test"
281
303
) as client:
282
-
response = await client.get("/audio/somefile/url")
304
+
response = await client.get(f"/audio/{track.file_id}/url")
305
+
306
+
assert response.status_code == 401
307
+
assert "authentication required" in response.json()["detail"]
308
+
309
+
310
+
# gated content regression tests
311
+
312
+
313
+
@pytest.fixture
314
+
async def gated_track(db_session: AsyncSession) -> Track:
315
+
"""create a gated track for testing supporter access."""
316
+
artist = Artist(
317
+
did="did:plc:gatedowner",
318
+
handle="gatedowner.bsky.social",
319
+
display_name="Gated Owner",
320
+
)
321
+
db_session.add(artist)
322
+
await db_session.flush()
323
+
324
+
track = Track(
325
+
title="Supporters Only Track",
326
+
artist_did=artist.did,
327
+
file_id="gated-regression-test",
328
+
file_type="mp3",
329
+
r2_url=None, # no cached URL - forces presigned URL generation
330
+
support_gate={"type": "any"},
331
+
)
332
+
db_session.add(track)
333
+
await db_session.commit()
334
+
await db_session.refresh(track)
335
+
336
+
return track
337
+
338
+
339
+
@pytest.fixture
340
+
def owner_session() -> Session:
341
+
"""session for the track owner."""
342
+
return Session(
343
+
session_id="owner-session-id",
344
+
did="did:plc:gatedowner",
345
+
handle="gatedowner.bsky.social",
346
+
oauth_session={
347
+
"access_token": "owner-access-token",
348
+
"refresh_token": "owner-refresh-token",
349
+
"dpop_key": {},
350
+
},
351
+
)
352
+
353
+
354
+
@pytest.fixture
355
+
def non_supporter_session() -> Session:
356
+
"""session for a user who is not a supporter."""
357
+
return Session(
358
+
session_id="non-supporter-session-id",
359
+
did="did:plc:randomuser",
360
+
handle="randomuser.bsky.social",
361
+
oauth_session={
362
+
"access_token": "random-access-token",
363
+
"refresh_token": "random-refresh-token",
364
+
"dpop_key": {},
365
+
},
366
+
)
367
+
368
+
369
+
async def test_gated_stream_requires_auth(test_app: FastAPI, gated_track: Track):
370
+
"""regression: GET /audio/{file_id} returns 401 for gated content without auth."""
371
+
test_app.dependency_overrides.pop(require_auth, None)
372
+
373
+
async with AsyncClient(
374
+
transport=ASGITransport(app=test_app), base_url="http://test"
375
+
) as client:
376
+
response = await client.get(
377
+
f"/audio/{gated_track.file_id}", follow_redirects=False
378
+
)
379
+
380
+
assert response.status_code == 401
381
+
assert "authentication required" in response.json()["detail"]
382
+
383
+
384
+
async def test_gated_head_requires_auth(test_app: FastAPI, gated_track: Track):
385
+
"""regression: HEAD /audio/{file_id} returns 401 for gated content without auth."""
386
+
test_app.dependency_overrides.pop(require_auth, None)
387
+
388
+
async with AsyncClient(
389
+
transport=ASGITransport(app=test_app), base_url="http://test"
390
+
) as client:
391
+
response = await client.head(f"/audio/{gated_track.file_id}")
283
392
284
393
assert response.status_code == 401
394
+
395
+
396
+
async def test_gated_head_owner_allowed(
397
+
test_app: FastAPI, gated_track: Track, owner_session: Session
398
+
):
399
+
"""regression: HEAD /audio/{file_id} returns 200 for track owner."""
400
+
from backend._internal import get_optional_session
401
+
402
+
test_app.dependency_overrides[get_optional_session] = lambda: owner_session
403
+
404
+
try:
405
+
async with AsyncClient(
406
+
transport=ASGITransport(app=test_app), base_url="http://test"
407
+
) as client:
408
+
response = await client.head(f"/audio/{gated_track.file_id}")
409
+
410
+
assert response.status_code == 200
411
+
finally:
412
+
test_app.dependency_overrides.pop(get_optional_session, None)
413
+
414
+
415
+
async def test_gated_stream_owner_redirects(
416
+
test_app: FastAPI, gated_track: Track, owner_session: Session
417
+
):
418
+
"""regression: GET /audio/{file_id} returns 307 redirect for track owner."""
419
+
from backend._internal import get_optional_session
420
+
421
+
mock_storage = MagicMock()
422
+
mock_storage.generate_presigned_url = AsyncMock(
423
+
return_value="https://presigned.example.com/audio/gated.mp3"
424
+
)
425
+
426
+
test_app.dependency_overrides[get_optional_session] = lambda: owner_session
427
+
428
+
try:
429
+
with patch("backend.api.audio.storage", mock_storage):
430
+
async with AsyncClient(
431
+
transport=ASGITransport(app=test_app), base_url="http://test"
432
+
) as client:
433
+
response = await client.get(
434
+
f"/audio/{gated_track.file_id}", follow_redirects=False
435
+
)
436
+
437
+
assert response.status_code == 307
438
+
assert "presigned.example.com" in response.headers["location"]
439
+
mock_storage.generate_presigned_url.assert_called_once()
440
+
finally:
441
+
test_app.dependency_overrides.pop(get_optional_session, None)
442
+
443
+
444
+
async def test_gated_head_non_supporter_denied(
445
+
test_app: FastAPI, gated_track: Track, non_supporter_session: Session
446
+
):
447
+
"""regression: HEAD /audio/{file_id} returns 402 for non-supporter."""
448
+
from backend._internal import get_optional_session
449
+
450
+
test_app.dependency_overrides[get_optional_session] = lambda: non_supporter_session
451
+
452
+
# mock validate_supporter to return invalid
453
+
mock_validation = MagicMock()
454
+
mock_validation.valid = False
455
+
456
+
try:
457
+
with patch(
458
+
"backend.api.audio.validate_supporter",
459
+
AsyncMock(return_value=mock_validation),
460
+
):
461
+
async with AsyncClient(
462
+
transport=ASGITransport(app=test_app), base_url="http://test"
463
+
) as client:
464
+
response = await client.head(f"/audio/{gated_track.file_id}")
465
+
466
+
assert response.status_code == 402
467
+
assert response.headers.get("x-support-required") == "true"
468
+
finally:
469
+
test_app.dependency_overrides.pop(get_optional_session, None)
470
+
471
+
472
+
async def test_gated_stream_non_supporter_denied(
473
+
test_app: FastAPI, gated_track: Track, non_supporter_session: Session
474
+
):
475
+
"""regression: GET /audio/{file_id} returns 402 for non-supporter."""
476
+
from backend._internal import get_optional_session
477
+
478
+
test_app.dependency_overrides[get_optional_session] = lambda: non_supporter_session
479
+
480
+
# mock validate_supporter to return invalid
481
+
mock_validation = MagicMock()
482
+
mock_validation.valid = False
483
+
484
+
try:
485
+
with patch(
486
+
"backend.api.audio.validate_supporter",
487
+
AsyncMock(return_value=mock_validation),
488
+
):
489
+
async with AsyncClient(
490
+
transport=ASGITransport(app=test_app), base_url="http://test"
491
+
) as client:
492
+
response = await client.get(
493
+
f"/audio/{gated_track.file_id}", follow_redirects=False
494
+
)
495
+
496
+
assert response.status_code == 402
497
+
assert "supporter access" in response.json()["detail"]
498
+
finally:
499
+
test_app.dependency_overrides.pop(get_optional_session, None)
+83
-1
backend/tests/test_moderation.py
+83
-1
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
···
11
12
get_active_copyright_labels,
12
13
scan_track_for_copyright,
13
14
)
14
-
from backend._internal.moderation_client import ModerationClient, ScanResult
15
+
from backend._internal.moderation_client import (
16
+
ModerationClient,
17
+
ScanResult,
18
+
SensitiveImagesResult,
19
+
)
15
20
from backend.models import Artist, CopyrightScan, Track
16
21
17
22
···
519
524
520
525
# scan2 should still be flagged
521
526
assert scan2.is_flagged is True
527
+
528
+
529
+
# tests for sensitive images
530
+
531
+
532
+
async def test_moderation_client_get_sensitive_images() -> None:
533
+
"""test ModerationClient.get_sensitive_images() with successful response."""
534
+
mock_response = Mock()
535
+
mock_response.json.return_value = {
536
+
"image_ids": ["abc123", "def456"],
537
+
"urls": ["https://example.com/image.jpg"],
538
+
}
539
+
mock_response.raise_for_status.return_value = None
540
+
541
+
client = ModerationClient(
542
+
service_url="https://test.example.com",
543
+
labeler_url="https://labeler.example.com",
544
+
auth_token="test-token",
545
+
timeout_seconds=30,
546
+
label_cache_prefix="test:label:",
547
+
label_cache_ttl_seconds=300,
548
+
)
549
+
550
+
with patch("httpx.AsyncClient.get", new_callable=AsyncMock) as mock_get:
551
+
mock_get.return_value = mock_response
552
+
553
+
result = await client.get_sensitive_images()
554
+
555
+
assert result.image_ids == ["abc123", "def456"]
556
+
assert result.urls == ["https://example.com/image.jpg"]
557
+
mock_get.assert_called_once()
558
+
559
+
560
+
async def test_moderation_client_get_sensitive_images_empty() -> None:
561
+
"""test ModerationClient.get_sensitive_images() with empty response."""
562
+
mock_response = Mock()
563
+
mock_response.json.return_value = {"image_ids": [], "urls": []}
564
+
mock_response.raise_for_status.return_value = None
565
+
566
+
client = ModerationClient(
567
+
service_url="https://test.example.com",
568
+
labeler_url="https://labeler.example.com",
569
+
auth_token="test-token",
570
+
timeout_seconds=30,
571
+
label_cache_prefix="test:label:",
572
+
label_cache_ttl_seconds=300,
573
+
)
574
+
575
+
with patch("httpx.AsyncClient.get", new_callable=AsyncMock) as mock_get:
576
+
mock_get.return_value = mock_response
577
+
578
+
result = await client.get_sensitive_images()
579
+
580
+
assert result.image_ids == []
581
+
assert result.urls == []
582
+
583
+
584
+
async def test_get_sensitive_images_endpoint(
585
+
client: TestClient,
586
+
) -> None:
587
+
"""test GET /moderation/sensitive-images endpoint proxies to moderation service."""
588
+
mock_result = SensitiveImagesResult(
589
+
image_ids=["image1", "image2"],
590
+
urls=["https://example.com/avatar.jpg"],
591
+
)
592
+
593
+
with patch("backend.api.moderation.get_moderation_client") as mock_get_client:
594
+
mock_client = AsyncMock()
595
+
mock_client.get_sensitive_images.return_value = mock_result
596
+
mock_get_client.return_value = mock_client
597
+
598
+
response = client.get("/moderation/sensitive-images")
599
+
600
+
assert response.status_code == 200
601
+
data = response.json()
602
+
assert data["image_ids"] == ["image1", "image2"]
603
+
assert data["urls"] == ["https://example.com/avatar.jpg"]
+29
-17
docs/backend/background-tasks.md
+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
+
```
+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>
+29
-20
docs/research/2025-12-20-atprotofans-paywall-integration.md
+29
-20
docs/research/2025-12-20-atprotofans-paywall-integration.md
···
91
91
92
92
## implementation phases
93
93
94
-
### phase 1: read-only validation (week 1)
94
+
### phase 1: read-only validation (week 1) - IMPLEMENTED
95
95
96
96
**goal**: show supporter badges, no platform registration required
97
97
98
-
1. **add validateSupporter calls to artist page**
98
+
**status**: completed 2025-12-20
99
+
100
+
1. **add validateSupporter calls to artist page** โ
99
101
```typescript
100
102
// when viewing artist page, if viewer is logged in:
101
-
const validation = await fetch(
102
-
`https://atprotofans.com/xrpc/com.atprotofans.validateSupporter` +
103
-
`?supporter=${viewer.did}&subject=${artist.did}&signer=${artist.did}`
104
-
);
105
-
if (validation.valid) {
106
-
// show "supporter" badge
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;
107
113
}
108
114
```
109
115
110
-
2. **cache validation results**
111
-
- redis cache with 5-minute TTL
112
-
- key: `atprotofans:supporter:{viewer_did}:{artist_did}`
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
113
119
114
-
3. **display supporter badge on profile**
115
-
- similar to verified badge styling
116
-
- tooltip: "supports this artist via atprotofans"
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
117
124
118
-
**frontend changes:**
119
-
- `+page.svelte` (artist): call validation on mount if viewer logged in
120
-
- new `SupporterBadge.svelte` component
125
+
**files changed:**
126
+
- `frontend/src/routes/u/[handle]/+page.svelte` - added validation logic
127
+
- `frontend/src/lib/components/SupporterBadge.svelte` - new component
121
128
122
-
**backend changes:**
123
-
- new endpoint: `GET /artists/{did}/supporter-status?viewer_did={did}`
124
-
- or: call atprotofans directly from frontend (simpler, public endpoint)
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
125
134
126
135
### phase 2: platform registration (week 2)
127
136
+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`)
+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;
+19
-17
frontend/src/lib/components/AddToMenu.svelte
+19
-17
frontend/src/lib/components/AddToMenu.svelte
···
10
10
trackUri?: string;
11
11
trackCid?: string;
12
12
fileId?: string;
13
+
gated?: boolean;
13
14
initialLiked?: boolean;
14
15
disabled?: boolean;
15
16
disabledReason?: string;
···
25
26
trackUri,
26
27
trackCid,
27
28
fileId,
29
+
gated,
28
30
initialLiked = false,
29
31
disabled = false,
30
32
disabledReason,
···
102
104
103
105
try {
104
106
const success = liked
105
-
? await likeTrack(trackId, fileId)
107
+
? await likeTrack(trackId, fileId, gated)
106
108
: await unlikeTrack(trackId);
107
109
108
110
if (!success) {
···
437
439
justify-content: center;
438
440
background: transparent;
439
441
border: 1px solid var(--border-default);
440
-
border-radius: 4px;
442
+
border-radius: var(--radius-sm);
441
443
color: var(--text-tertiary);
442
444
cursor: pointer;
443
445
transition: all 0.2s;
···
488
490
min-width: 200px;
489
491
background: var(--bg-secondary);
490
492
border: 1px solid var(--border-default);
491
-
border-radius: 8px;
493
+
border-radius: var(--radius-md);
492
494
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
493
495
overflow: hidden;
494
496
z-index: 10;
···
508
510
background: transparent;
509
511
border: none;
510
512
color: var(--text-primary);
511
-
font-size: 0.9rem;
513
+
font-size: var(--text-base);
512
514
font-family: inherit;
513
515
cursor: pointer;
514
516
transition: background 0.15s;
···
542
544
border: none;
543
545
border-bottom: 1px solid var(--border-subtle);
544
546
color: var(--text-secondary);
545
-
font-size: 0.85rem;
547
+
font-size: var(--text-sm);
546
548
font-family: inherit;
547
549
cursor: pointer;
548
550
transition: background 0.15s;
···
565
567
566
568
.playlist-list::-webkit-scrollbar-track {
567
569
background: transparent;
568
-
border-radius: 4px;
570
+
border-radius: var(--radius-sm);
569
571
}
570
572
571
573
.playlist-list::-webkit-scrollbar-thumb {
572
574
background: var(--border-default);
573
-
border-radius: 4px;
575
+
border-radius: var(--radius-sm);
574
576
}
575
577
576
578
.playlist-list::-webkit-scrollbar-thumb:hover {
···
586
588
background: transparent;
587
589
border: none;
588
590
color: var(--text-primary);
589
-
font-size: 0.9rem;
591
+
font-size: var(--text-base);
590
592
font-family: inherit;
591
593
cursor: pointer;
592
594
transition: background 0.15s;
···
605
607
.playlist-thumb-placeholder {
606
608
width: 32px;
607
609
height: 32px;
608
-
border-radius: 4px;
610
+
border-radius: var(--radius-sm);
609
611
flex-shrink: 0;
610
612
}
611
613
···
637
639
gap: 0.5rem;
638
640
padding: 1.5rem 1rem;
639
641
color: var(--text-tertiary);
640
-
font-size: 0.85rem;
642
+
font-size: var(--text-sm);
641
643
}
642
644
643
645
.create-playlist-btn {
···
650
652
border: none;
651
653
border-top: 1px solid var(--border-subtle);
652
654
color: var(--accent);
653
-
font-size: 0.9rem;
655
+
font-size: var(--text-base);
654
656
font-family: inherit;
655
657
cursor: pointer;
656
658
transition: background 0.15s;
···
673
675
padding: 0.625rem 0.75rem;
674
676
background: var(--bg-tertiary);
675
677
border: 1px solid var(--border-default);
676
-
border-radius: 6px;
678
+
border-radius: var(--radius-base);
677
679
color: var(--text-primary);
678
680
font-family: inherit;
679
-
font-size: 0.9rem;
681
+
font-size: var(--text-base);
680
682
}
681
683
682
684
.create-form input:focus {
···
696
698
padding: 0.625rem 1rem;
697
699
background: var(--accent);
698
700
border: none;
699
-
border-radius: 6px;
701
+
border-radius: var(--radius-base);
700
702
color: white;
701
703
font-family: inherit;
702
-
font-size: 0.9rem;
704
+
font-size: var(--text-base);
703
705
font-weight: 500;
704
706
cursor: pointer;
705
707
transition: opacity 0.15s;
···
719
721
height: 16px;
720
722
border: 2px solid var(--border-default);
721
723
border-top-color: var(--accent);
722
-
border-radius: 50%;
724
+
border-radius: var(--radius-full);
723
725
animation: spin 0.8s linear infinite;
724
726
}
725
727
···
781
783
782
784
.menu-item {
783
785
padding: 1rem 1.25rem;
784
-
font-size: 1rem;
786
+
font-size: var(--text-lg);
785
787
}
786
788
787
789
.back-button {
+7
-7
frontend/src/lib/components/AlbumSelect.svelte
+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 {
+4
-3
frontend/src/lib/components/LikeButton.svelte
+4
-3
frontend/src/lib/components/LikeButton.svelte
···
6
6
trackId: number;
7
7
trackTitle: string;
8
8
fileId?: string;
9
+
gated?: boolean;
9
10
initialLiked?: boolean;
10
11
disabled?: boolean;
11
12
disabledReason?: string;
12
13
onLikeChange?: (_liked: boolean) => void;
13
14
}
14
15
15
-
let { trackId, trackTitle, fileId, initialLiked = false, disabled = false, disabledReason, onLikeChange }: Props = $props();
16
+
let { trackId, trackTitle, fileId, gated, initialLiked = false, disabled = false, disabledReason, onLikeChange }: Props = $props();
16
17
17
18
// use overridable $derived (Svelte 5.25+) - syncs with prop but can be overridden for optimistic UI
18
19
let liked = $derived(initialLiked);
···
31
32
32
33
try {
33
34
const success = liked
34
-
? await likeTrack(trackId, fileId)
35
+
? await likeTrack(trackId, fileId, gated)
35
36
: await unlikeTrack(trackId);
36
37
37
38
if (!success) {
···
83
84
justify-content: center;
84
85
background: transparent;
85
86
border: 1px solid var(--border-default);
86
-
border-radius: 4px;
87
+
border-radius: var(--radius-sm);
87
88
color: var(--text-tertiary);
88
89
cursor: pointer;
89
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 {
+8
-8
frontend/src/lib/components/LinksMenu.svelte
+8
-8
frontend/src/lib/components/LinksMenu.svelte
···
140
140
height: 32px;
141
141
background: transparent;
142
142
border: 1px solid var(--border-default);
143
-
border-radius: 6px;
143
+
border-radius: var(--radius-base);
144
144
color: var(--text-secondary);
145
145
cursor: pointer;
146
146
transition: all 0.2s;
···
171
171
width: min(320px, calc(100vw - 2rem));
172
172
background: var(--bg-secondary);
173
173
border: 1px solid var(--border-default);
174
-
border-radius: 12px;
174
+
border-radius: var(--radius-lg);
175
175
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
176
176
z-index: 101;
177
177
animation: slideIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);
···
186
186
}
187
187
188
188
.menu-header span {
189
-
font-size: 0.9rem;
189
+
font-size: var(--text-base);
190
190
font-weight: 600;
191
191
color: var(--text-primary);
192
192
text-transform: uppercase;
···
201
201
height: 28px;
202
202
background: transparent;
203
203
border: none;
204
-
border-radius: 4px;
204
+
border-radius: var(--radius-sm);
205
205
color: var(--text-secondary);
206
206
cursor: pointer;
207
207
transition: all 0.2s;
···
224
224
gap: 1rem;
225
225
padding: 1rem;
226
226
background: transparent;
227
-
border-radius: 8px;
227
+
border-radius: var(--radius-md);
228
228
text-decoration: none;
229
229
color: var(--text-primary);
230
230
transition: all 0.2s;
···
245
245
}
246
246
247
247
.tangled-menu-icon {
248
-
border-radius: 4px;
248
+
border-radius: var(--radius-sm);
249
249
opacity: 0.7;
250
250
transition: opacity 0.2s, box-shadow 0.2s;
251
251
}
···
263
263
}
264
264
265
265
.link-title {
266
-
font-size: 0.95rem;
266
+
font-size: var(--text-base);
267
267
font-weight: 500;
268
268
color: var(--text-primary);
269
269
}
270
270
271
271
.link-subtitle {
272
-
font-size: 0.8rem;
272
+
font-size: var(--text-sm);
273
273
color: var(--text-tertiary);
274
274
}
275
275
+4
-4
frontend/src/lib/components/MigrationBanner.svelte
+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;
+19
-19
frontend/src/lib/components/ProfileMenu.svelte
+19
-19
frontend/src/lib/components/ProfileMenu.svelte
···
276
276
height: 44px;
277
277
background: transparent;
278
278
border: 1px solid var(--border-default);
279
-
border-radius: 8px;
279
+
border-radius: var(--radius-md);
280
280
color: var(--text-secondary);
281
281
cursor: pointer;
282
282
transition: all 0.15s;
···
311
311
overflow-y: auto;
312
312
background: var(--bg-secondary);
313
313
border: 1px solid var(--border-default);
314
-
border-radius: 16px;
314
+
border-radius: var(--radius-xl);
315
315
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
316
316
z-index: 101;
317
317
animation: slideIn 0.18s cubic-bezier(0.16, 1, 0.3, 1);
···
326
326
}
327
327
328
328
.menu-header span {
329
-
font-size: 0.9rem;
329
+
font-size: var(--text-base);
330
330
font-weight: 600;
331
331
color: var(--text-primary);
332
332
text-transform: uppercase;
···
341
341
height: 36px;
342
342
background: transparent;
343
343
border: none;
344
-
border-radius: 8px;
344
+
border-radius: var(--radius-md);
345
345
color: var(--text-secondary);
346
346
cursor: pointer;
347
347
transition: all 0.15s;
···
371
371
min-height: 56px;
372
372
background: transparent;
373
373
border: none;
374
-
border-radius: 12px;
374
+
border-radius: var(--radius-lg);
375
375
text-decoration: none;
376
376
color: var(--text-primary);
377
377
font-family: inherit;
···
414
414
}
415
415
416
416
.item-title {
417
-
font-size: 0.95rem;
417
+
font-size: var(--text-base);
418
418
font-weight: 500;
419
419
color: var(--text-primary);
420
420
}
421
421
422
422
.item-subtitle {
423
-
font-size: 0.8rem;
423
+
font-size: var(--text-sm);
424
424
color: var(--text-tertiary);
425
425
overflow: hidden;
426
426
text-overflow: ellipsis;
···
440
440
padding: 0.5rem 0.75rem;
441
441
background: transparent;
442
442
border: none;
443
-
border-radius: 6px;
443
+
border-radius: var(--radius-base);
444
444
color: var(--text-secondary);
445
445
font-family: inherit;
446
-
font-size: 0.85rem;
446
+
font-size: var(--text-sm);
447
447
cursor: pointer;
448
448
transition: all 0.15s;
449
449
-webkit-tap-highlight-color: transparent;
···
469
469
470
470
.settings-section h3 {
471
471
margin: 0;
472
-
font-size: 0.75rem;
472
+
font-size: var(--text-xs);
473
473
text-transform: uppercase;
474
474
letter-spacing: 0.08em;
475
475
color: var(--text-tertiary);
···
490
490
min-height: 54px;
491
491
background: var(--bg-tertiary);
492
492
border: 1px solid var(--border-default);
493
-
border-radius: 8px;
493
+
border-radius: var(--radius-md);
494
494
color: var(--text-secondary);
495
495
cursor: pointer;
496
496
transition: all 0.15s;
···
518
518
}
519
519
520
520
.theme-btn span {
521
-
font-size: 0.7rem;
521
+
font-size: var(--text-xs);
522
522
text-transform: uppercase;
523
523
letter-spacing: 0.05em;
524
524
}
···
533
533
width: 44px;
534
534
height: 44px;
535
535
border: 1px solid var(--border-default);
536
-
border-radius: 8px;
536
+
border-radius: var(--radius-md);
537
537
cursor: pointer;
538
538
background: transparent;
539
539
flex-shrink: 0;
···
544
544
}
545
545
546
546
.color-input::-webkit-color-swatch {
547
-
border-radius: 4px;
547
+
border-radius: var(--radius-sm);
548
548
border: none;
549
549
}
550
550
···
557
557
.preset-btn {
558
558
width: 36px;
559
559
height: 36px;
560
-
border-radius: 6px;
560
+
border-radius: var(--radius-base);
561
561
border: 2px solid transparent;
562
562
cursor: pointer;
563
563
transition: all 0.15s;
···
583
583
align-items: center;
584
584
gap: 0.75rem;
585
585
color: var(--text-primary);
586
-
font-size: 0.9rem;
586
+
font-size: var(--text-base);
587
587
cursor: pointer;
588
588
padding: 0.5rem 0;
589
589
}
···
592
592
appearance: none;
593
593
width: 48px;
594
594
height: 28px;
595
-
border-radius: 999px;
595
+
border-radius: var(--radius-full);
596
596
background: var(--border-default);
597
597
position: relative;
598
598
cursor: pointer;
···
608
608
left: 3px;
609
609
width: 20px;
610
610
height: 20px;
611
-
border-radius: 50%;
611
+
border-radius: var(--radius-full);
612
612
background: var(--text-secondary);
613
613
transition: transform 0.15s, background 0.15s;
614
614
}
···
635
635
border-top: 1px solid var(--border-subtle);
636
636
color: var(--text-secondary);
637
637
text-decoration: none;
638
-
font-size: 0.9rem;
638
+
font-size: var(--text-base);
639
639
transition: color 0.15s;
640
640
}
641
641
+19
-18
frontend/src/lib/components/Queue.svelte
+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 {
+18
-16
frontend/src/lib/components/TrackActionsMenu.svelte
+18
-16
frontend/src/lib/components/TrackActionsMenu.svelte
···
10
10
trackUri?: string;
11
11
trackCid?: string;
12
12
fileId?: string;
13
+
gated?: boolean;
13
14
initialLiked: boolean;
14
15
shareUrl: string;
15
16
onQueue: () => void;
···
24
25
trackUri,
25
26
trackCid,
26
27
fileId,
28
+
gated,
27
29
initialLiked,
28
30
shareUrl,
29
31
onQueue,
···
101
103
102
104
try {
103
105
const success = liked
104
-
? await likeTrack(trackId, fileId)
106
+
? await likeTrack(trackId, fileId, gated)
105
107
: await unlikeTrack(trackId);
106
108
107
109
if (!success) {
···
400
402
justify-content: center;
401
403
background: transparent;
402
404
border: 1px solid var(--border-default);
403
-
border-radius: 4px;
405
+
border-radius: var(--radius-sm);
404
406
color: var(--text-tertiary);
405
407
cursor: pointer;
406
408
transition: all 0.2s;
···
472
474
}
473
475
474
476
.menu-item span {
475
-
font-size: 1rem;
477
+
font-size: var(--text-lg);
476
478
font-weight: 400;
477
479
flex: 1;
478
480
}
···
506
508
border: none;
507
509
border-bottom: 1px solid var(--border-default);
508
510
color: var(--text-secondary);
509
-
font-size: 0.9rem;
511
+
font-size: var(--text-base);
510
512
font-family: inherit;
511
513
cursor: pointer;
512
514
transition: background 0.15s;
···
532
534
border: none;
533
535
border-bottom: 1px solid var(--border-subtle);
534
536
color: var(--text-primary);
535
-
font-size: 1rem;
537
+
font-size: var(--text-lg);
536
538
font-family: inherit;
537
539
cursor: pointer;
538
540
transition: background 0.15s;
···
556
558
.playlist-thumb-placeholder {
557
559
width: 36px;
558
560
height: 36px;
559
-
border-radius: 4px;
561
+
border-radius: var(--radius-sm);
560
562
flex-shrink: 0;
561
563
}
562
564
···
588
590
gap: 0.5rem;
589
591
padding: 2rem 1rem;
590
592
color: var(--text-tertiary);
591
-
font-size: 0.9rem;
593
+
font-size: var(--text-base);
592
594
}
593
595
594
596
.create-playlist-btn {
···
601
603
border: none;
602
604
border-top: 1px solid var(--border-subtle);
603
605
color: var(--accent);
604
-
font-size: 1rem;
606
+
font-size: var(--text-lg);
605
607
font-family: inherit;
606
608
cursor: pointer;
607
609
transition: background 0.15s;
···
625
627
padding: 0.75rem 1rem;
626
628
background: var(--bg-tertiary);
627
629
border: 1px solid var(--border-default);
628
-
border-radius: 8px;
630
+
border-radius: var(--radius-md);
629
631
color: var(--text-primary);
630
632
font-family: inherit;
631
-
font-size: 1rem;
633
+
font-size: var(--text-lg);
632
634
}
633
635
634
636
.create-form input:focus {
···
648
650
padding: 0.75rem 1rem;
649
651
background: var(--accent);
650
652
border: none;
651
-
border-radius: 8px;
653
+
border-radius: var(--radius-md);
652
654
color: white;
653
655
font-family: inherit;
654
-
font-size: 1rem;
656
+
font-size: var(--text-lg);
655
657
font-weight: 500;
656
658
cursor: pointer;
657
659
transition: opacity 0.15s;
···
671
673
height: 18px;
672
674
border: 2px solid var(--border-default);
673
675
border-top-color: var(--accent);
674
-
border-radius: 50%;
676
+
border-radius: var(--radius-full);
675
677
animation: spin 0.8s linear infinite;
676
678
}
677
679
···
698
700
top: 50%;
699
701
transform: translateY(-50%);
700
702
margin-right: 0.5rem;
701
-
border-radius: 8px;
703
+
border-radius: var(--radius-md);
702
704
min-width: 180px;
703
705
max-height: none;
704
706
animation: slideIn 0.15s cubic-bezier(0.16, 1, 0.3, 1);
···
721
723
}
722
724
723
725
.menu-item span {
724
-
font-size: 0.9rem;
726
+
font-size: var(--text-base);
725
727
}
726
728
727
729
.menu-item svg {
···
735
737
736
738
.playlist-item {
737
739
padding: 0.625rem 1rem;
738
-
font-size: 0.9rem;
740
+
font-size: var(--text-base);
739
741
}
740
742
741
743
.playlist-thumb,
+135
-58
frontend/src/lib/components/TrackItem.svelte
+135
-58
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;
···
74
75
75
76
function addToQueue(e: Event) {
76
77
e.stopPropagation();
78
+
if (!guardGatedTrack(track, isAuthenticated)) return;
77
79
queue.addTracks([track]);
78
80
toast.success(`queued ${track.title}`, 1800);
79
81
}
80
82
81
83
function handleQueue() {
84
+
if (!guardGatedTrack(track, isAuthenticated)) return;
82
85
queue.addTracks([track]);
83
86
toast.success(`queued ${track.title}`, 1800);
84
87
}
···
130
133
{/if}
131
134
<button
132
135
class="track"
133
-
onclick={(e) => {
136
+
onclick={async (e) => {
134
137
// only play if clicking the track itself, not a link inside
135
138
if (e.target instanceof HTMLAnchorElement || (e.target as HTMLElement).closest('a')) {
136
139
return;
137
140
}
138
-
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
+
}
139
147
}}
140
148
>
141
-
{#if track.image_url && !trackImageError}
142
-
<SensitiveImage src={track.image_url}>
143
-
<div class="track-image">
144
-
<img
145
-
src={track.image_url}
146
-
alt="{track.title} artwork"
147
-
width="48"
148
-
height="48"
149
-
loading={imageLoading}
150
-
fetchpriority={imageFetchPriority}
151
-
onerror={() => trackImageError = true}
152
-
/>
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>
153
187
</div>
154
-
</SensitiveImage>
155
-
{:else if track.artist_avatar_url && !avatarError}
156
-
<SensitiveImage src={track.artist_avatar_url}>
157
-
<a
158
-
href="/u/{track.artist_handle}"
159
-
class="track-avatar"
160
-
>
161
-
<img
162
-
src={track.artist_avatar_url}
163
-
alt={track.artist}
164
-
width="48"
165
-
height="48"
166
-
loading={imageLoading}
167
-
fetchpriority={imageFetchPriority}
168
-
onerror={() => avatarError = true}
169
-
/>
170
-
</a>
171
-
</SensitiveImage>
172
-
{:else}
173
-
<div class="track-image-placeholder">
174
-
<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">
175
-
<circle cx="8" cy="5" r="3" stroke="currentColor" stroke-width="1.5" fill="none" />
176
-
<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" />
177
-
</svg>
178
-
</div>
179
-
{/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>
180
197
<div class="track-info">
181
198
<div class="track-title">{track.title}</div>
182
199
<div class="track-metadata">
···
289
306
trackUri={track.atproto_record_uri}
290
307
trackCid={track.atproto_record_cid}
291
308
fileId={track.file_id}
309
+
gated={track.gated}
292
310
initialLiked={track.is_liked || false}
293
311
disabled={!track.atproto_record_uri}
294
312
disabledReason={!track.atproto_record_uri ? "track's record is unavailable" : undefined}
···
322
340
trackUri={track.atproto_record_uri}
323
341
trackCid={track.atproto_record_cid}
324
342
fileId={track.file_id}
343
+
gated={track.gated}
325
344
initialLiked={track.is_liked || false}
326
345
shareUrl={shareUrl}
327
346
onQueue={handleQueue}
···
340
359
gap: 0.75rem;
341
360
background: var(--track-bg, var(--bg-secondary));
342
361
border: 1px solid var(--track-border, var(--border-subtle));
343
-
border-radius: 8px;
362
+
border-radius: var(--radius-md);
344
363
padding: 1rem;
345
364
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
346
365
transition:
···
351
370
352
371
.track-index {
353
372
width: 24px;
354
-
font-size: 0.85rem;
373
+
font-size: var(--text-sm);
355
374
color: var(--text-muted);
356
375
text-align: center;
357
376
flex-shrink: 0;
···
397
416
font-family: inherit;
398
417
}
399
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
+
400
451
.track-image,
401
452
.track-image-placeholder {
402
453
flex-shrink: 0;
···
405
456
display: flex;
406
457
align-items: center;
407
458
justify-content: center;
408
-
border-radius: 4px;
459
+
border-radius: var(--radius-sm);
409
460
overflow: hidden;
410
461
background: var(--bg-tertiary);
411
462
border: 1px solid var(--border-subtle);
···
439
490
}
440
491
441
492
.track-avatar img {
442
-
border-radius: 50%;
493
+
border-radius: var(--radius-full);
443
494
border: 2px solid var(--border-default);
444
495
transition: border-color 0.2s;
445
496
}
···
489
540
align-items: flex-start;
490
541
gap: 0.15rem;
491
542
color: var(--text-secondary);
492
-
font-size: 0.9rem;
543
+
font-size: var(--text-base);
493
544
font-family: inherit;
494
545
min-width: 0;
495
546
width: 100%;
···
509
560
510
561
.metadata-separator {
511
562
display: none;
512
-
font-size: 0.7rem;
563
+
font-size: var(--text-xs);
513
564
}
514
565
515
566
.artist-link {
···
608
659
padding: 0.1rem 0.4rem;
609
660
background: color-mix(in srgb, var(--accent) 15%, transparent);
610
661
color: var(--accent-hover);
611
-
border-radius: 3px;
612
-
font-size: 0.75rem;
662
+
border-radius: var(--radius-sm);
663
+
font-size: var(--text-xs);
613
664
font-weight: 500;
614
665
text-decoration: none;
615
666
transition: all 0.15s;
···
628
679
background: var(--bg-tertiary);
629
680
color: var(--text-muted);
630
681
border: none;
631
-
border-radius: 3px;
632
-
font-size: 0.75rem;
682
+
border-radius: var(--radius-sm);
683
+
font-size: var(--text-xs);
633
684
font-weight: 500;
634
685
font-family: inherit;
635
686
cursor: pointer;
···
644
695
}
645
696
646
697
.track-meta {
647
-
font-size: 0.8rem;
698
+
font-size: var(--text-sm);
648
699
color: var(--text-tertiary);
649
700
display: flex;
650
701
align-items: center;
···
658
709
659
710
.meta-separator {
660
711
color: var(--text-muted);
661
-
font-size: 0.7rem;
712
+
font-size: var(--text-xs);
662
713
}
663
714
664
715
.likes {
···
705
756
justify-content: center;
706
757
background: transparent;
707
758
border: 1px solid var(--border-default);
708
-
border-radius: 4px;
759
+
border-radius: var(--radius-sm);
709
760
color: var(--text-tertiary);
710
761
cursor: pointer;
711
762
transition: all 0.2s;
···
750
801
gap: 0.5rem;
751
802
}
752
803
804
+
.track-image-wrapper,
753
805
.track-image,
754
806
.track-image-placeholder,
755
807
.track-avatar {
···
757
809
height: 40px;
758
810
}
759
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
+
760
824
.track-title {
761
-
font-size: 0.9rem;
825
+
font-size: var(--text-base);
762
826
}
763
827
764
828
.track-metadata {
765
-
font-size: 0.8rem;
829
+
font-size: var(--text-sm);
766
830
gap: 0.35rem;
767
831
}
768
832
769
833
.track-meta {
770
-
font-size: 0.7rem;
834
+
font-size: var(--text-xs);
771
835
}
772
836
773
837
.track-actions {
···
790
854
padding: 0.5rem 0.65rem;
791
855
}
792
856
857
+
.track-image-wrapper,
793
858
.track-image,
794
859
.track-image-placeholder,
795
860
.track-avatar {
···
797
862
height: 36px;
798
863
}
799
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
+
800
877
.track-title {
801
-
font-size: 0.85rem;
878
+
font-size: var(--text-sm);
802
879
}
803
880
804
881
.track-metadata {
805
-
font-size: 0.75rem;
882
+
font-size: var(--text-xs);
806
883
}
807
884
808
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
+138
-23
frontend/src/lib/components/player/Player.svelte
+138
-23
frontend/src/lib/components/player/Player.svelte
···
4
4
import { nowPlaying } from '$lib/now-playing.svelte';
5
5
import { moderation } from '$lib/moderation.svelte';
6
6
import { preferences } from '$lib/preferences.svelte';
7
+
import { toast } from '$lib/toast.svelte';
7
8
import { API_URL } from '$lib/config';
8
9
import { getCachedAudioUrl } from '$lib/storage';
9
10
import { onMount } from 'svelte';
···
11
12
import TrackInfo from './TrackInfo.svelte';
12
13
import PlaybackControls from './PlaybackControls.svelte';
13
14
import type { Track } from '$lib/types';
15
+
16
+
// atprotofans base URL for supporter CTAs
17
+
const ATPROTOFANS_URL = 'https://atprotofans.com';
14
18
15
19
// check if artwork should be shown in media session (respects sensitive content settings)
16
20
function shouldShowArtwork(url: string | null | undefined): boolean {
···
239
243
);
240
244
});
241
245
246
+
// gated content error types
247
+
interface GatedError {
248
+
type: 'gated';
249
+
artistDid: string;
250
+
artistHandle: string;
251
+
requiresAuth: boolean;
252
+
}
253
+
242
254
// get audio source URL - checks local cache first, falls back to network
243
-
async function getAudioSource(file_id: string): Promise<string> {
255
+
// throws GatedError if the track requires supporter access
256
+
async function getAudioSource(file_id: string, track: Track): Promise<string> {
244
257
try {
245
258
const cachedUrl = await getCachedAudioUrl(file_id);
246
259
if (cachedUrl) {
···
249
262
} catch (err) {
250
263
console.error('failed to check audio cache:', err);
251
264
}
265
+
266
+
// for gated tracks, check authorization first
267
+
if (track.gated) {
268
+
const response = await fetch(`${API_URL}/audio/${file_id}`, {
269
+
method: 'HEAD',
270
+
credentials: 'include'
271
+
});
272
+
273
+
if (response.status === 401) {
274
+
throw {
275
+
type: 'gated',
276
+
artistDid: track.artist_did,
277
+
artistHandle: track.artist_handle,
278
+
requiresAuth: true
279
+
} as GatedError;
280
+
}
281
+
282
+
if (response.status === 402) {
283
+
throw {
284
+
type: 'gated',
285
+
artistDid: track.artist_did,
286
+
artistHandle: track.artist_handle,
287
+
requiresAuth: false
288
+
} as GatedError;
289
+
}
290
+
}
291
+
252
292
return `${API_URL}/audio/${file_id}`;
253
293
}
254
294
···
266
306
let previousTrackId = $state<number | null>(null);
267
307
let isLoadingTrack = $state(false);
268
308
309
+
// store previous playback state for restoration on gated errors
310
+
let savedPlaybackState = $state<{
311
+
track: Track;
312
+
src: string;
313
+
currentTime: number;
314
+
paused: boolean;
315
+
} | null>(null);
316
+
269
317
$effect(() => {
270
318
if (!player.currentTrack || !player.audioElement) return;
271
319
272
320
// only load new track if it actually changed
273
321
if (player.currentTrack.id !== previousTrackId) {
274
322
const trackToLoad = player.currentTrack;
323
+
const audioElement = player.audioElement;
324
+
325
+
// save current playback state BEFORE changing anything
326
+
// (only if we have a playing/paused track to restore to)
327
+
if (previousTrackId !== null && audioElement.src && !audioElement.src.startsWith('blob:')) {
328
+
const prevTrack = queue.tracks.find((t) => t.id === previousTrackId);
329
+
if (prevTrack) {
330
+
savedPlaybackState = {
331
+
track: prevTrack,
332
+
src: audioElement.src,
333
+
currentTime: audioElement.currentTime,
334
+
paused: audioElement.paused
335
+
};
336
+
}
337
+
}
338
+
339
+
// update tracking state
275
340
previousTrackId = trackToLoad.id;
276
341
player.resetPlayCount();
277
342
isLoadingTrack = true;
···
280
345
cleanupBlobUrl();
281
346
282
347
// async: get audio source (cached or network)
283
-
getAudioSource(trackToLoad.file_id).then((src) => {
284
-
// check if track is still current (user may have changed tracks during await)
285
-
if (player.currentTrack?.id !== trackToLoad.id || !player.audioElement) {
286
-
// track changed, cleanup if we created a blob URL
348
+
getAudioSource(trackToLoad.file_id, trackToLoad)
349
+
.then((src) => {
350
+
// check if track is still current (user may have changed tracks during await)
351
+
if (player.currentTrack?.id !== trackToLoad.id || !player.audioElement) {
352
+
// track changed, cleanup if we created a blob URL
353
+
if (src.startsWith('blob:')) {
354
+
URL.revokeObjectURL(src);
355
+
}
356
+
return;
357
+
}
358
+
359
+
// successfully got source - clear saved state
360
+
savedPlaybackState = null;
361
+
362
+
// track if this is a blob URL so we can revoke it later
287
363
if (src.startsWith('blob:')) {
288
-
URL.revokeObjectURL(src);
364
+
currentBlobUrl = src;
289
365
}
290
-
return;
291
-
}
366
+
367
+
player.audioElement.src = src;
368
+
player.audioElement.load();
292
369
293
-
// track if this is a blob URL so we can revoke it later
294
-
if (src.startsWith('blob:')) {
295
-
currentBlobUrl = src;
296
-
}
370
+
// wait for audio to be ready before allowing playback
371
+
player.audioElement.addEventListener(
372
+
'loadeddata',
373
+
() => {
374
+
isLoadingTrack = false;
375
+
},
376
+
{ once: true }
377
+
);
378
+
})
379
+
.catch((err) => {
380
+
isLoadingTrack = false;
297
381
298
-
player.audioElement.src = src;
299
-
player.audioElement.load();
382
+
// handle gated content errors with supporter CTA
383
+
if (err && err.type === 'gated') {
384
+
const gatedErr = err as GatedError;
300
385
301
-
// wait for audio to be ready before allowing playback
302
-
player.audioElement.addEventListener(
303
-
'loadeddata',
304
-
() => {
305
-
isLoadingTrack = false;
306
-
},
307
-
{ once: true }
308
-
);
309
-
});
386
+
if (gatedErr.requiresAuth) {
387
+
toast.info('sign in to play supporter-only tracks');
388
+
} else {
389
+
// show toast with supporter CTA
390
+
const supportUrl = gatedErr.artistDid
391
+
? `${ATPROTOFANS_URL}/${gatedErr.artistDid}`
392
+
: `${ATPROTOFANS_URL}/${gatedErr.artistHandle}`;
393
+
394
+
toast.info('this track is for supporters only', 5000, {
395
+
label: 'become a supporter',
396
+
href: supportUrl
397
+
});
398
+
}
399
+
400
+
// restore previous playback if we had something playing
401
+
if (savedPlaybackState && player.audioElement) {
402
+
player.currentTrack = savedPlaybackState.track;
403
+
previousTrackId = savedPlaybackState.track.id;
404
+
player.audioElement.src = savedPlaybackState.src;
405
+
player.audioElement.currentTime = savedPlaybackState.currentTime;
406
+
if (!savedPlaybackState.paused) {
407
+
player.audioElement.play().catch(() => {});
408
+
}
409
+
savedPlaybackState = null;
410
+
return;
411
+
}
412
+
413
+
// no previous state to restore - skip to next or stop
414
+
if (queue.hasNext) {
415
+
queue.next();
416
+
} else {
417
+
player.currentTrack = null;
418
+
player.paused = true;
419
+
}
420
+
return;
421
+
}
422
+
423
+
console.error('failed to load audio:', err);
424
+
});
310
425
}
311
426
});
312
427
+4
-4
frontend/src/lib/components/player/TrackInfo.svelte
+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
+
}
+4
-2
frontend/src/lib/tracks.svelte.ts
+4
-2
frontend/src/lib/tracks.svelte.ts
···
133
133
export const tracksCache = new TracksCache();
134
134
135
135
// like/unlike track functions
136
-
export async function likeTrack(trackId: number, fileId?: string): Promise<boolean> {
136
+
// gated: true means viewer lacks access (non-supporter), false means accessible
137
+
export async function likeTrack(trackId: number, fileId?: string, gated?: boolean): Promise<boolean> {
137
138
try {
138
139
const response = await fetch(`${API_URL}/tracks/${trackId}/like`, {
139
140
method: 'POST',
···
148
149
tracksCache.invalidate();
149
150
150
151
// auto-download if preference is enabled and file_id provided
151
-
if (fileId && preferences.autoDownloadLiked) {
152
+
// skip download only if track is gated AND viewer lacks access (gated === true)
153
+
if (fileId && preferences.autoDownloadLiked && gated !== true) {
152
154
try {
153
155
const alreadyDownloaded = await isDownloaded(fileId);
154
156
if (!alreadyDownloaded) {
+7
frontend/src/lib/types.ts
+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>
+32
-4
frontend/src/routes/+layout.svelte
+32
-4
frontend/src/routes/+layout.svelte
···
450
450
--text-muted: #666666;
451
451
452
452
/* typography scale */
453
-
--text-page-heading: 1.5rem;
453
+
--text-xs: 0.75rem;
454
+
--text-sm: 0.85rem;
455
+
--text-base: 0.9rem;
456
+
--text-lg: 1rem;
457
+
--text-xl: 1.1rem;
458
+
--text-2xl: 1.25rem;
459
+
--text-3xl: 1.5rem;
460
+
461
+
/* semantic typography (aliases) */
462
+
--text-page-heading: var(--text-3xl);
454
463
--text-section-heading: 1.2rem;
455
-
--text-body: 1rem;
456
-
--text-small: 0.9rem;
464
+
--text-body: var(--text-lg);
465
+
--text-small: var(--text-base);
466
+
467
+
/* border radius scale */
468
+
--radius-sm: 4px;
469
+
--radius-base: 6px;
470
+
--radius-md: 8px;
471
+
--radius-lg: 12px;
472
+
--radius-xl: 16px;
473
+
--radius-2xl: 24px;
474
+
--radius-full: 9999px;
457
475
458
476
/* semantic */
459
477
--success: #4ade80;
···
516
534
color: var(--accent-muted);
517
535
}
518
536
537
+
/* shared animation for active play buttons */
538
+
@keyframes -global-ethereal-glow {
539
+
0%, 100% {
540
+
box-shadow: 0 0 8px 1px color-mix(in srgb, var(--accent) 25%, transparent);
541
+
}
542
+
50% {
543
+
box-shadow: 0 0 14px 3px color-mix(in srgb, var(--accent) 45%, transparent);
544
+
}
545
+
}
546
+
519
547
:global(body) {
520
548
margin: 0;
521
549
padding: 0;
···
589
617
right: 20px;
590
618
width: 48px;
591
619
height: 48px;
592
-
border-radius: 50%;
620
+
border-radius: var(--radius-full);
593
621
background: var(--bg-secondary);
594
622
border: 1px solid var(--border-default);
595
623
color: var(--text-secondary);
+1
-1
frontend/src/routes/+page.svelte
+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 {
+8
-8
frontend/src/routes/login/+page.svelte
+8
-8
frontend/src/routes/login/+page.svelte
···
142
142
.login-card {
143
143
background: var(--bg-tertiary);
144
144
border: 1px solid var(--border-subtle);
145
-
border-radius: 12px;
145
+
border-radius: var(--radius-lg);
146
146
padding: 2.5rem;
147
147
max-width: 420px;
148
148
width: 100%;
···
171
171
172
172
label {
173
173
color: var(--text-secondary);
174
-
font-size: 0.9rem;
174
+
font-size: var(--text-base);
175
175
}
176
176
177
177
button.primary {
···
180
180
background: var(--accent);
181
181
color: white;
182
182
border: none;
183
-
border-radius: 8px;
184
-
font-size: 0.95rem;
183
+
border-radius: var(--radius-md);
184
+
font-size: var(--text-base);
185
185
font-weight: 500;
186
186
font-family: inherit;
187
187
cursor: pointer;
···
213
213
border: none;
214
214
color: var(--text-secondary);
215
215
font-family: inherit;
216
-
font-size: 0.9rem;
216
+
font-size: var(--text-base);
217
217
cursor: pointer;
218
218
text-align: left;
219
219
}
···
234
234
.faq-content {
235
235
padding: 0 0 1rem 0;
236
236
color: var(--text-tertiary);
237
-
font-size: 0.85rem;
237
+
font-size: var(--text-sm);
238
238
line-height: 1.6;
239
239
}
240
240
···
259
259
.faq-content code {
260
260
background: var(--bg-secondary);
261
261
padding: 0.15rem 0.4rem;
262
-
border-radius: 4px;
262
+
border-radius: var(--radius-sm);
263
263
font-size: 0.85em;
264
264
}
265
265
···
269
269
}
270
270
271
271
h1 {
272
-
font-size: 1.5rem;
272
+
font-size: var(--text-3xl);
273
273
}
274
274
}
275
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;
+46
-46
frontend/src/routes/settings/+page.svelte
+46
-46
frontend/src/routes/settings/+page.svelte
···
774
774
.token-overlay-content {
775
775
background: var(--bg-secondary);
776
776
border: 1px solid var(--border-default);
777
-
border-radius: 16px;
777
+
border-radius: var(--radius-xl);
778
778
padding: 2rem;
779
779
max-width: 500px;
780
780
width: 100%;
···
788
788
789
789
.token-overlay-content h2 {
790
790
margin: 0 0 0.75rem;
791
-
font-size: 1.5rem;
791
+
font-size: var(--text-3xl);
792
792
color: var(--text-primary);
793
793
}
794
794
795
795
.token-overlay-warning {
796
796
color: var(--warning);
797
-
font-size: 0.9rem;
797
+
font-size: var(--text-base);
798
798
margin: 0 0 1.5rem;
799
799
line-height: 1.5;
800
800
}
···
804
804
gap: 0.5rem;
805
805
background: var(--bg-primary);
806
806
border: 1px solid var(--border-default);
807
-
border-radius: 8px;
807
+
border-radius: var(--radius-md);
808
808
padding: 1rem;
809
809
margin-bottom: 1rem;
810
810
}
811
811
812
812
.token-overlay-display code {
813
813
flex: 1;
814
-
font-size: 0.85rem;
814
+
font-size: var(--text-sm);
815
815
word-break: break-all;
816
816
color: var(--accent);
817
817
text-align: left;
···
822
822
padding: 0.5rem 1rem;
823
823
background: var(--accent);
824
824
border: none;
825
-
border-radius: 6px;
825
+
border-radius: var(--radius-base);
826
826
color: var(--text-primary);
827
827
font-family: inherit;
828
-
font-size: 0.85rem;
828
+
font-size: var(--text-sm);
829
829
font-weight: 600;
830
830
cursor: pointer;
831
831
white-space: nowrap;
···
837
837
}
838
838
839
839
.token-overlay-hint {
840
-
font-size: 0.8rem;
840
+
font-size: var(--text-sm);
841
841
color: var(--text-tertiary);
842
842
margin: 0 0 1.5rem;
843
843
}
···
855
855
padding: 0.75rem 2rem;
856
856
background: var(--bg-tertiary);
857
857
border: 1px solid var(--border-default);
858
-
border-radius: 8px;
858
+
border-radius: var(--radius-md);
859
859
color: var(--text-secondary);
860
860
font-family: inherit;
861
-
font-size: 0.9rem;
861
+
font-size: var(--text-base);
862
862
cursor: pointer;
863
863
transition: all 0.15s;
864
864
}
···
901
901
.portal-link {
902
902
color: var(--text-secondary);
903
903
text-decoration: none;
904
-
font-size: 0.85rem;
904
+
font-size: var(--text-sm);
905
905
padding: 0.4rem 0.75rem;
906
906
background: var(--bg-tertiary);
907
-
border-radius: 6px;
907
+
border-radius: var(--radius-base);
908
908
border: 1px solid var(--border-default);
909
909
transition: all 0.15s;
910
910
}
···
919
919
}
920
920
921
921
.settings-section h2 {
922
-
font-size: 0.8rem;
922
+
font-size: var(--text-sm);
923
923
text-transform: uppercase;
924
924
letter-spacing: 0.08em;
925
925
color: var(--text-tertiary);
···
930
930
.settings-card {
931
931
background: var(--bg-tertiary);
932
932
border: 1px solid var(--border-subtle);
933
-
border-radius: 10px;
933
+
border-radius: var(--radius-md);
934
934
padding: 1rem 1.25rem;
935
935
}
936
936
···
957
957
958
958
.setting-info h3 {
959
959
margin: 0 0 0.25rem;
960
-
font-size: 0.95rem;
960
+
font-size: var(--text-base);
961
961
font-weight: 600;
962
962
color: var(--text-primary);
963
963
}
964
964
965
965
.setting-info p {
966
966
margin: 0;
967
-
font-size: 0.8rem;
967
+
font-size: var(--text-sm);
968
968
color: var(--text-tertiary);
969
969
line-height: 1.4;
970
970
}
···
993
993
padding: 0.6rem 0.75rem;
994
994
background: var(--bg-primary);
995
995
border: 1px solid var(--border-default);
996
-
border-radius: 8px;
996
+
border-radius: var(--radius-md);
997
997
color: var(--text-secondary);
998
998
cursor: pointer;
999
999
transition: all 0.15s;
···
1034
1034
width: 40px;
1035
1035
height: 40px;
1036
1036
border: 1px solid var(--border-default);
1037
-
border-radius: 8px;
1037
+
border-radius: var(--radius-md);
1038
1038
cursor: pointer;
1039
1039
background: transparent;
1040
1040
}
···
1044
1044
}
1045
1045
1046
1046
.color-input::-webkit-color-swatch {
1047
-
border-radius: 4px;
1047
+
border-radius: var(--radius-sm);
1048
1048
border: none;
1049
1049
}
1050
1050
···
1056
1056
.preset-btn {
1057
1057
width: 32px;
1058
1058
height: 32px;
1059
-
border-radius: 6px;
1059
+
border-radius: var(--radius-base);
1060
1060
border: 2px solid transparent;
1061
1061
cursor: pointer;
1062
1062
transition: all 0.15s;
···
1085
1085
padding: 0.5rem 0.75rem;
1086
1086
background: var(--bg-primary);
1087
1087
border: 1px solid var(--border-default);
1088
-
border-radius: 6px;
1088
+
border-radius: var(--radius-base);
1089
1089
color: var(--text-primary);
1090
-
font-size: 0.85rem;
1090
+
font-size: var(--text-sm);
1091
1091
font-family: inherit;
1092
1092
}
1093
1093
···
1104
1104
display: flex;
1105
1105
align-items: center;
1106
1106
gap: 0.4rem;
1107
-
font-size: 0.8rem;
1107
+
font-size: var(--text-sm);
1108
1108
color: var(--text-secondary);
1109
1109
cursor: pointer;
1110
1110
}
···
1132
1132
width: 48px;
1133
1133
height: 28px;
1134
1134
background: var(--border-default);
1135
-
border-radius: 999px;
1135
+
border-radius: var(--radius-full);
1136
1136
position: relative;
1137
1137
cursor: pointer;
1138
1138
transition: background 0.2s;
···
1145
1145
left: 4px;
1146
1146
width: 20px;
1147
1147
height: 20px;
1148
-
border-radius: 50%;
1148
+
border-radius: var(--radius-full);
1149
1149
background: var(--text-secondary);
1150
1150
transition: transform 0.2s, background 0.2s;
1151
1151
}
···
1167
1167
padding: 0.75rem;
1168
1168
background: color-mix(in srgb, var(--warning) 10%, transparent);
1169
1169
border: 1px solid color-mix(in srgb, var(--warning) 30%, transparent);
1170
-
border-radius: 6px;
1170
+
border-radius: var(--radius-base);
1171
1171
margin-top: 0.75rem;
1172
-
font-size: 0.8rem;
1172
+
font-size: var(--text-sm);
1173
1173
color: var(--warning);
1174
1174
}
1175
1175
···
1191
1191
/* developer tokens */
1192
1192
.loading-tokens {
1193
1193
color: var(--text-tertiary);
1194
-
font-size: 0.85rem;
1194
+
font-size: var(--text-sm);
1195
1195
}
1196
1196
1197
1197
.existing-tokens {
···
1199
1199
}
1200
1200
1201
1201
.tokens-header {
1202
-
font-size: 0.75rem;
1202
+
font-size: var(--text-xs);
1203
1203
text-transform: uppercase;
1204
1204
letter-spacing: 0.05em;
1205
1205
color: var(--text-tertiary);
···
1220
1220
padding: 0.75rem;
1221
1221
background: var(--bg-primary);
1222
1222
border: 1px solid var(--border-default);
1223
-
border-radius: 6px;
1223
+
border-radius: var(--radius-base);
1224
1224
}
1225
1225
1226
1226
.token-info {
···
1233
1233
.token-name {
1234
1234
font-weight: 500;
1235
1235
color: var(--text-primary);
1236
-
font-size: 0.9rem;
1236
+
font-size: var(--text-base);
1237
1237
}
1238
1238
1239
1239
.token-meta {
1240
-
font-size: 0.75rem;
1240
+
font-size: var(--text-xs);
1241
1241
color: var(--text-tertiary);
1242
1242
}
1243
1243
···
1245
1245
padding: 0.4rem 0.75rem;
1246
1246
background: transparent;
1247
1247
border: 1px solid var(--border-emphasis);
1248
-
border-radius: 4px;
1248
+
border-radius: var(--radius-sm);
1249
1249
color: var(--text-secondary);
1250
1250
font-family: inherit;
1251
-
font-size: 0.8rem;
1251
+
font-size: var(--text-sm);
1252
1252
cursor: pointer;
1253
1253
transition: all 0.15s;
1254
1254
white-space: nowrap;
···
1272
1272
padding: 0.75rem;
1273
1273
background: var(--bg-primary);
1274
1274
border: 1px solid var(--border-default);
1275
-
border-radius: 6px;
1275
+
border-radius: var(--radius-base);
1276
1276
}
1277
1277
1278
1278
.token-value {
1279
1279
flex: 1;
1280
-
font-size: 0.8rem;
1280
+
font-size: var(--text-sm);
1281
1281
word-break: break-all;
1282
1282
color: var(--accent);
1283
1283
}
···
1287
1287
padding: 0.4rem 0.6rem;
1288
1288
background: var(--bg-tertiary);
1289
1289
border: 1px solid var(--border-default);
1290
-
border-radius: 4px;
1290
+
border-radius: var(--radius-sm);
1291
1291
color: var(--text-secondary);
1292
1292
font-family: inherit;
1293
-
font-size: 0.8rem;
1293
+
font-size: var(--text-sm);
1294
1294
cursor: pointer;
1295
1295
transition: all 0.15s;
1296
1296
}
···
1303
1303
1304
1304
.token-warning {
1305
1305
margin-top: 0.5rem;
1306
-
font-size: 0.8rem;
1306
+
font-size: var(--text-sm);
1307
1307
color: var(--warning);
1308
1308
}
1309
1309
···
1320
1320
padding: 0.6rem 0.75rem;
1321
1321
background: var(--bg-primary);
1322
1322
border: 1px solid var(--border-default);
1323
-
border-radius: 6px;
1323
+
border-radius: var(--radius-base);
1324
1324
color: var(--text-primary);
1325
-
font-size: 0.9rem;
1325
+
font-size: var(--text-base);
1326
1326
font-family: inherit;
1327
1327
}
1328
1328
···
1335
1335
display: flex;
1336
1336
align-items: center;
1337
1337
gap: 0.5rem;
1338
-
font-size: 0.85rem;
1338
+
font-size: var(--text-sm);
1339
1339
color: var(--text-secondary);
1340
1340
}
1341
1341
···
1343
1343
padding: 0.5rem 0.75rem;
1344
1344
background: var(--bg-primary);
1345
1345
border: 1px solid var(--border-default);
1346
-
border-radius: 6px;
1346
+
border-radius: var(--radius-base);
1347
1347
color: var(--text-primary);
1348
-
font-size: 0.85rem;
1348
+
font-size: var(--text-sm);
1349
1349
font-family: inherit;
1350
1350
cursor: pointer;
1351
1351
}
···
1359
1359
padding: 0.6rem 1rem;
1360
1360
background: var(--accent);
1361
1361
border: none;
1362
-
border-radius: 6px;
1362
+
border-radius: var(--radius-base);
1363
1363
color: var(--text-primary);
1364
1364
font-family: inherit;
1365
-
font-size: 0.9rem;
1365
+
font-size: var(--text-base);
1366
1366
font-weight: 600;
1367
1367
cursor: pointer;
1368
1368
transition: all 0.15s;
+10
-10
frontend/src/routes/tag/[name]/+page.svelte
+10
-10
frontend/src/routes/tag/[name]/+page.svelte
···
197
197
}
198
198
199
199
.subtitle {
200
-
font-size: 0.95rem;
200
+
font-size: var(--text-base);
201
201
color: var(--text-tertiary);
202
202
margin: 0;
203
203
text-shadow: var(--text-shadow, none);
···
211
211
background: var(--glass-btn-bg, transparent);
212
212
border: 1px solid var(--glass-btn-border, var(--accent));
213
213
color: var(--accent);
214
-
border-radius: 6px;
215
-
font-size: 0.9rem;
214
+
border-radius: var(--radius-base);
215
+
font-size: var(--text-base);
216
216
font-family: inherit;
217
217
cursor: pointer;
218
218
transition: all 0.2s;
···
240
240
}
241
241
242
242
.empty-state h2 {
243
-
font-size: 1.5rem;
243
+
font-size: var(--text-3xl);
244
244
font-weight: 600;
245
245
color: var(--text-secondary);
246
246
margin: 0 0 0.5rem 0;
247
247
}
248
248
249
249
.empty-state p {
250
-
font-size: 0.95rem;
250
+
font-size: var(--text-base);
251
251
margin: 0;
252
252
}
253
253
···
264
264
text-align: center;
265
265
padding: 4rem 1rem;
266
266
color: var(--text-tertiary);
267
-
font-size: 0.95rem;
267
+
font-size: var(--text-base);
268
268
}
269
269
270
270
.tracks-list {
···
292
292
}
293
293
294
294
.empty-state h2 {
295
-
font-size: 1.25rem;
295
+
font-size: var(--text-2xl);
296
296
}
297
297
298
298
.btn-queue-all {
299
299
padding: 0.5rem 0.75rem;
300
-
font-size: 0.85rem;
300
+
font-size: var(--text-sm);
301
301
}
302
302
303
303
.btn-queue-all svg {
···
330
330
}
331
331
332
332
.subtitle {
333
-
font-size: 0.85rem;
333
+
font-size: var(--text-sm);
334
334
}
335
335
336
336
.btn-queue-all {
337
337
padding: 0.45rem 0.65rem;
338
-
font-size: 0.8rem;
338
+
font-size: var(--text-sm);
339
339
}
340
340
341
341
.btn-queue-all svg {
+108
-51
frontend/src/routes/track/[id]/+page.svelte
+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 {
+95
-36
frontend/src/routes/u/[handle]/+page.svelte
+95
-36
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';
···
15
16
import { fetchLikedTracks, fetchUserLikes } from '$lib/tracks.svelte';
16
17
import { APP_NAME, APP_CANONICAL_URL } from '$lib/branding';
17
18
import type { PageData } from './$types';
19
+
18
20
19
21
// receive server-loaded data
20
22
let { data }: { data: PageData } = $props();
···
38
40
const supportUrl = $derived(() => {
39
41
if (!artist?.support_url) return null;
40
42
if (artist.support_url === 'atprotofans') {
41
-
return `https://atprotofans.com/u/${artist.did}`;
43
+
return getAtprotofansSupportUrl(artist.did);
42
44
}
43
45
return artist.support_url;
44
46
});
···
66
68
67
69
// public playlists for collections section
68
70
let publicPlaylists = $state<Playlist[]>([]);
71
+
72
+
// supporter status - true if logged-in viewer supports this artist via atprotofans
73
+
let isSupporter = $state(false);
69
74
70
75
// track which artist we've loaded data for to detect navigation
71
76
let loadedForDid = $state<string | null>(null);
···
130
135
}
131
136
}
132
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
+
133
175
// reload data when navigating between artist pages
134
176
// watch data.artist?.did (from server) not artist?.did (local derived)
135
177
$effect(() => {
···
143
185
tracksHydrated = false;
144
186
likedTracksCount = null;
145
187
publicPlaylists = [];
188
+
isSupporter = false;
146
189
147
190
// sync tracks and pagination from server data
148
191
tracks = data.tracks ?? [];
···
158
201
void hydrateTracksWithLikes();
159
202
void loadLikedTracksCount();
160
203
void loadPublicPlaylists();
204
+
void checkSupporterStatus();
161
205
}
162
206
});
163
207
···
310
354
<div class="artist-details">
311
355
<div class="artist-info">
312
356
<h1>{artist.display_name}</h1>
313
-
<a href="https://bsky.app/profile/{artist.handle}" target="_blank" rel="noopener" class="handle">
314
-
@{artist.handle}
315
-
</a>
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>
316
365
{#if artist.bio}
317
366
<p class="bio">{artist.bio}</p>
318
367
{/if}
···
564
613
padding: 2rem;
565
614
background: var(--bg-secondary);
566
615
border: 1px solid var(--border-subtle);
567
-
border-radius: 8px;
616
+
border-radius: var(--radius-md);
568
617
}
569
618
570
619
.artist-details {
···
602
651
padding: 0 0.75rem;
603
652
background: color-mix(in srgb, var(--accent) 15%, transparent);
604
653
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
605
-
border-radius: 4px;
654
+
border-radius: var(--radius-sm);
606
655
color: var(--accent);
607
-
font-size: 0.85rem;
656
+
font-size: var(--text-sm);
608
657
text-decoration: none;
609
658
transition: all 0.2s ease;
610
659
}
···
622
671
.artist-avatar {
623
672
width: 120px;
624
673
height: 120px;
625
-
border-radius: 50%;
674
+
border-radius: var(--radius-full);
626
675
object-fit: cover;
627
676
border: 3px solid var(--border-default);
628
677
}
···
634
683
word-wrap: break-word;
635
684
overflow-wrap: break-word;
636
685
hyphens: auto;
686
+
}
687
+
688
+
.handle-row {
689
+
display: flex;
690
+
align-items: center;
691
+
gap: 0.75rem;
692
+
flex-wrap: wrap;
693
+
margin-bottom: 1rem;
637
694
}
638
695
639
696
.handle {
640
697
color: var(--text-tertiary);
641
-
font-size: 1.1rem;
642
-
margin: 0 0 1rem 0;
698
+
font-size: var(--text-xl);
643
699
text-decoration: none;
644
700
transition: color 0.2s;
645
-
display: inline-block;
646
701
}
647
702
648
703
.handle:hover {
···
683
738
684
739
.section-header span {
685
740
color: var(--text-tertiary);
686
-
font-size: 0.9rem;
741
+
font-size: var(--text-base);
687
742
text-transform: uppercase;
688
743
letter-spacing: 0.1em;
689
744
}
···
701
756
padding: 1rem;
702
757
background: var(--bg-secondary);
703
758
border: 1px solid var(--border-subtle);
704
-
border-radius: 10px;
759
+
border-radius: var(--radius-md);
705
760
color: inherit;
706
761
text-decoration: none;
707
762
transition: transform 0.15s ease, border-color 0.15s ease;
···
717
772
.album-cover-wrapper {
718
773
width: 72px;
719
774
height: 72px;
720
-
border-radius: 6px;
775
+
border-radius: var(--radius-base);
721
776
overflow: hidden;
722
777
flex-shrink: 0;
723
778
background: var(--bg-tertiary);
···
765
820
.album-card-meta p {
766
821
margin: 0;
767
822
color: var(--text-tertiary);
768
-
font-size: 0.9rem;
823
+
font-size: var(--text-base);
769
824
display: flex;
770
825
align-items: center;
771
826
gap: 0.4rem;
···
779
834
.stat-card {
780
835
background: var(--bg-secondary);
781
836
border: 1px solid var(--border-subtle);
782
-
border-radius: 8px;
837
+
border-radius: var(--radius-md);
783
838
padding: 1.5rem;
784
839
transition: border-color 0.2s;
785
840
}
···
798
853
799
854
.stat-label {
800
855
color: var(--text-tertiary);
801
-
font-size: 0.9rem;
856
+
font-size: var(--text-base);
802
857
text-transform: lowercase;
803
858
line-height: 1;
804
859
}
805
860
806
861
.stat-duration {
807
862
margin-top: 0.5rem;
808
-
font-size: 0.85rem;
863
+
font-size: var(--text-sm);
809
864
color: var(--text-secondary);
810
865
font-variant-numeric: tabular-nums;
811
866
}
···
833
888
834
889
.top-item-plays {
835
890
color: var(--accent);
836
-
font-size: 1rem;
891
+
font-size: var(--text-lg);
837
892
line-height: 1;
838
893
}
839
894
···
853
908
);
854
909
background-size: 200% 100%;
855
910
animation: shimmer 1.5s ease-in-out infinite;
856
-
border-radius: 4px;
911
+
border-radius: var(--radius-sm);
857
912
}
858
913
859
914
/* match .stat-value dimensions: 2.5rem font + 0.5rem margin-bottom */
···
905
960
padding: 0.75rem 1.5rem;
906
961
background: var(--bg-secondary);
907
962
border: 1px solid var(--border-subtle);
908
-
border-radius: 8px;
963
+
border-radius: var(--radius-md);
909
964
color: var(--text-secondary);
910
965
font-family: inherit;
911
-
font-size: 0.95rem;
966
+
font-size: var(--text-base);
912
967
cursor: pointer;
913
968
transition: all 0.2s ease;
914
969
}
···
926
981
927
982
.tracks-loading {
928
983
margin-left: 0.75rem;
929
-
font-size: 0.95rem;
984
+
font-size: var(--text-base);
930
985
color: var(--text-secondary);
931
986
font-weight: 400;
932
987
text-transform: lowercase;
···
943
998
padding: 3rem;
944
999
background: var(--bg-secondary);
945
1000
border: 1px solid var(--border-subtle);
946
-
border-radius: 8px;
1001
+
border-radius: var(--radius-md);
947
1002
}
948
1003
949
1004
.empty-message {
950
1005
color: var(--text-secondary);
951
-
font-size: 1.25rem;
1006
+
font-size: var(--text-2xl);
952
1007
margin: 0 0 0.5rem 0;
953
1008
}
954
1009
···
960
1015
.bsky-link {
961
1016
color: var(--accent);
962
1017
text-decoration: none;
963
-
font-size: 1rem;
1018
+
font-size: var(--text-lg);
964
1019
padding: 0.75rem 1.5rem;
965
1020
border: 1px solid var(--accent);
966
-
border-radius: 6px;
1021
+
border-radius: var(--radius-base);
967
1022
transition: all 0.2s;
968
1023
display: inline-block;
969
1024
}
···
1001
1056
1002
1057
.artist-info {
1003
1058
text-align: center;
1059
+
}
1060
+
1061
+
.handle-row {
1062
+
justify-content: center;
1004
1063
}
1005
1064
1006
1065
.artist-actions-desktop {
···
1013
1072
1014
1073
.support-btn {
1015
1074
height: 28px;
1016
-
font-size: 0.8rem;
1075
+
font-size: var(--text-sm);
1017
1076
padding: 0 0.6rem;
1018
1077
}
1019
1078
···
1053
1112
.album-cover-wrapper {
1054
1113
width: 56px;
1055
1114
height: 56px;
1056
-
border-radius: 4px;
1115
+
border-radius: var(--radius-sm);
1057
1116
}
1058
1117
1059
1118
.album-card-meta h3 {
1060
-
font-size: 0.95rem;
1119
+
font-size: var(--text-base);
1061
1120
margin-bottom: 0.25rem;
1062
1121
}
1063
1122
1064
1123
.album-card-meta p {
1065
-
font-size: 0.8rem;
1124
+
font-size: var(--text-sm);
1066
1125
}
1067
1126
}
1068
1127
···
1089
1148
padding: 1.25rem 1.5rem;
1090
1149
background: var(--bg-secondary);
1091
1150
border: 1px solid var(--border-subtle);
1092
-
border-radius: 10px;
1151
+
border-radius: var(--radius-md);
1093
1152
color: inherit;
1094
1153
text-decoration: none;
1095
1154
transition: transform 0.15s ease, border-color 0.15s ease;
···
1103
1162
.collection-icon {
1104
1163
width: 48px;
1105
1164
height: 48px;
1106
-
border-radius: 8px;
1165
+
border-radius: var(--radius-md);
1107
1166
display: flex;
1108
1167
align-items: center;
1109
1168
justify-content: center;
···
1134
1193
1135
1194
.collection-info h3 {
1136
1195
margin: 0 0 0.25rem 0;
1137
-
font-size: 1.1rem;
1196
+
font-size: var(--text-xl);
1138
1197
color: var(--text-primary);
1139
1198
}
1140
1199
1141
1200
.collection-info p {
1142
1201
margin: 0;
1143
-
font-size: 0.9rem;
1202
+
font-size: var(--text-base);
1144
1203
color: var(--text-tertiary);
1145
1204
}
1146
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.