Discord (DXP4800 NAS, 7148 tracks): library updates kept dying with "Update appears stuck — no
progress for 300s (last phase: Fetching all tracks in bulk...)". not actually hung.
root cause: the bulk track/album fetch used a single 10000-item page, so a whole library came
back in ONE request that emitted NO progress while in flight. the watchdog (database_update_health)
kills a job with no progress for 300s — so on a slow server that one silent request tripped it even
though it was alive, not stuck. raising the timeout cap only buys the silent request more rope; a
bigger library or slower disk just needs a higher number. the per-batch progress line also only ran
when there was a NEXT page, so a sub-page-size library reported nothing at all.
fix: extract a pure paginate_all_items seam (core/library/bulk_paginate.py) that pages in 1000s and
reports progress after EVERY page — so the watchdog is fed on a cadence set by page size, not library
size, and can't starve mid-fetch no matter how big the library. both Jellyfin bulk loops (tracks +
albums, same defect) now route through it. preserves the failure-shrink resilience (halve to a floor,
then give up). does NOT change what's fetched — same query, fields, items.
note: changes nothing about WHICH tracks come back; only how they're paged + that every page reports.
keep the raised cap on dev as a margin — this is the actual fix. Plex/Navidrome don't share the
pattern (checked). 9 seam tests incl. the watchdog-feed invariant (progress count scales with
N/page_size, never one call for the whole library) + the sub-page regression + failure-shrink.
467 jellyfin/library tests green, ruff clean.
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.
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.
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.
Reorganize leaves a cover.jpg (and other leftovers) behind when it moves an album,
and the Empty Folder Cleaner is too conservative to sweep them. Two complementary
fixes over one shared 'residual file' predicate (core/library/residual_files.py:
junk + cover/scan images + lyric/metadata sidecars), so both features agree on what
a dead folder is.
1. Reorganize (proactive): _delete_album_sidecars now sweeps ALL residual files when
a source dir has no audio left — previously only a fixed list of cover NAMES, so
back.jpg / disc.png / .webp survived and kept the folder un-prunable.
2. Empty Folder Cleaner (the request): new opt-in 'Remove Residual Files' setting
(default off) treats a folder holding only images/sidecars/junk as removable —
cleans the existing backlog + arbitrary names. Auto-renders as a UI toggle.
Safe by construction: whitelist-only (a booklet.pdf / video / .txt is real content
and kept), reorganize sweep gated on no-audio, cleaner re-checks at apply time.
20 new tests; 272 reorganize/repair/empty-folder green.
ROOT CAUSE of the quality scanner's "18/18 could not be probed". The shared
resolver (core/library/path_resolver.py) suffix-walked starting at index 1,
which is correct for absolute media-server paths (/music/Artist/... — index 0
is the empty leading segment) but WRONG for SoulSync's own library, which
stores RELATIVE paths like "Asketa/Another Side/track.flac". Index 0 there is
the artist folder; dropping it meant the resolver joined base/Another Side/...
(no artist) and nothing ever matched — so every library track came back
unresolved and the probe opened a relative path that didn't exist from CWD.
Start the suffix walk at index 0 so the FULL relative path is tried first.
Safe for absolute paths (i=0 yields base//Artist/... which harmlessly misses
and falls through to i=1) and Windows drive parts (E: fails on POSIX, falls
through). Other tools (orphan/fake-lossless detectors) were unaffected because
they os.walk the transfer folder directly and never used this resolver.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A Library Maintenance job that cleans up downloads tracked by Download Origins
once they pass a per-origin retention window — findings by default, opt-in
auto-delete.
A download is only ever proposed for deletion when ALL hold: older than its
origin's retention, NOT still in an actively-mirrored playlist / watched
artist, and played fewer than the keep-threshold (default 2 → "played more
than once is kept"). Only touches downloads recorded from the Download Origins
feature forward — never pre-existing or manual library.
- core/library/expired_cleanup.py: pure decision core (retention_cutoff,
is_expired, select_expired) — no DB/clock, fully tested. play_count is the
reliable listen signal (last_played is often unpopulated, so recency isn't
used).
- ExpiredDownloadCleanerJob: gathers facts (play_count via a new
get_origin_cleanup_candidates join; active-mirror via get_mirrored_playlists;
watch via get_watchlist_artists) and either creates 'expired_download'
findings or, with auto_delete on, deletes in-scan. Default OFF, both
retentions default 'off'. Settings auto-render in the Library Maintenance
panel (same as Cover Art / Lyrics / Re-tag).
- delete_origin_download(): shared delete (resolve path → remove file → drop
track row → drop history row); a file that won't delete keeps its row +
reports. Used by auto mode AND the _fix_expired_download apply handler.
- Frontend: type/action ('Delete')/result labels + finding detail render.
Tests: 9 on the pure brain (windows, off, per-origin, protected, play-count
threshold, bad age) + 7 on the job (no-op when off, findings, mirror/watch
protection, auto-delete, delete helper missing/real file). 185 repair/origin
tests pass.
Ashh: the manual-match modal fuzzy-searches a service and shows the top 8.
When the right release isn't in those 8 (common title — their example was
"Idols", which returns 8 unrelated releases and not Yungblud's), there was no
way through. But the user usually already knows the exact MBID.
Now the modal's search box doubles as a direct-ID box. Paste a MusicBrainz
MBID (bare UUID or a musicbrainz.org URL) and SoulSync looks that exact
entity up and shows it as the single result to confirm + Match — no fighting
the search ranking.
- core/library/direct_id.py: pure detector, returns the canonical ID only
when the text unambiguously IS one (whole-query UUID, or a UUID inside a
musicbrainz.org URL). "Idols", "Yungblud Idols", a UUID buried in free
text → None, so normal search is never hijacked.
- _search_service: direct-ID fast path before the fuzzy search —
get_release (→ get_release_group fallback for albums) / get_artist /
get_recording. A pasted-but-unresolvable ID falls THROUGH to fuzzy search,
so a typo can't dead-end the modal.
- UI: MusicBrainz placeholder now says "…or paste a MusicBrainz ID/URL".
Detector is service-keyed so Spotify/iTunes/etc. direct IDs can be added
later; today only MusicBrainz has a confirmable direct lookup, matching the
reporter's ask + screenshot. 9 tests: detector truth table (bare/URL/plain/
buried/other-service) + dispatch (confirmed release, release-group fallback,
unresolvable→fuzzy, plain query skips direct lookup).
Extends the manual "Import IDs from File Tags" backfill so newly-scanned
files get their embedded provider IDs pulled into the DB automatically —
no button press needed to keep up with new music.
How it works:
- insert_or_update_media_track now returns 'inserted' / 'updated' / False
(truthy-compatible; existing `if track_success` callers unaffected) so
the scan worker can tell a genuinely new row from an update.
- DatabaseUpdateWorker collects the ids it newly INSERTED this run
(self._new_track_ids) across all insert paths (Plex/Jellyfin/deep).
- After run()/run_deep_scan(), web_server calls _reconcile_after_scan(),
which gap-fills embedded IDs for just those new tracks. Runs as a
post-scan pass (the scan loop itself is untouched/fast — the media
server API never exposes these custom IDs, so the file must be read
once regardless; batching at the end keeps it out of the hot loop and
best-effort so it can never abort a scan). A progress phase ("Reading
file tags for N new tracks…") surfaces the full-refresh tail.
Shared engine:
- New reconcile_library() in core does the paging + lazy parent-map
loading (only loads albums/artists actually referenced — cheap when
scoped to a few new tracks) + per-page commits. BOTH the manual button
and the scan hook call it, so there's one tested orchestration, no
duplication. The backfill job was refactored onto it.
Same hardened safety: gap-fill only, atomically guarded against
overwrite, schema-introspected, idempotent. Scoped to new arrivals for
incremental/deep; full refresh re-inserts everything as new (recovering
the IDs a full-refresh wipe destroys).
+10 reconcile tests (reconcile_library scope/idempotency/progress/stop +
the engine). Full suite clean (only pre-existing soundcloud /app env
failures remain).
Files SoulSync (or MusicBrainz Picard) already tagged carry Spotify /
iTunes / MusicBrainz / Deezer / Tidal / AudioDB / Genius / Last.fm IDs in
their metadata. Enrichment workers gate their queues on
{provider}_match_status IS NULL, so reading those IDs back and gap-filling
the {provider}_id + match_status='matched' columns lets the workers skip
the API lookup entirely — big API savings on an already-tagged library.
New manual job in Tools -> Database & Scanning ("Import IDs from File
Tags"): scans every library file, reads embedded IDs, fills any that are
missing in the DB. Background job + progress card, mirroring the
write-tags-batch pattern.
core/library/embedded_id_reconcile.py (pure + tested):
- plan_reconcile(): gap-fill plan for a track + its album + artist. Only
empty id columns are planned; a disagreeing embedded id is a conflict,
never applied.
- apply_reconcile_plan(): one guarded UPDATE per id column —
WHERE id=? AND (col IS NULL OR col=''). The guard makes the fill atomic:
if an enrichment worker matched the same entity between our read and
this write, the UPDATE affects 0 rows instead of clobbering it. Columns
are introspected so a schema missing a provider's columns is skipped.
- reconcile_track_row(): per-track orchestration (id extraction, plan ->
apply, keeping the in-memory parent maps fresh for sibling tracks).
Job hardening: paged track scan (bounded memory), per-page commits (don't
starve concurrent workers), per-file try/finally (one bad file can't abort
the run), counters from real rowcount.
Scope: 19 column-fills across 8 providers. MB *recording* (track) id is
left out (UFID frame the reader doesn't surface; Vorbis key ambiguous) —
MB album+artist are covered. Amazon/ASIN deliberately excluded (ASIN is a
different namespace than the worker's amazon_id). All target columns
verified against the live schema.
Purely additive: new module, two new endpoints, one new Tools card —
no existing behavior changed. 20 unit tests (incl. the concurrency guard).
Full suite clean (only pre-existing soundcloud /app env failures remain).
Removing the old Retag Tool (d91e6a38) deleted core/library/retag.py but left
its tests behind. tests/library/test_retag.py imported the gone module at
module top, so pytest aborted collection of the ENTIRE suite (CI red on every
run). tests/test_lyrics_reembed_from_sidecar.py had two RetagDeps tests for the
same removed dataclass (lazy imports — would fail at runtime once collection
proceeded).
Delete the orphaned test file + drop the two dead RetagDeps tests (the rest of
the lyrics-sidecar file is unrelated and stays). Also drop a pointless
f-string in test_library_retag_job. Suite now collects all 5008 tests.
Add service-level coverage for the Enhanced Library I Have This flow: copying an existing source file, writing the target album DB row, preserving source audio, inheriting album identity tags, and migrating older track tables that lack disc_number.
POSIX os.path.dirname doesn't treat '\' as separator, so the
assertion 'Drake' in result fails on Linux CI even though the
function's rstrip removes the trailing backslash correctly.
The forward-slash test already covers the trim contract.
Closes#572 (rhwc).
Navidrome has no API for setting an artist image — it reads
`artist.jpg` (or `folder.jpg`) from the artist folder during
library scans. SoulSync's `update_artist_poster` for Navidrome
was a no-op, so users only ever saw album-art-derived thumbnails
as the artist photo.
- new "Write Artist Image" button on artist detail page
- POST /api/artist/<id>/write-image-to-disk derives the artist
folder from any track's resolved file_path (reuses
_resolve_library_file_path so docker mount translation +
library.music_paths probes from #558 apply), fetches the photo
from the configured metadata source priority chain, downloads
with content-type validation, writes atomically via
`<filename>.tmp + os.replace`
- when active server is Navidrome, triggers a library scan
immediately so the file is picked up
- respects existing artist.jpg (frontend prompts before
overwriting) so user-supplied photos aren't clobbered
- works for plex / jellyfin too as a fallback layer — both
servers also read artist.jpg from disk
26 tests pin the pure helpers in core/library/artist_image.py:
folder derivation (trailing sep / empty / non-string), URL
picking (missing attr / whitespace / non-string), download
(non-image content-type / 404 / timeout / empty body), atomic
write (replace / temp-cleanup-on-failure / overwrite guard /
missing folder).
- new "Audit" button on each download row in the library history
modal opens a second modal visualizing the download lifecycle as
an interactive horizontal stepper (request → source → match →
verify → process → place) with click-to-expand detail cards
- hero header with album art + track title + meta line + status
pills (source / quality / acoustid result)
- three tabs: Lifecycle / Tags / Lyrics
- Tags tab reads the audio file live via mutagen at audit-open
time via new GET /api/library/history/<id>/file-tags endpoint;
file is the single source of truth so background enrichment
writes (audiodb / lastfm / genius / replaygain / lyrics fetch)
show up too. flat key/value rows stacked vertically (label-above-
value) so long MBIDs / URLs / joined genre lists wrap cleanly.
source IDs grouped per-service into 2-col sub-card grid.
- Lyrics tab renders the full transcript with dimmed timecodes.
- post-processing step infers observable changes from source-vs-
final state (format conversion, file rename via tag template,
folder template).
- "Download History" button also added to the Downloads page batch
panel header so it's reachable outside the dashboard.
- mobile responsive: tabs + stepper scroll horizontally, modal
goes full-screen, hero stacks below 480px.
19 helper tests pin the mutagen reader: id3 (TIT2/TPE1/TALB + TXXX
+ USLT + APIC), vorbis (FLAC dict + _id/_url passthrough), file
metadata (format / bitrate / duration), defensive paths (empty /
missing file / mutagen returns None / mutagen raises), stringify
edge cases (list / tuple / int / frame-with-text / whitespace).
GitHub issue #558: clicking Auto-Fill / Fix Selected on the Album
Completeness findings page returned a flat "Could not determine album
folder from existing tracks" error with no diagnostic. Reporter is on
Navidrome on Docker — the path resolver in
`core/library/path_resolver.py` couldn't find any of the album's tracks
on disk because Navidrome's Subsonic API doesn't expose filesystem
library paths the way Plex's API does (probed in #476). Default
settings → `library.music_paths` empty → no base directories to probe →
silent None. User had no signal about what to configure.
Not a regression of #476 — that fix targeted Plex auto-discovery and
worked correctly for it. Navidrome was never covered because the
protocol gives the resolver nothing to probe.
Fix scoped to the diagnostic surface, not auto-magic discovery:
- Added `resolve_library_file_path_with_diagnostic` returning
`(resolved, ResolveAttempt)`. ResolveAttempt records what the resolver
tried — `raw_path_existed`, `base_dirs_tried`, `had_config_manager`,
`had_plex_client`. Pure data, no rendering opinions.
- Legacy `resolve_library_file_path` becomes a thin wrapper that
drops the attempt; every existing call site is unchanged.
- `RepairWorker._fix_incomplete_album` now uses the diagnostic helper
and renders a multi-part error via `_build_unresolvable_album_folder_error`:
names the active media server, shows one sample DB-recorded path,
lists every base directory the resolver actually probed, and points
the user at Settings → Library → Music Paths as the actionable fix.
- Distinguishes empty-base-dirs vs tried-and-failed cases so the user
knows whether to add a mount or fix the existing one.
- No auto-probing of common Docker conventions (`/music`, `/media`, etc).
Speculative — could resolve to wrong dirs on the suffix-walk if a
conventional path happens to contain a partial collision. User stays
in control.
12 new tests:
- 7 in `tests/library/test_path_resolver.py`: tuple-shape contract,
raw-path-existed short-circuit, base-dirs listed even on walk
failure, had-flags reflect caller inputs, no-base-dirs returns
None with empty attempt, legacy `resolve_library_file_path`
delegates correctly across happy / suffix-walk / failure paths.
- 8 in `tests/test_repair_worker_unresolvable_folder_error.py`:
active server name in error, sample DB path verbatim, base dirs
listed, empty-base-dirs phrased differently, Settings hint always
present, defensive against None attempt / missing sample / missing
config_manager.
Full pytest sweep: 2774 passed.
GitHub issue #476 (gabistek, Docker on Arch host): "Auto-Fill" / "Fix
Selected" on the Album Completeness findings page returned
"Could not determine album folder from existing tracks" for every album.
Reproduces on any setup where the media-server library lives outside the
SoulSync transfer/download folders — Docker is the headline case but
native installs that point Plex at a NAS via SMB hit it too.
Root cause: `core/repair_worker.py:_resolve_file_path` only probed the
transfer + download folders. Docker users have their Plex/Jellyfin
library bind-mounted at /music (or similar) — neither configured in
SoulSync. Every existing track got silently treated as missing, so
`album_folder` stayed None and the fix workflow bailed.
The same incomplete logic was duplicated four more times in the
repair_jobs/ modules, all with the same bug. Album Completeness was
just the most user-visible — the same setups were also producing false
"missing file" findings from Dead File Cleaner, silent skips in
MBID Mismatch Detector, etc.
The web server already had the correct logic at
`web_server.py:_resolve_library_file_path` (probes transfer + download
+ Plex-reported library locations + user-configured library.music_paths).
The repair workers had never been updated to match.
Fix:
- New `core/library/path_resolver.py` extracts the union logic into a
single shared function `resolve_library_file_path()`. Probes (in
order, deduped): explicit transfer/download kwargs, config-derived
soulseek.transfer_path/download_path, Plex-reported library
locations (when a plex_client is passed), user-configured
library.music_paths. Each defensive: malformed config or a flaky
Plex client degrades to the dirs that did succeed.
- `core/repair_worker.py:_resolve_file_path` becomes a delegating
wrapper preserving the legacy signature, with a new `config_manager`
kwarg. All 15 in-tree call sites updated to thread
`self._config_manager` through.
- `core/repair_jobs/dead_file_cleaner.py`,
`mbid_mismatch_detector.py`, and `lossy_converter.py` get the same
treatment: duplicate function replaced with a thin wrapper, call
sites pass `context.config_manager`.
- `core/repair_jobs/acoustid_scanner.py` and
`unknown_artist_fixer.py` (which used to import from repair_worker)
now call the shared resolver directly with `context.config_manager`.
Side benefit: every other repair job (Dead File Cleaner, MBID
Mismatch Detector, Lossy Converter, AcoustID Scanner, Unknown Artist
Fixer) also stops missing files in the media-server library mount.
Single fix unblocks five user-visible features.
Tests: `tests/library/test_path_resolver.py` — 20 cases covering all
four base-dir sources, suffix-walk algorithm, dedup, defensive paths
(None plex client, malformed config entries, raising config_manager.get,
broken plex attribute access), Docker path translation. Full suite
1677 passed locally.
WHATS_NEW entry under '2.4.2' dev cycle.
Pulls the 258-line retag worker out of `web_server.py` into a new
`core/library/` package. Pure 1:1 lift — wrapper keeps the original
entry-point name so the retag-trigger endpoint continues to work
without changes.
What `execute_retag` does:
1. Fetch album + track metadata for the new `album_id` (Spotify or
iTunes — the Spotify client transparently falls back).
2. Load existing files in the retag group from the DB.
3. Match each existing track to a new Spotify track:
- Priority 1: same disc + track number.
- Priority 2: title similarity >= 0.6 (SequenceMatcher).
4. For each matched pair:
- Re-write metadata tags via `_enhance_file_metadata`.
- Compute the new path via `_build_final_path_for_track` and move
the audio file (plus .lrc / .txt sidecars) if the path changes.
- Drop an orphaned cover.jpg if it's left in an empty directory.
- Clean up empty parent directories left behind.
- Download the new cover art into the new album dir.
5. Update the retag group record with new artist / album / image /
total_tracks / release_date and the appropriate Spotify-or-iTunes
album ID (numeric → iTunes, alphanumeric → Spotify).
6. Mark the retag state 'finished' (or 'error' on exception).
Strict 1:1 byte parity:
The original mutated `retag_state` as a module global (the function
declared `global retag_state` even though it only mutates in place).
Here `retag_state` is exposed through the `RetagDeps` proxy as a Python
property so the lifted body keeps `name[key] = value` /
`name.update(...)` syntax. The property setter rebinds the
web_server.py reference if the function ever reassigns it (currently
it doesn't, but the setter is wired for parity with the watchlist lift).
Diff vs original after `deps.X` → global X normalization is **zero
differences** apart from the dropped `global retag_state` decl and the
inline `from database.music_database import get_database` (replaced by
deps.get_database()). 258 lines orig = 258 lines lifted, byte-identical
body otherwise.
Dependencies injected via `RetagDeps` (13 fields) — config_manager,
retag_lock, spotify_client, plus 8 callable helpers
(get_audio_quality_string, enhance_file_metadata,
build_final_path_for_track, safe_move_file, cleanup_empty_directories,
download_cover_art, docker_resolve_path, get_database) and 2 property
delegates (_get_retag_state / _set_retag_state).
Tests: 11 new under tests/library/test_retag.py covering setup error
paths (no album data, no album tracks, no existing tracks),
track-number priority match, title-similarity fallback, no-match skip,
missing file skip, file move when path changes, group record update
(spotify vs iTunes ID branching by alphanumeric vs numeric album_id),
multi-disc total_discs computation.
Full suite: 1330 passing (was 1319). Ruff clean.