commits
Detected suspicious activity: 72 requests in 17 seconds from a single IP
with no user agent, targeting only this endpoint. Added 10/minute rate
limit to prevent abuse.
Investigation details:
- Single IP (172.16.17.202 via Fly proxy) hitting endpoint repeatedly
- Requests spaced ~230ms apart (too consistent for human browsing)
- No corresponding user activity (page loads, audio streams)
- All requests had no User-Agent header
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- STATUS.md: add sprint section (Dec 20-31) with two tracks:
- moderation architecture overhaul (Osprey/Ozone patterns)
- atprotofans paywall integration (supporter gating)
- research docs for both tracks with implementation phases
- updated immediate priorities to reflect sprint focus
tracking: #625
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
- LikeButton: use overridable $derived instead of $state + $effect for
optimistic UI, add aria-label for accessibility
- TrackItem: use $derived for likeCount/commentCount that sync with props,
use $effect.pre for resetting local UI state on track change
- docs: document overridable $derived pattern in state-management.md
reviewed against Svelte MCP autofixer recommendations
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- beartype runtime type checking (PR #619)
- moderation cleanup consolidation (PRs #617-618)
- login UX improvements (PRs #604, #613)
- artist page pagination + mobile fixes (PR #615)
- Open Graph tags for tag pages (PRs #605-607)
- misc: upload button, background settings, atprotofans link, AudD billing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Buttons don't inherit font from parent by default in CSS.
Co-authored-by: Claude <noreply@anthropic.com>
* fix: enable beartype runtime type checking and fix type violations
enables beartype for runtime type checking across the backend package.
this catches type violations at function call time, improving reliability.
**type fixes:**
- `_get_existing_track_order`: accept `str | None` for album_atproto_uri
- `_emit_copyright_label`: use `int` for highest_score (matches db model)
- `ModerationClient.__init__`: accept `int | float` for timeout_seconds
- `UploadProgressTracker`: accept `int | float` for min_time_between_updates
- `hash_file_chunked`: use `BinaryIO | IOBase` (works with BytesIO and file handles)
- `build_track_record` callers: guard against None r2_url before calling
**test fixes:**
- `MockStorage`: inherit from `R2Storage` for proper type compatibility
- `test_update_album_title`: add `r2_url` to track fixture
**refactors:**
- `storage/__init__.py`: import `R2Storage` directly (no lazy forward ref)
- `image.py`, `audio.py`: use `typing.Self` for classmethod return types
- `auth.py`: import `EllipticCurvePrivateKey` directly
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* chore: add beartype as explicit dependency
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: disable automatic perpetual task scheduling in tests
The docket worker's automatic perpetual task scheduling was causing
event loop issues during test teardown. The Worker creates async
connections that get attached to one event loop, but TestClient
teardown runs on a different loop.
Added DOCKET_SCHEDULE_AUTOMATIC_TASKS setting (default: true) and
set it to false in test environment to prevent this issue.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* perf: session-scope TestClient fixture for 5x faster tests
The client fixture was function-scoped, causing the full FastAPI
lifespan (database init, services, docket worker) to run for each
test. Switching to session-scope reduces test_stats.py from 26s to 5s.
Full test suite now runs in ~17s instead of potentially much longer.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor: remove init_db() from lifespan
init_db() called Base.metadata.create_all on every server start.
This was a no-op since all tables already exist in dev/staging/prod.
Tests handle their own table creation via conftest.py.
Dead code removed. Database schema is managed by alembic migrations.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
The task was registered but never scheduled periodically. Adding
Perpetual(every=timedelta(minutes=5), automatic=True) makes it run
automatically every 5 minutes after worker startup.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* refactor: moderation cleanup (#541, #542, #543)
this PR consolidates moderation architecture across three related issues:
**#541: extract ModerationClient class**
- new `moderation_client.py` with centralized client for all moderation service calls
- replaces scattered httpx.AsyncClient instantiation with singleton pattern
- consistent timeout, auth, and caching behavior
**#542: move lazy resolution to background task**
- add `sync_copyright_resolutions()` background task
- removes lazy reconciliation from read paths
- runs periodically to sync labeler negation status
**#543: simplify get_copyright_info to pure read**
- remove write-on-read pattern from aggregations
- `is_flagged` is now the source of truth (synced by background task)
- labeler remains authoritative; we just don't query it on every read
**breaking: removes resolution columns from copyright_scans**
- `resolution`, `reviewed_at`, `reviewed_by`, `review_notes` removed
- labeler is now the single source of truth for resolution status
- migration included to drop columns
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add research/plan/implement workflow commands
inspired by HumanLayer patterns, adds a three-phase workflow for complex tasks:
- `/research [topic]` - deep dive on a topic, persist to docs/research/
- `/plan [issue or description]` - create implementation plan, persist to docs/plans/
- `/implement [plan path]` - execute a plan phase by phase
also improves `/consider-review` to properly fetch and process PR feedback.
the workflow encourages:
- exploring before implementing
- persisting knowledge for future reference
- no open questions in plans
- systematic verification during implementation
🤖 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>
- pass hasMoreTracks and nextCursor from API response to frontend
- add "load more tracks" button when there are more tracks to load
- display total track count in section header (from analytics)
- fix album cards running off screen on mobile:
- smaller cover images (56px vs 72px)
- tighter padding and gaps
- smaller text sizes
- ensure overflow:hidden on cards
closes pyxorium.com visibility issue (252 tracks, only 50 shown)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
strips common prefixes from user input on the login page:
- `@user.bsky.social` → `user.bsky.social`
- `at://user.bsky.social` → `user.bsky.social`
- `at://did:plc:abc123` → `did:plc:abc123`
the more complex URL handling and .bsky.social auto-append
need backend support and are deferred for now.
inspired by https://ngerakines.leaflet.pub/3ma7hed2kdk2x
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add offline mode foundation with auto-download liked tracks
- add storage.ts with Cache API + IndexedDB for offline audio storage
- add GET /audio/{file_id}/url endpoint returning direct R2 URLs for caching
- add auto_download_liked preference (stored in localStorage, device-specific)
- add settings toggle that bulk-downloads all liked tracks when enabled
- auto-download new tracks when liking (if preference enabled)
- Player component checks for cached audio before streaming
- fix TLFM reauth notice to show correct message
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: require auth for audio URL endpoint
adds authentication to GET /audio/{file_id}/url to prevent
unauthenticated enumeration of audio URLs.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: improve IndexedDB robustness
- close database connections after each operation (fixes connection leak)
- deduplicate concurrent downloads using in-flight promise map
- verify cache entry exists in isDownloaded() and clean up stale metadata
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor: move auto-download toggle to experimental section
keeps the feature available but clearly marked as experimental
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add offline mode foundation with auto-download liked tracks
- add storage.ts with Cache API + IndexedDB for offline audio storage
- add GET /audio/{file_id}/url endpoint returning direct R2 URLs for caching
- add auto_download_liked preference (stored in localStorage, device-specific)
- add settings toggle that bulk-downloads all liked tracks when enabled
- auto-download new tracks when liking (if preference enabled)
- Player component checks for cached audio before streaming
- fix TLFM reauth notice to show correct message
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: require auth for audio URL endpoint
adds authentication to GET /audio/{file_id}/url to prevent
unauthenticated enumeration of audio URLs.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: improve IndexedDB robustness
- close database connections after each operation (fixes connection leak)
- deduplicate concurrent downloads using in-flight promise map
- verify cache entry exists in isDownloaded() and clean up stale metadata
🤖 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>
* fix: mobile modal positioning to use full screen
On mobile, modals were getting cut off due to Safari sticky+fixed
positioning issues. Changed from center-based positioning to inset-based
(top/left/right/bottom) to use the full available screen space.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: use svelte-portal for modal positioning
the header's backdrop-filter creates a CSS containing block, which
causes position:fixed modals to be positioned relative to the header
instead of the viewport. use svelte-portal to render modals directly
on document.body, preserving proper centering.
- add svelte-portal dependency
- apply use:portal={'body'} to LinksMenu and ProfileMenu modals
- add docs/frontend/portals.md documenting this pattern
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
The layout was rendering default OG tags even for tag pages because
/tag/ wasn't in the hasPageMetadata list, causing crawlers to use
the first (default) tags instead of the page-specific ones.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- add +page.server.ts for SSR tag metadata (name, track count)
- add og:title, og:description, twitter:card meta tags
- keep tracks fetched client-side to preserve auth state
- link previews now show "X tracks tagged #tagname"
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- use "internet handle" label with link to internethandle.org
- collapsible FAQ sections for explanation
- simpler "sign in" button
- cleaner spacing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
Audio update covering Dec 8-17:
- Glass effects and custom background images
- Accurate AudD cost tracking from track duration
- Moderation agent with audit trails
- Performance improvements (removed slow moderation calls)
- iOS Safari fixes, sticky header, album track order preservation
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
reverts PR #529. the letta account has been discontinued, so reverting
to the original claude-code-action workflow.
changes:
- restore .github/workflows/status-maintenance.yml to use anthropics/claude-code-action@v1
- delete scripts/status_maintenance.py (letta-backed script)
- delete scripts/letta_status_agent.py (local testing script)
- update STATUS.md to remove letta references
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- calculate API requests from track duration (1 request = 12s audio)
- remove hardcoded Nov 24 fallback - now fully dynamic from DB
- add new fields: requests_this_period, base_cost, overage_cost, billable_requests
- update daily chart to show requests instead of scan count
- change GHA workflow from daily to hourly for near real-time visibility
- update frontend to display request-based metrics with explainer text
AudD billing: $5/mo base + $5/1k requests over 6000 free tier
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- adds upload link between library and @handle in desktop nav
- styled with accent border/color as primary CTA
- hover fills with accent background
- hidden when already on /upload page
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- add pointer-events: none to body::before (fixes input blocking)
- remove disabled state from background URL input (can set fallback)
- fix toast message when toggling playing artwork off
- update descriptions to clarify fallback behavior
- always show tile checkbox when background URL is set
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- visual customization section (PRs #595-596): custom backgrounds, glass effects
- added #557 to album management (slug sync fix)
- added #549 to costs dashboard (dedicated R2 bucket)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
the translateZ transform was causing tracks to overlap each other
in 3D space, blocking click events on like/share buttons for all
tracks below the first one. the queue button worked because it was
positioned differently in the layout.
removed the wheel effect entirely - the visual effect wasn't worth
the broken interactivity.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add glass effects and track item styling
- add CSS variables for glass effects (--glass-bg, --glass-blur, --glass-border)
- apply backdrop-filter blur to Player, Header, and Queue sidebar
- add translucent backgrounds to TrackItem without blur (performance safe)
- add subtle border-radius (6px) and box-shadow to track items
- support both dark and light themes with appropriate glass values
- remove conflicting light theme overrides in favor of CSS variables
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add ui_settings JSONB column for extensible preferences
- add ui_settings JSONB column to user_preferences table
- update preferences API to expose ui_settings field
- merge ui_settings on partial updates to support incremental changes
- add migration for new column
- add tests for ui_settings CRUD and persistence
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add background image settings UI
- add UiSettings interface with background_image_url and background_tile
- add background image URL input and tile toggle in settings page
- apply background image via CSS custom properties in layout
- update preferences manager with updateUiSettings method
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: refine track item hover behavior and glass styling
- remove chunky left border, use uniform subtle border
- add tactile hover: 0.5px lift with accent-tinted glow
- smooth cubic-bezier easing for polished feel
- active state settles back down on click
- adjust track background opacity (88%) for better balance
- fix background image input reactivity bug
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add subtle 3D wheel scroll effect to track items
tracks now appear on a convex cylinder surface:
- items at viewport center are closest
- items above/below rotate away slightly (2° max)
- uses passive scroll listener for performance
- transform-style: preserve-3d for proper layering
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: glass button styling for background image visibility
icon buttons now have translucent backgrounds when a
background image is set, ensuring they remain visible
against any background:
- ShareButton gets glass background
- playlist page icon-btn (edit, delete) gets glass bg
- HiddenTagsFilter eyeball toggle gets glass bg
- glass button CSS variables set dynamically in layout
when background image is present
- respects light/dark theme with appropriate opacity
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: consistent glass button styling and preserve bg image on refresh
- unified queue/action buttons across tag, liked, playlist, and album pages
to use glass button CSS variables (--glass-btn-bg, --glass-btn-border)
- only apply background image changes when preferences are actually loaded
to prevent clearing the background image on refresh/hydration
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: use playing track artwork as background option
adds a new toggle in settings to use the currently playing
track's artwork as the background image:
- new ui_settings.use_playing_artwork_as_background option
- when enabled, overrides custom background image URL
- background changes dynamically as tracks change
- disables the custom URL input when enabled
- playing artwork never tiles (always cover)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: blur and tile playing artwork background
- playing artwork now tiles in a 4x4 grid (25% size)
- applies 40px blur for smooth, ambient effect
- uses body::before pseudo-element with scale(1.1) to prevent blur edge artifacts
- custom background images remain unblurred
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: fall back to custom bg when playing track has no artwork
when "use playing artwork as background" is enabled but the
current track has no artwork, now falls back to the custom
background URL if one is set (instead of showing nothing)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add subtle text glow for readability against backgrounds
adds a --text-shadow CSS variable that provides a soft glow effect
around gray metadata text when a background image is set. this improves
readability without being visually heavy like a drop shadow.
applied to:
- album page metadata (type, title, meta, artist link)
- tag page track count subtitle
- settings page section headers
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: use proper fallbacks for glass button styling
when no background image is set, buttons should fall back to
transparent backgrounds and standard border colors rather than
hardcoded dark theme glass values.
🤖 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>
STATUS.md:
- added missing section for Dec 10-12 work (PRs #558-572)
- documents background task expansion (like/comment PDS writes, album sync)
- documents Redis cache for copyright labels (PR #566)
- documents mobile UX improvements and misc fixes
background-tasks.md:
- added new tasks: album list sync, PDS like/unlike, PDS comment CRUD
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* fix: make header sticky on scroll
- change overflow-x from hidden to clip on .app-layout to preserve
position: sticky on descendants
- add position: sticky and background to header so it sticks to top
when scrolling through long lists
previously, scrolling down a long track list required scrolling all the
way back up to access search or navigation. now the header stays visible.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* docs: update STATUS.md with Dec 14-15 work
- performance improvements (PRs #590-591)
- moderation agent (PRs #586, #588)
- bug fixes (PRs #589, #592, #593)
- iOS Safari fixes (PRs #573-576)
🤖 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>
* fix: preserve album track order during ATProto sync (#587)
when syncing album list records to ATProto, the background sync was
always ordering tracks by created_at, overwriting any custom order
the user had set via the frontend reorder feature.
now the sync:
1. fetches the existing ATProto list record order (if any)
2. preserves that order for existing tracks
3. appends any new tracks at the end (sorted by created_at)
this prevents user-reordered albums from reverting on login.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: use unique DID and cleanup in test to avoid CI conflicts
the test was sharing fixtures with fixed DIDs causing foreign key
constraint violations during teardown when running in parallel with xdist.
now uses:
- unique DID with uuid suffix for test isolation
- explicit cleanup in finally block (tracks → albums → artists order)
- properly typed capture function for lint compliance
🤖 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>
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>
Detected suspicious activity: 72 requests in 17 seconds from a single IP
with no user agent, targeting only this endpoint. Added 10/minute rate
limit to prevent abuse.
Investigation details:
- Single IP (172.16.17.202 via Fly proxy) hitting endpoint repeatedly
- Requests spaced ~230ms apart (too consistent for human browsing)
- No corresponding user activity (page loads, audio streams)
- All requests had no User-Agent header
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- STATUS.md: add sprint section (Dec 20-31) with two tracks:
- moderation architecture overhaul (Osprey/Ozone patterns)
- atprotofans paywall integration (supporter gating)
- research docs for both tracks with implementation phases
- updated immediate priorities to reflect sprint focus
tracking: #625
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
- LikeButton: use overridable $derived instead of $state + $effect for
optimistic UI, add aria-label for accessibility
- TrackItem: use $derived for likeCount/commentCount that sync with props,
use $effect.pre for resetting local UI state on track change
- docs: document overridable $derived pattern in state-management.md
reviewed against Svelte MCP autofixer recommendations
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- beartype runtime type checking (PR #619)
- moderation cleanup consolidation (PRs #617-618)
- login UX improvements (PRs #604, #613)
- artist page pagination + mobile fixes (PR #615)
- Open Graph tags for tag pages (PRs #605-607)
- misc: upload button, background settings, atprotofans link, AudD billing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* fix: enable beartype runtime type checking and fix type violations
enables beartype for runtime type checking across the backend package.
this catches type violations at function call time, improving reliability.
**type fixes:**
- `_get_existing_track_order`: accept `str | None` for album_atproto_uri
- `_emit_copyright_label`: use `int` for highest_score (matches db model)
- `ModerationClient.__init__`: accept `int | float` for timeout_seconds
- `UploadProgressTracker`: accept `int | float` for min_time_between_updates
- `hash_file_chunked`: use `BinaryIO | IOBase` (works with BytesIO and file handles)
- `build_track_record` callers: guard against None r2_url before calling
**test fixes:**
- `MockStorage`: inherit from `R2Storage` for proper type compatibility
- `test_update_album_title`: add `r2_url` to track fixture
**refactors:**
- `storage/__init__.py`: import `R2Storage` directly (no lazy forward ref)
- `image.py`, `audio.py`: use `typing.Self` for classmethod return types
- `auth.py`: import `EllipticCurvePrivateKey` directly
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* chore: add beartype as explicit dependency
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: disable automatic perpetual task scheduling in tests
The docket worker's automatic perpetual task scheduling was causing
event loop issues during test teardown. The Worker creates async
connections that get attached to one event loop, but TestClient
teardown runs on a different loop.
Added DOCKET_SCHEDULE_AUTOMATIC_TASKS setting (default: true) and
set it to false in test environment to prevent this issue.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* perf: session-scope TestClient fixture for 5x faster tests
The client fixture was function-scoped, causing the full FastAPI
lifespan (database init, services, docket worker) to run for each
test. Switching to session-scope reduces test_stats.py from 26s to 5s.
Full test suite now runs in ~17s instead of potentially much longer.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor: remove init_db() from lifespan
init_db() called Base.metadata.create_all on every server start.
This was a no-op since all tables already exist in dev/staging/prod.
Tests handle their own table creation via conftest.py.
Dead code removed. Database schema is managed by alembic migrations.
🤖 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>
* refactor: moderation cleanup (#541, #542, #543)
this PR consolidates moderation architecture across three related issues:
**#541: extract ModerationClient class**
- new `moderation_client.py` with centralized client for all moderation service calls
- replaces scattered httpx.AsyncClient instantiation with singleton pattern
- consistent timeout, auth, and caching behavior
**#542: move lazy resolution to background task**
- add `sync_copyright_resolutions()` background task
- removes lazy reconciliation from read paths
- runs periodically to sync labeler negation status
**#543: simplify get_copyright_info to pure read**
- remove write-on-read pattern from aggregations
- `is_flagged` is now the source of truth (synced by background task)
- labeler remains authoritative; we just don't query it on every read
**breaking: removes resolution columns from copyright_scans**
- `resolution`, `reviewed_at`, `reviewed_by`, `review_notes` removed
- labeler is now the single source of truth for resolution status
- migration included to drop columns
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add research/plan/implement workflow commands
inspired by HumanLayer patterns, adds a three-phase workflow for complex tasks:
- `/research [topic]` - deep dive on a topic, persist to docs/research/
- `/plan [issue or description]` - create implementation plan, persist to docs/plans/
- `/implement [plan path]` - execute a plan phase by phase
also improves `/consider-review` to properly fetch and process PR feedback.
the workflow encourages:
- exploring before implementing
- persisting knowledge for future reference
- no open questions in plans
- systematic verification during implementation
🤖 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>
- pass hasMoreTracks and nextCursor from API response to frontend
- add "load more tracks" button when there are more tracks to load
- display total track count in section header (from analytics)
- fix album cards running off screen on mobile:
- smaller cover images (56px vs 72px)
- tighter padding and gaps
- smaller text sizes
- ensure overflow:hidden on cards
closes pyxorium.com visibility issue (252 tracks, only 50 shown)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
strips common prefixes from user input on the login page:
- `@user.bsky.social` → `user.bsky.social`
- `at://user.bsky.social` → `user.bsky.social`
- `at://did:plc:abc123` → `did:plc:abc123`
the more complex URL handling and .bsky.social auto-append
need backend support and are deferred for now.
inspired by https://ngerakines.leaflet.pub/3ma7hed2kdk2x
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add offline mode foundation with auto-download liked tracks
- add storage.ts with Cache API + IndexedDB for offline audio storage
- add GET /audio/{file_id}/url endpoint returning direct R2 URLs for caching
- add auto_download_liked preference (stored in localStorage, device-specific)
- add settings toggle that bulk-downloads all liked tracks when enabled
- auto-download new tracks when liking (if preference enabled)
- Player component checks for cached audio before streaming
- fix TLFM reauth notice to show correct message
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: require auth for audio URL endpoint
adds authentication to GET /audio/{file_id}/url to prevent
unauthenticated enumeration of audio URLs.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: improve IndexedDB robustness
- close database connections after each operation (fixes connection leak)
- deduplicate concurrent downloads using in-flight promise map
- verify cache entry exists in isDownloaded() and clean up stale metadata
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* refactor: move auto-download toggle to experimental section
keeps the feature available but clearly marked as experimental
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add offline mode foundation with auto-download liked tracks
- add storage.ts with Cache API + IndexedDB for offline audio storage
- add GET /audio/{file_id}/url endpoint returning direct R2 URLs for caching
- add auto_download_liked preference (stored in localStorage, device-specific)
- add settings toggle that bulk-downloads all liked tracks when enabled
- auto-download new tracks when liking (if preference enabled)
- Player component checks for cached audio before streaming
- fix TLFM reauth notice to show correct message
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: require auth for audio URL endpoint
adds authentication to GET /audio/{file_id}/url to prevent
unauthenticated enumeration of audio URLs.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: improve IndexedDB robustness
- close database connections after each operation (fixes connection leak)
- deduplicate concurrent downloads using in-flight promise map
- verify cache entry exists in isDownloaded() and clean up stale metadata
🤖 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>
* fix: mobile modal positioning to use full screen
On mobile, modals were getting cut off due to Safari sticky+fixed
positioning issues. Changed from center-based positioning to inset-based
(top/left/right/bottom) to use the full available screen space.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: use svelte-portal for modal positioning
the header's backdrop-filter creates a CSS containing block, which
causes position:fixed modals to be positioned relative to the header
instead of the viewport. use svelte-portal to render modals directly
on document.body, preserving proper centering.
- add svelte-portal dependency
- apply use:portal={'body'} to LinksMenu and ProfileMenu modals
- add docs/frontend/portals.md documenting this pattern
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
The layout was rendering default OG tags even for tag pages because
/tag/ wasn't in the hasPageMetadata list, causing crawlers to use
the first (default) tags instead of the page-specific ones.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- add +page.server.ts for SSR tag metadata (name, track count)
- add og:title, og:description, twitter:card meta tags
- keep tracks fetched client-side to preserve auth state
- link previews now show "X tracks tagged #tagname"
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Audio update covering Dec 8-17:
- Glass effects and custom background images
- Accurate AudD cost tracking from track duration
- Moderation agent with audit trails
- Performance improvements (removed slow moderation calls)
- iOS Safari fixes, sticky header, album track order preservation
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
reverts PR #529. the letta account has been discontinued, so reverting
to the original claude-code-action workflow.
changes:
- restore .github/workflows/status-maintenance.yml to use anthropics/claude-code-action@v1
- delete scripts/status_maintenance.py (letta-backed script)
- delete scripts/letta_status_agent.py (local testing script)
- update STATUS.md to remove letta references
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- calculate API requests from track duration (1 request = 12s audio)
- remove hardcoded Nov 24 fallback - now fully dynamic from DB
- add new fields: requests_this_period, base_cost, overage_cost, billable_requests
- update daily chart to show requests instead of scan count
- change GHA workflow from daily to hourly for near real-time visibility
- update frontend to display request-based metrics with explainer text
AudD billing: $5/mo base + $5/1k requests over 6000 free tier
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
- add pointer-events: none to body::before (fixes input blocking)
- remove disabled state from background URL input (can set fallback)
- fix toast message when toggling playing artwork off
- update descriptions to clarify fallback behavior
- always show tile checkbox when background URL is set
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
the translateZ transform was causing tracks to overlap each other
in 3D space, blocking click events on like/share buttons for all
tracks below the first one. the queue button worked because it was
positioned differently in the layout.
removed the wheel effect entirely - the visual effect wasn't worth
the broken interactivity.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add glass effects and track item styling
- add CSS variables for glass effects (--glass-bg, --glass-blur, --glass-border)
- apply backdrop-filter blur to Player, Header, and Queue sidebar
- add translucent backgrounds to TrackItem without blur (performance safe)
- add subtle border-radius (6px) and box-shadow to track items
- support both dark and light themes with appropriate glass values
- remove conflicting light theme overrides in favor of CSS variables
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add ui_settings JSONB column for extensible preferences
- add ui_settings JSONB column to user_preferences table
- update preferences API to expose ui_settings field
- merge ui_settings on partial updates to support incremental changes
- add migration for new column
- add tests for ui_settings CRUD and persistence
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add background image settings UI
- add UiSettings interface with background_image_url and background_tile
- add background image URL input and tile toggle in settings page
- apply background image via CSS custom properties in layout
- update preferences manager with updateUiSettings method
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: refine track item hover behavior and glass styling
- remove chunky left border, use uniform subtle border
- add tactile hover: 0.5px lift with accent-tinted glow
- smooth cubic-bezier easing for polished feel
- active state settles back down on click
- adjust track background opacity (88%) for better balance
- fix background image input reactivity bug
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add subtle 3D wheel scroll effect to track items
tracks now appear on a convex cylinder surface:
- items at viewport center are closest
- items above/below rotate away slightly (2° max)
- uses passive scroll listener for performance
- transform-style: preserve-3d for proper layering
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: glass button styling for background image visibility
icon buttons now have translucent backgrounds when a
background image is set, ensuring they remain visible
against any background:
- ShareButton gets glass background
- playlist page icon-btn (edit, delete) gets glass bg
- HiddenTagsFilter eyeball toggle gets glass bg
- glass button CSS variables set dynamically in layout
when background image is present
- respects light/dark theme with appropriate opacity
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: consistent glass button styling and preserve bg image on refresh
- unified queue/action buttons across tag, liked, playlist, and album pages
to use glass button CSS variables (--glass-btn-bg, --glass-btn-border)
- only apply background image changes when preferences are actually loaded
to prevent clearing the background image on refresh/hydration
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: use playing track artwork as background option
adds a new toggle in settings to use the currently playing
track's artwork as the background image:
- new ui_settings.use_playing_artwork_as_background option
- when enabled, overrides custom background image URL
- background changes dynamically as tracks change
- disables the custom URL input when enabled
- playing artwork never tiles (always cover)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: blur and tile playing artwork background
- playing artwork now tiles in a 4x4 grid (25% size)
- applies 40px blur for smooth, ambient effect
- uses body::before pseudo-element with scale(1.1) to prevent blur edge artifacts
- custom background images remain unblurred
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: fall back to custom bg when playing track has no artwork
when "use playing artwork as background" is enabled but the
current track has no artwork, now falls back to the custom
background URL if one is set (instead of showing nothing)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat: add subtle text glow for readability against backgrounds
adds a --text-shadow CSS variable that provides a soft glow effect
around gray metadata text when a background image is set. this improves
readability without being visually heavy like a drop shadow.
applied to:
- album page metadata (type, title, meta, artist link)
- tag page track count subtitle
- settings page section headers
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: use proper fallbacks for glass button styling
when no background image is set, buttons should fall back to
transparent backgrounds and standard border colors rather than
hardcoded dark theme glass values.
🤖 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>
STATUS.md:
- added missing section for Dec 10-12 work (PRs #558-572)
- documents background task expansion (like/comment PDS writes, album sync)
- documents Redis cache for copyright labels (PR #566)
- documents mobile UX improvements and misc fixes
background-tasks.md:
- added new tasks: album list sync, PDS like/unlike, PDS comment CRUD
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
* fix: make header sticky on scroll
- change overflow-x from hidden to clip on .app-layout to preserve
position: sticky on descendants
- add position: sticky and background to header so it sticks to top
when scrolling through long lists
previously, scrolling down a long track list required scrolling all the
way back up to access search or navigation. now the header stays visible.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* docs: update STATUS.md with Dec 14-15 work
- performance improvements (PRs #590-591)
- moderation agent (PRs #586, #588)
- bug fixes (PRs #589, #592, #593)
- iOS Safari fixes (PRs #573-576)
🤖 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>
* fix: preserve album track order during ATProto sync (#587)
when syncing album list records to ATProto, the background sync was
always ordering tracks by created_at, overwriting any custom order
the user had set via the frontend reorder feature.
now the sync:
1. fetches the existing ATProto list record order (if any)
2. preserves that order for existing tracks
3. appends any new tracks at the end (sorted by created_at)
this prevents user-reordered albums from reverting on login.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* fix: use unique DID and cleanup in test to avoid CI conflicts
the test was sharing fixtures with fixed DIDs causing foreign key
constraint violations during teardown when running in parallel with xdist.
now uses:
- unique DID with uuid suffix for test isolation
- explicit cleanup in finally block (tracks → albums → artists order)
- properly typed capture function for lint compliance
🤖 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>
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>