Non-admin: 3-tab layout (Music Services | Server | Scrobbling).
Admin: just ListenBrainz, no tabs (unchanged).
Server tab auto-detects active server (Plex/Jellyfin) and shows
library name dropdowns instead of raw ID inputs. Modal has max-height
with scroll, tab bar with accent underline indicator.
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.
Pending count queries included NULL-ID rows that _get_next_item filters
out, so pending stayed > 0 even when no processable items remained.
Workers reported running instead of idle, UI never turned green. Added
AND id IS NOT NULL to _count_pending_items across all 9 workers to
match the _get_next_item filter.
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.
New setting: Min Completion % (default 0 = disabled). Skips albums
where the user has fewer than N% of tracks — filters out playlist
imports where a single track exists but the full album was never
intended to be downloaded. Catches real failed downloads (6/12)
while ignoring incidental singles (1/12).
Skip refresh attempt if client_id/secret not configured — clears stale
tokens to stop retry loop permanently. Add 5-minute backoff after
failed refresh to prevent hammering Tidal API every 2 seconds.
New pipelines: Startup Recovery (3 automations), Import Pipeline (3),
Weekly Deep Clean (5), Beatport Fresh (1). Fix deploy calling
nonexistent loadAutomationsPage — now calls loadAutomations so the
list updates immediately after deployment.
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.
Wishlist entries from auto-fill were missing album images, album ID,
track number, disc number, and total tracks. Downloads would have no
cover art and wrong file organization. Now includes full album context
matching the standard wishlist data format.
When suffix path matching fails, reads file tags (title+artist) and
checks against DB tracks. Prevents false orphan detection from path
mismatches in Docker where DB paths differ from filesystem paths.
Only runs for files that fail suffix matching — zero overhead in
normal cases.
- 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
Finding was created with entity_id=None (file-based) but fix handler
required entity_id for DB update. Rewrote handler to work with file_path
as primary — writes corrected track number to file tags, renames file
if needed, updates DB path. Also stores total_tracks in finding details
for correct tag writing.
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.
Replace 3-column glassmorphic card wall with centered single-column
tabbed interface. Horizontal pill tab bar (Connections, Downloads,
Library, Appearance, Advanced) with category switching.
- Kill glassmorphic cards, accent gradient bars, and box shadows
- Clean section headers with subtle dividers
- Horizontal setting rows (label left, control right)
- Custom styled select dropdowns with SVG arrow
- Quality Profile moved into Downloads tab (conditionally visible)
- Help text wraps to new line below controls
- Path inputs and template inputs properly styled
- Mobile responsive (rows stack, tab bar scrolls)
- Zero functional changes — all element IDs and JS logic preserved
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
Was Spotify-only — users without Spotify got zero results. Now queries
albums with any source ID (spotify_album_id, itunes_album_id, deezer_id)
and uses the matching API client for track count and missing track lookup.
Falls back gracefully across sources with client-type detection.
When >50% of files are flagged as orphans (likely a DB path mismatch),
findings are marked as warnings with mass_orphan flag. Fixing these
requires typing "witness me" to confirm — prevents nuking an entire
library from a false-positive orphan scan.
_sanitize_context_values passed empty strings through _sanitize_filename
which converts '' to '_' (empty-name fallback). Template $album ($year)
became Album (_) instead of Album () which the cleanup regex couldn't
match. Now preserves empty strings so the existing () cleanup works.
- Add _fix_path_mismatch handler so fixing Library Reorganize dry-run
findings actually moves files (was returning 400 with no handler)
- Add path_mismatch to fixable_types and _execute_fix handler map
- Add recent_releases and wishlist_tracks as year sources in
_load_album_years to cover more playlist-synced tracks missing years
- Add sys and json imports needed by new code
- Add single_album_redundant to fixable_types in bulk_fix_findings so
Fix All actually includes these findings (Fix Selected worked, Fix All
silently returned 0)
- Expand version keyword regex from 9 to 25 terms (remastered, deluxe,
unplugged, etc.) to reduce false positives in Single/Album Dedup
- Add word boundary anchors to prevent substring matches (e.g. "live"
inside "Alive", "edit" inside "Meditate")
- Cast similarity thresholds to float for config type safety
The SQL HAVING clause filtered on local track count instead of expected
track count, excluding albums with fewer than 3 local tracks from the
scan entirely. Now fetches all Spotify-matched albums and filters by
expected track count in the loop.
- 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.
If album info is missing, not a dict, or named "Unknown Album",
it gets repaired using the track name as fallback instead of
storing junk display data.
EPs are no longer classified as singles — removing EP tracks would
break the release and conflict with album completeness checks.
Only actual singles (album_type='single' or unknown type with <=2
tracks) are flagged as redundant when the same song exists on an album.
Tracks on different albums (e.g., same song on a studio album and a
compilation) are no longer flagged as duplicates by default, keeping
albums complete. Setting is enabled by default and configurable via
the job settings UI.
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.
Post-processing now writes all 18 MusicBrainz tags that Picard writes:
Release Group ID, Album Artist ID, Release Track ID, Release Type,
Status, Country, Original Date, Media, Barcode, Catalog #, ASIN,
Script, Total Discs (plus the 5 already supported). One cached API
call per album via get_release with recordings include.
New "Tags to Embed" settings section with 10 category toggles (all
enabled by default): MusicBrainz IDs, Release Info, Source IDs, ISRC,
BPM, Mood & Style, Copyright & Label, Genre Merging, URLs, Quality.
Each shows inline description of what it includes.
Playlist-synced tracks often have no year in file tags or the DB albums
table. Now checks discovery_pool release_date as a second DB source,
then batch-lookups remaining missing years from the user's active
metadata source (Spotify/iTunes/Deezer) capped at 50 API calls.
Multiple code paths built wishlist album data as a bare string instead
of a dict with images/album_type/total_tracks, causing wishlist entries
to lose album name and artwork. Fixed in: Beatport fallback conversion,
sync normalization, _ensure_spotify_track_format, and wishlist service
track reconstruction. Now uses track name as album fallback and always
includes required album dict keys.
New dropdown in Soulseek settings lets users filter out slow peers at
search time (Any/1/2/3/4/5/10 Mbps). Passes minimumPeerUploadSpeed
to slskd API in bytes/sec.
Also fixes quality scoring tiers which were using wrong units — old
thresholds (5000, 1000, 500) treated bytes/sec values as kbps,
making speed scoring effectively meaningless. Now uses correct
bytes/sec thresholds based on real peer data.
Deep scan passed skip_existing_tracks=True which skipped calling
insert_or_update_media_track entirely for known tracks, so stale
file paths were never refreshed from the media server. Now always
calls insert_or_update (which safely uses UPDATE for existing tracks,
preserving enrichment data) so file paths stay in sync.
Normalization was stripping parenthetical content before comparison,
so 'title' and 'title (xxx remix)' both became 'title' and always
matched at 1.0 regardless of the user's threshold setting.