Compare changes

Choose any two refs to compare.

Changed files
+1141 -337
.claude
commands
.github
.status_history
backend
docs
frontend
src
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 47 47 preferences.data = data.preferences; 48 48 // fetch explicit images list (public, no auth needed) 49 49 moderation.initialize(); 50 + if (data.isAuthenticated && queue.revision === null) { 51 + void queue.fetchQueue(); 52 + } 50 53 } 51 54 }); 52 55
+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
··· 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

This is a binary file and will not be displayed.