commits
same issue as /tracks/ - copyright info is only displayed in artist
portal, not public tag browse pages.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
the copyright check was making an HTTP call to moderation.plyr.fm on
every /tracks/ request, adding ~1 second latency. this info is only
displayed in /tracks/me (artist portal), not the main feed.
- remove get_copyright_info from main listing endpoint
- keep it in /tracks/me and /tracks/me/broken where it's needed
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
the playlist endpoint wasn't checking for authenticated users, so
tracks always showed is_liked: false even when the user had liked them.
backend:
- add session cookie check to GET /lists/playlists/{id}
- query user's liked tracks when authenticated
- pass liked_track_ids to TrackResponse.from_track()
frontend:
- add client-side liked state hydration (matching artist page pattern)
- prime from localStorage cache, then fetch fresh data
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- update prompt to de-emphasize unreliable match scores
- focus analysis on title/artist name matching instead
- remove 200 char truncation on reasoning notes
- make resolution notes use full card width in admin UI
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- add AI-powered moderation agent (scripts/moderation_agent.py) that:
- fetches pending copyright flags from moderation service
- uses Claude to categorize as violation/false positive/needs review
- shows reasoning for each decision in scannable table format
- bulk resolves false positives with human approval
- fix JSON API endpoint to store resolution reason and notes
(previously only the htmx endpoint saved this data)
- update admin UI to display resolution notes prominently
for resolved items instead of hiding in tooltip
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
documents the confidential OAuth client implementation:
- PR #578: initial confidential client support
- PRs #580-582: bug fixes for aud claim and kid header
- fork updates for issuer and kid parameters
- outcome: 180-day refresh tokens, remember-me working
- links to #583 for future account switching work
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
the ATProto OAuth spec requires client assertions to include the kid
in the JWT header so the PDS knows which public key to use for
verification.
changes:
- rename _load_client_secret_key() to _load_client_secret()
- return tuple of (key, kid) instead of just key
- validate that OAUTH_JWK includes kid field
- pass client_secret_kid to OAuthClient
- update atproto fork to v0.0.1.dev470 with kid support
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
python-jose's to_dict() doesn't include the kid field from the
original JWK. this fix explicitly copies the kid from the input
JWK to the public key output.
the kid is required by ATProto OAuth for key identification when
validating client assertions.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- update atproto fork to include aud claim fix (dfbaf00)
- client assertion JWT now uses issuer (not token endpoint) as aud claim
- this fixes "unexpected aud claim value" errors with confidential clients
- fix tests to explicitly mock OAUTH_JWK setting
- tests were relying on env not having OAUTH_JWK set, but dev env has it
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add confidential OAuth client support for longer-lived sessions
adds support for ATProto OAuth confidential clients using private_key_jwt
authentication. when OAUTH_JWK is configured, the client authenticates
with a cryptographic key, earning 180-day refresh tokens (vs 2-week for
public clients).
changes:
- add OAUTH_JWK setting to AtprotoSettings for ES256 private key
- update OAuthClient to pass client_secret_key when configured
- add /.well-known/jwks.json endpoint for public key discovery
- update /oauth-client-metadata.json to include confidential client fields
- add scripts/gen_oauth_jwk.py utility to generate keys
- add tests for is_confidential_client() and get_public_jwks()
to enable:
1. run: uv run python scripts/gen_oauth_jwk.py
2. add output to .env as OAUTH_JWK='...'
closes #577
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* address review comments: remove deferred imports, add type annotations
- move imports to top level in main.py (is_confidential_client, get_public_jwks, HTTPException)
- add type annotation to metadata variable: dict[str, Any]
- update return types to dict[str, Any]
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* docs: add OAuth confidential client documentation
explains what confidential clients are, why they matter for plyr.fm,
and how the implementation works. includes sources and key rotation
guidance.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* docs: add token refresh mechanism and migration notes
- explain how token refresh works (trigger, detection, refresh, retry)
- document what gets refreshed (access vs refresh tokens)
- add observability notes (logfire log messages)
- document migration impact for existing sessions
- clarify that existing tokens can't be upgraded (2-week refresh limit)
- note that only 3 internal dev tokens affected in production
🤖 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 Opus 4.5 <noreply@anthropic.com>
* debug: add console logs to AddToMenu for iOS Safari debugging
Logs:
- toggleMenu called with menuOpen state and viewport size
- dropdown element presence check
- computed styles (position, z-index, display, visibility, etc.)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: override open-upward class in mobile media query
The .open-upward class was setting top: auto and bottom: calc(100% + 4px)
which wasn't being overridden by the mobile media query's .menu-dropdown
styles, causing the dropdown to render at top: -218px (off-screen).
🤖 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>
Logs:
- toggleMenu called with menuOpen state and viewport size
- dropdown element presence check
- computed styles (position, z-index, display, visibility, etc.)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- bump z-index above Player (backdrop: 199, dropdown: 200)
- add translateZ(0) to force GPU layer and fix iOS stacking context
- add explicit width constraints
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- center share button on album detail page mobile view
- fix album grid overflow on narrow mobile screens using min()
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
without an explicit id, iOS may conflate different PWAs when routing
media session events. this caused tapping track metadata in control
center to open the wrong home screen app (leaflet.pub instead of plyr.fm).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- AddToMenu now includes queue and share actions (matching TrackActionsMenu)
- Both menus open from top on mobile instead of bottom (doesn't cover player)
- Added backdrop for proper dismissal on mobile
- Track detail page passes shareUrl and onQueue props to AddToMenu
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add redis cache for copyright label lookups
the moderation service call was causing 2-3s latency spikes on GET /tracks/
in production. since we have multiple fly.io instances, in-memory caching
wouldn't work - added distributed redis cache (reusing docket's redis).
- add backend/utilities/redis.py for async client from docket URL
- cache active label status with 5min TTL (matches queue cache)
- use mget/pipeline for efficient batch operations
- invalidate cache when labels are emitted
- fail closed on errors (treat as active)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: move cache settings to config, add async context manager
- add label_cache_prefix and label_cache_ttl_seconds to ModerationSettings
- add async_redis_client() context manager for isolated connections
- add clear_client_cache() for test cleanup
- remove hardcoded cache constants from moderation.py
- fix tests to properly clear client cache between runs
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* test: add redis isolation for xdist parallel tests
- add redis_database fixture that assigns different DB per worker
- use unique URIs in cache tests to avoid cross-test pollution
- document redis test isolation pattern in docs/testing/README.md
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: handle missing redis gracefully in test fixture
the redis_database fixture now catches ConnectionError and skips
silently when redis is unavailable. tests that don't need redis
will pass, and tests that do need it will fail with specific errors.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: mock settings in moderation cache tests
tests that mock httpx must also mock settings.moderation to avoid
early return on auth_token check. without this, tests pass locally
(where MODERATION_AUTH_TOKEN may be set) but fail in CI.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: use D: prefix for DOCKET_URL to allow CI override
pytest-env's D: prefix means "default" - only set if not already
set by the environment. this allows CI's DOCKET_URL=redis://localhost:6379
to take precedence over pyproject.toml's 6380 (for local docker-compose).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: move deferred imports to top of file, add type hints
- move redis imports to module level in moderation.py
- move asyncio import to module level in redis.py
- add type hint for kwargs dict in redis.py
- move imports to module level in conftest.py
- add rule to AGENTS.md: DO NOT UNNECESSARILY DEFER IMPORTS
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
Add APP_STAGE constant to branding.ts (configurable via VITE_APP_STAGE)
and display it as a superscript badge next to the app name in the header.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* feat: add atprotofans support link mode
adds three support link modes for artist profiles:
- none: no support link displayed
- atprotofans: links to atprotofans.com/profile/{handle}
- custom: user-provided https:// URL (existing behavior)
backend:
- validate support_url accepts 'atprotofans' magic value
- reject non-https URLs for custom links
- add tests for all three modes
frontend:
- portal page: radio button selector for support link mode
- artist profile: compute actual URL from mode + handle
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: validate atprotofans eligibility before selection
checks user's PDS for com.atprotofans.profile/self record with
acceptingSupporters=true before allowing atprotofans selection.
- client-side check on portal load (no backend sprawl)
- shows "set up" link when not eligible
- shows "ready" when eligible
- disables option when ineligible
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: use ATProto SDK for proper handle/PDS resolution
- add @atproto/api SDK to frontend for proper ATProto operations
- fix portal atprotofans eligibility check to resolve DID to PDS first
- fix backend handles.py to use AsyncIdResolver instead of bsky.social
- fix frontend error.svelte to use SDK for handle resolution
the issue was hardcoded bsky.social URLs which don't work for users
on self-hosted PDS instances. now we properly resolve DIDs via
plc.directory and query the user's actual PDS.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: show 'profile ready' as link to atprotofans profile
when eligible, the status now shows "profile ready" as a clickable
link to the user's actual atprotofans profile page.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: correct atprotofans URL to use /u/{did} not /profile/{handle}
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat: move like and comment PDS writes to background tasks
Likes:
- Update like/unlike endpoints to use optimistic DB writes
- Schedule PDS record operations via docket background tasks
- Make atproto_like_uri nullable to support async PDS writes
- Update tests to verify background task scheduling
Comments:
- Update create/update/delete comment endpoints to use optimistic DB writes
- Schedule PDS record operations via docket background tasks
- Make atproto_comment_uri nullable to support async PDS writes
- Update tests to verify background task scheduling
This reduces API response times by moving slow PDS writes to the background
while keeping the UI responsive with immediate local database updates.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: move deferred imports to module level
Move imports that were unnecessarily deferred inside PDS task functions
to the top of the module for clarity.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
the default docket worker polls redis every 250ms (4x/sec), which
caused ~3.4M redis commands in 2 days and $6.73 in upstash costs.
changes:
- add check_interval_seconds and scheduling_resolution_seconds to DocketSettings
- default both to 5 seconds (20x reduction in polling frequency)
- update background.py Worker to use these settings
- update audd costs in export_costs.py ($3.91 -> $9.13 for current period)
expected redis cost reduction: ~95% for idle polling
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
adds sync_album_list task to sync album ATProto list records immediately
after track operations, rather than waiting for the user's next login.
changes:
- add sync_album_list task and schedule_album_list_sync to background_tasks.py
- refactor _register_tasks to use docket.register_collection() per Guidry's advice
- call sync on: track upload, track delete, track album change, record restore
- mock schedule_album_list_sync in affected tests
fixes the issue where albums created during track upload didn't get
ATProto list records until the user logged in again.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
when renaming an album via PATCH /albums/{album_id}, the slug was not
being updated to match the new title. this caused get_or_create_album()
to fail lookups when adding tracks to renamed albums (since it looks up
by artist_did + slugify(title)), resulting in duplicate albums.
- regenerate slug from new title when title changes
- add regression test for slug sync behavior
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
STATUS.md:
- add PRs #550-554 (pagination, album management, playlist toggle)
- update last modified date
docs/frontend/state-management.md:
- add pagination support to tracks cache section
docs/backend/background-tasks.md:
- add ATProto sync and teal scrobbling to task list
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* feat: add pagination and infinite scroll to tracks list
backend:
- add cursor-based pagination to /tracks/ endpoint
- use created_at timestamp as cursor for stable pagination
- return next_cursor and has_more fields in response
- default page size of 50, max 100
frontend:
- update TracksCache to support fetchMore() for pagination
- persist pagination state (nextCursor, hasMore) to localStorage
- implement infinite scroll on homepage using IntersectionObserver
- add scroll sentinel element with loading indicator
- trigger fetch 200px before reaching bottom for smooth UX
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: address PR feedback
- move default page size to settings.app.default_page_size
- raise 400 error on invalid cursor format instead of ignoring
- use walrus operator for has_more logic
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
the show_on_profile toggle was removed when the playlist page was
refactored from modal editing to inline editing (PR #531).
this restores the UI control so users can choose whether their
playlists appear on their public profile. the backend support
was already in place, just the UI was missing.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
Update album title in UI immediately instead of blocking on backend
response. Toast appears when backend completes, and UI reverts on error.
This makes the edit feel much snappier since the backend call can take
several seconds (updating all track ATProto records + list record).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
When an album title is updated via PATCH /albums/{album_id}, we now also
update the album's ATProto list record with the new name. Previously only
track records were updated, leaving the list record with the stale name.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* fix: album delete endpoint and track ordering from ATProto list
fixes two user-reported issues:
1. album deletion: adds DELETE /albums/{album_id} endpoint
- by default orphans tracks (sets album_id to null)
- with ?cascade=true deletes all tracks in the album
- cleans up ATProto list record and cover image
2. album track ordering: respects ATProto list record order
- get_album now fetches the ATProto list record to determine track order
- falls back to created_at order if no ATProto record exists
- fixes issue where reordering tracks in the frontend didn't persist
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: album edit mode with ATProto sync and portal UX improvements
- add PATCH /albums/{album_id} endpoint for title/description updates
- sync album title changes to all track ATProto records
- add DELETE /albums/{album_id}/tracks/{track_id} to remove tracks from albums
- implement album page edit mode (inline title, cover upload, delete album)
- simplify portal albums section to clickable links (matches playlist UX)
- fix icon buttons to use square shape matching playlist page
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
- created plyr-stats bucket with public dev-url access
- costs data now lives in dedicated bucket, shared across all envs
- removes dependency on environment-specific audio buckets
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* feat: public costs dashboard with daily export to R2
- add /costs page showing monthly infrastructure costs
- costs.json exported daily via GitHub Action to R2
- backend /config endpoint exposes costs_json_url
- ko-fi support link for community funding
- hardcoded Fly/Neon/Cloudflare costs, dynamic AudD from DB
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: correct audd cost calculation with one-time adjustment
the copyright_scans table was created Nov 24 but first scan recorded
Nov 30, so database counts are incomplete for this billing period.
- hardcode audd values for Nov 24 - Dec 24 from dashboard (6781 requests, $3.91)
- after Dec 24, automatically uses live database counts
- includes cleanup comment to remove hardcoded block after transition
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* docs: update STATUS.md - cost dashboard complete
- add public cost dashboard to recent work section
- remove from immediate priorities (done)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* docs: audit and update documentation
- promote connection-pool-exhaustion runbook from sandbox to docs/runbooks/
- update CLAUDE.md files with current module descriptions
- _internal: add background tasks, moderation, jobs
- api: add albums, playlists, exports, moderation, stats
- docs: add runbooks, testing, moderation, lexicons
- refresh docs/README.md with streamlined navigation
- update STATUS.md:
- add Dec 9 docket/concurrent exports section
- update immediate priorities (moderation cleanup, cost dashboard)
- de-emphasize transcoder (moved to backlog)
- add docket to technical architecture
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* docs: update README with current state
- add docket, logfire to tech stack
- add moderation/transcoder services section
- update local dev to include dev-services (redis)
- add useful commands section with actual justfile targets
- update features (playlists, scrobbling, timed comments, support links)
- add data ownership section
- update project structure (_internal services)
- consolidate links + documentation into single section
- add mirrors section (github, tangled)
- remove stale atproto fork reference
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
terminal dashboard for monitoring AudD copyright scan API usage
and costs. tracks scans per billing period (24th of month), shows
remaining free requests, estimated costs, and daily breakdown with
plotext charts.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
previously tracks were downloaded sequentially, making exports slow
for users with many tracks or large files. now downloads happen
concurrently (up to 4 at a time) while zip creation remains sequential
(zipfile isn't thread-safe).
- refactor process_export to use asyncio.gather + semaphore
- add regression test verifying concurrent download behavior
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* feat: migrate ATProto sync and teal scrobbling to docket
- add sync_atproto and scrobble_to_teal as docket background tasks
- remove all fallback/bifurcation code - Redis is always required
- simplify background.py (remove is_docket_enabled)
- update auth.py and playback.py to use new schedulers
- add Redis service to CI workflow
- update tests for simplified docket-only flow
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update test for schedule_atproto_sync, fix cookies deprecation
- test_list_record_sync: patch schedule_atproto_sync instead of removed sync_atproto_records
- test_hidden_tags_filter: move cookies to client constructor to fix httpx deprecation warning
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: template database pattern for fast parallel test execution
- use template db + clone for xdist workers (instant file copy vs migrations)
- advisory locks coordinate template creation between workers
- patch settings.database.url per-worker for production code compatibility
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* ci: enable parallel test execution with xdist
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: ensure test_app fixture depends on db_session for xdist
fixes race condition where tests using test_app without db_session
would run before database URL was patched for the worker
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* docs: add testing philosophy and xdist parallel execution guide
- template database pattern for fast parallel tests
- behavior vs implementation testing philosophy
- common pitfalls with fixture dependencies
- guidance on when private function tests are acceptable
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat: migrate ATProto sync and teal scrobbling to docket
- add sync_atproto and scrobble_to_teal as docket background tasks
- remove all fallback/bifurcation code - Redis is always required
- simplify background.py (remove is_docket_enabled)
- update auth.py and playback.py to use new schedulers
- add Redis service to CI workflow
- update tests for simplified docket-only flow
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update test for schedule_atproto_sync, fix cookies deprecation
- test_list_record_sync: patch schedule_atproto_sync instead of removed sync_atproto_records
- test_hidden_tags_filter: move cookies to client constructor to fix httpx deprecation warning
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: template database pattern for fast parallel test execution
- use template db + clone for xdist workers (instant file copy vs migrations)
- advisory locks coordinate template creation between workers
- patch settings.database.url per-worker for production code compatibility
- borrowed pattern from prefecthq/nebula
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* ci: enable parallel test execution with xdist
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: ensure test_app fixture depends on db_session for xdist
fixes race condition where tests using test_app without db_session
would run before database URL was patched for the worker
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
- remove text-transform: lowercase from album card titles on artist page
- add theme-aware scrollbar styling to AddToMenu playlist list
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
- move export processing from FastAPI BackgroundTasks to docket
- add process_export task function with full R2 upload logic
- add schedule_export helper with asyncio fallback
- register process_export in docket worker
- add tests for scheduling with docket and asyncio fallback
- update background-tasks.md documentation
exports now benefit from docket's durability: if a worker crashes
mid-export, the task will be retried on restart.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* docs: update env docs for docket and suppressed loggers
- fly.toml: update secrets comments with DOCKET_URL, remove stale entries
- fly.staging.toml: add secrets comments (was missing)
- .env.example: add DOCKET_* and LOGFIRE_SUPPRESSED_LOGGERS settings
- configuration.md: document suppressed_loggers setting
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: add docket_runs.py script to check task status
standalone uv script to inspect docket runs in redis:
- works with local, staging, or production environments
- no SSH required - connects directly to redis
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat: add pydocket for background task infrastructure
- add pydocket dependency for Redis-backed background tasks
- add DocketSettings to config (defaults to memory:// for local dev)
- create background.py with docket initialization and worker lifespan
- create background_tasks.py with scan_copyright task
- migrate copyright scan from asyncio.create_task to docket.add()
- add Redis to test docker-compose
- update AGENTS.md with test command note
the copyright scan is the first task migrated to docket. upload processing
still uses FastAPI BackgroundTasks pending auth session refactoring.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: make docket opt-in, add fallback to asyncio.create_task
- change DOCKET_URL default from "memory://plyr" to "" (disabled)
- add is_docket_enabled() check function
- update background_worker_lifespan() to yield None when disabled
- uploads.py: check is_docket_enabled(), fallback to asyncio.create_task
- remove stub process_upload from background_tasks.py
memory mode won't work in production with multiple machines, so docket
must be explicitly enabled with a Redis URL. when disabled, copyright
scans fall back to fire-and-forget asyncio.create_task().
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: clean up upload background processing
- extract featured artist resolution to handles.py
- extract schedule_copyright_scan to unify docket/asyncio branching
- break _process_upload_background into smaller helper functions:
- _save_audio_to_storage
- _save_image_to_storage
- _add_tags_to_track
- _send_track_notification
- fix sketchy json.JSONDecodeError pass with proper logging
- add compose.yaml for local redis
- add just commands: dev-up, dev-down, dev
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* chore: simplify just dev to single command
removes dev-up/dev-down, just use 'just dev' which starts redis,
backend (with DOCKET_URL set), and frontend.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* docs: add background tasks and redis documentation
- add docs/backend/background-tasks.md covering docket/redis setup
- update configuration.md with docket settings
- update environments.md with redis instances per environment
- created Upstash Redis instances:
- plyr-redis-prd (production, iad region)
- plyr-redis-stg (staging, iad region)
note: DOCKET_URL not yet wired to fly secrets - will test in dev first
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: use UploadContext dataclass for background processing
- replace 12 function arguments with single UploadContext dataclass
- cleaner interface for _process_upload_background
- update just commands: dev-services / dev-services-down
- update local dev docs to reflect separate terminal workflow
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: reorganize atproto module by lexicon namespace
- split monolithic records.py (1000+ lines) into focused modules:
- client.py: PDS request/auth logic, token refresh with per-session locks
- records/fm_plyr/: plyr.fm lexicons (track, like, comment, list, profile)
- records/fm_teal/: teal.fm lexicons (play, status)
- sync.py: high-level sync orchestration
- maintain backward compatibility via re-exports in records/__init__.py
- update test patch paths for new module locations
- add dev-services commands for local redis via docker compose
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* style: make header stats smaller and evenly distributed
- reduce stats font size (0.75rem → 0.65rem) and icons (14px → 12px)
- distribute stats and search evenly across left margin with space-evenly
- margin width responds correctly when queue panel opens
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: resolve docket startup and logging issues
- rename 'name' to 'docket_name' in log extra dict (conflicts with LogRecord.name)
- suppress docket logger noise by setting level to WARNING
- remove unnecessary sleep(0.1) from worker shutdown
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: make suppressed loggers configurable
- add LOGFIRE_SUPPRESSED_LOGGERS setting (defaults to "docket")
- iterate over setting in main.py instead of hardcoding
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
- artist support link feature (PR #532)
- inline playlist editing (PR #531)
- platform stats enhancements (PRs #522, #528)
- navigation data loading fixes (PR #527)
- copyright moderation improvements (PR #480)
- letta-backed status maintenance (PR #529)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* feat: add artist support link setting
allows artists to set a support URL (Ko-fi, Patreon, etc.) in their
settings that displays as a button on their public profile page.
- add support_url field to UserPreferences model with migration
- update preferences API to handle support_url get/update
- expose support_url on public artist profile endpoints
- add settings UI with https:// validation
- display support button next to share on artist profiles
- add backend tests for support_url functionality
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: move support link to portal profile section
- moved support URL field from settings page to portal profile section
- renamed "profile settings" to "profile" in portal
- aligned support button with share button on artist profile (32px height)
- cleaned up unused CSS from settings page
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat: letta-backed status maintenance
replaces claude-code-action with a letta-backed python script that
maintains persistent memory across runs. the agent remembers:
- previous status updates and their content
- architectural decisions and patterns
- recurring themes in development
this means the agent doesn't need to re-read everything from scratch
each week - it builds on accumulated context.
new files:
- scripts/status_maintenance.py - main CI script
- scripts/letta_status_agent.py - local testing/interaction
workflow changes:
- uses uv run scripts/status_maintenance.py instead of claude-code-action
- adds LETTA_API_KEY secret requirement
- adds --dry-run option for testing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: use structured outputs and read full STATUS.md
- use anthropic's structured outputs beta (structured-outputs-2025-11-13)
with Pydantic model for guaranteed schema compliance
- read the full STATUS.md content, not truncated to 3000 chars
- remove manual JSON parsing/begging - let the API handle it
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: use opus 4.5 by default, make model configurable
- add model setting defaulting to claude-opus-4-5-20251101
- configurable via ANTHROPIC_MODEL env var
- use settings.model everywhere instead of hardcoded sonnet
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: memory blocks for project understanding, restore full prompt guidance
memory blocks refactored:
- project_architecture: how plyr.fm is built, key design decisions
- atproto_context: understanding of ATProto, lexicons, NSIDs
- recurring_patterns: themes that come up repeatedly
the agent no longer tracks 'what it processed' - github (last merged PR)
is the source of truth for the time window. memory is purely for
accumulating project understanding.
restored all original prompt guidance:
- full narrative structure (opening, main story, secondary, rapid fire, closing)
- tone requirements (skeptical, amused, no superlatives)
- pronunciation rules (player FM, never plyr.fm)
- time reference rules (specific dates, never 'last week')
- identifying what shipped vs improved
- first episode detection and longer script guidance
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* chore: tighten system prompt (-75 lines)
preserve critical guidance:
- pronunciation (player FM)
- time bounds (specific dates)
- narrative structure
- tone (dry, sardonic)
remove redundancy:
- consolidated CRITICAL markers
- merged duplicate tone sections
- simplified structure timing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* Add unified edit mode for playlists with track deletion
- Consolidate edit functionality into single edit mode (like Spotify)
- Edit button now toggles edit mode instead of opening modal
- In edit mode: show drag handles for reordering, delete buttons for tracks, and edit details button
- Remove separate reorder button - reordering is now part of edit mode
- Add track deletion UI with loading states and error handling
- Track deletion uses existing backend API endpoint
* fix: simplify playlist edit mode UX
- fix undefined showEdit variable errors (use showEditModal)
- simplify edit mode: one button to enter/exit edit mode
- in edit mode: cover art is clickable to edit metadata, tracks show
drag handles and delete buttons, add tracks button appears inline
- remove redundant "edit details", "add tracks", "saving..." buttons
- use proper button element for clickable cover art (fixes a11y warning)
- clean up unused CSS selectors
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: inline playlist editing with direct cover upload
- title editable inline as input field when in edit mode
- cover art directly replaceable via click (opens file picker)
- removed edit modal entirely - all editing is now inline
- fixed font inheritance on overlay text
- cleaned up unused CSS selectors from removed modal
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
- Stats and search now in one flex container, centered in left margin
- Mirrors how logout is centered in right margin
- Container has max-width to prevent overflow into content area
- Stats can wrap to 2 rows if numbers get large (flex-wrap)
- Smaller font size (0.75rem) for stats to fit better
- Breakpoint at 1599px to account for queue panel
* fix: reload data when navigating between detail pages of same type
SvelteKit reuses component instances when navigating between routes with
the same layout. This meant `onMount` didn't re-run when going from one
artist page to another, or one track page to another.
- replace `onMount` with `$effect` watching server data (data.artist.did,
data.track.id) to detect navigation
- reset local state and reload fresh data when route params change
- also: change "of music" to "of audio" in platform stats (not all
uploads are music - some are audiobooks, public domain recordings, etc)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: add Tangled repo link and fix responsive header breakpoints
- Add Tangled link with sheep avatar to desktop header and mobile LinksMenu
- Consolidate breakpoints: desktop elements and mobile layout now switch at 1299px
- Previously stats/search/logout disappeared at 1499px but mobile didn't show until 1299px,
leaving a gap where users couldn't access those features
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: bump responsive breakpoint to 1399px to prevent stats/search collision
Switch to mobile layout earlier to avoid cramped margin elements
* fix: accent color on hover for nav-link and Tangled icon
- nav-link (feed/library) now uses accent color on hover
- Tangled icon gets accent-colored ring on hover
- Nudge search component 20px right to prevent overlap with stats
---------
Co-authored-by: Claude <noreply@anthropic.com>
- add `total_duration_seconds` to platform stats endpoint (`/stats`)
- add `total_duration_seconds` to artist analytics endpoint (`/artists/{did}/analytics`)
- display duration in homepage stats bar (header and menu variants)
- show duration as subtitle in artist page "total tracks" card
- add `formatDuration()` helper for human-readable format (e.g., "25h 32m")
- add `scripts/user_upload_stats.py` for viewing per-user upload durations
- add regression tests for stats and analytics endpoints
this lays groundwork for future upload caps per user.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* fix: sync copyright flag resolution from labeler to portal
when an admin marks a track as "false positive" in the moderation UI,
the flag now correctly clears from the user's portal. previously the
backend's copyright_scans table was never updated, leaving stale flags.
the fix uses an idempotent approach - the backend now queries the
labeler for the source of truth rather than requiring a sync/backfill:
moderation service:
- add POST /admin/active-labels endpoint
- returns which URIs have active (non-negated) copyright-violation labels
backend:
- add get_active_copyright_labels() to query the labeler
- update get_copyright_info() to check labeler for pending flags
- lazily update resolution field when labeler confirms resolution
- skip labeler call for already-resolved scans (optimization)
behavior:
- existing resolved flags immediately take effect (no backfill needed)
- fails closed: if labeler unreachable, flags remain visible (safe default)
- lazy DB update reduces future labeler calls for same track
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: add copyright attestation checkbox to upload flow
users must now confirm they have distribution rights before uploading.
includes educational copy about "publicly available ≠ licensed" to reduce
good-faith mistakes (per tigers blood incident retrospective).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
- backend expects tags as JSON arrays (`["ai"]`), not bare strings
- check existing tracks via `plyrfm my-tracks` before uploading
- if a track for today already exists, add episode number (#2, #3, etc)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <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 <noreply@anthropic.com>
- switch from sonnet (default) to opus for higher quality output
- add Task tool to enable subagent investigation of PRs
- restructure podcast script generation to:
- deeply investigate PRs before writing (read bodies, diffs, design decisions)
- categorize changes into big ticket items vs smaller fixes
- follow explicit chronological narrative structure:
- opening (10s)
- main story with design discussion (60-90s)
- secondary feature (30-45s)
- rapid fire smaller changes (20-30s)
- closing (10s)
- emphasize explaining HOW things were designed, not just WHAT
closes #521
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
- add CLAUDE.md with instructions for getting workflow summary URLs
- fix pronunciation: tell model to WRITE "player FM" not "plyr.fm" (TTS mispronounces plyr)
- add guidance to distinguish major feature launches from polish/fixes
- explicitly forbid vague time references like "last week" - require specific dates
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
- add get_record_public() to _internal/atproto/records.py for unauthenticated ATProto record fetches
- remove auth requirement from GET /playlists/{id} endpoint
- remove auth redirect from frontend playlist page loader
- edit/delete buttons still only show for playlist owner (via client-side auth check)
ATProto records are public by design - the previous auth requirement was unnecessary for read access.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
the workflow was using $(date +%Y%m%d) for branch names, causing
collisions when run multiple times on the same day. this led to
the audio file not being committed in PR #516.
now uses $(date +%Y%m%d-%H%M%S) stored in a variable to ensure
unique branch names and consistent usage across checkout and push.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* fix: detail page button layouts
playlist detail page:
- show edit/delete buttons on mobile (were hidden, only share showed)
- rename mobile-share-button to mobile-buttons for clarity
track detail page:
- remove bifurcated desktop layout (buttons on left/right sides)
- use centered button layout everywhere (like mobile had)
- simplify CSS by removing side-button-left/right styles
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: AddToMenu opens upward when near bottom of viewport
when the menu trigger is close to the bottom of the viewport,
detect available space and open the menu upward instead of downward.
this prevents the menu from being cut off by the player.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
living documentation explaining:
- what lexicons are and how ATProto uses them
- our fm.plyr namespace and environment awareness
- each lexicon (track, like, comment, list, profile) with history
- ATProto primitives we use (tid, literal:self, strongRef, knownValues)
- local indexing pattern for performance
- future codegen plans (issue #494)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
the workflow was using "last week" as its time window, which caused it
to miss work that happened between the last merged PR and ~7 days ago.
changes:
- query gh pr list to find when last status-maintenance PR was MERGED
- filter by branch name (startswith "status-maintenance-") and sort by mergedAt
- use that merge date as the starting point, not "last week"
- clarify that archive files are organized by month (YYYY-MM.md)
- add examples for archive file naming (2025-12.md for December, etc.)
- remove hardcoded "weekly" titles, let Claude craft descriptive titles
fixes the issue where PR #512 only covered Dec 7th onwards when it
should have covered everything since Dec 2nd (last merge date).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
documents the fast-follow fixes after the playlists release:
- PR #507: playlist og:image link previews
- PR #508: auth invalidation after login
- PR #509: playlist menu fixes and link previews
- PR #510: inline playlist creation to avoid playback interruption
also updates the playlists section to reflect that create playlist is now
inline rather than navigation-based.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
same issue as /tracks/ - copyright info is only displayed in artist
portal, not public tag browse pages.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
the copyright check was making an HTTP call to moderation.plyr.fm on
every /tracks/ request, adding ~1 second latency. this info is only
displayed in /tracks/me (artist portal), not the main feed.
- remove get_copyright_info from main listing endpoint
- keep it in /tracks/me and /tracks/me/broken where it's needed
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
the playlist endpoint wasn't checking for authenticated users, so
tracks always showed is_liked: false even when the user had liked them.
backend:
- add session cookie check to GET /lists/playlists/{id}
- query user's liked tracks when authenticated
- pass liked_track_ids to TrackResponse.from_track()
frontend:
- add client-side liked state hydration (matching artist page pattern)
- prime from localStorage cache, then fetch fresh data
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- update prompt to de-emphasize unreliable match scores
- focus analysis on title/artist name matching instead
- remove 200 char truncation on reasoning notes
- make resolution notes use full card width in admin UI
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- add AI-powered moderation agent (scripts/moderation_agent.py) that:
- fetches pending copyright flags from moderation service
- uses Claude to categorize as violation/false positive/needs review
- shows reasoning for each decision in scannable table format
- bulk resolves false positives with human approval
- fix JSON API endpoint to store resolution reason and notes
(previously only the htmx endpoint saved this data)
- update admin UI to display resolution notes prominently
for resolved items instead of hiding in tooltip
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
documents the confidential OAuth client implementation:
- PR #578: initial confidential client support
- PRs #580-582: bug fixes for aud claim and kid header
- fork updates for issuer and kid parameters
- outcome: 180-day refresh tokens, remember-me working
- links to #583 for future account switching work
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
the ATProto OAuth spec requires client assertions to include the kid
in the JWT header so the PDS knows which public key to use for
verification.
changes:
- rename _load_client_secret_key() to _load_client_secret()
- return tuple of (key, kid) instead of just key
- validate that OAUTH_JWK includes kid field
- pass client_secret_kid to OAuthClient
- update atproto fork to v0.0.1.dev470 with kid support
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
python-jose's to_dict() doesn't include the kid field from the
original JWK. this fix explicitly copies the kid from the input
JWK to the public key output.
the kid is required by ATProto OAuth for key identification when
validating client assertions.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- update atproto fork to include aud claim fix (dfbaf00)
- client assertion JWT now uses issuer (not token endpoint) as aud claim
- this fixes "unexpected aud claim value" errors with confidential clients
- fix tests to explicitly mock OAUTH_JWK setting
- tests were relying on env not having OAUTH_JWK set, but dev env has it
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add confidential OAuth client support for longer-lived sessions
adds support for ATProto OAuth confidential clients using private_key_jwt
authentication. when OAUTH_JWK is configured, the client authenticates
with a cryptographic key, earning 180-day refresh tokens (vs 2-week for
public clients).
changes:
- add OAUTH_JWK setting to AtprotoSettings for ES256 private key
- update OAuthClient to pass client_secret_key when configured
- add /.well-known/jwks.json endpoint for public key discovery
- update /oauth-client-metadata.json to include confidential client fields
- add scripts/gen_oauth_jwk.py utility to generate keys
- add tests for is_confidential_client() and get_public_jwks()
to enable:
1. run: uv run python scripts/gen_oauth_jwk.py
2. add output to .env as OAUTH_JWK='...'
closes #577
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* address review comments: remove deferred imports, add type annotations
- move imports to top level in main.py (is_confidential_client, get_public_jwks, HTTPException)
- add type annotation to metadata variable: dict[str, Any]
- update return types to dict[str, Any]
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* docs: add OAuth confidential client documentation
explains what confidential clients are, why they matter for plyr.fm,
and how the implementation works. includes sources and key rotation
guidance.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* docs: add token refresh mechanism and migration notes
- explain how token refresh works (trigger, detection, refresh, retry)
- document what gets refreshed (access vs refresh tokens)
- add observability notes (logfire log messages)
- document migration impact for existing sessions
- clarify that existing tokens can't be upgraded (2-week refresh limit)
- note that only 3 internal dev tokens affected in production
🤖 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>
* debug: add console logs to AddToMenu for iOS Safari debugging
Logs:
- toggleMenu called with menuOpen state and viewport size
- dropdown element presence check
- computed styles (position, z-index, display, visibility, etc.)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: override open-upward class in mobile media query
The .open-upward class was setting top: auto and bottom: calc(100% + 4px)
which wasn't being overridden by the mobile media query's .menu-dropdown
styles, causing the dropdown to render at top: -218px (off-screen).
🤖 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>
without an explicit id, iOS may conflate different PWAs when routing
media session events. this caused tapping track metadata in control
center to open the wrong home screen app (leaflet.pub instead of plyr.fm).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- AddToMenu now includes queue and share actions (matching TrackActionsMenu)
- Both menus open from top on mobile instead of bottom (doesn't cover player)
- Added backdrop for proper dismissal on mobile
- Track detail page passes shareUrl and onQueue props to AddToMenu
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add redis cache for copyright label lookups
the moderation service call was causing 2-3s latency spikes on GET /tracks/
in production. since we have multiple fly.io instances, in-memory caching
wouldn't work - added distributed redis cache (reusing docket's redis).
- add backend/utilities/redis.py for async client from docket URL
- cache active label status with 5min TTL (matches queue cache)
- use mget/pipeline for efficient batch operations
- invalidate cache when labels are emitted
- fail closed on errors (treat as active)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: move cache settings to config, add async context manager
- add label_cache_prefix and label_cache_ttl_seconds to ModerationSettings
- add async_redis_client() context manager for isolated connections
- add clear_client_cache() for test cleanup
- remove hardcoded cache constants from moderation.py
- fix tests to properly clear client cache between runs
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* test: add redis isolation for xdist parallel tests
- add redis_database fixture that assigns different DB per worker
- use unique URIs in cache tests to avoid cross-test pollution
- document redis test isolation pattern in docs/testing/README.md
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: handle missing redis gracefully in test fixture
the redis_database fixture now catches ConnectionError and skips
silently when redis is unavailable. tests that don't need redis
will pass, and tests that do need it will fail with specific errors.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: mock settings in moderation cache tests
tests that mock httpx must also mock settings.moderation to avoid
early return on auth_token check. without this, tests pass locally
(where MODERATION_AUTH_TOKEN may be set) but fail in CI.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: use D: prefix for DOCKET_URL to allow CI override
pytest-env's D: prefix means "default" - only set if not already
set by the environment. this allows CI's DOCKET_URL=redis://localhost:6379
to take precedence over pyproject.toml's 6380 (for local docker-compose).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: move deferred imports to top of file, add type hints
- move redis imports to module level in moderation.py
- move asyncio import to module level in redis.py
- add type hint for kwargs dict in redis.py
- move imports to module level in conftest.py
- add rule to AGENTS.md: DO NOT UNNECESSARILY DEFER IMPORTS
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat: add atprotofans support link mode
adds three support link modes for artist profiles:
- none: no support link displayed
- atprotofans: links to atprotofans.com/profile/{handle}
- custom: user-provided https:// URL (existing behavior)
backend:
- validate support_url accepts 'atprotofans' magic value
- reject non-https URLs for custom links
- add tests for all three modes
frontend:
- portal page: radio button selector for support link mode
- artist profile: compute actual URL from mode + handle
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: validate atprotofans eligibility before selection
checks user's PDS for com.atprotofans.profile/self record with
acceptingSupporters=true before allowing atprotofans selection.
- client-side check on portal load (no backend sprawl)
- shows "set up" link when not eligible
- shows "ready" when eligible
- disables option when ineligible
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: use ATProto SDK for proper handle/PDS resolution
- add @atproto/api SDK to frontend for proper ATProto operations
- fix portal atprotofans eligibility check to resolve DID to PDS first
- fix backend handles.py to use AsyncIdResolver instead of bsky.social
- fix frontend error.svelte to use SDK for handle resolution
the issue was hardcoded bsky.social URLs which don't work for users
on self-hosted PDS instances. now we properly resolve DIDs via
plc.directory and query the user's actual PDS.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: show 'profile ready' as link to atprotofans profile
when eligible, the status now shows "profile ready" as a clickable
link to the user's actual atprotofans profile page.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: correct atprotofans URL to use /u/{did} not /profile/{handle}
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat: move like and comment PDS writes to background tasks
Likes:
- Update like/unlike endpoints to use optimistic DB writes
- Schedule PDS record operations via docket background tasks
- Make atproto_like_uri nullable to support async PDS writes
- Update tests to verify background task scheduling
Comments:
- Update create/update/delete comment endpoints to use optimistic DB writes
- Schedule PDS record operations via docket background tasks
- Make atproto_comment_uri nullable to support async PDS writes
- Update tests to verify background task scheduling
This reduces API response times by moving slow PDS writes to the background
while keeping the UI responsive with immediate local database updates.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: move deferred imports to module level
Move imports that were unnecessarily deferred inside PDS task functions
to the top of the module for clarity.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
the default docket worker polls redis every 250ms (4x/sec), which
caused ~3.4M redis commands in 2 days and $6.73 in upstash costs.
changes:
- add check_interval_seconds and scheduling_resolution_seconds to DocketSettings
- default both to 5 seconds (20x reduction in polling frequency)
- update background.py Worker to use these settings
- update audd costs in export_costs.py ($3.91 -> $9.13 for current period)
expected redis cost reduction: ~95% for idle polling
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
adds sync_album_list task to sync album ATProto list records immediately
after track operations, rather than waiting for the user's next login.
changes:
- add sync_album_list task and schedule_album_list_sync to background_tasks.py
- refactor _register_tasks to use docket.register_collection() per Guidry's advice
- call sync on: track upload, track delete, track album change, record restore
- mock schedule_album_list_sync in affected tests
fixes the issue where albums created during track upload didn't get
ATProto list records until the user logged in again.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
when renaming an album via PATCH /albums/{album_id}, the slug was not
being updated to match the new title. this caused get_or_create_album()
to fail lookups when adding tracks to renamed albums (since it looks up
by artist_did + slugify(title)), resulting in duplicate albums.
- regenerate slug from new title when title changes
- add regression test for slug sync behavior
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
STATUS.md:
- add PRs #550-554 (pagination, album management, playlist toggle)
- update last modified date
docs/frontend/state-management.md:
- add pagination support to tracks cache section
docs/backend/background-tasks.md:
- add ATProto sync and teal scrobbling to task list
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* feat: add pagination and infinite scroll to tracks list
backend:
- add cursor-based pagination to /tracks/ endpoint
- use created_at timestamp as cursor for stable pagination
- return next_cursor and has_more fields in response
- default page size of 50, max 100
frontend:
- update TracksCache to support fetchMore() for pagination
- persist pagination state (nextCursor, hasMore) to localStorage
- implement infinite scroll on homepage using IntersectionObserver
- add scroll sentinel element with loading indicator
- trigger fetch 200px before reaching bottom for smooth UX
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: address PR feedback
- move default page size to settings.app.default_page_size
- raise 400 error on invalid cursor format instead of ignoring
- use walrus operator for has_more logic
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
the show_on_profile toggle was removed when the playlist page was
refactored from modal editing to inline editing (PR #531).
this restores the UI control so users can choose whether their
playlists appear on their public profile. the backend support
was already in place, just the UI was missing.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
Update album title in UI immediately instead of blocking on backend
response. Toast appears when backend completes, and UI reverts on error.
This makes the edit feel much snappier since the backend call can take
several seconds (updating all track ATProto records + list record).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
When an album title is updated via PATCH /albums/{album_id}, we now also
update the album's ATProto list record with the new name. Previously only
track records were updated, leaving the list record with the stale name.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* fix: album delete endpoint and track ordering from ATProto list
fixes two user-reported issues:
1. album deletion: adds DELETE /albums/{album_id} endpoint
- by default orphans tracks (sets album_id to null)
- with ?cascade=true deletes all tracks in the album
- cleans up ATProto list record and cover image
2. album track ordering: respects ATProto list record order
- get_album now fetches the ATProto list record to determine track order
- falls back to created_at order if no ATProto record exists
- fixes issue where reordering tracks in the frontend didn't persist
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: album edit mode with ATProto sync and portal UX improvements
- add PATCH /albums/{album_id} endpoint for title/description updates
- sync album title changes to all track ATProto records
- add DELETE /albums/{album_id}/tracks/{track_id} to remove tracks from albums
- implement album page edit mode (inline title, cover upload, delete album)
- simplify portal albums section to clickable links (matches playlist UX)
- fix icon buttons to use square shape matching playlist page
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
- created plyr-stats bucket with public dev-url access
- costs data now lives in dedicated bucket, shared across all envs
- removes dependency on environment-specific audio buckets
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* feat: public costs dashboard with daily export to R2
- add /costs page showing monthly infrastructure costs
- costs.json exported daily via GitHub Action to R2
- backend /config endpoint exposes costs_json_url
- ko-fi support link for community funding
- hardcoded Fly/Neon/Cloudflare costs, dynamic AudD from DB
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: correct audd cost calculation with one-time adjustment
the copyright_scans table was created Nov 24 but first scan recorded
Nov 30, so database counts are incomplete for this billing period.
- hardcode audd values for Nov 24 - Dec 24 from dashboard (6781 requests, $3.91)
- after Dec 24, automatically uses live database counts
- includes cleanup comment to remove hardcoded block after transition
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* docs: update STATUS.md - cost dashboard complete
- add public cost dashboard to recent work section
- remove from immediate priorities (done)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* docs: audit and update documentation
- promote connection-pool-exhaustion runbook from sandbox to docs/runbooks/
- update CLAUDE.md files with current module descriptions
- _internal: add background tasks, moderation, jobs
- api: add albums, playlists, exports, moderation, stats
- docs: add runbooks, testing, moderation, lexicons
- refresh docs/README.md with streamlined navigation
- update STATUS.md:
- add Dec 9 docket/concurrent exports section
- update immediate priorities (moderation cleanup, cost dashboard)
- de-emphasize transcoder (moved to backlog)
- add docket to technical architecture
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* docs: update README with current state
- add docket, logfire to tech stack
- add moderation/transcoder services section
- update local dev to include dev-services (redis)
- add useful commands section with actual justfile targets
- update features (playlists, scrobbling, timed comments, support links)
- add data ownership section
- update project structure (_internal services)
- consolidate links + documentation into single section
- add mirrors section (github, tangled)
- remove stale atproto fork reference
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
terminal dashboard for monitoring AudD copyright scan API usage
and costs. tracks scans per billing period (24th of month), shows
remaining free requests, estimated costs, and daily breakdown with
plotext charts.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
previously tracks were downloaded sequentially, making exports slow
for users with many tracks or large files. now downloads happen
concurrently (up to 4 at a time) while zip creation remains sequential
(zipfile isn't thread-safe).
- refactor process_export to use asyncio.gather + semaphore
- add regression test verifying concurrent download behavior
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* feat: migrate ATProto sync and teal scrobbling to docket
- add sync_atproto and scrobble_to_teal as docket background tasks
- remove all fallback/bifurcation code - Redis is always required
- simplify background.py (remove is_docket_enabled)
- update auth.py and playback.py to use new schedulers
- add Redis service to CI workflow
- update tests for simplified docket-only flow
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update test for schedule_atproto_sync, fix cookies deprecation
- test_list_record_sync: patch schedule_atproto_sync instead of removed sync_atproto_records
- test_hidden_tags_filter: move cookies to client constructor to fix httpx deprecation warning
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: template database pattern for fast parallel test execution
- use template db + clone for xdist workers (instant file copy vs migrations)
- advisory locks coordinate template creation between workers
- patch settings.database.url per-worker for production code compatibility
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* ci: enable parallel test execution with xdist
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: ensure test_app fixture depends on db_session for xdist
fixes race condition where tests using test_app without db_session
would run before database URL was patched for the worker
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* docs: add testing philosophy and xdist parallel execution guide
- template database pattern for fast parallel tests
- behavior vs implementation testing philosophy
- common pitfalls with fixture dependencies
- guidance on when private function tests are acceptable
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat: migrate ATProto sync and teal scrobbling to docket
- add sync_atproto and scrobble_to_teal as docket background tasks
- remove all fallback/bifurcation code - Redis is always required
- simplify background.py (remove is_docket_enabled)
- update auth.py and playback.py to use new schedulers
- add Redis service to CI workflow
- update tests for simplified docket-only flow
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: update test for schedule_atproto_sync, fix cookies deprecation
- test_list_record_sync: patch schedule_atproto_sync instead of removed sync_atproto_records
- test_hidden_tags_filter: move cookies to client constructor to fix httpx deprecation warning
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: template database pattern for fast parallel test execution
- use template db + clone for xdist workers (instant file copy vs migrations)
- advisory locks coordinate template creation between workers
- patch settings.database.url per-worker for production code compatibility
- borrowed pattern from prefecthq/nebula
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* ci: enable parallel test execution with xdist
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: ensure test_app fixture depends on db_session for xdist
fixes race condition where tests using test_app without db_session
would run before database URL was patched for the worker
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
- move export processing from FastAPI BackgroundTasks to docket
- add process_export task function with full R2 upload logic
- add schedule_export helper with asyncio fallback
- register process_export in docket worker
- add tests for scheduling with docket and asyncio fallback
- update background-tasks.md documentation
exports now benefit from docket's durability: if a worker crashes
mid-export, the task will be retried on restart.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* docs: update env docs for docket and suppressed loggers
- fly.toml: update secrets comments with DOCKET_URL, remove stale entries
- fly.staging.toml: add secrets comments (was missing)
- .env.example: add DOCKET_* and LOGFIRE_SUPPRESSED_LOGGERS settings
- configuration.md: document suppressed_loggers setting
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: add docket_runs.py script to check task status
standalone uv script to inspect docket runs in redis:
- works with local, staging, or production environments
- no SSH required - connects directly to redis
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat: add pydocket for background task infrastructure
- add pydocket dependency for Redis-backed background tasks
- add DocketSettings to config (defaults to memory:// for local dev)
- create background.py with docket initialization and worker lifespan
- create background_tasks.py with scan_copyright task
- migrate copyright scan from asyncio.create_task to docket.add()
- add Redis to test docker-compose
- update AGENTS.md with test command note
the copyright scan is the first task migrated to docket. upload processing
still uses FastAPI BackgroundTasks pending auth session refactoring.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: make docket opt-in, add fallback to asyncio.create_task
- change DOCKET_URL default from "memory://plyr" to "" (disabled)
- add is_docket_enabled() check function
- update background_worker_lifespan() to yield None when disabled
- uploads.py: check is_docket_enabled(), fallback to asyncio.create_task
- remove stub process_upload from background_tasks.py
memory mode won't work in production with multiple machines, so docket
must be explicitly enabled with a Redis URL. when disabled, copyright
scans fall back to fire-and-forget asyncio.create_task().
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: clean up upload background processing
- extract featured artist resolution to handles.py
- extract schedule_copyright_scan to unify docket/asyncio branching
- break _process_upload_background into smaller helper functions:
- _save_audio_to_storage
- _save_image_to_storage
- _add_tags_to_track
- _send_track_notification
- fix sketchy json.JSONDecodeError pass with proper logging
- add compose.yaml for local redis
- add just commands: dev-up, dev-down, dev
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* chore: simplify just dev to single command
removes dev-up/dev-down, just use 'just dev' which starts redis,
backend (with DOCKET_URL set), and frontend.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* docs: add background tasks and redis documentation
- add docs/backend/background-tasks.md covering docket/redis setup
- update configuration.md with docket settings
- update environments.md with redis instances per environment
- created Upstash Redis instances:
- plyr-redis-prd (production, iad region)
- plyr-redis-stg (staging, iad region)
note: DOCKET_URL not yet wired to fly secrets - will test in dev first
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: use UploadContext dataclass for background processing
- replace 12 function arguments with single UploadContext dataclass
- cleaner interface for _process_upload_background
- update just commands: dev-services / dev-services-down
- update local dev docs to reflect separate terminal workflow
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: reorganize atproto module by lexicon namespace
- split monolithic records.py (1000+ lines) into focused modules:
- client.py: PDS request/auth logic, token refresh with per-session locks
- records/fm_plyr/: plyr.fm lexicons (track, like, comment, list, profile)
- records/fm_teal/: teal.fm lexicons (play, status)
- sync.py: high-level sync orchestration
- maintain backward compatibility via re-exports in records/__init__.py
- update test patch paths for new module locations
- add dev-services commands for local redis via docker compose
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* style: make header stats smaller and evenly distributed
- reduce stats font size (0.75rem → 0.65rem) and icons (14px → 12px)
- distribute stats and search evenly across left margin with space-evenly
- margin width responds correctly when queue panel opens
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: resolve docket startup and logging issues
- rename 'name' to 'docket_name' in log extra dict (conflicts with LogRecord.name)
- suppress docket logger noise by setting level to WARNING
- remove unnecessary sleep(0.1) from worker shutdown
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: make suppressed loggers configurable
- add LOGFIRE_SUPPRESSED_LOGGERS setting (defaults to "docket")
- iterate over setting in main.py instead of hardcoding
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
- artist support link feature (PR #532)
- inline playlist editing (PR #531)
- platform stats enhancements (PRs #522, #528)
- navigation data loading fixes (PR #527)
- copyright moderation improvements (PR #480)
- letta-backed status maintenance (PR #529)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* feat: add artist support link setting
allows artists to set a support URL (Ko-fi, Patreon, etc.) in their
settings that displays as a button on their public profile page.
- add support_url field to UserPreferences model with migration
- update preferences API to handle support_url get/update
- expose support_url on public artist profile endpoints
- add settings UI with https:// validation
- display support button next to share on artist profiles
- add backend tests for support_url functionality
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: move support link to portal profile section
- moved support URL field from settings page to portal profile section
- renamed "profile settings" to "profile" in portal
- aligned support button with share button on artist profile (32px height)
- cleaned up unused CSS from settings page
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat: letta-backed status maintenance
replaces claude-code-action with a letta-backed python script that
maintains persistent memory across runs. the agent remembers:
- previous status updates and their content
- architectural decisions and patterns
- recurring themes in development
this means the agent doesn't need to re-read everything from scratch
each week - it builds on accumulated context.
new files:
- scripts/status_maintenance.py - main CI script
- scripts/letta_status_agent.py - local testing/interaction
workflow changes:
- uses uv run scripts/status_maintenance.py instead of claude-code-action
- adds LETTA_API_KEY secret requirement
- adds --dry-run option for testing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: use structured outputs and read full STATUS.md
- use anthropic's structured outputs beta (structured-outputs-2025-11-13)
with Pydantic model for guaranteed schema compliance
- read the full STATUS.md content, not truncated to 3000 chars
- remove manual JSON parsing/begging - let the API handle it
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: use opus 4.5 by default, make model configurable
- add model setting defaulting to claude-opus-4-5-20251101
- configurable via ANTHROPIC_MODEL env var
- use settings.model everywhere instead of hardcoded sonnet
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: memory blocks for project understanding, restore full prompt guidance
memory blocks refactored:
- project_architecture: how plyr.fm is built, key design decisions
- atproto_context: understanding of ATProto, lexicons, NSIDs
- recurring_patterns: themes that come up repeatedly
the agent no longer tracks 'what it processed' - github (last merged PR)
is the source of truth for the time window. memory is purely for
accumulating project understanding.
restored all original prompt guidance:
- full narrative structure (opening, main story, secondary, rapid fire, closing)
- tone requirements (skeptical, amused, no superlatives)
- pronunciation rules (player FM, never plyr.fm)
- time reference rules (specific dates, never 'last week')
- identifying what shipped vs improved
- first episode detection and longer script guidance
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* chore: tighten system prompt (-75 lines)
preserve critical guidance:
- pronunciation (player FM)
- time bounds (specific dates)
- narrative structure
- tone (dry, sardonic)
remove redundancy:
- consolidated CRITICAL markers
- merged duplicate tone sections
- simplified structure timing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* Add unified edit mode for playlists with track deletion
- Consolidate edit functionality into single edit mode (like Spotify)
- Edit button now toggles edit mode instead of opening modal
- In edit mode: show drag handles for reordering, delete buttons for tracks, and edit details button
- Remove separate reorder button - reordering is now part of edit mode
- Add track deletion UI with loading states and error handling
- Track deletion uses existing backend API endpoint
* fix: simplify playlist edit mode UX
- fix undefined showEdit variable errors (use showEditModal)
- simplify edit mode: one button to enter/exit edit mode
- in edit mode: cover art is clickable to edit metadata, tracks show
drag handles and delete buttons, add tracks button appears inline
- remove redundant "edit details", "add tracks", "saving..." buttons
- use proper button element for clickable cover art (fixes a11y warning)
- clean up unused CSS selectors
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: inline playlist editing with direct cover upload
- title editable inline as input field when in edit mode
- cover art directly replaceable via click (opens file picker)
- removed edit modal entirely - all editing is now inline
- fixed font inheritance on overlay text
- cleaned up unused CSS selectors from removed modal
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
- Stats and search now in one flex container, centered in left margin
- Mirrors how logout is centered in right margin
- Container has max-width to prevent overflow into content area
- Stats can wrap to 2 rows if numbers get large (flex-wrap)
- Smaller font size (0.75rem) for stats to fit better
- Breakpoint at 1599px to account for queue panel
* fix: reload data when navigating between detail pages of same type
SvelteKit reuses component instances when navigating between routes with
the same layout. This meant `onMount` didn't re-run when going from one
artist page to another, or one track page to another.
- replace `onMount` with `$effect` watching server data (data.artist.did,
data.track.id) to detect navigation
- reset local state and reload fresh data when route params change
- also: change "of music" to "of audio" in platform stats (not all
uploads are music - some are audiobooks, public domain recordings, etc)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: add Tangled repo link and fix responsive header breakpoints
- Add Tangled link with sheep avatar to desktop header and mobile LinksMenu
- Consolidate breakpoints: desktop elements and mobile layout now switch at 1299px
- Previously stats/search/logout disappeared at 1499px but mobile didn't show until 1299px,
leaving a gap where users couldn't access those features
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: bump responsive breakpoint to 1399px to prevent stats/search collision
Switch to mobile layout earlier to avoid cramped margin elements
* fix: accent color on hover for nav-link and Tangled icon
- nav-link (feed/library) now uses accent color on hover
- Tangled icon gets accent-colored ring on hover
- Nudge search component 20px right to prevent overlap with stats
---------
Co-authored-by: Claude <noreply@anthropic.com>
- add `total_duration_seconds` to platform stats endpoint (`/stats`)
- add `total_duration_seconds` to artist analytics endpoint (`/artists/{did}/analytics`)
- display duration in homepage stats bar (header and menu variants)
- show duration as subtitle in artist page "total tracks" card
- add `formatDuration()` helper for human-readable format (e.g., "25h 32m")
- add `scripts/user_upload_stats.py` for viewing per-user upload durations
- add regression tests for stats and analytics endpoints
this lays groundwork for future upload caps per user.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* fix: sync copyright flag resolution from labeler to portal
when an admin marks a track as "false positive" in the moderation UI,
the flag now correctly clears from the user's portal. previously the
backend's copyright_scans table was never updated, leaving stale flags.
the fix uses an idempotent approach - the backend now queries the
labeler for the source of truth rather than requiring a sync/backfill:
moderation service:
- add POST /admin/active-labels endpoint
- returns which URIs have active (non-negated) copyright-violation labels
backend:
- add get_active_copyright_labels() to query the labeler
- update get_copyright_info() to check labeler for pending flags
- lazily update resolution field when labeler confirms resolution
- skip labeler call for already-resolved scans (optimization)
behavior:
- existing resolved flags immediately take effect (no backfill needed)
- fails closed: if labeler unreachable, flags remain visible (safe default)
- lazy DB update reduces future labeler calls for same track
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: add copyright attestation checkbox to upload flow
users must now confirm they have distribution rights before uploading.
includes educational copy about "publicly available ≠ licensed" to reduce
good-faith mistakes (per tigers blood incident retrospective).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
- backend expects tags as JSON arrays (`["ai"]`), not bare strings
- check existing tracks via `plyrfm my-tracks` before uploading
- if a track for today already exists, add episode number (#2, #3, etc)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
- switch from sonnet (default) to opus for higher quality output
- add Task tool to enable subagent investigation of PRs
- restructure podcast script generation to:
- deeply investigate PRs before writing (read bodies, diffs, design decisions)
- categorize changes into big ticket items vs smaller fixes
- follow explicit chronological narrative structure:
- opening (10s)
- main story with design discussion (60-90s)
- secondary feature (30-45s)
- rapid fire smaller changes (20-30s)
- closing (10s)
- emphasize explaining HOW things were designed, not just WHAT
closes #521
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
- add CLAUDE.md with instructions for getting workflow summary URLs
- fix pronunciation: tell model to WRITE "player FM" not "plyr.fm" (TTS mispronounces plyr)
- add guidance to distinguish major feature launches from polish/fixes
- explicitly forbid vague time references like "last week" - require specific dates
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
- add get_record_public() to _internal/atproto/records.py for unauthenticated ATProto record fetches
- remove auth requirement from GET /playlists/{id} endpoint
- remove auth redirect from frontend playlist page loader
- edit/delete buttons still only show for playlist owner (via client-side auth check)
ATProto records are public by design - the previous auth requirement was unnecessary for read access.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
the workflow was using $(date +%Y%m%d) for branch names, causing
collisions when run multiple times on the same day. this led to
the audio file not being committed in PR #516.
now uses $(date +%Y%m%d-%H%M%S) stored in a variable to ensure
unique branch names and consistent usage across checkout and push.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* fix: detail page button layouts
playlist detail page:
- show edit/delete buttons on mobile (were hidden, only share showed)
- rename mobile-share-button to mobile-buttons for clarity
track detail page:
- remove bifurcated desktop layout (buttons on left/right sides)
- use centered button layout everywhere (like mobile had)
- simplify CSS by removing side-button-left/right styles
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: AddToMenu opens upward when near bottom of viewport
when the menu trigger is close to the bottom of the viewport,
detect available space and open the menu upward instead of downward.
this prevents the menu from being cut off by the player.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
living documentation explaining:
- what lexicons are and how ATProto uses them
- our fm.plyr namespace and environment awareness
- each lexicon (track, like, comment, list, profile) with history
- ATProto primitives we use (tid, literal:self, strongRef, knownValues)
- local indexing pattern for performance
- future codegen plans (issue #494)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
the workflow was using "last week" as its time window, which caused it
to miss work that happened between the last merged PR and ~7 days ago.
changes:
- query gh pr list to find when last status-maintenance PR was MERGED
- filter by branch name (startswith "status-maintenance-") and sort by mergedAt
- use that merge date as the starting point, not "last week"
- clarify that archive files are organized by month (YYYY-MM.md)
- add examples for archive file naming (2025-12.md for December, etc.)
- remove hardcoded "weekly" titles, let Claude craft descriptive titles
fixes the issue where PR #512 only covered Dec 7th onwards when it
should have covered everything since Dec 2nd (last merge date).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
documents the fast-follow fixes after the playlists release:
- PR #507: playlist og:image link previews
- PR #508: auth invalidation after login
- PR #509: playlist menu fixes and link previews
- PR #510: inline playlist creation to avoid playback interruption
also updates the playlists section to reflect that create playlist is now
inline rather than navigation-based.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>