Compare changes

Choose any two refs to compare.

Changed files
+1133 -334
.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.
+3 -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 ··· 298 299 fi 299 300 300 301 echo "Uploading as: $TITLE" 301 - 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" 302 303 env: 303 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.
+34 -164
STATUS.md
··· 79 79 80 80 --- 81 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 100 + 101 + --- 102 + 82 103 #### artist bio links (PRs #700-701, Jan 2) 83 104 84 105 **links in artist bios now render as clickable** - supports full URLs and bare domains (e.g., "example.com"): ··· 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) ··· 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.