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):

  • playlists and playlist_tracks tables with Alembic migration
  • POST /lists/playlists - create playlist
  • PUT /lists/playlists/{id} - rename playlist
  • DELETE /lists/playlists/{id} - delete playlist
  • POST /lists/playlists/{id}/tracks - add track to playlist
  • DELETE /lists/playlists/{id}/tracks/{track_id} - remove track
  • PUT /lists/playlists/{id}/tracks/reorder - reorder tracks
  • POST /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.list lexicon for syncing playlists and albums to user PDSes
  • fm.plyr.actor.profile lexicon 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_profile preference
  • 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
  • listType semantically 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/reorder endpoint - reorder user's liked tracks list
  • PUT /lists/{rkey}/reorder endpoint - reorder any list by ATProto rkey
  • both endpoints take items array 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-over and .is-dragging states
  • saves order to ATProto via putRecord when user clicks "done"
  • added atproto_record_cid to TrackResponse schema (needed for strongRefs)
  • added artist_did and list_uri to 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/start endpoint initiates OAuth with expanded scopes
  • pending_scope_upgrades table 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_client vs oauth_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=0 to pytest env vars
  • dispose cached engines after each test to prevent connection accumulation
  • fixed mock function signatures for refresh_session tests

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 /settings route 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: inherit on 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 CommaSeparatedStringSet type for parsing comma-delimited env vars into sets
  • uses pydantic BeforeValidator with Annotated pattern (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_images table to flag problematic images by R2 image_id or external URL
  • show_sensitive_artwork user 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-images and stores flagged IDs/URLs
  • SensitiveImage component wraps images and checks against flagged list
  • server-side check via +layout.server.ts for 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_images with image_id (R2) or url (external)
  • images are blurred immediately for all users
  • users who enable show_sensitive_artwork see 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 integration
  • TEAL_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.domain computed property for environment-aware URLs
  • extracted get_session_id_from_request() utility for bearer token parsing
  • added field validator on DeveloperTokenInfo.session_id for 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_trgm extension 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: hidden so 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-dev flag 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 ProfileMenu component — collapses profile, upload, settings, logout into touch-optimized menu (44px tap targets)
  • dedicated /upload page — 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)
  • HiddenTagsFilter component 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.md to prevent recurrence

SDK tag support (plyr-python-client v0.0.1-alpha.10):

  • added tags: set[str] parameter to upload() in SDK
  • added -t/--tag CLI option (can be used multiple times)
  • updated MCP upload_guide prompt 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 bufo trigger 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
  • TagEffects wrapper 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-width CSS 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_size from 5 → 10 (handle more concurrent cold start requests)
  • increased max_overflow from 0 → 5 (allow burst to 15 connections)
  • increased connection_timeout from 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} and GET /now-playing/by-handle/{handle} endpoints
  • returns track metadata, playback position, timestamp
  • 204 when nothing playing, 200 with track data otherwise

teal.fm integration:


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

what shipped:

  • standalone labeler service integrated into moderation Rust service
  • implements com.atproto.label.queryLabels and subscribeLabels XRPC endpoints
  • k256 ECDSA signing for cryptographic label verification
  • web interface at /admin for 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_settings JSONB 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: translateZ transforms 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_jwt authentication
  • when OAUTH_JWK is 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.json endpoint for public key discovery
  • updated /oauth-client-metadata.json with confidential client fields

bug fixes (PRs #580-582):

  • fixed client assertion JWT to use Authorization Server's issuer as aud claim (not token endpoint URL)
  • fixed JWKS endpoint to preserve kid field from original JWK
  • fixed OAuthClient to pass client_secret_kid for JWT header

atproto fork updates (zzstoatzz/atproto#6, #7):

  • added issuer parameter to _make_token_request() for correct aud claim
  • added client_secret_kid parameter to include kid in 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)#

  • /costs page showing live platform infrastructure costs
  • daily export to R2 via GitHub Action, proxied through /stats/costs endpoint
  • dedicated plyr-stats R2 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.py for 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 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_url in 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

Late December 2025 Work (Dec 17-31)#

offline mode foundation (PRs #610-611, Dec 17)#

experimental offline playback:

  • storage layer using Cache API for audio bytes + IndexedDB for metadata
  • GET /audio/{file_id}/url backend endpoint returns direct R2 URLs for client-side caching
  • "auto-download liked" toggle in experimental settings section
  • Player checks for cached audio before streaming from R2

UX polish (PRs #604-607, #613, #615, Dec 16-18)#

login improvements (PRs #604, #613):

  • login page now uses "internet handle" terminology for clarity
  • input normalization: strips @ and at:// prefixes automatically

artist page fixes (PR #615):

  • track pagination on artist pages now works correctly
  • fixed mobile album card overflow

mobile + metadata (PRs #605-607):

  • Open Graph tags added to tag detail pages for link previews
  • mobile modals now use full screen positioning
  • fixed /tag/ routes in hasPageMetadata check

beartype + moderation cleanup (PRs #617-619, Dec 19)#

runtime type checking (PR #619):

  • enabled beartype runtime type validation across the backend
  • catches type errors at runtime instead of silently passing bad data
  • test infrastructure improvements: session-scoped TestClient fixture (5x faster tests)

moderation cleanup (PRs #617-618):

  • consolidated moderation code, addressing issues #541-543
  • sync_copyright_resolutions now runs automatically via docket Perpetual task
  • removed dead init_db() from lifespan (handled by alembic migrations)

end-of-year sprint (PR #626, Dec 20)#

focus: two foundational systems with experimental implementations.

track focus status
moderation consolidate architecture, batch review, Claude vision shipped
atprotofans supporter validation, content gating shipped

research docs:


rate limit moderation endpoint (PR #629, Dec 21)#

incident response: detected suspicious activity - 72 requests in 17 seconds from a single IP targeting /moderation/sensitive-images. added 10/minute rate limit using existing slowapi infrastructure. this was the first real probe of our moderation endpoints, validating the decision to add rate limiting before it became a problem.


supporter badges (PR #627, Dec 21-22)#

phase 1 of atprotofans integration:

  • supporter badge displays on artist pages when logged-in viewer supports the artist
  • calls atprotofans validateSupporter API directly from frontend (public endpoint)
  • badge only shows when viewer is authenticated and not viewing their own profile

supporter-gated content (PR #637, Dec 22-23)#

atprotofans paywall integration - artists can now mark tracks as "supporters only":

  • tracks with support_gate require atprotofans validation before playback
  • non-supporters see lock icon and "become a supporter" CTA linking to atprotofans
  • artists can always play their own gated tracks

backend architecture:

  • audio endpoint validates supporter status via atprotofans API before serving gated content
  • HEAD requests return 200/401/402 for pre-flight auth checks (avoids CORS issues with cross-origin redirects)
  • gated files stored in private R2 bucket, served via presigned URLs (SigV4 signatures)
  • R2Storage.move_audio() moves files between public/private buckets when toggling gate
  • background task handles bucket migration asynchronously
  • ATProto record syncs when toggling gate (updates supportGate field and audioUrl to point at our endpoint instead of R2)

frontend:

  • playback.svelte.ts guards queue operations with gated checks BEFORE modifying state
  • clicking locked track shows toast with CTA - does NOT interrupt current playback
  • portal shows support gate toggle in track edit UI

key decision: gated status is resolved server-side in track listings, not client-side. this means the lock icon appears instantly without additional API calls, and prevents information leakage about which tracks are gated vs which the user simply can't access.


CSS design tokens (PRs #662-664, Dec 29-30)#

design system foundations:

  • border-radius tokens (--radius-sm, --radius-md, etc.)
  • typography scale tokens
  • consolidated form styles
  • documented in docs/frontend/design-tokens.md

self-hosted redis (PRs #674-675, Dec 30)#

replaced Upstash with self-hosted Redis on Fly.io - ~$75/month → ~$4/month:

  • Upstash pay-as-you-go was charging per command (37M commands = $75) - discovered when reviewing December costs
  • docket's heartbeat mechanism is chatty by design, making pay-per-command pricing unsuitable
  • self-hosted Redis on 256MB Fly VMs costs fixed ~$2/month per environment
  • deployed plyr-redis (prod) and plyr-redis-stg (staging)
  • added CI workflow for redis deployments on merge

no state migration needed - docket stores ephemeral task queue data, job progress lives in postgres.

incident (Dec 30): while optimizing redis overhead, a heartbeat_interval=30s change broke docket task execution. likes created Dec 29-30 were missing ATProto records. reverted in PR #669, documented in docs/backend/background-tasks.md. filed upstream: https://github.com/chrisguidry/docket/issues/267


batch review system (PR #672, Dec 30)#

moderation batch review UI - mobile-friendly interface for reviewing flagged content:

  • filter by flag status, paginated results
  • auto-resolve flags for deleted tracks (PR #681)
  • full URL in DM notifications (PR #678)
  • required auth flow fix (PR #679) - review page was accessible without login

top tracks homepage (PR #684, Dec 31)#

homepage now shows top tracks - quick access to popular content for new visitors.


avatar sync on login (PR #685, Dec 31)#

avatars now stay fresh - previously set once at artist creation, causing stale/broken avatars throughout the app:

  • on login, avatar is refreshed from Bluesky and synced to both postgres and ATProto profile record
  • added avatar field to fm.plyr.actor.profile lexicon (optional, URI format)
  • one-time backfill script (scripts/backfill_avatars.py) refreshed 28 stale avatars in production

automated image moderation (PRs #687-690, Dec 31)#

Claude vision integration for sensitive image detection:

  • images analyzed on upload via Claude Sonnet 4.5 (had to fix model ID - was using wrong identifier)
  • flagged images trigger DM notifications to admin
  • non-false-positive flags sent to batch review queue
  • complements the batch review system built earlier in the sprint

header redesign (PR #691, Dec 31)#

new header layout with UserMenu dropdown and even spacing across the top bar.