Skip to content

Library overhaul - #1062

Open
nick2000713 wants to merge 265 commits into
Nezreka:devfrom
nick2000713:library-overhaul
Open

Library overhaul#1062
nick2000713 wants to merge 265 commits into
Nezreka:devfrom
nick2000713:library-overhaul

Conversation

@nick2000713

@nick2000713 nick2000713 commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Library Manager v2 — a real, Lidarr-equivalent library manager, built entirely on SoulSync's own pipeline

Update after the full lib2 cutover: this rework was honestly rougher than i expected, and @Nezreka was right to keep asking which database would actually be maintained in the future.

keeping the legacy library and lib2 alive as two equal databases made no sense. every metadata edit, enrichment result, import, repair job and media-server sync would need dual writes and permanent synchronization, and sooner or later both versions would disagree. i stopped trying to maintain both.

all active Library reads, writes, imports, enrichment and catalogue repair paths now use lib2 directly. lib2 is the main Library and the single runtime source of truth. the old tables remain only temporarily for the one-time upgrade import and as an upgrade/rollback safety boundary while this is tested on real large libraries. if that proves solid, the legacy schema can be physically removed in a later update.

this was a much larger and rougher rewrite than i originally planned, but the architecture is finally unambiguous: future Library development and maintenance happens on lib2, not on two databases at once. some older sections below describe the original opt-in/two-library phase and should be read as historical context.

Status: Draft / RFC. This is not ready to merge. I'm opening it early on purpose — to get eyes on the direction, get people testing it on their own libraries, and collect feedback before I lock in decisions that get expensive to change later. Please read the "What's still rough" section before you judge it — I tried to be brutally honest about what's missing.


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 .-> Bridge
Loading

If 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:

  • Never media-server-dependent — not even for cover art. Artist/album images come from the file itself (embedded cover) first, then a metadata provider, cached to local disk. A pure SoulSync install with no Plex/Jellyfin/Navidrome attached works exactly the same as one with a media server.
  • Monitoring mirrors the systems you already trust. "Monitor" on an Artist = added to the Watchlist. "Monitor" on an Album/Track = added to the Wishlist. The existing auto-scan/auto-download machinery just keeps working — Library v2 doesn't replace it, it feeds it.
  • One quality-profile table, period. No shadow copy for the new UI. Every piece of the pipeline — search ranking, the AcoustID skip decision, the import quality gate, the upgrade scanner — resolves the same profile live, every time. Change a profile in Settings and it's immediately in effect everywhere, including for things Library v2 didn't queue itself.
  • The database is the source of truth, not your folder layout. Every file's location is tracked per-row, so your library is reconstructable regardless of how you've organized folders.
  • Reuse, don't reinvent. Search, download, tagging, repair jobs, quality logic — all the exact same code the rest of the app uses. This is a front end and a decision layer, not a parallel app.

Feature checklist — where it actually lands vs. Lidarr

Capability Old Library page Library v2 Lidarr
Read-only mirror of the media server ✅ (that's all it is) ❌ (own DB, own model)
Works with zero media server attached
Monitor artist / album / track individually
Missing-tracks view with real titles, not just gaps
Interactive search → pick a specific release ✅ (source-aware, quality preview badges)
Quality Profiles enforced at every pipeline stage partial
Full discography browse + "monitor future releases"
Re-tag preview (diff file tags vs. library metadata)
Manage duplicate single/album versions
Per-artist scoped repair/maintenance jobs ✅ (where job-level support exists)
Playlists visible in the library separate page basic view + trigger (see below) ✅ dedicated
Delete removes the actual files, safely 🚧 DB-only for now

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:

  • Full Artist/Album/Track data model with multi-artist credit splitting, single-vs-album linking, and an importer that migrates your existing library into it without duplicating anything.
  • Interactive Search & Grab with source-aware results (Soulseek slots/queue, Usenet grabs, age columns for Usenet/torrent), quality/AcoustID toggle overrides, and profile "preview badges" that tell you before you grab whether a result will meet your quality cutoff.
  • Quality Profiles, for real — per-artist assignment, per-track evaluation against upgrade policy and cutoff, live-resolved everywhere instead of frozen at add-time.
  • Discography browser — full provider catalog per artist, EPs section, bulk monitor/unmonitor, background re-sync job so new releases show up without you clicking anything.
  • Manage Tracks — see single-vs-album duplicate pairs, unlink them, move the file to the "correct" version without touching disk.
  • Re-Tag Preview — a real Lidarr-style diff table (file tags vs. library metadata) before you write anything.
  • Maintenance panel wired to the existing repair jobs (metadata gap fill, unknown-artist fixer, tag consistency, rename/reorganize), scoped to the artist you're looking at where the job supports it.
  • Basic Playlists integration — a read-only view of your existing mirrored playlists (source, owner, artwork, discovery/wanted/library counters) with a button that triggers the existing playlist mirror pipeline. Deliberately minimal — see below.
  • All of the above verified against a real ~285-track library, plus a from-scratch Docker + Playwright pass that actually clicks through the UI in a browser rather than trusting curl/unit tests alone.

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:

features.library_v2 = true

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:

  • Deleting an artist/album only removes database rows — it does not delete files from disk yet. The safe-delete flow (preview what would be removed, journal it, actual recycle/unlink) is designed but not built. Don't expect "Delete" to free up disk space right now.
  • Cover art has a cosmetic bug: the artwork cache always writes .jpg and serves image/jpeg regardless of what format the source image actually was. Usually harmless, occasionally shows a broken image for a non-JPEG provider result.
  • Artist photos currently reuse an embedded album cover rather than always fetching a dedicated artist photo from a provider — so an artist's "photo" can end up being whatever album cover was picked, which isn't always what you'd expect. Worth a real decision before I "fix" it, since it's a deliberate fast/local choice right now, not an oversight.
  • A few UI rough edges: some monitor/save actions can fail silently without a visible error; "Search Monitored" and "Search Upgrades" read as artist-scoped buttons but currently run against the whole library; the interactive search modal doesn't yet expose the same source-priority controls the rest of the app has.
  • Playlists are intentionally shallow right now. You can see them and trigger the existing sync/mirror pipeline, but there's no per-playlist quality profile yet, and no resolution logic for what happens when a track is wanted through a playlist and an artist you've already set a different quality profile for. I want that (track-specific override beats artist-specific beats playlist-default, most specific wins) — but it's a genuinely complicated UX problem given we already have multiple playlist-related pages, so I'm deliberately not rushing it. It's documented and parked, not forgotten.
  • Very large libraries may show their age in a couple of list/detail queries that aren't fully optimized yet (N+1 patterns in a few spots).
  • Scan/Retag hold a database write-lock slightly longer than they need to while reading files off disk — mostly invisible unless you're on a slow network mount, but worth calling out.

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):

I'm excited to test this! Would this replace the entire library page or one of the views that exist in it. Standard view was nothing special but enhanced view was more library focused for manipulation

@nick2000713 (2026-07-14):

It’s still a long way from being finished, so there’s a lot missing and a lot left to implement.

The idea is that it will eventually replace both the standard library and Library Enhanced, although it’s mainly meant as a replacement for Library Enhanced for power users.

If you notice something missing, definitely let me know. There’s still plenty I want to add.

Personal note: I actually started working on this before SoulSync expanded into movies as well. Personally, I’d rather keep focusing on music for now. I feel like the movie/TV (and even YouTube) space is already covered really well by projects like Sonarr, Radarr and TubeSync, which are, in my opinion, still superior in those areas. Because of that, I haven’t really designed or optimized Library v2 with those use cases in mind.

@Nezreka (2026-07-14):

Sounds great! I'm just messing with it now, importing my library. Excited to see how it all works. Yeah you focus on anything you want to, the entire video side is very fresh with more work to be done. Music and video side are fully disconnected though so no worries you will impact anything. I've been daily driving it replacing radarr, sonarr, kometa and ytdl-sub. takes a day or so to enrich the library for the functions to work though.

@Nezreka (2026-07-14):

I'm noticing that the import is taking quite a bit without any live status. Been about 30 min without much happening but it says it is importing. I have a 320,000 track library though.

@nick2000713 (2026-07-14):

Oh Wow, definitely never tested it at 320k tracks 😅 Good news, it's not actually stuck.. the DB import itself is fast, but right after that it silently kicks off an enrichment pass that fetches tracklists + artwork per album from Spotify/Deezer, one by one. At your scale that's tens of thousands of sequential API calls, so itjust grinds for a while.

The actual bug is that none of that shows up in the UI — it just sits on "Importing…" forever with zero
indication anything's happening, even though the backend is tracking real progress under the hood. Logged it as P2-25, will get real progress reporting wired up. For now if you tail the backend logs you should see it steadily crunching through albums.

@nick2000713 (2026-07-14):

To be completely honest, the import still needs quite a bit of work. Several features from the old library haven’t been implemented yet, and not all information that was available in the old library is displayed in Library V2 yet.

If you’d like to try it, feel free to do so, but keep in mind that the import is still a work in progress.

For example, manual artist and track matching, metadata enrichment, and the detailed match overview from the old library are still on the roadmap. Some source information is not shown in Library V2 yet, but I plan to bring all of these features back.

I’m aware that the new library hasn’t reached feature parity with the old one yet, but that’s the goal. I’ll also be revisiting the import process to make it more complete and efficient.

@Nezreka (2026-07-14):

I’m aware that the new library hasn’t reached feature parity with the old one yet, but that’s the goal. I’ll also be revisiting the import process to make it more complete and efficient.

You're killing it, man. I appreciate your work. Take your time, let me know if you ever want me to pop in and check things. Moving SoulSync away from the reliance of media servers is necessary for other things planned down the line anyway.

@nick2000713 (2026-07-16):

Hey @Nezreka, I've worked on optimizing the import pipeline. It is now parallelized (for artwork, tracklists, and tag pre-caching) and runs significantly faster. Several bugs (like track-number healing and missing ReplayGain/Lyrics features after legacy import) have also been resolved. Feel free to try it out and let me know what you think..

@nick2000713 (2026-07-18):

Hey @Nezreka,

I've worked again on the import pipeline and resolved many many bugs.

Several bugs are still known... I still have problems when the artist hasn't the right metadata or has duplicate artists, but I think I have it now somewhat under control.

I also fixed the issue where newly split component artists were automatically set to monitored/watched instead of just the respective track. Now they correctly inherit the monitored status of the parent combined artist.

I would recommend to reset everything and start the import again since many many bugs are now resolved. It's better if you reset and start fresh, maybe now on a smaller library ;)

If you want, you can execute this command in the browser. This is how you can reset it, but be aware it also removes everything on your wishlist/watchlist:

(async () => {
  console.log('Hole Liste aller Künstler...');
  let page = 1;
  let allArtistIds = [];
  
  while (true) {
    const res = await fetch(`/api/library/v2/artists?page=${page}&limit=500`).then(r => r.json());
    if (!res.success || !res.artists || res.artists.length === 0) break;
    allArtistIds.push(...res.artists.map(a => a.id));
    page++;
  }
   
  console.log(`${allArtistIds.length} Künstler gefunden. Löschvorgang gestartet...`);
  
  for (let i = 0; i < allArtistIds.length; i++) {
    const id = allArtistIds[i];
    await fetch(`/api/library/v2/artists/${id}`, { method: 'DELETE' });
    console.log(`[${i + 1}/${allArtistIds.length}] Künstler mit ID ${id} gelöscht`);
  }
   
  console.log('Bibliothek vollständig geleert! Lade Seite neu...');
  location.reload();
})();

After the import and caching is done, I would recommend to manually start the "Reconcile unmapped artists" job. It resolves collaboration names into the real component artists and several other things.

Try it out and tell me what you think, thank you!

@nick2000713 (2026-07-18):

I am now working on the diverse tools that work with the old library, etc. together. For me, every feature I want and the old library had is implemented now. It's easier for me to build solely on the new library (Library v2), so I have to change/modify nearly every tool because it depends on the old library and database tables. For me to develop, it's easier to solely concentrate on Library v2 and migrate every tool over. Therefore, with the new code, the old library will be removed so it makes it easier going forward. If something is missing for you, tell me now...

And another thing is, I have found several bugs anyway and solved them in the download pipeline. Like when you approve a track in the quarantine, it would later be detected as an orphan file because the metadata after a given time weren't present so it wasn't written in the database. Such bugs and several others are also resolved because I stumbled upon them. @Nezreka

@Nezreka (2026-07-19):

Okay, I'm starting to understand this better now that I've got it running on a test library. My big question: what happens to the artist-detail pages?

Right now those are the only thing that lets me pull up an artist's full discography for artists I don't own yet — as far as I can tell Library v2 only works on artists already in my library, so I want to understand how that "find an artist I have nothing by and start monitoring them" flow survives once the old library is gone.

I'll be honest, this is getting a little big lol — scope mostly. But I'm willing to see it through and test as much as you need but also it's early and I haven't seen the full picture yet. I just want us on the same page about what's getting replaced (a page goes away) vs. rewritten (same page, new plumbing underneath) before it gets expensive to change.

@nick2000713 (2026-07-19):

Thanks for testing it so thoroughly. And don't worry, we absolutely don't have to rush this. I completely agree that before something this big gets merged, the architecture has to be right. I'd rather take more time now than end up with something we regret later.

The reason this became much larger than I originally expected is that I started Library V2 as an opt-in feature. Because of that, I introduced separate database tables instead of replacing the existing ones. That worked fine at first, since Library V2 was just a new view sitting on top of what already existed. But once I actually started testing it against a real library, I kept running into background jobs that quietly assumed a track could only ever live in the old tables. As one example: the lyrics-fetch job resolved a track's file path through the old library tables, and once tracks could also exist purely in Library V2's own tables, that same resolver silently returned wrong paths for V2-only tracks. I fixed that, and a few days later hit the exact same class of bug independently in the ReplayGain job, because it had its own separate copy of similar path-resolution logic. And it wasn't just those two — there are 33 separate repair/maintenance tools (quality scanning, tagging, cover art, orphan detection, and so on) that all made the same old-tables assumption in one place or another. At that point I basically had two choices:

  • keep both libraries alive and write synchronization between the old and new tables for every one of those tools, or
  • migrate the tools to Library V2 directly.

I chose the second option because synchronization is much more error-prone than having a single source of truth, and because fixing the same class of bug over and over, one tool at a time, wasn't sustainable. In hindsight, that's also why this PR grew much more than I originally anticipated.

I understand your concern about the artist pages, but I don't think anything you're actually worried about is being removed — this is a wording problem, not a scope problem. To be clear about what's actually going away: it's only the old Library page itself. Search, Discover, the artist-detail page, Watchlist, and Wishlist are separate pages and none of them are being touched by this migration.

Given that, I have to admit I'm not fully sure I still see the problem — you can already search for an artist you don't own today, open their page, browse their discography, and add them to the Watchlist or download straight from there, all without that artist ever being in the library. That flow doesn't sit on the page I'm removing, so it keeps working exactly as it does now. If there's a specific part of it you're worried about beyond the wording, let me know and I'll take a closer look.

The old Library page itself is essentially replaced by Library V2, so I don't see much value in keeping two versions of the same thing around. Everything else stays as is for now.

Also, while working on this I stumbled across a surprising number of unrelated bugs in the existing pipeline. Some of those fixes probably don't even belong in this PR and could be split into separate pull requests to make this one easier to review.

On top of that, the playlist view — we can strike that entirely from scope. I'm not attached to it and it's not something I need in this PR.

@nick2000713 nick2000713 changed the title chore: squash library-overhaul onto dev for a real merge-base Library overhaul Jul 22, 2026
dev and others added 28 commits July 25, 2026 23:59
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>
dev and others added 30 commits August 25, 2026 05:37
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants