commits
adds support for github.com/... style URLs without protocol prefix.
regex now matches domain.tld/path patterns and prepends https://.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* feat: render links in artist bio
Adds RichText component that auto-detects and renders:
- bare URLs (https://example.com, www.example.com)
- markdown-style links ([text](url))
Links open in new tab with proper security attributes.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: align link hover style with existing patterns
remove opacity transition to match .comment-link styling.
add word-break: break-all for long URLs.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Updates the atproto dependency to include the fix for OAuth scope
validation when using permission sets. The PDS expands `include:`
scopes into `repo?collection=` format, which the SDK now handles.
Also updates docs with correct DNS setup (`_lexicon` prefix, not `_atproto`).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
adds permission set lexicon that bundles OAuth permissions under
a human-readable title. users will see "plyr.fm Music Library" instead
of "fm.plyr.track, fm.plyr.like, fm.plyr.comment..."
lexicon:
- fm.plyr.authFullApp: full access for main web app
config:
- ATPROTO_USE_PERMISSION_SETS=true enables permission sets
- defaults to false (granular scopes) until lexicons are published
docs:
- research doc on how permission sets work
- updated lexicons overview with permission set section
to enable: publish lexicon to com.atproto.lexicon.schema on plyr.fm
authority repo, then set ATPROTO_USE_PERMISSION_SETS=true
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
FastAPI was matching /artists/batch as /{did} with did="batch".
Moving the POST /batch route before the GET /{did} route fixes this.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* feat: show atprotofans supporter count and list on artist pages
- add `getAtprotofansProfile` and `getAtprotofansSupporters` API calls
- display supporter count badge in support button
- add supporters section with avatar grid linking to bsky profiles
- responsive styling for mobile
uses atprotofans public endpoints:
- `com.atprotofans.getProfile` for supporter count
- `com.atprotofans.getSupporters` for list of supporters
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: remove count badge from support button, fix avatar handling
- remove supporter count from support button (unnecessary)
- properly check avatar field before rendering
- clean up unused CSS
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: enrich atprotofans supporters with avatars from Bluesky
Fetch each supporter's profile from Bluesky's public API
(app.bsky.actor.getProfile) to get their avatar URL, following
the same pattern used elsewhere in the app where backend
provides avatar_url for display.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor: remove frontend Bluesky API calls for avatar enrichment
Don't add external API surface area from the frontend - use what
atprotofans returns directly. If avatars aren't provided, the
placeholder will show. Backend should handle enrichment if needed.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: enrich supporters with avatar_url via backend batch endpoint
- Add POST /artists/batch endpoint to get artist data for multiple DIDs
- Frontend calls atprotofans for supporter DIDs, then enriches via our backend
- Uses same pattern as likers: avatar_url comes from Artist table
- Use SensitiveImage wrapper and initials placeholder (consistent with LikersTooltip)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* style: compact overlapping avatar circles for supporters
GitHub-sponsors style: small circles with negative margin overlap,
"+N" badge for overflow. Bounded height regardless of supporter count.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: link supporter avatars to plyr.fm artist pages
Keep users in the app instead of linking to Bluesky.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- frontend: rename `dev` to `run` (keep `dev` as alias for compat)
- add `r` alias to backend and frontend (matches transcoder/moderation)
- add `just tunnel` command for ngrok
- update docs to reference `just frontend run`
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- Removes shuffle button from PlaybackControls
- Adds shuffle button to Queue header next to clear button
- Same functionality, just relocated to reduce player clutter
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add feed/library toggle to maintain consistent header items
When on library page, shows "feed" button to go home.
When not on library, shows "library" button.
This keeps the same number of nav items regardless of page,
preventing layout shifts from space-between redistribution.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: include liked and playlist pages in collection check
Shows feed button on /library, /liked, and /playlist/* pages.
Shows library button everywhere else.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: show feed/library buttons independently
- Feed button: shows when not on homepage
- Library button: shows when not on /library
- Both can show at the same time, space-between handles it
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* feat: redesign header for cleaner desktop layout
- add UserMenu dropdown (handle + portal/settings/logout)
- simplify desktop nav: search | library | upload | user menu
- move social links and stats to LinksMenu only (mobile)
- remove unused SearchTrigger and SettingsMenu components
- update CLAUDE.md to reflect component changes
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* style: increase nav item spacing for better visual rhythm
* style: flatten header structure for even space-between distribution
* fix: restore social links (bluesky, status, tangled) to desktop header
* fix: move social links to left of logo (original position)
* feat: add social links to header left margin
Restores Bluesky, status page, and Tangled links in absolutely
positioned left margin area, outside the main header content flow.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: hide social links on narrow desktop screens
Adds media query to hide margin-left when viewport is under 1000px,
preventing overflow when there's insufficient margin space.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: remove nav spacers for proper redistribution
Nav items now naturally redistribute when library/upload links are
hidden on their respective pages, instead of leaving awkward gaps.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
When Claude flags an uploaded image (track cover or album cover) as
sensitive, send a DM to the admin with details about the flag.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Update model from claude-3-5-sonnet-latest to claude-sonnet-4-5-20250929.
Tested and verified working with production deployment.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
adds automated scanning of uploaded images (track covers, album covers)
for policy violations using Claude Sonnet vision capabilities.
## moderation service (rust)
- new `/scan-image` endpoint accepts multipart form with image + image_id
- `claude.rs`: Claude API client with vision support
- `image_scans` table for cost tracking and audit trail
- auto-flags unsafe images to `sensitive_images` table
## backend (python)
- `ModerationClient.scan_image()` method for calling the new endpoint
- integration in `upload_track_image()` and album cover upload
- `image_moderation_enabled` setting (default: true)
moderation is best-effort - failures are logged but don't block uploads.
flagged images are blurred in the UI (existing sensitive_images behavior).
closes #166
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* feat: sync avatar on login and add to ATProto profile record
- add optional avatar field to fm.plyr.actor.profile lexicon
- update profile record builder to accept and include avatar
- refresh avatar from bluesky on login sync
- update postgres if avatar changed
- sync avatar to ATProto profile record
fixes stale avatars in likers tooltip and other displays
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* add one-time avatar backfill script
refreshes avatar_url for all artists from bluesky. run with:
cd backend && uv run python ../scripts/backfill_avatars.py --dry-run
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
adds a "top tracks" section above the existing "latest tracks" feed,
showing the 10 most-liked tracks on the platform. this helps surface
quality content instead of the homepage being dominated by bulk uploads.
backend:
- new `/tracks/top` endpoint returning tracks ordered by like count
- `get_top_track_ids()` aggregation helper
frontend:
- fetches top tracks concurrently with latest tracks on mount
- displays section only when there are liked tracks
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
previously VIOLATION flags were ignored and left in pending limbo.
now anything that isn't auto-resolved as FALSE_POSITIVE goes to the
batch for human review.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
displays count matching current filter (e.g., "27 pending", "5 resolved")
right-aligned in the filter row for quick visibility
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- add PlyrClient to check track existence via plyr.fm API
- before LLM analysis, check each flagged track still exists
- auto-resolve with reason "content_deleted" if track returns 404
- add ContentDeleted variant to ResolutionReason enum in Rust
prevents labels from persisting in the ether for deleted content
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- move review to /admin/review/:id (proper admin namespace)
- use admin.css for consistent styling with dashboard
- add "← back to dashboard" navigation link
- add three action types:
- clear: false positive, emit negation label
- defer: acknowledge but take no action (flag stays active)
- confirm: mark as real violation (flag stays active)
- toggle decisions by clicking same button again
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* fix: add auth flow to review page like admin
- make /review/:id HTML page public (keep data/submit protected)
- add auth input, localStorage token check/save
- send X-Moderation-Key header with submit request
- handle 401 by showing auth prompt again
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* ci: only check rust services that actually changed
use dorny/paths-filter to detect which service changed,
skip cargo check and docker build for unchanged services.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
batch['url'] returns relative path, prepend base_url for clickable link.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* feat(moderation): add batch review system with mobile-friendly UI
- Add review batch tables (review_batches, batch_flags) with migrations
- Add /admin/batches POST endpoint to create review batches
- Add /review/:id endpoints for auth-protected review UI
- Review page renders server-side HTML with embedded JS
- Same auth middleware as admin endpoints (X-Moderation-Key header)
- Update moderation_loop.py to create batches and send DM links
- Simplify loop: DM is now just a notification channel, not for parsing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat(ci): add workflow dispatch for moderation loop
Allows triggering the moderation loop from GitHub Actions UI with:
- dry_run toggle (default: true for safety)
- limit input for testing with subset of flags
- env selector (prod/staging/dev)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- environments.md: update redis column to show fly.io apps
- background-tasks.md: update production/staging section with fly URLs and pricing
- docket_runs.py: update hints to show flyctl proxy commands
- fly.toml/fly.staging.toml: update DOCKET_URL comments
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- redis/fly.staging.toml: staging Redis config
- redis/fly.toml: fix services to reference processes
- .github/workflows/deploy-redis.yml: deploy on changes to redis/
Requires FLY_API_TOKEN_REDIS secret to be set in GitHub.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* infra: add self-hosted redis config, remove upstash
Upstash was costing ~$75/month at 37M commands. Self-hosted Redis on
Fly will cost ~$2/month.
Changes:
- redis/fly.toml: Redis 7 Alpine with AOF persistence, 256MB VM
- redis/README.md: deployment and switchover instructions
- Remove upstash from costs script and frontend (redis cost is
included in fly_io since it's a Fly VM)
Deployment steps:
1. fly apps create plyr-redis
2. fly volumes create redis_data --region iad --size 1 -a plyr-redis
3. fly deploy -a plyr-redis (from redis/ dir)
4. fly secrets set DOCKET_URL=redis://plyr-redis.internal:6379 -a relay-api
5. Verify docket tasks work
6. fly redis destroy plyr-redis-prd
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* docs: update README costs and project structure for redis
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
The portal page was making 4 API calls sequentially during mount:
- loadMyTracks()
- loadArtistProfile()
- loadMyAlbums()
- loadMyPlaylists()
Each waited for the previous to complete before starting. Since these
are independent, run them in parallel with Promise.all() to reduce
time-to-interactive.
Expected improvement: ~300-800ms reduction in LCP depending on network
conditions (from sequential to parallel latency).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
adopts the track detail page pattern for playlist and album pages:
- show "play" / "pause" text (not "play now" / icon-only)
- consistent pause icon path across all pages
- add ethereal-glow animation to track page for consistency
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
adds warning about modifying Docket/Worker constructor args after the
heartbeat_interval=30s change broke all task execution. documents:
- which settings not to change
- the incident timeline
- testing requirements if settings must be changed
also filed issue on pydocket: https://github.com/chrisguidry/docket/issues/267
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
The 30s heartbeat_interval added in #653 broke background task execution.
Likes scheduled since Dec 29 deploy have null atproto_like_uri.
Reverts the Docket heartbeat_interval back to default (2s).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
the play button on playlist and album pages wasn't resuming playback
after pausing. the issue was in the `isPlaylistPlaying` derived state
which included `!player.paused` - when paused, this became false,
causing the button to call `playNow()` instead of `togglePlayPause()`.
since the queue already had the same track at index 0, `setQueue` did
nothing and playback didn't resume.
fix: separate "is this playlist/album active" (track is from here) from
"is it playing" (active AND not paused). button now uses `isActive` to
decide toggle vs start fresh, while `isPlaying` controls the visual state.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- Play button toggles between play/pause when collection is playing
- Shows pause icon (no text) when active, "play now" when inactive
- Subtle breathing glow animation indicates playback state
- Shared keyframes in layout for consistent behavior across pages
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- consolidate input/textarea styles in portal page (-12 lines)
- add docs/frontend/design-tokens.md documenting all CSS tokens
- update frontend/CLAUDE.md with design tokens guidance
closes #661
* refactor(css): add typography scale tokens
adds a typography scale to the design system and replaces hardcoded
font-size values across the frontend.
tokens added:
- --text-xs: 0.75rem
- --text-sm: 0.85rem
- --text-base: 0.9rem
- --text-lg: 1rem
- --text-xl: 1.1rem
- --text-2xl: 1.25rem
- --text-3xl: 1.5rem
semantic aliases updated to use scale:
- --text-page-heading: var(--text-3xl)
- --text-body: var(--text-lg)
- --text-small: var(--text-base)
397/471 occurrences tokenized (84%). remaining 74 are edge cases
(large display text, very small text, pixel values).
normalizations:
- 0.7rem → --text-xs (0.75rem)
- 0.8rem → --text-sm (0.85rem)
- 0.95rem → --text-base (0.9rem)
closes #658
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* chore: add digest slash command for external resource analysis
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* refactor(css): add border-radius design tokens
adds a border-radius scale to the design system and replaces hardcoded
values across 46 frontend files.
tokens added:
- --radius-sm: 4px
- --radius-base: 6px
- --radius-md: 8px
- --radius-lg: 12px
- --radius-xl: 16px
- --radius-2xl: 24px
- --radius-full: 9999px
285/296 occurrences tokenized (96%). remaining 11 are intentional edge
cases (partial radii, calc(), 2px slider tracks).
normalizations:
- 5px, 3px → --radius-sm (4px)
- 10px → --radius-md (8px)
- 20px → --radius-xl (16px)
closes #657
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* chore: update STATUS.md for end-of-year QoL mode
- sprint #625 complete, moved to quality of life mode
- documented what shipped (moderation consolidation, atprotofans)
- deferred rules engine and time-release gating to aspirational
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
restored original styling that was accidentally replaced:
- chart bars now use margin-top: auto (bars grow upward)
- heart icon in support section restored
- time range toggle uses font-family: inherit
- correct CSS variables (--bg-tertiary, --border-subtle)
kept the minimal upstash additions from previous commit.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- show upload progress percentage in error messages (e.g. "failed at 45%")
- detect mobile devices and show targeted guidance for large file failures
- warn mobile users proactively when uploading files >50MB
- provide actionable suggestions (WiFi, desktop browser) instead of generic errors
context: user reported upload failures on mobile with unhelpful error messages
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- increase docket heartbeat interval from 2s to 30s
- reduces redis commands by ~15x (heartbeat tracks 12 tasks)
- dead worker detection: 10s → 2.5min (acceptable for 5-min perpetual task)
- add upstash to /costs dashboard
- track redis usage in cost breakdown
- currently free tier (256MB, 500K commands/month)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- core cleanup shipped (PR #617)
- sensitive images moved to moderation service (PR #644)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
consolidates sensitive image management in the rust moderation service:
- adds sensitive_images table to moderation db with migration
- adds GET /sensitive-images (public), POST /admin/sensitive-images,
POST /admin/sensitive-images/remove endpoints to moderation service
- adds get_sensitive_images() method to ModerationClient
- updates backend /moderation/sensitive-images to proxy to moderation service
- adds migration script for existing data
closes #544
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
"gate tracks for supporters" → "offer exclusive tracks to supporters"
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
the handle-row uses flexbox which ignores text-align from the parent.
added justify-content: center in the mobile media query.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
The liked state wasn't loading on the track detail page because of a
race condition with auth initialization:
1. Page loads, navigation effect runs immediately
2. Effect sets loadedForTrackId = currentId
3. BUT auth.isAuthenticated is still false (async auth not resolved)
4. loadLikedState() is skipped
5. Auth resolves, isAuthenticated becomes true
6. Effect runs again, but loadedForTrackId === currentId, so nothing happens
7. Liked state is never fetched
Fix: Split into two separate effects:
- One for general track data (comments, etc.) - runs immediately
- One for liked state - runs when auth.isAuthenticated becomes true
This ensures the liked heart shows correctly even when auth loads after
the initial page render.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- use slice-based filtering for time ranges (shows last N days)
- fix chart overflow with flex shrink and min-width: 0
- add font-family: inherit to toggle buttons
- reduce label font size and add overflow handling
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- export script now queries 30 days of daily data (independent of billing cycle)
- frontend adds toggle buttons (24h / 7d / 30d) to filter the chart
- billing period stats (free remaining, billable) still use AudD cycle for cost accuracy
- defaults to 30d view
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* fix: lower header mobile breakpoint from 1599px to 1100px
the header was switching to mobile layout at 1599px, way too high.
at 1512px viewport width, users were seeing mobile header (no stats,
no icons) even on desktop.
changes:
- lower header breakpoint to 1100px (800px content + margin space)
- add breakpoints.ts as single source of truth for breakpoint values
- add comments referencing breakpoints.ts in Header.svelte
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: hide search in margin at 1300px to prevent crowding stats
adds intermediate breakpoint - search hides from margin before stats,
giving stats more breathing room as viewport narrows.
breakpoints now:
- >1300px: full desktop (stats + search in margin)
- 1100-1300px: stats only in margin (search hidden)
- <1100px: mobile layout (margin elements hidden)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: switch to mobile layout at 1300px instead of intermediate state
simpler approach: just switch to mobile layout when margin space gets
tight, rather than hiding individual elements.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
the `gated` field is viewer-resolved (true = no access, false = has access).
previously, auto-download would attempt for all tracks then fail silently.
now we pass `gated` through the like flow and only skip when gated === true.
- supporters (gated === false): download proceeds normally
- non-supporters (gated === true): download skipped client-side
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- shows lock icon next to title when track.gated is true
- guards addToQueue with gated check + toast
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* fix: resolve gated status server-side, show lock icon for inaccessible content
- add `gated: bool` field to TrackResponse that resolves access at serialization
- backend checks if viewer is owner or supporter before returning tracks
- add `get_supported_artists()` helper for batch atprotofans API checks
- change frontend icon from heart to lock for gated content
- lock only shows when content is actually inaccessible to the viewer
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* docs: add R2_PRIVATE_BUCKET to configuration docs
* fix: update portal page to use lock icon for gated tracks
* feat: show toast when non-supporter tries to queue gated track
adds sync guard function that uses server-resolved gated status
to show toast without network call when adding to queue.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add supporter-gated content infrastructure
backend support for atprotofans content gating:
- create private R2 buckets (audio-private-dev/staging/prod)
- add R2_PRIVATE_BUCKET and PRESIGNED_URL_EXPIRY_SECONDS settings
- implement save_gated() and generate_presigned_url() in R2Storage
- add supportGate field to fm.plyr.track lexicon
- add support_gate JSONB column to tracks table
- add atprotofans validation helper (_internal/atprotofans.py)
- update audio endpoint to check supporter status for gated tracks
- 401 if not authenticated
- 402 if not a supporter
- presigned URL redirect if valid supporter
the supportGate object starts with type: "any" (any support unlocks),
with room to grow for tiers (recurring, minimum amounts, etc.)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add support_gate parameter to upload flow
enable artists to upload supporter-gated tracks:
- add support_gate form parameter to upload endpoint
- validate support_gate JSON structure (must have type: "any")
- require atprotofans to be enabled in settings to use gating
- use save_gated() for gated tracks (private R2 bucket)
- store support_gate in Track model and ATProto record
- gated tracks use API endpoint URL instead of direct R2 URL
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat(frontend): add UI for supporter-gated tracks
- TrackItem: show heart badge overlay on gated tracks with dimmed image
- Player: detect 401/402 on gated content, show toast with supporter CTA
- Upload: add "supporters only" toggle when artist has atprotofans enabled
- Types: add SupportGate interface and support_gate field to Track
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: complete supporter-gated content implementation
- fix presigned URL generation with SigV4 signature (was returning 401)
- add playback helper to check gated access BEFORE modifying queue state
(clicking locked track no longer interrupts current playback)
- add support_gate toggle to track edit UI in portal
- centralize atprotofans support URL generation in config.ts
- add 7 regression tests for gated content access control
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: migrate audio to private bucket when enabling support_gate
when enabling support_gate on an existing track, the audio file must be
migrated from the public bucket to the private bucket. otherwise the
original public r2_url remains accessible, bypassing the paywall.
- add R2Storage.migrate_to_private_bucket() - copies file then deletes original
- add migrate_track_to_private_bucket background task
- schedule migration in PATCH endpoint when support_gate is enabled on
a track that has an r2_url (indicating it's in the public bucket)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor: unify audio bucket migration to single move_audio method
- R2Storage.move_audio(file_id, extension, to_private) handles both directions
- move_track_audio background task replaces separate migrate_to_private/public
- PATCH endpoint schedules move when toggling support_gate in either direction:
- enabling gate on public track → move to private
- disabling gate on private track → move to public
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: sync ATProto record when toggling support_gate
- include support_gate changes in metadata_changed check
- pass support_gate to build_track_record
- use backend API URL for gated tracks (r2_url is None)
- fix upload page: link to /portal not /settings for atprotofans setup
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
direct atprotofans contributions use the artist's DID as the signer,
not the broker DID. the broker DID is only used when plyr.fm is a
registered platform that created the support template.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
beartype runtime type checking requires exact type matches.
`progress_pct=0` (int) fails validation against `float | None`.
fixes upload failure: "BeartypeCallHintParamViolation: parameter
progress_pct=0 violates type hint float | None"
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
most users are listeners first, uploaders second - landing on the library
(liked tracks, playlists) is a better default than the portal (artist management).
- change backend OAuth callback to redirect to /library
- add exchange_token handling to library page (same pattern as portal)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- add SupporterBadge component with heart icon styling
- add validateSupporter API call to artist page when viewer is logged in
- call atprotofans API directly from frontend (public endpoint)
- use broker DID for signer parameter
- only show badge when:
- viewer is authenticated
- artist has support_url: 'atprotofans'
- viewer is not the artist themselves
- validation returns valid: true
- update research doc with implementation status and correct API usage
- add new research doc for supporter-gated content architecture (R2 presigned URLs)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- documented PR #629 rate limiting fix for moderation endpoint
- added tip to status-update command suggesting use after /deploy
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* feat: render links in artist bio
Adds RichText component that auto-detects and renders:
- bare URLs (https://example.com, www.example.com)
- markdown-style links ([text](url))
Links open in new tab with proper security attributes.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: align link hover style with existing patterns
remove opacity transition to match .comment-link styling.
add word-break: break-all for long URLs.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Updates the atproto dependency to include the fix for OAuth scope
validation when using permission sets. The PDS expands `include:`
scopes into `repo?collection=` format, which the SDK now handles.
Also updates docs with correct DNS setup (`_lexicon` prefix, not `_atproto`).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
adds permission set lexicon that bundles OAuth permissions under
a human-readable title. users will see "plyr.fm Music Library" instead
of "fm.plyr.track, fm.plyr.like, fm.plyr.comment..."
lexicon:
- fm.plyr.authFullApp: full access for main web app
config:
- ATPROTO_USE_PERMISSION_SETS=true enables permission sets
- defaults to false (granular scopes) until lexicons are published
docs:
- research doc on how permission sets work
- updated lexicons overview with permission set section
to enable: publish lexicon to com.atproto.lexicon.schema on plyr.fm
authority repo, then set ATPROTO_USE_PERMISSION_SETS=true
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
FastAPI was matching /artists/batch as /{did} with did="batch".
Moving the POST /batch route before the GET /{did} route fixes this.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* feat: show atprotofans supporter count and list on artist pages
- add `getAtprotofansProfile` and `getAtprotofansSupporters` API calls
- display supporter count badge in support button
- add supporters section with avatar grid linking to bsky profiles
- responsive styling for mobile
uses atprotofans public endpoints:
- `com.atprotofans.getProfile` for supporter count
- `com.atprotofans.getSupporters` for list of supporters
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: remove count badge from support button, fix avatar handling
- remove supporter count from support button (unnecessary)
- properly check avatar field before rendering
- clean up unused CSS
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: enrich atprotofans supporters with avatars from Bluesky
Fetch each supporter's profile from Bluesky's public API
(app.bsky.actor.getProfile) to get their avatar URL, following
the same pattern used elsewhere in the app where backend
provides avatar_url for display.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor: remove frontend Bluesky API calls for avatar enrichment
Don't add external API surface area from the frontend - use what
atprotofans returns directly. If avatars aren't provided, the
placeholder will show. Backend should handle enrichment if needed.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: enrich supporters with avatar_url via backend batch endpoint
- Add POST /artists/batch endpoint to get artist data for multiple DIDs
- Frontend calls atprotofans for supporter DIDs, then enriches via our backend
- Uses same pattern as likers: avatar_url comes from Artist table
- Use SensitiveImage wrapper and initials placeholder (consistent with LikersTooltip)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* style: compact overlapping avatar circles for supporters
GitHub-sponsors style: small circles with negative margin overlap,
"+N" badge for overflow. Bounded height regardless of supporter count.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: link supporter avatars to plyr.fm artist pages
Keep users in the app instead of linking to Bluesky.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- frontend: rename `dev` to `run` (keep `dev` as alias for compat)
- add `r` alias to backend and frontend (matches transcoder/moderation)
- add `just tunnel` command for ngrok
- update docs to reference `just frontend run`
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add feed/library toggle to maintain consistent header items
When on library page, shows "feed" button to go home.
When not on library, shows "library" button.
This keeps the same number of nav items regardless of page,
preventing layout shifts from space-between redistribution.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: include liked and playlist pages in collection check
Shows feed button on /library, /liked, and /playlist/* pages.
Shows library button everywhere else.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: show feed/library buttons independently
- Feed button: shows when not on homepage
- Library button: shows when not on /library
- Both can show at the same time, space-between handles it
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* feat: redesign header for cleaner desktop layout
- add UserMenu dropdown (handle + portal/settings/logout)
- simplify desktop nav: search | library | upload | user menu
- move social links and stats to LinksMenu only (mobile)
- remove unused SearchTrigger and SettingsMenu components
- update CLAUDE.md to reflect component changes
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* style: increase nav item spacing for better visual rhythm
* style: flatten header structure for even space-between distribution
* fix: restore social links (bluesky, status, tangled) to desktop header
* fix: move social links to left of logo (original position)
* feat: add social links to header left margin
Restores Bluesky, status page, and Tangled links in absolutely
positioned left margin area, outside the main header content flow.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: hide social links on narrow desktop screens
Adds media query to hide margin-left when viewport is under 1000px,
preventing overflow when there's insufficient margin space.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: remove nav spacers for proper redistribution
Nav items now naturally redistribute when library/upload links are
hidden on their respective pages, instead of leaving awkward gaps.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Update model from claude-3-5-sonnet-latest to claude-sonnet-4-5-20250929.
Tested and verified working with production deployment.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
adds automated scanning of uploaded images (track covers, album covers)
for policy violations using Claude Sonnet vision capabilities.
## moderation service (rust)
- new `/scan-image` endpoint accepts multipart form with image + image_id
- `claude.rs`: Claude API client with vision support
- `image_scans` table for cost tracking and audit trail
- auto-flags unsafe images to `sensitive_images` table
## backend (python)
- `ModerationClient.scan_image()` method for calling the new endpoint
- integration in `upload_track_image()` and album cover upload
- `image_moderation_enabled` setting (default: true)
moderation is best-effort - failures are logged but don't block uploads.
flagged images are blurred in the UI (existing sensitive_images behavior).
closes #166
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* feat: sync avatar on login and add to ATProto profile record
- add optional avatar field to fm.plyr.actor.profile lexicon
- update profile record builder to accept and include avatar
- refresh avatar from bluesky on login sync
- update postgres if avatar changed
- sync avatar to ATProto profile record
fixes stale avatars in likers tooltip and other displays
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* add one-time avatar backfill script
refreshes avatar_url for all artists from bluesky. run with:
cd backend && uv run python ../scripts/backfill_avatars.py --dry-run
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
adds a "top tracks" section above the existing "latest tracks" feed,
showing the 10 most-liked tracks on the platform. this helps surface
quality content instead of the homepage being dominated by bulk uploads.
backend:
- new `/tracks/top` endpoint returning tracks ordered by like count
- `get_top_track_ids()` aggregation helper
frontend:
- fetches top tracks concurrently with latest tracks on mount
- displays section only when there are liked tracks
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- add PlyrClient to check track existence via plyr.fm API
- before LLM analysis, check each flagged track still exists
- auto-resolve with reason "content_deleted" if track returns 404
- add ContentDeleted variant to ResolutionReason enum in Rust
prevents labels from persisting in the ether for deleted content
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- move review to /admin/review/:id (proper admin namespace)
- use admin.css for consistent styling with dashboard
- add "← back to dashboard" navigation link
- add three action types:
- clear: false positive, emit negation label
- defer: acknowledge but take no action (flag stays active)
- confirm: mark as real violation (flag stays active)
- toggle decisions by clicking same button again
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* fix: add auth flow to review page like admin
- make /review/:id HTML page public (keep data/submit protected)
- add auth input, localStorage token check/save
- send X-Moderation-Key header with submit request
- handle 401 by showing auth prompt again
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* ci: only check rust services that actually changed
use dorny/paths-filter to detect which service changed,
skip cargo check and docker build for unchanged services.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* feat(moderation): add batch review system with mobile-friendly UI
- Add review batch tables (review_batches, batch_flags) with migrations
- Add /admin/batches POST endpoint to create review batches
- Add /review/:id endpoints for auth-protected review UI
- Review page renders server-side HTML with embedded JS
- Same auth middleware as admin endpoints (X-Moderation-Key header)
- Update moderation_loop.py to create batches and send DM links
- Simplify loop: DM is now just a notification channel, not for parsing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat(ci): add workflow dispatch for moderation loop
Allows triggering the moderation loop from GitHub Actions UI with:
- dry_run toggle (default: true for safety)
- limit input for testing with subset of flags
- env selector (prod/staging/dev)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- environments.md: update redis column to show fly.io apps
- background-tasks.md: update production/staging section with fly URLs and pricing
- docket_runs.py: update hints to show flyctl proxy commands
- fly.toml/fly.staging.toml: update DOCKET_URL comments
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- redis/fly.staging.toml: staging Redis config
- redis/fly.toml: fix services to reference processes
- .github/workflows/deploy-redis.yml: deploy on changes to redis/
Requires FLY_API_TOKEN_REDIS secret to be set in GitHub.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* infra: add self-hosted redis config, remove upstash
Upstash was costing ~$75/month at 37M commands. Self-hosted Redis on
Fly will cost ~$2/month.
Changes:
- redis/fly.toml: Redis 7 Alpine with AOF persistence, 256MB VM
- redis/README.md: deployment and switchover instructions
- Remove upstash from costs script and frontend (redis cost is
included in fly_io since it's a Fly VM)
Deployment steps:
1. fly apps create plyr-redis
2. fly volumes create redis_data --region iad --size 1 -a plyr-redis
3. fly deploy -a plyr-redis (from redis/ dir)
4. fly secrets set DOCKET_URL=redis://plyr-redis.internal:6379 -a relay-api
5. Verify docket tasks work
6. fly redis destroy plyr-redis-prd
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* docs: update README costs and project structure for redis
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
The portal page was making 4 API calls sequentially during mount:
- loadMyTracks()
- loadArtistProfile()
- loadMyAlbums()
- loadMyPlaylists()
Each waited for the previous to complete before starting. Since these
are independent, run them in parallel with Promise.all() to reduce
time-to-interactive.
Expected improvement: ~300-800ms reduction in LCP depending on network
conditions (from sequential to parallel latency).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
adopts the track detail page pattern for playlist and album pages:
- show "play" / "pause" text (not "play now" / icon-only)
- consistent pause icon path across all pages
- add ethereal-glow animation to track page for consistency
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
adds warning about modifying Docket/Worker constructor args after the
heartbeat_interval=30s change broke all task execution. documents:
- which settings not to change
- the incident timeline
- testing requirements if settings must be changed
also filed issue on pydocket: https://github.com/chrisguidry/docket/issues/267
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
The 30s heartbeat_interval added in #653 broke background task execution.
Likes scheduled since Dec 29 deploy have null atproto_like_uri.
Reverts the Docket heartbeat_interval back to default (2s).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
the play button on playlist and album pages wasn't resuming playback
after pausing. the issue was in the `isPlaylistPlaying` derived state
which included `!player.paused` - when paused, this became false,
causing the button to call `playNow()` instead of `togglePlayPause()`.
since the queue already had the same track at index 0, `setQueue` did
nothing and playback didn't resume.
fix: separate "is this playlist/album active" (track is from here) from
"is it playing" (active AND not paused). button now uses `isActive` to
decide toggle vs start fresh, while `isPlaying` controls the visual state.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- Play button toggles between play/pause when collection is playing
- Shows pause icon (no text) when active, "play now" when inactive
- Subtle breathing glow animation indicates playback state
- Shared keyframes in layout for consistent behavior across pages
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* refactor(css): add typography scale tokens
adds a typography scale to the design system and replaces hardcoded
font-size values across the frontend.
tokens added:
- --text-xs: 0.75rem
- --text-sm: 0.85rem
- --text-base: 0.9rem
- --text-lg: 1rem
- --text-xl: 1.1rem
- --text-2xl: 1.25rem
- --text-3xl: 1.5rem
semantic aliases updated to use scale:
- --text-page-heading: var(--text-3xl)
- --text-body: var(--text-lg)
- --text-small: var(--text-base)
397/471 occurrences tokenized (84%). remaining 74 are edge cases
(large display text, very small text, pixel values).
normalizations:
- 0.7rem → --text-xs (0.75rem)
- 0.8rem → --text-sm (0.85rem)
- 0.95rem → --text-base (0.9rem)
closes #658
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* chore: add digest slash command for external resource analysis
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* refactor(css): add border-radius design tokens
adds a border-radius scale to the design system and replaces hardcoded
values across 46 frontend files.
tokens added:
- --radius-sm: 4px
- --radius-base: 6px
- --radius-md: 8px
- --radius-lg: 12px
- --radius-xl: 16px
- --radius-2xl: 24px
- --radius-full: 9999px
285/296 occurrences tokenized (96%). remaining 11 are intentional edge
cases (partial radii, calc(), 2px slider tracks).
normalizations:
- 5px, 3px → --radius-sm (4px)
- 10px → --radius-md (8px)
- 20px → --radius-xl (16px)
closes #657
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* chore: update STATUS.md for end-of-year QoL mode
- sprint #625 complete, moved to quality of life mode
- documented what shipped (moderation consolidation, atprotofans)
- deferred rules engine and time-release gating to aspirational
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
restored original styling that was accidentally replaced:
- chart bars now use margin-top: auto (bars grow upward)
- heart icon in support section restored
- time range toggle uses font-family: inherit
- correct CSS variables (--bg-tertiary, --border-subtle)
kept the minimal upstash additions from previous commit.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- show upload progress percentage in error messages (e.g. "failed at 45%")
- detect mobile devices and show targeted guidance for large file failures
- warn mobile users proactively when uploading files >50MB
- provide actionable suggestions (WiFi, desktop browser) instead of generic errors
context: user reported upload failures on mobile with unhelpful error messages
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- increase docket heartbeat interval from 2s to 30s
- reduces redis commands by ~15x (heartbeat tracks 12 tasks)
- dead worker detection: 10s → 2.5min (acceptable for 5-min perpetual task)
- add upstash to /costs dashboard
- track redis usage in cost breakdown
- currently free tier (256MB, 500K commands/month)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
consolidates sensitive image management in the rust moderation service:
- adds sensitive_images table to moderation db with migration
- adds GET /sensitive-images (public), POST /admin/sensitive-images,
POST /admin/sensitive-images/remove endpoints to moderation service
- adds get_sensitive_images() method to ModerationClient
- updates backend /moderation/sensitive-images to proxy to moderation service
- adds migration script for existing data
closes #544
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
The liked state wasn't loading on the track detail page because of a
race condition with auth initialization:
1. Page loads, navigation effect runs immediately
2. Effect sets loadedForTrackId = currentId
3. BUT auth.isAuthenticated is still false (async auth not resolved)
4. loadLikedState() is skipped
5. Auth resolves, isAuthenticated becomes true
6. Effect runs again, but loadedForTrackId === currentId, so nothing happens
7. Liked state is never fetched
Fix: Split into two separate effects:
- One for general track data (comments, etc.) - runs immediately
- One for liked state - runs when auth.isAuthenticated becomes true
This ensures the liked heart shows correctly even when auth loads after
the initial page render.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- use slice-based filtering for time ranges (shows last N days)
- fix chart overflow with flex shrink and min-width: 0
- add font-family: inherit to toggle buttons
- reduce label font size and add overflow handling
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- export script now queries 30 days of daily data (independent of billing cycle)
- frontend adds toggle buttons (24h / 7d / 30d) to filter the chart
- billing period stats (free remaining, billable) still use AudD cycle for cost accuracy
- defaults to 30d view
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* fix: lower header mobile breakpoint from 1599px to 1100px
the header was switching to mobile layout at 1599px, way too high.
at 1512px viewport width, users were seeing mobile header (no stats,
no icons) even on desktop.
changes:
- lower header breakpoint to 1100px (800px content + margin space)
- add breakpoints.ts as single source of truth for breakpoint values
- add comments referencing breakpoints.ts in Header.svelte
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: hide search in margin at 1300px to prevent crowding stats
adds intermediate breakpoint - search hides from margin before stats,
giving stats more breathing room as viewport narrows.
breakpoints now:
- >1300px: full desktop (stats + search in margin)
- 1100-1300px: stats only in margin (search hidden)
- <1100px: mobile layout (margin elements hidden)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: switch to mobile layout at 1300px instead of intermediate state
simpler approach: just switch to mobile layout when margin space gets
tight, rather than hiding individual elements.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
the `gated` field is viewer-resolved (true = no access, false = has access).
previously, auto-download would attempt for all tracks then fail silently.
now we pass `gated` through the like flow and only skip when gated === true.
- supporters (gated === false): download proceeds normally
- non-supporters (gated === true): download skipped client-side
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* fix: resolve gated status server-side, show lock icon for inaccessible content
- add `gated: bool` field to TrackResponse that resolves access at serialization
- backend checks if viewer is owner or supporter before returning tracks
- add `get_supported_artists()` helper for batch atprotofans API checks
- change frontend icon from heart to lock for gated content
- lock only shows when content is actually inaccessible to the viewer
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* docs: add R2_PRIVATE_BUCKET to configuration docs
* fix: update portal page to use lock icon for gated tracks
* feat: show toast when non-supporter tries to queue gated track
adds sync guard function that uses server-resolved gated status
to show toast without network call when adding to queue.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add supporter-gated content infrastructure
backend support for atprotofans content gating:
- create private R2 buckets (audio-private-dev/staging/prod)
- add R2_PRIVATE_BUCKET and PRESIGNED_URL_EXPIRY_SECONDS settings
- implement save_gated() and generate_presigned_url() in R2Storage
- add supportGate field to fm.plyr.track lexicon
- add support_gate JSONB column to tracks table
- add atprotofans validation helper (_internal/atprotofans.py)
- update audio endpoint to check supporter status for gated tracks
- 401 if not authenticated
- 402 if not a supporter
- presigned URL redirect if valid supporter
the supportGate object starts with type: "any" (any support unlocks),
with room to grow for tiers (recurring, minimum amounts, etc.)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add support_gate parameter to upload flow
enable artists to upload supporter-gated tracks:
- add support_gate form parameter to upload endpoint
- validate support_gate JSON structure (must have type: "any")
- require atprotofans to be enabled in settings to use gating
- use save_gated() for gated tracks (private R2 bucket)
- store support_gate in Track model and ATProto record
- gated tracks use API endpoint URL instead of direct R2 URL
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat(frontend): add UI for supporter-gated tracks
- TrackItem: show heart badge overlay on gated tracks with dimmed image
- Player: detect 401/402 on gated content, show toast with supporter CTA
- Upload: add "supporters only" toggle when artist has atprotofans enabled
- Types: add SupportGate interface and support_gate field to Track
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: complete supporter-gated content implementation
- fix presigned URL generation with SigV4 signature (was returning 401)
- add playback helper to check gated access BEFORE modifying queue state
(clicking locked track no longer interrupts current playback)
- add support_gate toggle to track edit UI in portal
- centralize atprotofans support URL generation in config.ts
- add 7 regression tests for gated content access control
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: migrate audio to private bucket when enabling support_gate
when enabling support_gate on an existing track, the audio file must be
migrated from the public bucket to the private bucket. otherwise the
original public r2_url remains accessible, bypassing the paywall.
- add R2Storage.migrate_to_private_bucket() - copies file then deletes original
- add migrate_track_to_private_bucket background task
- schedule migration in PATCH endpoint when support_gate is enabled on
a track that has an r2_url (indicating it's in the public bucket)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor: unify audio bucket migration to single move_audio method
- R2Storage.move_audio(file_id, extension, to_private) handles both directions
- move_track_audio background task replaces separate migrate_to_private/public
- PATCH endpoint schedules move when toggling support_gate in either direction:
- enabling gate on public track → move to private
- disabling gate on private track → move to public
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: sync ATProto record when toggling support_gate
- include support_gate changes in metadata_changed check
- pass support_gate to build_track_record
- use backend API URL for gated tracks (r2_url is None)
- fix upload page: link to /portal not /settings for atprotofans setup
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
direct atprotofans contributions use the artist's DID as the signer,
not the broker DID. the broker DID is only used when plyr.fm is a
registered platform that created the support template.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
beartype runtime type checking requires exact type matches.
`progress_pct=0` (int) fails validation against `float | None`.
fixes upload failure: "BeartypeCallHintParamViolation: parameter
progress_pct=0 violates type hint float | None"
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
most users are listeners first, uploaders second - landing on the library
(liked tracks, playlists) is a better default than the portal (artist management).
- change backend OAuth callback to redirect to /library
- add exchange_token handling to library page (same pattern as portal)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- add SupporterBadge component with heart icon styling
- add validateSupporter API call to artist page when viewer is logged in
- call atprotofans API directly from frontend (public endpoint)
- use broker DID for signer parameter
- only show badge when:
- viewer is authenticated
- artist has support_url: 'atprotofans'
- viewer is not the artist themselves
- validation returns valid: true
- update research doc with implementation status and correct API usage
- add new research doc for supporter-gated content architecture (R2 presigned URLs)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>