# 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](https://find-bufo.fly.dev/) - 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**: - 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.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 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_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**: - [moderation architecture overhaul](docs/research/2025-12-20-moderation-architecture-overhaul.md) - [atprotofans paywall integration](docs/research/2025-12-20-atprotofans-paywall-integration.md) --- ### 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.