+65
-22
.claude/commands/status-update.md
+65
-22
.claude/commands/status-update.md
···
1
1
# status update
2
2
3
-
update STATUS.md after completing significant work.
3
+
update STATUS.md to reflect recent work.
4
4
5
-
## when to update
5
+
## workflow
6
+
7
+
### 1. understand current state
6
8
7
-
after shipping something notable:
8
-
- new features or endpoints
9
-
- bug fixes worth documenting
10
-
- architectural changes
11
-
- deployment/infrastructure changes
12
-
- incidents and their resolutions
9
+
read STATUS.md to understand:
10
+
- what's already documented in `## recent work`
11
+
- the last update date (noted at the bottom)
12
+
- current priorities and known issues
13
+
14
+
### 2. find undocumented work
15
+
16
+
```bash
17
+
# find the last STATUS.md update
18
+
git log --oneline -1 -- STATUS.md
19
+
20
+
# get all commits since then
21
+
git log --oneline <last-status-commit>..HEAD
22
+
```
23
+
24
+
for each significant commit or PR:
25
+
- read the commit message and changed files
26
+
- understand WHY the change was made, not just what changed
27
+
- note architectural decisions, trade-offs, or lessons learned
28
+
29
+
### 3. decide what to document
30
+
31
+
not everything needs documentation. focus on:
32
+
- **features**: new capabilities users or developers can use
33
+
- **fixes**: bugs that affected users, especially if they might recur
34
+
- **architecture**: changes to how systems connect or data flows
35
+
- **decisions**: trade-offs made and why (future readers need context)
36
+
- **incidents**: what broke, why, and how it was resolved
37
+
38
+
skip:
39
+
- routine maintenance (dependency bumps, typo fixes)
40
+
- work-in-progress that didn't ship
41
+
- changes already well-documented in the PR
42
+
43
+
### 4. write the update
44
+
45
+
add a new subsection under `## recent work` following existing patterns:
13
46
14
-
**tip**: after running `/deploy`, consider running `/status-update` to document what shipped.
47
+
```markdown
48
+
#### brief title (PRs #NNN, date)
15
49
16
-
## how to update
50
+
**why**: the problem or motivation (1-2 sentences)
17
51
18
-
1. add a new subsection under `## recent work` with today's date
19
-
2. describe what shipped, why it matters, and any relevant PR numbers
20
-
3. update `## immediate priorities` if priorities changed
21
-
4. update `## technical state` if architecture changed
52
+
**what shipped**:
53
+
- concrete changes users or developers will notice
54
+
- link to relevant docs if applicable
22
55
23
-
## structure
56
+
**technical notes** (if architectural):
57
+
- decisions made and why
58
+
- trade-offs accepted
59
+
```
24
60
25
-
STATUS.md follows this structure:
26
-
- **long-term vision** - why the project exists
27
-
- **recent work** - chronological log of what shipped (newest first)
28
-
- **immediate priorities** - what's next
29
-
- **technical state** - architecture, what's working, known issues
61
+
### 5. update other sections if needed
30
62
31
-
old content is automatically archived to `.status_history/` - you don't need to manage this.
63
+
- `## priorities` - if focus has shifted
64
+
- `## known issues` - if bugs were fixed or discovered
65
+
- `## technical state` - if architecture changed
32
66
33
67
## tone
34
68
35
-
direct, technical, honest about limitations. useful for someone with no prior context.
69
+
write for someone with no prior context who needs to understand:
70
+
- what changed
71
+
- why it matters
72
+
- why this approach was chosen over alternatives
73
+
74
+
be direct and technical. avoid marketing language.
75
+
76
+
## after updating
77
+
78
+
commit the STATUS.md changes and open a PR for review.
+8
-2
.github/workflows/status-maintenance.yml
+8
-2
.github/workflows/status-maintenance.yml
···
65
65
```bash
66
66
date
67
67
# get the most recently merged status-maintenance PR (filter by branch name, sort by merge date)
68
-
gh pr list --state merged --search "status-maintenance" --limit 20 --json number,title,mergedAt,headRefName | jq '[.[] | select(.headRefName | startswith("status-maintenance-"))] | sort_by(.mergedAt) | reverse | .[0]'
68
+
# NOTE: excluding #724 which was reverted - remove this exclusion after next successful run
69
+
gh pr list --state merged --search "status-maintenance" --limit 20 --json number,title,mergedAt,headRefName | jq '[.[] | select(.headRefName | startswith("status-maintenance-")) | select(.number != 724)] | sort_by(.mergedAt) | reverse | .[0]'
69
70
git log --oneline -50
70
71
ls -la .status_history/ 2>/dev/null || echo "no archive directory yet"
71
72
wc -l STATUS.md
···
212
213
the TTS engine will mispronounce "plyr" as "plir" or "p-l-y-r" if you write it that way.
213
214
write phonetically for correct pronunciation: "player FM", "player dot FM".
214
215
216
+
### terminology
217
+
218
+
plyr.fm is built on **ATProto** (the protocol), not Bluesky (the app).
219
+
say "ATProto identities" or just "identities" - never "Bluesky accounts".
220
+
215
221
### identifying what actually shipped
216
222
217
223
read the commit messages and PR bodies carefully to understand what changed.
···
293
299
fi
294
300
295
301
echo "Uploading as: $TITLE"
296
-
uv run --with plyrfm -- plyrfm upload update.wav "$TITLE" --album "$YEAR" -t '["ai"]'
302
+
uv run --with plyrfm -- plyrfm upload update.wav "$TITLE" --album "$YEAR" -t "ai"
297
303
env:
298
304
PLYR_TOKEN: ${{ secrets.PLYR_BOT_TOKEN }}
+163
.status_history/2025-12.md
+163
.status_history/2025-12.md
···
606
606
**documentation** (PR #514):
607
607
- added lexicons overview documentation at `docs/lexicons/overview.md`
608
608
- covers `fm.plyr.track`, `fm.plyr.like`, `fm.plyr.comment`, `fm.plyr.list`, `fm.plyr.actor.profile`
609
+
610
+
---
611
+
612
+
## Late December 2025 Work (Dec 17-31)
613
+
614
+
### offline mode foundation (PRs #610-611, Dec 17)
615
+
616
+
**experimental offline playback**:
617
+
- storage layer using Cache API for audio bytes + IndexedDB for metadata
618
+
- `GET /audio/{file_id}/url` backend endpoint returns direct R2 URLs for client-side caching
619
+
- "auto-download liked" toggle in experimental settings section
620
+
- Player checks for cached audio before streaming from R2
621
+
622
+
---
623
+
624
+
### UX polish (PRs #604-607, #613, #615, Dec 16-18)
625
+
626
+
**login improvements** (PRs #604, #613):
627
+
- login page now uses "internet handle" terminology for clarity
628
+
- input normalization: strips `@` and `at://` prefixes automatically
629
+
630
+
**artist page fixes** (PR #615):
631
+
- track pagination on artist pages now works correctly
632
+
- fixed mobile album card overflow
633
+
634
+
**mobile + metadata** (PRs #605-607):
635
+
- Open Graph tags added to tag detail pages for link previews
636
+
- mobile modals now use full screen positioning
637
+
- fixed `/tag/` routes in hasPageMetadata check
638
+
639
+
---
640
+
641
+
### beartype + moderation cleanup (PRs #617-619, Dec 19)
642
+
643
+
**runtime type checking** (PR #619):
644
+
- enabled beartype runtime type validation across the backend
645
+
- catches type errors at runtime instead of silently passing bad data
646
+
- test infrastructure improvements: session-scoped TestClient fixture (5x faster tests)
647
+
648
+
**moderation cleanup** (PRs #617-618):
649
+
- consolidated moderation code, addressing issues #541-543
650
+
- `sync_copyright_resolutions` now runs automatically via docket Perpetual task
651
+
- removed dead `init_db()` from lifespan (handled by alembic migrations)
652
+
653
+
---
654
+
655
+
### end-of-year sprint (PR #626, Dec 20)
656
+
657
+
**focus**: two foundational systems with experimental implementations.
658
+
659
+
| track | focus | status |
660
+
|-------|-------|--------|
661
+
| moderation | consolidate architecture, batch review, Claude vision | shipped |
662
+
| atprotofans | supporter validation, content gating | shipped |
663
+
664
+
**research docs**:
665
+
- [moderation architecture overhaul](docs/research/2025-12-20-moderation-architecture-overhaul.md)
666
+
- [atprotofans paywall integration](docs/research/2025-12-20-atprotofans-paywall-integration.md)
667
+
668
+
---
669
+
670
+
### rate limit moderation endpoint (PR #629, Dec 21)
671
+
672
+
**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. this was the first real probe of our moderation endpoints, validating the decision to add rate limiting before it became a problem.
673
+
674
+
---
675
+
676
+
### supporter badges (PR #627, Dec 21-22)
677
+
678
+
**phase 1 of atprotofans integration**:
679
+
- supporter badge displays on artist pages when logged-in viewer supports the artist
680
+
- calls atprotofans `validateSupporter` API directly from frontend (public endpoint)
681
+
- badge only shows when viewer is authenticated and not viewing their own profile
682
+
683
+
---
684
+
685
+
### supporter-gated content (PR #637, Dec 22-23)
686
+
687
+
**atprotofans paywall integration** - artists can now mark tracks as "supporters only":
688
+
- tracks with `support_gate` require atprotofans validation before playback
689
+
- non-supporters see lock icon and "become a supporter" CTA linking to atprotofans
690
+
- artists can always play their own gated tracks
691
+
692
+
**backend architecture**:
693
+
- audio endpoint validates supporter status via atprotofans API before serving gated content
694
+
- HEAD requests return 200/401/402 for pre-flight auth checks (avoids CORS issues with cross-origin redirects)
695
+
- gated files stored in private R2 bucket, served via presigned URLs (SigV4 signatures)
696
+
- `R2Storage.move_audio()` moves files between public/private buckets when toggling gate
697
+
- background task handles bucket migration asynchronously
698
+
- ATProto record syncs when toggling gate (updates `supportGate` field and `audioUrl` to point at our endpoint instead of R2)
699
+
700
+
**frontend**:
701
+
- `playback.svelte.ts` guards queue operations with gated checks BEFORE modifying state
702
+
- clicking locked track shows toast with CTA - does NOT interrupt current playback
703
+
- portal shows support gate toggle in track edit UI
704
+
705
+
**key decision**: gated status is resolved server-side in track listings, not client-side. this means the lock icon appears instantly without additional API calls, and prevents information leakage about which tracks are gated vs which the user simply can't access.
706
+
707
+
---
708
+
709
+
### CSS design tokens (PRs #662-664, Dec 29-30)
710
+
711
+
**design system foundations**:
712
+
- border-radius tokens (`--radius-sm`, `--radius-md`, etc.)
713
+
- typography scale tokens
714
+
- consolidated form styles
715
+
- documented in `docs/frontend/design-tokens.md`
716
+
717
+
---
718
+
719
+
### self-hosted redis (PRs #674-675, Dec 30)
720
+
721
+
**replaced Upstash with self-hosted Redis on Fly.io** - ~$75/month โ ~$4/month:
722
+
- Upstash pay-as-you-go was charging per command (37M commands = $75) - discovered when reviewing December costs
723
+
- docket's heartbeat mechanism is chatty by design, making pay-per-command pricing unsuitable
724
+
- self-hosted Redis on 256MB Fly VMs costs fixed ~$2/month per environment
725
+
- deployed `plyr-redis` (prod) and `plyr-redis-stg` (staging)
726
+
- added CI workflow for redis deployments on merge
727
+
728
+
**no state migration needed** - docket stores ephemeral task queue data, job progress lives in postgres.
729
+
730
+
**incident (Dec 30)**: while optimizing redis overhead, a `heartbeat_interval=30s` change broke docket task execution. likes created Dec 29-30 were missing ATProto records. reverted in PR #669, documented in `docs/backend/background-tasks.md`. filed upstream: https://github.com/chrisguidry/docket/issues/267
731
+
732
+
---
733
+
734
+
### batch review system (PR #672, Dec 30)
735
+
736
+
**moderation batch review UI** - mobile-friendly interface for reviewing flagged content:
737
+
- filter by flag status, paginated results
738
+
- auto-resolve flags for deleted tracks (PR #681)
739
+
- full URL in DM notifications (PR #678)
740
+
- required auth flow fix (PR #679) - review page was accessible without login
741
+
742
+
---
743
+
744
+
### top tracks homepage (PR #684, Dec 31)
745
+
746
+
**homepage now shows top tracks** - quick access to popular content for new visitors.
747
+
748
+
---
749
+
750
+
### avatar sync on login (PR #685, Dec 31)
751
+
752
+
**avatars now stay fresh** - previously set once at artist creation, causing stale/broken avatars throughout the app:
753
+
- on login, avatar is refreshed from Bluesky and synced to both postgres and ATProto profile record
754
+
- added `avatar` field to `fm.plyr.actor.profile` lexicon (optional, URI format)
755
+
- one-time backfill script (`scripts/backfill_avatars.py`) refreshed 28 stale avatars in production
756
+
757
+
---
758
+
759
+
### automated image moderation (PRs #687-690, Dec 31)
760
+
761
+
**Claude vision integration** for sensitive image detection:
762
+
- images analyzed on upload via Claude Sonnet 4.5 (had to fix model ID - was using wrong identifier)
763
+
- flagged images trigger DM notifications to admin
764
+
- non-false-positive flags sent to batch review queue
765
+
- complements the batch review system built earlier in the sprint
766
+
767
+
---
768
+
769
+
### header redesign (PR #691, Dec 31)
770
+
771
+
**new header layout** with UserMenu dropdown and even spacing across the top bar.
+37
-167
STATUS.md
+37
-167
STATUS.md
···
49
49
50
50
#### multi-account experience (PRs #707, #710, #712-714, Jan 3-5)
51
51
52
-
**why**: many users have multiple Bluesky identities (personal, artist, label). forcing re-authentication to switch was friction that discouraged uploads from secondary accounts.
52
+
**why**: many users have multiple ATProto identities (personal, artist, label). forcing re-authentication to switch was friction that discouraged uploads from secondary accounts.
53
53
54
-
**users can now link multiple Bluesky accounts** to a single browser session:
54
+
**users can now link multiple identities** to a single browser session:
55
55
- add additional accounts via "add account" in user menu (triggers OAuth with `prompt=login`)
56
56
- switch between linked accounts instantly without re-authenticating
57
57
- logout from individual accounts or all at once
···
76
76
- fixed `invalidateAll()` not refreshing client-side loaded data by using `window.location.reload()` (PR #713)
77
77
78
78
**docs**: [research/2026-01-03-multi-account-experience.md](docs/research/2026-01-03-multi-account-experience.md)
79
+
80
+
---
81
+
82
+
#### auth stabilization (PRs #734-736, Jan 6-7)
83
+
84
+
**why**: multi-account support introduced edge cases where auth state could become inconsistent between frontend components, and sessions could outlive their refresh tokens.
85
+
86
+
**session expiry alignment** (PR #734):
87
+
- sessions now track refresh token lifetime and respect it during validation
88
+
- prevents sessions from appearing valid after their underlying OAuth grant expires
89
+
- dev token expiration handling aligned with same pattern
90
+
91
+
**queue auth boundary fix** (PR #735):
92
+
- queue component now uses shared layout auth state instead of localStorage session IDs
93
+
- fixes race condition where queue could attempt authenticated requests before layout resolved auth
94
+
- ensures remote queue snapshots don't inherit local update flags during hydration
95
+
96
+
**playlist cover upload fix** (PR #736):
97
+
- `R2Storage.save()` was rejecting `BytesIO` objects due to beartype's strict `BinaryIO` protocol checking
98
+
- changed type hint to `BinaryIO | BytesIO` to explicitly accept both
99
+
- found via Logfire: only 2 failures in production, both on Jan 3
79
100
80
101
---
81
102
···
137
158
138
159
### December 2025
139
160
140
-
#### header redesign (PR #691, Dec 31)
141
-
142
-
**new header layout** with UserMenu dropdown and even spacing across the top bar.
143
-
144
-
---
145
-
146
-
#### automated image moderation (PRs #687-690, Dec 31)
147
-
148
-
**Claude vision integration** for sensitive image detection:
149
-
- images analyzed on upload via Claude Sonnet 4.5 (had to fix model ID - was using wrong identifier)
150
-
- flagged images trigger DM notifications to admin
151
-
- non-false-positive flags sent to batch review queue
152
-
- complements the batch review system built earlier in the sprint
153
-
154
-
---
155
-
156
-
#### avatar sync on login (PR #685, Dec 31)
157
-
158
-
**avatars now stay fresh** - previously set once at artist creation, causing stale/broken avatars throughout the app:
159
-
- on login, avatar is refreshed from Bluesky and synced to both postgres and ATProto profile record
160
-
- added `avatar` field to `fm.plyr.actor.profile` lexicon (optional, URI format)
161
-
- one-time backfill script (`scripts/backfill_avatars.py`) refreshed 28 stale avatars in production
162
-
163
-
---
164
-
165
-
#### top tracks homepage (PR #684, Dec 31)
166
-
167
-
**homepage now shows top tracks** - quick access to popular content for new visitors.
168
-
169
-
---
170
-
171
-
#### batch review system (PR #672, Dec 30)
172
-
173
-
**moderation batch review UI** - mobile-friendly interface for reviewing flagged content:
174
-
- filter by flag status, paginated results
175
-
- auto-resolve flags for deleted tracks (PR #681)
176
-
- full URL in DM notifications (PR #678)
177
-
- required auth flow fix (PR #679) - review page was accessible without login
178
-
179
-
---
180
-
181
-
#### CSS design tokens (PRs #662-664, Dec 29-30)
182
-
183
-
**design system foundations**:
184
-
- border-radius tokens (`--radius-sm`, `--radius-md`, etc.)
185
-
- typography scale tokens
186
-
- consolidated form styles
187
-
- documented in `docs/frontend/design-tokens.md`
188
-
189
-
---
190
-
191
-
#### self-hosted redis (PRs #674-675, Dec 30)
192
-
193
-
**replaced Upstash with self-hosted Redis on Fly.io** - ~$75/month โ ~$4/month:
194
-
- Upstash pay-as-you-go was charging per command (37M commands = $75) - discovered when reviewing December costs
195
-
- docket's heartbeat mechanism is chatty by design, making pay-per-command pricing unsuitable
196
-
- self-hosted Redis on 256MB Fly VMs costs fixed ~$2/month per environment
197
-
- deployed `plyr-redis` (prod) and `plyr-redis-stg` (staging)
198
-
- added CI workflow for redis deployments on merge
199
-
200
-
**no state migration needed** - docket stores ephemeral task queue data, job progress lives in postgres.
201
-
202
-
**incident (Dec 30)**: while optimizing redis overhead, a `heartbeat_interval=30s` change broke docket task execution. likes created Dec 29-30 were missing ATProto records. reverted in PR #669, documented in `docs/backend/background-tasks.md`. filed upstream: https://github.com/chrisguidry/docket/issues/267
203
-
204
-
---
205
-
206
-
#### supporter-gated content (PR #637, Dec 22-23)
207
-
208
-
**atprotofans paywall integration** - artists can now mark tracks as "supporters only":
209
-
- tracks with `support_gate` require atprotofans validation before playback
210
-
- non-supporters see lock icon and "become a supporter" CTA linking to atprotofans
211
-
- artists can always play their own gated tracks
212
-
213
-
**backend architecture**:
214
-
- audio endpoint validates supporter status via atprotofans API before serving gated content
215
-
- HEAD requests return 200/401/402 for pre-flight auth checks (avoids CORS issues with cross-origin redirects)
216
-
- gated files stored in private R2 bucket, served via presigned URLs (SigV4 signatures)
217
-
- `R2Storage.move_audio()` moves files between public/private buckets when toggling gate
218
-
- background task handles bucket migration asynchronously
219
-
- ATProto record syncs when toggling gate (updates `supportGate` field and `audioUrl` to point at our endpoint instead of R2)
220
-
221
-
**frontend**:
222
-
- `playback.svelte.ts` guards queue operations with gated checks BEFORE modifying state
223
-
- clicking locked track shows toast with CTA - does NOT interrupt current playback
224
-
- portal shows support gate toggle in track edit UI
225
-
226
-
**key decision**: gated status is resolved server-side in track listings, not client-side. this means the lock icon appears instantly without additional API calls, and prevents information leakage about which tracks are gated vs which the user simply can't access.
227
-
228
-
---
229
-
230
-
#### supporter badges (PR #627, Dec 21-22)
231
-
232
-
**phase 1 of atprotofans integration**:
233
-
- supporter badge displays on artist pages when logged-in viewer supports the artist
234
-
- calls atprotofans `validateSupporter` API directly from frontend (public endpoint)
235
-
- badge only shows when viewer is authenticated and not viewing their own profile
236
-
237
-
---
238
-
239
-
#### rate limit moderation endpoint (PR #629, Dec 21)
240
-
241
-
**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. this was the first real probe of our moderation endpoints, validating the decision to add rate limiting before it became a problem.
242
-
243
-
---
244
-
245
-
#### end-of-year sprint (PR #626, Dec 20)
246
-
247
-
**focus**: two foundational systems with experimental implementations.
248
-
249
-
| track | focus | status |
250
-
|-------|-------|--------|
251
-
| moderation | consolidate architecture, batch review, Claude vision | shipped |
252
-
| atprotofans | supporter validation, content gating | shipped |
253
-
254
-
**research docs**:
255
-
- [moderation architecture overhaul](docs/research/2025-12-20-moderation-architecture-overhaul.md)
256
-
- [atprotofans paywall integration](docs/research/2025-12-20-atprotofans-paywall-integration.md)
257
-
258
-
---
259
-
260
-
#### beartype + moderation cleanup (PRs #617-619, Dec 19)
261
-
262
-
**runtime type checking** (PR #619):
263
-
- enabled beartype runtime type validation across the backend
264
-
- catches type errors at runtime instead of silently passing bad data
265
-
- test infrastructure improvements: session-scoped TestClient fixture (5x faster tests)
266
-
267
-
**moderation cleanup** (PRs #617-618):
268
-
- consolidated moderation code, addressing issues #541-543
269
-
- `sync_copyright_resolutions` now runs automatically via docket Perpetual task
270
-
- removed dead `init_db()` from lifespan (handled by alembic migrations)
271
-
272
-
---
273
-
274
-
#### UX polish (PRs #604-607, #613, #615, Dec 16-18)
275
-
276
-
**login improvements** (PRs #604, #613):
277
-
- login page now uses "internet handle" terminology for clarity
278
-
- input normalization: strips `@` and `at://` prefixes automatically
279
-
280
-
**artist page fixes** (PR #615):
281
-
- track pagination on artist pages now works correctly
282
-
- fixed mobile album card overflow
283
-
284
-
**mobile + metadata** (PRs #605-607):
285
-
- Open Graph tags added to tag detail pages for link previews
286
-
- mobile modals now use full screen positioning
287
-
- fixed `/tag/` routes in hasPageMetadata check
288
-
289
-
---
290
-
291
-
#### offline mode foundation (PRs #610-611, Dec 17)
292
-
293
-
**experimental offline playback**:
294
-
- storage layer using Cache API for audio bytes + IndexedDB for metadata
295
-
- `GET /audio/{file_id}/url` backend endpoint returns direct R2 URLs for client-side caching
296
-
- "auto-download liked" toggle in experimental settings section
297
-
- Player checks for cached audio before streaming from R2
298
-
299
-
---
300
-
301
-
### Earlier December 2025
302
-
303
161
See `.status_history/2025-12.md` for detailed history including:
162
+
- header redesign and UI polish (PRs #691-693, Dec 31)
163
+
- automated image moderation with Claude vision (PRs #687-690, Dec 31)
164
+
- avatar sync on login (PR #685, Dec 31)
165
+
- top tracks homepage (PR #684, Dec 31)
166
+
- batch review system (PR #672, Dec 30)
167
+
- CSS design tokens (PRs #662-664, Dec 29-30)
168
+
- self-hosted redis migration (PRs #674-675, Dec 30)
169
+
- supporter-gated content (PR #637, Dec 22-23)
170
+
- supporter badges (PR #627, Dec 21-22)
171
+
- end-of-year sprint: moderation + atprotofans (PRs #617-629, Dec 19-21)
172
+
- offline mode foundation (PRs #610-611, Dec 17)
173
+
- UX polish and login improvements (PRs #604-615, Dec 16-18)
304
174
- visual customization with custom backgrounds (PRs #595-596, Dec 16)
305
175
- performance & moderation polish (PRs #586-593, Dec 14-15)
306
176
- mobile UI polish & background task expansion (PRs #558-572, Dec 10-12)
···
378
248
379
249
**core functionality**
380
250
- โ
ATProto OAuth 2.1 authentication
381
-
- โ
multi-account support (link multiple Bluesky accounts)
251
+
- โ
multi-account support (link multiple ATProto identities)
382
252
- โ
secure session management via HttpOnly cookies
383
253
- โ
developer tokens with independent OAuth grants
384
254
- โ
platform stats and Media Session API
···
498
368
499
369
---
500
370
501
-
this is a living document. last updated 2026-01-05.
371
+
this is a living document. last updated 2026-01-07.
+15
backend/src/backend/_internal/atproto/client.py
+15
backend/src/backend/_internal/atproto/client.py
···
3
3
import asyncio
4
4
import json
5
5
import logging
6
+
from datetime import UTC, datetime, timedelta
6
7
from typing import Any
7
8
8
9
from atproto_oauth.models import OAuthSession
···
10
11
11
12
from backend._internal import Session as AuthSession
12
13
from backend._internal import get_oauth_client, get_session, update_session_tokens
14
+
from backend._internal.auth import (
15
+
get_client_auth_method,
16
+
get_refresh_token_lifetime_days,
17
+
)
13
18
14
19
logger = logging.getLogger(__name__)
15
20
···
123
128
"dpop_authserver_nonce": refreshed_session.dpop_authserver_nonce,
124
129
"dpop_pds_nonce": refreshed_session.dpop_pds_nonce or "",
125
130
}
131
+
client_auth_method = get_client_auth_method(updated_oauth_data)
132
+
refresh_lifetime_days = get_refresh_token_lifetime_days(client_auth_method)
133
+
refresh_expires_at = datetime.now(UTC) + timedelta(
134
+
days=refresh_lifetime_days
135
+
)
136
+
updated_session_data["client_auth_method"] = client_auth_method
137
+
updated_session_data["refresh_token_lifetime_days"] = refresh_lifetime_days
138
+
updated_session_data["refresh_token_expires_at"] = (
139
+
refresh_expires_at.isoformat()
140
+
)
126
141
127
142
# update session in database
128
143
await update_session_tokens(session_id, updated_session_data)
+113
-9
backend/src/backend/_internal/auth.py
+113
-9
backend/src/backend/_internal/auth.py
···
25
25
26
26
logger = logging.getLogger(__name__)
27
27
28
+
PUBLIC_REFRESH_TOKEN_DAYS = 14
29
+
CONFIDENTIAL_REFRESH_TOKEN_DAYS = 180
30
+
28
31
29
32
def _parse_scopes(scope_string: str) -> set[str]:
30
33
"""parse an OAuth scope string into a set of individual scopes.
···
163
166
return bool(settings.atproto.oauth_jwk)
164
167
165
168
169
+
def get_client_auth_method(oauth_session_data: dict[str, Any] | None = None) -> str:
170
+
"""resolve client auth method for a session."""
171
+
if oauth_session_data:
172
+
method = oauth_session_data.get("client_auth_method")
173
+
if method in {"public", "confidential"}:
174
+
return method
175
+
return "confidential" if is_confidential_client() else "public"
176
+
177
+
178
+
def get_refresh_token_lifetime_days(client_auth_method: str | None) -> int:
179
+
"""get expected refresh token lifetime in days."""
180
+
method = client_auth_method or get_client_auth_method()
181
+
return (
182
+
CONFIDENTIAL_REFRESH_TOKEN_DAYS
183
+
if method == "confidential"
184
+
else PUBLIC_REFRESH_TOKEN_DAYS
185
+
)
186
+
187
+
188
+
def _compute_refresh_token_expires_at(
189
+
now: datetime, client_auth_method: str | None
190
+
) -> datetime:
191
+
"""compute refresh token expiration time."""
192
+
return now + timedelta(days=get_refresh_token_lifetime_days(client_auth_method))
193
+
194
+
195
+
def _parse_datetime(value: str | None) -> datetime | None:
196
+
"""parse ISO datetime string safely."""
197
+
if not value:
198
+
return None
199
+
try:
200
+
return datetime.fromisoformat(value)
201
+
except ValueError:
202
+
return None
203
+
204
+
205
+
def _get_refresh_token_expires_at(
206
+
user_session: UserSession,
207
+
oauth_session_data: dict[str, Any],
208
+
) -> datetime | None:
209
+
"""determine refresh token expiry for a session."""
210
+
parsed = _parse_datetime(oauth_session_data.get("refresh_token_expires_at"))
211
+
if parsed:
212
+
return parsed
213
+
214
+
client_auth_method = oauth_session_data.get("client_auth_method")
215
+
if client_auth_method:
216
+
return user_session.created_at + timedelta(
217
+
days=get_refresh_token_lifetime_days(client_auth_method)
218
+
)
219
+
220
+
if user_session.is_developer_token:
221
+
return user_session.created_at + timedelta(days=PUBLIC_REFRESH_TOKEN_DAYS)
222
+
223
+
return None
224
+
225
+
166
226
def get_oauth_client(include_teal: bool = False) -> OAuthClient:
167
227
"""create an OAuth client with the appropriate scopes.
168
228
···
249
309
did: user's decentralized identifier
250
310
handle: user's ATProto handle
251
311
oauth_session: OAuth session data to encrypt and store
252
-
expires_in_days: session expiration in days (default 14, use 0 for no expiration)
312
+
expires_in_days: session expiration in days (default 14, capped by refresh lifetime)
253
313
is_developer_token: whether this is a developer token (for listing/revocation)
254
314
token_name: optional name for the token (only for developer tokens)
255
315
group_id: optional session group ID for multi-account support
256
316
"""
257
317
session_id = secrets.token_urlsafe(32)
318
+
now = datetime.now(UTC)
258
319
259
-
encrypted_data = _encrypt_data(json.dumps(oauth_session))
320
+
client_auth_method = get_client_auth_method(oauth_session)
321
+
refresh_lifetime_days = get_refresh_token_lifetime_days(client_auth_method)
322
+
refresh_expires_at = _compute_refresh_token_expires_at(now, client_auth_method)
260
323
261
-
expires_at = (
262
-
datetime.now(UTC) + timedelta(days=expires_in_days)
263
-
if expires_in_days > 0
264
-
else None
324
+
oauth_session = dict(oauth_session)
325
+
oauth_session.setdefault("client_auth_method", client_auth_method)
326
+
oauth_session.setdefault("refresh_token_lifetime_days", refresh_lifetime_days)
327
+
oauth_session.setdefault("refresh_token_expires_at", refresh_expires_at.isoformat())
328
+
329
+
effective_days = (
330
+
refresh_lifetime_days
331
+
if expires_in_days <= 0
332
+
else min(expires_in_days, refresh_lifetime_days)
265
333
)
334
+
expires_at = now + timedelta(days=effective_days)
335
+
336
+
encrypted_data = _encrypt_data(json.dumps(oauth_session))
266
337
267
338
async with db_session() as db:
268
339
user_session = UserSession(
···
301
372
if decrypted_data is None:
302
373
# decryption failed - session is invalid (key changed or data corrupted)
303
374
# delete the corrupted session
375
+
await delete_session(session_id)
376
+
return None
377
+
378
+
oauth_session_data = json.loads(decrypted_data)
379
+
380
+
refresh_expires_at = _get_refresh_token_expires_at(
381
+
user_session, oauth_session_data
382
+
)
383
+
if refresh_expires_at and datetime.now(UTC) > refresh_expires_at:
304
384
await delete_session(session_id)
305
385
return None
306
386
···
308
388
session_id=user_session.session_id,
309
389
did=user_session.did,
310
390
handle=user_session.handle,
311
-
oauth_session=json.loads(decrypted_data),
391
+
oauth_session=oauth_session_data,
312
392
)
313
393
314
394
···
445
525
encryption_algorithm=serialization.NoEncryption(),
446
526
).decode("utf-8")
447
527
528
+
client_auth_method = get_client_auth_method()
529
+
refresh_lifetime_days = get_refresh_token_lifetime_days(client_auth_method)
530
+
refresh_expires_at = _compute_refresh_token_expires_at(
531
+
datetime.now(UTC), client_auth_method
532
+
)
533
+
448
534
# store full OAuth session with tokens in database
449
535
session_data = {
450
536
"did": oauth_session.did,
···
457
543
"dpop_private_key_pem": dpop_key_pem,
458
544
"dpop_authserver_nonce": oauth_session.dpop_authserver_nonce,
459
545
"dpop_pds_nonce": oauth_session.dpop_pds_nonce or "",
546
+
"client_auth_method": client_auth_method,
547
+
"refresh_token_lifetime_days": refresh_lifetime_days,
548
+
"refresh_token_expires_at": refresh_expires_at.isoformat(),
460
549
}
461
550
return oauth_session.did, oauth_session.handle, session_data
462
551
except Exception as e:
···
658
747
sessions = result.scalars().all()
659
748
660
749
tokens = []
750
+
now = datetime.now(UTC)
661
751
for session in sessions:
752
+
decrypted_data = _decrypt_data(session.oauth_session_data)
753
+
oauth_session_data = (
754
+
json.loads(decrypted_data) if decrypted_data is not None else {}
755
+
)
756
+
refresh_expires_at = _get_refresh_token_expires_at(
757
+
session, oauth_session_data
758
+
)
759
+
effective_expires_at = session.expires_at
760
+
if refresh_expires_at and (
761
+
effective_expires_at is None
762
+
or refresh_expires_at < effective_expires_at
763
+
):
764
+
effective_expires_at = refresh_expires_at
765
+
662
766
# check if expired
663
-
if session.expires_at and datetime.now(UTC) > session.expires_at:
767
+
if effective_expires_at and now > effective_expires_at:
664
768
continue # skip expired tokens
665
769
666
770
tokens.append(
···
668
772
session_id=session.session_id,
669
773
token_name=session.token_name,
670
774
created_at=session.created_at,
671
-
expires_at=session.expires_at,
775
+
expires_at=effective_expires_at,
672
776
)
673
777
)
674
778
+5
-2
backend/src/backend/_internal/background.py
+5
-2
backend/src/backend/_internal/background.py
···
92
92
)
93
93
yield docket
94
94
finally:
95
-
# cancel the worker task and wait for it to finish
95
+
# cancel the worker task with timeout to avoid hanging on shutdown
96
96
if worker_task:
97
97
worker_task.cancel()
98
98
try:
99
-
await worker_task
99
+
# wait briefly for clean shutdown, but don't block forever
100
+
await asyncio.wait_for(worker_task, timeout=2.0)
101
+
except TimeoutError:
102
+
logger.warning("docket worker did not stop within timeout")
100
103
except asyncio.CancelledError:
101
104
logger.debug("docket worker task cancelled")
102
105
# clear global after worker is fully stopped
+6
-1
backend/src/backend/api/auth.py
+6
-1
backend/src/backend/api/auth.py
···
35
35
start_oauth_flow_with_scopes,
36
36
switch_active_account,
37
37
)
38
+
from backend._internal.auth import get_refresh_token_lifetime_days
38
39
from backend._internal.background_tasks import schedule_atproto_sync
39
40
from backend.config import settings
40
41
from backend.models import Artist, get_db
···
466
467
if expires_in_days > max_days:
467
468
raise HTTPException(
468
469
status_code=400,
469
-
detail=f"expires_in_days cannot exceed {max_days} (use 0 for no expiration)",
470
+
detail=f"expires_in_days cannot exceed {max_days}",
470
471
)
472
+
473
+
refresh_lifetime_days = get_refresh_token_lifetime_days(None)
474
+
if expires_in_days <= 0 or expires_in_days > refresh_lifetime_days:
475
+
expires_in_days = refresh_lifetime_days
471
476
472
477
# start OAuth flow using the user's handle
473
478
auth_url, state = await start_oauth_flow(session.handle)
+2
-1
backend/src/backend/api/tracks/metadata_service.py
+2
-1
backend/src/backend/api/tracks/metadata_service.py
···
6
6
from io import BytesIO
7
7
from typing import TYPE_CHECKING, Any
8
8
9
-
from fastapi import HTTPException, UploadFile
9
+
from fastapi import HTTPException
10
10
from sqlalchemy.ext.asyncio import AsyncSession
11
11
from sqlalchemy.orm import attributes
12
+
from starlette.datastructures import UploadFile
12
13
13
14
from backend._internal.atproto.handles import resolve_handle
14
15
from backend._internal.image import ImageFormat
+22
-4
backend/src/backend/api/tracks/mutations.py
+22
-4
backend/src/backend/api/tracks/mutations.py
···
180
180
Form(description="JSON object for supporter gating, or 'null' to remove"),
181
181
] = None,
182
182
image: UploadFile | None = File(None),
183
+
remove_image: Annotated[
184
+
str | None,
185
+
Form(description="Set to 'true' to remove artwork"),
186
+
] = None,
183
187
) -> TrackResponse:
184
188
"""Update track metadata (only by owner)."""
185
189
result = await db.execute(
···
250
254
251
255
image_changed = False
252
256
image_url = None
253
-
if image and image.filename:
257
+
258
+
# handle image removal
259
+
if remove_image and remove_image.lower() == "true" and track.image_id:
260
+
# only delete image from R2 if album doesn't share it
261
+
album_shares_image = (
262
+
track.album_rel and track.album_rel.image_id == track.image_id
263
+
)
264
+
if not album_shares_image:
265
+
with contextlib.suppress(Exception):
266
+
await storage.delete(track.image_id)
267
+
track.image_id = None
268
+
track.image_url = None
269
+
image_changed = True
270
+
elif image and image.filename:
271
+
# handle image upload/replacement
254
272
image_id, image_url = await upload_track_image(image)
255
273
256
274
if track.image_id:
···
305
323
try:
306
324
await _update_atproto_record(track, auth_session, image_url)
307
325
except Exception as exc:
308
-
logger.error(
309
-
f"failed to update ATProto record for track {track.id}: {exc}",
310
-
exc_info=True,
326
+
logfire.exception(
327
+
"failed to update ATProto record",
328
+
track_id=track.id,
311
329
)
312
330
await db.rollback()
313
331
raise HTTPException(
+1
-1
backend/src/backend/config.py
+1
-1
backend/src/backend/config.py
···
666
666
667
667
developer_token_default_days: int = Field(
668
668
default=90,
669
-
description="Default expiration in days for developer tokens (0 = no expiration)",
669
+
description="Default expiration in days for developer tokens (capped by refresh lifetime)",
670
670
)
671
671
developer_token_max_days: int = Field(
672
672
default=365,
+10
-3
backend/src/backend/main.py
+10
-3
backend/src/backend/main.py
···
1
1
"""relay fastapi application."""
2
2
3
+
import asyncio
3
4
import logging
4
5
import re
5
6
import warnings
···
157
158
app.state.docket = docket
158
159
yield
159
160
160
-
# shutdown: cleanup resources
161
-
await notification_service.shutdown()
162
-
await queue_service.shutdown()
161
+
# shutdown: cleanup resources with timeouts to avoid hanging
162
+
try:
163
+
await asyncio.wait_for(notification_service.shutdown(), timeout=2.0)
164
+
except TimeoutError:
165
+
logging.warning("notification_service.shutdown() timed out")
166
+
try:
167
+
await asyncio.wait_for(queue_service.shutdown(), timeout=2.0)
168
+
except TimeoutError:
169
+
logging.warning("queue_service.shutdown() timed out")
163
170
164
171
165
172
app = FastAPI(
+3
-2
backend/src/backend/storage/r2.py
+3
-2
backend/src/backend/storage/r2.py
···
2
2
3
3
import time
4
4
from collections.abc import Callable
5
+
from io import BytesIO
5
6
from pathlib import Path
6
7
from typing import BinaryIO
7
8
···
120
121
121
122
async def save(
122
123
self,
123
-
file: BinaryIO,
124
+
file: BinaryIO | BytesIO,
124
125
filename: str,
125
126
progress_callback: Callable[[float], None] | None = None,
126
127
) -> str:
···
444
445
445
446
async def save_gated(
446
447
self,
447
-
file: BinaryIO,
448
+
file: BinaryIO | BytesIO,
448
449
filename: str,
449
450
progress_callback: Callable[[float], None] | None = None,
450
451
) -> str:
+14
-6
backend/tests/test_auth.py
+14
-6
backend/tests/test_auth.py
···
17
17
create_session,
18
18
delete_session,
19
19
get_public_jwks,
20
+
get_refresh_token_lifetime_days,
20
21
get_session,
21
22
is_confidential_client,
22
23
update_session_tokens,
···
259
260
260
261
261
262
async def test_create_session_with_custom_expiration(db_session: AsyncSession):
262
-
"""verify session creation with custom expiration works."""
263
+
"""verify session creation with custom expiration is capped by refresh lifetime."""
263
264
did = "did:plc:customexp123"
264
265
handle = "customexp.bsky.social"
265
266
oauth_data = {"access_token": "token", "refresh_token": "refresh"}
···
280
281
assert db_session_record is not None
281
282
assert db_session_record.expires_at is not None
282
283
283
-
# should expire roughly 30 days from now
284
-
expected_expiry = datetime.now(UTC) + timedelta(days=30)
284
+
expected_days = min(30, get_refresh_token_lifetime_days(None))
285
+
# should expire roughly expected_days from now
286
+
expected_expiry = datetime.now(UTC) + timedelta(days=expected_days)
285
287
actual_expiry = db_session_record.expires_at.replace(tzinfo=UTC)
286
288
diff = abs((expected_expiry - actual_expiry).total_seconds())
287
289
assert diff < 60 # within 1 minute
288
290
289
291
290
292
async def test_create_session_with_no_expiration(db_session: AsyncSession):
291
-
"""verify session creation with expires_in_days=0 creates non-expiring session."""
293
+
"""verify session creation with expires_in_days=0 caps to refresh lifetime."""
292
294
did = "did:plc:noexp123"
293
295
handle = "noexp.bsky.social"
294
296
oauth_data = {"access_token": "token", "refresh_token": "refresh"}
···
301
303
assert session is not None
302
304
assert session.did == did
303
305
304
-
# verify expires_at is None
306
+
# verify expires_at is capped to refresh token lifetime
305
307
result = await db_session.execute(
306
308
select(UserSession).where(UserSession.session_id == session_id)
307
309
)
308
310
db_session_record = result.scalar_one_or_none()
309
311
assert db_session_record is not None
310
-
assert db_session_record.expires_at is None
312
+
assert db_session_record.expires_at is not None
313
+
314
+
expected_days = get_refresh_token_lifetime_days(None)
315
+
expected_expiry = datetime.now(UTC) + timedelta(days=expected_days)
316
+
actual_expiry = db_session_record.expires_at.replace(tzinfo=UTC)
317
+
diff = abs((expected_expiry - actual_expiry).total_seconds())
318
+
assert diff < 60 # within 1 minute
311
319
312
320
313
321
async def test_create_session_default_expiration(db_session: AsyncSession):
+53
backend/tests/test_storage_types.py
+53
backend/tests/test_storage_types.py
···
1
+
"""test storage type hints accept BytesIO.
2
+
3
+
regression test for: https://github.com/zzstoatzz/plyr.fm/pull/736
4
+
beartype was rejecting BytesIO for BinaryIO type hint in R2Storage.save()
5
+
"""
6
+
7
+
from io import BytesIO
8
+
from unittest.mock import AsyncMock, patch
9
+
10
+
from backend.storage.r2 import R2Storage
11
+
12
+
13
+
async def test_r2_save_accepts_bytesio():
14
+
"""R2Storage.save() should accept BytesIO objects.
15
+
16
+
BytesIO is the standard way to create in-memory binary streams,
17
+
and is used throughout the codebase for image uploads.
18
+
19
+
This test verifies that the type hint on save() is compatible
20
+
with BytesIO, which beartype validates at runtime.
21
+
"""
22
+
# create a minimal image-like BytesIO
23
+
image_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 # fake PNG header
24
+
file_obj = BytesIO(image_data)
25
+
26
+
# mock the R2 client internals
27
+
with (
28
+
patch.object(R2Storage, "__init__", lambda self: None),
29
+
patch("backend.storage.r2.hash_file_chunked", return_value="abc123def456"),
30
+
):
31
+
storage = R2Storage()
32
+
storage.async_session = AsyncMock()
33
+
storage.image_bucket_name = "test-images"
34
+
storage.audio_bucket_name = "test-audio"
35
+
36
+
# mock the async context manager for S3 client
37
+
mock_client = AsyncMock()
38
+
mock_client.upload_fileobj = AsyncMock()
39
+
40
+
mock_cm = AsyncMock()
41
+
mock_cm.__aenter__ = AsyncMock(return_value=mock_client)
42
+
mock_cm.__aexit__ = AsyncMock(return_value=None)
43
+
storage.async_session.client = lambda *args, **kwargs: mock_cm
44
+
storage.endpoint_url = "https://test.r2.dev"
45
+
storage.aws_access_key_id = "test"
46
+
storage.aws_secret_access_key = "test"
47
+
48
+
# this should NOT raise a beartype error
49
+
# before the fix: BeartypeCallHintParamViolation
50
+
file_id = await storage.save(file_obj, "test.png")
51
+
52
+
assert file_id == "abc123def456"[:16]
53
+
mock_client.upload_fileobj.assert_called_once()
+3
-3
docs/authentication.md
+3
-3
docs/authentication.md
···
439
439
backend settings in `AuthSettings`:
440
440
- `developer_token_default_days`: default expiration (90 days)
441
441
- `developer_token_max_days`: max allowed expiration (365 days)
442
-
- use `expires_in_days: 0` for tokens that never expire
442
+
- use `expires_in_days: 0` to request the maximum allowed by refresh lifetime
443
443
444
444
### how it works
445
445
···
485
485
with public clients, the underlying ATProto refresh token expires after 2 weeks regardless of what we store in our database. users would need to re-authenticate with their PDS every 2 weeks.
486
486
487
487
with confidential clients:
488
-
- **developer tokens actually work long-term** - not limited to 2 weeks
488
+
- **developer tokens work long-term** - not limited to 2 weeks
489
489
- **users don't get randomly kicked out** after 2 weeks of inactivity
490
-
- **sessions last effectively forever** as long as tokens are refreshed within 180 days
490
+
- **sessions last up to refresh lifetime** as long as tokens are refreshed within 180 days
491
491
492
492
### how it works
493
493
+7
-11
docs/frontend/data-loading.md
+7
-11
docs/frontend/data-loading.md
···
44
44
45
45
used for:
46
46
- auth-dependent data (liked tracks, user preferences)
47
-
- data that needs client context (localStorage, cookies)
47
+
- data that needs client context (local caches, media state)
48
48
- progressive enhancement
49
49
50
50
```typescript
51
51
// frontend/src/routes/liked/+page.ts
52
52
export const load: PageLoad = async ({ fetch }) => {
53
-
const sessionId = localStorage.getItem('session_id');
54
-
if (!sessionId) return { tracks: [] };
55
-
56
53
const response = await fetch(`${API_URL}/tracks/liked`, {
57
-
headers: { 'Authorization': `Bearer ${sessionId}` }
54
+
credentials: 'include'
58
55
});
56
+
57
+
if (!response.ok) return { tracks: [] };
59
58
60
59
return { tracks: await response.json() };
61
60
};
62
61
```
63
62
64
63
**benefits**:
65
-
- access to browser APIs (localStorage, cookies)
66
-
- runs on client, can use session tokens
64
+
- access to browser APIs (window, local caches)
65
+
- runs on client, can use HttpOnly cookie auth
67
66
- still loads before component mounts (faster than `onMount`)
68
67
69
68
### layout loading (`+layout.ts`)
···
75
74
```typescript
76
75
// frontend/src/routes/+layout.ts
77
76
export async function load({ fetch }: LoadEvent) {
78
-
const sessionId = localStorage.getItem('session_id');
79
-
if (!sessionId) return { user: null, isAuthenticated: false };
80
-
81
77
const response = await fetch(`${API_URL}/auth/me`, {
82
-
headers: { 'Authorization': `Bearer ${sessionId}` }
78
+
credentials: 'include'
83
79
});
84
80
85
81
if (response.ok) {
+53
docs/frontend/state-management.md
+53
docs/frontend/state-management.md
···
133
133
2. no console errors about "Cannot access X before initialization"
134
134
3. UI reflects current variable value
135
135
136
+
### waiting for async conditions with `$effect`
137
+
138
+
when you need to perform an action after some async condition is met (like audio being ready), **don't rely on event listeners** - they may not attach in time if the target element doesn't exist yet or the event fires before your listener is registered.
139
+
140
+
**instead, use a reactive `$effect` that watches for the conditions to be met:**
141
+
142
+
```typescript
143
+
// โ WRONG - event listener may not attach in time
144
+
onMount(() => {
145
+
queue.playNow(track); // triggers async loading in Player component
146
+
147
+
// player.audioElement might be undefined here!
148
+
// even if it exists, loadedmetadata may fire before this runs
149
+
player.audioElement?.addEventListener('loadedmetadata', () => {
150
+
player.audioElement.currentTime = seekTime;
151
+
});
152
+
});
153
+
```
154
+
155
+
```typescript
156
+
// โ
CORRECT - reactive effect waits for conditions
157
+
let pendingSeekMs = $state<number | null>(null);
158
+
159
+
onMount(() => {
160
+
pendingSeekMs = 11000; // store the pending action
161
+
queue.playNow(track); // trigger the async operation
162
+
});
163
+
164
+
// effect runs whenever dependencies change, including when audio becomes ready
165
+
$effect(() => {
166
+
if (
167
+
pendingSeekMs !== null &&
168
+
player.currentTrack?.id === track.id &&
169
+
player.audioElement &&
170
+
player.audioElement.readyState >= 1
171
+
) {
172
+
player.audioElement.currentTime = pendingSeekMs / 1000;
173
+
pendingSeekMs = null; // clear after performing action
174
+
}
175
+
});
176
+
```
177
+
178
+
**why this works:**
179
+
- `$effect` re-runs whenever any of its dependencies change
180
+
- when `player.audioElement` becomes available and ready, the effect fires
181
+
- no race condition - the effect will catch the ready state even if it happened "in the past"
182
+
- setting `pendingSeekMs = null` ensures the action only runs once
183
+
184
+
**use this pattern when:**
185
+
- waiting for DOM elements to exist
186
+
- waiting for async operations to complete
187
+
- coordinating between components that load independently
188
+
136
189
## global state management
137
190
138
191
### overview
+143
docs/tools/status-maintenance.md
+143
docs/tools/status-maintenance.md
···
1
+
# status maintenance workflow
2
+
3
+
automated workflow that archives old STATUS.md content and generates audio updates.
4
+
5
+
## what it does
6
+
7
+
1. **archives old content**: moves previous month's sections from STATUS.md to `.status_history/YYYY-MM.md`
8
+
2. **generates audio**: creates a podcast-style audio update covering recent work
9
+
3. **opens PR**: commits changes and opens a PR for review
10
+
4. **uploads audio**: after PR merge, uploads the audio to plyr.fm
11
+
12
+
## workflow file
13
+
14
+
`.github/workflows/status-maintenance.yml`
15
+
16
+
## triggers
17
+
18
+
- **manual**: `workflow_dispatch` (run from Actions tab)
19
+
- **on PR merge**: uploads audio after status-maintenance PR is merged
20
+
21
+
schedule is currently disabled but can be enabled for weekly runs.
22
+
23
+
## how it determines the time window
24
+
25
+
the workflow finds the most recently merged PR with a branch starting with `status-maintenance-`:
26
+
27
+
```bash
28
+
gh pr list --state merged --search "status-maintenance" --limit 20 \
29
+
--json number,title,mergedAt,headRefName | \
30
+
jq '[.[] | select(.headRefName | startswith("status-maintenance-"))] | sort_by(.mergedAt) | reverse | .[0]'
31
+
```
32
+
33
+
everything merged since that date is considered "new work" for the audio script.
34
+
35
+
### handling reverted PRs
36
+
37
+
if a status-maintenance PR is merged then reverted, it still appears as "merged" in GitHub's API. this can cause the workflow to think there's no new content.
38
+
39
+
**workaround**: temporarily add an exclusion to the jq filter:
40
+
41
+
```bash
42
+
| select(.number != 724) # exclude reverted PR
43
+
```
44
+
45
+
remove the exclusion after the next successful run.
46
+
47
+
## archival rules
48
+
49
+
**line count targets**:
50
+
- ideal: ~200 lines
51
+
- acceptable: 300-450 lines
52
+
- maximum: 500 lines (must not exceed)
53
+
54
+
**what gets archived**:
55
+
- content from months BEFORE the current month
56
+
- if today is January 2026, December 2025 sections move to `.status_history/2025-12.md`
57
+
58
+
**how archival works**:
59
+
1. CUT the full section from STATUS.md (headers, bullets, everything)
60
+
2. APPEND to the appropriate `.status_history/YYYY-MM.md` file
61
+
3. REPLACE in STATUS.md with a brief cross-reference
62
+
63
+
archival means MOVING content, not summarizing. the detailed write-ups are preserved in the archive.
64
+
65
+
## audio generation
66
+
67
+
### pronunciation
68
+
69
+
the project name is pronounced "player FM". in scripts, write:
70
+
- "player FM" or "player dot FM"
71
+
- never "plyr.fm" or "plyr" (TTS mispronounces it)
72
+
73
+
### terminology
74
+
75
+
plyr.fm operates at the ATProto protocol layer:
76
+
- say "ATProto identities" or "identities"
77
+
- never "Bluesky accounts"
78
+
79
+
Bluesky is one application on ATProto, like plyr.fm is another.
80
+
81
+
### tone
82
+
83
+
dry, matter-of-fact, slightly sardonic. avoid:
84
+
- "exciting", "amazing", "incredible"
85
+
- over-congratulating or sensationalizing
86
+
87
+
### script structure
88
+
89
+
1. opening (10s): date range, focus
90
+
2. main story (60-90s): biggest feature, design decisions
91
+
3. secondary feature (30-45s): if applicable
92
+
4. rapid fire (20-30s): smaller changes
93
+
5. closing (10s): wrap up
94
+
95
+
## inputs
96
+
97
+
| input | type | default | description |
98
+
|-------|------|---------|-------------|
99
+
| `skip_audio` | boolean | false | skip audio generation |
100
+
101
+
## secrets required
102
+
103
+
| secret | purpose |
104
+
|--------|---------|
105
+
| `ANTHROPIC_API_KEY` | claude code |
106
+
| `GOOGLE_API_KEY` | gemini TTS |
107
+
| `PLYR_BOT_TOKEN` | plyr.fm upload |
108
+
109
+
## manual run
110
+
111
+
```bash
112
+
gh workflow run "status maintenance" --ref main
113
+
```
114
+
115
+
with skip_audio:
116
+
```bash
117
+
gh workflow run "status maintenance" --ref main -f skip_audio=true
118
+
```
119
+
120
+
## troubleshooting
121
+
122
+
### workflow sees wrong time window
123
+
124
+
check which PR it's using as the baseline:
125
+
126
+
```bash
127
+
gh pr list --state merged --search "status-maintenance" --limit 5 \
128
+
--json number,title,mergedAt,headRefName
129
+
```
130
+
131
+
if a reverted PR is polluting the results, add a temporary exclusion.
132
+
133
+
### audio has wrong terminology
134
+
135
+
check the terminology section in the workflow prompt. common mistakes:
136
+
- "Bluesky accounts" should be "ATProto identities"
137
+
- "plyr" should be "player FM" (phonetic)
138
+
139
+
### STATUS.md over 500 lines
140
+
141
+
the archival step should handle this, but verify:
142
+
- december content should be in `.status_history/2025-12.md`
143
+
- only current month content stays in STATUS.md
+5
-3
frontend/src/lib/queue.svelte.ts
+5
-3
frontend/src/lib/queue.svelte.ts
···
2
2
import type { QueueResponse, QueueState, Track } from './types';
3
3
import { API_URL } from './config';
4
4
import { APP_BROADCAST_PREFIX } from './branding';
5
+
import { auth } from './auth.svelte';
5
6
6
7
const SYNC_DEBOUNCE_MS = 250;
7
8
···
141
142
142
143
private isAuthenticated(): boolean {
143
144
if (!browser) return false;
144
-
return !!localStorage.getItem('session_id');
145
+
return auth.isAuthenticated;
145
146
}
146
147
147
148
async fetchQueue(force = false) {
···
188
189
this.revision = data.revision;
189
190
this.etag = newEtag;
190
191
192
+
this.lastUpdateWasLocal = false;
191
193
this.applySnapshot(data);
192
194
} catch (error) {
193
195
console.error('failed to fetch queue:', error);
···
414
416
this.schedulePush();
415
417
}
416
418
417
-
playNow(track: Track) {
418
-
this.lastUpdateWasLocal = true;
419
+
playNow(track: Track, autoPlay = true) {
420
+
this.lastUpdateWasLocal = autoPlay;
419
421
const upNext = this.tracks.slice(this.currentIndex + 1);
420
422
this.tracks = [track, ...upNext];
421
423
this.originalOrder = [...this.tracks];
+3
frontend/src/routes/+layout.svelte
+3
frontend/src/routes/+layout.svelte
+359
-96
frontend/src/routes/portal/+page.svelte
+359
-96
frontend/src/routes/portal/+page.svelte
···
28
28
let editFeaturedArtists = $state<FeaturedArtist[]>([]);
29
29
let editTags = $state<string[]>([]);
30
30
let editImageFile = $state<File | null>(null);
31
+
let editImagePreviewUrl = $state<string | null>(null);
32
+
let editRemoveImage = $state(false);
31
33
let editSupportGate = $state(false);
32
34
let hasUnresolvedEditFeaturesInput = $state(false);
33
35
···
328
330
editFeaturedArtists = [];
329
331
editTags = [];
330
332
editImageFile = null;
333
+
if (editImagePreviewUrl) {
334
+
URL.revokeObjectURL(editImagePreviewUrl);
335
+
}
336
+
editImagePreviewUrl = null;
337
+
editRemoveImage = false;
331
338
editSupportGate = false;
332
339
}
333
340
···
351
358
} else {
352
359
formData.append('support_gate', 'null');
353
360
}
354
-
if (editImageFile) {
361
+
// handle artwork: remove, replace, or leave unchanged
362
+
if (editRemoveImage) {
363
+
formData.append('remove_image', 'true');
364
+
} else if (editImageFile) {
355
365
formData.append('image', editImageFile);
356
366
}
357
367
···
730
740
/>
731
741
</div>
732
742
<div class="edit-field-group">
733
-
<label for="edit-image" class="edit-label">artwork (optional)</label>
734
-
{#if track.image_url && !editImageFile}
735
-
<div class="current-image-preview">
736
-
<img src={track.image_url} alt="current artwork" />
737
-
<span class="current-image-label">current artwork</span>
738
-
</div>
739
-
{/if}
740
-
<input
741
-
id="edit-image"
742
-
type="file"
743
-
accept=".jpg,.jpeg,.png,.webp,.gif,image/jpeg,image/png,image/webp,image/gif"
744
-
onchange={(e) => {
745
-
const target = e.target as HTMLInputElement;
746
-
editImageFile = target.files?.[0] ?? null;
747
-
}}
748
-
class="edit-input"
749
-
/>
750
-
{#if editImageFile}
751
-
<p class="file-info">{editImageFile.name} (will replace current)</p>
752
-
{/if}
743
+
<span class="edit-label">artwork (optional)</span>
744
+
<div class="artwork-editor">
745
+
{#if editImagePreviewUrl}
746
+
<!-- New image selected - show preview -->
747
+
<div class="artwork-preview">
748
+
<img src={editImagePreviewUrl} alt="new artwork preview" />
749
+
<div class="artwork-preview-overlay">
750
+
<button
751
+
type="button"
752
+
class="artwork-action-btn"
753
+
onclick={() => {
754
+
editImageFile = null;
755
+
if (editImagePreviewUrl) {
756
+
URL.revokeObjectURL(editImagePreviewUrl);
757
+
}
758
+
editImagePreviewUrl = null;
759
+
}}
760
+
title="remove selection"
761
+
>
762
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
763
+
<line x1="18" y1="6" x2="6" y2="18"></line>
764
+
<line x1="6" y1="6" x2="18" y2="18"></line>
765
+
</svg>
766
+
</button>
767
+
</div>
768
+
</div>
769
+
<span class="artwork-status">new artwork selected</span>
770
+
{:else if editRemoveImage}
771
+
<!-- User chose to remove artwork -->
772
+
<div class="artwork-removed">
773
+
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
774
+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
775
+
<line x1="9" y1="9" x2="15" y2="15"></line>
776
+
<line x1="15" y1="9" x2="9" y2="15"></line>
777
+
</svg>
778
+
<span>artwork will be removed</span>
779
+
<button
780
+
type="button"
781
+
class="undo-remove-btn"
782
+
onclick={() => { editRemoveImage = false; }}
783
+
>
784
+
undo
785
+
</button>
786
+
</div>
787
+
{:else if track.image_url}
788
+
<!-- Current artwork exists -->
789
+
<div class="artwork-preview">
790
+
<img src={track.image_url} alt="current artwork" />
791
+
<div class="artwork-preview-overlay">
792
+
<button
793
+
type="button"
794
+
class="artwork-action-btn"
795
+
onclick={() => { editRemoveImage = true; }}
796
+
title="remove artwork"
797
+
>
798
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
799
+
<polyline points="3 6 5 6 21 6"></polyline>
800
+
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
801
+
</svg>
802
+
</button>
803
+
</div>
804
+
</div>
805
+
<span class="artwork-status current">current artwork</span>
806
+
{:else}
807
+
<!-- No artwork -->
808
+
<div class="artwork-empty">
809
+
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
810
+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
811
+
<circle cx="8.5" cy="8.5" r="1.5"></circle>
812
+
<polyline points="21 15 16 10 5 21"></polyline>
813
+
</svg>
814
+
<span>no artwork</span>
815
+
</div>
816
+
{/if}
817
+
{#if !editRemoveImage}
818
+
<label class="artwork-upload-btn">
819
+
<input
820
+
type="file"
821
+
accept=".jpg,.jpeg,.png,.webp,.gif,image/jpeg,image/png,image/webp,image/gif"
822
+
onchange={(e) => {
823
+
const target = e.target as HTMLInputElement;
824
+
const file = target.files?.[0];
825
+
if (file) {
826
+
editImageFile = file;
827
+
if (editImagePreviewUrl) {
828
+
URL.revokeObjectURL(editImagePreviewUrl);
829
+
}
830
+
editImagePreviewUrl = URL.createObjectURL(file);
831
+
}
832
+
}}
833
+
/>
834
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
835
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
836
+
<polyline points="17 8 12 3 7 8"></polyline>
837
+
<line x1="12" y1="3" x2="12" y2="15"></line>
838
+
</svg>
839
+
{track.image_url || editImagePreviewUrl ? 'replace' : 'upload'}
840
+
</label>
841
+
{/if}
842
+
</div>
753
843
</div>
754
844
{#if atprotofansEligible || track.support_gate}
755
845
<div class="edit-field-group">
···
771
861
</div>
772
862
<div class="edit-actions">
773
863
<button
774
-
class="action-btn save-btn"
775
-
onclick={() => saveTrackEdit(track.id)}
776
-
disabled={hasUnresolvedEditFeaturesInput}
777
-
title={hasUnresolvedEditFeaturesInput ? "please select or clear featured artist" : "save changes"}
864
+
type="button"
865
+
class="edit-cancel-btn"
866
+
onclick={cancelEdit}
778
867
>
779
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
780
-
<polyline points="20 6 9 17 4 12"></polyline>
781
-
</svg>
868
+
cancel
782
869
</button>
783
870
<button
784
-
class="action-btn cancel-btn"
785
-
onclick={cancelEdit}
786
-
title="cancel"
871
+
type="button"
872
+
class="edit-save-btn"
873
+
onclick={() => saveTrackEdit(track.id)}
874
+
disabled={hasUnresolvedEditFeaturesInput}
875
+
title={hasUnresolvedEditFeaturesInput ? "please select or clear featured artist" : "save changes"}
787
876
>
788
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
789
-
<line x1="18" y1="6" x2="6" y2="18"></line>
790
-
<line x1="6" y1="6" x2="18" y2="18"></line>
791
-
</svg>
877
+
save changes
792
878
</button>
793
879
</div>
794
880
</div>
···
880
966
</div>
881
967
<div class="track-actions">
882
968
<button
883
-
class="action-btn edit-btn"
969
+
type="button"
970
+
class="track-action-btn edit"
884
971
onclick={() => startEditTrack(track)}
885
-
title="edit track"
886
972
>
887
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
973
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
888
974
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
889
975
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
890
976
</svg>
977
+
edit
891
978
</button>
892
979
<button
893
-
class="action-btn delete-btn"
980
+
type="button"
981
+
class="track-action-btn delete"
894
982
onclick={() => deleteTrack(track.id, track.title)}
895
-
title="delete track"
896
983
>
897
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
984
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
898
985
<polyline points="3 6 5 6 21 6"></polyline>
899
986
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
900
987
</svg>
988
+
delete
901
989
</button>
902
990
</div>
903
991
{/if}
···
1451
1539
cursor: not-allowed;
1452
1540
}
1453
1541
1454
-
.file-info {
1455
-
margin-top: 0.5rem;
1456
-
font-size: var(--text-sm);
1457
-
color: var(--text-muted);
1458
-
}
1459
-
1460
-
button {
1542
+
/* form submit buttons only */
1543
+
form button[type="submit"] {
1461
1544
width: 100%;
1462
1545
padding: 0.75rem;
1463
1546
background: var(--accent);
···
1471
1554
transition: all 0.2s;
1472
1555
}
1473
1556
1474
-
button:hover:not(:disabled) {
1557
+
form button[type="submit"]:hover:not(:disabled) {
1475
1558
background: var(--accent-hover);
1476
1559
transform: translateY(-1px);
1477
1560
box-shadow: 0 4px 12px color-mix(in srgb, var(--accent) 30%, transparent);
1478
1561
}
1479
1562
1480
-
button:disabled {
1563
+
form button[type="submit"]:disabled {
1481
1564
opacity: 0.5;
1482
1565
cursor: not-allowed;
1483
1566
transform: none;
1484
1567
}
1485
1568
1486
-
button:active:not(:disabled) {
1569
+
form button[type="submit"]:active:not(:disabled) {
1487
1570
transform: translateY(0);
1488
1571
}
1489
1572
···
1787
1870
align-self: flex-start;
1788
1871
}
1789
1872
1790
-
.action-btn {
1791
-
display: flex;
1873
+
/* track action buttons (edit/delete in non-editing state) */
1874
+
.track-action-btn {
1875
+
display: inline-flex;
1792
1876
align-items: center;
1793
-
justify-content: center;
1794
-
width: 32px;
1795
-
height: 32px;
1796
-
padding: 0;
1877
+
gap: 0.35rem;
1878
+
padding: 0.4rem 0.65rem;
1797
1879
background: transparent;
1798
1880
border: 1px solid var(--border-default);
1799
-
border-radius: var(--radius-base);
1881
+
border-radius: var(--radius-full);
1800
1882
color: var(--text-tertiary);
1883
+
font-size: var(--text-sm);
1884
+
font-family: inherit;
1885
+
font-weight: 500;
1801
1886
cursor: pointer;
1802
1887
transition: all 0.15s;
1803
-
flex-shrink: 0;
1888
+
white-space: nowrap;
1889
+
width: auto;
1890
+
}
1891
+
1892
+
.track-action-btn:hover {
1893
+
transform: none;
1894
+
box-shadow: none;
1895
+
border-color: var(--border-emphasis);
1896
+
color: var(--text-secondary);
1897
+
}
1898
+
1899
+
.track-action-btn.delete:hover {
1900
+
color: var(--text-secondary);
1901
+
}
1902
+
1903
+
/* edit mode action buttons */
1904
+
.edit-actions {
1905
+
display: flex;
1906
+
gap: 0.75rem;
1907
+
justify-content: flex-end;
1908
+
padding-top: 0.75rem;
1909
+
border-top: 1px solid var(--border-subtle);
1910
+
margin-top: 0.5rem;
1804
1911
}
1805
1912
1806
-
.action-btn svg {
1807
-
flex-shrink: 0;
1913
+
.edit-cancel-btn {
1914
+
padding: 0.6rem 1.25rem;
1915
+
background: transparent;
1916
+
border: 1px solid var(--border-default);
1917
+
border-radius: var(--radius-base);
1918
+
color: var(--text-secondary);
1919
+
font-size: var(--text-base);
1920
+
font-weight: 500;
1921
+
font-family: inherit;
1922
+
cursor: pointer;
1923
+
transition: all 0.15s;
1924
+
width: auto;
1808
1925
}
1809
1926
1810
-
.action-btn:hover {
1927
+
.edit-cancel-btn:hover {
1928
+
border-color: var(--text-tertiary);
1929
+
background: var(--bg-hover);
1811
1930
transform: none;
1812
1931
box-shadow: none;
1813
1932
}
1814
1933
1815
-
.edit-btn:hover {
1816
-
background: color-mix(in srgb, var(--accent) 12%, transparent);
1817
-
border-color: var(--accent);
1934
+
.edit-save-btn {
1935
+
padding: 0.6rem 1.25rem;
1936
+
background: transparent;
1937
+
border: 1px solid var(--accent);
1938
+
border-radius: var(--radius-base);
1818
1939
color: var(--accent);
1940
+
font-size: var(--text-base);
1941
+
font-weight: 500;
1942
+
font-family: inherit;
1943
+
cursor: pointer;
1944
+
transition: all 0.15s;
1945
+
width: auto;
1819
1946
}
1820
1947
1821
-
.delete-btn:hover {
1822
-
background: color-mix(in srgb, var(--error) 12%, transparent);
1823
-
border-color: var(--error);
1824
-
color: var(--error);
1948
+
.edit-save-btn:hover:not(:disabled) {
1949
+
background: color-mix(in srgb, var(--accent) 8%, transparent);
1825
1950
}
1826
1951
1827
-
.save-btn:hover {
1828
-
background: color-mix(in srgb, var(--success) 12%, transparent);
1829
-
border-color: var(--success);
1830
-
color: var(--success);
1831
-
}
1832
-
1833
-
.cancel-btn:hover {
1834
-
background: color-mix(in srgb, var(--text-tertiary) 12%, transparent);
1835
-
border-color: var(--text-tertiary);
1836
-
color: var(--text-secondary);
1952
+
.edit-save-btn:disabled {
1953
+
opacity: 0.5;
1954
+
cursor: not-allowed;
1837
1955
}
1838
1956
1839
1957
.edit-input {
···
1847
1965
font-family: inherit;
1848
1966
}
1849
1967
1850
-
.current-image-preview {
1968
+
/* artwork editor */
1969
+
.artwork-editor {
1851
1970
display: flex;
1852
1971
align-items: center;
1853
-
gap: 0.75rem;
1854
-
padding: 0.5rem;
1972
+
gap: 1rem;
1973
+
padding: 0.75rem;
1855
1974
background: var(--bg-primary);
1856
1975
border: 1px solid var(--border-default);
1857
-
border-radius: var(--radius-sm);
1858
-
margin-bottom: 0.5rem;
1976
+
border-radius: var(--radius-base);
1859
1977
}
1860
1978
1861
-
.current-image-preview img {
1862
-
width: 48px;
1863
-
height: 48px;
1864
-
border-radius: var(--radius-sm);
1979
+
.artwork-preview {
1980
+
position: relative;
1981
+
width: 80px;
1982
+
height: 80px;
1983
+
border-radius: var(--radius-base);
1984
+
overflow: hidden;
1985
+
flex-shrink: 0;
1986
+
}
1987
+
1988
+
.artwork-preview img {
1989
+
width: 100%;
1990
+
height: 100%;
1865
1991
object-fit: cover;
1866
1992
}
1867
1993
1868
-
.current-image-label {
1994
+
.artwork-preview-overlay {
1995
+
position: absolute;
1996
+
inset: 0;
1997
+
background: rgba(0, 0, 0, 0.5);
1998
+
display: flex;
1999
+
align-items: center;
2000
+
justify-content: center;
2001
+
opacity: 0;
2002
+
transition: opacity 0.15s;
2003
+
}
2004
+
2005
+
.artwork-preview:hover .artwork-preview-overlay {
2006
+
opacity: 1;
2007
+
}
2008
+
2009
+
.artwork-action-btn {
2010
+
display: flex;
2011
+
align-items: center;
2012
+
justify-content: center;
2013
+
width: 32px;
2014
+
height: 32px;
2015
+
padding: 0;
2016
+
background: rgba(255, 255, 255, 0.15);
2017
+
border: none;
2018
+
border-radius: var(--radius-full);
2019
+
color: white;
2020
+
cursor: pointer;
2021
+
transition: all 0.15s;
2022
+
}
2023
+
2024
+
.artwork-action-btn:hover {
2025
+
background: var(--error);
2026
+
transform: scale(1.1);
2027
+
box-shadow: none;
2028
+
}
2029
+
2030
+
.artwork-status {
2031
+
font-size: var(--text-sm);
2032
+
color: var(--accent);
2033
+
font-weight: 500;
2034
+
}
2035
+
2036
+
.artwork-status.current {
1869
2037
color: var(--text-tertiary);
2038
+
font-weight: 400;
2039
+
}
2040
+
2041
+
.artwork-removed {
2042
+
display: flex;
2043
+
flex-direction: column;
2044
+
align-items: center;
2045
+
gap: 0.5rem;
2046
+
padding: 0.75rem 1rem;
2047
+
color: var(--text-tertiary);
2048
+
}
2049
+
2050
+
.artwork-removed span {
1870
2051
font-size: var(--text-sm);
2052
+
}
2053
+
2054
+
.undo-remove-btn {
2055
+
padding: 0.25rem 0.75rem;
2056
+
background: transparent;
2057
+
border: 1px solid var(--border-default);
2058
+
border-radius: var(--radius-full);
2059
+
color: var(--accent);
2060
+
font-size: var(--text-sm);
2061
+
font-family: inherit;
2062
+
cursor: pointer;
2063
+
transition: all 0.15s;
2064
+
width: auto;
2065
+
}
2066
+
2067
+
.undo-remove-btn:hover {
2068
+
border-color: var(--accent);
2069
+
background: color-mix(in srgb, var(--accent) 10%, transparent);
2070
+
transform: none;
2071
+
box-shadow: none;
2072
+
}
2073
+
2074
+
.artwork-empty {
2075
+
display: flex;
2076
+
flex-direction: column;
2077
+
align-items: center;
2078
+
gap: 0.5rem;
2079
+
padding: 0.75rem 1rem;
2080
+
color: var(--text-muted);
2081
+
}
2082
+
2083
+
.artwork-empty span {
2084
+
font-size: var(--text-sm);
2085
+
}
2086
+
2087
+
.artwork-upload-btn {
2088
+
display: inline-flex;
2089
+
align-items: center;
2090
+
gap: 0.4rem;
2091
+
padding: 0.5rem 0.85rem;
2092
+
background: transparent;
2093
+
border: 1px solid var(--accent);
2094
+
border-radius: var(--radius-full);
2095
+
color: var(--accent);
2096
+
font-size: var(--text-sm);
2097
+
font-weight: 500;
2098
+
cursor: pointer;
2099
+
transition: all 0.15s;
2100
+
margin-left: auto;
2101
+
}
2102
+
2103
+
.artwork-upload-btn:hover {
2104
+
background: color-mix(in srgb, var(--accent) 12%, transparent);
2105
+
}
2106
+
2107
+
.artwork-upload-btn input {
2108
+
display: none;
1871
2109
}
1872
2110
1873
2111
.edit-input:focus {
···
2424
2662
.track-actions {
2425
2663
margin-left: 0.5rem;
2426
2664
gap: 0.35rem;
2665
+
flex-direction: column;
2427
2666
}
2428
2667
2429
-
.action-btn {
2430
-
width: 30px;
2431
-
height: 30px;
2668
+
.track-action-btn {
2669
+
padding: 0.35rem 0.55rem;
2670
+
font-size: var(--text-xs);
2432
2671
}
2433
2672
2434
-
.action-btn svg {
2435
-
width: 14px;
2436
-
height: 14px;
2673
+
.track-action-btn svg {
2674
+
width: 12px;
2675
+
height: 12px;
2437
2676
}
2438
2677
2439
2678
/* edit mode mobile */
···
2455
2694
}
2456
2695
2457
2696
.edit-actions {
2458
-
gap: 0.35rem;
2697
+
gap: 0.5rem;
2698
+
flex-direction: column;
2699
+
}
2700
+
2701
+
.edit-cancel-btn,
2702
+
.edit-save-btn {
2703
+
width: 100%;
2704
+
padding: 0.6rem;
2705
+
font-size: var(--text-sm);
2706
+
}
2707
+
2708
+
/* artwork editor mobile */
2709
+
.artwork-editor {
2710
+
flex-direction: column;
2711
+
gap: 0.75rem;
2712
+
padding: 0.65rem;
2713
+
}
2714
+
2715
+
.artwork-preview {
2716
+
width: 64px;
2717
+
height: 64px;
2718
+
}
2719
+
2720
+
.artwork-upload-btn {
2721
+
margin-left: 0;
2459
2722
}
2460
2723
2461
2724
/* data section mobile */
+51
-4
frontend/src/routes/track/[id]/+page.svelte
+51
-4
frontend/src/routes/track/[id]/+page.svelte
···
1
1
<script lang="ts">
2
2
import { fade } from 'svelte/transition';
3
+
import { onMount } from 'svelte';
3
4
import { browser } from '$app/environment';
5
+
import { page } from '$app/stores';
4
6
import type { PageData } from './$types';
5
7
import { APP_NAME, APP_CANONICAL_URL } from '$lib/branding';
6
8
import { API_URL } from '$lib/config';
···
231
233
}
232
234
}
233
235
236
+
async function copyCommentLink(timestampMs: number) {
237
+
const seconds = Math.floor(timestampMs / 1000);
238
+
const url = `${window.location.origin}/track/${track.id}?t=${seconds}`;
239
+
await navigator.clipboard.writeText(url);
240
+
toast.success('link copied');
241
+
}
242
+
234
243
function formatRelativeTime(isoString: string): string {
235
244
const date = new Date(isoString);
236
245
const now = new Date();
···
308
317
// track if we've loaded liked state for this track (separate from general load)
309
318
let likedStateLoadedForTrackId = $state<number | null>(null);
310
319
320
+
// pending seek time from ?t= URL param (milliseconds)
321
+
let pendingSeekMs = $state<number | null>(null);
322
+
311
323
// reload data when navigating between track pages
312
324
// watch data.track.id (from server) not track.id (local state)
313
325
$effect(() => {
···
324
336
editingCommentId = null;
325
337
editingCommentText = '';
326
338
likedStateLoadedForTrackId = null; // reset liked state tracking
339
+
pendingSeekMs = null; // reset pending seek
327
340
328
341
// sync track from server data
329
342
track = data.track;
···
355
368
shareUrl = `${window.location.origin}/track/${track.id}`;
356
369
}
357
370
});
371
+
372
+
// handle ?t= timestamp param for deep linking (youtube-style)
373
+
onMount(() => {
374
+
const t = $page.url.searchParams.get('t');
375
+
if (t) {
376
+
const seconds = parseInt(t, 10);
377
+
if (!isNaN(seconds) && seconds >= 0) {
378
+
pendingSeekMs = seconds * 1000;
379
+
// load the track without auto-playing (browser blocks autoplay without interaction)
380
+
if (track.gated) {
381
+
void playTrack(track);
382
+
} else {
383
+
queue.playNow(track, false);
384
+
}
385
+
}
386
+
}
387
+
});
388
+
389
+
// perform pending seek once track is loaded and ready
390
+
$effect(() => {
391
+
if (
392
+
pendingSeekMs !== null &&
393
+
player.currentTrack?.id === track.id &&
394
+
player.audioElement &&
395
+
player.audioElement.readyState >= 1
396
+
) {
397
+
const seekTo = pendingSeekMs / 1000;
398
+
pendingSeekMs = null;
399
+
player.audioElement.currentTime = seekTo;
400
+
// don't auto-play - browser policy blocks it without user interaction
401
+
// user will click play themselves
402
+
}
403
+
});
358
404
</script>
359
405
360
406
<svelte:head>
···
647
693
</div>
648
694
{:else}
649
695
<p class="comment-text">{#each parseTextWithLinks(comment.text) as segment}{#if segment.type === 'link'}<a href={segment.url} target="_blank" rel="noopener noreferrer" class="comment-link">{segment.url}</a>{:else}{segment.content}{/if}{/each}</p>
650
-
{#if auth.user?.did === comment.user_did}
651
-
<div class="comment-actions">
696
+
<div class="comment-actions">
697
+
<button class="comment-action-btn" onclick={() => copyCommentLink(comment.timestamp_ms)}>share</button>
698
+
{#if auth.user?.did === comment.user_did}
652
699
<button class="comment-action-btn" onclick={() => startEditing(comment)}>edit</button>
653
700
<button class="comment-action-btn delete" onclick={() => deleteComment(comment.id)}>delete</button>
654
-
</div>
655
-
{/if}
701
+
{/if}
702
+
</div>
656
703
{/if}
657
704
</div>
658
705
</div>
update.wav
update.wav
This is a binary file and will not be displayed.