From edb2c2044fe594aafb44015dbda9676560f15526 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Sun, 26 Apr 2026 13:22:07 -0700
Subject: [PATCH 001/145] Delete dead historical changelog strings from
web_server.py
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
_OLD_V22_NOTES (655 lines) and _OLD_V2_NOTES (556 lines) were
triple-quoted Python strings holding old release-notes JSON. No code
references them — `grep _OLD_V22_NOTES|_OLD_V2_NOTES` returns only
the definitions themselves. They were leftover from earlier
version-info refactors and have been sitting in the file unread.
Pure deletion. No behavior change.
---
web_server.py | 1213 -------------------------------------------------
1 file changed, 1213 deletions(-)
diff --git a/web_server.py b/web_server.py
index 4fb243a5..30e7fd79 100644
--- a/web_server.py
+++ b/web_server.py
@@ -23063,1219 +23063,6 @@ def get_version_info():
}
return jsonify(version_data)
-_OLD_V22_NOTES = """
- {
- "title": "Wing It — Download or Sync Without Discovery",
- "description": "Bypass metadata discovery and use raw track names directly",
- "features": [
- "• Wing It button on all discovery modals and ListenBrainz Discover page cards",
- "• Choose Download or Sync from a compact dropdown — no extra dialogs",
- "• Download: sends raw artist/title to the download engine with full post-processing",
- "• Sync: creates playlist on media server by matching raw names against library",
- "• Failed tracks are NOT added to wishlist — clean wing-it behavior",
- "• Live sync progress displayed inline just like normal sync",
- "• Download creates a bubble on the dashboard for progress tracking"
- ],
- "usage_note": "Click the Wing It button next to Start Discovery or Download Missing in any playlist modal."
- },
- {
- "title": "Global Search Bar — Search From Anywhere",
- "description": "Spotlight-style search bar accessible from every page",
- "features": [
- "• Persistent search bar at the bottom of the screen — faded when idle, expands on focus",
- "• Full enhanced search parity — artists, albums, singles/EPs, tracks with source tabs",
- "• Keyboard shortcuts: / or Ctrl+K to focus, Escape to close",
- "• Click artists to navigate to their detail page, albums to open download modal",
- "• In Library badges and green play buttons for tracks you already own",
- "• Source tabs (Spotify, iTunes, Deezer) with result counts",
- "• Results collapse on navigation, search bar stays visible"
- ],
- "usage_note": "Press / or Ctrl+K from any page, or click the search bar at the bottom of the screen."
- },
- {
- "title": "Redesigned Notification System",
- "description": "Modern compact toasts with notification history and bell button",
- "features": [
- "• Compact pill-shaped toasts in the bottom-right — one at a time, auto-dismiss after 3.5s",
- "• Notification bell button with unread badge counter next to the help button",
- "• Click the bell to open the notification history panel with the last 50 notifications",
- "• Each notification shows type icon, message, relative timestamp, and optional 'Learn more' link",
- "• Unread dot indicators — panel marks all as read when opened",
- "• Clear All button to empty notification history",
- "• Click any toast to dismiss it immediately"
- ]
- },
- {
- "title": "Track Redownload — Fix Mismatched Downloads",
- "description": "Replace wrong downloads with the correct version using manual source selection",
- "features": [
- "• Redownload button (↻) on each track in the enhanced library view",
- "• Step 1: All metadata sources (Spotify, iTunes, Deezer) searched in columns side-by-side",
- "• Step 2: All download sources searched simultaneously — results stream in as each source responds",
- "• Step 3: Download with real progress bar, old file deleted, DB path updated automatically",
- "• Full pipeline parity — track number, album context, metadata tagging all work correctly",
- "• Source Info (ℹ) button shows where each track was downloaded from",
- "• Download provenance tracking — every download's source is recorded for future reference",
- "• Smart Delete: choose to remove from library only or delete the file from disk too",
- "• Download Blacklist: block specific sources from the Source Info popover — blacklisted sources skipped in all future downloads",
- "• Blacklist viewer on dashboard Tools section with remove capability"
- ],
- "usage_note": "In the enhanced library view, click ↻ to redownload, ℹ for source info, or to delete."
- },
- {
- "title": "Spotify API Rate Limit Improvements",
- "description": "Reduced Spotify API usage through caching and smart worker management",
- "features": [
- "• get_artist_albums now cached — discography views, completion badges hit cache instead of API",
- "• Watchlist scans bypass cache with skip_cache flag to always detect new releases",
- "• All 5 discovery workers (Tidal, YouTube, ListenBrainz, Beatport, playlist search) now use cached search methods",
- "• Eliminated duplicate API calls — discovery workers were calling sp.search() AND search_tracks() per query",
- "• Auth probe cache TTL increased from 5 to 15 minutes — reduces /v1/me calls by 66%",
- "• Spotify, Last.fm, and Genius enrichment workers auto-pause during active downloads to preserve rate limit headroom",
- "• Dashboard shows 'Yielding for downloads' when workers are auto-paused"
- ]
- },
- {
- "title": "Additional Fixes",
- "description": "Bug fixes and quality-of-life improvements",
- "features": [
- "• $discnum template variable — unpadded disc number for multi-disc album path templates",
- "• Media player no longer collapses in sidebar on short viewports and mobile",
- "• Playlist Explorer controls redesigned — prominent Explore button, icons, polish",
- "• YouTube '- Topic' suffix stripped from auto-generated channel names (#231)",
- "• Cover Art Archive album art now opt-in via Settings toggle (#232)",
- "• cover.jpg now correctly uses Cover Art Archive when enabled (was silently failing)",
- "• Genius artist search returns multiple results for manual matching (#233)",
- "• Genius API interval increased from 1.5s to 2s to reduce 429 rate limits",
- "• MusicBrainz cache now visible in Cache Browser with browse, clear, and clear-failed-only options",
- "• Cache Health popup shows MusicBrainz alongside other sources, 'Failed Lookups' clarified as MB-specific",
- "• Block artists from discovery — hover any track in a discovery playlist and click to permanently exclude that artist",
- "• Configurable concurrent downloads (1-10) — Settings → Downloads, Soulseek albums stay at 1",
- "• Streaming search sources — Apple Music results load progressively instead of blocking for 9+ seconds",
- "• API Rate Monitor — real-time speedometer gauges for all services on Dashboard, click for 24h history",
- "• Spotify pagination throttled — prevents 429 bans during watchlist scans with large discographies",
- "• Import now triggers full scan → DB update chain through automation engine",
- "• Track source-info and redownload work with Jellyfin string IDs (#237)",
- "• Clear Match button to undo wrong manual matches (#236)",
- "• Tidal auth no longer crashes when download orchestrator not initialized",
- "• Download orchestrator hardened — one failing client no longer kills all download sources",
- "• Webhook THEN action — send HTTP POST to any URL (Gotify, Home Assistant, Slack, n8n) from automations",
- "• M3U auto-export now skips albums — only generates for playlists (#241)",
- "• Copy Debug Info includes API call rates, Spotify rate limit state, and download client failures",
- "• Discogs integration — new metadata source with enrichment worker, fallback source, search tabs, watchlist, cache",
- "• Discogs enriches: genres/styles (400+ taxonomy), labels, catalog numbers, bios, community ratings",
- "• Track provenance preserved through lossy transcoding with bit depth/sample rate/bitrate (#245)",
- "• spotify_public playlists use full API when authenticated, no longer overwrite discovery data",
- "• Watchlist backfills all sources (Spotify, iTunes, Deezer, Discogs) at start of every scan",
- "• Collectors edition album matching for library completion checks",
- "• Mobile responsive styles for rate monitor, notifications, and global search"
- ]
- },
- {
- "title": "Server Playlist Manager — Compare & Fix Matches",
- "description": "Review and fix track matches between your source playlists and media server",
- "features": [
- "• New Server Playlists tab (default on Sync page) — shows server playlists that match your mirrored playlists",
- "• Dual-column comparison view — source tracks on the left, server tracks on the right with match status",
- "• Click any track to highlight and auto-scroll to its pair in the other column",
- "• Find & Add — click empty slots to search your library and add tracks at the correct position",
- "• Swap — replace a matched track with a different version from your library",
- "• Remove — delete incorrect tracks from server playlists with confirmation",
- "• Title similarity percentage shown on each match (exact, high, or fuzzy)",
- "• Disambiguation modal when multiple mirrored playlists share the same name",
- "• Album art shown for source tracks, server tracks, and search results",
- "• Smart matching — exact title match first, then fuzzy artist+title match (≥75% threshold)",
- "• Works with Plex, Jellyfin, and Navidrome"
- ],
- "usage_note": "Navigate to Sync → Server Playlists tab. Click any playlist card to open the comparison editor."
- },
- {
- "title": "Sync History Dashboard with Per-Track Details",
- "description": "Dashboard shows recent syncs as visual cards with full per-track match data",
- "features": [
- "• Recent Syncs section on dashboard with scrolling cards showing match percentage and health indicators",
- "• Click any sync card to see per-track match details — status, confidence score, album art, download/wishlist status",
- "• Filter by All, Matched, Unmatched, or Downloaded tracks in the detail modal",
- "• Per-track data cached for all sync types — playlist-to-server, download missing tracks, wishlist processing",
- "• Auto-refreshes every 30 seconds when viewing dashboard"
- ]
- },
- {
- "title": "Fix Japanese Song Searches Producing Gibberish",
- "description": "CJK text no longer mangled by unidecode in Soulseek search queries",
- "features": [
- "• Japanese kanji, hiragana, katakana, and Korean hangul preserved in search queries",
- "• unidecode was converting Japanese to Chinese pinyin (e.g. 命の灯火 → 'tvanimedei')",
- "• Soulseek users typically share files with original CJK characters in filenames"
- ]
- },
- {
- "title": "Fix Partial Name Matching False Positives (#225)",
- "description": "Track ownership check no longer falsely matches prefix/suffix variations",
- "features": [
- "• 'Believe' no longer matches 'Believe In Me' — length ratio penalty prevents partial title matches",
- "• Titles differing in length by more than 30% get their similarity score penalized proportionally",
- "• Exact matches and cleaned matches (e.g. remastered tags stripped) are unaffected"
- ]
- },
- {
- "title": "Fix Pipeline Stops When Metadata Match Fails (#224)",
- "description": "Playlist sync no longer drops tracks that failed iTunes/Apple Music discovery",
- "features": [
- "• Tracks that fail metadata discovery now continue through the pipeline using original playlist data",
- "• Track name and artist from the source playlist are used for Soulseek search when discovery fails",
- "• Only tracks with completely missing name/artist are skipped (not tracks that simply failed matching)"
- ]
- },
- {
- "title": "Playlist Explorer — Visual Discovery Tree",
- "description": "Use playlists as seeds to discover full albums and discographies",
- "features": [
- "• New Explorer page with interactive tree visualization",
- "• Select any mirrored playlist and choose Albums or Discographies mode",
- "• Tree builds progressively — artist nodes appear as Spotify data streams in",
- "• Click artists to expand and see all their albums with art, year, and track counts",
- "• Select individual albums or entire branches, then add to wishlist in one click",
- "• Albums mode shows only albums containing playlist tracks; Discographies shows everything",
- "• SVG connecting lines with animated draw-in effect",
- "• 'In Library' and 'In Playlist' badges on album cards"
- ]
- },
- {
- "title": "Fix .LRC Files Written Without Timestamps",
- "description": "Plain lyrics now saved as .txt instead of invalid .lrc files",
- "features": [
- "• Synced (timestamped) lyrics → .lrc file — valid format for Plex, Navidrome, Jellyfin",
- "• Plain (unsynced) lyrics → .txt file — no longer written with incorrect .lrc extension",
- "• Lyrics still embedded in audio file tags regardless of type (players can display both)",
- "• File move/rename operations updated to handle both .lrc and .txt sidecars"
- ]
- },
- {
- "title": "Fix Collaborative Album Artist Not Applied to Singles (#215)",
- "description": "Single path template now respects the First Listed Artist setting",
- "features": [
- "• Single downloads now include structured artists list for collab artist extraction",
- "• $albumartist variable now works in single and playlist path templates",
- "• Settings UI updated to show $albumartist as available for single and playlist templates"
- ]
- },
- {
- "title": "Fix Enrichment Overwriting Manual Matches (#221)",
- "description": "Enriching an entity that was manually matched no longer reverts the status to not_found",
- "features": [
- "• Genius and AudioDB workers now check for existing service IDs before searching by name",
- "• Manual matches are used for direct API lookup instead of re-searching by name",
- "• If the direct lookup succeeds, metadata is enriched and match status is preserved",
- "• If the direct lookup fails, the manual match status is preserved (not overwritten to not_found)",
- "• Added AudioDB lookup-by-ID methods for artist, album, and track"
- ]
- },
- {
- "title": "Fix Spotify OAuth ERR_EMPTY_RESPONSE in Docker (#220)",
- "description": "OAuth callback server hardened for Docker/SSH tunnel setups",
- "features": [
- "• Top-level error handler ensures an HTTP response is always sent (no more ERR_EMPTY_RESPONSE)",
- "• All callback logging now goes to app.log (was only in Docker stdout before)",
- "• Health check at http://localhost:8888/ to verify the callback server is running",
- "• Startup logs the actual bind address for diagnosing port conflicts",
- "• Port-in-use errors now logged clearly with explanation"
- ]
- },
- {
- "title": "Show All Services on Dashboard (#219)",
- "description": "Dashboard now shows connection status for all external services, not just the core three",
- "features": [
- "• Enrichment services shown as color-coded chips below core service cards",
- "• API call counts per service: 1-hour and 24-hour windowed totals shown on each chip",
- "• Spotify chip includes daily budget bar (used/3000) with color-coded fill",
- "• Unconfigured services show dashed border — click to jump directly to their Settings section",
- "• All configurable services clickable — navigates to Settings → Connections and scrolls to the service",
- "• Spotify card always labeled 'Spotify' — no longer confusingly switches to 'Apple Music'",
- "• Fallback state (using iTunes/Deezer) shown with amber indicator when Spotify is not connected"
- ]
- },
- {
- "title": "Add Qobuz to Connections Tab (#218)",
- "description": "Qobuz credentials now available on the Connections tab for metadata enrichment",
- "features": [
- "• New Qobuz section on Settings → Connections tab for enrichment auth",
- "• Users can connect Qobuz for metadata enrichment regardless of download source",
- "• Auth status syncs between Connections and Downloads tabs"
- ]
- },
- {
- "title": "Fix Enrichment Widget Showing 'Running' When Rate Limited",
- "description": "Enrichment tooltip now shows Rate Limited or Daily Limit Reached instead of stuck on Running",
- "features": [
- "• Shows 'Rate Limited' with countdown when Spotify rate limit is active",
- "• Shows 'Daily Limit Reached' with reset time when daily budget is exhausted",
- "• Shows 'Waiting for next item...' instead of blank when no current item"
- ]
- },
- {
- "title": "Metadata Cache Maintenance",
- "description": "The cache evictor now runs four maintenance phases to keep the metadata cache clean",
- "features": [
- "• Input validation prevents junk entities (Unknown Artist, empty names) from being cached",
- "• Junk cleanup removes existing placeholder entries from the cache",
- "• Orphan cleanup removes search results pointing to deleted entities",
- "• MusicBrainz null cleanup removes failed lookups after 30 days (was 90) so they get retried",
- "• Health stats available in the repair dashboard"
- ]
- },
- {
- "title": "Fix Wishlist Download Selection Ignoring Checkboxes",
- "description": "Download Selection now respects which tracks are checked in the wishlist overview",
- "features": [
- "• Selected track IDs are collected before closing the overview modal",
- "• Only checked tracks are sent to the download analysis board",
- "• If nothing is checked, downloads the full category (same as before)"
- ]
- },
- {
- "title": "Fix Tidal OAuth Redirect URI in Docker",
- "description": "Tidal OAuth now uses the configured redirect URI instead of the Docker container hostname",
- "features": [
- "• Respects the redirect URI set in Settings instead of overriding with request hostname",
- "• Falls back to dynamic host detection only if no redirect URI is configured",
- "• Fixes Tidal authentication failing in Docker due to internal hostname in OAuth URL"
- ]
- },
- {
- "title": "High-Resolution Cover Art from Cover Art Archive",
- "description": "Album art now sourced from Cover Art Archive when available — often 1200x1200+ original quality",
- "features": [
- "• Tries Cover Art Archive first using MusicBrainz release ID (full resolution)",
- "• Falls back to Spotify/iTunes/Deezer URL (640x640) if CAA unavailable",
- "• Source ID embedding now runs before art embedding to make release ID available"
- ]
- },
- {
- "title": "Embedded Lyrics in Audio Files",
- "description": "Lyrics are now embedded directly in audio file tags alongside the .lrc sidecar file",
- "features": [
- "• Lyrics embedded as USLT (MP3), lyrics (FLAC/OGG), or ©lyr (M4A) tags",
- "• Navidrome, Jellyfin, and Plex can now display lyrics without .lrc file support",
- "• .lrc sidecar files are still created for compatibility with other players"
- ]
- },
- {
- "title": "Fix AcoustID False Positives for Non-English Tracks",
- "description": "AcoustID no longer quarantines correct files when titles are in different languages",
- "features": [
- "• High-confidence fingerprint matches (95%+) now SKIP instead of FAIL when title/artist don't match",
- "• Prevents Japanese, Chinese, Korean, and other non-Latin tracks from being falsely quarantined",
- "• Audio fingerprint confirms the recording is correct — metadata mismatch is just a language difference"
- ]
- },
- {
- "title": "Fix Soulseek Junk Tags Surviving Post-Processing",
- "description": "Tags from Soulseek source files are now wiped to disk immediately, before metadata enhancement",
- "features": [
- "• Clears and saves tags before any API calls or metadata extraction",
- "• If enhancement fails, file has clean empty tags instead of inconsistent junk",
- "• Fixes album fragmentation in Navidrome/Jellyfin/Plex caused by partial MusicBrainz data",
- "• Happy path unchanged — full metadata still written on success"
- ]
- },
- {
- "title": "Watch All Unwatched Preview Modal",
- "description": "The Watch All Unwatched button now opens a modal showing exactly which artists will be added",
- "features": [
- "• Preview list shows all eligible artists with images, track counts, and matched sources",
- "• Clear separation of eligible vs ineligible artists (no external ID)",
- "• Collapsible section explains why some artists can't be added yet",
- "• Confirm before adding — no more silent 'Added 0' surprises",
- "• Results summary shown after completion"
- ]
- },
- {
- "title": "Fix Watch All Unwatched Skipping Deezer Artists",
- "description": "Watch All Unwatched now supports Deezer as an ID source",
- "features": [
- "• Added Deezer ID support to the bulk watchlist add flow",
- "• Source detection based on actual ID field used instead of numeric heuristic",
- "• Fallback chain: active source first, then Spotify, iTunes, Deezer"
- ]
- },
- {
- "title": "Fix Library Maintenance Path Fixes Failing Silently",
- "description": "Path mismatch fixes now use fresh config and report errors to the UI",
- "features": [
- "• Output folder path is re-read from config before each fix attempt",
- "• Fix failure reasons are now shown in the toast notification",
- "• Bulk fix failures are logged individually with finding ID and error details"
- ]
- },
- {
- "title": "Fix Spotify Manual Match Storing Wrong IDs",
- "description": "Manual match modals no longer store iTunes/Deezer IDs in Spotify ID columns",
- "features": [
- "• Detects actual provider from result IDs — Spotify IDs are alphanumeric, iTunes/Deezer are numeric",
- "• Match button now stores IDs in the correct service column (itunes_artist_id vs spotify_artist_id)",
- "• Results show provider label when falling back (e.g. 'ID: 312095 (itunes)')",
- "• Fixes broken Spotify links on artist pages caused by stored iTunes IDs"
- ]
- },
- {
- "title": "Spotify Enrichment Daily Budget",
- "description": "The background enrichment worker now caps itself at 3,000 items per day to prevent rate limit bans",
- "features": [
- "• Worker-only daily budget — user-initiated searches, playlist operations, etc. are unaffected",
- "• Counter resets automatically at midnight each day",
- "• Worker sleeps when budget is exhausted and resumes the next day",
- "• Budget status exposed in the enrichment worker dashboard widget"
- ]
- },
- {
- "title": "Deezer Download Source",
- "description": "Download music directly from Deezer with ARL authentication",
- "features": [
- "• New download source: Deezer joins Soulseek, YouTube, Tidal, Qobuz, and HiFi",
- "• FLAC lossless, MP3 320, and MP3 128 with automatic quality fallback",
- "• ARL token authentication — paste from browser cookies, test connection in Settings",
- "• Full hybrid mode support — use Deezer as primary, fallback, or in any priority order",
- "• Blowfish CBC decryption handles Deezer's encrypted streams transparently",
- "• AcoustID verification automatically skipped for Deezer (and Tidal/Qobuz/HiFi) — trusted API sources"
- ]
- },
- {
- "title": "Cache-Powered Discovery",
- "description": "Five new discover sections mined from your metadata cache — zero API calls",
- "features": [
- "• Undiscovered Albums: albums by your most-played artists that aren't in your library",
- "• New In Your Genres: recently released albums matching your top genres",
- "• From Your Labels: popular albums on labels already in your library",
- "• Deep Cuts: low-popularity tracks from artists you listen to — find the hidden gems",
- "• Genre Explorer: genre landscape pills with artist counts — tap to deep dive",
- "• All data sourced from local metadata cache — instant, no API rate limits"
- ]
- },
- {
- "title": "Genre Deep Dive Modal",
- "description": "Tap any genre pill to explore artists, tracks, and albums in that genre",
- "features": [
- "• Artists section with scaled avatars — top artist gets largest, 'In Library' badges",
- "• Click any artist → navigates directly to their page on the Artists tab",
- "• Popular tracks list with album art, duration, click to open album download modal",
- "• Albums carousel with 'In Library' badges and full download flow on click",
- "• Related genres pills — click to seamlessly switch to a sibling genre",
- "• Header shows counts: '12 artists · 15 tracks · 20 albums'",
- "• Accent gradient header with animated light sweep"
- ]
- },
- {
- "title": "Database Storage Visualization",
- "description": "See how your database space is distributed across tables",
- "features": [
- "• Donut chart on Stats page showing storage breakdown by table",
- "• Uses SQLite dbstat for real byte sizes, falls back to row counts",
- "• Top 8 tables shown individually, rest grouped as 'Other'",
- "• Center label shows total database file size"
- ]
- },
- {
- "title": "Library Page Performance",
- "description": "Library artist grid loads significantly faster with smoother animations",
- "features": [
- "• innerHTML batch rendering replaces per-card DOM manipulation — near-instant grid population",
- "• Database query split into 3 steps: paginate first, then batch-fetch counts for visible page only",
- "• Event delegation — single click listener instead of 75+ individual handlers",
- "• Staggered card fade-in animation on page load"
- ]
- },
- {
- "title": "Per-Artist Enrichment Rings",
- "description": "See metadata coverage for each artist on their detail page",
- "features": [
- "• SVG ring indicators for all 9 enrichment services below the album/EP/singles bars",
- "• Rings animate on page load with staggered fill-in effect",
- "• Hover glow in each service's brand color",
- "• Stats page enrichment coverage also expanded to all 9 services"
- ]
- },
- {
- "title": "Mobile Responsive Overhaul",
- "description": "Comprehensive mobile layout fixes across all pages",
- "features": [
- "• Stats, Automations, Hydrabase, Issues, Help pages now fully mobile responsive",
- "• Artist hero section stacks properly with compact image, wrapping badges, bio clamp",
- "• Enhanced library track table: action columns collapse into iOS-style bottom sheet popover",
- "• Genre explorer, enrichment rings, filter bars all adapt to narrow screens"
- ]
- },
- {
- "title": "Album Split Fix (Navidrome)",
- "description": "Prevent deluxe/standard editions from splitting into separate albums",
- "features": [
- "• MusicBrainz release cache key normalized — strips edition suffixes (Deluxe, Remastered, etc.)",
- "• First track's MBID locked in for all subsequent tracks in the same album",
- "• Handles both parenthetical '(Deluxe Edition)' and bare 'Deluxe Edition' suffixes",
- "• Opus bitrate capped at 256kbps to prevent encoding failures"
- ]
- },
- {
- "title": "Picard-Style Album Tagging",
- "description": "All tracks in an album now get the same MusicBrainz release ID automatically",
- "features": [
- "• Pre-flight MB release lookup before album tracks start downloading",
- "• Picks ONE release, validates track count, caches for all tracks in the batch",
- "• Strips Spotify edition suffixes (Super Deluxe, Remastered) for better MB matching",
- "• New Album Tag Consistency repair job: scan and fix existing albums with mismatched tags"
- ]
- },
- {
- "title": "Enrichment & Repair Fixes",
- "description": "Critical fixes for background workers and maintenance jobs",
- "features": [
- "• All 9 enrichment workers: error status items no longer auto-retry in infinite loops",
- "• Cover art filler: findings no longer recreated after being fixed",
- "• Spotify rate limit respected by search_tracks, search_albums, and cover art scanner",
- "• Config save: 30s timeout + WAL mode fixes 'database is locked' on busy systems",
- "• Enrichment workers auto-pause during DB scans and resume when complete"
- ]
- },
- {
- "title": "Automation Signal Chain Fix",
- "description": "Event-triggered automations now receive playlist context properly",
- "features": [
- "• playlist_id forwarded from events to action handlers (fixes silent 'No playlist specified')",
- "• Mirrored playlist discovery no longer pre-marks tracks as discovered with wrong album art",
- "• Reorganize modal now loads saved path template instead of hardcoded default",
- "• Spotify enrichment worker starts unpaused by default like all other workers"
- ]
- },
- {
- "title": "Unified Glass UI Redesign",
- "description": "Consistent visual style across all cards, modals, and buttons",
- "features": [
- "• Dashboard tool cards, service cards, and stat cards: unified glass style",
- "• Sync page playlist cards: all sources (Spotify, YouTube, Tidal, Deezer, Mirrored, Beatport)",
- "• Download missing and wishlist modals: cleaner backgrounds, softer shadows",
- "• Watchlist and enhance quality buttons: glass hover with accent glow",
- "• Library page: innerHTML rendering + staggered card animation for faster loads"
- ]
- },
- {
- "title": "Scrobbling to Last.fm & ListenBrainz",
- "description": "Automatically scrobble your plays from Plex, Jellyfin, or Navidrome",
- "features": [
- "• Listen on your media server — SoulSync automatically scrobbles to Last.fm and/or ListenBrainz",
- "• Last.fm: full web auth flow, ListenBrainz: simple token-based",
- "• Batch scrobbling with dedup tracking — events only scrobbled once"
- ]
- },
- {
- "title": "Personalized Discovery + Listening Stats",
- "description": "Discovery playlists use your listening history, plus a full stats dashboard",
- "features": [
- "• Release Radar, Discovery Weekly, and Because You Listen To: personalized by play history",
- "• Listening Stats page: timeline chart, genre breakdown, top artists/albums/tracks",
- "• Database storage donut chart in Library Health section",
- "• Play buttons on stats page tracks with cover art"
- ]
- },
- {
- "title": "Interactive Help System",
- "description": "Full contextual help platform accessible from the floating ? button",
- "features": [
- "• 200+ contextual help entries — click any UI element to learn what it does",
- "• 11 guided tours covering every page (97 steps total) with spotlight overlay",
- "• Page-aware menu suggests the relevant tour for your current page",
- "• Search across all help topics, tours, and keyboard shortcuts (Ctrl+K)",
- "• Setup Progress tracker with auto-detection — checks your services, library, and watchlist",
- "• What's New panel with version-tagged highlights and 'Show me' navigation",
- "• Troubleshoot mode scans for disconnected services and shows fix steps",
- "• Keyboard shortcut overlay showing all hotkeys grouped by scope",
- "• Quick action buttons in popovers (e.g., 'Open Settings' on service cards)",
- "• First-launch welcome prompt for new users"
- ]
- },
- {
- "title": "Rich Artist Profiles",
- "description": "Full-bleed hero section on the Artists page with deep metadata",
- "features": [
- "• Large portrait image with blurred background, glassmorphic design",
- "• Bio, genres, listening stats from Last.fm, service logo badges",
- "• Multi-source genre explorer with Deezer genre support",
- "• Similar artist cards with full-bleed library-card styling"
- ]
- },
- {
- "title": "Enhanced Library Manager",
- "description": "Inline metadata editing and tag writing from the library view",
- "features": [
- "• Toggle between Standard and Enhanced view on any artist detail page",
- "• Inline-edit track title, number, BPM; album and artist fields editable",
- "• Write tags directly to audio files (MP3, FLAC, OGG, M4A) with diff preview",
- "• Bulk select tracks across albums for batch edit and batch tag write",
- "• Server sync after writes — Plex per-track, Jellyfin library scan"
- ]
- },
- {
- "title": "In Library Badges + Search Improvements",
- "description": "Know what you already own before downloading",
- "features": [
- "• 'In Library' badges on enhanced search album and track results",
- "• Async post-render matching — search results appear instantly, badges fill in",
- "• Multi-source search tabs: compare results from Spotify, iTunes, and Deezer",
- "• Clickable artist name in download modal navigates to discography"
- ]
- },
- {
- "title": "FLAC Bit Depth + Quality Filter",
- "description": "Finer control over audio quality preferences",
- "features": [
- "• Quality profile enforces 16-bit vs 24-bit FLAC preference",
- "• Bit depth fallback option: accept other bit depth if preferred unavailable",
- "• 1450 kbps threshold separates 16-bit from 24-bit FLAC",
- "• Sort prioritizes audio quality (effective kbps) over peer speed"
- ]
- },
- {
- "title": "Enrichment Worker Improvements",
- "description": "Better name matching and quieter logs across all 8+ workers",
- "features": [
- "• Dash-suffix normalization: 'Title - Remix' now matches 'Title (Remix)' across all workers",
- "• AcoustID log noise reduced — individual recording matches moved to DEBUG",
- "• Streaming source verification: artist/title fuzzy match prevents wrong track downloads",
- "• Deezer enrichment worker caches API calls through metadata cache",
- "• Per-source quality fallback toggles for streaming download sources"
- ]
- },
- {
- "title": "Launch PIN Lock Screen",
- "description": "Protect SoulSync access with a PIN on every page load",
- "features": [
- "• Toggle in Settings → Advanced → Security to require PIN on launch",
- "• Full-screen lock overlay with PIN input — closing the tab requires re-entry",
- "• PIN validated server-side against admin profile (bcrypt hashed)",
- "• Inline PIN creation if admin has no PIN set",
- "• Shake animation on wrong PIN, auto-focus input"
- ]
- },
- {
- "title": "Stream Source Setting",
- "description": "Choose where track previews come from — independent of download source",
- "features": [
- "• New dropdown in Settings → Downloads: YouTube (instant, default) or Active Download Source",
- "• If active source is Soulseek, automatically falls back to YouTube",
- "• YouTube streams require no auth — instant playback"
- ]
- },
- {
- "title": "YouTube Download Fix",
- "description": "Fixed 'Requested format not available' errors affecting all YouTube downloads",
- "features": [
- "• Removed stale player_client and HLS/DASH skip overrides that blocked audio formats",
- "• Browser cookie fallback — retries without cookies when authenticated sessions restrict formats",
- "• Docker containers auto-update yt-dlp on every start"
- ]
- },
- {
- "title": "Accurate Album Completion Badges",
- "description": "Album completion now uses exact track counts instead of percentage rounding",
- "features": [
- "• Exact match: 'Complete' only when all tracks are present — no more 90% rounding",
- "• Deduplicated counting: duplicate album entries don't inflate track counts",
- "• Multi-artist album detection: finds albums filed under different artists in your library",
- "• Censored title matching: 'B*****t Faucet' now matches 'Bullshit Faucet' (Apple Music)"
- ]
- },
- {
- "title": "Collaborative Album Handling",
- "description": "Smart folder naming and matching for albums with multiple artists",
- "features": [
- "• New setting: Collaborative Album Artist — use first listed artist or all combined",
- "• Spotify: picks first from separate artist objects. Deezer: already first-only",
- "• iTunes: resolves primary artist via artistId API lookup (safe for 'Tyler, the Creator')",
- "• Album-aware track matching prevents re-downloads of collab albums filed under different artists"
- ]
- },
- {
- "title": "Per-Artist Library Sync",
- "description": "Validate and clean up individual artist library entries",
- "features": [
- "• New 'Sync' button on enhanced library view",
- "• Checks each track's file exists on disk, removes stale entries",
- "• Cleans empty albums and updates track counts",
- "• Per-artist watchlist lookback period override"
- ]
- },
- {
- "title": "Stability & Bug Fixes",
- "description": "Various fixes for crashes, data integrity, and UX",
- "features": [
- "• Enrichment worker pause state persists across restarts",
- "• Soulseek timeout spam prevention — skips API calls when disconnected",
- "• Navidrome playlist sync uses POST (fixes truncation on large playlists)",
- "• Deezer metadata cache no longer serves stale data missing track numbers/year",
- "• Track numbering fix for non-Spotify metadata sources (Deezer/iTunes)",
- "• Album delete endpoint accepts all ID formats (fixes Navidrome string IDs)",
- "• Hydrabase auto-reconnect when server restarts",
- "• Wishlist process API endpoint for external apps"
- ]
- }
-""" # end of _OLD_V22_NOTES
-
-_OLD_V2_NOTES = r"""
- "features": [
- "• Generates soul IDs using SHA-256 hash of normalized names",
- "• Artists: hash(name + debut_year) — debut year from iTunes + Deezer API verification",
- "• Albums: hash(artist + album), Tracks: dual IDs (song + album-specific)",
- "• Dashboard worker button with rainbow spinner and hover tooltip",
- "• SoulSync badge on library artist cards when matched"
- ]
- },
- {
- "title": "Lossy Codec Expansion + Retroactive Converter",
- "description": "Opus and AAC support for post-download conversion, plus a repair job for existing files",
- "features": [
- "• Lossy copy now supports MP3, Opus, and AAC (M4A) — configurable codec and bitrate",
- "• Opus: -map 0:a for clean audio extraction, cover art embedded via METADATA_BLOCK_PICTURE",
- "• AAC: MP4Cover embedding, -movflags +faststart for streaming optimization",
- "• New Lossy Converter repair job: scans FLAC library, creates findings, Fix/Fix All converts",
- "• Job reads codec/bitrate from current settings at fix time (change settings after scanning)",
- "• Independent Blasphemy Mode toggle per job (separate from download-time setting)"
- ]
- },
- {
- "title": "Smarter Staging Import",
- "description": "Tag-first matching and auto-grouping for the import workflow",
- "features": [
- "• Tags take priority over filename parsing — no more '08' as artist name",
- "• Auto-detected album groups from file tags shown as one-click import cards",
- "• Match scoring rebalanced: title (0.45) + artist (0.15) + track# (0.30) + album bonus (0.10)",
- "• Filename parser pattern order fixed — track numbers no longer misidentified as artists"
- ]
- },
- {
- "title": "Library Artist Hero Redesign",
- "description": "Expanded artist detail section with Last.fm integration",
- "features": [
- "• Horizontal service badge row with hover lift animations",
- "• Last.fm bio with Read More toggle, listener/play count stats",
- "• Scrollable top 100 tracks from Last.fm in sidebar card",
- "• Last.fm tags merged with existing genres",
- "• Compact inline progress bars for Albums/EPs/Singles completion"
- ]
- },
- {
- "title": "Hydrabase Search & Routing",
- "description": "Hydrabase shows as a search tab with proper ID routing",
- "features": [
- "• Hydrabase appears as a source tab on enhanced search when connected",
- "• Plugin-aware ID routing: numeric IDs → iTunes, alphanumeric → Spotify",
- "• Artist images fetched from iTunes for Hydrabase results",
- "• Full Spotify-compatible interface: get_album, get_artist, get_track_details"
- ]
- },
- {
- "title": "Orphan File Detector + MusicBrainz Fixes",
- "description": "Better orphan detection and album version matching",
- "features": [
- "• Orphan detector: normalized tag matching strips feat./parentheticals to reduce false positives",
- "• Orphan fix now prompts 'Move to Staging' or 'Delete' instead of auto-deleting",
- "• MusicBrainz release matching: version qualifier scoring prevents deluxe → standard MBID mismatch",
- "• Playlist sync crash fixed: profile ID captured at request time, not in background thread"
- ]
- },
- {
- "title": "Release Year Collection",
- "description": "Post-processing now collects release year from all metadata sources",
- "features": [
- "• Year extracted from MusicBrainz, Deezer, Tidal, Qobuz during post-processing",
- "• First source to find a year wins — written to ORIGINALDATE/DATE tags and album DB year",
- "• Library Reorganize API year lookup cap raised from 50 to 200"
- ]
- },
- {
- "title": "Multi-Source Search Tabs",
- "description": "View search results from Spotify, iTunes, and Deezer side by side",
- "features": [
- "• Enhanced search now fires parallel queries against all available metadata sources",
- "• Switchable tabs above results — click to view results from Spotify, Apple Music, or Deezer",
- "• Tabs load progressively — primary source shows instantly, alternates appear as they complete",
- "• Click an artist or album from any tab to browse that source's data temporarily",
- "• Downloads use the metadata from whichever source tab you're viewing",
- "• Similar artists always use your primary source — no accidental cross-source mixing"
- ]
- },
- {
- "title": "Per-Profile Service Credentials",
- "description": "Each profile can connect their own Spotify, Tidal, and media server library",
- "features": [
- "• Non-admin profiles can enter their own Spotify credentials and authenticate their own account",
- "• Per-profile Tidal authentication — connect your own Tidal account through the shared app",
- "• Per-profile media server library selection — choose which Plex library or Jellyfin user playlists sync to",
- "• Tabbed personal settings modal: Music Services, Server, and Scrobbling tabs",
- "• Server tab auto-detects active server and shows library name dropdowns (not raw IDs)",
- "• All credentials encrypted. Admin users see zero change — fully backwards compatible"
- ]
- },
- {
- "title": "Modern Settings Redesign",
- "description": "Settings page rebuilt with tabbed single-column layout",
- "features": [
- "• Horizontal tab bar: Connections, Downloads, Library, Appearance, Advanced",
- "• Single centered column replaces 3-column wall of cards",
- "• Clean row layout — label left, control right",
- "• Custom styled dropdowns with SVG arrows and hover states",
- "• Mobile responsive — rows stack, tab bar scrolls"
- ]
- },
- {
- "title": "Hybrid N-Source Download Priority",
- "description": "Hybrid mode now supports all 5 download sources with drag-to-reorder priority",
- "features": [
- "• Enable/disable any combination of Soulseek, YouTube, Tidal, Qobuz, and HiFi",
- "• Up/down arrows to reorder source priority — downloads try each enabled source in order",
- "• Source icons with toggle switches and priority numbers",
- "• Configurable download timeout and max peer queue length for Soulseek",
- "• Peer quality (upload speed, free slots, queue length) now factors into result ranking"
- ]
- },
- {
- "title": "Automation Hub Pipelines",
- "description": "One-click deployment of multi-automation pipelines",
- "features": [
- "• 11 pre-built pipelines: Release Radar, Discovery Weekly, Playlist Auto-Sync, Nightly Operations, and more",
- "• Each pipeline deploys 2-5 linked automations with signal chaining in one click",
- "• Visual pipeline cards with connected flow nodes and accent-colored design",
- "• Pipeline detail modal shows full WHEN/DO/THEN breakdown for each automation",
- "• Deploy prompts for notification config (Discord/Telegram/Pushbullet) when pipeline includes alerts"
- ]
- },
- {
- "title": "Staging Folder Pre-Download Check",
- "description": "Check your import folder for existing files before downloading",
- "features": [
- "• Before searching Soulseek/YouTube, checks the import folder for a matching file",
- "• Tag-based matching (Mutagen) with filename parsing fallback",
- "• On match, copies the file to transfer and runs normal post-processing",
- "• Staging scan cached per batch — only scans once for the entire download"
- ]
- },
- {
- "title": "Library Safety & Repair Fixes",
- "description": "Critical fixes for library maintenance jobs and safety guards",
- "features": [
- "• Mass orphan safety guard — 'witness me' confirmation required when >50% of files flagged as orphans",
- "• Tag-based orphan fallback — reads file metadata before marking as orphan to prevent false positives",
- "• Album Completeness expanded to support iTunes and Deezer (was Spotify-only)",
- "• Album Completeness min completion % filter — skip playlist imports, catch real failed downloads",
- "• Fix Track Number Repair returning 400 on fix (entity_id was NULL for file-based findings)",
- "• Fix Library Reorganize producing (_) in paths when year is empty",
- "• Fix Fix All ignoring Single/Album Dedup findings",
- "• Fix enrichment workers looping infinitely on tracks with NULL IDs",
- "• Fix Tidal token refresh hammering API when credentials removed",
- "• Fix YouTube playlist parsing capped at ~100 tracks",
- "• Allow re-sync from download_complete state with Rediscover button"
- ]
- },
- {
- "title": "Deezer Metadata Source",
- "description": "Deezer added as a configurable free metadata fallback alongside iTunes/Apple Music",
- "features": [
- "• New setting to choose between iTunes and Deezer as your fallback metadata source — switch anytime from Settings",
- "• All metadata lookups, watchlist scans, discovery, and enrichment seamlessly use whichever fallback is configured",
- "• On-the-fly artist ID resolution — switching sources auto-matches existing watchlist artists by name on the next scan",
- "• Source badges on watchlist artist cards show which services (Spotify, iTunes, Deezer) each artist is matched to",
- "• Full backward compatibility — existing iTunes users experience zero changes on upgrade",
- "• Name-based duplicate detection prevents adding the same artist twice across different metadata sources"
- ]
- },
- {
- "title": "Library History",
- "description": "Persistent record of every download and server import — viewable from the dashboard",
- "features": [
- "• History button next to Recent Activity opens a modal with Downloads and Server Imports tabs",
- "• Every completed SoulSync download is logged with title, artist, album, quality, and cover art",
- "• Every new track imported from Plex, Jellyfin, or Navidrome is logged automatically",
- "• Paginated browsing with tab count badges and relative timestamps",
- "• History persists across restarts — unlike the in-memory activity feed"
- ]
- },
- {
- "title": "MusicBrainz MBID Mismatch Repair",
- "description": "New repair job to detect and fix wrong MusicBrainz recording IDs on library tracks",
- "features": [
- "• Detects tracks where the stored MusicBrainz recording ID resolves to a different title than expected",
- "• Fix action clears the bad MBID so enrichment can re-match correctly",
- "• Also fixes MusicBrainz recording matching returning wrong titles due to unstable MBID lookups"
- ]
- },
- {
- "title": "HiFi Download Source",
- "description": "Free lossless downloads via public hifi-api instances — no account or subscription required",
- "features": [
- "• New download mode alongside Soulseek, YouTube, Tidal, and Qobuz — select HiFi Only or use in hybrid mode",
- "• Quality selection: Hi-Res, Lossless, High, or Low with automatic fallback chain (hires → lossless → high → low)",
- "• Automatic instance rotation across 6 public API servers — any server error triggers failover to the next instance",
- "• Full search, download, streaming, and post-processing support — works identically to other download sources",
- "• Test connection button in Settings to verify instance availability"
- ]
- },
- {
- "title": "Spotify Link (No API Credentials)",
- "description": "Scrape Spotify playlists and albums by URL without needing Spotify API credentials",
- "features": [
- "• New Spotify Link tab on the playlist sync page — paste any public Spotify playlist or album URL",
- "• Extracts all track metadata (title, artist, album, duration, cover art) via web scraping",
- "• Works without Spotify client ID/secret — great for users who don't want to set up a Spotify developer app",
- "• Full download and sync support — tracks are matched and downloaded like any other playlist source"
- ]
- },
- {
- "title": "Library Maintenance Suite",
- "description": "Full-featured library repair system with 9 automated jobs, fix actions, and rich findings UI",
- "features": [
- "• 9 repair jobs: track number mismatch, dead files, duplicates, metadata gaps, album completeness, missing cover art, AcoustID scanner, orphan files, fake lossless detection",
- "• One-click fix actions for findings — remove dead entries, delete orphans, resolve duplicates, apply metadata, update track numbers",
- "• Findings dashboard with per-job filter chips, summary stats, and expandable detail panels",
- "• Album art and artist images displayed in findings with labeled media cards",
- "• Real-time progress on job cards via WebSocket — phase, log lines, and per-item activity",
- "• Visual detail renderers: cover art previews, KEEP/REMOVE badges for duplicates, completion progress bars, spectral analysis for fake lossless",
- "• Job help text modals explaining what each repair job checks and how to interpret findings"
- ]
- },
- {
- "title": "Post-Processing Enhancements",
- "description": "Granular control over post-processing and richer file tagging",
- "features": [
- "• Granular toggles for each post-processing step — enable/disable metadata services, cover art, and lyrics individually",
- "• Embed Tidal, Qobuz, Last.fm, and Genius metadata directly into audio file tags during post-processing",
- "• FLAC bit depth fallback option in quality profiles — accept lower bit depth when preferred isn't available"
- ]
- },
- {
- "title": "Per-Profile ListenBrainz",
- "description": "Each profile can connect their own ListenBrainz account for personalized playlists",
- "features": [
- "• Personal settings modal with ListenBrainz connect/disconnect flow",
- "• Per-profile playlist caching — switching profiles shows that user's playlists",
- "• Graceful fallback to global ListenBrainz token when no personal token is set",
- "• Stale playlist cache recovery for interrupted syncs"
- ]
- },
- {
- "title": "Quality Enhance",
- "description": "Upgrade existing library tracks to higher quality versions",
- "features": [
- "• Quality enhance button on library tracks — find and download a higher quality version",
- "• iTunes fallback for quality enhance when Spotify metadata isn't available",
- "• Full metadata source parity between Spotify and iTunes for upgrade searches"
- ]
- },
- {
- "title": "Hi-Res FLAC Downsampling",
- "description": "Automatically convert 24-bit hi-res downloads to 16-bit/44.1kHz CD quality",
- "features": [
- "• New toggle in Settings → Post-Download Conversion: downsample hi-res FLAC to CD quality after download",
- "• Converts 24-bit and/or high sample rate FLAC files to 16-bit/44.1kHz — saves ~50% disk space with no audible difference",
- "• Safe in-place replacement: writes to temp file, verifies output, then atomic swap — original untouched on failure",
- "• Runs before lossy copy so MP3s are created from the downsampled version when both are enabled",
- "• Automatically updates $quality in filenames and QUALITY tags after conversion",
- "• Overrides strict bit depth rejection — files are accepted and converted instead of quarantined"
- ]
- },
- {
- "title": "Recent Bug Fixes & Improvements",
- "description": "Stability fixes, UX improvements, and edge case handling",
- "features": [
- "• Fix $year template variable empty for playlist/sync downloads — album metadata now backfilled from Spotify API",
- "• Fix dead file cleaner reporting 66k+ false positives — transfer path fell back to ./Transfer under DB contention",
- "• Fix library reorganize not updating database paths after moving files — suffix-based matching with SQL LIKE escaping",
- "• Fix library reorganize not moving cover.jpg and other album-level sidecar files with tracks",
- "• Fix orphaned sidecar files left behind after reorganize — post-pass sweep moves remaining non-audio files",
- "• Fix Navidrome library scan was a no-op — now triggers actual scan via Subsonic startScan API",
- "• Select All, Fix Selected, and Fix All bulk actions for Library Maintenance findings",
- "• Fix empty brackets in folder names ($year, $quality etc.) not being cleaned when template variables resolve to empty",
- "• Fix missing album cover art in download progress bubbles for redownload and issue modal downloads",
- "• Cancel button for watchlist scans — stop manual or automation-triggered scans mid-run",
- "• Fix HiFi client not failing over to next instance on HTTP 500 — previously only 502/503/504 triggered rotation",
- "• Fix service status labels missing HiFi and Qobuz display names",
- "• Redownload button on enhanced library view — re-download any album directly from the library manager",
- "• Hemisphere setting for seasonal playlists — southern hemisphere users get correct seasonal recommendations",
- "• Play button on repair findings — preview tracks directly from the maintenance findings list",
- "• Spotify rate limit guards added to all repair jobs — prevents ban escalation during library maintenance",
- "• Fix watchlist migration dropping profile_id & fix profile delete dialog hidden behind overlay",
- "• Fix watchlist NOT NULL constraint blocking iTunes-only artists from being added",
- "• Fix Windows path mangling for artist names with trailing dots (e.g. Fred again..)",
- "• Fix watchlist scan failing entirely when Spotify is rate limited — iTunes provider fallback added",
- "• Fix per-profile ListenBrainz playlist cache scoping and stale data recovery",
- "• Harden metadata cache — prevent simplified data from overwriting full entries, fix connection leaks",
- "• Scope automation-triggered watchlist scans to the calling profile",
- "• Fix watchlist scan silently skipping all albums due to metadata cache returning incomplete data",
- "• Optimized enhanced library view performance with event delegation and scoped DOM queries",
- "• Fix Qobuz and HiFi streaming source checks that blocked playback with 'format not supported' error"
- ]
- },
- {
- "title": "Library Issue Reporting",
- "description": "Report and track issues for tracks, albums, and artists directly from the library",
- "features": [
- "• Report issues on any library item — tracks, albums, or artists — with category, priority, and notes",
- "• Actionable issue detail modal with album art, artist photo overlay, genre tags, and format badges",
- "• Download Album and Add to Wishlist buttons directly from the issue modal (admin only)",
- "• Enhanced-library-style track listing with format and bitrate indicators",
- "• Smart album fetch — uses Spotify ID when available, falls back to enhanced search"
- ]
- },
- {
- "title": "Album File Reorganization",
- "description": "Reorganize album files on disk from the Enhanced Library Manager",
- "features": [
- "• Move and rename album files to match your configured folder template",
- "• Preview the reorganization with before/after file paths before applying",
- "• Supports multi-disc albums with automatic disc subfolder creation",
- "• Database paths updated automatically after files are moved"
- ]
- },
- {
- "title": "Interactive REST API Docs",
- "description": "Full API documentation with a built-in endpoint tester",
- "features": [
- "• Comprehensive docs for all API endpoints organized by category",
- "• Built-in endpoint tester — execute API calls directly from the docs page",
- "• JSON response viewer with syntax highlighting and copy support",
- "• Complete metadata serialization for all entity types"
- ]
- },
- {
- "title": "Watchlist Improvements",
- "description": "Smarter cross-provider matching, manual artist linking, and scan timestamp fixes",
- "features": [
- "• Cross-provider artist matching now uses fuzzy name comparison instead of blindly taking the first result",
- "• Manual artist linking UI — change the linked Spotify/iTunes artist from the watchlist config modal",
- "• Mismatch warning when the linked provider artist name differs from the watchlist entry",
- "• Watchlist settings gear button accessible from artist detail page and artist cards",
- "• Scan timestamps preserved for UI display — 'Never scanned' no longer shows after lookback changes",
- "• Lookback period changes use a one-time rescan flag instead of wiping all timestamps"
- ]
- },
- {
- "title": "AcoustID Verification Fix",
- "description": "More accurate audio file verification with broader title normalization",
- "features": [
- "• Strip ALL parentheticals in title normalization — fixes false mismatches for parody, soundtrack, and featured artist suffixes",
- "• Previously only whitelisted suffixes like (Live) and (Remastered) were stripped"
- ]
- },
- {
- "title": "Deezer Playlist Sync",
- "description": "Full Deezer integration for playlist sync alongside Spotify, Tidal, and YouTube",
- "features": [
- "• Import and sync Deezer playlists with full track matching and discovery",
- "• Deezer discovery worker with Spotify/iTunes match caching",
- "• Fix modal for unmatched Deezer tracks with manual search",
- "• Manual fixes persist to discovery cache across restarts"
- ]
- },
- {
- "title": "Discovery Page Improvements",
- "description": "Better playlist generation, caching, and iTunes parity",
- "features": [
- "• iTunes discovery playlists now produce quality results — synthetic popularity scoring replaces broken 0-popularity tiering",
- "• EPs included in iTunes discovery pool (previously excluded)",
- "• Popular Picks and Hidden Gems playlists now work correctly for iTunes users",
- "• Seasonal playlists fully work with iTunes — album search, watchlist search, and track fetching",
- "• Similar artist metadata (images, genres, popularity) cached at scan time — no more redundant API calls",
- "• Hero slider loads instantly from cache instead of making 10 Spotify API calls per page load",
- "• View Recommended modal uses cached data — only uncached artists trigger API calls",
- "• Album art now displays in discovery pool modal for both Spotify and iTunes matches"
- ]
- },
- {
- "title": "Rate Limit Detection Fix",
- "description": "Rate limit handling completely overhauled — escalating bans, no more rate limit loops",
- "features": [
- "• Fixed rate limits going undetected in get_album, get_artist, and batch artist enrichment",
- "• These methods previously swallowed 429 exceptions — global ban was never activated",
- "• Escalating ban durations — repeated rate limits within 1 hour double the ban (30m → 1h → 2h → 4h max)",
- "• Default ban raised from 10 minutes to 30 minutes — prevents rapid re-ban cycling",
- "• Exhausted-retry detection — 5 consecutive 429s trigger a 1-hour ban instead of re-raising",
- "• Rate limit modal with live countdown timer, ban duration, and triggering endpoint",
- "• Redundant get_album_tracks API call removed from iTunes discovery pool population"
- ]
- },
- {
- "title": "Download & Matching Fixes",
- "description": "Accuracy improvements for album downloads and track matching",
- "features": [
- "• Album download pre-flight search finds complete album folders before track-by-track downloading",
- "• Fix wrong track downloads when album name matches a track title in hybrid mode",
- "• Improved album download analysis with album-scoped track matching",
- "• Fix Tidal playlist sync dropping remix/version info from track titles",
- "• Race guard verification extended to all download source monitors"
- ]
- },
- {
- "title": "Security & Config",
- "description": "Encryption at rest and config improvements",
- "features": [
- "• Sensitive config values (API keys, passwords, tokens) encrypted at rest with Fernet",
- "• Transparent migration — existing plaintext values auto-encrypt on first load",
- "• Tidal OAuth fix — override Accept header on token requests"
- ]
- },
- {
- "title": "Recent Bug Fixes",
- "description": "Stability and UX fixes",
- "features": [
- "• Fix sync stuck at 80% — serialize datetime in SyncResult for WebSocket emit",
- "• Fix automated scans for non-Plex servers and incremental scan performance",
- "• Fix Tidal/Qobuz enrichment backfill failing on dict-type copyright and isrc fields",
- "• Fix false positive track matching and tag writing visibility for library files",
- "• Stop unnecessary Spotify API call every 60s from enrichment status polling",
- "• Spotify rate limit UX — persistent modal with countdown, dismiss, and disconnect buttons",
- "• Navidrome ReportRealPath guidance when library files can't be found",
- "• Enhanced library write-all modal and confirmation dialog improvements"
- ]
- },
- {
- "title": "Tidal & Qobuz Enrichment Workers",
- "description": "Two new background enrichment workers for Tidal and Qobuz metadata",
- "features": [
- "• Tidal worker enriches artists, albums, and tracks with Tidal IDs, thumbnails, and metadata",
- "• Qobuz worker enriches artists, albums, and tracks with Qobuz IDs, labels, genres, and metadata",
- "• Dashboard buttons with real-time status, progress tracking, and pause/resume controls",
- "• Smart no-auth detection — buttons grey out when not authenticated to either service",
- "• Module-level rate limiting for Qobuz — shared throttle across all client instances",
- "• Full Enhanced Library Manager integration — status chips, manual matching, clickable service badges",
- "• Library artist card badges and discography 'View on' buttons for both services",
- "• Total enrichment worker count now at 9: Spotify, iTunes, MusicBrainz, AudioDB, Deezer, Last.fm, Genius, Tidal, Qobuz"
- ]
- },
- {
- "title": "Full Qobuz Support",
- "description": "Qobuz added as a first-class download source alongside Tidal and Soulseek",
- "features": [
- "• Search, browse, and download from Qobuz with quality selection up to Hi-Res 24-bit/192kHz",
- "• Qobuz appears as a download source in hybrid mode with configurable priority",
- "• Playlist import from Qobuz URLs with mirrored playlist support",
- "• Settings page integration with conditional source visibility"
- ]
- },
- {
- "title": "Hybrid Mode Redesign",
- "description": "Overhauled download source selection and priority system",
- "features": [
- "• Redesigned hybrid mode with drag-and-drop source priority ordering",
- "• Tidal, Qobuz, and Soulseek sources with per-source quality preferences",
- "• Conditional settings — source-specific options only appear when that source is enabled",
- "• Reorganized settings page with clearer Download Source section"
- ]
- },
- {
- "title": "Spotify Rate Limit Protection",
- "description": "Smart detection and handling of Spotify API rate limits with escalating bans",
- "features": [
- "• Automatic detection of long rate limit bans (Retry-After > 60s) from Spotify",
- "• Escalating ban durations — repeated hits within 1 hour double the ban (30m → 1h → 2h → 4h)",
- "• Global suppression of all Spotify API calls during a ban — no wasted requests",
- "• Seamless iTunes/Apple Music fallback for searches while Spotify is rate limited",
- "• Enrichment worker auto-pauses during rate limit and resumes when ban expires",
- "• Rate limit modal with live countdown timer, ban duration, triggering endpoint, and dismiss/disconnect buttons",
- "• One-click Disconnect Spotify button to clear ban, pause enrichment, and delete cache",
- "• Auth probe no longer makes API calls during ban — prevents extending the ban",
- "• Cooldown-to-restored transition auto-closes modal and refreshes discover page"
- ]
- },
- {
- "title": "Profile Permissions & Page Access Control",
- "description": "Granular admin controls over what each profile can see and do",
- "features": [
- "• Admin can control which sidebar pages each profile can access",
- "• Per-profile download toggle — disable downloading for specific users (frontend + backend enforced)",
- "• Per-user home page — every user can choose their own landing page",
- "• Enhanced Library Manager restricted to admin profiles only",
- "• Non-admin users default to Discover page instead of Dashboard"
- ]
- },
- {
- "title": "Now Playing Overhaul",
- "description": "Redesigned media player with expanded Now Playing modal and smart radio",
- "features": [
- "• Expanded Now Playing modal — click the sidebar player to open a full-screen experience",
- "• Album art ambient glow — dominant color from cover art tints the modal background",
- "• Smart Radio mode — auto-queue up to 50 similar tracks based on genre, mood, and style",
- "• Queue system — add tracks from the library, manage queue in Now Playing modal",
- "• Web Audio visualizer — real frequency-driven bars responding to actual playback",
- "• Repeat modes (off, repeat-all, repeat-one), shuffle, Media Session API controls",
- "• Keyboard shortcuts — Space, arrows, M (mute), Escape (close)"
- ]
- },
- {
- "title": "Enhanced Library Manager",
- "description": "Professional-grade library management with tag writing and server sync",
- "features": [
- "• Toggle between Standard and Enhanced views on any artist's discography",
- "• Inline metadata editing — click any field to edit artist, album, or track data",
- "• Per-service manual matching for all 9 enrichment services",
- "• Write Tags to File — sync database metadata to audio file tags (MP3/FLAC/OGG/M4A)",
- "• Tag preview modal showing a diff of file vs database values before writing",
- "• Batch write tags for entire albums or bulk-selected tracks with live progress",
- "• Optional cover art embedding with per-album caching",
- "• Server sync after tag writes — push updated metadata to Plex, Jellyfin, or Navidrome",
- "• Bulk select and batch edit tracks across albums",
- "• Sortable track table columns, multi-disc support, play tracks from library"
- ],
- "usage_note": "Open any artist's detail page and click 'Enhanced' in the view toggle to access the library manager."
- },
- {
- "title": "Last.fm & Genius Enrichment Workers",
- "description": "Background enrichment workers for Last.fm and Genius metadata",
- "features": [
- "• Last.fm worker enriches artists, albums, and tracks with listener counts, play counts, tags, and bios",
- "• Genius worker enriches artists and tracks with descriptions, alternate names, and lyrics",
- "• Dashboard buttons with status, progress, and pause/resume controls",
- "• No-auth detection — buttons grey out with guidance when API keys are missing",
- "• Settings reload — changing API keys takes effect immediately without restarting"
- ]
- },
- {
- "title": "UI & Visual Overhaul",
- "description": "Per-page particle animations, sidebar visualizer, watchlist redesign, and design refresh",
- "features": [
- "• Per-page particle animations with unique themes for each page",
- "• Particle toggle in Settings — disable background particles to reduce GPU usage",
- "• Sidebar audio visualizer with 5 reactive styles and settings toggle",
- "• Sidebar SVG icons with accent-colored navigation and ambient aura",
- "• Watchlist modal redesign — gradient overlay cards, staggered entrance animations, SVG icon buttons, glassmorphic styling",
- "• Settings page visual refresh — premium header, custom toggle switches, refined input styling",
- "• Page headers with sidebar icons and gradient shimmer styling",
- "• Service badges on library artist cards for all 9 enrichment services",
- "• Glassmorphic 'View on' buttons on artist discography pages",
- "• Help & Docs page — comprehensive in-app documentation covering every feature"
- ]
- },
- {
- "title": "Tidal Download Improvements",
- "description": "Stability and accuracy fixes for Tidal downloads",
- "features": [
- "• Tidal download validation — detect and clean up unplayable hi-res stubs",
- "• Tidal playlist pagination rate limiting with exponential backoff",
- "• Include Tidal version field in track names — fixes remixes resolving to base title",
- "• Direct single-playlist fetch instead of redundantly re-fetching all playlists"
- ]
- },
- {
- "title": "Bug Fixes & Stability",
- "description": "Reliability improvements across the board",
- "features": [
- "• Fix Genius search blindly matching wrong artists — all bad matches auto-reset",
- "• Fix library page albums merging across different artists with same album title/year",
- "• Fix post-processing race condition on files already moved by another thread",
- "• iTunes storefront fallback — ID lookups automatically try 10 regional storefronts",
- "• Fix infinite Spotify rate limit loop from unguarded auth probes",
- "• Fix playlist folder downloads marked as failed despite successful processing",
- "• Fix Docker upgrade crashes from stale volume mounts and partial DB migrations",
- "• Isolate service client initialization so one failure doesn't break the app",
- "• Explicit content filter with configurable toggle to skip explicit tracks",
-""" # end of _OLD_V2_NOTES_REMOVED
-
def _simple_monitor_task():
"""The actual monitoring task that runs in the background thread.
From 7714b51a505616928dc83d26393b2a21699ac749 Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Sun, 26 Apr 2026 13:32:30 -0700
Subject: [PATCH 002/145] Lift version modal data into helper.js, delete
/api/version-info
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The version modal pulled its content from /api/version-info — a 295-line
hand-curated Python dict in web_server.py. The "What's New" panel pulled
its content from WHATS_NEW in helper.js. Same release notes, two files,
two languages, hand-edited at every release — drift was inevitable
(and happened: the kettui-fix entries I added recently differed in
detail between the two surfaces).
This commit makes helper.js the single editing surface:
- Adds VERSION_MODAL_SECTIONS const in helper.js right beside WHATS_NEW,
with a comment block documenting the relationship: WHATS_NEW is the
per-version detailed log used by the helper popover; VERSION_MODAL_SECTIONS
is the curated highlight reel shown by the sidebar version button. Both
edited at release time, both in the same file.
- Rewires showVersionInfo() in downloads.js to read from those consts
directly. No backend round-trip; the changelog content ships in the
same JS bundle the browser already loaded.
- Deletes the /api/version-info route and its 295-line version_data dict.
- Updates the line-39 comment to drop the now-stale "version-info endpoint"
reference.
Note: this is collocation, not true unification. WHATS_NEW and
VERSION_MODAL_SECTIONS are still two distinct structures with overlapping
content, linked by a comment convention rather than a shared schema. A
deeper refactor (e.g. a `featured` flag on WHATS_NEW entries that the
modal aggregates) was rejected as out-of-scope — the curated section
titles ("Earlier in v2.3", "Recent Fixes") aren't 1:1 mappable to
WHATS_NEW entries. Saving for a follow-up if the drift problem persists.
Risk audit:
- Load order: helper.js loads at line 7967, downloads.js at line 7873.
Both classic scripts execute synchronously before any clickable
interaction, so showVersionInfo (only invoked on the version-button
onclick) always sees both consts defined.
- populateVersionModal() unchanged — receives the same {title, subtitle,
sections: [{title, description, features, usage_note?}]} shape.
- Stale-cache window during deploy: old downloads.js hitting a 404 on
the deleted endpoint falls through to the existing catch + toast path
("Failed to load version information"). Cache-buster ?v=static_v
resolves on next page load.
553 tests pass. helper.js + downloads.js parse cleanly. No residual
references to /api/version-info anywhere in the repo.
---
web_server.py | 298 +-----------------------------------
webui/static/downloads.js | 35 +++--
webui/static/helper.js | 309 ++++++++++++++++++++++++++++++++++++++
3 files changed, 327 insertions(+), 315 deletions(-)
diff --git a/web_server.py b/web_server.py
index 30e7fd79..c473e037 100644
--- a/web_server.py
+++ b/web_server.py
@@ -36,7 +36,7 @@ _log_path = config_manager.get('logging.path', 'logs/app.log')
_log_dir = Path(_log_path).parent
logger = setup_logging(_log_level, _log_path)
-# App version — single source of truth for backup metadata, version-info endpoint, etc.
+# App version — single source of truth for backup metadata, system-info, update check, etc.
# Semver: MAJOR.MINOR.PATCH. Bump at each dev→main release.
_SOULSYNC_BASE_VERSION = "2.4.0"
@@ -22767,302 +22767,6 @@ def check_for_update():
'is_docker': os.path.exists('/.dockerenv'),
})
-@app.route('/api/version-info', methods=['GET'])
-def get_version_info():
- """
- Returns version information and release notes, matching the GUI's VersionInfoModal content.
- This provides the same data that the GUI version modal displays.
- """
- version_data = {
- "version": SOULSYNC_VERSION,
- "title": "What's New in SoulSync",
- "subtitle": f"Version {SOULSYNC_VERSION} — Latest Changes",
- "sections": [
- {
- "title": "Reorganize Queue: Race-Condition Hardening (kettui Review)",
- "description": "Three concurrency / dedupe issues kettui caught in his review of PR #377, plus two related polish items from the same pass.",
- "features": [
- "• Worker pick + status flip is now atomic — fixes a window where a cancel() landing between 'pick next queued' and 'flip to running' could mark an item cancelled but the worker still ran it",
- "• Replaced the lock + wakeup-event pair with a single threading.Condition so newly-queued items can't sleep up to 60s waiting for the next wakeup tick (the old pair had an empty-check / clear-event race)",
- "• enqueue_many now holds the queue lock for the whole batch and tracks a per-batch seen set, so duplicate album_ids inside one bulk call are deduped against each other (not just against pre-existing items)",
- "• Reorganize-preview Apply button no longer gets stuck disabled when an early return / network error skipped the re-enable line — moved into a finally",
- "• DB helpers get_album_display_meta and get_artist_albums_for_reorganize now let exceptions bubble instead of swallowing them as 'not found' / empty list — a real DB outage now surfaces as a 500 to the user instead of looking like a missing album",
- ],
- },
- {
- "title": "Reorganize Queue with Live Status Panel",
- "description": "Reorganizing albums is no longer a foreground operation that locks the page. Click → enqueue → keep working. A status panel surfaces live progress.",
- "features": [
- "• Per-album Reorganize and Reorganize All both enqueue into a single FIFO queue with a backend worker that drains one item at a time",
- "• Buttons stay clickable — spam-clicking the same album silently dedupes (returns 'already queued' instead of 409-ing)",
- "• Status panel at the top of the artist actions bar shows: active item (progress bar, current track, moved/skipped/failed counts), queued count, and recently-finished items with success/warning indicators",
- "• Click the panel to expand: full queue list with per-item cancel buttons; running item can't be cancelled mid-flight (Python threads aren't cleanly killable, post-process spawns subprocesses)",
- "• 'Cancel All' button drops every queued item at once — the running one continues",
- "• Items belonging to a different artist than the page you're on are flagged with the artist name so cross-artist progress is obvious",
- "• Each queued item carries its own metadata source pick (Spotify / iTunes / Deezer / Discogs / Hydrabase) — switching modal selections per album works",
- "• 'Reorganize All' is now one backend call instead of N JS-driven calls — the loop runs server-side and is much faster",
- "• Continue-on-failure: a single failed album never stalls the queue; the worker logs and moves on",
- "• Retired the old single-slot reorganize state endpoint plus the polling loops that depended on it",
- ],
- },
- {
- "title": "Fix Wrong-Artist Tracks Silently Downloading",
- "description": "A critical bug where searching for a track could silently download a completely different artist's song with the same name",
- "features": [
- "• Example: searching 'Maduk — Leave A Light On' on Tidal was downloading Tom Walker's unrelated song of the same name, then embedding Maduk's metadata into Tom Walker's audio",
- "• Root cause 1: candidate artist gate used `< 0.4` similarity but Maduk/Tom Walker scored exactly 0.400, slipping past the fencepost — raised to `< 0.5`",
- "• Root cause 2: AcoustID verification returned SKIP (accept) instead of FAIL (quarantine) when title matched but artist was clearly different — now FAILs when artist similarity is below 0.3",
- "• Preserves SKIP for the ambiguous 0.3–0.6 range (covers, collabs, formatting differences) so legitimate tracks aren't falsely quarantined",
- "• Both pre-download candidate validation AND post-download verification are now fixed — defense in depth",
- ],
- },
- {
- "title": "Tidal Search Falls Back on Long Queries",
- "description": "Tidal's search chokes on long remix-credit queries — now retries with progressively-shortened variants when the original returns 0 results",
- "features": [
- "• Example: 'maduk transformations remixed fire away fred v remix' returned 0; now falls back to shorter queries until Tidal finds the track",
- "• Up to 4 shortened variants tried, capped total 5 requests, 100ms between attempts",
- "• Qualifier-safe: Live/Remix/Acoustic/Extended searches only accept fallback results that still contain the qualifier — studio version never replaces a '(Live)' request",
- "• Returns empty if no variant preserves qualifiers — same outcome as before",
- ],
- },
- {
- "title": "Manual Discovery Fixes Persist Across Restart",
- "description": "When you manually fix a discovery match, the fix is now saved under your active metadata source instead of always 'spotify' — so Deezer/iTunes/Discogs/Hydrabase users' fixes actually survive restart and re-scan",
- "features": [
- "• Affected Tidal, Deezer, Spotify Public, YouTube, and Discovery Pool manual fixes",
- "• Symmetric with how the auto-discovery worker saves — no more mismatch",
- "• Existing Spotify-primary users unaffected (the hardcoded value matched their source)",
- ],
- },
- {
- "title": "Watchlist Content Filters Fixed",
- "description": "Global Override settings and live-version detection now behave the way the UI implies",
- "features": [
- "• Scheduled auto-watchlist now honors Watchlist → Global Override (was bypassing it and using per-artist defaults)",
- "• 'Live' detection tightened — no more false positives on titles like 'What We Live For' or 'Live Forever'",
- "• Same fix applies to the Library Maintenance Live/Commentary Cleaner",
- "• Still catches (Live), - Live, Live at/from/in/on/version/session/recording, Unplugged, In Concert",
- ],
- },
- {
- "title": "Discography Backfill",
- "description": "New maintenance job that fills gaps in your library — scans each artist's full discography and finds what you're missing",
- "features": [
- "• Scans each artist in your library against metadata source discographies",
- "• Creates findings for missing tracks — review and click 'Add to Wishlist' to queue downloads",
- "• Respects all content filters (live, remix, acoustic, compilation, instrumental)",
- "• Release type filters (album, EP, single) with configurable defaults",
- "• Optional 'auto-add to wishlist' setting — create findings AND push to wishlist in one pass",
- "• 3-option fix prompt (Add to Wishlist / Just Clear / Cancel) for manual review",
- "• Batched in-memory library matching — same fast path the Library pages use",
- "• Opt-in, disabled by default — runs weekly, processes up to 50 artists per run",
- "• Rate-limited to avoid hammering metadata APIs",
- ],
- },
- {
- "title": "Repair 'Run Now' Honored While Paused",
- "description": "Force-running a repair job no longer stalls forever when the master repair worker is paused",
- "features": [
- "• Jobs queued via 'Run Now' run to completion even if the master worker is paused",
- "• Fixes silent stalls where Discography Backfill logged 'scanning 50 artists' then did nothing",
- "• Master-pause still blocks scheduled runs — this only affects explicit user-triggered runs",
- ],
- },
- {
- "title": "Multi-Artist Tagging",
- "description": "Enhanced control over how multiple artists are written to audio file tags",
- "features": [
- "• Configurable artist separator: comma, semicolon, or slash",
- "• Multi-value ARTISTS tag for Navidrome/Jellyfin multi-artist linking",
- "• 'Move featured artists to title' mode — primary artist in ARTIST tag, others as (feat. ...) in title",
- "• All opt-in with defaults matching current behavior",
- ],
- },
- {
- "title": "Enriched Downloads Page",
- "description": "Download cards now show rich metadata instead of just filenames",
- "features": [
- "• Album artwork thumbnail on each download card",
- "• Artist name, album name, and source badge",
- "• Quality badge appears after post-processing",
- "• Falls back gracefully for transfers without metadata context",
- ],
- },
- {
- "title": "Template Variable Delimiters",
- "description": "Use ${var} syntax to append literal text to template variables",
- "features": [
- "• ${albumtype}s produces 'Albums', 'Singles', 'EPs'",
- "• Both $var and ${var} syntaxes work in all templates",
- "• Validation updated to accept delimited variables",
- ],
- },
- {
- "title": "Reorganize All Albums",
- "description": "Bulk reorganize all albums for an artist from the enhanced library view",
- "features": [
- "• New 'Reorganize All' button in the artist header",
- "• Processes albums sequentially with progress toasts",
- "• Continues on error — one failed album doesn't block the rest",
- "• Uses the same template and endpoint as per-album reorganize",
- ],
- },
- {
- "title": "SoulSync Standalone Library",
- "description": "Use SoulSync without Plex, Jellyfin, or Navidrome — manage your library directly",
- "features": [
- "• New 'Standalone' server option in Settings → Connections",
- "• Downloads and imports write artist/album/track to the library database immediately",
- "• Pre-populated enrichment IDs (Spotify, Deezer, MusicBrainz) — workers skip re-discovery",
- "• Deep scan finds untracked files in Transfer → moves to Staging for processing",
- "• Deep scan removes stale DB records when files are deleted from disk",
- "• Sync page and sync buttons hidden automatically in standalone mode",
- "• Full library page, artist detail, discography, and enhanced view all work standalone",
- ],
- "usage_note": "Go to Settings → Connections and click the 'Standalone' button. No media server needed."
- },
- {
- "title": "Auto-Import",
- "description": "Background import folder watcher that automatically identifies and imports music into your library",
- "features": [
- "• Recursive scan — any folder depth (Artist/Album/tracks, Album/tracks, loose files)",
- "• Single file support — loose audio files identified via tags, filename, or AcoustID",
- "• Tag-based identification preferred over weak metadata matches (85% confidence for tagged files)",
- "• AcoustID fingerprinting fallback for untagged or ambiguous files",
- "• Stats bar, filter pills (All/Review/Imported/Failed), Scan Now, Approve All, Clear History",
- "• Expandable track match details with per-track confidence scores",
- "• Race condition fix prevents duplicate processing during multi-track albums",
- ],
- "usage_note": "Enable on the Import page Auto tab. Set your import folder in Settings."
- },
- {
- "title": "Wishlist Nebula",
- "description": "Wishlist redesigned as an interactive artist orb visualization",
- "features": [
- "• Each artist is a glowing orb with their photo — album fans and single moons orbit around them",
- "• Click orbs to expand and see albums/singles, download directly from the nebula",
- "• Processing state shows live progress with spinning ring animation",
- "• Stats strip at top shows total artists, albums, singles, and tracks",
- ],
- "usage_note": "Click Wishlist in the sidebar to see the Nebula view."
- },
- {
- "title": "Automation Group Management",
- "description": "Organize and manage your automation groups with full control",
- "features": [
- "• Rename, delete, and bulk-toggle automation groups from group headers",
- "• Drag-and-drop automations between groups to reorganize",
- "• Delete confirmation dialog with group name and automation count",
- ],
- "usage_note": "Right-click or use the action buttons on group headers in the Automations page."
- },
- {
- "title": "Bidirectional Artist Sync & Server Playlists",
- "description": "Artist sync now goes both ways, and server playlists show full coverage",
- "features": [
- "• Artist Sync pulls new content from your media server AND removes stale library entries",
- "• Deep scan mode fetches full metadata for newly discovered tracks",
- "• Server playlist view shows all playlists with clear synced vs unsynced visual separation",
- ],
- },
- {
- "title": "Provider-Agnostic Discovery",
- "description": "Discovery features work with any configured metadata source instead of requiring Spotify",
- "features": [
- "• Similar artist matching, discovery pool, and incremental updates use source priority",
- "• Falls back through Spotify, iTunes, and Deezer in configured order",
- "• MusicMap URL encoding fixed for artists with special characters",
- "• Freshness check simplified to age-based — backfill handles missing IDs separately",
- ],
- },
- {
- "title": "Dashboard & Navigation",
- "description": "Dashboard improvements and sidebar navigation enhancements",
- "features": [
- "• Library Status card on Dashboard — shows server state, track counts, scan buttons",
- "• Tools page in sidebar — all maintenance tools moved from Dashboard modal",
- "• Watchlist and Wishlist promoted to full sidebar pages with live count badges",
- "• AcoustID scanner scans full library with actionable fix options (retag, redownload, delete)",
- ],
- },
- {
- "title": "MusicBrainz & Metadata Fixes",
- "description": "Critical tag embedding fix and Picard-style album consistency",
- "features": [
- "• Fix: source ID tags (Spotify, MusicBrainz, Deezer, AudioDB) were silently skipped on every download — now embed correctly",
- "• Picard-style release preference scoring prevents Navidrome album splits",
- "• Source tags wiped when metadata enhancement is skipped or fails",
- "• Spotify API no longer called when Deezer/iTunes is the configured primary source",
- ],
- },
- {
- "title": "Downloads & Soulseek Improvements",
- "description": "Better download management, search accuracy, and queue control",
- "features": [
- "• Downloads batch panel — color-coded batch cards with progress, cancel, expand, and 7-day history",
- "• Soulseek search queries now include album name — reduces wrong-artist downloads",
- "• Reject Soulseek results from Various Artists/VA/Unknown Artist folders",
- "• Clearing wishlist now cancels the active wishlist download batch",
- "• Album delete with 'Delete Files Too' option on enhanced library page",
- "• Fix download modal freezing mid-download — M3U auto-save was exhausting server threads",
- "• Fix Unknown Artist when adding playlist tracks to wishlist",
- "• Fix slskd timeout spam when Soulseek is not the active download source",
- ],
- },
- {
- "title": "Recent Fixes",
- "description": "Bug fixes from recent releases and community reports",
- "features": [
- "• Fix watchlist scan false failures — empty discography no longer reported as error",
- "• Fix deezer_artist_id column error on enhanced library sync",
- "• Fix wishlist button intermittently not navigating to page",
- "• Fix worker orb tooltips rendering behind dashboard content",
- "• Fix OAuth callback port hardcoding — custom ports now respected",
- "• Fix allow duplicates setting not saving",
- "• Fix wishlist dropping cross-album tracks when duplicates enabled",
- "• Fix replace lower quality setting not persisting",
- "• Fix Spotify enrichment worker infinite loop on pre-matched artists",
- "• Reject Qobuz 30-second sample/preview downloads",
- "• Fix library page crash on All filter — non-string soul_id broke card rendering",
- "• Auto Wing It fallback for failed discovery — unmatched tracks download via Soulseek with raw metadata",
- "• Lidarr download source now production-ready — full orchestrator integration",
- "• Fix album track lookup hardcoded to Spotify — now uses configured primary source",
- "• Fix M3U showing all tracks as missing — regenerate with real paths after post-processing",
- "• Fix AcoustID retag not writing corrected tags to audio file",
- "• Fix wishlist albums cycle stuck at 1 concurrent worker instead of configured value",
- "• Fix downloads badge dropping to 300 after opening Downloads page",
- "• Fix server playlist Find & Add inserting at wrong position on Plex",
- "• Smarter Fix modal results — standard album versions sorted above live/remix/cover/soundtrack variants",
- "• Unmatch discovery tracks — red ✕ button to remove bad matches from playlist discovery",
- "• Customizable music video naming — path template with $artist, $title, $year variables",
- "• Fix soulseek log spam when not configured as download source",
- ],
- },
- {
- "title": "Earlier in v2.3",
- "description": "Major features from earlier in this release cycle",
- "features": [
- "• Centralized Downloads page with live-updating list and filter pills",
- "• First-Run Setup Wizard — 7-step guided configuration",
- "• Music Videos — search and download from YouTube",
- "• Inbound Music Request API for external tools (Discord bots, Home Assistant)",
- "• Lidarr download source (development) — 7th source for Usenet/torrent via Lidarr",
- "• Graceful shutdown — all workers respond to shutdown signals immediately",
- "• Unknown Artist prevention with 3-tier metadata fallback",
- "• Deezer multi-artist tagging using contributors field",
- "• Artist Map — Watchlist Constellation, Genre Map, and Artist Explorer canvas modes",
- "• Discogs integration — enrichment worker, fallback source, enhanced search tab",
- "• Wing It mode, Global Search Bar, Redesigned Notifications",
- "• Server Playlist Manager, Sync History Dashboard, Playlist Explorer",
- "• Enhanced Library Manager with inline tag editing and write-to-file",
- "• Automation Signals, Multi-Source Search Tabs, Rich Artist Profiles",
- ],
- },
- ]
- }
- return jsonify(version_data)
-
def _simple_monitor_task():
"""The actual monitoring task that runs in the background thread.
diff --git a/webui/static/downloads.js b/webui/static/downloads.js
index 30a9d2bd..1720038b 100644
--- a/webui/static/downloads.js
+++ b/webui/static/downloads.js
@@ -5752,27 +5752,26 @@ async function showVersionInfo() {
} catch (e) { /* ignore */ }
}
+ // Build version data straight from helper.js — single source of truth.
+ // No backend round-trip; the changelog content is shipped in the same
+ // bundle the browser already loaded.
+ const version = (typeof _getCurrentVersion === 'function')
+ ? _getCurrentVersion()
+ : (btn ? btn.textContent.trim().replace('v', '') : '');
+ const sections = (typeof VERSION_MODAL_SECTIONS !== 'undefined')
+ ? VERSION_MODAL_SECTIONS
+ : [];
+ const versionData = {
+ version,
+ title: "What's New in SoulSync",
+ subtitle: version ? `Version ${version} — Latest Changes` : 'Latest Changes',
+ sections,
+ };
+
try {
- console.log('Fetching version info...');
-
- // Fetch version data from API
- const response = await fetch('/api/version-info');
- if (!response.ok) {
- throw new Error('Failed to fetch version info');
- }
-
- const versionData = await response.json();
- console.log('Version data received:', versionData);
-
- // Populate modal content
populateVersionModal(versionData, hadUpdate ? updateInfo : null);
-
- // Show modal
const modalOverlay = document.getElementById('version-modal-overlay');
- modalOverlay.classList.remove('hidden');
-
- console.log('Version modal opened');
-
+ if (modalOverlay) modalOverlay.classList.remove('hidden');
} catch (error) {
console.error('Error showing version info:', error);
showToast('Failed to load version information', 'error');
diff --git a/webui/static/helper.js b/webui/static/helper.js
index 663fa00c..90c2d45d 100644
--- a/webui/static/helper.js
+++ b/webui/static/helper.js
@@ -3669,6 +3669,315 @@ const WHATS_NEW = {
],
};
+// ═══════════════════════════════════════════════════════════════════════════
+// VERSION MODAL — curated highlight reel
+// ═══════════════════════════════════════════════════════════════════════════
+//
+// `WHATS_NEW` above is the per-version detailed log used by the "What's New"
+// helper-popover panel — short one-liners, internal page links, every entry
+// shown on every browse-back through versions.
+//
+// `VERSION_MODAL_SECTIONS` (this block) is the curated highlight reel shown
+// when the user clicks the version button in the sidebar. It's NOT a
+// mechanical view of WHATS_NEW — it's editorial curation: bigger-picture
+// sections, bullet-list expansions, optional "usage" hints at the bottom.
+// Some sections aggregate across multiple WHATS_NEW entries ("Recent Fixes",
+// "Earlier in v2.3"); some don't have a 1:1 WHATS_NEW counterpart at all.
+//
+// Both consts live here so a release editor only opens one file. At release
+// time:
+// 1. Add the per-version block to `WHATS_NEW` (one entry per shipped item).
+// 2. Promote any items worth a modal-section into `VERSION_MODAL_SECTIONS`
+// at the top of the array (latest highlights lead).
+// 3. Roll older sections down or merge them into a "Recent Fixes" /
+// "Earlier in vX.Y" aggregator section as they age out of the spotlight.
+//
+// Section shape: { title, description, features: [bullet strings],
+// usage_note?: 'optional hint shown at the bottom' }
+const VERSION_MODAL_SECTIONS = [
+ {
+ title: "Reorganize Queue: Race-Condition Hardening (kettui Review)",
+ description: "Three concurrency / dedupe issues kettui caught in his review of PR #377, plus two related polish items from the same pass.",
+ features: [
+ "• Worker pick + status flip is now atomic — fixes a window where a cancel() landing between 'pick next queued' and 'flip to running' could mark an item cancelled but the worker still ran it",
+ "• Replaced the lock + wakeup-event pair with a single threading.Condition so newly-queued items can't sleep up to 60s waiting for the next wakeup tick (the old pair had an empty-check / clear-event race)",
+ "• enqueue_many now holds the queue lock for the whole batch and tracks a per-batch seen set, so duplicate album_ids inside one bulk call are deduped against each other (not just against pre-existing items)",
+ "• Reorganize-preview Apply button no longer gets stuck disabled when an early return / network error skipped the re-enable line — moved into a finally",
+ "• DB helpers get_album_display_meta and get_artist_albums_for_reorganize now let exceptions bubble instead of swallowing them as 'not found' / empty list — a real DB outage now surfaces as a 500 to the user instead of looking like a missing album",
+ ],
+ },
+ {
+ title: "Reorganize Queue with Live Status Panel",
+ description: "Reorganizing albums is no longer a foreground operation that locks the page. Click → enqueue → keep working. A status panel surfaces live progress.",
+ features: [
+ "• Per-album Reorganize and Reorganize All both enqueue into a single FIFO queue with a backend worker that drains one item at a time",
+ "• Buttons stay clickable — spam-clicking the same album silently dedupes (returns 'already queued' instead of 409-ing)",
+ "• Status panel at the top of the artist actions bar shows: active item (progress bar, current track, moved/skipped/failed counts), queued count, and recently-finished items with success/warning indicators",
+ "• Click the panel to expand: full queue list with per-item cancel buttons; running item can't be cancelled mid-flight (Python threads aren't cleanly killable, post-process spawns subprocesses)",
+ "• 'Cancel All' button drops every queued item at once — the running one continues",
+ "• Items belonging to a different artist than the page you're on are flagged with the artist name so cross-artist progress is obvious",
+ "• Each queued item carries its own metadata source pick (Spotify / iTunes / Deezer / Discogs / Hydrabase) — switching modal selections per album works",
+ "• 'Reorganize All' is now one backend call instead of N JS-driven calls — the loop runs server-side and is much faster",
+ "• Continue-on-failure: a single failed album never stalls the queue; the worker logs and moves on",
+ "• Retired the old single-slot reorganize state endpoint plus the polling loops that depended on it",
+ ],
+ },
+ {
+ title: "Fix Wrong-Artist Tracks Silently Downloading",
+ description: "A critical bug where searching for a track could silently download a completely different artist's song with the same name",
+ features: [
+ "• Example: searching 'Maduk — Leave A Light On' on Tidal was downloading Tom Walker's unrelated song of the same name, then embedding Maduk's metadata into Tom Walker's audio",
+ "• Root cause 1: candidate artist gate used `< 0.4` similarity but Maduk/Tom Walker scored exactly 0.400, slipping past the fencepost — raised to `< 0.5`",
+ "• Root cause 2: AcoustID verification returned SKIP (accept) instead of FAIL (quarantine) when title matched but artist was clearly different — now FAILs when artist similarity is below 0.3",
+ "• Preserves SKIP for the ambiguous 0.3–0.6 range (covers, collabs, formatting differences) so legitimate tracks aren't falsely quarantined",
+ "• Both pre-download candidate validation AND post-download verification are now fixed — defense in depth",
+ ],
+ },
+ {
+ title: "Tidal Search Falls Back on Long Queries",
+ description: "Tidal's search chokes on long remix-credit queries — now retries with progressively-shortened variants when the original returns 0 results",
+ features: [
+ "• Example: 'maduk transformations remixed fire away fred v remix' returned 0; now falls back to shorter queries until Tidal finds the track",
+ "• Up to 4 shortened variants tried, capped total 5 requests, 100ms between attempts",
+ "• Qualifier-safe: Live/Remix/Acoustic/Extended searches only accept fallback results that still contain the qualifier — studio version never replaces a '(Live)' request",
+ "• Returns empty if no variant preserves qualifiers — same outcome as before",
+ ],
+ },
+ {
+ title: "Manual Discovery Fixes Persist Across Restart",
+ description: "When you manually fix a discovery match, the fix is now saved under your active metadata source instead of always 'spotify' — so Deezer/iTunes/Discogs/Hydrabase users' fixes actually survive restart and re-scan",
+ features: [
+ "• Affected Tidal, Deezer, Spotify Public, YouTube, and Discovery Pool manual fixes",
+ "• Symmetric with how the auto-discovery worker saves — no more mismatch",
+ "• Existing Spotify-primary users unaffected (the hardcoded value matched their source)",
+ ],
+ },
+ {
+ title: "Watchlist Content Filters Fixed",
+ description: "Global Override settings and live-version detection now behave the way the UI implies",
+ features: [
+ "• Scheduled auto-watchlist now honors Watchlist → Global Override (was bypassing it and using per-artist defaults)",
+ "• 'Live' detection tightened — no more false positives on titles like 'What We Live For' or 'Live Forever'",
+ "• Same fix applies to the Library Maintenance Live/Commentary Cleaner",
+ "• Still catches (Live), - Live, Live at/from/in/on/version/session/recording, Unplugged, In Concert",
+ ],
+ },
+ {
+ title: "Discography Backfill",
+ description: "New maintenance job that fills gaps in your library — scans each artist's full discography and finds what you're missing",
+ features: [
+ "• Scans each artist in your library against metadata source discographies",
+ "• Creates findings for missing tracks — review and click 'Add to Wishlist' to queue downloads",
+ "• Respects all content filters (live, remix, acoustic, compilation, instrumental)",
+ "• Release type filters (album, EP, single) with configurable defaults",
+ "• Optional 'auto-add to wishlist' setting — create findings AND push to wishlist in one pass",
+ "• 3-option fix prompt (Add to Wishlist / Just Clear / Cancel) for manual review",
+ "• Batched in-memory library matching — same fast path the Library pages use",
+ "• Opt-in, disabled by default — runs weekly, processes up to 50 artists per run",
+ "• Rate-limited to avoid hammering metadata APIs",
+ ],
+ },
+ {
+ title: "Repair 'Run Now' Honored While Paused",
+ description: "Force-running a repair job no longer stalls forever when the master repair worker is paused",
+ features: [
+ "• Jobs queued via 'Run Now' run to completion even if the master worker is paused",
+ "• Fixes silent stalls where Discography Backfill logged 'scanning 50 artists' then did nothing",
+ "• Master-pause still blocks scheduled runs — this only affects explicit user-triggered runs",
+ ],
+ },
+ {
+ title: "Multi-Artist Tagging",
+ description: "Enhanced control over how multiple artists are written to audio file tags",
+ features: [
+ "• Configurable artist separator: comma, semicolon, or slash",
+ "• Multi-value ARTISTS tag for Navidrome/Jellyfin multi-artist linking",
+ "• 'Move featured artists to title' mode — primary artist in ARTIST tag, others as (feat. ...) in title",
+ "• All opt-in with defaults matching current behavior",
+ ],
+ },
+ {
+ title: "Enriched Downloads Page",
+ description: "Download cards now show rich metadata instead of just filenames",
+ features: [
+ "• Album artwork thumbnail on each download card",
+ "• Artist name, album name, and source badge",
+ "• Quality badge appears after post-processing",
+ "• Falls back gracefully for transfers without metadata context",
+ ],
+ },
+ {
+ title: "Template Variable Delimiters",
+ description: "Use ${var} syntax to append literal text to template variables",
+ features: [
+ "• ${albumtype}s produces 'Albums', 'Singles', 'EPs'",
+ "• Both $var and ${var} syntaxes work in all templates",
+ "• Validation updated to accept delimited variables",
+ ],
+ },
+ {
+ title: "Reorganize All Albums",
+ description: "Bulk reorganize all albums for an artist from the enhanced library view",
+ features: [
+ "• New 'Reorganize All' button in the artist header",
+ "• Processes albums sequentially with progress toasts",
+ "• Continues on error — one failed album doesn't block the rest",
+ "• Uses the same template and endpoint as per-album reorganize",
+ ],
+ },
+ {
+ title: "SoulSync Standalone Library",
+ description: "Use SoulSync without Plex, Jellyfin, or Navidrome — manage your library directly",
+ features: [
+ "• New 'Standalone' server option in Settings → Connections",
+ "• Downloads and imports write artist/album/track to the library database immediately",
+ "• Pre-populated enrichment IDs (Spotify, Deezer, MusicBrainz) — workers skip re-discovery",
+ "• Deep scan finds untracked files in Transfer → moves to Staging for processing",
+ "• Deep scan removes stale DB records when files are deleted from disk",
+ "• Sync page and sync buttons hidden automatically in standalone mode",
+ "• Full library page, artist detail, discography, and enhanced view all work standalone",
+ ],
+ usage_note: "Go to Settings → Connections and click the 'Standalone' button. No media server needed.",
+ },
+ {
+ title: "Auto-Import",
+ description: "Background import folder watcher that automatically identifies and imports music into your library",
+ features: [
+ "• Recursive scan — any folder depth (Artist/Album/tracks, Album/tracks, loose files)",
+ "• Single file support — loose audio files identified via tags, filename, or AcoustID",
+ "• Tag-based identification preferred over weak metadata matches (85% confidence for tagged files)",
+ "• AcoustID fingerprinting fallback for untagged or ambiguous files",
+ "• Stats bar, filter pills (All/Review/Imported/Failed), Scan Now, Approve All, Clear History",
+ "• Expandable track match details with per-track confidence scores",
+ "• Race condition fix prevents duplicate processing during multi-track albums",
+ ],
+ usage_note: "Enable on the Import page Auto tab. Set your import folder in Settings.",
+ },
+ {
+ title: "Wishlist Nebula",
+ description: "Wishlist redesigned as an interactive artist orb visualization",
+ features: [
+ "• Each artist is a glowing orb with their photo — album fans and single moons orbit around them",
+ "• Click orbs to expand and see albums/singles, download directly from the nebula",
+ "• Processing state shows live progress with spinning ring animation",
+ "• Stats strip at top shows total artists, albums, singles, and tracks",
+ ],
+ usage_note: "Click Wishlist in the sidebar to see the Nebula view.",
+ },
+ {
+ title: "Automation Group Management",
+ description: "Organize and manage your automation groups with full control",
+ features: [
+ "• Rename, delete, and bulk-toggle automation groups from group headers",
+ "• Drag-and-drop automations between groups to reorganize",
+ "• Delete confirmation dialog with group name and automation count",
+ ],
+ usage_note: "Right-click or use the action buttons on group headers in the Automations page.",
+ },
+ {
+ title: "Bidirectional Artist Sync & Server Playlists",
+ description: "Artist sync now goes both ways, and server playlists show full coverage",
+ features: [
+ "• Artist Sync pulls new content from your media server AND removes stale library entries",
+ "• Deep scan mode fetches full metadata for newly discovered tracks",
+ "• Server playlist view shows all playlists with clear synced vs unsynced visual separation",
+ ],
+ },
+ {
+ title: "Provider-Agnostic Discovery",
+ description: "Discovery features work with any configured metadata source instead of requiring Spotify",
+ features: [
+ "• Similar artist matching, discovery pool, and incremental updates use source priority",
+ "• Falls back through Spotify, iTunes, and Deezer in configured order",
+ "• MusicMap URL encoding fixed for artists with special characters",
+ "• Freshness check simplified to age-based — backfill handles missing IDs separately",
+ ],
+ },
+ {
+ title: "Dashboard & Navigation",
+ description: "Dashboard improvements and sidebar navigation enhancements",
+ features: [
+ "• Library Status card on Dashboard — shows server state, track counts, scan buttons",
+ "• Tools page in sidebar — all maintenance tools moved from Dashboard modal",
+ "• Watchlist and Wishlist promoted to full sidebar pages with live count badges",
+ "• AcoustID scanner scans full library with actionable fix options (retag, redownload, delete)",
+ ],
+ },
+ {
+ title: "MusicBrainz & Metadata Fixes",
+ description: "Critical tag embedding fix and Picard-style album consistency",
+ features: [
+ "• Fix: source ID tags (Spotify, MusicBrainz, Deezer, AudioDB) were silently skipped on every download — now embed correctly",
+ "• Picard-style release preference scoring prevents Navidrome album splits",
+ "• Source tags wiped when metadata enhancement is skipped or fails",
+ "• Spotify API no longer called when Deezer/iTunes is the configured primary source",
+ ],
+ },
+ {
+ title: "Downloads & Soulseek Improvements",
+ description: "Better download management, search accuracy, and queue control",
+ features: [
+ "• Downloads batch panel — color-coded batch cards with progress, cancel, expand, and 7-day history",
+ "• Soulseek search queries now include album name — reduces wrong-artist downloads",
+ "• Reject Soulseek results from Various Artists/VA/Unknown Artist folders",
+ "• Clearing wishlist now cancels the active wishlist download batch",
+ "• Album delete with 'Delete Files Too' option on enhanced library page",
+ "• Fix download modal freezing mid-download — M3U auto-save was exhausting server threads",
+ "• Fix Unknown Artist when adding playlist tracks to wishlist",
+ "• Fix slskd timeout spam when Soulseek is not the active download source",
+ ],
+ },
+ {
+ title: "Recent Fixes",
+ description: "Bug fixes from recent releases and community reports",
+ features: [
+ "• Fix watchlist scan false failures — empty discography no longer reported as error",
+ "• Fix deezer_artist_id column error on enhanced library sync",
+ "• Fix wishlist button intermittently not navigating to page",
+ "• Fix worker orb tooltips rendering behind dashboard content",
+ "• Fix OAuth callback port hardcoding — custom ports now respected",
+ "• Fix allow duplicates setting not saving",
+ "• Fix wishlist dropping cross-album tracks when duplicates enabled",
+ "• Fix replace lower quality setting not persisting",
+ "• Fix Spotify enrichment worker infinite loop on pre-matched artists",
+ "• Reject Qobuz 30-second sample/preview downloads",
+ "• Fix library page crash on All filter — non-string soul_id broke card rendering",
+ "• Auto Wing It fallback for failed discovery — unmatched tracks download via Soulseek with raw metadata",
+ "• Lidarr download source now production-ready — full orchestrator integration",
+ "• Fix album track lookup hardcoded to Spotify — now uses configured primary source",
+ "• Fix M3U showing all tracks as missing — regenerate with real paths after post-processing",
+ "• Fix AcoustID retag not writing corrected tags to audio file",
+ "• Fix wishlist albums cycle stuck at 1 concurrent worker instead of configured value",
+ "• Fix downloads badge dropping to 300 after opening Downloads page",
+ "• Fix server playlist Find & Add inserting at wrong position on Plex",
+ "• Smarter Fix modal results — standard album versions sorted above live/remix/cover/soundtrack variants",
+ "• Unmatch discovery tracks — red ✕ button to remove bad matches from playlist discovery",
+ "• Customizable music video naming — path template with $artist, $title, $year variables",
+ "• Fix soulseek log spam when not configured as download source",
+ ],
+ },
+ {
+ title: "Earlier in v2.3",
+ description: "Major features from earlier in this release cycle",
+ features: [
+ "• Centralized Downloads page with live-updating list and filter pills",
+ "• First-Run Setup Wizard — 7-step guided configuration",
+ "• Music Videos — search and download from YouTube",
+ "• Inbound Music Request API for external tools (Discord bots, Home Assistant)",
+ "• Lidarr download source (development) — 7th source for Usenet/torrent via Lidarr",
+ "• Graceful shutdown — all workers respond to shutdown signals immediately",
+ "• Unknown Artist prevention with 3-tier metadata fallback",
+ "• Deezer multi-artist tagging using contributors field",
+ "• Artist Map — Watchlist Constellation, Genre Map, and Artist Explorer canvas modes",
+ "• Discogs integration — enrichment worker, fallback source, enhanced search tab",
+ "• Wing It mode, Global Search Bar, Redesigned Notifications",
+ "• Server Playlist Manager, Sync History Dashboard, Playlist Explorer",
+ "• Enhanced Library Manager with inline tag editing and write-to-file",
+ "• Automation Signals, Multi-Source Search Tabs, Rich Artist Profiles",
+ ],
+ },
+];
+
function _getCurrentVersion() {
const btn = document.querySelector('.version-button');
return btn ? btn.textContent.trim().replace('v', '') : '2.4.0';
From 04ff287c72ebe24e7df1c3152bf400195a49e87c Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Sun, 26 Apr 2026 14:25:30 -0700
Subject: [PATCH 003/145] Rewrite changelog entries in user voice
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Trimmed the WHATS_NEW '2.4.0' block (27 entries) and the full
VERSION_MODAL_SECTIONS array (23 sections) from the diagnostic-paragraph
style I'd been defaulting to into something terse and casual:
- Descriptions are 1-2 short sentences instead of multi-clause writeups.
- Modal feature bullets capped at 3-7 short items each.
- Stripped parenthetical credits from titles (no more "(kettui Review)",
"(Images, Counts, Title Hints)" — those belong in git history, not UI).
- Lowercase casual tone throughout description bodies.
- No reporter handles in entry text.
Net: 176 insertions / 194 deletions. helper.js parses, 553 tests pass.
---
webui/static/helper.js | 370 ++++++++++++++++++++---------------------
1 file changed, 176 insertions(+), 194 deletions(-)
diff --git a/webui/static/helper.js b/webui/static/helper.js
index 90c2d45d..8c6553c1 100644
--- a/webui/static/helper.js
+++ b/webui/static/helper.js
@@ -3444,33 +3444,33 @@ const WHATS_NEW = {
'2.4.0': [
// --- April 26, 2026 — Search & Artists unification + reorganize queue ---
{ date: 'April 26, 2026 — 2.4.0 release' },
- { title: 'Reorganize Queue: Race-Condition Hardening (kettui Review)', desc: 'kettui\'s review of PR #377 caught two real concurrency bugs in the new reorganize queue and one input-deduplication gap. (1) Worker race: the worker thread looked up the next queued item, then released the lock, then re-acquired it to flip status to "running". A cancel() landing in that window would mark the item cancelled but the worker still ran it. Now picks and flips atomically under a single lock acquisition. (2) Wakeup race: the worker cleared its wakeup event after observing an empty queue, but enqueue could fire its wakeup.set() between the empty check and the clear, making a freshly-queued album sleep up to 60 seconds before the worker noticed. Replaced the lock + event pair with a single threading.Condition so check-and-wait happen under the same lock atomically. (3) Bulk-enqueue dedupe: enqueue_many called single-item enqueue in a loop, so two copies of the same album_id in one bulk request could both slip through if the worker finished the first copy before the loop reached the second. Now holds the queue lock for the entire batch and tracks a per-batch seen set, so intra-batch duplicates are deduped against each other, not just against pre-existing items. Also fixed two related issues from the same review: the reorganize-preview Apply button could get stuck disabled when an early return / network error skipped the re-enable line (moved into a finally), and the new DB helpers (get_album_display_meta, get_artist_albums_for_reorganize) used to swallow every exception and return None / [], which made a real DB outage look like "album not found" — they now let exceptions bubble so the route layer surfaces a proper 500', page: 'library' },
- { title: 'Reorganize Queue with Live Status Panel', desc: 'Reorganizing albums no longer locks up the page or runs as a JS-driven loop. Each click on the per-album reorganize button — or "Reorganize All" — now enqueues into a single FIFO queue that a backend worker drains one item at a time. Buttons stay clickable: spam-clicking the same album silently dedupes, and you can keep browsing while items run. A status panel mounted at the top of the artist actions bar shows what\'s active (with a progress bar, current track, and live moved/skipped/failed counts), how many items are queued behind it, and recently-finished items with success/warning indicators. The panel expands to show the full queue with per-item cancel buttons (running items can\'t be cancelled mid-flight; queued ones can) and a "Cancel All" button for the queued tail. Items belonging to a different artist than the page you\'re on are flagged with a "(other artist)" hint so you understand what you\'re seeing. Bonus: "Reorganize All" is now one backend call instead of N JS-driven calls — much faster, and the artist context is captured server-side per item so the queue can show cross-artist progress correctly. Also retired the old single-slot status endpoint and the polling loop that depended on it', page: 'library' },
- { title: 'Fix Album Completeness Job Reporting Zero Findings for Everyone', desc: 'sassmastawillis reported the Album Completeness maintenance job was finishing in 0.1s with 0 findings, even for users with obviously-incomplete albums. Root cause: the job used `albums.track_count` as the "expected total" to compare against the library\'s actual count. But `track_count` is populated by server syncs (Plex leafCount, SoulSync standalone len(tracks)) — it\'s always the OBSERVED count, never what the metadata provider says the album should contain. So expected == actual always, and every album looked complete. Fix: new `api_track_count` column on the albums table, written only by metadata-source code paths (Spotify, iTunes, Deezer, and Discogs enrichment workers now populate it whenever they fetch album data, so it piggybacks on existing API calls instead of making new ones). Server syncs never touch this column, so it stays authoritative. The repair job uses it as the expected total; if an album somehow hasn\'t been enriched yet, the job falls back to a live API lookup and caches the result. For users with an already-enriched library, the first completeness scan after the upgrade is fast because the workers will have populated the column during normal enrichment cycles', page: 'library' },
- { title: 'Library Reorganize: Reroute Through the Download Pipeline', desc: 'Reported by winecountrygames — using "Reorganize All" on a 3-disc Aerosmith deluxe collapsed it to a flat 1-disc layout, and on other albums it left half the tracks in their original location with no error or count of what was skipped. Root cause: the reorganize endpoint reinvented several wheels (its own template engine, its own disc-number resolution from file tags, its own sidecar sweep, its own collision detection) and each had drifted from the canonical post-processing path used by downloads. The reorganize-only logic read disc_number from file tags and silently defaulted to 1 on any failure, so a single tag-less file collapsed the whole album to single-disc. Tracks whose file paths didn\'t resolve on disk were silently skipped. Rewrote it to follow the import page\'s pattern: copy each file to a per-album staging folder under your download path, look up the canonical tracklist from your configured metadata source (Deezer / Spotify / iTunes / Discogs / Hydrabase) using the album\'s stored source IDs, then route each file through the same `_post_process_matched_download` function fresh downloads use — same template, same tagging, same multi-disc subfolder logic, same sidecar handling, same AcoustID verification. Albums with no stored source ID are reported back and skipped entirely (degrading silently to file tags is what caused the original bug). Tracks not in the source\'s catalog version (bonus tracks on a deluxe edition) are reported as skipped and left in place rather than force-fed wrong context. Files that don\'t resolve on disk are surfaced with the offending DB path so the UI can show them. The 230-line inline reorganize logic in web_server.py was extracted into core/library_reorganize.py — net -195 lines from the monolith, +13 unit tests for the new orchestrator. Frontend behavior change: the per-call template parameter in the reorganize modal is now ignored — reorganize uses your configured download template, matching the pipeline downloads use', page: 'library' },
- { title: 'Spotify: Longer Post-Ban Cooldown (30 min)', desc: 'A user reported their Spotify rate-limit ban expired after 4 hours, the system ran its 5-minute post-ban cooldown, and then 32 seconds after the cooldown ended a single get_artist_albums call from a background worker was hit with another 4-hour ban. Diagnosis: Spotify\'s server-side memory of the previous offense outlasted our 5-minute cooldown, so the very first call after cooldown got slapped immediately. The cooldown exists specifically to prevent the "ban expires → we probe → re-ban" cycle, but the value was too short. Bumped from 5 minutes to 30 minutes — same mechanism, just enough room for Spotify to actually forget. A more principled follow-up (adaptive cooldown that scales with the previous ban size, plus making the first post-cooldown call a single light probe rather than allowing background workers through) is documented as a future PR if reports persist after this bump', page: 'dashboard' },
- { title: 'Tidal: Reject Silent Quality Downgrades', desc: 'Netti93 reported that with Tidal set to "HiRes only" and quality fallback disabled, tracks were still downloading successfully — as m4a 320kbps files. Root cause: Tidal\'s API silently serves whatever tier your account + the track + your region permits. Ask for HI_RES_LOSSLESS on a track that\'s only in LOW_320K and Tidal returns the AAC stream without raising. The downloader wrote the m4a to disk, the filesize cleared the 100KB stub threshold, and the download reported success. The worker-level fallback chain (hires → lossless → high → low) also never got a chance to advance, because every tier "succeeded" at the first one that returned anything. Fix: after getting the stream, compare stream.audio_quality against what we requested using a rank-based tier comparison (LOW < HIGH < LOSSLESS < HI_RES < HI_RES_LOSSLESS). Same tier or better = accept (so occasional Tidal upgrades don\'t get thrown away). Lower tier = treat this tier as failed, which lets the fallback chain advance when fallback is enabled or fails the whole download honestly when the user has "HiRes only, no fallback" configured. Unrecognized audioQuality values (a new Tidal tier we haven\'t mapped yet) are rejected conservatively so the final diagnostic log can name the unknown value. Older tidalapi builds without the audio_quality attribute fall through to the pre-existing codec / file-size guards so nothing regresses', page: 'downloads' },
- { title: 'Search Source Picker Icon Row', desc: 'The Search page now has a row of source icons above the search bar — one per source (Spotify, Apple Music, Deezer, Discogs, Hydrabase, MusicBrainz, Music Videos, Soulseek). Typing searches only the currently-selected source instead of fanning out to every one by default. Click a different icon to switch; results come back on demand. The default icon on page load is your configured primary metadata source. Replaces the short-lived "Search from" dropdown that preceded this', page: 'search' },
- { title: 'Per-Query Source Cache (No More Re-Fetching)', desc: 'Once you\'ve searched a source for a given query, switching back to it is instant — results are cached for the current query. A small dot on each source icon shows which ones already have cached results this query. Type a new query and the whole cache resets. Same behavior in the sidebar global search popover. Net effect: roughly 6-7x fewer API calls per search compared to the old default fan-out', page: 'search' },
- { title: 'Global Search Widget Source Parity', desc: 'The sidebar Cmd+K / "/" search popover gained the same source icon row as the full Search page. Pick your source up front, see cache dots for already-fetched sources this query, and the rate-limit fallback banner appears if the backend substituted a different source than the one you clicked. Clicking the Soulseek icon hands off to the full Search page (raw file results need more room than the popover provides)', page: 'search' },
- { title: 'Rate-Limit Fallback Banner', desc: 'If you click Spotify but the backend auto-fell back to Deezer because Spotify was rate-limited, the search results now lead with a small amber banner ("Spotify unavailable — showing Deezer.") and the Spotify icon gets an amber border. Previously results just silently showed as the fallback source with no signal that anything unusual happened', page: 'search' },
- { title: 'Explicit Source Selection on /api/enhanced-search', desc: 'The enhanced-search endpoint now accepts an optional `source` body param (spotify, itunes, deezer, discogs, hydrabase, musicbrainz, auto). When a specific source is chosen, only that provider is queried and db_artists (local library matches) still come back. Cache keys isolate per-source so single-source and multi-source results don\'t collide. Omitted or `auto` preserves the old multi-source fan-out behavior unchanged — nothing breaks for existing callers', page: 'search' },
- { title: 'Shared Enhanced-Search Fetch Helper', desc: 'Internal refactor — the Search page dropdown and the global search widget now route through one shared enhancedSearchFetch helper in search.js instead of duplicating the POST boilerplate. Zero UX change, but it means any future source-picker tweak only needs wiring in one place', page: 'search' },
- { title: 'Search Page Renamed to /search', desc: 'The Search page\'s internal id is now "search" instead of the confusing "downloads" (which clashed with the actual Downloads page). Sidebar label unchanged. URL is now /search; /downloads still resolves so old bookmarks keep working. Profile ACL "Page Access" now saves as "search"; existing profiles with "downloads" in allowed_pages still resolve through a legacy-compat check', page: 'search' },
- { title: 'Embedded Download Manager Removed from Search Page', desc: 'The Search page used to carry a second copy of the Download Manager (active + finished queues, clear/cancel-all buttons) that was hidden by default and duplicated the dedicated Downloads page. That duplicate is gone — toggle button, side-panel HTML, and its 1-second polling loop all removed. About 330 lines of dead code cleaned up. The dedicated Downloads sidebar page is now the single downloads UI', page: 'search' },
- { title: 'Artists Sidebar Entry Retired — Use Search Instead', desc: 'Cin flagged that "Artists" in the sidebar read like a library section but was actually a dedicated artist-search page, duplicating what the unified Search already does. The sidebar entry is gone. New flow: Sidebar → Search → type artist name → click their result. "Browse Artists" on the empty Watchlist page and "View artist from Wishlist" now open Search pre-filled with the artist\'s name. Removed "Artists" from profile Home Page + Page Access options. Deep link to /artists still resolves so old bookmarks keep working — the page just isn\'t promoted anywhere', page: 'search' },
- { title: 'Artist Detail Back Button Fallback', desc: 'The back button on the Artists-page inline detail view used to dump users on an empty "Search for an artist..." screen when they arrived from outside the Artists page — a dead end now that Artists isn\'t in the sidebar. If you searched inside the Artists page, back still returns to your results list. Otherwise (arriving from Search, Discover, Watchlist, etc.), back uses the browser history to land you on whichever page you came from. Falls back to the Search page only when there\'s no browser history to go back to (the natural place to find another artist)', page: 'search' },
- { title: 'Interactive Help Updated for Unified Search', desc: 'The click-for-help annotations and the "Your First Download" guided tour were rewritten for the new Search page. Stale annotations pointing at removed elements (Basic/Enhanced toggle button, side-panel queues, download-manager controls) are deleted. The first-download tour now runs on /search and opens with the source picker. PAGE_TOUR_MAP accepts both "search" and the legacy "downloads" id so old bookmarks still match a tour. Retired the standalone "Browse Artists" tour', page: 'help' },
- { title: 'Unified Source-Picker Controller (Search Page + Global Widget)', desc: 'Internal refactor — the source picker state machine (query, active source, per-query cache, fallbacks, loading state, configured-source discovery) is now a single createSearchController factory in shared-helpers.js. Both the full Search page and the sidebar global search popover consume the same controller with per-surface wiring (DOM elements, Soulseek handoff, unconfigured-source click). About 380 lines of near-duplicated state + fetch + render code consolidated into one implementation, so a bug fix or behavior tweak to the picker lands everywhere at once. Zero UX change — every keystroke, icon click, cache hit, rate-limit fallback, and unconfigured-source redirect behaves identically to before', page: 'search' },
- { title: 'Fix Clean Search History Automation Failing with AttributeError', desc: 'The hourly Clean Search History maintenance automation was crashing with "DownloadOrchestrator object has no attribute base_url". Root cause: the check `soulseek_client.base_url` was written before the orchestrator refactor — `soulseek_client` is now a DownloadOrchestrator that wraps individual download clients, with the real Soulseek client at `.soulseek`. Two other call sites in web_server.py already used the correct `soulseek_client.soulseek.base_url` pattern; this one was missed. Now matches the same getattr-guarded pattern and the hourly cleanup runs again', page: 'stats' },
- { title: 'Search Results Always Visible — Show/Hide Button Removed', desc: 'The "Show Results / Hide Results" toggle next to the search bar is gone. There was nothing else on the page worth seeing instead of results, so toggling visibility never made sense. Cin flagged it during PR review. Dropdown visibility is now a pure function of query state — empty input hides it, results show it', page: 'search' },
- { title: 'Cached Search Results Restore on Navigate-Back', desc: 'Previously, navigating away from /search via a sidebar link dismissed the dropdown (the click registered as outside-click). When you came back, the input still held your query but the results were hidden until you typed again or clicked Show Results. Now the per-query cache renders automatically when you re-enter /search, so your results are right where you left them. Cin flagged the round-trip during PR review', page: 'search' },
- { title: 'Fix Soulseek Handoff from Global Search Going Through Metadata Flow', desc: 'When you clicked the Soulseek icon in the sidebar global search popover, it navigated to /search and wrote the query into the enhanced-search input — which then ran the metadata flow against whatever your default source was (Spotify, Deezer, etc.) instead of the raw Soulseek file search you actually wanted. Cin flagged it during PR review. Now the handoff pre-fills the basic-search input directly and clicks the Search page\'s Soulseek icon so the controller\'s onSoulseekSelected callback owns the section swap and runs performDownloadsSearch with the right query', page: 'search' },
- { title: 'Stale Search Requests No Longer Flash Empty Results on Fast Retype', desc: 'Cin flagged a race in createSearchController: when you typed a query then quickly re-typed before the first fetch returned, the first fetch\'s catch block (firing on AbortError after the second submitQuery aborted it) cleared loadingSources and notified the UI, causing a brief flash of empty/error state while the new query\'s fetch was still mid-flight. Added a monotonic _requestSeq token — each fetch captures the next value, and stale completions bail before mutating shared state. The controller still aborts in-flight fetches on supersession; this just keeps the abort-cleanup of the old request from clobbering the new one\'s spinner', page: 'search' },
- { title: 'Source Picker Dims Soulseek When slskd Isn\'t Configured', desc: 'Cin pointed out that the Soulseek icon was always rendered as configured, so users without slskd set up could click it and fire searches that would never succeed. Soulseek is now in the backend config-status registry as `required: [slskd_url]` and removed from the frontend\'s always-configured set. Without slskd, the icon dims and clicking it routes to Settings → Downloads tab (where the slskd URL field lives, gated behind the download-source dropdown) instead of Settings → Connections', page: 'search' },
- { title: 'Fix Discover Hero "View Discography" 404ing on Source Artists', desc: 'Clicking "View Discography" on the Discover page hero slideshow was calling navigateToArtistDetail without a source, so /api/artist-detail defaulted to a library lookup and returned 404 for artists that don\'t exist in your library (which is nearly every hero artist — they come from discover similar-artists, not the library). Regression from the unification PR that rewrote the click handler to route to /artist-detail but forgot to pass the source. Backend already sends artist.source on each hero entry; we now stash it as data-source on the discography button and thread it through to navigateToArtistDetail so the API call includes source=itunes/deezer/etc. and returns the synthesized discography', page: 'discover' },
- { title: 'MusicBrainz Search Actually Works Now', desc: 'kettui flagged during PR #371 review that the MusicBrainz source tab never returned artists and served garbage tracks/albums. Three things were wrong. First, the artist search was hardcoded to return an empty list — re-enabled with a proper fuzzy query (bare Lucene string against alias/artist/sortname indexes) and score-filtered at 80+ to drop tribute bands. Second, track and album searches used text-search on recording/release TITLES — so typing "metallica" matched random tracks literally named "Metallica" (all scoring 100 because they\'re exact title hits). Now a bare name query resolves to the top-scoring artist, then BROWSES that artist\'s release-groups and recordings directly — the same pattern Plex uses. Structured "Artist - Title" queries still take the text-search path since the user gave an explicit title to match. Third, the adapter was firing synchronous Cover Art Archive HEAD requests (up to 30s of blocking probes per search) — replaced with deterministic URL construction so the browser loads images lazily with fallback. Search completes in ~3 seconds instead of 30+ on cold cache. Also shipped: project URL in User-Agent per MB\'s rate-limit policy recommendations', page: 'search' },
- { title: 'MusicBrainz Search Follow-Ups (Images, Counts, Title Hints)', desc: 'Three fixes from kettui\'s follow-up pass on the MusicBrainz search PR. (1) Artist images were missing because MB doesn\'t store artist art — the lazy-load endpoint now accepts an optional `name` query param and resolves images by searching iTunes/Deezer for that artist name. (2) Track total_tracks was off by one because the counter initialized at 1 before summing release media track-counts — an 11-track album reported 12. Initialized to 0 now, with a special case for standalone recordings that have no release (report 1). (3) Queries like "The Beatles Abbey Road" used to browse The Beatles\' whole discography because the artist-first path resolved the artist and ignored the trailing title. Now extracts the title hint from queries shaped like "Artist Title", filters browse results to match, and falls back to text-search when no browse result matches (so "The Beatles Totally Fake Album" still finds something rather than nothing). 10 new tests covering title-hint extraction, browse-filter behavior, total_tracks edge cases', page: 'search' },
+ { title: 'Reorganize Queue Polish', desc: 'cleaned up some race conditions in the reorganize queue. cancel + bulk dedupe behavior is solid now. preview button no longer gets stuck disabled on errors.', page: 'library' },
+ { title: 'Reorganize Queue with Live Status Panel', desc: 'reorganize is now a queue with a live status panel. spam-click all you want — items run one at a time and you can keep browsing while they go. expand the panel to see queue + cancel buttons.', page: 'library' },
+ { title: 'Album Completeness Job Actually Works', desc: 'completeness job was finding zero issues for everyone. now it works — uses real expected track counts from your metadata source instead of comparing your library to itself.', page: 'library' },
+ { title: 'Reorganize Routes Through the Download Pipeline', desc: 'reorganize now uses the same pipeline downloads use. fixes 3-disc albums collapsing to single-disc and tracks silently disappearing on you. extracted to core/library_reorganize.py.', page: 'library' },
+ { title: 'Spotify: Longer Post-Ban Cooldown', desc: 'bumped the post-ban cooldown from 5 to 30 minutes. first call after a ban was getting re-banned within seconds because spotify\'s memory outlasts the cooldown.', page: 'dashboard' },
+ { title: 'Tidal: No More Silent Quality Downgrades', desc: 'tidal was silently serving 320kbps when you asked for hires. now it rejects the downgrade and the fallback chain advances properly — or fails honestly if you have "hires only, no fallback" set.', page: 'downloads' },
+ { title: 'Search Source Picker Icon Row', desc: 'search page now has a row of source icons above the bar — one per source. typing only searches the active source instead of fanning out to all of them. click another icon to switch.', page: 'search' },
+ { title: 'Per-Query Source Cache', desc: 'switching back to a source you already searched is instant — results are cached for the current query. cache resets when you type a new query. ~6-7x fewer api calls per search.', page: 'search' },
+ { title: 'Global Search Widget Source Parity', desc: 'the sidebar global search popover got the same source icon row + cache dots + fallback banner as the full search page.', page: 'search' },
+ { title: 'Rate-Limit Fallback Banner', desc: 'if the backend swaps your selected source for a working one (e.g. spotify rate-limited → deezer), you get a small amber banner explaining the swap. icon for the failed source gets an amber border.', page: 'search' },
+ { title: 'Explicit Source Selection on /api/enhanced-search', desc: 'enhanced-search endpoint takes a source param now to skip the fan-out backend-side. cache keys isolate per-source so single and multi-source results don\'t collide.', page: 'search' },
+ { title: 'Shared Enhanced-Search Fetch Helper', desc: 'internal — search dropdown and global widget share one fetch helper now instead of duplicating the post boilerplate.', page: 'search' },
+ { title: 'Search Page Renamed to /search', desc: 'search page is now /search instead of the confusing /downloads (which clashed with the actual downloads page). old urls still work.', page: 'search' },
+ { title: 'Embedded Download Manager Removed from Search Page', desc: 'killed the duplicate download manager on the search page (~330 lines of dead code). dedicated downloads page is the only one now.', page: 'search' },
+ { title: 'Artists Sidebar Entry Retired', desc: 'removed the artists sidebar entry — unified search already does what it did. old /artists urls still resolve.', page: 'search' },
+ { title: 'Artist Detail Back Button Fallback', desc: 'back button on inline artist detail uses browser history when you arrived from outside the artists page, instead of dumping you on an empty artists search.', page: 'search' },
+ { title: 'Interactive Help Updated for Unified Search', desc: 'rewrote the click-for-help annotations and the first-download tour for the new search page. retired the standalone browse-artists tour.', page: 'help' },
+ { title: 'Unified Source-Picker Controller', desc: 'internal — search page and global widget share one controller now (~380 lines of duplicate state/fetch/render code gone). bug fixes land everywhere at once.', page: 'search' },
+ { title: 'Fix Clean Search History Automation Crashing', desc: 'hourly clean-search-history automation was crashing on a stale base_url path. fixed.', page: 'stats' },
+ { title: 'Search Results Always Visible', desc: 'killed the show/hide results toggle. visibility is just based on whether you\'ve typed a query.', page: 'search' },
+ { title: 'Cached Search Results Restore on Navigate-Back', desc: 'leaving and coming back to /search now re-renders your last query\'s results from cache instead of hiding them.', page: 'search' },
+ { title: 'Fix Soulseek Handoff from Global Search', desc: 'clicking soulseek in the global search popover used to run metadata search against your default source instead of basic file search. fixed.', page: 'search' },
+ { title: 'Stale Search Requests No Longer Flash Empty', desc: 'fast retypes used to flash an empty state for a moment while the new fetch was still mid-flight. added a request-sequence token so old responses don\'t clobber new ones.', page: 'search' },
+ { title: 'Soulseek Icon Dims When slskd Isn\'t Configured', desc: 'soulseek icon dims if you don\'t have slskd set up. clicking it routes to settings → downloads instead of failing silently.', page: 'search' },
+ { title: 'Fix Discover Hero View Discography 404', desc: 'view discography on the discover hero was 404ing for non-library artists. fixed by passing the source through to /api/artist-detail.', page: 'discover' },
+ { title: 'MusicBrainz Search Actually Works', desc: 'musicbrainz search was returning empty/garbage results and taking 30+ seconds. rewrote it — artist, track, and album searches all work now and complete in ~3 seconds on cold cache.', page: 'search' },
+ { title: 'MusicBrainz Search Follow-Ups', desc: 'three more musicbrainz fixes — artist images now resolve via itunes/deezer fallback, total_tracks off-by-one fixed, and "artist title" queries no longer browse the whole discography.', page: 'search' },
],
'2.39': [
// --- April 22, 2026 ---
@@ -3696,284 +3696,266 @@ const WHATS_NEW = {
// usage_note?: 'optional hint shown at the bottom' }
const VERSION_MODAL_SECTIONS = [
{
- title: "Reorganize Queue: Race-Condition Hardening (kettui Review)",
- description: "Three concurrency / dedupe issues kettui caught in his review of PR #377, plus two related polish items from the same pass.",
+ title: "Reorganize Queue Polish",
+ description: "cleaned up some race conditions in the queue. behavior is solid now.",
features: [
- "• Worker pick + status flip is now atomic — fixes a window where a cancel() landing between 'pick next queued' and 'flip to running' could mark an item cancelled but the worker still ran it",
- "• Replaced the lock + wakeup-event pair with a single threading.Condition so newly-queued items can't sleep up to 60s waiting for the next wakeup tick (the old pair had an empty-check / clear-event race)",
- "• enqueue_many now holds the queue lock for the whole batch and tracks a per-batch seen set, so duplicate album_ids inside one bulk call are deduped against each other (not just against pre-existing items)",
- "• Reorganize-preview Apply button no longer gets stuck disabled when an early return / network error skipped the re-enable line — moved into a finally",
- "• DB helpers get_album_display_meta and get_artist_albums_for_reorganize now let exceptions bubble instead of swallowing them as 'not found' / empty list — a real DB outage now surfaces as a 500 to the user instead of looking like a missing album",
+ "• worker pick + status flip is atomic now — cancel can\'t land between them and let a cancelled item still run",
+ "• swapped lock + wakeup-event for a single threading.Condition — newly-queued items don\'t sleep up to 60s anymore",
+ "• bulk enqueue dedupes within a single batch (was only deduping against pre-existing items)",
+ "• reorganize-preview Apply button no longer gets stuck disabled on errors",
+ "• db helpers let exceptions bubble instead of swallowing them as \"album not found\"",
],
},
{
title: "Reorganize Queue with Live Status Panel",
- description: "Reorganizing albums is no longer a foreground operation that locks the page. Click → enqueue → keep working. A status panel surfaces live progress.",
+ description: "reorganize is now a queue with a live status panel. spam-click all you want — items run one at a time and you can keep browsing.",
features: [
- "• Per-album Reorganize and Reorganize All both enqueue into a single FIFO queue with a backend worker that drains one item at a time",
- "• Buttons stay clickable — spam-clicking the same album silently dedupes (returns 'already queued' instead of 409-ing)",
- "• Status panel at the top of the artist actions bar shows: active item (progress bar, current track, moved/skipped/failed counts), queued count, and recently-finished items with success/warning indicators",
- "• Click the panel to expand: full queue list with per-item cancel buttons; running item can't be cancelled mid-flight (Python threads aren't cleanly killable, post-process spawns subprocesses)",
- "• 'Cancel All' button drops every queued item at once — the running one continues",
- "• Items belonging to a different artist than the page you're on are flagged with the artist name so cross-artist progress is obvious",
- "• Each queued item carries its own metadata source pick (Spotify / iTunes / Deezer / Discogs / Hydrabase) — switching modal selections per album works",
- "• 'Reorganize All' is now one backend call instead of N JS-driven calls — the loop runs server-side and is much faster",
- "• Continue-on-failure: a single failed album never stalls the queue; the worker logs and moves on",
- "• Retired the old single-slot reorganize state endpoint plus the polling loops that depended on it",
+ "• per-album reorganize and reorganize all both enqueue into a single backend queue",
+ "• buttons stay clickable — clicking the same album twice silently dedupes",
+ "• status panel shows active progress, queued count, and recent finishes",
+ "• expand the panel for the full queue + per-item cancel buttons (running items can\'t be cancelled mid-flight)",
+ "• cross-artist items get tagged so you know what\'s queued from where",
+ "• continue-on-failure: one bad album never stalls the queue",
+ "• reorganize all is now one backend call instead of N js-driven calls — way faster",
],
},
{
title: "Fix Wrong-Artist Tracks Silently Downloading",
- description: "A critical bug where searching for a track could silently download a completely different artist's song with the same name",
+ description: "searching for a track could silently download a completely different artist\'s song with the same name. fixed at two layers.",
features: [
- "• Example: searching 'Maduk — Leave A Light On' on Tidal was downloading Tom Walker's unrelated song of the same name, then embedding Maduk's metadata into Tom Walker's audio",
- "• Root cause 1: candidate artist gate used `< 0.4` similarity but Maduk/Tom Walker scored exactly 0.400, slipping past the fencepost — raised to `< 0.5`",
- "• Root cause 2: AcoustID verification returned SKIP (accept) instead of FAIL (quarantine) when title matched but artist was clearly different — now FAILs when artist similarity is below 0.3",
- "• Preserves SKIP for the ambiguous 0.3–0.6 range (covers, collabs, formatting differences) so legitimate tracks aren't falsely quarantined",
- "• Both pre-download candidate validation AND post-download verification are now fixed — defense in depth",
+ "• example: \"maduk — leave a light on\" on tidal was downloading tom walker\'s song of the same name with maduk\'s metadata embedded",
+ "• tightened the candidate artist gate (was letting through 0.4 similarity, now blocks at 0.5)",
+ "• acoustid verification now FAILs (quarantines) clear artist mismatches instead of accepting them",
+ "• ambiguous matches (covers, collabs) still get the benefit of the doubt — only obvious mismatches get blocked",
],
},
{
title: "Tidal Search Falls Back on Long Queries",
- description: "Tidal's search chokes on long remix-credit queries — now retries with progressively-shortened variants when the original returns 0 results",
+ description: "tidal\'s search chokes on long remix-credit queries. now retries with shorter variants when the original returns 0 results.",
features: [
- "• Example: 'maduk transformations remixed fire away fred v remix' returned 0; now falls back to shorter queries until Tidal finds the track",
- "• Up to 4 shortened variants tried, capped total 5 requests, 100ms between attempts",
- "• Qualifier-safe: Live/Remix/Acoustic/Extended searches only accept fallback results that still contain the qualifier — studio version never replaces a '(Live)' request",
- "• Returns empty if no variant preserves qualifiers — same outcome as before",
+ "• example: \"maduk transformations remixed fire away fred v remix\" returned 0 — falls back to shorter queries until tidal finds the track",
+ "• up to 4 shortened variants tried, capped at 5 total requests",
+ "• qualifier-safe: live/remix/acoustic searches only accept fallback results that keep the qualifier",
+ "• returns empty if no variant preserves the qualifiers — same as before",
],
},
{
title: "Manual Discovery Fixes Persist Across Restart",
- description: "When you manually fix a discovery match, the fix is now saved under your active metadata source instead of always 'spotify' — so Deezer/iTunes/Discogs/Hydrabase users' fixes actually survive restart and re-scan",
+ description: "manual discovery fixes are now saved under your active metadata source instead of always \"spotify\" — so deezer / itunes / discogs / hydrabase users\' fixes survive restart.",
features: [
- "• Affected Tidal, Deezer, Spotify Public, YouTube, and Discovery Pool manual fixes",
- "• Symmetric with how the auto-discovery worker saves — no more mismatch",
- "• Existing Spotify-primary users unaffected (the hardcoded value matched their source)",
+ "• affects tidal, deezer, spotify public, youtube, and discovery pool manual fixes",
+ "• matches how the auto-discovery worker already saved",
+ "• spotify-primary users unaffected (hardcoded value matched their source)",
],
},
{
title: "Watchlist Content Filters Fixed",
- description: "Global Override settings and live-version detection now behave the way the UI implies",
+ description: "global override and live-version detection now behave the way the ui implies.",
features: [
- "• Scheduled auto-watchlist now honors Watchlist → Global Override (was bypassing it and using per-artist defaults)",
- "• 'Live' detection tightened — no more false positives on titles like 'What We Live For' or 'Live Forever'",
- "• Same fix applies to the Library Maintenance Live/Commentary Cleaner",
- "• Still catches (Live), - Live, Live at/from/in/on/version/session/recording, Unplugged, In Concert",
+ "• scheduled auto-watchlist honors watchlist → global override (was bypassing it)",
+ "• live detection tightened — no more false positives on titles like \"what we live for\"",
+ "• same fix applies to the library maintenance live/commentary cleaner",
+ "• still catches (live), - live, live at/from/in/on, unplugged, in concert",
],
},
{
title: "Discography Backfill",
- description: "New maintenance job that fills gaps in your library — scans each artist's full discography and finds what you're missing",
+ description: "new maintenance job that scans each artist\'s full discography and finds what you\'re missing.",
features: [
- "• Scans each artist in your library against metadata source discographies",
- "• Creates findings for missing tracks — review and click 'Add to Wishlist' to queue downloads",
- "• Respects all content filters (live, remix, acoustic, compilation, instrumental)",
- "• Release type filters (album, EP, single) with configurable defaults",
- "• Optional 'auto-add to wishlist' setting — create findings AND push to wishlist in one pass",
- "• 3-option fix prompt (Add to Wishlist / Just Clear / Cancel) for manual review",
- "• Batched in-memory library matching — same fast path the Library pages use",
- "• Opt-in, disabled by default — runs weekly, processes up to 50 artists per run",
- "• Rate-limited to avoid hammering metadata APIs",
+ "• scans each library artist against your metadata source",
+ "• creates findings for missing tracks — review and add to wishlist",
+ "• respects all content filters (live, remix, acoustic, etc.) and release type filters",
+ "• optional auto-add-to-wishlist setting for hands-off operation",
+ "• opt-in, runs weekly, processes up to 50 artists per run",
],
},
{
title: "Repair 'Run Now' Honored While Paused",
- description: "Force-running a repair job no longer stalls forever when the master repair worker is paused",
+ description: "force-running a repair job no longer stalls forever when the master worker is paused.",
features: [
- "• Jobs queued via 'Run Now' run to completion even if the master worker is paused",
- "• Fixes silent stalls where Discography Backfill logged 'scanning 50 artists' then did nothing",
- "• Master-pause still blocks scheduled runs — this only affects explicit user-triggered runs",
+ "• jobs queued via run now complete even if the master worker is paused",
+ "• fixes silent stalls where the job logged \"scanning 50 artists\" then did nothing",
+ "• master-pause still blocks scheduled runs — only affects user-triggered runs",
],
},
{
title: "Multi-Artist Tagging",
- description: "Enhanced control over how multiple artists are written to audio file tags",
+ description: "more control over how multiple artists are written to audio file tags.",
features: [
- "• Configurable artist separator: comma, semicolon, or slash",
- "• Multi-value ARTISTS tag for Navidrome/Jellyfin multi-artist linking",
- "• 'Move featured artists to title' mode — primary artist in ARTIST tag, others as (feat. ...) in title",
- "• All opt-in with defaults matching current behavior",
+ "• configurable separator: comma, semicolon, or slash",
+ "• multi-value ARTISTS tag for navidrome / jellyfin multi-artist linking",
+ "• \"move featured artists to title\" mode — primary in ARTIST tag, others as (feat. ...) in title",
+ "• opt-in, defaults match current behavior",
],
},
{
title: "Enriched Downloads Page",
- description: "Download cards now show rich metadata instead of just filenames",
+ description: "download cards now show rich metadata instead of just filenames.",
features: [
- "• Album artwork thumbnail on each download card",
- "• Artist name, album name, and source badge",
- "• Quality badge appears after post-processing",
- "• Falls back gracefully for transfers without metadata context",
+ "• album artwork thumbnail on each card",
+ "• artist name, album name, source badge",
+ "• quality badge appears after post-processing",
+ "• falls back gracefully for transfers without metadata context",
],
},
{
title: "Template Variable Delimiters",
- description: "Use ${var} syntax to append literal text to template variables",
+ description: "use ${var} syntax to append literal text to template variables.",
features: [
- "• ${albumtype}s produces 'Albums', 'Singles', 'EPs'",
- "• Both $var and ${var} syntaxes work in all templates",
- "• Validation updated to accept delimited variables",
+ "• ${albumtype}s produces \"Albums\", \"Singles\", \"EPs\"",
+ "• both $var and ${var} syntaxes work everywhere",
+ "• validation updated to accept delimited variables",
],
},
{
title: "Reorganize All Albums",
- description: "Bulk reorganize all albums for an artist from the enhanced library view",
+ description: "bulk reorganize all albums for an artist from the enhanced library view.",
features: [
- "• New 'Reorganize All' button in the artist header",
- "• Processes albums sequentially with progress toasts",
- "• Continues on error — one failed album doesn't block the rest",
- "• Uses the same template and endpoint as per-album reorganize",
+ "• new reorganize all button in the artist header",
+ "• processes sequentially with progress toasts",
+ "• continues on error — one failed album doesn\'t block the rest",
+ "• uses the same template + endpoint as per-album reorganize",
],
},
{
title: "SoulSync Standalone Library",
- description: "Use SoulSync without Plex, Jellyfin, or Navidrome — manage your library directly",
+ description: "use soulsync without plex, jellyfin, or navidrome — manage your library directly.",
features: [
- "• New 'Standalone' server option in Settings → Connections",
- "• Downloads and imports write artist/album/track to the library database immediately",
- "• Pre-populated enrichment IDs (Spotify, Deezer, MusicBrainz) — workers skip re-discovery",
- "• Deep scan finds untracked files in Transfer → moves to Staging for processing",
- "• Deep scan removes stale DB records when files are deleted from disk",
- "• Sync page and sync buttons hidden automatically in standalone mode",
- "• Full library page, artist detail, discography, and enhanced view all work standalone",
+ "• new standalone server option in settings → connections",
+ "• downloads and imports write to the library db immediately",
+ "• pre-populated enrichment ids — workers skip re-discovery",
+ "• deep scan finds untracked files and removes stale db records",
+ "• sync page hidden automatically in standalone mode",
+ "• full library / artist detail / discography all work standalone",
],
- usage_note: "Go to Settings → Connections and click the 'Standalone' button. No media server needed.",
+ usage_note: "settings → connections → standalone. no media server needed.",
},
{
title: "Auto-Import",
- description: "Background import folder watcher that automatically identifies and imports music into your library",
+ description: "background folder watcher that automatically identifies and imports music into your library.",
features: [
- "• Recursive scan — any folder depth (Artist/Album/tracks, Album/tracks, loose files)",
- "• Single file support — loose audio files identified via tags, filename, or AcoustID",
- "• Tag-based identification preferred over weak metadata matches (85% confidence for tagged files)",
- "• AcoustID fingerprinting fallback for untagged or ambiguous files",
- "• Stats bar, filter pills (All/Review/Imported/Failed), Scan Now, Approve All, Clear History",
- "• Expandable track match details with per-track confidence scores",
- "• Race condition fix prevents duplicate processing during multi-track albums",
+ "• recursive scan — any folder depth (artist/album/tracks, loose files, whatever)",
+ "• tag-based identification preferred, acoustid fingerprinting as fallback",
+ "• stats bar, filter pills, scan now, approve all, clear history",
+ "• expandable per-track match details with confidence scores",
+ "• race condition fix prevents duplicate processing on multi-track albums",
],
- usage_note: "Enable on the Import page Auto tab. Set your import folder in Settings.",
+ usage_note: "import page → auto tab. set your import folder in settings.",
},
{
title: "Wishlist Nebula",
- description: "Wishlist redesigned as an interactive artist orb visualization",
+ description: "wishlist redesigned as an interactive artist orb visualization.",
features: [
- "• Each artist is a glowing orb with their photo — album fans and single moons orbit around them",
- "• Click orbs to expand and see albums/singles, download directly from the nebula",
- "• Processing state shows live progress with spinning ring animation",
- "• Stats strip at top shows total artists, albums, singles, and tracks",
+ "• each artist is a glowing orb — albums and singles orbit around it",
+ "• click orbs to expand and download directly from the nebula",
+ "• live progress with spinning ring animation while processing",
+ "• stats strip up top: total artists, albums, singles, tracks",
],
- usage_note: "Click Wishlist in the sidebar to see the Nebula view.",
+ usage_note: "click wishlist in the sidebar.",
},
{
title: "Automation Group Management",
- description: "Organize and manage your automation groups with full control",
+ description: "organize and manage automation groups properly.",
features: [
- "• Rename, delete, and bulk-toggle automation groups from group headers",
- "• Drag-and-drop automations between groups to reorganize",
- "• Delete confirmation dialog with group name and automation count",
+ "• rename, delete, and bulk-toggle groups from the group header",
+ "• drag-and-drop automations between groups",
+ "• delete confirmation shows group name and automation count",
],
- usage_note: "Right-click or use the action buttons on group headers in the Automations page.",
+ usage_note: "use the action buttons on group headers in the automations page.",
},
{
title: "Bidirectional Artist Sync & Server Playlists",
- description: "Artist sync now goes both ways, and server playlists show full coverage",
+ description: "artist sync now goes both ways, and server playlists show full coverage.",
features: [
- "• Artist Sync pulls new content from your media server AND removes stale library entries",
- "• Deep scan mode fetches full metadata for newly discovered tracks",
- "• Server playlist view shows all playlists with clear synced vs unsynced visual separation",
+ "• artist sync pulls new content from your media server AND removes stale library entries",
+ "• deep scan mode fetches full metadata for newly-discovered tracks",
+ "• server playlist view shows all playlists with synced vs unsynced visual separation",
],
},
{
title: "Provider-Agnostic Discovery",
- description: "Discovery features work with any configured metadata source instead of requiring Spotify",
+ description: "discovery features work with any configured metadata source instead of requiring spotify.",
features: [
- "• Similar artist matching, discovery pool, and incremental updates use source priority",
- "• Falls back through Spotify, iTunes, and Deezer in configured order",
- "• MusicMap URL encoding fixed for artists with special characters",
- "• Freshness check simplified to age-based — backfill handles missing IDs separately",
+ "• similar artist matching, discovery pool, and incremental updates use source priority",
+ "• falls back through spotify, itunes, deezer in configured order",
+ "• musicmap url encoding fixed for artists with special characters",
+ "• freshness check simplified to age-based",
],
},
{
title: "Dashboard & Navigation",
- description: "Dashboard improvements and sidebar navigation enhancements",
+ description: "dashboard improvements and sidebar navigation enhancements.",
features: [
- "• Library Status card on Dashboard — shows server state, track counts, scan buttons",
- "• Tools page in sidebar — all maintenance tools moved from Dashboard modal",
- "• Watchlist and Wishlist promoted to full sidebar pages with live count badges",
- "• AcoustID scanner scans full library with actionable fix options (retag, redownload, delete)",
+ "• library status card on dashboard — server state, track counts, scan buttons",
+ "• tools page in sidebar — maintenance tools moved out of the dashboard modal",
+ "• watchlist and wishlist promoted to full sidebar pages with live count badges",
+ "• acoustid scanner scans full library with retag / redownload / delete fix options",
],
},
{
title: "MusicBrainz & Metadata Fixes",
- description: "Critical tag embedding fix and Picard-style album consistency",
+ description: "critical tag embedding fix and picard-style album consistency.",
features: [
- "• Fix: source ID tags (Spotify, MusicBrainz, Deezer, AudioDB) were silently skipped on every download — now embed correctly",
- "• Picard-style release preference scoring prevents Navidrome album splits",
- "• Source tags wiped when metadata enhancement is skipped or fails",
- "• Spotify API no longer called when Deezer/iTunes is the configured primary source",
+ "• source id tags (spotify, musicbrainz, deezer, audiodb) were silently skipped on every download — now embed correctly",
+ "• picard-style release preference scoring prevents navidrome album splits",
+ "• source tags wiped when metadata enhancement is skipped or fails",
+ "• spotify api no longer called when deezer/itunes is your primary source",
],
},
{
title: "Downloads & Soulseek Improvements",
- description: "Better download management, search accuracy, and queue control",
+ description: "better download management, search accuracy, and queue control.",
features: [
- "• Downloads batch panel — color-coded batch cards with progress, cancel, expand, and 7-day history",
- "• Soulseek search queries now include album name — reduces wrong-artist downloads",
- "• Reject Soulseek results from Various Artists/VA/Unknown Artist folders",
- "• Clearing wishlist now cancels the active wishlist download batch",
- "• Album delete with 'Delete Files Too' option on enhanced library page",
- "• Fix download modal freezing mid-download — M3U auto-save was exhausting server threads",
- "• Fix Unknown Artist when adding playlist tracks to wishlist",
- "• Fix slskd timeout spam when Soulseek is not the active download source",
+ "• downloads batch panel — color-coded cards with progress, cancel, expand, 7-day history",
+ "• soulseek queries include album name now — fewer wrong-artist downloads",
+ "• reject results from various artists / unknown artist folders",
+ "• clearing wishlist cancels the active wishlist download batch",
+ "• album delete with \"delete files too\" option on enhanced library",
+ "• fix download modal freezing mid-download (m3u auto-save was exhausting server threads)",
+ "• fix unknown artist when adding playlist tracks to wishlist",
],
},
{
title: "Recent Fixes",
- description: "Bug fixes from recent releases and community reports",
+ description: "smaller bug fixes from recent releases and community reports.",
features: [
- "• Fix watchlist scan false failures — empty discography no longer reported as error",
- "• Fix deezer_artist_id column error on enhanced library sync",
- "• Fix wishlist button intermittently not navigating to page",
- "• Fix worker orb tooltips rendering behind dashboard content",
- "• Fix OAuth callback port hardcoding — custom ports now respected",
- "• Fix allow duplicates setting not saving",
- "• Fix wishlist dropping cross-album tracks when duplicates enabled",
- "• Fix replace lower quality setting not persisting",
- "• Fix Spotify enrichment worker infinite loop on pre-matched artists",
- "• Reject Qobuz 30-second sample/preview downloads",
- "• Fix library page crash on All filter — non-string soul_id broke card rendering",
- "• Auto Wing It fallback for failed discovery — unmatched tracks download via Soulseek with raw metadata",
- "• Lidarr download source now production-ready — full orchestrator integration",
- "• Fix album track lookup hardcoded to Spotify — now uses configured primary source",
- "• Fix M3U showing all tracks as missing — regenerate with real paths after post-processing",
- "• Fix AcoustID retag not writing corrected tags to audio file",
- "• Fix wishlist albums cycle stuck at 1 concurrent worker instead of configured value",
- "• Fix downloads badge dropping to 300 after opening Downloads page",
- "• Fix server playlist Find & Add inserting at wrong position on Plex",
- "• Smarter Fix modal results — standard album versions sorted above live/remix/cover/soundtrack variants",
- "• Unmatch discovery tracks — red ✕ button to remove bad matches from playlist discovery",
- "• Customizable music video naming — path template with $artist, $title, $year variables",
- "• Fix soulseek log spam when not configured as download source",
+ "• fix watchlist scan false failures — empty discography no longer reported as error",
+ "• fix deezer_artist_id column error on enhanced library sync",
+ "• fix wishlist button intermittently not navigating",
+ "• fix worker orb tooltips rendering behind dashboard content",
+ "• fix oauth callback port hardcoding — custom ports respected now",
+ "• fix allow duplicates and replace-lower-quality settings not saving",
+ "• fix wishlist dropping cross-album tracks when duplicates enabled",
+ "• fix spotify enrichment worker infinite loop on pre-matched artists",
+ "• reject qobuz 30-second sample/preview downloads",
+ "• auto wing-it fallback for failed discovery",
+ "• fix album track lookup hardcoded to spotify — uses configured primary now",
+ "• fix m3u showing all tracks as missing after post-processing",
+ "• fix acoustid retag not writing corrected tags to file",
+ "• fix downloads badge dropping to 300 after opening downloads page",
+ "• unmatch discovery tracks (red ✕ button)",
+ "• customizable music video naming with $artist, $title, $year",
+ "• fix soulseek log spam when not configured as download source",
],
},
{
title: "Earlier in v2.3",
- description: "Major features from earlier in this release cycle",
+ description: "major features from earlier in this release cycle.",
features: [
- "• Centralized Downloads page with live-updating list and filter pills",
- "• First-Run Setup Wizard — 7-step guided configuration",
- "• Music Videos — search and download from YouTube",
- "• Inbound Music Request API for external tools (Discord bots, Home Assistant)",
- "• Lidarr download source (development) — 7th source for Usenet/torrent via Lidarr",
- "• Graceful shutdown — all workers respond to shutdown signals immediately",
- "• Unknown Artist prevention with 3-tier metadata fallback",
- "• Deezer multi-artist tagging using contributors field",
- "• Artist Map — Watchlist Constellation, Genre Map, and Artist Explorer canvas modes",
- "• Discogs integration — enrichment worker, fallback source, enhanced search tab",
- "• Wing It mode, Global Search Bar, Redesigned Notifications",
- "• Server Playlist Manager, Sync History Dashboard, Playlist Explorer",
- "• Enhanced Library Manager with inline tag editing and write-to-file",
- "• Automation Signals, Multi-Source Search Tabs, Rich Artist Profiles",
+ "• centralized downloads page with live-updating list and filter pills",
+ "• first-run setup wizard — 7-step guided configuration",
+ "• music videos — search and download from youtube",
+ "• inbound music request api for external tools (discord bots, home assistant)",
+ "• lidarr download source (in development) for usenet / torrent",
+ "• graceful shutdown — all workers respond to shutdown signals immediately",
+ "• unknown artist prevention with 3-tier metadata fallback",
+ "• deezer multi-artist tagging via contributors field",
+ "• artist map — watchlist constellation, genre map, artist explorer",
+ "• discogs integration — enrichment worker, fallback source, search tab",
+ "• wing it mode, global search bar, redesigned notifications",
+ "• server playlist manager, sync history dashboard, playlist explorer",
+ "• enhanced library manager with inline tag editing and write-to-file",
+ "• automation signals, multi-source search tabs, rich artist profiles",
],
},
];
From 013eebf350dadfc3c1ec1b8675e0806381b3ec6a Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Sun, 26 Apr 2026 16:27:10 -0700
Subject: [PATCH 004/145] =?UTF-8?q?Lock=20down=20Socket.IO=20CORS=20?=
=?UTF-8?q?=E2=80=94=20same-origin=20default=20+=20opt-in=20allow-list?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes #366 (reported by JohnBaumb).
Socket.IO was initialized with `cors_allowed_origins='*'`, accepting
WebSocket connections from any origin. A malicious site could open a
WS to a user's local SoulSync instance and exfiltrate live progress /
toast / activity events.
This commit:
- Defaults to engineio's same-origin behavior (`cors_allowed_origins=None`),
which automatically honors X-Forwarded-Host so reverse proxies that
send that header (Caddy / Traefik by default, properly-configured
Nginx) work transparently.
- Adds a `security.cors_origins` config setting + Settings → Security
textarea where users behind unusual proxies / Electron wrappers /
cross-origin integrations can whitelist their origin. Accepts comma
or newline separated values; `*` on its own line opts back into the
legacy wildcard with a startup-warning log.
- Logs a clear warning the first time engineio rejects each unique
origin, naming the rejected Origin and request Host and pointing
users to the settings field. Without this, engineio silently 403s
the upgrade and the user just sees a half-broken UI with no clue
why. Threadsafe dedup so a hostile origin can't spam logs.
Logic lives in `core/socketio_cors.py` (resolver, rejection
predictor, dedup logger class, startup-status emitter) — pure
functions, no Flask dependency. `web_server.py` adds 23 lines of
wiring and imports.
Important catch during review: my first pass used `cors_allowed_origins=[]`
as the "secure default." Reading engineio's source revealed `[]` actually
means "DISABLE CORS HANDLING" (engineio/server.py:202: `if cors_allowed_origins != []:`)
— identical security to `'*'`. Fixed to use `None` (engineio's actual
same-origin sentinel) and pinned with a regression test that asserts
the resolver never returns `[]` for any input shape.
Tests:
- tests/test_socketio_cors.py — 45 unit tests covering 19 resolver shape
cases (None, empty, whitespace, comma, newline, garbage types, lists),
the `[]`-must-never-be-returned security regression, 12 rejection
prediction cases, X-Forwarded-Host handling, dedup logger behavior,
threadsafe race (8 threads × 50 hammers → exactly 1 warning), and
startup-status emitter outputs.
Frontend:
- Settings → Security gains an "Allowed WebSocket Origins" textarea
with help text explaining same-origin default + when to add a domain
+ the `*` opt-out.
- helper.js — new '2.4.1' WHATS_NEW block (hidden until version bump)
with a chill-voice entry describing the change.
Conftest.py left at `'*'` — test environment, no security concern.
598 tests pass.
---
core/socketio_cors.py | 191 +++++++++++++++++++++
tests/test_socketio_cors.py | 333 ++++++++++++++++++++++++++++++++++++
web_server.py | 25 ++-
webui/index.html | 8 +
webui/static/helper.js | 5 +
webui/static/settings.js | 6 +
6 files changed, 567 insertions(+), 1 deletion(-)
create mode 100644 core/socketio_cors.py
create mode 100644 tests/test_socketio_cors.py
diff --git a/core/socketio_cors.py b/core/socketio_cors.py
new file mode 100644
index 00000000..61b4e4ce
--- /dev/null
+++ b/core/socketio_cors.py
@@ -0,0 +1,191 @@
+"""Socket.IO CORS allow-list resolution + rejection logging.
+
+Three concerns lifted out of `web_server.py`:
+
+- :func:`resolve_cors_origins` — read the user's
+ ``security.cors_origins`` config setting (string, list, or unset) and
+ return what to hand to Flask-SocketIO's ``cors_allowed_origins``
+ parameter: an empty list (same-origin only), the literal ``'*'``
+ (wildcard, opt-in), or a list of explicit origin URLs.
+
+- :func:`will_reject` — predict whether engineio's CORS check will
+ reject a request, given the resolved allow-list, the request's
+ ``Origin`` header, and the request's ``Host`` header. Used to log a
+ helpful warning *before* engineio silently 403s a WebSocket upgrade.
+ (Without this, the user just sees a half-broken UI with no live
+ updates and nothing in the logs explaining why.)
+
+- :class:`RejectionLogger` — threadsafe dedup wrapper around the warning
+ emitter. Each unique origin is logged once per process so a malicious
+ site repeatedly hammering the WS endpoint can't spam logs.
+
+Pure logic, no Flask app dependency. Web_server.py imports these and
+wires them into the SocketIO init + a Flask ``before_request`` hook.
+"""
+
+from __future__ import annotations
+
+import threading
+from typing import Any, List, Optional, Set, Union
+
+
+# What ``cors_allowed_origins`` accepts and what we hand to Flask-SocketIO:
+#
+# - ``None`` → engineio's same-origin default. engineio computes the
+# allowed origin list from the request itself: ``scheme://HTTP_HOST``
+# plus ``X-Forwarded-Proto://X-Forwarded-Host`` when those headers are
+# present. Reverse proxies that set X-Forwarded-Host (Nginx with
+# ``proxy_set_header X-Forwarded-Host`` — and Caddy/Traefik by default)
+# work transparently. THE SECURE DEFAULT.
+#
+# - ``'*'`` → allow any origin. Insecure; opt-in only.
+#
+# - ``[origin, ...]`` → explicit allow-list. For setups whose Origin
+# matches neither the backend's Host nor any forwarded header.
+#
+# IMPORTANT: do NOT use ``[]``. In engineio that means "disable CORS
+# handling entirely" (server.py:202: ``if cors_allowed_origins != []:``)
+# which is identical to the ``'*'`` wildcard from a security standpoint.
+ResolvedOrigins = Union[List[str], str, None]
+
+
+def resolve_cors_origins(config_manager: Any) -> ResolvedOrigins:
+ """Resolve the configured Socket.IO allow-list.
+
+ Reads ``security.cors_origins`` from ``config_manager`` and normalizes
+ whatever shape the user typed (or didn't) into one of three values:
+
+ - ``None`` (the secure default). Hand to Flask-SocketIO and engineio
+ enforces same-origin, with automatic support for X-Forwarded-Host
+ so reverse-proxy users don't need to configure anything.
+ - ``'*'`` — literal wildcard. Allows any origin. Insecure; opt-in.
+ - ``[origin, ...]`` — list of explicit origin URLs. For users behind
+ a proxy that doesn't send the forwarded headers OR for custom
+ contexts (Electron wrappers, browser extensions).
+
+ Accepts the config value as either a string (comma OR newline
+ separated, since the settings UI is a textarea) or a list. Anything
+ else falls back to ``None`` — the secure default.
+ """
+ raw = config_manager.get('security.cors_origins', None) if config_manager else None
+ if raw is None:
+ return None
+ if isinstance(raw, str):
+ if not raw.strip():
+ return None
+ parts = [p.strip() for p in raw.replace('\n', ',').split(',')]
+ elif isinstance(raw, (list, tuple)):
+ parts = [str(p).strip() for p in raw]
+ else:
+ return None
+ parts = [p for p in parts if p]
+ if not parts:
+ return None
+ if any(p == '*' for p in parts):
+ return '*'
+ return parts
+
+
+def will_reject(
+ allowed: ResolvedOrigins,
+ origin: str,
+ host: str,
+ forwarded_host: str = '',
+) -> bool:
+ """Predict whether engineio's CORS check will reject this request.
+
+ Mirrors engineio's allow-list / same-origin logic so callers can log
+ a helpful warning *before* the rejection happens. Returns ``True``
+ when the request will be rejected.
+
+ Same-origin check: ``Origin``'s ``host[:port]`` portion matches the
+ request's ``Host`` header OR the ``X-Forwarded-Host`` header. Engineio
+ checks both when ``cors_allowed_origins`` is ``None``; we mirror that
+ so reverse-proxy users with proper proxy headers don't trigger
+ spurious "rejected" log lines.
+ """
+ if allowed == '*':
+ return False
+ if isinstance(allowed, list) and origin in allowed:
+ return False
+ # Origin is "scheme://host[:port][/path]"; pull just host[:port].
+ origin_host = origin.split('://', 1)[-1].split('/', 1)[0]
+ if host and origin_host == host:
+ return False
+ if forwarded_host and origin_host == forwarded_host.split(',')[0].strip():
+ return False
+ return True
+
+
+class RejectionLogger:
+ """Threadsafe dedup wrapper that logs each rejected origin only once.
+
+ Engineio silently 403s WebSocket upgrades from disallowed origins.
+ Without a log line the user sees a half-broken UI (no live progress,
+ no toasts) and has no idea what's wrong. This class watches incoming
+ requests via :meth:`maybe_log` and emits a clear warning the first
+ time each unique origin appears, telling the user where to add it.
+
+ Bounded by the number of unique origins ever attempted; cleared on
+ process restart. The dedup is intentional — a malicious site
+ hammering the endpoint shouldn't be able to spam logs.
+ """
+
+ def __init__(self, logger: Any):
+ self._logger = logger
+ self._seen: Set[str] = set()
+ self._lock = threading.Lock()
+
+ def maybe_log(
+ self,
+ allowed: ResolvedOrigins,
+ origin: Optional[str],
+ host: str,
+ forwarded_host: str = '',
+ ) -> bool:
+ """Log a rejection warning if applicable, deduped.
+
+ Returns ``True`` if a warning was emitted this call. Designed to
+ be safe to call from a Flask ``before_request`` hook on every
+ Socket.IO request — it short-circuits early on requests that
+ won't be rejected (no Origin header, allowed origin, same-origin
+ match against either Host or X-Forwarded-Host).
+ """
+ if not origin:
+ return False # Non-browser clients (curl, server-to-server)
+ if not will_reject(allowed, origin, host, forwarded_host):
+ return False
+ with self._lock:
+ if origin in self._seen:
+ return False
+ self._seen.add(origin)
+ self._logger.warning(
+ f"[Socket.IO] Rejecting WebSocket connection from origin '{origin}' "
+ f"(request Host='{host}'). If this is your reverse-proxy or custom "
+ f"domain, add it to Settings → Security → Allowed WebSocket Origins."
+ )
+ return True
+
+ def reset_for_tests(self) -> None:
+ """Clear the dedup cache. Test-only."""
+ with self._lock:
+ self._seen.clear()
+
+
+def log_startup_status(allowed: ResolvedOrigins, logger: Any) -> None:
+ """Emit a one-shot startup log line describing the resolved policy.
+
+ - For ``'*'`` (wildcard) → warning, since it's a security risk.
+ - For a non-empty list → info, so the user can confirm their config
+ took effect.
+ - For ``None`` (same-origin default) → silent. That's the default;
+ nothing noteworthy.
+ """
+ if allowed == '*':
+ logger.warning(
+ "[Socket.IO] cors_allowed_origins is set to '*' — any website can open "
+ "a WebSocket to this instance. Set Settings → Security → Allowed Origins "
+ "to a specific list (or leave empty for same-origin only) to lock this down."
+ )
+ elif allowed:
+ logger.info(f"[Socket.IO] Allowed cross-origin connections from: {allowed}")
diff --git a/tests/test_socketio_cors.py b/tests/test_socketio_cors.py
new file mode 100644
index 00000000..267260e0
--- /dev/null
+++ b/tests/test_socketio_cors.py
@@ -0,0 +1,333 @@
+"""Tests for `core.socketio_cors` — the resolver, rejection predictor,
+and dedup logger that gate Socket.IO WebSocket origins.
+
+These pin the security-relevant behavior:
+
+- The resolver returns ``None`` (engineio's same-origin default — also
+ the secure default) for anything other than an explicit allow-list or
+ the wildcard. CRITICAL: the resolver must NEVER return ``[]`` — in
+ engineio that means "disable CORS handling" which is identical to the
+ ``'*'`` wildcard from a security standpoint (engineio/server.py:202:
+ ``if cors_allowed_origins != []``). And it must never silently turn
+ into ``'*'`` from a misshapen config value.
+- The rejection predictor must mirror engineio's same-origin check
+ exactly so the warning we log is accurate. This includes accepting
+ matches against ``X-Forwarded-Host`` since engineio honors that
+ automatically when ``cors_allowed_origins`` is ``None``.
+- The dedup logger must emit each unique origin only once so a malicious
+ site repeatedly hammering the WS endpoint can't spam logs.
+
+Pure unit tests — no Flask, no engineio, no network. Just the logic.
+"""
+
+import logging
+import threading
+from typing import Any, List
+
+import pytest
+
+from core.socketio_cors import (
+ RejectionLogger,
+ log_startup_status,
+ resolve_cors_origins,
+ will_reject,
+)
+
+
+# ── helpers ───────────────────────────────────────────────────────────────
+
+
+class _FakeConfig:
+ """Minimal config_manager stub that returns one canned value for the
+ `security.cors_origins` key. Anything else returns the default."""
+
+ def __init__(self, value: Any):
+ self._value = value
+
+ def get(self, key: str, default: Any = None) -> Any:
+ if key == 'security.cors_origins':
+ return self._value
+ return default
+
+
+class _CapturingLogger:
+ """Stand-in logger that records every warning/info call so tests can
+ assert what was emitted (and how many times)."""
+
+ def __init__(self):
+ self.warnings: List[str] = []
+ self.infos: List[str] = []
+
+ def warning(self, msg: str) -> None:
+ self.warnings.append(msg)
+
+ def info(self, msg: str) -> None:
+ self.infos.append(msg)
+
+
+# ── resolve_cors_origins ──────────────────────────────────────────────────
+
+
+@pytest.mark.parametrize("value, expected", [
+ # Unset / empty / whitespace / bogus types → None (engineio same-origin default)
+ (None, None),
+ ('', None),
+ (' ', None),
+ ('\n\n', None),
+ (',,,', None),
+ (12345, None), # numeric — invalid type
+ ({'a': 1}, None), # dict — invalid type
+ ([], None), # explicit empty list
+ ([' ', ''], None), # list of all-empty strings
+
+ # Wildcard
+ ('*', '*'),
+ (' * ', '*'),
+ (['*'], '*'),
+ (['https://x.com', '*'], '*'), # wildcard in a list still wins
+
+ # Single origin
+ ('https://x.com', ['https://x.com']),
+ (['https://x.com'], ['https://x.com']),
+
+ # Multiple origins, comma-separated
+ ('https://x.com, http://y.com', ['https://x.com', 'http://y.com']),
+
+ # Multiple origins, newline-separated (textarea input)
+ ('https://x.com\nhttp://y.com', ['https://x.com', 'http://y.com']),
+
+ # Mixed separators + extra commas / whitespace get cleaned
+ ('https://x.com,, http://y.com,\n http://z.com', ['https://x.com', 'http://y.com', 'http://z.com']),
+
+ # List with mixed types (bytes-like → str coerce)
+ (['https://x.com', ' ', 'http://y.com'], ['https://x.com', 'http://y.com']),
+])
+def test_resolve_cors_origins_normalizes_input(value, expected):
+ assert resolve_cors_origins(_FakeConfig(value)) == expected
+
+
+def test_resolve_cors_origins_handles_missing_config_manager():
+ """Defensive: if config_manager is None (e.g., very early init), the
+ resolver must fall back to the secure default rather than crashing."""
+ assert resolve_cors_origins(None) is None
+
+
+def test_resolve_cors_origins_never_returns_empty_list():
+ """SECURITY CRITICAL: ``cors_allowed_origins=[]`` in engineio means
+ "disable CORS handling entirely" — identical security to ``'*'``
+ (engineio/server.py:202). The resolver must return ``None`` for the
+ secure default, never ``[]``, regardless of what the user typed."""
+ edge_cases = [None, '', ' ', '\n\n', ',,,', 12345, 3.14, {'a': 1},
+ object(), True, False, [], [' '], ['', ' '], (' ',)]
+ for value in edge_cases:
+ result = resolve_cors_origins(_FakeConfig(value))
+ assert result != [], (
+ f"resolve_cors_origins({value!r}) returned [] — that disables "
+ f"engineio's CORS check entirely, allowing all origins. Must be None."
+ )
+
+
+def test_resolve_cors_origins_never_silently_returns_wildcard_for_garbage():
+ """Security-critical: a misshapen config value must NEVER turn into
+ `'*'` by accident. Anything we can't parse falls back to same-origin."""
+ for bogus in [12345, 3.14, {'a': 1}, object(), True, False]:
+ assert resolve_cors_origins(_FakeConfig(bogus)) is None, (
+ f"resolve_cors_origins({bogus!r}) returned a non-None value — "
+ f"bogus inputs must default to same-origin only"
+ )
+
+
+# ── will_reject ───────────────────────────────────────────────────────────
+
+
+@pytest.mark.parametrize("allowed, origin, host, expected_reject", [
+ # Same-origin (Origin's host:port matches the request Host) — allow
+ (None, 'http://localhost:8888', 'localhost:8888', False),
+ (None, 'http://192.168.1.5:8888','192.168.1.5:8888',False),
+ (None, 'https://soulsync.foo', 'soulsync.foo', False),
+
+ # Cross-origin with default allow-list — reject
+ (None, 'https://x.com', 'localhost:8888', True),
+ (None, 'https://soulsync.foo', 'localhost:8888', True), # reverse proxy NOT forwarding Host
+
+ # Wildcard short-circuit — allow
+ ('*', 'https://x.com', 'localhost:8888', False),
+ ('*', 'https://anything.evil', 'localhost:8888', False),
+
+ # Origin in allow-list — allow
+ (['https://x.com'], 'https://x.com', 'localhost:8888', False),
+ (['https://soulsync.foo'], 'https://soulsync.foo', 'localhost:8888', False),
+
+ # Cross-origin not in allow-list — reject
+ (['https://x.com'], 'https://y.com', 'localhost:8888', True),
+
+ # Same-origin still works even when allow-list has other entries
+ (['https://x.com'], 'http://localhost:8888', 'localhost:8888', False),
+
+ # Origin with path component — only host:port should be compared
+ (None, 'http://x.com:8080/path', 'x.com:8080', False),
+])
+def test_will_reject_predicts_engineio_decision(allowed, origin, host, expected_reject):
+ assert will_reject(allowed, origin, host) is expected_reject
+
+
+def test_will_reject_with_empty_host_only_uses_allowlist():
+ """If the request somehow has no Host header (shouldn't happen but be
+ safe), same-origin can't be checked — fall through to allow-list only."""
+ assert will_reject(None, 'https://x.com', '') is True
+ assert will_reject(['https://x.com'], 'https://x.com', '') is False
+ assert will_reject('*', 'https://x.com', '') is False
+
+
+def test_will_reject_honors_x_forwarded_host():
+ """Engineio honors X-Forwarded-Host automatically when
+ cors_allowed_origins is None (engineio/base_server.py:_cors_allowed_origins).
+ Our predictor must mirror that — otherwise reverse-proxy users with
+ proper proxy headers would trigger spurious "rejected" log lines."""
+ # Same-origin via X-Forwarded-Host (typical reverse-proxy setup)
+ assert will_reject(None, 'https://soulsync.foo', 'internal:8888',
+ forwarded_host='soulsync.foo') is False
+
+ # X-Forwarded-Host with comma list (proxy chain) — first entry wins
+ assert will_reject(None, 'https://soulsync.foo', 'internal:8888',
+ forwarded_host='soulsync.foo, edge.proxy') is False
+
+ # X-Forwarded-Host doesn't match either — still reject
+ assert will_reject(None, 'https://attacker.com', 'internal:8888',
+ forwarded_host='soulsync.foo') is True
+
+ # X-Forwarded-Host empty — falls back to Host check (the unset case)
+ assert will_reject(None, 'https://soulsync.foo', 'soulsync.foo',
+ forwarded_host='') is False
+
+
+# ── RejectionLogger ───────────────────────────────────────────────────────
+
+
+def test_rejection_logger_emits_once_per_unique_origin():
+ log = _CapturingLogger()
+ rl = RejectionLogger(log)
+
+ # Same origin three times — only one warning
+ for _ in range(3):
+ rl.maybe_log(None, 'https://attacker.com', 'localhost:8888')
+ assert len(log.warnings) == 1
+ assert 'attacker.com' in log.warnings[0]
+
+ # Different origin — separate warning
+ rl.maybe_log(None, 'https://other.evil', 'localhost:8888')
+ assert len(log.warnings) == 2
+ assert 'other.evil' in log.warnings[1]
+
+
+def test_rejection_logger_silent_when_request_would_be_allowed():
+ log = _CapturingLogger()
+ rl = RejectionLogger(log)
+
+ # Same-origin — no warning
+ rl.maybe_log(None, 'http://localhost:8888', 'localhost:8888')
+ # Wildcard — no warning
+ rl.maybe_log('*', 'https://x.com', 'localhost:8888')
+ # In allow-list — no warning
+ rl.maybe_log(['https://x.com'], 'https://x.com', 'localhost:8888')
+ # Same-origin via X-Forwarded-Host — no warning
+ rl.maybe_log(None, 'https://soulsync.foo', 'internal:8888', 'soulsync.foo')
+
+ assert log.warnings == []
+
+
+def test_rejection_logger_silent_when_no_origin_header():
+ """Non-browser clients (curl, server-to-server) don't send Origin —
+ they should not trigger the warning."""
+ log = _CapturingLogger()
+ rl = RejectionLogger(log)
+
+ rl.maybe_log(None, None, 'localhost:8888')
+ rl.maybe_log(None, '', 'localhost:8888')
+
+ assert log.warnings == []
+
+
+def test_rejection_logger_warning_message_points_user_to_settings():
+ """The warning is the ONLY signal users get when their reverse proxy
+ setup is broken. It must name the origin AND tell them where to fix it."""
+ log = _CapturingLogger()
+ rl = RejectionLogger(log)
+
+ rl.maybe_log(None, 'https://soulsync.example.com', 'internal-host:8888')
+
+ assert len(log.warnings) == 1
+ msg = log.warnings[0]
+ assert 'soulsync.example.com' in msg, "warning must include the rejected origin"
+ assert 'internal-host:8888' in msg, "warning must include the request Host so users can debug proxy config"
+ assert 'Settings' in msg, "warning must point users to Settings"
+ assert 'Allowed' in msg, "warning must name the field they need to edit"
+
+
+def test_rejection_logger_dedup_is_threadsafe():
+ """Two threads racing on the same novel origin must result in exactly
+ one warning, not two. Locks the dedup set internally."""
+ log = _CapturingLogger()
+ rl = RejectionLogger(log)
+ barrier = threading.Barrier(8)
+
+ def hammer():
+ barrier.wait()
+ for _ in range(50):
+ rl.maybe_log(None, 'https://race.test', 'localhost:8888')
+
+ threads = [threading.Thread(target=hammer) for _ in range(8)]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+
+ assert len(log.warnings) == 1
+
+
+def test_rejection_logger_reset_for_tests_clears_dedup():
+ log = _CapturingLogger()
+ rl = RejectionLogger(log)
+
+ rl.maybe_log(None, 'https://x.com', 'localhost:8888')
+ assert len(log.warnings) == 1
+
+ rl.reset_for_tests()
+ rl.maybe_log(None, 'https://x.com', 'localhost:8888')
+ assert len(log.warnings) == 2 # logged again after reset
+
+
+# ── log_startup_status ────────────────────────────────────────────────────
+
+
+def test_startup_status_warns_on_wildcard():
+ """The wildcard is a security risk — startup must log a warning that
+ points users to the settings page, not just an info line."""
+ log = _CapturingLogger()
+ log_startup_status('*', log)
+
+ assert len(log.warnings) == 1
+ assert "'*'" in log.warnings[0]
+ assert 'Settings' in log.warnings[0]
+ assert log.infos == []
+
+
+def test_startup_status_info_logs_nonempty_allowlist():
+ """Non-empty allow-list → info, so users can confirm their config
+ actually took effect."""
+ log = _CapturingLogger()
+ log_startup_status(['https://x.com', 'https://y.com'], log)
+
+ assert log.warnings == []
+ assert len(log.infos) == 1
+ assert 'https://x.com' in log.infos[0]
+
+
+def test_startup_status_silent_on_default_same_origin():
+ """None (default) → no log. Same-origin-only is the default;
+ nothing noteworthy to announce on every startup."""
+ log = _CapturingLogger()
+ log_startup_status(None, log)
+
+ assert log.warnings == []
+ assert log.infos == []
diff --git a/web_server.py b/web_server.py
index c473e037..77690b3b 100644
--- a/web_server.py
+++ b/web_server.py
@@ -210,12 +210,35 @@ def _init_flask_secret_key():
app.secret_key = _init_flask_secret_key()
# --- WebSocket (Socket.IO) Setup ---
-socketio = SocketIO(app, async_mode='threading', cors_allowed_origins='*')
+from core.socketio_cors import (
+ resolve_cors_origins as _resolve_socketio_cors_origins,
+ RejectionLogger as _SocketIORejectionLogger,
+ log_startup_status as _log_socketio_startup_status,
+)
+_socketio_cors_origins = _resolve_socketio_cors_origins(config_manager)
+socketio = SocketIO(app, async_mode='threading', cors_allowed_origins=_socketio_cors_origins)
+_log_socketio_startup_status(_socketio_cors_origins, logger)
+_socketio_rejection_logger = _SocketIORejectionLogger(logger)
# Plex PIN auth requests stored in memory for polling
_plex_pin_requests = {}
_plex_pin_requests_lock = threading.Lock()
+@app.before_request
+def _log_rejected_socketio_origin():
+ """Hook the WS upgrade path so users see a clear log line when their
+ Origin is about to be rejected (engineio otherwise just silently 403s
+ the upgrade). Dedup + threading lives in `core/socketio_cors`."""
+ if not request.path.startswith('/socket.io/'):
+ return
+ _socketio_rejection_logger.maybe_log(
+ _socketio_cors_origins,
+ request.headers.get('Origin'),
+ request.headers.get('Host', ''),
+ request.headers.get('X-Forwarded-Host', ''),
+ )
+
+
# --- Profile Context (before_request hook) ---
@app.before_request
def _set_profile_context():
diff --git a/webui/index.html b/webui/index.html
index 9732573f..1bd71056 100644
--- a/webui/index.html
+++ b/webui/index.html
@@ -5409,6 +5409,14 @@
* on its own line to allow any origin (insecure — only do this if you understand why you need it).
+ -XgANtp4#HsYhB*@#{0N-{wjrho_blK
z5=ok++ K$^LR62kc;{C!$luB4wuW(lFbSDez{(ZLc>;+*6Qi>+CJa!P
zjj=i41vv*-Ds0O_2uOt{@fE6Cz?qrC
z_3|XW3CZRb!P>e`!?7vP&8Yl={p>jI^OpzRe{%od`GGV4by&1ps(Vyk|MxTh_tNm%
zS8jJ&y7AT}sbr3w&z!?`T*iT5d^y0$&G9GeLq77vCEjr7UG&zPNZX}U0VnQr_?9I;
z{mgZmfk&fgI6UJ}o-cC#%77
S=-D>;
zLXmNI%&^m;RPkB6)+3Ee#9oz8p1*<0<(Q-b+b@wO6JiYAVZd~)$}o(%bon~-i}Q@a
z5s@~G(-
Xh;6_a?1<2zWs
zLs1%h>EWo7T;!xO!4G^cWHU0`W~Jt1iWH$Nc58J$+^q4yH@%Vh>yEI!S|@CE33N?N
zCaJmc;zbkf&IPGa(s6Mt2h>5wY+R)1qC!UnNF@i&EtDR(5DaIDVm$3DQ`
zC!RnEhq`Lv+dh>lNYsM4o{;E-T&I+j2&DiUAr(@AWrNWf!f2#cRJnxgKuX;%6i#L1
z8wbB~aY_qSfLA&A#=