results comes from asyncio.gather over to_search, so they're always equal length — strict=True asserts the invariant and satisfies ruff B905. Carried in with #896.
The discovery pool synthesizes deezer popularity onto a 0-100 score (base 45 + bonuses, capped at 100), but _get_popularity_thresholds had deezer on the raw-rank scale (500000/100000). So Popular Picks' 'popularity >= 500000' matched nothing — empty for every deezer-primary user — while Hidden Gems' '< 100000' caught the whole pool. Deezer thresholds now sit on the 0-100 scale (60/50, like Spotify's 60/40). Tested.
Every library track was stored with disc_number=1 because the Jellyfin/Plex/Navidrome scan parsed the track number but never the disc field. Multi-disc albums collapsed onto disc 1, so disc-2+ tracks were mis-filed (shown under disc 1) and flagged 'missing' — the frontend title-fallback band-aid couldn't recover it (breaks on iTunes title mismatches).
Now the shared insert_or_update_media_track reads the disc number (Jellyfin .discNumber=ParentIndexNumber, Navidrome .discNumber, Plex .parentIndex), floors to >=1, and stores it in the INSERT + UPDATE. The disc_number column is ensured on init (it was only added by a migration that doesn't run on fresh installs, so the new INSERT would have hard-failed for new users). The enhanced album view already carries disc_number through (SELECT * -> dict), so the display fixes itself once the column is populated — a re-scan backfills existing libraries. Seam-tested across Jellyfin/Navidrome/Plex shapes + the floor-to-1 + re-scan-update cases.
Adds an opt-in `rank_candidates_by_quality` profile flag. When on, the
priority-mode download walk orders candidates by the ranked-target quality
(confidence/speed only break ties) instead of confidence-first. Default off
keeps the byte-for-byte old behaviour, so existing installs are unaffected.
Best-quality search mode is always quality-first regardless of the flag; the
toggle only affects priority mode. Search-time source selection is unchanged —
nothing is skipped, so a track can never go missing, only the order in which
copies are tried changes.
The version-mismatch force-import follows automatically: it accepts the
first-tried (= best-ordered) quarantined candidate, which is the highest-quality
one once the walk is quality-first. No change to its selection logic needed.
- core/quality/selection.py: load_rank_candidates_by_quality() (fail-closed).
- core/downloads/task_worker.py: _best_quality_ordering -> _candidate_ordering;
quality-first when best_quality mode OR the toggle is on.
- database/music_database.py: default profile carries the flag (False).
- web_server.py: flag is preserved globally across preset apply/reset, like
search_mode.
- core/imports/version_mismatch_fallback.py: comment clarified (no behaviour
change).
Tests (TDD): load_rank_candidates_by_quality default/enabled/disabled/error;
_candidate_ordering across all mode+toggle combinations + fail-closed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two subsystems post-process the same completed transfer: the browser-poll
status endpoint (web_server) and the background download monitor. Both watch
the same slskd/streaming transfers and each launches the verification
pipeline. When one path quarantines + requeues the next-best candidate
(clearing username/filename, status -> 'searching'), the monitor's
already-submitted run_post_processing_worker then runs, finds no source info,
and falsely marks the task 'failed' ("missing file or source information") —
clobbering the in-flight retry while a parallel attempt imports the song.
Fix: a single atomic claim (downloading/queued -> post_processing under
tasks_lock) so exactly one path processes each download.
- runtime_state: new claim_for_post_processing() helper
- post_processing: race guard — worker bails (no fail/notify) if the task is
no longer 'post_processing' when it runs
- web_server: both poll paths (Soulseek + streaming) claim before launching;
claim is released on thread-launch failure
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The tab reads the v2 personalized framework (personalized_playlists), but the Discover page generates through the legacy path and nothing seeded those v2 rows -> the tab was empty. Fixes:
- New 'listening_mix' v2 generator: hands the scan's stored 'listening_recs_tracks_full' tracks to the personalized manager so the Listening Mix can mirror + Auto-Sync like every other kind (no pool hydration; can't shrink on rotation). Registered + tested.
- Sync tab now lists every registered SINGLETON kind (Listening Mix, Fresh Tape, Archives, Hidden Gems, Discovery Shuffle, Popular Picks) as a card, not just already-generated rows. Clicking 'Refresh & Mirror' runs the generator + mirrors. Variant kinds (decade/genre/daily) need a picker, so they're not auto-listed; existing variant rows still show.
Additive: new generator + frontend merge, no backend endpoint changes. End-to-end verified (refresh -> generate -> persist -> syncable tracks).
The mix is (artist, title) pairs acquired via Soulseek, so the recommendation fetch needn't match the user's active source. When the active source can't fetch top tracks (iTunes/Discogs/MusicBrainz — or Spotify when unauthed), fall back to Deezer's public artist/{id}/top (no auth, available to everyone). All five active sources now build a full mix without switching; the name-search + names_match guard still prevents wrong-artist results. New pure helper choose_mix_fetch_source + tests.
#913 was silently producing 0 recs: similar_artists.source_artist_id is a SOURCE id (Spotify/etc.), but the scan keyed id->name by internal artists.id (resolved nothing), and the consensus ranker was fed the name-collapsed get_top_similar_artists (consensus could never fire). Fixed + elevated:
- id->name keyed by source-id columns; raw per-seed edges (real consensus); similarity_rank threaded into the score; recency-weighted seeds (recent plays boost lifetime favs)
- new 'Based On Your Listening' artist row (/api/discover/listening-recommendations) with 'because you listen to X' explanations
- new 'Your Listening Mix' track row: each rec's top tracks via a guarded, name-resolved Spotify/Deezer fetch (falls back to the discovery pool), stored as full render dicts so the row can't shrink on pool rotation
- pure tested core: similarity_from_rank, build_recency_weighted_seeds, to_mix_track, names_match (+ rank-aware grouping)
Fresh Tape (5-10 tracks): future-dated albums sorted to the top of get_discovery_recent_albums and ate the 50-album budget before the is_future_release skip ran. Add exclude_future_years + fetch a generous budget; downstream caps unchanged. Regression tested.
Also drop the per-track block 'X' from the compact playlist rows (wrong spot). Plan/audit in DISCOVER_BEST_IN_CLASS_PLAN.md.
The SoundCloud/Amazon/Tidal/Qobuz/Deezer/HiFi/Lidarr clients did an UNGUARDED
mkdir(parents=True) on the configured download path in __init__. With a Docker
'/app' path (or any unmounted/misconfigured volume), that raises Permission
Denied, the plugin registry nulls the whole client, and the source vanishes —
SoulseekClient already guards the identical mkdir and just warns. Outside the
container this also failed every test_download_orchestrator_soundcloud.py test
(10) by leaving client('soundcloud') = None for the patch targets.
Fix: wrap the mkdir in try/except OSError + warn (matching soulseek) across all
seven clients and the orchestrator's runtime path-update; the dir is created
lazily at download time. Real robustness win: a slow/unmounted volume at boot no
longer silently drops download sources. Regression test forces an uncreatable
path and asserts init doesn't raise — pinned in any environment.
Full suite green: 6713 passed, 0 failed (was 10 failed).
'sami matar' was a wing-it FALLBACK stub — a placeholder the discovery pipeline
makes when it can't resolve a track to real metadata (no album, no cover). The
live sync explicitly skips wing_it_* ids for the wishlist (no metadata to act on),
but my re-add didn't — so it stored a coverless, single-classified placeholder.
That's why: sync didn't add it, no images, marked single.
Fix (parity): reconstruct refuses ids starting 'wing_it_'. Frontend renders the
'-> Wishlist' status as plain, non-clickable text for wing-it rows (with a tooltip)
since they were never actually wishlisted. Real tracks keep the working button +
the byte-identical-payload re-add from the prior fix.
Root cause (the real one): the auto-add passes original_tracks_map[id] — tracks_json
run through a specific normalization (album->dict with images/album_type/total_tracks/
release_date, artists->dicts). My re-add hand-rolled a different shape, so the stored
spotify_data didn't match and the wishlist's nebula (which reads spotify_data.album.
images[0].url) had no cover, plus album/single classification could differ.
Fix: extract that normalization into one shared build_original_tracks_map() and use it
in BOTH the live sync (core.discovery.sync) and the re-add. The re-add now resolves the
track by source_track_id through the same map — byte-identical payload. Verified on a
real sync row: re-add payload == live-sync payload, album.images present. (The shared
normalizer is also copy-safe, fixing a latent tracks_json mutation in the old inline
version.)
Fallback (track absent from tracks_json) rebuilds through the same normalizer with the
cover seeded from the row's image_url. 10 tests incl. a direct parity assertion.
The re-add showed no album/single art. Cause: reconstruct returned the full track
from tracks_json AS-IS — and some syncs store tracks_json lean (no album.images),
so the re-added wishlist entry had an empty album.images even though the track's
cover was sitting right there in the track_result's image_url.
Fix: always backfill album.images from the track_result's image_url when the album
has none (and copy the dict so tracks_json isn't mutated). Real album art is kept
when present; the 250px thumb only fills a gap. Verified against a real sync row in
all three cases (full / lean tracks_json / no tracks_json) — album.images now
populated in every one. The wishlist card reads album.images, so the cover shows.
In the dashboard Recent Syncs detail modal, the '→ Wishlist' status on unmatched
tracks is now a button. Clicking it re-adds that exact track to the wishlist with
the SAME context the sync used (source_type='playlist' + the playlist's name/id +
failure_reason), so it's indistinguishable from the original auto-add.
- reconstruct_sync_track_data() (pure, tested): prefers the full cached track from
tracks_json (by source_track_id, then index) so album art/full data carry over;
falls back to the track_result fields; refuses non-'wishlist' rows and rows with
no id (can't re-wishlist a matched/unidentifiable track).
- POST /api/sync/history/<id>/track/<i>/wishlist resolves the entry server-side and
calls the wishlist service; idempotent (reports added vs already-on-wishlist).
- button shows a busy state then '✓ Re-added' / '✓ On wishlist'.
7 pure tests (full-track preference, id-vs-index match, fallback rebuild, non-
wishlist + out-of-range refusal). JS/PY/ruff clean.
The ranked-target list is now the single source of truth for which formats
download, in the user's exact priority order, for ALL sources — no hardcoded
format hierarchy decides anything. A candidate passes only if it matches a
ranked target; if nothing matches, the existing Use-Fallback toggle decides.
- source_map: new shared format_from_extension() + AUDIO_EXTENSIONS — one
source of truth for extension→format used by every extension-based source, so
adding a format lights it up everywhere. Soulseek now classifies through it
(opus/wav/aiff were previously dropped as 'unknown').
- file_ops.probe_audio_quality (generic import-time guard, all sources): add
WMA; detect ALAC from the real codec (an .m4a is AAC or ALAC).
- soulseek: drop the AAC-specific opt-in gate — AAC now follows the same
universal rule as every format.
- model.tier_score: documented as ONLY a same-format tiebreak + fallback order,
never cross-format priority (the list owns that); add opus/alac bases.
- UI: ranked-target editor offers all formats (FLAC/ALAC/WAV·AIFF lossless with
bit-depth+sample-rate; MP3/AAC/OGG/Opus/WMA lossy with min-bitrate).
- tests: AAC retargeted to the universal model; new coverage for
format_from_extension and matches_target across all formats.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes align across all three servers. Jellyfin reorders in place: DELETE the
extra entries (Mirror) then POST /Playlists/{id}/Items/{entryId}/Move/{index} for
each desired track in ascending order — so the playlist's poster/name/Id survive
(no delete-recreate), same as Plex/Navidrome. Mirrors the existing reconcile path's
entry-id handling (PlaylistItemId via /Playlists/{id}/Items).
- jellyfin reorder_playlist() + get_playlist_track_ids(); reuses the shared, tested
plan_align_rewrite planner (no new pure logic).
- /align endpoint + frontend gate now cover navidrome|plex|jellyfin.
UNTESTED LIVE: no Jellyfin instance to verify against (same status as the Navidrome
path). Plex is the only one confirmed working end-to-end so far.
The align buttons were gated to Navidrome, so Plex users (the actual tester) never
saw them. Plex reorders in place via plexapi moveItem/removeItems — preserves the
playlist's poster/summary/ratingKey (no delete-recreate), same spirit as Navidrome's
overwrite.
- plex_client.reorder_playlist(): moves each desired track into sequence, removes
any current item not in the ordered list (Mirror drops extras; Keep includes them).
get_playlist_track_ids() feeds the shared tested plan_align_rewrite.
- /align endpoint dispatches navidrome + plex; reuses the pure planner for both.
- frontend gate opened to navidrome|plex.
- modal redesigned: cover art per row, gradient header, pop/fade animation, hover
rows, real polish (was a plain numbered list).
plexapi moveItem/removeItems signatures verified against the installed version.
Adds the 'Align playlists' action to the out-of-order modal — a dedicated,
order-only write path that does NOT touch the normal sync. Subsonic has no
per-track move, so it overwrites the song list in source order via createPlaylist
+ playlistId (same primitive replace-mode uses; identity/id preserved).
- plan_align_rewrite() (pure, tested): matched server ids in source order; every
one must already be in the playlist (never injects a track); extras either
dropped ('Mirror source') or parked at the end ('Keep extras'); returns None on
stale data so a vanished track can't be written.
- navidrome rewrite_playlist_order() primitive (raw ordered ids).
- /api/server/playlist/<id>/align: validates ids are in the live playlist, then
rewrites. Navidrome-only for now (Plex/Jellyfin reorder = follow-up).
- modal gets two explained options; missing tracks are NOT added (normal sync's
job) and that's stated. Metadata-free by design — it only reshuffles existing
server ids, so there's no sync-parity surface.
Open: confirm createPlaylist+playlistId preserves the playlist comment/image on a
live Navidrome (same risk as replace mode); add a re-apply step if it doesn't.
- file_ops.probe_audio_quality: .aiff/.aif were opened with mutagen.wave.WAVE,
which can't parse AIFF — it raised, failed open, and let AIFF silently bypass
the quality filter. Route aiff/aif to mutagen.aiff.AIFF (still the 'wav'
lossless tier).
- test_hifi_preview_guard: _get_hls_manifest gained an expected_duration_s kwarg
and the start tier now comes from quality_tier_for_source (default profile ->
'hires'); accept the kwarg and pin the tier so the chain is deterministic.
- test_quarantine_management: quarantine_group_key intentionally no longer uses
source-specific ids/uri (they break cross-batch sibling matching); assert the
isrc -> normalized-name contract instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
repair_worker had TWO _fix_quality_upgrade methods; the legacy one (expecting
expected_title/_fix_action) shadowed the new findings-based one (matched_track_
data), so applying a Quality Upgrade finding failed live with "No title/artist".
The "remove dead functions" refactor missed it. Merge into a single handler:
matched_track_data -> wishlist (safe pattern, no auto-delete on redownload) plus
_fix_action='delete' -> remove file + row. Also drops the duplicate dispatch key.
test_quality_upgrade: the pure-decision tests called deleted helpers
(meets_preferred_quality / classify_track_quality / preferred_quality_floor /
RANK_*). Rewire them to the shared v3 API (targets_from_profile +
quality_meets_profile); drop the few that only pinned deleted internals; update
the scan stubs for the new resolve_library_file_path/_read_file_ids signatures.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 2.7.4 merge replaced the bucket-based quality filter with core.quality.model
and silently dropped #886's AAC handling. Restore it in the new model:
- v2_qualities_to_ranked_targets maps an enabled 'aac' tier to a format-only
target (slskd AAC rarely carries a bitrate, so no min_bitrate gate); priority
order (above MP3, below FLAC) comes from the caller's sort.
- soulseek filter_results_by_quality_preference drops AAC/.m4a candidates when
the profile has no AAC target, so AAC stays OFF-by-default instead of slipping
through via fallback.
- the three factory presets carry the legacy qualities dict again with the
disabled aac tier (used by the settings UI + the #886 opt-in toggle).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#3 priority mode is quality-agnostic again: search_with_fallback (the
priority/hybrid path — best_quality has its own search_all_sources) returned
the first source whose results met a target, so an mp3 with bitrate=None (slskd
omits it often) was deemed unsatisfied and deprioritised, changing which source
wins for users who never opted in. Restore "first source with tracks wins",
byte-for-byte; cross-source quality pooling stays in best_quality mode.
#4 metadata-less FLAC no longer over-claims a hi-res target: matches_target let
a FLAC with no sample_rate/bit_depth satisfy a 24-bit/192k target while a real
16/44 FLAC failed it, so unknown-spec files outranked and discarded genuine CD
FLAC under audiophile/hi-res profiles. An unconfirmable spec now fails the strict
tier and falls to the plain-flac bucket.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#1 DB init crash: the idx_lh_verification_status index was created before the
ALTER TABLE that adds the column, so a fresh library_history (every existing
install + clean checkouts) died on startup with "no such column:
verification_status". Move the index after the column migration.
#2#652 quarantine loop returns: the rewritten
filter_results_by_quality_preference dropped the _drop_quarantined_sources()
pre-filter, letting a previously-quarantined (user, file) win the picker again
and re-quarantine forever. Re-wire it at the top of the method.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The editor renders the server column in SOURCE order (reconcile_playlist pairs
each server track to its source row), so a reordered-but-same-membership playlist
read as '5 matched / in sync' when Navidrome's real order actually differed — the
reorder never reaching the server was invisible.
- compute_order_status() (pure, tested): matched tracks' server positions must be
strictly ascending in source order; uses RELATIVE order so missing/extra tracks
never false-flag. reconcile entries now carry server_index (additive).
- endpoint returns order_status + server_order (the server's actual sequence).
- editor shows an amber 'out of order' badge on the server column when membership
matches but sequence differs, opening a read-only modal of the real server order.
One-way: source order stays the source of truth; no server-side editing.
Tests reproduce the reported 'Real Love Baby moved to #2' case + guard against
false-flagging on missing/extra. The actual 'sync order' WRITE is a separate
follow-up (membership/extra semantics + live identity-preservation test pending).
The limit=200 fix only helped FRESH fetches. The metadata cache is persistent
(SQLite, 30-day TTL), so any album whose tracklist was cached at 50 BEFORE the
fix keeps returning 50 from cache in every window that loads it (line returned
the cached entry without revalidating) — which is why the Standard-view add-album
modal still showed 50 while a freshly-fetched album in the download window showed
the full list. Same album, different cache state.
Fix: get_album_tracks now marks freshly-fetched entries '_complete'. On a cache
hit, a legacy entry without that flag is revalidated against the album's known
trackCount (from collection metadata, unaffected by the bug) and re-fetched if
short. The '_complete' flag makes the heal one-time and avoids a re-fetch loop on
region-restricted albums where available tracks < trackCount.
Tests: stale-truncated -> refetch+heal; _complete -> trusted; legacy-complete and
unknown-trackCount -> trusted (no regression). Fresh fetch carries _complete.
A Spotify Free (no-auth) user saw 'Showing Discogs results - not from your
primary source (Deezer)' on the manual album-import search. Root cause:
get_primary_source() deliberately downgrades an unauthenticated Spotify to the
working fallback (deezer) so client routing always yields a usable client - and
the import payload reused that FUNCTIONAL value for the LABEL. The free source
has no album-name search (SpotifyFreeMetadataClient.search_albums() returns []),
so falling back for results is correct; only the label was wrong.
Fix: get_primary_source_label() preserves the user's configured intent (Spotify
Free reads as 'spotify') without touching client routing or the search chain.
The import album/track/suggestions payloads now return the label; the functional
source still drives the hydrabase-enqueue + fallback chain. Banner now reads
'not from your primary source (Spotify)'.
Tests: seam tests for get_primary_source_label + route regression pinning the
label/functional decoupling; updated 4 existing import-route tests.
RANK_LOSSLESS/320/256/192/BELOW, _PROFILE_KEY_RANK, classify_track_quality,
preferred_quality_floor, meets_preferred_quality, _rank_label — none of these
were called from scan(). The job already uses rank_candidate() + targets_from_profile
from the quality model, which is fully profile-driven (no hardcoded thresholds).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
HTML <select> options can only store string values, so setting_options booleans
([True, False]) were serialised as 'true'/'false' strings and sent to the API.
Python's `x is True` check returned False for the string, making require_top_target
and deep_audio_verify permanently read as False regardless of what the user saved.
Fix JS: convert 'true'/'false' strings to real booleans before POSTing.
Fix Python: _to_bool() in quality_upgrade + inline coercion in scanner to handle
both existing string values in config and correct future booleans.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
With require_top_target=True the old code used check_targets = targets[:1]
plus quality_meets_profile — which fell back to ALL targets when only one
target was configured, so single-target profiles (FLAC (any) or FLAC 24-bit
alone) never flagged anything.
Replace with a direct rank_candidate(measured_aq, targets) call: skip only
when idx==0 (file already at rank 0, the top tier). Any lower rank (or no
matching rank) is flagged for upgrade search. This is fully profile-driven:
a library of 16-bit FLACs against a [24-bit/192, 24-bit/96, 24-bit/44, 16-bit]
ranking will flag the 16-bit files; a library already at 24-bit/192 won't.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Quarantine grouping:
- First candidate shown as normal row; others hidden under a "▾ N more"
button inline in the actions bar — no separate header row
- Group open state tracked in _verifQuarOpenGroups (Set), survives
periodic re-renders so the list no longer auto-collapses
Quality Upgrade Finder:
- Default scope changed from 'watchlist' to 'all' so it scans the whole
library when no scope is explicitly configured
- _get_settings rewritten to read the full settings dict at once
(same pattern as QualityUpgradeScannerJob) to fix silent read failures
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
resolve_library_file_path() was called without config_manager, so
_collect_base_dirs() returned an empty list and every track stored
with a relative path (Artist/Album/track.flac) resolved to None.
probe_audio_quality was never called → 0 files checked.
Also threads resolved_path through _read_file_ids to avoid a second
redundant resolution pass on the same file.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Race condition: cancelling the retry task didn't stop an already-in-flight
download from completing and creating a new quarantine entry via the pipeline.
- Approve endpoint now sets _quarantine_approved_alternative=True on the
cancelled task alongside status='cancelled'
- Pipeline completion handler checks this flag when _acoustid_quarantined
is set: immediately deletes the freshly-created quarantine entry and
returns, so no stale entry accumulates from the race
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Source-specific IDs (Spotify id, Qobuz id, URI) break sibling detection
when the same song is imported from different playlists or sources, since
each batch produces a different id: key. Siblings from a second batch were
never recognised and stayed in quarantine after approving the first.
Drop id:/uri: fallbacks; keep only ISRC (truly universal) and nm:artist|track
(stable across all sources). This correctly groups all quarantine entries for
the same intended target regardless of which batch or source produced them.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
safe_move_file used shutil.move, which for a CROSS-filesystem move (downloads volume ->
library volume, common in Docker/NAS) copies the file to the FINAL path incrementally. A
media-server real-time watcher (Jellyfin) can catch that partial file mid-write and cache it
with null/incomplete metadata — tracks landing with no disc. Inconsistent because it only bites
cross-fs and races the scan tick; 're-add library' fixes it (rescan reads the now-complete file)
and the on-disk tags are fine — exactly the reported symptoms.
Fix: same-fs uses an atomic os.replace (also overwrites dst); cross-fs copies to a HIDDEN temp
sibling, fsyncs, then atomic os.replace into place (+ temp cleanup on failure). A watcher only
ever sees the COMPLETE file. EXDEV/EPERM/EACCES + the old string check route here, so detection
is strictly broader than before.
Tests: same-fs move, simulated EXDEV routes to the atomic path and leaves no partial temp, helper
completes+cleans, helper cleans temp + preserves source on failure. Existing replace-destination
test still green; 574 imports+relocate tests pass.
Wires the pure recommendation core into curate_discovery_playlists via a new self-contained,
double-guarded method _build_listening_recommendations: gathers inputs already in the DB (top 30
played artists as seeds + play_count weight, the similar_artists graph, a library id->name map +
owned set, discovery-pool tracks) — NO new network — runs group_similars_by_seed -> rank ->
aggregate, and stores results under NEW keys (metadata 'listening_recs_artists', curated playlist
'listening_recs_tracks'). Additive: touches no existing BYLT/curation logic, writes no existing
key, and both the call site and the method body are try/except-wrapped so it can never disturb the
scan.
Phase-1 candidate tracks come from the discovery pool (like BYLT); a later phase swaps in a direct
top-tracks fetch for pool-independent coverage. py_compile + ruff clean; 51 watchlist tests green.
The stored similar_artists rows key the similar artist by the SEED's source/db id, not its name,
so rank_recommended_artists can't consume them directly. group_similars_by_seed resolves each
row's source id to a seed name via a caller-supplied id_to_name map and reshapes to the
{seed_name: [{'name': similar}]} the ranker wants — the fragile id->name join, now pure + tested
(dataclass + dict rows, unknown-id drop, non-seed drop, group->rank end-to-end). 15 tests total.
New, fully-additive module — the heart of the 'expand Because You Listen To into a real
listening-driven block' plan. Two pure functions, no DB/network/config:
- rank_recommended_artists(seeds, similars_by_seed, owned): consensus-ranked artists you'd love
but don't own. Score = Σ over endorsing seeds of (play_weight × similarity) — rewards consensus,
play weight and similarity strength in one sum. Excludes owned + seeds; min_seed_count is the
adventurousness dial's lever; exposes seed_count + which seeds ('because you like A, B, C').
- aggregate_candidate_tracks(recs, top_tracks_by_artist, owned): per-artist-capped, deduped,
rank-ordered candidate list for the generated playlist; exclude_owned toggles discovery vs replay.
11 tests (consensus vs single, play-weight, similarity, owned/seed exclusion, min_seed_count,
case-insensitive dedup, per-artist cap, owned exclusion, total limit, empty-artist skip). Nothing
existing touched — wiring into the watchlist scan + playlist sync comes next.
Second leak of the same class: redownload_start built full album_data (release_date/album_type/
total_tracks) only in the Spotify branch. The iTunes and Deezer branches set just track/disc number
and left album_data lean ({'name': ...}), so single-track redownloads on those sources dropped the
$year — same symptom as #915 in the add/download path.
Fix: both branches now fetch the album via get_album_for_source (cached, source-aware) and build
album_data through the shared _album_data_from_source helper, mirroring the Spotify branch. Falls
back to the lean default if the fetch returns nothing (no regression). get_album is cached on both
iTunes and Deezer, so no extra API cost.
Tests: _album_data_from_source (full build, image-url fallback, defaults). 694 library+downloads
tests green.
Root cause: the only album-context backfill in the download path (hydrate_download_metadata) goes
through spotify_client.get_track_details — Spotify-only. An iTunes/Deezer-primary user's download
kept a lean context (no release_date), so the path dropped $year and the date defaulted to
YYYY-01-01 — until they ran a Reorganize, which reads the full album from the PRIMARY source. That
asymmetry IS the bug.
Fix: when the context is lean and the primary source isn't Spotify, hydrate it from that source via
get_album_for_source — the exact path Reorganize/Enrich use. Verified the primary source returns the
real data (live iTunes get_album for the reporter's album: release_date 2024-04-17, not 2024-01-01).
backfill_album_context_from_source is a pure, injected-fn seam: 6 tests (hydrate, no-op when
complete / spotify-primary / sentinel-id, stays-lean on None, swallows source errors). 552 downloads
tests green.
iTunes get_album_tracks called _lookup(id, entity='song') with no limit. The iTunes Lookup API
returns only 50 related entities unless limit is passed (max 200), so albums over 50 tracks showed
only the first 50 in the download window. Pass limit=200 on the main lookup AND the fallback-
storefront request.
Proven against the live iTunes API on the reporter's exact album (Frieren OST, id 1739445636,
70 tracks): no limit -> 50 songs, limit=200 -> 70 songs. Spotify already paginates; Deezer uses
limit=500 — iTunes was the only truncating source. Regression test asserts limit=200 is requested.
The import rebuilds the destination path from album metadata. When the albums row has no year,
release_date is empty, the path template drops $year, and the copied file lands in a NEW yearless
directory instead of the album's existing 'Album (YYYY)' folder. (The code logically forces this:
the year only drops when album.year is empty.)
Fix: when album.year is empty, recover it from a sibling track — its own year column, else a
(YYYY)/[YYYY] in the album folder name — so the rebuilt path matches the existing directory.
No-op when album.year is already set.
Tests: _existing_album_year_from_sibling covers year-column, paren folder, bracket folder, no-signal,
and target-slot exclusion.
Adds an opt-in setting (default off) to both Quality Upgrade jobs.
When enabled, a file only counts as "good enough" if it meets the
highest-priority target in the ranked profile — not just any target.
Example: with [FLAC 24-bit, FLAC 16-bit], a 16-bit file is flagged as
a candidate for upgrade even though it satisfies the profile's fallback
target. Finding titles say "Upgradeable" (not "Below quality") and the
description names the preferred target explicitly.
- quality_upgrade.py (Finder): reads require_top_target from settings,
builds check_targets = targets[:1] when on, improves finding description
- quality_upgrade_scanner.py (flag-only): same option + matching finding
title/description change
Pairs naturally with the best_quality search mode on this branch: the
scanner surfaces the candidates, the wishlist download picks the best
available version across all sources.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- pipeline: use trigger='acoustid_unverified' (not 'acoustid') when
require_verified=ON rejects an unconfirmed track — quarantine badge now
shows "ACOUSTID UNVERIFIED" instead of "ACOUSTID MISMATCH"
- web_server: /api/verification/config now also returns require_verified
- pages-extra: collapse the Unverified sub-view to quarantine-only when
require_verified=true (same path as acoustid_enabled=false); new trigger
entry in _VERIF_QUAR_TRIGGERS for acoustid_unverified
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
reconcile_playlist read the existing playlist's track ids with str(t.id), but NavidromeTrack
exposes the Subsonic song id as .ratingKey and has NO .id attribute (append_to_playlist already
reads ratingKey — reconcile was the straggler). So current_ids came back EMPTY every time:
plan_playlist_reconcile saw an 'empty' playlist, re-added the entire matched set, and removed
nothing. Result: the playlist grew by the full track count on every sync (warl0ck: 5 songs, one
removed -> 9), in reconcile mode and whenever reconcile is the active mode.
Fix: read current ids via ratingKey, matching append_to_playlist.
Verified: tests/test_navidrome_reconcile.py drives the real reconcile_playlist with a stubbed
server — reverting the one-char change flips 3 tests red (they show it re-adding all 4 tracks),
the fix flips them green. Covers no-op resync, a removed track (remove, don't re-add all), and an
added track (append once).
iTunes appends featured-artist credits to track titles ('The Chase (feat. Y)') while the user's
file is often just 'The Chase'. _normalize_title only stripped the parens, keeping 'feat y' as
words, so the title-match ratio fell below the 0.6 substring floor — and with no track-number
rescue the track was reported 'no matching track in the iTunes tracklist' even though it was the
right song.
Strip feat/ft/featuring credits (parenthesised anywhere, or a bare trailing 'feat. X') before
normalizing, so both sides reduce to the same title and match exactly. Guarded so 'The Feat',
'Defeat', 'Lift' aren't touched, and version differentiators (Remix) still hard-reject.
Tests: 8 new (strip variants + the exact no-tn failure + cross-match/remix regressions); 63
existing reorganize tests still green.
YouTube's flat playlist extraction returns ONLY the title (verified: no artist/channel/uploader
field at all), so a track starts as 'Unknown Artist' and only gains a name if per-video recovery
succeeds. When recovery comes up empty (no cookies / age-gated / bot-checked) but the track still
matched confidently, the worker threw the match's artist away and left the column 'Unknown Artist'
— the #909 symptom.
Now the displayed yt_artist falls back to the matched artist when it's still Unknown. Display-only:
the match itself, track['artists'], cache, and download flow are untouched, so a real recovered
name always wins and an unmatched/error row honestly stays Unknown. Extracted resolve_display_artist
as a pure, tested seam; applied in the cache-hit and fresh-match result paths (the error path has
no match to draw from).
The #891 'also remove image/sidecar-only folders' toggle never worked. Job settings are
persisted as a nested dict under repair.jobs.<id>.settings (RepairWorker.set_job_settings),
but the scan read flat keys — repair.jobs.empty_folder_cleaner.remove_residual_files — which
never matched, so it always fell back to the False default and skipped every image/.lrc-only
folder. (remove_junk_files had the same mismatch but its default is True, which is why only the
truly-empty 'deleted' folder kept showing up.) Now reads from .settings like get_job_config /
lossy_converter do.
The pure dir_is_removable logic was already correct + tested; the bug was purely the config
read in scan(), which had no test. Added two scan-level regression tests driving the real
JobContext + a config that stores the toggle the way the UI does.
New setting acoustid.require_verified (default off), shown under
Settings → Quality Profile only when AcoustID is enabled.
When on, an AcoustID SKIP (ran but couldn't confirm — no fingerprint match
or cross-script metadata, the ⚠ "unverified" case) is treated like a FAIL:
the file is quarantined and the next-best candidate is tried, instead of
importing an unverified file. Only a clean AcoustID PASS is kept.
Transient ERROR results (rate-limit / outage) are deliberately NOT blocked —
that would stall the whole pipeline during an AcoustID outage. Those still
import with their existing flag.
- pipeline.py: SKIP routes through the existing FAIL quarantine + retry path
(trigger 'acoustid') when require_verified is on.
- UI: checkbox under Quality Profile, visibility tied to acoustid-enabled via
syncAcoustidRequireVerifiedVisibility(); load/save wired in settings.js.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The two library quality jobs overlapped confusingly. Keep both, but make
each one's role obvious and put them on the same v3 quality definition.
Quality Upgrade Finder (quality_upgrade) — the ACTIVE job:
- Quality decision moved from v2 (extension + DB bitrate) to v3: probes the
REAL file with mutagen (measured bit depth / sample rate / bitrate) and
checks it against the profile's ranked targets — same as the import guard.
- New optional `deep_audio_verify` setting (default OFF): also run the ffmpeg
decode guard (truncation + silence); a broken file is proposed for replacement.
- Renamed to "Quality Upgrade Finder (active — proposes a replacement)" + help
text spells out it actively searches a better version and queues it.
- v3 helpers imported at module level so they stay monkeypatchable in tests.
Quality Check (quality_upgrade_scanner) — the FLAG-ONLY job:
- `deep_audio_verify` default flipped ON->OFF (the ffmpeg decode is the
CPU-heavy step; matches the download pipeline's default).
- Renamed to "Quality Check (flag only — you decide per finding)" + help text
contrasts it with the active Finder.
UI: deep_audio_verify setting label now shows "(ffmpeg decode — CPU heavy)".
Tests: scan() tests stub the v3 probe path (probe_audio_quality /
quality_meets_profile / resolve_library_file_path) since they use fake paths.
The v2 pure-function helpers stay (still unit-tested).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The audio-completeness guard (detect_broken_audio) is the only post-processing
step that fully DECODES the file with ffmpeg, making it the most CPU-heavy step.
Two changes reduce and gate that cost:
1. Single ffmpeg pass: astats (truncation) + silencedetect (silence) now run in
one chained -af filter over a single decode, instead of two full decodes.
~50% less CPU, no detection lost. Pure parsers unchanged.
2. Opt-in toggle: new post_processing.audio_completeness_check (default False).
The decode now only runs when the user enables it under
Settings → Post-processing → Core Features. Most preview/truncation cases are
already caught at the source (HiFi/Qobuz have their own guards), so the
expensive whole-file decode stays off unless explicitly turned on.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>