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.
Large libraries (first import) can take longer than 10 seconds for
getArtists to respond. The short timeout caused the library fetch
to fail with 0 artists returned.
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.
Same song from different albums was blocked from entering the
wishlist by a name+artist dedup check. Added toggle in Settings →
Library → File Organization: "Allow duplicate tracks across albums"
(on by default). When enabled, the dedup is skipped — different
album versions of the same song can coexist in the wishlist for
complete discography downloads. The UNIQUE constraint on track ID
still prevents the exact same track from being added twice.
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.
ETH address was wrong in the support modal. Also fixed clipboard
copy failing on HTTP (Docker) — navigator.clipboard requires HTTPS.
Added textarea fallback for insecure contexts, and shows the address
in a toast as last resort if both methods fail.
With metadata-only listing, tracks aren't pre-loaded. Now shows
loading overlay while fetching tracks via /api/tidal/playlist/<id>,
then dismisses overlay and opens discovery modal. Overlay is hidden
at every exit point (error, empty, success) to prevent it from
blocking the modal.
_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.
mirror_playlist() was deleting all tracks and re-inserting them,
which wiped the extra_data containing discovery results. Now saves
the {source_track_id: extra_data} map before deleting and restores
it on re-insert for tracks that don't bring their own extra_data.
This prevents discovery loss when playlists auto-refresh on tab load.
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.
The metadata-only optimization broke two things:
1. Cards showed 0 tracks because tracks were no longer in the listing
2. Auto-mirror skipped all playlists because tracks array was empty
Fix: cards render instantly from metadata, then tracks are fetched
per-playlist in the background via /api/tidal/playlist/<id>. As each
playlist's tracks arrive, the card count updates and the playlist
is auto-mirrored. Also tried multiple V2 attribute names for track
count (numberOfTracks, numberOfItems, etc.) and fixed the card DOM
selector for count updates.
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.
The original #221 fix only covered Genius and AudioDB. All other
workers (Spotify, iTunes, Last.fm, MusicBrainz, Deezer, Tidal,
Qobuz) had the same bug: enrichment overwrites manual match status
to not_found when name search fails. Each worker now checks for an
existing service ID before searching by name and returns early if
one exists, preserving the manual match.
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.
When spotipy exhausts all retries on 429 errors, the actual
Retry-After value (often 10+ hours) is consumed internally by
spotipy and not passed in the exception. The default ban was
only 1 hour, causing an endless retry cycle. Increased to 4
hours to match the escalation max and give Spotify's ban time
to expire.
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.
Status text and indicators now use fixed Material Design colors
instead of accent-dependent values — green for running/idle, amber
for paused, red for stopped, dim white for not configured. Readable
regardless of the user's chosen accent color.
normalize_string() was running unidecode on all text, converting
Japanese kanji to Chinese pinyin gibberish (命の灯火 → "tvanimedei").
Now detects CJK characters (kanji, hiragana, katakana, hangul,
fullwidth forms) and skips unidecode for text containing them —
just lowercases instead. Non-CJK text (Latin accents, Cyrillic)
still goes through unidecode normally.
"Believe" was falsely matching "Believe In Me" because SequenceMatcher
gives high scores when the search string is fully contained in the
match. Added a length ratio penalty: when cleaned titles differ in
length by more than 30%, the similarity score is multiplied by the
ratio (min/max length). This crushes prefix/suffix false positives
while leaving exact matches and cleaned variants (remastered, deluxe)
unaffected.
Playlist auto-sync was dropping tracks that failed iTunes/Apple Music
discovery — they never reached the wishlist or download pipeline. Now
undiscovered tracks continue through using available metadata: first
from the spotify_hint (embed scraper data with real Spotify track ID,
name, artists), then from raw playlist fields if a source track ID
exists. Album cover art from the mirrored playlist is included. Only
tracks with no usable ID or name are skipped.
Always-visible button in Spotify API section that clears the OAuth
token cache, pauses enrichment, and switches to the configured
fallback metadata source. Also fixed the dashboard service card to
show the actual active source name (Spotify/iTunes/Deezer) instead
of always showing "Spotify" with an amber fallback indicator.
New dedicated Explorer page with interactive node graph visualization.
Users select a mirrored playlist, choose Albums or Discographies mode,
and the app builds a branching tree: playlist root → artist nodes →
album nodes → track nodes. Supports all metadata sources (Spotify,
iTunes, Deezer) with source-aware discovery cache integration.
Features:
- Streaming NDJSON builds tree progressively as artist data arrives
- Circular artist nodes with photos, rounded album nodes with art
- SVG bezier connections that draw in on completion, fade on hover
- Click artist to expand albums, double-click album for track listing
- Single-click albums to select, Select All/Deselect for bulk ops
- Wishlist confirmation modal with per-album progress (NDJSON streaming)
- Artist nodes glow when any of their albums are selected
- Playlist picker with source tabs, discovery % gate (50% minimum)
- Zoom (scroll/pinch/buttons), pan (right/middle-drag), fit-to-view
- Metadata cache for discographies and album track listings
- Owned album detection from library database
- Fallback track-name matching when album names are missing
Plain (unsynced) lyrics were being saved with .lrc extension despite
having no timestamps, making them invalid for Plex and other players
that expect LRC format. Synced lyrics now write as .lrc, plain lyrics
write as .txt. Both types still get embedded in audio file tags.
Updated all file move/rename operations to handle .txt sidecars
alongside .lrc.
Enrichment chips now show live activity: 24h call count for all
services and daily budget usage (used/3,000) with gradient progress
bar for Spotify. Tracking is centralized in _get_enrichment_status
using cumulative stat diffs over a rolling deque — no worker files
modified. Added section header, "Configure →" label for unconfigured
services, and full 1h/24h breakdown in tooltips.
Single path template was missing _artists_list and _itunes_artist_id
context keys, so the collab mode first-artist extraction in
_apply_path_template had nothing to work with — $albumartist resolved
to the full multi-artist string. Added both keys matching the exact
pattern used by album and playlist modes, including the iTunes
spotify_album.external_urls fallback. Updated settings UI hints to
show $albumartist as available for single and playlist templates.
When a user manually matched an artist to a service ID then triggered
enrichment, the worker re-searched by name, failed to find a match,
and overwrote the status back to not_found — despite the ID being
valid. Now both Genius and AudioDB workers check for existing service
IDs before searching by name. If an ID exists (from manual match),
the worker uses it for a direct API lookup to enrich metadata while
preserving the matched status. Added AudioDB lookup-by-ID client
methods for artist, album, and track.
Top-level try/except in do_GET ensures an HTTP response is always sent
— previously, unhandled exceptions caused BaseHTTPRequestHandler to
silently close the connection (ERR_EMPTY_RESPONSE). All callback
logging now uses the app logger instead of print() so output appears
in app.log rather than only Docker stdout. Added health check at / to
verify the callback server is running, and startup now logs the actual
bind address to help diagnose port conflicts.
Dashboard now displays all enrichment services as live-status chips
below the core service cards. Each chip shows Running, Idle, Paused,
Stopped, or Not Configured state with color-coded left border accents.
Unconfigured services appear dimmed with dashed borders — clicking any
configurable chip navigates to Settings → Connections and scrolls to
the relevant service section.
Also fixes the Spotify card always being labeled "Apple Music" when
using iTunes fallback — card now always says "Spotify" with an amber
"Using iTunes/Deezer" indicator when fallback is active.
Qobuz login was only available on the Downloads tab when Qobuz was
selected as download source. But Qobuz credentials are also needed
for the enrichment worker which runs independently. Users saw
"Connect Qobuz in Settings" on the dashboard but couldn't find it.
Adds a Qobuz section to Settings → Connections (same pattern as
Tidal's existing Connections section). checkQobuzAuthStatus() now
syncs both the Connections and Downloads tab sections. Login from
either tab updates both. No backend changes — same API endpoints.
The tooltip only checked paused/authenticated/idle/running states.
When Spotify was rate limited or daily budget exhausted, the worker
thread was still alive (sleeping in guards) so it showed "Running"
with no current item and stale 0% progress.
Now checks rate_limited and daily_budget.exhausted before running:
- Rate limited: "Rate Limited — Waiting Xm for rate limit to clear"
- Budget exhausted: "Daily Limit Reached — Resets in Xh Xm"
- No current item: "Waiting for next item..." instead of blank
Also adds rate_limit info object to get_stats() response for the
countdown display.
Cache maintenance:
- Input validation rejects junk entities (Unknown Artist, empty names)
from being cached, with exemptions for synthetic entries (_features,
_tracks suffixes)
- CacheEvictorJob expanded to 4 phases: TTL eviction, junk cleanup,
orphaned search cleanup, MusicBrainz failed lookup cleanup
- MusicBrainz null results now expire after 30 days (was 90) so failed
lookups get retried sooner
Cache health UI:
- Polished modal accessible from Dashboard "Cache Health" button and
repair dashboard health bar
- Shows health status banner (healthy/fair/poor), stat cards, source
breakdown with colored progress bars, type pills, and metrics table
- Repair dashboard shows compact bar with health dot indicator
downloadSelectedCategory() was passing only the category name to the
download function, which fetched ALL tracks in that category. Now
collects checked track IDs from checkboxes BEFORE closing the modal
(DOM is destroyed on close), then filters the fetched tracks to only
the selected ones.
If nothing is checked, downloads the full category (same as before).
Other callers of openDownloadMissingWishlistModal are unaffected —
the new selectedTrackIds parameter defaults to null.
The auth_tidal() endpoint was overriding the user's configured
redirect_uri with one built from request.host. In Docker, request.host
is the container hostname (e.g. "soulsync-webui"), not the external
URL the user configured in settings.
Now checks config_manager for the user's configured redirect_uri first.
Only falls back to request.host dynamic detection if no redirect URI
is configured.
Album art embedding now tries Cover Art Archive first (using the
MusicBrainz release ID from source ID embedding) before falling back
to Spotify/iTunes/Deezer URLs. CAA provides original-quality artwork,
often 1200x1200 or higher vs Spotify's 640x640 max.
Reordered _embed_source_ids to run before _embed_album_art_metadata
so the MusicBrainz release ID is available for the CAA lookup. Also
fixed hardcoded 640x640 FLAC picture dimensions — now detects actual
size from image bytes. Falls back to existing behavior if CAA fails
or no release ID exists.
When the fingerprint score is >=0.95 but title/artist don't match
(e.g. English expected vs Japanese returned), SKIP instead of FAIL.
A 95%+ fingerprint means the audio IS the correct recording — the
metadata mismatch is just a language/script difference, not a wrong
file. Prevents Japanese, Chinese, Korean, and other non-Latin tracks
from being falsely quarantined.
Happy path unchanged — matching title/artist still returns PASS at
the earlier check before this code is reached.
Previously, tags.clear() only cleared in memory — if any later step
threw (metadata extraction, API calls, album art download), the file
was moved with its original Soulseek source tags intact. This caused
album fragmentation in media servers when some tracks had MusicBrainz
IDs and others didn't.
Now the cleared tags are saved to disk immediately after wiping. If
enhancement succeeds, the file is saved again with full metadata
(identical to before). If it fails, the file has clean empty tags
instead of inconsistent junk — media servers group by folder structure
which is always correct.
When artist or title contains non-ASCII characters (Japanese, Chinese,
Korean, etc.), prepend the original un-romanized text as the first
search query. unidecode converts Japanese kanji to Chinese pinyin
(e.g. "藤澤慶昌" → "wu zhi zhuan sheng") which never matches on
Soulseek. The original characters match filenames directly.
Romanized fallback queries are still generated after for coverage.
Zero impact on ASCII-only tracks (isascii check skips them).
Replaces the fire-and-forget button with a premium modal that shows
exactly which artists will be added before confirming. Features:
- Glassmorphic modal with stat cards, two-column artist grid, search
filter, collapsible ineligible section, and loading spinner
- Source-aware filtering: only shows artists with the active source's
ID (Spotify/iTunes/Deezer) as eligible
- Frontend and backend both paginate at 400 to avoid SQLite variable
limit (SQLITE_MAX_VARIABLE_NUMBER=999) that silently broke queries
above ~500 artists
- Backend source detection aligned with frontend — uses only the
active source's ID, falls back to configured metadata source
The bulk watchlist add had no Deezer ID support — artists with only a
deezer_id were silently skipped (artist_id stayed None). Also fixed the
source detection to use the actual ID field picked instead of a numeric
heuristic that could assign Deezer IDs to the wrong service column.
Fallback chain is now: active source first, then Spotify → iTunes → Deezer.
fix_finding() was using a potentially stale transfer_folder that was
only refreshed during scheduled job runs, not on manual fix attempts.
Now re-reads the transfer path from config before each fix, matching
the same logic used by _run_next_job().
Also surfaces fix failure reasons to the user — bulk fix now logs each
failure with finding ID and error, and the frontend toast shows the
actual error message instead of just "X failed".
Artist/album/track matching previously took the first Spotify search
result above the 0.80 similarity threshold. If Spotify returned a
near-match before the exact match (e.g. "Brother's Keeper" before
"Brothers Keepers"), the wrong entity would be selected.
Now scores all candidates and picks the highest, so an exact match
(1.0) always wins over a near-match (0.94). No change to threshold
or batch matching logic — strictly better or equal results.