- Stop passing in spotify_id as the id in the UI, use the actual db id instead
- Fixes an issue where albums for another artist would end up being returned for the actual searched artist
- Remove the redundant artist_id filtering code
- Fixes an issue where not-currently-owned albums would be filtered out from the results, even if they were successfully fetched from the configured metadata provider
M3U files were generated when the download batch completed but
before post-processing finished tagging and moving files. Paths
pointed to download locations instead of final library paths,
making every track show as missing. Now regenerates the M3U from
the backend batch completion handler after all post-processing
is guaranteed done, resolving real file paths from the library DB.
Skips overwrite if zero tracks resolve to avoid replacing a
partially-good M3U with an all-missing one.
The retag fix for AcoustID mismatches was only updating the DB
record (title, artist_id) without writing corrected tags to the
actual audio file. Users would click Fix, the finding disappeared,
but the file on disk stayed unchanged. Now writes title and artist
tags to the file via Mutagen after the DB update.
Also fixed artist INSERT missing server_source when creating a new
artist during retag — now uses the active media server value.
Auto-wishlist albums cycle was passing is_album=True to
_get_batch_max_concurrent which returns 1 for soulseek mode.
This restriction is for folder-based album grabs from a single
peer, not individual track downloads. Wishlist always does
single-track downloads regardless of cycle, so it should use
the user's configured concurrency setting.
_adlFetch() fetches /api/downloads/all?limit=300 then _adlUpdateBadge()
was counting active statuses from that truncated array, overwriting
the real server-side count maintained by WebSocket. Removed the
badge update from _adlFetch — the WebSocket status push already
keeps it accurate.
Fix modal results: sort standard album versions above live, remix,
cover, soundtrack, remaster, and deluxe variants so users see the
original studio track first instead of obscure versions.
Plex Find & Add: tracks were always appended to the end of the
playlist because addItems ignores position. Now moves the track
to the correct slot after adding via moveItem.
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.
Coverage for fix 2.1:
TestResolveDbTrackIdsBatch:
- Batch returns the same (title, artist) -> id mapping as the
per-event lookup would have
- Case-insensitive matching preserved
- Empty event list returns an empty dict
- Events without a title are skipped
- A cursor-execute counter proxy confirms 50 events trigger exactly
one SQL query (not 50)
TestMapPlayCountsToDb:
- Returns updates only for server IDs that exist in tracks
- Empty input returns an empty list
- 30 server IDs trigger one batched query
TestEnrichStatsItems:
- Populates image_url / id / artist_id on matching artists, albums,
and tracks; skips rows with no match
- Empty or missing top_* lists are safe
- Three batched queries total (one per section) regardless of the
number of items in each list
The listening stats worker ran three N+1 query patterns on every
30-minute poll cycle:
1. _resolve_db_track_id was called once per history event (up to
500 events = 500 SELECTs).
2. _map_play_counts_to_db ran one SELECT per server track ID.
3. _enrich_stats_items ran one SELECT per top_artist, top_album,
and top_track (typically 60 extra queries per rebuild).
All three paths now use batched IN queries with 500-row chunks
(well under SQLite's default variable limit of 999). Case-insensitive
matching and LIMIT 1 semantics are preserved via setdefault() on the
Python-side result dict.
Track resolution uses SQLite row-value IN ((?,?), ...) on
(LOWER(title), LOWER(artist_name)), available in SQLite 3.15+
(bundled with Python 3.13).
Coverage for fix 4.2:
- _cleanup_old_requests evicts entries older than _MAX_REQUEST_AGE
and leaves fresh entries intact; returns the number removed
- Empty map is safe (no error)
- start_cleanup_thread is idempotent (returns False on second call)
- stop_cleanup_thread joins the thread and clears the handle
- The thread actually evicts stale entries on wakeup
- stop signals the thread to exit promptly via the stop event
instead of waiting for the next interval
Inbound music requests are tracked in an in-memory _pending_requests
dict with a 1-hour TTL. Cleanup was only triggered inside
create_request(), so during quiet periods stale entries stayed in
memory until the next inbound request.
Add a background thread that wakes every 5 minutes and evicts any
entry older than _MAX_REQUEST_AGE. The thread is started once during
API blueprint registration (start_cleanup_thread is idempotent) and
is a daemon, so it exits automatically on process shutdown.
stop_cleanup_thread() is exposed for tests and future graceful-
shutdown hooks. It signals the stop event so the thread exits
without waiting for the next cleanup interval.
Covers fix 4.1:
- Default limit (100) applied when no params given
- limit and offset slice correctly without overlap between pages
- status param accepts single or comma-separated values
- Unknown status returns empty list with total=0
- limit is clamped to a max of 500
- Negative or non-integer limit/offset fall back to safe defaults
- Tasks are returned newest-first by status_change_time
GET /api/v1/downloads previously serialized every entry in the
in-memory download_tasks dict on every call. With a long-running
server and many historical downloads this produces an unbounded
response payload.
The endpoint now accepts:
limit - max items to return (default 100, clamped to 1..500)
offset - skip first N items (default 0)
status - comma-separated statuses to include (e.g. downloading,queued)
The response now includes total (post-filter count), limit, and
offset so clients can paginate without loading everything first.
Tasks are sorted by status_change_time descending so the newest
activity is on page 1.
Backward compatibility: clients that ignore the new query params
get the same shape plus the extra top-level fields; the downloads
list itself is just capped at 100 instead of unbounded.
Coverage for fix 1.1:
TestBackfillMigration verifies the one-shot migration sets
match_status='matched' for rows that already have a populated
external ID (lastfm_url, musicbrainz_release_id,
musicbrainz_recording_id, tidal_id, qobuz_id) but NULL match_status,
and leaves rows without an ID untouched.
TestGetExistingIdColumnMapping verifies lastfm_worker reads
lastfm_url for all entity types and musicbrainz_worker reads the
correct per-type column (musicbrainz_id / musicbrainz_release_id /
musicbrainz_recording_id).
TestLastFMWorkerMarksMatched / TestTidalWorkerMarksMatched /
TestQobuzWorkerMarksMatched / TestMusicBrainzWorkerMarksMatched
verify each worker's _process_* short-circuit path sets
match_status='matched' (and does not re-call the external API) when
the entity already has an ID populated.
Four enrichment workers (Last.fm, MusicBrainz, Tidal, Qobuz) had a
bug where every background loop re-processed the same rows because
the existing-ID short-circuit path never set match_status, and two
workers queried the wrong column when checking for an existing ID.
lastfm_worker._get_existing_id queried a non-existent lastfm_id
column; the real column is lastfm_url. The method now reads
lastfm_url for all three entity types.
musicbrainz_worker._get_existing_id queried musicbrainz_id for all
entity types, but albums use musicbrainz_release_id and tracks use
musicbrainz_recording_id. The method now uses a per-type column map.
All four workers (lastfm, musicbrainz, tidal, qobuz) now write
match_status='matched' when they short-circuit on an already-present
external ID, so these rows are no longer re-selected on the next
worker sweep.
A new migration (_backfill_match_status_for_existing_ids) runs once
on startup to retroactively set match_status='matched' for rows that
already have an external ID but NULL match_status. This covers legacy
data, manual matches, and rows populated from file tags outside the
worker.
The /api/v1/library/tracks endpoint called search_tracks() to get
DatabaseTrack objects, then immediately called api_get_tracks_by_ids()
to re-hydrate full rows for serialization. Two round trips per search.
Added api_search_tracks() that returns dict rows with all track columns
plus artist_name, album_title, and album_thumb_url in a single query.
The basic and fuzzy search helpers were refactored to share raw-row
implementations, so the existing search_tracks() still returns
DatabaseTrack objects for the many internal callers that depend on
that shape (matching pipeline, repair worker, web UI search).
The wishlist list endpoint previously loaded and JSON-decoded the full
wishlist, filtered by category in Python, then sliced in memory. Cost
grew linearly with wishlist size on every page request.
get_wishlist_tracks now accepts offset and category parameters, both
applied in SQL via LIMIT/OFFSET and json_extract. get_wishlist_count
also accepts category so COUNT(*) matches the filtered page. The API
endpoint uses these to return only the requested page.
Backward compatible: other callers (core/wishlist_service) pass no
offset/category and still receive the full list.
Covers original ordering preservation, partial/full hit thresholds,
empty result_ids, TTL expiration, cache miss behavior, and a
round-trip count assertion confirming 50 entities resolve in a
single SELECT (not 50).
MetadataCache.get_search_results previously looped over each cached
entity ID and issued one SELECT per ID, producing N extra queries per
cached search hit. It now resolves all entities in a single batched
IN query (chunked at 500 to stay under the SQLite variable limit),
then reconstructs the result list in the original result_ids order
using an in-memory dict lookup.
Every authenticated API request previously called config_mgr.set(api_keys),
which rewrites the entire app config blob to SQLite. Under load this caused
significant write amplification and lock contention.
Persistence of last_used_at is now throttled per key hash to once every
15 minutes. The in-memory timestamp on the matched key is still updated
immediately, so reads within the same process see the live value; only
the on-disk persistence is throttled.
8 test files had _DummyConfigManager missing get_active_media_server(),
causing failures when pytest ran them before the test file that had it.
Whichever file set sys.modules first won, and the incomplete dummy broke
later tests. Also fix script.js read_text() missing encoding='utf-8'
which failed on non-UTF-8 default locales.
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.
- broaden the artist-detail dedup helper to catch trailing parenthetical edition and remaster variants
- keep the legacy hyphenated suffix fallback for older metadata
- add regression coverage for language-specific Edition and remaster cases
- 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
Install dev dependencies, compile Python sources, and run pytest on every push to catch any potential issues that might've gone unnoticed during development
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.