audio streaming app plyr.fm

plyr.fm Status History - February 2026 (Early)#

Archived from STATUS.md — covers Feb 2-12, 2026.


playlist track recommendations (PRs #895-898, Feb 11)#

inline recommendations when editing playlists: shows 3 recommended tracks below the track list based on CLAP audio embeddings in turbopuffer. adaptive algorithm scales with playlist size — direct vector query for 1 track, per-track Reciprocal Rank Fusion for 2-5, k-means clustering into centroids for 6+. results cached in Redis keyed on the playlist's ATProto record CID (auto-invalidates when tracks change).

backend: new recommendations.py module with pure-python k-means (no numpy), RRF merge, and get_vectors() in the turbopuffer client. new GET /playlists/{id}/recommendations endpoint with owner-only auth, Redis caching (24h TTL), and graceful degradation when turbopuffer is disabled.

frontend: recommendation cards match TrackItem geometry exactly — dashed border + reduced opacity (0.7 → 1.0 on hover) distinguishes suggestions from committed playlist tracks. "add tracks" button and recommendations section align with track card width inside edit mode rows.

no feature flag needed — degrades gracefully when turbopuffer or embeddings are unavailable.

see docs/backend/playlist-recommendations.md for full architecture.


main.py extraction + bug fixes (PRs #890-894, Feb 10)#

main.py extraction (PR #890): main.py shrunk from 372 to 138 lines — now pure orchestration (imports, lifespan, wiring). extracted SecurityHeadersMiddlewareutilities/middleware.py, logfire/span enrichment → utilities/observability.py, and 6 root-level endpoints → api/meta.py router. added __main__.py for python -m backend convenience.

notification DM fix (PR #891): upload notification DMs were silently dropped because _send_track_notification received a Track object from a closed session. the db.refresh() call hit DetachedInstanceError, caught by a blanket except. fix: accept track_id: int and re-fetch with joinedload(Track.artist) from the current session. discovered via Logfire.

mobile share fix (PR #892): on mobile Safari, await fetch() before navigator.clipboard.writeText() consumes the transient user activation, breaking clipboard access. fix: eagerly create tracked share links when the menu opens (not on tap). added navigator.share() as fallback.

developer token scoping docs (PR #893): corrected misleading "full account access" language — tokens are actually scoped to fm.plyr.* via ATProto OAuth. fixed stale resolved_scope examples.

artist page track limit (PR #894): initial load was showing 50 tracks (backend default), burying albums below the fold. reduced to 5 with "load more" (10 per click).


OAuth permission set cleanup + docs audit (PR #889, Feb 8)#

OAuth permission set: authorization page was showing raw NSID (fm.plyr.authFullApp) instead of human-readable description. root cause: ATProto permission sets use detail field (not description) for the subtitle text. updated lexicon and publish script, republished to PDS. also modernized publish script from raw os.environ to pydantic settings.

docs audit (PR #888): fixed stale/broken documentation across 6 files — wrong table names in copyright docs, outdated pool_recycle values, broken links in docs index, missing tools entries. updated README to reflect full feature set. added semantic search and playlists to search.md.


auth state refresh + backend refactor (PRs #886-887, Feb 8)#

auth state refresh (PR #887): after account switch or login, stale user data persisted because AuthManager.initialize() no-ops once the initialized flag is set. added refresh() that resets the flag before re-fetching, used in all exchange-token call sites.

backend package split (PR #886): split three monolith files into focused packages:

  • auth.py (1,400 lines) → auth/ package (8 modules)
  • background_tasks.py (803 lines) → tasks/ package (5 domain modules: copyright, ml, pds, storage, sync)
  • 5 *_client.py files → clients/ package
  • extracted upload pipeline into 7 named phase functions, shared tag ops to utilities/tags.py

all public APIs preserved via __init__.py re-exports. 424 tests pass.


portal pagination + perf optimization (PRs #878-879, Feb 8)#

portal pagination (PR #878): GET /tracks/me now supports limit/offset pagination (default 10 per page). portal loads first 10 tracks with a "load more" button. export section uses total count for accurate messaging.

GET /tracks/top latency fix (PR #879): baseline p95 was 1.2s due to stale connection reconnects and redundant DB queries.

  • merged top-track-ids + like-counts into single get_top_tracks_with_counts() query (1 fewer round-trip)
  • scoped liked-track check to track_id IN (...) (10 rows) instead of all user likes
  • pool_recycle 7200s → 1800s to reduce stale connection spikes
  • authenticated requests dropped from 11 DB queries to 7. post-deploy p95: ~550ms
  • 14 new regression tests

repo reorganization (PR #876, Feb 8)#

moved auxiliary services into services/ (transcoder, moderation, clap) and infrastructure into infrastructure/ (redis). updated all GitHub Actions workflows, pre-commit config, justfile module paths, and docs.


auto-tag at upload + ML audit (PRs #870-872, Feb 7)#

auto-tag on upload (PR #871): checkbox on the upload form ("auto-tag with recommended genres") that applies top genre tags after classification completes. ratio-to-top filter (>= 50% of top score, capped at 5), additive with manual tags. flag stored in track.extra, cleaned up after use.

genre/subgenre split (PR #870): compound Discogs labels like "Electronic---Ambient" now produce two separate tags ("electronic", "ambient") instead of one compound tag.

ML audit script (PR #872): scripts/ml_audit.py reports which tracks/artists have been processed by ML features. supports --verbose and --check-embeddings for privacy/ToS auditing.


ML genre classification + suggested tags (PRs #864-868, Feb 6-7)#

genre classification via Replicate: tracks classified into genre labels using effnet-discogs on Replicate (EfficientNet trained on Discogs ~400 categories).

  • on upload: classification runs as docket background task if REPLICATE_ENABLED=true
  • on demand: GET /tracks/{id}/recommended-tags classifies on the fly if no cached predictions
  • predictions stored in track.extra["genre_predictions"] with file_id-based cache invalidation
  • raw Discogs labels cleaned to lowercase format. cost: ~$0.00019/run
  • Replicate SDK incompatible with Python 3.14 (pydantic v1) — uses httpx directly with Prefer: wait header

frontend UX (PR #868): suggested genre tags appear as clickable dashed-border chips in the portal edit modal. $derived reactively hides suggestions matching manually-typed tags.


mood search (PRs #848-858, Feb 5-6)#

search by how music sounds — type "chill lo-fi beats" into the search bar and find tracks that match the vibe, not just the title.

architecture: CLAP (Contrastive Language-Audio Pretraining) model hosted on Modal generates audio embeddings at upload time and text embeddings at search time. vectors stored in turbopuffer. keyword and semantic searches fire in parallel — keyword results appear instantly (~50ms), semantic results append when ready (~1-2s).

key design decisions:

  • unified search: no mode toggle. keyword + semantic results merge by score, client-side deduplication removes overlap
  • graceful degradation: backend returns available: false instead of 502/503 when CLAP/turbopuffer are down
  • quality controls: distance threshold, spread check to filter low-signal results, result cap
  • gated behind vibe-search feature flag with version-aware terms re-acceptance

hardening (PRs #849-858): m4a support for CLAP, correct R2 URLs, normalize similarity scores, switch from larger_clap_music to clap-htsat-unfused, handle empty turbopuffer namespace, rename "vibe search" → "mood search", concurrent backfill script.


GET /tracks/{track_id}/recommended-tags finds tracks with similar CLAP embeddings in turbopuffer, aggregates their tags weighted by similarity score. excludes existing tags, normalizes scores to 0-1. replaced by genre classification (PR #864) but the endpoint pattern persisted.


mobile login UX + misc fixes (PRs #841-845, Feb 2)#

  • handle hint sizing (PRs #843-845): iterative fix for login page handle hint wrapping on mobile — final approach: reduced font size, gap, and nowrap to keep full text visible
  • PDS backfill gate (PR #842): PDS backfill button gated behind pds-audio-uploads feature flag
  • share button reuse (PR #841): track detail page now uses shared ShareButton component