+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
contents: read
12
13
jobs:
14
check:
15
name: cargo check
16
runs-on: ubuntu-latest
17
timeout-minutes: 15
18
19
strategy:
20
matrix:
21
-
service: [moderation, transcoder]
22
23
steps:
24
- uses: actions/checkout@v4
25
26
- name: install rust toolchain
27
uses: dtolnay/rust-toolchain@stable
28
29
- name: cache cargo
30
uses: Swatinem/rust-cache@v2
31
with:
32
workspaces: ${{ matrix.service }}
33
34
- name: cargo check
35
working-directory: ${{ matrix.service }}
36
run: cargo check --release
37
38
docker-build:
39
name: docker build
40
runs-on: ubuntu-latest
41
timeout-minutes: 10
42
-
needs: check
43
44
strategy:
45
matrix:
46
-
service: [moderation, transcoder]
47
48
steps:
49
- uses: actions/checkout@v4
50
51
- name: build docker image
52
working-directory: ${{ matrix.service }}
53
run: docker build -t ${{ matrix.service }}:ci-test .
···
11
contents: read
12
13
jobs:
14
+
changes:
15
+
name: detect changes
16
+
runs-on: ubuntu-latest
17
+
outputs:
18
+
moderation: ${{ steps.filter.outputs.moderation }}
19
+
transcoder: ${{ steps.filter.outputs.transcoder }}
20
+
steps:
21
+
- uses: actions/checkout@v4
22
+
- uses: dorny/paths-filter@v3
23
+
id: filter
24
+
with:
25
+
filters: |
26
+
moderation:
27
+
- 'moderation/**'
28
+
- '.github/workflows/check-rust.yml'
29
+
transcoder:
30
+
- 'transcoder/**'
31
+
- '.github/workflows/check-rust.yml'
32
+
33
check:
34
name: cargo check
35
runs-on: ubuntu-latest
36
timeout-minutes: 15
37
+
needs: changes
38
39
strategy:
40
+
fail-fast: false
41
matrix:
42
+
include:
43
+
- service: moderation
44
+
changed: ${{ needs.changes.outputs.moderation }}
45
+
- service: transcoder
46
+
changed: ${{ needs.changes.outputs.transcoder }}
47
48
steps:
49
- uses: actions/checkout@v4
50
+
if: matrix.changed == 'true'
51
52
- name: install rust toolchain
53
+
if: matrix.changed == 'true'
54
uses: dtolnay/rust-toolchain@stable
55
56
- name: cache cargo
57
+
if: matrix.changed == 'true'
58
uses: Swatinem/rust-cache@v2
59
with:
60
workspaces: ${{ matrix.service }}
61
62
- name: cargo check
63
+
if: matrix.changed == 'true'
64
working-directory: ${{ matrix.service }}
65
run: cargo check --release
66
67
+
- name: skip (no changes)
68
+
if: matrix.changed != 'true'
69
+
run: echo "skipping ${{ matrix.service }} - no changes"
70
+
71
docker-build:
72
name: docker build
73
runs-on: ubuntu-latest
74
timeout-minutes: 10
75
+
needs: [changes, check]
76
77
strategy:
78
+
fail-fast: false
79
matrix:
80
+
include:
81
+
- service: moderation
82
+
changed: ${{ needs.changes.outputs.moderation }}
83
+
- service: transcoder
84
+
changed: ${{ needs.changes.outputs.transcoder }}
85
86
steps:
87
- uses: actions/checkout@v4
88
+
if: matrix.changed == 'true'
89
90
- name: build docker image
91
+
if: matrix.changed == 'true'
92
working-directory: ${{ matrix.service }}
93
run: docker build -t ${{ matrix.service }}:ci-test .
94
+
95
+
- name: skip (no changes)
96
+
if: matrix.changed != 'true'
97
+
run: echo "skipping ${{ matrix.service }} - no changes"
+43
.github/workflows/deploy-redis.yml
+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
- 6 original artists (people uploading their own distributed music)
413
414
**documentation**: see `docs/moderation/atproto-labeler.md`
415
+
416
+
---
417
+
418
+
## Mid-December 2025 Work (Dec 8-16)
419
+
420
+
### visual customization (PRs #595-596, Dec 16)
421
+
422
+
**custom backgrounds** (PR #595):
423
+
- users can set a custom background image URL in settings with optional tiling
424
+
- new "playing artwork as background" toggle - uses current track's artwork as blurred page background
425
+
- glass effect styling for track items (translucent backgrounds, subtle shadows)
426
+
- new `ui_settings` JSONB column in preferences for extensible UI settings
427
+
428
+
**bug fix** (PR #596):
429
+
- removed 3D wheel scroll effect that was blocking like/share button clicks
430
+
- root cause: `translateZ` transforms created z-index stacking that intercepted pointer events
431
+
432
+
---
433
+
434
+
### performance & UX polish (PRs #586-593, Dec 14-15)
435
+
436
+
**performance improvements** (PRs #590-591):
437
+
- removed moderation service call from `/tracks/` listing endpoint
438
+
- removed copyright check from tag listing endpoint
439
+
- faster page loads for track feeds
440
+
441
+
**moderation agent** (PRs #586, #588):
442
+
- added moderation agent script with audit trail support
443
+
- improved moderation prompt and UI layout
444
+
445
+
**bug fixes** (PRs #589, #592, #593):
446
+
- fixed liked state display on playlist detail page
447
+
- preserved album track order during ATProto sync
448
+
- made header sticky on scroll for better mobile navigation
449
+
450
+
**iOS Safari fixes** (PRs #573-576):
451
+
- fixed AddToMenu visibility issue on iOS Safari
452
+
- menu now correctly opens upward when near viewport bottom
453
+
454
+
---
455
+
456
+
### mobile UI polish & background task expansion (PRs #558-572, Dec 10-12)
457
+
458
+
**background task expansion** (PRs #558, #561):
459
+
- moved like/unlike and comment PDS writes to docket background tasks
460
+
- API responses now immediate; PDS sync happens asynchronously
461
+
- added targeted album list sync background task for ATProto record updates
462
+
463
+
**performance caching** (PR #566):
464
+
- added Redis cache for copyright label lookups (5-minute TTL)
465
+
- fixed 2-3s latency spikes on `/tracks/` endpoint
466
+
- batch operations via `mget`/pipeline for efficiency
467
+
468
+
**mobile UX improvements** (PRs #569, #572):
469
+
- mobile action menus now open from top with all actions visible
470
+
- UI polish for album and artist pages on small screens
471
+
472
+
**misc** (PRs #559, #562, #563, #570):
473
+
- reduced docket Redis polling from 250ms to 5s (lower resource usage)
474
+
- added atprotofans support link mode for ko-fi integration
475
+
- added alpha badge to header branding
476
+
- fixed web manifest ID for PWA stability
477
+
478
+
---
479
+
480
+
### confidential OAuth client (PRs #578, #580-582, Dec 12-13)
481
+
482
+
**confidential client support** (PR #578):
483
+
- implemented ATProto OAuth confidential client using `private_key_jwt` authentication
484
+
- when `OAUTH_JWK` is configured, plyr.fm authenticates with a cryptographic key
485
+
- confidential clients earn 180-day refresh tokens (vs 2-week for public clients)
486
+
- added `/.well-known/jwks.json` endpoint for public key discovery
487
+
- updated `/oauth-client-metadata.json` with confidential client fields
488
+
489
+
**bug fixes** (PRs #580-582):
490
+
- fixed client assertion JWT to use Authorization Server's issuer as `aud` claim (not token endpoint URL)
491
+
- fixed JWKS endpoint to preserve `kid` field from original JWK
492
+
- fixed `OAuthClient` to pass `client_secret_kid` for JWT header
493
+
494
+
**atproto fork updates** (zzstoatzz/atproto#6, #7):
495
+
- added `issuer` parameter to `_make_token_request()` for correct `aud` claim
496
+
- added `client_secret_kid` parameter to include `kid` in client assertion JWT header
497
+
498
+
**outcome**: users now get 180-day refresh tokens, and "remember this account" on the PDS authorization page works (auto-approves subsequent logins). see #583 for future work on account switching via OAuth `prompt` parameter.
499
+
500
+
---
501
+
502
+
### pagination & album management (PRs #550-554, Dec 9-10)
503
+
504
+
**tracks list pagination** (PR #554):
505
+
- cursor-based pagination on `/tracks/` endpoint (default 50 per page)
506
+
- infinite scroll on homepage using native IntersectionObserver
507
+
- zero new dependencies - uses browser APIs only
508
+
- pagination state persisted to localStorage for fast subsequent loads
509
+
510
+
**album management improvements** (PRs #550-552, #557):
511
+
- album delete and track reorder fixes
512
+
- album page edit mode matching playlist UX (inline title editing, cover upload)
513
+
- optimistic UI updates for album title changes (instant feedback)
514
+
- ATProto record sync when album title changes (updates all track records + list record)
515
+
- fixed album slug sync on rename (prevented duplicate albums when adding tracks)
516
+
517
+
**playlist show on profile** (PR #553):
518
+
- restored "show on profile" toggle that was lost during inline editing refactor
519
+
- users can now control whether playlists appear on their public profile
520
+
521
+
---
522
+
523
+
### public cost dashboard (PRs #548-549, Dec 9)
524
+
525
+
- `/costs` page showing live platform infrastructure costs
526
+
- daily export to R2 via GitHub Action, proxied through `/stats/costs` endpoint
527
+
- dedicated `plyr-stats` R2 bucket with public access (shared across environments)
528
+
- includes fly.io, neon, cloudflare, and audd API costs
529
+
- ko-fi integration for community support
530
+
531
+
### docket background tasks & concurrent exports (PRs #534-546, Dec 9)
532
+
533
+
**docket integration** (PRs #534, #536, #539):
534
+
- migrated background tasks from inline asyncio to docket (Redis-backed task queue)
535
+
- copyright scanning, media export, ATProto sync, and teal scrobbling now run via docket
536
+
- graceful fallback to asyncio for local development without Redis
537
+
- parallel test execution with xdist template databases (#540)
538
+
539
+
**concurrent export downloads** (PR #545):
540
+
- exports now download tracks in parallel (up to 4 concurrent) instead of sequentially
541
+
- significantly faster for users with many tracks or large files
542
+
- zip creation remains sequential (zipfile constraint)
543
+
544
+
**ATProto refactor** (PR #534):
545
+
- reorganized ATProto record code into `_internal/atproto/records/` by lexicon namespace
546
+
- extracted `client.py` for low-level PDS operations
547
+
- cleaner separation between plyr.fm and teal.fm lexicons
548
+
549
+
**documentation & observability**:
550
+
- AudD API cost tracking dashboard (#546)
551
+
- promoted runbooks from sandbox to `docs/runbooks/`
552
+
- updated CLAUDE.md files across the codebase
553
+
554
+
---
555
+
556
+
### artist support links & inline playlist editing (PRs #520-532, Dec 8)
557
+
558
+
**artist support link** (PR #532):
559
+
- artists can set a support URL (Ko-fi, Patreon, etc.) in their portal profile
560
+
- support link displays as a button on artist profile pages next to the share button
561
+
- URLs validated to require https:// prefix
562
+
563
+
**inline playlist editing** (PR #531):
564
+
- edit playlist name and description directly on playlist detail page
565
+
- click-to-upload cover art replacement without modal
566
+
- cleaner UX - no more edit modal popup
567
+
568
+
**platform stats enhancements** (PRs #522, #528):
569
+
- total duration displayed in platform stats (e.g., "42h 15m of music")
570
+
- duration shown per artist in analytics section
571
+
- combined stats and search into single centered container for cleaner layout
572
+
573
+
**navigation & data loading fixes** (PR #527):
574
+
- fixed stale data when navigating between detail pages of the same type
575
+
- e.g., clicking from one artist to another now properly reloads data
576
+
577
+
**copyright moderation improvements** (PR #480):
578
+
- enhanced moderation workflow for copyright claims
579
+
- improved labeler integration
580
+
581
+
**status maintenance workflow** (PR #529):
582
+
- automated status maintenance using claude-code-action
583
+
- reviews merged PRs and updates STATUS.md narratively
584
+
585
+
---
586
+
587
+
### playlist fast-follow fixes (PRs #507-519, Dec 7-8)
588
+
589
+
**public playlist viewing** (PR #519):
590
+
- playlists now publicly viewable without authentication
591
+
- ATProto records are public by design - auth was unnecessary for read access
592
+
- shared playlist URLs no longer redirect unauthenticated users to homepage
593
+
594
+
**inline playlist creation** (PR #510):
595
+
- clicking "create new playlist" from AddToMenu previously navigated to `/library?create=playlist`
596
+
- this caused SvelteKit to reinitialize the layout, destroying the audio element and stopping playback
597
+
- fix: added inline create form that creates playlist and adds track in one action without navigation
598
+
599
+
**UI polish** (PRs #507-509, #515):
600
+
- include `image_url` in playlist SSR data for og:image link previews
601
+
- invalidate layout data after token exchange - fixes stale auth state after login
602
+
- fixed stopPropagation blocking "create new playlist" link clicks
603
+
- detail page button layouts: all buttons visible on mobile, centered AddToMenu on track detail
604
+
- AddToMenu smart positioning: menu opens upward when near viewport bottom
605
+
606
+
**documentation** (PR #514):
607
+
- added lexicons overview documentation at `docs/lexicons/overview.md`
608
+
- covers `fm.plyr.track`, `fm.plyr.like`, `fm.plyr.comment`, `fm.plyr.list`, `fm.plyr.actor.profile`
+7
-5
README.md
+7
-5
README.md
···
119
│ └── src/routes/ # pages
120
├── moderation/ # Rust labeler service
121
├── transcoder/ # Rust audio service
122
├── docs/ # documentation
123
└── justfile # task runner
124
```
···
128
<details>
129
<summary>costs</summary>
130
131
-
~$35-40/month:
132
-
- fly.io backend (prod + staging): ~$10/month
133
-
- fly.io transcoder: ~$0-5/month (auto-scales to zero)
134
- neon postgres: $5/month
135
-
- audd audio fingerprinting: ~$10/month
136
-
- cloudflare (pages + r2): ~$0.16/month
137
138
</details>
139
···
119
│ └── src/routes/ # pages
120
├── moderation/ # Rust labeler service
121
├── transcoder/ # Rust audio service
122
+
├── redis/ # self-hosted Redis config
123
├── docs/ # documentation
124
└── justfile # task runner
125
```
···
129
<details>
130
<summary>costs</summary>
131
132
+
~$20/month:
133
+
- fly.io (backend + redis + moderation): ~$14/month
134
- neon postgres: $5/month
135
+
- cloudflare (pages + r2): ~$1/month
136
+
- audd audio fingerprinting: $5-10/month (usage-based)
137
+
138
+
live dashboard: https://plyr.fm/costs
139
140
</details>
141
+104
-266
STATUS.md
+104
-266
STATUS.md
···
47
48
### December 2025
49
50
-
#### beartype + moderation cleanup (PRs #617-619, Dec 19)
51
52
-
**runtime type checking** (PR #619):
53
-
- enabled beartype runtime type validation across the backend
54
-
- catches type errors at runtime instead of silently passing bad data
55
-
- test infrastructure improvements: session-scoped TestClient fixture (5x faster tests)
56
-
- disabled automatic perpetual task scheduling in tests
57
58
-
**moderation cleanup** (PRs #617-618):
59
-
- consolidated moderation code, addressing issues #541-543
60
-
- `sync_copyright_resolutions` now runs automatically via docket Perpetual task
61
-
- removed `init_db()` from lifespan (handled by alembic migrations)
62
63
---
64
65
-
#### UX polish (PRs #604-607, #613, #615, Dec 16-18)
66
67
-
**login improvements** (PRs #604, #613):
68
-
- login page now uses "internet handle" terminology for clarity
69
-
- input normalization: strips `@` and `at://` prefixes automatically
70
71
-
**artist page fixes** (PR #615):
72
-
- track pagination on artist pages now works correctly
73
-
- fixed mobile album card overflow
74
75
-
**mobile + metadata** (PRs #605-607):
76
-
- Open Graph tags added to tag detail pages for link previews
77
-
- mobile modals now use full screen positioning
78
-
- fixed `/tag/` routes in hasPageMetadata check
79
-
80
-
**misc** (PRs #598-601):
81
-
- upload button added to desktop header nav
82
-
- background settings UX improvements
83
-
- switched support link to atprotofans
84
-
- AudD costs now derived from track duration for accurate billing
85
86
---
87
88
-
#### offline mode foundation (PRs #610-611, Dec 17)
89
-
90
-
**experimental offline playback**:
91
-
- new storage layer using Cache API for audio bytes + IndexedDB for metadata
92
-
- `GET /audio/{file_id}/url` backend endpoint returns direct R2 URLs for client-side caching
93
-
- "auto-download liked" toggle in experimental settings section
94
-
- when enabled, bulk-downloads all liked tracks and auto-downloads future likes
95
-
- Player checks for cached audio before streaming from R2
96
-
- works offline once tracks are downloaded
97
-
98
-
**robustness improvements**:
99
-
- IndexedDB connections properly closed after each operation
100
-
- concurrent downloads deduplicated via in-flight promise tracking
101
-
- stale metadata cleanup when cache entries are missing
102
-
103
-
---
104
-
105
-
#### visual customization (PRs #595-596, Dec 16)
106
-
107
-
**custom backgrounds** (PR #595):
108
-
- users can set a custom background image URL in settings with optional tiling
109
-
- new "playing artwork as background" toggle - uses current track's artwork as blurred page background
110
-
- glass effect styling for track items (translucent backgrounds, subtle shadows)
111
-
- new `ui_settings` JSONB column in preferences for extensible UI settings
112
113
-
**bug fix** (PR #596):
114
-
- removed 3D wheel scroll effect that was blocking like/share button clicks
115
-
- root cause: `translateZ` transforms created z-index stacking that intercepted pointer events
116
117
---
118
119
-
#### performance & UX polish (PRs #586-593, Dec 14-15)
120
-
121
-
**performance improvements** (PRs #590-591):
122
-
- removed moderation service call from `/tracks/` listing endpoint
123
-
- removed copyright check from tag listing endpoint
124
-
- faster page loads for track feeds
125
-
126
-
**moderation agent** (PRs #586, #588):
127
-
- added moderation agent script with audit trail support
128
-
- improved moderation prompt and UI layout
129
-
130
-
**bug fixes** (PRs #589, #592, #593):
131
-
- fixed liked state display on playlist detail page
132
-
- preserved album track order during ATProto sync
133
-
- made header sticky on scroll for better mobile navigation
134
-
135
-
**iOS Safari fixes** (PRs #573-576):
136
-
- fixed AddToMenu visibility issue on iOS Safari
137
-
- menu now correctly opens upward when near viewport bottom
138
-
139
-
---
140
-
141
-
#### mobile UI polish & background task expansion (PRs #558-572, Dec 10-12)
142
-
143
-
**background task expansion** (PRs #558, #561):
144
-
- moved like/unlike and comment PDS writes to docket background tasks
145
-
- API responses now immediate; PDS sync happens asynchronously
146
-
- added targeted album list sync background task for ATProto record updates
147
-
148
-
**performance caching** (PR #566):
149
-
- added Redis cache for copyright label lookups (5-minute TTL)
150
-
- fixed 2-3s latency spikes on `/tracks/` endpoint
151
-
- batch operations via `mget`/pipeline for efficiency
152
-
153
-
**mobile UX improvements** (PRs #569, #572):
154
-
- mobile action menus now open from top with all actions visible
155
-
- UI polish for album and artist pages on small screens
156
-
157
-
**misc** (PRs #559, #562, #563, #570):
158
-
- reduced docket Redis polling from 250ms to 5s (lower resource usage)
159
-
- added atprotofans support link mode for ko-fi integration
160
-
- added alpha badge to header branding
161
-
- fixed web manifest ID for PWA stability
162
-
163
-
---
164
-
165
-
#### confidential OAuth client (PRs #578, #580-582, Dec 12-13)
166
-
167
-
**confidential client support** (PR #578):
168
-
- implemented ATProto OAuth confidential client using `private_key_jwt` authentication
169
-
- when `OAUTH_JWK` is configured, plyr.fm authenticates with a cryptographic key
170
-
- confidential clients earn 180-day refresh tokens (vs 2-week for public clients)
171
-
- added `/.well-known/jwks.json` endpoint for public key discovery
172
-
- updated `/oauth-client-metadata.json` with confidential client fields
173
-
174
-
**bug fixes** (PRs #580-582):
175
-
- fixed client assertion JWT to use Authorization Server's issuer as `aud` claim (not token endpoint URL)
176
-
- fixed JWKS endpoint to preserve `kid` field from original JWK
177
-
- fixed `OAuthClient` to pass `client_secret_kid` for JWT header
178
-
179
-
**atproto fork updates** (zzstoatzz/atproto#6, #7):
180
-
- added `issuer` parameter to `_make_token_request()` for correct `aud` claim
181
-
- added `client_secret_kid` parameter to include `kid` in client assertion JWT header
182
-
183
-
**outcome**: users now get 180-day refresh tokens, and "remember this account" on the PDS authorization page works (auto-approves subsequent logins). see #583 for future work on account switching via OAuth `prompt` parameter.
184
-
185
-
---
186
-
187
-
#### pagination & album management (PRs #550-554, Dec 9-10)
188
-
189
-
**tracks list pagination** (PR #554):
190
-
- cursor-based pagination on `/tracks/` endpoint (default 50 per page)
191
-
- infinite scroll on homepage using native IntersectionObserver
192
-
- zero new dependencies - uses browser APIs only
193
-
- pagination state persisted to localStorage for fast subsequent loads
194
-
195
-
**album management improvements** (PRs #550-552, #557):
196
-
- album delete and track reorder fixes
197
-
- album page edit mode matching playlist UX (inline title editing, cover upload)
198
-
- optimistic UI updates for album title changes (instant feedback)
199
-
- ATProto record sync when album title changes (updates all track records + list record)
200
-
- fixed album slug sync on rename (prevented duplicate albums when adding tracks)
201
-
202
-
**playlist show on profile** (PR #553):
203
-
- restored "show on profile" toggle that was lost during inline editing refactor
204
-
- users can now control whether playlists appear on their public profile
205
-
206
-
---
207
-
208
-
#### public cost dashboard (PRs #548-549, Dec 9)
209
-
210
-
- `/costs` page showing live platform infrastructure costs
211
-
- daily export to R2 via GitHub Action, proxied through `/stats/costs` endpoint
212
-
- dedicated `plyr-stats` R2 bucket with public access (shared across environments)
213
-
- includes fly.io, neon, cloudflare, and audd API costs
214
-
- ko-fi integration for community support
215
-
216
-
#### docket background tasks & concurrent exports (PRs #534-546, Dec 9)
217
-
218
-
**docket integration** (PRs #534, #536, #539):
219
-
- migrated background tasks from inline asyncio to docket (Redis-backed task queue)
220
-
- copyright scanning, media export, ATProto sync, and teal scrobbling now run via docket
221
-
- graceful fallback to asyncio for local development without Redis
222
-
- parallel test execution with xdist template databases (#540)
223
-
224
-
**concurrent export downloads** (PR #545):
225
-
- exports now download tracks in parallel (up to 4 concurrent) instead of sequentially
226
-
- significantly faster for users with many tracks or large files
227
-
- zip creation remains sequential (zipfile constraint)
228
-
229
-
**ATProto refactor** (PR #534):
230
-
- reorganized ATProto record code into `_internal/atproto/records/` by lexicon namespace
231
-
- extracted `client.py` for low-level PDS operations
232
-
- cleaner separation between plyr.fm and teal.fm lexicons
233
234
-
**documentation & observability**:
235
-
- AudD API cost tracking dashboard (#546)
236
-
- promoted runbooks from sandbox to `docs/runbooks/`
237
-
- updated CLAUDE.md files across the codebase
238
239
---
240
241
-
#### artist support links & inline playlist editing (PRs #520-532, Dec 8)
242
243
-
**artist support link** (PR #532):
244
-
- artists can set a support URL (Ko-fi, Patreon, etc.) in their portal profile
245
-
- support link displays as a button on artist profile pages next to the share button
246
-
- URLs validated to require https:// prefix
247
248
-
**inline playlist editing** (PR #531):
249
-
- edit playlist name and description directly on playlist detail page
250
-
- click-to-upload cover art replacement without modal
251
-
- cleaner UX - no more edit modal popup
252
253
-
**platform stats enhancements** (PRs #522, #528):
254
-
- total duration displayed in platform stats (e.g., "42h 15m of music")
255
-
- duration shown per artist in analytics section
256
-
- combined stats and search into single centered container for cleaner layout
257
-
258
-
**navigation & data loading fixes** (PR #527):
259
-
- fixed stale data when navigating between detail pages of the same type
260
-
- e.g., clicking from one artist to another now properly reloads data
261
-
262
-
**copyright moderation improvements** (PR #480):
263
-
- enhanced moderation workflow for copyright claims
264
-
- improved labeler integration
265
-
266
-
**status maintenance workflow** (PR #529):
267
-
- automated status maintenance using claude-code-action
268
-
- reviews merged PRs and updates STATUS.md narratively
269
270
---
271
272
-
#### playlist fast-follow fixes (PRs #507-519, Dec 7-8)
273
-
274
-
**public playlist viewing** (PR #519):
275
-
- playlists now publicly viewable without authentication
276
-
- ATProto records are public by design - auth was unnecessary for read access
277
-
- shared playlist URLs no longer redirect unauthenticated users to homepage
278
279
-
**inline playlist creation** (PR #510):
280
-
- clicking "create new playlist" from AddToMenu previously navigated to `/library?create=playlist`
281
-
- this caused SvelteKit to reinitialize the layout, destroying the audio element and stopping playback
282
-
- fix: added inline create form that creates playlist and adds track in one action without navigation
283
-
284
-
**UI polish** (PRs #507-509, #515):
285
-
- include `image_url` in playlist SSR data for og:image link previews
286
-
- invalidate layout data after token exchange - fixes stale auth state after login
287
-
- fixed stopPropagation blocking "create new playlist" link clicks
288
-
- detail page button layouts: all buttons visible on mobile, centered AddToMenu on track detail
289
-
- AddToMenu smart positioning: menu opens upward when near viewport bottom
290
291
-
**documentation** (PR #514):
292
-
- added lexicons overview documentation at `docs/lexicons/overview.md`
293
-
- covers `fm.plyr.track`, `fm.plyr.like`, `fm.plyr.comment`, `fm.plyr.list`, `fm.plyr.actor.profile`
294
295
---
296
297
-
#### playlists, ATProto sync, and library hub (PR #499, Dec 6-7)
298
299
-
**playlists** (full CRUD):
300
-
- create, rename, delete playlists with cover art upload
301
-
- add/remove/reorder tracks with drag-and-drop
302
-
- playlist detail page with edit modal
303
-
- "add to playlist" menu on tracks with inline create
304
-
- playlist sharing with OpenGraph link previews
305
306
-
**ATProto integration**:
307
-
- `fm.plyr.list` lexicon for syncing playlists/albums to user PDSes
308
-
- `fm.plyr.actor.profile` lexicon for artist profiles
309
-
- automatic sync of albums, liked tracks, profile on login
310
311
-
**library hub** (`/library`):
312
-
- unified page with tabs: liked, playlists, albums
313
-
- nav changed from "liked" → "library"
314
-
315
-
**related**: scope upgrade OAuth flow (PR #503), settings consolidation (PR #496)
316
317
---
318
319
-
#### sensitive image moderation (PRs #471-488, Dec 5-6)
320
321
-
- `sensitive_images` table flags problematic images
322
-
- `show_sensitive_artwork` user preference
323
-
- flagged images blurred everywhere: track lists, player, artist pages, search, embeds
324
-
- Media Session API respects sensitive preference
325
-
- SSR-safe filtering for og:image link previews
326
327
---
328
329
-
#### teal.fm scrobbling (PR #467, Dec 4)
330
331
-
- native scrobbling to user's PDS using teal's ATProto lexicons
332
-
- scrobble at 30% or 30 seconds (same threshold as play counts)
333
-
- toggle in settings, link to pdsls.dev to view records
334
-
335
-
---
336
337
-
### Earlier December / November 2025
338
339
-
See `.status_history/2025-12.md` and `.status_history/2025-11.md` for detailed history including:
340
-
- unified search with Cmd+K (PR #447)
341
-
- light/dark theme system (PR #441)
342
-
- tag filtering and bufo easter egg (PRs #431-438)
343
- developer tokens (PR #367)
344
- copyright moderation system (PRs #382-395)
345
- export & upload reliability (PRs #337-344)
···
347
348
## immediate priorities
349
350
### known issues
351
- playback auto-start on refresh (#225)
352
- iOS PWA audio may hang on first play after backgrounding
353
354
-
### immediate focus
355
-
- **moderation cleanup**: consolidate copyright detection, reduce AudD API costs, streamline labeler integration (issues #541-544)
356
-
357
-
### feature ideas
358
-
- issue #334: add 'share to bluesky' option for tracks
359
-
- issue #373: lyrics field and Genius-style annotations
360
-
361
### backlog
362
- audio transcoding pipeline integration (#153) - transcoder service deployed, integration deferred
363
364
## technical state
365
···
405
- ✅ copyright moderation with ATProto labeler
406
- ✅ docket background tasks (copyright scan, export, atproto sync, scrobble)
407
- ✅ media export with concurrent downloads
408
409
**albums**
410
- ✅ album CRUD with cover art
···
436
437
## cost structure
438
439
-
current monthly costs: ~$18/month (plyr.fm specific)
440
441
see live dashboard: [plyr.fm/costs](https://plyr.fm/costs)
442
443
-
- fly.io (plyr apps only): ~$12/month
444
-
- relay-api (prod): $5.80
445
-
- relay-api-staging: $5.60
446
-
- plyr-moderation: $0.24
447
-
- plyr-transcoder: $0.02
448
- neon postgres: $5/month
449
-
- cloudflare (R2 + pages + domain): ~$1.16/month
450
-
- audd audio fingerprinting: $0-10/month (6000 free/month)
451
- logfire: $0 (free tier)
452
453
## admin tooling
···
498
│ └── src/routes/ # pages
499
├── moderation/ # Rust moderation service (ATProto labeler)
500
├── transcoder/ # Rust audio transcoding service
501
├── docs/ # documentation
502
└── justfile # task runner
503
```
···
513
514
---
515
516
-
this is a living document. last updated 2025-12-19.
···
47
48
### December 2025
49
50
+
#### self-hosted redis (PR #674-675, Dec 30)
51
52
+
**replaced Upstash with self-hosted Redis on Fly.io** - ~$75/month → ~$4/month:
53
+
- Upstash pay-as-you-go was charging per command (37M commands = $75)
54
+
- self-hosted Redis on 256MB Fly VMs costs fixed ~$2/month per environment
55
+
- deployed `plyr-redis` (prod) and `plyr-redis-stg` (staging)
56
+
- added CI workflow for redis deployments on merge
57
58
+
**no state migration needed** - docket stores ephemeral task queue data, job progress lives in postgres.
59
60
---
61
62
+
#### supporter-gated content (PR #637, Dec 22-23)
63
64
+
**atprotofans paywall integration** - artists can now mark tracks as "supporters only":
65
+
- tracks with `support_gate` require atprotofans validation before playback
66
+
- non-supporters see lock icon and "become a supporter" CTA linking to atprotofans
67
+
- artists can always play their own gated tracks
68
69
+
**backend architecture**:
70
+
- audio endpoint validates supporter status via atprotofans API before serving gated content
71
+
- HEAD requests return 200/401/402 for pre-flight auth checks (avoids CORS issues)
72
+
- `R2Storage.move_audio()` moves files between public/private buckets when toggling gate
73
+
- background task handles bucket migration asynchronously
74
+
- ATProto record syncs when toggling gate (updates `supportGate` field and `audioUrl`)
75
76
+
**frontend**:
77
+
- `playback.svelte.ts` guards queue operations with gated checks BEFORE modifying state
78
+
- clicking locked track shows toast with CTA - does NOT interrupt current playback
79
+
- portal shows support gate toggle in track edit UI
80
81
---
82
83
+
#### supporter badges (PR #627, Dec 21-22)
84
85
+
**phase 1 of atprotofans integration**:
86
+
- supporter badge displays on artist pages when logged-in viewer supports the artist
87
+
- calls atprotofans `validateSupporter` API directly from frontend (public endpoint)
88
+
- badge only shows when viewer is authenticated and not viewing their own profile
89
90
---
91
92
+
#### rate limit moderation endpoint (PR #629, Dec 21)
93
94
+
**incident response**: detected suspicious activity - 72 requests in 17 seconds from a single IP targeting `/moderation/sensitive-images`. added `10/minute` rate limit using existing slowapi infrastructure.
95
96
---
97
98
+
#### end-of-year sprint planning (PR #626, Dec 20)
99
100
+
**focus**: two foundational systems need solid experimental implementations by 2026.
101
102
+
| track | focus | status |
103
+
|-------|-------|--------|
104
+
| moderation | consolidate architecture, add rules engine | in progress |
105
+
| atprotofans | supporter validation, content gating | shipped (phase 1-3) |
106
107
+
**research docs**:
108
+
- [moderation architecture overhaul](docs/research/2025-12-20-moderation-architecture-overhaul.md)
109
+
- [atprotofans paywall integration](docs/research/2025-12-20-atprotofans-paywall-integration.md)
110
111
---
112
113
+
#### beartype + moderation cleanup (PRs #617-619, Dec 19)
114
115
+
**runtime type checking** (PR #619):
116
+
- enabled beartype runtime type validation across the backend
117
+
- catches type errors at runtime instead of silently passing bad data
118
+
- test infrastructure improvements: session-scoped TestClient fixture (5x faster tests)
119
120
+
**moderation cleanup** (PRs #617-618):
121
+
- consolidated moderation code, addressing issues #541-543
122
+
- `sync_copyright_resolutions` now runs automatically via docket Perpetual task
123
+
- removed dead `init_db()` from lifespan (handled by alembic migrations)
124
125
---
126
127
+
#### UX polish (PRs #604-607, #613, #615, Dec 16-18)
128
129
+
**login improvements** (PRs #604, #613):
130
+
- login page now uses "internet handle" terminology for clarity
131
+
- input normalization: strips `@` and `at://` prefixes automatically
132
133
+
**artist page fixes** (PR #615):
134
+
- track pagination on artist pages now works correctly
135
+
- fixed mobile album card overflow
136
137
+
**mobile + metadata** (PRs #605-607):
138
+
- Open Graph tags added to tag detail pages for link previews
139
+
- mobile modals now use full screen positioning
140
+
- fixed `/tag/` routes in hasPageMetadata check
141
142
---
143
144
+
#### offline mode foundation (PRs #610-611, Dec 17)
145
146
+
**experimental offline playback**:
147
+
- storage layer using Cache API for audio bytes + IndexedDB for metadata
148
+
- `GET /audio/{file_id}/url` backend endpoint returns direct R2 URLs for client-side caching
149
+
- "auto-download liked" toggle in experimental settings section
150
+
- Player checks for cached audio before streaming from R2
151
152
---
153
154
+
### Earlier December 2025
155
156
+
See `.status_history/2025-12.md` for detailed history including:
157
+
- visual customization with custom backgrounds (PRs #595-596, Dec 16)
158
+
- performance & moderation polish (PRs #586-593, Dec 14-15)
159
+
- mobile UI polish & background task expansion (PRs #558-572, Dec 10-12)
160
+
- confidential OAuth client for 180-day sessions (PRs #578-582, Dec 12-13)
161
+
- pagination & album management (PRs #550-554, Dec 9-10)
162
+
- public cost dashboard (PRs #548-549, Dec 9)
163
+
- docket background tasks & concurrent exports (PRs #534-546, Dec 9)
164
+
- artist support links & inline playlist editing (PRs #520-532, Dec 8)
165
+
- playlist fast-follow fixes (PRs #507-519, Dec 7-8)
166
+
- playlists, ATProto sync, and library hub (PR #499, Dec 6-7)
167
+
- sensitive image moderation (PRs #471-488, Dec 5-6)
168
+
- teal.fm scrobbling (PR #467, Dec 4)
169
+
- unified search with Cmd+K (PR #447, Dec 3)
170
+
- light/dark theme system (PR #441, Dec 2-3)
171
+
- tag filtering and bufo easter egg (PRs #431-438, Dec 2)
172
173
+
### November 2025
174
175
+
See `.status_history/2025-11.md` for detailed history including:
176
- developer tokens (PR #367)
177
- copyright moderation system (PRs #382-395)
178
- export & upload reliability (PRs #337-344)
···
180
181
## immediate priorities
182
183
+
### quality of life mode (Dec 29-31)
184
+
185
+
end-of-year sprint [#625](https://github.com/zzstoatzz/plyr.fm/issues/625) complete. remaining days before 2026 are for minor polish and bug fixes as they arise.
186
+
187
+
**what shipped in the sprint:**
188
+
- moderation consolidation: sensitive images moved to moderation service (#644)
189
+
- atprotofans: supporter badges (#627) and content gating (#637)
190
+
191
+
**aspirational (deferred until scale justifies):**
192
+
- configurable rules engine for moderation
193
+
- time-release gating (#642)
194
+
195
### known issues
196
- playback auto-start on refresh (#225)
197
- iOS PWA audio may hang on first play after backgrounding
198
199
### backlog
200
- audio transcoding pipeline integration (#153) - transcoder service deployed, integration deferred
201
+
- share to bluesky (#334)
202
+
- lyrics and annotations (#373)
203
204
## technical state
205
···
245
- ✅ copyright moderation with ATProto labeler
246
- ✅ docket background tasks (copyright scan, export, atproto sync, scrobble)
247
- ✅ media export with concurrent downloads
248
+
- ✅ supporter-gated content via atprotofans
249
250
**albums**
251
- ✅ album CRUD with cover art
···
277
278
## cost structure
279
280
+
current monthly costs: ~$20/month (plyr.fm specific)
281
282
see live dashboard: [plyr.fm/costs](https://plyr.fm/costs)
283
284
+
- fly.io (backend + redis + moderation): ~$14/month
285
- neon postgres: $5/month
286
+
- cloudflare (R2 + pages + domain): ~$1/month
287
+
- audd audio fingerprinting: $5-10/month (usage-based)
288
- logfire: $0 (free tier)
289
290
## admin tooling
···
335
│ └── src/routes/ # pages
336
├── moderation/ # Rust moderation service (ATProto labeler)
337
├── transcoder/ # Rust audio transcoding service
338
+
├── redis/ # self-hosted Redis config
339
├── docs/ # documentation
340
└── justfile # task runner
341
```
···
351
352
---
353
354
+
this is a living document. last updated 2025-12-30.
+1
backend/.env.example
+1
backend/.env.example
···
32
R2_ENDPOINT_URL=https://8feb33b5fb57ce2bc093bc6f4141f40a.r2.cloudflarestorage.com
33
R2_PUBLIC_BUCKET_URL=https://pub-154b70b3121149eda0cf1ccbae78cb33.r2.dev
34
R2_PUBLIC_IMAGE_BUCKET_URL=https://pub-154b70b3121149eda0cf1ccbae78cb33.r2.dev
35
MAX_UPLOAD_SIZE_MB=1536 # max audio upload size (default: 1536MB / 1.5GB - supports 2-hour WAV)
36
37
# atproto
···
32
R2_ENDPOINT_URL=https://8feb33b5fb57ce2bc093bc6f4141f40a.r2.cloudflarestorage.com
33
R2_PUBLIC_BUCKET_URL=https://pub-154b70b3121149eda0cf1ccbae78cb33.r2.dev
34
R2_PUBLIC_IMAGE_BUCKET_URL=https://pub-154b70b3121149eda0cf1ccbae78cb33.r2.dev
35
+
R2_PRIVATE_BUCKET=audio-private-dev # private bucket for supporter-gated audio
36
MAX_UPLOAD_SIZE_MB=1536 # max audio upload size (default: 1536MB / 1.5GB - supports 2-hour WAV)
37
38
# atproto
+37
backend/alembic/versions/2025_12_22_190115_9ee155c078ed_add_support_gate_to_tracks.py
+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
+1
-1
backend/fly.toml
+1
-1
backend/fly.toml
+3
backend/src/backend/_internal/__init__.py
+3
backend/src/backend/_internal/__init__.py
···
32
from backend._internal.notifications import notification_service
33
from backend._internal.now_playing import now_playing_service
34
from backend._internal.queue import queue_service
35
36
__all__ = [
37
"DeveloperToken",
···
51
"get_pending_dev_token",
52
"get_pending_scope_upgrade",
53
"get_session",
54
"handle_oauth_callback",
55
"list_developer_tokens",
56
"notification_service",
···
64
"start_oauth_flow",
65
"start_oauth_flow_with_scopes",
66
"update_session_tokens",
67
]
···
32
from backend._internal.notifications import notification_service
33
from backend._internal.now_playing import now_playing_service
34
from backend._internal.queue import queue_service
35
+
from backend._internal.atprotofans import get_supported_artists, validate_supporter
36
37
__all__ = [
38
"DeveloperToken",
···
52
"get_pending_dev_token",
53
"get_pending_scope_upgrade",
54
"get_session",
55
+
"get_supported_artists",
56
"handle_oauth_callback",
57
"list_developer_tokens",
58
"notification_service",
···
66
"start_oauth_flow",
67
"start_oauth_flow_with_scopes",
68
"update_session_tokens",
69
+
"validate_supporter",
70
]
+9
-2
backend/src/backend/_internal/atproto/records/fm_plyr/track.py
+9
-2
backend/src/backend/_internal/atproto/records/fm_plyr/track.py
···
20
duration: int | None = None,
21
features: list[dict] | None = None,
22
image_url: str | None = None,
23
) -> dict[str, Any]:
24
"""Build a track record dict for ATProto.
25
26
args:
27
title: track title
28
artist: artist name
29
-
audio_url: R2 URL for audio file
30
file_type: file extension (mp3, wav, etc)
31
album: optional album name
32
duration: optional duration in seconds
33
features: optional list of featured artists [{did, handle, display_name, avatar_url}]
34
image_url: optional cover art image URL
35
36
returns:
37
record dict ready for ATProto
···
64
# validate image URL comes from allowed origin
65
settings.storage.validate_image_url(image_url)
66
record["imageUrl"] = image_url
67
68
return record
69
···
78
duration: int | None = None,
79
features: list[dict] | None = None,
80
image_url: str | None = None,
81
) -> tuple[str, str]:
82
"""Create a track record on the user's PDS using the configured collection.
83
···
85
auth_session: authenticated user session
86
title: track title
87
artist: artist name
88
-
audio_url: R2 URL for audio file
89
file_type: file extension (mp3, wav, etc)
90
album: optional album name
91
duration: optional duration in seconds
92
features: optional list of featured artists [{did, handle, display_name, avatar_url}]
93
image_url: optional cover art image URL
94
95
returns:
96
tuple of (record_uri, record_cid)
···
108
duration=duration,
109
features=features,
110
image_url=image_url,
111
)
112
113
payload = {
···
20
duration: int | None = None,
21
features: list[dict] | None = None,
22
image_url: str | None = None,
23
+
support_gate: dict | None = None,
24
) -> dict[str, Any]:
25
"""Build a track record dict for ATProto.
26
27
args:
28
title: track title
29
artist: artist name
30
+
audio_url: R2 URL for audio file (placeholder for gated tracks)
31
file_type: file extension (mp3, wav, etc)
32
album: optional album name
33
duration: optional duration in seconds
34
features: optional list of featured artists [{did, handle, display_name, avatar_url}]
35
image_url: optional cover art image URL
36
+
support_gate: optional gating config (e.g., {"type": "any"})
37
38
returns:
39
record dict ready for ATProto
···
66
# validate image URL comes from allowed origin
67
settings.storage.validate_image_url(image_url)
68
record["imageUrl"] = image_url
69
+
if support_gate:
70
+
record["supportGate"] = support_gate
71
72
return record
73
···
82
duration: int | None = None,
83
features: list[dict] | None = None,
84
image_url: str | None = None,
85
+
support_gate: dict | None = None,
86
) -> tuple[str, str]:
87
"""Create a track record on the user's PDS using the configured collection.
88
···
90
auth_session: authenticated user session
91
title: track title
92
artist: artist name
93
+
audio_url: R2 URL for audio file (placeholder URL for gated tracks)
94
file_type: file extension (mp3, wav, etc)
95
album: optional album name
96
duration: optional duration in seconds
97
features: optional list of featured artists [{did, handle, display_name, avatar_url}]
98
image_url: optional cover art image URL
99
+
support_gate: optional gating config (e.g., {"type": "any"})
100
101
returns:
102
tuple of (record_uri, record_cid)
···
114
duration=duration,
115
features=features,
116
image_url=image_url,
117
+
support_gate=support_gate,
118
)
119
120
payload = {
+121
backend/src/backend/_internal/atprotofans.py
+121
backend/src/backend/_internal/atprotofans.py
···
···
1
+
"""atprotofans integration for supporter validation.
2
+
3
+
atprotofans is a creator support platform on ATProto. this module provides
4
+
server-side validation of supporter relationships for content gating.
5
+
6
+
the validation uses the three-party model:
7
+
- supporter: has com.atprotofans.supporter record in their PDS
8
+
- creator: has com.atprotofans.supporterProof record in their PDS
9
+
- broker: has com.atprotofans.brokerProof record (atprotofans service)
10
+
11
+
for direct atprotofans contributions (not via platform registration),
12
+
the signer is the artist's own DID.
13
+
14
+
see: https://atprotofans.leaflet.pub/3mabsmts3rs2b
15
+
"""
16
+
17
+
import asyncio
18
+
19
+
import httpx
20
+
import logfire
21
+
from pydantic import BaseModel
22
+
23
+
24
+
class SupporterValidation(BaseModel):
25
+
"""result of validating supporter status."""
26
+
27
+
valid: bool
28
+
profile: dict | None = None
29
+
30
+
31
+
async def validate_supporter(
32
+
supporter_did: str,
33
+
artist_did: str,
34
+
timeout: float = 5.0,
35
+
) -> SupporterValidation:
36
+
"""validate if a user supports an artist via atprotofans.
37
+
38
+
for direct atprotofans contributions, the signer is the artist's DID.
39
+
40
+
args:
41
+
supporter_did: DID of the potential supporter
42
+
artist_did: DID of the artist (also used as signer)
43
+
timeout: request timeout in seconds
44
+
45
+
returns:
46
+
SupporterValidation with valid=True if supporter, valid=False otherwise
47
+
"""
48
+
url = "https://atprotofans.com/xrpc/com.atprotofans.validateSupporter"
49
+
params = {
50
+
"supporter": supporter_did,
51
+
"subject": artist_did,
52
+
"signer": artist_did, # for direct contributions, signer = artist
53
+
}
54
+
55
+
with logfire.span(
56
+
"atprotofans.validate_supporter",
57
+
supporter_did=supporter_did,
58
+
artist_did=artist_did,
59
+
):
60
+
try:
61
+
async with httpx.AsyncClient(timeout=timeout) as client:
62
+
response = await client.get(url, params=params)
63
+
64
+
if response.status_code != 200:
65
+
logfire.warn(
66
+
"atprotofans validation failed",
67
+
status_code=response.status_code,
68
+
response_text=response.text[:200],
69
+
)
70
+
return SupporterValidation(valid=False)
71
+
72
+
data = response.json()
73
+
is_valid = data.get("valid", False)
74
+
75
+
logfire.info(
76
+
"atprotofans validation result",
77
+
valid=is_valid,
78
+
has_profile=data.get("profile") is not None,
79
+
)
80
+
81
+
return SupporterValidation(
82
+
valid=is_valid,
83
+
profile=data.get("profile"),
84
+
)
85
+
86
+
except httpx.TimeoutException:
87
+
logfire.warn("atprotofans validation timeout")
88
+
return SupporterValidation(valid=False)
89
+
except Exception as e:
90
+
logfire.error(
91
+
"atprotofans validation error",
92
+
error=str(e),
93
+
exc_info=True,
94
+
)
95
+
return SupporterValidation(valid=False)
96
+
97
+
98
+
async def get_supported_artists(
99
+
supporter_did: str,
100
+
artist_dids: set[str],
101
+
timeout: float = 5.0,
102
+
) -> set[str]:
103
+
"""batch check which artists a user supports.
104
+
105
+
args:
106
+
supporter_did: DID of the potential supporter
107
+
artist_dids: set of artist DIDs to check
108
+
timeout: request timeout per check
109
+
110
+
returns:
111
+
set of artist DIDs the user supports
112
+
"""
113
+
if not artist_dids:
114
+
return set()
115
+
116
+
async def check_one(artist_did: str) -> str | None:
117
+
result = await validate_supporter(supporter_did, artist_did, timeout)
118
+
return artist_did if result.valid else None
119
+
120
+
results = await asyncio.gather(*[check_one(did) for did in artist_dids])
121
+
return {did for did in results if did is not None}
+2
backend/src/backend/_internal/background.py
+2
backend/src/backend/_internal/background.py
···
55
extra={"docket_name": settings.docket.name, "url": settings.docket.url},
56
)
57
58
+
# WARNING: do not modify Docket() or Worker() constructor args without
59
+
# reading docs/backend/background-tasks.md - see 2025-12-30 incident
60
async with Docket(
61
name=settings.docket.name,
62
url=settings.docket.url,
+59
-2
backend/src/backend/_internal/background_tasks.py
+59
-2
backend/src/backend/_internal/background_tasks.py
···
281
export_id,
282
JobStatus.PROCESSING,
283
f"downloading {total} tracks...",
284
-
progress_pct=0,
285
result={"processed_count": 0, "total_count": total},
286
)
287
···
304
export_id,
305
JobStatus.PROCESSING,
306
"creating zip archive...",
307
-
progress_pct=100,
308
result={
309
"processed_count": len(successful_downloads),
310
"total_count": total,
···
865
logfire.info("scheduled pds comment update", comment_id=comment_id)
866
867
868
# collection of all background task functions for docket registration
869
background_tasks = [
870
scan_copyright,
···
878
pds_create_comment,
879
pds_delete_comment,
880
pds_update_comment,
881
]
···
281
export_id,
282
JobStatus.PROCESSING,
283
f"downloading {total} tracks...",
284
+
progress_pct=0.0,
285
result={"processed_count": 0, "total_count": total},
286
)
287
···
304
export_id,
305
JobStatus.PROCESSING,
306
"creating zip archive...",
307
+
progress_pct=100.0,
308
result={
309
"processed_count": len(successful_downloads),
310
"total_count": total,
···
865
logfire.info("scheduled pds comment update", comment_id=comment_id)
866
867
868
+
async def move_track_audio(track_id: int, to_private: bool) -> None:
869
+
"""move a track's audio file between public and private buckets.
870
+
871
+
called when support_gate is toggled on an existing track.
872
+
873
+
args:
874
+
track_id: database ID of the track
875
+
to_private: if True, move to private bucket; if False, move to public
876
+
"""
877
+
from backend.models import Track
878
+
from backend.storage import storage
879
+
880
+
async with db_session() as db:
881
+
result = await db.execute(select(Track).where(Track.id == track_id))
882
+
track = result.scalar_one_or_none()
883
+
884
+
if not track:
885
+
logger.warning(f"move_track_audio: track {track_id} not found")
886
+
return
887
+
888
+
if not track.file_id or not track.file_type:
889
+
logger.warning(
890
+
f"move_track_audio: track {track_id} missing file_id/file_type"
891
+
)
892
+
return
893
+
894
+
result_url = await storage.move_audio(
895
+
file_id=track.file_id,
896
+
extension=track.file_type,
897
+
to_private=to_private,
898
+
)
899
+
900
+
# update r2_url: None for private, public URL for public
901
+
if to_private:
902
+
# moved to private - result_url is None on success, None on failure
903
+
# we check by verifying the file was actually moved (no error logged)
904
+
track.r2_url = None
905
+
await db.commit()
906
+
logger.info(f"moved track {track_id} to private bucket")
907
+
elif result_url:
908
+
# moved to public - result_url is the public URL
909
+
track.r2_url = result_url
910
+
await db.commit()
911
+
logger.info(f"moved track {track_id} to public bucket")
912
+
else:
913
+
logger.error(f"failed to move track {track_id}")
914
+
915
+
916
+
async def schedule_move_track_audio(track_id: int, to_private: bool) -> None:
917
+
"""schedule a track audio move via docket."""
918
+
docket = get_docket()
919
+
await docket.add(move_track_audio)(track_id, to_private)
920
+
direction = "private" if to_private else "public"
921
+
logfire.info(f"scheduled track audio move to {direction}", track_id=track_id)
922
+
923
+
924
# collection of all background task functions for docket registration
925
background_tasks = [
926
scan_copyright,
···
934
pds_create_comment,
935
pds_delete_comment,
936
pds_update_comment,
937
+
move_track_audio,
938
]
+31
backend/src/backend/_internal/moderation_client.py
+31
backend/src/backend/_internal/moderation_client.py
···
35
error: str | None = None
36
37
38
class ModerationClient:
39
"""client for the plyr.fm moderation service.
40
···
152
except Exception as e:
153
logger.warning("failed to emit copyright label for %s: %s", uri, e)
154
return EmitLabelResult(success=False, error=str(e))
155
156
async def get_active_labels(self, uris: list[str]) -> set[str]:
157
"""check which URIs have active (non-negated) copyright-violation labels.
···
35
error: str | None = None
36
37
38
+
@dataclass
39
+
class SensitiveImagesResult:
40
+
"""result from fetching sensitive images."""
41
+
42
+
image_ids: list[str]
43
+
urls: list[str]
44
+
45
+
46
class ModerationClient:
47
"""client for the plyr.fm moderation service.
48
···
160
except Exception as e:
161
logger.warning("failed to emit copyright label for %s: %s", uri, e)
162
return EmitLabelResult(success=False, error=str(e))
163
+
164
+
async def get_sensitive_images(self) -> SensitiveImagesResult:
165
+
"""fetch all sensitive images from the moderation service.
166
+
167
+
returns:
168
+
SensitiveImagesResult with image_ids and urls
169
+
170
+
raises:
171
+
httpx.HTTPStatusError: on non-2xx response
172
+
httpx.TimeoutException: on timeout
173
+
"""
174
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
175
+
response = await client.get(
176
+
f"{self.labeler_url}/sensitive-images",
177
+
# no auth required for this public endpoint
178
+
)
179
+
response.raise_for_status()
180
+
data = response.json()
181
+
182
+
return SensitiveImagesResult(
183
+
image_ids=data.get("image_ids", []),
184
+
urls=data.get("urls", []),
185
+
)
186
187
async def get_active_labels(self, uris: list[str]) -> set[str]:
188
"""check which URIs have active (non-negated) copyright-violation labels.
+127
-19
backend/src/backend/api/audio.py
+127
-19
backend/src/backend/api/audio.py
···
1
"""audio streaming endpoint."""
2
3
import logfire
4
-
from fastapi import APIRouter, Depends, HTTPException
5
from fastapi.responses import RedirectResponse
6
from pydantic import BaseModel
7
from sqlalchemy import func, select
8
9
-
from backend._internal import Session, require_auth
10
from backend.models import Track
11
from backend.storage import storage
12
from backend.utilities.database import db_session
···
22
file_type: str | None
23
24
25
@router.get("/{file_id}")
26
-
async def stream_audio(file_id: str):
27
"""stream audio file by redirecting to R2 CDN URL.
28
29
-
looks up track to get cached r2_url and file extension,
30
-
eliminating the need to probe multiple formats.
31
32
images are served directly via R2 URLs stored in the image_url field,
33
not through this endpoint.
34
"""
35
-
# look up track to get r2_url and file_type
36
async with db_session() as db:
37
# check for duplicates (multiple tracks with same file_id)
38
count_result = await db.execute(
···
50
count=count,
51
)
52
53
-
# get the best track: prefer non-null r2_url, then newest
54
result = await db.execute(
55
-
select(Track.r2_url, Track.file_type)
56
.where(Track.file_id == file_id)
57
.order_by(Track.r2_url.is_not(None).desc(), Track.created_at.desc())
58
.limit(1)
59
)
60
track_data = result.first()
61
62
-
r2_url, file_type = track_data
63
64
-
# if we have a valid r2_url cached, use it directly (zero HEADs)
65
if r2_url and r2_url.startswith("http"):
66
return RedirectResponse(url=r2_url)
67
···
72
return RedirectResponse(url=url)
73
74
75
@router.get("/{file_id}/url")
76
async def get_audio_url(
77
file_id: str,
78
-
session: Session = Depends(require_auth),
79
) -> AudioUrlResponse:
80
-
"""return direct R2 URL for offline caching.
81
82
-
unlike the streaming endpoint which returns a 307 redirect,
83
-
this returns the URL as JSON so the frontend can fetch and
84
-
cache the audio directly via the Cache API.
85
86
-
used for offline mode - frontend fetches from R2 and stores locally.
87
"""
88
async with db_session() as db:
89
result = await db.execute(
90
-
select(Track.r2_url, Track.file_type)
91
.where(Track.file_id == file_id)
92
.order_by(Track.r2_url.is_not(None).desc(), Track.created_at.desc())
93
.limit(1)
···
97
if not track_data:
98
raise HTTPException(status_code=404, detail="audio file not found")
99
100
-
r2_url, file_type = track_data
101
102
-
# if we have a cached r2_url, return it
103
if r2_url and r2_url.startswith("http"):
104
return AudioUrlResponse(url=r2_url, file_id=file_id, file_type=file_type)
105
···
1
"""audio streaming endpoint."""
2
3
import logfire
4
+
from fastapi import APIRouter, Depends, HTTPException, Request, Response
5
from fastapi.responses import RedirectResponse
6
from pydantic import BaseModel
7
from sqlalchemy import func, select
8
9
+
from backend._internal import Session, get_optional_session, validate_supporter
10
from backend.models import Track
11
from backend.storage import storage
12
from backend.utilities.database import db_session
···
22
file_type: str | None
23
24
25
+
@router.head("/{file_id}")
26
@router.get("/{file_id}")
27
+
async def stream_audio(
28
+
file_id: str,
29
+
request: Request,
30
+
session: Session | None = Depends(get_optional_session),
31
+
):
32
"""stream audio file by redirecting to R2 CDN URL.
33
34
+
for public tracks: redirects to R2 CDN URL.
35
+
for gated tracks: validates supporter status and returns presigned URL.
36
+
37
+
HEAD requests are used for pre-flight auth checks - they return
38
+
200/401/402 status without redirecting to avoid CORS issues.
39
40
images are served directly via R2 URLs stored in the image_url field,
41
not through this endpoint.
42
"""
43
+
is_head_request = request.method == "HEAD"
44
+
# look up track to get r2_url, file_type, support_gate, and artist_did
45
async with db_session() as db:
46
# check for duplicates (multiple tracks with same file_id)
47
count_result = await db.execute(
···
59
count=count,
60
)
61
62
+
# get the track with gating info
63
result = await db.execute(
64
+
select(Track.r2_url, Track.file_type, Track.support_gate, Track.artist_did)
65
.where(Track.file_id == file_id)
66
.order_by(Track.r2_url.is_not(None).desc(), Track.created_at.desc())
67
.limit(1)
68
)
69
track_data = result.first()
70
+
r2_url, file_type, support_gate, artist_did = track_data
71
72
+
# check if track is gated
73
+
if support_gate is not None:
74
+
return await _handle_gated_audio(
75
+
file_id=file_id,
76
+
file_type=file_type,
77
+
artist_did=artist_did,
78
+
session=session,
79
+
is_head_request=is_head_request,
80
+
)
81
82
+
# public track - use cached r2_url if available
83
if r2_url and r2_url.startswith("http"):
84
return RedirectResponse(url=r2_url)
85
···
90
return RedirectResponse(url=url)
91
92
93
+
async def _handle_gated_audio(
94
+
file_id: str,
95
+
file_type: str,
96
+
artist_did: str,
97
+
session: Session | None,
98
+
is_head_request: bool = False,
99
+
) -> RedirectResponse | Response:
100
+
"""handle streaming for supporter-gated content.
101
+
102
+
validates that the user is authenticated and either:
103
+
- is the artist who uploaded the track, OR
104
+
- supports the artist via atprotofans
105
+
before returning a presigned URL for the private bucket.
106
+
107
+
for HEAD requests (used for pre-flight auth checks), returns 200 status
108
+
without redirecting to avoid CORS issues with cross-origin redirects.
109
+
"""
110
+
# must be authenticated to access gated content
111
+
if not session:
112
+
raise HTTPException(
113
+
status_code=401,
114
+
detail="authentication required for supporter-gated content",
115
+
)
116
+
117
+
# artist can always play their own gated tracks
118
+
if session.did == artist_did:
119
+
logfire.info(
120
+
"serving gated content to owner",
121
+
file_id=file_id,
122
+
artist_did=artist_did,
123
+
)
124
+
else:
125
+
# validate supporter status via atprotofans
126
+
validation = await validate_supporter(
127
+
supporter_did=session.did,
128
+
artist_did=artist_did,
129
+
)
130
+
131
+
if not validation.valid:
132
+
raise HTTPException(
133
+
status_code=402,
134
+
detail="this track requires supporter access",
135
+
headers={"X-Support-Required": "true"},
136
+
)
137
+
138
+
# for HEAD requests, just return 200 to confirm access
139
+
# (avoids CORS issues with cross-origin redirects)
140
+
if is_head_request:
141
+
return Response(status_code=200)
142
+
143
+
# authorized - generate presigned URL for private bucket
144
+
if session.did != artist_did:
145
+
logfire.info(
146
+
"serving gated content to supporter",
147
+
file_id=file_id,
148
+
supporter_did=session.did,
149
+
artist_did=artist_did,
150
+
)
151
+
152
+
url = await storage.generate_presigned_url(file_id=file_id, extension=file_type)
153
+
return RedirectResponse(url=url)
154
+
155
+
156
@router.get("/{file_id}/url")
157
async def get_audio_url(
158
file_id: str,
159
+
session: Session | None = Depends(get_optional_session),
160
) -> AudioUrlResponse:
161
+
"""return direct URL for audio file.
162
163
+
for public tracks: returns R2 CDN URL for offline caching.
164
+
for gated tracks: returns presigned URL after supporter validation.
165
166
+
used for offline mode - frontend fetches and caches locally.
167
"""
168
async with db_session() as db:
169
result = await db.execute(
170
+
select(Track.r2_url, Track.file_type, Track.support_gate, Track.artist_did)
171
.where(Track.file_id == file_id)
172
.order_by(Track.r2_url.is_not(None).desc(), Track.created_at.desc())
173
.limit(1)
···
177
if not track_data:
178
raise HTTPException(status_code=404, detail="audio file not found")
179
180
+
r2_url, file_type, support_gate, artist_did = track_data
181
182
+
# check if track is gated
183
+
if support_gate is not None:
184
+
# must be authenticated
185
+
if not session:
186
+
raise HTTPException(
187
+
status_code=401,
188
+
detail="authentication required for supporter-gated content",
189
+
)
190
+
191
+
# artist can always access their own gated tracks
192
+
if session.did != artist_did:
193
+
# validate supporter status
194
+
validation = await validate_supporter(
195
+
supporter_did=session.did,
196
+
artist_did=artist_did,
197
+
)
198
+
199
+
if not validation.valid:
200
+
raise HTTPException(
201
+
status_code=402,
202
+
detail="this track requires supporter access",
203
+
headers={"X-Support-Required": "true"},
204
+
)
205
+
206
+
# return presigned URL
207
+
url = await storage.generate_presigned_url(file_id=file_id, extension=file_type)
208
+
return AudioUrlResponse(url=url, file_id=file_id, file_type=file_type)
209
+
210
+
# public track - return cached r2_url if available
211
if r2_url and r2_url.startswith("http"):
212
return AudioUrlResponse(url=r2_url, file_id=file_id, file_type=file_type)
213
+17
-12
backend/src/backend/api/moderation.py
+17
-12
backend/src/backend/api/moderation.py
···
1
"""content moderation api endpoints."""
2
3
-
from typing import Annotated
4
5
-
from fastapi import APIRouter, Depends
6
from pydantic import BaseModel
7
-
from sqlalchemy import select
8
-
from sqlalchemy.ext.asyncio import AsyncSession
9
10
-
from backend.models import SensitiveImage, get_db
11
12
router = APIRouter(prefix="/moderation", tags=["moderation"])
13
···
22
23
24
@router.get("/sensitive-images")
25
async def get_sensitive_images(
26
-
db: Annotated[AsyncSession, Depends(get_db)],
27
) -> SensitiveImagesResponse:
28
"""get all flagged sensitive images.
29
30
returns both image_ids (for R2-stored images) and full URLs
31
(for external images like avatars). clients should check both.
32
"""
33
-
result = await db.execute(select(SensitiveImage))
34
-
images = result.scalars().all()
35
-
36
-
image_ids = [img.image_id for img in images if img.image_id]
37
-
urls = [img.url for img in images if img.url]
38
39
-
return SensitiveImagesResponse(image_ids=image_ids, urls=urls)
···
1
"""content moderation api endpoints."""
2
3
+
import logging
4
5
+
from fastapi import APIRouter, Request
6
from pydantic import BaseModel
7
+
8
+
from backend._internal.moderation_client import get_moderation_client
9
+
from backend.utilities.rate_limit import limiter
10
11
+
logger = logging.getLogger(__name__)
12
13
router = APIRouter(prefix="/moderation", tags=["moderation"])
14
···
23
24
25
@router.get("/sensitive-images")
26
+
@limiter.limit("10/minute")
27
async def get_sensitive_images(
28
+
request: Request,
29
) -> SensitiveImagesResponse:
30
"""get all flagged sensitive images.
31
32
+
proxies to the moderation service which is the source of truth
33
+
for sensitive image data.
34
+
35
returns both image_ids (for R2-stored images) and full URLs
36
(for external images like avatars). clients should check both.
37
"""
38
+
client = get_moderation_client()
39
+
result = await client.get_sensitive_images()
40
41
+
return SensitiveImagesResponse(
42
+
image_ids=result.image_ids,
43
+
urls=result.urls,
44
+
)
+18
-1
backend/src/backend/api/tracks/listing.py
+18
-1
backend/src/backend/api/tracks/listing.py
···
12
from sqlalchemy.orm import selectinload
13
14
from backend._internal import Session as AuthSession
15
-
from backend._internal import get_optional_session, require_auth
16
from backend.config import settings
17
from backend.models import (
18
Artist,
···
233
await asyncio.gather(*[resolve_image(t) for t in tracks_needing_images])
234
await db.commit()
235
236
# fetch all track responses concurrently with like status and counts
237
track_responses = await asyncio.gather(
238
*[
···
243
like_counts,
244
comment_counts,
245
track_tags=track_tags,
246
)
247
for track in tracks
248
]
···
12
from sqlalchemy.orm import selectinload
13
14
from backend._internal import Session as AuthSession
15
+
from backend._internal import get_optional_session, get_supported_artists, require_auth
16
from backend.config import settings
17
from backend.models import (
18
Artist,
···
233
await asyncio.gather(*[resolve_image(t) for t in tracks_needing_images])
234
await db.commit()
235
236
+
# resolve supporter status for gated content
237
+
viewer_did = session.did if session else None
238
+
supported_artist_dids: set[str] = set()
239
+
if viewer_did:
240
+
# collect artist DIDs with gated tracks (excluding viewer's own tracks)
241
+
gated_artist_dids = {
242
+
t.artist_did
243
+
for t in tracks
244
+
if t.support_gate and t.artist_did != viewer_did
245
+
}
246
+
if gated_artist_dids:
247
+
supported_artist_dids = await get_supported_artists(
248
+
viewer_did, gated_artist_dids
249
+
)
250
+
251
# fetch all track responses concurrently with like status and counts
252
track_responses = await asyncio.gather(
253
*[
···
258
like_counts,
259
comment_counts,
260
track_tags=track_tags,
261
+
viewer_did=viewer_did,
262
+
supported_artist_dids=supported_artist_dids,
263
)
264
for track in tracks
265
]
+64
-4
backend/src/backend/api/tracks/mutations.py
+64
-4
backend/src/backend/api/tracks/mutations.py
···
1
"""Track mutation endpoints (delete/update/restore)."""
2
3
import contextlib
4
import logging
5
from datetime import UTC, datetime
6
from typing import Annotated
7
8
import logfire
9
from fastapi import Depends, File, Form, HTTPException, UploadFile
···
23
update_record,
24
)
25
from backend._internal.atproto.tid import datetime_to_tid
26
-
from backend._internal.background_tasks import schedule_album_list_sync
27
from backend.config import settings
28
from backend.models import Artist, Tag, Track, TrackTag, get_db
29
from backend.schemas import TrackResponse
···
170
album: Annotated[str | None, Form()] = None,
171
features: Annotated[str | None, Form()] = None,
172
tags: Annotated[str | None, Form(description="JSON array of tag names")] = None,
173
image: UploadFile | None = File(None),
174
) -> TrackResponse:
175
"""Update track metadata (only by owner)."""
···
196
track.title = title
197
title_changed = True
198
199
# track album changes for list sync
200
old_album_id = track.album_id
201
await apply_album_update(db, track, album)
···
252
updated_tags.add(tag_name)
253
254
# always update ATProto record if any metadata changed
255
metadata_changed = (
256
-
title_changed or album is not None or features is not None or image_changed
257
)
258
if track.atproto_record_uri and metadata_changed:
259
try:
···
281
if new_album_id:
282
await schedule_album_list_sync(auth_session.session_id, new_album_id)
283
284
# build track_tags dict for response
285
# if tags were updated, use updated_tags; otherwise query for existing
286
if tags is not None:
···
304
Exception: if ATProto record update fails
305
"""
306
record_uri = track.atproto_record_uri
307
-
audio_url = track.r2_url
308
-
if not record_uri or not audio_url:
309
return
310
311
updated_record = build_track_record(
312
title=track.title,
313
artist=track.artist.display_name,
···
317
duration=track.duration,
318
features=track.features if track.features else None,
319
image_url=image_url_override or await track.get_image_url(),
320
)
321
322
result = await update_record(
···
1
"""Track mutation endpoints (delete/update/restore)."""
2
3
import contextlib
4
+
import json
5
import logging
6
from datetime import UTC, datetime
7
from typing import Annotated
8
+
from urllib.parse import urljoin
9
10
import logfire
11
from fastapi import Depends, File, Form, HTTPException, UploadFile
···
25
update_record,
26
)
27
from backend._internal.atproto.tid import datetime_to_tid
28
+
from backend._internal.background_tasks import (
29
+
schedule_album_list_sync,
30
+
schedule_move_track_audio,
31
+
)
32
from backend.config import settings
33
from backend.models import Artist, Tag, Track, TrackTag, get_db
34
from backend.schemas import TrackResponse
···
175
album: Annotated[str | None, Form()] = None,
176
features: Annotated[str | None, Form()] = None,
177
tags: Annotated[str | None, Form(description="JSON array of tag names")] = None,
178
+
support_gate: Annotated[
179
+
str | None,
180
+
Form(description="JSON object for supporter gating, or 'null' to remove"),
181
+
] = None,
182
image: UploadFile | None = File(None),
183
) -> TrackResponse:
184
"""Update track metadata (only by owner)."""
···
205
track.title = title
206
title_changed = True
207
208
+
# handle support_gate update
209
+
# track migration direction: None = no move, True = to private, False = to public
210
+
move_to_private: bool | None = None
211
+
if support_gate is not None:
212
+
was_gated = track.support_gate is not None
213
+
if support_gate.lower() == "null" or support_gate == "":
214
+
# removing gating - need to move file back to public if it was gated
215
+
if was_gated and track.r2_url is None:
216
+
move_to_private = False
217
+
track.support_gate = None
218
+
else:
219
+
try:
220
+
parsed_gate = json.loads(support_gate)
221
+
if not isinstance(parsed_gate, dict):
222
+
raise ValueError("support_gate must be a JSON object")
223
+
if "type" not in parsed_gate:
224
+
raise ValueError("support_gate must have a 'type' field")
225
+
if parsed_gate["type"] not in ("any",):
226
+
raise ValueError(
227
+
f"unsupported support_gate type: {parsed_gate['type']}"
228
+
)
229
+
# enabling gating - need to move file to private if it was public
230
+
if not was_gated and track.r2_url is not None:
231
+
move_to_private = True
232
+
track.support_gate = parsed_gate
233
+
except json.JSONDecodeError as e:
234
+
raise HTTPException(
235
+
status_code=400, detail=f"invalid support_gate JSON: {e}"
236
+
) from e
237
+
except ValueError as e:
238
+
raise HTTPException(status_code=400, detail=str(e)) from e
239
+
240
# track album changes for list sync
241
old_album_id = track.album_id
242
await apply_album_update(db, track, album)
···
293
updated_tags.add(tag_name)
294
295
# always update ATProto record if any metadata changed
296
+
support_gate_changed = move_to_private is not None
297
metadata_changed = (
298
+
title_changed
299
+
or album is not None
300
+
or features is not None
301
+
or image_changed
302
+
or support_gate_changed
303
)
304
if track.atproto_record_uri and metadata_changed:
305
try:
···
327
if new_album_id:
328
await schedule_album_list_sync(auth_session.session_id, new_album_id)
329
330
+
# move audio file between buckets if support_gate was toggled
331
+
if move_to_private is not None:
332
+
await schedule_move_track_audio(track.id, to_private=move_to_private)
333
+
334
# build track_tags dict for response
335
# if tags were updated, use updated_tags; otherwise query for existing
336
if tags is not None:
···
354
Exception: if ATProto record update fails
355
"""
356
record_uri = track.atproto_record_uri
357
+
if not record_uri:
358
return
359
360
+
# for gated tracks, use the API endpoint URL instead of r2_url
361
+
# (r2_url is None for private bucket tracks)
362
+
if track.support_gate is not None:
363
+
backend_url = settings.atproto.redirect_uri.rsplit("/", 2)[0]
364
+
audio_url = urljoin(backend_url + "/", f"audio/{track.file_id}")
365
+
else:
366
+
audio_url = track.r2_url
367
+
if not audio_url:
368
+
return
369
+
370
updated_record = build_track_record(
371
title=track.title,
372
artist=track.artist.display_name,
···
376
duration=track.duration,
377
features=track.features if track.features else None,
378
image_url=image_url_override or await track.get_image_url(),
379
+
support_gate=track.support_gate,
380
)
381
382
result = await update_record(
+108
-24
backend/src/backend/api/tracks/uploads.py
+108
-24
backend/src/backend/api/tracks/uploads.py
···
37
from backend._internal.image import ImageFormat
38
from backend._internal.jobs import job_service
39
from backend.config import settings
40
-
from backend.models import Artist, Tag, Track, TrackTag
41
from backend.models.job import JobStatus, JobType
42
from backend.storage import storage
43
from backend.utilities.audio import extract_duration
···
75
image_path: str | None = None
76
image_filename: str | None = None
77
image_content_type: str | None = None
78
79
80
async def _get_or_create_tag(
···
119
upload_id: str,
120
file_path: str,
121
filename: str,
122
) -> str | None:
123
-
"""save audio file to storage, returning file_id or None on failure."""
124
await job_service.update_progress(
125
upload_id,
126
JobStatus.PROCESSING,
127
-
"uploading to storage...",
128
phase="upload",
129
-
progress_pct=0,
130
)
131
try:
132
async with R2ProgressTracker(
133
job_id=upload_id,
134
-
message="uploading to storage...",
135
phase="upload",
136
) as tracker:
137
with open(file_path, "rb") as file_obj:
138
-
file_id = await storage.save(
139
-
file_obj, filename, progress_callback=tracker.on_progress
140
-
)
141
142
await job_service.update_progress(
143
upload_id,
144
JobStatus.PROCESSING,
145
-
"uploading to storage...",
146
phase="upload",
147
progress_pct=100.0,
148
)
149
-
logfire.info("storage.save completed", file_id=file_id)
150
return file_id
151
152
except Exception as e:
···
255
with open(ctx.file_path, "rb") as f:
256
duration = extract_duration(f)
257
258
-
# save audio to storage
259
file_id = await _save_audio_to_storage(
260
-
ctx.upload_id, ctx.file_path, ctx.filename
261
)
262
if not file_id:
263
return
···
279
)
280
return
281
282
-
# get R2 URL
283
-
r2_url = await storage.get_url(
284
-
file_id, file_type="audio", extension=ext[1:]
285
-
)
286
-
if not r2_url:
287
-
await job_service.update_progress(
288
-
ctx.upload_id,
289
-
JobStatus.FAILED,
290
-
"upload failed",
291
-
error="failed to get public audio URL",
292
)
293
-
return
294
295
# save image if provided
296
image_url = None
···
338
phase="atproto",
339
)
340
try:
341
atproto_result = await create_track_record(
342
auth_session=ctx.auth_session,
343
title=ctx.title,
344
artist=artist.display_name,
345
-
audio_url=r2_url,
346
file_type=ext[1:],
347
album=ctx.album,
348
duration=duration,
349
features=featured_artists or None,
350
image_url=image_url,
351
)
352
if not atproto_result:
353
raise ValueError("PDS returned no record data")
···
403
atproto_record_cid=atproto_cid,
404
image_id=image_id,
405
image_url=image_url,
406
)
407
408
db.add(track)
···
467
album: Annotated[str | None, Form()] = None,
468
features: Annotated[str | None, Form()] = None,
469
tags: Annotated[str | None, Form(description="JSON array of tag names")] = None,
470
file: UploadFile = File(...),
471
image: UploadFile | None = File(None),
472
) -> dict:
···
477
album: Optional album name/ID to associate with the track.
478
features: Optional JSON array of ATProto handles, e.g.,
479
["user1.bsky.social", "user2.bsky.social"].
480
file: Audio file to upload (required).
481
image: Optional image file for track artwork.
482
background_tasks: FastAPI background-task runner.
···
491
except ValueError as e:
492
raise HTTPException(status_code=400, detail=str(e)) from e
493
494
# validate audio file type upfront
495
if not file.filename:
496
raise HTTPException(status_code=400, detail="no filename provided")
···
577
image_path=image_path,
578
image_filename=image_filename,
579
image_content_type=image_content_type,
580
)
581
background_tasks.add_task(_process_upload_background, ctx)
582
except Exception:
···
37
from backend._internal.image import ImageFormat
38
from backend._internal.jobs import job_service
39
from backend.config import settings
40
+
from backend.models import Artist, Tag, Track, TrackTag, UserPreferences
41
from backend.models.job import JobStatus, JobType
42
from backend.storage import storage
43
from backend.utilities.audio import extract_duration
···
75
image_path: str | None = None
76
image_filename: str | None = None
77
image_content_type: str | None = None
78
+
79
+
# supporter-gated content (e.g., {"type": "any"})
80
+
support_gate: dict | None = None
81
82
83
async def _get_or_create_tag(
···
122
upload_id: str,
123
file_path: str,
124
filename: str,
125
+
*,
126
+
gated: bool = False,
127
) -> str | None:
128
+
"""save audio file to storage, returning file_id or None on failure.
129
+
130
+
args:
131
+
upload_id: job tracking ID
132
+
file_path: path to temp file
133
+
filename: original filename
134
+
gated: if True, save to private bucket (no public URL)
135
+
"""
136
+
message = "uploading to private storage..." if gated else "uploading to storage..."
137
await job_service.update_progress(
138
upload_id,
139
JobStatus.PROCESSING,
140
+
message,
141
phase="upload",
142
+
progress_pct=0.0,
143
)
144
try:
145
async with R2ProgressTracker(
146
job_id=upload_id,
147
+
message=message,
148
phase="upload",
149
) as tracker:
150
with open(file_path, "rb") as file_obj:
151
+
if gated:
152
+
file_id = await storage.save_gated(
153
+
file_obj, filename, progress_callback=tracker.on_progress
154
+
)
155
+
else:
156
+
file_id = await storage.save(
157
+
file_obj, filename, progress_callback=tracker.on_progress
158
+
)
159
160
await job_service.update_progress(
161
upload_id,
162
JobStatus.PROCESSING,
163
+
message,
164
phase="upload",
165
progress_pct=100.0,
166
)
167
+
logfire.info("storage.save completed", file_id=file_id, gated=gated)
168
return file_id
169
170
except Exception as e:
···
273
with open(ctx.file_path, "rb") as f:
274
duration = extract_duration(f)
275
276
+
# validate gating requirements if support_gate is set
277
+
is_gated = ctx.support_gate is not None
278
+
if is_gated:
279
+
async with db_session() as db:
280
+
prefs_result = await db.execute(
281
+
select(UserPreferences).where(
282
+
UserPreferences.did == ctx.artist_did
283
+
)
284
+
)
285
+
prefs = prefs_result.scalar_one_or_none()
286
+
if not prefs or prefs.support_url != "atprotofans":
287
+
await job_service.update_progress(
288
+
ctx.upload_id,
289
+
JobStatus.FAILED,
290
+
"upload failed",
291
+
error="supporter gating requires atprotofans to be enabled in settings",
292
+
)
293
+
return
294
+
295
+
# save audio to storage (private bucket if gated)
296
file_id = await _save_audio_to_storage(
297
+
ctx.upload_id, ctx.file_path, ctx.filename, gated=is_gated
298
)
299
if not file_id:
300
return
···
316
)
317
return
318
319
+
# get R2 URL (only for public tracks - gated tracks have no public URL)
320
+
r2_url: str | None = None
321
+
if not is_gated:
322
+
r2_url = await storage.get_url(
323
+
file_id, file_type="audio", extension=ext[1:]
324
)
325
+
if not r2_url:
326
+
await job_service.update_progress(
327
+
ctx.upload_id,
328
+
JobStatus.FAILED,
329
+
"upload failed",
330
+
error="failed to get public audio URL",
331
+
)
332
+
return
333
334
# save image if provided
335
image_url = None
···
377
phase="atproto",
378
)
379
try:
380
+
# for gated tracks, use API endpoint URL instead of direct R2 URL
381
+
# this ensures playback goes through our auth check
382
+
if is_gated:
383
+
# use backend URL for gated audio
384
+
from urllib.parse import urljoin
385
+
386
+
backend_url = settings.atproto.redirect_uri.rsplit("/", 2)[0]
387
+
audio_url_for_record = urljoin(
388
+
backend_url + "/", f"audio/{file_id}"
389
+
)
390
+
else:
391
+
# r2_url is guaranteed non-None here - we returned early above if None
392
+
assert r2_url is not None
393
+
audio_url_for_record = r2_url
394
+
395
atproto_result = await create_track_record(
396
auth_session=ctx.auth_session,
397
title=ctx.title,
398
artist=artist.display_name,
399
+
audio_url=audio_url_for_record,
400
file_type=ext[1:],
401
album=ctx.album,
402
duration=duration,
403
features=featured_artists or None,
404
image_url=image_url,
405
+
support_gate=ctx.support_gate,
406
)
407
if not atproto_result:
408
raise ValueError("PDS returned no record data")
···
458
atproto_record_cid=atproto_cid,
459
image_id=image_id,
460
image_url=image_url,
461
+
support_gate=ctx.support_gate,
462
)
463
464
db.add(track)
···
523
album: Annotated[str | None, Form()] = None,
524
features: Annotated[str | None, Form()] = None,
525
tags: Annotated[str | None, Form(description="JSON array of tag names")] = None,
526
+
support_gate: Annotated[
527
+
str | None,
528
+
Form(description='JSON object for supporter gating, e.g., {"type": "any"}'),
529
+
] = None,
530
file: UploadFile = File(...),
531
image: UploadFile | None = File(None),
532
) -> dict:
···
537
album: Optional album name/ID to associate with the track.
538
features: Optional JSON array of ATProto handles, e.g.,
539
["user1.bsky.social", "user2.bsky.social"].
540
+
support_gate: Optional JSON object for supporter gating.
541
+
Requires atprotofans to be enabled in settings.
542
+
Example: {"type": "any"} - requires any atprotofans support.
543
file: Audio file to upload (required).
544
image: Optional image file for track artwork.
545
background_tasks: FastAPI background-task runner.
···
554
except ValueError as e:
555
raise HTTPException(status_code=400, detail=str(e)) from e
556
557
+
# parse and validate support_gate if provided
558
+
parsed_support_gate: dict | None = None
559
+
if support_gate:
560
+
try:
561
+
parsed_support_gate = json.loads(support_gate)
562
+
if not isinstance(parsed_support_gate, dict):
563
+
raise ValueError("support_gate must be a JSON object")
564
+
if "type" not in parsed_support_gate:
565
+
raise ValueError("support_gate must have a 'type' field")
566
+
if parsed_support_gate["type"] not in ("any",):
567
+
raise ValueError(
568
+
f"unsupported support_gate type: {parsed_support_gate['type']}"
569
+
)
570
+
except json.JSONDecodeError as e:
571
+
raise HTTPException(
572
+
status_code=400, detail=f"invalid support_gate JSON: {e}"
573
+
) from e
574
+
except ValueError as e:
575
+
raise HTTPException(status_code=400, detail=str(e)) from e
576
+
577
# validate audio file type upfront
578
if not file.filename:
579
raise HTTPException(status_code=400, detail="no filename provided")
···
660
image_path=image_path,
661
image_filename=image_filename,
662
image_content_type=image_content_type,
663
+
support_gate=parsed_support_gate,
664
)
665
background_tasks.add_task(_process_upload_background, ctx)
666
except Exception:
+10
backend/src/backend/config.py
+10
backend/src/backend/config.py
···
256
validation_alias="R2_IMAGE_BUCKET",
257
description="R2 bucket name for image files",
258
)
259
+
r2_private_bucket: str = Field(
260
+
default="",
261
+
validation_alias="R2_PRIVATE_BUCKET",
262
+
description="R2 private bucket for supporter-gated audio (no public URL)",
263
+
)
264
+
presigned_url_expiry_seconds: int = Field(
265
+
default=3600,
266
+
validation_alias="PRESIGNED_URL_EXPIRY_SECONDS",
267
+
description="Expiry time in seconds for presigned URLs (default 1 hour)",
268
+
)
269
r2_endpoint_url: str = Field(
270
default="",
271
validation_alias="R2_ENDPOINT_URL",
+10
backend/src/backend/models/track.py
+10
backend/src/backend/models/track.py
···
87
nullable=False, default=False, server_default="false"
88
)
89
90
+
# supporter-gated content (e.g., {"type": "any"} requires any atprotofans support)
91
+
support_gate: Mapped[dict | None] = mapped_column(
92
+
JSONB, nullable=True, default=None
93
+
)
94
+
95
+
@property
96
+
def is_gated(self) -> bool:
97
+
"""check if this track requires supporter access."""
98
+
return self.support_gate is not None
99
+
100
@property
101
def album(self) -> str | None:
102
"""get album name from extra (for ATProto compatibility)."""
+20
backend/src/backend/schemas.py
+20
backend/src/backend/schemas.py
···
50
title: str
51
artist: str
52
artist_handle: str
53
artist_avatar_url: str | None
54
file_id: str
55
file_type: str
···
70
None # None = not scanned, False = clear, True = flagged
71
)
72
copyright_match: str | None = None # "Title by Artist" of primary match
73
74
@classmethod
75
async def from_track(
···
81
comment_counts: dict[int, int] | None = None,
82
copyright_info: dict[int, CopyrightInfo] | None = None,
83
track_tags: dict[int, set[str]] | None = None,
84
) -> "TrackResponse":
85
"""build track response from Track model.
86
···
92
comment_counts: optional dict of track_id -> comment_count
93
copyright_info: optional dict of track_id -> CopyrightInfo
94
track_tags: optional dict of track_id -> set of tag names
95
"""
96
# check if user has liked this track
97
is_liked = liked_track_ids is not None and track.id in liked_track_ids
···
135
# get tags for this track
136
tags = track_tags.get(track.id, set()) if track_tags else set()
137
138
return cls(
139
id=track.id,
140
title=track.title,
141
artist=track.artist.display_name,
142
artist_handle=track.artist.handle,
143
artist_avatar_url=track.artist.avatar_url,
144
file_id=track.file_id,
145
file_type=track.file_type,
···
158
tags=tags,
159
copyright_flagged=copyright_flagged,
160
copyright_match=copyright_match,
161
)
···
50
title: str
51
artist: str
52
artist_handle: str
53
+
artist_did: str
54
artist_avatar_url: str | None
55
file_id: str
56
file_type: str
···
71
None # None = not scanned, False = clear, True = flagged
72
)
73
copyright_match: str | None = None # "Title by Artist" of primary match
74
+
support_gate: dict[str, Any] | None = None # supporter gating config
75
+
gated: bool = False # true if track is gated AND viewer lacks access
76
77
@classmethod
78
async def from_track(
···
84
comment_counts: dict[int, int] | None = None,
85
copyright_info: dict[int, CopyrightInfo] | None = None,
86
track_tags: dict[int, set[str]] | None = None,
87
+
viewer_did: str | None = None,
88
+
supported_artist_dids: set[str] | None = None,
89
) -> "TrackResponse":
90
"""build track response from Track model.
91
···
97
comment_counts: optional dict of track_id -> comment_count
98
copyright_info: optional dict of track_id -> CopyrightInfo
99
track_tags: optional dict of track_id -> set of tag names
100
+
viewer_did: optional DID of the viewer (for gated content resolution)
101
+
supported_artist_dids: optional set of artist DIDs the viewer supports
102
"""
103
# check if user has liked this track
104
is_liked = liked_track_ids is not None and track.id in liked_track_ids
···
142
# get tags for this track
143
tags = track_tags.get(track.id, set()) if track_tags else set()
144
145
+
# resolve gated status for viewer
146
+
# gated = true only if track has support_gate AND viewer lacks access
147
+
gated = False
148
+
if track.support_gate:
149
+
is_owner = viewer_did and viewer_did == track.artist_did
150
+
is_supporter = (
151
+
supported_artist_dids and track.artist_did in supported_artist_dids
152
+
)
153
+
gated = not (is_owner or is_supporter)
154
+
155
return cls(
156
id=track.id,
157
title=track.title,
158
artist=track.artist.display_name,
159
artist_handle=track.artist.handle,
160
+
artist_did=track.artist_did,
161
artist_avatar_url=track.artist.avatar_url,
162
file_id=track.file_id,
163
file_type=track.file_type,
···
176
tags=tags,
177
copyright_flagged=copyright_flagged,
178
copyright_match=copyright_match,
179
+
support_gate=track.support_gate,
180
+
gated=gated,
181
)
+219
backend/src/backend/storage/r2.py
+219
backend/src/backend/storage/r2.py
···
95
96
self.audio_bucket_name = settings.storage.r2_bucket
97
self.image_bucket_name = settings.storage.r2_image_bucket
98
self.public_audio_bucket_url = settings.storage.r2_public_bucket_url
99
self.public_image_bucket_url = settings.storage.r2_public_image_bucket_url
100
101
# sync client for upload (used in background tasks)
102
self.client = boto3.client(
···
439
image_bucket=self.image_bucket_name,
440
)
441
return False
···
95
96
self.audio_bucket_name = settings.storage.r2_bucket
97
self.image_bucket_name = settings.storage.r2_image_bucket
98
+
self.private_audio_bucket_name = settings.storage.r2_private_bucket
99
self.public_audio_bucket_url = settings.storage.r2_public_bucket_url
100
self.public_image_bucket_url = settings.storage.r2_public_image_bucket_url
101
+
self.presigned_url_expiry = settings.storage.presigned_url_expiry_seconds
102
103
# sync client for upload (used in background tasks)
104
self.client = boto3.client(
···
441
image_bucket=self.image_bucket_name,
442
)
443
return False
444
+
445
+
async def save_gated(
446
+
self,
447
+
file: BinaryIO,
448
+
filename: str,
449
+
progress_callback: Callable[[float], None] | None = None,
450
+
) -> str:
451
+
"""save supporter-gated audio file to private R2 bucket.
452
+
453
+
same as save() but uses the private bucket with no public URL.
454
+
files in this bucket are only accessible via presigned URLs.
455
+
456
+
args:
457
+
file: file-like object to upload
458
+
filename: original filename (used to determine media type)
459
+
progress_callback: optional callback for upload progress
460
+
"""
461
+
if not self.private_audio_bucket_name:
462
+
raise ValueError("R2_PRIVATE_BUCKET not configured")
463
+
464
+
with logfire.span("R2 save_gated", filename=filename):
465
+
# compute hash in chunks (constant memory)
466
+
file_id = hash_file_chunked(file)[:16]
467
+
logfire.info("computed file hash for gated content", file_id=file_id)
468
+
469
+
# determine file extension - only audio supported for gated content
470
+
ext = Path(filename).suffix.lower()
471
+
audio_format = AudioFormat.from_extension(ext)
472
+
if not audio_format:
473
+
raise ValueError(
474
+
f"unsupported audio type for gated content: {ext}. "
475
+
f"supported: {AudioFormat.supported_extensions_str()}"
476
+
)
477
+
478
+
key = f"audio/{file_id}{ext}"
479
+
media_type = audio_format.media_type
480
+
481
+
# get file size for progress tracking
482
+
file_size = file.seek(0, 2)
483
+
file.seek(0)
484
+
485
+
logfire.info(
486
+
"uploading gated content to private R2",
487
+
bucket=self.private_audio_bucket_name,
488
+
key=key,
489
+
media_type=media_type,
490
+
file_size=file_size,
491
+
)
492
+
493
+
try:
494
+
async with self.async_session.client(
495
+
"s3",
496
+
endpoint_url=self.endpoint_url,
497
+
aws_access_key_id=self.aws_access_key_id,
498
+
aws_secret_access_key=self.aws_secret_access_key,
499
+
) as client:
500
+
upload_kwargs = {
501
+
"Fileobj": file,
502
+
"Bucket": self.private_audio_bucket_name,
503
+
"Key": key,
504
+
"ExtraArgs": {"ContentType": media_type},
505
+
}
506
+
507
+
if progress_callback and file_size > 0:
508
+
tracker = UploadProgressTracker(file_size, progress_callback)
509
+
upload_kwargs["Callback"] = tracker
510
+
511
+
await client.upload_fileobj(**upload_kwargs)
512
+
except Exception as e:
513
+
logfire.error(
514
+
"R2 gated upload failed",
515
+
error=str(e),
516
+
bucket=self.private_audio_bucket_name,
517
+
key=key,
518
+
exc_info=True,
519
+
)
520
+
raise
521
+
522
+
logfire.info("R2 gated upload complete", file_id=file_id, key=key)
523
+
return file_id
524
+
525
+
async def generate_presigned_url(
526
+
self,
527
+
file_id: str,
528
+
extension: str,
529
+
expires_in: int | None = None,
530
+
) -> str:
531
+
"""generate a presigned URL for accessing gated content.
532
+
533
+
presigned URLs allow time-limited access to private bucket objects
534
+
without exposing credentials. the URL includes a signature that
535
+
expires after the specified duration.
536
+
537
+
args:
538
+
file_id: the file identifier hash
539
+
extension: file extension (e.g., "mp3", "flac")
540
+
expires_in: optional override for expiry seconds (default from settings)
541
+
542
+
returns:
543
+
presigned URL string
544
+
545
+
raises:
546
+
ValueError: if private bucket not configured
547
+
"""
548
+
if not self.private_audio_bucket_name:
549
+
raise ValueError("R2_PRIVATE_BUCKET not configured")
550
+
551
+
ext = extension.lstrip(".")
552
+
key = f"audio/{file_id}.{ext}"
553
+
expiry = expires_in or self.presigned_url_expiry
554
+
555
+
with logfire.span(
556
+
"R2 generate_presigned_url",
557
+
file_id=file_id,
558
+
key=key,
559
+
expires_in=expiry,
560
+
):
561
+
async with self.async_session.client(
562
+
"s3",
563
+
endpoint_url=self.endpoint_url,
564
+
aws_access_key_id=self.aws_access_key_id,
565
+
aws_secret_access_key=self.aws_secret_access_key,
566
+
config=Config(signature_version="s3v4"),
567
+
) as client:
568
+
url = await client.generate_presigned_url(
569
+
"get_object",
570
+
Params={
571
+
"Bucket": self.private_audio_bucket_name,
572
+
"Key": key,
573
+
},
574
+
ExpiresIn=expiry,
575
+
)
576
+
logfire.info(
577
+
"generated presigned URL",
578
+
file_id=file_id,
579
+
expires_in=expiry,
580
+
)
581
+
return url
582
+
583
+
async def move_audio(
584
+
self,
585
+
file_id: str,
586
+
extension: str,
587
+
*,
588
+
to_private: bool,
589
+
) -> str | None:
590
+
"""move an audio file between public and private buckets.
591
+
592
+
copies the file to the destination bucket, then deletes from source.
593
+
594
+
args:
595
+
file_id: the file identifier hash
596
+
extension: file extension (e.g., "mp3", "flac")
597
+
to_private: if True, move public->private; if False, move private->public
598
+
599
+
returns:
600
+
new URL if successful (public URL or None for private), None on failure
601
+
602
+
raises:
603
+
ValueError: if private bucket not configured
604
+
"""
605
+
if not self.private_audio_bucket_name:
606
+
raise ValueError("R2_PRIVATE_BUCKET not configured")
607
+
608
+
ext = extension.lstrip(".")
609
+
key = f"audio/{file_id}.{ext}"
610
+
611
+
if to_private:
612
+
src_bucket = self.audio_bucket_name
613
+
dst_bucket = self.private_audio_bucket_name
614
+
else:
615
+
src_bucket = self.private_audio_bucket_name
616
+
dst_bucket = self.audio_bucket_name
617
+
618
+
with logfire.span(
619
+
"R2 move_audio",
620
+
file_id=file_id,
621
+
key=key,
622
+
to_private=to_private,
623
+
):
624
+
try:
625
+
async with self.async_session.client(
626
+
"s3",
627
+
endpoint_url=self.endpoint_url,
628
+
aws_access_key_id=self.aws_access_key_id,
629
+
aws_secret_access_key=self.aws_secret_access_key,
630
+
) as client:
631
+
# copy to destination
632
+
await client.copy_object(
633
+
CopySource={"Bucket": src_bucket, "Key": key},
634
+
Bucket=dst_bucket,
635
+
Key=key,
636
+
)
637
+
logfire.info(
638
+
"copied audio file",
639
+
file_id=file_id,
640
+
src=src_bucket,
641
+
dst=dst_bucket,
642
+
)
643
+
644
+
# delete from source
645
+
await client.delete_object(Bucket=src_bucket, Key=key)
646
+
logfire.info("deleted from source bucket", file_id=file_id)
647
+
648
+
# return public URL if moved to public, None if moved to private
649
+
if to_private:
650
+
return None
651
+
return f"{self.public_audio_bucket_url}/{key}"
652
+
653
+
except ClientError as e:
654
+
logfire.error(
655
+
"R2 move_audio failed",
656
+
file_id=file_id,
657
+
error=str(e),
658
+
exc_info=True,
659
+
)
660
+
return None
+218
-3
backend/tests/api/test_audio.py
+218
-3
backend/tests/api/test_audio.py
···
271
test_app.dependency_overrides.pop(require_auth, None)
272
273
274
-
async def test_get_audio_url_requires_auth(test_app: FastAPI):
275
-
"""test that /url endpoint returns 401 without authentication."""
276
# ensure no auth override
277
test_app.dependency_overrides.pop(require_auth, None)
278
279
async with AsyncClient(
280
transport=ASGITransport(app=test_app), base_url="http://test"
281
) as client:
282
-
response = await client.get("/audio/somefile/url")
283
284
assert response.status_code == 401
···
271
test_app.dependency_overrides.pop(require_auth, None)
272
273
274
+
async def test_get_audio_url_gated_requires_auth(
275
+
test_app: FastAPI, db_session: AsyncSession
276
+
):
277
+
"""test that /url endpoint returns 401 for gated content without authentication."""
278
+
# create a gated track
279
+
artist = Artist(
280
+
did="did:plc:gatedartist",
281
+
handle="gatedartist.bsky.social",
282
+
display_name="Gated Artist",
283
+
)
284
+
db_session.add(artist)
285
+
await db_session.flush()
286
+
287
+
track = Track(
288
+
title="Gated Track",
289
+
artist_did=artist.did,
290
+
file_id="gated-test-file",
291
+
file_type="mp3",
292
+
r2_url="https://cdn.example.com/audio/gated.mp3",
293
+
support_gate={"type": "any"},
294
+
)
295
+
db_session.add(track)
296
+
await db_session.commit()
297
+
298
# ensure no auth override
299
test_app.dependency_overrides.pop(require_auth, None)
300
301
async with AsyncClient(
302
transport=ASGITransport(app=test_app), base_url="http://test"
303
) as client:
304
+
response = await client.get(f"/audio/{track.file_id}/url")
305
+
306
+
assert response.status_code == 401
307
+
assert "authentication required" in response.json()["detail"]
308
+
309
+
310
+
# gated content regression tests
311
+
312
+
313
+
@pytest.fixture
314
+
async def gated_track(db_session: AsyncSession) -> Track:
315
+
"""create a gated track for testing supporter access."""
316
+
artist = Artist(
317
+
did="did:plc:gatedowner",
318
+
handle="gatedowner.bsky.social",
319
+
display_name="Gated Owner",
320
+
)
321
+
db_session.add(artist)
322
+
await db_session.flush()
323
+
324
+
track = Track(
325
+
title="Supporters Only Track",
326
+
artist_did=artist.did,
327
+
file_id="gated-regression-test",
328
+
file_type="mp3",
329
+
r2_url=None, # no cached URL - forces presigned URL generation
330
+
support_gate={"type": "any"},
331
+
)
332
+
db_session.add(track)
333
+
await db_session.commit()
334
+
await db_session.refresh(track)
335
+
336
+
return track
337
+
338
+
339
+
@pytest.fixture
340
+
def owner_session() -> Session:
341
+
"""session for the track owner."""
342
+
return Session(
343
+
session_id="owner-session-id",
344
+
did="did:plc:gatedowner",
345
+
handle="gatedowner.bsky.social",
346
+
oauth_session={
347
+
"access_token": "owner-access-token",
348
+
"refresh_token": "owner-refresh-token",
349
+
"dpop_key": {},
350
+
},
351
+
)
352
+
353
+
354
+
@pytest.fixture
355
+
def non_supporter_session() -> Session:
356
+
"""session for a user who is not a supporter."""
357
+
return Session(
358
+
session_id="non-supporter-session-id",
359
+
did="did:plc:randomuser",
360
+
handle="randomuser.bsky.social",
361
+
oauth_session={
362
+
"access_token": "random-access-token",
363
+
"refresh_token": "random-refresh-token",
364
+
"dpop_key": {},
365
+
},
366
+
)
367
+
368
+
369
+
async def test_gated_stream_requires_auth(test_app: FastAPI, gated_track: Track):
370
+
"""regression: GET /audio/{file_id} returns 401 for gated content without auth."""
371
+
test_app.dependency_overrides.pop(require_auth, None)
372
+
373
+
async with AsyncClient(
374
+
transport=ASGITransport(app=test_app), base_url="http://test"
375
+
) as client:
376
+
response = await client.get(
377
+
f"/audio/{gated_track.file_id}", follow_redirects=False
378
+
)
379
+
380
+
assert response.status_code == 401
381
+
assert "authentication required" in response.json()["detail"]
382
+
383
+
384
+
async def test_gated_head_requires_auth(test_app: FastAPI, gated_track: Track):
385
+
"""regression: HEAD /audio/{file_id} returns 401 for gated content without auth."""
386
+
test_app.dependency_overrides.pop(require_auth, None)
387
+
388
+
async with AsyncClient(
389
+
transport=ASGITransport(app=test_app), base_url="http://test"
390
+
) as client:
391
+
response = await client.head(f"/audio/{gated_track.file_id}")
392
393
assert response.status_code == 401
394
+
395
+
396
+
async def test_gated_head_owner_allowed(
397
+
test_app: FastAPI, gated_track: Track, owner_session: Session
398
+
):
399
+
"""regression: HEAD /audio/{file_id} returns 200 for track owner."""
400
+
from backend._internal import get_optional_session
401
+
402
+
test_app.dependency_overrides[get_optional_session] = lambda: owner_session
403
+
404
+
try:
405
+
async with AsyncClient(
406
+
transport=ASGITransport(app=test_app), base_url="http://test"
407
+
) as client:
408
+
response = await client.head(f"/audio/{gated_track.file_id}")
409
+
410
+
assert response.status_code == 200
411
+
finally:
412
+
test_app.dependency_overrides.pop(get_optional_session, None)
413
+
414
+
415
+
async def test_gated_stream_owner_redirects(
416
+
test_app: FastAPI, gated_track: Track, owner_session: Session
417
+
):
418
+
"""regression: GET /audio/{file_id} returns 307 redirect for track owner."""
419
+
from backend._internal import get_optional_session
420
+
421
+
mock_storage = MagicMock()
422
+
mock_storage.generate_presigned_url = AsyncMock(
423
+
return_value="https://presigned.example.com/audio/gated.mp3"
424
+
)
425
+
426
+
test_app.dependency_overrides[get_optional_session] = lambda: owner_session
427
+
428
+
try:
429
+
with patch("backend.api.audio.storage", mock_storage):
430
+
async with AsyncClient(
431
+
transport=ASGITransport(app=test_app), base_url="http://test"
432
+
) as client:
433
+
response = await client.get(
434
+
f"/audio/{gated_track.file_id}", follow_redirects=False
435
+
)
436
+
437
+
assert response.status_code == 307
438
+
assert "presigned.example.com" in response.headers["location"]
439
+
mock_storage.generate_presigned_url.assert_called_once()
440
+
finally:
441
+
test_app.dependency_overrides.pop(get_optional_session, None)
442
+
443
+
444
+
async def test_gated_head_non_supporter_denied(
445
+
test_app: FastAPI, gated_track: Track, non_supporter_session: Session
446
+
):
447
+
"""regression: HEAD /audio/{file_id} returns 402 for non-supporter."""
448
+
from backend._internal import get_optional_session
449
+
450
+
test_app.dependency_overrides[get_optional_session] = lambda: non_supporter_session
451
+
452
+
# mock validate_supporter to return invalid
453
+
mock_validation = MagicMock()
454
+
mock_validation.valid = False
455
+
456
+
try:
457
+
with patch(
458
+
"backend.api.audio.validate_supporter",
459
+
AsyncMock(return_value=mock_validation),
460
+
):
461
+
async with AsyncClient(
462
+
transport=ASGITransport(app=test_app), base_url="http://test"
463
+
) as client:
464
+
response = await client.head(f"/audio/{gated_track.file_id}")
465
+
466
+
assert response.status_code == 402
467
+
assert response.headers.get("x-support-required") == "true"
468
+
finally:
469
+
test_app.dependency_overrides.pop(get_optional_session, None)
470
+
471
+
472
+
async def test_gated_stream_non_supporter_denied(
473
+
test_app: FastAPI, gated_track: Track, non_supporter_session: Session
474
+
):
475
+
"""regression: GET /audio/{file_id} returns 402 for non-supporter."""
476
+
from backend._internal import get_optional_session
477
+
478
+
test_app.dependency_overrides[get_optional_session] = lambda: non_supporter_session
479
+
480
+
# mock validate_supporter to return invalid
481
+
mock_validation = MagicMock()
482
+
mock_validation.valid = False
483
+
484
+
try:
485
+
with patch(
486
+
"backend.api.audio.validate_supporter",
487
+
AsyncMock(return_value=mock_validation),
488
+
):
489
+
async with AsyncClient(
490
+
transport=ASGITransport(app=test_app), base_url="http://test"
491
+
) as client:
492
+
response = await client.get(
493
+
f"/audio/{gated_track.file_id}", follow_redirects=False
494
+
)
495
+
496
+
assert response.status_code == 402
497
+
assert "supporter access" in response.json()["detail"]
498
+
finally:
499
+
test_app.dependency_overrides.pop(get_optional_session, None)
+83
-1
backend/tests/test_moderation.py
+83
-1
backend/tests/test_moderation.py
···
4
5
import httpx
6
import pytest
7
from sqlalchemy import select
8
from sqlalchemy.ext.asyncio import AsyncSession
9
···
11
get_active_copyright_labels,
12
scan_track_for_copyright,
13
)
14
-
from backend._internal.moderation_client import ModerationClient, ScanResult
15
from backend.models import Artist, CopyrightScan, Track
16
17
···
519
520
# scan2 should still be flagged
521
assert scan2.is_flagged is True
···
4
5
import httpx
6
import pytest
7
+
from fastapi.testclient import TestClient
8
from sqlalchemy import select
9
from sqlalchemy.ext.asyncio import AsyncSession
10
···
12
get_active_copyright_labels,
13
scan_track_for_copyright,
14
)
15
+
from backend._internal.moderation_client import (
16
+
ModerationClient,
17
+
ScanResult,
18
+
SensitiveImagesResult,
19
+
)
20
from backend.models import Artist, CopyrightScan, Track
21
22
···
524
525
# scan2 should still be flagged
526
assert scan2.is_flagged is True
527
+
528
+
529
+
# tests for sensitive images
530
+
531
+
532
+
async def test_moderation_client_get_sensitive_images() -> None:
533
+
"""test ModerationClient.get_sensitive_images() with successful response."""
534
+
mock_response = Mock()
535
+
mock_response.json.return_value = {
536
+
"image_ids": ["abc123", "def456"],
537
+
"urls": ["https://example.com/image.jpg"],
538
+
}
539
+
mock_response.raise_for_status.return_value = None
540
+
541
+
client = ModerationClient(
542
+
service_url="https://test.example.com",
543
+
labeler_url="https://labeler.example.com",
544
+
auth_token="test-token",
545
+
timeout_seconds=30,
546
+
label_cache_prefix="test:label:",
547
+
label_cache_ttl_seconds=300,
548
+
)
549
+
550
+
with patch("httpx.AsyncClient.get", new_callable=AsyncMock) as mock_get:
551
+
mock_get.return_value = mock_response
552
+
553
+
result = await client.get_sensitive_images()
554
+
555
+
assert result.image_ids == ["abc123", "def456"]
556
+
assert result.urls == ["https://example.com/image.jpg"]
557
+
mock_get.assert_called_once()
558
+
559
+
560
+
async def test_moderation_client_get_sensitive_images_empty() -> None:
561
+
"""test ModerationClient.get_sensitive_images() with empty response."""
562
+
mock_response = Mock()
563
+
mock_response.json.return_value = {"image_ids": [], "urls": []}
564
+
mock_response.raise_for_status.return_value = None
565
+
566
+
client = ModerationClient(
567
+
service_url="https://test.example.com",
568
+
labeler_url="https://labeler.example.com",
569
+
auth_token="test-token",
570
+
timeout_seconds=30,
571
+
label_cache_prefix="test:label:",
572
+
label_cache_ttl_seconds=300,
573
+
)
574
+
575
+
with patch("httpx.AsyncClient.get", new_callable=AsyncMock) as mock_get:
576
+
mock_get.return_value = mock_response
577
+
578
+
result = await client.get_sensitive_images()
579
+
580
+
assert result.image_ids == []
581
+
assert result.urls == []
582
+
583
+
584
+
async def test_get_sensitive_images_endpoint(
585
+
client: TestClient,
586
+
) -> None:
587
+
"""test GET /moderation/sensitive-images endpoint proxies to moderation service."""
588
+
mock_result = SensitiveImagesResult(
589
+
image_ids=["image1", "image2"],
590
+
urls=["https://example.com/avatar.jpg"],
591
+
)
592
+
593
+
with patch("backend.api.moderation.get_moderation_client") as mock_get_client:
594
+
mock_client = AsyncMock()
595
+
mock_client.get_sensitive_images.return_value = mock_result
596
+
mock_get_client.return_value = mock_client
597
+
598
+
response = client.get("/moderation/sensitive-images")
599
+
600
+
assert response.status_code == 200
601
+
data = response.json()
602
+
assert data["image_ids"] == ["image1", "image2"]
603
+
assert data["urls"] == ["https://example.com/avatar.jpg"]
+29
-17
docs/backend/background-tasks.md
+29
-17
docs/backend/background-tasks.md
···
39
DOCKET_WORKER_CONCURRENCY=10 # concurrent task limit
40
```
41
42
when `DOCKET_URL` is not set, docket is disabled and tasks fall back to `asyncio.create_task()` (fire-and-forget).
43
44
### local development
···
54
55
### production/staging
56
57
-
Redis instances are provisioned via Upstash (managed Redis):
58
59
-
| environment | instance | region |
60
-
|-------------|----------|--------|
61
-
| production | `plyr-redis-prd` | us-east-1 (near fly.io) |
62
-
| staging | `plyr-redis-stg` | us-east-1 |
63
64
set `DOCKET_URL` in fly.io secrets:
65
```bash
66
-
flyctl secrets set DOCKET_URL=rediss://default:xxx@xxx.upstash.io:6379 -a relay-api
67
-
flyctl secrets set DOCKET_URL=rediss://default:xxx@xxx.upstash.io:6379 -a relay-api-staging
68
```
69
70
-
note: use `rediss://` (with double 's') for TLS connections to Upstash.
71
72
## usage
73
···
117
118
## costs
119
120
-
**Upstash pricing** (pay-per-request):
121
-
- free tier: 10k commands/day
122
-
- pro: $0.2 per 100k commands + $0.25/GB storage
123
124
-
for plyr.fm's volume (~100 uploads/day), this stays well within free tier or costs $0-5/mo.
125
-
126
-
**tips to avoid surprise bills**:
127
-
- use **regional** (not global) replication
128
-
- set **max data limit** (256MB is plenty for a task queue)
129
-
- monitor usage in Upstash dashboard
130
131
## fallback behavior
132
···
39
DOCKET_WORKER_CONCURRENCY=10 # concurrent task limit
40
```
41
42
+
### ⚠️ worker settings - do not modify
43
+
44
+
the worker is initialized in `backend/_internal/background.py` with pydocket's defaults. **do not change these settings without extensive testing:**
45
+
46
+
| setting | default | why it matters |
47
+
|---------|---------|----------------|
48
+
| `heartbeat_interval` | 2s | changing this broke all task execution (2025-12-30 incident) |
49
+
| `minimum_check_interval` | 1s | affects how quickly tasks are picked up |
50
+
| `scheduling_resolution` | 1s | affects scheduled task precision |
51
+
52
+
**2025-12-30 incident**: setting `heartbeat_interval=30s` caused all scheduled tasks (likes, comments, exports) to silently fail while perpetual tasks continued running. root cause unclear - correlation was definitive but mechanism wasn't found in pydocket source. reverted in PR #669.
53
+
54
+
if you need to tune worker settings:
55
+
1. test extensively in staging with real task volume
56
+
2. verify ALL task types execute (not just perpetual tasks)
57
+
3. check logfire for task execution spans
58
+
59
when `DOCKET_URL` is not set, docket is disabled and tasks fall back to `asyncio.create_task()` (fire-and-forget).
60
61
### local development
···
71
72
### production/staging
73
74
+
Redis instances are self-hosted on Fly.io (redis:7-alpine):
75
76
+
| environment | fly app | region |
77
+
|-------------|---------|--------|
78
+
| production | `plyr-redis` | iad |
79
+
| staging | `plyr-redis-stg` | iad |
80
81
set `DOCKET_URL` in fly.io secrets:
82
```bash
83
+
flyctl secrets set DOCKET_URL=redis://plyr-redis.internal:6379 -a relay-api
84
+
flyctl secrets set DOCKET_URL=redis://plyr-redis-stg.internal:6379 -a relay-api-staging
85
```
86
87
+
note: uses Fly internal networking (`.internal` domain), no TLS needed within private network.
88
89
## usage
90
···
134
135
## costs
136
137
+
**self-hosted Redis on Fly.io** (fixed monthly):
138
+
- ~$2/month per instance (256MB shared-cpu VM)
139
+
- ~$4/month total for prod + staging
140
141
+
this replaced Upstash pay-per-command pricing which was costing ~$75/month at scale (37M commands/month).
142
143
## fallback behavior
144
+3
-1
docs/backend/configuration.md
+3
-1
docs/backend/configuration.md
···
27
28
# storage settings (cloudflare r2)
29
settings.storage.backend # from STORAGE_BACKEND
30
-
settings.storage.r2_bucket # from R2_BUCKET (audio files)
31
settings.storage.r2_image_bucket # from R2_IMAGE_BUCKET (image files)
32
settings.storage.r2_endpoint_url # from R2_ENDPOINT_URL
33
settings.storage.r2_public_bucket_url # from R2_PUBLIC_BUCKET_URL (audio files)
···
84
# storage
85
STORAGE_BACKEND=r2 # or "filesystem"
86
R2_BUCKET=your-audio-bucket
87
R2_IMAGE_BUCKET=your-image-bucket
88
R2_ENDPOINT_URL=https://xxx.r2.cloudflarestorage.com
89
R2_PUBLIC_BUCKET_URL=https://pub-xxx.r2.dev # for audio files
···
27
28
# storage settings (cloudflare r2)
29
settings.storage.backend # from STORAGE_BACKEND
30
+
settings.storage.r2_bucket # from R2_BUCKET (public audio files)
31
+
settings.storage.r2_private_bucket # from R2_PRIVATE_BUCKET (gated audio files)
32
settings.storage.r2_image_bucket # from R2_IMAGE_BUCKET (image files)
33
settings.storage.r2_endpoint_url # from R2_ENDPOINT_URL
34
settings.storage.r2_public_bucket_url # from R2_PUBLIC_BUCKET_URL (audio files)
···
85
# storage
86
STORAGE_BACKEND=r2 # or "filesystem"
87
R2_BUCKET=your-audio-bucket
88
+
R2_PRIVATE_BUCKET=your-private-audio-bucket # for supporter-gated content
89
R2_IMAGE_BUCKET=your-image-bucket
90
R2_ENDPOINT_URL=https://xxx.r2.cloudflarestorage.com
91
R2_PUBLIC_BUCKET_URL=https://pub-xxx.r2.dev # for audio files
+2
-2
docs/deployment/environments.md
+2
-2
docs/deployment/environments.md
···
7
| environment | trigger | backend URL | database | redis | frontend | storage |
8
|-------------|---------|-------------|----------|-------|----------|---------|
9
| **development** | local | localhost:8001 | plyr-dev (neon) | localhost:6379 (docker) | localhost:5173 | audio-dev, images-dev (r2) |
10
-
| **staging** | push to main | api-stg.plyr.fm | plyr-stg (neon) | plyr-redis-stg (upstash) | stg.plyr.fm (main branch) | audio-staging, images-staging (r2) |
11
-
| **production** | github release | api.plyr.fm | plyr-prd (neon) | plyr-redis-prd (upstash) | plyr.fm (production-fe branch) | audio-prod, images-prod (r2) |
12
13
## workflow
14
···
7
| environment | trigger | backend URL | database | redis | frontend | storage |
8
|-------------|---------|-------------|----------|-------|----------|---------|
9
| **development** | local | localhost:8001 | plyr-dev (neon) | localhost:6379 (docker) | localhost:5173 | audio-dev, images-dev (r2) |
10
+
| **staging** | push to main | api-stg.plyr.fm | plyr-stg (neon) | plyr-redis-stg (fly.io) | stg.plyr.fm (main branch) | audio-staging, images-staging (r2) |
11
+
| **production** | github release | api.plyr.fm | plyr-prd (neon) | plyr-redis (fly.io) | plyr.fm (production-fe branch) | audio-prod, images-prod (r2) |
12
13
## workflow
14
+117
docs/frontend/design-tokens.md
+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
- is bound to form inputs (`bind:value={title}`)
39
- is checked in reactive blocks (`$effect(() => { ... })`)
40
41
+
### overridable `$derived` for optimistic UI (Svelte 5.25+)
42
+
43
+
as of Svelte 5.25, `$derived` values can be temporarily overridden by reassignment. this is the recommended pattern for optimistic UI where you want to:
44
+
1. sync with a prop value (derived behavior)
45
+
2. temporarily override for immediate feedback (state behavior)
46
+
3. auto-reset when the prop updates
47
+
48
+
```typescript
49
+
// ✅ RECOMMENDED for optimistic UI (Svelte 5.25+)
50
+
let liked = $derived(initialLiked);
51
+
52
+
async function toggleLike() {
53
+
const previous = liked;
54
+
liked = !liked; // optimistic update - works in 5.25+!
55
+
56
+
try {
57
+
await saveLike(liked);
58
+
} catch {
59
+
liked = previous; // revert on failure
60
+
}
61
+
}
62
+
```
63
+
64
+
this replaces the older pattern of `$state` + `$effect` to sync with props:
65
+
66
+
```typescript
67
+
// ❌ OLD pattern - still works but more verbose
68
+
let liked = $state(initialLiked);
69
+
70
+
$effect(() => {
71
+
liked = initialLiked; // sync with prop
72
+
});
73
+
```
74
+
75
use plain `let` for:
76
- constants that never change
77
- variables only used in functions/callbacks (not template)
+1
docs/local-development/setup.md
+1
docs/local-development/setup.md
···
53
# storage (r2 or filesystem)
54
STORAGE_BACKEND=filesystem # or "r2" for cloudflare r2
55
R2_BUCKET=audio-dev
56
+
R2_PRIVATE_BUCKET=audio-private-dev # for supporter-gated content
57
R2_IMAGE_BUCKET=images-dev
58
R2_ENDPOINT_URL=<your-r2-endpoint>
59
R2_PUBLIC_BUCKET_URL=<your-r2-public-url>
+259
docs/research/2025-12-20-atprotofans-paywall-integration.md
+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
* **`X-XSS-Protection: 1; mode=block`:** Enables browser cross-site scripting filters.
25
* **`Referrer-Policy: strict-origin-when-cross-origin`:** Controls how much referrer information is included with requests.
26
27
## CORS
28
29
Cross-Origin Resource Sharing (CORS) is configured to allow:
···
24
* **`X-XSS-Protection: 1; mode=block`:** Enables browser cross-site scripting filters.
25
* **`Referrer-Policy: strict-origin-when-cross-origin`:** Controls how much referrer information is included with requests.
26
27
+
## Supporter-Gated Content
28
+
29
+
Tracks with `support_gate` set require atprotofans supporter validation before streaming.
30
+
31
+
### Access Model
32
+
33
+
```
34
+
request → /audio/{file_id} → check support_gate
35
+
↓
36
+
┌──────────┴──────────┐
37
+
↓ ↓
38
+
public gated track
39
+
↓ ↓
40
+
307 → R2 CDN validate_supporter()
41
+
↓
42
+
┌──────────┴──────────┐
43
+
↓ ↓
44
+
is supporter not supporter
45
+
↓ ↓
46
+
presigned URL (5min) 402 error
47
+
```
48
+
49
+
### Storage Architecture
50
+
51
+
- **public bucket**: `plyr-audio` - CDN-backed, public read access
52
+
- **private bucket**: `plyr-audio-private` - no public access, presigned URLs only
53
+
54
+
when `support_gate` is toggled, a background task moves the file between buckets.
55
+
56
+
### Presigned URL Behavior
57
+
58
+
presigned URLs are time-limited (5 minutes) and grant direct R2 access. security considerations:
59
+
60
+
1. **URL sharing**: a supporter could share the presigned URL. mitigation: short TTL, URLs expire quickly.
61
+
62
+
2. **offline caching**: if a supporter downloads content (via "download liked tracks"), the cached audio persists locally even if support lapses. this is **intentional** - they legitimately accessed it when authorized.
63
+
64
+
3. **auto-download + gated tracks**: the `gated` field is viewer-resolved (true = no access, false = has access). when liking a track with auto-download enabled:
65
+
- **supporters** (`gated === false`): download proceeds normally via presigned URL
66
+
- **non-supporters** (`gated === true`): download is skipped client-side to avoid wasted 402 requests
67
+
68
+
### ATProto Record Behavior
69
+
70
+
when a track is gated, the ATProto `fm.plyr.track` record's `audioUrl` changes:
71
+
- **public**: points to R2 CDN URL (e.g., `https://cdn.plyr.fm/audio/abc123.mp3`)
72
+
- **gated**: points to API endpoint (e.g., `https://api.plyr.fm/audio/abc123`)
73
+
74
+
this means ATProto clients cannot stream gated content without authentication through plyr.fm's API.
75
+
76
+
### Validation Caching
77
+
78
+
currently, `validate_supporter()` makes a fresh call to atprotofans on every request. for high-traffic gated tracks, consider adding a short TTL cache (e.g., 60s in redis) to reduce latency and avoid rate limits.
79
+
80
## CORS
81
82
Cross-Origin Resource Sharing (CORS) is configured to allow:
+1
frontend/CLAUDE.md
+1
frontend/CLAUDE.md
···
6
- **state**: global managers in `lib/*.svelte.ts` using `$state` runes (player, queue, uploader, tracks cache)
7
- **components**: reusable ui in `lib/components/` (LikeButton, Toast, Player, etc)
8
- **routes**: pages in `routes/` with `+page.svelte` and `+page.ts` for data loading
9
10
gotchas:
11
- **svelte 5 runes mode**: component-local state MUST use `$state()` - plain `let` has no reactivity (see `docs/frontend/state-management.md`)
···
6
- **state**: global managers in `lib/*.svelte.ts` using `$state` runes (player, queue, uploader, tracks cache)
7
- **components**: reusable ui in `lib/components/` (LikeButton, Toast, Player, etc)
8
- **routes**: pages in `routes/` with `+page.svelte` and `+page.ts` for data loading
9
+
- **design tokens**: use CSS variables from `+layout.svelte` - never hardcode colors, radii, or font sizes (see `docs/frontend/design-tokens.md`)
10
11
gotchas:
12
- **svelte 5 runes mode**: component-local state MUST use `$state()` - plain `let` has no reactivity (see `docs/frontend/state-management.md`)
+29
frontend/src/lib/breakpoints.ts
+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
trackUri?: string;
11
trackCid?: string;
12
fileId?: string;
13
initialLiked?: boolean;
14
disabled?: boolean;
15
disabledReason?: string;
···
25
trackUri,
26
trackCid,
27
fileId,
28
initialLiked = false,
29
disabled = false,
30
disabledReason,
···
102
103
try {
104
const success = liked
105
-
? await likeTrack(trackId, fileId)
106
: await unlikeTrack(trackId);
107
108
if (!success) {
···
437
justify-content: center;
438
background: transparent;
439
border: 1px solid var(--border-default);
440
-
border-radius: 4px;
441
color: var(--text-tertiary);
442
cursor: pointer;
443
transition: all 0.2s;
···
488
min-width: 200px;
489
background: var(--bg-secondary);
490
border: 1px solid var(--border-default);
491
-
border-radius: 8px;
492
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
493
overflow: hidden;
494
z-index: 10;
···
508
background: transparent;
509
border: none;
510
color: var(--text-primary);
511
-
font-size: 0.9rem;
512
font-family: inherit;
513
cursor: pointer;
514
transition: background 0.15s;
···
542
border: none;
543
border-bottom: 1px solid var(--border-subtle);
544
color: var(--text-secondary);
545
-
font-size: 0.85rem;
546
font-family: inherit;
547
cursor: pointer;
548
transition: background 0.15s;
···
565
566
.playlist-list::-webkit-scrollbar-track {
567
background: transparent;
568
-
border-radius: 4px;
569
}
570
571
.playlist-list::-webkit-scrollbar-thumb {
572
background: var(--border-default);
573
-
border-radius: 4px;
574
}
575
576
.playlist-list::-webkit-scrollbar-thumb:hover {
···
586
background: transparent;
587
border: none;
588
color: var(--text-primary);
589
-
font-size: 0.9rem;
590
font-family: inherit;
591
cursor: pointer;
592
transition: background 0.15s;
···
605
.playlist-thumb-placeholder {
606
width: 32px;
607
height: 32px;
608
-
border-radius: 4px;
609
flex-shrink: 0;
610
}
611
···
637
gap: 0.5rem;
638
padding: 1.5rem 1rem;
639
color: var(--text-tertiary);
640
-
font-size: 0.85rem;
641
}
642
643
.create-playlist-btn {
···
650
border: none;
651
border-top: 1px solid var(--border-subtle);
652
color: var(--accent);
653
-
font-size: 0.9rem;
654
font-family: inherit;
655
cursor: pointer;
656
transition: background 0.15s;
···
673
padding: 0.625rem 0.75rem;
674
background: var(--bg-tertiary);
675
border: 1px solid var(--border-default);
676
-
border-radius: 6px;
677
color: var(--text-primary);
678
font-family: inherit;
679
-
font-size: 0.9rem;
680
}
681
682
.create-form input:focus {
···
696
padding: 0.625rem 1rem;
697
background: var(--accent);
698
border: none;
699
-
border-radius: 6px;
700
color: white;
701
font-family: inherit;
702
-
font-size: 0.9rem;
703
font-weight: 500;
704
cursor: pointer;
705
transition: opacity 0.15s;
···
719
height: 16px;
720
border: 2px solid var(--border-default);
721
border-top-color: var(--accent);
722
-
border-radius: 50%;
723
animation: spin 0.8s linear infinite;
724
}
725
···
781
782
.menu-item {
783
padding: 1rem 1.25rem;
784
-
font-size: 1rem;
785
}
786
787
.back-button {
···
10
trackUri?: string;
11
trackCid?: string;
12
fileId?: string;
13
+
gated?: boolean;
14
initialLiked?: boolean;
15
disabled?: boolean;
16
disabledReason?: string;
···
26
trackUri,
27
trackCid,
28
fileId,
29
+
gated,
30
initialLiked = false,
31
disabled = false,
32
disabledReason,
···
104
105
try {
106
const success = liked
107
+
? await likeTrack(trackId, fileId, gated)
108
: await unlikeTrack(trackId);
109
110
if (!success) {
···
439
justify-content: center;
440
background: transparent;
441
border: 1px solid var(--border-default);
442
+
border-radius: var(--radius-sm);
443
color: var(--text-tertiary);
444
cursor: pointer;
445
transition: all 0.2s;
···
490
min-width: 200px;
491
background: var(--bg-secondary);
492
border: 1px solid var(--border-default);
493
+
border-radius: var(--radius-md);
494
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
495
overflow: hidden;
496
z-index: 10;
···
510
background: transparent;
511
border: none;
512
color: var(--text-primary);
513
+
font-size: var(--text-base);
514
font-family: inherit;
515
cursor: pointer;
516
transition: background 0.15s;
···
544
border: none;
545
border-bottom: 1px solid var(--border-subtle);
546
color: var(--text-secondary);
547
+
font-size: var(--text-sm);
548
font-family: inherit;
549
cursor: pointer;
550
transition: background 0.15s;
···
567
568
.playlist-list::-webkit-scrollbar-track {
569
background: transparent;
570
+
border-radius: var(--radius-sm);
571
}
572
573
.playlist-list::-webkit-scrollbar-thumb {
574
background: var(--border-default);
575
+
border-radius: var(--radius-sm);
576
}
577
578
.playlist-list::-webkit-scrollbar-thumb:hover {
···
588
background: transparent;
589
border: none;
590
color: var(--text-primary);
591
+
font-size: var(--text-base);
592
font-family: inherit;
593
cursor: pointer;
594
transition: background 0.15s;
···
607
.playlist-thumb-placeholder {
608
width: 32px;
609
height: 32px;
610
+
border-radius: var(--radius-sm);
611
flex-shrink: 0;
612
}
613
···
639
gap: 0.5rem;
640
padding: 1.5rem 1rem;
641
color: var(--text-tertiary);
642
+
font-size: var(--text-sm);
643
}
644
645
.create-playlist-btn {
···
652
border: none;
653
border-top: 1px solid var(--border-subtle);
654
color: var(--accent);
655
+
font-size: var(--text-base);
656
font-family: inherit;
657
cursor: pointer;
658
transition: background 0.15s;
···
675
padding: 0.625rem 0.75rem;
676
background: var(--bg-tertiary);
677
border: 1px solid var(--border-default);
678
+
border-radius: var(--radius-base);
679
color: var(--text-primary);
680
font-family: inherit;
681
+
font-size: var(--text-base);
682
}
683
684
.create-form input:focus {
···
698
padding: 0.625rem 1rem;
699
background: var(--accent);
700
border: none;
701
+
border-radius: var(--radius-base);
702
color: white;
703
font-family: inherit;
704
+
font-size: var(--text-base);
705
font-weight: 500;
706
cursor: pointer;
707
transition: opacity 0.15s;
···
721
height: 16px;
722
border: 2px solid var(--border-default);
723
border-top-color: var(--accent);
724
+
border-radius: var(--radius-full);
725
animation: spin 0.8s linear infinite;
726
}
727
···
783
784
.menu-item {
785
padding: 1rem 1.25rem;
786
+
font-size: var(--text-lg);
787
}
788
789
.back-button {
+7
-7
frontend/src/lib/components/AlbumSelect.svelte
+7
-7
frontend/src/lib/components/AlbumSelect.svelte
···
102
padding: 0.75rem;
103
background: var(--bg-primary);
104
border: 1px solid var(--border-default);
105
-
border-radius: 4px;
106
color: var(--text-primary);
107
-
font-size: 1rem;
108
font-family: inherit;
109
transition: all 0.2s;
110
}
···
127
overflow-y: auto;
128
background: var(--bg-tertiary);
129
border: 1px solid var(--border-default);
130
-
border-radius: 4px;
131
margin-top: 0.25rem;
132
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
133
}
···
139
140
.album-results::-webkit-scrollbar-track {
141
background: var(--bg-primary);
142
-
border-radius: 4px;
143
}
144
145
.album-results::-webkit-scrollbar-thumb {
146
background: var(--border-default);
147
-
border-radius: 4px;
148
}
149
150
.album-results::-webkit-scrollbar-thumb:hover {
···
203
}
204
205
.album-stats {
206
-
font-size: 0.85rem;
207
color: var(--text-tertiary);
208
overflow: hidden;
209
text-overflow: ellipsis;
···
212
213
.similar-hint {
214
margin-top: 0.5rem;
215
-
font-size: 0.85rem;
216
color: var(--warning);
217
font-style: italic;
218
margin-bottom: 0;
···
102
padding: 0.75rem;
103
background: var(--bg-primary);
104
border: 1px solid var(--border-default);
105
+
border-radius: var(--radius-sm);
106
color: var(--text-primary);
107
+
font-size: var(--text-lg);
108
font-family: inherit;
109
transition: all 0.2s;
110
}
···
127
overflow-y: auto;
128
background: var(--bg-tertiary);
129
border: 1px solid var(--border-default);
130
+
border-radius: var(--radius-sm);
131
margin-top: 0.25rem;
132
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
133
}
···
139
140
.album-results::-webkit-scrollbar-track {
141
background: var(--bg-primary);
142
+
border-radius: var(--radius-sm);
143
}
144
145
.album-results::-webkit-scrollbar-thumb {
146
background: var(--border-default);
147
+
border-radius: var(--radius-sm);
148
}
149
150
.album-results::-webkit-scrollbar-thumb:hover {
···
203
}
204
205
.album-stats {
206
+
font-size: var(--text-sm);
207
color: var(--text-tertiary);
208
overflow: hidden;
209
text-overflow: ellipsis;
···
212
213
.similar-hint {
214
margin-top: 0.5rem;
215
+
font-size: var(--text-sm);
216
color: var(--warning);
217
font-style: italic;
218
margin-bottom: 0;
+15
-15
frontend/src/lib/components/BrokenTracks.svelte
+15
-15
frontend/src/lib/components/BrokenTracks.svelte
···
194
margin-bottom: 3rem;
195
background: color-mix(in srgb, var(--warning) 5%, transparent);
196
border: 1px solid color-mix(in srgb, var(--warning) 20%, transparent);
197
-
border-radius: 8px;
198
padding: 1.5rem;
199
}
200
···
213
}
214
215
.section-header h2 {
216
-
font-size: 1.5rem;
217
margin: 0;
218
color: var(--warning);
219
}
···
222
padding: 0.5rem 1rem;
223
background: color-mix(in srgb, var(--warning) 20%, transparent);
224
border: 1px solid color-mix(in srgb, var(--warning) 50%, transparent);
225
-
border-radius: 4px;
226
color: var(--warning);
227
font-family: inherit;
228
-
font-size: 0.9rem;
229
font-weight: 600;
230
cursor: pointer;
231
transition: all 0.2s;
···
248
background: color-mix(in srgb, var(--warning) 20%, transparent);
249
color: var(--warning);
250
padding: 0.25rem 0.6rem;
251
-
border-radius: 12px;
252
-
font-size: 0.85rem;
253
font-weight: 600;
254
}
255
···
263
.broken-track-item {
264
background: var(--bg-tertiary);
265
border: 1px solid color-mix(in srgb, var(--warning) 30%, transparent);
266
-
border-radius: 6px;
267
padding: 1rem;
268
display: flex;
269
align-items: center;
···
280
}
281
282
.warning-icon {
283
-
font-size: 1.25rem;
284
flex-shrink: 0;
285
}
286
···
291
292
.track-title {
293
font-weight: 600;
294
-
font-size: 1rem;
295
margin-bottom: 0.25rem;
296
color: var(--text-primary);
297
}
298
299
.track-meta {
300
-
font-size: 0.9rem;
301
color: var(--text-secondary);
302
margin-bottom: 0.5rem;
303
}
304
305
.issue-description {
306
-
font-size: 0.85rem;
307
color: var(--warning);
308
}
309
···
311
padding: 0.5rem 1rem;
312
background: color-mix(in srgb, var(--warning) 15%, transparent);
313
border: 1px solid color-mix(in srgb, var(--warning) 40%, transparent);
314
-
border-radius: 4px;
315
color: var(--warning);
316
font-family: inherit;
317
-
font-size: 0.9rem;
318
font-weight: 500;
319
cursor: pointer;
320
transition: all 0.2s;
···
337
.info-box {
338
background: var(--bg-primary);
339
border: 1px solid var(--border-subtle);
340
-
border-radius: 6px;
341
padding: 1rem;
342
-
font-size: 0.9rem;
343
color: var(--text-secondary);
344
}
345
···
194
margin-bottom: 3rem;
195
background: color-mix(in srgb, var(--warning) 5%, transparent);
196
border: 1px solid color-mix(in srgb, var(--warning) 20%, transparent);
197
+
border-radius: var(--radius-md);
198
padding: 1.5rem;
199
}
200
···
213
}
214
215
.section-header h2 {
216
+
font-size: var(--text-3xl);
217
margin: 0;
218
color: var(--warning);
219
}
···
222
padding: 0.5rem 1rem;
223
background: color-mix(in srgb, var(--warning) 20%, transparent);
224
border: 1px solid color-mix(in srgb, var(--warning) 50%, transparent);
225
+
border-radius: var(--radius-sm);
226
color: var(--warning);
227
font-family: inherit;
228
+
font-size: var(--text-base);
229
font-weight: 600;
230
cursor: pointer;
231
transition: all 0.2s;
···
248
background: color-mix(in srgb, var(--warning) 20%, transparent);
249
color: var(--warning);
250
padding: 0.25rem 0.6rem;
251
+
border-radius: var(--radius-lg);
252
+
font-size: var(--text-sm);
253
font-weight: 600;
254
}
255
···
263
.broken-track-item {
264
background: var(--bg-tertiary);
265
border: 1px solid color-mix(in srgb, var(--warning) 30%, transparent);
266
+
border-radius: var(--radius-base);
267
padding: 1rem;
268
display: flex;
269
align-items: center;
···
280
}
281
282
.warning-icon {
283
+
font-size: var(--text-2xl);
284
flex-shrink: 0;
285
}
286
···
291
292
.track-title {
293
font-weight: 600;
294
+
font-size: var(--text-lg);
295
margin-bottom: 0.25rem;
296
color: var(--text-primary);
297
}
298
299
.track-meta {
300
+
font-size: var(--text-base);
301
color: var(--text-secondary);
302
margin-bottom: 0.5rem;
303
}
304
305
.issue-description {
306
+
font-size: var(--text-sm);
307
color: var(--warning);
308
}
309
···
311
padding: 0.5rem 1rem;
312
background: color-mix(in srgb, var(--warning) 15%, transparent);
313
border: 1px solid color-mix(in srgb, var(--warning) 40%, transparent);
314
+
border-radius: var(--radius-sm);
315
color: var(--warning);
316
font-family: inherit;
317
+
font-size: var(--text-base);
318
font-weight: 500;
319
cursor: pointer;
320
transition: all 0.2s;
···
337
.info-box {
338
background: var(--bg-primary);
339
border: 1px solid var(--border-subtle);
340
+
border-radius: var(--radius-base);
341
padding: 1rem;
342
+
font-size: var(--text-base);
343
color: var(--text-secondary);
344
}
345
+8
-8
frontend/src/lib/components/ColorSettings.svelte
+8
-8
frontend/src/lib/components/ColorSettings.svelte
···
115
border: 1px solid var(--border-default);
116
color: var(--text-secondary);
117
padding: 0.5rem;
118
-
border-radius: 4px;
119
cursor: pointer;
120
transition: all 0.2s;
121
display: flex;
···
134
right: 0;
135
background: var(--bg-secondary);
136
border: 1px solid var(--border-default);
137
-
border-radius: 6px;
138
padding: 1rem;
139
min-width: 240px;
140
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
···
147
align-items: center;
148
margin-bottom: 1rem;
149
color: var(--text-primary);
150
-
font-size: 0.9rem;
151
}
152
153
.close-btn {
154
background: transparent;
155
border: none;
156
color: var(--text-secondary);
157
-
font-size: 1.5rem;
158
cursor: pointer;
159
padding: 0;
160
width: 24px;
···
182
width: 48px;
183
height: 32px;
184
border: 1px solid var(--border-default);
185
-
border-radius: 4px;
186
cursor: pointer;
187
background: transparent;
188
}
···
198
199
.color-value {
200
font-family: monospace;
201
-
font-size: 0.85rem;
202
color: var(--text-secondary);
203
}
204
···
209
}
210
211
.presets-label {
212
-
font-size: 0.85rem;
213
color: var(--text-tertiary);
214
}
215
···
222
.preset-btn {
223
width: 32px;
224
height: 32px;
225
-
border-radius: 4px;
226
border: 2px solid transparent;
227
cursor: pointer;
228
transition: all 0.2s;
···
115
border: 1px solid var(--border-default);
116
color: var(--text-secondary);
117
padding: 0.5rem;
118
+
border-radius: var(--radius-sm);
119
cursor: pointer;
120
transition: all 0.2s;
121
display: flex;
···
134
right: 0;
135
background: var(--bg-secondary);
136
border: 1px solid var(--border-default);
137
+
border-radius: var(--radius-base);
138
padding: 1rem;
139
min-width: 240px;
140
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
···
147
align-items: center;
148
margin-bottom: 1rem;
149
color: var(--text-primary);
150
+
font-size: var(--text-base);
151
}
152
153
.close-btn {
154
background: transparent;
155
border: none;
156
color: var(--text-secondary);
157
+
font-size: var(--text-3xl);
158
cursor: pointer;
159
padding: 0;
160
width: 24px;
···
182
width: 48px;
183
height: 32px;
184
border: 1px solid var(--border-default);
185
+
border-radius: var(--radius-sm);
186
cursor: pointer;
187
background: transparent;
188
}
···
198
199
.color-value {
200
font-family: monospace;
201
+
font-size: var(--text-sm);
202
color: var(--text-secondary);
203
}
204
···
209
}
210
211
.presets-label {
212
+
font-size: var(--text-sm);
213
color: var(--text-tertiary);
214
}
215
···
222
.preset-btn {
223
width: 32px;
224
height: 32px;
225
+
border-radius: var(--radius-sm);
226
border: 2px solid transparent;
227
cursor: pointer;
228
transition: all 0.2s;
+9
-9
frontend/src/lib/components/HandleAutocomplete.svelte
+9
-9
frontend/src/lib/components/HandleAutocomplete.svelte
···
131
padding: 0.75rem;
132
background: var(--bg-primary);
133
border: 1px solid var(--border-default);
134
-
border-radius: 4px;
135
color: var(--text-primary);
136
-
font-size: 1rem;
137
font-family: inherit;
138
transition: border-color 0.2s;
139
box-sizing: border-box;
···
159
top: 50%;
160
transform: translateY(-50%);
161
color: var(--text-muted);
162
-
font-size: 0.85rem;
163
}
164
165
.results {
···
170
overflow-y: auto;
171
background: var(--bg-tertiary);
172
border: 1px solid var(--border-default);
173
-
border-radius: 4px;
174
margin-top: 0.25rem;
175
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
176
scrollbar-width: thin;
···
183
184
.results::-webkit-scrollbar-track {
185
background: var(--bg-primary);
186
-
border-radius: 4px;
187
}
188
189
.results::-webkit-scrollbar-thumb {
190
background: var(--border-default);
191
-
border-radius: 4px;
192
}
193
194
.results::-webkit-scrollbar-thumb:hover {
···
222
.avatar {
223
width: 36px;
224
height: 36px;
225
-
border-radius: 50%;
226
object-fit: cover;
227
border: 2px solid var(--border-default);
228
flex-shrink: 0;
···
231
.avatar-placeholder {
232
width: 36px;
233
height: 36px;
234
-
border-radius: 50%;
235
background: var(--border-default);
236
flex-shrink: 0;
237
}
···
252
}
253
254
.handle {
255
-
font-size: 0.85rem;
256
color: var(--text-tertiary);
257
overflow: hidden;
258
text-overflow: ellipsis;
···
131
padding: 0.75rem;
132
background: var(--bg-primary);
133
border: 1px solid var(--border-default);
134
+
border-radius: var(--radius-sm);
135
color: var(--text-primary);
136
+
font-size: var(--text-lg);
137
font-family: inherit;
138
transition: border-color 0.2s;
139
box-sizing: border-box;
···
159
top: 50%;
160
transform: translateY(-50%);
161
color: var(--text-muted);
162
+
font-size: var(--text-sm);
163
}
164
165
.results {
···
170
overflow-y: auto;
171
background: var(--bg-tertiary);
172
border: 1px solid var(--border-default);
173
+
border-radius: var(--radius-sm);
174
margin-top: 0.25rem;
175
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
176
scrollbar-width: thin;
···
183
184
.results::-webkit-scrollbar-track {
185
background: var(--bg-primary);
186
+
border-radius: var(--radius-sm);
187
}
188
189
.results::-webkit-scrollbar-thumb {
190
background: var(--border-default);
191
+
border-radius: var(--radius-sm);
192
}
193
194
.results::-webkit-scrollbar-thumb:hover {
···
222
.avatar {
223
width: 36px;
224
height: 36px;
225
+
border-radius: var(--radius-full);
226
object-fit: cover;
227
border: 2px solid var(--border-default);
228
flex-shrink: 0;
···
231
.avatar-placeholder {
232
width: 36px;
233
height: 36px;
234
+
border-radius: var(--radius-full);
235
background: var(--border-default);
236
flex-shrink: 0;
237
}
···
252
}
253
254
.handle {
255
+
font-size: var(--text-sm);
256
color: var(--text-tertiary);
257
overflow: hidden;
258
text-overflow: ellipsis;
+15
-15
frontend/src/lib/components/HandleSearch.svelte
+15
-15
frontend/src/lib/components/HandleSearch.svelte
···
179
padding: 0.75rem;
180
background: var(--bg-primary);
181
border: 1px solid var(--border-default);
182
-
border-radius: 4px;
183
color: var(--text-primary);
184
-
font-size: 1rem;
185
font-family: inherit;
186
transition: all 0.2s;
187
}
···
201
right: 0.75rem;
202
top: 50%;
203
transform: translateY(-50%);
204
-
font-size: 0.85rem;
205
color: var(--text-muted);
206
}
207
···
213
overflow-y: auto;
214
background: var(--bg-tertiary);
215
border: 1px solid var(--border-default);
216
-
border-radius: 4px;
217
margin-top: 0.25rem;
218
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
219
}
···
225
226
.search-results::-webkit-scrollbar-track {
227
background: var(--bg-primary);
228
-
border-radius: 4px;
229
}
230
231
.search-results::-webkit-scrollbar-thumb {
232
background: var(--border-default);
233
-
border-radius: 4px;
234
}
235
236
.search-results::-webkit-scrollbar-thumb:hover {
···
277
.result-avatar {
278
width: 36px;
279
height: 36px;
280
-
border-radius: 50%;
281
object-fit: cover;
282
border: 2px solid var(--border-default);
283
flex-shrink: 0;
···
299
}
300
301
.result-handle {
302
-
font-size: 0.85rem;
303
color: var(--text-tertiary);
304
overflow: hidden;
305
text-overflow: ellipsis;
···
320
padding: 0.5rem 0.75rem;
321
background: color-mix(in srgb, var(--accent) 10%, var(--bg-tertiary));
322
border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border-subtle));
323
-
border-radius: 20px;
324
color: var(--text-primary);
325
-
font-size: 0.9rem;
326
}
327
328
.chip-avatar {
329
width: 24px;
330
height: 24px;
331
-
border-radius: 50%;
332
object-fit: cover;
333
border: 1px solid var(--border-default);
334
}
···
365
366
.max-features-message {
367
margin-top: 0.5rem;
368
-
font-size: 0.85rem;
369
color: var(--warning);
370
}
371
···
374
padding: 0.75rem;
375
background: color-mix(in srgb, var(--warning) 10%, var(--bg-primary));
376
border: 1px solid color-mix(in srgb, var(--warning) 20%, var(--border-subtle));
377
-
border-radius: 4px;
378
color: var(--warning);
379
-
font-size: 0.9rem;
380
text-align: center;
381
}
382
···
401
402
.selected-artist-chip {
403
padding: 0.4rem 0.6rem;
404
-
font-size: 0.85rem;
405
}
406
407
.chip-avatar {
···
179
padding: 0.75rem;
180
background: var(--bg-primary);
181
border: 1px solid var(--border-default);
182
+
border-radius: var(--radius-sm);
183
color: var(--text-primary);
184
+
font-size: var(--text-lg);
185
font-family: inherit;
186
transition: all 0.2s;
187
}
···
201
right: 0.75rem;
202
top: 50%;
203
transform: translateY(-50%);
204
+
font-size: var(--text-sm);
205
color: var(--text-muted);
206
}
207
···
213
overflow-y: auto;
214
background: var(--bg-tertiary);
215
border: 1px solid var(--border-default);
216
+
border-radius: var(--radius-sm);
217
margin-top: 0.25rem;
218
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
219
}
···
225
226
.search-results::-webkit-scrollbar-track {
227
background: var(--bg-primary);
228
+
border-radius: var(--radius-sm);
229
}
230
231
.search-results::-webkit-scrollbar-thumb {
232
background: var(--border-default);
233
+
border-radius: var(--radius-sm);
234
}
235
236
.search-results::-webkit-scrollbar-thumb:hover {
···
277
.result-avatar {
278
width: 36px;
279
height: 36px;
280
+
border-radius: var(--radius-full);
281
object-fit: cover;
282
border: 2px solid var(--border-default);
283
flex-shrink: 0;
···
299
}
300
301
.result-handle {
302
+
font-size: var(--text-sm);
303
color: var(--text-tertiary);
304
overflow: hidden;
305
text-overflow: ellipsis;
···
320
padding: 0.5rem 0.75rem;
321
background: color-mix(in srgb, var(--accent) 10%, var(--bg-tertiary));
322
border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border-subtle));
323
+
border-radius: var(--radius-xl);
324
color: var(--text-primary);
325
+
font-size: var(--text-base);
326
}
327
328
.chip-avatar {
329
width: 24px;
330
height: 24px;
331
+
border-radius: var(--radius-full);
332
object-fit: cover;
333
border: 1px solid var(--border-default);
334
}
···
365
366
.max-features-message {
367
margin-top: 0.5rem;
368
+
font-size: var(--text-sm);
369
color: var(--warning);
370
}
371
···
374
padding: 0.75rem;
375
background: color-mix(in srgb, var(--warning) 10%, var(--bg-primary));
376
border: 1px solid color-mix(in srgb, var(--warning) 20%, var(--border-subtle));
377
+
border-radius: var(--radius-sm);
378
color: var(--warning);
379
+
font-size: var(--text-base);
380
text-align: center;
381
}
382
···
401
402
.selected-artist-chip {
403
padding: 0.4rem 0.6rem;
404
+
font-size: var(--text-sm);
405
}
406
407
.chip-avatar {
+18
-18
frontend/src/lib/components/Header.svelte
+18
-18
frontend/src/lib/components/Header.svelte
···
228
justify-content: center;
229
width: 44px;
230
height: 44px;
231
-
border-radius: 10px;
232
background: transparent;
233
border: none;
234
color: var(--text-secondary);
···
275
border: 1px solid var(--border-emphasis);
276
color: var(--text-secondary);
277
padding: 0.5rem 1rem;
278
-
border-radius: 6px;
279
-
font-size: 0.9rem;
280
font-family: inherit;
281
cursor: pointer;
282
transition: all 0.2s;
···
309
}
310
311
.tangled-icon {
312
-
border-radius: 4px;
313
opacity: 0.7;
314
transition: opacity 0.2s, box-shadow 0.2s;
315
}
···
320
}
321
322
h1 {
323
-
font-size: 1.5rem;
324
margin: 0;
325
color: var(--text-primary);
326
transition: color 0.2s;
···
353
.nav-link {
354
color: var(--text-secondary);
355
text-decoration: none;
356
-
font-size: 0.9rem;
357
transition: all 0.2s;
358
white-space: nowrap;
359
display: flex;
360
align-items: center;
361
gap: 0.4rem;
362
padding: 0.4rem 0.75rem;
363
-
border-radius: 6px;
364
border: 1px solid transparent;
365
}
366
···
388
.user-handle {
389
color: var(--text-secondary);
390
text-decoration: none;
391
-
font-size: 0.9rem;
392
padding: 0.4rem 0.75rem;
393
background: var(--bg-tertiary);
394
-
border-radius: 6px;
395
border: 1px solid var(--border-default);
396
transition: all 0.2s;
397
white-space: nowrap;
···
408
border: 1px solid var(--accent);
409
color: var(--accent);
410
padding: 0.5rem 1rem;
411
-
border-radius: 6px;
412
-
font-size: 0.9rem;
413
text-decoration: none;
414
transition: all 0.2s;
415
cursor: pointer;
···
421
color: var(--bg-primary);
422
}
423
424
-
/* Hide margin-positioned elements and switch to mobile layout at the same breakpoint.
425
-
Account for queue panel (320px) potentially being open - need extra headroom */
426
-
@media (max-width: 1599px) {
427
.margin-left,
428
.logout-right {
429
display: none !important;
···
442
}
443
}
444
445
-
/* Smaller screens: compact header */
446
@media (max-width: 768px) {
447
.header-content {
448
padding: 0.75rem 0.75rem;
···
467
468
.nav-link {
469
padding: 0.3rem 0.5rem;
470
-
font-size: 0.8rem;
471
}
472
473
.nav-link span {
···
475
}
476
477
.user-handle {
478
-
font-size: 0.8rem;
479
padding: 0.3rem 0.5rem;
480
}
481
482
.btn-primary {
483
-
font-size: 0.8rem;
484
padding: 0.3rem 0.65rem;
485
}
486
}
···
228
justify-content: center;
229
width: 44px;
230
height: 44px;
231
+
border-radius: var(--radius-md);
232
background: transparent;
233
border: none;
234
color: var(--text-secondary);
···
275
border: 1px solid var(--border-emphasis);
276
color: var(--text-secondary);
277
padding: 0.5rem 1rem;
278
+
border-radius: var(--radius-base);
279
+
font-size: var(--text-base);
280
font-family: inherit;
281
cursor: pointer;
282
transition: all 0.2s;
···
309
}
310
311
.tangled-icon {
312
+
border-radius: var(--radius-sm);
313
opacity: 0.7;
314
transition: opacity 0.2s, box-shadow 0.2s;
315
}
···
320
}
321
322
h1 {
323
+
font-size: var(--text-3xl);
324
margin: 0;
325
color: var(--text-primary);
326
transition: color 0.2s;
···
353
.nav-link {
354
color: var(--text-secondary);
355
text-decoration: none;
356
+
font-size: var(--text-base);
357
transition: all 0.2s;
358
white-space: nowrap;
359
display: flex;
360
align-items: center;
361
gap: 0.4rem;
362
padding: 0.4rem 0.75rem;
363
+
border-radius: var(--radius-base);
364
border: 1px solid transparent;
365
}
366
···
388
.user-handle {
389
color: var(--text-secondary);
390
text-decoration: none;
391
+
font-size: var(--text-base);
392
padding: 0.4rem 0.75rem;
393
background: var(--bg-tertiary);
394
+
border-radius: var(--radius-base);
395
border: 1px solid var(--border-default);
396
transition: all 0.2s;
397
white-space: nowrap;
···
408
border: 1px solid var(--accent);
409
color: var(--accent);
410
padding: 0.5rem 1rem;
411
+
border-radius: var(--radius-base);
412
+
font-size: var(--text-base);
413
text-decoration: none;
414
transition: all 0.2s;
415
cursor: pointer;
···
421
color: var(--bg-primary);
422
}
423
424
+
/* header mobile breakpoint - see $lib/breakpoints.ts
425
+
switch to mobile before margin elements crowd each other */
426
+
@media (max-width: 1300px) {
427
.margin-left,
428
.logout-right {
429
display: none !important;
···
442
}
443
}
444
445
+
/* mobile breakpoint - see $lib/breakpoints.ts */
446
@media (max-width: 768px) {
447
.header-content {
448
padding: 0.75rem 0.75rem;
···
467
468
.nav-link {
469
padding: 0.3rem 0.5rem;
470
+
font-size: var(--text-sm);
471
}
472
473
.nav-link span {
···
475
}
476
477
.user-handle {
478
+
font-size: var(--text-sm);
479
padding: 0.3rem 0.5rem;
480
}
481
482
.btn-primary {
483
+
font-size: var(--text-sm);
484
padding: 0.3rem 0.65rem;
485
}
486
}
+11
-11
frontend/src/lib/components/HiddenTagsFilter.svelte
+11
-11
frontend/src/lib/components/HiddenTagsFilter.svelte
···
126
align-items: center;
127
gap: 0.5rem;
128
flex-wrap: wrap;
129
-
font-size: 0.8rem;
130
}
131
132
.filter-toggle {
···
139
color: var(--text-tertiary);
140
cursor: pointer;
141
transition: all 0.15s;
142
-
border-radius: 6px;
143
}
144
145
.filter-toggle:hover {
···
157
}
158
159
.filter-count {
160
-
font-size: 0.7rem;
161
color: var(--text-tertiary);
162
}
163
164
.filter-label {
165
color: var(--text-tertiary);
166
white-space: nowrap;
167
-
font-size: 0.75rem;
168
font-family: inherit;
169
}
170
···
183
background: transparent;
184
border: 1px solid var(--border-default);
185
color: var(--text-secondary);
186
-
border-radius: 3px;
187
-
font-size: 0.75rem;
188
font-family: inherit;
189
cursor: pointer;
190
transition: all 0.15s;
···
201
}
202
203
.remove-icon {
204
-
font-size: 0.8rem;
205
line-height: 1;
206
opacity: 0.5;
207
}
···
219
padding: 0;
220
background: transparent;
221
border: 1px dashed var(--border-default);
222
-
border-radius: 3px;
223
color: var(--text-tertiary);
224
-
font-size: 0.8rem;
225
cursor: pointer;
226
transition: all 0.15s;
227
}
···
236
background: transparent;
237
border: 1px solid var(--border-default);
238
color: var(--text-primary);
239
-
font-size: 0.75rem;
240
font-family: inherit;
241
min-height: 24px;
242
width: 70px;
243
outline: none;
244
-
border-radius: 3px;
245
}
246
247
.add-input:focus {
···
126
align-items: center;
127
gap: 0.5rem;
128
flex-wrap: wrap;
129
+
font-size: var(--text-sm);
130
}
131
132
.filter-toggle {
···
139
color: var(--text-tertiary);
140
cursor: pointer;
141
transition: all 0.15s;
142
+
border-radius: var(--radius-base);
143
}
144
145
.filter-toggle:hover {
···
157
}
158
159
.filter-count {
160
+
font-size: var(--text-xs);
161
color: var(--text-tertiary);
162
}
163
164
.filter-label {
165
color: var(--text-tertiary);
166
white-space: nowrap;
167
+
font-size: var(--text-xs);
168
font-family: inherit;
169
}
170
···
183
background: transparent;
184
border: 1px solid var(--border-default);
185
color: var(--text-secondary);
186
+
border-radius: var(--radius-sm);
187
+
font-size: var(--text-xs);
188
font-family: inherit;
189
cursor: pointer;
190
transition: all 0.15s;
···
201
}
202
203
.remove-icon {
204
+
font-size: var(--text-sm);
205
line-height: 1;
206
opacity: 0.5;
207
}
···
219
padding: 0;
220
background: transparent;
221
border: 1px dashed var(--border-default);
222
+
border-radius: var(--radius-sm);
223
color: var(--text-tertiary);
224
+
font-size: var(--text-sm);
225
cursor: pointer;
226
transition: all 0.15s;
227
}
···
236
background: transparent;
237
border: 1px solid var(--border-default);
238
color: var(--text-primary);
239
+
font-size: var(--text-xs);
240
font-family: inherit;
241
min-height: 24px;
242
width: 70px;
243
outline: none;
244
+
border-radius: var(--radius-sm);
245
}
246
247
.add-input:focus {
+7
-9
frontend/src/lib/components/LikeButton.svelte
+7
-9
frontend/src/lib/components/LikeButton.svelte
···
6
trackId: number;
7
trackTitle: string;
8
fileId?: string;
9
initialLiked?: boolean;
10
disabled?: boolean;
11
disabledReason?: string;
12
onLikeChange?: (_liked: boolean) => void;
13
}
14
15
-
let { trackId, trackTitle, fileId, initialLiked = false, disabled = false, disabledReason, onLikeChange }: Props = $props();
16
17
-
let liked = $state(initialLiked);
18
let loading = $state(false);
19
20
-
// update liked state when initialLiked changes
21
-
$effect(() => {
22
-
liked = initialLiked;
23
-
});
24
-
25
async function toggleLike(e: Event) {
26
e.stopPropagation();
27
···
35
36
try {
37
const success = liked
38
-
? await likeTrack(trackId, fileId)
39
: await unlikeTrack(trackId);
40
41
if (!success) {
···
70
class:disabled-state={disabled}
71
onclick={toggleLike}
72
title={disabled && disabledReason ? disabledReason : (liked ? 'unlike' : 'like')}
73
disabled={loading || disabled}
74
>
75
<svg width="16" height="16" viewBox="0 0 24 24" fill={liked ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
···
86
justify-content: center;
87
background: transparent;
88
border: 1px solid var(--border-default);
89
-
border-radius: 4px;
90
color: var(--text-tertiary);
91
cursor: pointer;
92
transition: all 0.2s;
···
6
trackId: number;
7
trackTitle: string;
8
fileId?: string;
9
+
gated?: boolean;
10
initialLiked?: boolean;
11
disabled?: boolean;
12
disabledReason?: string;
13
onLikeChange?: (_liked: boolean) => void;
14
}
15
16
+
let { trackId, trackTitle, fileId, gated, initialLiked = false, disabled = false, disabledReason, onLikeChange }: Props = $props();
17
18
+
// use overridable $derived (Svelte 5.25+) - syncs with prop but can be overridden for optimistic UI
19
+
let liked = $derived(initialLiked);
20
let loading = $state(false);
21
22
async function toggleLike(e: Event) {
23
e.stopPropagation();
24
···
32
33
try {
34
const success = liked
35
+
? await likeTrack(trackId, fileId, gated)
36
: await unlikeTrack(trackId);
37
38
if (!success) {
···
67
class:disabled-state={disabled}
68
onclick={toggleLike}
69
title={disabled && disabledReason ? disabledReason : (liked ? 'unlike' : 'like')}
70
+
aria-label={disabled && disabledReason ? disabledReason : (liked ? 'unlike' : 'like')}
71
disabled={loading || disabled}
72
>
73
<svg width="16" height="16" viewBox="0 0 24 24" fill={liked ? 'currentColor' : 'none'} stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
···
84
justify-content: center;
85
background: transparent;
86
border: 1px solid var(--border-default);
87
+
border-radius: var(--radius-sm);
88
color: var(--text-tertiary);
89
cursor: pointer;
90
transition: all 0.2s;
+9
-9
frontend/src/lib/components/LikersTooltip.svelte
+9
-9
frontend/src/lib/components/LikersTooltip.svelte
···
134
margin-bottom: 0.5rem;
135
background: var(--bg-secondary);
136
border: 1px solid var(--border-default);
137
-
border-radius: 8px;
138
padding: 0.75rem;
139
min-width: 240px;
140
max-width: 320px;
···
155
.error,
156
.empty {
157
color: var(--text-tertiary);
158
-
font-size: 0.85rem;
159
text-align: center;
160
padding: 0.5rem;
161
}
···
177
align-items: center;
178
gap: 0.75rem;
179
padding: 0.5rem;
180
-
border-radius: 6px;
181
text-decoration: none;
182
transition: background 0.2s;
183
}
···
190
.avatar-placeholder {
191
width: 32px;
192
height: 32px;
193
-
border-radius: 50%;
194
flex-shrink: 0;
195
}
196
···
206
justify-content: center;
207
color: var(--text-tertiary);
208
font-weight: 600;
209
-
font-size: 0.9rem;
210
}
211
212
.liker-info {
···
217
.display-name {
218
color: var(--text-primary);
219
font-weight: 500;
220
-
font-size: 0.9rem;
221
white-space: nowrap;
222
overflow: hidden;
223
text-overflow: ellipsis;
···
225
226
.handle {
227
color: var(--text-tertiary);
228
-
font-size: 0.8rem;
229
white-space: nowrap;
230
overflow: hidden;
231
text-overflow: ellipsis;
···
233
234
.liked-time {
235
color: var(--text-muted);
236
-
font-size: 0.75rem;
237
flex-shrink: 0;
238
}
239
···
248
249
.likers-list::-webkit-scrollbar-thumb {
250
background: var(--border-default);
251
-
border-radius: 3px;
252
}
253
254
.likers-list::-webkit-scrollbar-thumb:hover {
···
134
margin-bottom: 0.5rem;
135
background: var(--bg-secondary);
136
border: 1px solid var(--border-default);
137
+
border-radius: var(--radius-md);
138
padding: 0.75rem;
139
min-width: 240px;
140
max-width: 320px;
···
155
.error,
156
.empty {
157
color: var(--text-tertiary);
158
+
font-size: var(--text-sm);
159
text-align: center;
160
padding: 0.5rem;
161
}
···
177
align-items: center;
178
gap: 0.75rem;
179
padding: 0.5rem;
180
+
border-radius: var(--radius-base);
181
text-decoration: none;
182
transition: background 0.2s;
183
}
···
190
.avatar-placeholder {
191
width: 32px;
192
height: 32px;
193
+
border-radius: var(--radius-full);
194
flex-shrink: 0;
195
}
196
···
206
justify-content: center;
207
color: var(--text-tertiary);
208
font-weight: 600;
209
+
font-size: var(--text-base);
210
}
211
212
.liker-info {
···
217
.display-name {
218
color: var(--text-primary);
219
font-weight: 500;
220
+
font-size: var(--text-base);
221
white-space: nowrap;
222
overflow: hidden;
223
text-overflow: ellipsis;
···
225
226
.handle {
227
color: var(--text-tertiary);
228
+
font-size: var(--text-sm);
229
white-space: nowrap;
230
overflow: hidden;
231
text-overflow: ellipsis;
···
233
234
.liked-time {
235
color: var(--text-muted);
236
+
font-size: var(--text-xs);
237
flex-shrink: 0;
238
}
239
···
248
249
.likers-list::-webkit-scrollbar-thumb {
250
background: var(--border-default);
251
+
border-radius: var(--radius-sm);
252
}
253
254
.likers-list::-webkit-scrollbar-thumb:hover {
+8
-8
frontend/src/lib/components/LinksMenu.svelte
+8
-8
frontend/src/lib/components/LinksMenu.svelte
···
140
height: 32px;
141
background: transparent;
142
border: 1px solid var(--border-default);
143
-
border-radius: 6px;
144
color: var(--text-secondary);
145
cursor: pointer;
146
transition: all 0.2s;
···
171
width: min(320px, calc(100vw - 2rem));
172
background: var(--bg-secondary);
173
border: 1px solid var(--border-default);
174
-
border-radius: 12px;
175
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
176
z-index: 101;
177
animation: slideIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);
···
186
}
187
188
.menu-header span {
189
-
font-size: 0.9rem;
190
font-weight: 600;
191
color: var(--text-primary);
192
text-transform: uppercase;
···
201
height: 28px;
202
background: transparent;
203
border: none;
204
-
border-radius: 4px;
205
color: var(--text-secondary);
206
cursor: pointer;
207
transition: all 0.2s;
···
224
gap: 1rem;
225
padding: 1rem;
226
background: transparent;
227
-
border-radius: 8px;
228
text-decoration: none;
229
color: var(--text-primary);
230
transition: all 0.2s;
···
245
}
246
247
.tangled-menu-icon {
248
-
border-radius: 4px;
249
opacity: 0.7;
250
transition: opacity 0.2s, box-shadow 0.2s;
251
}
···
263
}
264
265
.link-title {
266
-
font-size: 0.95rem;
267
font-weight: 500;
268
color: var(--text-primary);
269
}
270
271
.link-subtitle {
272
-
font-size: 0.8rem;
273
color: var(--text-tertiary);
274
}
275
···
140
height: 32px;
141
background: transparent;
142
border: 1px solid var(--border-default);
143
+
border-radius: var(--radius-base);
144
color: var(--text-secondary);
145
cursor: pointer;
146
transition: all 0.2s;
···
171
width: min(320px, calc(100vw - 2rem));
172
background: var(--bg-secondary);
173
border: 1px solid var(--border-default);
174
+
border-radius: var(--radius-lg);
175
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
176
z-index: 101;
177
animation: slideIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);
···
186
}
187
188
.menu-header span {
189
+
font-size: var(--text-base);
190
font-weight: 600;
191
color: var(--text-primary);
192
text-transform: uppercase;
···
201
height: 28px;
202
background: transparent;
203
border: none;
204
+
border-radius: var(--radius-sm);
205
color: var(--text-secondary);
206
cursor: pointer;
207
transition: all 0.2s;
···
224
gap: 1rem;
225
padding: 1rem;
226
background: transparent;
227
+
border-radius: var(--radius-md);
228
text-decoration: none;
229
color: var(--text-primary);
230
transition: all 0.2s;
···
245
}
246
247
.tangled-menu-icon {
248
+
border-radius: var(--radius-sm);
249
opacity: 0.7;
250
transition: opacity 0.2s, box-shadow 0.2s;
251
}
···
263
}
264
265
.link-title {
266
+
font-size: var(--text-base);
267
font-weight: 500;
268
color: var(--text-primary);
269
}
270
271
.link-subtitle {
272
+
font-size: var(--text-sm);
273
color: var(--text-tertiary);
274
}
275
+4
-4
frontend/src/lib/components/MigrationBanner.svelte
+4
-4
frontend/src/lib/components/MigrationBanner.svelte
···
152
.migration-banner {
153
background: var(--bg-tertiary);
154
border: 1px solid var(--border-default);
155
-
border-radius: 8px;
156
padding: 1rem;
157
margin-bottom: 1.5rem;
158
}
···
190
gap: 1rem;
191
background: color-mix(in srgb, var(--success) 10%, transparent);
192
border: 1px solid color-mix(in srgb, var(--success) 30%, transparent);
193
-
border-radius: 6px;
194
padding: 1rem;
195
animation: slideIn 0.3s ease-out;
196
}
···
239
.collection-name {
240
background: color-mix(in srgb, var(--text-primary) 5%, transparent);
241
padding: 0.15em 0.4em;
242
-
border-radius: 3px;
243
font-family: monospace;
244
font-size: 0.95em;
245
color: var(--text-primary);
···
265
.migrate-button,
266
.dismiss-button {
267
padding: 0.5rem 1rem;
268
-
border-radius: 4px;
269
font-size: 0.9em;
270
font-family: inherit;
271
cursor: pointer;
···
152
.migration-banner {
153
background: var(--bg-tertiary);
154
border: 1px solid var(--border-default);
155
+
border-radius: var(--radius-md);
156
padding: 1rem;
157
margin-bottom: 1.5rem;
158
}
···
190
gap: 1rem;
191
background: color-mix(in srgb, var(--success) 10%, transparent);
192
border: 1px solid color-mix(in srgb, var(--success) 30%, transparent);
193
+
border-radius: var(--radius-base);
194
padding: 1rem;
195
animation: slideIn 0.3s ease-out;
196
}
···
239
.collection-name {
240
background: color-mix(in srgb, var(--text-primary) 5%, transparent);
241
padding: 0.15em 0.4em;
242
+
border-radius: var(--radius-sm);
243
font-family: monospace;
244
font-size: 0.95em;
245
color: var(--text-primary);
···
265
.migrate-button,
266
.dismiss-button {
267
padding: 0.5rem 1rem;
268
+
border-radius: var(--radius-sm);
269
font-size: 0.9em;
270
font-family: inherit;
271
cursor: pointer;
+4
-4
frontend/src/lib/components/PlatformStats.svelte
+4
-4
frontend/src/lib/components/PlatformStats.svelte
···
182
gap: 0.5rem;
183
margin-bottom: 0.75rem;
184
color: var(--text-secondary);
185
-
font-size: 0.7rem;
186
font-weight: 600;
187
text-transform: uppercase;
188
letter-spacing: 0.05em;
···
203
background: linear-gradient(90deg, var(--bg-tertiary) 0%, var(--bg-hover) 50%, var(--bg-tertiary) 100%);
204
background-size: 200% 100%;
205
animation: shimmer 1.5s ease-in-out infinite;
206
-
border-radius: 6px;
207
}
208
209
.stats-menu-grid {
···
219
gap: 0.15rem;
220
padding: 0.6rem 0.4rem;
221
background: var(--bg-tertiary, #1a1a1a);
222
-
border-radius: 6px;
223
}
224
225
.menu-stat-icon {
···
229
}
230
231
.stats-menu-value {
232
-
font-size: 0.95rem;
233
font-weight: 600;
234
color: var(--text-primary);
235
font-variant-numeric: tabular-nums;
···
182
gap: 0.5rem;
183
margin-bottom: 0.75rem;
184
color: var(--text-secondary);
185
+
font-size: var(--text-xs);
186
font-weight: 600;
187
text-transform: uppercase;
188
letter-spacing: 0.05em;
···
203
background: linear-gradient(90deg, var(--bg-tertiary) 0%, var(--bg-hover) 50%, var(--bg-tertiary) 100%);
204
background-size: 200% 100%;
205
animation: shimmer 1.5s ease-in-out infinite;
206
+
border-radius: var(--radius-base);
207
}
208
209
.stats-menu-grid {
···
219
gap: 0.15rem;
220
padding: 0.6rem 0.4rem;
221
background: var(--bg-tertiary, #1a1a1a);
222
+
border-radius: var(--radius-base);
223
}
224
225
.menu-stat-icon {
···
229
}
230
231
.stats-menu-value {
232
+
font-size: var(--text-base);
233
font-weight: 600;
234
color: var(--text-primary);
235
font-variant-numeric: tabular-nums;
+19
-19
frontend/src/lib/components/ProfileMenu.svelte
+19
-19
frontend/src/lib/components/ProfileMenu.svelte
···
276
height: 44px;
277
background: transparent;
278
border: 1px solid var(--border-default);
279
-
border-radius: 8px;
280
color: var(--text-secondary);
281
cursor: pointer;
282
transition: all 0.15s;
···
311
overflow-y: auto;
312
background: var(--bg-secondary);
313
border: 1px solid var(--border-default);
314
-
border-radius: 16px;
315
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
316
z-index: 101;
317
animation: slideIn 0.18s cubic-bezier(0.16, 1, 0.3, 1);
···
326
}
327
328
.menu-header span {
329
-
font-size: 0.9rem;
330
font-weight: 600;
331
color: var(--text-primary);
332
text-transform: uppercase;
···
341
height: 36px;
342
background: transparent;
343
border: none;
344
-
border-radius: 8px;
345
color: var(--text-secondary);
346
cursor: pointer;
347
transition: all 0.15s;
···
371
min-height: 56px;
372
background: transparent;
373
border: none;
374
-
border-radius: 12px;
375
text-decoration: none;
376
color: var(--text-primary);
377
font-family: inherit;
···
414
}
415
416
.item-title {
417
-
font-size: 0.95rem;
418
font-weight: 500;
419
color: var(--text-primary);
420
}
421
422
.item-subtitle {
423
-
font-size: 0.8rem;
424
color: var(--text-tertiary);
425
overflow: hidden;
426
text-overflow: ellipsis;
···
440
padding: 0.5rem 0.75rem;
441
background: transparent;
442
border: none;
443
-
border-radius: 6px;
444
color: var(--text-secondary);
445
font-family: inherit;
446
-
font-size: 0.85rem;
447
cursor: pointer;
448
transition: all 0.15s;
449
-webkit-tap-highlight-color: transparent;
···
469
470
.settings-section h3 {
471
margin: 0;
472
-
font-size: 0.75rem;
473
text-transform: uppercase;
474
letter-spacing: 0.08em;
475
color: var(--text-tertiary);
···
490
min-height: 54px;
491
background: var(--bg-tertiary);
492
border: 1px solid var(--border-default);
493
-
border-radius: 8px;
494
color: var(--text-secondary);
495
cursor: pointer;
496
transition: all 0.15s;
···
518
}
519
520
.theme-btn span {
521
-
font-size: 0.7rem;
522
text-transform: uppercase;
523
letter-spacing: 0.05em;
524
}
···
533
width: 44px;
534
height: 44px;
535
border: 1px solid var(--border-default);
536
-
border-radius: 8px;
537
cursor: pointer;
538
background: transparent;
539
flex-shrink: 0;
···
544
}
545
546
.color-input::-webkit-color-swatch {
547
-
border-radius: 4px;
548
border: none;
549
}
550
···
557
.preset-btn {
558
width: 36px;
559
height: 36px;
560
-
border-radius: 6px;
561
border: 2px solid transparent;
562
cursor: pointer;
563
transition: all 0.15s;
···
583
align-items: center;
584
gap: 0.75rem;
585
color: var(--text-primary);
586
-
font-size: 0.9rem;
587
cursor: pointer;
588
padding: 0.5rem 0;
589
}
···
592
appearance: none;
593
width: 48px;
594
height: 28px;
595
-
border-radius: 999px;
596
background: var(--border-default);
597
position: relative;
598
cursor: pointer;
···
608
left: 3px;
609
width: 20px;
610
height: 20px;
611
-
border-radius: 50%;
612
background: var(--text-secondary);
613
transition: transform 0.15s, background 0.15s;
614
}
···
635
border-top: 1px solid var(--border-subtle);
636
color: var(--text-secondary);
637
text-decoration: none;
638
-
font-size: 0.9rem;
639
transition: color 0.15s;
640
}
641
···
276
height: 44px;
277
background: transparent;
278
border: 1px solid var(--border-default);
279
+
border-radius: var(--radius-md);
280
color: var(--text-secondary);
281
cursor: pointer;
282
transition: all 0.15s;
···
311
overflow-y: auto;
312
background: var(--bg-secondary);
313
border: 1px solid var(--border-default);
314
+
border-radius: var(--radius-xl);
315
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
316
z-index: 101;
317
animation: slideIn 0.18s cubic-bezier(0.16, 1, 0.3, 1);
···
326
}
327
328
.menu-header span {
329
+
font-size: var(--text-base);
330
font-weight: 600;
331
color: var(--text-primary);
332
text-transform: uppercase;
···
341
height: 36px;
342
background: transparent;
343
border: none;
344
+
border-radius: var(--radius-md);
345
color: var(--text-secondary);
346
cursor: pointer;
347
transition: all 0.15s;
···
371
min-height: 56px;
372
background: transparent;
373
border: none;
374
+
border-radius: var(--radius-lg);
375
text-decoration: none;
376
color: var(--text-primary);
377
font-family: inherit;
···
414
}
415
416
.item-title {
417
+
font-size: var(--text-base);
418
font-weight: 500;
419
color: var(--text-primary);
420
}
421
422
.item-subtitle {
423
+
font-size: var(--text-sm);
424
color: var(--text-tertiary);
425
overflow: hidden;
426
text-overflow: ellipsis;
···
440
padding: 0.5rem 0.75rem;
441
background: transparent;
442
border: none;
443
+
border-radius: var(--radius-base);
444
color: var(--text-secondary);
445
font-family: inherit;
446
+
font-size: var(--text-sm);
447
cursor: pointer;
448
transition: all 0.15s;
449
-webkit-tap-highlight-color: transparent;
···
469
470
.settings-section h3 {
471
margin: 0;
472
+
font-size: var(--text-xs);
473
text-transform: uppercase;
474
letter-spacing: 0.08em;
475
color: var(--text-tertiary);
···
490
min-height: 54px;
491
background: var(--bg-tertiary);
492
border: 1px solid var(--border-default);
493
+
border-radius: var(--radius-md);
494
color: var(--text-secondary);
495
cursor: pointer;
496
transition: all 0.15s;
···
518
}
519
520
.theme-btn span {
521
+
font-size: var(--text-xs);
522
text-transform: uppercase;
523
letter-spacing: 0.05em;
524
}
···
533
width: 44px;
534
height: 44px;
535
border: 1px solid var(--border-default);
536
+
border-radius: var(--radius-md);
537
cursor: pointer;
538
background: transparent;
539
flex-shrink: 0;
···
544
}
545
546
.color-input::-webkit-color-swatch {
547
+
border-radius: var(--radius-sm);
548
border: none;
549
}
550
···
557
.preset-btn {
558
width: 36px;
559
height: 36px;
560
+
border-radius: var(--radius-base);
561
border: 2px solid transparent;
562
cursor: pointer;
563
transition: all 0.15s;
···
583
align-items: center;
584
gap: 0.75rem;
585
color: var(--text-primary);
586
+
font-size: var(--text-base);
587
cursor: pointer;
588
padding: 0.5rem 0;
589
}
···
592
appearance: none;
593
width: 48px;
594
height: 28px;
595
+
border-radius: var(--radius-full);
596
background: var(--border-default);
597
position: relative;
598
cursor: pointer;
···
608
left: 3px;
609
width: 20px;
610
height: 20px;
611
+
border-radius: var(--radius-full);
612
background: var(--text-secondary);
613
transition: transform 0.15s, background 0.15s;
614
}
···
635
border-top: 1px solid var(--border-subtle);
636
color: var(--text-secondary);
637
text-decoration: none;
638
+
font-size: var(--text-base);
639
transition: color 0.15s;
640
}
641
+19
-18
frontend/src/lib/components/Queue.svelte
+19
-18
frontend/src/lib/components/Queue.svelte
···
1
<script lang="ts">
2
import { queue } from '$lib/queue.svelte';
3
import type { Track } from '$lib/types';
4
5
let draggedIndex = $state<number | null>(null);
···
167
ondragover={(e) => handleDragOver(e, index)}
168
ondrop={(e) => handleDrop(e, index)}
169
ondragend={handleDragEnd}
170
-
onclick={() => queue.goTo(index)}
171
-
onkeydown={(e) => e.key === 'Enter' && queue.goTo(index)}
172
>
173
<!-- drag handle for reordering -->
174
<button
···
248
249
.queue-header h2 {
250
margin: 0;
251
-
font-size: 1rem;
252
text-transform: uppercase;
253
letter-spacing: 0.12em;
254
color: var(--text-tertiary);
···
262
263
.clear-btn {
264
padding: 0.25rem 0.75rem;
265
-
font-size: 0.75rem;
266
font-family: inherit;
267
text-transform: uppercase;
268
letter-spacing: 0.08em;
269
background: transparent;
270
border: 1px solid var(--border-subtle);
271
color: var(--text-tertiary);
272
-
border-radius: 4px;
273
cursor: pointer;
274
transition: all 0.15s ease;
275
}
···
289
}
290
291
.section-label {
292
-
font-size: 0.75rem;
293
text-transform: uppercase;
294
letter-spacing: 0.08em;
295
color: var(--text-tertiary);
···
301
align-items: center;
302
justify-content: space-between;
303
padding: 1rem 1.1rem;
304
-
border-radius: 10px;
305
background: var(--bg-secondary);
306
border: 1px solid var(--border-default);
307
gap: 1rem;
···
315
}
316
317
.now-playing-card .track-artist {
318
-
font-size: 0.9rem;
319
color: var(--text-secondary);
320
}
321
···
343
justify-content: space-between;
344
align-items: center;
345
color: var(--text-tertiary);
346
-
font-size: 0.85rem;
347
text-transform: uppercase;
348
letter-spacing: 0.08em;
349
}
350
351
.section-header h3 {
352
margin: 0;
353
-
font-size: 0.85rem;
354
font-weight: 600;
355
color: var(--text-secondary);
356
text-transform: uppercase;
···
371
align-items: center;
372
gap: 0.5rem;
373
padding: 0.85rem 0.9rem;
374
-
border-radius: 8px;
375
cursor: pointer;
376
transition: all 0.2s;
377
border: 1px solid var(--border-subtle);
···
411
color: var(--text-muted);
412
cursor: grab;
413
touch-action: none;
414
-
border-radius: 4px;
415
transition: all 0.2s;
416
flex-shrink: 0;
417
}
···
448
}
449
450
.track-artist {
451
-
font-size: 0.85rem;
452
color: var(--text-tertiary);
453
white-space: nowrap;
454
overflow: hidden;
···
475
align-items: center;
476
justify-content: center;
477
transition: all 0.2s;
478
-
border-radius: 4px;
479
opacity: 0;
480
flex-shrink: 0;
481
}
···
498
499
.empty-up-next {
500
border: 1px dashed var(--border-subtle);
501
-
border-radius: 6px;
502
padding: 1.25rem;
503
text-align: center;
504
color: var(--text-tertiary);
···
523
524
.empty-state p {
525
margin: 0.5rem 0 0.25rem;
526
-
font-size: 1.1rem;
527
color: var(--text-secondary);
528
}
529
530
.empty-state span {
531
-
font-size: 0.9rem;
532
}
533
534
.queue-tracks::-webkit-scrollbar {
···
541
542
.queue-tracks::-webkit-scrollbar-thumb {
543
background: var(--border-default);
544
-
border-radius: 4px;
545
}
546
547
.queue-tracks::-webkit-scrollbar-thumb:hover {
···
1
<script lang="ts">
2
import { queue } from '$lib/queue.svelte';
3
+
import { goToIndex } from '$lib/playback.svelte';
4
import type { Track } from '$lib/types';
5
6
let draggedIndex = $state<number | null>(null);
···
168
ondragover={(e) => handleDragOver(e, index)}
169
ondrop={(e) => handleDrop(e, index)}
170
ondragend={handleDragEnd}
171
+
onclick={() => goToIndex(index)}
172
+
onkeydown={(e) => e.key === 'Enter' && goToIndex(index)}
173
>
174
<!-- drag handle for reordering -->
175
<button
···
249
250
.queue-header h2 {
251
margin: 0;
252
+
font-size: var(--text-lg);
253
text-transform: uppercase;
254
letter-spacing: 0.12em;
255
color: var(--text-tertiary);
···
263
264
.clear-btn {
265
padding: 0.25rem 0.75rem;
266
+
font-size: var(--text-xs);
267
font-family: inherit;
268
text-transform: uppercase;
269
letter-spacing: 0.08em;
270
background: transparent;
271
border: 1px solid var(--border-subtle);
272
color: var(--text-tertiary);
273
+
border-radius: var(--radius-sm);
274
cursor: pointer;
275
transition: all 0.15s ease;
276
}
···
290
}
291
292
.section-label {
293
+
font-size: var(--text-xs);
294
text-transform: uppercase;
295
letter-spacing: 0.08em;
296
color: var(--text-tertiary);
···
302
align-items: center;
303
justify-content: space-between;
304
padding: 1rem 1.1rem;
305
+
border-radius: var(--radius-md);
306
background: var(--bg-secondary);
307
border: 1px solid var(--border-default);
308
gap: 1rem;
···
316
}
317
318
.now-playing-card .track-artist {
319
+
font-size: var(--text-base);
320
color: var(--text-secondary);
321
}
322
···
344
justify-content: space-between;
345
align-items: center;
346
color: var(--text-tertiary);
347
+
font-size: var(--text-sm);
348
text-transform: uppercase;
349
letter-spacing: 0.08em;
350
}
351
352
.section-header h3 {
353
margin: 0;
354
+
font-size: var(--text-sm);
355
font-weight: 600;
356
color: var(--text-secondary);
357
text-transform: uppercase;
···
372
align-items: center;
373
gap: 0.5rem;
374
padding: 0.85rem 0.9rem;
375
+
border-radius: var(--radius-md);
376
cursor: pointer;
377
transition: all 0.2s;
378
border: 1px solid var(--border-subtle);
···
412
color: var(--text-muted);
413
cursor: grab;
414
touch-action: none;
415
+
border-radius: var(--radius-sm);
416
transition: all 0.2s;
417
flex-shrink: 0;
418
}
···
449
}
450
451
.track-artist {
452
+
font-size: var(--text-sm);
453
color: var(--text-tertiary);
454
white-space: nowrap;
455
overflow: hidden;
···
476
align-items: center;
477
justify-content: center;
478
transition: all 0.2s;
479
+
border-radius: var(--radius-sm);
480
opacity: 0;
481
flex-shrink: 0;
482
}
···
499
500
.empty-up-next {
501
border: 1px dashed var(--border-subtle);
502
+
border-radius: var(--radius-base);
503
padding: 1.25rem;
504
text-align: center;
505
color: var(--text-tertiary);
···
524
525
.empty-state p {
526
margin: 0.5rem 0 0.25rem;
527
+
font-size: var(--text-xl);
528
color: var(--text-secondary);
529
}
530
531
.empty-state span {
532
+
font-size: var(--text-base);
533
}
534
535
.queue-tracks::-webkit-scrollbar {
···
542
543
.queue-tracks::-webkit-scrollbar-thumb {
544
background: var(--border-default);
545
+
border-radius: var(--radius-sm);
546
}
547
548
.queue-tracks::-webkit-scrollbar-thumb:hover {
+20
-20
frontend/src/lib/components/SearchModal.svelte
+20
-20
frontend/src/lib/components/SearchModal.svelte
···
276
backdrop-filter: blur(20px) saturate(180%);
277
-webkit-backdrop-filter: blur(20px) saturate(180%);
278
border: 1px solid var(--border-subtle);
279
-
border-radius: 16px;
280
box-shadow:
281
0 24px 80px color-mix(in srgb, var(--bg-primary) 50%, transparent),
282
0 0 1px var(--border-subtle) inset;
···
303
background: transparent;
304
border: none;
305
outline: none;
306
-
font-size: 1rem;
307
font-family: inherit;
308
color: var(--text-primary);
309
}
···
313
}
314
315
.search-shortcut {
316
-
font-size: 0.7rem;
317
padding: 0.25rem 0.5rem;
318
background: var(--bg-tertiary);
319
border: 1px solid var(--border-default);
320
-
border-radius: 5px;
321
color: var(--text-muted);
322
font-family: inherit;
323
}
···
327
height: 16px;
328
border: 2px solid var(--border-default);
329
border-top-color: var(--accent);
330
-
border-radius: 50%;
331
animation: spin 0.6s linear infinite;
332
}
333
···
351
352
.search-results::-webkit-scrollbar-track {
353
background: transparent;
354
-
border-radius: 4px;
355
}
356
357
.search-results::-webkit-scrollbar-thumb {
358
background: var(--border-default);
359
-
border-radius: 4px;
360
}
361
362
.search-results::-webkit-scrollbar-thumb:hover {
···
371
padding: 0.75rem;
372
background: transparent;
373
border: none;
374
-
border-radius: 8px;
375
cursor: pointer;
376
text-align: left;
377
font-family: inherit;
···
396
align-items: center;
397
justify-content: center;
398
background: var(--bg-tertiary);
399
-
border-radius: 8px;
400
-
font-size: 0.9rem;
401
flex-shrink: 0;
402
position: relative;
403
overflow: hidden;
···
409
width: 100%;
410
height: 100%;
411
object-fit: cover;
412
-
border-radius: 8px;
413
}
414
415
.result-icon[data-type='track'] {
···
441
}
442
443
.result-title {
444
-
font-size: 0.9rem;
445
font-weight: 500;
446
white-space: nowrap;
447
overflow: hidden;
···
449
}
450
451
.result-subtitle {
452
-
font-size: 0.75rem;
453
color: var(--text-secondary);
454
white-space: nowrap;
455
overflow: hidden;
···
463
color: var(--text-muted);
464
padding: 0.2rem 0.45rem;
465
background: var(--bg-tertiary);
466
-
border-radius: 4px;
467
flex-shrink: 0;
468
}
469
···
471
padding: 2rem;
472
text-align: center;
473
color: var(--text-secondary);
474
-
font-size: 0.9rem;
475
}
476
477
.search-hints {
···
482
.search-hints p {
483
margin: 0 0 1rem 0;
484
color: var(--text-secondary);
485
-
font-size: 0.85rem;
486
}
487
488
.hint-shortcuts {
···
490
justify-content: center;
491
gap: 1.5rem;
492
color: var(--text-muted);
493
-
font-size: 0.75rem;
494
}
495
496
.hint-shortcuts span {
···
504
padding: 0.15rem 0.35rem;
505
background: var(--bg-tertiary);
506
border: 1px solid var(--border-default);
507
-
border-radius: 4px;
508
font-family: inherit;
509
}
510
···
512
padding: 1rem;
513
text-align: center;
514
color: var(--error);
515
-
font-size: 0.85rem;
516
}
517
518
/* mobile optimizations */
···
535
}
536
537
.search-input::placeholder {
538
-
font-size: 0.85rem;
539
}
540
541
.search-results {
···
276
backdrop-filter: blur(20px) saturate(180%);
277
-webkit-backdrop-filter: blur(20px) saturate(180%);
278
border: 1px solid var(--border-subtle);
279
+
border-radius: var(--radius-xl);
280
box-shadow:
281
0 24px 80px color-mix(in srgb, var(--bg-primary) 50%, transparent),
282
0 0 1px var(--border-subtle) inset;
···
303
background: transparent;
304
border: none;
305
outline: none;
306
+
font-size: var(--text-lg);
307
font-family: inherit;
308
color: var(--text-primary);
309
}
···
313
}
314
315
.search-shortcut {
316
+
font-size: var(--text-xs);
317
padding: 0.25rem 0.5rem;
318
background: var(--bg-tertiary);
319
border: 1px solid var(--border-default);
320
+
border-radius: var(--radius-sm);
321
color: var(--text-muted);
322
font-family: inherit;
323
}
···
327
height: 16px;
328
border: 2px solid var(--border-default);
329
border-top-color: var(--accent);
330
+
border-radius: var(--radius-full);
331
animation: spin 0.6s linear infinite;
332
}
333
···
351
352
.search-results::-webkit-scrollbar-track {
353
background: transparent;
354
+
border-radius: var(--radius-sm);
355
}
356
357
.search-results::-webkit-scrollbar-thumb {
358
background: var(--border-default);
359
+
border-radius: var(--radius-sm);
360
}
361
362
.search-results::-webkit-scrollbar-thumb:hover {
···
371
padding: 0.75rem;
372
background: transparent;
373
border: none;
374
+
border-radius: var(--radius-md);
375
cursor: pointer;
376
text-align: left;
377
font-family: inherit;
···
396
align-items: center;
397
justify-content: center;
398
background: var(--bg-tertiary);
399
+
border-radius: var(--radius-md);
400
+
font-size: var(--text-base);
401
flex-shrink: 0;
402
position: relative;
403
overflow: hidden;
···
409
width: 100%;
410
height: 100%;
411
object-fit: cover;
412
+
border-radius: var(--radius-md);
413
}
414
415
.result-icon[data-type='track'] {
···
441
}
442
443
.result-title {
444
+
font-size: var(--text-base);
445
font-weight: 500;
446
white-space: nowrap;
447
overflow: hidden;
···
449
}
450
451
.result-subtitle {
452
+
font-size: var(--text-xs);
453
color: var(--text-secondary);
454
white-space: nowrap;
455
overflow: hidden;
···
463
color: var(--text-muted);
464
padding: 0.2rem 0.45rem;
465
background: var(--bg-tertiary);
466
+
border-radius: var(--radius-sm);
467
flex-shrink: 0;
468
}
469
···
471
padding: 2rem;
472
text-align: center;
473
color: var(--text-secondary);
474
+
font-size: var(--text-base);
475
}
476
477
.search-hints {
···
482
.search-hints p {
483
margin: 0 0 1rem 0;
484
color: var(--text-secondary);
485
+
font-size: var(--text-sm);
486
}
487
488
.hint-shortcuts {
···
490
justify-content: center;
491
gap: 1.5rem;
492
color: var(--text-muted);
493
+
font-size: var(--text-xs);
494
}
495
496
.hint-shortcuts span {
···
504
padding: 0.15rem 0.35rem;
505
background: var(--bg-tertiary);
506
border: 1px solid var(--border-default);
507
+
border-radius: var(--radius-sm);
508
font-family: inherit;
509
}
510
···
512
padding: 1rem;
513
text-align: center;
514
color: var(--error);
515
+
font-size: var(--text-sm);
516
}
517
518
/* mobile optimizations */
···
535
}
536
537
.search-input::placeholder {
538
+
font-size: var(--text-sm);
539
}
540
541
.search-results {
+1
-1
frontend/src/lib/components/SearchTrigger.svelte
+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
margin-bottom: 4px;
71
background: var(--bg-primary);
72
border: 1px solid var(--border-default);
73
-
border-radius: 4px;
74
padding: 0.25rem 0.5rem;
75
-
font-size: 0.7rem;
76
color: var(--text-tertiary);
77
white-space: nowrap;
78
opacity: 0;
···
89
transform: translate(-50%, -50%);
90
margin-bottom: 0;
91
padding: 0.5rem 0.75rem;
92
-
font-size: 0.8rem;
93
}
94
95
.sensitive-wrapper.blur:hover .sensitive-tooltip {
···
70
margin-bottom: 4px;
71
background: var(--bg-primary);
72
border: 1px solid var(--border-default);
73
+
border-radius: var(--radius-sm);
74
padding: 0.25rem 0.5rem;
75
+
font-size: var(--text-xs);
76
color: var(--text-tertiary);
77
white-space: nowrap;
78
opacity: 0;
···
89
transform: translate(-50%, -50%);
90
margin-bottom: 0;
91
padding: 0.5rem 0.75rem;
92
+
font-size: var(--text-sm);
93
}
94
95
.sensitive-wrapper.blur:hover .sensitive-tooltip {
+14
-14
frontend/src/lib/components/SettingsMenu.svelte
+14
-14
frontend/src/lib/components/SettingsMenu.svelte
···
181
border: 1px solid var(--border-default);
182
color: var(--text-secondary);
183
padding: 0.5rem;
184
-
border-radius: 4px;
185
cursor: pointer;
186
transition: all 0.2s;
187
display: flex;
···
200
right: 0;
201
background: var(--bg-secondary);
202
border: 1px solid var(--border-default);
203
-
border-radius: 8px;
204
padding: 1.25rem;
205
min-width: 280px;
206
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45);
···
216
align-items: center;
217
color: var(--text-primary);
218
font-weight: 600;
219
-
font-size: 0.95rem;
220
}
221
222
.close-btn {
···
245
246
.settings-section h3 {
247
margin: 0;
248
-
font-size: 0.85rem;
249
text-transform: uppercase;
250
letter-spacing: 0.08em;
251
color: var(--text-tertiary);
···
265
padding: 0.6rem 0.5rem;
266
background: var(--bg-tertiary);
267
border: 1px solid var(--border-default);
268
-
border-radius: 6px;
269
color: var(--text-secondary);
270
cursor: pointer;
271
transition: all 0.2s;
···
288
}
289
290
.theme-btn span {
291
-
font-size: 0.7rem;
292
text-transform: uppercase;
293
letter-spacing: 0.05em;
294
}
···
303
width: 48px;
304
height: 32px;
305
border: 1px solid var(--border-default);
306
-
border-radius: 4px;
307
cursor: pointer;
308
background: transparent;
309
}
···
319
320
.color-value {
321
font-family: monospace;
322
-
font-size: 0.85rem;
323
color: var(--text-secondary);
324
}
325
···
332
.preset-btn {
333
width: 32px;
334
height: 32px;
335
-
border-radius: 4px;
336
border: 2px solid transparent;
337
cursor: pointer;
338
transition: all 0.2s;
···
354
align-items: center;
355
gap: 0.65rem;
356
color: var(--text-primary);
357
-
font-size: 0.9rem;
358
}
359
360
.toggle input {
361
appearance: none;
362
width: 42px;
363
height: 22px;
364
-
border-radius: 999px;
365
background: var(--border-default);
366
position: relative;
367
cursor: pointer;
···
377
left: 2px;
378
width: 16px;
379
height: 16px;
380
-
border-radius: 50%;
381
background: var(--text-secondary);
382
transition: transform 0.2s, background 0.2s;
383
}
···
403
.toggle-hint {
404
margin: 0;
405
color: var(--text-tertiary);
406
-
font-size: 0.8rem;
407
line-height: 1.3;
408
}
409
···
415
border-top: 1px solid var(--border-subtle);
416
color: var(--text-secondary);
417
text-decoration: none;
418
-
font-size: 0.85rem;
419
transition: color 0.15s;
420
}
421
···
181
border: 1px solid var(--border-default);
182
color: var(--text-secondary);
183
padding: 0.5rem;
184
+
border-radius: var(--radius-sm);
185
cursor: pointer;
186
transition: all 0.2s;
187
display: flex;
···
200
right: 0;
201
background: var(--bg-secondary);
202
border: 1px solid var(--border-default);
203
+
border-radius: var(--radius-md);
204
padding: 1.25rem;
205
min-width: 280px;
206
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45);
···
216
align-items: center;
217
color: var(--text-primary);
218
font-weight: 600;
219
+
font-size: var(--text-base);
220
}
221
222
.close-btn {
···
245
246
.settings-section h3 {
247
margin: 0;
248
+
font-size: var(--text-sm);
249
text-transform: uppercase;
250
letter-spacing: 0.08em;
251
color: var(--text-tertiary);
···
265
padding: 0.6rem 0.5rem;
266
background: var(--bg-tertiary);
267
border: 1px solid var(--border-default);
268
+
border-radius: var(--radius-base);
269
color: var(--text-secondary);
270
cursor: pointer;
271
transition: all 0.2s;
···
288
}
289
290
.theme-btn span {
291
+
font-size: var(--text-xs);
292
text-transform: uppercase;
293
letter-spacing: 0.05em;
294
}
···
303
width: 48px;
304
height: 32px;
305
border: 1px solid var(--border-default);
306
+
border-radius: var(--radius-sm);
307
cursor: pointer;
308
background: transparent;
309
}
···
319
320
.color-value {
321
font-family: monospace;
322
+
font-size: var(--text-sm);
323
color: var(--text-secondary);
324
}
325
···
332
.preset-btn {
333
width: 32px;
334
height: 32px;
335
+
border-radius: var(--radius-sm);
336
border: 2px solid transparent;
337
cursor: pointer;
338
transition: all 0.2s;
···
354
align-items: center;
355
gap: 0.65rem;
356
color: var(--text-primary);
357
+
font-size: var(--text-base);
358
}
359
360
.toggle input {
361
appearance: none;
362
width: 42px;
363
height: 22px;
364
+
border-radius: var(--radius-full);
365
background: var(--border-default);
366
position: relative;
367
cursor: pointer;
···
377
left: 2px;
378
width: 16px;
379
height: 16px;
380
+
border-radius: var(--radius-full);
381
background: var(--text-secondary);
382
transition: transform 0.2s, background 0.2s;
383
}
···
403
.toggle-hint {
404
margin: 0;
405
color: var(--text-tertiary);
406
+
font-size: var(--text-sm);
407
line-height: 1.3;
408
}
409
···
415
border-top: 1px solid var(--border-subtle);
416
color: var(--text-secondary);
417
text-decoration: none;
418
+
font-size: var(--text-sm);
419
transition: color 0.15s;
420
}
421
+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
padding: 0.75rem;
179
background: var(--bg-primary);
180
border: 1px solid var(--border-default);
181
-
border-radius: 4px;
182
min-height: 48px;
183
transition: all 0.2s;
184
}
···
195
background: color-mix(in srgb, var(--accent) 10%, transparent);
196
border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
197
color: var(--accent-hover);
198
-
border-radius: 20px;
199
-
font-size: 0.9rem;
200
font-weight: 500;
201
}
202
···
233
background: transparent;
234
border: none;
235
color: var(--text-primary);
236
-
font-size: 1rem;
237
font-family: inherit;
238
outline: none;
239
}
···
249
250
.spinner {
251
color: var(--text-muted);
252
-
font-size: 0.85rem;
253
margin-left: auto;
254
}
255
···
261
overflow-y: auto;
262
background: var(--bg-secondary);
263
border: 1px solid var(--border-default);
264
-
border-radius: 4px;
265
margin-top: 0.25rem;
266
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
267
scrollbar-width: thin;
···
274
275
.suggestions::-webkit-scrollbar-track {
276
background: var(--bg-primary);
277
-
border-radius: 4px;
278
}
279
280
.suggestions::-webkit-scrollbar-thumb {
281
background: var(--border-default);
282
-
border-radius: 4px;
283
}
284
285
.suggestions::-webkit-scrollbar-thumb:hover {
···
317
}
318
319
.tag-count {
320
-
font-size: 0.85rem;
321
color: var(--text-tertiary);
322
}
323
···
332
333
.tag-chip {
334
padding: 0.3rem 0.5rem;
335
-
font-size: 0.85rem;
336
}
337
}
338
</style>
···
178
padding: 0.75rem;
179
background: var(--bg-primary);
180
border: 1px solid var(--border-default);
181
+
border-radius: var(--radius-sm);
182
min-height: 48px;
183
transition: all 0.2s;
184
}
···
195
background: color-mix(in srgb, var(--accent) 10%, transparent);
196
border: 1px solid color-mix(in srgb, var(--accent) 20%, transparent);
197
color: var(--accent-hover);
198
+
border-radius: var(--radius-xl);
199
+
font-size: var(--text-base);
200
font-weight: 500;
201
}
202
···
233
background: transparent;
234
border: none;
235
color: var(--text-primary);
236
+
font-size: var(--text-lg);
237
font-family: inherit;
238
outline: none;
239
}
···
249
250
.spinner {
251
color: var(--text-muted);
252
+
font-size: var(--text-sm);
253
margin-left: auto;
254
}
255
···
261
overflow-y: auto;
262
background: var(--bg-secondary);
263
border: 1px solid var(--border-default);
264
+
border-radius: var(--radius-sm);
265
margin-top: 0.25rem;
266
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
267
scrollbar-width: thin;
···
274
275
.suggestions::-webkit-scrollbar-track {
276
background: var(--bg-primary);
277
+
border-radius: var(--radius-sm);
278
}
279
280
.suggestions::-webkit-scrollbar-thumb {
281
background: var(--border-default);
282
+
border-radius: var(--radius-sm);
283
}
284
285
.suggestions::-webkit-scrollbar-thumb:hover {
···
317
}
318
319
.tag-count {
320
+
font-size: var(--text-sm);
321
color: var(--text-tertiary);
322
}
323
···
332
333
.tag-chip {
334
padding: 0.3rem 0.5rem;
335
+
font-size: var(--text-sm);
336
}
337
}
338
</style>
+5
-5
frontend/src/lib/components/Toast.svelte
+5
-5
frontend/src/lib/components/Toast.svelte
···
61
backdrop-filter: blur(12px);
62
-webkit-backdrop-filter: blur(12px);
63
border: 1px solid rgba(255, 255, 255, 0.06);
64
-
border-radius: 8px;
65
pointer-events: none;
66
-
font-size: 0.85rem;
67
max-width: 450px;
68
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
69
}
70
71
.toast-icon {
72
-
font-size: 0.8rem;
73
flex-shrink: 0;
74
opacity: 0.7;
75
margin-top: 0.1rem;
···
135
136
.toast {
137
padding: 0.35rem 0.7rem;
138
-
font-size: 0.8rem;
139
max-width: 90vw;
140
}
141
142
.toast-icon {
143
-
font-size: 0.75rem;
144
}
145
146
.toast-message {
···
61
backdrop-filter: blur(12px);
62
-webkit-backdrop-filter: blur(12px);
63
border: 1px solid rgba(255, 255, 255, 0.06);
64
+
border-radius: var(--radius-md);
65
pointer-events: none;
66
+
font-size: var(--text-sm);
67
max-width: 450px;
68
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
69
}
70
71
.toast-icon {
72
+
font-size: var(--text-sm);
73
flex-shrink: 0;
74
opacity: 0.7;
75
margin-top: 0.1rem;
···
135
136
.toast {
137
padding: 0.35rem 0.7rem;
138
+
font-size: var(--text-sm);
139
max-width: 90vw;
140
}
141
142
.toast-icon {
143
+
font-size: var(--text-xs);
144
}
145
146
.toast-message {
+18
-16
frontend/src/lib/components/TrackActionsMenu.svelte
+18
-16
frontend/src/lib/components/TrackActionsMenu.svelte
···
10
trackUri?: string;
11
trackCid?: string;
12
fileId?: string;
13
initialLiked: boolean;
14
shareUrl: string;
15
onQueue: () => void;
···
24
trackUri,
25
trackCid,
26
fileId,
27
initialLiked,
28
shareUrl,
29
onQueue,
···
101
102
try {
103
const success = liked
104
-
? await likeTrack(trackId, fileId)
105
: await unlikeTrack(trackId);
106
107
if (!success) {
···
400
justify-content: center;
401
background: transparent;
402
border: 1px solid var(--border-default);
403
-
border-radius: 4px;
404
color: var(--text-tertiary);
405
cursor: pointer;
406
transition: all 0.2s;
···
472
}
473
474
.menu-item span {
475
-
font-size: 1rem;
476
font-weight: 400;
477
flex: 1;
478
}
···
506
border: none;
507
border-bottom: 1px solid var(--border-default);
508
color: var(--text-secondary);
509
-
font-size: 0.9rem;
510
font-family: inherit;
511
cursor: pointer;
512
transition: background 0.15s;
···
532
border: none;
533
border-bottom: 1px solid var(--border-subtle);
534
color: var(--text-primary);
535
-
font-size: 1rem;
536
font-family: inherit;
537
cursor: pointer;
538
transition: background 0.15s;
···
556
.playlist-thumb-placeholder {
557
width: 36px;
558
height: 36px;
559
-
border-radius: 4px;
560
flex-shrink: 0;
561
}
562
···
588
gap: 0.5rem;
589
padding: 2rem 1rem;
590
color: var(--text-tertiary);
591
-
font-size: 0.9rem;
592
}
593
594
.create-playlist-btn {
···
601
border: none;
602
border-top: 1px solid var(--border-subtle);
603
color: var(--accent);
604
-
font-size: 1rem;
605
font-family: inherit;
606
cursor: pointer;
607
transition: background 0.15s;
···
625
padding: 0.75rem 1rem;
626
background: var(--bg-tertiary);
627
border: 1px solid var(--border-default);
628
-
border-radius: 8px;
629
color: var(--text-primary);
630
font-family: inherit;
631
-
font-size: 1rem;
632
}
633
634
.create-form input:focus {
···
648
padding: 0.75rem 1rem;
649
background: var(--accent);
650
border: none;
651
-
border-radius: 8px;
652
color: white;
653
font-family: inherit;
654
-
font-size: 1rem;
655
font-weight: 500;
656
cursor: pointer;
657
transition: opacity 0.15s;
···
671
height: 18px;
672
border: 2px solid var(--border-default);
673
border-top-color: var(--accent);
674
-
border-radius: 50%;
675
animation: spin 0.8s linear infinite;
676
}
677
···
698
top: 50%;
699
transform: translateY(-50%);
700
margin-right: 0.5rem;
701
-
border-radius: 8px;
702
min-width: 180px;
703
max-height: none;
704
animation: slideIn 0.15s cubic-bezier(0.16, 1, 0.3, 1);
···
721
}
722
723
.menu-item span {
724
-
font-size: 0.9rem;
725
}
726
727
.menu-item svg {
···
735
736
.playlist-item {
737
padding: 0.625rem 1rem;
738
-
font-size: 0.9rem;
739
}
740
741
.playlist-thumb,
···
10
trackUri?: string;
11
trackCid?: string;
12
fileId?: string;
13
+
gated?: boolean;
14
initialLiked: boolean;
15
shareUrl: string;
16
onQueue: () => void;
···
25
trackUri,
26
trackCid,
27
fileId,
28
+
gated,
29
initialLiked,
30
shareUrl,
31
onQueue,
···
103
104
try {
105
const success = liked
106
+
? await likeTrack(trackId, fileId, gated)
107
: await unlikeTrack(trackId);
108
109
if (!success) {
···
402
justify-content: center;
403
background: transparent;
404
border: 1px solid var(--border-default);
405
+
border-radius: var(--radius-sm);
406
color: var(--text-tertiary);
407
cursor: pointer;
408
transition: all 0.2s;
···
474
}
475
476
.menu-item span {
477
+
font-size: var(--text-lg);
478
font-weight: 400;
479
flex: 1;
480
}
···
508
border: none;
509
border-bottom: 1px solid var(--border-default);
510
color: var(--text-secondary);
511
+
font-size: var(--text-base);
512
font-family: inherit;
513
cursor: pointer;
514
transition: background 0.15s;
···
534
border: none;
535
border-bottom: 1px solid var(--border-subtle);
536
color: var(--text-primary);
537
+
font-size: var(--text-lg);
538
font-family: inherit;
539
cursor: pointer;
540
transition: background 0.15s;
···
558
.playlist-thumb-placeholder {
559
width: 36px;
560
height: 36px;
561
+
border-radius: var(--radius-sm);
562
flex-shrink: 0;
563
}
564
···
590
gap: 0.5rem;
591
padding: 2rem 1rem;
592
color: var(--text-tertiary);
593
+
font-size: var(--text-base);
594
}
595
596
.create-playlist-btn {
···
603
border: none;
604
border-top: 1px solid var(--border-subtle);
605
color: var(--accent);
606
+
font-size: var(--text-lg);
607
font-family: inherit;
608
cursor: pointer;
609
transition: background 0.15s;
···
627
padding: 0.75rem 1rem;
628
background: var(--bg-tertiary);
629
border: 1px solid var(--border-default);
630
+
border-radius: var(--radius-md);
631
color: var(--text-primary);
632
font-family: inherit;
633
+
font-size: var(--text-lg);
634
}
635
636
.create-form input:focus {
···
650
padding: 0.75rem 1rem;
651
background: var(--accent);
652
border: none;
653
+
border-radius: var(--radius-md);
654
color: white;
655
font-family: inherit;
656
+
font-size: var(--text-lg);
657
font-weight: 500;
658
cursor: pointer;
659
transition: opacity 0.15s;
···
673
height: 18px;
674
border: 2px solid var(--border-default);
675
border-top-color: var(--accent);
676
+
border-radius: var(--radius-full);
677
animation: spin 0.8s linear infinite;
678
}
679
···
700
top: 50%;
701
transform: translateY(-50%);
702
margin-right: 0.5rem;
703
+
border-radius: var(--radius-md);
704
min-width: 180px;
705
max-height: none;
706
animation: slideIn 0.15s cubic-bezier(0.16, 1, 0.3, 1);
···
723
}
724
725
.menu-item span {
726
+
font-size: var(--text-base);
727
}
728
729
.menu-item svg {
···
737
738
.playlist-item {
739
padding: 0.625rem 1rem;
740
+
font-size: var(--text-base);
741
}
742
743
.playlist-thumb,
+151
-70
frontend/src/lib/components/TrackItem.svelte
+151
-70
frontend/src/lib/components/TrackItem.svelte
···
7
import type { Track } from '$lib/types';
8
import { queue } from '$lib/queue.svelte';
9
import { toast } from '$lib/toast.svelte';
10
11
interface Props {
12
track: Track;
···
37
const imageFetchPriority = index < 2 ? 'high' : undefined;
38
39
let showLikersTooltip = $state(false);
40
-
let likeCount = $state(track.like_count || 0);
41
-
let commentCount = $state(track.comment_count || 0);
42
let trackImageError = $state(false);
43
let avatarError = $state(false);
44
let tagsExpanded = $state(false);
45
46
// limit visible tags to prevent vertical sprawl (max 2 shown)
47
const MAX_VISIBLE_TAGS = 2;
···
52
(track.tags?.length || 0) - MAX_VISIBLE_TAGS
53
);
54
55
-
// sync counts when track changes
56
-
$effect(() => {
57
-
likeCount = track.like_count || 0;
58
-
commentCount = track.comment_count || 0;
59
-
// reset error states when track changes (e.g. recycled component)
60
-
trackImageError = false;
61
-
avatarError = false;
62
-
tagsExpanded = false;
63
-
});
64
-
65
// construct shareable URL - use /track/[id] for link previews
66
// the track page will redirect to home with query param for actual playback
67
const shareUrl = typeof window !== 'undefined'
···
70
71
function addToQueue(e: Event) {
72
e.stopPropagation();
73
queue.addTracks([track]);
74
toast.success(`queued ${track.title}`, 1800);
75
}
76
77
function handleQueue() {
78
queue.addTracks([track]);
79
toast.success(`queued ${track.title}`, 1800);
80
}
···
126
{/if}
127
<button
128
class="track"
129
-
onclick={(e) => {
130
// only play if clicking the track itself, not a link inside
131
if (e.target instanceof HTMLAnchorElement || (e.target as HTMLElement).closest('a')) {
132
return;
133
}
134
-
onPlay(track);
135
}}
136
>
137
-
{#if track.image_url && !trackImageError}
138
-
<SensitiveImage src={track.image_url}>
139
-
<div class="track-image">
140
-
<img
141
-
src={track.image_url}
142
-
alt="{track.title} artwork"
143
-
width="48"
144
-
height="48"
145
-
loading={imageLoading}
146
-
fetchpriority={imageFetchPriority}
147
-
onerror={() => trackImageError = true}
148
-
/>
149
</div>
150
-
</SensitiveImage>
151
-
{:else if track.artist_avatar_url && !avatarError}
152
-
<SensitiveImage src={track.artist_avatar_url}>
153
-
<a
154
-
href="/u/{track.artist_handle}"
155
-
class="track-avatar"
156
-
>
157
-
<img
158
-
src={track.artist_avatar_url}
159
-
alt={track.artist}
160
-
width="48"
161
-
height="48"
162
-
loading={imageLoading}
163
-
fetchpriority={imageFetchPriority}
164
-
onerror={() => avatarError = true}
165
-
/>
166
-
</a>
167
-
</SensitiveImage>
168
-
{:else}
169
-
<div class="track-image-placeholder">
170
-
<svg width="24" height="24" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
171
-
<circle cx="8" cy="5" r="3" stroke="currentColor" stroke-width="1.5" fill="none" />
172
-
<path d="M3 14c0-2.5 2-4.5 5-4.5s5 2 5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
173
-
</svg>
174
-
</div>
175
-
{/if}
176
<div class="track-info">
177
<div class="track-title">{track.title}</div>
178
<div class="track-metadata">
···
285
trackUri={track.atproto_record_uri}
286
trackCid={track.atproto_record_cid}
287
fileId={track.file_id}
288
initialLiked={track.is_liked || false}
289
disabled={!track.atproto_record_uri}
290
disabledReason={!track.atproto_record_uri ? "track's record is unavailable" : undefined}
···
318
trackUri={track.atproto_record_uri}
319
trackCid={track.atproto_record_cid}
320
fileId={track.file_id}
321
initialLiked={track.is_liked || false}
322
shareUrl={shareUrl}
323
onQueue={handleQueue}
···
336
gap: 0.75rem;
337
background: var(--track-bg, var(--bg-secondary));
338
border: 1px solid var(--track-border, var(--border-subtle));
339
-
border-radius: 8px;
340
padding: 1rem;
341
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
342
transition:
···
347
348
.track-index {
349
width: 24px;
350
-
font-size: 0.85rem;
351
color: var(--text-muted);
352
text-align: center;
353
flex-shrink: 0;
···
393
font-family: inherit;
394
}
395
396
.track-image,
397
.track-image-placeholder {
398
flex-shrink: 0;
···
401
display: flex;
402
align-items: center;
403
justify-content: center;
404
-
border-radius: 4px;
405
overflow: hidden;
406
background: var(--bg-tertiary);
407
border: 1px solid var(--border-subtle);
···
435
}
436
437
.track-avatar img {
438
-
border-radius: 50%;
439
border: 2px solid var(--border-default);
440
transition: border-color 0.2s;
441
}
···
485
align-items: flex-start;
486
gap: 0.15rem;
487
color: var(--text-secondary);
488
-
font-size: 0.9rem;
489
font-family: inherit;
490
min-width: 0;
491
width: 100%;
···
505
506
.metadata-separator {
507
display: none;
508
-
font-size: 0.7rem;
509
}
510
511
.artist-link {
···
604
padding: 0.1rem 0.4rem;
605
background: color-mix(in srgb, var(--accent) 15%, transparent);
606
color: var(--accent-hover);
607
-
border-radius: 3px;
608
-
font-size: 0.75rem;
609
font-weight: 500;
610
text-decoration: none;
611
transition: all 0.15s;
···
624
background: var(--bg-tertiary);
625
color: var(--text-muted);
626
border: none;
627
-
border-radius: 3px;
628
-
font-size: 0.75rem;
629
font-weight: 500;
630
font-family: inherit;
631
cursor: pointer;
···
640
}
641
642
.track-meta {
643
-
font-size: 0.8rem;
644
color: var(--text-tertiary);
645
display: flex;
646
align-items: center;
···
654
655
.meta-separator {
656
color: var(--text-muted);
657
-
font-size: 0.7rem;
658
}
659
660
.likes {
···
701
justify-content: center;
702
background: transparent;
703
border: 1px solid var(--border-default);
704
-
border-radius: 4px;
705
color: var(--text-tertiary);
706
cursor: pointer;
707
transition: all 0.2s;
···
746
gap: 0.5rem;
747
}
748
749
.track-image,
750
.track-image-placeholder,
751
.track-avatar {
···
753
height: 40px;
754
}
755
756
.track-title {
757
-
font-size: 0.9rem;
758
}
759
760
.track-metadata {
761
-
font-size: 0.8rem;
762
gap: 0.35rem;
763
}
764
765
.track-meta {
766
-
font-size: 0.7rem;
767
}
768
769
.track-actions {
···
786
padding: 0.5rem 0.65rem;
787
}
788
789
.track-image,
790
.track-image-placeholder,
791
.track-avatar {
···
793
height: 36px;
794
}
795
796
.track-title {
797
-
font-size: 0.85rem;
798
}
799
800
.track-metadata {
801
-
font-size: 0.75rem;
802
}
803
804
.metadata-separator {
···
7
import type { Track } from '$lib/types';
8
import { queue } from '$lib/queue.svelte';
9
import { toast } from '$lib/toast.svelte';
10
+
import { playTrack, guardGatedTrack } from '$lib/playback.svelte';
11
12
interface Props {
13
track: Track;
···
38
const imageFetchPriority = index < 2 ? 'high' : undefined;
39
40
let showLikersTooltip = $state(false);
41
+
// use overridable $derived (Svelte 5.25+) - syncs with prop but can be overridden for optimistic UI
42
+
let likeCount = $derived(track.like_count || 0);
43
+
let commentCount = $derived(track.comment_count || 0);
44
+
// local UI state keyed by track.id - reset when track changes (component recycling)
45
let trackImageError = $state(false);
46
let avatarError = $state(false);
47
let tagsExpanded = $state(false);
48
+
let prevTrackId: number | undefined;
49
+
50
+
// reset local UI state when track changes (component may be recycled)
51
+
// using $effect.pre so state is ready before render
52
+
$effect.pre(() => {
53
+
if (prevTrackId !== undefined && track.id !== prevTrackId) {
54
+
trackImageError = false;
55
+
avatarError = false;
56
+
tagsExpanded = false;
57
+
}
58
+
prevTrackId = track.id;
59
+
});
60
61
// limit visible tags to prevent vertical sprawl (max 2 shown)
62
const MAX_VISIBLE_TAGS = 2;
···
67
(track.tags?.length || 0) - MAX_VISIBLE_TAGS
68
);
69
70
// construct shareable URL - use /track/[id] for link previews
71
// the track page will redirect to home with query param for actual playback
72
const shareUrl = typeof window !== 'undefined'
···
75
76
function addToQueue(e: Event) {
77
e.stopPropagation();
78
+
if (!guardGatedTrack(track, isAuthenticated)) return;
79
queue.addTracks([track]);
80
toast.success(`queued ${track.title}`, 1800);
81
}
82
83
function handleQueue() {
84
+
if (!guardGatedTrack(track, isAuthenticated)) return;
85
queue.addTracks([track]);
86
toast.success(`queued ${track.title}`, 1800);
87
}
···
133
{/if}
134
<button
135
class="track"
136
+
onclick={async (e) => {
137
// only play if clicking the track itself, not a link inside
138
if (e.target instanceof HTMLAnchorElement || (e.target as HTMLElement).closest('a')) {
139
return;
140
}
141
+
// use playTrack for gated content checks, fall back to onPlay for non-gated
142
+
if (track.gated) {
143
+
await playTrack(track);
144
+
} else {
145
+
onPlay(track);
146
+
}
147
}}
148
>
149
+
<div class="track-image-wrapper" class:gated={track.gated}>
150
+
{#if track.image_url && !trackImageError}
151
+
<SensitiveImage src={track.image_url}>
152
+
<div class="track-image">
153
+
<img
154
+
src={track.image_url}
155
+
alt="{track.title} artwork"
156
+
width="48"
157
+
height="48"
158
+
loading={imageLoading}
159
+
fetchpriority={imageFetchPriority}
160
+
onerror={() => trackImageError = true}
161
+
/>
162
+
</div>
163
+
</SensitiveImage>
164
+
{:else if track.artist_avatar_url && !avatarError}
165
+
<SensitiveImage src={track.artist_avatar_url}>
166
+
<a
167
+
href="/u/{track.artist_handle}"
168
+
class="track-avatar"
169
+
>
170
+
<img
171
+
src={track.artist_avatar_url}
172
+
alt={track.artist}
173
+
width="48"
174
+
height="48"
175
+
loading={imageLoading}
176
+
fetchpriority={imageFetchPriority}
177
+
onerror={() => avatarError = true}
178
+
/>
179
+
</a>
180
+
</SensitiveImage>
181
+
{:else}
182
+
<div class="track-image-placeholder">
183
+
<svg width="24" height="24" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg">
184
+
<circle cx="8" cy="5" r="3" stroke="currentColor" stroke-width="1.5" fill="none" />
185
+
<path d="M3 14c0-2.5 2-4.5 5-4.5s5 2 5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
186
+
</svg>
187
+
</div>
188
+
{/if}
189
+
{#if track.gated}
190
+
<div class="gated-badge" title="supporters only">
191
+
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
192
+
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/>
193
+
</svg>
194
</div>
195
+
{/if}
196
+
</div>
197
<div class="track-info">
198
<div class="track-title">{track.title}</div>
199
<div class="track-metadata">
···
306
trackUri={track.atproto_record_uri}
307
trackCid={track.atproto_record_cid}
308
fileId={track.file_id}
309
+
gated={track.gated}
310
initialLiked={track.is_liked || false}
311
disabled={!track.atproto_record_uri}
312
disabledReason={!track.atproto_record_uri ? "track's record is unavailable" : undefined}
···
340
trackUri={track.atproto_record_uri}
341
trackCid={track.atproto_record_cid}
342
fileId={track.file_id}
343
+
gated={track.gated}
344
initialLiked={track.is_liked || false}
345
shareUrl={shareUrl}
346
onQueue={handleQueue}
···
359
gap: 0.75rem;
360
background: var(--track-bg, var(--bg-secondary));
361
border: 1px solid var(--track-border, var(--border-subtle));
362
+
border-radius: var(--radius-md);
363
padding: 1rem;
364
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
365
transition:
···
370
371
.track-index {
372
width: 24px;
373
+
font-size: var(--text-sm);
374
color: var(--text-muted);
375
text-align: center;
376
flex-shrink: 0;
···
416
font-family: inherit;
417
}
418
419
+
.track-image-wrapper {
420
+
position: relative;
421
+
flex-shrink: 0;
422
+
width: 48px;
423
+
height: 48px;
424
+
}
425
+
426
+
.track-image-wrapper.gated::after {
427
+
content: '';
428
+
position: absolute;
429
+
inset: 0;
430
+
background: rgba(0, 0, 0, 0.3);
431
+
border-radius: var(--radius-sm);
432
+
pointer-events: none;
433
+
}
434
+
435
+
.gated-badge {
436
+
position: absolute;
437
+
bottom: -4px;
438
+
right: -4px;
439
+
width: 18px;
440
+
height: 18px;
441
+
display: flex;
442
+
align-items: center;
443
+
justify-content: center;
444
+
background: var(--accent);
445
+
border: 2px solid var(--bg-secondary);
446
+
border-radius: var(--radius-full);
447
+
color: white;
448
+
z-index: 1;
449
+
}
450
+
451
.track-image,
452
.track-image-placeholder {
453
flex-shrink: 0;
···
456
display: flex;
457
align-items: center;
458
justify-content: center;
459
+
border-radius: var(--radius-sm);
460
overflow: hidden;
461
background: var(--bg-tertiary);
462
border: 1px solid var(--border-subtle);
···
490
}
491
492
.track-avatar img {
493
+
border-radius: var(--radius-full);
494
border: 2px solid var(--border-default);
495
transition: border-color 0.2s;
496
}
···
540
align-items: flex-start;
541
gap: 0.15rem;
542
color: var(--text-secondary);
543
+
font-size: var(--text-base);
544
font-family: inherit;
545
min-width: 0;
546
width: 100%;
···
560
561
.metadata-separator {
562
display: none;
563
+
font-size: var(--text-xs);
564
}
565
566
.artist-link {
···
659
padding: 0.1rem 0.4rem;
660
background: color-mix(in srgb, var(--accent) 15%, transparent);
661
color: var(--accent-hover);
662
+
border-radius: var(--radius-sm);
663
+
font-size: var(--text-xs);
664
font-weight: 500;
665
text-decoration: none;
666
transition: all 0.15s;
···
679
background: var(--bg-tertiary);
680
color: var(--text-muted);
681
border: none;
682
+
border-radius: var(--radius-sm);
683
+
font-size: var(--text-xs);
684
font-weight: 500;
685
font-family: inherit;
686
cursor: pointer;
···
695
}
696
697
.track-meta {
698
+
font-size: var(--text-sm);
699
color: var(--text-tertiary);
700
display: flex;
701
align-items: center;
···
709
710
.meta-separator {
711
color: var(--text-muted);
712
+
font-size: var(--text-xs);
713
}
714
715
.likes {
···
756
justify-content: center;
757
background: transparent;
758
border: 1px solid var(--border-default);
759
+
border-radius: var(--radius-sm);
760
color: var(--text-tertiary);
761
cursor: pointer;
762
transition: all 0.2s;
···
801
gap: 0.5rem;
802
}
803
804
+
.track-image-wrapper,
805
.track-image,
806
.track-image-placeholder,
807
.track-avatar {
···
809
height: 40px;
810
}
811
812
+
.gated-badge {
813
+
width: 16px;
814
+
height: 16px;
815
+
bottom: -3px;
816
+
right: -3px;
817
+
}
818
+
819
+
.gated-badge svg {
820
+
width: 8px;
821
+
height: 8px;
822
+
}
823
+
824
.track-title {
825
+
font-size: var(--text-base);
826
}
827
828
.track-metadata {
829
+
font-size: var(--text-sm);
830
gap: 0.35rem;
831
}
832
833
.track-meta {
834
+
font-size: var(--text-xs);
835
}
836
837
.track-actions {
···
854
padding: 0.5rem 0.65rem;
855
}
856
857
+
.track-image-wrapper,
858
.track-image,
859
.track-image-placeholder,
860
.track-avatar {
···
862
height: 36px;
863
}
864
865
+
.gated-badge {
866
+
width: 14px;
867
+
height: 14px;
868
+
bottom: -2px;
869
+
right: -2px;
870
+
}
871
+
872
+
.gated-badge svg {
873
+
width: 7px;
874
+
height: 7px;
875
+
}
876
+
877
.track-title {
878
+
font-size: var(--text-sm);
879
}
880
881
.track-metadata {
882
+
font-size: var(--text-xs);
883
}
884
885
.metadata-separator {
+1
-1
frontend/src/lib/components/WaveLoading.svelte
+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
align-items: center;
245
justify-content: center;
246
transition: all 0.2s;
247
-
border-radius: 50%;
248
}
249
250
.control-btn svg {
···
282
display: flex;
283
align-items: center;
284
justify-content: center;
285
-
border-radius: 6px;
286
transition: all 0.2s;
287
position: relative;
288
}
···
310
}
311
312
.time {
313
-
font-size: 0.85rem;
314
color: var(--text-tertiary);
315
min-width: 45px;
316
font-variant-numeric: tabular-nums;
···
382
background: var(--accent);
383
height: 14px;
384
width: 14px;
385
-
border-radius: 50%;
386
margin-top: -5px;
387
transition: all 0.2s;
388
box-shadow: 0 0 0 8px transparent;
···
419
background: var(--accent);
420
height: 14px;
421
width: 14px;
422
-
border-radius: 50%;
423
border: none;
424
transition: all 0.2s;
425
box-shadow: 0 0 0 8px transparent;
···
493
}
494
495
.time {
496
-
font-size: 0.75rem;
497
min-width: 38px;
498
}
499
···
244
align-items: center;
245
justify-content: center;
246
transition: all 0.2s;
247
+
border-radius: var(--radius-full);
248
}
249
250
.control-btn svg {
···
282
display: flex;
283
align-items: center;
284
justify-content: center;
285
+
border-radius: var(--radius-base);
286
transition: all 0.2s;
287
position: relative;
288
}
···
310
}
311
312
.time {
313
+
font-size: var(--text-sm);
314
color: var(--text-tertiary);
315
min-width: 45px;
316
font-variant-numeric: tabular-nums;
···
382
background: var(--accent);
383
height: 14px;
384
width: 14px;
385
+
border-radius: var(--radius-full);
386
margin-top: -5px;
387
transition: all 0.2s;
388
box-shadow: 0 0 0 8px transparent;
···
419
background: var(--accent);
420
height: 14px;
421
width: 14px;
422
+
border-radius: var(--radius-full);
423
border: none;
424
transition: all 0.2s;
425
box-shadow: 0 0 0 8px transparent;
···
493
}
494
495
.time {
496
+
font-size: var(--text-xs);
497
min-width: 38px;
498
}
499
+138
-23
frontend/src/lib/components/player/Player.svelte
+138
-23
frontend/src/lib/components/player/Player.svelte
···
4
import { nowPlaying } from '$lib/now-playing.svelte';
5
import { moderation } from '$lib/moderation.svelte';
6
import { preferences } from '$lib/preferences.svelte';
7
import { API_URL } from '$lib/config';
8
import { getCachedAudioUrl } from '$lib/storage';
9
import { onMount } from 'svelte';
···
11
import TrackInfo from './TrackInfo.svelte';
12
import PlaybackControls from './PlaybackControls.svelte';
13
import type { Track } from '$lib/types';
14
15
// check if artwork should be shown in media session (respects sensitive content settings)
16
function shouldShowArtwork(url: string | null | undefined): boolean {
···
239
);
240
});
241
242
// get audio source URL - checks local cache first, falls back to network
243
-
async function getAudioSource(file_id: string): Promise<string> {
244
try {
245
const cachedUrl = await getCachedAudioUrl(file_id);
246
if (cachedUrl) {
···
249
} catch (err) {
250
console.error('failed to check audio cache:', err);
251
}
252
return `${API_URL}/audio/${file_id}`;
253
}
254
···
266
let previousTrackId = $state<number | null>(null);
267
let isLoadingTrack = $state(false);
268
269
$effect(() => {
270
if (!player.currentTrack || !player.audioElement) return;
271
272
// only load new track if it actually changed
273
if (player.currentTrack.id !== previousTrackId) {
274
const trackToLoad = player.currentTrack;
275
previousTrackId = trackToLoad.id;
276
player.resetPlayCount();
277
isLoadingTrack = true;
···
280
cleanupBlobUrl();
281
282
// async: get audio source (cached or network)
283
-
getAudioSource(trackToLoad.file_id).then((src) => {
284
-
// check if track is still current (user may have changed tracks during await)
285
-
if (player.currentTrack?.id !== trackToLoad.id || !player.audioElement) {
286
-
// track changed, cleanup if we created a blob URL
287
if (src.startsWith('blob:')) {
288
-
URL.revokeObjectURL(src);
289
}
290
-
return;
291
-
}
292
293
-
// track if this is a blob URL so we can revoke it later
294
-
if (src.startsWith('blob:')) {
295
-
currentBlobUrl = src;
296
-
}
297
298
-
player.audioElement.src = src;
299
-
player.audioElement.load();
300
301
-
// wait for audio to be ready before allowing playback
302
-
player.audioElement.addEventListener(
303
-
'loadeddata',
304
-
() => {
305
-
isLoadingTrack = false;
306
-
},
307
-
{ once: true }
308
-
);
309
-
});
310
}
311
});
312
···
4
import { nowPlaying } from '$lib/now-playing.svelte';
5
import { moderation } from '$lib/moderation.svelte';
6
import { preferences } from '$lib/preferences.svelte';
7
+
import { toast } from '$lib/toast.svelte';
8
import { API_URL } from '$lib/config';
9
import { getCachedAudioUrl } from '$lib/storage';
10
import { onMount } from 'svelte';
···
12
import TrackInfo from './TrackInfo.svelte';
13
import PlaybackControls from './PlaybackControls.svelte';
14
import type { Track } from '$lib/types';
15
+
16
+
// atprotofans base URL for supporter CTAs
17
+
const ATPROTOFANS_URL = 'https://atprotofans.com';
18
19
// check if artwork should be shown in media session (respects sensitive content settings)
20
function shouldShowArtwork(url: string | null | undefined): boolean {
···
243
);
244
});
245
246
+
// gated content error types
247
+
interface GatedError {
248
+
type: 'gated';
249
+
artistDid: string;
250
+
artistHandle: string;
251
+
requiresAuth: boolean;
252
+
}
253
+
254
// get audio source URL - checks local cache first, falls back to network
255
+
// throws GatedError if the track requires supporter access
256
+
async function getAudioSource(file_id: string, track: Track): Promise<string> {
257
try {
258
const cachedUrl = await getCachedAudioUrl(file_id);
259
if (cachedUrl) {
···
262
} catch (err) {
263
console.error('failed to check audio cache:', err);
264
}
265
+
266
+
// for gated tracks, check authorization first
267
+
if (track.gated) {
268
+
const response = await fetch(`${API_URL}/audio/${file_id}`, {
269
+
method: 'HEAD',
270
+
credentials: 'include'
271
+
});
272
+
273
+
if (response.status === 401) {
274
+
throw {
275
+
type: 'gated',
276
+
artistDid: track.artist_did,
277
+
artistHandle: track.artist_handle,
278
+
requiresAuth: true
279
+
} as GatedError;
280
+
}
281
+
282
+
if (response.status === 402) {
283
+
throw {
284
+
type: 'gated',
285
+
artistDid: track.artist_did,
286
+
artistHandle: track.artist_handle,
287
+
requiresAuth: false
288
+
} as GatedError;
289
+
}
290
+
}
291
+
292
return `${API_URL}/audio/${file_id}`;
293
}
294
···
306
let previousTrackId = $state<number | null>(null);
307
let isLoadingTrack = $state(false);
308
309
+
// store previous playback state for restoration on gated errors
310
+
let savedPlaybackState = $state<{
311
+
track: Track;
312
+
src: string;
313
+
currentTime: number;
314
+
paused: boolean;
315
+
} | null>(null);
316
+
317
$effect(() => {
318
if (!player.currentTrack || !player.audioElement) return;
319
320
// only load new track if it actually changed
321
if (player.currentTrack.id !== previousTrackId) {
322
const trackToLoad = player.currentTrack;
323
+
const audioElement = player.audioElement;
324
+
325
+
// save current playback state BEFORE changing anything
326
+
// (only if we have a playing/paused track to restore to)
327
+
if (previousTrackId !== null && audioElement.src && !audioElement.src.startsWith('blob:')) {
328
+
const prevTrack = queue.tracks.find((t) => t.id === previousTrackId);
329
+
if (prevTrack) {
330
+
savedPlaybackState = {
331
+
track: prevTrack,
332
+
src: audioElement.src,
333
+
currentTime: audioElement.currentTime,
334
+
paused: audioElement.paused
335
+
};
336
+
}
337
+
}
338
+
339
+
// update tracking state
340
previousTrackId = trackToLoad.id;
341
player.resetPlayCount();
342
isLoadingTrack = true;
···
345
cleanupBlobUrl();
346
347
// async: get audio source (cached or network)
348
+
getAudioSource(trackToLoad.file_id, trackToLoad)
349
+
.then((src) => {
350
+
// check if track is still current (user may have changed tracks during await)
351
+
if (player.currentTrack?.id !== trackToLoad.id || !player.audioElement) {
352
+
// track changed, cleanup if we created a blob URL
353
+
if (src.startsWith('blob:')) {
354
+
URL.revokeObjectURL(src);
355
+
}
356
+
return;
357
+
}
358
+
359
+
// successfully got source - clear saved state
360
+
savedPlaybackState = null;
361
+
362
+
// track if this is a blob URL so we can revoke it later
363
if (src.startsWith('blob:')) {
364
+
currentBlobUrl = src;
365
}
366
+
367
+
player.audioElement.src = src;
368
+
player.audioElement.load();
369
370
+
// wait for audio to be ready before allowing playback
371
+
player.audioElement.addEventListener(
372
+
'loadeddata',
373
+
() => {
374
+
isLoadingTrack = false;
375
+
},
376
+
{ once: true }
377
+
);
378
+
})
379
+
.catch((err) => {
380
+
isLoadingTrack = false;
381
382
+
// handle gated content errors with supporter CTA
383
+
if (err && err.type === 'gated') {
384
+
const gatedErr = err as GatedError;
385
386
+
if (gatedErr.requiresAuth) {
387
+
toast.info('sign in to play supporter-only tracks');
388
+
} else {
389
+
// show toast with supporter CTA
390
+
const supportUrl = gatedErr.artistDid
391
+
? `${ATPROTOFANS_URL}/${gatedErr.artistDid}`
392
+
: `${ATPROTOFANS_URL}/${gatedErr.artistHandle}`;
393
+
394
+
toast.info('this track is for supporters only', 5000, {
395
+
label: 'become a supporter',
396
+
href: supportUrl
397
+
});
398
+
}
399
+
400
+
// restore previous playback if we had something playing
401
+
if (savedPlaybackState && player.audioElement) {
402
+
player.currentTrack = savedPlaybackState.track;
403
+
previousTrackId = savedPlaybackState.track.id;
404
+
player.audioElement.src = savedPlaybackState.src;
405
+
player.audioElement.currentTime = savedPlaybackState.currentTime;
406
+
if (!savedPlaybackState.paused) {
407
+
player.audioElement.play().catch(() => {});
408
+
}
409
+
savedPlaybackState = null;
410
+
return;
411
+
}
412
+
413
+
// no previous state to restore - skip to next or stop
414
+
if (queue.hasNext) {
415
+
queue.next();
416
+
} else {
417
+
player.currentTrack = null;
418
+
player.paused = true;
419
+
}
420
+
return;
421
+
}
422
+
423
+
console.error('failed to load audio:', err);
424
+
});
425
}
426
});
427
+4
-4
frontend/src/lib/components/player/TrackInfo.svelte
+4
-4
frontend/src/lib/components/player/TrackInfo.svelte
···
156
flex-shrink: 0;
157
width: 56px;
158
height: 56px;
159
-
border-radius: 4px;
160
overflow: hidden;
161
background: var(--bg-tertiary);
162
border: 1px solid var(--border-default);
···
197
198
.player-title,
199
.player-title-link {
200
-
font-size: 0.95rem;
201
font-weight: 600;
202
color: var(--text-primary);
203
margin-bottom: 0;
···
384
385
.player-title,
386
.player-title-link {
387
-
font-size: 0.9rem;
388
margin-bottom: 0;
389
}
390
391
.player-metadata {
392
-
font-size: 0.8rem;
393
}
394
395
.player-title.scrolling,
···
156
flex-shrink: 0;
157
width: 56px;
158
height: 56px;
159
+
border-radius: var(--radius-sm);
160
overflow: hidden;
161
background: var(--bg-tertiary);
162
border: 1px solid var(--border-default);
···
197
198
.player-title,
199
.player-title-link {
200
+
font-size: var(--text-base);
201
font-weight: 600;
202
color: var(--text-primary);
203
margin-bottom: 0;
···
384
385
.player-title,
386
.player-title-link {
387
+
font-size: var(--text-base);
388
margin-bottom: 0;
389
}
390
391
.player-metadata {
392
+
font-size: var(--text-sm);
393
}
394
395
.player-title.scrolling,
+8
frontend/src/lib/config.ts
+8
frontend/src/lib/config.ts
···
2
3
export const API_URL = PUBLIC_API_URL || 'http://localhost:8001';
4
5
+
/**
6
+
* generate atprotofans support URL for an artist.
7
+
* canonical format: https://atprotofans.com/support/{did}
8
+
*/
9
+
export function getAtprotofansSupportUrl(did: string): string {
10
+
return `https://atprotofans.com/support/${did}`;
11
+
}
12
+
13
interface ServerConfig {
14
max_upload_size_mb: number;
15
max_image_size_mb: number;
+187
frontend/src/lib/playback.svelte.ts
+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
export const tracksCache = new TracksCache();
134
135
// like/unlike track functions
136
-
export async function likeTrack(trackId: number, fileId?: string): Promise<boolean> {
137
try {
138
const response = await fetch(`${API_URL}/tracks/${trackId}/like`, {
139
method: 'POST',
···
148
tracksCache.invalidate();
149
150
// auto-download if preference is enabled and file_id provided
151
-
if (fileId && preferences.autoDownloadLiked) {
152
try {
153
const alreadyDownloaded = await isDownloaded(fileId);
154
if (!alreadyDownloaded) {
···
133
export const tracksCache = new TracksCache();
134
135
// like/unlike track functions
136
+
// gated: true means viewer lacks access (non-supporter), false means accessible
137
+
export async function likeTrack(trackId: number, fileId?: string, gated?: boolean): Promise<boolean> {
138
try {
139
const response = await fetch(`${API_URL}/tracks/${trackId}/like`, {
140
method: 'POST',
···
149
tracksCache.invalidate();
150
151
// auto-download if preference is enabled and file_id provided
152
+
// skip download only if track is gated AND viewer lacks access (gated === true)
153
+
if (fileId && preferences.autoDownloadLiked && gated !== true) {
154
try {
155
const alreadyDownloaded = await isDownloaded(fileId);
156
if (!alreadyDownloaded) {
+7
frontend/src/lib/types.ts
+7
frontend/src/lib/types.ts
···
27
tracks: Track[];
28
}
29
30
export interface Track {
31
id: number;
32
title: string;
···
36
file_type: string;
37
artist_handle: string;
38
artist_avatar_url?: string;
39
r2_url?: string;
40
atproto_record_uri?: string;
41
atproto_record_cid?: string;
···
50
is_liked?: boolean;
51
copyright_flagged?: boolean | null; // null = not scanned, false = clear, true = flagged
52
copyright_match?: string | null; // "Title by Artist" of primary match
53
}
54
55
export interface User {
···
27
tracks: Track[];
28
}
29
30
+
export interface SupportGate {
31
+
type: 'any' | string;
32
+
}
33
+
34
export interface Track {
35
id: number;
36
title: string;
···
40
file_type: string;
41
artist_handle: string;
42
artist_avatar_url?: string;
43
+
artist_did?: string;
44
r2_url?: string;
45
atproto_record_uri?: string;
46
atproto_record_cid?: string;
···
55
is_liked?: boolean;
56
copyright_flagged?: boolean | null; // null = not scanned, false = clear, true = flagged
57
copyright_match?: string | null; // "Title by Artist" of primary match
58
+
support_gate?: SupportGate | null; // if set, track requires supporter access
59
+
gated?: boolean; // true if track is gated AND viewer lacks access
60
}
61
62
export interface User {
+54
-4
frontend/src/lib/uploader.svelte.ts
+54
-4
frontend/src/lib/uploader.svelte.ts
···
23
onError?: (_error: string) => void;
24
}
25
26
// global upload manager using Svelte 5 runes
27
class UploaderState {
28
activeUploads = $state<Map<string, UploadTask>>(new Map());
···
34
features: FeaturedArtist[],
35
image: File | null | undefined,
36
tags: string[],
37
onSuccess?: () => void,
38
callbacks?: UploadProgressCallback
39
): void {
40
const taskId = crypto.randomUUID();
41
const fileSizeMB = file.size / 1024 / 1024;
42
const uploadMessage = fileSizeMB > 10
43
? 'uploading track... (large file, this may take a moment)'
44
: 'uploading track...';
45
// 0 means infinite/persist until dismissed
46
const toastId = toast.info(uploadMessage, 0);
47
48
if (!browser) return;
49
const formData = new FormData();
50
formData.append('file', file);
···
60
if (image) {
61
formData.append('image', image);
62
}
63
64
const xhr = new XMLHttpRequest();
65
xhr.open('POST', `${API_URL}/tracks/`);
···
70
xhr.upload.addEventListener('progress', (e) => {
71
if (e.lengthComputable && !uploadComplete) {
72
const percent = Math.round((e.loaded / e.total) * 100);
73
const progressMsg = `retrieving your file... ${percent}%`;
74
toast.update(toastId, progressMsg);
75
if (callbacks?.onProgress) {
···
168
errorMsg = error.detail || errorMsg;
169
} catch {
170
if (xhr.status === 0) {
171
-
errorMsg = 'network error: connection failed. check your internet connection and try again';
172
} else if (xhr.status >= 500) {
173
errorMsg = 'server error: please try again in a moment';
174
} else if (xhr.status === 413) {
175
errorMsg = 'file too large: please use a smaller file';
176
} else if (xhr.status === 408 || xhr.status === 504) {
177
-
errorMsg = 'upload timed out: please try again with a better connection';
178
}
179
}
180
toast.error(errorMsg);
···
186
187
xhr.addEventListener('error', () => {
188
toast.dismiss(toastId);
189
-
const errorMsg = 'network error: connection failed. check your internet connection and try again';
190
toast.error(errorMsg);
191
if (callbacks?.onError) {
192
callbacks.onError(errorMsg);
···
195
196
xhr.addEventListener('timeout', () => {
197
toast.dismiss(toastId);
198
-
const errorMsg = 'upload timed out: please try again with a better connection';
199
toast.error(errorMsg);
200
if (callbacks?.onError) {
201
callbacks.onError(errorMsg);
···
23
onError?: (_error: string) => void;
24
}
25
26
+
function isMobileDevice(): boolean {
27
+
if (!browser) return false;
28
+
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
29
+
}
30
+
31
+
const MOBILE_LARGE_FILE_THRESHOLD_MB = 50;
32
+
33
+
function buildNetworkErrorMessage(progressPercent: number, fileSizeMB: number, isMobile: boolean): string {
34
+
const progressInfo = progressPercent > 0 ? ` (failed at ${progressPercent}%)` : '';
35
+
36
+
if (isMobile && fileSizeMB > MOBILE_LARGE_FILE_THRESHOLD_MB) {
37
+
return `upload failed${progressInfo}: large files often fail on mobile networks. try uploading from a desktop or use WiFi`;
38
+
}
39
+
40
+
if (progressPercent > 0 && progressPercent < 100) {
41
+
return `upload failed${progressInfo}: connection was interrupted. check your network and try again`;
42
+
}
43
+
44
+
return `upload failed${progressInfo}: connection failed. check your internet connection and try again`;
45
+
}
46
+
47
+
function buildTimeoutErrorMessage(progressPercent: number, fileSizeMB: number, isMobile: boolean): string {
48
+
const progressInfo = progressPercent > 0 ? ` (stopped at ${progressPercent}%)` : '';
49
+
50
+
if (isMobile) {
51
+
return `upload timed out${progressInfo}: mobile uploads can be slow. try WiFi or a desktop browser`;
52
+
}
53
+
54
+
if (fileSizeMB > 100) {
55
+
return `upload timed out${progressInfo}: large file (${Math.round(fileSizeMB)}MB) - try a faster connection`;
56
+
}
57
+
58
+
return `upload timed out${progressInfo}: try again with a better connection`;
59
+
}
60
+
61
// global upload manager using Svelte 5 runes
62
class UploaderState {
63
activeUploads = $state<Map<string, UploadTask>>(new Map());
···
69
features: FeaturedArtist[],
70
image: File | null | undefined,
71
tags: string[],
72
+
supportGated: boolean,
73
onSuccess?: () => void,
74
callbacks?: UploadProgressCallback
75
): void {
76
const taskId = crypto.randomUUID();
77
const fileSizeMB = file.size / 1024 / 1024;
78
+
const isMobile = isMobileDevice();
79
+
80
+
// warn about large files on mobile
81
+
if (isMobile && fileSizeMB > MOBILE_LARGE_FILE_THRESHOLD_MB) {
82
+
toast.info(`uploading ${Math.round(fileSizeMB)}MB file on mobile - ensure stable connection`, 5000);
83
+
}
84
+
85
const uploadMessage = fileSizeMB > 10
86
? 'uploading track... (large file, this may take a moment)'
87
: 'uploading track...';
88
// 0 means infinite/persist until dismissed
89
const toastId = toast.info(uploadMessage, 0);
90
91
+
// track upload progress for error messages
92
+
let lastProgressPercent = 0;
93
+
94
if (!browser) return;
95
const formData = new FormData();
96
formData.append('file', file);
···
106
if (image) {
107
formData.append('image', image);
108
}
109
+
if (supportGated) {
110
+
formData.append('support_gate', JSON.stringify({ type: 'any' }));
111
+
}
112
113
const xhr = new XMLHttpRequest();
114
xhr.open('POST', `${API_URL}/tracks/`);
···
119
xhr.upload.addEventListener('progress', (e) => {
120
if (e.lengthComputable && !uploadComplete) {
121
const percent = Math.round((e.loaded / e.total) * 100);
122
+
lastProgressPercent = percent;
123
const progressMsg = `retrieving your file... ${percent}%`;
124
toast.update(toastId, progressMsg);
125
if (callbacks?.onProgress) {
···
218
errorMsg = error.detail || errorMsg;
219
} catch {
220
if (xhr.status === 0) {
221
+
errorMsg = buildNetworkErrorMessage(lastProgressPercent, fileSizeMB, isMobile);
222
} else if (xhr.status >= 500) {
223
errorMsg = 'server error: please try again in a moment';
224
} else if (xhr.status === 413) {
225
errorMsg = 'file too large: please use a smaller file';
226
} else if (xhr.status === 408 || xhr.status === 504) {
227
+
errorMsg = buildTimeoutErrorMessage(lastProgressPercent, fileSizeMB, isMobile);
228
}
229
}
230
toast.error(errorMsg);
···
236
237
xhr.addEventListener('error', () => {
238
toast.dismiss(toastId);
239
+
const errorMsg = buildNetworkErrorMessage(lastProgressPercent, fileSizeMB, isMobile);
240
toast.error(errorMsg);
241
if (callbacks?.onError) {
242
callbacks.onError(errorMsg);
···
245
246
xhr.addEventListener('timeout', () => {
247
toast.dismiss(toastId);
248
+
const errorMsg = buildTimeoutErrorMessage(lastProgressPercent, fileSizeMB, isMobile);
249
toast.error(errorMsg);
250
if (callbacks?.onError) {
251
callbacks.onError(errorMsg);
+5
-5
frontend/src/routes/+error.svelte
+5
-5
frontend/src/routes/+error.svelte
···
62
}
63
64
.error-message {
65
-
font-size: 1.25rem;
66
color: var(--text-secondary);
67
margin: 0 0 0.5rem 0;
68
}
69
70
.error-detail {
71
-
font-size: 1rem;
72
color: var(--text-tertiary);
73
margin: 0 0 2rem 0;
74
}
···
76
.home-link {
77
color: var(--accent);
78
text-decoration: none;
79
-
font-size: 1.1rem;
80
padding: 0.75rem 1.5rem;
81
border: 1px solid var(--accent);
82
-
border-radius: 6px;
83
transition: all 0.2s;
84
}
85
···
98
}
99
100
.error-message {
101
-
font-size: 1.1rem;
102
}
103
}
104
</style>
···
62
}
63
64
.error-message {
65
+
font-size: var(--text-2xl);
66
color: var(--text-secondary);
67
margin: 0 0 0.5rem 0;
68
}
69
70
.error-detail {
71
+
font-size: var(--text-lg);
72
color: var(--text-tertiary);
73
margin: 0 0 2rem 0;
74
}
···
76
.home-link {
77
color: var(--accent);
78
text-decoration: none;
79
+
font-size: var(--text-xl);
80
padding: 0.75rem 1.5rem;
81
border: 1px solid var(--accent);
82
+
border-radius: var(--radius-base);
83
transition: all 0.2s;
84
}
85
···
98
}
99
100
.error-message {
101
+
font-size: var(--text-xl);
102
}
103
}
104
</style>
+32
-4
frontend/src/routes/+layout.svelte
+32
-4
frontend/src/routes/+layout.svelte
···
450
--text-muted: #666666;
451
452
/* typography scale */
453
-
--text-page-heading: 1.5rem;
454
--text-section-heading: 1.2rem;
455
-
--text-body: 1rem;
456
-
--text-small: 0.9rem;
457
458
/* semantic */
459
--success: #4ade80;
···
516
color: var(--accent-muted);
517
}
518
519
:global(body) {
520
margin: 0;
521
padding: 0;
···
589
right: 20px;
590
width: 48px;
591
height: 48px;
592
-
border-radius: 50%;
593
background: var(--bg-secondary);
594
border: 1px solid var(--border-default);
595
color: var(--text-secondary);
···
450
--text-muted: #666666;
451
452
/* typography scale */
453
+
--text-xs: 0.75rem;
454
+
--text-sm: 0.85rem;
455
+
--text-base: 0.9rem;
456
+
--text-lg: 1rem;
457
+
--text-xl: 1.1rem;
458
+
--text-2xl: 1.25rem;
459
+
--text-3xl: 1.5rem;
460
+
461
+
/* semantic typography (aliases) */
462
+
--text-page-heading: var(--text-3xl);
463
--text-section-heading: 1.2rem;
464
+
--text-body: var(--text-lg);
465
+
--text-small: var(--text-base);
466
+
467
+
/* border radius scale */
468
+
--radius-sm: 4px;
469
+
--radius-base: 6px;
470
+
--radius-md: 8px;
471
+
--radius-lg: 12px;
472
+
--radius-xl: 16px;
473
+
--radius-2xl: 24px;
474
+
--radius-full: 9999px;
475
476
/* semantic */
477
--success: #4ade80;
···
534
color: var(--accent-muted);
535
}
536
537
+
/* shared animation for active play buttons */
538
+
@keyframes -global-ethereal-glow {
539
+
0%, 100% {
540
+
box-shadow: 0 0 8px 1px color-mix(in srgb, var(--accent) 25%, transparent);
541
+
}
542
+
50% {
543
+
box-shadow: 0 0 14px 3px color-mix(in srgb, var(--accent) 45%, transparent);
544
+
}
545
+
}
546
+
547
:global(body) {
548
margin: 0;
549
padding: 0;
···
617
right: 20px;
618
width: 48px;
619
height: 48px;
620
+
border-radius: var(--radius-full);
621
background: var(--bg-secondary);
622
border: 1px solid var(--border-default);
623
color: var(--text-secondary);
+1
-1
frontend/src/routes/+page.svelte
+1
-1
frontend/src/routes/+page.svelte
+146
-38
frontend/src/routes/costs/+page.svelte
+146
-38
frontend/src/routes/costs/+page.svelte
···
59
let loading = $state(true);
60
let error = $state<string | null>(null);
61
let data = $state<CostData | null>(null);
62
63
// derived values for bar chart scaling
64
let maxCost = $derived(
···
72
: 1
73
);
74
75
-
let maxRequests = $derived(
76
-
data?.costs.audd.daily.length
77
-
? Math.max(...data.costs.audd.daily.map((d) => d.requests))
78
-
: 1
79
-
);
80
81
onMount(async () => {
82
try {
···
216
217
<!-- audd details -->
218
<section class="audd-section">
219
-
<h2>copyright scanning (audd)</h2>
220
<div class="audd-stats">
221
<div class="stat">
222
-
<span class="stat-value">{data.costs.audd.requests_this_period.toLocaleString()}</span>
223
-
<span class="stat-label">API requests</span>
224
</div>
225
<div class="stat">
226
<span class="stat-value">{data.costs.audd.remaining_free.toLocaleString()}</span>
227
<span class="stat-label">free remaining</span>
228
</div>
229
<div class="stat">
230
-
<span class="stat-value">{data.costs.audd.scans_this_period.toLocaleString()}</span>
231
<span class="stat-label">tracks scanned</span>
232
</div>
233
</div>
···
236
1 request = 12s of audio. {data.costs.audd.free_requests.toLocaleString()} free/month,
237
then ${(5).toFixed(2)}/1k requests.
238
{#if data.costs.audd.billable_requests > 0}
239
-
<strong>{data.costs.audd.billable_requests.toLocaleString()} billable</strong> this period.
240
{/if}
241
</p>
242
243
-
{#if data.costs.audd.daily.length > 0}
244
<div class="daily-chart">
245
<h3>daily requests</h3>
246
<div class="chart-bars">
247
-
{#each data.costs.audd.daily as day}
248
<div class="chart-bar-container">
249
<div
250
class="chart-bar"
···
256
{/each}
257
</div>
258
</div>
259
{/if}
260
</section>
261
···
303
304
.subtitle {
305
color: var(--text-tertiary);
306
-
font-size: 0.9rem;
307
margin: 0;
308
}
309
···
321
322
.error-state .hint {
323
color: var(--text-tertiary);
324
-
font-size: 0.85rem;
325
margin-top: 0.5rem;
326
}
327
···
337
padding: 2rem;
338
background: var(--bg-tertiary);
339
border: 1px solid var(--border-subtle);
340
-
border-radius: 12px;
341
}
342
343
.total-label {
344
-
font-size: 0.8rem;
345
text-transform: uppercase;
346
letter-spacing: 0.08em;
347
color: var(--text-tertiary);
···
356
357
.updated {
358
text-align: center;
359
-
font-size: 0.75rem;
360
color: var(--text-tertiary);
361
margin-top: 0.75rem;
362
}
···
368
369
.breakdown-section h2,
370
.audd-section h2 {
371
-
font-size: 0.8rem;
372
text-transform: uppercase;
373
letter-spacing: 0.08em;
374
color: var(--text-tertiary);
···
384
.cost-item {
385
background: var(--bg-tertiary);
386
border: 1px solid var(--border-subtle);
387
-
border-radius: 8px;
388
padding: 1rem;
389
}
390
···
409
.cost-bar-bg {
410
height: 8px;
411
background: var(--bg-primary);
412
-
border-radius: 4px;
413
overflow: hidden;
414
margin-bottom: 0.5rem;
415
}
···
417
.cost-bar {
418
height: 100%;
419
background: var(--accent);
420
-
border-radius: 4px;
421
transition: width 0.3s ease;
422
}
423
···
426
}
427
428
.cost-note {
429
-
font-size: 0.75rem;
430
color: var(--text-tertiary);
431
}
432
···
435
margin-bottom: 2rem;
436
}
437
438
.audd-stats {
439
display: grid;
440
grid-template-columns: repeat(3, 1fr);
···
443
}
444
445
.audd-explainer {
446
-
font-size: 0.8rem;
447
color: var(--text-secondary);
448
margin-bottom: 1.5rem;
449
line-height: 1.5;
···
460
padding: 1rem;
461
background: var(--bg-tertiary);
462
border: 1px solid var(--border-subtle);
463
-
border-radius: 8px;
464
}
465
466
.stat-value {
467
-
font-size: 1.25rem;
468
font-weight: 700;
469
color: var(--text-primary);
470
font-variant-numeric: tabular-nums;
471
}
472
473
.stat-label {
474
-
font-size: 0.7rem;
475
color: var(--text-tertiary);
476
text-align: center;
477
margin-top: 0.25rem;
···
481
.daily-chart {
482
background: var(--bg-tertiary);
483
border: 1px solid var(--border-subtle);
484
-
border-radius: 8px;
485
padding: 1rem;
486
}
487
488
.daily-chart h3 {
489
-
font-size: 0.75rem;
490
text-transform: uppercase;
491
letter-spacing: 0.05em;
492
color: var(--text-tertiary);
···
496
.chart-bars {
497
display: flex;
498
align-items: flex-end;
499
-
gap: 4px;
500
height: 100px;
501
}
502
503
.chart-bar-container {
504
-
flex: 1;
505
display: flex;
506
flex-direction: column;
507
align-items: center;
···
522
}
523
524
.chart-label {
525
-
font-size: 0.6rem;
526
color: var(--text-tertiary);
527
-
margin-top: 0.5rem;
528
white-space: nowrap;
529
}
530
531
/* support section */
···
544
var(--bg-tertiary)
545
);
546
border: 1px solid var(--border-subtle);
547
-
border-radius: 12px;
548
}
549
550
.support-icon {
···
554
555
.support-text h3 {
556
margin: 0 0 0.5rem;
557
-
font-size: 1.1rem;
558
color: var(--text-primary);
559
}
560
561
.support-text p {
562
margin: 0 0 1.5rem;
563
color: var(--text-secondary);
564
-
font-size: 0.9rem;
565
}
566
567
.support-button {
···
571
padding: 0.75rem 1.5rem;
572
background: var(--accent);
573
color: white;
574
-
border-radius: 8px;
575
text-decoration: none;
576
font-weight: 600;
577
-
font-size: 0.9rem;
578
transition: transform 0.15s, box-shadow 0.15s;
579
}
580
···
586
/* footer */
587
.footer-note {
588
text-align: center;
589
-
font-size: 0.8rem;
590
color: var(--text-tertiary);
591
padding-bottom: 1rem;
592
}
···
59
let loading = $state(true);
60
let error = $state<string | null>(null);
61
let data = $state<CostData | null>(null);
62
+
let timeRange = $state<'day' | 'week' | 'month'>('month');
63
+
64
+
// filter daily data based on selected time range
65
+
// returns the last N days of data based on selection
66
+
let filteredDaily = $derived.by(() => {
67
+
if (!data?.costs.audd.daily.length) return [];
68
+
const daily = data.costs.audd.daily;
69
+
if (timeRange === 'day') {
70
+
// show last 2 days (today + yesterday) for 24h view
71
+
return daily.slice(-2);
72
+
} else if (timeRange === 'week') {
73
+
// show last 7 days
74
+
return daily.slice(-7);
75
+
} else {
76
+
// show all (up to 30 days)
77
+
return daily;
78
+
}
79
+
});
80
+
81
+
// calculate totals for selected time range
82
+
let filteredTotals = $derived.by(() => {
83
+
return {
84
+
requests: filteredDaily.reduce((sum, d) => sum + d.requests, 0),
85
+
scans: filteredDaily.reduce((sum, d) => sum + d.scans, 0)
86
+
};
87
+
});
88
89
// derived values for bar chart scaling
90
let maxCost = $derived(
···
98
: 1
99
);
100
101
+
let maxRequests = $derived.by(() => {
102
+
return filteredDaily.length ? Math.max(...filteredDaily.map((d) => d.requests)) : 1;
103
+
});
104
105
onMount(async () => {
106
try {
···
240
241
<!-- audd details -->
242
<section class="audd-section">
243
+
<div class="audd-header">
244
+
<h2>api requests (audd)</h2>
245
+
<div class="time-range-toggle">
246
+
<button
247
+
class:active={timeRange === 'day'}
248
+
onclick={() => (timeRange = 'day')}
249
+
>
250
+
24h
251
+
</button>
252
+
<button
253
+
class:active={timeRange === 'week'}
254
+
onclick={() => (timeRange = 'week')}
255
+
>
256
+
7d
257
+
</button>
258
+
<button
259
+
class:active={timeRange === 'month'}
260
+
onclick={() => (timeRange = 'month')}
261
+
>
262
+
30d
263
+
</button>
264
+
</div>
265
+
</div>
266
+
267
<div class="audd-stats">
268
<div class="stat">
269
+
<span class="stat-value">{filteredTotals.requests.toLocaleString()}</span>
270
+
<span class="stat-label">requests ({timeRange === 'day' ? '24h' : timeRange === 'week' ? '7d' : '30d'})</span>
271
</div>
272
<div class="stat">
273
<span class="stat-value">{data.costs.audd.remaining_free.toLocaleString()}</span>
274
<span class="stat-label">free remaining</span>
275
</div>
276
<div class="stat">
277
+
<span class="stat-value">{filteredTotals.scans.toLocaleString()}</span>
278
<span class="stat-label">tracks scanned</span>
279
</div>
280
</div>
···
283
1 request = 12s of audio. {data.costs.audd.free_requests.toLocaleString()} free/month,
284
then ${(5).toFixed(2)}/1k requests.
285
{#if data.costs.audd.billable_requests > 0}
286
+
<strong>{data.costs.audd.billable_requests.toLocaleString()} billable</strong> this billing period.
287
{/if}
288
</p>
289
290
+
{#if filteredDaily.length > 0}
291
<div class="daily-chart">
292
<h3>daily requests</h3>
293
<div class="chart-bars">
294
+
{#each filteredDaily as day}
295
<div class="chart-bar-container">
296
<div
297
class="chart-bar"
···
303
{/each}
304
</div>
305
</div>
306
+
{:else}
307
+
<p class="no-data">no requests in this time range</p>
308
{/if}
309
</section>
310
···
352
353
.subtitle {
354
color: var(--text-tertiary);
355
+
font-size: var(--text-base);
356
margin: 0;
357
}
358
···
370
371
.error-state .hint {
372
color: var(--text-tertiary);
373
+
font-size: var(--text-sm);
374
margin-top: 0.5rem;
375
}
376
···
386
padding: 2rem;
387
background: var(--bg-tertiary);
388
border: 1px solid var(--border-subtle);
389
+
border-radius: var(--radius-lg);
390
}
391
392
.total-label {
393
+
font-size: var(--text-sm);
394
text-transform: uppercase;
395
letter-spacing: 0.08em;
396
color: var(--text-tertiary);
···
405
406
.updated {
407
text-align: center;
408
+
font-size: var(--text-xs);
409
color: var(--text-tertiary);
410
margin-top: 0.75rem;
411
}
···
417
418
.breakdown-section h2,
419
.audd-section h2 {
420
+
font-size: var(--text-sm);
421
text-transform: uppercase;
422
letter-spacing: 0.08em;
423
color: var(--text-tertiary);
···
433
.cost-item {
434
background: var(--bg-tertiary);
435
border: 1px solid var(--border-subtle);
436
+
border-radius: var(--radius-md);
437
padding: 1rem;
438
}
439
···
458
.cost-bar-bg {
459
height: 8px;
460
background: var(--bg-primary);
461
+
border-radius: var(--radius-sm);
462
overflow: hidden;
463
margin-bottom: 0.5rem;
464
}
···
466
.cost-bar {
467
height: 100%;
468
background: var(--accent);
469
+
border-radius: var(--radius-sm);
470
transition: width 0.3s ease;
471
}
472
···
475
}
476
477
.cost-note {
478
+
font-size: var(--text-xs);
479
color: var(--text-tertiary);
480
}
481
···
484
margin-bottom: 2rem;
485
}
486
487
+
.audd-header {
488
+
display: flex;
489
+
justify-content: space-between;
490
+
align-items: center;
491
+
margin-bottom: 1rem;
492
+
gap: 1rem;
493
+
}
494
+
495
+
.audd-header h2 {
496
+
margin-bottom: 0;
497
+
}
498
+
499
+
.time-range-toggle {
500
+
display: flex;
501
+
gap: 0.25rem;
502
+
background: var(--bg-tertiary);
503
+
border: 1px solid var(--border-subtle);
504
+
border-radius: var(--radius-base);
505
+
padding: 0.25rem;
506
+
}
507
+
508
+
.time-range-toggle button {
509
+
padding: 0.35rem 0.75rem;
510
+
font-family: inherit;
511
+
font-size: var(--text-xs);
512
+
font-weight: 500;
513
+
background: transparent;
514
+
border: none;
515
+
border-radius: var(--radius-sm);
516
+
color: var(--text-secondary);
517
+
cursor: pointer;
518
+
transition: all 0.15s;
519
+
}
520
+
521
+
.time-range-toggle button:hover {
522
+
color: var(--text-primary);
523
+
}
524
+
525
+
.time-range-toggle button.active {
526
+
background: var(--accent);
527
+
color: white;
528
+
}
529
+
530
+
.no-data {
531
+
text-align: center;
532
+
color: var(--text-tertiary);
533
+
font-size: var(--text-sm);
534
+
padding: 2rem;
535
+
background: var(--bg-tertiary);
536
+
border: 1px solid var(--border-subtle);
537
+
border-radius: var(--radius-md);
538
+
}
539
+
540
.audd-stats {
541
display: grid;
542
grid-template-columns: repeat(3, 1fr);
···
545
}
546
547
.audd-explainer {
548
+
font-size: var(--text-sm);
549
color: var(--text-secondary);
550
margin-bottom: 1.5rem;
551
line-height: 1.5;
···
562
padding: 1rem;
563
background: var(--bg-tertiary);
564
border: 1px solid var(--border-subtle);
565
+
border-radius: var(--radius-md);
566
}
567
568
.stat-value {
569
+
font-size: var(--text-2xl);
570
font-weight: 700;
571
color: var(--text-primary);
572
font-variant-numeric: tabular-nums;
573
}
574
575
.stat-label {
576
+
font-size: var(--text-xs);
577
color: var(--text-tertiary);
578
text-align: center;
579
margin-top: 0.25rem;
···
583
.daily-chart {
584
background: var(--bg-tertiary);
585
border: 1px solid var(--border-subtle);
586
+
border-radius: var(--radius-md);
587
padding: 1rem;
588
+
overflow: hidden;
589
}
590
591
.daily-chart h3 {
592
+
font-size: var(--text-xs);
593
text-transform: uppercase;
594
letter-spacing: 0.05em;
595
color: var(--text-tertiary);
···
599
.chart-bars {
600
display: flex;
601
align-items: flex-end;
602
+
gap: 2px;
603
height: 100px;
604
+
width: 100%;
605
}
606
607
.chart-bar-container {
608
+
flex: 1 1 0;
609
+
min-width: 0;
610
display: flex;
611
flex-direction: column;
612
align-items: center;
···
627
}
628
629
.chart-label {
630
+
font-size: 0.55rem;
631
color: var(--text-tertiary);
632
+
margin-top: 0.25rem;
633
white-space: nowrap;
634
+
overflow: hidden;
635
+
text-overflow: ellipsis;
636
+
max-width: 100%;
637
}
638
639
/* support section */
···
652
var(--bg-tertiary)
653
);
654
border: 1px solid var(--border-subtle);
655
+
border-radius: var(--radius-lg);
656
}
657
658
.support-icon {
···
662
663
.support-text h3 {
664
margin: 0 0 0.5rem;
665
+
font-size: var(--text-xl);
666
color: var(--text-primary);
667
}
668
669
.support-text p {
670
margin: 0 0 1.5rem;
671
color: var(--text-secondary);
672
+
font-size: var(--text-base);
673
}
674
675
.support-button {
···
679
padding: 0.75rem 1.5rem;
680
background: var(--accent);
681
color: white;
682
+
border-radius: var(--radius-md);
683
text-decoration: none;
684
font-weight: 600;
685
+
font-size: var(--text-base);
686
transition: transform 0.15s, box-shadow 0.15s;
687
}
688
···
694
/* footer */
695
.footer-note {
696
text-align: center;
697
+
font-size: var(--text-sm);
698
color: var(--text-tertiary);
699
padding-bottom: 1rem;
700
}
+1
-1
frontend/src/routes/embed/track/[id]/+page.svelte
+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
<script lang="ts">
2
import Header from '$lib/components/Header.svelte';
3
import { auth } from '$lib/auth.svelte';
4
-
import { goto } from '$app/navigation';
5
import { API_URL } from '$lib/config';
6
import type { PageData } from './$types';
7
import type { Playlist } from '$lib/types';
···
13
let newPlaylistName = $state('');
14
let creating = $state(false);
15
let error = $state('');
16
17
async function handleLogout() {
18
await auth.logout();
···
226
}
227
228
.page-header p {
229
-
font-size: 0.9rem;
230
color: var(--text-tertiary);
231
margin: 0;
232
}
···
244
padding: 1rem 1.25rem;
245
background: var(--bg-secondary);
246
border: 1px solid var(--border-default);
247
-
border-radius: 12px;
248
text-decoration: none;
249
color: inherit;
250
transition: all 0.15s;
···
262
.collection-icon {
263
width: 48px;
264
height: 48px;
265
-
border-radius: 10px;
266
display: flex;
267
align-items: center;
268
justify-content: center;
···
282
.playlist-artwork {
283
width: 48px;
284
height: 48px;
285
-
border-radius: 10px;
286
object-fit: cover;
287
flex-shrink: 0;
288
}
···
293
}
294
295
.collection-info h3 {
296
-
font-size: 1rem;
297
font-weight: 600;
298
color: var(--text-primary);
299
margin: 0 0 0.15rem 0;
···
303
}
304
305
.collection-info p {
306
-
font-size: 0.85rem;
307
color: var(--text-tertiary);
308
margin: 0;
309
}
···
332
}
333
334
.section-header h2 {
335
-
font-size: 1.1rem;
336
font-weight: 600;
337
color: var(--text-primary);
338
margin: 0;
···
346
background: var(--accent);
347
color: white;
348
border: none;
349
-
border-radius: 8px;
350
font-family: inherit;
351
font-size: 0.875rem;
352
font-weight: 500;
···
377
padding: 3rem 2rem;
378
background: var(--bg-secondary);
379
border: 1px dashed var(--border-default);
380
-
border-radius: 12px;
381
text-align: center;
382
}
383
384
.empty-icon {
385
width: 64px;
386
height: 64px;
387
-
border-radius: 16px;
388
display: flex;
389
align-items: center;
390
justify-content: center;
···
394
}
395
396
.empty-state p {
397
-
font-size: 1rem;
398
font-weight: 500;
399
color: var(--text-secondary);
400
margin: 0 0 0.25rem 0;
401
}
402
403
.empty-state span {
404
-
font-size: 0.85rem;
405
color: var(--text-muted);
406
}
407
···
423
.modal {
424
background: var(--bg-primary);
425
border: 1px solid var(--border-default);
426
-
border-radius: 16px;
427
width: 100%;
428
max-width: 400px;
429
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
···
438
}
439
440
.modal-header h3 {
441
-
font-size: 1.1rem;
442
font-weight: 600;
443
color: var(--text-primary);
444
margin: 0;
···
452
height: 32px;
453
background: transparent;
454
border: none;
455
-
border-radius: 8px;
456
color: var(--text-secondary);
457
cursor: pointer;
458
transition: all 0.15s;
···
469
470
.modal-body label {
471
display: block;
472
-
font-size: 0.85rem;
473
font-weight: 500;
474
color: var(--text-secondary);
475
margin-bottom: 0.5rem;
···
480
padding: 0.75rem 1rem;
481
background: var(--bg-secondary);
482
border: 1px solid var(--border-default);
483
-
border-radius: 8px;
484
font-family: inherit;
485
-
font-size: 1rem;
486
color: var(--text-primary);
487
transition: border-color 0.15s;
488
}
···
498
499
.modal-body .error {
500
margin: 0.5rem 0 0 0;
501
-
font-size: 0.85rem;
502
color: #ef4444;
503
}
504
···
512
.cancel-btn,
513
.confirm-btn {
514
padding: 0.625rem 1.25rem;
515
-
border-radius: 8px;
516
font-family: inherit;
517
-
font-size: 0.9rem;
518
font-weight: 500;
519
cursor: pointer;
520
transition: all 0.15s;
···
558
}
559
560
.page-header h1 {
561
-
font-size: 1.5rem;
562
}
563
564
.collection-card {
···
571
}
572
573
.collection-info h3 {
574
-
font-size: 0.95rem;
575
}
576
577
.section-header h2 {
578
-
font-size: 1rem;
579
}
580
581
.create-btn {
582
padding: 0.5rem 0.875rem;
583
-
font-size: 0.85rem;
584
}
585
586
.empty-state {
···
1
<script lang="ts">
2
+
import { onMount } from 'svelte';
3
+
import { replaceState, invalidateAll, goto } from '$app/navigation';
4
import Header from '$lib/components/Header.svelte';
5
import { auth } from '$lib/auth.svelte';
6
+
import { preferences } from '$lib/preferences.svelte';
7
import { API_URL } from '$lib/config';
8
import type { PageData } from './$types';
9
import type { Playlist } from '$lib/types';
···
15
let newPlaylistName = $state('');
16
let creating = $state(false);
17
let error = $state('');
18
+
19
+
onMount(async () => {
20
+
// check if exchange_token is in URL (from OAuth callback)
21
+
const params = new URLSearchParams(window.location.search);
22
+
const exchangeToken = params.get('exchange_token');
23
+
const isDevToken = params.get('dev_token') === 'true';
24
+
25
+
// redirect dev token callbacks to settings page
26
+
if (exchangeToken && isDevToken) {
27
+
window.location.href = `/settings?exchange_token=${exchangeToken}&dev_token=true`;
28
+
return;
29
+
}
30
+
31
+
if (exchangeToken) {
32
+
// regular login - exchange token for session
33
+
try {
34
+
const exchangeResponse = await fetch(`${API_URL}/auth/exchange`, {
35
+
method: 'POST',
36
+
headers: { 'Content-Type': 'application/json' },
37
+
credentials: 'include',
38
+
body: JSON.stringify({ exchange_token: exchangeToken })
39
+
});
40
+
41
+
if (exchangeResponse.ok) {
42
+
// invalidate all load functions so they rerun with the new session cookie
43
+
await invalidateAll();
44
+
await auth.initialize();
45
+
await preferences.fetch();
46
+
}
47
+
} catch (_e) {
48
+
console.error('failed to exchange token:', _e);
49
+
}
50
+
51
+
replaceState('/library', {});
52
+
}
53
+
});
54
55
async function handleLogout() {
56
await auth.logout();
···
264
}
265
266
.page-header p {
267
+
font-size: var(--text-base);
268
color: var(--text-tertiary);
269
margin: 0;
270
}
···
282
padding: 1rem 1.25rem;
283
background: var(--bg-secondary);
284
border: 1px solid var(--border-default);
285
+
border-radius: var(--radius-lg);
286
text-decoration: none;
287
color: inherit;
288
transition: all 0.15s;
···
300
.collection-icon {
301
width: 48px;
302
height: 48px;
303
+
border-radius: var(--radius-md);
304
display: flex;
305
align-items: center;
306
justify-content: center;
···
320
.playlist-artwork {
321
width: 48px;
322
height: 48px;
323
+
border-radius: var(--radius-md);
324
object-fit: cover;
325
flex-shrink: 0;
326
}
···
331
}
332
333
.collection-info h3 {
334
+
font-size: var(--text-lg);
335
font-weight: 600;
336
color: var(--text-primary);
337
margin: 0 0 0.15rem 0;
···
341
}
342
343
.collection-info p {
344
+
font-size: var(--text-sm);
345
color: var(--text-tertiary);
346
margin: 0;
347
}
···
370
}
371
372
.section-header h2 {
373
+
font-size: var(--text-xl);
374
font-weight: 600;
375
color: var(--text-primary);
376
margin: 0;
···
384
background: var(--accent);
385
color: white;
386
border: none;
387
+
border-radius: var(--radius-md);
388
font-family: inherit;
389
font-size: 0.875rem;
390
font-weight: 500;
···
415
padding: 3rem 2rem;
416
background: var(--bg-secondary);
417
border: 1px dashed var(--border-default);
418
+
border-radius: var(--radius-lg);
419
text-align: center;
420
}
421
422
.empty-icon {
423
width: 64px;
424
height: 64px;
425
+
border-radius: var(--radius-xl);
426
display: flex;
427
align-items: center;
428
justify-content: center;
···
432
}
433
434
.empty-state p {
435
+
font-size: var(--text-lg);
436
font-weight: 500;
437
color: var(--text-secondary);
438
margin: 0 0 0.25rem 0;
439
}
440
441
.empty-state span {
442
+
font-size: var(--text-sm);
443
color: var(--text-muted);
444
}
445
···
461
.modal {
462
background: var(--bg-primary);
463
border: 1px solid var(--border-default);
464
+
border-radius: var(--radius-xl);
465
width: 100%;
466
max-width: 400px;
467
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
···
476
}
477
478
.modal-header h3 {
479
+
font-size: var(--text-xl);
480
font-weight: 600;
481
color: var(--text-primary);
482
margin: 0;
···
490
height: 32px;
491
background: transparent;
492
border: none;
493
+
border-radius: var(--radius-md);
494
color: var(--text-secondary);
495
cursor: pointer;
496
transition: all 0.15s;
···
507
508
.modal-body label {
509
display: block;
510
+
font-size: var(--text-sm);
511
font-weight: 500;
512
color: var(--text-secondary);
513
margin-bottom: 0.5rem;
···
518
padding: 0.75rem 1rem;
519
background: var(--bg-secondary);
520
border: 1px solid var(--border-default);
521
+
border-radius: var(--radius-md);
522
font-family: inherit;
523
+
font-size: var(--text-lg);
524
color: var(--text-primary);
525
transition: border-color 0.15s;
526
}
···
536
537
.modal-body .error {
538
margin: 0.5rem 0 0 0;
539
+
font-size: var(--text-sm);
540
color: #ef4444;
541
}
542
···
550
.cancel-btn,
551
.confirm-btn {
552
padding: 0.625rem 1.25rem;
553
+
border-radius: var(--radius-md);
554
font-family: inherit;
555
+
font-size: var(--text-base);
556
font-weight: 500;
557
cursor: pointer;
558
transition: all 0.15s;
···
596
}
597
598
.page-header h1 {
599
+
font-size: var(--text-3xl);
600
}
601
602
.collection-card {
···
609
}
610
611
.collection-info h3 {
612
+
font-size: var(--text-base);
613
}
614
615
.section-header h2 {
616
+
font-size: var(--text-lg);
617
}
618
619
.create-btn {
620
padding: 0.5rem 0.875rem;
621
+
font-size: var(--text-sm);
622
}
623
624
.empty-state {
+12
-12
frontend/src/routes/liked/+page.svelte
+12
-12
frontend/src/routes/liked/+page.svelte
···
345
}
346
347
.count {
348
-
font-size: 0.85rem;
349
font-weight: 500;
350
color: var(--text-tertiary);
351
background: var(--bg-tertiary);
352
padding: 0.2rem 0.55rem;
353
-
border-radius: 4px;
354
}
355
356
.header-actions {
···
362
.queue-button,
363
.reorder-button {
364
padding: 0.75rem 1.5rem;
365
-
border-radius: 24px;
366
font-weight: 600;
367
-
font-size: 0.95rem;
368
font-family: inherit;
369
cursor: pointer;
370
transition: all 0.2s;
···
419
}
420
421
.empty-state h2 {
422
-
font-size: 1.5rem;
423
font-weight: 600;
424
color: var(--text-secondary);
425
margin: 0 0 0.5rem 0;
426
}
427
428
.empty-state p {
429
-
font-size: 0.95rem;
430
margin: 0;
431
}
432
···
441
display: flex;
442
align-items: center;
443
gap: 0.5rem;
444
-
border-radius: 8px;
445
transition: all 0.2s;
446
position: relative;
447
}
···
473
color: var(--text-muted);
474
cursor: grab;
475
touch-action: none;
476
-
border-radius: 4px;
477
transition: all 0.2s;
478
flex-shrink: 0;
479
}
···
505
}
506
507
.section-header h2 {
508
-
font-size: 1.25rem;
509
}
510
511
.count {
512
-
font-size: 0.8rem;
513
padding: 0.15rem 0.45rem;
514
}
515
···
518
}
519
520
.empty-state h2 {
521
-
font-size: 1.25rem;
522
}
523
524
.header-actions {
···
528
.queue-button,
529
.reorder-button {
530
padding: 0.6rem 1rem;
531
-
font-size: 0.85rem;
532
}
533
534
.queue-button svg,
···
345
}
346
347
.count {
348
+
font-size: var(--text-sm);
349
font-weight: 500;
350
color: var(--text-tertiary);
351
background: var(--bg-tertiary);
352
padding: 0.2rem 0.55rem;
353
+
border-radius: var(--radius-sm);
354
}
355
356
.header-actions {
···
362
.queue-button,
363
.reorder-button {
364
padding: 0.75rem 1.5rem;
365
+
border-radius: var(--radius-2xl);
366
font-weight: 600;
367
+
font-size: var(--text-base);
368
font-family: inherit;
369
cursor: pointer;
370
transition: all 0.2s;
···
419
}
420
421
.empty-state h2 {
422
+
font-size: var(--text-3xl);
423
font-weight: 600;
424
color: var(--text-secondary);
425
margin: 0 0 0.5rem 0;
426
}
427
428
.empty-state p {
429
+
font-size: var(--text-base);
430
margin: 0;
431
}
432
···
441
display: flex;
442
align-items: center;
443
gap: 0.5rem;
444
+
border-radius: var(--radius-md);
445
transition: all 0.2s;
446
position: relative;
447
}
···
473
color: var(--text-muted);
474
cursor: grab;
475
touch-action: none;
476
+
border-radius: var(--radius-sm);
477
transition: all 0.2s;
478
flex-shrink: 0;
479
}
···
505
}
506
507
.section-header h2 {
508
+
font-size: var(--text-2xl);
509
}
510
511
.count {
512
+
font-size: var(--text-sm);
513
padding: 0.15rem 0.45rem;
514
}
515
···
518
}
519
520
.empty-state h2 {
521
+
font-size: var(--text-2xl);
522
}
523
524
.header-actions {
···
528
.queue-button,
529
.reorder-button {
530
padding: 0.6rem 1rem;
531
+
font-size: var(--text-sm);
532
}
533
534
.queue-button svg,
+16
-16
frontend/src/routes/liked/[handle]/+page.svelte
+16
-16
frontend/src/routes/liked/[handle]/+page.svelte
···
126
.avatar {
127
width: 64px;
128
height: 64px;
129
-
border-radius: 50%;
130
object-fit: cover;
131
flex-shrink: 0;
132
}
···
137
justify-content: center;
138
background: var(--bg-tertiary);
139
color: var(--text-secondary);
140
-
font-size: 1.5rem;
141
font-weight: 600;
142
}
143
···
149
}
150
151
.user-info h1 {
152
-
font-size: 1.5rem;
153
font-weight: 700;
154
color: var(--text-primary);
155
margin: 0;
···
159
}
160
161
.handle {
162
-
font-size: 0.9rem;
163
color: var(--text-tertiary);
164
text-decoration: none;
165
transition: color 0.15s;
···
189
}
190
191
.count {
192
-
font-size: 0.95rem;
193
font-weight: 500;
194
color: var(--text-secondary);
195
}
···
208
background: transparent;
209
border: 1px solid var(--border-default);
210
color: var(--text-secondary);
211
-
border-radius: 6px;
212
-
font-size: 0.85rem;
213
font-family: inherit;
214
cursor: pointer;
215
transition: all 0.15s;
···
241
}
242
243
.empty-state h2 {
244
-
font-size: 1.5rem;
245
font-weight: 600;
246
color: var(--text-secondary);
247
margin: 0 0 0.5rem 0;
248
}
249
250
.empty-state p {
251
-
font-size: 0.95rem;
252
margin: 0;
253
}
254
···
275
}
276
277
.avatar-placeholder {
278
-
font-size: 1.25rem;
279
}
280
281
.user-info h1 {
282
-
font-size: 1.25rem;
283
}
284
285
.handle {
286
-
font-size: 0.85rem;
287
}
288
289
.section-header h2 {
290
-
font-size: 1.25rem;
291
}
292
293
.count {
294
-
font-size: 0.85rem;
295
}
296
297
.empty-state {
···
299
}
300
301
.empty-state h2 {
302
-
font-size: 1.25rem;
303
}
304
305
.btn-action {
306
padding: 0.45rem 0.7rem;
307
-
font-size: 0.8rem;
308
}
309
310
.btn-action svg {
···
126
.avatar {
127
width: 64px;
128
height: 64px;
129
+
border-radius: var(--radius-full);
130
object-fit: cover;
131
flex-shrink: 0;
132
}
···
137
justify-content: center;
138
background: var(--bg-tertiary);
139
color: var(--text-secondary);
140
+
font-size: var(--text-3xl);
141
font-weight: 600;
142
}
143
···
149
}
150
151
.user-info h1 {
152
+
font-size: var(--text-3xl);
153
font-weight: 700;
154
color: var(--text-primary);
155
margin: 0;
···
159
}
160
161
.handle {
162
+
font-size: var(--text-base);
163
color: var(--text-tertiary);
164
text-decoration: none;
165
transition: color 0.15s;
···
189
}
190
191
.count {
192
+
font-size: var(--text-base);
193
font-weight: 500;
194
color: var(--text-secondary);
195
}
···
208
background: transparent;
209
border: 1px solid var(--border-default);
210
color: var(--text-secondary);
211
+
border-radius: var(--radius-base);
212
+
font-size: var(--text-sm);
213
font-family: inherit;
214
cursor: pointer;
215
transition: all 0.15s;
···
241
}
242
243
.empty-state h2 {
244
+
font-size: var(--text-3xl);
245
font-weight: 600;
246
color: var(--text-secondary);
247
margin: 0 0 0.5rem 0;
248
}
249
250
.empty-state p {
251
+
font-size: var(--text-base);
252
margin: 0;
253
}
254
···
275
}
276
277
.avatar-placeholder {
278
+
font-size: var(--text-2xl);
279
}
280
281
.user-info h1 {
282
+
font-size: var(--text-2xl);
283
}
284
285
.handle {
286
+
font-size: var(--text-sm);
287
}
288
289
.section-header h2 {
290
+
font-size: var(--text-2xl);
291
}
292
293
.count {
294
+
font-size: var(--text-sm);
295
}
296
297
.empty-state {
···
299
}
300
301
.empty-state h2 {
302
+
font-size: var(--text-2xl);
303
}
304
305
.btn-action {
306
padding: 0.45rem 0.7rem;
307
+
font-size: var(--text-sm);
308
}
309
310
.btn-action svg {
+8
-8
frontend/src/routes/login/+page.svelte
+8
-8
frontend/src/routes/login/+page.svelte
···
142
.login-card {
143
background: var(--bg-tertiary);
144
border: 1px solid var(--border-subtle);
145
-
border-radius: 12px;
146
padding: 2.5rem;
147
max-width: 420px;
148
width: 100%;
···
171
172
label {
173
color: var(--text-secondary);
174
-
font-size: 0.9rem;
175
}
176
177
button.primary {
···
180
background: var(--accent);
181
color: white;
182
border: none;
183
-
border-radius: 8px;
184
-
font-size: 0.95rem;
185
font-weight: 500;
186
font-family: inherit;
187
cursor: pointer;
···
213
border: none;
214
color: var(--text-secondary);
215
font-family: inherit;
216
-
font-size: 0.9rem;
217
cursor: pointer;
218
text-align: left;
219
}
···
234
.faq-content {
235
padding: 0 0 1rem 0;
236
color: var(--text-tertiary);
237
-
font-size: 0.85rem;
238
line-height: 1.6;
239
}
240
···
259
.faq-content code {
260
background: var(--bg-secondary);
261
padding: 0.15rem 0.4rem;
262
-
border-radius: 4px;
263
font-size: 0.85em;
264
}
265
···
269
}
270
271
h1 {
272
-
font-size: 1.5rem;
273
}
274
}
275
</style>
···
142
.login-card {
143
background: var(--bg-tertiary);
144
border: 1px solid var(--border-subtle);
145
+
border-radius: var(--radius-lg);
146
padding: 2.5rem;
147
max-width: 420px;
148
width: 100%;
···
171
172
label {
173
color: var(--text-secondary);
174
+
font-size: var(--text-base);
175
}
176
177
button.primary {
···
180
background: var(--accent);
181
color: white;
182
border: none;
183
+
border-radius: var(--radius-md);
184
+
font-size: var(--text-base);
185
font-weight: 500;
186
font-family: inherit;
187
cursor: pointer;
···
213
border: none;
214
color: var(--text-secondary);
215
font-family: inherit;
216
+
font-size: var(--text-base);
217
cursor: pointer;
218
text-align: left;
219
}
···
234
.faq-content {
235
padding: 0 0 1rem 0;
236
color: var(--text-tertiary);
237
+
font-size: var(--text-sm);
238
line-height: 1.6;
239
}
240
···
259
.faq-content code {
260
background: var(--bg-secondary);
261
padding: 0.15rem 0.4rem;
262
+
border-radius: var(--radius-sm);
263
font-size: 0.85em;
264
}
265
···
269
}
270
271
h1 {
272
+
font-size: var(--text-3xl);
273
}
274
}
275
</style>
+73
-51
frontend/src/routes/playlist/[id]/+page.svelte
+73
-51
frontend/src/routes/playlist/[id]/+page.svelte
···
12
import { toast } from "$lib/toast.svelte";
13
import { player } from "$lib/player.svelte";
14
import { queue } from "$lib/queue.svelte";
15
import { fetchLikedTracks } from "$lib/tracks.svelte";
16
import type { PageData } from "./$types";
17
import type { PlaylistWithTracks, Track } from "$lib/types";
···
143
queue.playNow(track);
144
}
145
146
-
function playNow() {
147
if (tracks.length > 0) {
148
-
queue.setQueue(tracks);
149
-
queue.playNow(tracks[0]);
150
-
toast.success(`playing ${playlist.name}`, 1800);
151
}
152
}
153
···
604
605
// check if user owns this playlist
606
const isOwner = $derived(auth.user?.did === playlist.owner_did);
607
</script>
608
609
<svelte:window on:keydown={handleKeydown} />
···
862
</div>
863
864
<div class="playlist-actions">
865
-
<button class="play-button" onclick={playNow}>
866
-
<svg
867
-
width="20"
868
-
height="20"
869
-
viewBox="0 0 24 24"
870
-
fill="currentColor"
871
-
>
872
-
<path d="M8 5v14l11-7z" />
873
-
</svg>
874
-
play now
875
</button>
876
<button class="queue-button" onclick={addToQueue}>
877
<svg
···
1338
.playlist-art {
1339
width: 200px;
1340
height: 200px;
1341
-
border-radius: 8px;
1342
object-fit: cover;
1343
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
1344
}
···
1346
.playlist-art-placeholder {
1347
width: 200px;
1348
height: 200px;
1349
-
border-radius: 8px;
1350
background: var(--bg-tertiary);
1351
border: 1px solid var(--border-subtle);
1352
display: flex;
···
1391
opacity: 0;
1392
transition: opacity 0.2s;
1393
pointer-events: none;
1394
-
border-radius: 8px;
1395
font-family: inherit;
1396
}
1397
1398
.art-edit-overlay span {
1399
font-family: inherit;
1400
-
font-size: 0.85rem;
1401
font-weight: 500;
1402
}
1403
···
1429
1430
.playlist-type {
1431
text-transform: uppercase;
1432
-
font-size: 0.75rem;
1433
font-weight: 600;
1434
letter-spacing: 0.1em;
1435
color: var(--text-tertiary);
···
1470
display: flex;
1471
align-items: center;
1472
gap: 0.75rem;
1473
-
font-size: 0.95rem;
1474
color: var(--text-secondary);
1475
}
1476
···
1487
1488
.meta-separator {
1489
color: var(--text-muted);
1490
-
font-size: 0.7rem;
1491
}
1492
1493
.show-on-profile-toggle {
···
1496
gap: 0.5rem;
1497
margin-top: 0.75rem;
1498
cursor: pointer;
1499
-
font-size: 0.85rem;
1500
color: var(--text-secondary);
1501
}
1502
···
1523
height: 32px;
1524
background: var(--glass-btn-bg, rgba(18, 18, 18, 0.75));
1525
border: 1px solid var(--glass-btn-border, rgba(255, 255, 255, 0.1));
1526
-
border-radius: 6px;
1527
color: var(--text-secondary);
1528
cursor: pointer;
1529
transition: all 0.15s;
···
1557
.play-button,
1558
.queue-button {
1559
padding: 0.75rem 1.5rem;
1560
-
border-radius: 24px;
1561
font-weight: 600;
1562
-
font-size: 0.95rem;
1563
font-family: inherit;
1564
cursor: pointer;
1565
transition: all 0.2s;
···
1578
transform: scale(1.05);
1579
}
1580
1581
.queue-button {
1582
background: var(--glass-btn-bg, transparent);
1583
color: var(--text-primary);
···
1612
}
1613
1614
.section-heading {
1615
-
font-size: 1.25rem;
1616
font-weight: 600;
1617
color: var(--text-primary);
1618
margin-bottom: 1rem;
···
1631
display: flex;
1632
align-items: center;
1633
gap: 0.5rem;
1634
-
border-radius: 8px;
1635
transition: all 0.2s;
1636
position: relative;
1637
}
···
1663
color: var(--text-muted);
1664
cursor: grab;
1665
touch-action: none;
1666
-
border-radius: 4px;
1667
transition: all 0.2s;
1668
flex-shrink: 0;
1669
}
···
1696
padding: 0.5rem;
1697
background: transparent;
1698
border: 1px solid var(--border-default);
1699
-
border-radius: 4px;
1700
color: var(--text-muted);
1701
cursor: pointer;
1702
transition: all 0.2s;
···
1729
margin-top: 0.5rem;
1730
background: transparent;
1731
border: 1px dashed var(--border-default);
1732
-
border-radius: 8px;
1733
color: var(--text-tertiary);
1734
font-family: inherit;
1735
-
font-size: 0.9rem;
1736
cursor: pointer;
1737
transition: all 0.2s;
1738
}
···
1756
.empty-icon {
1757
width: 64px;
1758
height: 64px;
1759
-
border-radius: 16px;
1760
display: flex;
1761
align-items: center;
1762
justify-content: center;
···
1766
}
1767
1768
.empty-state p {
1769
-
font-size: 1rem;
1770
font-weight: 500;
1771
color: var(--text-secondary);
1772
margin: 0 0 0.25rem 0;
1773
}
1774
1775
.empty-state span {
1776
-
font-size: 0.85rem;
1777
color: var(--text-muted);
1778
margin-bottom: 1.5rem;
1779
}
···
1783
background: var(--accent);
1784
color: white;
1785
border: none;
1786
-
border-radius: 8px;
1787
font-family: inherit;
1788
-
font-size: 0.9rem;
1789
font-weight: 500;
1790
cursor: pointer;
1791
transition: all 0.15s;
···
1813
.modal {
1814
background: var(--bg-primary);
1815
border: 1px solid var(--border-default);
1816
-
border-radius: 16px;
1817
width: 100%;
1818
max-width: 400px;
1819
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
···
1835
}
1836
1837
.modal-header h3 {
1838
-
font-size: 1.1rem;
1839
font-weight: 600;
1840
color: var(--text-primary);
1841
margin: 0;
···
1849
height: 32px;
1850
background: transparent;
1851
border: none;
1852
-
border-radius: 8px;
1853
color: var(--text-secondary);
1854
cursor: pointer;
1855
transition: all 0.15s;
···
1874
background: transparent;
1875
border: none;
1876
font-family: inherit;
1877
-
font-size: 1rem;
1878
color: var(--text-primary);
1879
outline: none;
1880
}
···
1895
padding: 2rem 1.5rem;
1896
text-align: center;
1897
color: var(--text-muted);
1898
-
font-size: 0.9rem;
1899
margin: 0;
1900
}
1901
···
1919
.result-image-placeholder {
1920
width: 40px;
1921
height: 40px;
1922
-
border-radius: 6px;
1923
flex-shrink: 0;
1924
}
1925
···
1944
}
1945
1946
.result-title {
1947
-
font-size: 0.9rem;
1948
font-weight: 500;
1949
color: var(--text-primary);
1950
white-space: nowrap;
···
1953
}
1954
1955
.result-artist {
1956
-
font-size: 0.8rem;
1957
color: var(--text-tertiary);
1958
white-space: nowrap;
1959
overflow: hidden;
···
1968
height: 36px;
1969
background: var(--accent);
1970
border: none;
1971
-
border-radius: 8px;
1972
color: white;
1973
cursor: pointer;
1974
transition: all 0.15s;
···
1991
.modal-body p {
1992
margin: 0;
1993
color: var(--text-secondary);
1994
-
font-size: 0.95rem;
1995
line-height: 1.5;
1996
}
1997
···
2005
.cancel-btn,
2006
.confirm-btn {
2007
padding: 0.625rem 1.25rem;
2008
-
border-radius: 8px;
2009
font-family: inherit;
2010
-
font-size: 0.9rem;
2011
font-weight: 500;
2012
cursor: pointer;
2013
transition: all 0.15s;
···
2050
height: 16px;
2051
border: 2px solid currentColor;
2052
border-top-color: transparent;
2053
-
border-radius: 50%;
2054
animation: spin 0.6s linear infinite;
2055
}
2056
···
2096
}
2097
2098
.playlist-meta {
2099
-
font-size: 0.85rem;
2100
}
2101
2102
.playlist-actions {
···
2139
}
2140
2141
.playlist-meta {
2142
-
font-size: 0.8rem;
2143
flex-wrap: wrap;
2144
}
2145
}
···
12
import { toast } from "$lib/toast.svelte";
13
import { player } from "$lib/player.svelte";
14
import { queue } from "$lib/queue.svelte";
15
+
import { playQueue } from "$lib/playback.svelte";
16
import { fetchLikedTracks } from "$lib/tracks.svelte";
17
import type { PageData } from "./$types";
18
import type { PlaylistWithTracks, Track } from "$lib/types";
···
144
queue.playNow(track);
145
}
146
147
+
async function playNow() {
148
if (tracks.length > 0) {
149
+
// use playQueue to check gated access on first track before modifying queue
150
+
const played = await playQueue(tracks);
151
+
if (played) {
152
+
toast.success(`playing ${playlist.name}`, 1800);
153
+
}
154
}
155
}
156
···
607
608
// check if user owns this playlist
609
const isOwner = $derived(auth.user?.did === playlist.owner_did);
610
+
611
+
// check if current track is from this playlist (active, regardless of paused state)
612
+
const isPlaylistActive = $derived(
613
+
player.currentTrack !== null &&
614
+
tracks.some(t => t.id === player.currentTrack?.id)
615
+
);
616
+
617
+
// check if actively playing (not paused)
618
+
const isPlaylistPlaying = $derived(isPlaylistActive && !player.paused);
619
</script>
620
621
<svelte:window on:keydown={handleKeydown} />
···
874
</div>
875
876
<div class="playlist-actions">
877
+
<button
878
+
class="play-button"
879
+
class:is-playing={isPlaylistPlaying}
880
+
onclick={() => isPlaylistActive ? player.togglePlayPause() : playNow()}
881
+
>
882
+
{#if isPlaylistPlaying}
883
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
884
+
<path d="M6 4h4v16H6zM14 4h4v16h-4z"/>
885
+
</svg>
886
+
pause
887
+
{:else}
888
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
889
+
<path d="M8 5v14l11-7z" />
890
+
</svg>
891
+
play
892
+
{/if}
893
</button>
894
<button class="queue-button" onclick={addToQueue}>
895
<svg
···
1356
.playlist-art {
1357
width: 200px;
1358
height: 200px;
1359
+
border-radius: var(--radius-md);
1360
object-fit: cover;
1361
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
1362
}
···
1364
.playlist-art-placeholder {
1365
width: 200px;
1366
height: 200px;
1367
+
border-radius: var(--radius-md);
1368
background: var(--bg-tertiary);
1369
border: 1px solid var(--border-subtle);
1370
display: flex;
···
1409
opacity: 0;
1410
transition: opacity 0.2s;
1411
pointer-events: none;
1412
+
border-radius: var(--radius-md);
1413
font-family: inherit;
1414
}
1415
1416
.art-edit-overlay span {
1417
font-family: inherit;
1418
+
font-size: var(--text-sm);
1419
font-weight: 500;
1420
}
1421
···
1447
1448
.playlist-type {
1449
text-transform: uppercase;
1450
+
font-size: var(--text-xs);
1451
font-weight: 600;
1452
letter-spacing: 0.1em;
1453
color: var(--text-tertiary);
···
1488
display: flex;
1489
align-items: center;
1490
gap: 0.75rem;
1491
+
font-size: var(--text-base);
1492
color: var(--text-secondary);
1493
}
1494
···
1505
1506
.meta-separator {
1507
color: var(--text-muted);
1508
+
font-size: var(--text-xs);
1509
}
1510
1511
.show-on-profile-toggle {
···
1514
gap: 0.5rem;
1515
margin-top: 0.75rem;
1516
cursor: pointer;
1517
+
font-size: var(--text-sm);
1518
color: var(--text-secondary);
1519
}
1520
···
1541
height: 32px;
1542
background: var(--glass-btn-bg, rgba(18, 18, 18, 0.75));
1543
border: 1px solid var(--glass-btn-border, rgba(255, 255, 255, 0.1));
1544
+
border-radius: var(--radius-base);
1545
color: var(--text-secondary);
1546
cursor: pointer;
1547
transition: all 0.15s;
···
1575
.play-button,
1576
.queue-button {
1577
padding: 0.75rem 1.5rem;
1578
+
border-radius: var(--radius-2xl);
1579
font-weight: 600;
1580
+
font-size: var(--text-base);
1581
font-family: inherit;
1582
cursor: pointer;
1583
transition: all 0.2s;
···
1596
transform: scale(1.05);
1597
}
1598
1599
+
.play-button.is-playing {
1600
+
animation: ethereal-glow 3s ease-in-out infinite;
1601
+
}
1602
+
1603
.queue-button {
1604
background: var(--glass-btn-bg, transparent);
1605
color: var(--text-primary);
···
1634
}
1635
1636
.section-heading {
1637
+
font-size: var(--text-2xl);
1638
font-weight: 600;
1639
color: var(--text-primary);
1640
margin-bottom: 1rem;
···
1653
display: flex;
1654
align-items: center;
1655
gap: 0.5rem;
1656
+
border-radius: var(--radius-md);
1657
transition: all 0.2s;
1658
position: relative;
1659
}
···
1685
color: var(--text-muted);
1686
cursor: grab;
1687
touch-action: none;
1688
+
border-radius: var(--radius-sm);
1689
transition: all 0.2s;
1690
flex-shrink: 0;
1691
}
···
1718
padding: 0.5rem;
1719
background: transparent;
1720
border: 1px solid var(--border-default);
1721
+
border-radius: var(--radius-sm);
1722
color: var(--text-muted);
1723
cursor: pointer;
1724
transition: all 0.2s;
···
1751
margin-top: 0.5rem;
1752
background: transparent;
1753
border: 1px dashed var(--border-default);
1754
+
border-radius: var(--radius-md);
1755
color: var(--text-tertiary);
1756
font-family: inherit;
1757
+
font-size: var(--text-base);
1758
cursor: pointer;
1759
transition: all 0.2s;
1760
}
···
1778
.empty-icon {
1779
width: 64px;
1780
height: 64px;
1781
+
border-radius: var(--radius-xl);
1782
display: flex;
1783
align-items: center;
1784
justify-content: center;
···
1788
}
1789
1790
.empty-state p {
1791
+
font-size: var(--text-lg);
1792
font-weight: 500;
1793
color: var(--text-secondary);
1794
margin: 0 0 0.25rem 0;
1795
}
1796
1797
.empty-state span {
1798
+
font-size: var(--text-sm);
1799
color: var(--text-muted);
1800
margin-bottom: 1.5rem;
1801
}
···
1805
background: var(--accent);
1806
color: white;
1807
border: none;
1808
+
border-radius: var(--radius-md);
1809
font-family: inherit;
1810
+
font-size: var(--text-base);
1811
font-weight: 500;
1812
cursor: pointer;
1813
transition: all 0.15s;
···
1835
.modal {
1836
background: var(--bg-primary);
1837
border: 1px solid var(--border-default);
1838
+
border-radius: var(--radius-xl);
1839
width: 100%;
1840
max-width: 400px;
1841
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
···
1857
}
1858
1859
.modal-header h3 {
1860
+
font-size: var(--text-xl);
1861
font-weight: 600;
1862
color: var(--text-primary);
1863
margin: 0;
···
1871
height: 32px;
1872
background: transparent;
1873
border: none;
1874
+
border-radius: var(--radius-md);
1875
color: var(--text-secondary);
1876
cursor: pointer;
1877
transition: all 0.15s;
···
1896
background: transparent;
1897
border: none;
1898
font-family: inherit;
1899
+
font-size: var(--text-lg);
1900
color: var(--text-primary);
1901
outline: none;
1902
}
···
1917
padding: 2rem 1.5rem;
1918
text-align: center;
1919
color: var(--text-muted);
1920
+
font-size: var(--text-base);
1921
margin: 0;
1922
}
1923
···
1941
.result-image-placeholder {
1942
width: 40px;
1943
height: 40px;
1944
+
border-radius: var(--radius-base);
1945
flex-shrink: 0;
1946
}
1947
···
1966
}
1967
1968
.result-title {
1969
+
font-size: var(--text-base);
1970
font-weight: 500;
1971
color: var(--text-primary);
1972
white-space: nowrap;
···
1975
}
1976
1977
.result-artist {
1978
+
font-size: var(--text-sm);
1979
color: var(--text-tertiary);
1980
white-space: nowrap;
1981
overflow: hidden;
···
1990
height: 36px;
1991
background: var(--accent);
1992
border: none;
1993
+
border-radius: var(--radius-md);
1994
color: white;
1995
cursor: pointer;
1996
transition: all 0.15s;
···
2013
.modal-body p {
2014
margin: 0;
2015
color: var(--text-secondary);
2016
+
font-size: var(--text-base);
2017
line-height: 1.5;
2018
}
2019
···
2027
.cancel-btn,
2028
.confirm-btn {
2029
padding: 0.625rem 1.25rem;
2030
+
border-radius: var(--radius-md);
2031
font-family: inherit;
2032
+
font-size: var(--text-base);
2033
font-weight: 500;
2034
cursor: pointer;
2035
transition: all 0.15s;
···
2072
height: 16px;
2073
border: 2px solid currentColor;
2074
border-top-color: transparent;
2075
+
border-radius: var(--radius-full);
2076
animation: spin 0.6s linear infinite;
2077
}
2078
···
2118
}
2119
2120
.playlist-meta {
2121
+
font-size: var(--text-sm);
2122
}
2123
2124
.playlist-actions {
···
2161
}
2162
2163
.playlist-meta {
2164
+
font-size: var(--text-sm);
2165
flex-wrap: wrap;
2166
}
2167
}
+181
-122
frontend/src/routes/portal/+page.svelte
+181
-122
frontend/src/routes/portal/+page.svelte
···
28
let editFeaturedArtists = $state<FeaturedArtist[]>([]);
29
let editTags = $state<string[]>([]);
30
let editImageFile = $state<File | null>(null);
31
let hasUnresolvedEditFeaturesInput = $state(false);
32
33
// profile editing state
···
105
}
106
107
try {
108
-
await loadMyTracks();
109
-
await loadArtistProfile();
110
-
await loadMyAlbums();
111
-
await loadMyPlaylists();
112
} catch (_e) {
113
console.error('error loading portal data:', _e);
114
error = 'failed to load portal data';
···
315
editAlbum = track.album?.title || '';
316
editFeaturedArtists = track.features || [];
317
editTags = track.tags || [];
318
}
319
320
function cancelEdit() {
···
324
editFeaturedArtists = [];
325
editTags = [];
326
editImageFile = null;
327
}
328
329
···
340
}
341
// always send tags (empty array clears them)
342
formData.append('tags', JSON.stringify(editTags));
343
if (editImageFile) {
344
formData.append('image', editImageFile);
345
}
···
740
<p class="file-info">{editImageFile.name} (will replace current)</p>
741
{/if}
742
</div>
743
</div>
744
<div class="edit-actions">
745
<button
···
784
<div class="track-info">
785
<div class="track-title">
786
{track.title}
787
{#if track.copyright_flagged}
788
{@const matchText = track.copyright_match ? `potential copyright violation: ${track.copyright_match}` : 'potential copyright violation'}
789
{#if track.atproto_record_url}
···
1109
.view-profile-link {
1110
color: var(--text-secondary);
1111
text-decoration: none;
1112
-
font-size: 0.8rem;
1113
padding: 0.35rem 0.6rem;
1114
background: var(--bg-tertiary);
1115
-
border-radius: 5px;
1116
border: 1px solid var(--border-default);
1117
transition: all 0.15s;
1118
white-space: nowrap;
···
1127
.settings-link {
1128
color: var(--text-secondary);
1129
text-decoration: none;
1130
-
font-size: 0.8rem;
1131
padding: 0.35rem 0.6rem;
1132
background: var(--bg-tertiary);
1133
-
border-radius: 5px;
1134
border: 1px solid var(--border-default);
1135
transition: all 0.15s;
1136
white-space: nowrap;
···
1150
padding: 1rem 1.25rem;
1151
background: var(--bg-tertiary);
1152
border: 1px solid var(--border-default);
1153
-
border-radius: 8px;
1154
text-decoration: none;
1155
color: var(--text-primary);
1156
transition: all 0.15s;
···
1173
width: 44px;
1174
height: 44px;
1175
background: color-mix(in srgb, var(--accent) 15%, transparent);
1176
-
border-radius: 10px;
1177
color: var(--accent);
1178
flex-shrink: 0;
1179
}
···
1186
.upload-card-title {
1187
display: block;
1188
font-weight: 600;
1189
-
font-size: 0.95rem;
1190
color: var(--text-primary);
1191
}
1192
1193
.upload-card-subtitle {
1194
display: block;
1195
-
font-size: 0.8rem;
1196
color: var(--text-tertiary);
1197
}
1198
···
1210
form {
1211
background: var(--bg-tertiary);
1212
padding: 1.25rem;
1213
-
border-radius: 8px;
1214
border: 1px solid var(--border-subtle);
1215
}
1216
···
1226
display: block;
1227
color: var(--text-secondary);
1228
margin-bottom: 0.4rem;
1229
-
font-size: 0.85rem;
1230
}
1231
1232
-
input[type='text'] {
1233
width: 100%;
1234
padding: 0.6rem 0.75rem;
1235
background: var(--bg-primary);
1236
border: 1px solid var(--border-default);
1237
-
border-radius: 4px;
1238
color: var(--text-primary);
1239
-
font-size: 0.95rem;
1240
font-family: inherit;
1241
transition: all 0.15s;
1242
}
1243
1244
-
input[type='text']:focus {
1245
outline: none;
1246
border-color: var(--accent);
1247
}
1248
1249
-
input[type='text']:disabled {
1250
opacity: 0.5;
1251
cursor: not-allowed;
1252
}
1253
1254
textarea {
1255
-
width: 100%;
1256
-
padding: 0.6rem 0.75rem;
1257
-
background: var(--bg-primary);
1258
-
border: 1px solid var(--border-default);
1259
-
border-radius: 4px;
1260
-
color: var(--text-primary);
1261
-
font-size: 0.95rem;
1262
-
font-family: inherit;
1263
-
transition: all 0.15s;
1264
resize: vertical;
1265
min-height: 80px;
1266
}
1267
1268
-
textarea:focus {
1269
-
outline: none;
1270
-
border-color: var(--accent);
1271
-
}
1272
-
1273
-
textarea:disabled {
1274
-
opacity: 0.5;
1275
-
cursor: not-allowed;
1276
-
}
1277
-
1278
.hint {
1279
margin-top: 0.35rem;
1280
-
font-size: 0.75rem;
1281
color: var(--text-muted);
1282
}
1283
···
1295
display: block;
1296
color: var(--text-secondary);
1297
margin-bottom: 0.6rem;
1298
-
font-size: 0.85rem;
1299
}
1300
1301
.support-options {
···
1312
padding: 0.6rem 0.75rem;
1313
background: var(--bg-primary);
1314
border: 1px solid var(--border-default);
1315
-
border-radius: 6px;
1316
cursor: pointer;
1317
transition: all 0.15s;
1318
margin-bottom: 0;
···
1335
}
1336
1337
.support-option span {
1338
-
font-size: 0.9rem;
1339
color: var(--text-primary);
1340
}
1341
1342
.support-status {
1343
margin-left: auto;
1344
-
font-size: 0.75rem;
1345
color: var(--text-tertiary);
1346
}
1347
1348
.support-setup-link,
1349
.support-status-link {
1350
margin-left: auto;
1351
-
font-size: 0.75rem;
1352
text-decoration: none;
1353
}
1354
···
1379
padding: 0.6rem 0.75rem;
1380
background: var(--bg-primary);
1381
border: 1px solid var(--border-default);
1382
-
border-radius: 4px;
1383
color: var(--text-primary);
1384
-
font-size: 0.95rem;
1385
font-family: inherit;
1386
transition: all 0.15s;
1387
margin-bottom: 0.5rem;
···
1407
.avatar-preview img {
1408
width: 64px;
1409
height: 64px;
1410
-
border-radius: 50%;
1411
object-fit: cover;
1412
border: 2px solid var(--border-default);
1413
}
···
1417
padding: 0.75rem;
1418
background: var(--bg-primary);
1419
border: 1px solid var(--border-default);
1420
-
border-radius: 4px;
1421
color: var(--text-primary);
1422
-
font-size: 0.9rem;
1423
font-family: inherit;
1424
cursor: pointer;
1425
}
···
1431
1432
.file-info {
1433
margin-top: 0.5rem;
1434
-
font-size: 0.85rem;
1435
color: var(--text-muted);
1436
}
1437
···
1441
background: var(--accent);
1442
color: var(--text-primary);
1443
border: none;
1444
-
border-radius: 4px;
1445
-
font-size: 1rem;
1446
font-weight: 600;
1447
font-family: inherit;
1448
cursor: pointer;
···
1479
padding: 2rem;
1480
text-align: center;
1481
background: var(--bg-tertiary);
1482
-
border-radius: 8px;
1483
border: 1px solid var(--border-subtle);
1484
}
1485
···
1496
gap: 1rem;
1497
background: var(--bg-tertiary);
1498
border: 1px solid var(--border-subtle);
1499
-
border-radius: 6px;
1500
padding: 1rem;
1501
transition: all 0.2s;
1502
}
···
1531
.track-artwork {
1532
width: 48px;
1533
height: 48px;
1534
-
border-radius: 4px;
1535
overflow: hidden;
1536
background: var(--bg-primary);
1537
border: 1px solid var(--border-subtle);
···
1553
}
1554
1555
.track-view-link {
1556
-
font-size: 0.7rem;
1557
color: var(--text-muted);
1558
text-decoration: none;
1559
transition: color 0.15s;
···
1600
}
1601
1602
.edit-label {
1603
-
font-size: 0.85rem;
1604
color: var(--text-secondary);
1605
}
1606
1607
.track-title {
1608
font-weight: 600;
1609
-
font-size: 1rem;
1610
margin-bottom: 0.25rem;
1611
color: var(--text-primary);
1612
display: flex;
···
1614
gap: 0.5rem;
1615
}
1616
1617
.copyright-flag {
1618
display: inline-flex;
1619
align-items: center;
···
1635
}
1636
1637
.track-meta {
1638
-
font-size: 0.9rem;
1639
color: var(--text-secondary);
1640
margin-bottom: 0.25rem;
1641
display: flex;
···
1703
padding: 0.1rem 0.4rem;
1704
background: color-mix(in srgb, var(--accent) 15%, transparent);
1705
color: var(--accent-hover);
1706
-
border-radius: 3px;
1707
-
font-size: 0.8rem;
1708
font-weight: 500;
1709
text-decoration: none;
1710
transition: all 0.15s;
···
1716
}
1717
1718
.track-date {
1719
-
font-size: 0.85rem;
1720
color: var(--text-muted);
1721
}
1722
···
1737
padding: 0;
1738
background: transparent;
1739
border: 1px solid var(--border-default);
1740
-
border-radius: 6px;
1741
color: var(--text-tertiary);
1742
cursor: pointer;
1743
transition: all 0.15s;
···
1782
padding: 0.5rem;
1783
background: var(--bg-primary);
1784
border: 1px solid var(--border-default);
1785
-
border-radius: 4px;
1786
color: var(--text-primary);
1787
-
font-size: 0.9rem;
1788
font-family: inherit;
1789
}
1790
···
1795
padding: 0.5rem;
1796
background: var(--bg-primary);
1797
border: 1px solid var(--border-default);
1798
-
border-radius: 4px;
1799
margin-bottom: 0.5rem;
1800
}
1801
1802
.current-image-preview img {
1803
width: 48px;
1804
height: 48px;
1805
-
border-radius: 4px;
1806
object-fit: cover;
1807
}
1808
1809
.current-image-label {
1810
color: var(--text-tertiary);
1811
-
font-size: 0.85rem;
1812
}
1813
1814
.edit-input:focus {
···
1840
.album-card {
1841
background: var(--bg-tertiary);
1842
border: 1px solid var(--border-subtle);
1843
-
border-radius: 8px;
1844
padding: 1rem;
1845
transition: all 0.2s;
1846
display: flex;
···
1858
.album-cover {
1859
width: 100%;
1860
aspect-ratio: 1;
1861
-
border-radius: 6px;
1862
object-fit: cover;
1863
}
1864
1865
.album-cover-placeholder {
1866
width: 100%;
1867
aspect-ratio: 1;
1868
-
border-radius: 6px;
1869
background: linear-gradient(135deg, rgba(var(--accent-rgb, 139, 92, 246), 0.15), rgba(var(--accent-rgb, 139, 92, 246), 0.05));
1870
display: flex;
1871
align-items: center;
···
1879
}
1880
1881
.album-title {
1882
-
font-size: 1rem;
1883
font-weight: 600;
1884
color: var(--text-primary);
1885
margin: 0 0 0.25rem 0;
···
1889
}
1890
1891
.album-stats {
1892
-
font-size: 0.85rem;
1893
color: var(--text-tertiary);
1894
margin: 0;
1895
}
···
1907
.view-playlists-link {
1908
color: var(--text-secondary);
1909
text-decoration: none;
1910
-
font-size: 0.8rem;
1911
padding: 0.35rem 0.6rem;
1912
background: var(--bg-tertiary);
1913
-
border-radius: 5px;
1914
border: 1px solid var(--border-default);
1915
transition: all 0.15s;
1916
white-space: nowrap;
···
1931
.playlist-card {
1932
background: var(--bg-tertiary);
1933
border: 1px solid var(--border-subtle);
1934
-
border-radius: 8px;
1935
padding: 1rem;
1936
transition: all 0.2s;
1937
display: flex;
···
1949
.playlist-cover {
1950
width: 100%;
1951
aspect-ratio: 1;
1952
-
border-radius: 6px;
1953
object-fit: cover;
1954
}
1955
1956
.playlist-cover-placeholder {
1957
width: 100%;
1958
aspect-ratio: 1;
1959
-
border-radius: 6px;
1960
background: linear-gradient(135deg, rgba(var(--accent-rgb, 139, 92, 246), 0.15), rgba(var(--accent-rgb, 139, 92, 246), 0.05));
1961
display: flex;
1962
align-items: center;
···
1970
}
1971
1972
.playlist-title {
1973
-
font-size: 1rem;
1974
font-weight: 600;
1975
color: var(--text-primary);
1976
margin: 0 0 0.25rem 0;
···
1980
}
1981
1982
.playlist-stats {
1983
-
font-size: 0.85rem;
1984
color: var(--text-tertiary);
1985
margin: 0;
1986
}
···
1999
padding: 1rem 1.25rem;
2000
background: var(--bg-tertiary);
2001
border: 1px solid var(--border-subtle);
2002
-
border-radius: 8px;
2003
display: flex;
2004
justify-content: space-between;
2005
align-items: center;
···
2017
}
2018
2019
.control-info h3 {
2020
-
font-size: 0.9rem;
2021
font-weight: 600;
2022
margin: 0 0 0.15rem 0;
2023
color: var(--text-primary);
2024
}
2025
2026
.control-description {
2027
-
font-size: 0.75rem;
2028
color: var(--text-tertiary);
2029
margin: 0;
2030
line-height: 1.4;
···
2035
background: var(--accent);
2036
color: var(--text-primary);
2037
border: none;
2038
-
border-radius: 6px;
2039
-
font-size: 0.9rem;
2040
font-weight: 600;
2041
cursor: pointer;
2042
transition: all 0.2s;
···
2080
background: transparent;
2081
color: var(--error);
2082
border: 1px solid var(--error);
2083
-
border-radius: 6px;
2084
font-family: inherit;
2085
-
font-size: 0.9rem;
2086
font-weight: 600;
2087
cursor: pointer;
2088
transition: all 0.2s;
···
2098
padding: 1rem;
2099
background: var(--bg-primary);
2100
border: 1px solid var(--border-default);
2101
-
border-radius: 8px;
2102
}
2103
2104
.delete-warning {
2105
margin: 0 0 1rem;
2106
color: var(--error);
2107
-
font-size: 0.9rem;
2108
line-height: 1.5;
2109
}
2110
···
2112
margin-bottom: 1rem;
2113
padding: 0.75rem;
2114
background: var(--bg-tertiary);
2115
-
border-radius: 6px;
2116
}
2117
2118
.atproto-option {
2119
display: flex;
2120
align-items: center;
2121
gap: 0.5rem;
2122
-
font-size: 0.9rem;
2123
color: var(--text-primary);
2124
cursor: pointer;
2125
}
···
2132
2133
.atproto-note {
2134
margin: 0.5rem 0 0;
2135
-
font-size: 0.8rem;
2136
color: var(--text-tertiary);
2137
}
2138
···
2149
margin: 0.5rem 0 0;
2150
padding: 0.5rem;
2151
background: color-mix(in srgb, var(--warning) 10%, transparent);
2152
-
border-radius: 4px;
2153
-
font-size: 0.8rem;
2154
color: var(--warning);
2155
}
2156
2157
.confirm-prompt {
2158
margin: 0 0 0.5rem;
2159
-
font-size: 0.9rem;
2160
color: var(--text-secondary);
2161
}
2162
···
2165
padding: 0.6rem 0.75rem;
2166
background: var(--bg-tertiary);
2167
border: 1px solid var(--border-default);
2168
-
border-radius: 6px;
2169
color: var(--text-primary);
2170
-
font-size: 0.9rem;
2171
font-family: inherit;
2172
margin-bottom: 1rem;
2173
}
···
2187
padding: 0.6rem;
2188
background: transparent;
2189
border: 1px solid var(--border-default);
2190
-
border-radius: 6px;
2191
color: var(--text-secondary);
2192
font-family: inherit;
2193
-
font-size: 0.9rem;
2194
cursor: pointer;
2195
transition: all 0.15s;
2196
}
···
2209
padding: 0.6rem;
2210
background: var(--error);
2211
border: none;
2212
-
border-radius: 6px;
2213
color: white;
2214
font-family: inherit;
2215
-
font-size: 0.9rem;
2216
font-weight: 600;
2217
cursor: pointer;
2218
transition: all 0.15s;
···
2238
}
2239
2240
.portal-header h2 {
2241
-
font-size: 1.25rem;
2242
}
2243
2244
.profile-section h2,
···
2246
.albums-section h2,
2247
.playlists-section h2,
2248
.data-section h2 {
2249
-
font-size: 1.1rem;
2250
}
2251
2252
.section-header {
···
2254
}
2255
2256
.view-profile-link {
2257
-
font-size: 0.75rem;
2258
padding: 0.3rem 0.5rem;
2259
}
2260
···
2267
}
2268
2269
label {
2270
-
font-size: 0.8rem;
2271
margin-bottom: 0.3rem;
2272
}
2273
···
2275
input[type='url'],
2276
textarea {
2277
padding: 0.5rem 0.6rem;
2278
-
font-size: 0.9rem;
2279
}
2280
2281
textarea {
···
2283
}
2284
2285
.hint {
2286
-
font-size: 0.7rem;
2287
}
2288
2289
.avatar-preview img {
···
2293
2294
button[type="submit"] {
2295
padding: 0.6rem;
2296
-
font-size: 0.9rem;
2297
}
2298
2299
/* upload card mobile */
···
2313
}
2314
2315
.upload-card-title {
2316
-
font-size: 0.9rem;
2317
}
2318
2319
.upload-card-subtitle {
2320
-
font-size: 0.75rem;
2321
}
2322
2323
/* tracks mobile */
···
2351
}
2352
2353
.track-title {
2354
-
font-size: 0.9rem;
2355
}
2356
2357
.track-meta {
2358
-
font-size: 0.8rem;
2359
}
2360
2361
.track-date {
2362
-
font-size: 0.75rem;
2363
}
2364
2365
.track-actions {
···
2387
}
2388
2389
.edit-label {
2390
-
font-size: 0.8rem;
2391
}
2392
2393
.edit-input {
2394
padding: 0.45rem 0.5rem;
2395
-
font-size: 0.85rem;
2396
}
2397
2398
.edit-actions {
···
2406
}
2407
2408
.control-info h3 {
2409
-
font-size: 0.85rem;
2410
}
2411
2412
.control-description {
2413
-
font-size: 0.7rem;
2414
}
2415
2416
.export-btn {
2417
padding: 0.5rem 0.85rem;
2418
-
font-size: 0.8rem;
2419
}
2420
2421
/* albums mobile */
···
2430
}
2431
2432
.album-title {
2433
-
font-size: 0.85rem;
2434
}
2435
2436
/* playlists mobile */
···
2445
}
2446
2447
.playlist-title {
2448
-
font-size: 0.85rem;
2449
}
2450
2451
.playlist-stats {
2452
-
font-size: 0.75rem;
2453
}
2454
2455
.view-playlists-link {
2456
-
font-size: 0.75rem;
2457
padding: 0.3rem 0.5rem;
2458
}
2459
}
···
28
let editFeaturedArtists = $state<FeaturedArtist[]>([]);
29
let editTags = $state<string[]>([]);
30
let editImageFile = $state<File | null>(null);
31
+
let editSupportGate = $state(false);
32
let hasUnresolvedEditFeaturesInput = $state(false);
33
34
// profile editing state
···
106
}
107
108
try {
109
+
await Promise.all([
110
+
loadMyTracks(),
111
+
loadArtistProfile(),
112
+
loadMyAlbums(),
113
+
loadMyPlaylists()
114
+
]);
115
} catch (_e) {
116
console.error('error loading portal data:', _e);
117
error = 'failed to load portal data';
···
318
editAlbum = track.album?.title || '';
319
editFeaturedArtists = track.features || [];
320
editTags = track.tags || [];
321
+
editSupportGate = track.support_gate !== null && track.support_gate !== undefined;
322
}
323
324
function cancelEdit() {
···
328
editFeaturedArtists = [];
329
editTags = [];
330
editImageFile = null;
331
+
editSupportGate = false;
332
}
333
334
···
345
}
346
// always send tags (empty array clears them)
347
formData.append('tags', JSON.stringify(editTags));
348
+
// send support_gate - null to remove, or {type: "any"} to enable
349
+
if (editSupportGate) {
350
+
formData.append('support_gate', JSON.stringify({ type: 'any' }));
351
+
} else {
352
+
formData.append('support_gate', 'null');
353
+
}
354
if (editImageFile) {
355
formData.append('image', editImageFile);
356
}
···
751
<p class="file-info">{editImageFile.name} (will replace current)</p>
752
{/if}
753
</div>
754
+
{#if atprotofansEligible || track.support_gate}
755
+
<div class="edit-field-group">
756
+
<label class="edit-label">supporter access</label>
757
+
<label class="toggle-row">
758
+
<input
759
+
type="checkbox"
760
+
bind:checked={editSupportGate}
761
+
/>
762
+
<span>only supporters can play this track</span>
763
+
</label>
764
+
{#if editSupportGate}
765
+
<p class="field-hint">
766
+
only users who support you via <a href="https://atprotofans.com" target="_blank" rel="noopener">atprotofans</a> can play this track
767
+
</p>
768
+
{/if}
769
+
</div>
770
+
{/if}
771
</div>
772
<div class="edit-actions">
773
<button
···
812
<div class="track-info">
813
<div class="track-title">
814
{track.title}
815
+
{#if track.support_gate}
816
+
<span class="support-gate-badge" title="supporters only">
817
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
818
+
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/>
819
+
</svg>
820
+
</span>
821
+
{/if}
822
{#if track.copyright_flagged}
823
{@const matchText = track.copyright_match ? `potential copyright violation: ${track.copyright_match}` : 'potential copyright violation'}
824
{#if track.atproto_record_url}
···
1144
.view-profile-link {
1145
color: var(--text-secondary);
1146
text-decoration: none;
1147
+
font-size: var(--text-sm);
1148
padding: 0.35rem 0.6rem;
1149
background: var(--bg-tertiary);
1150
+
border-radius: var(--radius-sm);
1151
border: 1px solid var(--border-default);
1152
transition: all 0.15s;
1153
white-space: nowrap;
···
1162
.settings-link {
1163
color: var(--text-secondary);
1164
text-decoration: none;
1165
+
font-size: var(--text-sm);
1166
padding: 0.35rem 0.6rem;
1167
background: var(--bg-tertiary);
1168
+
border-radius: var(--radius-sm);
1169
border: 1px solid var(--border-default);
1170
transition: all 0.15s;
1171
white-space: nowrap;
···
1185
padding: 1rem 1.25rem;
1186
background: var(--bg-tertiary);
1187
border: 1px solid var(--border-default);
1188
+
border-radius: var(--radius-md);
1189
text-decoration: none;
1190
color: var(--text-primary);
1191
transition: all 0.15s;
···
1208
width: 44px;
1209
height: 44px;
1210
background: color-mix(in srgb, var(--accent) 15%, transparent);
1211
+
border-radius: var(--radius-md);
1212
color: var(--accent);
1213
flex-shrink: 0;
1214
}
···
1221
.upload-card-title {
1222
display: block;
1223
font-weight: 600;
1224
+
font-size: var(--text-base);
1225
color: var(--text-primary);
1226
}
1227
1228
.upload-card-subtitle {
1229
display: block;
1230
+
font-size: var(--text-sm);
1231
color: var(--text-tertiary);
1232
}
1233
···
1245
form {
1246
background: var(--bg-tertiary);
1247
padding: 1.25rem;
1248
+
border-radius: var(--radius-md);
1249
border: 1px solid var(--border-subtle);
1250
}
1251
···
1261
display: block;
1262
color: var(--text-secondary);
1263
margin-bottom: 0.4rem;
1264
+
font-size: var(--text-sm);
1265
}
1266
1267
+
input[type='text'],
1268
+
input[type='url'],
1269
+
textarea {
1270
width: 100%;
1271
padding: 0.6rem 0.75rem;
1272
background: var(--bg-primary);
1273
border: 1px solid var(--border-default);
1274
+
border-radius: var(--radius-sm);
1275
color: var(--text-primary);
1276
+
font-size: var(--text-base);
1277
font-family: inherit;
1278
transition: all 0.15s;
1279
}
1280
1281
+
input[type='text']:focus,
1282
+
input[type='url']:focus,
1283
+
textarea:focus {
1284
outline: none;
1285
border-color: var(--accent);
1286
}
1287
1288
+
input[type='text']:disabled,
1289
+
input[type='url']:disabled,
1290
+
textarea:disabled {
1291
opacity: 0.5;
1292
cursor: not-allowed;
1293
}
1294
1295
textarea {
1296
resize: vertical;
1297
min-height: 80px;
1298
}
1299
1300
.hint {
1301
margin-top: 0.35rem;
1302
+
font-size: var(--text-xs);
1303
color: var(--text-muted);
1304
}
1305
···
1317
display: block;
1318
color: var(--text-secondary);
1319
margin-bottom: 0.6rem;
1320
+
font-size: var(--text-sm);
1321
}
1322
1323
.support-options {
···
1334
padding: 0.6rem 0.75rem;
1335
background: var(--bg-primary);
1336
border: 1px solid var(--border-default);
1337
+
border-radius: var(--radius-base);
1338
cursor: pointer;
1339
transition: all 0.15s;
1340
margin-bottom: 0;
···
1357
}
1358
1359
.support-option span {
1360
+
font-size: var(--text-base);
1361
color: var(--text-primary);
1362
}
1363
1364
.support-status {
1365
margin-left: auto;
1366
+
font-size: var(--text-xs);
1367
color: var(--text-tertiary);
1368
}
1369
1370
.support-setup-link,
1371
.support-status-link {
1372
margin-left: auto;
1373
+
font-size: var(--text-xs);
1374
text-decoration: none;
1375
}
1376
···
1401
padding: 0.6rem 0.75rem;
1402
background: var(--bg-primary);
1403
border: 1px solid var(--border-default);
1404
+
border-radius: var(--radius-sm);
1405
color: var(--text-primary);
1406
+
font-size: var(--text-base);
1407
font-family: inherit;
1408
transition: all 0.15s;
1409
margin-bottom: 0.5rem;
···
1429
.avatar-preview img {
1430
width: 64px;
1431
height: 64px;
1432
+
border-radius: var(--radius-full);
1433
object-fit: cover;
1434
border: 2px solid var(--border-default);
1435
}
···
1439
padding: 0.75rem;
1440
background: var(--bg-primary);
1441
border: 1px solid var(--border-default);
1442
+
border-radius: var(--radius-sm);
1443
color: var(--text-primary);
1444
+
font-size: var(--text-base);
1445
font-family: inherit;
1446
cursor: pointer;
1447
}
···
1453
1454
.file-info {
1455
margin-top: 0.5rem;
1456
+
font-size: var(--text-sm);
1457
color: var(--text-muted);
1458
}
1459
···
1463
background: var(--accent);
1464
color: var(--text-primary);
1465
border: none;
1466
+
border-radius: var(--radius-sm);
1467
+
font-size: var(--text-lg);
1468
font-weight: 600;
1469
font-family: inherit;
1470
cursor: pointer;
···
1501
padding: 2rem;
1502
text-align: center;
1503
background: var(--bg-tertiary);
1504
+
border-radius: var(--radius-md);
1505
border: 1px solid var(--border-subtle);
1506
}
1507
···
1518
gap: 1rem;
1519
background: var(--bg-tertiary);
1520
border: 1px solid var(--border-subtle);
1521
+
border-radius: var(--radius-base);
1522
padding: 1rem;
1523
transition: all 0.2s;
1524
}
···
1553
.track-artwork {
1554
width: 48px;
1555
height: 48px;
1556
+
border-radius: var(--radius-sm);
1557
overflow: hidden;
1558
background: var(--bg-primary);
1559
border: 1px solid var(--border-subtle);
···
1575
}
1576
1577
.track-view-link {
1578
+
font-size: var(--text-xs);
1579
color: var(--text-muted);
1580
text-decoration: none;
1581
transition: color 0.15s;
···
1622
}
1623
1624
.edit-label {
1625
+
font-size: var(--text-sm);
1626
color: var(--text-secondary);
1627
}
1628
1629
+
.toggle-row {
1630
+
display: flex;
1631
+
align-items: center;
1632
+
gap: 0.5rem;
1633
+
cursor: pointer;
1634
+
font-size: var(--text-base);
1635
+
color: var(--text-primary);
1636
+
}
1637
+
1638
+
.toggle-row input[type="checkbox"] {
1639
+
width: 16px;
1640
+
height: 16px;
1641
+
accent-color: var(--accent);
1642
+
}
1643
+
1644
+
.field-hint {
1645
+
font-size: var(--text-sm);
1646
+
color: var(--text-tertiary);
1647
+
margin-top: 0.25rem;
1648
+
}
1649
+
1650
+
.field-hint a {
1651
+
color: var(--accent);
1652
+
text-decoration: none;
1653
+
}
1654
+
1655
+
.field-hint a:hover {
1656
+
text-decoration: underline;
1657
+
}
1658
+
1659
.track-title {
1660
font-weight: 600;
1661
+
font-size: var(--text-lg);
1662
margin-bottom: 0.25rem;
1663
color: var(--text-primary);
1664
display: flex;
···
1666
gap: 0.5rem;
1667
}
1668
1669
+
.support-gate-badge {
1670
+
display: inline-flex;
1671
+
align-items: center;
1672
+
color: var(--accent);
1673
+
flex-shrink: 0;
1674
+
}
1675
+
1676
.copyright-flag {
1677
display: inline-flex;
1678
align-items: center;
···
1694
}
1695
1696
.track-meta {
1697
+
font-size: var(--text-base);
1698
color: var(--text-secondary);
1699
margin-bottom: 0.25rem;
1700
display: flex;
···
1762
padding: 0.1rem 0.4rem;
1763
background: color-mix(in srgb, var(--accent) 15%, transparent);
1764
color: var(--accent-hover);
1765
+
border-radius: var(--radius-sm);
1766
+
font-size: var(--text-sm);
1767
font-weight: 500;
1768
text-decoration: none;
1769
transition: all 0.15s;
···
1775
}
1776
1777
.track-date {
1778
+
font-size: var(--text-sm);
1779
color: var(--text-muted);
1780
}
1781
···
1796
padding: 0;
1797
background: transparent;
1798
border: 1px solid var(--border-default);
1799
+
border-radius: var(--radius-base);
1800
color: var(--text-tertiary);
1801
cursor: pointer;
1802
transition: all 0.15s;
···
1841
padding: 0.5rem;
1842
background: var(--bg-primary);
1843
border: 1px solid var(--border-default);
1844
+
border-radius: var(--radius-sm);
1845
color: var(--text-primary);
1846
+
font-size: var(--text-base);
1847
font-family: inherit;
1848
}
1849
···
1854
padding: 0.5rem;
1855
background: var(--bg-primary);
1856
border: 1px solid var(--border-default);
1857
+
border-radius: var(--radius-sm);
1858
margin-bottom: 0.5rem;
1859
}
1860
1861
.current-image-preview img {
1862
width: 48px;
1863
height: 48px;
1864
+
border-radius: var(--radius-sm);
1865
object-fit: cover;
1866
}
1867
1868
.current-image-label {
1869
color: var(--text-tertiary);
1870
+
font-size: var(--text-sm);
1871
}
1872
1873
.edit-input:focus {
···
1899
.album-card {
1900
background: var(--bg-tertiary);
1901
border: 1px solid var(--border-subtle);
1902
+
border-radius: var(--radius-md);
1903
padding: 1rem;
1904
transition: all 0.2s;
1905
display: flex;
···
1917
.album-cover {
1918
width: 100%;
1919
aspect-ratio: 1;
1920
+
border-radius: var(--radius-base);
1921
object-fit: cover;
1922
}
1923
1924
.album-cover-placeholder {
1925
width: 100%;
1926
aspect-ratio: 1;
1927
+
border-radius: var(--radius-base);
1928
background: linear-gradient(135deg, rgba(var(--accent-rgb, 139, 92, 246), 0.15), rgba(var(--accent-rgb, 139, 92, 246), 0.05));
1929
display: flex;
1930
align-items: center;
···
1938
}
1939
1940
.album-title {
1941
+
font-size: var(--text-lg);
1942
font-weight: 600;
1943
color: var(--text-primary);
1944
margin: 0 0 0.25rem 0;
···
1948
}
1949
1950
.album-stats {
1951
+
font-size: var(--text-sm);
1952
color: var(--text-tertiary);
1953
margin: 0;
1954
}
···
1966
.view-playlists-link {
1967
color: var(--text-secondary);
1968
text-decoration: none;
1969
+
font-size: var(--text-sm);
1970
padding: 0.35rem 0.6rem;
1971
background: var(--bg-tertiary);
1972
+
border-radius: var(--radius-sm);
1973
border: 1px solid var(--border-default);
1974
transition: all 0.15s;
1975
white-space: nowrap;
···
1990
.playlist-card {
1991
background: var(--bg-tertiary);
1992
border: 1px solid var(--border-subtle);
1993
+
border-radius: var(--radius-md);
1994
padding: 1rem;
1995
transition: all 0.2s;
1996
display: flex;
···
2008
.playlist-cover {
2009
width: 100%;
2010
aspect-ratio: 1;
2011
+
border-radius: var(--radius-base);
2012
object-fit: cover;
2013
}
2014
2015
.playlist-cover-placeholder {
2016
width: 100%;
2017
aspect-ratio: 1;
2018
+
border-radius: var(--radius-base);
2019
background: linear-gradient(135deg, rgba(var(--accent-rgb, 139, 92, 246), 0.15), rgba(var(--accent-rgb, 139, 92, 246), 0.05));
2020
display: flex;
2021
align-items: center;
···
2029
}
2030
2031
.playlist-title {
2032
+
font-size: var(--text-lg);
2033
font-weight: 600;
2034
color: var(--text-primary);
2035
margin: 0 0 0.25rem 0;
···
2039
}
2040
2041
.playlist-stats {
2042
+
font-size: var(--text-sm);
2043
color: var(--text-tertiary);
2044
margin: 0;
2045
}
···
2058
padding: 1rem 1.25rem;
2059
background: var(--bg-tertiary);
2060
border: 1px solid var(--border-subtle);
2061
+
border-radius: var(--radius-md);
2062
display: flex;
2063
justify-content: space-between;
2064
align-items: center;
···
2076
}
2077
2078
.control-info h3 {
2079
+
font-size: var(--text-base);
2080
font-weight: 600;
2081
margin: 0 0 0.15rem 0;
2082
color: var(--text-primary);
2083
}
2084
2085
.control-description {
2086
+
font-size: var(--text-xs);
2087
color: var(--text-tertiary);
2088
margin: 0;
2089
line-height: 1.4;
···
2094
background: var(--accent);
2095
color: var(--text-primary);
2096
border: none;
2097
+
border-radius: var(--radius-base);
2098
+
font-size: var(--text-base);
2099
font-weight: 600;
2100
cursor: pointer;
2101
transition: all 0.2s;
···
2139
background: transparent;
2140
color: var(--error);
2141
border: 1px solid var(--error);
2142
+
border-radius: var(--radius-base);
2143
font-family: inherit;
2144
+
font-size: var(--text-base);
2145
font-weight: 600;
2146
cursor: pointer;
2147
transition: all 0.2s;
···
2157
padding: 1rem;
2158
background: var(--bg-primary);
2159
border: 1px solid var(--border-default);
2160
+
border-radius: var(--radius-md);
2161
}
2162
2163
.delete-warning {
2164
margin: 0 0 1rem;
2165
color: var(--error);
2166
+
font-size: var(--text-base);
2167
line-height: 1.5;
2168
}
2169
···
2171
margin-bottom: 1rem;
2172
padding: 0.75rem;
2173
background: var(--bg-tertiary);
2174
+
border-radius: var(--radius-base);
2175
}
2176
2177
.atproto-option {
2178
display: flex;
2179
align-items: center;
2180
gap: 0.5rem;
2181
+
font-size: var(--text-base);
2182
color: var(--text-primary);
2183
cursor: pointer;
2184
}
···
2191
2192
.atproto-note {
2193
margin: 0.5rem 0 0;
2194
+
font-size: var(--text-sm);
2195
color: var(--text-tertiary);
2196
}
2197
···
2208
margin: 0.5rem 0 0;
2209
padding: 0.5rem;
2210
background: color-mix(in srgb, var(--warning) 10%, transparent);
2211
+
border-radius: var(--radius-sm);
2212
+
font-size: var(--text-sm);
2213
color: var(--warning);
2214
}
2215
2216
.confirm-prompt {
2217
margin: 0 0 0.5rem;
2218
+
font-size: var(--text-base);
2219
color: var(--text-secondary);
2220
}
2221
···
2224
padding: 0.6rem 0.75rem;
2225
background: var(--bg-tertiary);
2226
border: 1px solid var(--border-default);
2227
+
border-radius: var(--radius-base);
2228
color: var(--text-primary);
2229
+
font-size: var(--text-base);
2230
font-family: inherit;
2231
margin-bottom: 1rem;
2232
}
···
2246
padding: 0.6rem;
2247
background: transparent;
2248
border: 1px solid var(--border-default);
2249
+
border-radius: var(--radius-base);
2250
color: var(--text-secondary);
2251
font-family: inherit;
2252
+
font-size: var(--text-base);
2253
cursor: pointer;
2254
transition: all 0.15s;
2255
}
···
2268
padding: 0.6rem;
2269
background: var(--error);
2270
border: none;
2271
+
border-radius: var(--radius-base);
2272
color: white;
2273
font-family: inherit;
2274
+
font-size: var(--text-base);
2275
font-weight: 600;
2276
cursor: pointer;
2277
transition: all 0.15s;
···
2297
}
2298
2299
.portal-header h2 {
2300
+
font-size: var(--text-2xl);
2301
}
2302
2303
.profile-section h2,
···
2305
.albums-section h2,
2306
.playlists-section h2,
2307
.data-section h2 {
2308
+
font-size: var(--text-xl);
2309
}
2310
2311
.section-header {
···
2313
}
2314
2315
.view-profile-link {
2316
+
font-size: var(--text-xs);
2317
padding: 0.3rem 0.5rem;
2318
}
2319
···
2326
}
2327
2328
label {
2329
+
font-size: var(--text-sm);
2330
margin-bottom: 0.3rem;
2331
}
2332
···
2334
input[type='url'],
2335
textarea {
2336
padding: 0.5rem 0.6rem;
2337
+
font-size: var(--text-base);
2338
}
2339
2340
textarea {
···
2342
}
2343
2344
.hint {
2345
+
font-size: var(--text-xs);
2346
}
2347
2348
.avatar-preview img {
···
2352
2353
button[type="submit"] {
2354
padding: 0.6rem;
2355
+
font-size: var(--text-base);
2356
}
2357
2358
/* upload card mobile */
···
2372
}
2373
2374
.upload-card-title {
2375
+
font-size: var(--text-base);
2376
}
2377
2378
.upload-card-subtitle {
2379
+
font-size: var(--text-xs);
2380
}
2381
2382
/* tracks mobile */
···
2410
}
2411
2412
.track-title {
2413
+
font-size: var(--text-base);
2414
}
2415
2416
.track-meta {
2417
+
font-size: var(--text-sm);
2418
}
2419
2420
.track-date {
2421
+
font-size: var(--text-xs);
2422
}
2423
2424
.track-actions {
···
2446
}
2447
2448
.edit-label {
2449
+
font-size: var(--text-sm);
2450
}
2451
2452
.edit-input {
2453
padding: 0.45rem 0.5rem;
2454
+
font-size: var(--text-sm);
2455
}
2456
2457
.edit-actions {
···
2465
}
2466
2467
.control-info h3 {
2468
+
font-size: var(--text-sm);
2469
}
2470
2471
.control-description {
2472
+
font-size: var(--text-xs);
2473
}
2474
2475
.export-btn {
2476
padding: 0.5rem 0.85rem;
2477
+
font-size: var(--text-sm);
2478
}
2479
2480
/* albums mobile */
···
2489
}
2490
2491
.album-title {
2492
+
font-size: var(--text-sm);
2493
}
2494
2495
/* playlists mobile */
···
2504
}
2505
2506
.playlist-title {
2507
+
font-size: var(--text-sm);
2508
}
2509
2510
.playlist-stats {
2511
+
font-size: var(--text-xs);
2512
}
2513
2514
.view-playlists-link {
2515
+
font-size: var(--text-xs);
2516
padding: 0.3rem 0.5rem;
2517
}
2518
}
+8
-8
frontend/src/routes/profile/setup/+page.svelte
+8
-8
frontend/src/routes/profile/setup/+page.svelte
···
222
223
.error {
224
padding: 1rem;
225
-
border-radius: 4px;
226
margin-bottom: 1.5rem;
227
background: color-mix(in srgb, var(--error) 10%, transparent);
228
border: 1px solid color-mix(in srgb, var(--error) 30%, transparent);
···
232
form {
233
background: var(--bg-tertiary);
234
padding: 2rem;
235
-
border-radius: 8px;
236
border: 1px solid var(--border-subtle);
237
}
238
···
248
display: block;
249
color: var(--text-secondary);
250
margin-bottom: 0.5rem;
251
-
font-size: 0.9rem;
252
font-weight: 500;
253
}
254
···
259
padding: 0.75rem;
260
background: var(--bg-primary);
261
border: 1px solid var(--border-default);
262
-
border-radius: 4px;
263
color: var(--text-primary);
264
-
font-size: 1rem;
265
font-family: inherit;
266
transition: all 0.2s;
267
}
···
287
288
.hint {
289
margin-top: 0.5rem;
290
-
font-size: 0.85rem;
291
color: var(--text-muted);
292
}
293
···
297
background: var(--accent);
298
color: white;
299
border: none;
300
-
border-radius: 4px;
301
-
font-size: 1rem;
302
font-weight: 600;
303
cursor: pointer;
304
transition: all 0.2s;
···
222
223
.error {
224
padding: 1rem;
225
+
border-radius: var(--radius-sm);
226
margin-bottom: 1.5rem;
227
background: color-mix(in srgb, var(--error) 10%, transparent);
228
border: 1px solid color-mix(in srgb, var(--error) 30%, transparent);
···
232
form {
233
background: var(--bg-tertiary);
234
padding: 2rem;
235
+
border-radius: var(--radius-md);
236
border: 1px solid var(--border-subtle);
237
}
238
···
248
display: block;
249
color: var(--text-secondary);
250
margin-bottom: 0.5rem;
251
+
font-size: var(--text-base);
252
font-weight: 500;
253
}
254
···
259
padding: 0.75rem;
260
background: var(--bg-primary);
261
border: 1px solid var(--border-default);
262
+
border-radius: var(--radius-sm);
263
color: var(--text-primary);
264
+
font-size: var(--text-lg);
265
font-family: inherit;
266
transition: all 0.2s;
267
}
···
287
288
.hint {
289
margin-top: 0.5rem;
290
+
font-size: var(--text-sm);
291
color: var(--text-muted);
292
}
293
···
297
background: var(--accent);
298
color: white;
299
border: none;
300
+
border-radius: var(--radius-sm);
301
+
font-size: var(--text-lg);
302
font-weight: 600;
303
cursor: pointer;
304
transition: all 0.2s;
+46
-46
frontend/src/routes/settings/+page.svelte
+46
-46
frontend/src/routes/settings/+page.svelte
···
774
.token-overlay-content {
775
background: var(--bg-secondary);
776
border: 1px solid var(--border-default);
777
-
border-radius: 16px;
778
padding: 2rem;
779
max-width: 500px;
780
width: 100%;
···
788
789
.token-overlay-content h2 {
790
margin: 0 0 0.75rem;
791
-
font-size: 1.5rem;
792
color: var(--text-primary);
793
}
794
795
.token-overlay-warning {
796
color: var(--warning);
797
-
font-size: 0.9rem;
798
margin: 0 0 1.5rem;
799
line-height: 1.5;
800
}
···
804
gap: 0.5rem;
805
background: var(--bg-primary);
806
border: 1px solid var(--border-default);
807
-
border-radius: 8px;
808
padding: 1rem;
809
margin-bottom: 1rem;
810
}
811
812
.token-overlay-display code {
813
flex: 1;
814
-
font-size: 0.85rem;
815
word-break: break-all;
816
color: var(--accent);
817
text-align: left;
···
822
padding: 0.5rem 1rem;
823
background: var(--accent);
824
border: none;
825
-
border-radius: 6px;
826
color: var(--text-primary);
827
font-family: inherit;
828
-
font-size: 0.85rem;
829
font-weight: 600;
830
cursor: pointer;
831
white-space: nowrap;
···
837
}
838
839
.token-overlay-hint {
840
-
font-size: 0.8rem;
841
color: var(--text-tertiary);
842
margin: 0 0 1.5rem;
843
}
···
855
padding: 0.75rem 2rem;
856
background: var(--bg-tertiary);
857
border: 1px solid var(--border-default);
858
-
border-radius: 8px;
859
color: var(--text-secondary);
860
font-family: inherit;
861
-
font-size: 0.9rem;
862
cursor: pointer;
863
transition: all 0.15s;
864
}
···
901
.portal-link {
902
color: var(--text-secondary);
903
text-decoration: none;
904
-
font-size: 0.85rem;
905
padding: 0.4rem 0.75rem;
906
background: var(--bg-tertiary);
907
-
border-radius: 6px;
908
border: 1px solid var(--border-default);
909
transition: all 0.15s;
910
}
···
919
}
920
921
.settings-section h2 {
922
-
font-size: 0.8rem;
923
text-transform: uppercase;
924
letter-spacing: 0.08em;
925
color: var(--text-tertiary);
···
930
.settings-card {
931
background: var(--bg-tertiary);
932
border: 1px solid var(--border-subtle);
933
-
border-radius: 10px;
934
padding: 1rem 1.25rem;
935
}
936
···
957
958
.setting-info h3 {
959
margin: 0 0 0.25rem;
960
-
font-size: 0.95rem;
961
font-weight: 600;
962
color: var(--text-primary);
963
}
964
965
.setting-info p {
966
margin: 0;
967
-
font-size: 0.8rem;
968
color: var(--text-tertiary);
969
line-height: 1.4;
970
}
···
993
padding: 0.6rem 0.75rem;
994
background: var(--bg-primary);
995
border: 1px solid var(--border-default);
996
-
border-radius: 8px;
997
color: var(--text-secondary);
998
cursor: pointer;
999
transition: all 0.15s;
···
1034
width: 40px;
1035
height: 40px;
1036
border: 1px solid var(--border-default);
1037
-
border-radius: 8px;
1038
cursor: pointer;
1039
background: transparent;
1040
}
···
1044
}
1045
1046
.color-input::-webkit-color-swatch {
1047
-
border-radius: 4px;
1048
border: none;
1049
}
1050
···
1056
.preset-btn {
1057
width: 32px;
1058
height: 32px;
1059
-
border-radius: 6px;
1060
border: 2px solid transparent;
1061
cursor: pointer;
1062
transition: all 0.15s;
···
1085
padding: 0.5rem 0.75rem;
1086
background: var(--bg-primary);
1087
border: 1px solid var(--border-default);
1088
-
border-radius: 6px;
1089
color: var(--text-primary);
1090
-
font-size: 0.85rem;
1091
font-family: inherit;
1092
}
1093
···
1104
display: flex;
1105
align-items: center;
1106
gap: 0.4rem;
1107
-
font-size: 0.8rem;
1108
color: var(--text-secondary);
1109
cursor: pointer;
1110
}
···
1132
width: 48px;
1133
height: 28px;
1134
background: var(--border-default);
1135
-
border-radius: 999px;
1136
position: relative;
1137
cursor: pointer;
1138
transition: background 0.2s;
···
1145
left: 4px;
1146
width: 20px;
1147
height: 20px;
1148
-
border-radius: 50%;
1149
background: var(--text-secondary);
1150
transition: transform 0.2s, background 0.2s;
1151
}
···
1167
padding: 0.75rem;
1168
background: color-mix(in srgb, var(--warning) 10%, transparent);
1169
border: 1px solid color-mix(in srgb, var(--warning) 30%, transparent);
1170
-
border-radius: 6px;
1171
margin-top: 0.75rem;
1172
-
font-size: 0.8rem;
1173
color: var(--warning);
1174
}
1175
···
1191
/* developer tokens */
1192
.loading-tokens {
1193
color: var(--text-tertiary);
1194
-
font-size: 0.85rem;
1195
}
1196
1197
.existing-tokens {
···
1199
}
1200
1201
.tokens-header {
1202
-
font-size: 0.75rem;
1203
text-transform: uppercase;
1204
letter-spacing: 0.05em;
1205
color: var(--text-tertiary);
···
1220
padding: 0.75rem;
1221
background: var(--bg-primary);
1222
border: 1px solid var(--border-default);
1223
-
border-radius: 6px;
1224
}
1225
1226
.token-info {
···
1233
.token-name {
1234
font-weight: 500;
1235
color: var(--text-primary);
1236
-
font-size: 0.9rem;
1237
}
1238
1239
.token-meta {
1240
-
font-size: 0.75rem;
1241
color: var(--text-tertiary);
1242
}
1243
···
1245
padding: 0.4rem 0.75rem;
1246
background: transparent;
1247
border: 1px solid var(--border-emphasis);
1248
-
border-radius: 4px;
1249
color: var(--text-secondary);
1250
font-family: inherit;
1251
-
font-size: 0.8rem;
1252
cursor: pointer;
1253
transition: all 0.15s;
1254
white-space: nowrap;
···
1272
padding: 0.75rem;
1273
background: var(--bg-primary);
1274
border: 1px solid var(--border-default);
1275
-
border-radius: 6px;
1276
}
1277
1278
.token-value {
1279
flex: 1;
1280
-
font-size: 0.8rem;
1281
word-break: break-all;
1282
color: var(--accent);
1283
}
···
1287
padding: 0.4rem 0.6rem;
1288
background: var(--bg-tertiary);
1289
border: 1px solid var(--border-default);
1290
-
border-radius: 4px;
1291
color: var(--text-secondary);
1292
font-family: inherit;
1293
-
font-size: 0.8rem;
1294
cursor: pointer;
1295
transition: all 0.15s;
1296
}
···
1303
1304
.token-warning {
1305
margin-top: 0.5rem;
1306
-
font-size: 0.8rem;
1307
color: var(--warning);
1308
}
1309
···
1320
padding: 0.6rem 0.75rem;
1321
background: var(--bg-primary);
1322
border: 1px solid var(--border-default);
1323
-
border-radius: 6px;
1324
color: var(--text-primary);
1325
-
font-size: 0.9rem;
1326
font-family: inherit;
1327
}
1328
···
1335
display: flex;
1336
align-items: center;
1337
gap: 0.5rem;
1338
-
font-size: 0.85rem;
1339
color: var(--text-secondary);
1340
}
1341
···
1343
padding: 0.5rem 0.75rem;
1344
background: var(--bg-primary);
1345
border: 1px solid var(--border-default);
1346
-
border-radius: 6px;
1347
color: var(--text-primary);
1348
-
font-size: 0.85rem;
1349
font-family: inherit;
1350
cursor: pointer;
1351
}
···
1359
padding: 0.6rem 1rem;
1360
background: var(--accent);
1361
border: none;
1362
-
border-radius: 6px;
1363
color: var(--text-primary);
1364
font-family: inherit;
1365
-
font-size: 0.9rem;
1366
font-weight: 600;
1367
cursor: pointer;
1368
transition: all 0.15s;
···
774
.token-overlay-content {
775
background: var(--bg-secondary);
776
border: 1px solid var(--border-default);
777
+
border-radius: var(--radius-xl);
778
padding: 2rem;
779
max-width: 500px;
780
width: 100%;
···
788
789
.token-overlay-content h2 {
790
margin: 0 0 0.75rem;
791
+
font-size: var(--text-3xl);
792
color: var(--text-primary);
793
}
794
795
.token-overlay-warning {
796
color: var(--warning);
797
+
font-size: var(--text-base);
798
margin: 0 0 1.5rem;
799
line-height: 1.5;
800
}
···
804
gap: 0.5rem;
805
background: var(--bg-primary);
806
border: 1px solid var(--border-default);
807
+
border-radius: var(--radius-md);
808
padding: 1rem;
809
margin-bottom: 1rem;
810
}
811
812
.token-overlay-display code {
813
flex: 1;
814
+
font-size: var(--text-sm);
815
word-break: break-all;
816
color: var(--accent);
817
text-align: left;
···
822
padding: 0.5rem 1rem;
823
background: var(--accent);
824
border: none;
825
+
border-radius: var(--radius-base);
826
color: var(--text-primary);
827
font-family: inherit;
828
+
font-size: var(--text-sm);
829
font-weight: 600;
830
cursor: pointer;
831
white-space: nowrap;
···
837
}
838
839
.token-overlay-hint {
840
+
font-size: var(--text-sm);
841
color: var(--text-tertiary);
842
margin: 0 0 1.5rem;
843
}
···
855
padding: 0.75rem 2rem;
856
background: var(--bg-tertiary);
857
border: 1px solid var(--border-default);
858
+
border-radius: var(--radius-md);
859
color: var(--text-secondary);
860
font-family: inherit;
861
+
font-size: var(--text-base);
862
cursor: pointer;
863
transition: all 0.15s;
864
}
···
901
.portal-link {
902
color: var(--text-secondary);
903
text-decoration: none;
904
+
font-size: var(--text-sm);
905
padding: 0.4rem 0.75rem;
906
background: var(--bg-tertiary);
907
+
border-radius: var(--radius-base);
908
border: 1px solid var(--border-default);
909
transition: all 0.15s;
910
}
···
919
}
920
921
.settings-section h2 {
922
+
font-size: var(--text-sm);
923
text-transform: uppercase;
924
letter-spacing: 0.08em;
925
color: var(--text-tertiary);
···
930
.settings-card {
931
background: var(--bg-tertiary);
932
border: 1px solid var(--border-subtle);
933
+
border-radius: var(--radius-md);
934
padding: 1rem 1.25rem;
935
}
936
···
957
958
.setting-info h3 {
959
margin: 0 0 0.25rem;
960
+
font-size: var(--text-base);
961
font-weight: 600;
962
color: var(--text-primary);
963
}
964
965
.setting-info p {
966
margin: 0;
967
+
font-size: var(--text-sm);
968
color: var(--text-tertiary);
969
line-height: 1.4;
970
}
···
993
padding: 0.6rem 0.75rem;
994
background: var(--bg-primary);
995
border: 1px solid var(--border-default);
996
+
border-radius: var(--radius-md);
997
color: var(--text-secondary);
998
cursor: pointer;
999
transition: all 0.15s;
···
1034
width: 40px;
1035
height: 40px;
1036
border: 1px solid var(--border-default);
1037
+
border-radius: var(--radius-md);
1038
cursor: pointer;
1039
background: transparent;
1040
}
···
1044
}
1045
1046
.color-input::-webkit-color-swatch {
1047
+
border-radius: var(--radius-sm);
1048
border: none;
1049
}
1050
···
1056
.preset-btn {
1057
width: 32px;
1058
height: 32px;
1059
+
border-radius: var(--radius-base);
1060
border: 2px solid transparent;
1061
cursor: pointer;
1062
transition: all 0.15s;
···
1085
padding: 0.5rem 0.75rem;
1086
background: var(--bg-primary);
1087
border: 1px solid var(--border-default);
1088
+
border-radius: var(--radius-base);
1089
color: var(--text-primary);
1090
+
font-size: var(--text-sm);
1091
font-family: inherit;
1092
}
1093
···
1104
display: flex;
1105
align-items: center;
1106
gap: 0.4rem;
1107
+
font-size: var(--text-sm);
1108
color: var(--text-secondary);
1109
cursor: pointer;
1110
}
···
1132
width: 48px;
1133
height: 28px;
1134
background: var(--border-default);
1135
+
border-radius: var(--radius-full);
1136
position: relative;
1137
cursor: pointer;
1138
transition: background 0.2s;
···
1145
left: 4px;
1146
width: 20px;
1147
height: 20px;
1148
+
border-radius: var(--radius-full);
1149
background: var(--text-secondary);
1150
transition: transform 0.2s, background 0.2s;
1151
}
···
1167
padding: 0.75rem;
1168
background: color-mix(in srgb, var(--warning) 10%, transparent);
1169
border: 1px solid color-mix(in srgb, var(--warning) 30%, transparent);
1170
+
border-radius: var(--radius-base);
1171
margin-top: 0.75rem;
1172
+
font-size: var(--text-sm);
1173
color: var(--warning);
1174
}
1175
···
1191
/* developer tokens */
1192
.loading-tokens {
1193
color: var(--text-tertiary);
1194
+
font-size: var(--text-sm);
1195
}
1196
1197
.existing-tokens {
···
1199
}
1200
1201
.tokens-header {
1202
+
font-size: var(--text-xs);
1203
text-transform: uppercase;
1204
letter-spacing: 0.05em;
1205
color: var(--text-tertiary);
···
1220
padding: 0.75rem;
1221
background: var(--bg-primary);
1222
border: 1px solid var(--border-default);
1223
+
border-radius: var(--radius-base);
1224
}
1225
1226
.token-info {
···
1233
.token-name {
1234
font-weight: 500;
1235
color: var(--text-primary);
1236
+
font-size: var(--text-base);
1237
}
1238
1239
.token-meta {
1240
+
font-size: var(--text-xs);
1241
color: var(--text-tertiary);
1242
}
1243
···
1245
padding: 0.4rem 0.75rem;
1246
background: transparent;
1247
border: 1px solid var(--border-emphasis);
1248
+
border-radius: var(--radius-sm);
1249
color: var(--text-secondary);
1250
font-family: inherit;
1251
+
font-size: var(--text-sm);
1252
cursor: pointer;
1253
transition: all 0.15s;
1254
white-space: nowrap;
···
1272
padding: 0.75rem;
1273
background: var(--bg-primary);
1274
border: 1px solid var(--border-default);
1275
+
border-radius: var(--radius-base);
1276
}
1277
1278
.token-value {
1279
flex: 1;
1280
+
font-size: var(--text-sm);
1281
word-break: break-all;
1282
color: var(--accent);
1283
}
···
1287
padding: 0.4rem 0.6rem;
1288
background: var(--bg-tertiary);
1289
border: 1px solid var(--border-default);
1290
+
border-radius: var(--radius-sm);
1291
color: var(--text-secondary);
1292
font-family: inherit;
1293
+
font-size: var(--text-sm);
1294
cursor: pointer;
1295
transition: all 0.15s;
1296
}
···
1303
1304
.token-warning {
1305
margin-top: 0.5rem;
1306
+
font-size: var(--text-sm);
1307
color: var(--warning);
1308
}
1309
···
1320
padding: 0.6rem 0.75rem;
1321
background: var(--bg-primary);
1322
border: 1px solid var(--border-default);
1323
+
border-radius: var(--radius-base);
1324
color: var(--text-primary);
1325
+
font-size: var(--text-base);
1326
font-family: inherit;
1327
}
1328
···
1335
display: flex;
1336
align-items: center;
1337
gap: 0.5rem;
1338
+
font-size: var(--text-sm);
1339
color: var(--text-secondary);
1340
}
1341
···
1343
padding: 0.5rem 0.75rem;
1344
background: var(--bg-primary);
1345
border: 1px solid var(--border-default);
1346
+
border-radius: var(--radius-base);
1347
color: var(--text-primary);
1348
+
font-size: var(--text-sm);
1349
font-family: inherit;
1350
cursor: pointer;
1351
}
···
1359
padding: 0.6rem 1rem;
1360
background: var(--accent);
1361
border: none;
1362
+
border-radius: var(--radius-base);
1363
color: var(--text-primary);
1364
font-family: inherit;
1365
+
font-size: var(--text-base);
1366
font-weight: 600;
1367
cursor: pointer;
1368
transition: all 0.15s;
+10
-10
frontend/src/routes/tag/[name]/+page.svelte
+10
-10
frontend/src/routes/tag/[name]/+page.svelte
···
197
}
198
199
.subtitle {
200
-
font-size: 0.95rem;
201
color: var(--text-tertiary);
202
margin: 0;
203
text-shadow: var(--text-shadow, none);
···
211
background: var(--glass-btn-bg, transparent);
212
border: 1px solid var(--glass-btn-border, var(--accent));
213
color: var(--accent);
214
-
border-radius: 6px;
215
-
font-size: 0.9rem;
216
font-family: inherit;
217
cursor: pointer;
218
transition: all 0.2s;
···
240
}
241
242
.empty-state h2 {
243
-
font-size: 1.5rem;
244
font-weight: 600;
245
color: var(--text-secondary);
246
margin: 0 0 0.5rem 0;
247
}
248
249
.empty-state p {
250
-
font-size: 0.95rem;
251
margin: 0;
252
}
253
···
264
text-align: center;
265
padding: 4rem 1rem;
266
color: var(--text-tertiary);
267
-
font-size: 0.95rem;
268
}
269
270
.tracks-list {
···
292
}
293
294
.empty-state h2 {
295
-
font-size: 1.25rem;
296
}
297
298
.btn-queue-all {
299
padding: 0.5rem 0.75rem;
300
-
font-size: 0.85rem;
301
}
302
303
.btn-queue-all svg {
···
330
}
331
332
.subtitle {
333
-
font-size: 0.85rem;
334
}
335
336
.btn-queue-all {
337
padding: 0.45rem 0.65rem;
338
-
font-size: 0.8rem;
339
}
340
341
.btn-queue-all svg {
···
197
}
198
199
.subtitle {
200
+
font-size: var(--text-base);
201
color: var(--text-tertiary);
202
margin: 0;
203
text-shadow: var(--text-shadow, none);
···
211
background: var(--glass-btn-bg, transparent);
212
border: 1px solid var(--glass-btn-border, var(--accent));
213
color: var(--accent);
214
+
border-radius: var(--radius-base);
215
+
font-size: var(--text-base);
216
font-family: inherit;
217
cursor: pointer;
218
transition: all 0.2s;
···
240
}
241
242
.empty-state h2 {
243
+
font-size: var(--text-3xl);
244
font-weight: 600;
245
color: var(--text-secondary);
246
margin: 0 0 0.5rem 0;
247
}
248
249
.empty-state p {
250
+
font-size: var(--text-base);
251
margin: 0;
252
}
253
···
264
text-align: center;
265
padding: 4rem 1rem;
266
color: var(--text-tertiary);
267
+
font-size: var(--text-base);
268
}
269
270
.tracks-list {
···
292
}
293
294
.empty-state h2 {
295
+
font-size: var(--text-2xl);
296
}
297
298
.btn-queue-all {
299
padding: 0.5rem 0.75rem;
300
+
font-size: var(--text-sm);
301
}
302
303
.btn-queue-all svg {
···
330
}
331
332
.subtitle {
333
+
font-size: var(--text-sm);
334
}
335
336
.btn-queue-all {
337
padding: 0.45rem 0.65rem;
338
+
font-size: var(--text-sm);
339
}
340
341
.btn-queue-all svg {
+108
-51
frontend/src/routes/track/[id]/+page.svelte
+108
-51
frontend/src/routes/track/[id]/+page.svelte
···
12
import { checkImageSensitive } from '$lib/moderation.svelte';
13
import { player } from '$lib/player.svelte';
14
import { queue } from '$lib/queue.svelte';
15
import { auth } from '$lib/auth.svelte';
16
import { toast } from '$lib/toast.svelte';
17
import type { Track } from '$lib/types';
···
103
window.location.href = '/';
104
}
105
106
-
function handlePlay() {
107
if (player.currentTrack?.id === track.id) {
108
// this track is already loaded - just toggle play/pause
109
player.togglePlayPause();
110
} else {
111
// different track or no track - start this one
112
-
queue.playNow(track);
113
}
114
}
115
116
function addToQueue() {
117
queue.addTracks([track]);
118
toast.success(`queued ${track.title}`, 1800);
119
}
···
187
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
188
}
189
190
-
function seekToTimestamp(ms: number) {
191
const doSeek = () => {
192
if (player.audioElement) {
193
player.audioElement.currentTime = ms / 1000;
···
201
}
202
203
// otherwise start playing and wait for audio to be ready
204
-
queue.playNow(track);
205
if (player.audioElement && player.audioElement.readyState >= 1) {
206
doSeek();
207
} else {
···
288
289
// track which track we've loaded data for to detect navigation
290
let loadedForTrackId = $state<number | null>(null);
291
292
// reload data when navigating between track pages
293
// watch data.track.id (from server) not track.id (local state)
···
304
newCommentText = '';
305
editingCommentId = null;
306
editingCommentText = '';
307
308
// sync track from server data
309
track = data.track;
···
311
// mark as loaded for this track
312
loadedForTrackId = currentId;
313
314
-
// load fresh data
315
-
if (auth.isAuthenticated) {
316
-
void loadLikedState();
317
-
}
318
void loadComments();
319
}
320
});
321
322
let shareUrl = $state('');
323
324
$effect(() => {
···
413
<!-- track info wrapper -->
414
<div class="track-info-wrapper">
415
<div class="track-info">
416
-
<h1 class="track-title">{track.title}</h1>
417
<div class="track-metadata">
418
<a href="/u/{track.artist_handle}" class="artist-link">
419
{track.artist}
···
465
trackTitle={track.title}
466
trackUri={track.atproto_record_uri}
467
trackCid={track.atproto_record_cid}
468
initialLiked={track.is_liked || false}
469
shareUrl={shareUrl}
470
onQueue={addToQueue}
···
657
width: 100%;
658
max-width: 300px;
659
aspect-ratio: 1;
660
-
border-radius: 8px;
661
overflow: hidden;
662
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
663
}
···
703
margin: 0;
704
line-height: 1.2;
705
text-align: center;
706
}
707
708
.track-metadata {
···
712
gap: 0.75rem;
713
flex-wrap: wrap;
714
color: var(--text-secondary);
715
-
font-size: 1.1rem;
716
}
717
718
.separator {
719
color: var(--text-muted);
720
-
font-size: 0.8rem;
721
}
722
723
.artist-link {
···
798
799
.track-stats {
800
color: var(--text-tertiary);
801
-
font-size: 0.95rem;
802
display: flex;
803
align-items: center;
804
gap: 0.5rem;
···
806
}
807
808
.track-stats .separator {
809
-
font-size: 0.7rem;
810
}
811
812
.track-tags {
···
821
padding: 0.25rem 0.6rem;
822
background: color-mix(in srgb, var(--accent) 15%, transparent);
823
color: var(--accent-hover);
824
-
border-radius: 4px;
825
-
font-size: 0.85rem;
826
font-weight: 500;
827
text-decoration: none;
828
transition: all 0.15s;
···
857
background: var(--accent);
858
color: var(--bg-primary);
859
border: none;
860
-
border-radius: 24px;
861
-
font-size: 0.95rem;
862
font-weight: 600;
863
font-family: inherit;
864
cursor: pointer;
···
871
}
872
873
.btn-play.playing {
874
-
opacity: 0.8;
875
}
876
877
.btn-queue {
···
882
background: transparent;
883
color: var(--text-primary);
884
border: 1px solid var(--border-emphasis);
885
-
border-radius: 24px;
886
-
font-size: 0.95rem;
887
font-weight: 500;
888
font-family: inherit;
889
cursor: pointer;
···
927
}
928
929
.track-title {
930
-
font-size: 1.5rem;
931
}
932
933
.track-metadata {
934
-
font-size: 0.9rem;
935
gap: 0.5rem;
936
}
937
938
.track-stats {
939
-
font-size: 0.85rem;
940
}
941
942
.track-actions {
···
953
min-width: calc(50% - 0.25rem);
954
justify-content: center;
955
padding: 0.6rem 1rem;
956
-
font-size: 0.9rem;
957
}
958
959
.btn-play svg {
···
966
min-width: calc(50% - 0.25rem);
967
justify-content: center;
968
padding: 0.6rem 1rem;
969
-
font-size: 0.9rem;
970
}
971
972
.btn-queue svg {
···
985
}
986
987
.comments-title {
988
-
font-size: 1rem;
989
font-weight: 600;
990
color: var(--text-primary);
991
margin: 0 0 0.75rem 0;
···
1010
padding: 0.6rem 0.8rem;
1011
background: var(--bg-tertiary);
1012
border: 1px solid var(--border-default);
1013
-
border-radius: 6px;
1014
color: var(--text-primary);
1015
-
font-size: 0.9rem;
1016
font-family: inherit;
1017
}
1018
···
1030
background: var(--accent);
1031
color: var(--bg-primary);
1032
border: none;
1033
-
border-radius: 6px;
1034
-
font-size: 0.9rem;
1035
font-weight: 600;
1036
font-family: inherit;
1037
cursor: pointer;
···
1049
1050
.login-prompt {
1051
color: var(--text-tertiary);
1052
-
font-size: 0.9rem;
1053
margin-bottom: 1rem;
1054
}
1055
···
1064
1065
.no-comments {
1066
color: var(--text-muted);
1067
-
font-size: 0.9rem;
1068
text-align: center;
1069
padding: 1rem;
1070
}
···
1085
1086
.comments-list::-webkit-scrollbar-track {
1087
background: var(--bg-primary);
1088
-
border-radius: 4px;
1089
}
1090
1091
.comments-list::-webkit-scrollbar-thumb {
1092
background: var(--border-default);
1093
-
border-radius: 4px;
1094
}
1095
1096
.comments-list::-webkit-scrollbar-thumb:hover {
···
1103
gap: 0.6rem;
1104
padding: 0.5rem 0.6rem;
1105
background: var(--bg-tertiary);
1106
-
border-radius: 6px;
1107
transition: background 0.15s;
1108
}
1109
···
1112
}
1113
1114
.comment-timestamp {
1115
-
font-size: 0.8rem;
1116
font-weight: 600;
1117
color: var(--accent);
1118
background: color-mix(in srgb, var(--accent) 10%, transparent);
1119
padding: 0.2rem 0.5rem;
1120
-
border-radius: 4px;
1121
white-space: nowrap;
1122
height: fit-content;
1123
border: none;
···
1150
}
1151
1152
.comment-time {
1153
-
font-size: 0.75rem;
1154
color: var(--text-muted);
1155
}
1156
1157
.comment-avatar {
1158
width: 20px;
1159
height: 20px;
1160
-
border-radius: 50%;
1161
object-fit: cover;
1162
}
1163
1164
.comment-avatar-placeholder {
1165
width: 20px;
1166
height: 20px;
1167
-
border-radius: 50%;
1168
background: var(--border-default);
1169
}
1170
1171
.comment-author {
1172
-
font-size: 0.85rem;
1173
font-weight: 500;
1174
color: var(--text-secondary);
1175
text-decoration: none;
···
1180
}
1181
1182
.comment-text {
1183
-
font-size: 0.9rem;
1184
color: var(--text-primary);
1185
margin: 0;
1186
line-height: 1.4;
···
1220
border: none;
1221
padding: 0;
1222
color: var(--text-muted);
1223
-
font-size: 0.8rem;
1224
cursor: pointer;
1225
transition: color 0.15s;
1226
font-family: inherit;
···
1253
padding: 0.5rem;
1254
background: var(--bg-primary);
1255
border: 1px solid var(--border-default);
1256
-
border-radius: 4px;
1257
color: var(--text-primary);
1258
-
font-size: 0.9rem;
1259
font-family: inherit;
1260
}
1261
···
1272
1273
.edit-form-btn {
1274
padding: 0.25rem 0.6rem;
1275
-
font-size: 0.8rem;
1276
font-family: inherit;
1277
-
border-radius: 4px;
1278
cursor: pointer;
1279
transition: all 0.15s;
1280
}
···
1324
);
1325
background-size: 200% 100%;
1326
animation: shimmer 1.5s ease-in-out infinite;
1327
-
border-radius: 4px;
1328
}
1329
1330
.comment-timestamp-skeleton {
···
1336
.comment-avatar-skeleton {
1337
width: 20px;
1338
height: 20px;
1339
-
border-radius: 50%;
1340
}
1341
1342
.comment-author-skeleton {
···
1386
}
1387
1388
.comment-timestamp {
1389
-
font-size: 0.75rem;
1390
padding: 0.15rem 0.4rem;
1391
}
1392
}
···
12
import { checkImageSensitive } from '$lib/moderation.svelte';
13
import { player } from '$lib/player.svelte';
14
import { queue } from '$lib/queue.svelte';
15
+
import { playTrack, guardGatedTrack } from '$lib/playback.svelte';
16
import { auth } from '$lib/auth.svelte';
17
import { toast } from '$lib/toast.svelte';
18
import type { Track } from '$lib/types';
···
104
window.location.href = '/';
105
}
106
107
+
async function handlePlay() {
108
if (player.currentTrack?.id === track.id) {
109
// this track is already loaded - just toggle play/pause
110
player.togglePlayPause();
111
} else {
112
// different track or no track - start this one
113
+
// use playTrack for gated content checks
114
+
if (track.gated) {
115
+
await playTrack(track);
116
+
} else {
117
+
queue.playNow(track);
118
+
}
119
}
120
}
121
122
function addToQueue() {
123
+
if (!guardGatedTrack(track, auth.isAuthenticated)) return;
124
queue.addTracks([track]);
125
toast.success(`queued ${track.title}`, 1800);
126
}
···
194
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
195
}
196
197
+
async function seekToTimestamp(ms: number) {
198
const doSeek = () => {
199
if (player.audioElement) {
200
player.audioElement.currentTime = ms / 1000;
···
208
}
209
210
// otherwise start playing and wait for audio to be ready
211
+
// use playTrack for gated content checks
212
+
let played = false;
213
+
if (track.gated) {
214
+
played = await playTrack(track);
215
+
} else {
216
+
queue.playNow(track);
217
+
played = true;
218
+
}
219
+
220
+
if (!played) return; // gated - can't seek
221
+
222
if (player.audioElement && player.audioElement.readyState >= 1) {
223
doSeek();
224
} else {
···
305
306
// track which track we've loaded data for to detect navigation
307
let loadedForTrackId = $state<number | null>(null);
308
+
// track if we've loaded liked state for this track (separate from general load)
309
+
let likedStateLoadedForTrackId = $state<number | null>(null);
310
311
// reload data when navigating between track pages
312
// watch data.track.id (from server) not track.id (local state)
···
323
newCommentText = '';
324
editingCommentId = null;
325
editingCommentText = '';
326
+
likedStateLoadedForTrackId = null; // reset liked state tracking
327
328
// sync track from server data
329
track = data.track;
···
331
// mark as loaded for this track
332
loadedForTrackId = currentId;
333
334
+
// load comments (doesn't require auth)
335
void loadComments();
336
}
337
});
338
339
+
// separate effect to load liked state when auth becomes available
340
+
$effect(() => {
341
+
const currentId = data.track?.id;
342
+
if (!currentId || !browser) return;
343
+
344
+
// load liked state when authenticated and haven't loaded for this track yet
345
+
if (auth.isAuthenticated && likedStateLoadedForTrackId !== currentId) {
346
+
likedStateLoadedForTrackId = currentId;
347
+
void loadLikedState();
348
+
}
349
+
});
350
+
351
let shareUrl = $state('');
352
353
$effect(() => {
···
442
<!-- track info wrapper -->
443
<div class="track-info-wrapper">
444
<div class="track-info">
445
+
<h1 class="track-title">
446
+
{track.title}
447
+
{#if track.gated}
448
+
<span class="gated-badge" title="supporters only">
449
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
450
+
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/>
451
+
</svg>
452
+
</span>
453
+
{/if}
454
+
</h1>
455
<div class="track-metadata">
456
<a href="/u/{track.artist_handle}" class="artist-link">
457
{track.artist}
···
503
trackTitle={track.title}
504
trackUri={track.atproto_record_uri}
505
trackCid={track.atproto_record_cid}
506
+
fileId={track.file_id}
507
+
gated={track.gated}
508
initialLiked={track.is_liked || false}
509
shareUrl={shareUrl}
510
onQueue={addToQueue}
···
697
width: 100%;
698
max-width: 300px;
699
aspect-ratio: 1;
700
+
border-radius: var(--radius-md);
701
overflow: hidden;
702
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
703
}
···
743
margin: 0;
744
line-height: 1.2;
745
text-align: center;
746
+
display: inline-flex;
747
+
align-items: center;
748
+
justify-content: center;
749
+
gap: 0.5rem;
750
+
flex-wrap: wrap;
751
+
}
752
+
753
+
.gated-badge {
754
+
display: inline-flex;
755
+
align-items: center;
756
+
justify-content: center;
757
+
color: var(--accent);
758
+
opacity: 0.8;
759
+
}
760
+
761
+
.gated-badge svg {
762
+
display: block;
763
}
764
765
.track-metadata {
···
769
gap: 0.75rem;
770
flex-wrap: wrap;
771
color: var(--text-secondary);
772
+
font-size: var(--text-xl);
773
}
774
775
.separator {
776
color: var(--text-muted);
777
+
font-size: var(--text-sm);
778
}
779
780
.artist-link {
···
855
856
.track-stats {
857
color: var(--text-tertiary);
858
+
font-size: var(--text-base);
859
display: flex;
860
align-items: center;
861
gap: 0.5rem;
···
863
}
864
865
.track-stats .separator {
866
+
font-size: var(--text-xs);
867
}
868
869
.track-tags {
···
878
padding: 0.25rem 0.6rem;
879
background: color-mix(in srgb, var(--accent) 15%, transparent);
880
color: var(--accent-hover);
881
+
border-radius: var(--radius-sm);
882
+
font-size: var(--text-sm);
883
font-weight: 500;
884
text-decoration: none;
885
transition: all 0.15s;
···
914
background: var(--accent);
915
color: var(--bg-primary);
916
border: none;
917
+
border-radius: var(--radius-2xl);
918
+
font-size: var(--text-base);
919
font-weight: 600;
920
font-family: inherit;
921
cursor: pointer;
···
928
}
929
930
.btn-play.playing {
931
+
animation: ethereal-glow 3s ease-in-out infinite;
932
}
933
934
.btn-queue {
···
939
background: transparent;
940
color: var(--text-primary);
941
border: 1px solid var(--border-emphasis);
942
+
border-radius: var(--radius-2xl);
943
+
font-size: var(--text-base);
944
font-weight: 500;
945
font-family: inherit;
946
cursor: pointer;
···
984
}
985
986
.track-title {
987
+
font-size: var(--text-3xl);
988
}
989
990
.track-metadata {
991
+
font-size: var(--text-base);
992
gap: 0.5rem;
993
}
994
995
.track-stats {
996
+
font-size: var(--text-sm);
997
}
998
999
.track-actions {
···
1010
min-width: calc(50% - 0.25rem);
1011
justify-content: center;
1012
padding: 0.6rem 1rem;
1013
+
font-size: var(--text-base);
1014
}
1015
1016
.btn-play svg {
···
1023
min-width: calc(50% - 0.25rem);
1024
justify-content: center;
1025
padding: 0.6rem 1rem;
1026
+
font-size: var(--text-base);
1027
}
1028
1029
.btn-queue svg {
···
1042
}
1043
1044
.comments-title {
1045
+
font-size: var(--text-lg);
1046
font-weight: 600;
1047
color: var(--text-primary);
1048
margin: 0 0 0.75rem 0;
···
1067
padding: 0.6rem 0.8rem;
1068
background: var(--bg-tertiary);
1069
border: 1px solid var(--border-default);
1070
+
border-radius: var(--radius-base);
1071
color: var(--text-primary);
1072
+
font-size: var(--text-base);
1073
font-family: inherit;
1074
}
1075
···
1087
background: var(--accent);
1088
color: var(--bg-primary);
1089
border: none;
1090
+
border-radius: var(--radius-base);
1091
+
font-size: var(--text-base);
1092
font-weight: 600;
1093
font-family: inherit;
1094
cursor: pointer;
···
1106
1107
.login-prompt {
1108
color: var(--text-tertiary);
1109
+
font-size: var(--text-base);
1110
margin-bottom: 1rem;
1111
}
1112
···
1121
1122
.no-comments {
1123
color: var(--text-muted);
1124
+
font-size: var(--text-base);
1125
text-align: center;
1126
padding: 1rem;
1127
}
···
1142
1143
.comments-list::-webkit-scrollbar-track {
1144
background: var(--bg-primary);
1145
+
border-radius: var(--radius-sm);
1146
}
1147
1148
.comments-list::-webkit-scrollbar-thumb {
1149
background: var(--border-default);
1150
+
border-radius: var(--radius-sm);
1151
}
1152
1153
.comments-list::-webkit-scrollbar-thumb:hover {
···
1160
gap: 0.6rem;
1161
padding: 0.5rem 0.6rem;
1162
background: var(--bg-tertiary);
1163
+
border-radius: var(--radius-base);
1164
transition: background 0.15s;
1165
}
1166
···
1169
}
1170
1171
.comment-timestamp {
1172
+
font-size: var(--text-sm);
1173
font-weight: 600;
1174
color: var(--accent);
1175
background: color-mix(in srgb, var(--accent) 10%, transparent);
1176
padding: 0.2rem 0.5rem;
1177
+
border-radius: var(--radius-sm);
1178
white-space: nowrap;
1179
height: fit-content;
1180
border: none;
···
1207
}
1208
1209
.comment-time {
1210
+
font-size: var(--text-xs);
1211
color: var(--text-muted);
1212
}
1213
1214
.comment-avatar {
1215
width: 20px;
1216
height: 20px;
1217
+
border-radius: var(--radius-full);
1218
object-fit: cover;
1219
}
1220
1221
.comment-avatar-placeholder {
1222
width: 20px;
1223
height: 20px;
1224
+
border-radius: var(--radius-full);
1225
background: var(--border-default);
1226
}
1227
1228
.comment-author {
1229
+
font-size: var(--text-sm);
1230
font-weight: 500;
1231
color: var(--text-secondary);
1232
text-decoration: none;
···
1237
}
1238
1239
.comment-text {
1240
+
font-size: var(--text-base);
1241
color: var(--text-primary);
1242
margin: 0;
1243
line-height: 1.4;
···
1277
border: none;
1278
padding: 0;
1279
color: var(--text-muted);
1280
+
font-size: var(--text-sm);
1281
cursor: pointer;
1282
transition: color 0.15s;
1283
font-family: inherit;
···
1310
padding: 0.5rem;
1311
background: var(--bg-primary);
1312
border: 1px solid var(--border-default);
1313
+
border-radius: var(--radius-sm);
1314
color: var(--text-primary);
1315
+
font-size: var(--text-base);
1316
font-family: inherit;
1317
}
1318
···
1329
1330
.edit-form-btn {
1331
padding: 0.25rem 0.6rem;
1332
+
font-size: var(--text-sm);
1333
font-family: inherit;
1334
+
border-radius: var(--radius-sm);
1335
cursor: pointer;
1336
transition: all 0.15s;
1337
}
···
1381
);
1382
background-size: 200% 100%;
1383
animation: shimmer 1.5s ease-in-out infinite;
1384
+
border-radius: var(--radius-sm);
1385
}
1386
1387
.comment-timestamp-skeleton {
···
1393
.comment-avatar-skeleton {
1394
width: 20px;
1395
height: 20px;
1396
+
border-radius: var(--radius-full);
1397
}
1398
1399
.comment-author-skeleton {
···
1443
}
1444
1445
.comment-timestamp {
1446
+
font-size: var(--text-xs);
1447
padding: 0.15rem 0.4rem;
1448
}
1449
}
+5
-5
frontend/src/routes/u/[handle]/+error.svelte
+5
-5
frontend/src/routes/u/[handle]/+error.svelte
···
96
}
97
98
.error-message {
99
-
font-size: 1.25rem;
100
color: var(--text-secondary);
101
margin: 0 0 0.5rem 0;
102
}
103
104
.error-detail {
105
-
font-size: 1rem;
106
color: var(--text-tertiary);
107
margin: 0 0 2rem 0;
108
}
···
118
.bsky-link {
119
color: var(--accent);
120
text-decoration: none;
121
-
font-size: 1.1rem;
122
padding: 0.75rem 1.5rem;
123
border: 1px solid var(--accent);
124
-
border-radius: 6px;
125
transition: all 0.2s;
126
display: inline-block;
127
}
···
151
}
152
153
.error-message {
154
-
font-size: 1.1rem;
155
}
156
157
.actions {
···
96
}
97
98
.error-message {
99
+
font-size: var(--text-2xl);
100
color: var(--text-secondary);
101
margin: 0 0 0.5rem 0;
102
}
103
104
.error-detail {
105
+
font-size: var(--text-lg);
106
color: var(--text-tertiary);
107
margin: 0 0 2rem 0;
108
}
···
118
.bsky-link {
119
color: var(--accent);
120
text-decoration: none;
121
+
font-size: var(--text-xl);
122
padding: 0.75rem 1.5rem;
123
border: 1px solid var(--accent);
124
+
border-radius: var(--radius-base);
125
transition: all 0.2s;
126
display: inline-block;
127
}
···
151
}
152
153
.error-message {
154
+
font-size: var(--text-xl);
155
}
156
157
.actions {
+95
-36
frontend/src/routes/u/[handle]/+page.svelte
+95
-36
frontend/src/routes/u/[handle]/+page.svelte
···
1
<script lang="ts">
2
import { fade } from 'svelte/transition';
3
-
import { API_URL } from '$lib/config';
4
import { browser } from '$app/environment';
5
import type { Analytics, Track, Playlist } from '$lib/types';
6
import { formatDuration } from '$lib/stats.svelte';
···
8
import ShareButton from '$lib/components/ShareButton.svelte';
9
import Header from '$lib/components/Header.svelte';
10
import SensitiveImage from '$lib/components/SensitiveImage.svelte';
11
import { checkImageSensitive } from '$lib/moderation.svelte';
12
import { player } from '$lib/player.svelte';
13
import { queue } from '$lib/queue.svelte';
···
15
import { fetchLikedTracks, fetchUserLikes } from '$lib/tracks.svelte';
16
import { APP_NAME, APP_CANONICAL_URL } from '$lib/branding';
17
import type { PageData } from './$types';
18
19
// receive server-loaded data
20
let { data }: { data: PageData } = $props();
···
38
const supportUrl = $derived(() => {
39
if (!artist?.support_url) return null;
40
if (artist.support_url === 'atprotofans') {
41
-
return `https://atprotofans.com/u/${artist.did}`;
42
}
43
return artist.support_url;
44
});
···
66
67
// public playlists for collections section
68
let publicPlaylists = $state<Playlist[]>([]);
69
70
// track which artist we've loaded data for to detect navigation
71
let loadedForDid = $state<string | null>(null);
···
130
}
131
}
132
133
// reload data when navigating between artist pages
134
// watch data.artist?.did (from server) not artist?.did (local derived)
135
$effect(() => {
···
143
tracksHydrated = false;
144
likedTracksCount = null;
145
publicPlaylists = [];
146
147
// sync tracks and pagination from server data
148
tracks = data.tracks ?? [];
···
158
void hydrateTracksWithLikes();
159
void loadLikedTracksCount();
160
void loadPublicPlaylists();
161
}
162
});
163
···
310
<div class="artist-details">
311
<div class="artist-info">
312
<h1>{artist.display_name}</h1>
313
-
<a href="https://bsky.app/profile/{artist.handle}" target="_blank" rel="noopener" class="handle">
314
-
@{artist.handle}
315
-
</a>
316
{#if artist.bio}
317
<p class="bio">{artist.bio}</p>
318
{/if}
···
564
padding: 2rem;
565
background: var(--bg-secondary);
566
border: 1px solid var(--border-subtle);
567
-
border-radius: 8px;
568
}
569
570
.artist-details {
···
602
padding: 0 0.75rem;
603
background: color-mix(in srgb, var(--accent) 15%, transparent);
604
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
605
-
border-radius: 4px;
606
color: var(--accent);
607
-
font-size: 0.85rem;
608
text-decoration: none;
609
transition: all 0.2s ease;
610
}
···
622
.artist-avatar {
623
width: 120px;
624
height: 120px;
625
-
border-radius: 50%;
626
object-fit: cover;
627
border: 3px solid var(--border-default);
628
}
···
634
word-wrap: break-word;
635
overflow-wrap: break-word;
636
hyphens: auto;
637
}
638
639
.handle {
640
color: var(--text-tertiary);
641
-
font-size: 1.1rem;
642
-
margin: 0 0 1rem 0;
643
text-decoration: none;
644
transition: color 0.2s;
645
-
display: inline-block;
646
}
647
648
.handle:hover {
···
683
684
.section-header span {
685
color: var(--text-tertiary);
686
-
font-size: 0.9rem;
687
text-transform: uppercase;
688
letter-spacing: 0.1em;
689
}
···
701
padding: 1rem;
702
background: var(--bg-secondary);
703
border: 1px solid var(--border-subtle);
704
-
border-radius: 10px;
705
color: inherit;
706
text-decoration: none;
707
transition: transform 0.15s ease, border-color 0.15s ease;
···
717
.album-cover-wrapper {
718
width: 72px;
719
height: 72px;
720
-
border-radius: 6px;
721
overflow: hidden;
722
flex-shrink: 0;
723
background: var(--bg-tertiary);
···
765
.album-card-meta p {
766
margin: 0;
767
color: var(--text-tertiary);
768
-
font-size: 0.9rem;
769
display: flex;
770
align-items: center;
771
gap: 0.4rem;
···
779
.stat-card {
780
background: var(--bg-secondary);
781
border: 1px solid var(--border-subtle);
782
-
border-radius: 8px;
783
padding: 1.5rem;
784
transition: border-color 0.2s;
785
}
···
798
799
.stat-label {
800
color: var(--text-tertiary);
801
-
font-size: 0.9rem;
802
text-transform: lowercase;
803
line-height: 1;
804
}
805
806
.stat-duration {
807
margin-top: 0.5rem;
808
-
font-size: 0.85rem;
809
color: var(--text-secondary);
810
font-variant-numeric: tabular-nums;
811
}
···
833
834
.top-item-plays {
835
color: var(--accent);
836
-
font-size: 1rem;
837
line-height: 1;
838
}
839
···
853
);
854
background-size: 200% 100%;
855
animation: shimmer 1.5s ease-in-out infinite;
856
-
border-radius: 4px;
857
}
858
859
/* match .stat-value dimensions: 2.5rem font + 0.5rem margin-bottom */
···
905
padding: 0.75rem 1.5rem;
906
background: var(--bg-secondary);
907
border: 1px solid var(--border-subtle);
908
-
border-radius: 8px;
909
color: var(--text-secondary);
910
font-family: inherit;
911
-
font-size: 0.95rem;
912
cursor: pointer;
913
transition: all 0.2s ease;
914
}
···
926
927
.tracks-loading {
928
margin-left: 0.75rem;
929
-
font-size: 0.95rem;
930
color: var(--text-secondary);
931
font-weight: 400;
932
text-transform: lowercase;
···
943
padding: 3rem;
944
background: var(--bg-secondary);
945
border: 1px solid var(--border-subtle);
946
-
border-radius: 8px;
947
}
948
949
.empty-message {
950
color: var(--text-secondary);
951
-
font-size: 1.25rem;
952
margin: 0 0 0.5rem 0;
953
}
954
···
960
.bsky-link {
961
color: var(--accent);
962
text-decoration: none;
963
-
font-size: 1rem;
964
padding: 0.75rem 1.5rem;
965
border: 1px solid var(--accent);
966
-
border-radius: 6px;
967
transition: all 0.2s;
968
display: inline-block;
969
}
···
1001
1002
.artist-info {
1003
text-align: center;
1004
}
1005
1006
.artist-actions-desktop {
···
1013
1014
.support-btn {
1015
height: 28px;
1016
-
font-size: 0.8rem;
1017
padding: 0 0.6rem;
1018
}
1019
···
1053
.album-cover-wrapper {
1054
width: 56px;
1055
height: 56px;
1056
-
border-radius: 4px;
1057
}
1058
1059
.album-card-meta h3 {
1060
-
font-size: 0.95rem;
1061
margin-bottom: 0.25rem;
1062
}
1063
1064
.album-card-meta p {
1065
-
font-size: 0.8rem;
1066
}
1067
}
1068
···
1089
padding: 1.25rem 1.5rem;
1090
background: var(--bg-secondary);
1091
border: 1px solid var(--border-subtle);
1092
-
border-radius: 10px;
1093
color: inherit;
1094
text-decoration: none;
1095
transition: transform 0.15s ease, border-color 0.15s ease;
···
1103
.collection-icon {
1104
width: 48px;
1105
height: 48px;
1106
-
border-radius: 8px;
1107
display: flex;
1108
align-items: center;
1109
justify-content: center;
···
1134
1135
.collection-info h3 {
1136
margin: 0 0 0.25rem 0;
1137
-
font-size: 1.1rem;
1138
color: var(--text-primary);
1139
}
1140
1141
.collection-info p {
1142
margin: 0;
1143
-
font-size: 0.9rem;
1144
color: var(--text-tertiary);
1145
}
1146
···
1
<script lang="ts">
2
import { fade } from 'svelte/transition';
3
+
import { API_URL, getAtprotofansSupportUrl } from '$lib/config';
4
import { browser } from '$app/environment';
5
import type { Analytics, Track, Playlist } from '$lib/types';
6
import { formatDuration } from '$lib/stats.svelte';
···
8
import ShareButton from '$lib/components/ShareButton.svelte';
9
import Header from '$lib/components/Header.svelte';
10
import SensitiveImage from '$lib/components/SensitiveImage.svelte';
11
+
import SupporterBadge from '$lib/components/SupporterBadge.svelte';
12
import { checkImageSensitive } from '$lib/moderation.svelte';
13
import { player } from '$lib/player.svelte';
14
import { queue } from '$lib/queue.svelte';
···
16
import { fetchLikedTracks, fetchUserLikes } from '$lib/tracks.svelte';
17
import { APP_NAME, APP_CANONICAL_URL } from '$lib/branding';
18
import type { PageData } from './$types';
19
+
20
21
// receive server-loaded data
22
let { data }: { data: PageData } = $props();
···
40
const supportUrl = $derived(() => {
41
if (!artist?.support_url) return null;
42
if (artist.support_url === 'atprotofans') {
43
+
return getAtprotofansSupportUrl(artist.did);
44
}
45
return artist.support_url;
46
});
···
68
69
// public playlists for collections section
70
let publicPlaylists = $state<Playlist[]>([]);
71
+
72
+
// supporter status - true if logged-in viewer supports this artist via atprotofans
73
+
let isSupporter = $state(false);
74
75
// track which artist we've loaded data for to detect navigation
76
let loadedForDid = $state<string | null>(null);
···
135
}
136
}
137
138
+
/**
139
+
* check if the logged-in viewer supports this artist via atprotofans.
140
+
* only called when:
141
+
* 1. viewer is authenticated
142
+
* 2. artist has atprotofans support enabled
143
+
* 3. viewer is not the artist themselves
144
+
*/
145
+
async function checkSupporterStatus() {
146
+
// reset state
147
+
isSupporter = false;
148
+
149
+
// only check if viewer is logged in
150
+
if (!auth.isAuthenticated || !auth.user?.did) return;
151
+
152
+
// only check if artist has atprotofans enabled
153
+
if (artist?.support_url !== 'atprotofans') return;
154
+
155
+
// don't show badge on your own profile
156
+
if (auth.user.did === artist.did) return;
157
+
158
+
try {
159
+
const url = new URL('https://atprotofans.com/xrpc/com.atprotofans.validateSupporter');
160
+
url.searchParams.set('supporter', auth.user.did);
161
+
url.searchParams.set('subject', artist.did);
162
+
url.searchParams.set('signer', artist.did);
163
+
164
+
const response = await fetch(url.toString());
165
+
if (response.ok) {
166
+
const data = await response.json();
167
+
isSupporter = data.valid === true;
168
+
}
169
+
} catch (_e) {
170
+
// silently fail - supporter badge is optional enhancement
171
+
console.error('failed to check supporter status:', _e);
172
+
}
173
+
}
174
+
175
// reload data when navigating between artist pages
176
// watch data.artist?.did (from server) not artist?.did (local derived)
177
$effect(() => {
···
185
tracksHydrated = false;
186
likedTracksCount = null;
187
publicPlaylists = [];
188
+
isSupporter = false;
189
190
// sync tracks and pagination from server data
191
tracks = data.tracks ?? [];
···
201
void hydrateTracksWithLikes();
202
void loadLikedTracksCount();
203
void loadPublicPlaylists();
204
+
void checkSupporterStatus();
205
}
206
});
207
···
354
<div class="artist-details">
355
<div class="artist-info">
356
<h1>{artist.display_name}</h1>
357
+
<div class="handle-row">
358
+
<a href="https://bsky.app/profile/{artist.handle}" target="_blank" rel="noopener" class="handle">
359
+
@{artist.handle}
360
+
</a>
361
+
{#if isSupporter}
362
+
<SupporterBadge />
363
+
{/if}
364
+
</div>
365
{#if artist.bio}
366
<p class="bio">{artist.bio}</p>
367
{/if}
···
613
padding: 2rem;
614
background: var(--bg-secondary);
615
border: 1px solid var(--border-subtle);
616
+
border-radius: var(--radius-md);
617
}
618
619
.artist-details {
···
651
padding: 0 0.75rem;
652
background: color-mix(in srgb, var(--accent) 15%, transparent);
653
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
654
+
border-radius: var(--radius-sm);
655
color: var(--accent);
656
+
font-size: var(--text-sm);
657
text-decoration: none;
658
transition: all 0.2s ease;
659
}
···
671
.artist-avatar {
672
width: 120px;
673
height: 120px;
674
+
border-radius: var(--radius-full);
675
object-fit: cover;
676
border: 3px solid var(--border-default);
677
}
···
683
word-wrap: break-word;
684
overflow-wrap: break-word;
685
hyphens: auto;
686
+
}
687
+
688
+
.handle-row {
689
+
display: flex;
690
+
align-items: center;
691
+
gap: 0.75rem;
692
+
flex-wrap: wrap;
693
+
margin-bottom: 1rem;
694
}
695
696
.handle {
697
color: var(--text-tertiary);
698
+
font-size: var(--text-xl);
699
text-decoration: none;
700
transition: color 0.2s;
701
}
702
703
.handle:hover {
···
738
739
.section-header span {
740
color: var(--text-tertiary);
741
+
font-size: var(--text-base);
742
text-transform: uppercase;
743
letter-spacing: 0.1em;
744
}
···
756
padding: 1rem;
757
background: var(--bg-secondary);
758
border: 1px solid var(--border-subtle);
759
+
border-radius: var(--radius-md);
760
color: inherit;
761
text-decoration: none;
762
transition: transform 0.15s ease, border-color 0.15s ease;
···
772
.album-cover-wrapper {
773
width: 72px;
774
height: 72px;
775
+
border-radius: var(--radius-base);
776
overflow: hidden;
777
flex-shrink: 0;
778
background: var(--bg-tertiary);
···
820
.album-card-meta p {
821
margin: 0;
822
color: var(--text-tertiary);
823
+
font-size: var(--text-base);
824
display: flex;
825
align-items: center;
826
gap: 0.4rem;
···
834
.stat-card {
835
background: var(--bg-secondary);
836
border: 1px solid var(--border-subtle);
837
+
border-radius: var(--radius-md);
838
padding: 1.5rem;
839
transition: border-color 0.2s;
840
}
···
853
854
.stat-label {
855
color: var(--text-tertiary);
856
+
font-size: var(--text-base);
857
text-transform: lowercase;
858
line-height: 1;
859
}
860
861
.stat-duration {
862
margin-top: 0.5rem;
863
+
font-size: var(--text-sm);
864
color: var(--text-secondary);
865
font-variant-numeric: tabular-nums;
866
}
···
888
889
.top-item-plays {
890
color: var(--accent);
891
+
font-size: var(--text-lg);
892
line-height: 1;
893
}
894
···
908
);
909
background-size: 200% 100%;
910
animation: shimmer 1.5s ease-in-out infinite;
911
+
border-radius: var(--radius-sm);
912
}
913
914
/* match .stat-value dimensions: 2.5rem font + 0.5rem margin-bottom */
···
960
padding: 0.75rem 1.5rem;
961
background: var(--bg-secondary);
962
border: 1px solid var(--border-subtle);
963
+
border-radius: var(--radius-md);
964
color: var(--text-secondary);
965
font-family: inherit;
966
+
font-size: var(--text-base);
967
cursor: pointer;
968
transition: all 0.2s ease;
969
}
···
981
982
.tracks-loading {
983
margin-left: 0.75rem;
984
+
font-size: var(--text-base);
985
color: var(--text-secondary);
986
font-weight: 400;
987
text-transform: lowercase;
···
998
padding: 3rem;
999
background: var(--bg-secondary);
1000
border: 1px solid var(--border-subtle);
1001
+
border-radius: var(--radius-md);
1002
}
1003
1004
.empty-message {
1005
color: var(--text-secondary);
1006
+
font-size: var(--text-2xl);
1007
margin: 0 0 0.5rem 0;
1008
}
1009
···
1015
.bsky-link {
1016
color: var(--accent);
1017
text-decoration: none;
1018
+
font-size: var(--text-lg);
1019
padding: 0.75rem 1.5rem;
1020
border: 1px solid var(--accent);
1021
+
border-radius: var(--radius-base);
1022
transition: all 0.2s;
1023
display: inline-block;
1024
}
···
1056
1057
.artist-info {
1058
text-align: center;
1059
+
}
1060
+
1061
+
.handle-row {
1062
+
justify-content: center;
1063
}
1064
1065
.artist-actions-desktop {
···
1072
1073
.support-btn {
1074
height: 28px;
1075
+
font-size: var(--text-sm);
1076
padding: 0 0.6rem;
1077
}
1078
···
1112
.album-cover-wrapper {
1113
width: 56px;
1114
height: 56px;
1115
+
border-radius: var(--radius-sm);
1116
}
1117
1118
.album-card-meta h3 {
1119
+
font-size: var(--text-base);
1120
margin-bottom: 0.25rem;
1121
}
1122
1123
.album-card-meta p {
1124
+
font-size: var(--text-sm);
1125
}
1126
}
1127
···
1148
padding: 1.25rem 1.5rem;
1149
background: var(--bg-secondary);
1150
border: 1px solid var(--border-subtle);
1151
+
border-radius: var(--radius-md);
1152
color: inherit;
1153
text-decoration: none;
1154
transition: transform 0.15s ease, border-color 0.15s ease;
···
1162
.collection-icon {
1163
width: 48px;
1164
height: 48px;
1165
+
border-radius: var(--radius-md);
1166
display: flex;
1167
align-items: center;
1168
justify-content: center;
···
1193
1194
.collection-info h3 {
1195
margin: 0 0 0.25rem 0;
1196
+
font-size: var(--text-xl);
1197
color: var(--text-primary);
1198
}
1199
1200
.collection-info p {
1201
margin: 0;
1202
+
font-size: var(--text-base);
1203
color: var(--text-tertiary);
1204
}
1205
+58
-31
frontend/src/routes/u/[handle]/album/[slug]/+page.svelte
+58
-31
frontend/src/routes/u/[handle]/album/[slug]/+page.svelte
···
7
import { checkImageSensitive } from '$lib/moderation.svelte';
8
import { player } from '$lib/player.svelte';
9
import { queue } from '$lib/queue.svelte';
10
import { toast } from '$lib/toast.svelte';
11
import { auth } from '$lib/auth.svelte';
12
import { API_URL } from '$lib/config';
···
26
tracks = [...data.album.tracks];
27
});
28
29
// check if current user owns this album
30
const isOwner = $derived(auth.user?.did === albumMetadata.artist_did);
31
// can only reorder if owner and album has an ATProto list
32
const canReorder = $derived(isOwner && !!albumMetadata.list_uri);
33
34
-
// local mutable copy of tracks for reordering
35
-
let tracks = $state<Track[]>([...data.album.tracks]);
36
37
// edit mode state
38
let isEditMode = $state(false);
···
70
queue.playNow(track);
71
}
72
73
-
function playNow() {
74
if (tracks.length > 0) {
75
-
queue.setQueue(tracks);
76
-
queue.playNow(tracks[0]);
77
-
toast.success(`playing ${albumMetadata.title}`, 1800);
78
}
79
}
80
···
542
</div>
543
544
<div class="album-actions">
545
-
<button class="play-button" onclick={playNow}>
546
-
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
547
-
<path d="M8 5v14l11-7z"/>
548
-
</svg>
549
-
play now
550
</button>
551
<button class="queue-button" onclick={addToQueue}>
552
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
···
763
.album-art {
764
width: 200px;
765
height: 200px;
766
-
border-radius: 8px;
767
object-fit: cover;
768
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
769
}
···
771
.album-art-placeholder {
772
width: 200px;
773
height: 200px;
774
-
border-radius: 8px;
775
background: var(--bg-tertiary);
776
border: 1px solid var(--border-subtle);
777
display: flex;
···
814
height: 32px;
815
background: transparent;
816
border: 1px solid var(--border-default);
817
-
border-radius: 4px;
818
color: var(--text-tertiary);
819
cursor: pointer;
820
transition: all 0.15s;
···
855
position: absolute;
856
inset: 0;
857
background: rgba(0, 0, 0, 0.6);
858
-
border-radius: 8px;
859
display: flex;
860
flex-direction: column;
861
align-items: center;
862
justify-content: center;
863
gap: 0.5rem;
864
color: white;
865
-
font-size: 0.85rem;
866
opacity: 0;
867
transition: opacity 0.2s;
868
}
···
890
891
.album-type {
892
text-transform: uppercase;
893
-
font-size: 0.75rem;
894
font-weight: 600;
895
letter-spacing: 0.1em;
896
color: var(--text-tertiary);
···
914
display: flex;
915
align-items: center;
916
gap: 0.75rem;
917
-
font-size: 0.95rem;
918
color: var(--text-secondary);
919
text-shadow: var(--text-shadow, none);
920
}
···
933
934
.meta-separator {
935
color: var(--text-muted);
936
-
font-size: 0.7rem;
937
}
938
939
.album-actions {
···
945
.play-button,
946
.queue-button {
947
padding: 0.75rem 1.5rem;
948
-
border-radius: 24px;
949
font-weight: 600;
950
-
font-size: 0.95rem;
951
font-family: inherit;
952
cursor: pointer;
953
transition: all 0.2s;
···
966
transform: scale(1.05);
967
}
968
969
.queue-button {
970
background: var(--glass-btn-bg, transparent);
971
color: var(--text-primary);
···
997
}
998
999
.section-heading {
1000
-
font-size: 1.25rem;
1001
font-weight: 600;
1002
color: var(--text-primary);
1003
margin-bottom: 1rem;
···
1015
display: flex;
1016
align-items: center;
1017
gap: 0.5rem;
1018
-
border-radius: 8px;
1019
transition: all 0.2s;
1020
position: relative;
1021
}
···
1047
color: var(--text-muted);
1048
cursor: grab;
1049
touch-action: none;
1050
-
border-radius: 4px;
1051
transition: all 0.2s;
1052
flex-shrink: 0;
1053
}
···
1108
}
1109
1110
.album-meta {
1111
-
font-size: 0.85rem;
1112
}
1113
1114
.album-actions {
···
1140
}
1141
1142
.album-meta {
1143
-
font-size: 0.8rem;
1144
flex-wrap: wrap;
1145
}
1146
}
···
1152
justify-content: center;
1153
width: 32px;
1154
height: 32px;
1155
-
border-radius: 50%;
1156
background: transparent;
1157
border: none;
1158
color: var(--text-muted);
···
1185
1186
.modal {
1187
background: var(--bg-secondary);
1188
-
border-radius: 12px;
1189
padding: 1.5rem;
1190
max-width: 400px;
1191
width: calc(100% - 2rem);
···
1199
1200
.modal-header h3 {
1201
margin: 0;
1202
-
font-size: 1.25rem;
1203
font-weight: 600;
1204
color: var(--text-primary);
1205
}
···
1223
.cancel-btn,
1224
.confirm-btn {
1225
padding: 0.625rem 1.25rem;
1226
-
border-radius: 8px;
1227
font-weight: 500;
1228
-
font-size: 0.9rem;
1229
font-family: inherit;
1230
cursor: pointer;
1231
transition: all 0.2s;
···
7
import { checkImageSensitive } from '$lib/moderation.svelte';
8
import { player } from '$lib/player.svelte';
9
import { queue } from '$lib/queue.svelte';
10
+
import { playQueue } from '$lib/playback.svelte';
11
import { toast } from '$lib/toast.svelte';
12
import { auth } from '$lib/auth.svelte';
13
import { API_URL } from '$lib/config';
···
27
tracks = [...data.album.tracks];
28
});
29
30
+
// local mutable copy of tracks for reordering
31
+
let tracks = $state<Track[]>([...data.album.tracks]);
32
+
33
// check if current user owns this album
34
const isOwner = $derived(auth.user?.did === albumMetadata.artist_did);
35
// can only reorder if owner and album has an ATProto list
36
const canReorder = $derived(isOwner && !!albumMetadata.list_uri);
37
38
+
// check if current track is from this album (active, regardless of paused state)
39
+
const isAlbumActive = $derived(
40
+
player.currentTrack !== null &&
41
+
tracks.some(t => t.id === player.currentTrack?.id)
42
+
);
43
+
44
+
// check if actively playing (not paused)
45
+
const isAlbumPlaying = $derived(isAlbumActive && !player.paused);
46
47
// edit mode state
48
let isEditMode = $state(false);
···
80
queue.playNow(track);
81
}
82
83
+
async function playNow() {
84
if (tracks.length > 0) {
85
+
// use playQueue to check gated access on first track before modifying queue
86
+
const played = await playQueue(tracks);
87
+
if (played) {
88
+
toast.success(`playing ${albumMetadata.title}`, 1800);
89
+
}
90
}
91
}
92
···
554
</div>
555
556
<div class="album-actions">
557
+
<button
558
+
class="play-button"
559
+
class:is-playing={isAlbumPlaying}
560
+
onclick={() => isAlbumActive ? player.togglePlayPause() : playNow()}
561
+
>
562
+
{#if isAlbumPlaying}
563
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
564
+
<path d="M6 4h4v16H6zM14 4h4v16h-4z"/>
565
+
</svg>
566
+
pause
567
+
{:else}
568
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
569
+
<path d="M8 5v14l11-7z"/>
570
+
</svg>
571
+
play
572
+
{/if}
573
</button>
574
<button class="queue-button" onclick={addToQueue}>
575
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
···
786
.album-art {
787
width: 200px;
788
height: 200px;
789
+
border-radius: var(--radius-md);
790
object-fit: cover;
791
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
792
}
···
794
.album-art-placeholder {
795
width: 200px;
796
height: 200px;
797
+
border-radius: var(--radius-md);
798
background: var(--bg-tertiary);
799
border: 1px solid var(--border-subtle);
800
display: flex;
···
837
height: 32px;
838
background: transparent;
839
border: 1px solid var(--border-default);
840
+
border-radius: var(--radius-sm);
841
color: var(--text-tertiary);
842
cursor: pointer;
843
transition: all 0.15s;
···
878
position: absolute;
879
inset: 0;
880
background: rgba(0, 0, 0, 0.6);
881
+
border-radius: var(--radius-md);
882
display: flex;
883
flex-direction: column;
884
align-items: center;
885
justify-content: center;
886
gap: 0.5rem;
887
color: white;
888
+
font-size: var(--text-sm);
889
opacity: 0;
890
transition: opacity 0.2s;
891
}
···
913
914
.album-type {
915
text-transform: uppercase;
916
+
font-size: var(--text-xs);
917
font-weight: 600;
918
letter-spacing: 0.1em;
919
color: var(--text-tertiary);
···
937
display: flex;
938
align-items: center;
939
gap: 0.75rem;
940
+
font-size: var(--text-base);
941
color: var(--text-secondary);
942
text-shadow: var(--text-shadow, none);
943
}
···
956
957
.meta-separator {
958
color: var(--text-muted);
959
+
font-size: var(--text-xs);
960
}
961
962
.album-actions {
···
968
.play-button,
969
.queue-button {
970
padding: 0.75rem 1.5rem;
971
+
border-radius: var(--radius-2xl);
972
font-weight: 600;
973
+
font-size: var(--text-base);
974
font-family: inherit;
975
cursor: pointer;
976
transition: all 0.2s;
···
989
transform: scale(1.05);
990
}
991
992
+
.play-button.is-playing {
993
+
animation: ethereal-glow 3s ease-in-out infinite;
994
+
}
995
+
996
.queue-button {
997
background: var(--glass-btn-bg, transparent);
998
color: var(--text-primary);
···
1024
}
1025
1026
.section-heading {
1027
+
font-size: var(--text-2xl);
1028
font-weight: 600;
1029
color: var(--text-primary);
1030
margin-bottom: 1rem;
···
1042
display: flex;
1043
align-items: center;
1044
gap: 0.5rem;
1045
+
border-radius: var(--radius-md);
1046
transition: all 0.2s;
1047
position: relative;
1048
}
···
1074
color: var(--text-muted);
1075
cursor: grab;
1076
touch-action: none;
1077
+
border-radius: var(--radius-sm);
1078
transition: all 0.2s;
1079
flex-shrink: 0;
1080
}
···
1135
}
1136
1137
.album-meta {
1138
+
font-size: var(--text-sm);
1139
}
1140
1141
.album-actions {
···
1167
}
1168
1169
.album-meta {
1170
+
font-size: var(--text-sm);
1171
flex-wrap: wrap;
1172
}
1173
}
···
1179
justify-content: center;
1180
width: 32px;
1181
height: 32px;
1182
+
border-radius: var(--radius-full);
1183
background: transparent;
1184
border: none;
1185
color: var(--text-muted);
···
1212
1213
.modal {
1214
background: var(--bg-secondary);
1215
+
border-radius: var(--radius-lg);
1216
padding: 1.5rem;
1217
max-width: 400px;
1218
width: calc(100% - 2rem);
···
1226
1227
.modal-header h3 {
1228
margin: 0;
1229
+
font-size: var(--text-2xl);
1230
font-weight: 600;
1231
color: var(--text-primary);
1232
}
···
1250
.cancel-btn,
1251
.confirm-btn {
1252
padding: 0.625rem 1.25rem;
1253
+
border-radius: var(--radius-md);
1254
font-weight: 500;
1255
+
font-size: var(--text-base);
1256
font-family: inherit;
1257
cursor: pointer;
1258
transition: all 0.2s;
+129
-16
frontend/src/routes/upload/+page.svelte
+129
-16
frontend/src/routes/upload/+page.svelte
···
6
import AlbumSelect from "$lib/components/AlbumSelect.svelte";
7
import WaveLoading from "$lib/components/WaveLoading.svelte";
8
import TagInput from "$lib/components/TagInput.svelte";
9
-
import type { FeaturedArtist, AlbumSummary } from "$lib/types";
10
import { API_URL, getServerConfig } from "$lib/config";
11
import { uploader } from "$lib/uploader.svelte";
12
import { toast } from "$lib/toast.svelte";
···
38
let uploadTags = $state<string[]>([]);
39
let hasUnresolvedFeaturesInput = $state(false);
40
let attestedRights = $state(false);
41
42
// albums for selection
43
let albums = $state<AlbumSummary[]>([]);
44
45
onMount(async () => {
46
// wait for auth to finish loading
···
53
return;
54
}
55
56
-
await loadMyAlbums();
57
loading = false;
58
});
59
60
async function loadMyAlbums() {
61
if (!auth.user) return;
62
try {
···
82
const uploadFeatures = [...featuredArtists];
83
const uploadImage = imageFile;
84
const tagsToUpload = [...uploadTags];
85
86
const clearForm = () => {
87
title = "";
···
91
featuredArtists = [];
92
uploadTags = [];
93
attestedRights = false;
94
95
const fileInput = document.getElementById(
96
"file-input",
···
109
uploadFeatures,
110
uploadImage,
111
tagsToUpload,
112
async () => {
113
await loadMyAlbums();
114
},
···
286
{/if}
287
</div>
288
289
<div class="form-group attestation">
290
<label class="checkbox-label">
291
<input
···
327
>
328
<span>upload track</span>
329
</button>
330
</form>
331
</main>
332
{/if}
···
370
form {
371
background: var(--bg-tertiary);
372
padding: 2rem;
373
-
border-radius: 8px;
374
border: 1px solid var(--border-subtle);
375
}
376
···
382
display: block;
383
color: var(--text-secondary);
384
margin-bottom: 0.5rem;
385
-
font-size: 0.9rem;
386
}
387
388
input[type="text"] {
···
390
padding: 0.75rem;
391
background: var(--bg-primary);
392
border: 1px solid var(--border-default);
393
-
border-radius: 4px;
394
color: var(--text-primary);
395
-
font-size: 1rem;
396
font-family: inherit;
397
transition: all 0.2s;
398
}
···
407
padding: 0.75rem;
408
background: var(--bg-primary);
409
border: 1px solid var(--border-default);
410
-
border-radius: 4px;
411
color: var(--text-primary);
412
-
font-size: 0.9rem;
413
font-family: inherit;
414
cursor: pointer;
415
}
416
417
.format-hint {
418
margin-top: 0.25rem;
419
-
font-size: 0.8rem;
420
color: var(--text-tertiary);
421
}
422
423
.file-info {
424
margin-top: 0.5rem;
425
-
font-size: 0.85rem;
426
color: var(--text-muted);
427
}
428
···
432
background: var(--accent);
433
color: var(--text-primary);
434
border: none;
435
-
border-radius: 4px;
436
-
font-size: 1rem;
437
font-weight: 600;
438
font-family: inherit;
439
cursor: pointer;
···
467
.attestation {
468
background: var(--bg-primary);
469
padding: 1rem;
470
-
border-radius: 4px;
471
border: 1px solid var(--border-default);
472
}
473
···
489
}
490
491
.checkbox-text {
492
-
font-size: 0.95rem;
493
color: var(--text-primary);
494
line-height: 1.4;
495
}
···
497
.attestation-note {
498
margin-top: 0.75rem;
499
margin-left: 2rem;
500
-
font-size: 0.8rem;
501
color: var(--text-tertiary);
502
line-height: 1.4;
503
}
···
511
text-decoration: underline;
512
}
513
514
@media (max-width: 768px) {
515
main {
516
padding: 0 0.75rem
···
525
}
526
527
.section-header h2 {
528
-
font-size: 1.25rem;
529
}
530
}
531
</style>
···
6
import AlbumSelect from "$lib/components/AlbumSelect.svelte";
7
import WaveLoading from "$lib/components/WaveLoading.svelte";
8
import TagInput from "$lib/components/TagInput.svelte";
9
+
import type { FeaturedArtist, AlbumSummary, Artist } from "$lib/types";
10
import { API_URL, getServerConfig } from "$lib/config";
11
import { uploader } from "$lib/uploader.svelte";
12
import { toast } from "$lib/toast.svelte";
···
38
let uploadTags = $state<string[]>([]);
39
let hasUnresolvedFeaturesInput = $state(false);
40
let attestedRights = $state(false);
41
+
let supportGated = $state(false);
42
43
// albums for selection
44
let albums = $state<AlbumSummary[]>([]);
45
+
46
+
// artist profile for checking atprotofans eligibility
47
+
let artistProfile = $state<Artist | null>(null);
48
49
onMount(async () => {
50
// wait for auth to finish loading
···
57
return;
58
}
59
60
+
await Promise.all([loadMyAlbums(), loadArtistProfile()]);
61
loading = false;
62
});
63
64
+
async function loadArtistProfile() {
65
+
if (!auth.user) return;
66
+
try {
67
+
const response = await fetch(
68
+
`${API_URL}/artists/by-handle/${auth.user.handle}`,
69
+
);
70
+
if (response.ok) {
71
+
artistProfile = await response.json();
72
+
}
73
+
} catch (_e) {
74
+
console.error("failed to load artist profile:", _e);
75
+
}
76
+
}
77
+
78
async function loadMyAlbums() {
79
if (!auth.user) return;
80
try {
···
100
const uploadFeatures = [...featuredArtists];
101
const uploadImage = imageFile;
102
const tagsToUpload = [...uploadTags];
103
+
const isGated = supportGated;
104
105
const clearForm = () => {
106
title = "";
···
110
featuredArtists = [];
111
uploadTags = [];
112
attestedRights = false;
113
+
supportGated = false;
114
115
const fileInput = document.getElementById(
116
"file-input",
···
129
uploadFeatures,
130
uploadImage,
131
tagsToUpload,
132
+
isGated,
133
async () => {
134
await loadMyAlbums();
135
},
···
307
{/if}
308
</div>
309
310
+
<div class="form-group supporter-gating">
311
+
{#if artistProfile?.support_url}
312
+
<label class="checkbox-label">
313
+
<input
314
+
type="checkbox"
315
+
bind:checked={supportGated}
316
+
/>
317
+
<span class="checkbox-text">
318
+
<svg class="heart-icon" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
319
+
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
320
+
</svg>
321
+
supporters only
322
+
</span>
323
+
</label>
324
+
<p class="gating-note">
325
+
only users who support you via <a href={artistProfile.support_url} target="_blank" rel="noopener">atprotofans</a> can play this track
326
+
</p>
327
+
{:else}
328
+
<div class="gating-disabled">
329
+
<span class="gating-disabled-icon">
330
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
331
+
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
332
+
</svg>
333
+
</span>
334
+
<span class="gating-disabled-text">
335
+
want to offer exclusive tracks to supporters? <a href="https://atprotofans.com" target="_blank" rel="noopener">set up atprotofans</a>, then enable it in your <a href="/portal">portal</a>
336
+
</span>
337
+
</div>
338
+
{/if}
339
+
</div>
340
+
341
<div class="form-group attestation">
342
<label class="checkbox-label">
343
<input
···
379
>
380
<span>upload track</span>
381
</button>
382
+
383
</form>
384
</main>
385
{/if}
···
423
form {
424
background: var(--bg-tertiary);
425
padding: 2rem;
426
+
border-radius: var(--radius-md);
427
border: 1px solid var(--border-subtle);
428
}
429
···
435
display: block;
436
color: var(--text-secondary);
437
margin-bottom: 0.5rem;
438
+
font-size: var(--text-base);
439
}
440
441
input[type="text"] {
···
443
padding: 0.75rem;
444
background: var(--bg-primary);
445
border: 1px solid var(--border-default);
446
+
border-radius: var(--radius-sm);
447
color: var(--text-primary);
448
+
font-size: var(--text-lg);
449
font-family: inherit;
450
transition: all 0.2s;
451
}
···
460
padding: 0.75rem;
461
background: var(--bg-primary);
462
border: 1px solid var(--border-default);
463
+
border-radius: var(--radius-sm);
464
color: var(--text-primary);
465
+
font-size: var(--text-base);
466
font-family: inherit;
467
cursor: pointer;
468
}
469
470
.format-hint {
471
margin-top: 0.25rem;
472
+
font-size: var(--text-sm);
473
color: var(--text-tertiary);
474
}
475
476
.file-info {
477
margin-top: 0.5rem;
478
+
font-size: var(--text-sm);
479
color: var(--text-muted);
480
}
481
···
485
background: var(--accent);
486
color: var(--text-primary);
487
border: none;
488
+
border-radius: var(--radius-sm);
489
+
font-size: var(--text-lg);
490
font-weight: 600;
491
font-family: inherit;
492
cursor: pointer;
···
520
.attestation {
521
background: var(--bg-primary);
522
padding: 1rem;
523
+
border-radius: var(--radius-sm);
524
border: 1px solid var(--border-default);
525
}
526
···
542
}
543
544
.checkbox-text {
545
+
font-size: var(--text-base);
546
color: var(--text-primary);
547
line-height: 1.4;
548
}
···
550
.attestation-note {
551
margin-top: 0.75rem;
552
margin-left: 2rem;
553
+
font-size: var(--text-sm);
554
color: var(--text-tertiary);
555
line-height: 1.4;
556
}
···
564
text-decoration: underline;
565
}
566
567
+
.supporter-gating {
568
+
background: color-mix(in srgb, var(--accent) 8%, var(--bg-primary));
569
+
padding: 1rem;
570
+
border-radius: var(--radius-sm);
571
+
border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border-default));
572
+
}
573
+
574
+
.supporter-gating .checkbox-text {
575
+
display: inline-flex;
576
+
align-items: center;
577
+
gap: 0.4rem;
578
+
}
579
+
580
+
.supporter-gating .heart-icon {
581
+
color: var(--accent);
582
+
}
583
+
584
+
.gating-note {
585
+
margin-top: 0.5rem;
586
+
margin-left: 2rem;
587
+
font-size: var(--text-sm);
588
+
color: var(--text-tertiary);
589
+
line-height: 1.4;
590
+
}
591
+
592
+
.gating-note a {
593
+
color: var(--accent);
594
+
text-decoration: none;
595
+
}
596
+
597
+
.gating-note a:hover {
598
+
text-decoration: underline;
599
+
}
600
+
601
+
.gating-disabled {
602
+
display: flex;
603
+
align-items: flex-start;
604
+
gap: 0.75rem;
605
+
color: var(--text-muted);
606
+
}
607
+
608
+
.gating-disabled-icon {
609
+
flex-shrink: 0;
610
+
margin-top: 0.1rem;
611
+
}
612
+
613
+
.gating-disabled-text {
614
+
font-size: var(--text-sm);
615
+
line-height: 1.4;
616
+
}
617
+
618
+
.gating-disabled-text a {
619
+
color: var(--accent);
620
+
text-decoration: none;
621
+
}
622
+
623
+
.gating-disabled-text a:hover {
624
+
text-decoration: underline;
625
+
}
626
+
627
@media (max-width: 768px) {
628
main {
629
padding: 0 0.75rem
···
638
}
639
640
.section-header h2 {
641
+
font-size: var(--text-2xl);
642
}
643
}
644
</style>
+17
lexicons/track.json
+17
lexicons/track.json
···
61
"type": "string",
62
"format": "datetime",
63
"description": "Timestamp when the track was uploaded."
64
+
},
65
+
"supportGate": {
66
+
"type": "ref",
67
+
"ref": "#supportGate",
68
+
"description": "If set, this track requires viewer to be a supporter of the artist via atprotofans."
69
}
70
+
}
71
+
}
72
+
},
73
+
"supportGate": {
74
+
"type": "object",
75
+
"description": "Configuration for supporter-gated content.",
76
+
"required": ["type"],
77
+
"properties": {
78
+
"type": {
79
+
"type": "string",
80
+
"description": "The type of support required to access this content.",
81
+
"knownValues": ["any"]
82
}
83
}
84
},
+1
-1
moderation/Cargo.toml
+1
-1
moderation/Cargo.toml
···
6
[dependencies]
7
anyhow = "1.0"
8
axum = { version = "0.7", features = ["macros", "json", "ws"] }
9
bytes = "1.0"
10
chrono = { version = "0.4", features = ["serde"] }
11
futures = "0.3"
···
25
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
26
27
[dev-dependencies]
28
-
rand = "0.8"
···
6
[dependencies]
7
anyhow = "1.0"
8
axum = { version = "0.7", features = ["macros", "json", "ws"] }
9
+
rand = "0.8"
10
bytes = "1.0"
11
chrono = { version = "0.4", features = ["serde"] }
12
futures = "0.3"
···
26
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
27
28
[dev-dependencies]
+168
moderation/src/admin.rs
+168
moderation/src/admin.rs
···
104
pub active_uris: Vec<String>,
105
}
106
107
/// List all flagged tracks - returns JSON for API, HTML for htmx.
108
pub async fn list_flagged(
109
State(state): State<AppState>,
···
294
}))
295
}
296
297
/// Serve the admin UI HTML from static file.
298
pub async fn admin_ui() -> Result<Response, AppError> {
299
let html = tokio::fs::read_to_string("static/admin.html").await?;
···
306
let resolved_active = if current_filter == "resolved" { " active" } else { "" };
307
let all_active = if current_filter == "all" { " active" } else { "" };
308
309
let filter_buttons = format!(
310
"<div class=\"filter-row\">\
311
<span class=\"filter-label\">show:</span>\
312
<button type=\"button\" class=\"filter-btn{}\" hx-get=\"/admin/flags-html?filter=pending\" hx-target=\"#flags-list\">pending</button>\
313
<button type=\"button\" class=\"filter-btn{}\" hx-get=\"/admin/flags-html?filter=resolved\" hx-target=\"#flags-list\">resolved</button>\
314
<button type=\"button\" class=\"filter-btn{}\" hx-get=\"/admin/flags-html?filter=all\" hx-target=\"#flags-list\">all</button>\
315
</div>",
316
pending_active,
317
resolved_active,
318
all_active,
319
);
320
321
if tracks.is_empty() {
···
104
pub active_uris: Vec<String>,
105
}
106
107
+
/// Request to add a sensitive image.
108
+
#[derive(Debug, Deserialize)]
109
+
pub struct AddSensitiveImageRequest {
110
+
/// R2 storage ID (for track/album artwork)
111
+
pub image_id: Option<String>,
112
+
/// Full URL (for external images like avatars)
113
+
pub url: Option<String>,
114
+
/// Why this image was flagged
115
+
pub reason: Option<String>,
116
+
/// Admin who flagged it
117
+
pub flagged_by: Option<String>,
118
+
}
119
+
120
+
/// Response after adding a sensitive image.
121
+
#[derive(Debug, Serialize)]
122
+
pub struct AddSensitiveImageResponse {
123
+
pub id: i64,
124
+
pub message: String,
125
+
}
126
+
127
+
/// Request to remove a sensitive image.
128
+
#[derive(Debug, Deserialize)]
129
+
pub struct RemoveSensitiveImageRequest {
130
+
pub id: i64,
131
+
}
132
+
133
+
/// Response after removing a sensitive image.
134
+
#[derive(Debug, Serialize)]
135
+
pub struct RemoveSensitiveImageResponse {
136
+
pub removed: bool,
137
+
pub message: String,
138
+
}
139
+
140
+
/// Request to create a review batch.
141
+
#[derive(Debug, Deserialize)]
142
+
pub struct CreateBatchRequest {
143
+
/// URIs to include. If empty, uses all pending flags.
144
+
#[serde(default)]
145
+
pub uris: Vec<String>,
146
+
/// Who created this batch.
147
+
pub created_by: Option<String>,
148
+
}
149
+
150
+
/// Response after creating a review batch.
151
+
#[derive(Debug, Serialize)]
152
+
pub struct CreateBatchResponse {
153
+
pub id: String,
154
+
pub url: String,
155
+
pub flag_count: usize,
156
+
}
157
+
158
/// List all flagged tracks - returns JSON for API, HTML for htmx.
159
pub async fn list_flagged(
160
State(state): State<AppState>,
···
345
}))
346
}
347
348
+
/// Create a review batch from pending flags.
349
+
pub async fn create_batch(
350
+
State(state): State<AppState>,
351
+
Json(request): Json<CreateBatchRequest>,
352
+
) -> Result<Json<CreateBatchResponse>, AppError> {
353
+
let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?;
354
+
355
+
// Get URIs to include
356
+
let uris = if request.uris.is_empty() {
357
+
let pending = db.get_pending_flags().await?;
358
+
pending
359
+
.into_iter()
360
+
.filter(|t| !t.resolved)
361
+
.map(|t| t.uri)
362
+
.collect()
363
+
} else {
364
+
request.uris
365
+
};
366
+
367
+
if uris.is_empty() {
368
+
return Err(AppError::BadRequest("no flags to review".to_string()));
369
+
}
370
+
371
+
let id = generate_batch_id();
372
+
let flag_count = uris.len();
373
+
374
+
tracing::info!(
375
+
batch_id = %id,
376
+
flag_count = flag_count,
377
+
"creating review batch"
378
+
);
379
+
380
+
db.create_batch(&id, &uris, request.created_by.as_deref())
381
+
.await?;
382
+
383
+
let url = format!("/admin/review/{}", id);
384
+
385
+
Ok(Json(CreateBatchResponse { id, url, flag_count }))
386
+
}
387
+
388
+
/// Generate a short, URL-safe batch ID.
389
+
fn generate_batch_id() -> String {
390
+
use std::time::{SystemTime, UNIX_EPOCH};
391
+
let now = SystemTime::now()
392
+
.duration_since(UNIX_EPOCH)
393
+
.unwrap()
394
+
.as_millis();
395
+
let rand_part: u32 = rand::random();
396
+
format!("{:x}{:x}", (now as u64) & 0xFFFFFFFF, rand_part & 0xFFFF)
397
+
}
398
+
399
+
/// Add a sensitive image entry.
400
+
pub async fn add_sensitive_image(
401
+
State(state): State<AppState>,
402
+
Json(request): Json<AddSensitiveImageRequest>,
403
+
) -> Result<Json<AddSensitiveImageResponse>, AppError> {
404
+
let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?;
405
+
406
+
// Validate: at least one of image_id or url must be provided
407
+
if request.image_id.is_none() && request.url.is_none() {
408
+
return Err(AppError::BadRequest(
409
+
"at least one of image_id or url must be provided".to_string(),
410
+
));
411
+
}
412
+
413
+
tracing::info!(
414
+
image_id = ?request.image_id,
415
+
url = ?request.url,
416
+
reason = ?request.reason,
417
+
flagged_by = ?request.flagged_by,
418
+
"adding sensitive image"
419
+
);
420
+
421
+
let id = db
422
+
.add_sensitive_image(
423
+
request.image_id.as_deref(),
424
+
request.url.as_deref(),
425
+
request.reason.as_deref(),
426
+
request.flagged_by.as_deref(),
427
+
)
428
+
.await?;
429
+
430
+
Ok(Json(AddSensitiveImageResponse {
431
+
id,
432
+
message: "sensitive image added".to_string(),
433
+
}))
434
+
}
435
+
436
+
/// Remove a sensitive image entry.
437
+
pub async fn remove_sensitive_image(
438
+
State(state): State<AppState>,
439
+
Json(request): Json<RemoveSensitiveImageRequest>,
440
+
) -> Result<Json<RemoveSensitiveImageResponse>, AppError> {
441
+
let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?;
442
+
443
+
tracing::info!(id = request.id, "removing sensitive image");
444
+
445
+
let removed = db.remove_sensitive_image(request.id).await?;
446
+
447
+
let message = if removed {
448
+
format!("sensitive image {} removed", request.id)
449
+
} else {
450
+
format!("sensitive image {} not found", request.id)
451
+
};
452
+
453
+
Ok(Json(RemoveSensitiveImageResponse { removed, message }))
454
+
}
455
+
456
/// Serve the admin UI HTML from static file.
457
pub async fn admin_ui() -> Result<Response, AppError> {
458
let html = tokio::fs::read_to_string("static/admin.html").await?;
···
465
let resolved_active = if current_filter == "resolved" { " active" } else { "" };
466
let all_active = if current_filter == "all" { " active" } else { "" };
467
468
+
let count = tracks.len();
469
+
let count_label = match current_filter {
470
+
"pending" => format!("{} pending", count),
471
+
"resolved" => format!("{} resolved", count),
472
+
_ => format!("{} total", count),
473
+
};
474
+
475
let filter_buttons = format!(
476
"<div class=\"filter-row\">\
477
<span class=\"filter-label\">show:</span>\
478
<button type=\"button\" class=\"filter-btn{}\" hx-get=\"/admin/flags-html?filter=pending\" hx-target=\"#flags-list\">pending</button>\
479
<button type=\"button\" class=\"filter-btn{}\" hx-get=\"/admin/flags-html?filter=resolved\" hx-target=\"#flags-list\">resolved</button>\
480
<button type=\"button\" class=\"filter-btn{}\" hx-get=\"/admin/flags-html?filter=all\" hx-target=\"#flags-list\">all</button>\
481
+
<span class=\"filter-count\">{}</span>\
482
</div>",
483
pending_active,
484
resolved_active,
485
all_active,
486
+
count_label,
487
);
488
489
if tracks.is_empty() {
+6
-1
moderation/src/auth.rs
+6
-1
moderation/src/auth.rs
···
12
let path = req.uri().path();
13
14
// Public endpoints - no auth required
15
-
// Note: /admin serves HTML, auth is handled client-side for API calls
16
// Static files must be public for admin UI CSS/JS to load
17
if path == "/"
18
|| path == "/health"
19
|| path == "/admin"
20
|| path.starts_with("/static/")
21
|| path.starts_with("/xrpc/com.atproto.label.")
22
{
···
12
let path = req.uri().path();
13
14
// Public endpoints - no auth required
15
+
// Note: /admin and /admin/review/:id serve HTML, auth is handled client-side for API calls
16
// Static files must be public for admin UI CSS/JS to load
17
+
let is_review_page = path.starts_with("/admin/review/")
18
+
&& !path.ends_with("/data")
19
+
&& !path.ends_with("/submit");
20
if path == "/"
21
|| path == "/health"
22
+
|| path == "/sensitive-images"
23
|| path == "/admin"
24
+
|| is_review_page
25
|| path.starts_with("/static/")
26
|| path.starts_with("/xrpc/com.atproto.label.")
27
{
+336
-1
moderation/src/db.rs
+336
-1
moderation/src/db.rs
···
2
3
use chrono::{DateTime, Utc};
4
use serde::{Deserialize, Serialize};
5
-
use sqlx::{postgres::PgPoolOptions, PgPool};
6
7
use crate::admin::FlaggedTrack;
8
use crate::labels::Label;
9
10
/// Type alias for context row from database query.
11
type ContextRow = (
12
Option<i64>, // track_id
···
55
FingerprintNoise,
56
/// Legal cover version or remix
57
CoverVersion,
58
/// Other reason (see resolution_notes)
59
Other,
60
}
···
67
Self::Licensed => "licensed",
68
Self::FingerprintNoise => "fingerprint noise",
69
Self::CoverVersion => "cover/remix",
70
Self::Other => "other",
71
}
72
}
···
78
"licensed" => Some(Self::Licensed),
79
"fingerprint_noise" => Some(Self::FingerprintNoise),
80
"cover_version" => Some(Self::CoverVersion),
81
"other" => Some(Self::Other),
82
_ => None,
83
}
···
193
.execute(&self.pool)
194
.await?;
195
sqlx::query("ALTER TABLE label_context ADD COLUMN IF NOT EXISTS resolution_notes TEXT")
196
.execute(&self.pool)
197
.await?;
198
···
592
.collect();
593
594
Ok(tracks)
595
}
596
}
597
···
2
3
use chrono::{DateTime, Utc};
4
use serde::{Deserialize, Serialize};
5
+
use sqlx::{postgres::PgPoolOptions, FromRow, PgPool};
6
7
use crate::admin::FlaggedTrack;
8
use crate::labels::Label;
9
10
+
/// Sensitive image record from the database.
11
+
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
12
+
pub struct SensitiveImageRow {
13
+
pub id: i64,
14
+
/// R2 storage ID (for track/album artwork)
15
+
pub image_id: Option<String>,
16
+
/// Full URL (for external images like avatars)
17
+
pub url: Option<String>,
18
+
/// Why this image was flagged
19
+
pub reason: Option<String>,
20
+
/// When the image was flagged
21
+
pub flagged_at: DateTime<Utc>,
22
+
/// Admin who flagged it
23
+
pub flagged_by: Option<String>,
24
+
}
25
+
26
+
/// Review batch for mobile-friendly flag review.
27
+
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
28
+
pub struct ReviewBatch {
29
+
pub id: String,
30
+
pub created_at: DateTime<Utc>,
31
+
pub expires_at: Option<DateTime<Utc>>,
32
+
/// Status: pending, completed.
33
+
pub status: String,
34
+
/// Who created this batch.
35
+
pub created_by: Option<String>,
36
+
}
37
+
38
+
/// A flag within a review batch.
39
+
#[derive(Debug, Clone, FromRow, Serialize, Deserialize)]
40
+
pub struct BatchFlag {
41
+
pub id: i64,
42
+
pub batch_id: String,
43
+
pub uri: String,
44
+
pub reviewed: bool,
45
+
pub reviewed_at: Option<DateTime<Utc>>,
46
+
/// Decision: approved, rejected, or null.
47
+
pub decision: Option<String>,
48
+
}
49
+
50
/// Type alias for context row from database query.
51
type ContextRow = (
52
Option<i64>, // track_id
···
95
FingerprintNoise,
96
/// Legal cover version or remix
97
CoverVersion,
98
+
/// Content was deleted from plyr.fm
99
+
ContentDeleted,
100
/// Other reason (see resolution_notes)
101
Other,
102
}
···
109
Self::Licensed => "licensed",
110
Self::FingerprintNoise => "fingerprint noise",
111
Self::CoverVersion => "cover/remix",
112
+
Self::ContentDeleted => "content deleted",
113
Self::Other => "other",
114
}
115
}
···
121
"licensed" => Some(Self::Licensed),
122
"fingerprint_noise" => Some(Self::FingerprintNoise),
123
"cover_version" => Some(Self::CoverVersion),
124
+
"content_deleted" => Some(Self::ContentDeleted),
125
"other" => Some(Self::Other),
126
_ => None,
127
}
···
237
.execute(&self.pool)
238
.await?;
239
sqlx::query("ALTER TABLE label_context ADD COLUMN IF NOT EXISTS resolution_notes TEXT")
240
+
.execute(&self.pool)
241
+
.await?;
242
+
243
+
// Sensitive images table for content moderation
244
+
sqlx::query(
245
+
r#"
246
+
CREATE TABLE IF NOT EXISTS sensitive_images (
247
+
id BIGSERIAL PRIMARY KEY,
248
+
image_id TEXT,
249
+
url TEXT,
250
+
reason TEXT,
251
+
flagged_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
252
+
flagged_by TEXT
253
+
)
254
+
"#,
255
+
)
256
+
.execute(&self.pool)
257
+
.await?;
258
+
259
+
sqlx::query("CREATE INDEX IF NOT EXISTS idx_sensitive_images_image_id ON sensitive_images(image_id)")
260
+
.execute(&self.pool)
261
+
.await?;
262
+
sqlx::query("CREATE INDEX IF NOT EXISTS idx_sensitive_images_url ON sensitive_images(url)")
263
+
.execute(&self.pool)
264
+
.await?;
265
+
266
+
// Review batches for mobile-friendly flag review
267
+
sqlx::query(
268
+
r#"
269
+
CREATE TABLE IF NOT EXISTS review_batches (
270
+
id TEXT PRIMARY KEY,
271
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
272
+
expires_at TIMESTAMPTZ,
273
+
status TEXT NOT NULL DEFAULT 'pending',
274
+
created_by TEXT
275
+
)
276
+
"#,
277
+
)
278
+
.execute(&self.pool)
279
+
.await?;
280
+
281
+
// Flags within review batches
282
+
sqlx::query(
283
+
r#"
284
+
CREATE TABLE IF NOT EXISTS batch_flags (
285
+
id BIGSERIAL PRIMARY KEY,
286
+
batch_id TEXT NOT NULL REFERENCES review_batches(id) ON DELETE CASCADE,
287
+
uri TEXT NOT NULL,
288
+
reviewed BOOLEAN NOT NULL DEFAULT FALSE,
289
+
reviewed_at TIMESTAMPTZ,
290
+
decision TEXT,
291
+
UNIQUE(batch_id, uri)
292
+
)
293
+
"#,
294
+
)
295
+
.execute(&self.pool)
296
+
.await?;
297
+
298
+
sqlx::query("CREATE INDEX IF NOT EXISTS idx_batch_flags_batch_id ON batch_flags(batch_id)")
299
.execute(&self.pool)
300
.await?;
301
···
695
.collect();
696
697
Ok(tracks)
698
+
}
699
+
700
+
// -------------------------------------------------------------------------
701
+
// Review batches
702
+
// -------------------------------------------------------------------------
703
+
704
+
/// Create a review batch with the given flags.
705
+
pub async fn create_batch(
706
+
&self,
707
+
id: &str,
708
+
uris: &[String],
709
+
created_by: Option<&str>,
710
+
) -> Result<ReviewBatch, sqlx::Error> {
711
+
let batch = sqlx::query_as::<_, ReviewBatch>(
712
+
r#"
713
+
INSERT INTO review_batches (id, created_by)
714
+
VALUES ($1, $2)
715
+
RETURNING id, created_at, expires_at, status, created_by
716
+
"#,
717
+
)
718
+
.bind(id)
719
+
.bind(created_by)
720
+
.fetch_one(&self.pool)
721
+
.await?;
722
+
723
+
for uri in uris {
724
+
sqlx::query(
725
+
r#"
726
+
INSERT INTO batch_flags (batch_id, uri)
727
+
VALUES ($1, $2)
728
+
ON CONFLICT (batch_id, uri) DO NOTHING
729
+
"#,
730
+
)
731
+
.bind(id)
732
+
.bind(uri)
733
+
.execute(&self.pool)
734
+
.await?;
735
+
}
736
+
737
+
Ok(batch)
738
+
}
739
+
740
+
/// Get a batch by ID.
741
+
pub async fn get_batch(&self, id: &str) -> Result<Option<ReviewBatch>, sqlx::Error> {
742
+
sqlx::query_as::<_, ReviewBatch>(
743
+
r#"
744
+
SELECT id, created_at, expires_at, status, created_by
745
+
FROM review_batches
746
+
WHERE id = $1
747
+
"#,
748
+
)
749
+
.bind(id)
750
+
.fetch_optional(&self.pool)
751
+
.await
752
+
}
753
+
754
+
/// Get all flags in a batch with their context.
755
+
pub async fn get_batch_flags(&self, batch_id: &str) -> Result<Vec<FlaggedTrack>, sqlx::Error> {
756
+
let rows: Vec<FlaggedRow> = sqlx::query_as(
757
+
r#"
758
+
SELECT l.seq, l.uri, l.val, l.cts,
759
+
c.track_id, c.track_title, c.artist_handle, c.artist_did, c.highest_score, c.matches,
760
+
c.resolution_reason, c.resolution_notes
761
+
FROM batch_flags bf
762
+
JOIN labels l ON l.uri = bf.uri AND l.val = 'copyright-violation' AND l.neg = false
763
+
LEFT JOIN label_context c ON l.uri = c.uri
764
+
WHERE bf.batch_id = $1
765
+
ORDER BY l.seq DESC
766
+
"#,
767
+
)
768
+
.bind(batch_id)
769
+
.fetch_all(&self.pool)
770
+
.await?;
771
+
772
+
let batch_uris: Vec<String> = rows.iter().map(|r| r.1.clone()).collect();
773
+
let negated_uris: std::collections::HashSet<String> = if !batch_uris.is_empty() {
774
+
sqlx::query_scalar::<_, String>(
775
+
r#"
776
+
SELECT DISTINCT uri
777
+
FROM labels
778
+
WHERE val = 'copyright-violation' AND neg = true AND uri = ANY($1)
779
+
"#,
780
+
)
781
+
.bind(&batch_uris)
782
+
.fetch_all(&self.pool)
783
+
.await?
784
+
.into_iter()
785
+
.collect()
786
+
} else {
787
+
std::collections::HashSet::new()
788
+
};
789
+
790
+
let tracks = rows
791
+
.into_iter()
792
+
.map(
793
+
|(
794
+
seq,
795
+
uri,
796
+
val,
797
+
cts,
798
+
track_id,
799
+
track_title,
800
+
artist_handle,
801
+
artist_did,
802
+
highest_score,
803
+
matches,
804
+
resolution_reason,
805
+
resolution_notes,
806
+
)| {
807
+
let context = if track_id.is_some()
808
+
|| track_title.is_some()
809
+
|| artist_handle.is_some()
810
+
|| resolution_reason.is_some()
811
+
{
812
+
Some(LabelContext {
813
+
track_id,
814
+
track_title,
815
+
artist_handle,
816
+
artist_did,
817
+
highest_score,
818
+
matches: matches.and_then(|v| serde_json::from_value(v).ok()),
819
+
resolution_reason: resolution_reason
820
+
.and_then(|s| ResolutionReason::from_str(&s)),
821
+
resolution_notes,
822
+
})
823
+
} else {
824
+
None
825
+
};
826
+
827
+
FlaggedTrack {
828
+
seq,
829
+
uri: uri.clone(),
830
+
val,
831
+
created_at: cts.format("%Y-%m-%d %H:%M:%S").to_string(),
832
+
resolved: negated_uris.contains(&uri),
833
+
context,
834
+
}
835
+
},
836
+
)
837
+
.collect();
838
+
839
+
Ok(tracks)
840
+
}
841
+
842
+
/// Update batch status.
843
+
pub async fn update_batch_status(&self, id: &str, status: &str) -> Result<bool, sqlx::Error> {
844
+
let result = sqlx::query("UPDATE review_batches SET status = $1 WHERE id = $2")
845
+
.bind(status)
846
+
.bind(id)
847
+
.execute(&self.pool)
848
+
.await?;
849
+
Ok(result.rows_affected() > 0)
850
+
}
851
+
852
+
/// Mark a flag in a batch as reviewed.
853
+
pub async fn mark_flag_reviewed(
854
+
&self,
855
+
batch_id: &str,
856
+
uri: &str,
857
+
decision: &str,
858
+
) -> Result<bool, sqlx::Error> {
859
+
let result = sqlx::query(
860
+
r#"
861
+
UPDATE batch_flags
862
+
SET reviewed = true, reviewed_at = NOW(), decision = $1
863
+
WHERE batch_id = $2 AND uri = $3
864
+
"#,
865
+
)
866
+
.bind(decision)
867
+
.bind(batch_id)
868
+
.bind(uri)
869
+
.execute(&self.pool)
870
+
.await?;
871
+
Ok(result.rows_affected() > 0)
872
+
}
873
+
874
+
/// Get pending (non-reviewed) flags from a batch.
875
+
pub async fn get_batch_pending_uris(&self, batch_id: &str) -> Result<Vec<String>, sqlx::Error> {
876
+
sqlx::query_scalar::<_, String>(
877
+
r#"
878
+
SELECT uri FROM batch_flags
879
+
WHERE batch_id = $1 AND reviewed = false
880
+
"#,
881
+
)
882
+
.bind(batch_id)
883
+
.fetch_all(&self.pool)
884
+
.await
885
+
}
886
+
887
+
// -------------------------------------------------------------------------
888
+
// Sensitive images
889
+
// -------------------------------------------------------------------------
890
+
891
+
/// Get all sensitive images.
892
+
pub async fn get_sensitive_images(&self) -> Result<Vec<SensitiveImageRow>, sqlx::Error> {
893
+
sqlx::query_as::<_, SensitiveImageRow>(
894
+
"SELECT id, image_id, url, reason, flagged_at, flagged_by FROM sensitive_images ORDER BY flagged_at DESC",
895
+
)
896
+
.fetch_all(&self.pool)
897
+
.await
898
+
}
899
+
900
+
/// Add a sensitive image entry.
901
+
pub async fn add_sensitive_image(
902
+
&self,
903
+
image_id: Option<&str>,
904
+
url: Option<&str>,
905
+
reason: Option<&str>,
906
+
flagged_by: Option<&str>,
907
+
) -> Result<i64, sqlx::Error> {
908
+
sqlx::query_scalar::<_, i64>(
909
+
r#"
910
+
INSERT INTO sensitive_images (image_id, url, reason, flagged_by)
911
+
VALUES ($1, $2, $3, $4)
912
+
RETURNING id
913
+
"#,
914
+
)
915
+
.bind(image_id)
916
+
.bind(url)
917
+
.bind(reason)
918
+
.bind(flagged_by)
919
+
.fetch_one(&self.pool)
920
+
.await
921
+
}
922
+
923
+
/// Remove a sensitive image entry by ID.
924
+
pub async fn remove_sensitive_image(&self, id: i64) -> Result<bool, sqlx::Error> {
925
+
let result = sqlx::query("DELETE FROM sensitive_images WHERE id = $1")
926
+
.bind(id)
927
+
.execute(&self.pool)
928
+
.await?;
929
+
Ok(result.rows_affected() > 0)
930
}
931
}
932
+26
moderation/src/handlers.rs
+26
moderation/src/handlers.rs
···
63
pub label: Label,
64
}
65
66
+
/// Response for sensitive images endpoint.
67
+
#[derive(Debug, Serialize)]
68
+
pub struct SensitiveImagesResponse {
69
+
/// R2 image IDs (for track/album artwork)
70
+
pub image_ids: Vec<String>,
71
+
/// Full URLs (for external images like avatars)
72
+
pub urls: Vec<String>,
73
+
}
74
+
75
// --- handlers ---
76
77
/// Health check endpoint.
···
215
}
216
217
Ok(Json(EmitLabelResponse { seq, label }))
218
+
}
219
+
220
+
/// Get all sensitive images (public endpoint).
221
+
///
222
+
/// Returns image_ids (R2 storage IDs) and urls (full URLs) for all flagged images.
223
+
/// Clients should check both lists when determining if an image is sensitive.
224
+
pub async fn get_sensitive_images(
225
+
State(state): State<AppState>,
226
+
) -> Result<Json<SensitiveImagesResponse>, AppError> {
227
+
let db = state.db.as_ref().ok_or(AppError::LabelerNotConfigured)?;
228
+
229
+
let images = db.get_sensitive_images().await?;
230
+
231
+
let image_ids: Vec<String> = images.iter().filter_map(|i| i.image_id.clone()).collect();
232
+
let urls: Vec<String> = images.iter().filter_map(|i| i.url.clone()).collect();
233
+
234
+
Ok(Json(SensitiveImagesResponse { image_ids, urls }))
235
}
236
237
#[cfg(test)]
+13
moderation/src/main.rs
+13
moderation/src/main.rs
···
25
mod db;
26
mod handlers;
27
mod labels;
28
mod state;
29
mod xrpc;
30
···
72
.route("/", get(handlers::landing))
73
// Health check
74
.route("/health", get(handlers::health))
75
// AuDD scanning
76
.route("/scan", post(audd::scan))
77
// Label emission (internal API)
···
84
.route("/admin/resolve-htmx", post(admin::resolve_flag_htmx))
85
.route("/admin/context", post(admin::store_context))
86
.route("/admin/active-labels", post(admin::get_active_labels))
87
// Static files (CSS, JS for admin UI)
88
.nest_service("/static", ServeDir::new("static"))
89
// ATProto XRPC endpoints (public)
···
25
mod db;
26
mod handlers;
27
mod labels;
28
+
mod review;
29
mod state;
30
mod xrpc;
31
···
73
.route("/", get(handlers::landing))
74
// Health check
75
.route("/health", get(handlers::health))
76
+
// Sensitive images (public)
77
+
.route("/sensitive-images", get(handlers::get_sensitive_images))
78
// AuDD scanning
79
.route("/scan", post(audd::scan))
80
// Label emission (internal API)
···
87
.route("/admin/resolve-htmx", post(admin::resolve_flag_htmx))
88
.route("/admin/context", post(admin::store_context))
89
.route("/admin/active-labels", post(admin::get_active_labels))
90
+
.route("/admin/sensitive-images", post(admin::add_sensitive_image))
91
+
.route(
92
+
"/admin/sensitive-images/remove",
93
+
post(admin::remove_sensitive_image),
94
+
)
95
+
.route("/admin/batches", post(admin::create_batch))
96
+
// Review endpoints (under admin, auth protected)
97
+
.route("/admin/review/:id", get(review::review_page))
98
+
.route("/admin/review/:id/data", get(review::review_data))
99
+
.route("/admin/review/:id/submit", post(review::submit_review))
100
// Static files (CSS, JS for admin UI)
101
.nest_service("/static", ServeDir::new("static"))
102
// ATProto XRPC endpoints (public)
+526
moderation/src/review.rs
+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
#[error("labeler not configured")]
33
LabelerNotConfigured,
34
35
#[error("label error: {0}")]
36
Label(#[from] LabelError),
37
···
50
AppError::LabelerNotConfigured => {
51
(StatusCode::SERVICE_UNAVAILABLE, "LabelerNotConfigured")
52
}
53
AppError::Label(_) => (StatusCode::INTERNAL_SERVER_ERROR, "LabelError"),
54
AppError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "DatabaseError"),
55
AppError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, "IoError"),
···
32
#[error("labeler not configured")]
33
LabelerNotConfigured,
34
35
+
#[error("bad request: {0}")]
36
+
BadRequest(String),
37
+
38
+
#[error("not found: {0}")]
39
+
NotFound(String),
40
+
41
#[error("label error: {0}")]
42
Label(#[from] LabelError),
43
···
56
AppError::LabelerNotConfigured => {
57
(StatusCode::SERVICE_UNAVAILABLE, "LabelerNotConfigured")
58
}
59
+
AppError::BadRequest(_) => (StatusCode::BAD_REQUEST, "BadRequest"),
60
+
AppError::NotFound(_) => (StatusCode::NOT_FOUND, "NotFound"),
61
AppError::Label(_) => (StatusCode::INTERNAL_SERVER_ERROR, "LabelError"),
62
AppError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "DatabaseError"),
63
AppError::Io(_) => (StatusCode::INTERNAL_SERVER_ERROR, "IoError"),
+6
moderation/static/admin.css
+6
moderation/static/admin.css
+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
AUDD_COST_PER_1000 = 5.00 # $5 per 1000 requests
36
AUDD_BASE_COST = 5.00 # $5/month base
37
38
-
# fixed monthly costs (updated 2025-12-16)
39
# fly.io: manually updated from cost explorer (TODO: use fly billing API)
40
# neon: fixed $5/month
41
# cloudflare: mostly free tier
42
FIXED_COSTS = {
43
"fly_io": {
44
"breakdown": {
···
116
import asyncpg
117
118
billing_start = get_billing_period_start()
119
120
conn = await asyncpg.connect(db_url)
121
try:
122
# get totals: scans, flagged, and derived API requests from duration
123
row = await conn.fetchrow(
124
"""
125
SELECT
···
139
total_requests = row["total_requests"]
140
total_seconds = row["total_seconds"]
141
142
-
# daily breakdown for chart - now includes requests derived from duration
143
daily = await conn.fetch(
144
"""
145
SELECT
···
153
GROUP BY DATE(cs.scanned_at)
154
ORDER BY date
155
""",
156
-
billing_start,
157
AUDD_SECONDS_PER_REQUEST,
158
)
159
···
35
AUDD_COST_PER_1000 = 5.00 # $5 per 1000 requests
36
AUDD_BASE_COST = 5.00 # $5/month base
37
38
+
# fixed monthly costs (updated 2025-12-26)
39
# fly.io: manually updated from cost explorer (TODO: use fly billing API)
40
# neon: fixed $5/month
41
# cloudflare: mostly free tier
42
+
# redis: self-hosted on fly (included in fly_io costs)
43
FIXED_COSTS = {
44
"fly_io": {
45
"breakdown": {
···
117
import asyncpg
118
119
billing_start = get_billing_period_start()
120
+
# 30 days of history for the daily chart (independent of billing cycle)
121
+
history_start = datetime.now() - timedelta(days=30)
122
123
conn = await asyncpg.connect(db_url)
124
try:
125
# get totals: scans, flagged, and derived API requests from duration
126
+
# uses billing period for accurate cost calculation
127
row = await conn.fetchrow(
128
"""
129
SELECT
···
143
total_requests = row["total_requests"]
144
total_seconds = row["total_seconds"]
145
146
+
# daily breakdown for chart - 30 days of history for flexible views
147
daily = await conn.fetch(
148
"""
149
SELECT
···
157
GROUP BY DATE(cs.scanned_at)
158
ORDER BY date
159
""",
160
+
history_start,
161
AUDD_SECONDS_PER_REQUEST,
162
)
163
+4
-6
scripts/docket_runs.py
+4
-6
scripts/docket_runs.py
···
38
url = os.environ.get("DOCKET_URL_STAGING")
39
if not url:
40
print("error: DOCKET_URL_STAGING not set")
41
-
print(
42
-
"hint: export DOCKET_URL_STAGING=rediss://default:xxx@xxx.upstash.io:6379"
43
-
)
44
return 1
45
elif args.env == "production":
46
url = os.environ.get("DOCKET_URL_PRODUCTION")
47
if not url:
48
print("error: DOCKET_URL_PRODUCTION not set")
49
-
print(
50
-
"hint: export DOCKET_URL_PRODUCTION=rediss://default:xxx@xxx.upstash.io:6379"
51
-
)
52
return 1
53
54
print(f"connecting to {args.env}...")
···
38
url = os.environ.get("DOCKET_URL_STAGING")
39
if not url:
40
print("error: DOCKET_URL_STAGING not set")
41
+
print("hint: flyctl proxy 6380:6379 -a plyr-redis-stg")
42
+
print(" export DOCKET_URL_STAGING=redis://localhost:6380")
43
return 1
44
elif args.env == "production":
45
url = os.environ.get("DOCKET_URL_PRODUCTION")
46
if not url:
47
print("error: DOCKET_URL_PRODUCTION not set")
48
+
print("hint: flyctl proxy 6381:6379 -a plyr-redis")
49
+
print(" export DOCKET_URL_PRODUCTION=redis://localhost:6381")
50
return 1
51
52
print(f"connecting to {args.env}...")
+229
scripts/migrate_sensitive_images.py
+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.