plyr.fm Status History - December 2025#
Early December 2025 Work (Dec 1-7)#
playlists, ATProto sync, and library hub (feat/playlists branch, PR #499, Dec 6-7)#
status: shipped and deployed.
playlists (full CRUD):
playlistsandplaylist_trackstables with Alembic migrationPOST /lists/playlists- create playlistPUT /lists/playlists/{id}- rename playlistDELETE /lists/playlists/{id}- delete playlistPOST /lists/playlists/{id}/tracks- add track to playlistDELETE /lists/playlists/{id}/tracks/{track_id}- remove trackPUT /lists/playlists/{id}/tracks/reorder- reorder tracksPOST /lists/playlists/{id}/cover- upload cover art- playlist detail page (
/playlist/[id]) with edit modal, drag-and-drop reordering - playlists in global search results
- "add to playlist" menu on tracks (filters out current playlist when on playlist page)
- inline "create new playlist" in add-to menu (creates playlist and adds track in one action)
- playlist sharing with OpenGraph link previews
ATProto integration:
fm.plyr.listlexicon for syncing playlists and albums to user PDSesfm.plyr.actor.profilelexicon for syncing artist profiles- automatic sync of albums, liked tracks, and profile on login (fire-and-forget)
- scope upgrade OAuth flow for teal.fm integration (#503)
library hub (/library):
- unified page with tabs: liked, playlists, albums
- create playlist modal with inline form
- consistent card layouts across sections
- nav changed from "liked" → "library"
user experience:
- public liked pages for any user (
/liked/[handle]) show_liked_on_profilepreference- portal album/playlist section visual consistency
- toast notifications for all mutations (playlist CRUD, profile updates)
- z-index fixes for dropdown menus
accessibility fixes:
- fixed 32 svelte-check warnings (ARIA roles, button nesting, unused CSS)
- proper roles on modals, menus, and drag-drop elements
design decisions:
- lists are generic ordered collections of any ATProto records
listTypesemantically categorizes (album, playlist, liked) but doesn't restrict content- array order = display order, reorder via
putRecord - strongRef (uri + cid) for content-addressable item references
- "library" = umbrella term for personal collections
sync architecture:
- profile, albums, liked tracks: synced on login via
GET /artists/me(fire-and-forget background tasks) - playlists: synced on create/modify (not at login) - avoids N playlist syncs on every login
- sync tasks don't block the response (~300-500ms for the endpoint, PDS calls happen in background)
- putRecord calls take ~50-100ms each, with automatic DPoP nonce retry on 401
file size audit (candidates for future modularization):
portal/+page.svelte: 2,436 lines (58% CSS)playlist/[id]/+page.svelte: 1,644 lines (48% CSS)api/lists.py: 855 lines- CSS-heavy files could benefit from shared style extraction in future
related issues: #221, #146, #498
list reordering UI (feat/playlists branch, Dec 7)#
what's done:
PUT /lists/liked/reorderendpoint - reorder user's liked tracks listPUT /lists/{rkey}/reorderendpoint - reorder any list by ATProto rkey- both endpoints take
itemsarray of strongRefs (uri + cid) in desired order - liked tracks page (
/liked) now has "reorder" button for authenticated users - album page has "reorder" button for album owner (if album has ATProto list record)
- drag-and-drop reordering on desktop (HTML5 drag API)
- touch reordering on mobile (6-dot grip handle, same pattern as queue)
- visual feedback during drag:
.drag-overand.is-draggingstates - saves order to ATProto via
putRecordwhen user clicks "done" - added
atproto_record_cidto TrackResponse schema (needed for strongRefs) - added
artist_didandlist_urito AlbumMetadata response
UX design:
- button toggles between "reorder" and "done" states
- in edit mode, drag handles appear next to each track
- saving shows spinner, success/error toast on completion
- only owners can see/use reorder button (liked list = current user, album = artist)
scope upgrade OAuth flow (feat/scope-invalidation branch, Dec 7) - merged to feat/playlists#
problem: when users enabled teal.fm scrobbling, the app showed a passive "please log out and back in" message because the session lacked the required OAuth scopes. this was confusing UX.
solution: immediate OAuth handshake when enabling features that require new scopes (same pattern as developer tokens).
what's done:
POST /auth/scope-upgrade/startendpoint initiates OAuth with expanded scopespending_scope_upgradestable tracks in-flight upgrades (10min TTL)- callback replaces old session with new one, redirects to
/settings?scope_upgraded=true - frontend shows spinner during redirect, success toast on return
- fixed preferences bug where toggling settings reset theme to dark mode
code quality:
- eliminated bifurcated OAuth clients (
oauth_clientvsoauth_client_with_teal) - replaced with
get_oauth_client(include_teal=False)factory function - at ~17 OAuth flows/day, instantiation cost is negligible
- explicit scope selection at call site instead of module-level state
developer token UX:
- full-page overlay when returning from OAuth after creating a developer token
- token displayed prominently with warning that it won't be shown again
- copy button with success feedback, link to python SDK docs
- prevents users from missing their token (was buried at bottom of page)
test fixes:
- fixed connection pool exhaustion in tests (was hitting Neon's connection limit)
- added
DATABASE_POOL_SIZE=2,DATABASE_MAX_OVERFLOW=0to pytest env vars - dispose cached engines after each test to prevent connection accumulation
- fixed mock function signatures for
refresh_sessiontests
tests: 4 new tests for scope upgrade flow, all 281 tests passing
settings consolidation (PR #496, Dec 6)#
problem: user preferences were scattered across multiple locations with confusing terminology:
- SensitiveImage tooltip said "enable in portal" but mobile menu said "profile"
- clicking gear icon (SettingsMenu) only showed appearance/playback, not all settings
- portal mixed content management with preferences
solution: clear separation between settings (preferences) and portal (content & data):
| page | purpose |
|---|---|
/settings |
preferences: theme, accent color, auto-advance, sensitive artwork, timed comments, teal.fm, developer tokens |
/portal |
your content & data: profile, tracks, albums, export, delete account |
changes:
- created dedicated
/settingsroute consolidating all user preferences - slimmed portal to focus on content management
- added "all settings →" link to SettingsMenu and ProfileMenu
- renamed mobile menu "profile" → "portal" to match route
- moved delete account to portal's "your data" section (it's about data, not preferences)
- fixed
font-family: inheriton all settings page buttons - updated SensitiveImage tooltip: "enable in settings"
bufo easter egg improvements (PRs #491-492, Dec 6)#
what shipped:
- configurable exclude/include patterns via env vars for bufo easter egg
BUFO_EXCLUDE_PATTERNS: regex patterns to filter out (default:^bigbufo_)BUFO_INCLUDE_PATTERNS: allowlist that overrides exclude (default:bigbufo_0_0,bigbufo_2_1)- cache key now includes patterns so config changes take effect immediately
reusable type:
- added
CommaSeparatedStringSettype for parsing comma-delimited env vars into sets - uses pydantic
BeforeValidatorwithAnnotatedpattern (not class-coupled validators) - handles:
VAR=a,b,c→{"a", "b", "c"}
context: bigbufo tiles are 4x4 grid fragments that looked weird floating individually. now excluded by default, with two specific tiles allowed through.
thread: https://bsky.app/profile/zzstoatzzdevlog.bsky.social/post/3m7e3ndmgwl2m
mobile artwork upload fix (PR #489, Dec 6)#
problem: artwork uploads from iOS Photos library silently failed - track uploaded successfully but without artwork.
root cause: iOS stores photos in HEIC format. when selected, iOS converts content to JPEG but often keeps the .heic filename. backend validated format using only filename extension → rejected as "unsupported format".
fix:
- backend now prefers MIME content_type over filename extension for format detection
- added
ImageFormat.from_content_type()method - frontend uses
accept="image/*"for broader iOS compatibility
sensitive image moderation (PRs #471-488, Dec 5-6)#
what shipped:
sensitive_imagestable to flag problematic images by R2image_idor external URLshow_sensitive_artworkuser preference (default: hidden, toggle in portal → "your data")- flagged images blurred everywhere: track lists, player, artist pages, likers tooltip, search results, embeds
- Media Session API (CarPlay, lock screen, control center) respects sensitive preference
- SSR-safe filtering: link previews (og:image) exclude sensitive images on track, artist, and album pages
- likers tooltip UX: max-height with scroll, hover interaction fix, viewport-aware flip positioning
- likers tooltip z-index: elevates entire track-container when tooltip open (prevents sibling tracks bleeding through)
how it works:
- frontend fetches
/moderation/sensitive-imagesand stores flagged IDs/URLs SensitiveImagecomponent wraps images and checks against flagged list- server-side check via
+layout.server.tsfor meta tag filtering - users can opt-in to view sensitive artwork via portal toggle
coverage (PR #488):
| context | approach |
|---|---|
| DOM images needing blur | SensitiveImage component |
| small avatars in lists | SensitiveImage with compact prop |
| SSR meta tags (og:image) | checkImageSensitive() function |
| non-DOM APIs (media session) | direct isSensitive() + showSensitiveArtwork check |
moderation workflow:
- admin adds row to
sensitive_imageswithimage_id(R2) orurl(external) - images are blurred immediately for all users
- users who enable
show_sensitive_artworksee unblurred images
teal.fm scrobbling integration (PR #467, Dec 4)#
what shipped:
- native teal.fm scrobbling: when users enable the toggle, plays are recorded to their PDS using teal's ATProto lexicons
- scrobble triggers at 30% or 30 seconds (whichever comes first) - same threshold as play counts
- user preference stored in database, toggleable from portal → "your data"
- settings link to pdsls.dev so users can view their scrobble records
lexicons used:
fm.teal.alpha.feed.play- individual play records (scrobbles)fm.teal.alpha.actor.status- now-playing status updates
configuration (all optional, sensible defaults):
TEAL_ENABLED(default:true) - feature flag for entire integrationTEAL_PLAY_COLLECTION(default:fm.teal.alpha.feed.play)TEAL_STATUS_COLLECTION(default:fm.teal.alpha.actor.status)
code quality improvements (same PR):
- added
settings.frontend.domaincomputed property for environment-aware URLs - extracted
get_session_id_from_request()utility for bearer token parsing - added field validator on
DeveloperTokenInfo.session_idfor auto-truncation - applied walrus operators throughout auth and playback code
- fixed now-playing endpoint firing every 1 second (fingerprint update bug in scheduled reports)
documentation: backend/src/backend/_internal/atproto/teal.py contains inline docs on the scrobbling flow
unified search (PR #447, Dec 3)#
what shipped:
Cmd+K(mac) /Ctrl+K(windows/linux) opens search modal from anywhere- fuzzy matching across tracks, artists, albums, and tags using PostgreSQL
pg_trgm - results grouped by type with relevance scores (0.0-1.0)
- keyboard navigation (arrow keys, enter, esc)
- artwork/avatars displayed with lazy loading and fallback icons
- glassmorphism modal styling with backdrop blur
- debounced input (150ms) with client-side validation
database:
- enabled
pg_trgmextension for trigram-based similarity search - GIN indexes on
tracks.title,artists.handle,artists.display_name,albums.title,tags.name
documentation: docs/frontend/search.md, docs/frontend/keyboard-shortcuts.md
follow-up polish (PRs #449-463):
- mobile search icon in header (PRs #455-456)
- theme-aware modal styling with styled scrollbar (#450)
- ILIKE fallback for substring matches when trigram fails (#452)
- tag collapse with +N button (#453)
- input focus fix: removed
visibility: hiddenso focus works on open (#457, #463) - album artwork fallback in player when track has no image (#458)
- rate limiting exemption for now-playing endpoints (#460)
--no-devflag for release command to prevent dev dep installation (#461)
light/dark theme and mobile UX overhaul (Dec 2-3)#
theme system (PR #441):
- replaced hardcoded colors across 35 files with CSS custom properties
- semantic tokens:
--bg-primary,--text-secondary,--accent, etc. - theme switcher in settings: dark / light / system (follows OS preference)
- removed zen mode feature (superseded by proper theme support)
mobile UX improvements (PR #443):
- new
ProfileMenucomponent — collapses profile, upload, settings, logout into touch-optimized menu (44px tap targets) - dedicated
/uploadpage — extracted from portal for cleaner mobile flow - portal overhaul — tighter forms, track detail links under artwork, fixed icon alignment
- standardized section headers across home and liked tracks pages
player scroll timing fix (PR #445):
- reduced title scroll cycle from 10s → 8s, artist/album from 15s → 10s
- eliminated 1.5s invisible pause at end of scroll animation
- fixed duplicate upload toast (was firing twice on success)
- upload success toast now includes "view track" link
CI optimization (PR #444):
- pre-commit hooks now skip based on changed paths
- result: ~10s for most PRs instead of ~1m20s
- only installs tooling (uv, bun) needed for changed directories
tag filtering system and SDK tag support (Dec 2)#
tag filtering (PRs #431-434):
- users can now hide tracks by tag via eye icon filter in discovery feed
- preferences centralized in root layout (fetched once, shared across app)
HiddenTagsFiltercomponent with expandable UI for managing hidden tags- default hidden tags:
["ai"]for new users - tag detail pages at
/tag/[name]with all tracks for that tag - clickable tag badges on tracks navigate to tag pages
navigation fix (PR #435):
- fixed tag links interrupting audio playback
- root cause:
stopPropagation()on links breaks SvelteKit's client-side router - documented pattern in
docs/frontend/navigation.mdto prevent recurrence
SDK tag support (plyr-python-client v0.0.1-alpha.10):
- added
tags: set[str]parameter toupload()in SDK - added
-t/--tagCLI option (can be used multiple times) - updated MCP
upload_guideprompt with tag examples - status maintenance workflow now tags AI-generated podcasts with
ai(#436)
tags in detail pages (PR #437):
- track detail endpoint (
/tracks/{id}) now returns tags - album detail endpoint (
/albums/{handle}/{slug}) now returns tags for all tracks - track detail page displays clickable tag badges
bufo easter egg (PR #438, improved in #491-492):
- tracks tagged with
bufotrigger animated toad GIFs on the detail page - uses track title as semantic search query against find-bufo API
- toads are semantically matched to the song's vibe (e.g., "Happy Vibes" gets happy toads)
- results cached in localStorage (1 week TTL) to minimize API calls
TagEffectswrapper component provides extensibility for future tag-based plugins- respects
prefers-reduced-motion; fails gracefully if API unavailable - configurable exclude/include patterns via env vars (see Dec 6 entry above)
queue touch reordering and header stats fix (Dec 2)#
queue mobile UX (PR #428):
- added 6-dot drag handle to queue items for touch-friendly reordering
- implemented touch event handlers for mobile drag-and-drop
- track follows finger during drag with smooth translateY transform
- drop target highlights while dragging over other tracks
header stats positioning (PR #426):
- fixed platform stats not adjusting when queue sidebar opens/closes
- added
--queue-widthCSS custom property updated dynamically - stats now shift left with smooth transition when queue opens
connection pool resilience for Neon cold starts (Dec 2)#
incident: ~5 minute API outage (01:55-02:00 UTC) - all requests returned 500 errors
root cause: Neon serverless cold start after 5 minutes of idle traffic
- queue listener heartbeat detected dead connection, began reconnection
- first 5 user requests each held a connection waiting for Neon to wake up (3-5 min each)
- with pool_size=5 and max_overflow=0, pool exhausted immediately
- all subsequent requests got
QueuePool limit of size 5 overflow 0 reached
fix:
- increased
pool_sizefrom 5 → 10 (handle more concurrent cold start requests) - increased
max_overflowfrom 0 → 5 (allow burst to 15 connections) - increased
connection_timeoutfrom 3s → 10s (wait for Neon wake-up)
related: this is a recurrence of the Nov 17 incident. that fix addressed the queue listener's asyncpg connection but not the SQLAlchemy pool connections.
now-playing API (PR #416, Dec 1)#
what shipped:
GET /now-playing/{did}andGET /now-playing/by-handle/{handle}endpoints- returns track metadata, playback position, timestamp
- 204 when nothing playing, 200 with track data otherwise
teal.fm integration:
- native scrobbling shipped in PR #467 (Dec 4) - plyr.fm writes directly to user's PDS
- Piper integration (external polling) still open: https://github.com/teal-fm/piper/pull/27
admin UI improvements for moderation (PRs #408-414, Dec 1)#
what shipped:
- dropdown menu for false positive reasons (fingerprint noise, original artist, fair use, other)
- artist/track links open in new tabs for verification
- AuDD score normalization (scores shown as 0-100 range)
- filter controls to show only high-confidence matches
- form submission fixes for htmx POST requests
ATProto labeler and copyright moderation (PRs #382-395, Nov 29-Dec 1)#
what shipped:
- standalone labeler service integrated into moderation Rust service
- implements
com.atproto.label.queryLabelsandsubscribeLabelsXRPC endpoints - k256 ECDSA signing for cryptographic label verification
- web interface at
/adminfor reviewing copyright flags - htmx for server-rendered interactivity
- integrates with AuDD enterprise API for audio fingerprinting
- fire-and-forget background task on track upload
- review workflow with resolution tracking (violation, false_positive, original_artist)
initial review results (25 flagged tracks):
- 8 violations (actual copyright issues)
- 11 false positives (fingerprint noise)
- 6 original artists (people uploading their own distributed music)
documentation: see docs/moderation/atproto-labeler.md
Mid-December 2025 Work (Dec 8-16)#
visual customization (PRs #595-596, Dec 16)#
custom backgrounds (PR #595):
- users can set a custom background image URL in settings with optional tiling
- new "playing artwork as background" toggle - uses current track's artwork as blurred page background
- glass effect styling for track items (translucent backgrounds, subtle shadows)
- new
ui_settingsJSONB column in preferences for extensible UI settings
bug fix (PR #596):
- removed 3D wheel scroll effect that was blocking like/share button clicks
- root cause:
translateZtransforms created z-index stacking that intercepted pointer events
performance & UX polish (PRs #586-593, Dec 14-15)#
performance improvements (PRs #590-591):
- removed moderation service call from
/tracks/listing endpoint - removed copyright check from tag listing endpoint
- faster page loads for track feeds
moderation agent (PRs #586, #588):
- added moderation agent script with audit trail support
- improved moderation prompt and UI layout
bug fixes (PRs #589, #592, #593):
- fixed liked state display on playlist detail page
- preserved album track order during ATProto sync
- made header sticky on scroll for better mobile navigation
iOS Safari fixes (PRs #573-576):
- fixed AddToMenu visibility issue on iOS Safari
- menu now correctly opens upward when near viewport bottom
mobile UI polish & background task expansion (PRs #558-572, Dec 10-12)#
background task expansion (PRs #558, #561):
- moved like/unlike and comment PDS writes to docket background tasks
- API responses now immediate; PDS sync happens asynchronously
- added targeted album list sync background task for ATProto record updates
performance caching (PR #566):
- added Redis cache for copyright label lookups (5-minute TTL)
- fixed 2-3s latency spikes on
/tracks/endpoint - batch operations via
mget/pipeline for efficiency
mobile UX improvements (PRs #569, #572):
- mobile action menus now open from top with all actions visible
- UI polish for album and artist pages on small screens
misc (PRs #559, #562, #563, #570):
- reduced docket Redis polling from 250ms to 5s (lower resource usage)
- added atprotofans support link mode for ko-fi integration
- added alpha badge to header branding
- fixed web manifest ID for PWA stability
confidential OAuth client (PRs #578, #580-582, Dec 12-13)#
confidential client support (PR #578):
- implemented ATProto OAuth confidential client using
private_key_jwtauthentication - when
OAUTH_JWKis configured, plyr.fm authenticates with a cryptographic key - confidential clients earn 180-day refresh tokens (vs 2-week for public clients)
- added
/.well-known/jwks.jsonendpoint for public key discovery - updated
/oauth-client-metadata.jsonwith confidential client fields
bug fixes (PRs #580-582):
- fixed client assertion JWT to use Authorization Server's issuer as
audclaim (not token endpoint URL) - fixed JWKS endpoint to preserve
kidfield from original JWK - fixed
OAuthClientto passclient_secret_kidfor JWT header
atproto fork updates (zzstoatzz/atproto#6, #7):
- added
issuerparameter to_make_token_request()for correctaudclaim - added
client_secret_kidparameter to includekidin client assertion JWT header
outcome: users now get 180-day refresh tokens, and "remember this account" on the PDS authorization page works (auto-approves subsequent logins). see #583 for future work on account switching via OAuth prompt parameter.
pagination & album management (PRs #550-554, Dec 9-10)#
tracks list pagination (PR #554):
- cursor-based pagination on
/tracks/endpoint (default 50 per page) - infinite scroll on homepage using native IntersectionObserver
- zero new dependencies - uses browser APIs only
- pagination state persisted to localStorage for fast subsequent loads
album management improvements (PRs #550-552, #557):
- album delete and track reorder fixes
- album page edit mode matching playlist UX (inline title editing, cover upload)
- optimistic UI updates for album title changes (instant feedback)
- ATProto record sync when album title changes (updates all track records + list record)
- fixed album slug sync on rename (prevented duplicate albums when adding tracks)
playlist show on profile (PR #553):
- restored "show on profile" toggle that was lost during inline editing refactor
- users can now control whether playlists appear on their public profile
public cost dashboard (PRs #548-549, Dec 9)#
/costspage showing live platform infrastructure costs- daily export to R2 via GitHub Action, proxied through
/stats/costsendpoint - dedicated
plyr-statsR2 bucket with public access (shared across environments) - includes fly.io, neon, cloudflare, and audd API costs
- ko-fi integration for community support
docket background tasks & concurrent exports (PRs #534-546, Dec 9)#
docket integration (PRs #534, #536, #539):
- migrated background tasks from inline asyncio to docket (Redis-backed task queue)
- copyright scanning, media export, ATProto sync, and teal scrobbling now run via docket
- graceful fallback to asyncio for local development without Redis
- parallel test execution with xdist template databases (#540)
concurrent export downloads (PR #545):
- exports now download tracks in parallel (up to 4 concurrent) instead of sequentially
- significantly faster for users with many tracks or large files
- zip creation remains sequential (zipfile constraint)
ATProto refactor (PR #534):
- reorganized ATProto record code into
_internal/atproto/records/by lexicon namespace - extracted
client.pyfor low-level PDS operations - cleaner separation between plyr.fm and teal.fm lexicons
documentation & observability:
- AudD API cost tracking dashboard (#546)
- promoted runbooks from sandbox to
docs/runbooks/ - updated CLAUDE.md files across the codebase
artist support links & inline playlist editing (PRs #520-532, Dec 8)#
artist support link (PR #532):
- artists can set a support URL (Ko-fi, Patreon, etc.) in their portal profile
- support link displays as a button on artist profile pages next to the share button
- URLs validated to require https:// prefix
inline playlist editing (PR #531):
- edit playlist name and description directly on playlist detail page
- click-to-upload cover art replacement without modal
- cleaner UX - no more edit modal popup
platform stats enhancements (PRs #522, #528):
- total duration displayed in platform stats (e.g., "42h 15m of music")
- duration shown per artist in analytics section
- combined stats and search into single centered container for cleaner layout
navigation & data loading fixes (PR #527):
- fixed stale data when navigating between detail pages of the same type
- e.g., clicking from one artist to another now properly reloads data
copyright moderation improvements (PR #480):
- enhanced moderation workflow for copyright claims
- improved labeler integration
status maintenance workflow (PR #529):
- automated status maintenance using claude-code-action
- reviews merged PRs and updates STATUS.md narratively
playlist fast-follow fixes (PRs #507-519, Dec 7-8)#
public playlist viewing (PR #519):
- playlists now publicly viewable without authentication
- ATProto records are public by design - auth was unnecessary for read access
- shared playlist URLs no longer redirect unauthenticated users to homepage
inline playlist creation (PR #510):
- clicking "create new playlist" from AddToMenu previously navigated to
/library?create=playlist - this caused SvelteKit to reinitialize the layout, destroying the audio element and stopping playback
- fix: added inline create form that creates playlist and adds track in one action without navigation
UI polish (PRs #507-509, #515):
- include
image_urlin playlist SSR data for og:image link previews - invalidate layout data after token exchange - fixes stale auth state after login
- fixed stopPropagation blocking "create new playlist" link clicks
- detail page button layouts: all buttons visible on mobile, centered AddToMenu on track detail
- AddToMenu smart positioning: menu opens upward when near viewport bottom
documentation (PR #514):
- added lexicons overview documentation at
docs/lexicons/overview.md - covers
fm.plyr.track,fm.plyr.like,fm.plyr.comment,fm.plyr.list,fm.plyr.actor.profile