commits
* 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>
the create playlist link was navigating to /library?create=playlist, which
caused the layout to reinitialize and destroy the audio element. this fix
adds inline playlist creation to AddToMenu and TrackActionsMenu, allowing
users to create playlists and add tracks without leaving the current page.
changes:
- AddToMenu: replace link with inline create form that creates playlist
and adds track in one action
- TrackActionsMenu: same inline create form treatment
- portal: update empty state link to just go to /library (no query param)
- library page: remove query param handling (no longer needed)
- library +page.ts: use SvelteKit's fetch instead of window.fetch
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
- fix stopPropagation blocking link clicks in AddToMenu and TrackActionsMenu
- add /playlist/ to hasPageMetadata so layout default OG tags don't override
- replace LikeButton with AddToMenu on track detail page for playlist access
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
after exchanging the OAuth token for a session cookie, the root
layout's load function still has stale data (isAuthenticated: false)
from before the cookie was set.
this caused navigation to /library immediately after login to redirect
to / because the layout data said the user wasn't authenticated.
the fix: call invalidateAll() after successful token exchange so
SvelteKit reruns all load functions with the new session cookie.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
the SSR fallback object was missing image_url from playlistMeta,
causing og:image to be undefined in link previews even when the
playlist has cover art.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* perf: defer ATProto sync queries to background task
- move all sync-related DB queries out of /artists/me request path
- queries for albums, tracks, prefs, and likes now run in background
- reduces response time from ~1.2s to ~300ms (only artist lookup needed)
also fix: add trailing slash to playlist search URL (fixes 307 redirect)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: move ATProto sync from /artists/me to login callback
- create sync_atproto_records() function in records.py
- call sync as fire-and-forget background task after OAuth callback
- remove all sync logic from /artists/me (now just returns artist)
- also sync on scope upgrade flow
- update tests to verify new behavior
this ensures ATProto records sync immediately on login rather than
on profile page access, and removes unnecessary work from /artists/me
🤖 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 public liked pages for any user (#498)
likes are stored on users' PDSes as ATProto records, making them public data.
this enables viewing any user's liked tracks without authentication.
backend:
- add GET /users/{handle}/likes endpoint
- add get_optional_session dependency for endpoints that benefit from optional auth
- endpoint returns user info, tracks, and count
frontend:
- add /liked/[handle] route with user header and track list
- add fetchUserLikes API function with UserLikesResponse type
- update LikersTooltip to link to user's liked page instead of profile
tests:
- add test_users.py with 4 tests for the new endpoint
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: consolidate optional auth into get_optional_session dependency
replaces inline session_id extraction pattern with reusable FastAPI dependency:
- listing.py: uses get_optional_session instead of manual cookie/header parsing
- playback.py: uses get_optional_session for get_track and increment_play_count
- removes utilities/auth.py (get_session_id_from_request no longer needed)
- updates test_hidden_tags_filter.py to override dependency instead of patching
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: add show_liked_on_profile preference
Adds a new user preference (defaults to false) that will allow users
to display their liked tracks on their artist page.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: add show_liked_on_profile setting with artist page integration
- Add show_liked_on_profile to preferences API (get/update)
- Expose preference in artist API response for public profiles
- Add settings toggle in frontend privacy & display section
- Update artist page to conditionally fetch and display liked tracks
- Update Preferences interface and layout to include new field
When enabled, a user's liked tracks are displayed on their artist page
below albums, allowing others to discover music they enjoy.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: add fm.plyr.list lexicon for playlists and albums
introduces ATProto record type for ordered track collections:
- lexicons/list.json: defines fm.plyr.list with strongRef track references
- purpose field distinguishes "album", "playlist", "collection"
- items array ordering determines display order (reorder via putRecord)
- each item uses strongRef (uri + cid) for content-addressability
backend infrastructure:
- create_list_record/update_list_record in records.py
- list_collection added to OAuth scopes
- exported from _internal.atproto module
design notes:
- strongRef ensures list items point to specific track versions
- when tracks are deleted, list records should be updated to remove refs
- albums can be formalized as list records with purpose="album"
- no records created yet - this is infrastructure for future integration
relates to #221 (ATProto records for albums) and #146 (content-addressable storage)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: generalize fm.plyr.list to support any record type
- renamed listItem.track to listItem.subject for generic references
- added more purpose knownValues: discography, favorites
- updated descriptions to clarify any ATProto record can be referenced
- subject.uri indicates record type (e.g., fm.plyr.track, fm.plyr.list)
enables:
- lists of tracks (albums, playlists)
- lists of albums (discographies)
- lists of lists (playlist collections)
- lists of artists (following, featured)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: simplify fm.plyr.list lexicon to minimal fields
stripped to essentials per lexicon style guide:
- required: items, createdAt
- optional: name (display name), listType (semantic category)
- removed: purpose, description, imageUrl, addedAt
listType knownValues: album, playlist, liked
(extensible - any string valid per ATProto knownValues spec)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: add optional updatedAt field to fm.plyr.list
- lexicon: optional updatedAt datetime field
- update_list_record auto-sets updatedAt to now
- create_list_record omits updatedAt (new records)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* docs: add feat/playlists branch status to STATUS.md
documents current state of list infrastructure:
- what's done (lexicon, backend functions, oauth scopes)
- what's NOT done (no UI, no records created, no migrations)
- design decisions and next steps
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: add fm.plyr.actor.profile lexicon and upsert function
- create profile.json lexicon with bio, createdAt, updatedAt fields
- add profile_collection to AtprotoSettings config
- add profile collection to OAuth scopes
- implement build_profile_record and upsert_profile_record
- uses putRecord with rkey="self" for upsert semantics
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: wire up profile record upsert to artist bio endpoints
- call upsert_profile_record when bio is set on create/update
- handle ATProto failures gracefully (log but don't fail request)
- add tests for profile record integration
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: sync profile record on login with proper background task handling
- add background sync in GET /artists/me for existing users with bios
- skip write if ATProto record already exists with same bio (no-op)
- use proper task lifecycle management to prevent GC before completion
- return None from upsert_profile_record when skipped
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: simplify profile sync to silent fire-and-forget
remove over-engineered response field for toast notification.
profile record sync happens silently in background on GET /artists/me.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* chore: remove unused check_profile_record_sync_needed
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: always sync profile record, not just when bio exists
profile record should be created on login regardless of whether
user has a bio set. bio is optional in the lexicon.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: sync albums and liked tracks as ATProto list records on login
Backend:
- Add upsert_album_list_record() and upsert_liked_list_record() functions
- Wire up fire-and-forget sync on GET /artists/me for all artist albums
- Wire up fire-and-forget sync for user's liked tracks list
- Persist ATProto URIs/CIDs back to database after sync
- Migration: add liked_list_uri and liked_list_cid to user_preferences
Frontend:
- Artist page: replace inline liked tracks with link card to /liked/{handle}
- Add "collections" section header to distinguish from albums
- Liked page: handle link now goes to artist page, not Bluesky
Design decisions:
- Liked list references track records directly (not like records) for simplicity
- Array order = display order (ATProto-native approach)
- Albums ordered by track created_at asc, likes by created_at desc
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: add library hub page and update navigation
- create /library route as hub for personal collections
- show liked tracks card with count
- add placeholder for future playlists
- change nav from "liked" → "library" (heart icon goes to /library)
- keep /liked route for direct access
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* docs: update feat/playlists branch status in STATUS.md
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: scope upgrade OAuth flow for teal.fm integration (#503)
* feat: add scope upgrade OAuth flow for teal.fm integration
- Add /auth/scope-upgrade/start endpoint that initiates OAuth flow with
expanded scopes (mirrors developer token pattern)
- Replace passive "please re-login" message with immediate OAuth redirect
when user enables teal.fm scrobbling
- Fix preferences bug where toggling settings reset theme to dark mode
(theme is client-side only, preserved from localStorage on fetch)
- Add PendingScopeUpgrade model to track in-flight scope upgrades
- Handle scope_upgraded callback to replace old session with new one
- Add tests for scope upgrade flow
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* docs: update STATUS.md with scope upgrade OAuth flow
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: resolve test failures from connection pool exhaustion
- add DATABASE_POOL_SIZE=2, DATABASE_MAX_OVERFLOW=0 to pytest env vars
- dispose ENGINES cache after each test in conftest to prevent connection accumulation
- fix mock_refresh_session functions to accept `self` parameter (method signature)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: add full-page overlay for developer token display
when users return from OAuth after creating a developer token,
show a prominent overlay so they don't miss it. the token won't
be shown again after dismissing, so this ensures visibility.
- full-page modal with blur backdrop
- copy button with success feedback
- warning text emphasizing save-now urgency
- link to python SDK docs
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* docs: update STATUS.md with completed scope upgrade work
- mark feat/scope-invalidation as merged to feat/playlists
- document developer token overlay feature
- document test fixes for connection pool exhaustion
- note all 281 tests passing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* docs: remove confusing album migration note from STATUS.md
albums sync as list records on login - no migration needed
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: implement playlist CRUD with ATProto integration
- add playlist endpoints to lists.py (create, list, get, add/remove tracks, delete)
- add Playlist model for database caching of ATProto list records
- add playlist types to frontend (Playlist, PlaylistWithTracks, PlaylistTrack)
- update library page with playlist list and create modal
- fix font inheritance on create button
- filter search results to exclude tracks already in playlist
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: use API_URL for playlist endpoints and fix button font inheritance
- fix all fetch calls to use API_URL instead of /api/ relative paths
- add font-family: inherit to all modal buttons
- library page: create playlist modal buttons
- playlist page: add-btn, empty-add-btn, cancel/confirm buttons
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: add share button and link previews for playlists
- add font-family: inherit to search input in playlist modal
- add font-family: inherit to create playlist input
- add ShareButton component to playlist page (visible to all users)
- add public /lists/playlists/{id}/meta endpoint (no auth required)
- add +page.server.ts to fetch playlist meta for SSR
- add OG meta tags for link previews (og:type, og:title, og:description, twitter:card)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: playlist enhancements and UX improvements
- add "add to playlist" menu to track items (AddToMenu component)
- include playlists in global search results
- filter current playlist from add-to-playlist picker on playlist detail page
- add "create new playlist" link in playlist picker menus
- show playlist artwork in library page list
- fix portal empty playlist state link to open create modal
- update edit button tooltip to "edit playlist metadata"
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: portal album/playlist consistency
- use consistent edit icon (document+pencil) for album edit button
- match playlist grid sizing to album grid (200px min, 1.5rem gap)
- match playlist card padding and font sizes to album cards
- update placeholder icon size from 32 to 48
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: standardize liked tracks page buttons to match playlist/album style
* fix: populate is_liked on playlist tracks and resolve svelte-check warnings
* fix: add toast notifications for mutations (playlist CRUD, profile updates)
* feat: graceful degradation for unavailable tracks in playlists
when a track in someone's PDS list no longer exists in the database
(e.g., the track owner deleted it), we now show it grayed out with
"track unavailable" instead of silently hiding it.
- add UnavailableTrack schema to backend
- return unavailable_tracks array from get_playlist endpoint
- render unavailable tracks with muted styling in frontend
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* jpeg fix and a couple other tings
---------
Co-authored-by: Claude <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* feat: consolidate user preferences into dedicated settings page
- create /settings route with all preferences consolidated:
- appearance (theme, accent color)
- playback (auto-advance)
- privacy & display (sensitive artwork, timed comments)
- integrations (teal.fm scrobbling)
- developer (API tokens with OAuth flow)
- account (delete with confirmation)
- slim down portal to focus on content management:
- profile settings, tracks, albums, export
- remove preference toggles (moved to /settings)
- remove dev tokens section (moved to /settings)
- remove account deletion (moved to /settings)
- add "all settings →" link to both SettingsMenu and ProfileMenu
- update SensitiveImage tooltip from "enable in portal" to "enable in settings"
- add TokenInfo type for developer tokens
- clean up ~500 lines of unused CSS from portal
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: add font-family inherit to buttons, rename profile → portal in mobile menu
- add font-family: inherit to all buttons in settings page:
revoke-btn, copy-btn, dismiss-btn, create-token-btn,
delete-account-btn, cancel-btn, confirm-delete-btn
- rename mobile menu item from "profile" to "portal" to match route
- update icon to grid layout to better represent portal concept
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: move delete account from settings to portal
Delete account belongs in portal's "your data" section because:
- It's a destructive action on your data, not a preference
- It has an option about AT Protocol records (your data)
- Export is already in portal - delete is the inverse operation
Settings = preferences (theme, colors, toggles, API access)
Portal = your content and data (profile, tracks, albums, export, delete)
🤖 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 /deploy claude command
automates production deployment with preflight checks:
- verifies clean working tree, on main, up to date
- analyzes changes to determine frontend vs backend
- runs appropriate just target
- pushes to tangled remote
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* docs: add bluesky thread link to bufo section in STATUS.md
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
cache key was just the query, so pattern config changes didn't
invalidate cached results. now includes exclude/include patterns
so config changes take effect immediately.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
adds BUFO_EXCLUDE_PATTERNS and BUFO_INCLUDE_PATTERNS env vars to control
which bufo images appear in the easter egg animation.
- exclude: regex patterns to filter out (default: ^bigbufo_)
- include: allowlist that overrides exclude (default: bigbufo_0_0, bigbufo_2_1)
adds CommaSeparatedStringSet type for parsing comma-delimited env vars.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
- sensitive image coverage: complete coverage across all image displays
including media session, embeds, search results, album pages
- mobile artwork upload: iOS HEIC handling via content_type detection
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
on iOS, selecting a HEIC photo from the Photos library often results
in a file with a .heic filename but jpeg content (iOS converts on the
fly). the backend was using only the filename extension to validate
image format, causing these files to be silently rejected.
changes:
- backend: add from_content_type() method to ImageFormat
- backend: validate_and_extract() now prefers content_type over extension
- backend: pass image content_type through upload pipeline
- frontend: use accept="image/*" for broader iOS compatibility
- tests: add coverage for iOS HEIC case
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* fix: respect sensitive artwork preference in media session
the media session API (CarPlay, lock screen, control center) was
showing unblurred artwork for tracks flagged as sensitive, even when
the user had `show_sensitive_artwork` disabled.
now checks each artwork candidate against the moderation list and
the user's preference before including it in media session metadata.
if all artwork is sensitive and the user prefers not to see it, the
media session will display no artwork.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: apply SensitiveImage wrapper to remaining image displays
extends sensitive content handling to:
- embed page (track artwork)
- album detail page (artwork + og:image meta tags)
- artist page album grid
- track page comment avatars
- search modal results
- handle search/autocomplete avatars
uses compact mode for small avatars (blur only, no tooltip).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
- Fix local dev section: uses Neon dev database, not localhost
- Correct Fly.io app name from plyr-api to relay-api throughout
- Add "current capabilities" section documenting that migration
isolation (via release_command) and multi-environment pipeline
(dev/staging/prod) are already implemented
- Update database diagram to show all four databases (dev, staging,
prod on Neon + local test)
- Remove misleading "future considerations" for features that exist
Co-authored-by: Claude <noreply@anthropic.com>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
the previous fix only elevated the .likes element, but z-index on a child
doesn't help when sibling track-containers are in the same stacking context.
now:
- .track-container.likers-tooltip-open gets z-index: 60 (above header at 50)
- removes the ineffective .likes.tooltip-open z-index
- the entire track elevates above siblings, ensuring the tooltip renders on top
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
z-index fix:
- remove static z-index from .likes element
- only apply z-index: 10 when tooltip is open (.likes.tooltip-open)
- this ensures the active tooltip's parent elevates above sibling tracks
without all tracks competing at the same z-index level
flip detection:
- detect when likes element is <300px from viewport top
- flip tooltip to appear below instead of above
- prevents tooltip from colliding with sticky header
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* docs: add sensitive image moderation to STATUS.md
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* docs: add sensitive content moderation documentation
documents the sensitive image moderation system:
- database schema (sensitive_images table)
- frontend architecture (SSR + client-side)
- SensitiveImage component usage
- matching logic for R2 and external URLs
- API endpoint
- user experience flow
- current limitations (manual flagging only)
- future improvements needed (perceptual hashing, AI detection)
- moderation workflow with SQL examples
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: likers tooltip z-index and compact mode for sensitive avatars
- add z-index to .likes element so tooltip renders above header border
- add compact prop to SensitiveImage for avatars in lists (blur only, no tooltip, preserves layout)
- use compact mode in LikersTooltip to prevent layout breakage on blurred avatars
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
- Wrap liker avatars in SensitiveImage component
- Add max-height (240px) and scrolling to likers list
- Fix hover interaction: delay close to allow entering tooltip
- Add Escape key to close tooltip
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* fix: use data prop instead of parent() for sensitive images SSR
The +layout.ts file was incorrectly using parent() to access data from
+layout.server.ts. In SvelteKit, data from a server load function in the
same route comes via the data parameter, not parent(). parent() is only
for accessing data from parent routes.
This fixes SSR meta tag generation for sensitive images.
* fix: handle nullable data parameter in layout load function
TypeScript was complaining that data could be null. Use optional
chaining and extract sensitiveImages to a variable to avoid repetition.
Link previews weren't filtering sensitive images because the moderation
data was only fetched client-side. Now we fetch it in +layout.server.ts
and pass it through to pages for SSR-safe meta tag filtering.
- Add +layout.server.ts to fetch sensitive images
- Export checkImageSensitive() utility for shared use
- Update track and artist pages to use SSR-safe checks
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
Was blocking all Bluesky avatars. Keep only explicit flagging.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
- Fix regex to match R2 URLs (pub-*.r2.dev/{id}.{ext})
- Blur non-R2 images by default as they could be injected via ATProto records
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* feat: blur explicit images with user opt-in to show
- add explicit_images table to track flagged image URLs/IDs
- add show_explicit_artwork user preference (default: hidden)
- add /moderation/explicit-images endpoint to fetch flagged images
- add ExplicitImage component that blurs flagged images
- hide explicit images from og:image/twitter meta tags
- add portal toggle for explicit artwork preference
images can be flagged by image_id (R2) or full URL (external avatars).
frontend fetches the list once and checks all rendered images.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* chore: use 'sensitive' instead of 'explicit' in tooltip
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: rename explicit to sensitive throughout codebase
- Rename table: explicit_images → sensitive_images
- Rename model: ExplicitImage → SensitiveImage
- Rename preference: show_explicit_artwork → show_sensitive_artwork
- Rename component: ExplicitImage.svelte → SensitiveImage.svelte
- Update all references, endpoints, and UI text
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
added note about intermittent audio playback hangs in iOS standalone
(PWA) mode. PR #466 added NetworkOnly caching for audio routes, but
iOS Safari is slow to update service workers. workaround is to delete
and re-add home screen bookmark. flagged for further investigation if
issue persists.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
- Space: play/pause
- Left/Right arrows: seek ±10 seconds
- J/L: previous/next track
- M: mute/unmute
All shortcuts respect input focus and are disabled when search modal is open.
Also fixes AGENTS.md to reflect that STATUS.md is now tracked.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
terminal-based vanity metrics dashboard using plotext for visualization.
features:
- zone stats: requests, pageviews, unique visitors, bandwidth, cache ratio
- daily requests bar chart (cyan)
- pageviews vs uniques line chart (green/magenta)
- CLI options: --days/-d for time window, --no-cache to force refresh
- automatic daily caching (~/.cache/plyr-analytics/)
- pydantic-settings for .env integration (CF_API_TOKEN, CF_ZONE_ID)
usage:
uv run scripts/cf_analytics.py # last 7 days
uv run scripts/cf_analytics.py -d 30 # last 30 days
uv run scripts/cf_analytics.py --no-cache
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
the $effect that calls incrementPlayCount() runs on every currentTime
update (~4x/second). when threshold is first crossed, svelte's batched
reactive updates meant the guard (playCountedForTrack === currentTrack.id)
could miss rapid-fire calls, causing 2 scrobbles ~50-125ms apart.
fix: add synchronous (non-reactive) guard that blocks immediately,
before the async fetch fires.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
- add mutagen dependency for audio metadata extraction
- create backend/utilities/audio.py with extract_duration()
- extract duration during upload, store in track.extra["duration"]
- add backfill script for tracks uploaded before this feature
duration is now correctly passed to teal.fm scrobble records.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
- add Dec 4 section for PR #467 (teal.fm scrobbling)
- document lexicons, configuration, and code quality improvements
- update now-playing section to reflect native scrobbling
- add teal.fm to "what's working" list
- update last modified date
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* checkpoint
* checkpoint
* feat: teal.fm scrobbling integration
adds teal.fm scrobbling support - when users enable the toggle in settings
and re-authenticate, their plays are recorded to their PDS using teal's
ATProto lexicons.
changes:
- add TealSettings config class for configurable namespaces (TEAL_PLAY_COLLECTION,
TEAL_STATUS_COLLECTION, TEAL_ENABLED env vars)
- fix play count fetch missing credentials: 'include' (root cause of scrobbles
not triggering)
- add preferences.fetch() after login to ensure teal toggle state is current
- add unit tests for env var overrides (proves we can adapt when teal graduates
from alpha namespace)
the teal namespace defaults to fm.teal.alpha.* but can be changed via env vars
when teal.fm updates their lexicons.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: code quality improvements and bug fixes
- walrus operators throughout auth and playback code
- extract bearer token parsing into utilities/auth.py
- move DeveloperTokenInfo to top of file with field_validator for truncation
- fix now-playing firing every 1s (update lastReportedState in scheduled reports)
- use settings.frontend.url/domain for origin URLs (environment-aware)
- move teal.fm scrobbling setting from gear menu to portal "Your Data" section
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
audio streaming was intermittently hanging in iOS standalone (PWA) mode.
the service worker was intercepting /audio/{file_id} requests with
NetworkFirst caching, which caused issues with:
- 307 redirects to R2 CDN getting cached/stale
- range request headers not being handled properly on iOS Safari
switched to NetworkOnly for audio routes so the SW passes through
without interference. we weren't actually caching audio files anyway
(just the redirect response), so there's no functional loss.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
The logout button was rendered outside the isAuthenticated check,
causing both login and logout buttons to appear on desktop when
logged out.
🤖 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 <noreply@anthropic.com>
The search modal was using `visibility: hidden` to hide the backdrop,
which prevented the input from receiving focus. This caused:
- No text cursor on desktop when pressing Cmd+K
- No keyboard popup on mobile when tapping search icon
Changed to use only `opacity: 0` (already present) for hiding.
Elements with `opacity: 0` remain focusable, allowing the synchronous
focus() call in search.open() to work correctly - critical for mobile
keyboards which only open when focus is in the same call stack as the
user gesture.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
Removes debug logging that was left in from initial development in PR #233.
Keeps console.error for actual error handling.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
uv run by default syncs all dependencies including dev deps, which
caused the release command to hang downloading ruff, jedi, etc.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
the now-playing endpoints were hitting the global 100/minute rate limit
when multiple users/tabs were active simultaneously. since these endpoints
are already throttled client-side (10-second intervals, 1-second debounce,
5-second progress buckets), server-side rate limiting is unnecessary.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
the documented commands didn't match actual justfile recipes:
- `just frontend install` -> doesn't exist
- `just run-backend` -> `just backend run`
- `just lint` -> `just backend lint` / `just frontend check`
- `just migrate` -> `just backend migrate`
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
when a track doesn't have its own artwork but belongs to an album
with artwork, use the album artwork instead of the placeholder icon.
applies to:
- player artwork thumbnail (TrackInfo.svelte)
- media session metadata for system controls (Player.svelte)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
iOS/mobile browsers only open keyboard when focus() is called directly
in a user gesture handler. Previously, focus happened in a Svelte $effect
after state change, which broke the gesture chain.
Fix: Always render SearchModal (hidden via CSS), register input ref with
search state, and focus directly in search.open() before state change.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* feat: move search to top-level mobile header
- Add search icon button to mobile-center nav (left of feed/liked icons)
- Remove search from ProfileMenu dropdown
- Space icons evenly across mobile header
- Cleaner mobile UX with one less tap to search
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: hide keyboard hints on mobile, smaller placeholder text
- Hide Cmd+K shortcut badge on mobile
- Hide keyboard navigation hints (arrows, esc) on mobile
- Shrink placeholder text on mobile to prevent overflow
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix: collapse excess tags with +N button and fix mobile menu overlap
- limits visible tags to 2 by default (never wraps to second line)
- shows "+N" button when more tags exist
- clicking "+N" expands to show all tags (allows wrapping when expanded)
- uses flex-wrap: nowrap + overflow: hidden to guarantee single-line constraint
- collapses back when track changes (component recycle)
- fix LinksMenu and ProfileMenu mobile positioning to avoid player overlap
- menus now position from bottom with player height offset on mobile
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: keep mobile menus centered but shift up for player
- menus stay full-size and centered
- shift center point up by half player height when player is open
- cap max-height to avoid overlap with player
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: add search trigger to header and mobile menu
desktop:
- SearchTrigger component shows magnifying glass + keyboard shortcut hint
- detects platform for correct shortcut (⌘K on Mac, Ctrl+K on Windows/Linux)
- subtle styling with accent highlight on hover
mobile:
- search added as first item in ProfileMenu (three-dot menu)
- styled with accent tint background to stand out
- opens search modal and closes menu
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: hide portal link on portal page, move search to far right
- portal link (@handle) now hidden when already on /portal
- search trigger moved after logout button (far right)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: reorder desktop header - search in nav, logout on far right
- search trigger now in nav: @handle → search → settings
- logout button moved to far right (mirroring stats on left)
- hide portal link when already on /portal page
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: swap search and portal link order in nav
nav order now: feed → liked → search → @handle → settings
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* more stuff
* yuhhhhhhhh
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix: collapse excess tags with +N button and fix mobile menu overlap
- limits visible tags to 2 by default (never wraps to second line)
- shows "+N" button when more tags exist
- clicking "+N" expands to show all tags (allows wrapping when expanded)
- uses flex-wrap: nowrap + overflow: hidden to guarantee single-line constraint
- collapses back when track changes (component recycle)
- fix LinksMenu and ProfileMenu mobile positioning to avoid player overlap
- menus now position from bottom with player height offset on mobile
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: keep mobile menus centered but shift up for player
- menus stay full-size and centered
- shift center point up by half player height when player is open
- cap max-height to avoid overlap with player
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
trigram similarity alone misses cases where a short query
(e.g. "real") is a substring of a word in a long title.
added OR condition with ILIKE to catch exact substring matches
while preserving fuzzy matching behavior for typos.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* 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>
the create playlist link was navigating to /library?create=playlist, which
caused the layout to reinitialize and destroy the audio element. this fix
adds inline playlist creation to AddToMenu and TrackActionsMenu, allowing
users to create playlists and add tracks without leaving the current page.
changes:
- AddToMenu: replace link with inline create form that creates playlist
and adds track in one action
- TrackActionsMenu: same inline create form treatment
- portal: update empty state link to just go to /library (no query param)
- library page: remove query param handling (no longer needed)
- library +page.ts: use SvelteKit's fetch instead of window.fetch
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
- fix stopPropagation blocking link clicks in AddToMenu and TrackActionsMenu
- add /playlist/ to hasPageMetadata so layout default OG tags don't override
- replace LikeButton with AddToMenu on track detail page for playlist access
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
after exchanging the OAuth token for a session cookie, the root
layout's load function still has stale data (isAuthenticated: false)
from before the cookie was set.
this caused navigation to /library immediately after login to redirect
to / because the layout data said the user wasn't authenticated.
the fix: call invalidateAll() after successful token exchange so
SvelteKit reruns all load functions with the new session cookie.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* perf: defer ATProto sync queries to background task
- move all sync-related DB queries out of /artists/me request path
- queries for albums, tracks, prefs, and likes now run in background
- reduces response time from ~1.2s to ~300ms (only artist lookup needed)
also fix: add trailing slash to playlist search URL (fixes 307 redirect)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: move ATProto sync from /artists/me to login callback
- create sync_atproto_records() function in records.py
- call sync as fire-and-forget background task after OAuth callback
- remove all sync logic from /artists/me (now just returns artist)
- also sync on scope upgrade flow
- update tests to verify new behavior
this ensures ATProto records sync immediately on login rather than
on profile page access, and removes unnecessary work from /artists/me
🤖 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 public liked pages for any user (#498)
likes are stored on users' PDSes as ATProto records, making them public data.
this enables viewing any user's liked tracks without authentication.
backend:
- add GET /users/{handle}/likes endpoint
- add get_optional_session dependency for endpoints that benefit from optional auth
- endpoint returns user info, tracks, and count
frontend:
- add /liked/[handle] route with user header and track list
- add fetchUserLikes API function with UserLikesResponse type
- update LikersTooltip to link to user's liked page instead of profile
tests:
- add test_users.py with 4 tests for the new endpoint
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: consolidate optional auth into get_optional_session dependency
replaces inline session_id extraction pattern with reusable FastAPI dependency:
- listing.py: uses get_optional_session instead of manual cookie/header parsing
- playback.py: uses get_optional_session for get_track and increment_play_count
- removes utilities/auth.py (get_session_id_from_request no longer needed)
- updates test_hidden_tags_filter.py to override dependency instead of patching
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: add show_liked_on_profile preference
Adds a new user preference (defaults to false) that will allow users
to display their liked tracks on their artist page.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: add show_liked_on_profile setting with artist page integration
- Add show_liked_on_profile to preferences API (get/update)
- Expose preference in artist API response for public profiles
- Add settings toggle in frontend privacy & display section
- Update artist page to conditionally fetch and display liked tracks
- Update Preferences interface and layout to include new field
When enabled, a user's liked tracks are displayed on their artist page
below albums, allowing others to discover music they enjoy.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: add fm.plyr.list lexicon for playlists and albums
introduces ATProto record type for ordered track collections:
- lexicons/list.json: defines fm.plyr.list with strongRef track references
- purpose field distinguishes "album", "playlist", "collection"
- items array ordering determines display order (reorder via putRecord)
- each item uses strongRef (uri + cid) for content-addressability
backend infrastructure:
- create_list_record/update_list_record in records.py
- list_collection added to OAuth scopes
- exported from _internal.atproto module
design notes:
- strongRef ensures list items point to specific track versions
- when tracks are deleted, list records should be updated to remove refs
- albums can be formalized as list records with purpose="album"
- no records created yet - this is infrastructure for future integration
relates to #221 (ATProto records for albums) and #146 (content-addressable storage)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: generalize fm.plyr.list to support any record type
- renamed listItem.track to listItem.subject for generic references
- added more purpose knownValues: discography, favorites
- updated descriptions to clarify any ATProto record can be referenced
- subject.uri indicates record type (e.g., fm.plyr.track, fm.plyr.list)
enables:
- lists of tracks (albums, playlists)
- lists of albums (discographies)
- lists of lists (playlist collections)
- lists of artists (following, featured)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: simplify fm.plyr.list lexicon to minimal fields
stripped to essentials per lexicon style guide:
- required: items, createdAt
- optional: name (display name), listType (semantic category)
- removed: purpose, description, imageUrl, addedAt
listType knownValues: album, playlist, liked
(extensible - any string valid per ATProto knownValues spec)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: add optional updatedAt field to fm.plyr.list
- lexicon: optional updatedAt datetime field
- update_list_record auto-sets updatedAt to now
- create_list_record omits updatedAt (new records)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* docs: add feat/playlists branch status to STATUS.md
documents current state of list infrastructure:
- what's done (lexicon, backend functions, oauth scopes)
- what's NOT done (no UI, no records created, no migrations)
- design decisions and next steps
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: add fm.plyr.actor.profile lexicon and upsert function
- create profile.json lexicon with bio, createdAt, updatedAt fields
- add profile_collection to AtprotoSettings config
- add profile collection to OAuth scopes
- implement build_profile_record and upsert_profile_record
- uses putRecord with rkey="self" for upsert semantics
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: wire up profile record upsert to artist bio endpoints
- call upsert_profile_record when bio is set on create/update
- handle ATProto failures gracefully (log but don't fail request)
- add tests for profile record integration
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: sync profile record on login with proper background task handling
- add background sync in GET /artists/me for existing users with bios
- skip write if ATProto record already exists with same bio (no-op)
- use proper task lifecycle management to prevent GC before completion
- return None from upsert_profile_record when skipped
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: simplify profile sync to silent fire-and-forget
remove over-engineered response field for toast notification.
profile record sync happens silently in background on GET /artists/me.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* chore: remove unused check_profile_record_sync_needed
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: always sync profile record, not just when bio exists
profile record should be created on login regardless of whether
user has a bio set. bio is optional in the lexicon.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: sync albums and liked tracks as ATProto list records on login
Backend:
- Add upsert_album_list_record() and upsert_liked_list_record() functions
- Wire up fire-and-forget sync on GET /artists/me for all artist albums
- Wire up fire-and-forget sync for user's liked tracks list
- Persist ATProto URIs/CIDs back to database after sync
- Migration: add liked_list_uri and liked_list_cid to user_preferences
Frontend:
- Artist page: replace inline liked tracks with link card to /liked/{handle}
- Add "collections" section header to distinguish from albums
- Liked page: handle link now goes to artist page, not Bluesky
Design decisions:
- Liked list references track records directly (not like records) for simplicity
- Array order = display order (ATProto-native approach)
- Albums ordered by track created_at asc, likes by created_at desc
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: add library hub page and update navigation
- create /library route as hub for personal collections
- show liked tracks card with count
- add placeholder for future playlists
- change nav from "liked" → "library" (heart icon goes to /library)
- keep /liked route for direct access
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* docs: update feat/playlists branch status in STATUS.md
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: scope upgrade OAuth flow for teal.fm integration (#503)
* feat: add scope upgrade OAuth flow for teal.fm integration
- Add /auth/scope-upgrade/start endpoint that initiates OAuth flow with
expanded scopes (mirrors developer token pattern)
- Replace passive "please re-login" message with immediate OAuth redirect
when user enables teal.fm scrobbling
- Fix preferences bug where toggling settings reset theme to dark mode
(theme is client-side only, preserved from localStorage on fetch)
- Add PendingScopeUpgrade model to track in-flight scope upgrades
- Handle scope_upgraded callback to replace old session with new one
- Add tests for scope upgrade flow
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* docs: update STATUS.md with scope upgrade OAuth flow
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: resolve test failures from connection pool exhaustion
- add DATABASE_POOL_SIZE=2, DATABASE_MAX_OVERFLOW=0 to pytest env vars
- dispose ENGINES cache after each test in conftest to prevent connection accumulation
- fix mock_refresh_session functions to accept `self` parameter (method signature)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: add full-page overlay for developer token display
when users return from OAuth after creating a developer token,
show a prominent overlay so they don't miss it. the token won't
be shown again after dismissing, so this ensures visibility.
- full-page modal with blur backdrop
- copy button with success feedback
- warning text emphasizing save-now urgency
- link to python SDK docs
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* docs: update STATUS.md with completed scope upgrade work
- mark feat/scope-invalidation as merged to feat/playlists
- document developer token overlay feature
- document test fixes for connection pool exhaustion
- note all 281 tests passing
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* docs: remove confusing album migration note from STATUS.md
albums sync as list records on login - no migration needed
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: implement playlist CRUD with ATProto integration
- add playlist endpoints to lists.py (create, list, get, add/remove tracks, delete)
- add Playlist model for database caching of ATProto list records
- add playlist types to frontend (Playlist, PlaylistWithTracks, PlaylistTrack)
- update library page with playlist list and create modal
- fix font inheritance on create button
- filter search results to exclude tracks already in playlist
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: use API_URL for playlist endpoints and fix button font inheritance
- fix all fetch calls to use API_URL instead of /api/ relative paths
- add font-family: inherit to all modal buttons
- library page: create playlist modal buttons
- playlist page: add-btn, empty-add-btn, cancel/confirm buttons
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: add share button and link previews for playlists
- add font-family: inherit to search input in playlist modal
- add font-family: inherit to create playlist input
- add ShareButton component to playlist page (visible to all users)
- add public /lists/playlists/{id}/meta endpoint (no auth required)
- add +page.server.ts to fetch playlist meta for SSR
- add OG meta tags for link previews (og:type, og:title, og:description, twitter:card)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: playlist enhancements and UX improvements
- add "add to playlist" menu to track items (AddToMenu component)
- include playlists in global search results
- filter current playlist from add-to-playlist picker on playlist detail page
- add "create new playlist" link in playlist picker menus
- show playlist artwork in library page list
- fix portal empty playlist state link to open create modal
- update edit button tooltip to "edit playlist metadata"
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: portal album/playlist consistency
- use consistent edit icon (document+pencil) for album edit button
- match playlist grid sizing to album grid (200px min, 1.5rem gap)
- match playlist card padding and font sizes to album cards
- update placeholder icon size from 32 to 48
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: standardize liked tracks page buttons to match playlist/album style
* fix: populate is_liked on playlist tracks and resolve svelte-check warnings
* fix: add toast notifications for mutations (playlist CRUD, profile updates)
* feat: graceful degradation for unavailable tracks in playlists
when a track in someone's PDS list no longer exists in the database
(e.g., the track owner deleted it), we now show it grayed out with
"track unavailable" instead of silently hiding it.
- add UnavailableTrack schema to backend
- return unavailable_tracks array from get_playlist endpoint
- render unavailable tracks with muted styling in frontend
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* jpeg fix and a couple other tings
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat: consolidate user preferences into dedicated settings page
- create /settings route with all preferences consolidated:
- appearance (theme, accent color)
- playback (auto-advance)
- privacy & display (sensitive artwork, timed comments)
- integrations (teal.fm scrobbling)
- developer (API tokens with OAuth flow)
- account (delete with confirmation)
- slim down portal to focus on content management:
- profile settings, tracks, albums, export
- remove preference toggles (moved to /settings)
- remove dev tokens section (moved to /settings)
- remove account deletion (moved to /settings)
- add "all settings →" link to both SettingsMenu and ProfileMenu
- update SensitiveImage tooltip from "enable in portal" to "enable in settings"
- add TokenInfo type for developer tokens
- clean up ~500 lines of unused CSS from portal
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: add font-family inherit to buttons, rename profile → portal in mobile menu
- add font-family: inherit to all buttons in settings page:
revoke-btn, copy-btn, dismiss-btn, create-token-btn,
delete-account-btn, cancel-btn, confirm-delete-btn
- rename mobile menu item from "profile" to "portal" to match route
- update icon to grid layout to better represent portal concept
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: move delete account from settings to portal
Delete account belongs in portal's "your data" section because:
- It's a destructive action on your data, not a preference
- It has an option about AT Protocol records (your data)
- Export is already in portal - delete is the inverse operation
Settings = preferences (theme, colors, toggles, API access)
Portal = your content and data (profile, tracks, albums, export, delete)
🤖 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 /deploy claude command
automates production deployment with preflight checks:
- verifies clean working tree, on main, up to date
- analyzes changes to determine frontend vs backend
- runs appropriate just target
- pushes to tangled remote
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* docs: add bluesky thread link to bufo section in STATUS.md
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
cache key was just the query, so pattern config changes didn't
invalidate cached results. now includes exclude/include patterns
so config changes take effect immediately.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
adds BUFO_EXCLUDE_PATTERNS and BUFO_INCLUDE_PATTERNS env vars to control
which bufo images appear in the easter egg animation.
- exclude: regex patterns to filter out (default: ^bigbufo_)
- include: allowlist that overrides exclude (default: bigbufo_0_0, bigbufo_2_1)
adds CommaSeparatedStringSet type for parsing comma-delimited env vars.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
- sensitive image coverage: complete coverage across all image displays
including media session, embeds, search results, album pages
- mobile artwork upload: iOS HEIC handling via content_type detection
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
on iOS, selecting a HEIC photo from the Photos library often results
in a file with a .heic filename but jpeg content (iOS converts on the
fly). the backend was using only the filename extension to validate
image format, causing these files to be silently rejected.
changes:
- backend: add from_content_type() method to ImageFormat
- backend: validate_and_extract() now prefers content_type over extension
- backend: pass image content_type through upload pipeline
- frontend: use accept="image/*" for broader iOS compatibility
- tests: add coverage for iOS HEIC case
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* fix: respect sensitive artwork preference in media session
the media session API (CarPlay, lock screen, control center) was
showing unblurred artwork for tracks flagged as sensitive, even when
the user had `show_sensitive_artwork` disabled.
now checks each artwork candidate against the moderation list and
the user's preference before including it in media session metadata.
if all artwork is sensitive and the user prefers not to see it, the
media session will display no artwork.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: apply SensitiveImage wrapper to remaining image displays
extends sensitive content handling to:
- embed page (track artwork)
- album detail page (artwork + og:image meta tags)
- artist page album grid
- track page comment avatars
- search modal results
- handle search/autocomplete avatars
uses compact mode for small avatars (blur only, no tooltip).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
- Fix local dev section: uses Neon dev database, not localhost
- Correct Fly.io app name from plyr-api to relay-api throughout
- Add "current capabilities" section documenting that migration
isolation (via release_command) and multi-environment pipeline
(dev/staging/prod) are already implemented
- Update database diagram to show all four databases (dev, staging,
prod on Neon + local test)
- Remove misleading "future considerations" for features that exist
Co-authored-by: Claude <noreply@anthropic.com>
the previous fix only elevated the .likes element, but z-index on a child
doesn't help when sibling track-containers are in the same stacking context.
now:
- .track-container.likers-tooltip-open gets z-index: 60 (above header at 50)
- removes the ineffective .likes.tooltip-open z-index
- the entire track elevates above siblings, ensuring the tooltip renders on top
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
z-index fix:
- remove static z-index from .likes element
- only apply z-index: 10 when tooltip is open (.likes.tooltip-open)
- this ensures the active tooltip's parent elevates above sibling tracks
without all tracks competing at the same z-index level
flip detection:
- detect when likes element is <300px from viewport top
- flip tooltip to appear below instead of above
- prevents tooltip from colliding with sticky header
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* docs: add sensitive image moderation to STATUS.md
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* docs: add sensitive content moderation documentation
documents the sensitive image moderation system:
- database schema (sensitive_images table)
- frontend architecture (SSR + client-side)
- SensitiveImage component usage
- matching logic for R2 and external URLs
- API endpoint
- user experience flow
- current limitations (manual flagging only)
- future improvements needed (perceptual hashing, AI detection)
- moderation workflow with SQL examples
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: likers tooltip z-index and compact mode for sensitive avatars
- add z-index to .likes element so tooltip renders above header border
- add compact prop to SensitiveImage for avatars in lists (blur only, no tooltip, preserves layout)
- use compact mode in LikersTooltip to prevent layout breakage on blurred avatars
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
- Wrap liker avatars in SensitiveImage component
- Add max-height (240px) and scrolling to likers list
- Fix hover interaction: delay close to allow entering tooltip
- Add Escape key to close tooltip
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* fix: use data prop instead of parent() for sensitive images SSR
The +layout.ts file was incorrectly using parent() to access data from
+layout.server.ts. In SvelteKit, data from a server load function in the
same route comes via the data parameter, not parent(). parent() is only
for accessing data from parent routes.
This fixes SSR meta tag generation for sensitive images.
* fix: handle nullable data parameter in layout load function
TypeScript was complaining that data could be null. Use optional
chaining and extract sensitiveImages to a variable to avoid repetition.
Link previews weren't filtering sensitive images because the moderation
data was only fetched client-side. Now we fetch it in +layout.server.ts
and pass it through to pages for SSR-safe meta tag filtering.
- Add +layout.server.ts to fetch sensitive images
- Export checkImageSensitive() utility for shared use
- Update track and artist pages to use SSR-safe checks
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
Was blocking all Bluesky avatars. Keep only explicit flagging.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* feat: blur explicit images with user opt-in to show
- add explicit_images table to track flagged image URLs/IDs
- add show_explicit_artwork user preference (default: hidden)
- add /moderation/explicit-images endpoint to fetch flagged images
- add ExplicitImage component that blurs flagged images
- hide explicit images from og:image/twitter meta tags
- add portal toggle for explicit artwork preference
images can be flagged by image_id (R2) or full URL (external avatars).
frontend fetches the list once and checks all rendered images.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* chore: use 'sensitive' instead of 'explicit' in tooltip
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: rename explicit to sensitive throughout codebase
- Rename table: explicit_images → sensitive_images
- Rename model: ExplicitImage → SensitiveImage
- Rename preference: show_explicit_artwork → show_sensitive_artwork
- Rename component: ExplicitImage.svelte → SensitiveImage.svelte
- Update all references, endpoints, and UI text
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
added note about intermittent audio playback hangs in iOS standalone
(PWA) mode. PR #466 added NetworkOnly caching for audio routes, but
iOS Safari is slow to update service workers. workaround is to delete
and re-add home screen bookmark. flagged for further investigation if
issue persists.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
- Space: play/pause
- Left/Right arrows: seek ±10 seconds
- J/L: previous/next track
- M: mute/unmute
All shortcuts respect input focus and are disabled when search modal is open.
Also fixes AGENTS.md to reflect that STATUS.md is now tracked.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
terminal-based vanity metrics dashboard using plotext for visualization.
features:
- zone stats: requests, pageviews, unique visitors, bandwidth, cache ratio
- daily requests bar chart (cyan)
- pageviews vs uniques line chart (green/magenta)
- CLI options: --days/-d for time window, --no-cache to force refresh
- automatic daily caching (~/.cache/plyr-analytics/)
- pydantic-settings for .env integration (CF_API_TOKEN, CF_ZONE_ID)
usage:
uv run scripts/cf_analytics.py # last 7 days
uv run scripts/cf_analytics.py -d 30 # last 30 days
uv run scripts/cf_analytics.py --no-cache
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
the $effect that calls incrementPlayCount() runs on every currentTime
update (~4x/second). when threshold is first crossed, svelte's batched
reactive updates meant the guard (playCountedForTrack === currentTrack.id)
could miss rapid-fire calls, causing 2 scrobbles ~50-125ms apart.
fix: add synchronous (non-reactive) guard that blocks immediately,
before the async fetch fires.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
- add mutagen dependency for audio metadata extraction
- create backend/utilities/audio.py with extract_duration()
- extract duration during upload, store in track.extra["duration"]
- add backfill script for tracks uploaded before this feature
duration is now correctly passed to teal.fm scrobble records.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
- add Dec 4 section for PR #467 (teal.fm scrobbling)
- document lexicons, configuration, and code quality improvements
- update now-playing section to reflect native scrobbling
- add teal.fm to "what's working" list
- update last modified date
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* checkpoint
* checkpoint
* feat: teal.fm scrobbling integration
adds teal.fm scrobbling support - when users enable the toggle in settings
and re-authenticate, their plays are recorded to their PDS using teal's
ATProto lexicons.
changes:
- add TealSettings config class for configurable namespaces (TEAL_PLAY_COLLECTION,
TEAL_STATUS_COLLECTION, TEAL_ENABLED env vars)
- fix play count fetch missing credentials: 'include' (root cause of scrobbles
not triggering)
- add preferences.fetch() after login to ensure teal toggle state is current
- add unit tests for env var overrides (proves we can adapt when teal graduates
from alpha namespace)
the teal namespace defaults to fm.teal.alpha.* but can be changed via env vars
when teal.fm updates their lexicons.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* refactor: code quality improvements and bug fixes
- walrus operators throughout auth and playback code
- extract bearer token parsing into utilities/auth.py
- move DeveloperTokenInfo to top of file with field_validator for truncation
- fix now-playing firing every 1s (update lastReportedState in scheduled reports)
- use settings.frontend.url/domain for origin URLs (environment-aware)
- move teal.fm scrobbling setting from gear menu to portal "Your Data" section
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
audio streaming was intermittently hanging in iOS standalone (PWA) mode.
the service worker was intercepting /audio/{file_id} requests with
NetworkFirst caching, which caused issues with:
- 307 redirects to R2 CDN getting cached/stale
- range request headers not being handled properly on iOS Safari
switched to NetworkOnly for audio routes so the SW passes through
without interference. we weren't actually caching audio files anyway
(just the redirect response), so there's no functional loss.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
The search modal was using `visibility: hidden` to hide the backdrop,
which prevented the input from receiving focus. This caused:
- No text cursor on desktop when pressing Cmd+K
- No keyboard popup on mobile when tapping search icon
Changed to use only `opacity: 0` (already present) for hiding.
Elements with `opacity: 0` remain focusable, allowing the synchronous
focus() call in search.open() to work correctly - critical for mobile
keyboards which only open when focus is in the same call stack as the
user gesture.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
the now-playing endpoints were hitting the global 100/minute rate limit
when multiple users/tabs were active simultaneously. since these endpoints
are already throttled client-side (10-second intervals, 1-second debounce,
5-second progress buckets), server-side rate limiting is unnecessary.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
the documented commands didn't match actual justfile recipes:
- `just frontend install` -> doesn't exist
- `just run-backend` -> `just backend run`
- `just lint` -> `just backend lint` / `just frontend check`
- `just migrate` -> `just backend migrate`
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
when a track doesn't have its own artwork but belongs to an album
with artwork, use the album artwork instead of the placeholder icon.
applies to:
- player artwork thumbnail (TrackInfo.svelte)
- media session metadata for system controls (Player.svelte)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
iOS/mobile browsers only open keyboard when focus() is called directly
in a user gesture handler. Previously, focus happened in a Svelte $effect
after state change, which broke the gesture chain.
Fix: Always render SearchModal (hidden via CSS), register input ref with
search state, and focus directly in search.open() before state change.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
* feat: move search to top-level mobile header
- Add search icon button to mobile-center nav (left of feed/liked icons)
- Remove search from ProfileMenu dropdown
- Space icons evenly across mobile header
- Cleaner mobile UX with one less tap to search
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: hide keyboard hints on mobile, smaller placeholder text
- Hide Cmd+K shortcut badge on mobile
- Hide keyboard navigation hints (arrows, esc) on mobile
- Shrink placeholder text on mobile to prevent overflow
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix: collapse excess tags with +N button and fix mobile menu overlap
- limits visible tags to 2 by default (never wraps to second line)
- shows "+N" button when more tags exist
- clicking "+N" expands to show all tags (allows wrapping when expanded)
- uses flex-wrap: nowrap + overflow: hidden to guarantee single-line constraint
- collapses back when track changes (component recycle)
- fix LinksMenu and ProfileMenu mobile positioning to avoid player overlap
- menus now position from bottom with player height offset on mobile
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: keep mobile menus centered but shift up for player
- menus stay full-size and centered
- shift center point up by half player height when player is open
- cap max-height to avoid overlap with player
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* feat: add search trigger to header and mobile menu
desktop:
- SearchTrigger component shows magnifying glass + keyboard shortcut hint
- detects platform for correct shortcut (⌘K on Mac, Ctrl+K on Windows/Linux)
- subtle styling with accent highlight on hover
mobile:
- search added as first item in ProfileMenu (three-dot menu)
- styled with accent tint background to stand out
- opens search modal and closes menu
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: hide portal link on portal page, move search to far right
- portal link (@handle) now hidden when already on /portal
- search trigger moved after logout button (far right)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: reorder desktop header - search in nav, logout on far right
- search trigger now in nav: @handle → search → settings
- logout button moved to far right (mirroring stats on left)
- hide portal link when already on /portal page
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: swap search and portal link order in nav
nav order now: feed → liked → search → @handle → settings
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* more stuff
* yuhhhhhhhh
---------
Co-authored-by: Claude <noreply@anthropic.com>
* fix: collapse excess tags with +N button and fix mobile menu overlap
- limits visible tags to 2 by default (never wraps to second line)
- shows "+N" button when more tags exist
- clicking "+N" expands to show all tags (allows wrapping when expanded)
- uses flex-wrap: nowrap + overflow: hidden to guarantee single-line constraint
- collapses back when track changes (component recycle)
- fix LinksMenu and ProfileMenu mobile positioning to avoid player overlap
- menus now position from bottom with player height offset on mobile
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
* fix: keep mobile menus centered but shift up for player
- menus stay full-size and centered
- shift center point up by half player height when player is open
- cap max-height to avoid overlap with player
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
---------
Co-authored-by: Claude <noreply@anthropic.com>
trigram similarity alone misses cases where a short query
(e.g. "real") is a substring of a word in a long title.
added OR condition with ILIKE to catch exact substring matches
while preserving fuzzy matching behavior for typos.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>