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.
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.
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.
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).
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.
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 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.
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.
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.
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.
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.
Re-running an export created a new LB playlist every time (LB keys on MBID, not name, and
create always mints a new one). Now remember which LB playlist a mirror was pushed to and
update it in place:
- listenbrainz_client: refactor batched-add into _add_tracks_in_batches; add
get_playlist_track_count, delete_playlist, update_playlist (verify exists -> clear items via
item/delete -> re-add -> edit title; reports gone=True if deleted on LB), and
create_or_update_playlist (update when we have a prior MBID, else create; falls back to
create if the remembered one was deleted). Stable URL/MBID across re-syncs.
- playlist_export_targets table + get/set_playlist_export_target: remember (mirror, target) -> LB MBID.
- export job consults/stores the target so push updates in place.
+6 mocked tests (clear+re-add same mbid, gone-fallback, create-or-update branches, delete). API
endpoints (item/delete, playlist/edit, playlist/delete, GET count) confirmed against LB docs;
live round-trip pending explicit auth.
The live export status was a separate flex child with flex-basis:100%, which became a
greedy item in the card's flex row and squished the info column to min-content (text
wrapping vertically). Inject the status into the card's existing .card-meta line instead
(same approach as the pipeline phase indicator) so it sits inline and leaves the row intact.
Removes the offending div + CSS.
Phase 6 (UI). Adds an export button to the mirrored-playlist card's hover action row (next
to rename/link/delete). Click -> a small on-brand modal to pick a destination (Sync to
ListenBrainz directly, or Download .jspf). Starts the background export, then polls status
and shows live progress on the card ('Matching 340/1000 · 312 matched' -> 'Synced · 947/1000
matched · view'). Reuses the tested backend job/endpoints; additive (new button + CSS + JS
functions, existing card render untouched apart from the inserted button).
Phase 5. Three additive routes + an in-memory job registry (new globals, no existing code
touched):
- POST /api/playlists/<id>/export/listenbrainz {mode: download|push} — spawns a background
thread that loads the mirrored playlist's tracks, resolves each to a recording MBID via
the waterfall, builds the JSPF, and (push) creates the playlist on ListenBrainz. Returns job_id.
- GET /api/playlists/export/status/<job_id> — live status (phase/done/total/coverage) for the
card to poll; omits the heavy JSPF blob.
- GET /api/playlists/export/download/<job_id> — downloads the built .jspf.
Reuses the tested cores (build_resolve_fn, resolve_playlist_tracks, build_jspf, create_playlist).
Phase 4. core/exports/export_sources.py supplies the real I/O behind each waterfall source
and assembles resolve_fn: cache -> DB (tracks.musicbrainz_recording_id via text match) ->
file tag (UFID/musicbrainz_trackid) -> live MusicBrainz match_recording. Every source is
fail-safe (any error -> None -> fall through, export never breaks). A fresh non-cache hit is
written back to the persistent cache so the same song is free next export. Sources are
injectable; build_resolve_fn wiring (cache short-circuit + write-back) is unit-tested. 4 tests.
Phase 3. Additive backbone for the export job:
- mb_recording_cache table (IF NOT EXISTS) + core/exports/recording_mbid_cache.py: persistent
(artist,title)->recording_mbid cache, mirrors album_mbid_cache (lazy DB, error-degrades to
miss). The MusicBrainz tail is ~1 req/s, so a resolved MBID is remembered once and reused
across every export/playlist.
- core/exports/playlist_export.py: resolve_playlist_tracks(tracks, resolve_fn) — walks tracks,
dedups repeated songs within a run (resolve once), builds the ordered pseudo-playlist, tallies
live stats (resolved/unmatched/deduped/by_source). Pure (I/O injected via resolve_fn + progress
callback), so dedup + accounting are unit-tested with no DB/network. 5 tests.
No wiring into runtime yet; nothing existing touched except the additive table.
Phase 2. Add create_playlist(title, tracks, public) to the LB client: POST /playlist/create
for the MBID, then add tracks in batches of 100 (MAX_RECORDINGS_PER_ADD) via the item/add
endpoint so 1k-track playlists don't hit a single-request cap. Returns a result dict
{success, playlist_mbid, playlist_url, added, requested, error} and never raises — partial
add failures are reported honestly (playlist created, added count accurate). Extends the
existing token-auth client; additive. 4 mocked-network tests (batching, auth, failure).
Phase 1 of exporting mirrored playlists to ListenBrainz. Two pure, fully-tested seams,
zero runtime wiring yet (additive, no regression):
- core/exports/jspf_export.py: build_jspf(title, tracks) -> ({"playlist": {...}}, summary).
LB's POST /1/playlist/create requires every track to carry a string identifier
'https://musicbrainz.org/recording/<mbid>' (text-only tracks are rejected), so tracks
without a valid recording-MBID UUID are dropped and counted in the coverage summary.
- core/exports/mbid_resolver.py: resolve_recording_mbid(artist, title, sources) — the
cheapest-first waterfall (cache -> DB -> file tag -> MusicBrainz) as a pure function over
injected (label, fn) sources. Short-circuits expensive lookups, treats a raising source
as a miss (one flaky MB call can't fail the export), reports the resolving source label.
API spec confirmed against LB docs: POST /1/playlist/create, 'Authorization: Token <t>',
{"playlist": {"title", "track": [{"identifier": "<mb recording url>", title, creator, album}]}}.
13 tests.
Standalone _run_soulsync_deep_scan did a path-only diff (untracked = transfer files
not in the soulsync DB) and shutil.move'd EVERY untracked file to Staging — no guard.
When the DB is empty/out of sync with disk (volume swap, DB reset, external Picard
tag edits) but Transfer holds the real library, that flags the whole library as
untracked and relocates all of it; Phase 5 then deletes the rows, and with Staging
cleanup on the files are gone for good. Reporter lost ~1,500 tracks into Staging.
The stale_guard the orphan detector + media-server deep scan already use (#828, #908)
was never wired into this path. Fix:
- core/library/standalone_scan.py (pure, tested): plan_standalone_deep_scan() diffs
untracked (separator-normalized) and decides whether the move is safe. Blocks when
the untracked share is implausibly large (>20 files AND >50% of Transfer — the
desync signature, via is_implausible_orphan_flood) or when the user marked Transfer
permanent. A normal batch of new arrivals still moves.
- web_server: consult the planner before Phase 4; on block, move NOTHING, leave files
in place, and surface a loud warning + activity item. Guard Phase 5 deletes too
(skip on desync-block or implausible stale share).
- 'Transfer is my permanent library — never move files out' toggle
(import.transfer_is_permanent) in Settings.
- tests/library/test_standalone_scan.py: seam coverage + the #904 regression
(empty DB + 1,500 files -> blocked, nothing moved).
No behavior change for in-sync libraries; the guard only trips on the desync pattern.
Profiling the actually-painted dashboard found two pure-waste GPU costs (no visual
payoff), reclaimable with zero degradation:
- backdrop-filter on cards whose backgrounds are already 90-99% opaque, so the blur
is invisible: service-card (x3), stat-card-dashboard (x3), activity-feed-container.
Dropped the filter, nudged opacity to ~0.97 so the unblurred sliver is imperceptible.
- redundant/near-invisible box-shadow layers on the two biggest elements: page-shell
(near-fullscreen — collapsed two stacked outer shadows to one) and sidebar (dropped
a duplicate layer + a 0 0 60px accent glow at 6% opacity that's barely visible but a
costly 60px-blur pass).
Targets the DURING-USE cost, not idle. sidebar-header keeps its blur (genuinely
translucent), and the cursor blob is untouched (that one's a real visual tradeoff).
The dashboard particle preset built a fresh createRadialGradient + arc-fill for all
50 glows every frame. But each glow's gradient is a fixed size/colour for the
particle's life — only the pulse ALPHA changes per frame, and canvas multiplies
image alpha by ctx.globalAlpha. So bake the gradient once into a per-particle
offscreen canvas (full alpha) and drawImage it each frame with globalAlpha = pulse
(times the incoming globalAlpha, so transition fades stay identical). Rebuilt only
when the accent colour changes.
Pixel-identical output: same colour, same linear falloff, same source-over; sprite
rendered at ceil(glowSize) then downscaled to exact glowSize. Drops 50 gradient
allocations + arc-fills per frame to 50 cached blits. Scoped to the dashboard
preset only (smallest blast radius); other presets untouched.
People report SoulSync working their machine hard at idle. On likely-weak devices
(<=2 cores, or <=2GB, or low on both: <=4 cores AND <=4GB) auto-enable reduce-effects
once and toast why ('lower-power device — turn effects back on in Settings').
Device-scoped via localStorage on purpose: a weak laptop must not flip the server
setting for the user's other machines. Acts only when this device has no stored
preference (null), so it runs at most once and never overrides an explicit choice.
Conservative thresholds avoid flagging capable boxes (a 4-core/8GB laptop isn't
touched; Firefox/Safari, which don't expose deviceMemory, only trip on <=2 cores).
Settings-load now prefers the device-level localStorage value over the server
default, so opening Settings no longer clobbers the per-device (auto or manual) choice.
GPU fill/blur cost scales with radius. The sidebar aura orbs already fade to
transparent at 70% of their gradient, so dropping their blur 40->28px shrinks the
composited bounding box with no perceptible softness loss. The frosted header's
backdrop-filter is re-blurred whenever the orbs drift behind it; 28->18px cuts
that per-frame work ~a third while keeping the frosted look. Relief is biggest on
weak GPUs (the machines people complain about).
The .sidebar-header::after ambient sweep animated `left` (-100% -> 140%) on an
8s infinite loop — forcing a layout recalc every frame it's on screen, on the
sidebar that's present on every page. Convert to transform: translateX() with a
pixel-identical travel path (element is 60% of header width, so translateX(400%)
== the old 240%-of-header sweep) + will-change. Compositor-only now; no per-frame
layout. Zero visual change.
Private YT Music playlists (a user's Liked Music, list=LM) need auth, but the
only cookie option was cookiesfrombrowser — a browser on the same machine as
SoulSync, useless on a headless/Docker box (and locked to whatever account that
browser happens to be signed into). Add a 'Paste cookies.txt' mode so users can
supply the exact session they want from any machine.
- core/youtube_cookies.py: pure seam — build_youtube_cookie_opts (cookiefile vs
cookiesfrombrowser precedence, mutually exclusive, fail-safe on a missing file),
looks_like_cookiefile (needs a real cookie row; rejects junk/header-only),
write_pasted_cookiefile (validate + 0600 write; blank/junk never clobbers a saved file).
- _youtube_cookie_opts() delegates to the seam, so every yt-dlp call site gets it.
- /api/settings pops cookies_paste before the generic persist, validates (400 on
junk), writes config/youtube_cookies.txt, stores only the path (blob never hits config.json).
- Settings dropdown gains 'Paste cookies.txt'; selecting it reveals a textarea.
- tests/test_youtube_cookies.py: precedence, validation, fail-safe write (11 tests).
Tracks auto-downloaded from the playlist pipeline / wishlist / watchlist landed as
01/1 even though they belong to multi-track albums (wolf/Sokhi; verified live —
Deezer says 'Obelisk' is track 9 of The Grand Mirage, Olives is 3/4, etc.).
Root cause, located in code: discovery doesn't carry a per-track position for
sources whose search/track endpoint omits it (Deezer search, MusicBrainz
recordings — only their ALBUM endpoint has it). detect_album_info_web then set
'track_number': track_info.get('track_number') (= None) and never looked it up
from the album it HAD identified (context.py); the pipeline floored it to 1. The
one helper that does an album lookup only ran for the no-album-context branch and
is gated off by default. Not isolated to Deezer — the gap is source-agnostic.
Fix: when the album is known (album_id present) but the position is missing,
resolve the REAL (track_number, disc_number) from the album's own track list via
the source-agnostic get_album_tracks_for_source — using the album id discovery
already picked (no re-search, no edition guessing). Matches by ISRC -> source
track id -> title. Fail-safe: any miss/error leaves the number untouched, so it
still falls through to the filename exactly as before — never worse than today.
kettui: pure seam core/imports/album_position.resolve_track_position_in_album
(I/O-free, ISRC>id>title priority, skips position-less entries) + a fail-safe
integration wrapper, both covered — 11 tests incl. the 'Obelisk = 9/12' case,
priority resolution, and never-raises-on-fetch-error. 788 import/context/pipeline
tests green, ruff clean.
Confirmed from Sokhi's FLAC tags + screenshot: disc-2/3 tracks land in the 'Disc 1'
folder, collapsing every disc's track 3/4/5/6 into one folder. Root cause: the
import pipeline syncs the resolved TRACK number into album_info (so the folder
matches the tag — pipeline.py '[FIX] Updated album_info track_number') but never
did the same for DISC. So the 'Disc N' folder (built from album_info.disc_number,
often 1) used a different disc than the embedded tag (resolved per-track in
source.py — e.g. 2/3 from a MusicBrainz multi-medium release).
Fix: one SHARED resolver, resolve_disc_for_track(original_search, album_info),
used by BOTH source.py (the tag) and the pipeline (which now writes it back into
album_info before building the path). Same function + same inputs (the pipeline
pulls the identical get_import_original_search(context)), so folder and tag can
never disagree. Returns the first valid positive disc (per-track, then album),
else 1 — a falsy/unknown per-track disc falls through to the album instead of
flooring early.
Tests: resolver preference/fallback/floor + an explicit folder==tag lockstep check
incl. Sokhi's per-track-2/album-1 case. 2122 import/pipeline/metadata tests green.
Sokhi: some tracks in a multi-disc album showed up with a null disc in Jellyfin
and floated ungrouped above the disc sections (tracks 3/9/15). Mechanism: the tag
writer only wrote the disc tag when disc_number was truthy, and enrichment CLEARS
all tags before rewriting — so a track whose disc came back 0 / None / '' lost its
disc entirely. Those falsy values slipped through because source.py defaulted with
'is not None' (a literal 0 passed) and context.py's or-chain can yield None; this
happens especially when a track resolves to a different edition than its siblings.
Fix: normalize_disc_number() floors any value to >=1, and enrichment now writes the
disc tag UNCONDITIONALLY (like the track number) so a track is never disc-less.
source.py uses the same floor so the metadata dict (and the 'Disc N' folder org)
stays consistent. Valid multi-disc values are preserved untouched.
Tests: normalize floors 0/None/''/negatives/non-numeric -> 1, preserves 1..4 and
tolerates '2.0'. 1406 enrich/metadata/track-number tests green, ruff clean.
NOTE: this fixes the SYMPTOM (never ungrouped). The deeper cause — a track matching
a DIFFERENT edition/release than its album siblings (the Persona-box-set mismatch in
the sample file; the canonical-version problem) — is separate and still open.
The mirror_playlist fix only assigns stable ids to newly-imported playlists, so a
user with an existing file-import playlist would still have empty-id rows (and dead
Find & Add matches) until a manual re-import. Add an idempotent startup backfill that
assigns the SAME stable id a fresh import would to any mirrored track missing one —
so existing matches start sticking with no re-import. Runs once per db/process (the
init is guarded), only touches empty-id rows (no-op afterward), native ids untouched.
Tests: backfill fills empty ids with the exact fresh-import id, is idempotent (2nd
run = 0), and leaves native ids alone.
A Find & Add on a file-import (CSV/M3U/TXT) playlist track was silently dropped and
the track re-appeared as 'extra' (radoslav-orlov). Root cause: unlike Spotify/YouTube
(native ids), file-import + iTunes-only tracks arrive with an EMPTY source_track_id —
and the whole manual-match system keys on it. _persist_find_and_add_match is a no-op
on an empty id, and find_manual_library_match_by_source_track_id returns None for one,
so the match can be neither recorded nor looked up. That's the youtube-vs-file
difference the reporter noticed.
Fix: stable_source_track_id() derives a DETERMINISTIC 'file:<hash>' id from the track
identity (artist|title|album, normalized) when there's no native id; mirror_playlist
assigns it so the SAME song gets the SAME id across re-imports/discovery — exactly
what the match lookup needs. Native ids are used verbatim; bonus: discovery extra_data
now survives a re-import for these tracks too.
Tests: helper (native passthrough, deterministic + case/field-insensitive, distinct
per song, empty-on-no-title, file: prefix); mirror_playlist integration (file tracks
get stable distinct ids, stable across re-import, native ids untouched). 319 playlist/
sync/discovery/mirrored tests green.
wolf39us: a Find & Add manual match got dropped on auto-sync (60->57) with a 404
fetching the stored Plex ratingKey. Root cause: Plex re-keys tracks on a metadata
refresh, and the SoulSync DB id IS that ratingKey — so until a SoulSync rescan BOTH
the durable match's library_track_id AND the file-path self-heal (which reads the
same DB) land on the dead key, fetchItem 404s, the track falls through to fuzzy
(also the dead key) and is dropped.
Fix: when both DB-side lookups miss on Plex, re-resolve the manual match against
LIVE Plex by the matched track's metadata, disambiguated by the stored file path
so the user's EXACT chosen track wins among multiple versions; return the current
live track and heal the stored id + sync cache. A manual match is now never dropped
or silently re-matched — it 'always overrides' as asked. Scoped to Plex (DB-backed
servers materialize off the DB and don't have this re-key problem).
Seam tests: file-path picks the right version over a live alternate, basename
fallback for server-vs-local paths, no-file-match falls back to top hit (never
drop), no results/empty title -> None, and a broken client never raises.
Bumps _SOULSYNC_BASE_VERSION to 2.7.5, defaults the docker-publish workflow's
version_tag to 2.7.5, and refreshes the release notes for the fixes/features
since 2.7.4: deezer real track numbers, special-edition cover art (release-scope),
the leading-'The' dedup, HiFi preview rejection (#895), M3U/M3U8 import (#893),
organize-by-playlist file naming, durable Find & Add match, ignore-list management
+ manual-add unblock (#897), and the Unraid template fixes (#899). WHATS_NEW +
VERSION_MODAL_SECTIONS rolled to 2.7.5 with 2.7.4 folded into the earlier-versions recap.
The album_tracks cache store (deezer_client) and the mutagen audio-length probe
(hifi_client) swallowed errors with try/except/pass. ruff S110 (selected by the
project) flagged them — they were the only 2 lint errors app-wide. Log at debug
instead. ruff check . now passes clean.
A MusicBrainz album resolves its art at RELEASE-GROUP scope even for a concrete
release (musicbrainz_search _release_to_album -> _cached_art prefers the rg mbid).
On the Cover Art Archive a release-group front is a single REPRESENTATIVE cover
(CAA picks one release to stand for the group, ~always the standard edition), so
a special edition like "Clair Obscur: Expedition 33 (Gustave Edition)" got the
standard art baked into cover.jpg + embedded tags at download time.
Add core/metadata/caa_art.fetch_release_preferred_art: try the specific release
own /release/<mbid>/front first, fall back to the existing release-group/provider
URL only when the release has no art of its own (404 -> None). Wire it into both
download_cover_art (cover.jpg) and embed_album_art_metadata (tags) so they stay
in sync. min_bytes defaults to 0 so the fallback keeps its prior behavior — this
can only ever ADD a better edition match, never strip a cover that showed before.
Caveat (documented, not fixed here): this only helps when the resolved release IS
the right edition. If the upstream match picked the standard release/group, that
is the separate canonical-version matching problem.
Tests: pure helper (prefers release art, falls back on 404/tiny/exception, no-mbid
passes through, nothing-available -> None). 667 metadata/artwork/deezer/hifi tests pass.
Files inside an Organize-by-Playlist folder were stuck with the library filename
(materializer hardcoded os.path.basename) — users wanted control over the naming,
e.g. a playlist-order prefix so a DAP plays them in order.
Add an opt-in FILENAME template "Playlist File Naming" (file_organization.templates
.playlist_item), tokens $position/$artist/$album/$track/$title. It is a filename,
not a path: validated to forbid "/" or "\" and to require $title, both in the
Settings UI (blocks save with a reason) and in core/playlists/item_naming.py, which
also fails safe at apply time — a bad/empty template falls back to the library
filename, so it can never produce a broken name. Default empty = current behavior.
Works for symlink AND copy modes (a symlink name is independent of its target).
Applied in _rebuild_one_from_db (the live reconcile/rebuild path), which has the
per-track metadata + playlist order; the pure FS materializer just gained an
optional dest_names override and is otherwise untouched. $position is zero-padded
to playlist width for correct sorting.
Tests: pure validate/render (slash + missing-title rejected, fallback, sanitize,
no-separator guarantee), FS-layer dest_names + collision disambiguation + back-
compat, and end-to-end through the DB rebuild (07->01 rename + empty-template
keeps library filename).
Two issues behind #897:
1) Discoverability — the "Ignored" management modal (view/un-ignore/clear-all,
shipped with #874) was only reachable from the wishlist *overview modal*
footer, which most users never open. Add the same button to the wishlist
page toolbar next to Cleanup / Clear All, wired to openWishlistIgnoreModal().
2) Manual re-add silently blocked (carlosjfcasero) — the album-modal "add to
wishlist" endpoint passes source_type=album, but the ignore gate only
bypasses+clears for source_type=manual, so re-adding a previously-cancelled
track failed. We cannot just send manual: source_type drives Albums/Singles
categorisation and repair_worker legitimately uses album too. Thread an
explicit user_initiated flag (db.add_to_wishlist -> service -> album route)
that bypasses+clears the ignore while preserving the real source_type.
Regression test pins both: an automatic source_type=album add stays blocked,
the user_initiated add goes through, clears the ignore, and keeps source_type=album.
The import-from-file tool only took CSV/TSV/TXT. Add M3U/M3U8 — the most common
file-playlist format, and the one SoulSync itself exports. Parses extended M3U
(#EXTINF artist/title/duration, #PLAYLIST name) and simple M3U (derives
artist/title from the file name), and keeps "# MISSING:" entries from our own
export (those are exactly the tracks a user imports to go match/download).
Frontend-only: the parser runs client-side and feeds the existing import path.
The image declares /app/MusicVideos as a VOLUME but the template never mapped it,
so music-video downloads landed in an anonymous Docker volume. Add an (advanced,
optional) Music Videos path mount so users can point it at a share.
They referenced a third-party repo (snuffomega/SoulSync_unraid). Point them at
the canonical files in this repo. Use raw.githubusercontent.com (not /blob/,
which serves an HTML page) and the templates/ path where the files actually live.
_get_artist_variations only widened the candidate fetch by diacritics, so a
request for "The Black Eyed Peas" never pulled a library track filed under
"Black Eyed Peas" (or vice-versa) — it "failed to match" and re-downloaded a
duplicate. Toggle the leading "The" in both directions when widening the fetch;
the confidence scorer (50/50 title/artist, 0.882 across the "The" gap) still has
the final say, so this can only widen what gets fetched, never merge genuinely
different artists. Mid-word "The" (e.g. "Theory of a Deadman") is untouched.
Closes the gap from cf2b4d7d: that commit unit-tested the DB-only matcher twin but
not services.sync_service._find_track_in_media_server — the actual path a connected
Plex/Jellyfin user hits. Drive it directly (Jellyfin server-type, so no Plex fetchItem
mocking): durable manual match is honored when the volatile cache is wiped, and a
stale library id self-heals via the stored file path.
Find & Add was being forgotten on the next auto-sync. It persists two ways — a fast
sync_match_cache override AND a durable manual_library_match (#787) that survives a
rescan — but BOTH sync matchers (services.sync_service._find_track_in_media_server and
the DB-only fallback) only consulted the volatile cache. A library rescan wipes that
cache, so the next 'replace' auto-sync re-matched the track from scratch and the user
had to Find & Add it again.
Both matchers now fall back to the durable manual match when the cache misses
(self-healing a stale library id via the stored file path), exactly like the compare
view already does via resolve_durable_match_server_id. So a Find & Add pairing sticks
across rescans + auto-syncs. Seam tests: cache-wiped→durable hit, stale-id self-heal,
no-match→fuzzy fall-through.
The log from a fresh run showed detection working but a second bug: rejecting the
lossless preview ('decoded 30s of 180s — rejecting') just dropped to the SAME track's
'high' (lossy m4a) tier — the same 30s clip — which the bitrate check can't see (m4a,
not FLAC), so it was accepted. A preview at any tier means the SOURCE only has a
preview of the track; lower tiers are the same clip. So on preview detection (manifest
OR post-download) we now return None to FAIL HiFi entirely, letting the orchestrator's
hybrid fallback try the next SOURCE (soulseek/youtube) instead of landing a lower-tier
preview. (A genuinely-missing tier still falls through to the next tier as before.)
Integration test drives the post-download abort end-to-end and pins 'no tier cascade'.
The first fix (#895) only caught manifests that HONESTLY declared a short length. The
real-world fakes are nastier: every length header is faked to FULL — HLS #EXTINF, the
m4a moov, AND the demuxed FLAC's STREAMINFO total_samples — while the file holds only
~30s of real audio. So the manifest-sum and mutagen-length checks both read 'full' and
passed it through. Measured 23 issue files: every one decoded to ~30s with a claimed
full length; size/claimed gave an impossible 55-362 kbps 'lossless', size/30s gave a
plausible ~600-1100 kbps.
Detect it two independent ways (post-download), so it fires even when every header lies:
- decoded real length via ffmpeg (-f null) vs the largest claimed length — the ground
truth when a decoder is present (production demuxes with ffmpeg, so it is);
- for lossless, an impossibly-low implied bitrate (< 30% of raw PCM) — needs no decoder,
catches all 23 on its own.
Pure is_fake_lossless_bitrate / is_preview_download / parse_ffmpeg_time helpers with seam
tests pinned to the real #895 file numbers. Reference is max(expected, container-claimed)
so the file's own faked claim becomes the bar its real audio must clear.