Library overhaul - #1062
Open
nick2000713 wants to merge 265 commits into
Open
Conversation
nick2000713
force-pushed
the
library-overhaul
branch
from
July 22, 2026 02:37
dabe947 to
fb0096c
Compare
…d reclassify H-17 as feature F-12
Documents the 25 July diagnosis of the Library-v2 artist list being slower than legacy (five independent root causes) and of search results linking to the legacy artist page, plus their status tables. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ory scan perf25-01: _artwork_url built its ?v=<mtime> token with one Path.stat() per row, so a 75-artist page issued 75 syscalls on the request thread even when every image was already cached. The version now comes from a snapshot of the artwork directory that is revalidated only when the directory's own mtime changes; every managed write/delete forgets it explicitly so filesystems with coarse directory timestamps cannot hide a same-tick rewrite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
perf25-05b: every cold artwork miss decodes the source with Pillow and encodes two JPEG variants; optimize=True runs a second entropy pass on both. Its byte win is negligible on the full-size image, which the list view never requests, so only the per-row thumbnail keeps it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
perf25-02: a first visit to an uncached artist blocked one web worker per image on a sequential provider walk, an HTTP download and two Pillow encodes. The artwork endpoint now answers a cold miss immediately with the placeholder contract (404, no-store, X-Artwork-Pending) and schedules the resolve on a bounded background pool; build_artwork keeps owning the per-entity single-flight lock, so HTTP, background and precache builds cannot duplicate provider work. An explicit force rebuild and wait=1 stay synchronous. The Artwork component retries a local miss three times with backoff so the freshly cached cover appears without a reload, and shows the placeholder in between; remote provider URLs keep failing straight to the placeholder. The sequential provider fallback is deliberately left in place: now that resolution no longer blocks a request, fanning out to every stored identity would spend extra provider calls (and MusicBrainz rate limit) for latency that is no longer user-facing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
perf25-04: the batch precache job only covers the state of its last run, so an artist created by a finished download and albums added by a discography expand stayed cold until someone browsed them. Both call sites now queue their new entities on the shared background pool after their own commit; already cached entities cost nothing because the directory snapshot answers that, and the helper never raises — artwork is presentation data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
perf25-03: every artist-list request computed a disk-space roll-up — a window function over every file of the page's artists plus a SUM on top of it — even though its column is opt-in and off by default. The endpoint now reads the stored table preferences and only assembles those CTEs when the size column is actually shown. The alias-fold CTE is also scoped to the requested page instead of materializing the whole artist table, so the statement scales with page size rather than library size. Rendered values are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tches find25-search-02: an artist that entered lib2 through a finished download carries no legacy_artist_id, and it only shares a provider id with the legacy row when that download happened to bring one. Without either link the merge left the result without library_v2_id, so clicking an "In Your Library" hit opened the OLD library page and the same artist could appear twice. The merge now accepts an unambiguous normalized-name match as a third and last link — only when exactly one row on each side carries that name — and writes the back-reference once, guarded so it can never steal a legacy artist another lib2 row already claims. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nnot Manual matching should be the exception, and for a cross-script artist it was the only option: `match_artist` could not land `Sawano Hiroyuki` on `澤野弘之` by itself, for two reasons that compound. It searched strict-only. A strict query hits the `artist` field alone and skips MusicBrainz's alias and sortname indexes — where the romanised spelling of a natively-scripted artist lives. Issue Nezreka#586 fixed exactly this in `lookup_artist_aliases` and never came back for this function; it now falls back to the fuzzy index when strict returns nothing. And its confidence is `similarity * 60 + mb_score * 0.4`. Across scripts the similarity is 0.0 by construction, so the ceiling is 40 against a gate of 70. No amount of certainty on MusicBrainz's side could clear it — cross-script artists were structurally unmatchable. The signal that settles it was already being computed here and thrown away: the overlap between the albums the library owns and the candidate's release groups. Album titles survive a script difference far better than names do, and an entity whose catalogue holds records this library owns is not a different person. It was only ever consulted to disambiguate candidates that had already passed the name gate — never for the case where the name is worth nothing. Deliberately strict, because a wrong id here is worse than none: MusicBrainz confident about the name (>= 90) AND at least one owned album inside that entity's catalogue. With no owned albums to check against there is no evidence, and the artist stays unmatched rather than guessed. Same-script matching is untouched, including the near-miss it is supposed to refuse. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tion The catalogue-overlap match for an artist MusicBrainz writes in another script ran immediately after the candidates were scored — ahead of the name gate it was meant to rescue. It returns the FIRST cross-script candidate that clears MB score 90 and one overlapping owned album, at a confidence of 60 + 20 per overlap. So a candidate worth 80 was returned before a same-script candidate scoring 95 had been looked at. One shared album title is a low bar to beat a near-exact name on. "Home", "Anthology", a soundtrack two artists both appear on — any of those is enough overlap, and the entity does not have to be the same person for a title to repeat. Nor does the wrong id stop at this call: `_persist_artist_identity` writes it onto the artist row, and the AcoustID alias bridge reads its aliases from there on every later verification. Moved behind the >= 70 gate, so it only ever speaks where the name path found nobody at all. That is the case it was written for — a name in another script scores 0.0 against ours however certain MusicBrainz is, capping such a candidate at 40 — and it loses nothing by waiting: a same-script match that clears the gate has already been disambiguated against the owned catalogue by `pick_artist_by_catalog`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This is the bug behind the production report. A user's correctly downloaded files verified at import and then read "the artist is written in a different script, so the names cannot be compared" on every single later scan — with the right MusicBrainz identity sitting on the artist row the whole time. `fetch_artist_aliases` returned `[]` for a timeout, a 503 or a rate-limited call exactly as readily as for an artist MusicBrainz genuinely lists no alias for. `lookup_artist_aliases` then wrote that down as a result, together with the MBID it had just resolved — and the cache TTL for a row carrying an MBID is ninety days. Tier 2 honoured it, because a stored MBID counted as proof that MusicBrainz had answered. One rate-limited second during a download therefore froze "this artist has no alternate spellings" for a quarter of a year, and every scan of those files disagreed with the download that wrote the row. `resolve_artist_aliases` reports the difference: a list is an answer, `None` is "we do not know". Only an answer is cached, and only with a `resolved` marker; an unanswered fetch leaves the question open. `_cached_aliases` reads a row under the same rule, so the marker — never the presence of an id — is what lets an empty list stand. Rows written by the old code carry no marker and are simply asked again, which heals an existing library without a migration. `fetch_artist_aliases` stays as the wrapper for the two callers that cannot act on the difference. Tier 1b also consulted MusicBrainz before it consulted the cache, so an artist with no aliases cost one blocking request per scanned file, each of them queued behind the enrichment worker on the same one-per-second lock. It now accepts a cache row resolved against that same MBID — and only that MBID, since a name-keyed row for another entity says nothing about this one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four places where the pipeline recorded a verdict about a file on the strength of something that was never about the file. The availability probe moved into `verify_audio_file` when the scan and the download became one pipeline, and it answers SKIP per file. For the scan that is catastrophic: `acoustid.enabled` defaults to False while the scan job defaults to enabled, so a user who never turned on download-time verification got every row in the library stamped `acoustid_status='skip'` — over whatever an earlier working scan had concluded — and a green "scan completed" with zero errors. A missing key or no chromaprint did the same. Whether the client can run at all is a property of the run, so the job now refuses to start. The blanket `except` returned SKIP for any unexpected fault. Fail-open is right for the download and wrong as a recorded result: a database error or a MusicBrainz outage mid-verification reached the scan as "checked, no claim" and was persisted as a completed check. Worse on the download side — with `require_verified` on, SKIP is treated as FAIL, so an infrastructure blip threw away a correct file. ERROR says what it is, and both callers already handle it without touching the file's standing. `_acoustid_recording_mbids` is documented as the recording identity a verdict was made against, and a later scan trusts it as an identity contract. It was written before the confidence floor, so it also described lookups that never reached a verdict. And the healing this branch owed: files an earlier build of the scanner demoted to 'unverified' do not come back on their own, because a SKIP is by design not allowed to move the standing. Identity settles it — when the fingerprint still lands on the recording the import checked this file against, and only files the import let through are in the library at all, the standing is restored. That is the same evidence the FAIL guard above it already trusts. The scan now also records its own reason alongside its own status. `acoustid_message` is written at import time too, so a row could render "Skipped" over a tooltip reading "Audio verified: ... artist 100%" — the download's message under the scan's verdict. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`enrich_native_entity_for_service` ranks a provider's search results by normalised-name similarity against a 0.85 gate. For an artist whose MusicBrainz name is written in another script that similarity is 0.0 by construction, so the right entity is discarded — while a same-script near-namesake sails through. Against "Sawano Hiroyuki", MusicBrainz returns `澤野弘之` first at score 100 and it scores 0.00 here, while `SawanoHiroyuki[nZk]` — a different MusicBrainz entity — normalises to 0.88. The Enrich button could not reach a cross-script artist at all, and could confidently reach the wrong one. Nothing about that is specific to the button. The provider-gap backfill drives the same function across the whole library, so it could write that wrong id onto every cross-script artist it touched, record `matched` in the ledger, and the AcoustID alias bridge would then resolve its aliases from it. `match_artist` already decides this properly, including the owned-catalogue overlap that survives a script difference, so MusicBrainz artists go through it and fall through to the generic path when it has no answer. Albums and tracks are untouched — there is no `match_release` equivalent worth routing here, and their titles do not have the same problem. The backfill also now takes a `should_stop` callback. A full budget against MusicBrainz's one-request-per-second limiter is minutes of blocking calls, and the job checked for a stop only before the phase began, which made "stop" mean "stop eventually". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… wrong A review asked for corroboration where BOTH dimensions are written in another script: nothing disagrees there, but nothing agrees either, so a genuinely wrong download whose recording happens to be non-Latin is unreportable. The concern is real. The obvious remedy is not. Requiring a strong fingerprint before staying silent was implemented and reverted: the score says nothing about the NAMES, so it does not separate the two cases at all. What it separates is correct files from each other. "Zankoku na Tenshi no These" by "Yoko Takahashi" against 残酷な天使のテーゼ by 高橋洋子 at a perfectly ordinary 0.90 is the same shape as a wrong download, and quarantining it is the failure this branch spent seven commits removing. A false quarantine costs a user their file; a missed finding costs nothing. So the blind spot is a test, with what would actually close it: a duration or the recording MBID the import already recorded — evidence that distinguishes the two cases, rather than a stricter reading of a silence. The neighbouring branch (title agrees, artist unreadable) carries the same note; a matching title after a matching fingerprint is corroboration, and we take it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…or lost Review findings against the alias/identity work on this branch. All five are real; the first one defeated the others. **The client swallows the outage the service was built to detect.** `_search_and_score_artists` returns None for a failed search so a timeout is never cached as "no aliases" — except `MusicBrainzClient.search_artist` catches its own exceptions and returns `[]`, which is the exact value it uses for "MusicBrainz knows nobody by that name". The except block could not fire, the outage arrived as a completed no-result search, and the negative cache was written exactly as before. `get_artist` collapses the same two meanings into None. Both now take `raise_on_error`, default off so every other caller keeps its fail-soft list; the alias and identity paths ask for the failure. **A failed fallback search still reached the negative cache.** When strict returned weak candidates and the non-strict query timed out, `scored` was non-empty — so the "no results" guard did not fire — and the trust gate then cached "no aliases" on its way out. But for a cross-script artist the alias index IS the non-strict query, so the entity that would have passed may only ever have existed in the search that never answered. One `_remember_no_aliases()` now owns that decision for every gate. **The cross-script catalogue match took MusicBrainz's result order as a tie-break.** It returned the first candidate with any owned-album overlap. That ranking is decided by name relevance — the one signal this branch exists because it cannot use — and a title as ordinary as "Home" overlaps several catalogues. Every candidate is scored now, the best overlap wins, and a tie is refused outright: the id gets written onto the artist row and feeds the alias bridge from there, so a coin flip outlives the call. **A display name was treated as an identity.** `_artist_row_mbid` took `LIMIT 1` over rows matching the name, so with two artists sharing one an arbitrary row became authoritative for every verification against that name, and its aliases could let the wrong artist pass. It reads DISTINCT now and answers only when the rows agree — the same artist reached through two providers is the ordinary case and stays free. `_persist_artist_identity` had the same arbitrary pick; it reads every row under the name, refuses when one of them names another identity, and writes to all of them when they agree. The fifth finding (int() on a non-numeric artist id) does not apply here: `lib2_artists.id` is INTEGER AUTOINCREMENT, not the media server's key. It is fixed on the dev-side port, where `artists.id` migrates to TEXT. Also in here, found while porting: `tests/test_acoustid_scanner.py` patched `_resolve_expected_artist_aliases` on the SCANNER module, which stopped importing that name when the scan was routed through the shared verifier. The non-raising monkeypatch made it a silent no-op, so four tests were calling MusicBrainz over HTTP for real — 72s for the file, and a different answer when rate-limited. An autouse fixture stubs it: 0.5s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four tests stubbed `_resolve_expected_artist_aliases` on `core.repair_jobs.acoustid_scanner`. The scanner has never defined or imported that name — it lives in `core.acoustid_verification`, and the one-pipeline refactor is what routed the scan through it. `raising=False` turned the miss into silence: `monkeypatch.setattr` simply created an unused attribute on the scanner module and the real resolver kept running. So those four tests built a MusicBrainzService and queried MusicBrainz over HTTP: ~35s each, and a different answer whenever the rate limiter said so. A suite that reaches the network is not a suite that can fail for a reason you can read. One autouse fixture now stubs the resolver at its real address, and the test that IS about the alias bridge overrides it with the list it means. No test is added or removed — 22 before, 22 after, 2.1s for the file. The same fixture already landed on dev with Nezreka#1185; this is it on the branch that carries the lib2 persistence test dev does not have, so the merge has one less twin to reconcile.
The bulk of the conflict work is upstream's web_server split. Seven of the 31 conflicts have the same shape: our side is the inline endpoint, theirs is a comment saying "lifted to api/x.py". Keeping our side would have left two url_map rules per route, because the blueprints register themselves further down the file regardless. So the lift is adopted and our deltas are ported into the lifted modules — measured with an AST diff of every top-level function so the getter-vs-global rewrite did not read as a change. Ported into the lifted modules: the wishlist-mirror refresh and the upgrade policy carry-over (quality_profiles); the acquisition retry journal, the post-approval media scan and the recovery-to-staging shape (quarantine); lib2_track_files, mark_file_verification_status on its own transaction and the F-10 decision journal (verification); the lib2 roster with owned_sql roll-ups and the §69.1 reverse edge (artist_watchlist); the artist scope of /jobs/<id>/run (repair); defer_or_start (auto_import). One conflict block mixed three legacy endpoints with two new Discover routes. Split by hand: sync_artist_library / library_delete_album / library_delete_tracks_batch stay deleted, /api/discover/stations and /api/discover/resolve-playable come in. The legacy-usage ratchet went 0 -> 36 reads on the merge. All ported to lib2: the new vibe-season sourcing (last.fm tags live in the enrichment JSON here, not in a column of their own, and v2 keeps discography rows beside the owned ones so the album legs need owned_sql), the daily mixes, the play-now bridge and the stations row. Legacy stores duration in seconds and lib2 in milliseconds — two ported call sites had a `* 1000` that had to go. Upstream's new live-write guard sampled `tracks`, which is empty here: zero rows fell under its own floor and it reported "visible" every time. It reads lib2_track_files now. Its test found a real pre-existing bug on the way: library_retag has been in JOB_DATA_BASIS since it was written but never in _JOB_MODULES, so the job never registered. Three upstream fixes deliberately not taken, each with the reasoning at the call site: the atomic-publish media-server gate (the row it protects is written on every install here, so taking the gate would disable the guard — upstream's test is inverted to pin that), the cross-format duplicate filename pass (its job is retired here; the other two thirds of that commit came in), and the non-numeric artist id (lib2 keeps INTEGER). Left open: upstream's music-video shelf is orphaned — its only importer was the legacy artist-detail page this branch deleted. Mounting it in the v2 artist view is a placement decision, not a merge resolution. Python 17553 passed / 4 failed (2 documented baseline, 2 parallel-isolation flakes that are green alone). Frontend 358 files / 7428 tests, npm run check clean. Ratchet back to 0/0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts: # core/imports/side_effects.py # core/repair_jobs/lossy_converter.py # core/repair_jobs/quality_upgrade_scanner.py # tests/repair_jobs/test_quality_upgrade.py # tests/test_duplicate_detector_cross_format.py
The tracklist parser reduced every artist credit to a bare name, so each guest on a release was created as an id-less lib2_artists row. The unmapped-artist reconciler then had to infer an identity, and inferred it from the album anchor — which resolves to the album's PRIMARY artist. On the production library that gave a dozen guests Major Lazer's Spotify id and eleven more Sawano Hiroyuki's, along with his artwork and discography. TracklistTrack now carries ProviderArtistCredit tuples (the model already existed for discography rows) and emits them as `artist_credits` plus the answering `provider`, so the namespace travels with the ids. The name-only `artists` list stays for compatibility consumers, and caches written by the old parser still resolve — just without identities. Parser version bumped so stale snapshots refetch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…aims A title match is the stronger claim on a local track row, but it was evaluated per entry, so an earlier entry's (disc, number) fallback could consume a row a later entry would have matched exactly. Production case: the only owned file of "Peace Is The Mission (Extended)" was `04 - Lean On.flac`, while slot 4 of that edition is "Blaze Up the Fire (feat. Chronixx)". Slot 4 claimed the row positionally, so the file was handed that other song's provider ids and artist credits — Chronixx ended up credited on "Lean On" — the real slot 5 could no longer heal the row by title and inserted a duplicate "Lean On", and "Blaze Up the Fire" never became a missing-track row at all. Every exact title match is now reserved in one pass before materialization starts, and positional claims skip reserved rows. Rows no entry matches by title still bind positionally, so numbering repair is unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ntity `get_album_artist_identity_for_source` and its track sibling answer with the release's PRIMARY artist — that is their contract. Anchoring a guest on a release they merely appear on therefore resolved to somebody else every time. On the production library that gave twelve guests of one Major Lazer release his Spotify id, artwork, genres and discography, and ten more Sawano Hiroyuki's; 47 rows across six groups shared an identity they did not own. Three parts: - anchors are read only from releases and tracks the artist is the PRIMARY of; a guest has no anchor and the name search is the honest answer. - `identity_is_free` refuses to stamp a provider id another catalogue row outside this alias group already holds, on both the anchor and the name-search path. Two rows are two artists. - `release_borrowed_artist_identities` heals the existing backlog: the group member that actually fronts a release keeps the id, every guest that inherited it is cleared along with the artwork and genres that arrived in the same write, and a group with no catalogue owner is reported rather than guessed at. It runs first inside the reconcile pass, so the cleared rows re-resolve by name in that same run, and the endpoint drops their cached artwork. Verified against the production database: 40 identities released, 1 ambiguous group (two co-composers of one soundtrack) correctly left alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A discography sync pulls an artist's entire back catalogue so it can be browsed. Resolving those tracklists minted a full lib2_artists row for every name credited on every one of them, so the library filled with artists that own nothing: 82 of 380 on the production library (22%), each listed as a library artist whose "My Library" tab is necessarily empty while "All Releases" is full — exactly the symptom that was reported. Credits on a release the user neither owns nor monitors no longer create artists; artists the library already knows still get their appearance recorded, so nothing stops being credited. `prune_browse_only_artists` clears the existing backlog and runs inside the reconcile pass: a row goes only if it has nothing but a name and browse-only credits — not monitored, fronting no release, no link to anything owned or wanted, no file, no artwork lock, no metadata override. A row with no credits at all is left alone, because a watchlisted artist has an empty page too and somebody asked for that one. Fronting a TRACK is deliberately not a reason to keep: position 0 of every credit list is stored as role='primary', so the lead of any browse-only single would qualify — three such rows survived an earlier cut of the query on the production data purely because the single they lead is filed under the guest whose discography surfaced it. Verified against the production database: 380 → 298 artists, exactly the 82 ghosts, no owned track left uncredited and no album left without its primary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two fallbacks were handing out pictures of somebody else. The album-cover fallback took the first album the artist is credited on. For a guest that is the host's record, so 2 Chainz, DJ Snake and Dillon Francis all wore the cover of "Peace Is The Mission (Extended)" as their portrait. It now only considers a release the artist actually fronts; a guest with no provider photo gets the UI placeholder, which is the honest answer. Last.fm answers with one generic grey star for every artist it cannot picture, and it is a perfectly valid image URL — so it was stored, cached and served as a portrait for seven artists (40 Thevz, E-40, Kam, L.V., …). `is_placeholder_artist_image` recognises the asset and every funnel in `artist_image` drops it, so "no photo" travels as no photo instead of making the row look enriched and stopping every later lookup. `clear_placeholder_artist_images` heals the stored ones inside the reconcile pass, leaving an art-locked row alone because the user chose that picture. Verified against the production database: 7 cleared, exactly the observed rows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The reconcile action now also takes back identities a guest inherited from the release they appear on, removes artists that only a browsed release ever credited, and drops provider "no photo" placeholders stored as portraits — but it reported only the match counts, so a run that fixed dozens of rows read as if it had done nothing. The button's description says what it does now, including that it removes rows. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…portraits Two holes the first artwork pass left, both visible on the production library after the repair ran. Deezer does not use a magic asset id for "no photo": it returns the normal CDN URL with the asset hash simply MISSING (`/images/artist//1000x1000-…jpg`) and serves a grey silhouette for it. Verified live against api.deezer.com/artist/5541359 — that is exactly what became 40 Thevz's portrait the moment the Last.fm star was cleared. The empty-asset path is now recognised alongside the Last.fm marker. Narrowing the cover fallback to releases an artist fronts does not un-cache what the old rule already produced, and those rows have no other reason to be touched: 2 Chainz's cached portrait was still byte-identical to the cover of "Peace Is The Mission (Extended)", a release he only guests on, because his provider id is legitimately his own. `drop_borrowed_album_cover_portraits` compares the cached bytes against cached album covers and drops the ones belonging to a release the artist does not front, so the next render rebuilds under the current rule. Verified against the production artwork cache: drops 2 Chainz plus two orphan files of already-pruned artists, and leaves the 15 portraits that are their own album's cover alone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`reconcile_import_monitoring` derives `lib2_albums.monitored` as "the full
expected tracklist is represented and every represented track is covered", and
computed the expectation as
max(expected_track_count, track_count, known_tracks)
The third term makes the test vacuous — `known_tracks >= known_tracks` is
always true. A single whose provider tracklist had never been fetched (no
expected_track_count, no track_count, one row because one file came off disk)
therefore passed as complete and was monitored, without anything ever having
asked how many tracks that single really has. It only revealed itself as a
two-track release when somebody opened it, at which point the release was
flagged complete while one of its two tracks was neither owned nor wanted.
368 releases on the production library held that flag on non-evidence.
Despite living in `importer.py` this is not an import-time function: it runs
on every tracklist materialization, so it reaches a library that was never
imported from anywhere.
Withdrawing the unproven claim is only half of it, so the second half is here
too: `_partial_album_rows` selected on `expected_track_count > known_tracks`,
and `NULL > n` is NULL, so a release whose size nothing had established could
never enter the precache — 314 singles sat at `tracklist_status='idle'`
indefinitely, which is the "the catalogue only loads when I click on it" half
of the report. Library releases with no known size are now candidates;
discography rows deliberately are not.
Verified against the production database: 368 unproven flags withdrawn, the
490 fileless tracks that carry real acquisition intent keep it, and the 368
releases enter the verification queue instead of waiting for a click.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Library Manager v2 — a real, Lidarr-equivalent library manager, built entirely on SoulSync's own pipeline
Why I built this
SoulSync's library page has always just been a read-only mirror of whatever the media server (Plex/Jellyfin/Navidrome) reports back. No "monitor this artist," no missing-tracks visibility, no way to pick a specific release when auto-grab gets it wrong, no repair tools pointed at a single artist. It inherited every blind spot the media server has, and it couldn't do anything Lidarr does.
That always bugged me, because SoulSync already has everything a library manager needs under the hood: a real multi-source search, a download pipeline, quality enforcement, tagging, repair jobs. Lidarr has to shell out to external tools for all of that. We don't. So the actual gap wasn't "we need Lidarr's features" — it was "we need Lidarr's front end and decision layer wrapped around functionality we already have."
That's what this is. Not a reinvention of SoulSync's search/download/tag machinery — a proper Artist → Album → Track library model sitting on top of it, so you can finally manage your library the way you'd manage it in Lidarr, without needing Lidarr installed at all.
I want SoulSync's library management to be genuinely on par with Lidarr — not "good enough," actually equivalent. This PR is the first complete pass at that.
What it is, in one picture
Library v2 is not a second download/search/tagging system living next to the old one. It's a new front end and a new "what's monitored / what's wanted" model that talks to the same pipeline SoulSync already had:
flowchart TB subgraph UI["Library v2 UI (opt-in, new)"] A[Artist / Album / Track view] B[Interactive Search & Manage Tracks] C[Quality Profiles & Re-Tag Preview] end subgraph Bridge["Monitoring mirrors into the existing systems"] D[Artist monitor → Watchlist] E[Album/Track monitor → Wishlist] end subgraph Core["SoulSync's existing pipeline — unchanged, reused, not duplicated"] F[Multi-source Search] G[Download Orchestrator] H[Quality Gate + AcoustID] I[Tagging / Post-Processing] end UI --> Bridge --> Core Core -. same auto-download machinery keeps running .-> BridgeIf you never open the Library v2 page, nothing changes — it's dormant behind a feature flag. If you do open it, "Monitor this artist" doesn't invent a new download queue; it just adds the artist to the existing Watchlist, the same one the old page uses. Same for Wishlist. So the whole thing stays compatible with everything else running in the app instead of forking it.
Design rules I wouldn't break, even under pressure
These came out of a lot of back-and-forth with myself about where this could go wrong, and they held up through the whole build:
Feature checklist — where it actually lands vs. Lidarr
What's actually done (this is the bulk of the PR)
I'm not going to paste the full internal changelog here — it's long — but the shape of it:
Test status at the point of writing this: ~8,200 backend tests green, ~100 frontend tests (Vitest) green, zero lint/typecheck warnings, production build clean.
How to try it
It's off by default. To turn it on:
Set it either in
config/config.json(fresh installs, before the DB has a config row) or directly through the app's config store if you're on an existing install (the DB is the actual source of truth once it exists — editing the JSON file alone won't do anything on an established install). Restart the app afterward — the flag is only read at startup.Turning it on does not touch your existing library, files, or downloads. The only side effect is that finished downloads start getting mirrored into the new Library v2 tables in the background (so the new view stays in sync even for downloads that went through the old Wishlist/Watchlist flow) — that's bookkeeping only, it never writes to disk and never changes what gets downloaded.
What's still rough — please read this before testing
I'd rather list these myself than have someone find them and assume I didn't notice:
.jpgand servesimage/jpegregardless of what format the source image actually was. Usually harmless, occasionally shows a broken image for a non-JPEG provider result.None of these are secret landmines — they're all tracked, and none of them touch your files or your existing (non-Library-v2) workflows. But this is genuinely an early, opt-in feature, not a "flip it and forget it" one yet.
What I'm asking for
Try it on your actual library — especially if you're on a setup I can't easily replicate myself: very large libraries, path-mapped Docker volumes, multiple download sources (Usenet + torrent + Soulseek together), multiple user profiles, non-Plex media servers. Tell me what's confusing, what breaks, and what feels like it's missing compared to how you'd expect Lidarr to behave. I'd rather find the rough edges from real libraries now than after this becomes the default.
This has been a big chunk of work and I'm genuinely excited about it — but I also know "big chunk of work I'm excited about" is exactly the kind of PR that needs the most outside eyes before it merges, not the least. So: please poke at it. Break it. Tell me it's wrong. That's what this draft is for.
I screwed up — a force-push (git history cleanup) automatically closed the old PR #1025, and GitHub won't let it be reopened since then ("branch was force-pushed or recreated"). The branch/code is unchanged, only the PR container itself is gone. Here are the comments from last time so nothing gets lost:
@Nezreka (2026-07-14):
@nick2000713 (2026-07-14):
@Nezreka (2026-07-14):
@Nezreka (2026-07-14):
@nick2000713 (2026-07-14):
@nick2000713 (2026-07-14):
@Nezreka (2026-07-14):
@nick2000713 (2026-07-16):
@nick2000713 (2026-07-18):
@nick2000713 (2026-07-18):
@Nezreka (2026-07-19):
@nick2000713 (2026-07-19):