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
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}/urlbackend 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
@andat://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_resolutionsnow 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
validateSupporterAPI 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_gaterequire 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
supportGatefield andaudioUrlto point at our endpoint instead of R2)
frontend:
playback.svelte.tsguards 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) andplyr-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
avatarfield tofm.plyr.actor.profilelexicon (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.