Discovery Fix modal search results now sort standard album versions
above live recordings, remixes, covers, soundtracks, remasters,
deluxe editions, and other variants. Fixes cases where searching
"Mother Danzig" returned a live version first, or "Even Flow Pearl
Jam" returned a soundtrack instead of the original from Ten.
Unmatch: found tracks in playlist discovery now have a red X button
to remove bad matches. Clears match data, sets back to Not Found,
persists in DB for mirrored playlists, and respects user choice on
re-discovery runs (won't re-match automatically).
Video naming: new path template in Settings with $artist, $title,
$artistletter, $year variables. Default unchanged ($artist/$title-video)
so existing Plex setups aren't affected.
slskd logs: Clean Search History automation skips when Soulseek is
not the active download source, eliminating connection error spam.
Video naming: new path template in Settings → Paths & Organization
with $artist, $artistletter, $title, $year variables. Default
unchanged ($artist/$title-video → Artist/Title-video.mp4) so
existing Plex setups aren't affected. Users can remove the -video
suffix or reorganize however they like.
slskd logs: the Clean Search History automation now skips when
Soulseek is not the active download source, eliminating noisy
connection error logs for users who don't use Soulseek.
When playlist discovery fails to match a track on any metadata API,
instead of marking it "Not Found" and excluding it from downloads,
automatically build stub metadata from the raw source title/artist
and include it in the download queue. Soulseek searches with the
raw data, post-processing enhances whatever it can find.
All 7 discovery workers updated: YouTube, ListenBrainz, Tidal,
Deezer, Spotify Public, Beatport, and automated mirrored playlists.
Amber "Wing It" badge distinguishes stubs from real API matches.
Fix button still available so users can manually find a proper match.
Wing It stubs persist in DB for mirrored playlists and are
re-attempted on future discovery runs. Failed wing-it downloads
skip wishlist per-track (checked by wing_it_ ID prefix) so real
matched failures in the same batch still go to wishlist normally.
soul_id.startsWith() threw TypeError for non-string values, crashing
the entire card rendering pipeline. Letter-specific filters worked
because the problematic artist wasn't in those filtered results.
Added String() wrapper on all 3 soul_id.startsWith calls and a
try-catch around individual card rendering so one bad card can't
take down the whole page.
- Flask catch-all route serves index.html for client-side paths, excluding api/static/auth/callback/status prefixes.- navigateToPage pushes history state so URL reflects current page.- popstate listener handles browser back/forward without reloading.- Initial load reads window.location to restore the page after refresh or direct link.- artist-detail and playlist-explorer fall back to parent pages since they need runtime context.
- move artist-detail discography resolution onto the shared source-priority metadata service
- keep the variant dedup helper in the UI-facing adapter
- pass the chosen source through completion checks
- add coverage for the new adapter and dedup behavior
The ID resolver tried int() conversion first, which fails for text-based
IDs from Navidrome/Jellyfin. Now tries direct string match first (works
for both text and integer IDs), then integer fallback, then source
columns. Also added discogs_id to source column search. Fixes#323
Track and album delete with file removal now also cleans up associated
lyrics sidecar files (.lrc synced, .txt plain) that share the same
base filename as the audio file. Fixes#322
Move completion checks into metadata_service and make them follow the configured metadata source priority.
Drop the old test-mode path, remove the web_server wrapper indirection, and keep artist inference on explicit release metadata instead of guessing from a track search.
Add coverage for the source-priority completion behavior and the safer artist-name handling.
_resolve_db_album_id was missing deezer_album_id from stored ID checks
and hardcoded Spotify for the name-based search fallback. When Spotify
was rate limited (common for new Navidrome users), no fallback was tried
and the album returned 404.
Now checks all stored IDs (spotify, deezer, itunes, discogs) in priority
order matching the active metadata source, and falls back through all
available sources for name-based search instead of only Spotify.
- Fix level filter showing nothing: now uses heuristic classification
for print() output (error/traceback/failed→ERROR, warn→WARNING, etc.)
in addition to exact logger format matching
- Speed up WebSocket updates from 2s to 0.5s polling
- Add search box with 300ms debounce — filters both initial load and live
- Use DocumentFragment for batch DOM appends (performance)
- Increase line cap from 1000 to 2000
- Backend search parameter support in /api/logs/tail
Terminal-style real-time log viewer with:
- Log file selector (app, post-processing, acoustid, source reuse)
- Color-coded log levels (DEBUG gray, INFO blue, WARNING yellow, ERROR red)
- Level filter buttons (All/Debug/Info/Warn/Error)
- Auto-scroll with toggle, copy and clear buttons
- Live updates via WebSocket (2s polling, pushes new lines)
- Initial load fetches last 200 lines via REST API
- 1000-line display cap with oldest lines trimmed
Also fixes Advanced tab settings (Discovery Pool, Security, etc.) being
hidden inside collapsed Library Preferences section body — misplaced
closing div caused them to be invisible.
New toggle in Settings → Library → Post-Processing: "Apply ReplayGain
tags after download". When enabled, analyzes loudness via ffmpeg's
ebur128 filter and writes track-level ReplayGain gain/peak tags.
Runs after metadata tagging but before lossy copy so both files get
the tags. Off by default — adds a few seconds per track.
Applied to both album and playlist/single download paths.
New core/genre_filter.py with ~180 curated default genres. When strict
mode is enabled in Settings → Library Preferences → Genre Whitelist,
only whitelisted genres pass through during enrichment. Junk tags from
Last.fm (artist names, radio shows, playlist names) are silently dropped.
Applied at all 10 genre write points: Spotify, Last.fm, AudioDB, Deezer,
Discogs, iTunes, Qobuz enrichment workers + post-processing genre merge
+ initial download artist/album creation.
Strict mode is OFF by default — zero behavior change for existing users.
First enable auto-populates the whitelist with defaults. Users can add,
remove, search, and reset genres via the Settings UI.
Per-artist log lines now show the full details string from the worker
(e.g. "5 albums, 0 new tracks (150 existing updated)") instead of
just "5 albums, 0 tracks". Finished message shows "library up to date"
when no new content is found instead of "0 successful, 0 failed".
Full Refresh now clears all soulsync library records and rebuilds from
file tags in the output folder. Reads tags via Mutagen, groups by
artist/album, creates DB records with stable IDs. Files stay in place.
Previously Full Refresh did nothing for standalone — just returned.
Dashboard scan polling checked for 'completed' but backend sets 'finished'.
Added 'finished' to the completion check so polling stops, button resets,
stats refresh, and toast fires correctly. Also fixed deep scan reporting
stale record removals as 'failed' instead of 'successful'.
Playlist and single track downloads pass None as album_info to
_enhance_file_metadata. The downstream _extract_spotify_metadata
called .get() on it without a null guard, crashing with AttributeError.
All user-facing labels, docs, help text, tooltips, error messages, and debug
info output updated. Backend config keys, variable names, actual path values,
and Docker volume mounts are completely unchanged — zero functional impact.
Users can now override which metadata provider (Spotify, Deezer, Apple Music,
Discogs) is used when scanning a specific watchlist artist for new releases.
The selector appears in the artist config modal and only shows sources the
artist has enrichment IDs for. Default behavior is unchanged — all artists
use the global metadata source unless explicitly overridden.
The gunicorn PR blocked direct Python execution with SystemExit.
Replaced with _DIRECT_RUN flag at top and startup block at bottom
so both paths work:
- python web_server.py (Werkzeug dev server, Windows compatible)
- gunicorn -c gunicorn.conf.py wsgi:application (production)
Switch the web UI from Werkzeug's built-in server to Gunicorn for a more stable production deployment path.
Keep a separate dev config so local runs still reload quickly, while the production path uses a dedicated WSGI entrypoint and cleaner startup behavior.
The main motivation is to reduce the websocket teardown noise and make the server behavior more predictable under the app's mostly background-driven workload.
Added -vn flag to all codec ffmpeg commands (MP3, Opus, AAC) to strip
video/image streams during conversion. Embedded cover art in FLAC
files caused ffmpeg to fail when the output muxer couldn't handle
the image stream, producing 0KB output files. Cover art is
re-embedded afterwards by Mutagen.
The dedup key (normalized_title, year) caused different albums from
the same year to collide when title normalization stripped too much.
The "prefer more tracks" logic then kept compilations over studio
albums.
Two fixes:
- Title similarity check: if normalized titles are <85% similar,
they're different albums, not variants — keep both
- Compilation deprioritization: studio albums win over compilations
and "best of" collections when they do collide
Move Hydrabase availability checks into metadata_service so source resolution owns the policy. Keep web_server delegating to the centralized helper and add tests for the enabled/disabled cases.
Move artist discography resolution into core metadata_service, introduce MetadataLookupOptions, and keep web_server focused on request handling. Add focused tests for the new service boundary and preserve current fallback behavior for now.
Delay alternate-source fan-out until the primary enhanced-search response arrives, and stagger those follow-up requests so they do not all compete at once. Also parallelize artist, album, and track lookups inside each metadata source request to shorten the time the UI thread spends waiting on remote APIs. This keeps the single-worker web UI more responsive under the app's chatty search flow.
New MusicBrainz tab in Enhanced and Global search — finds tracks and
albums on MusicBrainz's community database with Cover Art Archive
images. Covers obscure tracks that Spotify/Deezer/iTunes miss.
- core/musicbrainz_search.py: search adapter with Track/Artist/Album
dataclasses, Cover Art Archive integration, smart query parsing
- Albums deduplicated (keeps best version with date and art)
- No artist results shown (MusicBrainz has no artist images)
- Album detail with full tracklist for download modal
- Smart word-boundary splitting for queries without separators
- Global search results container widened from 620px to 920px
- UI version bumped to 2.32
SoulSync Standalone Library is now the first section in both the
version modal and What's New popup. Auto-Import section updated with
all improvements (recursive scan, singles, tag preference, AcoustID).
New Downloads & Soulseek section groups download-related improvements.
Recent Fixes cleaned up — feature items moved to proper sections.
Deep scan for standalone mode:
- Scans Transfer folder for all audio files
- Compares against soulsync DB records by file_path
- Moves untracked files to Staging for auto-import processing
- Removes stale DB records where files no longer exist
- Cleans orphaned albums and artists with no tracks
Incremental scan skips for standalone — library updates at download
time, no periodic scanning needed. Both changes are purely additive
and only activate when server_type is 'soulsync'.
All sync-related buttons hidden when active server is SoulSync
Standalone. Covers static buttons (querySelectorAll on status update)
and dynamic modal buttons (_isSoulsyncStandalone flag).
UI version bumped to 2.31 (Docker stays at 2.3).
Single track ownership check was calling check_track_exists without
server_source, matching against all servers instead of the active one.
Album and EP checks already passed server_source correctly — this was
the only missing spot. Affects all server types.
Previously reused existing plex/jellyfin artist IDs, causing soulsync
tracks to be invisible on the library page (filtered by server_source).
Now always creates soulsync-specific artist and album records with
server_source='soulsync', avoiding PK collisions with hash suffixes.
Fourth server option on the Connections tab with SoulSync logo and
'Standalone' label. Config panel shows Transfer folder path and
Verify Folder button. Test connection counts audio files in the
Transfer folder. Settings save/load properly detects soulsync toggle.
New 'soulsync' media server option manages the library directly from
the filesystem, bypassing Plex/Jellyfin/Navidrome entirely.
Two paths populate the library:
1. Downloads/imports write artist/album/track to DB immediately at
post-processing completion, with pre-populated enrichment IDs
(Spotify, Deezer, MusicBrainz) so workers skip re-discovery
2. soulsync_client.py scans Transfer folder for incremental/deep scan
via DatabaseUpdateWorker (same interface as server clients)
New files:
- core/soulsync_client.py: filesystem scanner implementing the same
interface as Plex/Jellyfin/Navidrome clients. Recursive folder scan,
Mutagen tag reading, artist/album/track grouping, hash-based stable
IDs, incremental scan by modification time.
Modified:
- web_server.py: _record_soulsync_library_entry() at post-processing
completion, client init, scan endpoint integration, status endpoint,
web_scan_manager media_clients dict, test-connection cache updates
- config/settings.py: accept 'soulsync' in set_active_media_server,
get_active_media_server_config, is_configured, validate_config
- core/web_scan_manager.py: add soulsync to server_client_map
Dedup: checks existing artist/album by name across ALL server sources
before inserting to avoid duplicates. Enrichment IDs only written when
the column is empty (won't overwrite existing data).
Race condition: scanner re-scanned folders while post-processing was
still moving files, causing partial matches and ghost failures. Now
tracks in-progress paths and skips them on subsequent scans.
Coverage penalty fix: individual tracks that match at 80%+ confidence
now auto-import even when overall album coverage is low (e.g. 2 of 18
tracks present). Previously low coverage killed the entire import.
Import page: stats bar, filter pills, Scan Now, Approve All, Clear
History (clears imported + failed), live scan progress.
Album delete now shows a smart delete dialog with two options:
- Remove from Library (DB only, files untouched)
- Delete Files Too (removes DB records AND deletes audio files from
disk, cleans up empty album folder)
Backend /api/library/album/<id> DELETE now accepts ?delete_files=true
parameter, resolves each track's file path, and removes files before
deleting DB records. Reports files_deleted and files_failed counts.
The ffmpeg failure log was truncated to 200 chars which only showed the
version banner, hiding the actual error message. Now strips the banner
preamble and shows up to 500 chars of the real error with exit code.
Also cleans up 0KB output files left behind when ffmpeg fails.
Soulseek results from "Various Artists", "VA", "Unknown Artist", and
"Unknown Album" folders are now rejected before scoring. These
compilation folders rarely contain properly tagged files for the target
artist.
Clearing the wishlist now also cancels any active wishlist download
batch and resets the auto-processing flag, so downloads don't keep
running after the source tracks are removed.
Split Downloads page into main list (left) and batch panel (right).
Each active batch gets a color-coded card with artwork thumbnail,
progress bar, per-track status with download percentages, and
expandable track list. Download rows get matching color indicators.
- Click batch name to open its download/wishlist modal
- Filter icon narrows main list to one batch with clear banner
- Collapsible panel toggle for full-width list view
- Completed batches fade out after 15 seconds
- 7-day batch history with source type color dots
- Artwork fallback shows colored initial when no art available
- Per-track progress: download %, spinner for searching, proc label
- source_page column on sync_history for UI origin tracking
- /api/downloads/all includes batch summaries and per-track progress
- /api/downloads/batch-history endpoint for history queries
- Responsive layout, overflow-x hidden to prevent scroll flicker
Priority 0 query (artist + album + title) was gated behind a download
mode check that excluded Soulseek, the source that benefits most from
it. Soulseek searches match against file paths where users organize as
Artist/Album/Track — without the album name, ambiguous artist names
could match wrong-artist results (e.g. "Bleakness" as an album folder
instead of an artist). Removed the mode gate so all sources get the
most specific query first.
Dashboard stats (every 10s) and download status endpoint were
unconditionally calling slskd transfers/downloads API, causing
connection timeout spam for users with a slskd URL configured but
using YouTube/Tidal/etc as their download source. Now checks both
download source mode and status cache before making the API call.
Adds April 17 entries for Auto-Import, Wishlist Nebula, automation group
management, bidirectional artist sync, provider-agnostic discovery, live
sidebar badges, and critical source ID embedding fix. Version modal
reorganized to lead with current features and summarize earlier v2.2 work.
Full auto-import pipeline: background worker watches the staging folder,
identifies music using embedded tags → folder name parsing → AcoustID
fingerprinting, matches files to metadata source tracklists, and
processes high-confidence matches through the existing post-processing
pipeline automatically.
Worker: AutoImportWorker with start/stop/pause/resume, configurable
scan interval (default 60s), confidence threshold (default 90%), and
auto-process toggle. Processes one folder per cycle, alphabetical
order. Disc folder detection, stability checking, content hash dedup.
Confidence gate: 90%+ auto-processes silently, 70-90% queued as
pending review with approve/dismiss actions, <70% flagged for manual
identification. Track matching uses weighted algorithm (title 45%,
artist 15%, track number 30%, album tag 10%).
Database: auto_import_history table tracks every scan result with
folder hash, match data JSON, confidence, status, timestamps.
API: 7 endpoints — status, toggle, settings (GET/POST), results
(filtered/paginated), approve, reject.
UI: Auto tab on Import page with enable toggle, confidence slider,
scan interval selector. Live result cards with album art, confidence
bar (green/yellow/red), status badges, match stats. 5-second polling.
Artist Sync button on enhanced library page now does true bidirectional
sync: Phase 1 pulls new albums/tracks from the media server using the
DatabaseUpdateWorker in deep scan mode (preserves enrichment), Phase 2
removes stale DB entries for files no longer on disk. Works for Plex,
Jellyfin, and Navidrome. Toast shows +albums, +tracks, -stale counts.
Repair jobs tab redesigned: 2-column grid layout with glass gradient
cards, accent top line on hover, hover lift effect, job description
text below name, running state with pulsing accent bar. Responsive
to single column under 900px.
Fixed deezer_artist_id → deezer_id column name on artists table lookup.
The artists table uses 'deezer_id' but the enhanced library artist
lookup was querying 'deezer_artist_id' (the watchlist_artists column
name). Fixed to use the correct column name.
Watchlist scanner: empty discography (no new releases in lookback) was
treated as API failure, causing "Failed to get artist discography" for
artists like Kendrick Lamar who simply had no recent releases. Now
distinguishes None (API failure → try next source) from [] (success,
no new tracks). Spotify backfill now uses the authenticated client
instance instead of creating a fresh unauthenticated one.
Wishlist nebula: album remove now sends album_name (API updated to
accept album_name as fallback alongside album_id). Track remove
re-renders the nebula after deletion. Toned down processing pulse
animation.
Updated test to verify fallback triggers on API failure (None), not
on empty results.
Full automation page upgrade with group management and drag-and-drop:
Backend: batch_update_group() and bulk_set_enabled() DB methods, new
PUT /api/automations/group and POST /api/automations/bulk-toggle endpoints.
Group headers: rename (inline edit), delete (choice dialog — keep
automations or delete all), bulk toggle (enable/disable all in group).
Actions appear on hover, styled as small icon buttons.
Drag and drop: non-system cards are draggable between group sections.
Drop zones show dashed accent border feedback. Collapsed sections
auto-expand on 500ms drag-hover. System/Hub sections dimmed during drag.
dragenter counter pattern handles child element bubbling.
Delete group dialog: glass card modal with three options — keep
automations (move to My Automations), delete everything, or cancel.
The MusicBrainz consistency change referenced 'context' inside
_embed_source_ids(), but that variable was never passed to the function.
Every download since that commit silently skipped ALL source ID tags
(Spotify, MusicBrainz, Deezer, AudioDB, Tidal, Qobuz, Last.fm, Genius)
with the error 'name context is not defined' caught as non-fatal.
Fix: pass context from _enhance_file_metadata to _embed_source_ids,
with None default for backward compatibility.
Adaptive card on the Dashboard showing library state with four modes:
- No server: gold accent, directs to Settings
- Disconnected: gold warning with troubleshooting guidance
- Empty library: blue accent with prominent Scan Now button
- Healthy: green accent with stats grid (artists/albums/tracks/DB size),
Refresh button (incremental) and Deep Scan button (full re-check)
Stats displayed as mini cards with individual icons. Animated glow orb,
gradient accent top line, shimmer progress bar during scans. Deep scan
added to /api/database/update endpoint (deep_scan flag) — re-checks
every track, adds new ones, removes stale, preserves enrichment data.
Confirmation dialog explains what deep scan does before starting.
Soulseek source files often carry the uploader's MusicBrainz IDs from
different releases. When post-processing skipped tag clearing (missing
spotify_album context in wishlist batches, or enhancement exceptions),
these conflicting IDs persisted and caused Navidrome to split one album
into multiple entries.
Added _wipe_source_tags() — a lightweight emergency tag wipe that clears
all tags without writing new ones. Called in every failure/skip path:
stream processor exception, playlist mode exception, verification worker
missing context, and verification worker exception. Idempotent and
wrapped in try/except so it never interferes with the existing flow.
When the DB stored a path the resolver couldn't map to a local file
(common with Navidrome virtual paths or Docker path mismatches), file
deletion was silently skipped — the DB record was removed but the file
stayed on disk with no indication to the user.
Now logs the resolution failure with the stored path, returns a
file_error in the API response, and the frontend shows a warning toast
explaining the file wasn't deleted plus a second toast with the specific
reason (e.g. Navidrome 'Report Real Path' instructions).
Keep the weekly Last.fm radio generation step in the web watchlist scan post-processing chain so the higher-level scan behavior stays intact after moving the scan loop into the shared scanner core.
Bring placeholder tracklist skipping back into the shared watchlist scan path, and centralize the DB-only artist image backfill helper so both web scan entrypoints reuse the same logic.
Drop the legacy watchlist scan entrypoints that are no longer used by the web scan flow, and keep the live refresh path pointed at the shared scanner helper.
Move the shared watchlist scan loop into core/watchlist_scanner.py so web_server.py only handles triggers, locks, progress, and post-scan orchestration.
Manual and scheduled watchlist scans now share the same scanner-side core, while the web entrypoints keep profile selection and automation progress updates.
Recording MBIDs are now pulled from the matched release tracklist instead
of independent match_recording() searches, guaranteeing the recording ID
is consistent with the selected release. Batch-level artist name is used
for release cache keys so all tracks hit the same preflight-cached entry
even when Soulseek metadata spells the artist differently. A post-batch
consistency pass (run_album_consistency) rewrites album-level tags on all
files after the batch completes — the safety net that prevents Navidrome
album splits even when per-track lookups drift.
New POST /api/v1/request endpoint accepts a search query from external
sources (Discord bots, Home Assistant, curl) and triggers the
search-match-download pipeline asynchronously. Returns a request_id
for status polling via GET /api/v1/request/<id>. Optional notify_url
for callback on completion.
Also adds webhook_received trigger type and search_and_download action
type to the automation engine, so users can build custom flows like
"when webhook received → search & download → notify Discord".
Includes info panel in Settings showing endpoint URL and curl example.
Two bugs: (1) 'wishlist' was missing from the settings save whitelist,
so the toggle silently reset to ON on every page reload. (2) The
wishlist cleanup function unconditionally removed tracks sharing the
same name+artist regardless of album, ignoring the allow_duplicates
setting. Now when allow_duplicates is on, the dedup key includes the
album name so same song from different albums can coexist.
Prints all SOULSYNC_* environment variables before OAuth servers start,
helping diagnose reported Unraid issue where callback port env var is
present in the container but not seen by the Python process.
Auth instruction pages and log messages now use the actual configured
callback port instead of hardcoding 8888. Added startup logging that
prints whether SOULSYNC_SPOTIFY/TIDAL_CALLBACK_PORT env vars were
detected, helping diagnose Unraid/Docker env var issues. Also fixes
uses_main_port detection for custom callback ports and moves the
wishlist button handler to global init so it works on all pages.
Explored status was stored only in frontend memory; on reload the badge
disappeared because the API never returned it. Added explored_at column
to mirrored_playlists (auto-migrated), written when build-tree completes,
and read back via SELECT * so the badge survives page refreshes.
- Pass playlist image_url to _run_sync_task from all source-specific sync
start handlers (Deezer, Tidal, Spotify public, YouTube, automation mirror)
— previously only the /api/sync/start endpoint passed it
- Fix plex_client.set_playlist_image: use uploadPoster(url=) instead of
uploadPoster(data=) which is not a valid PlexAPI argument
- deezer_client: use picture_xl > picture_big > picture_medium fallback
for better cover art resolution
- tidal_client: extract image_url in get_playlist() from JSON:API
relationships (was only extracted in metadata-only listing)
- parse_youtube_playlist: capture playlist thumbnail from yt-dlp result
- Add visible logging for image upload attempts and outcomes
Spotify was being called for album/artist data fetching across multiple
background workers and the Artists page search even when the user had
Deezer or iTunes set as their primary metadata source. Being authenticated
for playlist sync was treated as permission to use Spotify for everything.
- watchlist_scanner: add _spotify_is_primary_source() that checks both
auth and primary source config; use it for all album/artist data fetching
(discovery pool, recent album caching, playlist curation, similar artist
ID matching, proactive ID backfill). _spotify_available_for_run() is kept
for sync_spotify_library_cache which must run regardless of primary source
- repair_jobs/metadata_gap_filler: gate Spotify ISRC lookup on primary
source being 'spotify'; MusicBrainz lookup unaffected
- repair_jobs/unknown_artist_fixer: replace hardcoded spotify_client with
source-aware client selection — primary source ID tried first, each ID
matched to its correct client (fixes latent bug passing Deezer IDs to
Spotify)
- web_server.py /api/match/search: Artists page search was hardcoded to
spotify_client.search_artists(); now uses _get_metadata_fallback_client()
so results come from the configured primary source
Adds a new Last.fm Radio section to the Discover page that lets users
search a track on Last.fm, generate a similar-tracks playlist, and run
it through the existing discovery/download/sync pipeline. Also generates
playlists automatically from top listening history during watchlist scans
(max once per week).
- core/lastfm_client.py: Add get_similar_tracks() using track.getsimilar
- core/listenbrainz_manager.py: Add save_lastfm_radio_playlist() with
deterministic MBID (MD5 seed), cleanup limit of 5 for lastfm_radio type
- web_server.py: Add /api/lastfm/configured, /api/lastfm/search/tracks,
/api/lastfm/radio/generate, /api/discover/listenbrainz/lastfm-radio;
fix playlist['name'] KeyError in discovery worker that was resetting
phase back to 'fresh' after completion
- core/watchlist_scanner.py: Add _generate_lastfm_radio_playlists() with
weekly throttle, called at end of scan_all_watchlist_artists()
- webui/index.html: Add #lastfm-radio-section above ListenBrainz section,
hidden unless Last.fm API key is configured
- webui/static/script.js: Search/generation/card-load functions; fix
discovery modal labels (Last.fm Radio vs ListenBrainz), description
update on completion, belt-and-suspenders completion handling inside
updateYouTubeDiscoveryModal; fix album/duration display for tracks
without metadata; music note SVG placeholder for missing art
- webui/static/style.css: Styles for search bar, dropdown, result rows
New core/replaygain.py module uses FFmpeg's ebur128 filter (already a
project dependency) to analyze integrated loudness and true peak, then
writes ReplayGain 2.0 tags (-18 LUFS reference) to MP3 (TXXX frames),
FLAC/OGG/Opus (Vorbis comments), and M4A/MP4 (freeform atoms).
Three analysis modes in the enhanced library view:
- Per-track RG button: synchronous single-track analysis (~1-3 s)
- Album "ReplayGain" button: background job writing both track gain
and album gain (mean LUFS across all album tracks) to every file
- Bulk bar "ReplayGain" button: batch track-gain for selected tracks
read_file_tags() in tag_writer.py extended with four new optional keys
(replaygain_track_gain/_peak, replaygain_album_gain/_peak) so existing
RG values surface in the tag-preview diff view. Purely additive — no
existing endpoints or DB schema changed.
Singles could not be saved as a flat file (e.g. "$artist - $title")
because the frontend blocked any template without a "/" and the
backend path builder treated an empty folder_path as falsy, falling
through to the hardcoded nested-folder structure.
Frontend: removed the must-include-slash validation for single
templates only (album templates still require it).
Backend: changed condition from `if folder_path and filename_base`
to `if filename_base` so an empty folder_path is handled correctly
as a flat drop into the transfer root.
Hardcoded ports 8888/8889 conflict when SoulSync runs behind Gluetun or
other containers that claim those ports. Introduce SOULSYNC_SPOTIFY_CALLBACK_PORT
and SOULSYNC_TIDAL_CALLBACK_PORT env vars (defaulting to 8888/8889) so
users can remap without rebuilding the image.
docker-compose.yml exposes the vars with comments explaining how to keep
the port mappings in sync with the redirect URI in Settings → Connections.
Track per-quality-tier failure reasons across all failure paths (stream
error, empty manifest, download exception, stub file, MP4 extraction
failure) and include them in the exhausted-tiers log message so failures
are diagnosable from logs.
When HiRes is configured with no fallback and all tiers are exhausted,
log an actionable hint directing the user to enable Quality Fallback.
Surface Tidal-specific error messages in the UI task on retry
exhaustion: distinguishes HiRes-unavailable (with actionable guidance)
from general Tidal auth/quality failures, rather than showing the
generic Soulseek error string.
track_downloads stores local Windows paths but tracks table stores
server-side paths (Plex/Jellyfin). Both the track_id lookup (NULL
due to failed auto-link at insert time) and exact file_path fallback
were failing.
Added filename-suffix LIKE matching as a final fallback in
get_track_source_info, plus a back-link so the track_id gets written
back for fast future lookups. Also improved the auto-link in
record_track_download to use the same suffix matching when exact path
fails.
M3U entries now resolve actual file paths from the DB instead of
synthesising a fake 'Artist - Title.mp3' string that no media server
could use. Adds optional M3U Entry Base Path setting (Downloads tab)
so servers requiring absolute paths (e.g. /mnt/music) can be supported.
- New POST /api/generate-playlist-m3u endpoint: per-artist batch DB
lookups with fuzzy title matching, prefixes entry_base_path when set
- autoSavePlaylistM3U and exportPlaylistAsM3U now call the new endpoint
- M3U Entry Base Path input added below Music Videos Dir in settings,
follows path-input-group pattern with Unlock button and autosave
Server Playlists was filtered to only show playlists matching mirrored_playlists entries,
but Discover syncs are stored in sync_history (not mirrored_playlists), so they were
excluded. Adds GET /api/sync/history/names returning distinct synced playlist names,
and includes those in the filter alongside mirrored playlists.
_try_staging_match() built a minimal context missing spotify_artist,
spotify_album, is_album_download, and has_clean_spotify_data. Post-
processing returned early at the missing-spotify_artist guard and the
copied file was left at the transfer root with its original filename.
Now mirrors the sync modal worker's context-building: uses
_explicit_album_context/_explicit_artist_context when available
(artist-page album downloads), falls back to track.album/track.artists
for playlists and sync modal. track_number and disc_number are also
forwarded so multi-disc albums land in the correct Disc N/ subfolder.
import_album_process never computed total_discs, so spotify_album.total_discs
always defaulted to 1 in _build_final_path_for_track. Now pre-computes
total_discs from the matched tracks before the per-track loop.
Builds a new Your Albums section on the Discover page that aggregates
saved/liked albums from all connected services, mirroring the Your Artists
pattern. Deezer works via both OAuth and ARL.
- tidal_client: add get_favorite_albums() with V2/V1 API fallback
- deezer_client: add get_user_favorite_albums() via OAuth (user/me/albums)
- deezer_download_client: add get_user_favorite_albums() via ARL session
- music_database: add liked_albums_pool table (deduped by artist::album
normalized key), upsert_liked_album, get_liked_albums,
get_liked_albums_last_fetch, clear_liked_albums
- web_server: GET /api/discover/your-albums (ownership-checked, paginated),
GET /api/discover/your-albums/sources, POST /api/discover/your-albums/refresh,
_fetch_liked_albums background worker (Spotify + Tidal + Deezer OAuth/ARL)
- frontend: Your Albums section with source selector cog, album grid reusing
spotify-library-card styles, search/filter/sort/pagination, download missing
button, auto-refresh poll on first load
Also fix: Deezer greyed out in Your Artists sources when using ARL — connection
check now accepts ARL auth (deezer_dl.is_authenticated()) in addition to OAuth,
and _fetch_and_match_liked_artists falls back to ARL client for artist fetching.
Add get_user_favorite_artists(limit=200) to DeezerDownloadClient to fetch a user's favorite artists via the public API using an ARL-authenticated session (paginated, error-handled, returns deezer_id, name, image_url).
Update web_server to treat Deezer as connected if either OAuth or ARL is authenticated, and to fetch favorite artists from OAuth client when available or from soulseek_client.deezer_dl (ARL) otherwise. Fetched artists are upserted into the database and appropriate log/console messages and counters are updated.
Three issues fixed:
1. Plex add-track used delete+recreate (Playlist.create) which was
unreliable — switched to addItems() which atomically appends the
track without touching existing playlist items.
2. After a successful add, the UI only did an optimistic local update.
On reopen the automatic matcher ran fresh and couldn't connect the
manually selected track to the source slot, making it look unfixed.
Now both add and replace re-fetch the compare view from the server
so the matcher sees the actual updated Plex state.
3. Matching algorithm was too strict for common title variants. Added
_norm_title() which strips feat./ft., remaster/remastered, and
edition qualifiers before comparison — so "Boy 1904" matches
"Boy 1904 (2019 Remaster)" and "Float Away" matches "Float Away
(feat. Flamingosis & Eric Benny Bloom)". Display titles unchanged.
Spotify album art: replace the 4-char size segment after '0000ab67616d'
with '82c1' to request the original uploaded master (up to 2000px+).
Applied via _upgrade_spotify_image_url() in Track, Artist, and Album
dataclass constructors and as a catch-all in _download_cover_art.
Scoped to the ab67616d album art prefix only — artist images use a
different prefix (ab676161) where the trick does not apply.
iTunes/Apple Music: replace '100x100bb' with '3000x3000bb' in all
artworkUrl100 replacements across Track, Artist, Album, and the
get_album images arrays. Also applied as a catch-all in _download_cover_art.
Deezer already uses cover_xl at its maximum — no changes needed there.
The status cache's source field was being set by _get_metadata_fallback_source(),
which internally calls is_spotify_authenticated() a second time via module import.
Any inconsistency between that path and the direct auth check already performed
would return 'deezer', poisoning the cache — and since the WebSocket takes over
from the HTTP poll, it never corrected itself.
Now reads config directly and reuses the single auth probe result.
Gear button next to View All opens a sources modal letting users pick
which connected services (Spotify, Tidal, Last.fm, Deezer) contribute
artists to the Your Artists carousel. Setting saved via standard
/api/settings endpoint under discover.your_artists_sources.
- GET /api/discover/your-artists/sources returns enabled config + which
services are currently connected
- _fetch_and_match_liked_artists skips sources not in the enabled list
- Disconnected services shown dimmed and non-interactive in modal
- Saving with nothing selected blocked with error toast
- Remove z-index from .sidebar-header (fixes artist map overlap)
- Add padding-bottom to #automations-list-view (search bar overlap fix)
The fallback test (used when no audio files exist in the library) sends
a dummy fingerprint to the AcoustID API. The API correctly rejects the
dummy fingerprint but this is not error code 4 (invalid key), so the
test was returning False instead of True. Any non-code-4 error from the
fallback means the API key was accepted — only code 4 means a bad key.
Instrument web_server.py to record Spotify API usage by calling core.api_call_tracker.api_call_tracker.record_call before various Spotify SDK calls. Added local imports and record_call invocations around artist, playlist, playlist_tracks_page, current_user_saved_tracks, artists_batch, track and related endpoints across functions such as get_artist_image, get_playlist_tracks, watchlist_artist_config, enrich_similar_artists, get_your_artist_info, get_artist_map_explore, import_album_process and import_singles_process. This enables per-endpoint monitoring and helps with rate-limit tracking while keeping the import local to avoid top-level dependency issues.
Deezer and iTunes defaulted to 50 albums max, silently truncating large
discographies. Deezer now paginates (100 per page) up to 200. iTunes
raised to 200 (single call). All callers in web_server.py updated to
use the new defaults instead of hardcoding limit=50.
Also adds diagnostic logging for allow_duplicates album comparison
to help debug inconsistent singles behavior.
Version bump to 2.3 with rewritten What's New modal covering all
changes since v2.2. Docker publish workflow default updated.
Sidebar improvements:
- Header stays pinned at top while nav and player scroll beneath it
- Media player collapses to compact single-line when no track is
playing, expands to full size when playback starts
Fixes:
- Server playlists endpoint Plex Tag object crash (getattr fix)
- Server playlists tab auto-refreshes after download completion
- Fixed dead code syntax error in archived version notes
M3U generator was calling .join() on an array of artist objects instead
of extracting .name first, producing "[object Object] - Track Name".
Now handles all artist formats: array of objects, array of strings,
single string, single object.
Also fix "name 'database' is not defined" error when updating album
year in post-processing — was using bare 'database' instead of
get_database() helper.
Plex API can return Tag objects mixed with playlists — these lack the
playlistType attribute, causing AttributeError. Use getattr with safe
default instead of direct attribute access.
Add 3-tier Unknown Artist guard in post-processing: checks track_info
artists, original search result, then re-fetches from metadata API
before building folder paths or embedding tags. Prevents files from
landing in Unknown Artist folders when the download context has
incomplete artist data.
- Stop active DB update work before tearing down executor pools.
- Short-circuit scan completion callbacks during shutdown so in-flight timer ticks don’t queue follow-up work.
- Prevent the download monitor from draining deferred/completed tasks after shutdown starts.
- Make listening stats startup stop-aware so it exits cleanly if teardown begins during warmup.
- If a metadata update is already running, it can now observe should_stop and exit cleanly instead of continuing after SIGTERM.
- Add interruptible stop events to background workers so shutdown
wakes out of long sleeps instead of waiting on fixed delays.
- Stop scan managers, repair worker, executors, and cleanup helpers
deterministically so process exit does not leave background threads
alive.
- Add startup warnings for stale SQLite WAL/SHM sidecars so unclean
shutdowns are easier to spot before init/migration errors cascade.
- Prevent forced kills from leaving SQLite sidecars behind, which
made rollbacks to older branches fail with malformed database
errors.
There's a lot side-effects happening at import-time (eg. client initialization etc.), some of which include meaningful logging as well (eg. migration-related logging in MusicDatabase client).
If we delay the logging initialization to the __main__ block, we'll lose out on these early logs
New sidebar page showing every download task across the app in a unified
live-updating list. Tracks from Sync, Discover, Artists, Search, and
Wishlist all appear in one place.
Features:
- Filter pills: All / Active / Queued / Completed / Failed
- Section headers grouping by status category
- Track position (3 of 19) for album/playlist batches
- Album art, artist/album metadata, batch context, error messages
- Status dots with accent glow for active, green for complete, red fail
- Clear Completed button removes terminal items from tracker
- Nav badge shows active download count from any page via WebSocket
Fixes artist [object Object] display — handles all format variations
(list of dicts, list of strings, dict, string) for artist and album
fields in the API response.
Serverless check (YouTube/HiFi/Qobuz) now runs before the slskd
connection test. Prevents 4-minute timeout hangs when Hybrid mode
includes both Soulseek and serverless sources but slskd isn't running.
Also reports green if any serverless source is in the hybrid order,
not just the first.
Wizard now shows automatically on fresh installs. Detection uses a
server-side flag (setup.completed) plus download_source.mode as a
fallback for existing users who configured settings before the wizard
existed. Config.json template defaults no longer fool the check.
Script load order fixed — setup-wizard.js loads before script.js so
openSetupWizard exists when DOMContentLoaded fires. Both finish and
skip paths set the server flag and localStorage, then continue app
initialization via callback.
7-step full-screen wizard: Welcome, Metadata Source, Download Source,
Paths & Media Server, Add Artists, First Download, Done. All settings
save to DB identically to the Settings page. Supports all 6 download
sources with inline config and test buttons. First download goes through
the full matched download pipeline with metadata context.
Fixes:
- Download clients (YouTube/HiFi/Tidal/Qobuz/Deezer) now reload
download_path when settings change instead of caching from init
- watchlist_artists table migrations now include deezer_artist_id and
discogs_artist_id in all 3 table rebuild locations (was being dropped)
- CREATE TABLE for watchlist_artists includes all provider ID columns
- Serverless download sources (YouTube/HiFi/Qobuz) show green status
instead of red disconnected on sidebar and dashboard
- Suppress repeated slskd 401 errors — logs once then silences until
connection recovers
Stripped 4,200+ emoji characters from print(), logger calls across
39 Python files. Logs are now clean text — easier to grep, more
professional, no encoding issues on terminals without Unicode support.
Seasonal config icons preserved for UI display.
Tag preview and writer now use track_artist (per-track artist) for the
Artist tag when available, falling back to artist_name (album artist)
when NULL. Album Artist tag always uses artist_name.
Fixes#277 — DJ mix albums no longer overwrite per-track artists
(Technique, Gouryella) with the album artist (Tiësto).
Lidarr integration:
- New core/lidarr_download_client.py with full interface parity
(search, download, status, cancel — same as Qobuz/Tidal/HiFi)
- Registered in download orchestrator with source routing
- Settings: URL + API key on Downloads tab with connection test
- Available as standalone source or in Hybrid mode priority order
- API key encrypted at rest
- All streaming source checks updated to include 'lidarr'
Lidarr downloads full albums via Usenet/torrent — SoulSync imports
only the tracks it needs and discards the rest.
Music video path validation:
- Empty/unconfigured path returns clear error instead of silent failure
- Write permission test before starting download
- Default changed from './MusicVideos' to empty (must be configured)
Clients are for the most part being initialized per-request, which leads to a lot of redundant client initialization, as well as noise on the logs, since each client initialization emits a row on the logs, eg. 'Deezer client initialized'
Click any video card in Music Videos tab to download. Flow:
1. Search primary metadata source for clean artist/title
2. Fall back to YouTube title parsing if no match
3. Download video via yt-dlp (best quality MP4)
4. Save to configured Music Videos folder as Artist/Title-video.mp4
UI shows circular progress ring on the thumbnail during download,
green checkmark on completion, red X on error (clickable to retry).
Cards are non-interactive while downloading.
Backend: /api/music-video/download and /api/music-video/status endpoints
YouTube client: download_music_video() method keeps video format
New "Music Videos" pill tab alongside Spotify/Deezer/iTunes/Discogs
in both enhanced search and global search. Searches YouTube via yt-dlp
and displays results in a video card grid with 16:9 thumbnails, play
overlay, duration badge, channel name, and view count.
- Backend: /api/enhanced-search/source/youtube_videos endpoint with
search_videos() method on YouTubeClient returning YouTubeSearchResult
- Frontend: Video grid layout with responsive cards, YouTube red tab
color, proper section hiding when switching between metadata and
video tabs
- Global search: Full parity with enhanced search video rendering
- No download functionality yet — display only
New configurable path for storing music videos separately from audio
files, following Plex's global music video folder convention.
- Settings: library.music_videos_path (default: ./MusicVideos)
- UI: Music Videos Dir field on Settings Downloads tab with lock/unlock
- Docker: /app/MusicVideos volume mount in Dockerfile and docker-compose
- Added 'library' to settings save whitelist (was missing — music_paths
also wasn't persisting through main settings save)
- No download functionality yet — path infrastructure only
The discovery worker skipped already-discovered tracks even when their
matched_data was incomplete (missing track_number, release_date, album
ID). These stale discoveries from before the enrichment fix would
persist forever, causing the automation pipeline to keep producing
tracks with no year, no track numbers, and no cover art.
Now treats discovered tracks as undiscovered if they're missing
track_number AND have no release_date or album ID, so the enriched
discovery pipeline fills in the gaps on the next run.
Adds two new sections to the version modal covering the Unknown Artist
fix, centralized metadata source selection, Deezer cache fix, sync
completion feedback, Fix Unknown Artists maintenance job, and the
matching engine artist gate improvements.
All metadata source decisions now flow through get_primary_source() and
get_primary_client() in core/metadata_service.py. Previously 6 different
files reimplemented this logic with inconsistent defaults ('itunes' vs
'deezer') and auth checks, causing bugs when any one was missed.
Changes:
- metadata_service.py: Added canonical get_primary_source/get_primary_client
- web_server.py: _get_metadata_fallback_source() and _get_active_discovery_source()
are now thin wrappers delegating to metadata_service
- seasonal_discovery.py: _get_source() delegates to metadata_service
- personalized_playlists.py: _get_active_source() delegates to metadata_service
- spotify_client.py: Fixed _fallback_source default from 'itunes' to 'deezer'
- watchlist_scanner.py: _get_fallback_metadata_client() delegates to metadata_service
Future changes to source selection only need to update one file.
Seasonal discovery, personalized playlists, and playlist explorer all
defaulted to Spotify when authenticated, ignoring the user's configured
primary source. Now they read from config first.
Spotify's related_artists API (no Deezer/iTunes equivalent) is preserved
as a fallback for all users in personalized playlists. Artist discography
endpoint intentionally unchanged — ID-based lookups need the source that
owns the ID.
When a playlist sync has unmatched tracks sent to wishlist, the
completion toast now shows the specific track names instead of just
a count. Uses warning style so it stands out. The unmatched track
list is included in the sync state result so it's available for
both live status polling and notification history.
Addresses #272 — silent sync failures where users couldn't tell
which tracks out of 150+ failed to match their Plex library.
Discovery workers now respect the user's configured primary metadata
source instead of always using Spotify when authenticated. This
completes the intent of commit 3c211ea.
The core fix addresses data loss in the discovery→sync→wishlist→download
pipeline: the Track dataclass strips album metadata to a plain string,
losing album ID, track_number, release_date, and images. Discovery
workers now enrich results via get_track_details() to recover this data.
Deezer's get_track_details() cache validation was incorrectly trusting
search-result cache (which lacks track_position), returning track_number=0.
Also fixes wishlist download processing where albums without IDs couldn't
map to artists, and the fallback read 'artist' (singular) instead of
'artists' (plural), always producing "Unknown Artist".
Includes a one-time migration to purge stale discovery cache entries.
New automation action that executes user scripts from a dedicated
scripts/ directory. Available as both a DO action and THEN action.
Scripts are selected from a dropdown populated by /api/scripts.
Security: only scripts in the scripts dir can run, path traversal
blocked, no shell=True, stdout/stderr capped, configurable timeout
(max 300s). Scripts receive SOULSYNC_EVENT, SOULSYNC_AUTOMATION,
and SOULSYNC_SCRIPTS_DIR environment variables.
Includes Dockerfile + docker-compose.yml changes for the scripts
volume mount, and three example scripts (hello_world.sh,
system_info.py, notify_ntfy.sh).
After a successful playlist sync, if the source playlist has cover
art (Spotify, Tidal, Deezer, etc.), the image is downloaded and
uploaded as the playlist poster on the media server. Plex uses
uploadPoster(), Jellyfin/Emby uses POST /Items/{id}/Images/Primary.
Navidrome skipped (no playlist image API). Failure is silent — sync
result unchanged. Automation-triggered syncs and playlists without
images are unaffected.
What's New: Added Deezer user playlists, Qobuz token auth, streaming
source artist gate, download history provenance, and comprehensive
fixes section covering artist name casing, future album skip, track
number fix, Emby sync, discovery fix fallback, and all new settings.
Help docs: Updated Qobuz auth to mention token option, added Music
Library Paths, Replace Lower Quality, and HiFi Instance Health to
Other Settings section.
New setting in Settings > Library lets users add folder paths where
their music files live. The file resolver checks these paths when
looking for library files, solving Docker path mismatches and multi-
folder libraries. Required for tag writing, streaming, and orphan
detection when the media server reports paths that differ from what
SoulSync can see. Docker users mount their music folder(s) with
read-write access and add the container-side path. Default is empty
— existing users see no change.
New toggle in Settings > Library: "Replace lower quality files on
import". When enabled, if a track already exists in the library at
a lower quality tier (e.g. MP3) and a higher quality version (e.g.
FLAC) is imported from staging, the existing file is replaced.
Comparison uses the existing QUALITY_TIERS system (lossless > opus/
ogg > m4a > mp3). When disabled (default), existing behavior is
unchanged — existing tracks are always kept. Also applies to regular
downloads that land on an existing file.
Discovery fix modal now tries the user's active metadata source
first, then falls back through all available sources (Spotify,
Deezer, iTunes) in sequence. Previously hardcoded Spotify with no
fallback, leaving users without Spotify stuck on "Searching...".
New /api/deezer/search_tracks endpoint exposes the existing
DeezerClient.search_tracks() method for the fix modal. Same
request/response format as Spotify and iTunes endpoints.
Qobuz added reCAPTCHA to their login endpoint, blocking automated
email/password auth for new users. Token login lets users paste
their X-User-Auth-Token from the browser DevTools after logging in
manually. Added to both Connections and Downloads tabs with
instructions. Existing email/password flow completely unchanged.
Backend validates token via user/get API and saves the session
identically to email/password login.
New "Check All Instances" button in Settings > Downloads > HiFi
shows each configured instance with color-coded status: green for
fully working (can download), orange for search-only (downloads
fail), red for offline/SSL error/timeout. Helps users understand
why HiFi downloads aren't working when community instances go down.
When downloading individual tracks from a playlist sync, the album
detection fallback hardcoded track_number to 1 instead of using the
value already available in the download context from the playlist
metadata. Now checks original_search.track_number and track_info
.track_number before falling back to 1. Full album downloads and
Spotify API lookup paths are completely unchanged.
New "Deezer" tab on sync page shows authenticated user's playlists,
identical to the Spotify tab pattern — same card layout, details
modal with track list, sync button, and download missing tracks
flow. Existing URL import renamed to "Deezer Link" tab (unchanged).
Backend: get_user_playlists() and get_playlist_tracks() on the
download client fetch via public API with ARL session cookies.
Album release dates batch-fetched for $year template variable.
Three new endpoints: arl-status, arl-playlists, arl-playlist/<id>.
Frontend: cards use Spotify-identical HTML structure with live sync
status, progress indicators, and View Progress/Results buttons.
Downloads reuse openDownloadMissingModal with zero modifications.
Track data cached on first open, instant on subsequent clicks.
Library page: new dropdown filter to show artists matched or unmatched
to any metadata source (Spotify, MusicBrainz, Deezer, Discogs, etc).
Select "No Discogs" to find artists needing manual Discogs matching.
Filter applied as WHERE clause on the source ID columns.
Discogs enrichment: added to valid_services whitelist, _enrichment_locks,
and _run_single_enrichment handler. The Enrich button was returning an
error when Discogs was selected from the dropdown.
Discogs was missing from valid_services, _enrichment_locks, and
_run_single_enrichment handler. The Enrich button on the enhanced
library page returned "service must be one of" error when Discogs
was selected. Added handler using _process_item pattern matching
other batch-style workers, with guard against track-level enrichment.
Settings > Advanced now shows database size, free pages, and auto-
vacuum mode. Two actions: Compact Database (full VACUUM to reclaim
dead space) and Enable Incremental Vacuum (one-time setup for
automatic page reclamation). Both have confirmation dialogs warning
about lock time on large databases. Info refreshes on Advanced tab
switch and after each operation.
Streaming matching: add artist gate rejecting candidates with artist
similarity below 0.4, raise threshold to 0.60, block fallback to
Soulseek filename matcher for Tidal/Qobuz/HiFi/Deezer. Fix single-
char artist containment bug where normalize_string strips non-ASCII
(e.g. "B小町" → "b") causing "b" to match any artist containing
that letter. Fixed in both score_track_match and the Soulseek scorer.
YouTube and Soulseek matching behavior unchanged.
Global search: add registerSearchDownload() calls to _gsClickAlbum
and _gsClickTrack so downloads create bubble snapshots on dashboard
and search page, matching the enhanced search standard.
Global search escaping: add _escAttr() helper to handle newlines in
album/artist names that broke inline onclick string literals.
Entries are now compact cards that expand on click to reveal source
details. Shows expected vs downloaded title/artist with red mismatch
highlighting. Source artist column added to DB. Streaming track IDs
extracted from the id||name filename pattern. File and ID always on
their own line to avoid edge-case misplacement.
Track original source filename, track ID, and AcoustID verification
result for every download. Helps debug wrong-file downloads from
streaming sources like Tidal. Each column migrated independently
for crash safety. Frontend shows source detail line and color-coded
AcoustID badge per entry. Button renamed to "Download History".
FLAC → Opus/AAC conversion only tried to read embedded art from the
source FLAC. If art was only in cover.jpg (not embedded), the lossy
copy got no art. Now falls back to cover.jpg in the same directory.
Previously skipped for Tidal/Qobuz/HiFi/Deezer as "trusted API sources"
but streaming APIs can return wrong versions (live, remix, cover).
AcoustID now runs for every download source when enabled.
Added user-follow-read scope to all 5 SpotifyOAuth instances in
web_server.py (was only in spotify_client.py). Fixed OAuth callbacks
using is_authenticated() instead of is_spotify_authenticated() — the
former returns True with iTunes/Deezer fallback, masking auth failures.
Credit: kettui (PR #253) identified both issues.
When using templates like "albums/$albumartist/$album/$track - $title",
post-processing created Transfer/ArtistName/ before computing the
template path, leaving an empty folder. _build_final_path_for_track
handles all directory creation based on the template — the premature
makedirs was redundant and incorrect.
- search_artists() now filters by active media server — no more duplicate
results from Plex/Jellyfin/Navidrome showing the same artist 3 times
- Per-artist Sync button re-fetches artist name from media server, catches
renames (e.g., Plex changing "Kendrick Lamar" to "eastside k-boy")
- Global search track click opens download modal directly instead of
navigating to enhanced search page (matches enhanced search behavior)
New download_source column on library_history table records which source
(Soulseek, Tidal, Qobuz, HiFi, YouTube, Deezer) each track was downloaded
from. Extracted from context username during post-processing.
Frontend shows source badge alongside quality badge on each download entry.
Source breakdown bar below tabs shows per-source totals with color-coded
chips (e.g., "Soulseek: 847 | Tidal: 203"). Includes DB migration for
existing installs. Existing entries show quality only (source is NULL).
Replaced single "Change" button with per-source rows showing match
status for each provider (Spotify, Apple Music, Deezer, Discogs).
Each row has Fix/Match button that searches that specific source API,
plus clear button to remove individual matches.
- Per-source search uses _search_service (same as enrichment modal)
- Backend: added Discogs to valid providers, empty ID clears match
- Fixed provider validation to accept 'discogs' alongside others
- Clear sets DB column to NULL instead of rejecting empty string
Manual scan path was only backfilling the active provider (e.g., only
Spotify IDs if Spotify was active). Now matches the auto-scan behavior:
backfills iTunes, Deezer, Spotify (if auth'd), and Discogs (if token)
for all watchlist artists before scanning begins.
Removed cross-guards that blocked wishlist downloads while watchlist scans
ran (and vice versa). Downloads use bandwidth, scans use API calls —
different resources. The per-call rate limiter handles any API contention.
- Automation engine: each handler now has self-only guard (no cross-block)
- _process_wishlist_automatically: removed watchlist scanning check
- Pipeline Phase 4: removed watchlist scanning check
- Manual watchlist scan endpoint: removed wishlist processing check
Users with large watchlists (6+ hour scans) will now see downloads
starting immediately instead of waiting for the scan to finish.
Initial load fetches 200 tracks instead of all. "Load More" button at
bottom shows (200 of 5,000) and loads next batch on click. Backend now
returns total count alongside limited results for both album and singles
categories. Rendering logic unchanged — just operates on smaller sets.
Removed as_dict=True from all three OAuth callback sites. Spotipy will
return token string directly in future versions — our code only checks
truthiness so both dict and string work.
API Call Tracker:
- Save/load 24h minute-bucketed history + events to database/api_call_history.json
- Persists across server restarts via atexit + signal handler hooks
- New record_event() for rate limit bans (called from _set_global_rate_limit)
- New get_debug_summary() for Copy Debug Info — 24h totals, peak cpm with
timestamp, per-endpoint breakdown, and last 20 rate limit events
- Fixed race condition: events iteration now inside lock during save
Spotify Rate Limit Mitigation:
- Enrichment worker: max_pages=5 on get_artist_albums (was unlimited — artist
with 217 albums caused 22 paginated API calls, now capped at 5)
- Enrichment worker: inter_item_sleep raised from 0.5s to 1.5s
Spotify Re-Auth Fix:
- Both OAuth callbacks (port 8008 + 8888) now clear rate limit ban AND
post-ban cooldown after successful re-auth — Spotify usable immediately
instead of stuck on Deezer fallback for 5 minutes
- Auth cache invalidated on both global client and enrichment worker client
Playlist Pipeline — single automation that runs the full playlist lifecycle:
refresh → discover → sync → download missing. Replaces 4-automation signal
chains. Phase-aware progress display (Phase 1/4, 2/4, etc.), guard function
prevents concurrent runs, fire-and-forget wishlist at end. Re-sync loop
catches newly downloaded tracks on next scheduled run.
- New action type 'playlist_pipeline' with handler, blocks endpoint config,
builder UI (playlist select, process all, skip wishlist checkboxes),
help modal, result display map, and Hub template
- Removed 3 redundant Hub templates (Release Radar, Discovery Weekly,
Playlist Auto-Sync) — all replaced by the pipeline
- Fixed sync completion polling (status is 'finished' not 'complete')
- Fixed refresh handler progress hijack (null out _automation_id)
- Fixed matched_tracks field access from sync_states result
Also in this commit:
- Wishlist badges on enhanced search and global search tracks (amber)
- Discogs added to manual enrichment modal search + artist/album dropdowns
- Profile PIN forgot recovery on profile selection dialog
Watchlist map nodes used w.get('key', '') which returns None when the
DB column is NULL (key exists with None value). None serialized to JSON
null, which is falsy in JS, causing 'No source ID' throw for every
artist click.
- Changed to `w.get('key') or ''` for all ID fields (coerces None→'')
- Added discogs_id to watchlist nodes (was missing entirely)
- Removed hard throw when no source ID — falls back to name-based lookup
- Added console.error logging for future diagnosis
Tidal, Qobuz, HiFi, and Deezer results were blindly taking the first
API result with minimal validation. Now all streaming sources use
score_track_match() — same 60% title / 30% artist / 10% duration
weighting as Soulseek, plus version detection penalties.
- web_server.py get_valid_candidates(): replaced loose title-sim check
with matching engine scoring, version penalty for live/remix/acoustic
- download_orchestrator.py: optional expected_track param enables
scoring in search_and_download_best (backward compatible)
- sync_service.py: passes spotify_track for validation
- Fixed wrong class name (MusicMatchingEngine not MatchingEngine)
cover.jpg was always written from Spotify/iTunes URL (640x640) when the
first track in an album reached _download_cover_art before MusicBrainz
lookup completed. Later tracks with MBID skipped because file existed.
Fix: when cover.jpg exists but is small (<200KB) and we now have a CAA
MBID, attempt to upgrade it with the high-res CAA version. If CAA fetch
fails, keep existing cover — no pointless overwrites.
Also adds Forgot PIN recovery to the profile selection PIN dialog,
reusing the same credential verification flow as the launch lock screen.
Backend reset endpoint now accepts profile_id parameter.
Runs at scan start after ID backfill — zero API calls, all DB lookups:
1. Metadata cache artist image (any source)
2. Deezer direct URL from stored deezer_artist_id
3. Deezer ID from metadata cache by name (even if not on watchlist row)
4. Album art fallback (iTunes artists have no artist images)
Also fixes update path for artists with no external IDs — falls back
to direct row ID update instead of silently matching zero rows.
- Image proxy endpoint (/api/image-proxy) for canvas CORS — allowlisted CDNs,
browser-like UA for Deezer, 24h cache headers. Direct CORS first, proxy fallback.
- Server-side 5-min cache on all artist map endpoints with auto-invalidation
on watchlist add/remove, scan complete, and new MusicMap discoveries.
- Explorer fetches similar artists from MusicMap on-the-fly when none stored,
saves to DB for instant future visits. Validates artist names against
Spotify/iTunes API before loading map — rejects gibberish with 404.
- Genre map per-genre cap removed (was 300 backend, 400 frontend).
- Center node in Explorer uses type 'center' not 'watchlist' — no longer
misidentified as a watchlist artist.
- Error overlay auto-dismisses after 2.5s and returns to Discover page.
- Helper What's New restructured with dated sections (April 4/3/2/1, March),
trimmed from ~80 to ~38 entries, date headers styled as purple dividers.
- Version modal updated with Artist Map section and recent fixes.
get_top_similar_artists now accepts require_source parameter to filter
by source ID in SQL. Previously fetched 200 artists then post-filtered,
but cycling logic (last_featured ASC) rotated artists without IDs to
the front, causing all 200 to be filtered out.
Both /api/discover/hero and /api/discover/similar-artists now pass
require_source=active_source so only artists with valid IDs are returned.
- Metadata cache backfill: batch-lookup all node names across all sources
to fill missing IDs, images, and genres
- Source-aware navigation: View Discography passes correct source to
artist page so non-active-source artists load correctly
- Constellation hover effect: 800ms delay, fade in/out animation, dim
overlay with glowing connection lines to related artists
- Click ripple animation on node selection
- Related artists list in info modal with clickable navigation
- Rich tooltip with artist photo, name, genres on hover
- Removed node dragging (caused visual desync with offscreen buffer)
- Performance: cached constellation lookups, lighter cache query
(no raw_json), canvas.width for proper DPR overlay coverage
Visual canvas map on Discover page showing watchlist artists as large
anchor bubbles surrounded by their similar artists, sized by relevance.
Layout: golden angle spiral for watchlist nodes with push-apart guarantee,
spiral packing with spatial grid collision detection for similar artists.
Offscreen buffer rendering for smooth pan/zoom (single drawImage blit).
Features:
- 320px watchlist bubbles, similar sized 25-55% by rank/occurrence
- Search bar with instant filter + animated zoom-to-node
- Tooltip with artist photo, name, genre tags on hover
- Touch support: single finger pan, pinch zoom, tap to click
- Zoom +/- buttons and fit-to-screen with smooth 250ms animation
- Click opens artist info modal (same as Your Artists)
- Loading overlay with image count progress
- Async image loading via createImageBitmap (non-blocking)
YOUR ARTISTS (major feature):
- Aggregates liked/followed artists from Spotify, Tidal, Last.fm, Deezer
- Matches to ALL metadata sources (Spotify, iTunes, Deezer, Discogs)
- DB-first matching: library → watchlist → cache → API search (capped)
- Image backfill from Spotify API for artists missing artwork
- Carousel on Discover page with 20 random matched artists
- View All modal with search, source filters, sort, pagination
- Artist info modal: hero image, matched source badges, genres, bio,
listeners/plays from Last.fm, watchlist toggle, view discography
- Auto-refresh with loading state on first load, polls until ready
- Deduplication by normalized name across all services
DEEZER OAUTH:
- Full OAuth flow: /auth/deezer + /deezer/callback
- Settings UI on Connections tab (App ID, Secret, Redirect URI)
- Token stored encrypted, auto-included in API calls
- get_user_favorite_artists() for liked artists pool
SERVICE CLIENTS:
- Spotify: added user-follow-read scope + get_followed_artists()
- Tidal: get_favorite_artists() with V2/V1 fallback
- Last.fm: get_authenticated_username() + get_user_top_artists()
FAILED MB LOOKUPS MANAGER:
- Manage button on Cache Health modal
- Browse/filter/search all failed MusicBrainz lookups
- Search MusicBrainz directly and manually match entries
- Optimized cache health queries (11 → 4 consolidated)
- Dashboard cache stats now poll every 15s
EXPLORER IMPROVEMENTS:
- Discover button on undiscovered playlist cards
- Status badges: explored/wishlisted/downloaded/ready
- Auto-refresh during discovery via polling
- Redesigned controls: prominent Explore button, icons
BUG FIXES:
- Fix album artist splitting on collab albums (collab mode fed
album-level artists instead of per-track)
- Fix cover.jpg not moving during library reorganize (post-pass sweep)
- Fix cover.jpg missing when album detection takes fallback path
- Fix wishlist auto-processing toast spam (was firing every 2s)
- Fix media player collapsing on short viewports
- Fix watchlist rate limiting (~90% fewer API calls)
- Configurable spotify.min_api_interval setting
- Better Retry-After header extraction
- Encrypt Last.fm and Discogs credentials at rest
- Add $discnum template variable (unpadded disc number)
$disc gives zero-padded "01", $discnum gives raw "1". Useful for
Plex-style naming like "102 - Track.flac" (disc 1 track 02).
Added to both _get_file_path_from_template and _get_file_path_from_template_raw
at all 6 replacement sites. $discnum always replaced before $disc to
prevent partial matching. No existing behavior changed.
1. Discover button on undiscovered playlist cards — triggers discovery
directly from Explorer instead of redirecting to Sync page. Button
changes to "Open" to reopen modal after closing.
2. Status badges on playlist cards: checkmark (in library), heart
(wishlisted), star (fully discovered), percentage (needs discovery).
Meta line shows "N in library · M wishlisted" counts.
3. Auto-refresh: polls every 5s during active discovery to update cards.
WebSocket listener for discovery:progress events. Cards refresh when
discovery completes.
4. Explored tracking: playlists get green checkmark badge after tree is
built (session-only, resets on reload).
Backend: new get_mirrored_playlist_status_counts with fail-safe design —
core discovery counts use simple reliable queries, library/wishlist
counts are best-effort extras that won't break discovery detection.
Card layout redesigned: badges inline with playlist name, discover
button below meta text, no more absolute positioning overlaps.
New feature: Failed MusicBrainz Lookups management modal accessible
from Cache Health. Browse all failed lookups with type filter tabs,
search bar, pagination. Click any entry to search MusicBrainz and
manually match — saves MBID at 100% confidence. Clear individual
entries or bulk clear all.
Backend: 4 new endpoints — failed-mb-lookups list, mb-entry delete,
musicbrainz/search (artist/release/recording), mb-match save.
Performance: Cache health stats consolidated from 11 queries to 4
using CASE expressions. Added partial index on musicbrainz_cache for
failed lookups. Dashboard cache stats now poll every 15s instead of
single fire-and-forget fetch. Failed MB type counts cached on frontend,
only re-fetched after mutations.
Also includes: library reorganize now moves cover.jpg via post-pass
sidecar sweep, and changelog updates.
When _detect_album_info_web couldn't find a good Spotify match, the
fallback dict lacked album_image_url — so _download_cover_art silently
skipped the download even though the URL was available in the context's
spotify_album object.
Now _download_cover_art accepts optional context parameter and falls
back to spotify_album.image_url or spotify_album.images[0].url when
album_info lacks the URL. All 4 call sites updated to pass context.
Feed collab mode album-level artists instead of per-track artists so
$albumartist and the album_artist tag are consistent across all tracks
in an album. Fixes media servers (Navidrome/Jellyfin/Plex) showing one
album split under multiple artist names (e.g. KPOP Demon Hunters).
- _build_final_path_for_track: resolve $albumartist from explicit batch
context or spotify_album.artists, pass album-level _artists_list to
collab mode instead of per-track artists
- _extract_spotify_metadata: same album-level artists for album_artist
tag collab resolution
- Wishlist path: pre-compute per-album artist map so all tracks from
the same album get the same artist context
- Download worker: propagate album artists array in spotify_album_context
- Test connection now shows "Discogs connection successful!" when
Discogs is the active fallback (was showing "iTunes")
- Spotify disconnect message shows correct fallback name
- Watchlist artist list: discogs_artist_id in API response
- Watchlist source badges: Discogs badge on artist cards
- Watchlist config modal: discogs_artist_id in SQL query, WHERE clause,
response, and linked provider section with badge
- CSS for watchlist-source-discogs and watchlist-provider-badge.discogs
- Add discogs_id to library artists SQL SELECT (was missing)
- Add discogs_id to artist detail discography SQL SELECT and service
IDs loop — fixes hero badges not showing Discogs
- DISCOGS_LOGO_URL constant, badge in library cards, hero, enhanced view
- Match status chip and manual match support for Discogs
- Fetch real track count from source during completion check when
total_tracks is 0 (Discogs masters) — one API call per album, runs
during existing per-album ownership check phase
- Reclassify album cards to single/EP when track count reveals 1-3/4-6
tracks — updates type label and data attribute in place
- Add collectors edition to album title variation patterns for matching
"Damn" against "DAMN. COLLECTORS EDITION." in library
- Hide 0/0 fraction when expected_tracks is 0, show proper count when
fetched
- get_album and get_album_tracks now try /masters/{id} first, fall back
to /releases/{id} — artist discography returns master IDs which are
in a different namespace than release IDs
- Fixes wrong album showing in download modal (master ID 3664443 for
GNX was hitting /releases/3664443 which is a different album)
- Add Discogs source override to all 6 artist/album/track endpoints
- Add discogs_id to _resolve_db_album_id lookup
- SpotifyClient: add _discogs lazy-load property, route _fallback to
DiscogsClient when configured (requires token, falls back to iTunes)
- web_server: _get_metadata_fallback_client returns DiscogsClient when
selected and token present
- Enhanced search: Discogs added as source tab with NDJSON streaming,
only available when token configured
- Alternate sources list includes Discogs when token is set
- Frontend: source labels, tab styling, fetch list all include Discogs
- Consistent with iTunes/Deezer pattern — same interfaces, same routing
- New core/discogs_worker.py — background worker enriching artists and
albums with Discogs metadata following AudioDBWorker pattern exactly
- Artist enrichment: discogs_id, bio, members, URLs, image backfill,
genre backfill, summary backfill from bio
- Album enrichment: discogs_id, genres, styles (400+ taxonomy), label,
catalog number, country, community rating, image backfill
- DB migration: discogs columns on artists (id, match_status, bio,
members, urls) and albums (id, match_status, genres, styles, label,
catno, country, rating, rating_count)
- Worker initialization with pause/resume persistence
- Status/pause/resume API endpoints
- Integrated into enrichment status system, rate monitor, auto-pause
during downloads/scans, WebSocket status emission
- New Discogs section on Settings → Connections with personal token input
- Discogs added as fallback metadata source option alongside iTunes/Deezer
- Token saved to discogs.token config key
- Discogs added to API rate monitor gauges (60/min with auth)
- Help text links to discogs.com/settings/developers for token generation
- Add bit_depth, sample_rate, bitrate columns to track_downloads table
- Read audio info from file via Mutagen when recording provenance
- Source Info popover shows "Audio: 24-bit · 96.0kHz · 2304kbps"
- These values are captured from the original file before transcoding,
so users can see the original specs even after Blasphemy Mode converts
FLAC to lossy format
- Update provenance file_path when Blasphemy Mode deletes the original
FLAC and replaces it with a lossy copy — provenance now points to
the transcoded file instead of the deleted original
- New update_provenance_file_path() database method
- Non-blocking: wrapped in try/except, never interrupts transcode flow
- Downsample (hi-res → CD quality) is unaffected — replaces in-place
with same filename, provenance stays valid
- When Spotify is authenticated, spotify_public playlists now use the
full API instead of the embed scraper — auto-discovers with album art,
consistent with regular spotify playlists
- When using scraper fallback, no longer sets extra_data on tracks —
lets preservation code keep existing discovery data instead of
overwriting discovered=true with discovered=false on every refresh
- Consistent with Tidal/YouTube/Deezer which never set extra_data
- Fixes Discover Weekly showing "not discovered" after overnight refresh
- New 'webhook' then-action: sends HTTP POST with JSON payload to any
user-configured URL (Gotify, Home Assistant, Slack, n8n, etc.)
- Config: URL, optional custom headers (Key: Value per line with
variable substitution), optional custom message
- Payload includes all event variables as JSON fields
- 15s timeout, errors on 400+ status codes
- Follows exact same pattern as Discord/Pushbullet/Telegram handlers
- Frontend: config fields, config reader, icon, help docs
- Updated changelogs with webhook, M3U fix, orchestrator hardening
- Each of the 6 download clients initializes independently via
_safe_init() — one failing client no longer kills the orchestrator
- All methods guarded against None clients with appropriate fallbacks
- Init failures logged at startup and tracked in _init_failures list
- Copy Debug Info shows "Download Client Failures" section when any
client failed to initialize, or "ALL" if orchestrator itself is dead
- Merge enrichment worker status into rate monitor WebSocket payload
- Hide old enrichment pills — rate monitor cards now show: service name,
worker status badge, arc gauge, calls/min, 1h/24h counts, budget bar
- Debounce idle detection with 5s grace period — prevents status
flickering between Running and Idle on every worker cycle
- Responsive grid layout with richer card design
- Add _get_tidal_download_client() helper that checks for None
soulseek_client before accessing .tidal attribute
- All 3 Tidal download auth endpoints use the helper
- Clear error messages: "Download orchestrator not initialized" or
"Tidal download client not available" instead of cryptic
"'NoneType' object has no attribute 'tidal'"
- Backend: include api_rates (per-service calls/min + Spotify endpoints)
and spotify_rate_limit (active, remaining, trigger endpoint) in debug-info
- Frontend: format API rates table with service name, cpm, limit, percentage,
and Spotify endpoint breakdown. Show bold warning block when rate limited
with trigger endpoint, remaining time, and retry-after value
- New core/api_call_tracker.py — centralized tracker with rolling 60s
timestamps (speedometer) and 24h minute-bucketed history (charts)
- Instrument all 9 service client rate_limited decorators to record
actual API calls with per-endpoint tracking for Spotify
- 1-second WebSocket push loop for real-time gauge updates
- Modern radial arc gauges with service brand colors, glowing active
arc, endpoint dot, 0/max scale labels, smooth CSS transitions
- Click any gauge to open detail modal with 24h call history chart
(Canvas 2D, HiDPI, gradient fill, grid lines, danger zone band)
- Spotify modal shows per-endpoint history lines with color legend
and live per-endpoint breakdown bars
- Rate limited state indicator — blinking red badge with countdown
timer appears on gauge card when Spotify ban is active
- REST endpoint GET /api/rate-monitor/history/<service> for chart data
- Responsive grid layout (5 cols desktop, 3 tablet, 2 phone)
- New "Concurrent Downloads" dropdown on Settings page (1-10, default 3)
- Saved to download_source.max_concurrent config key
- All 6 batch creation sites use configured value instead of hardcoded 3
- Soulseek-only album downloads still use 1 worker (source reuse per user)
- Hybrid/YouTube/Tidal/Qobuz/Deezer albums use full configured concurrency
- Source endpoint now streams artists/albums/tracks as separate NDJSON
lines as each search type completes — iTunes users see artists in ~3s
instead of waiting 9+ seconds for all 3 rate-limited calls to finish
- Enhanced search _fetchAlternateSource reads stream with ReadableStream
reader, merges each chunk into source data, re-renders tabs immediately
- Global search uses same streaming pattern via _gsFetchSourceStream
- No data loss: streamed data merges incrementally, primary response
preserves already-received alternate source data
- Remove direct request_scan() calls from album and singles import —
emit batch_complete through automation engine instead, matching the
same chain as download batches (scan → DB update)
- Show current track name in import queue status display instead of
just processed/total count
- Change <int:track_id> to <track_id> on 5 library track endpoints —
Jellyfin uses GUID strings, int converter rejected them with 404 (#237)
- Add PUT /api/library/clear-match endpoint — sets service ID to NULL
and match status to not_found, allowing users to undo wrong matches (#236)
- Add "Clear Match" button in the manual match modal for all services
- Add bottom padding to .page to prevent floating buttons (bell, help)
from overlapping track action buttons at page bottom (#237)
- New discovery_artist_blacklist table with NOCASE name matching
- Filter blacklisted artists from all 6 discovery pool queries, hero
endpoint, and recent releases via SQL subquery and Python set check
- Name-based filtering means one block covers all sources (Spotify/iTunes/Deezer)
- Hover any discovery track row → ✕ button to quick-block that artist
- 🚫 button on Discover hero opens management modal with search-to-add
(powered by enhanced search) and list of blocked artists with unblock
- CRUD API: GET/POST/DELETE /api/discover/artist-blacklist
- Updated changelogs
- Add MusicBrainz to Cache Browser: stats pill, source filter, dedicated
browse endpoint, cards with matched/failed status indicators
- Add Clear MusicBrainz and Clear Failed MB Only to cache clear dropdown
- Move MusicBrainz into Cache Health "By Source" bar chart alongside
Spotify/iTunes/Deezer instead of isolated metric row
- Rename ambiguous "Failed Lookups" to "Failed MB Lookups" in summary cards
- Add browse-musicbrainz and clear-musicbrainz API endpoints
- Add musicbrainz_total/musicbrainz_failed to cache stats response
- Add Global Search Bar and MusicBrainz cache to changelogs
- Strip cloned inline onclick on global search play button swap to prevent
simultaneous stream search + library play
- Include album thumb_url in library-check response and resolve relative
Plex paths to full URLs with base URL + token
- Pass album art through to playLibraryTrack from both global and enhanced
search library check handlers
- Add Plex music library locations as candidate dirs in _resolve_library_file_path
- Remove debug console.log from _gsDeactivate
Wing It bypasses Spotify/iTunes/Deezer matching and uses raw track
names directly. User chooses Download or Sync from a choice dialog.
Download: opens Download Missing modal with force-download-all
pre-checked. wing_it flag skips wishlist for failed tracks.
Sync: new POST /api/wing-it/sync endpoint runs _run_sync_task with
raw track dicts. Live inline sync status display on the LB card
using the same progress elements as normal sync. Unmatched tracks
skip wishlist via _skip_wishlist flag on sync_service.
Button in three places:
- Next to "Start Discovery" in all discovery modals (fresh phase)
- Next to "Download Missing"/"Sync" after discovery (discovered phase)
- Next to "Download" on ListenBrainz cards (Discover page)
Fixed force-download toggle ID, sync progress field names
(total_tracks/matched_tracks not total/matched). All changes
purely additive — normal flows unaffected.
Complete replacement of the old bottom-center stacking toast system:
Compact Toasts: Single toast at a time, bottom-right above buttons.
Pill shape with type-colored left border stripe, icon, message, and
optional "Learn more" link. Slides in, fades out after 3.5s. Click
to dismiss. New toasts replace the current one smoothly.
Notification Bell: 44px circle button next to the helper (?), with
red badge counter for unread notifications. Click opens panel.
Notification Panel: Glass popover above bell button showing history
of last 50 notifications. Each entry has type icon, message, relative
timestamp, and optional help link. Unread dot indicator. Clear All
button. Marks all as read when panel opens.
Same showToast(message, type, helpSection) signature — all 842
callers unchanged. Deduplication preserved. Updated version modal
and helper What's New.
The download orchestrator's hybrid search stops at the first source
that returns ANY results, even if all those results fail quality
filtering. This meant Soulseek returning 100 low-quality results
would prevent HiFi/Tidal/YouTube from ever being tried.
Added hybrid fallback in the download worker: when all queries exhaust
with no valid candidates and hybrid mode is active, remaining sources
in the hybrid order are searched directly, bypassing the orchestrator's
stop-at-first-hit logic. Each fallback source gets the first 2 queries
with full quality filtering and candidate validation.
Fixed guard condition from convoluted diagnostic string parsing to a
simple mode check. Uses getattr for safe attribute access.
_extract_track_number_from_filename now requires a separator after
digits (dash, dot, bracket) to prevent parsing artist names like
"50 Cent" as track number 50. Also handles disc-track format "1-03".
This is a last-resort fallback only — standard downloads get track
numbers from Spotify/iTunes/Deezer metadata. Only affects files with
no metadata where the filename starts with digits.
Pipeline parity: redownload/start now fetches full track details from
the selected metadata source (Spotify/iTunes/Deezer) for real
track_number, disc_number, and album context. Sets explicit album
context flags so post-processing uses the standard album download path.
Stuck batch fix: active_count was 0, decremented to -1 on completion,
so batch never detected as complete. Now initializes active_count=1
and queue_index=1 since we submit the worker directly.
Button timing: Download Selected handler wired up immediately before
streaming starts, reads from window._redownloadCandidates which
updates live as results arrive. No longer blocked by slow Soulseek.
Track number: _extract_track_number_from_filename requires separator
after digits so "50 Cent" is not parsed as track 50.
Progress: real download stats from /api/downloads/status. Handles
streaming sources showing "Processing..." when no transfer found.
Three bugs that would crash when clicking Download Selected:
1. Passed raw dict to _attempt_download_with_candidates which accesses
track.artists/track.album as attributes — now constructs Track object
2. TrackResult missing confidence attr — sort would crash — now set
from candidate data
3. Redownload hook (old file delete + DB update) was inside tasks_lock
blocking all task state changes — moved outside lock with proper
None initialization
Step 2 of the redownload modal now streams results as each download
source responds instead of waiting for all sources to finish. Tidal/
YouTube/Qobuz columns appear instantly while Soulseek searches.
Backend: search-sources endpoint uses ThreadPoolExecutor + NDJSON
streaming — one JSON line per source as it completes.
Frontend: reads the NDJSON stream, appends columns with fade-in
animation as each source responds. Download button enables as soon
as any results arrive.
Each source gets its own column with results grouped and sorted by
confidence. Visual confidence bars, format badges, and source-specific
metadata (Soulseek username/slots). Best overall match auto-selected.
Major redesign:
- All metadata sources shown as side-by-side columns (not tabs)
- Frosted glass modal background with blur(40px) saturate(1.4)
- Album cover art in header from DB thumb_url (resolved for Plex)
- 1100px width, all elements scaled up, white text on accent buttons
Bug fixes:
- Deezer: use global singleton client, title-only fallback search,
strip version suffixes from query
- Track.__init__: added missing popularity=0 parameter
- Overlay: dedicated .redownload-overlay class avoids CSS conflicts
New track_downloads table records every download with full source data:
service type (soulseek/youtube/tidal/etc), username, remote filename,
file size, and audio quality. Recorded at all 3 post-processing
completion points.
Source Info button (ℹ) on each track in the enhanced library view shows
a popover with download provenance: service, username, original filename,
size, quality, download date. Includes "Blacklist This Source" button
that stores the real username+filename (not guessed local filenames).
Removed broken "Delete & Blacklist" option from Smart Delete since it
had no access to real source data. Blacklisting now done exclusively
from the Source Info popover where actual provenance data exists.
Added blacklist CRUD API endpoints (GET/POST/DELETE /api/library/blacklist).
Three-step redownload flow in the enhanced library view:
1. Metadata Source — searches Spotify/iTunes/Deezer simultaneously,
shows results with match scores, flags current match
2. Download Source — searches all active download sources (Soulseek,
YouTube, Tidal, etc.), shows candidates with format/bitrate/size/
confidence, flags blacklisted sources
3. Download — starts download, polls for progress, deletes old file
on success, updates DB path
Also integrates the download blacklist into the download pipeline —
_attempt_download_with_candidates now skips blacklisted sources
automatically during all downloads (wishlist, playlist sync, etc.).
New redownload button (↻) on each track row in enhanced library view.
Post-processing hook deletes old file and updates DB track path after
successful redownload.
Track delete in the enhanced library now shows three options:
- Remove from Library: DB record only (existing behavior)
- Delete File Too: DB + os.remove() the file from disk
- Delete & Blacklist: DB + file removal + add source to blacklist
New download_blacklist table stores rejected sources (username + filename)
with CRUD methods. Blacklist will be checked by the download pipeline
and the upcoming track redownload modal.
Smart delete modal styled with the same glass/dark theme as other
SoulSync modals, with color-coded destructive options.
Dashboard enrichment chips show 'Yielding' instead of 'Paused' when
workers are auto-paused during downloads. Tooltips show 'Yielding for
downloads' for full context. Distinguishes user-paused from auto-paused.
Also handles edge case where user manually resumes a worker during
downloads — adds to override set so the loop doesn't re-pause it.
Override resets when downloads finish so next download session re-pauses.
Spotify, Last.fm, and Genius enrichment workers are now automatically
paused while any download batch is active. This prevents enrichment
API calls from competing with post-processing metadata lookups for
rate limit headroom, especially during heavy download scenarios
(3 playlist workers + wishlist downloads simultaneously).
Workers resume automatically when all downloads finish. Only workers
that were auto-paused are resumed — manually paused workers stay paused.
Piggybacks on the existing 2-second enrichment status loop with no new
threads or timers.
The manual match modal for Genius only returned 0 or 1 artist result
because search_artist() searched songs (per_page=5), extracted the
primary artist, and returned the first match or None.
Added search_artists() that returns multiple unique artists extracted
from song results with broader search (per_page=20). The manual match
endpoint now shows up to 8 artist candidates and multiple track results
instead of one-or-nothing. Also shows the Genius URL as extra info.
CAA art can be higher resolution (1200x1200+) but quality is
inconsistent — some releases have cellophane-wrapped photos or
low-quality scans. Spotify/iTunes/Deezer art is lower res (640x640)
but consistently clean and official.
New toggle: Settings → Post-Processing → "Use MusicBrainz Cover Art
Archive for album art" (off by default). Applies to both embedded
art and cover.jpg downloads.
Five places in web_server.py called spotify_client.sp.search() directly,
bypassing the cached search_tracks()/search_artists() methods. Each
discovery worker (Tidal, YouTube, ListenBrainz, Beatport) was also
doubling API calls — sp.search() for raw data then search_tracks() for
Track objects.
Now all use cached methods only. Raw track data for album art is
retrieved from the metadata cache by track ID after matching. Also fixed
a pre-existing bug where Tidal discovery could pair stale Spotify raw
data with a newer iTunes match.
Bumped is_spotify_authenticated() probe cache TTL from 5 to 15 minutes
to reduce /v1/me calls (~288/day → ~96/day). Manual disconnect still
takes effect immediately via _invalidate_auth_cache().
The watchlist auto-scan needs fresh data from Spotify to detect new
releases, so it bypasses the cache added in the previous commit.
All other callers (UI browsing, completion badges, discography views)
continue to benefit from cached results.
The MusicBrainz release ID found by _embed_source_ids was stored in the
metadata dict but never propagated to album_info. The old code tried to
write album_info['musicbrainz_release_id'] inside _embed_source_ids, but
album_info wasn't in that function's scope — causing a silent NameError.
Fix: copy the MBID from metadata to album_info in _enhance_file_metadata
(where both are in scope) right after _embed_source_ids returns. This
makes _download_cover_art see the MBID and use Cover Art Archive for
cover.jpg instead of falling back to the smaller Spotify/iTunes thumbnail.
YouTube's auto-generated artist channels use the format "Artist - Topic"
as the channel name. This suffix was not being stripped during playlist
parsing, causing metadata discovery to fail (e.g., searching for
"Koven - Topic" instead of "Koven" on iTunes/Deezer).
Fixed in all three places where YouTube artist names are cleaned:
- web_server.py clean_youtube_artist() — playlist parsing
- ui/pages/sync.py clean_youtube_artist() — UI-side parsing
- core/youtube_client.py — search result fallback artist extraction
New "Server Playlists" tab (default on Sync page) lets users compare
mirrored playlists against their media server and fix match issues.
- Dual-column comparison: source tracks (left) vs server tracks (right)
- Smart matching: exact title first, then fuzzy artist+title (≥75%)
- Find & Add: search library to fill missing slots at correct position
- Swap: replace matched tracks with different versions
- Remove: delete tracks from server playlists with confirmation
- Title similarity percentage badge on each match
- Disambiguation modal when multiple mirrored playlists share a name
- Album art on source tracks, server tracks, and search results
- Cross-column click-to-scroll highlighting
- Filter buttons (All/Matched/Missing/Extra) with live counts
- Escape key and backdrop click to close modals
- Mobile responsive (stacked columns under 768px)
- Works with Plex, Jellyfin, and Navidrome
New dashboard section shows recent syncs as scrolling cards with
playlist art, source badge, match percentage bar, and health color.
Click any card to open a detail modal showing every track's match
status, confidence score, album art, and download/wishlist status.
Per-track data is now cached in sync_history.track_results for all
sync paths: server-sync (playlist→media server), download missing
tracks, and wishlist processing. SyncResult carries match_details
from the sync service. Both image URLs and matched track info are
preserved for review.
Features:
- Staggered card entrance animation, delete button on hover
- Filter bar: All/Matched/Unmatched/Downloaded
- Color-coded confidence badges (green/amber/red)
- Unmatched tracks show "→ Wishlist" status
- 32px album art thumbnails per track row
- Auto-refreshes every 30 seconds on dashboard
- Falls back gracefully for old syncs without track_results
Sync operations now store per-track data (name, artist, match status,
confidence, download status) in a new track_results column on
sync_history. Also fixed missing config_manager import in
add_to_wishlist that crashed the duplicate tracks toggle.
Users who keep manual searches in slskd as reminders were losing
them when SoulSync auto-cleaned at 200+ entries. New toggle in
Settings → Downloads → Soulseek: "Auto-clear slskd search history"
(on by default, preserving current behavior). When disabled, both
the hourly cleanup automation and the full cleanup step skip the
search history maintenance.
The dashboard status poll and hybrid connection check were pinging
slskd every 2 minutes regardless of download source, flooding logs
with connection errors when slskd wasn't running. Now only checks
slskd when the download mode is 'soulseek' or when 'soulseek' is
in the hybrid order. Hybrid mode also only checks sources in the
configured priority order instead of all six.
Route used <int:artist_id> which rejected Spotify/iTunes/Deezer
artist IDs. Changed to accept any string — tries as DB integer ID
first (verified against DB), then falls back to checking
spotify_artist_id, itunes_artist_id, deezer_artist_id columns.
Handles numeric iTunes/Deezer IDs correctly by verifying the DB
row exists before assuming it's a DB ID.
_sync_discovery_results_to_mirrored was calling get_current_profile_id()
which requires Flask's g context, but runs in a background thread.
Now captures the profile ID in the Flask endpoint (where context exists)
and passes it through the discovery state to the background worker.
Also added debug logging to trace mirrored playlist matching.
Tidal, Deezer, and Beatport discovery workers found metadata matches
but only stored them in memory and the discovery cache — never wrote
them back to the mirrored playlist tracks. This meant playlists had
to be re-discovered every time. Now writes {discovered, provider,
confidence, matched_data} to each mirrored track's extra_data after
discovery completes, matching the pattern YouTube and automation
pipeline discovery already used. Matches tracks by source_track_id
first, falls back to position index. Purely additive — discovery
logic is untouched.
get_user_playlists_metadata_only() was fetching full track lists for
every playlist sequentially (1+ API calls per playlist with 1s sleep
between pagination pages). For 20+ playlists this took 30-60 seconds.
Now returns only metadata (name, ID, track count, image) from a
single V2 API call. Track count comes from the numberOfTracks
attribute. Tracks are fetched on-demand when the user selects a
specific playlist to sync/mirror via the existing get_playlist()
endpoint.
Fresh installs now default to hybrid download mode (HiFi → YouTube →
Soulseek) instead of Soulseek-only, and Deezer as the metadata
fallback source instead of iTunes. Existing users with saved settings
are unaffected — defaults only apply when config keys don't exist.
The tooltip on failed/not_found tracks was offset by 3-6 items because
the backend cleanup step removed owned tracks from the wishlist between
when the frontend rendered the table and when the backend assigned
track indices. Surviving tracks got new enumeration indices (0,1,2...)
that didn't match their original table row positions (0,1,3,4...).
Fix: stamp each track with its position in the frontend's track_ids
array as _original_index, so the track_index always matches the modal
table row regardless of how many tracks were cleaned during processing.
Worker threads were started before paused flag was set, allowing one
loop iteration (and API call) before pause took effect. Now sets
paused=True BEFORE start() so the thread sees it immediately. Fixes
the Spotify rate limit re-trigger on every container restart for users
with paused enrichment. Also increased max-retries rate limit ban
from 1 hour to 4 hours to prevent endless retry cycles.
YouTube and ListenBrainz discovery toasts had source_label hardcoded
to 'iTunes' when not using Spotify. Now uses discovery_source.upper()
like the other discovery functions, so it correctly shows DEEZER when
Deezer is the active metadata source.
cover.jpg was always using the Spotify/iTunes thumbnail (640x640).
Now tries Cover Art Archive first (1200x1200+) when MusicBrainz
release ID is available. One line stores the MBID on album_info
during _enhance_file_metadata so _download_cover_art can use it.
Falls back to the source thumbnail if CAA has no art. Works for
all metadata sources since MusicBrainz enrichment is source-agnostic.