All 11 background workers (9 enrichment + repair + SoulID) pause when a DB
scan starts and resume when it finishes. Tracks which workers were running
so manually-paused workers stay paused after scan. Covers incremental,
full refresh, deep scan, automation-triggered, and startup scans.
Embed scraper was pre-marking spotify_public tracks as discovered with
playlist-level images instead of per-track album art. Discover step then
skipped them entirely. Now stores spotify_hint (track ID) without marking
discovered, so Discover runs proper API lookups for real album art.
Event-triggered automations now receive playlist_id from the triggering event
when the action config doesn't have one set. Fixes silent 'No playlist specified'
failures in Discover/Sync chains. Added debug logging to trace event matching.
- Stats page: database storage donut chart with per-table breakdown and total size
- Discover page: 5 new sections mined from metadata cache (zero API calls):
Undiscovered Albums, New In Your Genres, From Your Labels, Deep Cuts, Genre Explorer
- Genre Deep Dive modal: artists (clickable → artist page), popular tracks,
albums with download flow, related genre pills, in-library badges
- All cache queries filtered by active metadata source (Spotify/iTunes/Deezer)
- Stale cache entries (404) gracefully fall back to name+artist resolution
- Album cards show "In Library" badge, artist avatars scaled by prominence
Integrates play history data into the discovery algorithm:
- Listening profile: _get_listening_profile() builds user's top artists,
genres, play counts, and listening velocity from the last 30 days
- Artist genre cache: pre-built from local DB for O(1) genre lookups
- Release Radar: +10 genre affinity, +15 artist familiarity, -10 overplay
penalty. Weights rebalanced to 45% recency + 25% popularity + bonuses
- Discovery Weekly: serendipity scoring within tiers — boosts unheard
artists in preferred genres, penalizes overplayed artists
- Recent Albums: adaptive time window (21-60 days) based on listening
velocity — heavy listeners get fresher content, casual listeners more
- New "Because You Listen To" sections: personalized carousels based on
user's top 3 played artists via similar artists + genre fallback
- New endpoint: /api/discover/because-you-listen-to with artist images
- Frontend: BYLT sections with artist photo headers on discover page
- All changes gracefully fall back when no listening data exists
Full stats dashboard that polls Plex/Jellyfin/Navidrome for play
history and presents it with Chart.js visualizations:
Backend:
- ListeningStatsWorker polls active server every 30 min
- listening_history DB table with dedup, play_count/last_played on tracks
- get_play_history() and get_track_play_counts() for all 3 servers
- Pre-computed cache for all time ranges (7d/30d/12m/all) rebuilt each sync
- Single cached endpoint serves all stats data instantly
- Stats query methods: top artists/albums/tracks, timeline, genres, health
Frontend:
- New Stats nav page with glassmorphic container matching dashboard style
- Overview cards (plays, time, artists, albums, tracks) with accent hover
- Listening timeline bar chart (Chart.js)
- Genre breakdown doughnut chart with legend
- Top artists visual bubbles with profile pictures + ranked list
- Top albums and tracks ranked lists with album art
- Library health: format breakdown bar, unplayed count, enrichment coverage
- Recently played timeline with relative timestamps
- Time range pills with instant switching via cache
- Sync Now button with spinner, last synced timestamp
- Clickable artist names navigate to library artist detail
- Last.fm global listeners shown alongside personal play counts
- SoulID badges on matched artists
- Empty state when no data synced yet
- Mobile responsive layout
DB migrations: listening_history table, play_count/last_played columns,
all with idempotent CREATE IF NOT EXISTS / PRAGMA checks.
Opus ffmpeg command now uses -map 0:a to extract only the audio stream,
matching the user's working command and preventing picture stream
interference with the encoder. After conversion, cover art is embedded
from the source FLAC using Mutagen: Opus gets METADATA_BLOCK_PICTURE
(base64-encoded per OGG spec), AAC gets MP4Cover. Applied to both
the post-download lossy copy and the repair job fix handler.
- Add get_artist_top_tracks to Last.fm client (up to 100 tracks)
- Include lastfm_listeners, lastfm_playcount, lastfm_tags, lastfm_bio,
and soul_id in artist detail API response
- New endpoint: /api/artist/<id>/lastfm-top-tracks for lazy loading
- Hero layout: image (160px) | center (name, badges, genres, bio,
listener/play stats, progress bars) | right card (scrollable top
100 tracks from Last.fm)
- Badges 36px with hover lift, bio in subtle card with Read More
toggle, Last.fm tags merged with existing genres
- Numbers formatted: 1234567 → 1.2M
- Graceful degradation: sections hidden when Last.fm data unavailable
Lossy copy now supports MP3, Opus, and AAC (M4A) codecs with a
configurable dropdown in settings. Each codec uses the appropriate
ffmpeg encoder (libmp3lame/libopus/aac) and Mutagen tag writer
(ID3/Vorbis/MP4). Quality tag, filename substitution, and Blasphemy
Mode file cleanup all work per-codec. Backward compatible — existing
configs default to MP3.
get_current_profile_id() uses Flask's g object which doesn't exist
in background threads. Now profile_id is captured at request time
and passed as a parameter to _run_sync_task. All 8 call sites
updated. Automation path defaults to profile_id=1.
Orphan detector: add normalized tag matching that strips parentheticals
and brackets (feat. X, [FLAC 16bit], etc.) and tries first-artist-only
for comma-separated artists. Prevents false orphan flags for tracks
like "The Mountain (feat. Dennis Hopper...)" that exist in DB as
"The Mountain". All lookups remain O(1) set operations.
Orphan fix: replace auto-delete with user choice prompt. Single Fix
and Fix All both show modal asking "Move to Staging" or "Delete".
Move to Staging relocates file to import staging folder for proper
re-import with metadata matching. Fix action flows through API
endpoint → repair_worker.fix_finding → _fix_orphan_file handler.
Staging path uses docker_resolve_path for container compatibility.
SoulID worker generates deterministic soul IDs for all library entities:
- Artists: hash(name + debut_year) — searches iTunes + Deezer APIs,
verifies correct artist by matching discography against local DB
albums via MusicMatchingEngine, pools years from both sources and
picks the earliest. Falls back to hash(name) if no match found.
- Albums: hash(artist + album)
- Tracks: song ID hash(artist + track) + album ID hash(artist + album + track)
Dashboard button with trans2.png logo, rainbow spinner, hover tooltip.
Worker orb with rainbow effect. SoulSync badge on library artist cards.
DB migration adds soul_id columns with indexes to artists/albums/tracks.
Migration version flag auto-resets artist soul IDs when algorithm changes.
1. Fix filename parser pattern order — "01 - Title" now matched
before "Artist - Title", preventing track numbers being treated
as artist names (e.g., "08" no longer becomes the artist)
2. Tag priority over filename parsing — shared _read_staging_file_metadata()
helper reads title, artist, albumartist, album, track_number, disc_number
from Mutagen tags. Only falls back to filename parsing when BOTH title
AND artist tags are empty. Applied to all 3 staging scan sites.
3. Improved match scoring — rebalanced from title(0.5)+tracknum(0.5) to
title(0.45)+artist(0.15)+tracknum(0.30)+album_bonus(0.10). Files
whose album tag matches the selected album get boosted.
4. Auto-group detection — new /api/import/staging/groups endpoint groups
staging files by album+artist tags. Frontend shows "Auto-Detected
Albums" section with one-click search. Match endpoint accepts
optional file_paths filter to scope matching to a specific group.
- Use correct server request types: 'tracks', 'albums', 'artists',
'artist.albums', 'album.tracks' (were singular, caused timeouts)
- Normalize artists to strings (server may send dicts)
- Use native plugin IDs (iTunes/Spotify) instead of soul_id for
album/artist/track IDs so downstream endpoints can resolve them
- Carry soul_id and plugin_id in external_urls for routing
- Pass plugin param from frontend to server for correct client routing
(iTunes vs Deezer vs Spotify) with isdigit() fallback
- Route source=hydrabase to iTunes client for artist images
- Include external_urls in enhanced search API response
- Reduce WebSocket timeout from 15s to 8s
- Remove stale hydrabase.enabled check, use is_connected() directly
- Add hydrabase to frontend alternate source fetch list
- Normalize Hydrabase artists to strings (server may send dicts),
fixing silent crashes that prevented albums/tracks from appearing
Post-processing now extracts release year from MusicBrainz, Deezer,
Tidal, Qobuz, and Spotify context (first source wins). Writes
ORIGINALDATE and DATE tags to file, and backfills the album year
in the DB if currently missing. Fixes Library Reorganize showing
blank years for Tidal-only downloads.
Also raises Library Reorganize API year lookup cap from 50 to 200.
- Add debouncedAutoSaveSettings() to moveHybridSource and toggleHybridSource
- Skip unconfigured sources at search time with is_configured() check
- Add get_source_status() to orchestrator, include in settings API response
- Auto-disable unconfigured sources in UI on settings load
Album pre-flight now tries 3 query variations instead of one:
full artist+album, cleaned artist+album (no feat./parentheticals),
and album-only. Stops at first success. Fixes cases where a banned
keyword in the artist name caused zero results, falling back to
slower track-by-track search unnecessarily.
Post-processing: Extract 7 inline source lookup blocks into standalone
_pp_lookup_* functions called in configurable order via
metadata_enhancement.post_process_order config. Default order matches
original hardcoded sequence — zero behavioral change.
Hydrabase: Fix connected Hydrabase incorrectly becoming primary source.
_is_hydrabase_active() now only returns True in dev_mode (legacy).
Auto-connect no longer forces dev_mode. Hydrabase as fallback works
through normal _get_metadata_fallback_client path like iTunes/Deezer.
Settings button min-width fix.
- Remove redundant enable checkbox — fallback dropdown is the enable
- Hydrabase option only appears in dropdown when connected
- Connect/disconnect dynamically adds/removes dropdown option
- _is_hydrabase_active checks fallback_source == hydrabase (not config toggle)
- Fallback client returns hydrabase_client when selected, iTunes if disconnected
- Auto-reconnect respects fallback selection for dev_mode handling
- hydrabase added to settings save service list for persistence
- Status shows green Connected on page load when auto-connected
Hydrabase appears as a switchable source tab alongside Spotify/iTunes/
Deezer when connected and enabled. Added to alternate_sources list and
enhanced-search/source endpoint. Tab only shows when Hydrabase is
available — zero change when disconnected.
Add Hydrabase section to Settings → Connections with enable toggle,
WebSocket URL, API key, auto-connect, and connect/disconnect button.
_is_hydrabase_active() now checks hydrabase.enabled config in addition
to dev_mode — either path activates it. Default disabled, zero change
for existing users. Dev admin page stays behind dev mode password.
Adds Tidal per-profile OAuth with token storage on profile row.
Auth initiation stores profile_id in PKCE state, callback detects
it and stores encrypted tokens per-profile instead of globally.
Personal settings modal now shows Spotify, Tidal, Server Library,
and ListenBrainz sections for non-admin profiles. Admin sees only
ListenBrainz (unchanged). Server library selection wired into
playlist sync via _apply_profile_library.
Full per-profile support: Spotify (credentials + OAuth + playlists),
Tidal (OAuth + token storage), server library (Plex/Jellyfin/Navidrome),
ListenBrainz (existing). All backwards compatible — upgrading users
see zero change.
PlaylistSyncService now accepts profile_id and applies per-profile
library selection before syncing. _apply_profile_library sets Plex
library name or Jellyfin user/library on the client based on the
profile's saved preferences. All existing _get_active_media_client
calls automatically pick up the active profile via instance state.
Admin and profiles without custom library settings: zero change.
get_spotify_client_for_profile() returns the right SpotifyClient for
the active profile. Admin and profiles without custom creds get the
global client (zero behavioral change). Profiles with custom creds
get a dedicated cached instance with separate OAuth token storage.
Playlist listing endpoint now uses per-profile client so non-admin
users with their own Spotify credentials see their own playlists.
DB migration adds 11 columns to profiles table for per-profile Spotify
credentials, Tidal tokens, and media server library selection. All
encrypted, all default NULL (fall back to global config).
API endpoints follow existing ListenBrainz per-profile pattern:
- GET/POST/DELETE /api/profiles/me/spotify
- GET/POST /api/profiles/me/server-library
Foundation only — no frontend UI or client initialization changes yet.
Workers would endlessly match the same track because UPDATE WHERE id =
NULL matches 0 rows in SQL. Added AND id IS NOT NULL to all enrichment
queries (individual, batch EXISTS, and batch fetch) across all 9
workers. Also added process-level guard for belt-and-suspenders safety.
Fix Deezer get_track → get_track_details method name mismatch.
Stamp _original_index on tracks before submitting to download processor
so task indices match frontend modal row order. Sanitization and
filtering could reorder tracks, causing the backend to assign indices
that didn't match the displayed rows. Visual-only fix — actual
downloads were always correct.
All 6 playlist sync endpoints now accept download_complete phase so
users can re-sync after downloading. Added Rediscover button to
discovered and download_complete states (YouTube + Beatport). Added
full button set (Sync, Download Missing, Rediscover) for
download_complete which previously showed no buttons at all.
- Only emit batch_complete automation event when successful_downloads > 0
- Remove int() cast on album_id in _fix_incomplete_album — Plex/Jellyfin
use hash/text IDs that SQLite stores as-is in INTEGER columns
- Use string comparison for album_id equality check
Replace fixed primary/secondary hybrid dropdowns with an ordered list
of all 5 download sources. Users enable/disable each source and reorder
with up/down arrows to set download priority. Sources are tried in
order until one returns results.
- New hybrid_order config field (backward compat with legacy primary/secondary)
- Download orchestrator loops ordered list with per-source error handling
- Sortable source list UI with icons, toggle switches, priority numbers
- Source-specific settings shown for all enabled hybrid sources
- Seamless migration from legacy 2-source to N-source format
Before searching Soulseek/YouTube, checks the staging folder for a
matching file using tag-based or filename-based fuzzy matching. On match,
copies the file to the transfer folder and runs normal post-processing
(tagging, AcoustID, path organization). Staging scan is cached per batch
to avoid re-walking for every track.
Search results now show switchable tabs for alternate metadata sources.
Primary source renders immediately, alternate sources load in parallel
and tabs appear progressively as each completes.
- New /api/enhanced-search/source/<name> endpoint for per-source queries
- Source-aware routing via ?source= param on discography, album tracks,
album detail, and artist image endpoints (prevents numeric ID
misrouting between iTunes and Deezer)
- Source override stored on artistsPageState for consistent navigation
- Tabs styled with source brand colors, show result counts
- All additive — users who ignore tabs see zero behavioral change
- Add max_peer_queue setting to skip peers with long queues (soft filter
with fallback to unfiltered if all results removed)
- Add download_timeout setting replacing hardcoded 10-minute limit
- Include quality_score (peer health: upload speed, free slots, queue
length) in result ranking — was calculated but never used in sort key
- New UI controls in Soulseek settings section
- Frontend was concatenating Track and Artist inputs into a single
query string, causing Spotify to return mixed results matching
either word in any field. Now sends track and artist as separate
params; backend builds field-filtered query (track:X artist:Y).
- Result limit was silently capped at 10 in spotify_client.search_tracks
via min(limit, 10). Raised to respect requested limit up to
Spotify's API max of 50.
- iTunes fallback endpoint updated with same field-specific params.
- Legacy ?query= param still supported for backward compatibility.
Fixes#194
- Duplicate `spotify` key in saveSettings() object literal caused
second definition (embed_tags/tags) to silently overwrite the first
(client_id/client_secret/redirect_uri), destroying credentials on
every save. Merged into single key.
- authenticateSpotify() and authenticateTidal() now await saveSettings()
before opening auth window, ensuring credentials are persisted.
- Tidal auth now dynamically sets redirect_uri from request host for
LAN/Docker users and stores it in tidal_oauth_state so the callback
token exchange uses the same URI.
Fixes#191
Preflight: hash track IDs before syncing and compare against last sync.
Skip only if exact same tracks were already synced and all matched.
Replaces the old count-based smart-skip which could miss track swaps.
Sync history: update existing entry for same playlist_id instead of
creating duplicates. Re-syncing the same playlist now refreshes the
existing row with new timestamps and stats.
Metadata sources (Deezer/iTunes/Spotify) return separate entries for
Explicit/Clean/Remastered variants of the same album. Normalize titles
by stripping variant suffixes and group by name+year, keeping the
version with the most tracks or preferring explicit.
Playlist refresh was storing matched_data.album as a bare string with
no images dict. When unmatched tracks were added to the wishlist, they
showed as 'Unknown Album' with no cover art. Now all three playlist
import paths (authenticated API, public scraper, spotify_public) store
album as a proper dict with name and images array, matching the format
used by the discovery worker.
Track count heuristics were overriding the album_type field from
Spotify/iTunes/Deezer, forcing any release with <=3 tracks into
singles and 4-6 tracks into EPs regardless of actual album type.
Now trusts the source's album_type directly.
Replace category-based tag settings (10 toggles) with per-tag controls
grouped by source service in an accordion UI. Each of the 11 service
groups (Spotify, iTunes, MusicBrainz, Deezer, AudioDB, Tidal, Qobuz,
Last.fm, Genius, General) has a master toggle that disables all child
tags, with individual toggles for fine-grained control. ISRC and
copyright fallback chains are now per-source toggleable. Genre merge
contributions from each source are independently controllable. All
tags default to enabled for backward compatibility.