Commit graph

4329 commits

Author SHA1 Message Date
dev
13bfdaeda2 feat(quality): re-port AAC opt-in tier (#886) onto ranked-targets
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>
2026-06-24 21:33:53 +02:00
dev
8934c6418c fix(quality): correct ranking behaviour flagged in PR #896 (#3, #4)
#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>
2026-06-24 21:25:36 +02:00
dev
5d5ed486c4 fix(quality): repair two PR #896 merge blockers
#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>
2026-06-24 21:24:54 +02:00
BoulderBadgeDad
ecd2500c39 Server playlist editor: surface 'accurate but out of order' + read-only server-order view
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).
2026-06-24 12:15:47 -07:00
BoulderBadgeDad
462a722cce #918 follow-up: self-heal stale truncated iTunes album-tracks cache
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.
2026-06-24 09:37:14 -07:00
BoulderBadgeDad
0f2d6ddb63 gitignore: ignore ALL database/*.db (+ -shm/-wal/backup), not just music_library
video_library.db wasn't covered, so it showed up as untracked and could be
committed by accident (live user data). Generalize the database patterns to a
glob so every current/future app DB is auto-ignored. database/*.py sources are
unaffected. (config/youtube_cookies.txt was already ignored — it only slipped
through earlier because it had been force-staged.)
2026-06-24 08:57:54 -07:00
BoulderBadgeDad
4c47c01076 #922: import search labelled Spotify Free users' primary source as 'Deezer'
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.
2026-06-24 08:43:52 -07:00
dev
1005c7e306 refactor(quality-upgrade): remove legacy hardcoded rank constants and dead functions
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>
2026-06-24 16:02:30 +02:00
dev
ff12d8bbf2 fix(repair-jobs): boolean settings saved as string 'true'/'false' by UI dropdown
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>
2026-06-24 15:59:25 +02:00
dev
94637cbe6f fix(quality-upgrade): ranking-based top-target check via rank_candidate
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>
2026-06-24 15:45:39 +02:00
dev
1f14fb4d5e fix(quarantine): align single rows with groups via fixed-width alt-slot
Move the "▾ N more" toggle out of .verif-actions into a dedicated
.verif-quar-alt-slot div (min-width: 68px) so every row reserves
the same horizontal space — action buttons now stay aligned whether
a row is a single track or the head of a group.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:44:23 +02:00
dev
ef0b68a973 fix(quarantine+quality): persistent group toggle, better alt-row UI, quality upgrade default scope
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>
2026-06-24 15:26:41 +02:00
dev
e4ba27d8b3 fix(quality-upgrade): pass config_manager to path resolver so relative DB paths resolve
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>
2026-06-24 15:16:42 +02:00
dev
bdbeff89d6 Merge feature/quarantine-ui-consolidation: consolidate quarantine UI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:14:59 +02:00
dev
5f5bf4b24e feat(quarantine): consolidate quarantine view into downloads page filter
Removes the duplicate Quarantine tab from the Library History modal and
brings the same-song grouping feature into the ⚠ Unverified/Quarantine
filter on the Downloads page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 15:14:54 +02:00
dev
83f21b248d fix(quarantine): manager-tab approve immediately marks task completed
When approving from the quarantine manager (no task_id in request), the
re-import ran through the simple pipeline path with no task to update, so
the downloads list stayed on 'failed' until the batch drained.

Scan download_tasks for the task whose quarantine_entry_id matches the
approved entry. If found, re-run through the verification wrapper with that
task_id so the task is marked completed immediately in the live downloads list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 13:05:25 +02:00
dev
8f66432592 fix(quarantine): discard late quarantine entries when alternative approved
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>
2026-06-24 13:04:06 +02:00
dev
1770cae04f fix(quarantine): use name-based group key across batches/sources
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>
2026-06-24 12:48:04 +02:00
dev
842bc0ab34 fix(quarantine): auto-delete siblings + cancel retry on approve (#920)
Multiple quarantine entries accumulate per track (one per retry attempt)
and approving one left the others behind and kept the retry running.

- Approve now sends remove_siblings=true — backend sibling deletion was
  already implemented but the flag was never passed from the UI
- Toast message now reports how many duplicate candidates were removed
- After approve, the backend cancels any in-flight quarantine-retry task
  for the same track (matched by title) so the engine stops fetching new
  candidates once the user has already accepted one
- Approve All also sends remove_siblings=true

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 12:37:33 +02:00
dev
55f7176c34 fix(quarantine): auto-refresh panel during batch + quarantine retry badge
Quarantine list was only fetched once on page load — new entries created
during a running batch never appeared without a manual tab click.

- _adlFetch (2 s poller) now also calls _verifLoadQuarantine(true) every
  7th poll (~14 s) so quarantine entries appear shortly after they land.

- Retry badge now shows 🛡 and a clearer tooltip when retry_trigger is
  'acoustid' or 'acoustid_unverified', making it visible during the
  quarantine-retry cycle that a previous candidate was quarantined.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 12:27:05 +02:00
BoulderBadgeDad
ec99d686cc
Merge pull request #919 from Nezreka/dev
Dev
2026-06-23 23:27:46 -07:00
BoulderBadgeDad
d647fc8ad1 Release 2.7.7: version bump + What's New / version modal + PR description + docker-publish default tag
- _SOULSYNC_BASE_VERSION -> 2.7.7; docker-publish default version_tag -> 2.7.7
- pr_description.md rewritten for 2.7.7 (the #915 primary-source parity headline, #913 listening-recs
  foundation, jellyfin atomic-write, and the #905/#908/#909/#910/#911/#912/#914/#916/#917/#918 batch)
- WHATS_NEW: replaced the 2.7.6 block with 2.7.7 (current release + a brief 'earlier versions' summary)
- VERSION_MODAL_SECTIONS: promoted the 2.7.7 highlights, rolled 2.7.6 into the 'Earlier' aggregator
2026-06-23 23:24:39 -07:00
BoulderBadgeDad
9d091207f6 fix: atomic file placement so Jellyfin can't index a half-written track (null-disc)
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.
2026-06-23 23:17:50 -07:00
BoulderBadgeDad
03926bd6e2 #913 phase 1: generate listening recommendations during the watchlist scan
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.
2026-06-23 22:58:04 -07:00
BoulderBadgeDad
c21031b9bc #913: add group_similars_by_seed assembly helper (pure, tested)
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.
2026-06-23 22:53:57 -07:00
BoulderBadgeDad
9ad5188610 #913: listening-driven recommendation core (pure, tested)
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.
2026-06-23 22:47:43 -07:00
BoulderBadgeDad
d4e80fdaa0 #915: redownload pulls full album_data from the primary source for iTunes/Deezer too
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.
2026-06-23 19:21:50 -07:00
BoulderBadgeDad
2b17ed8451 #915: post-processing hydrates lean album context from the PRIMARY source (parity with Reorganize)
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.
2026-06-23 18:46:35 -07:00
BoulderBadgeDad
cce7df4f3d #918: iTunes album fetch no longer truncates albums >50 tracks
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.
2026-06-23 17:58:17 -07:00
BoulderBadgeDad
600a744f7f #917: 'I have this' reuses the album's existing folder year instead of dropping it
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.
2026-06-23 16:18:00 -07:00
BoulderBadgeDad
2934903874 #916: align missing-track title match with the Reorganize matcher
Reporter's image 3 shows Reorganize maps all 62 multi-disc tracks correctly ('62 unchanged') —
it matches by title, proving the titles DO align on this album. My first normalizer DELETED
bracket content, so 'X - Main Theme' (file) vs 'X (Main Theme)' (canonical) would mismatch.
Reorganize treats brackets as separators (keeps the words); now _normTitleForMatch does the
same — drop only the (feat. Y) credit, turn every other separator into whitespace.

Verified: dash<->bracket, curly<->straight apostrophe, special<->regular hyphen, and feat all
normalize equal; distinct titles stay distinct.
2026-06-23 15:58:36 -07:00
dev
e32b4ec727 feat(repair): require_top_target option — flag files upgradeable to preferred quality
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>
2026-06-24 00:44:24 +02:00
dev
e8cc7ca2c8 fix(acoustid): distinguish unverified-quarantine from mismatch + gate unverified tab on require_verified
- 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>
2026-06-24 00:33:53 +02:00
BoulderBadgeDad
16cb29c9ee #916: enhanced view stops flagging multi-disc tracks as missing
Multi-disc albums store disc_number=1 for EVERY track in the library (verified across the live
DB — even a 175-track OST shows disc[1..1]; the scanner doesn't split discs). The enhanced view
matched owned<->canonical tracks strictly by disc:track_number slot, so every canonical disc-2+
track (slot 2:N) found no owned counterpart and was flagged missing ('62/72 · 36 missing').

_deriveEnhancedMissingTracks now matches each canonical track by slot first, then falls back to
title against any UNUSED owned track (consuming each owned track once, so genuine missings and
duplicate titles still count right). Display-only — no scanner/data change.

Verified by simulation (62 owned all disc-1, 72 canonical across 2 discs): old logic flags 36
missing, new flags 10 (the genuinely-absent tracks). Frontend-only; repo has no JS test runner.
2026-06-23 15:32:26 -07:00
BoulderBadgeDad
15fa64248c #905 (root cause): Navidrome reconcile read current tracks via missing t.id -> playlists doubled
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).
2026-06-23 15:19:47 -07:00
BoulderBadgeDad
10a1e1337f #905 (part 1): push the deduped track list to the media server, not the raw matched list
sync_playlist computed deduped_tracks but the dispatch (append/reconcile/replace) sent the raw
valid_tracks — so a library track matched by more than one source entry was pushed multiple
times each sync. Extracted _dedupe_by_rating_key (tested) and routed all three modes through it.

This fixes the WITHIN-sync duplication. The cross-sync growth reporters describe (Navidrome
playlist doubling every resync) is a separate server-push issue still under diagnosis.
2026-06-23 14:39:29 -07:00
BoulderBadgeDad
9d16abf952 #914: Reorganize matches bare local titles to iTunes '(feat. X)' tracks
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.
2026-06-23 14:27:31 -07:00
BoulderBadgeDad
fc2c38ad97 #908: get YouTube playlists past the ~100-track cap (yt-dlp #16943 workaround)
YouTube Music 'Liked Music' (and any large playlist) only returned ~104 tracks. Diagnosed it
to a YouTube/yt-dlp regression (upstream #16943): the webpage-based playlist path stops at the
first ~100-item continuation page. Not a SoulSync cap (no limit in the parse path) and not the
user's cookies/IP — reproduced on a fully-served public playlist.

Fix: pass extractor_args youtubetab:skip=webpage so yt-dlp pages via the InnerTube API instead.
Verified live on the reporter's setup: 100 -> 200 entries on a large playlist (the workaround is
itself partial upstream, but a major improvement until yt-dlp PR #16948 lands). Single touch point
— parse_youtube_playlist is the only place that lists a YouTube playlist.
2026-06-23 13:35:04 -07:00
BoulderBadgeDad
9f5bc0de89 #909: backfill the YT-artist column from a confident match instead of 'Unknown Artist'
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).
2026-06-23 11:54:55 -07:00
BoulderBadgeDad
e301877e64 #912: Empty Folder Cleaner reads its opt-in from the right config key
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.
2026-06-23 11:26:00 -07:00
BoulderBadgeDad
65f73fae92 #911: redownload via the album's CANONICAL source (covers the 67% with multiple ids)
The first pass only checked spotify then itunes. But ~67% of a real library (46k/69k albums)
carry BOTH a spotify and itunes id, and the canonical priority is spotify>deezer>itunes>mb>…,
so a spotify-first guess diverges from what the Enhanced view actually tags/displays the album
as (e.g. a deezer-canonical album with an itunes id too).

Now redownload reuses _getEnhancedAlbumCanonicalSource (the view's single source of truth) and
fetches via the same /api/album/<id>/tracks?source= endpoint the view uses for its canonical
tracklist — so a redownload is always the exact edition on screen, across every source. The
stored spotify/iTunes id + a last-resort search remain as fallbacks. Frontend-only; the album
endpoints and canonical resolver are unchanged.
2026-06-23 11:20:03 -07:00
BoulderBadgeDad
69cb51cc13 #911: album Redownload uses the stored match id, not a fresh search
The Enhanced-view album Redownload only honoured album.spotify_album_id. For an iTunes-matched
album (no spotify id) it fell through to a fresh /api/enhanced-search and grabbed the FIRST hit
— which can be a different edition than the one you have (issue: matched the 66-track 'Original
Soundtrack Collection', got the 19-track 'Volume 1').

Now it prefers the album row's stored source id (spotify, then iTunes — the iTunes endpoint
already returns a Spotify-shaped payload) and only searches when neither exists. Also fixed the
search fallback to fetch from the MATCHING source endpoint instead of always hitting Spotify
(latent bug for iTunes search hits).

Frontend-only orchestration fix; no JS test runner in the repo, the album endpoints are unchanged.
2026-06-23 11:12:29 -07:00
BoulderBadgeDad
f2f4f8ccee #910: add the per-track 'year' column the Full Refresh insert needs
Full Refresh INSERTs a per-track year (from file tags) into tracks.year, but that column
was only ever in the live INSERT — never in CREATE TABLE and never in a migration. So on
EVERY db (old and current — verified the shipped music_library.db lacks it too) every Full
Refresh track insert hard-failed with 'table tracks has no column named year', importing 0
tracks while artists/albums succeeded.

Fix (additive + nullable, nothing reads it but the writer):
- add year INTEGER to the tracks CREATE TABLE (new DBs)
- ALTER it onto existing tracks tables in _ensure_core_media_schema_columns (the repair
  backstop that already runs every init), right beside the file_size repair

Tests (tests/test_tracks_year_migration.py): fresh-DB has it, nullable, idempotent, ALTERs
onto an old year-less table, and a regression that the exact Full Refresh insert fails
before the repair and succeeds after.
2026-06-23 11:03:24 -07:00
nick2000713
b42ce3e0ca docs(acoustid): strengthen require_verified warning about false-quarantine risk
AcoustID 'could not confirm' (SKIP) is common for legitimate tracks not in its
fingerprint DB (new/obscure releases, classical, remixes, live, remasters,
non-Latin-script titles). Make the require_verified help text explicit that
turning it on quarantines all of those, so users expect manual review/approval
rather than discovering a flooded quarantine folder. Reinforces that the safer
default (off → import with the unverified badge) loses nothing and blocks nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 12:57:00 +02:00
nick2000713
674b80972a feat(acoustid): opt-in fail-closed mode — only import verified tracks
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>
2026-06-23 12:50:58 +02:00
nick2000713
d169818043 feat(quality): upgrade Finder to v3 quality + clarify the two quality jobs
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>
2026-06-23 12:43:02 +02:00
nick2000713
7186d24120 perf(imports): single-pass ffmpeg audio guard + opt-in toggle (default off)
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>
2026-06-23 12:07:28 +02:00
nick2000713
63374b32f1 Merge remote-tracking branch 'nezreka/dev' into feature/best-quality-search-mode
# Conflicts:
#	core/hifi_client.py
2026-06-23 11:33:50 +02:00
BoulderBadgeDad
dcd68e3631
Merge pull request #906 from Nezreka/dev
Dev
2026-06-22 22:56:53 -07:00
BoulderBadgeDad
cefddd73b7 Release 2.7.6: bump version + What's New / version modal + PR description + docker-publish default tag
- _SOULSYNC_BASE_VERSION 2.7.5 -> 2.7.6
- docker-publish.yml default version_tag 2.7.5 -> 2.7.6
- pr_description.md rewritten for 2.7.6 (ListenBrainz export #903, YouTube Liked Music #902,
  Deep Scan data-loss guard #904, dashboard performance, #901/multi-disc/track-number fixes)
- helper.js WHATS_NEW: new 2.7.6 block + earlier-versions summary
- helper.js VERSION_MODAL_SECTIONS: 2.7.6 highlights lead; 2.7.5 rolled down

ruff check . clean app-wide; export/#904/cookie suites green (54).
2026-06-22 22:48:21 -07:00