+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
+104
-228
STATUS.md
+104
-228
STATUS.md
···
47
47
48
48
### December 2025
49
49
50
-
#### offline mode foundation (PRs #610-611, Dec 17)
50
+
#### self-hosted redis (PR #674-675, Dec 30)
51
51
52
-
**experimental offline playback**:
53
-
- new storage layer using Cache API for audio bytes + IndexedDB for metadata
54
-
- `GET /audio/{file_id}/url` backend endpoint returns direct R2 URLs for client-side caching
55
-
- "auto-download liked" toggle in experimental settings section
56
-
- when enabled, bulk-downloads all liked tracks and auto-downloads future likes
57
-
- Player checks for cached audio before streaming from R2
58
-
- works offline once tracks are downloaded
59
-
60
-
**robustness improvements**:
61
-
- IndexedDB connections properly closed after each operation
62
-
- concurrent downloads deduplicated via in-flight promise tracking
63
-
- stale metadata cleanup when cache entries are missing
64
-
65
-
---
66
-
67
-
#### visual customization (PRs #595-596, Dec 16)
68
-
69
-
**custom backgrounds** (PR #595):
70
-
- users can set a custom background image URL in settings with optional tiling
71
-
- new "playing artwork as background" toggle - uses current track's artwork as blurred page background
72
-
- glass effect styling for track items (translucent backgrounds, subtle shadows)
73
-
- new `ui_settings` JSONB column in preferences for extensible UI settings
52
+
**replaced Upstash with self-hosted Redis on Fly.io** - ~$75/month โ ~$4/month:
53
+
- Upstash pay-as-you-go was charging per command (37M commands = $75)
54
+
- self-hosted Redis on 256MB Fly VMs costs fixed ~$2/month per environment
55
+
- deployed `plyr-redis` (prod) and `plyr-redis-stg` (staging)
56
+
- added CI workflow for redis deployments on merge
74
57
75
-
**bug fix** (PR #596):
76
-
- removed 3D wheel scroll effect that was blocking like/share button clicks
77
-
- root cause: `translateZ` transforms created z-index stacking that intercepted pointer events
58
+
**no state migration needed** - docket stores ephemeral task queue data, job progress lives in postgres.
78
59
79
60
---
80
61
81
-
#### performance & UX polish (PRs #586-593, Dec 14-15)
82
-
83
-
**performance improvements** (PRs #590-591):
84
-
- removed moderation service call from `/tracks/` listing endpoint
85
-
- removed copyright check from tag listing endpoint
86
-
- faster page loads for track feeds
87
-
88
-
**moderation agent** (PRs #586, #588):
89
-
- added moderation agent script with audit trail support
90
-
- improved moderation prompt and UI layout
91
-
92
-
**bug fixes** (PRs #589, #592, #593):
93
-
- fixed liked state display on playlist detail page
94
-
- preserved album track order during ATProto sync
95
-
- made header sticky on scroll for better mobile navigation
96
-
97
-
**iOS Safari fixes** (PRs #573-576):
98
-
- fixed AddToMenu visibility issue on iOS Safari
99
-
- menu now correctly opens upward when near viewport bottom
100
-
101
-
---
102
-
103
-
#### mobile UI polish & background task expansion (PRs #558-572, Dec 10-12)
104
-
105
-
**background task expansion** (PRs #558, #561):
106
-
- moved like/unlike and comment PDS writes to docket background tasks
107
-
- API responses now immediate; PDS sync happens asynchronously
108
-
- added targeted album list sync background task for ATProto record updates
109
-
110
-
**performance caching** (PR #566):
111
-
- added Redis cache for copyright label lookups (5-minute TTL)
112
-
- fixed 2-3s latency spikes on `/tracks/` endpoint
113
-
- batch operations via `mget`/pipeline for efficiency
114
-
115
-
**mobile UX improvements** (PRs #569, #572):
116
-
- mobile action menus now open from top with all actions visible
117
-
- UI polish for album and artist pages on small screens
118
-
119
-
**misc** (PRs #559, #562, #563, #570):
120
-
- reduced docket Redis polling from 250ms to 5s (lower resource usage)
121
-
- added atprotofans support link mode for ko-fi integration
122
-
- added alpha badge to header branding
123
-
- fixed web manifest ID for PWA stability
124
-
125
-
---
126
-
127
-
#### confidential OAuth client (PRs #578, #580-582, Dec 12-13)
128
-
129
-
**confidential client support** (PR #578):
130
-
- implemented ATProto OAuth confidential client using `private_key_jwt` authentication
131
-
- when `OAUTH_JWK` is configured, plyr.fm authenticates with a cryptographic key
132
-
- confidential clients earn 180-day refresh tokens (vs 2-week for public clients)
133
-
- added `/.well-known/jwks.json` endpoint for public key discovery
134
-
- updated `/oauth-client-metadata.json` with confidential client fields
62
+
#### supporter-gated content (PR #637, Dec 22-23)
135
63
136
-
**bug fixes** (PRs #580-582):
137
-
- fixed client assertion JWT to use Authorization Server's issuer as `aud` claim (not token endpoint URL)
138
-
- fixed JWKS endpoint to preserve `kid` field from original JWK
139
-
- fixed `OAuthClient` to pass `client_secret_kid` for JWT header
64
+
**atprotofans paywall integration** - artists can now mark tracks as "supporters only":
65
+
- tracks with `support_gate` require atprotofans validation before playback
66
+
- non-supporters see lock icon and "become a supporter" CTA linking to atprotofans
67
+
- artists can always play their own gated tracks
140
68
141
-
**atproto fork updates** (zzstoatzz/atproto#6, #7):
142
-
- added `issuer` parameter to `_make_token_request()` for correct `aud` claim
143
-
- added `client_secret_kid` parameter to include `kid` in client assertion JWT header
69
+
**backend architecture**:
70
+
- audio endpoint validates supporter status via atprotofans API before serving gated content
71
+
- HEAD requests return 200/401/402 for pre-flight auth checks (avoids CORS issues)
72
+
- `R2Storage.move_audio()` moves files between public/private buckets when toggling gate
73
+
- background task handles bucket migration asynchronously
74
+
- ATProto record syncs when toggling gate (updates `supportGate` field and `audioUrl`)
144
75
145
-
**outcome**: users now get 180-day refresh tokens, and "remember this account" on the PDS authorization page works (auto-approves subsequent logins). see #583 for future work on account switching via OAuth `prompt` parameter.
76
+
**frontend**:
77
+
- `playback.svelte.ts` guards queue operations with gated checks BEFORE modifying state
78
+
- clicking locked track shows toast with CTA - does NOT interrupt current playback
79
+
- portal shows support gate toggle in track edit UI
146
80
147
81
---
148
82
149
-
#### pagination & album management (PRs #550-554, Dec 9-10)
83
+
#### supporter badges (PR #627, Dec 21-22)
150
84
151
-
**tracks list pagination** (PR #554):
152
-
- cursor-based pagination on `/tracks/` endpoint (default 50 per page)
153
-
- infinite scroll on homepage using native IntersectionObserver
154
-
- zero new dependencies - uses browser APIs only
155
-
- pagination state persisted to localStorage for fast subsequent loads
156
-
157
-
**album management improvements** (PRs #550-552, #557):
158
-
- album delete and track reorder fixes
159
-
- album page edit mode matching playlist UX (inline title editing, cover upload)
160
-
- optimistic UI updates for album title changes (instant feedback)
161
-
- ATProto record sync when album title changes (updates all track records + list record)
162
-
- fixed album slug sync on rename (prevented duplicate albums when adding tracks)
163
-
164
-
**playlist show on profile** (PR #553):
165
-
- restored "show on profile" toggle that was lost during inline editing refactor
166
-
- users can now control whether playlists appear on their public profile
85
+
**phase 1 of atprotofans integration**:
86
+
- supporter badge displays on artist pages when logged-in viewer supports the artist
87
+
- calls atprotofans `validateSupporter` API directly from frontend (public endpoint)
88
+
- badge only shows when viewer is authenticated and not viewing their own profile
167
89
168
90
---
169
91
170
-
#### public cost dashboard (PRs #548-549, Dec 9)
92
+
#### rate limit moderation endpoint (PR #629, Dec 21)
171
93
172
-
- `/costs` page showing live platform infrastructure costs
173
-
- daily export to R2 via GitHub Action, proxied through `/stats/costs` endpoint
174
-
- dedicated `plyr-stats` R2 bucket with public access (shared across environments)
175
-
- includes fly.io, neon, cloudflare, and audd API costs
176
-
- ko-fi integration for community support
177
-
178
-
#### docket background tasks & concurrent exports (PRs #534-546, Dec 9)
179
-
180
-
**docket integration** (PRs #534, #536, #539):
181
-
- migrated background tasks from inline asyncio to docket (Redis-backed task queue)
182
-
- copyright scanning, media export, ATProto sync, and teal scrobbling now run via docket
183
-
- graceful fallback to asyncio for local development without Redis
184
-
- parallel test execution with xdist template databases (#540)
185
-
186
-
**concurrent export downloads** (PR #545):
187
-
- exports now download tracks in parallel (up to 4 concurrent) instead of sequentially
188
-
- significantly faster for users with many tracks or large files
189
-
- zip creation remains sequential (zipfile constraint)
190
-
191
-
**ATProto refactor** (PR #534):
192
-
- reorganized ATProto record code into `_internal/atproto/records/` by lexicon namespace
193
-
- extracted `client.py` for low-level PDS operations
194
-
- cleaner separation between plyr.fm and teal.fm lexicons
195
-
196
-
**documentation & observability**:
197
-
- AudD API cost tracking dashboard (#546)
198
-
- promoted runbooks from sandbox to `docs/runbooks/`
199
-
- updated CLAUDE.md files across the codebase
94
+
**incident response**: detected suspicious activity - 72 requests in 17 seconds from a single IP targeting `/moderation/sensitive-images`. added `10/minute` rate limit using existing slowapi infrastructure.
200
95
201
96
---
202
97
203
-
#### artist support links & inline playlist editing (PRs #520-532, Dec 8)
204
-
205
-
**artist support link** (PR #532):
206
-
- artists can set a support URL (Ko-fi, Patreon, etc.) in their portal profile
207
-
- support link displays as a button on artist profile pages next to the share button
208
-
- URLs validated to require https:// prefix
98
+
#### end-of-year sprint planning (PR #626, Dec 20)
209
99
210
-
**inline playlist editing** (PR #531):
211
-
- edit playlist name and description directly on playlist detail page
212
-
- click-to-upload cover art replacement without modal
213
-
- cleaner UX - no more edit modal popup
100
+
**focus**: two foundational systems need solid experimental implementations by 2026.
214
101
215
-
**platform stats enhancements** (PRs #522, #528):
216
-
- total duration displayed in platform stats (e.g., "42h 15m of music")
217
-
- duration shown per artist in analytics section
218
-
- combined stats and search into single centered container for cleaner layout
219
-
220
-
**navigation & data loading fixes** (PR #527):
221
-
- fixed stale data when navigating between detail pages of the same type
222
-
- e.g., clicking from one artist to another now properly reloads data
223
-
224
-
**copyright moderation improvements** (PR #480):
225
-
- enhanced moderation workflow for copyright claims
226
-
- improved labeler integration
102
+
| track | focus | status |
103
+
|-------|-------|--------|
104
+
| moderation | consolidate architecture, add rules engine | in progress |
105
+
| atprotofans | supporter validation, content gating | shipped (phase 1-3) |
227
106
228
-
**status maintenance workflow** (PR #529):
229
-
- automated status maintenance using claude-code-action
230
-
- reviews merged PRs and updates STATUS.md narratively
107
+
**research docs**:
108
+
- [moderation architecture overhaul](docs/research/2025-12-20-moderation-architecture-overhaul.md)
109
+
- [atprotofans paywall integration](docs/research/2025-12-20-atprotofans-paywall-integration.md)
231
110
232
111
---
233
112
234
-
#### playlist fast-follow fixes (PRs #507-519, Dec 7-8)
113
+
#### beartype + moderation cleanup (PRs #617-619, Dec 19)
235
114
236
-
**public playlist viewing** (PR #519):
237
-
- playlists now publicly viewable without authentication
238
-
- ATProto records are public by design - auth was unnecessary for read access
239
-
- shared playlist URLs no longer redirect unauthenticated users to homepage
115
+
**runtime type checking** (PR #619):
116
+
- enabled beartype runtime type validation across the backend
117
+
- catches type errors at runtime instead of silently passing bad data
118
+
- test infrastructure improvements: session-scoped TestClient fixture (5x faster tests)
240
119
241
-
**inline playlist creation** (PR #510):
242
-
- clicking "create new playlist" from AddToMenu previously navigated to `/library?create=playlist`
243
-
- this caused SvelteKit to reinitialize the layout, destroying the audio element and stopping playback
244
-
- fix: added inline create form that creates playlist and adds track in one action without navigation
245
-
246
-
**UI polish** (PRs #507-509, #515):
247
-
- include `image_url` in playlist SSR data for og:image link previews
248
-
- invalidate layout data after token exchange - fixes stale auth state after login
249
-
- fixed stopPropagation blocking "create new playlist" link clicks
250
-
- detail page button layouts: all buttons visible on mobile, centered AddToMenu on track detail
251
-
- AddToMenu smart positioning: menu opens upward when near viewport bottom
252
-
253
-
**documentation** (PR #514):
254
-
- added lexicons overview documentation at `docs/lexicons/overview.md`
255
-
- covers `fm.plyr.track`, `fm.plyr.like`, `fm.plyr.comment`, `fm.plyr.list`, `fm.plyr.actor.profile`
120
+
**moderation cleanup** (PRs #617-618):
121
+
- consolidated moderation code, addressing issues #541-543
122
+
- `sync_copyright_resolutions` now runs automatically via docket Perpetual task
123
+
- removed dead `init_db()` from lifespan (handled by alembic migrations)
256
124
257
125
---
258
126
259
-
#### playlists, ATProto sync, and library hub (PR #499, Dec 6-7)
127
+
#### UX polish (PRs #604-607, #613, #615, Dec 16-18)
260
128
261
-
**playlists** (full CRUD):
262
-
- create, rename, delete playlists with cover art upload
263
-
- add/remove/reorder tracks with drag-and-drop
264
-
- playlist detail page with edit modal
265
-
- "add to playlist" menu on tracks with inline create
266
-
- playlist sharing with OpenGraph link previews
267
-
268
-
**ATProto integration**:
269
-
- `fm.plyr.list` lexicon for syncing playlists/albums to user PDSes
270
-
- `fm.plyr.actor.profile` lexicon for artist profiles
271
-
- automatic sync of albums, liked tracks, profile on login
129
+
**login improvements** (PRs #604, #613):
130
+
- login page now uses "internet handle" terminology for clarity
131
+
- input normalization: strips `@` and `at://` prefixes automatically
272
132
273
-
**library hub** (`/library`):
274
-
- unified page with tabs: liked, playlists, albums
275
-
- nav changed from "liked" โ "library"
133
+
**artist page fixes** (PR #615):
134
+
- track pagination on artist pages now works correctly
135
+
- fixed mobile album card overflow
276
136
277
-
**related**: scope upgrade OAuth flow (PR #503), settings consolidation (PR #496)
137
+
**mobile + metadata** (PRs #605-607):
138
+
- Open Graph tags added to tag detail pages for link previews
139
+
- mobile modals now use full screen positioning
140
+
- fixed `/tag/` routes in hasPageMetadata check
278
141
279
142
---
280
143
281
-
#### sensitive image moderation (PRs #471-488, Dec 5-6)
144
+
#### offline mode foundation (PRs #610-611, Dec 17)
282
145
283
-
- `sensitive_images` table flags problematic images
284
-
- `show_sensitive_artwork` user preference
285
-
- flagged images blurred everywhere: track lists, player, artist pages, search, embeds
286
-
- Media Session API respects sensitive preference
287
-
- SSR-safe filtering for og:image link previews
146
+
**experimental offline playback**:
147
+
- storage layer using Cache API for audio bytes + IndexedDB for metadata
148
+
- `GET /audio/{file_id}/url` backend endpoint returns direct R2 URLs for client-side caching
149
+
- "auto-download liked" toggle in experimental settings section
150
+
- Player checks for cached audio before streaming from R2
288
151
289
152
---
290
153
291
-
#### teal.fm scrobbling (PR #467, Dec 4)
292
-
293
-
- native scrobbling to user's PDS using teal's ATProto lexicons
294
-
- scrobble at 30% or 30 seconds (same threshold as play counts)
295
-
- toggle in settings, link to pdsls.dev to view records
154
+
### Earlier December 2025
296
155
297
-
---
156
+
See `.status_history/2025-12.md` for detailed history including:
157
+
- visual customization with custom backgrounds (PRs #595-596, Dec 16)
158
+
- performance & moderation polish (PRs #586-593, Dec 14-15)
159
+
- mobile UI polish & background task expansion (PRs #558-572, Dec 10-12)
160
+
- confidential OAuth client for 180-day sessions (PRs #578-582, Dec 12-13)
161
+
- pagination & album management (PRs #550-554, Dec 9-10)
162
+
- public cost dashboard (PRs #548-549, Dec 9)
163
+
- docket background tasks & concurrent exports (PRs #534-546, Dec 9)
164
+
- artist support links & inline playlist editing (PRs #520-532, Dec 8)
165
+
- playlist fast-follow fixes (PRs #507-519, Dec 7-8)
166
+
- playlists, ATProto sync, and library hub (PR #499, Dec 6-7)
167
+
- sensitive image moderation (PRs #471-488, Dec 5-6)
168
+
- teal.fm scrobbling (PR #467, Dec 4)
169
+
- unified search with Cmd+K (PR #447, Dec 3)
170
+
- light/dark theme system (PR #441, Dec 2-3)
171
+
- tag filtering and bufo easter egg (PRs #431-438, Dec 2)
298
172
299
-
### Earlier December / November 2025
173
+
### November 2025
300
174
301
-
See `.status_history/2025-12.md` and `.status_history/2025-11.md` for detailed history including:
302
-
- unified search with Cmd+K (PR #447)
303
-
- light/dark theme system (PR #441)
304
-
- tag filtering and bufo easter egg (PRs #431-438)
175
+
See `.status_history/2025-11.md` for detailed history including:
305
176
- developer tokens (PR #367)
306
177
- copyright moderation system (PRs #382-395)
307
178
- export & upload reliability (PRs #337-344)
···
309
180
310
181
## immediate priorities
311
182
183
+
### quality of life mode (Dec 29-31)
184
+
185
+
end-of-year sprint [#625](https://github.com/zzstoatzz/plyr.fm/issues/625) complete. remaining days before 2026 are for minor polish and bug fixes as they arise.
186
+
187
+
**what shipped in the sprint:**
188
+
- moderation consolidation: sensitive images moved to moderation service (#644)
189
+
- atprotofans: supporter badges (#627) and content gating (#637)
190
+
191
+
**aspirational (deferred until scale justifies):**
192
+
- configurable rules engine for moderation
193
+
- time-release gating (#642)
194
+
312
195
### known issues
313
196
- playback auto-start on refresh (#225)
314
197
- iOS PWA audio may hang on first play after backgrounding
315
198
316
-
### immediate focus
317
-
- **moderation cleanup**: consolidate copyright detection, reduce AudD API costs, streamline labeler integration (issues #541-544)
318
-
319
-
### feature ideas
320
-
- issue #334: add 'share to bluesky' option for tracks
321
-
- issue #373: lyrics field and Genius-style annotations
322
-
323
199
### backlog
324
200
- audio transcoding pipeline integration (#153) - transcoder service deployed, integration deferred
201
+
- share to bluesky (#334)
202
+
- lyrics and annotations (#373)
325
203
326
204
## technical state
327
205
···
367
245
- โ
copyright moderation with ATProto labeler
368
246
- โ
docket background tasks (copyright scan, export, atproto sync, scrobble)
369
247
- โ
media export with concurrent downloads
248
+
- โ
supporter-gated content via atprotofans
370
249
371
250
**albums**
372
251
- โ
album CRUD with cover art
···
398
277
399
278
## cost structure
400
279
401
-
current monthly costs: ~$18/month (plyr.fm specific)
280
+
current monthly costs: ~$20/month (plyr.fm specific)
402
281
403
282
see live dashboard: [plyr.fm/costs](https://plyr.fm/costs)
404
283
405
-
- fly.io (plyr apps only): ~$12/month
406
-
- relay-api (prod): $5.80
407
-
- relay-api-staging: $5.60
408
-
- plyr-moderation: $0.24
409
-
- plyr-transcoder: $0.02
284
+
- fly.io (backend + redis + moderation): ~$14/month
410
285
- neon postgres: $5/month
411
-
- cloudflare (R2 + pages + domain): ~$1.16/month
412
-
- audd audio fingerprinting: $0-10/month (6000 free/month)
286
+
- cloudflare (R2 + pages + domain): ~$1/month
287
+
- audd audio fingerprinting: $5-10/month (usage-based)
413
288
- logfire: $0 (free tier)
414
289
415
290
## admin tooling
···
460
335
โ โโโ src/routes/ # pages
461
336
โโโ moderation/ # Rust moderation service (ATProto labeler)
462
337
โโโ transcoder/ # Rust audio transcoding service
338
+
โโโ redis/ # self-hosted Redis config
463
339
โโโ docs/ # documentation
464
340
โโโ justfile # task runner
465
341
```
···
475
351
476
352
---
477
353
478
-
this is a living document. last updated 2025-12-17.
354
+
this is a living document. last updated 2025-12-30.
+1
backend/.env.example
+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/pyproject.toml
+3
backend/pyproject.toml
···
29
29
"mutagen>=1.47.0",
30
30
"pydocket>=0.15.2",
31
31
"redis>=7.1.0",
32
+
"beartype>=0.22.8",
32
33
]
33
34
34
35
requires-python = ">=3.11"
···
84
85
# redis URL for cache tests (uses test-redis from docker-compose)
85
86
# D: prefix means don't override if already set (e.g., by CI workflow)
86
87
"D:DOCKET_URL=redis://localhost:6380/0",
88
+
# disable automatic perpetual task scheduling in tests to avoid event loop issues
89
+
"DOCKET_SCHEDULE_AUTOMATIC_TASKS=false",
87
90
]
88
91
markers = [
89
92
"integration: marks tests as integration tests (deselect with '-m \"not integration\"')",
+5
backend/src/backend/__init__.py
+5
backend/src/backend/__init__.py
+3
backend/src/backend/_internal/__init__.py
+3
backend/src/backend/_internal/__init__.py
···
32
32
from backend._internal.notifications import notification_service
33
33
from backend._internal.now_playing import now_playing_service
34
34
from backend._internal.queue import queue_service
35
+
from backend._internal.atprotofans import get_supported_artists, validate_supporter
35
36
36
37
__all__ = [
37
38
"DeveloperToken",
···
51
52
"get_pending_dev_token",
52
53
"get_pending_scope_upgrade",
53
54
"get_session",
55
+
"get_supported_artists",
54
56
"handle_oauth_callback",
55
57
"list_developer_tokens",
56
58
"notification_service",
···
64
66
"start_oauth_flow",
65
67
"start_oauth_flow_with_scopes",
66
68
"update_session_tokens",
69
+
"validate_supporter",
67
70
]
+9
-2
backend/src/backend/_internal/atproto/records/fm_plyr/track.py
+9
-2
backend/src/backend/_internal/atproto/records/fm_plyr/track.py
···
20
20
duration: int | None = None,
21
21
features: list[dict] | None = None,
22
22
image_url: str | None = None,
23
+
support_gate: dict | None = None,
23
24
) -> dict[str, Any]:
24
25
"""Build a track record dict for ATProto.
25
26
26
27
args:
27
28
title: track title
28
29
artist: artist name
29
-
audio_url: R2 URL for audio file
30
+
audio_url: R2 URL for audio file (placeholder for gated tracks)
30
31
file_type: file extension (mp3, wav, etc)
31
32
album: optional album name
32
33
duration: optional duration in seconds
33
34
features: optional list of featured artists [{did, handle, display_name, avatar_url}]
34
35
image_url: optional cover art image URL
36
+
support_gate: optional gating config (e.g., {"type": "any"})
35
37
36
38
returns:
37
39
record dict ready for ATProto
···
64
66
# validate image URL comes from allowed origin
65
67
settings.storage.validate_image_url(image_url)
66
68
record["imageUrl"] = image_url
69
+
if support_gate:
70
+
record["supportGate"] = support_gate
67
71
68
72
return record
69
73
···
78
82
duration: int | None = None,
79
83
features: list[dict] | None = None,
80
84
image_url: str | None = None,
85
+
support_gate: dict | None = None,
81
86
) -> tuple[str, str]:
82
87
"""Create a track record on the user's PDS using the configured collection.
83
88
···
85
90
auth_session: authenticated user session
86
91
title: track title
87
92
artist: artist name
88
-
audio_url: R2 URL for audio file
93
+
audio_url: R2 URL for audio file (placeholder URL for gated tracks)
89
94
file_type: file extension (mp3, wav, etc)
90
95
album: optional album name
91
96
duration: optional duration in seconds
92
97
features: optional list of featured artists [{did, handle, display_name, avatar_url}]
93
98
image_url: optional cover art image URL
99
+
support_gate: optional gating config (e.g., {"type": "any"})
94
100
95
101
returns:
96
102
tuple of (record_uri, record_cid)
···
108
114
duration=duration,
109
115
features=features,
110
116
image_url=image_url,
117
+
support_gate=support_gate,
111
118
)
112
119
113
120
payload = {
+1
-1
backend/src/backend/_internal/atproto/sync.py
+1
-1
backend/src/backend/_internal/atproto/sync.py
+121
backend/src/backend/_internal/atprotofans.py
+121
backend/src/backend/_internal/atprotofans.py
···
1
+
"""atprotofans integration for supporter validation.
2
+
3
+
atprotofans is a creator support platform on ATProto. this module provides
4
+
server-side validation of supporter relationships for content gating.
5
+
6
+
the validation uses the three-party model:
7
+
- supporter: has com.atprotofans.supporter record in their PDS
8
+
- creator: has com.atprotofans.supporterProof record in their PDS
9
+
- broker: has com.atprotofans.brokerProof record (atprotofans service)
10
+
11
+
for direct atprotofans contributions (not via platform registration),
12
+
the signer is the artist's own DID.
13
+
14
+
see: https://atprotofans.leaflet.pub/3mabsmts3rs2b
15
+
"""
16
+
17
+
import asyncio
18
+
19
+
import httpx
20
+
import logfire
21
+
from pydantic import BaseModel
22
+
23
+
24
+
class SupporterValidation(BaseModel):
25
+
"""result of validating supporter status."""
26
+
27
+
valid: bool
28
+
profile: dict | None = None
29
+
30
+
31
+
async def validate_supporter(
32
+
supporter_did: str,
33
+
artist_did: str,
34
+
timeout: float = 5.0,
35
+
) -> SupporterValidation:
36
+
"""validate if a user supports an artist via atprotofans.
37
+
38
+
for direct atprotofans contributions, the signer is the artist's DID.
39
+
40
+
args:
41
+
supporter_did: DID of the potential supporter
42
+
artist_did: DID of the artist (also used as signer)
43
+
timeout: request timeout in seconds
44
+
45
+
returns:
46
+
SupporterValidation with valid=True if supporter, valid=False otherwise
47
+
"""
48
+
url = "https://atprotofans.com/xrpc/com.atprotofans.validateSupporter"
49
+
params = {
50
+
"supporter": supporter_did,
51
+
"subject": artist_did,
52
+
"signer": artist_did, # for direct contributions, signer = artist
53
+
}
54
+
55
+
with logfire.span(
56
+
"atprotofans.validate_supporter",
57
+
supporter_did=supporter_did,
58
+
artist_did=artist_did,
59
+
):
60
+
try:
61
+
async with httpx.AsyncClient(timeout=timeout) as client:
62
+
response = await client.get(url, params=params)
63
+
64
+
if response.status_code != 200:
65
+
logfire.warn(
66
+
"atprotofans validation failed",
67
+
status_code=response.status_code,
68
+
response_text=response.text[:200],
69
+
)
70
+
return SupporterValidation(valid=False)
71
+
72
+
data = response.json()
73
+
is_valid = data.get("valid", False)
74
+
75
+
logfire.info(
76
+
"atprotofans validation result",
77
+
valid=is_valid,
78
+
has_profile=data.get("profile") is not None,
79
+
)
80
+
81
+
return SupporterValidation(
82
+
valid=is_valid,
83
+
profile=data.get("profile"),
84
+
)
85
+
86
+
except httpx.TimeoutException:
87
+
logfire.warn("atprotofans validation timeout")
88
+
return SupporterValidation(valid=False)
89
+
except Exception as e:
90
+
logfire.error(
91
+
"atprotofans validation error",
92
+
error=str(e),
93
+
exc_info=True,
94
+
)
95
+
return SupporterValidation(valid=False)
96
+
97
+
98
+
async def get_supported_artists(
99
+
supporter_did: str,
100
+
artist_dids: set[str],
101
+
timeout: float = 5.0,
102
+
) -> set[str]:
103
+
"""batch check which artists a user supports.
104
+
105
+
args:
106
+
supporter_did: DID of the potential supporter
107
+
artist_dids: set of artist DIDs to check
108
+
timeout: request timeout per check
109
+
110
+
returns:
111
+
set of artist DIDs the user supports
112
+
"""
113
+
if not artist_dids:
114
+
return set()
115
+
116
+
async def check_one(artist_did: str) -> str | None:
117
+
result = await validate_supporter(supporter_did, artist_did, timeout)
118
+
return artist_did if result.valid else None
119
+
120
+
results = await asyncio.gather(*[check_one(did) for did in artist_dids])
121
+
return {did for did in results if did is not None}
+2
-1
backend/src/backend/_internal/audio.py
+2
-1
backend/src/backend/_internal/audio.py
···
1
1
"""audio file type definitions."""
2
2
3
3
from enum import Enum
4
+
from typing import Self
4
5
5
6
6
7
class AudioFormat(str, Enum):
···
26
27
return media_types[self]
27
28
28
29
@classmethod
29
-
def from_extension(cls, ext: str) -> "AudioFormat | None":
30
+
def from_extension(cls, ext: str) -> Self | None:
30
31
"""get format from file extension (with or without dot)."""
31
32
ext = ext.lower().lstrip(".")
32
33
for format in cls:
+4
-6
backend/src/backend/_internal/auth.py
+4
-6
backend/src/backend/_internal/auth.py
···
5
5
import secrets
6
6
from dataclasses import dataclass
7
7
from datetime import UTC, datetime, timedelta
8
-
from typing import TYPE_CHECKING, Annotated, Any
8
+
from typing import Annotated, Any
9
9
10
10
from atproto_oauth import OAuthClient
11
11
from atproto_oauth.stores.memory import MemorySessionStore
12
12
from cryptography.fernet import Fernet
13
13
from cryptography.hazmat.primitives.asymmetric import ec
14
+
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
14
15
from cryptography.hazmat.primitives.serialization import load_pem_private_key
15
16
from fastapi import Cookie, Header, HTTPException
16
17
from jose import jwk
···
20
21
from backend.config import settings
21
22
from backend.models import ExchangeToken, PendingDevToken, UserPreferences, UserSession
22
23
from backend.utilities.database import db_session
23
-
24
-
if TYPE_CHECKING:
25
-
from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey
26
24
27
25
logger = logging.getLogger(__name__)
28
26
···
76
74
_session_store = MemorySessionStore()
77
75
78
76
# confidential client key (loaded lazily)
79
-
_client_secret_key: "EllipticCurvePrivateKey | None" = None
77
+
_client_secret_key: EllipticCurvePrivateKey | None = None
80
78
_client_secret_kid: str | None = None
81
79
_client_secret_key_loaded = False
82
80
83
81
84
-
def _load_client_secret() -> tuple["EllipticCurvePrivateKey | None", str | None]:
82
+
def _load_client_secret() -> tuple[EllipticCurvePrivateKey | None, str | None]:
85
83
"""load EC private key and kid from OAUTH_JWK setting for confidential client.
86
84
87
85
the key is expected to be a JSON-serialized JWK with ES256 (P-256) key.
+4
backend/src/backend/_internal/background.py
+4
backend/src/backend/_internal/background.py
···
55
55
extra={"docket_name": settings.docket.name, "url": settings.docket.url},
56
56
)
57
57
58
+
# WARNING: do not modify Docket() or Worker() constructor args without
59
+
# reading docs/backend/background-tasks.md - see 2025-12-30 incident
58
60
async with Docket(
59
61
name=settings.docket.name,
60
62
url=settings.docket.url,
···
77
79
scheduling_resolution=timedelta(
78
80
seconds=settings.docket.scheduling_resolution_seconds
79
81
),
82
+
# disable automatic perpetual tasks in tests to avoid event loop issues
83
+
schedule_automatic_tasks=settings.docket.schedule_automatic_tasks,
80
84
) as worker:
81
85
worker_task = asyncio.create_task(
82
86
worker.run_forever(),
+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
]
+4
-3
backend/src/backend/_internal/image.py
+4
-3
backend/src/backend/_internal/image.py
···
1
1
"""image format handling for media storage."""
2
2
3
3
from enum import Enum
4
+
from typing import Self
4
5
5
6
6
7
class ImageFormat(str, Enum):
···
24
25
}[self.value]
25
26
26
27
@classmethod
27
-
def from_filename(cls, filename: str) -> "ImageFormat | None":
28
+
def from_filename(cls, filename: str) -> Self | None:
28
29
"""extract image format from filename extension."""
29
30
ext = filename.lower().split(".")[-1]
30
31
if ext in ["jpg", "jpeg"]:
···
34
35
return None
35
36
36
37
@classmethod
37
-
def from_content_type(cls, content_type: str | None) -> "ImageFormat | None":
38
+
def from_content_type(cls, content_type: str | None) -> Self | None:
38
39
"""extract image format from MIME content type.
39
40
40
41
this is more reliable than filename extension, especially on iOS
···
56
57
@classmethod
57
58
def validate_and_extract(
58
59
cls, filename: str | None, content_type: str | None = None
59
-
) -> tuple["ImageFormat | None", bool]:
60
+
) -> tuple[Self | None, bool]:
60
61
"""validate image format from filename or content type.
61
62
62
63
prefers content_type over filename extension when available, since
+1
-1
backend/src/backend/_internal/moderation.py
+1
-1
backend/src/backend/_internal/moderation.py
···
107
107
track_title: str | None = None,
108
108
artist_handle: str | None = None,
109
109
artist_did: str | None = None,
110
-
highest_score: float | None = None,
110
+
highest_score: int | None = None,
111
111
matches: list[dict[str, Any]] | None = None,
112
112
) -> None:
113
113
"""emit a copyright-violation label to the ATProto labeler service."""
+32
-1
backend/src/backend/_internal/moderation_client.py
+32
-1
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
···
54
62
service_url: str,
55
63
labeler_url: str,
56
64
auth_token: str,
57
-
timeout_seconds: float,
65
+
timeout_seconds: float | int,
58
66
label_cache_prefix: str,
59
67
label_cache_ttl_seconds: int,
60
68
) -> None:
···
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.
+18
-17
backend/src/backend/api/albums.py
+18
-17
backend/src/backend/api/albums.py
···
517
517
track.extra = {}
518
518
track.extra = {**track.extra, "album": new_title}
519
519
520
-
# update ATProto record
521
-
updated_record = build_track_record(
522
-
title=track.title,
523
-
artist=track.artist.display_name,
524
-
audio_url=track.r2_url,
525
-
file_type=track.file_type,
526
-
album=new_title,
527
-
duration=track.duration,
528
-
features=track.features if track.features else None,
529
-
image_url=await track.get_image_url(),
530
-
)
520
+
# update ATProto record if track has one
521
+
if track.atproto_record_uri and track.r2_url and track.file_type:
522
+
updated_record = build_track_record(
523
+
title=track.title,
524
+
artist=track.artist.display_name,
525
+
audio_url=track.r2_url,
526
+
file_type=track.file_type,
527
+
album=new_title,
528
+
duration=track.duration,
529
+
features=track.features if track.features else None,
530
+
image_url=await track.get_image_url(),
531
+
)
531
532
532
-
_, new_cid = await update_record(
533
-
auth_session=auth_session,
534
-
record_uri=track.atproto_record_uri,
535
-
record=updated_record,
536
-
)
537
-
track.atproto_record_cid = new_cid
533
+
_, new_cid = await update_record(
534
+
auth_session=auth_session,
535
+
record_uri=track.atproto_record_uri,
536
+
record=updated_record,
537
+
)
538
+
track.atproto_record_cid = new_cid
538
539
539
540
# update the album's ATProto list record name
540
541
if album.atproto_record_uri:
+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:
+14
backend/src/backend/config.py
+14
backend/src/backend/config.py
···
256
256
validation_alias="R2_IMAGE_BUCKET",
257
257
description="R2 bucket name for image files",
258
258
)
259
+
r2_private_bucket: str = Field(
260
+
default="",
261
+
validation_alias="R2_PRIVATE_BUCKET",
262
+
description="R2 private bucket for supporter-gated audio (no public URL)",
263
+
)
264
+
presigned_url_expiry_seconds: int = Field(
265
+
default=3600,
266
+
validation_alias="PRESIGNED_URL_EXPIRY_SECONDS",
267
+
description="Expiry time in seconds for presigned URLs (default 1 hour)",
268
+
)
259
269
r2_endpoint_url: str = Field(
260
270
default="",
261
271
validation_alias="R2_ENDPOINT_URL",
···
596
606
scheduling_resolution_seconds: float = Field(
597
607
default=5.0,
598
608
description="How often to run the scheduler loop (seconds). Default 5s reduces Redis costs vs docket's 250ms default.",
609
+
)
610
+
schedule_automatic_tasks: bool = Field(
611
+
default=True,
612
+
description="Schedule automatic perpetual tasks at worker startup. Disable in tests to avoid event loop issues.",
599
613
)
600
614
601
615
-7
backend/src/backend/main.py
-7
backend/src/backend/main.py
···
46
46
from backend.api.lists import router as lists_router
47
47
from backend.api.migration import router as migration_router
48
48
from backend.config import settings
49
-
from backend.models import init_db
50
49
from backend.utilities.rate_limit import limiter
51
50
52
51
# configure logfire if enabled
···
148
147
@asynccontextmanager
149
148
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
150
149
"""handle application lifespan events."""
151
-
# startup: initialize database
152
-
# NOTE: init_db() is still needed because base tables (artists, tracks, user_sessions)
153
-
# don't have migrations - they were created before migrations were introduced.
154
-
# See issue #46 for removing this in favor of a proper initial migration.
155
-
await init_db()
156
-
157
150
# setup services
158
151
await notification_service.setup()
159
152
await queue_service.setup()
+1
-2
backend/src/backend/models/__init__.py
+1
-2
backend/src/backend/models/__init__.py
···
18
18
from backend.models.track import Track
19
19
from backend.models.track_comment import TrackComment
20
20
from backend.models.track_like import TrackLike
21
-
from backend.utilities.database import db_session, get_db, init_db
21
+
from backend.utilities.database import db_session, get_db
22
22
23
23
__all__ = [
24
24
"Album",
···
42
42
"UserSession",
43
43
"db_session",
44
44
"get_db",
45
-
"init_db",
46
45
]
+10
backend/src/backend/models/track.py
+10
backend/src/backend/models/track.py
···
87
87
nullable=False, default=False, server_default="false"
88
88
)
89
89
90
+
# supporter-gated content (e.g., {"type": "any"} requires any atprotofans support)
91
+
support_gate: Mapped[dict | None] = mapped_column(
92
+
JSONB, nullable=True, default=None
93
+
)
94
+
95
+
@property
96
+
def is_gated(self) -> bool:
97
+
"""check if this track requires supporter access."""
98
+
return self.support_gate is not None
99
+
90
100
@property
91
101
def album(self) -> str | None:
92
102
"""get album name from extra (for ATProto compatibility)."""
+20
backend/src/backend/schemas.py
+20
backend/src/backend/schemas.py
···
50
50
title: str
51
51
artist: str
52
52
artist_handle: str
53
+
artist_did: str
53
54
artist_avatar_url: str | None
54
55
file_id: str
55
56
file_type: str
···
70
71
None # None = not scanned, False = clear, True = flagged
71
72
)
72
73
copyright_match: str | None = None # "Title by Artist" of primary match
74
+
support_gate: dict[str, Any] | None = None # supporter gating config
75
+
gated: bool = False # true if track is gated AND viewer lacks access
73
76
74
77
@classmethod
75
78
async def from_track(
···
81
84
comment_counts: dict[int, int] | None = None,
82
85
copyright_info: dict[int, CopyrightInfo] | None = None,
83
86
track_tags: dict[int, set[str]] | None = None,
87
+
viewer_did: str | None = None,
88
+
supported_artist_dids: set[str] | None = None,
84
89
) -> "TrackResponse":
85
90
"""build track response from Track model.
86
91
···
92
97
comment_counts: optional dict of track_id -> comment_count
93
98
copyright_info: optional dict of track_id -> CopyrightInfo
94
99
track_tags: optional dict of track_id -> set of tag names
100
+
viewer_did: optional DID of the viewer (for gated content resolution)
101
+
supported_artist_dids: optional set of artist DIDs the viewer supports
95
102
"""
96
103
# check if user has liked this track
97
104
is_liked = liked_track_ids is not None and track.id in liked_track_ids
···
135
142
# get tags for this track
136
143
tags = track_tags.get(track.id, set()) if track_tags else set()
137
144
145
+
# resolve gated status for viewer
146
+
# gated = true only if track has support_gate AND viewer lacks access
147
+
gated = False
148
+
if track.support_gate:
149
+
is_owner = viewer_did and viewer_did == track.artist_did
150
+
is_supporter = (
151
+
supported_artist_dids and track.artist_did in supported_artist_dids
152
+
)
153
+
gated = not (is_owner or is_supporter)
154
+
138
155
return cls(
139
156
id=track.id,
140
157
title=track.title,
141
158
artist=track.artist.display_name,
142
159
artist_handle=track.artist.handle,
160
+
artist_did=track.artist_did,
143
161
artist_avatar_url=track.artist.avatar_url,
144
162
file_id=track.file_id,
145
163
file_type=track.file_type,
···
158
176
tags=tags,
159
177
copyright_flagged=copyright_flagged,
160
178
copyright_match=copyright_match,
179
+
support_gate=track.support_gate,
180
+
gated=gated,
161
181
)
+3
-8
backend/src/backend/storage/__init__.py
+3
-8
backend/src/backend/storage/__init__.py
···
1
1
"""storage implementations."""
2
2
3
-
from typing import TYPE_CHECKING
3
+
from backend.storage.r2 import R2Storage
4
4
5
-
if TYPE_CHECKING:
6
-
from backend.storage.r2 import R2Storage
5
+
_storage: R2Storage | None = None
7
6
8
-
_storage: "R2Storage | None" = None
9
7
10
-
11
-
def _get_storage() -> "R2Storage":
8
+
def _get_storage() -> R2Storage:
12
9
"""lazily initialize storage on first access."""
13
10
global _storage
14
11
if _storage is None:
15
-
from backend.storage.r2 import R2Storage
16
-
17
12
_storage = R2Storage()
18
13
return _storage
19
14
+220
-1
backend/src/backend/storage/r2.py
+220
-1
backend/src/backend/storage/r2.py
···
32
32
total_size: int,
33
33
callback: Callable[[float], None],
34
34
min_bytes_between_updates: int = 5 * 1024 * 1024, # 5MB
35
-
min_time_between_updates: float = 0.25, # 250ms
35
+
min_time_between_updates: float | int = 0.25, # 250ms
36
36
):
37
37
"""initialize progress tracker.
38
38
···
95
95
96
96
self.audio_bucket_name = settings.storage.r2_bucket
97
97
self.image_bucket_name = settings.storage.r2_image_bucket
98
+
self.private_audio_bucket_name = settings.storage.r2_private_bucket
98
99
self.public_audio_bucket_url = settings.storage.r2_public_bucket_url
99
100
self.public_image_bucket_url = settings.storage.r2_public_image_bucket_url
101
+
self.presigned_url_expiry = settings.storage.presigned_url_expiry_seconds
100
102
101
103
# sync client for upload (used in background tasks)
102
104
self.client = boto3.client(
···
439
441
image_bucket=self.image_bucket_name,
440
442
)
441
443
return False
444
+
445
+
async def save_gated(
446
+
self,
447
+
file: BinaryIO,
448
+
filename: str,
449
+
progress_callback: Callable[[float], None] | None = None,
450
+
) -> str:
451
+
"""save supporter-gated audio file to private R2 bucket.
452
+
453
+
same as save() but uses the private bucket with no public URL.
454
+
files in this bucket are only accessible via presigned URLs.
455
+
456
+
args:
457
+
file: file-like object to upload
458
+
filename: original filename (used to determine media type)
459
+
progress_callback: optional callback for upload progress
460
+
"""
461
+
if not self.private_audio_bucket_name:
462
+
raise ValueError("R2_PRIVATE_BUCKET not configured")
463
+
464
+
with logfire.span("R2 save_gated", filename=filename):
465
+
# compute hash in chunks (constant memory)
466
+
file_id = hash_file_chunked(file)[:16]
467
+
logfire.info("computed file hash for gated content", file_id=file_id)
468
+
469
+
# determine file extension - only audio supported for gated content
470
+
ext = Path(filename).suffix.lower()
471
+
audio_format = AudioFormat.from_extension(ext)
472
+
if not audio_format:
473
+
raise ValueError(
474
+
f"unsupported audio type for gated content: {ext}. "
475
+
f"supported: {AudioFormat.supported_extensions_str()}"
476
+
)
477
+
478
+
key = f"audio/{file_id}{ext}"
479
+
media_type = audio_format.media_type
480
+
481
+
# get file size for progress tracking
482
+
file_size = file.seek(0, 2)
483
+
file.seek(0)
484
+
485
+
logfire.info(
486
+
"uploading gated content to private R2",
487
+
bucket=self.private_audio_bucket_name,
488
+
key=key,
489
+
media_type=media_type,
490
+
file_size=file_size,
491
+
)
492
+
493
+
try:
494
+
async with self.async_session.client(
495
+
"s3",
496
+
endpoint_url=self.endpoint_url,
497
+
aws_access_key_id=self.aws_access_key_id,
498
+
aws_secret_access_key=self.aws_secret_access_key,
499
+
) as client:
500
+
upload_kwargs = {
501
+
"Fileobj": file,
502
+
"Bucket": self.private_audio_bucket_name,
503
+
"Key": key,
504
+
"ExtraArgs": {"ContentType": media_type},
505
+
}
506
+
507
+
if progress_callback and file_size > 0:
508
+
tracker = UploadProgressTracker(file_size, progress_callback)
509
+
upload_kwargs["Callback"] = tracker
510
+
511
+
await client.upload_fileobj(**upload_kwargs)
512
+
except Exception as e:
513
+
logfire.error(
514
+
"R2 gated upload failed",
515
+
error=str(e),
516
+
bucket=self.private_audio_bucket_name,
517
+
key=key,
518
+
exc_info=True,
519
+
)
520
+
raise
521
+
522
+
logfire.info("R2 gated upload complete", file_id=file_id, key=key)
523
+
return file_id
524
+
525
+
async def generate_presigned_url(
526
+
self,
527
+
file_id: str,
528
+
extension: str,
529
+
expires_in: int | None = None,
530
+
) -> str:
531
+
"""generate a presigned URL for accessing gated content.
532
+
533
+
presigned URLs allow time-limited access to private bucket objects
534
+
without exposing credentials. the URL includes a signature that
535
+
expires after the specified duration.
536
+
537
+
args:
538
+
file_id: the file identifier hash
539
+
extension: file extension (e.g., "mp3", "flac")
540
+
expires_in: optional override for expiry seconds (default from settings)
541
+
542
+
returns:
543
+
presigned URL string
544
+
545
+
raises:
546
+
ValueError: if private bucket not configured
547
+
"""
548
+
if not self.private_audio_bucket_name:
549
+
raise ValueError("R2_PRIVATE_BUCKET not configured")
550
+
551
+
ext = extension.lstrip(".")
552
+
key = f"audio/{file_id}.{ext}"
553
+
expiry = expires_in or self.presigned_url_expiry
554
+
555
+
with logfire.span(
556
+
"R2 generate_presigned_url",
557
+
file_id=file_id,
558
+
key=key,
559
+
expires_in=expiry,
560
+
):
561
+
async with self.async_session.client(
562
+
"s3",
563
+
endpoint_url=self.endpoint_url,
564
+
aws_access_key_id=self.aws_access_key_id,
565
+
aws_secret_access_key=self.aws_secret_access_key,
566
+
config=Config(signature_version="s3v4"),
567
+
) as client:
568
+
url = await client.generate_presigned_url(
569
+
"get_object",
570
+
Params={
571
+
"Bucket": self.private_audio_bucket_name,
572
+
"Key": key,
573
+
},
574
+
ExpiresIn=expiry,
575
+
)
576
+
logfire.info(
577
+
"generated presigned URL",
578
+
file_id=file_id,
579
+
expires_in=expiry,
580
+
)
581
+
return url
582
+
583
+
async def move_audio(
584
+
self,
585
+
file_id: str,
586
+
extension: str,
587
+
*,
588
+
to_private: bool,
589
+
) -> str | None:
590
+
"""move an audio file between public and private buckets.
591
+
592
+
copies the file to the destination bucket, then deletes from source.
593
+
594
+
args:
595
+
file_id: the file identifier hash
596
+
extension: file extension (e.g., "mp3", "flac")
597
+
to_private: if True, move public->private; if False, move private->public
598
+
599
+
returns:
600
+
new URL if successful (public URL or None for private), None on failure
601
+
602
+
raises:
603
+
ValueError: if private bucket not configured
604
+
"""
605
+
if not self.private_audio_bucket_name:
606
+
raise ValueError("R2_PRIVATE_BUCKET not configured")
607
+
608
+
ext = extension.lstrip(".")
609
+
key = f"audio/{file_id}.{ext}"
610
+
611
+
if to_private:
612
+
src_bucket = self.audio_bucket_name
613
+
dst_bucket = self.private_audio_bucket_name
614
+
else:
615
+
src_bucket = self.private_audio_bucket_name
616
+
dst_bucket = self.audio_bucket_name
617
+
618
+
with logfire.span(
619
+
"R2 move_audio",
620
+
file_id=file_id,
621
+
key=key,
622
+
to_private=to_private,
623
+
):
624
+
try:
625
+
async with self.async_session.client(
626
+
"s3",
627
+
endpoint_url=self.endpoint_url,
628
+
aws_access_key_id=self.aws_access_key_id,
629
+
aws_secret_access_key=self.aws_secret_access_key,
630
+
) as client:
631
+
# copy to destination
632
+
await client.copy_object(
633
+
CopySource={"Bucket": src_bucket, "Key": key},
634
+
Bucket=dst_bucket,
635
+
Key=key,
636
+
)
637
+
logfire.info(
638
+
"copied audio file",
639
+
file_id=file_id,
640
+
src=src_bucket,
641
+
dst=dst_bucket,
642
+
)
643
+
644
+
# delete from source
645
+
await client.delete_object(Bucket=src_bucket, Key=key)
646
+
logfire.info("deleted from source bucket", file_id=file_id)
647
+
648
+
# return public URL if moved to public, None if moved to private
649
+
if to_private:
650
+
return None
651
+
return f"{self.public_audio_bucket_url}/{key}"
652
+
653
+
except ClientError as e:
654
+
logfire.error(
655
+
"R2 move_audio failed",
656
+
file_id=file_id,
657
+
error=str(e),
658
+
exc_info=True,
659
+
)
660
+
return None
-9
backend/src/backend/utilities/database.py
-9
backend/src/backend/utilities/database.py
···
90
90
"""get async database session (for FastAPI dependency injection)."""
91
91
async with db_session() as session:
92
92
yield session
93
-
94
-
95
-
async def init_db():
96
-
"""initialize database tables."""
97
-
from backend.models.database import Base
98
-
99
-
engine = get_engine()
100
-
async with engine.begin() as conn:
101
-
await conn.run_sync(Base.metadata.create_all)
+2
-1
backend/src/backend/utilities/hashing.py
+2
-1
backend/src/backend/utilities/hashing.py
···
1
1
"""streaming hash calculation utilities."""
2
2
3
3
import hashlib
4
+
from io import IOBase
4
5
from typing import BinaryIO
5
6
6
7
# 8MB chunks balances memory usage and performance
7
8
CHUNK_SIZE = 8 * 1024 * 1024
8
9
9
10
10
-
def hash_file_chunked(file_obj: BinaryIO, algorithm: str = "sha256") -> str:
11
+
def hash_file_chunked(file_obj: BinaryIO | IOBase, algorithm: str = "sha256") -> str:
11
12
"""compute hash by reading file in chunks.
12
13
13
14
this prevents loading entire file into memory, enabling constant
+2
-1
backend/tests/api/test_albums.py
+2
-1
backend/tests/api/test_albums.py
···
657
657
track = Track(
658
658
title="Test Track",
659
659
file_id="test-file-update",
660
-
file_type="audio/mpeg",
660
+
file_type="mp3",
661
661
artist_did=artist.did,
662
662
album_id=album.id,
663
663
extra={"album": "Original Title"},
664
+
r2_url="https://r2.example.com/audio/test-file-update.mp3",
664
665
atproto_record_uri="at://did:test:user123/fm.plyr.track/track123",
665
666
atproto_record_cid="original_cid",
666
667
)
+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)
+22
-8
backend/tests/conftest.py
+22
-8
backend/tests/conftest.py
···
22
22
23
23
from backend.config import settings
24
24
from backend.models import Base
25
+
from backend.storage.r2 import R2Storage
25
26
from backend.utilities.redis import clear_client_cache
26
27
27
28
28
-
class MockStorage:
29
+
class MockStorage(R2Storage):
29
30
"""Mock storage for tests - no R2 credentials needed."""
30
31
32
+
def __init__(self):
33
+
# skip R2Storage.__init__ which requires credentials
34
+
pass
35
+
31
36
async def save(self, file_obj, filename: str, progress_callback=None) -> str:
32
37
"""Mock save - returns a fake file_id."""
33
38
return "mock_file_id_123"
34
39
35
40
async def get_url(
36
-
self, file_id: str, file_type: str | None = None, extension: str | None = None
37
-
) -> str:
41
+
self,
42
+
file_id: str,
43
+
*,
44
+
file_type: str | None = None,
45
+
extension: str | None = None,
46
+
) -> str | None:
38
47
"""Mock get_url - returns a fake URL."""
39
48
return f"https://mock.r2.dev/{file_id}"
40
49
41
-
async def delete(self, file_id: str, extension: str | None = None) -> None:
50
+
async def delete(self, file_id: str, file_type: str | None = None) -> bool:
42
51
"""Mock delete."""
52
+
return True
43
53
44
54
45
55
def pytest_configure(config):
···
359
369
yield session
360
370
361
371
362
-
@pytest.fixture
372
+
@pytest.fixture(scope="session")
363
373
def fastapi_app() -> FastAPI:
364
-
"""provides the FastAPI app instance."""
374
+
"""provides the FastAPI app instance (session-scoped for performance)."""
365
375
from backend.main import app as main_app
366
376
367
377
return main_app
368
378
369
379
370
-
@pytest.fixture
380
+
@pytest.fixture(scope="session")
371
381
def client(fastapi_app: FastAPI) -> Generator[TestClient, None, None]:
372
-
"""provides a TestClient for testing the FastAPI application."""
382
+
"""provides a TestClient for testing the FastAPI application.
383
+
384
+
session-scoped to avoid the overhead of starting the full lifespan
385
+
(database init, services, docket worker) for each test.
386
+
"""
373
387
with TestClient(fastapi_app) as tc:
374
388
yield tc
375
389
+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"]
+2
backend/uv.lock
+2
backend/uv.lock
···
317
317
{ name = "alembic" },
318
318
{ name = "asyncpg" },
319
319
{ name = "atproto" },
320
+
{ name = "beartype" },
320
321
{ name = "boto3" },
321
322
{ name = "cachetools" },
322
323
{ name = "fastapi" },
···
363
364
{ name = "alembic", specifier = ">=1.14.0" },
364
365
{ name = "asyncpg", specifier = ">=0.30.0" },
365
366
{ name = "atproto", git = "https://github.com/zzstoatzz/atproto?rev=main" },
367
+
{ name = "beartype", specifier = ">=0.22.8" },
366
368
{ name = "boto3", specifier = ">=1.37.0" },
367
369
{ name = "cachetools", specifier = ">=6.2.1" },
368
370
{ name = "fastapi", specifier = ">=0.115.0" },
+29
-17
docs/backend/background-tasks.md
+29
-17
docs/backend/background-tasks.md
···
39
39
DOCKET_WORKER_CONCURRENCY=10 # concurrent task limit
40
40
```
41
41
42
+
### โ ๏ธ worker settings - do not modify
43
+
44
+
the worker is initialized in `backend/_internal/background.py` with pydocket's defaults. **do not change these settings without extensive testing:**
45
+
46
+
| setting | default | why it matters |
47
+
|---------|---------|----------------|
48
+
| `heartbeat_interval` | 2s | changing this broke all task execution (2025-12-30 incident) |
49
+
| `minimum_check_interval` | 1s | affects how quickly tasks are picked up |
50
+
| `scheduling_resolution` | 1s | affects scheduled task precision |
51
+
52
+
**2025-12-30 incident**: setting `heartbeat_interval=30s` caused all scheduled tasks (likes, comments, exports) to silently fail while perpetual tasks continued running. root cause unclear - correlation was definitive but mechanism wasn't found in pydocket source. reverted in PR #669.
53
+
54
+
if you need to tune worker settings:
55
+
1. test extensively in staging with real task volume
56
+
2. verify ALL task types execute (not just perpetual tasks)
57
+
3. check logfire for task execution spans
58
+
42
59
when `DOCKET_URL` is not set, docket is disabled and tasks fall back to `asyncio.create_task()` (fire-and-forget).
43
60
44
61
### local development
···
54
71
55
72
### production/staging
56
73
57
-
Redis instances are provisioned via Upstash (managed Redis):
74
+
Redis instances are self-hosted on Fly.io (redis:7-alpine):
58
75
59
-
| environment | instance | region |
60
-
|-------------|----------|--------|
61
-
| production | `plyr-redis-prd` | us-east-1 (near fly.io) |
62
-
| staging | `plyr-redis-stg` | us-east-1 |
76
+
| environment | fly app | region |
77
+
|-------------|---------|--------|
78
+
| production | `plyr-redis` | iad |
79
+
| staging | `plyr-redis-stg` | iad |
63
80
64
81
set `DOCKET_URL` in fly.io secrets:
65
82
```bash
66
-
flyctl secrets set DOCKET_URL=rediss://default:xxx@xxx.upstash.io:6379 -a relay-api
67
-
flyctl secrets set DOCKET_URL=rediss://default:xxx@xxx.upstash.io:6379 -a relay-api-staging
83
+
flyctl secrets set DOCKET_URL=redis://plyr-redis.internal:6379 -a relay-api
84
+
flyctl secrets set DOCKET_URL=redis://plyr-redis-stg.internal:6379 -a relay-api-staging
68
85
```
69
86
70
-
note: use `rediss://` (with double 's') for TLS connections to Upstash.
87
+
note: uses Fly internal networking (`.internal` domain), no TLS needed within private network.
71
88
72
89
## usage
73
90
···
117
134
118
135
## costs
119
136
120
-
**Upstash pricing** (pay-per-request):
121
-
- free tier: 10k commands/day
122
-
- pro: $0.2 per 100k commands + $0.25/GB storage
137
+
**self-hosted Redis on Fly.io** (fixed monthly):
138
+
- ~$2/month per instance (256MB shared-cpu VM)
139
+
- ~$4/month total for prod + staging
123
140
124
-
for plyr.fm's volume (~100 uploads/day), this stays well within free tier or costs $0-5/mo.
125
-
126
-
**tips to avoid surprise bills**:
127
-
- use **regional** (not global) replication
128
-
- set **max data limit** (256MB is plenty for a task queue)
129
-
- monitor usage in Upstash dashboard
141
+
this replaced Upstash pay-per-command pricing which was costing ~$75/month at scale (37M commands/month).
130
142
131
143
## fallback behavior
132
144
+3
-1
docs/backend/configuration.md
+3
-1
docs/backend/configuration.md
···
27
27
28
28
# storage settings (cloudflare r2)
29
29
settings.storage.backend # from STORAGE_BACKEND
30
-
settings.storage.r2_bucket # from R2_BUCKET (audio files)
30
+
settings.storage.r2_bucket # from R2_BUCKET (public audio files)
31
+
settings.storage.r2_private_bucket # from R2_PRIVATE_BUCKET (gated audio files)
31
32
settings.storage.r2_image_bucket # from R2_IMAGE_BUCKET (image files)
32
33
settings.storage.r2_endpoint_url # from R2_ENDPOINT_URL
33
34
settings.storage.r2_public_bucket_url # from R2_PUBLIC_BUCKET_URL (audio files)
···
84
85
# storage
85
86
STORAGE_BACKEND=r2 # or "filesystem"
86
87
R2_BUCKET=your-audio-bucket
88
+
R2_PRIVATE_BUCKET=your-private-audio-bucket # for supporter-gated content
87
89
R2_IMAGE_BUCKET=your-image-bucket
88
90
R2_ENDPOINT_URL=https://xxx.r2.cloudflarestorage.com
89
91
R2_PUBLIC_BUCKET_URL=https://pub-xxx.r2.dev # for audio files
+2
-2
docs/deployment/environments.md
+2
-2
docs/deployment/environments.md
···
7
7
| environment | trigger | backend URL | database | redis | frontend | storage |
8
8
|-------------|---------|-------------|----------|-------|----------|---------|
9
9
| **development** | local | localhost:8001 | plyr-dev (neon) | localhost:6379 (docker) | localhost:5173 | audio-dev, images-dev (r2) |
10
-
| **staging** | push to main | api-stg.plyr.fm | plyr-stg (neon) | plyr-redis-stg (upstash) | stg.plyr.fm (main branch) | audio-staging, images-staging (r2) |
11
-
| **production** | github release | api.plyr.fm | plyr-prd (neon) | plyr-redis-prd (upstash) | plyr.fm (production-fe branch) | audio-prod, images-prod (r2) |
10
+
| **staging** | push to main | api-stg.plyr.fm | plyr-stg (neon) | plyr-redis-stg (fly.io) | stg.plyr.fm (main branch) | audio-staging, images-staging (r2) |
11
+
| **production** | github release | api.plyr.fm | plyr-prd (neon) | plyr-redis (fly.io) | plyr.fm (production-fe branch) | audio-prod, images-prod (r2) |
12
12
13
13
## workflow
14
14
+117
docs/frontend/design-tokens.md
+117
docs/frontend/design-tokens.md
···
1
+
# design tokens
2
+
3
+
CSS custom properties defined in `frontend/src/routes/+layout.svelte`. Use these instead of hardcoding values.
4
+
5
+
## border radius
6
+
7
+
```css
8
+
--radius-sm: 4px; /* tight corners (inputs, small elements) */
9
+
--radius-base: 6px; /* default for most elements */
10
+
--radius-md: 8px; /* cards, modals */
11
+
--radius-lg: 12px; /* larger containers */
12
+
--radius-xl: 16px; /* prominent elements */
13
+
--radius-2xl: 24px; /* hero elements */
14
+
--radius-full: 9999px; /* pills, circles */
15
+
```
16
+
17
+
## typography
18
+
19
+
```css
20
+
/* scale */
21
+
--text-xs: 0.75rem; /* 12px - hints, captions */
22
+
--text-sm: 0.85rem; /* 13.6px - labels, secondary */
23
+
--text-base: 0.9rem; /* 14.4px - body default */
24
+
--text-lg: 1rem; /* 16px - body emphasized */
25
+
--text-xl: 1.1rem; /* 17.6px - subheadings */
26
+
--text-2xl: 1.25rem; /* 20px - section headings */
27
+
--text-3xl: 1.5rem; /* 24px - page headings */
28
+
29
+
/* semantic aliases */
30
+
--text-page-heading: var(--text-3xl);
31
+
--text-section-heading: 1.2rem;
32
+
--text-body: var(--text-lg);
33
+
--text-small: var(--text-base);
34
+
```
35
+
36
+
## colors
37
+
38
+
### accent
39
+
40
+
```css
41
+
--accent: #6a9fff; /* primary brand color (user-customizable) */
42
+
--accent-hover: #8ab3ff; /* hover state */
43
+
--accent-muted: #4a7ddd; /* subdued variant */
44
+
--accent-rgb: 106, 159, 255; /* for rgba() usage */
45
+
```
46
+
47
+
### backgrounds
48
+
49
+
```css
50
+
/* dark theme */
51
+
--bg-primary: #0a0a0a; /* main background */
52
+
--bg-secondary: #141414; /* elevated surfaces */
53
+
--bg-tertiary: #1a1a1a; /* cards, modals */
54
+
--bg-hover: #1f1f1f; /* hover states */
55
+
56
+
/* light theme overrides these automatically */
57
+
```
58
+
59
+
### borders
60
+
61
+
```css
62
+
--border-subtle: #282828; /* barely visible */
63
+
--border-default: #333333; /* standard borders */
64
+
--border-emphasis: #444444; /* highlighted borders */
65
+
```
66
+
67
+
### text
68
+
69
+
```css
70
+
--text-primary; /* high contrast */
71
+
--text-secondary; /* medium contrast */
72
+
--text-tertiary; /* low contrast */
73
+
--text-muted; /* very low contrast */
74
+
```
75
+
76
+
### semantic
77
+
78
+
```css
79
+
--success: #22c55e;
80
+
--warning: #f59e0b;
81
+
--error: #ef4444;
82
+
```
83
+
84
+
## usage
85
+
86
+
```svelte
87
+
<style>
88
+
.card {
89
+
border-radius: var(--radius-md);
90
+
background: var(--bg-tertiary);
91
+
border: 1px solid var(--border-default);
92
+
}
93
+
94
+
.label {
95
+
font-size: var(--text-sm);
96
+
color: var(--text-secondary);
97
+
}
98
+
99
+
input:focus {
100
+
border-color: var(--accent);
101
+
}
102
+
</style>
103
+
```
104
+
105
+
## anti-patterns
106
+
107
+
```css
108
+
/* bad - hardcoded values */
109
+
border-radius: 8px;
110
+
font-size: 14px;
111
+
background: #1a1a1a;
112
+
113
+
/* good - use tokens */
114
+
border-radius: var(--radius-md);
115
+
font-size: var(--text-base);
116
+
background: var(--bg-tertiary);
117
+
```
+34
docs/frontend/state-management.md
+34
docs/frontend/state-management.md
···
38
38
- is bound to form inputs (`bind:value={title}`)
39
39
- is checked in reactive blocks (`$effect(() => { ... })`)
40
40
41
+
### overridable `$derived` for optimistic UI (Svelte 5.25+)
42
+
43
+
as of Svelte 5.25, `$derived` values can be temporarily overridden by reassignment. this is the recommended pattern for optimistic UI where you want to:
44
+
1. sync with a prop value (derived behavior)
45
+
2. temporarily override for immediate feedback (state behavior)
46
+
3. auto-reset when the prop updates
47
+
48
+
```typescript
49
+
// โ
RECOMMENDED for optimistic UI (Svelte 5.25+)
50
+
let liked = $derived(initialLiked);
51
+
52
+
async function toggleLike() {
53
+
const previous = liked;
54
+
liked = !liked; // optimistic update - works in 5.25+!
55
+
56
+
try {
57
+
await saveLike(liked);
58
+
} catch {
59
+
liked = previous; // revert on failure
60
+
}
61
+
}
62
+
```
63
+
64
+
this replaces the older pattern of `$state` + `$effect` to sync with props:
65
+
66
+
```typescript
67
+
// โ OLD pattern - still works but more verbose
68
+
let liked = $state(initialLiked);
69
+
70
+
$effect(() => {
71
+
liked = initialLiked; // sync with prop
72
+
});
73
+
```
74
+
41
75
use plain `let` for:
42
76
- constants that never change
43
77
- variables only used in functions/callbacks (not template)
+1
docs/local-development/setup.md
+1
docs/local-development/setup.md
···
53
53
# storage (r2 or filesystem)
54
54
STORAGE_BACKEND=filesystem # or "r2" for cloudflare r2
55
55
R2_BUCKET=audio-dev
56
+
R2_PRIVATE_BUCKET=audio-private-dev # for supporter-gated content
56
57
R2_IMAGE_BUCKET=images-dev
57
58
R2_ENDPOINT_URL=<your-r2-endpoint>
58
59
R2_PUBLIC_BUCKET_URL=<your-r2-public-url>
+112
docs/research/2025-12-19-beartype.md
+112
docs/research/2025-12-19-beartype.md
···
1
+
# research: beartype runtime type checking
2
+
3
+
**date**: 2025-12-19
4
+
**question**: investigate beartype for runtime type checking, determine how to integrate into plyr.fm
5
+
6
+
## summary
7
+
8
+
beartype is a runtime type checker that validates Python type hints at execution time with O(1) worst-case performance. it's already a transitive dependency via `py-key-value-aio`. FastMCP does **not** use beartype. integration would require adding `beartype_this_package()` to `backend/src/backend/__init__.py`.
9
+
10
+
## findings
11
+
12
+
### what beartype does
13
+
14
+
- validates type hints at runtime when functions are called
15
+
- O(1) non-amortized worst-case time (constant time regardless of data structure size)
16
+
- zero runtime dependencies, pure Python
17
+
- MIT license
18
+
19
+
### key integration patterns
20
+
21
+
**package-wide (recommended)**:
22
+
```python
23
+
# At the very top of backend/src/backend/__init__.py
24
+
from beartype.claw import beartype_this_package
25
+
beartype_this_package() # enables type-checking for all submodules
26
+
```
27
+
28
+
**per-function**:
29
+
```python
30
+
from beartype import beartype
31
+
32
+
@beartype
33
+
def my_function(x: int) -> str:
34
+
return str(x)
35
+
```
36
+
37
+
### configuration options (`BeartypeConf`)
38
+
39
+
key parameters:
40
+
- `violation_type` - exception class to raise (default: `BeartypeCallHintViolation`)
41
+
- `violation_param_type` - exception for parameter violations
42
+
- `violation_return_type` - exception for return type violations
43
+
- `strategy` - checking strategy (default: `O1` for O(1) time)
44
+
- `is_debug` - enable debugging output
45
+
- `claw_skip_package_names` - packages to exclude from type checking
46
+
47
+
**example with warnings for third-party code**:
48
+
```python
49
+
from beartype import BeartypeConf
50
+
from beartype.claw import beartype_all, beartype_this_package
51
+
52
+
beartype_this_package() # strict for our code
53
+
beartype_all(conf=BeartypeConf(violation_type=UserWarning)) # warn for third-party
54
+
```
55
+
56
+
### current state in plyr.fm
57
+
58
+
beartype is already installed as a transitive dependency:
59
+
- `backend/uv.lock:477-482` - beartype 0.22.8 present
60
+
- pulled in by `py-key-value-aio` and `py-key-value-shared`
61
+
62
+
### FastMCP status
63
+
64
+
FastMCP does **not** use beartype:
65
+
- not in FastMCP's dependencies
66
+
- FastMCP uses type hints for schema generation/documentation, not runtime validation
67
+
68
+
### integration approach for plyr.fm
69
+
70
+
1. **add explicit dependency** (optional but good for clarity):
71
+
```toml
72
+
# pyproject.toml
73
+
dependencies = [
74
+
"beartype>=0.22.0",
75
+
# ... existing deps
76
+
]
77
+
```
78
+
79
+
2. **enable in `__init__.py`**:
80
+
```python
81
+
# backend/src/backend/__init__.py
82
+
from beartype.claw import beartype_this_package
83
+
beartype_this_package()
84
+
85
+
def hello() -> str:
86
+
return "Hello from backend!"
87
+
```
88
+
89
+
3. **considerations**:
90
+
- must be called before importing any submodules
91
+
- main.py currently imports warnings before filtering, then imports submodules
92
+
- beartype should be activated in `__init__.py`, not `main.py`
93
+
94
+
### potential concerns
95
+
96
+
1. **performance**: O(1) guarantees should be fine, but worth benchmarking
97
+
2. **third-party compatibility**: some libraries may have inaccurate type hints; use `claw_skip_package_names` or warn mode
98
+
3. **FastAPI**: pydantic already validates request/response types; beartype adds internal function validation
99
+
100
+
## code references
101
+
102
+
- `backend/uv.lock:477-482` - beartype 0.22.8 in lockfile
103
+
- `backend/uv.lock:2240` - py-key-value-aio depends on beartype
104
+
- `backend/uv.lock:2261` - py-key-value-shared depends on beartype
105
+
- `backend/src/backend/__init__.py:1-2` - current init (needs modification)
106
+
- `backend/src/backend/main.py:1-50` - app initialization (imports after warnings filter)
107
+
108
+
## open questions
109
+
110
+
- should we enable strict mode (exceptions) or warning mode initially?
111
+
- which third-party packages might have problematic type hints to skip?
112
+
- should we benchmark API response times before/after enabling?
+259
docs/research/2025-12-20-atprotofans-paywall-integration.md
+259
docs/research/2025-12-20-atprotofans-paywall-integration.md
···
1
+
# research: atprotofans paywall integration
2
+
3
+
**date**: 2025-12-20
4
+
**question**: how should plyr.fm integrate with atprotofans to enable supporter-gated content?
5
+
6
+
## summary
7
+
8
+
atprotofans provides a creator support platform on ATProto. plyr.fm currently has basic support link integration (#562). The full platform integration model allows defining support tiers with metadata that round-trips through validation, enabling feature gating. Implementation should proceed in phases: read-only badge display first, then platform registration, then content gating.
9
+
10
+
## current integration
11
+
12
+
from PR #562, plyr.fm has:
13
+
- support link mode selector in portal: none / atprotofans / custom
14
+
- eligibility check queries user's PDS for `com.atprotofans.profile/self` record
15
+
- profile page shows support button linking to `atprotofans.com/u/{did}`
16
+
17
+
**code locations:**
18
+
- `frontend/src/routes/portal/+page.svelte:137-166` - eligibility check
19
+
- `frontend/src/routes/u/[handle]/+page.svelte:38-44` - support URL derivation
20
+
- `backend/src/backend/api/preferences.py` - support_url validation
21
+
22
+
## atprotofans API
23
+
24
+
### validated endpoints
25
+
26
+
**GET `/xrpc/com.atprotofans.validateSupporter`**
27
+
28
+
validates if a user supports an artist.
29
+
30
+
```
31
+
params:
32
+
supporter: did (the visitor)
33
+
subject: did (the artist)
34
+
signer: did (the broker/platform that signed the support template)
35
+
36
+
response (not a supporter):
37
+
{"valid": false}
38
+
39
+
response (is a supporter):
40
+
{
41
+
"valid": true,
42
+
"profile": {
43
+
"did": "did:plc:...",
44
+
"handle": "supporter.bsky.social",
45
+
"displayName": "Supporter Name",
46
+
...metadata from support template
47
+
}
48
+
}
49
+
```
50
+
51
+
**key insight**: the `metadata` field from the support template is returned in the validation response. this enables plyr.fm to define packages and check them at runtime.
52
+
53
+
### platform integration flow
54
+
55
+
from issue #564:
56
+
57
+
```
58
+
1. plyr.fm registers as platform with did:web:plyr.fm
59
+
60
+
2. artist creates support template from portal:
61
+
POST /xrpc/com.atprotofans.proposeSupportTemplate
62
+
{
63
+
"platform": "did:web:plyr.fm",
64
+
"beneficiary": "{artist_did}",
65
+
"billingCycle": "monthly",
66
+
"minAmount": 1000, // cents
67
+
"fees": {"platform": "5percent"},
68
+
"metadata": {"package": "early-access", "source": "plyr.fm"}
69
+
}
70
+
โ returns template_id
71
+
72
+
3. artist approves template on atprotofans.com
73
+
74
+
4. supporter visits atprotofans.com/support/{template_id}
75
+
โ pays, support record created with metadata
76
+
77
+
5. plyr.fm calls validateSupporter, gets metadata back
78
+
โ unlocks features based on package
79
+
```
80
+
81
+
## proposed tier system
82
+
83
+
| package | price | what supporter gets |
84
+
|---------|-------|---------------------|
85
+
| `supporter` | $5 one-time | badge on profile, listed in supporters |
86
+
| `early-access` | $10/mo | new releases 1 week early |
87
+
| `lossless` | $15/mo | access to FLAC/WAV downloads |
88
+
| `superfan` | $25/mo | all above + exclusive tracks |
89
+
90
+
artists would choose which tiers to offer. supporters select tier on atprotofans. plyr.fm validates and gates accordingly.
91
+
92
+
## implementation phases
93
+
94
+
### phase 1: read-only validation (week 1) - IMPLEMENTED
95
+
96
+
**goal**: show supporter badges, no platform registration required
97
+
98
+
**status**: completed 2025-12-20
99
+
100
+
1. **add validateSupporter calls to artist page** โ
101
+
```typescript
102
+
// when viewing artist page, if viewer is logged in:
103
+
const ATPROTOFANS_BROKER_DID = 'did:plc:7ewx3bksukdk6a4vycoykhhw';
104
+
const url = new URL('https://atprotofans.com/xrpc/com.atprotofans.validateSupporter');
105
+
url.searchParams.set('supporter', auth.user.did);
106
+
url.searchParams.set('subject', artist.did);
107
+
url.searchParams.set('signer', ATPROTOFANS_BROKER_DID);
108
+
109
+
const response = await fetch(url.toString());
110
+
if (response.ok) {
111
+
const data = await response.json();
112
+
isSupporter = data.valid === true;
113
+
}
114
+
```
115
+
116
+
2. **cache validation results** - deferred
117
+
- frontend calls atprotofans directly (no backend cache needed initially)
118
+
- can add redis cache later if rate limiting becomes an issue
119
+
120
+
3. **display supporter badge on profile** โ
121
+
- heart icon with "supporter" label
122
+
- tooltip: "you support this artist via atprotofans"
123
+
- only shown when logged-in viewer is a supporter
124
+
125
+
**files changed:**
126
+
- `frontend/src/routes/u/[handle]/+page.svelte` - added validation logic
127
+
- `frontend/src/lib/components/SupporterBadge.svelte` - new component
128
+
129
+
**implementation notes:**
130
+
- calls atprotofans directly from frontend (public endpoint, no auth needed)
131
+
- uses broker DID `did:plc:7ewx3bksukdk6a4vycoykhhw` as signer
132
+
- only checks if artist has `support_url: 'atprotofans'`
133
+
- doesn't show on your own profile
134
+
135
+
### phase 2: platform registration (week 2)
136
+
137
+
**goal**: let artists create plyr.fm-specific support tiers
138
+
139
+
1. **register plyr.fm as platform**
140
+
- obtain `did:web:plyr.fm` (may already have)
141
+
- register with atprotofans (talk to nick)
142
+
143
+
2. **add tier configuration to portal**
144
+
```typescript
145
+
// portal settings
146
+
let supportTiers = $state([
147
+
{ package: 'supporter', enabled: true, minAmount: 500 },
148
+
{ package: 'early-access', enabled: false, minAmount: 1000 },
149
+
]);
150
+
```
151
+
152
+
3. **create support templates on save**
153
+
- call `proposeSupportTemplate` for each enabled tier
154
+
- store template_ids in artist preferences
155
+
156
+
4. **link to support page**
157
+
- instead of `atprotofans.com/u/{did}`
158
+
- link to `atprotofans.com/support/{template_id}`
159
+
160
+
**backend changes:**
161
+
- new table: `support_templates` (artist_id, package, template_id, created_at)
162
+
- new endpoint: `POST /artists/me/support-templates`
163
+
- atprotofans API client
164
+
165
+
### phase 3: content gating (week 3+)
166
+
167
+
**goal**: restrict content access based on support tier
168
+
169
+
1. **track-level gating**
170
+
- new field: `required_support_tier` on tracks
171
+
- values: null (public), 'supporter', 'early-access', 'lossless', 'superfan'
172
+
173
+
2. **validation on play/download**
174
+
```python
175
+
async def check_access(track: Track, viewer_did: str) -> bool:
176
+
if not track.required_support_tier:
177
+
return True # public
178
+
179
+
validation = await atprotofans.validate_supporter(
180
+
supporter=viewer_did,
181
+
subject=track.artist_did,
182
+
signer="did:web:plyr.fm"
183
+
)
184
+
185
+
if not validation.valid:
186
+
return False
187
+
188
+
viewer_tier = validation.profile.get("metadata", {}).get("package")
189
+
return tier_includes(viewer_tier, track.required_support_tier)
190
+
```
191
+
192
+
3. **early access scheduling**
193
+
- new fields: `public_at` timestamp, `early_access_at` timestamp
194
+
- track visible to early-access supporters before public
195
+
196
+
4. **lossless file serving**
197
+
- store both lossy (mp3) and lossless (flac/wav) versions
198
+
- check tier before serving lossless
199
+
200
+
**database changes:**
201
+
- add `required_support_tier` to tracks table
202
+
- add `public_at`, `early_access_at` timestamps
203
+
204
+
**frontend changes:**
205
+
- track upload: tier selector
206
+
- track detail: locked state for non-supporters
207
+
- "become a supporter" CTA with link to atprotofans
208
+
209
+
## open questions
210
+
211
+
1. **what is the signer for existing atprotofans supporters?**
212
+
- when artist just has `support_url: 'atprotofans'` without platform registration
213
+
- likely `signer` = artist's own DID?
214
+
215
+
2. **how do we handle expired monthly subscriptions?**
216
+
- atprotofans likely returns `valid: false` for expired
217
+
- need to handle grace period for cached access?
218
+
219
+
3. **should lossless files be separate uploads or auto-transcoded?**
220
+
- current: only one audio file per track
221
+
- lossless requires either: dual upload or transcoding service
222
+
223
+
4. **what happens to gated content if artist disables tier?**
224
+
- option A: content becomes public
225
+
- option B: content stays gated, just no new supporters
226
+
- option C: error state
227
+
228
+
5. **how do we display "this content is supporter-only" without revealing what's behind it?**
229
+
- show track title/artwork but blur?
230
+
- completely hide until authenticated?
231
+
232
+
## code references
233
+
234
+
current integration:
235
+
- `frontend/src/routes/portal/+page.svelte:137-166` - atprotofans eligibility check
236
+
- `frontend/src/routes/u/[handle]/+page.svelte:38-44` - support URL handling
237
+
- `backend/src/backend/api/preferences.py` - support_url validation
238
+
239
+
## external references
240
+
241
+
- [atprotofans.com](https://atprotofans.com) - the platform
242
+
- issue #564 - platform integration proposal
243
+
- issue #562 - basic support link (merged)
244
+
- StreamPlace integration example (from nick's description in #564)
245
+
246
+
## next steps
247
+
248
+
1. **test validateSupporter with real data**
249
+
- find an artist who has atprotofans supporters
250
+
- verify response format and metadata structure
251
+
252
+
2. **talk to nick about platform registration**
253
+
- requirements for `did:web:plyr.fm`
254
+
- API authentication for `proposeSupportTemplate`
255
+
- fee structure options
256
+
257
+
3. **prototype phase 1 (badges)**
258
+
- start with frontend-only validation calls
259
+
- no backend changes needed initially
+239
docs/research/2025-12-20-moderation-architecture-overhaul.md
+239
docs/research/2025-12-20-moderation-architecture-overhaul.md
···
1
+
# research: moderation architecture overhaul
2
+
3
+
**date**: 2025-12-20
4
+
**question**: how should plyr.fm evolve its moderation architecture based on Roost Osprey and Bluesky Ozone patterns?
5
+
6
+
## summary
7
+
8
+
plyr.fm has a functional but minimal moderation system: AuDD copyright scanning + ATProto label emission. Osprey (Roost) provides a powerful rules engine for complex detection patterns, while Ozone (Bluesky) offers a mature moderation workflow UI. The recommendation is a phased approach: first consolidate the existing Rust labeler with Python moderation logic, then selectively adopt patterns from both projects.
9
+
10
+
## current plyr.fm architecture
11
+
12
+
### components
13
+
14
+
| layer | location | purpose |
15
+
|-------|----------|---------|
16
+
| moderation service | `moderation/` (Rust) | AuDD scanning, label signing, XRPC endpoints |
17
+
| backend integration | `backend/src/backend/_internal/moderation.py` | orchestrates scans, stores results, emits labels |
18
+
| moderation client | `backend/src/backend/_internal/moderation_client.py` | HTTP client with redis caching |
19
+
| background tasks | `backend/src/backend/_internal/background_tasks.py` | `sync_copyright_resolutions()` perpetual task |
20
+
| frontend | `frontend/src/lib/moderation.svelte.ts` | sensitive image state management |
21
+
22
+
### data flow
23
+
24
+
```
25
+
upload โ schedule_copyright_scan() โ docket task
26
+
โ
27
+
moderation service /scan
28
+
โ
29
+
AuDD API
30
+
โ
31
+
store in copyright_scans table
32
+
โ
33
+
if flagged โ emit_label() โ labels table (signed)
34
+
โ
35
+
frontend checks labels via redis-cached API
36
+
```
37
+
38
+
### limitations
39
+
40
+
1. **single detection type**: only copyright via AuDD fingerprinting
41
+
2. **no rules engine**: hard-coded threshold (score >= X = flagged)
42
+
3. **manual admin ui**: htmx-based but limited (no queues, no workflow states)
43
+
4. **split architecture**: sensitive images in backend, copyright labels in moderation service
44
+
5. **no audit trail**: resolutions tracked but no event sourcing
45
+
46
+
## osprey architecture (roost)
47
+
48
+
### key concepts
49
+
50
+
Osprey is a **rules engine** for real-time event processing, not just a labeler.
51
+
52
+
**core components:**
53
+
54
+
1. **SML rules language** - declarative Python subset for signal combining
55
+
```python
56
+
Spam_Rule = Rule(
57
+
when_all=[
58
+
HasLabel(entity=UserId, label='new_account'),
59
+
PostFrequency(user=UserId, window=TimeDelta(hours=1)) > 10,
60
+
],
61
+
description="High-frequency posting from new account"
62
+
)
63
+
```
64
+
65
+
2. **UDF plugin system** - extensible signals and effects
66
+
```python
67
+
@hookimpl_osprey
68
+
def register_udfs() -> Sequence[Type[UDFBase]]:
69
+
return [TextContains, AudioFingerprint, BanUser]
70
+
```
71
+
72
+
3. **stateful labels** - labels persist and are queryable in future rules
73
+
4. **batched async execution** - gevent greenlets with automatic batching
74
+
5. **output sinks** - kafka, postgres, webhooks for result distribution
75
+
76
+
### what osprey provides that plyr.fm lacks
77
+
78
+
| capability | plyr.fm | osprey |
79
+
|------------|---------|--------|
80
+
| multi-signal rules | no | yes (combine 10+ signals) |
81
+
| label persistence | yes (basic) | yes (with TTL, query) |
82
+
| rule composition | no | yes (import, require) |
83
+
| batched execution | no | yes (auto-batching UDFs) |
84
+
| investigation UI | minimal | full query interface |
85
+
| operator visibility | limited | full rule tracing |
86
+
87
+
### adoption considerations
88
+
89
+
**pros:**
90
+
- could replace hard-coded copyright threshold with configurable rules
91
+
- would enable combining signals (e.g., new account + flagged audio + no bio)
92
+
- plugin architecture aligns with plyr.fm's need for multiple moderation types
93
+
94
+
**cons:**
95
+
- heavy infrastructure (kafka, druid, postgres, redis)
96
+
- python-based (plyr.fm moderation service is Rust)
97
+
- overkill for current scale
98
+
99
+
## ozone architecture (bluesky)
100
+
101
+
### key concepts
102
+
103
+
Ozone is a **moderation workflow UI** with queue management and team coordination.
104
+
105
+
**review workflow:**
106
+
```
107
+
report received โ reviewOpen โ (escalate?) โ reviewClosed
108
+
โ
109
+
muted / appealed / takendown
110
+
```
111
+
112
+
**action types:**
113
+
- acknowledge, label, tag, mute, comment
114
+
- escalate, appeal, reverse takedown
115
+
- email (template-based)
116
+
- takedown (PDS or AppView target)
117
+
- strike (graduated enforcement)
118
+
119
+
### patterns applicable to plyr.fm
120
+
121
+
1. **queue-based review** - flagged content enters queue, moderators triage
122
+
2. **event-sourced audit trail** - every action is immutable event
123
+
3. **internal tags** - team metadata not exposed to users
124
+
4. **policy-linked actions** - associate decisions with documented policies
125
+
5. **bulk CSV import/export** - batch artist verification, label claims
126
+
6. **graduated enforcement (strikes)** - automatic actions at thresholds
127
+
7. **email templates** - DMCA notices, policy violations
128
+
129
+
### recent ozone updates (dec 2025)
130
+
131
+
from commits:
132
+
- `ae7c30b`: default to appview takedowns
133
+
- `858b6dc`: fix bulk tag operations
134
+
- `8a1f333`: age assurance events with access property
135
+
136
+
haley's team focus: making takedowns and policy association more robust.
137
+
138
+
## recommendation: phased approach
139
+
140
+
### phase 1: consolidate (week 1)
141
+
142
+
**goal**: unify moderation into single service, adopt patterns
143
+
144
+
1. **move sensitive images to moderation service** (issue #544)
145
+
- add `sensitive_images` table to moderation postgres
146
+
- add `/sensitive-images` endpoint
147
+
- update frontend to fetch from moderation service
148
+
149
+
2. **add event sourcing for audit trail**
150
+
- new `moderation_events` table: action, subject, actor, timestamp, details
151
+
- log: scans, label emissions, resolutions, sensitive flags
152
+
153
+
3. **implement negation labels on track deletion** (issue #571)
154
+
- emit `neg: true` when tracks with labels are deleted
155
+
- cleaner label state
156
+
157
+
### phase 2: rules engine (week 2)
158
+
159
+
**goal**: replace hard-coded thresholds with configurable rules
160
+
161
+
1. **add rule configuration** (can be simple JSON/YAML to start)
162
+
```yaml
163
+
rules:
164
+
copyright_violation:
165
+
when:
166
+
- audd_score >= 85
167
+
actions:
168
+
- emit_label: copyright-violation
169
+
170
+
suspicious_upload:
171
+
when:
172
+
- audd_score >= 60
173
+
- account_age_days < 7
174
+
actions:
175
+
- emit_label: needs-review
176
+
```
177
+
178
+
2. **extract UDF-like abstractions** for signals:
179
+
- `AuddScore(track_id)`
180
+
- `AccountAge(did)`
181
+
- `HasPreviousFlag(did)`
182
+
183
+
3. **add admin review queue** (borrowing from ozone patterns)
184
+
- list items by state: pending, reviewed, dismissed
185
+
- bulk actions
186
+
187
+
### phase 3: polish (week 3 if time)
188
+
189
+
**goal**: robustness and UX
190
+
191
+
1. **graduated enforcement** - track repeat offenders, auto-escalate
192
+
2. **policy association** - link decisions to documented policies
193
+
3. **email templates** - DMCA notices, takedown confirmations
194
+
195
+
## code references
196
+
197
+
current moderation code:
198
+
- `moderation/src/main.rs:70-101` - router setup
199
+
- `moderation/src/db.rs` - label storage
200
+
- `moderation/src/labels.rs` - secp256k1 signing
201
+
- `backend/src/backend/_internal/moderation.py` - scan orchestration
202
+
- `backend/src/backend/_internal/moderation_client.py` - HTTP client
203
+
- `backend/src/backend/_internal/background_tasks.py:180-220` - sync task
204
+
205
+
osprey patterns to adopt:
206
+
- `osprey_worker/src/osprey/engine/executor/executor.py` - batched execution model
207
+
- `osprey_worker/src/osprey/worker/adaptor/plugin_manager.py` - plugin hooks
208
+
- `example_plugins/register_plugins.py` - UDF registration pattern
209
+
210
+
ozone patterns to adopt:
211
+
- event-sourced moderation actions
212
+
- review state machine (open โ escalated โ closed)
213
+
- bulk workspace operations
214
+
215
+
## open questions
216
+
217
+
1. **should we rewrite moderation service in python?**
218
+
- pro: unified stack, easier to add rules engine
219
+
- con: rust is working, label signing is performance-sensitive
220
+
221
+
2. **how much of osprey do we actually need?**
222
+
- full osprey: kafka + druid + postgres + complex infra
223
+
- minimal: just the rule evaluation pattern with simple config
224
+
225
+
3. **do we need real-time event processing?**
226
+
- current: batch via docket (5-min perpetual task)
227
+
- osprey: real-time kafka streams
228
+
- likely overkill for plyr.fm scale
229
+
230
+
4. **should admin UI move to moderation service?**
231
+
- currently: htmx in rust service
232
+
- alternative: next.js like ozone, or svelte in frontend
233
+
234
+
## external references
235
+
236
+
- [Roost Osprey](https://github.com/roostorg/osprey) - rules engine
237
+
- [Bluesky Ozone](https://github.com/bluesky-social/ozone) - moderation UI
238
+
- [Roost roadmap](https://github.com/roostorg/community/blob/main/roadmap.md)
239
+
- [ATProto Label Spec](https://atproto.com/specs/label)
+288
docs/research/2025-12-20-supporter-gated-content-architecture.md
+288
docs/research/2025-12-20-supporter-gated-content-architecture.md
···
1
+
# research: supporter-gated content architecture
2
+
3
+
**date**: 2025-12-20
4
+
**question**: how do we prevent direct R2 access to paywalled audio content?
5
+
6
+
## the problem
7
+
8
+
current architecture:
9
+
```
10
+
upload โ R2 (public bucket) โ `https://pub-xxx.r2.dev/audio/{file_id}.mp3`
11
+
โ
12
+
anyone with URL can access
13
+
```
14
+
15
+
if we add supporter-gating at the API level, users can bypass it:
16
+
1. view network requests when a supporter plays a track
17
+
2. extract the R2 URL
18
+
3. share it directly (or access it themselves without being a supporter)
19
+
20
+
## solution options
21
+
22
+
### option 1: private bucket + presigned URLs
23
+
24
+
**architecture:**
25
+
```
26
+
upload โ R2 (PRIVATE bucket) โ not directly accessible
27
+
โ
28
+
backend generates presigned URL on demand
29
+
โ
30
+
supporter validated โ 1-hour presigned URL returned
31
+
not supporter โ 402 "become a supporter"
32
+
```
33
+
34
+
**how it works:**
35
+
```python
36
+
# backend/storage/r2.py
37
+
async def get_presigned_url(self, file_id: str, expires_in: int = 3600) -> str:
38
+
"""generate presigned URL for private bucket access."""
39
+
async with self.async_session.client(...) as client:
40
+
return await client.generate_presigned_url(
41
+
'get_object',
42
+
Params={'Bucket': self.private_audio_bucket, 'Key': f'audio/{file_id}.mp3'},
43
+
ExpiresIn=expires_in
44
+
)
45
+
```
46
+
47
+
**pros:**
48
+
- strong access control - no way to bypass
49
+
- URLs expire automatically
50
+
- standard S3 pattern, well-supported
51
+
52
+
**cons:**
53
+
- presigned URLs use S3 API domain (`<account>.r2.cloudflarestorage.com`), not CDN
54
+
- no Cloudflare caching (every request goes to origin)
55
+
- potentially higher latency and costs
56
+
- need separate bucket for gated content
57
+
58
+
**cost impact:**
59
+
- R2 egress is free, but no CDN caching means more origin requests
60
+
- Class A operations (PUT, LIST) cost more than Class B (GET)
61
+
62
+
### option 2: dual bucket (public + private)
63
+
64
+
**architecture:**
65
+
```
66
+
public tracks โ audio-public bucket โ direct CDN URLs
67
+
gated tracks โ audio-private bucket โ presigned URLs only
68
+
```
69
+
70
+
**upload flow:**
71
+
```python
72
+
if track.required_support_tier:
73
+
bucket = self.private_audio_bucket
74
+
else:
75
+
bucket = self.public_audio_bucket
76
+
```
77
+
78
+
**pros:**
79
+
- public content stays fast (CDN cached)
80
+
- only gated content needs presigned URLs
81
+
- gradual migration possible
82
+
83
+
**cons:**
84
+
- complexity of managing two buckets
85
+
- track tier change = file move between buckets
86
+
- still no CDN for gated content
87
+
88
+
### option 3: cloudflare access + workers (enterprise-ish)
89
+
90
+
**architecture:**
91
+
```
92
+
all audio โ R2 bucket (with custom domain)
93
+
โ
94
+
Cloudflare Worker validates JWT/supporter status
95
+
โ
96
+
pass โ serve from R2
97
+
fail โ 402 response
98
+
```
99
+
100
+
**how it works:**
101
+
- custom domain on R2 bucket (e.g., `audio.plyr.fm`)
102
+
- Cloudflare Worker intercepts requests
103
+
- worker validates supporter token from cookie/header
104
+
- if valid, proxies request to R2
105
+
106
+
**pros:**
107
+
- CDN caching works (huge for audio streaming)
108
+
- single bucket
109
+
- flexible access control
110
+
111
+
**cons:**
112
+
- requires custom domain setup
113
+
- worker invocations add latency (~1-5ms)
114
+
- more infrastructure to maintain
115
+
- Cloudflare Access (proper auth) requires Pro plan
116
+
117
+
### option 4: short-lived tokens in URL path
118
+
119
+
**architecture:**
120
+
```
121
+
backend generates: /audio/{token}/{file_id}
122
+
โ
123
+
token = sign({file_id, expires, user_did})
124
+
โ
125
+
frontend plays URL normally
126
+
โ
127
+
if token invalid/expired โ 403
128
+
```
129
+
130
+
**how it works:**
131
+
```python
132
+
# generate token
133
+
token = jwt.encode({
134
+
'file_id': file_id,
135
+
'exp': time.time() + 3600,
136
+
'sub': viewer_did
137
+
}, SECRET_KEY)
138
+
139
+
url = f"https://api.plyr.fm/audio/{token}/{file_id}"
140
+
141
+
# validate on request
142
+
@router.get("/audio/{token}/{file_id}")
143
+
async def stream_gated_audio(token: str, file_id: str):
144
+
payload = jwt.decode(token, SECRET_KEY)
145
+
if payload['file_id'] != file_id:
146
+
raise HTTPException(403)
147
+
if payload['exp'] < time.time():
148
+
raise HTTPException(403, "URL expired")
149
+
150
+
# proxy to R2 or redirect to presigned URL
151
+
return RedirectResponse(await storage.get_presigned_url(file_id))
152
+
```
153
+
154
+
**pros:**
155
+
- works with existing backend
156
+
- no new infrastructure
157
+
- token validates both file and user
158
+
159
+
**cons:**
160
+
- every request hits backend (no CDN)
161
+
- sharing URL shares access (until expiry)
162
+
- backend becomes bottleneck for streaming
163
+
164
+
## recommendation
165
+
166
+
**for MVP (phase 1-2)**: option 2 (dual bucket)
167
+
168
+
rationale:
169
+
- public content (majority) stays fast
170
+
- gated content works correctly, just slightly slower
171
+
- simple to implement with existing boto3 code
172
+
- no new infrastructure needed
173
+
174
+
**for scale (phase 3+)**: option 3 (workers)
175
+
176
+
rationale:
177
+
- CDN caching for all content
178
+
- better streaming performance
179
+
- more flexible access control
180
+
- worth the complexity at scale
181
+
182
+
## implementation plan for dual bucket
183
+
184
+
### 1. create private bucket
185
+
186
+
```bash
187
+
# create private audio bucket (no public access)
188
+
wrangler r2 bucket create audio-private-dev
189
+
wrangler r2 bucket create audio-private-staging
190
+
wrangler r2 bucket create audio-private-prod
191
+
```
192
+
193
+
### 2. add config
194
+
195
+
```python
196
+
# config.py
197
+
r2_private_audio_bucket: str = Field(
198
+
default="",
199
+
validation_alias="R2_PRIVATE_AUDIO_BUCKET",
200
+
description="R2 private bucket for supporter-gated audio",
201
+
)
202
+
```
203
+
204
+
### 3. update R2Storage
205
+
206
+
```python
207
+
# storage/r2.py
208
+
async def save_gated(self, file: BinaryIO, filename: str, ...) -> str:
209
+
"""save to private bucket for gated content."""
210
+
# same as save() but uses private_audio_bucket
211
+
212
+
async def get_presigned_url(self, file_id: str, expires_in: int = 3600) -> str:
213
+
"""generate presigned URL for private content."""
214
+
key = f"audio/{file_id}.mp3" # need extension from DB
215
+
return self.client.generate_presigned_url(
216
+
'get_object',
217
+
Params={'Bucket': self.private_audio_bucket, 'Key': key},
218
+
ExpiresIn=expires_in
219
+
)
220
+
```
221
+
222
+
### 4. update audio endpoint
223
+
224
+
```python
225
+
# api/audio.py
226
+
@router.get("/{file_id}")
227
+
async def stream_audio(file_id: str, session: Session | None = Depends(optional_auth)):
228
+
track = await get_track_by_file_id(file_id)
229
+
230
+
if track.required_support_tier:
231
+
# gated content - validate supporter status
232
+
if not session:
233
+
raise HTTPException(401, "login required")
234
+
235
+
is_supporter = await validate_supporter(
236
+
supporter=session.did,
237
+
subject=track.artist_did
238
+
)
239
+
240
+
if not is_supporter:
241
+
raise HTTPException(402, "supporter access required")
242
+
243
+
# return presigned URL for private bucket
244
+
url = await storage.get_presigned_url(file_id)
245
+
return RedirectResponse(url)
246
+
247
+
# public content - use CDN URL
248
+
return RedirectResponse(track.r2_url)
249
+
```
250
+
251
+
### 5. upload flow change
252
+
253
+
```python
254
+
# api/tracks.py (in upload handler)
255
+
if required_support_tier:
256
+
file_id = await storage.save_gated(file, filename)
257
+
# no public URL - will be generated on demand
258
+
r2_url = None
259
+
else:
260
+
file_id = await storage.save(file, filename)
261
+
r2_url = f"{settings.storage.r2_public_bucket_url}/audio/{file_id}{ext}"
262
+
```
263
+
264
+
## open questions
265
+
266
+
1. **what about tier changes?**
267
+
- if artist makes public track โ gated: need to move file to private bucket
268
+
- if gated โ public: move to public bucket
269
+
- or: store everything in private, just serve presigned URLs for everything (simpler but slower)
270
+
271
+
2. **presigned URL expiry for long audio?**
272
+
- 1 hour default should be plenty for any track
273
+
- frontend can request new URL if needed mid-playback
274
+
275
+
3. **should we cache presigned URLs?**
276
+
- could cache for 30 minutes to reduce generation overhead
277
+
- but then revocation is delayed
278
+
279
+
4. **offline mode interaction?**
280
+
- supporters could download gated tracks for offline
281
+
- presigned URL works for initial download
282
+
- cached locally, no expiry concern
283
+
284
+
## references
285
+
286
+
- [Cloudflare R2 presigned URLs docs](https://developers.cloudflare.com/r2/api/s3/presigned-urls/)
287
+
- [Cloudflare R2 + Access protection](https://developers.cloudflare.com/r2/tutorials/cloudflare-access/)
288
+
- boto3 `generate_presigned_url()` - already available in our client
+53
docs/security.md
+53
docs/security.md
···
24
24
* **`X-XSS-Protection: 1; mode=block`:** Enables browser cross-site scripting filters.
25
25
* **`Referrer-Policy: strict-origin-when-cross-origin`:** Controls how much referrer information is included with requests.
26
26
27
+
## Supporter-Gated Content
28
+
29
+
Tracks with `support_gate` set require atprotofans supporter validation before streaming.
30
+
31
+
### Access Model
32
+
33
+
```
34
+
request โ /audio/{file_id} โ check support_gate
35
+
โ
36
+
โโโโโโโโโโโโดโโโโโโโโโโโ
37
+
โ โ
38
+
public gated track
39
+
โ โ
40
+
307 โ R2 CDN validate_supporter()
41
+
โ
42
+
โโโโโโโโโโโโดโโโโโโโโโโโ
43
+
โ โ
44
+
is supporter not supporter
45
+
โ โ
46
+
presigned URL (5min) 402 error
47
+
```
48
+
49
+
### Storage Architecture
50
+
51
+
- **public bucket**: `plyr-audio` - CDN-backed, public read access
52
+
- **private bucket**: `plyr-audio-private` - no public access, presigned URLs only
53
+
54
+
when `support_gate` is toggled, a background task moves the file between buckets.
55
+
56
+
### Presigned URL Behavior
57
+
58
+
presigned URLs are time-limited (5 minutes) and grant direct R2 access. security considerations:
59
+
60
+
1. **URL sharing**: a supporter could share the presigned URL. mitigation: short TTL, URLs expire quickly.
61
+
62
+
2. **offline caching**: if a supporter downloads content (via "download liked tracks"), the cached audio persists locally even if support lapses. this is **intentional** - they legitimately accessed it when authorized.
63
+
64
+
3. **auto-download + gated tracks**: the `gated` field is viewer-resolved (true = no access, false = has access). when liking a track with auto-download enabled:
65
+
- **supporters** (`gated === false`): download proceeds normally via presigned URL
66
+
- **non-supporters** (`gated === true`): download is skipped client-side to avoid wasted 402 requests
67
+
68
+
### ATProto Record Behavior
69
+
70
+
when a track is gated, the ATProto `fm.plyr.track` record's `audioUrl` changes:
71
+
- **public**: points to R2 CDN URL (e.g., `https://cdn.plyr.fm/audio/abc123.mp3`)
72
+
- **gated**: points to API endpoint (e.g., `https://api.plyr.fm/audio/abc123`)
73
+
74
+
this means ATProto clients cannot stream gated content without authentication through plyr.fm's API.
75
+
76
+
### Validation Caching
77
+
78
+
currently, `validate_supporter()` makes a fresh call to atprotofans on every request. for high-traffic gated tracks, consider adding a short TTL cache (e.g., 60s in redis) to reduce latency and avoid rate limits.
79
+
27
80
## CORS
28
81
29
82
Cross-Origin Resource Sharing (CORS) is configured to allow:
+1
frontend/CLAUDE.md
+1
frontend/CLAUDE.md
···
6
6
- **state**: global managers in `lib/*.svelte.ts` using `$state` runes (player, queue, uploader, tracks cache)
7
7
- **components**: reusable ui in `lib/components/` (LikeButton, Toast, Player, etc)
8
8
- **routes**: pages in `routes/` with `+page.svelte` and `+page.ts` for data loading
9
+
- **design tokens**: use CSS variables from `+layout.svelte` - never hardcode colors, radii, or font sizes (see `docs/frontend/design-tokens.md`)
9
10
10
11
gotchas:
11
12
- **svelte 5 runes mode**: component-local state MUST use `$state()` - plain `let` has no reactivity (see `docs/frontend/state-management.md`)
+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 {
+7
-9
frontend/src/lib/components/LikeButton.svelte
+7
-9
frontend/src/lib/components/LikeButton.svelte
···
6
6
trackId: number;
7
7
trackTitle: string;
8
8
fileId?: string;
9
+
gated?: boolean;
9
10
initialLiked?: boolean;
10
11
disabled?: boolean;
11
12
disabledReason?: string;
12
13
onLikeChange?: (_liked: boolean) => void;
13
14
}
14
15
15
-
let { trackId, trackTitle, fileId, initialLiked = false, disabled = false, disabledReason, onLikeChange }: Props = $props();
16
+
let { trackId, trackTitle, fileId, gated, initialLiked = false, disabled = false, disabledReason, onLikeChange }: Props = $props();
16
17
17
-
let liked = $state(initialLiked);
18
+
// use overridable $derived (Svelte 5.25+) - syncs with prop but can be overridden for optimistic UI
19
+
let liked = $derived(initialLiked);
18
20
let loading = $state(false);
19
21
20
-
// update liked state when initialLiked changes
21
-
$effect(() => {
22
-
liked = initialLiked;
23
-
});
24
-
25
22
async function toggleLike(e: Event) {
26
23
e.stopPropagation();
27
24
···
35
32
36
33
try {
37
34
const success = liked
38
-
? await likeTrack(trackId, fileId)
35
+
? await likeTrack(trackId, fileId, gated)
39
36
: await unlikeTrack(trackId);
40
37
41
38
if (!success) {
···
70
67
class:disabled-state={disabled}
71
68
onclick={toggleLike}
72
69
title={disabled && disabledReason ? disabledReason : (liked ? 'unlike' : 'like')}
70
+
aria-label={disabled && disabledReason ? disabledReason : (liked ? 'unlike' : 'like')}
73
71
disabled={loading || disabled}
74
72
>
75
73
<svg width="16" height="16" viewBox="0 0 24 24" fill={liked ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
···
86
84
justify-content: center;
87
85
background: transparent;
88
86
border: 1px solid var(--border-default);
89
-
border-radius: 4px;
87
+
border-radius: var(--radius-sm);
90
88
color: var(--text-tertiary);
91
89
cursor: pointer;
92
90
transition: all 0.2s;
+9
-9
frontend/src/lib/components/LikersTooltip.svelte
+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,
+151
-70
frontend/src/lib/components/TrackItem.svelte
+151
-70
frontend/src/lib/components/TrackItem.svelte
···
7
7
import type { Track } from '$lib/types';
8
8
import { queue } from '$lib/queue.svelte';
9
9
import { toast } from '$lib/toast.svelte';
10
+
import { playTrack, guardGatedTrack } from '$lib/playback.svelte';
10
11
11
12
interface Props {
12
13
track: Track;
···
37
38
const imageFetchPriority = index < 2 ? 'high' : undefined;
38
39
39
40
let showLikersTooltip = $state(false);
40
-
let likeCount = $state(track.like_count || 0);
41
-
let commentCount = $state(track.comment_count || 0);
41
+
// use overridable $derived (Svelte 5.25+) - syncs with prop but can be overridden for optimistic UI
42
+
let likeCount = $derived(track.like_count || 0);
43
+
let commentCount = $derived(track.comment_count || 0);
44
+
// local UI state keyed by track.id - reset when track changes (component recycling)
42
45
let trackImageError = $state(false);
43
46
let avatarError = $state(false);
44
47
let tagsExpanded = $state(false);
48
+
let prevTrackId: number | undefined;
49
+
50
+
// reset local UI state when track changes (component may be recycled)
51
+
// using $effect.pre so state is ready before render
52
+
$effect.pre(() => {
53
+
if (prevTrackId !== undefined && track.id !== prevTrackId) {
54
+
trackImageError = false;
55
+
avatarError = false;
56
+
tagsExpanded = false;
57
+
}
58
+
prevTrackId = track.id;
59
+
});
45
60
46
61
// limit visible tags to prevent vertical sprawl (max 2 shown)
47
62
const MAX_VISIBLE_TAGS = 2;
···
52
67
(track.tags?.length || 0) - MAX_VISIBLE_TAGS
53
68
);
54
69
55
-
// sync counts when track changes
56
-
$effect(() => {
57
-
likeCount = track.like_count || 0;
58
-
commentCount = track.comment_count || 0;
59
-
// reset error states when track changes (e.g. recycled component)
60
-
trackImageError = false;
61
-
avatarError = false;
62
-
tagsExpanded = false;
63
-
});
64
-
65
70
// construct shareable URL - use /track/[id] for link previews
66
71
// the track page will redirect to home with query param for actual playback
67
72
const shareUrl = typeof window !== 'undefined'
···
70
75
71
76
function addToQueue(e: Event) {
72
77
e.stopPropagation();
78
+
if (!guardGatedTrack(track, isAuthenticated)) return;
73
79
queue.addTracks([track]);
74
80
toast.success(`queued ${track.title}`, 1800);
75
81
}
76
82
77
83
function handleQueue() {
84
+
if (!guardGatedTrack(track, isAuthenticated)) return;
78
85
queue.addTracks([track]);
79
86
toast.success(`queued ${track.title}`, 1800);
80
87
}
···
126
133
{/if}
127
134
<button
128
135
class="track"
129
-
onclick={(e) => {
136
+
onclick={async (e) => {
130
137
// only play if clicking the track itself, not a link inside
131
138
if (e.target instanceof HTMLAnchorElement || (e.target as HTMLElement).closest('a')) {
132
139
return;
133
140
}
134
-
onPlay(track);
141
+
// use playTrack for gated content checks, fall back to onPlay for non-gated
142
+
if (track.gated) {
143
+
await playTrack(track);
144
+
} else {
145
+
onPlay(track);
146
+
}
135
147
}}
136
148
>
137
-
{#if track.image_url && !trackImageError}
138
-
<SensitiveImage src={track.image_url}>
139
-
<div class="track-image">
140
-
<img
141
-
src={track.image_url}
142
-
alt="{track.title} artwork"
143
-
width="48"
144
-
height="48"
145
-
loading={imageLoading}
146
-
fetchpriority={imageFetchPriority}
147
-
onerror={() => trackImageError = true}
148
-
/>
149
+
<div class="track-image-wrapper" class:gated={track.gated}>
150
+
{#if track.image_url && !trackImageError}
151
+
<SensitiveImage src={track.image_url}>
152
+
<div class="track-image">
153
+
<img
154
+
src={track.image_url}
155
+
alt="{track.title} artwork"
156
+
width="48"
157
+
height="48"
158
+
loading={imageLoading}
159
+
fetchpriority={imageFetchPriority}
160
+
onerror={() => trackImageError = true}
161
+
/>
162
+
</div>
163
+
</SensitiveImage>
164
+
{:else if track.artist_avatar_url && !avatarError}
165
+
<SensitiveImage src={track.artist_avatar_url}>
166
+
<a
167
+
href="/u/{track.artist_handle}"
168
+
class="track-avatar"
169
+
>
170
+
<img
171
+
src={track.artist_avatar_url}
172
+
alt={track.artist}
173
+
width="48"
174
+
height="48"
175
+
loading={imageLoading}
176
+
fetchpriority={imageFetchPriority}
177
+
onerror={() => avatarError = true}
178
+
/>
179
+
</a>
180
+
</SensitiveImage>
181
+
{:else}
182
+
<div class="track-image-placeholder">
183
+
<svg width="24" height="24" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
184
+
<circle cx="8" cy="5" r="3" stroke="currentColor" stroke-width="1.5" fill="none" />
185
+
<path d="M3 14c0-2.5 2-4.5 5-4.5s5 2 5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
186
+
</svg>
187
+
</div>
188
+
{/if}
189
+
{#if track.gated}
190
+
<div class="gated-badge" title="supporters only">
191
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
192
+
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/>
193
+
</svg>
149
194
</div>
150
-
</SensitiveImage>
151
-
{:else if track.artist_avatar_url && !avatarError}
152
-
<SensitiveImage src={track.artist_avatar_url}>
153
-
<a
154
-
href="/u/{track.artist_handle}"
155
-
class="track-avatar"
156
-
>
157
-
<img
158
-
src={track.artist_avatar_url}
159
-
alt={track.artist}
160
-
width="48"
161
-
height="48"
162
-
loading={imageLoading}
163
-
fetchpriority={imageFetchPriority}
164
-
onerror={() => avatarError = true}
165
-
/>
166
-
</a>
167
-
</SensitiveImage>
168
-
{:else}
169
-
<div class="track-image-placeholder">
170
-
<svg width="24" height="24" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
171
-
<circle cx="8" cy="5" r="3" stroke="currentColor" stroke-width="1.5" fill="none" />
172
-
<path d="M3 14c0-2.5 2-4.5 5-4.5s5 2 5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
173
-
</svg>
174
-
</div>
175
-
{/if}
195
+
{/if}
196
+
</div>
176
197
<div class="track-info">
177
198
<div class="track-title">{track.title}</div>
178
199
<div class="track-metadata">
···
285
306
trackUri={track.atproto_record_uri}
286
307
trackCid={track.atproto_record_cid}
287
308
fileId={track.file_id}
309
+
gated={track.gated}
288
310
initialLiked={track.is_liked || false}
289
311
disabled={!track.atproto_record_uri}
290
312
disabledReason={!track.atproto_record_uri ? "track's record is unavailable" : undefined}
···
318
340
trackUri={track.atproto_record_uri}
319
341
trackCid={track.atproto_record_cid}
320
342
fileId={track.file_id}
343
+
gated={track.gated}
321
344
initialLiked={track.is_liked || false}
322
345
shareUrl={shareUrl}
323
346
onQueue={handleQueue}
···
336
359
gap: 0.75rem;
337
360
background: var(--track-bg, var(--bg-secondary));
338
361
border: 1px solid var(--track-border, var(--border-subtle));
339
-
border-radius: 8px;
362
+
border-radius: var(--radius-md);
340
363
padding: 1rem;
341
364
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
342
365
transition:
···
347
370
348
371
.track-index {
349
372
width: 24px;
350
-
font-size: 0.85rem;
373
+
font-size: var(--text-sm);
351
374
color: var(--text-muted);
352
375
text-align: center;
353
376
flex-shrink: 0;
···
393
416
font-family: inherit;
394
417
}
395
418
419
+
.track-image-wrapper {
420
+
position: relative;
421
+
flex-shrink: 0;
422
+
width: 48px;
423
+
height: 48px;
424
+
}
425
+
426
+
.track-image-wrapper.gated::after {
427
+
content: '';
428
+
position: absolute;
429
+
inset: 0;
430
+
background: rgba(0, 0, 0, 0.3);
431
+
border-radius: var(--radius-sm);
432
+
pointer-events: none;
433
+
}
434
+
435
+
.gated-badge {
436
+
position: absolute;
437
+
bottom: -4px;
438
+
right: -4px;
439
+
width: 18px;
440
+
height: 18px;
441
+
display: flex;
442
+
align-items: center;
443
+
justify-content: center;
444
+
background: var(--accent);
445
+
border: 2px solid var(--bg-secondary);
446
+
border-radius: var(--radius-full);
447
+
color: white;
448
+
z-index: 1;
449
+
}
450
+
396
451
.track-image,
397
452
.track-image-placeholder {
398
453
flex-shrink: 0;
···
401
456
display: flex;
402
457
align-items: center;
403
458
justify-content: center;
404
-
border-radius: 4px;
459
+
border-radius: var(--radius-sm);
405
460
overflow: hidden;
406
461
background: var(--bg-tertiary);
407
462
border: 1px solid var(--border-subtle);
···
435
490
}
436
491
437
492
.track-avatar img {
438
-
border-radius: 50%;
493
+
border-radius: var(--radius-full);
439
494
border: 2px solid var(--border-default);
440
495
transition: border-color 0.2s;
441
496
}
···
485
540
align-items: flex-start;
486
541
gap: 0.15rem;
487
542
color: var(--text-secondary);
488
-
font-size: 0.9rem;
543
+
font-size: var(--text-base);
489
544
font-family: inherit;
490
545
min-width: 0;
491
546
width: 100%;
···
505
560
506
561
.metadata-separator {
507
562
display: none;
508
-
font-size: 0.7rem;
563
+
font-size: var(--text-xs);
509
564
}
510
565
511
566
.artist-link {
···
604
659
padding: 0.1rem 0.4rem;
605
660
background: color-mix(in srgb, var(--accent) 15%, transparent);
606
661
color: var(--accent-hover);
607
-
border-radius: 3px;
608
-
font-size: 0.75rem;
662
+
border-radius: var(--radius-sm);
663
+
font-size: var(--text-xs);
609
664
font-weight: 500;
610
665
text-decoration: none;
611
666
transition: all 0.15s;
···
624
679
background: var(--bg-tertiary);
625
680
color: var(--text-muted);
626
681
border: none;
627
-
border-radius: 3px;
628
-
font-size: 0.75rem;
682
+
border-radius: var(--radius-sm);
683
+
font-size: var(--text-xs);
629
684
font-weight: 500;
630
685
font-family: inherit;
631
686
cursor: pointer;
···
640
695
}
641
696
642
697
.track-meta {
643
-
font-size: 0.8rem;
698
+
font-size: var(--text-sm);
644
699
color: var(--text-tertiary);
645
700
display: flex;
646
701
align-items: center;
···
654
709
655
710
.meta-separator {
656
711
color: var(--text-muted);
657
-
font-size: 0.7rem;
712
+
font-size: var(--text-xs);
658
713
}
659
714
660
715
.likes {
···
701
756
justify-content: center;
702
757
background: transparent;
703
758
border: 1px solid var(--border-default);
704
-
border-radius: 4px;
759
+
border-radius: var(--radius-sm);
705
760
color: var(--text-tertiary);
706
761
cursor: pointer;
707
762
transition: all 0.2s;
···
746
801
gap: 0.5rem;
747
802
}
748
803
804
+
.track-image-wrapper,
749
805
.track-image,
750
806
.track-image-placeholder,
751
807
.track-avatar {
···
753
809
height: 40px;
754
810
}
755
811
812
+
.gated-badge {
813
+
width: 16px;
814
+
height: 16px;
815
+
bottom: -3px;
816
+
right: -3px;
817
+
}
818
+
819
+
.gated-badge svg {
820
+
width: 8px;
821
+
height: 8px;
822
+
}
823
+
756
824
.track-title {
757
-
font-size: 0.9rem;
825
+
font-size: var(--text-base);
758
826
}
759
827
760
828
.track-metadata {
761
-
font-size: 0.8rem;
829
+
font-size: var(--text-sm);
762
830
gap: 0.35rem;
763
831
}
764
832
765
833
.track-meta {
766
-
font-size: 0.7rem;
834
+
font-size: var(--text-xs);
767
835
}
768
836
769
837
.track-actions {
···
786
854
padding: 0.5rem 0.65rem;
787
855
}
788
856
857
+
.track-image-wrapper,
789
858
.track-image,
790
859
.track-image-placeholder,
791
860
.track-avatar {
···
793
862
height: 36px;
794
863
}
795
864
865
+
.gated-badge {
866
+
width: 14px;
867
+
height: 14px;
868
+
bottom: -2px;
869
+
right: -2px;
870
+
}
871
+
872
+
.gated-badge svg {
873
+
width: 7px;
874
+
height: 7px;
875
+
}
876
+
796
877
.track-title {
797
-
font-size: 0.85rem;
878
+
font-size: var(--text-sm);
798
879
}
799
880
800
881
.track-metadata {
801
-
font-size: 0.75rem;
882
+
font-size: var(--text-xs);
802
883
}
803
884
804
885
.metadata-separator {
+1
-1
frontend/src/lib/components/WaveLoading.svelte
+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 {
+96
-36
frontend/src/routes/u/[handle]/+page.svelte
+96
-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
-
font-size: 0.95rem;
965
+
font-family: inherit;
966
+
font-size: var(--text-base);
911
967
cursor: pointer;
912
968
transition: all 0.2s ease;
913
969
}
···
925
981
926
982
.tracks-loading {
927
983
margin-left: 0.75rem;
928
-
font-size: 0.95rem;
984
+
font-size: var(--text-base);
929
985
color: var(--text-secondary);
930
986
font-weight: 400;
931
987
text-transform: lowercase;
···
942
998
padding: 3rem;
943
999
background: var(--bg-secondary);
944
1000
border: 1px solid var(--border-subtle);
945
-
border-radius: 8px;
1001
+
border-radius: var(--radius-md);
946
1002
}
947
1003
948
1004
.empty-message {
949
1005
color: var(--text-secondary);
950
-
font-size: 1.25rem;
1006
+
font-size: var(--text-2xl);
951
1007
margin: 0 0 0.5rem 0;
952
1008
}
953
1009
···
959
1015
.bsky-link {
960
1016
color: var(--accent);
961
1017
text-decoration: none;
962
-
font-size: 1rem;
1018
+
font-size: var(--text-lg);
963
1019
padding: 0.75rem 1.5rem;
964
1020
border: 1px solid var(--accent);
965
-
border-radius: 6px;
1021
+
border-radius: var(--radius-base);
966
1022
transition: all 0.2s;
967
1023
display: inline-block;
968
1024
}
···
1000
1056
1001
1057
.artist-info {
1002
1058
text-align: center;
1059
+
}
1060
+
1061
+
.handle-row {
1062
+
justify-content: center;
1003
1063
}
1004
1064
1005
1065
.artist-actions-desktop {
···
1012
1072
1013
1073
.support-btn {
1014
1074
height: 28px;
1015
-
font-size: 0.8rem;
1075
+
font-size: var(--text-sm);
1016
1076
padding: 0 0.6rem;
1017
1077
}
1018
1078
···
1052
1112
.album-cover-wrapper {
1053
1113
width: 56px;
1054
1114
height: 56px;
1055
-
border-radius: 4px;
1115
+
border-radius: var(--radius-sm);
1056
1116
}
1057
1117
1058
1118
.album-card-meta h3 {
1059
-
font-size: 0.95rem;
1119
+
font-size: var(--text-base);
1060
1120
margin-bottom: 0.25rem;
1061
1121
}
1062
1122
1063
1123
.album-card-meta p {
1064
-
font-size: 0.8rem;
1124
+
font-size: var(--text-sm);
1065
1125
}
1066
1126
}
1067
1127
···
1088
1148
padding: 1.25rem 1.5rem;
1089
1149
background: var(--bg-secondary);
1090
1150
border: 1px solid var(--border-subtle);
1091
-
border-radius: 10px;
1151
+
border-radius: var(--radius-md);
1092
1152
color: inherit;
1093
1153
text-decoration: none;
1094
1154
transition: transform 0.15s ease, border-color 0.15s ease;
···
1102
1162
.collection-icon {
1103
1163
width: 48px;
1104
1164
height: 48px;
1105
-
border-radius: 8px;
1165
+
border-radius: var(--radius-md);
1106
1166
display: flex;
1107
1167
align-items: center;
1108
1168
justify-content: center;
···
1133
1193
1134
1194
.collection-info h3 {
1135
1195
margin: 0 0 0.25rem 0;
1136
-
font-size: 1.1rem;
1196
+
font-size: var(--text-xl);
1137
1197
color: var(--text-primary);
1138
1198
}
1139
1199
1140
1200
.collection-info p {
1141
1201
margin: 0;
1142
-
font-size: 0.9rem;
1202
+
font-size: var(--text-base);
1143
1203
color: var(--text-tertiary);
1144
1204
}
1145
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.