- The download-modal 'Organize by Playlist' toggle had no onchange, so flipping
it never saved or synced the saved per-playlist preference. Add the handler
(source auto-derived from the ref) so both controls read/write the one
organize_by_playlist value — manual action persists, the other reflects it.
- loadDashboardSyncHistory polled /api/sync/history every 30s even while the
launch-PIN/login gate was active, 401-spamming the log. Skip when locked, and
on a 401 (stale session after a restart) surface the unlock screen so it
self-heals instead of spamming.
Reported via Find & Add (Billie Eilish "bad guy"): the track was in the library
and on Plex, but never showed in the modal's 20 results. Root cause (proven
against the real 307k-track DB): the search did `ORDER BY tracks.title`, which is
case-SENSITIVE in SQLite (BINARY collation sorts 'B' before 'b'). Billie's title
is lowercase "bad guy"; everyone else's is "Bad Guy", so all the capitalised ones
sorted first, filled the LIMIT, and her exact match landed at ~#25 — cut off.
- search_tracks now ranks by relevance: exact title match first (case-insensitive
via unidecode_lower), then prefix, then alphabetical — so an exact match can't
be sorted below the limit by a capital letter. Helps every caller.
- Added a rank-only `rank_artist` hint (never filters): Find & Add already knows
the source track's artist, so it now passes it and the exact title+artist match
floats to #1. Filtering was deliberately avoided — if the track is tagged under
a slightly different artist on the server, a filter would re-hide it.
Verified on the real DB: title-only "bad guy" now surfaces Billie at #4 (was
>#20); with the artist hint she's #1. Seam tests: lowercase exact title isn't
buried; rank hint floats the match without filtering; exact title beats a
superstring title. 10 tests pass.
On fresh page load the Downloads pill now immediately reflects whether
Download Verification is enabled (calls _verifLoadConfig in
loadActiveDownloadsPage instead of only on first filter click).
Also changed /api/verification/config to check the `acoustid.enabled`
toggle rather than the raw api_key string — matches the UI setting
"Enable Download Verification".
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ⚠ Unverified filter rows gain actions: inline play (range-streamed from the
history file path, server-side only), YouTube compare, Approve -> new
human_verified status (tag + history + tracks; AcoustID scanner skips these
entirely), Delete (file + entry)
- API: /api/verification/<id>/stream|approve|delete (path only from DB row)
- backfill: history rows with acoustid_result='fail' that exist at all were
imported despite the failure = force_imported (covers pre-fix fallback
imports like the user's 'My Ordinary Life')
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- '⚠ Unverified' filter pill on the Downloads page lists completed downloads
whose verification status is unverified/force_imported (review queue)
- the quarantine-retry engine's attempt counter (already tracked internally)
is now surfaced: task.retry_info ('2/5') shows next to Searching/Downloading
in the modal and as 🔁 on the Downloads page rows, with the trigger in the
tooltip
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The persistent Completed list is built from library_history (not live tasks),
so the badge never showed after a session ended. Column added (additive),
written at import, passed through _build_history_download_item, rendered by
_adlVerifBadge next to the status label.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Find & Add on the playlist-sync page only wrote sync_match_cache, which is
DELETEd wholesale after every DB scan — so the source->library pairing (and
the user's manual matches) reverted to 'extra'/red-dot on the next shallow
scan. The three match stores (sync_match_cache, manual_library_track_matches,
discovery extra_data) were disconnected and all pointed at tracks.id, which a
rescan re-keys (esp. Jellyfin/Navidrome GUIDs).
Unify the match so it's one durable fact, recorded once, honored everywhere:
- Find & Add also writes a durable manual_library_track_matches row (one-way;
the manual-match tool has no playlist to act on, so no reverse). Carries the
library file path.
- New library_file_path column (idempotent migration) + find_track_id_by_file_path:
re-resolve a stale library_track_id after a rescan re-keys the track, and
self-heal the row.
- The sync compare display's override lookup now falls back to the durable
manual match (resolve_durable_match_server_id) when sync_match_cache misses —
so the pairing persists across a scan instead of reverting to a red dot.
Purely additive: only adds matches when the cache returns nothing.
Tests: durable resolver (valid / stale-reresolve+self-heal / no-match / not-in-
playlist / missing-methods), file_path persistence + find_track_id_by_file_path.
Two measured, universally-beneficial fixes (kept after determining the rest of
the earlier perf work was chasing a Bitwarden extension that pegged the main
thread, not real app bugs):
- .main-content had a linear-gradient background. A gradient on the scroll
container is re-rastered across the whole scrolled area every scroll frame
(the compositor can't translate a cached tile): ~25% dropped frames -> <1%
once flattened to a solid color (visually identical, was rgb 10->15->11).
- The explorer wheel-zoom listener was a non-passive listener on `document`,
which disables compositor (async) scrolling app-wide so every wheel/trackpad
scroll runs through the main thread. Scoped it to the explorer viewport.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The per-track list inside an expanded batch was a cramped flat row with a faint
title and a -2px progress-bar hack, and the nested scrollbar sat on top of the
text. Reworked:
- Each row is now a grid: track number · title (+ artist sub-line) · right-aligned
state, with hover, tabular-aligned numbers, per-row state coloring (✓ green /
✗ red / % accent / dim queued / strikethrough cancelled), and a clean full-width
progress bar beneath downloading rows.
- Track list gets right padding + a thin, subtle scrollbar so it no longer
clips titles; same thin-scrollbar treatment on the panel itself.
- Panel widened 340->366 with rebalanced side padding for more readable content.
Collapsed-panel behavior unchanged.
Takes the Active Downloads batch panel from flat cards to a glanceable,
information-rich view:
- Sticky aggregate summary strip: 'N batches · X downloading · Y queued · speed · ~ETA'.
- Segmented progress bar per batch — proportional done (green) / failed (red) /
active (accent, animated shimmer) / remaining, so the state reads at a glance
instead of one dim fill.
- Colored stat chips (✓ done · ✗ failed · ↓ active · queued) + a per-batch ETA
from a client-side completion-rate sampler (album bundles use the downloader's
own speed/size). No backend changes — Phase A is frontend-only.
- 'Now downloading' line showing the live track on active batches.
- Expand chevron affordance (rotates when open); subtle phase tinting.
- Polished empty state with quick-start links (Search / Sync / Wishlist).
Card actions (filter / cancel / open-modal / expand) and the fade/history
behavior are unchanged. ETA/speed for non-bundle batches and a retry-failed
action are Phases B/C (backend).
PR 4 of 4 in the wishlist-album-bundle issue series. UI fix only —
zero behavior change.
User's 26-track wishlist run rendered all 26 sub-batches as
"Analyzing..." simultaneously. Pre-fix the rows were created with
``phase='analysis'`` BEFORE being submitted to ``missing_download_executor``
(max_workers=3 by default), so 23 batches sat in the executor queue
visually identical to the 3 actually running. Misled users into
thinking SoulSync was processing 26 in parallel; really only 3 ever
ran at once with the rest waiting their turn.
Fix:
- Wishlist auto-flow submission sites now create batch rows with
``phase='queued'``.
- The master worker (``core/downloads/master.py:328``) already flipped
phase to ``'analysis'`` as its first action on entry — that
transition becomes the real signal that the executor picked the
batch up.
- ``core/downloads/status.py`` surfaces ``analysis_progress`` for
the ``queued`` phase too so the UI has the track count to render
"Queued — N tracks" instead of an empty card.
- Frontend (``webui/static/pages-extra.js``, ``downloads.js``) renders
"Queued ⏳" for ``phase='queued'`` distinct from the spinner-laden
"Analyzing..." for ``phase='analysis'``.
Scope choices:
- Only the auto-wishlist submission sites flipped this PR
(``core/wishlist/processing.py:860`` album sub-batches +
``core/wishlist/processing.py:907`` residual). The manual-wishlist
sites at ``:451`` and ``:627`` use the same executor + worker, but
those create a caller-allocated batch_id that the frontend polls
immediately — wanted to verify the manual-poll path handles
``queued`` cleanly before flipping those. Trivial follow-up.
- Other submission sites in album_bundle_dispatch / web_server.py /
task_worker.py left untouched — they don't go through the
executor-queue pattern that causes this UI confusion.
Tests:
- Updated ``test_process_wishlist_automatically_creates_batch_for_matching_tracks``
to assert ``phase='queued'`` on creation (was ``'analysis'``); explanatory
comment names the executor-pool reason.
- New ``test_queued_phase_surfaces_analysis_progress_for_ui_count`` in
``tests/downloads/test_downloads_status.py`` pinning the new
``queued ⊂ analysis_progress`` rendering contract.
- 884 tests pass across wishlist + downloads + imports suites.
- Ruff clean on changed Python files; JS syntax OK on changed
webui files.
PR 3 (sibling-completion gate) was investigated and dropped — the
"1/26 finalized" symptom turns out to be downstream of the
staging-match bug (PR 2's instrumentation will catch it on the
user's next reproduction run), not an independent sibling-gate bug.
The gate logic itself is correct.
Include a capped recent tail of database-backed download history in the unified Downloads page so completed Deezer and other streaming downloads remain visible after runtime tasks are cleaned up or the container restarts. Use persistent download history for the dashboard finished count, keep live tasks authoritative for active rows, avoid showing the local clear-completed action for persisted history rows, and cover history hydration/deduping/capping in status tests.
Clarifies album-bundle progress text in the download modal and active downloads panel so release-first downloads read as downloading a release, then matching tracks after staging.
Adds waiting-state copy and tooltips for rows blocked on release staging, plus source-specific library history badge styling for Torrent, Usenet, Staging, and Auto-Import.
Route torrent and Usenet album bundles through private per-batch staging so Auto-Import cannot race public staging or duplicate imports.
Expose album-bundle progress in batch status and render it on the Downloads page while the external client is still downloading.
Tighten release handoff safety by rejecting archive path traversal, ignoring torrent candidates without a usable URL, and skipping Soulseek source reuse for torrent/Usenet batches.
Tests: .venv/bin/python -m pytest tests/downloads/test_downloads_status.py tests/test_album_bundle_dispatch.py tests/downloads/test_downloads_staging.py tests/test_torrent_usenet_plugins.py
- watchlist_scanner: fall back to album.image_url when album object has no
images list (affects MusicBrainz CAA URLs, iTunes, Deezer — all use
image_url on the Album dataclass, not the Spotify-style images array)
- Pulse Downloads nav icon while active downloads are in progress, same
pattern as watchlist scan animation
Closes#585. When a Spotify source track had a versioned suffix not
present in the local file ("Iron Man - 2012 - Remaster" vs "Iron Man"),
the auto-matcher missed the pair. User could click Find & Add to pick
the right local file — that worked, file got added to the Plex
playlist — but the source track stayed in Missing while the added
file appeared in Extra, because the matcher kept no record of the
user-confirmed pairing. On the next sync the source track re-tried
to download.
Fix: every Find & Add selection now writes a (spotify_track_id →
server_track_id) override into sync_match_cache at confidence=1.0.
The matching algorithm runs an override pass BEFORE the existing
exact and fuzzy passes, so any user-confirmed pair short-circuits
straight to "matched" without going through title normalization.
Covers every mismatch class — dash-suffix remasters, covers /
karaoke, alt masters, cross-language titles, typo'd local files.
- core/sync/match_overrides.py (new) — pure helpers
resolve_match_overrides + record_manual_match. 18 boundary tests
pin: cache hits, cache misses falling through to normal matching,
stale-cache (server track removed) handled gracefully, str/int
id coercion, partial cache hits, defensive against non-dict
inputs and DB exceptions.
- web_server.py — get_server_playlist_tracks runs the override
pre-pass before exact/fuzzy matching. server_playlist_add_track
accepts source_track_id + source_title + source_artist and
persists the override after every successful add (Plex / Jellyfin
/ Navidrome). source_track_id added to source_tracks payload so
the frontend has it.
- webui/static/pages-extra.js — _serverSelectTrack sends
source_track_id + source_title + source_artist when adding a
track from a mirrored playlist context.
- Sync match cache schema unchanged — already had UNIQUE
(spotify_track_id, server_source) which fits the override
semantics perfectly. Manual overrides distinguished from
auto-discovered matches by confidence=1.0.
Full suite: 3010 passed.
The bulk download_discography endpoint picked one metadata client
based on the configured primary source and called .get_album() on
every album with that single client. Albums whose IDs came from a
fallback/provider-specific source (e.g. Deezer-formatted IDs surfaced
through Hydrabase) failed with "Album not found" because the primary
client couldn't resolve them.
Bulk now uses the same source-aware resolver
(core.metadata.album_tracks.get_artist_album_tracks) the working
individual-album endpoint already uses, so the resolver's source-chain
walk finds each album under whichever provider actually has it. Also
adds explicit Discogs and Hydrabase support (the old if/elif chain
silently 500'd for those primaries).
Frontend (library.js + pages-extra.js) now sends a richer
`{ albums: [{id, name, artist_name, source}] }` payload so each album
can be resolved through its own source. The legacy `album_ids` payload
still works as a fallback path.
Closes#399.