Skip to content

Dev - #1152

Merged
Nezreka merged 48 commits into
mainfrom
dev
Aug 19, 2026
Merged

Dev#1152
Nezreka merged 48 commits into
mainfrom
dev

Conversation

@Nezreka

@Nezreka Nezreka commented Aug 16, 2026

Copy link
Copy Markdown
Owner

soulsync 3.2.2: devmain

five features and twenty-one fixes, most of them from reports. seven
community PRs merged in as well.

reassign an album to a different artist

TheHomeGuy asked for this: he had albums filed under the wrong artist, usually
because a featured artist got picked up as the album artist. there was no way
to move them.

⇄ Reassign now sits in the album action row next to Reorganize and Redownload
(artist detail → enhanced view → expand an album). you search for the artist it
should belong to, pick one of their releases, and see how your tracks line up
before anything happens.

you never type an artist name, you pick a real one and then a real release of
theirs. that way the identity handed to the import pipeline is one the source
can actually resolve.

it doesn't retag or move anything itself. each track is staged with a hint
naming the release you picked and the import pipeline re-files it, same as a
single-track re-identify. tags, folder and database rows all come from the code
that handles a fresh download, so there's no second version to drift.

the review screen matters because an album is many files. it shows every track,
what it maps to and why (track number or title), plus anything it couldn't
place. if some tracks don't line up it won't proceed without you saying so,
because moving half an album leaves the rest under the old artist which is the
exact thing you were trying to fix.

collapsible sidebar (#1155)

wishx runs soulsync in a narrow wavebox tab where a fixed 240px sidebar eats
most of the width. one toggle in the sidebar header now shrinks it to 68px of
icons, with the tab name on hover. persisted, off by default, desktop only.

collapsed, the section headers become divider lines and everything under them
is shown. a chevron you can't read is a dead control — you'd click a nameless
arrow, items would vanish, and nothing would tell you what you hid. your saved
per-section collapse state is untouched and comes back when you expand.

prefer a specific version of a track

eN1gma: spotify often carries only the radio edit, but the extended mix is on
soulseek and nothing went looking for it. Settings → Soulseek → "Prefer a
version when one exists". off by default.

nothing is ever skipped. if that version isn't out there the normal one
downloads exactly as before. picking live, remix, acoustic or instrumental
lifts a protection that normally throws those out on sight, and the help text
says so.

youtube premium quality (#1154)

lucasssvaz's PR, the biggest community one this release. youtube used to be
the source that lied: flat video search has no artist/album and no formats,
missing formats got invented as MP3 320, and opus bitrates showed as a dash.

  • search can use the Music catalog with cookies.txt auth, so hits carry
    structured artist/album metadata and matching works the same shape as tidal
  • downloads rank the real opus/aac formats against your quality profile.
    missing formats claim typical opus 160, never invented MP3 320. premium
    cookies unlock the premium itags (774/141) via the music player, and only a
    pasted cookies.txt counts — a browser cookie store is never treated as
    premium
  • mixed best-quality pools score per source now: a soulseek peer no longer
    forces tidal and youtube through the P2P matcher, and a rejected youtube
    profile no longer wipes the rest of the pool
  • opus bitrate is estimated from size/duration on import and scan, and VBR
    averages show as ~160 in Enhanced view instead of a dash

took a stabilization round after the merge: a config.settings import silently
disabled transcode and catalog auth on a clean tree, matcher-rejected catalog
hits now fall through to ytsearch, and expired cookies are only dropped on
the last download retry.

remove a server playlist from the compare editor

the server tab could edit a playlist every way except delete it. a "remove
from server" pill now sits next to export m3u — confirms, deletes the
playlist on plex/jellyfin/navidrome (soulsync-made or not), closes the editor
and drops back to the refreshed list. same id-first name-fallback shape as
the rest of the edit family: plex and jellyfin delete-recreate on edit, so a
stale page id gets re-resolved by name instead of 404ing with the live
playlist standing.

fixes

  • radio edits were being thrown away, including when you asked for one.
    \bedit\b lived in the remix patterns and remix was checked first, so "radio
    edit" classified as a remix — and remix is reject-on-sight. the file scored
    0.0 and got binned even when spotify itself named the radio edit. ask for
    "Song - Radio Edit" now and you get it; ask for the plain version and you
    still get the plain version
  • Bug: FLAC-only quality profile is bypassed by Soulseek fallback #1150 per-item quality profiles were ignored at the soulseek search
    (Zombiehamser). assigning a profile to a wishlist row changed what got
    ACCEPTED at import but not what got CONSIDERED at search — the filter read
    the app-wide default. a strict item under a loose default downloaded lossy
    files and only failed at the guard
  • Tools don't respect custom file organization templates #1127 navidrome paths whose filename is synthesized (Clover-Moss). the
    subsonic api builds the whole path from tags, so the filename can be wrong
    too, not just the album folder. dead file cleaner called every file
    unreachable and album completeness refused to fix anything
  • navidrome's verified-empty check read the wrong folder key, so it could
    never pass with a music folder selected
  • songs stuck on "Processing", blocking everything else (Sokhi). nothing
    was hung. one pool of 3 threads ran both soulseek searches and
    post-processing, and a search holds its thread 25-60s and usually finds
    nothing. a finished download queued behind them while showing "Processing" —
    his log has one waiting 52 seconds for 3 seconds of work. post-processing has
    its own pool now. measured 33.6s → 0.0s
  • "Concurrent Downloads" did nothing above 3. the setting offers up to 10
    and is a per-batch limit, but the worker pool was hardwired to 3, so seven of
    ten workers just sat in a queue while the help text promised a speedup. it's
    sized from the setting now (takes a restart)
  • a batch wedged in post-processing had no rescue. the 30-minute timeout
    only ran when the batch had orphaned tasks, and a batch whose remaining
    tracks are all post-processing produces none. it held its slots forever
  • the album stall-guard left zombie transfers in slskd (sassmastawillis).
    marking a stalled transfer failed only resolved our bundle — slskd kept the
    enqueue alive, so the file sat at "queued, remotely" for hours after the
    guard said it was handled, and a retry picking the same peer+file collided
    with the zombie instead of issuing a fresh request. the guard now cancels
    and removes each stalled transfer, same as the monitor's retry path
  • quality quarantine wedged the task in Processing (Sokhi again). the
    verification wrapper pops the task id out of the context before the inner
    pipeline runs, so the quarantine branch's requeue and failed-marking always
    saw task_id=None and silently did nothing. the task then sat in
    post_processing until stuck detection reaped it at 30 minutes — one wedged
    download slot per quarantined file, 8x in one log
  • the whole download subsystem logged nowhere app.log could see. twelve
    modules named their logger outside the hierarchy the file handler is attached
    to, so every completion check and stuck-task decision was invisible in the
    debug export users paste into issues. we were diagnosing Sokhi's stall half
    blind. 49 modules elsewhere are still silent
  • [Bug] Deezer API returns genre names localized to server region #1157 deezer genres came back in the server's language
    (PfannkuchenWolf). deezer localises genre names by the caller's IP, so a
    german-hosted server got "Filme/Videospiele" instead of "Films/Games". one
    library ended up with both spellings and strict genre filtering matched
    neither. auditing the fallback map against the live list also turned up genre
    464 filed as "Rap" when it is Metal
  • deezer playlists always said NEVER SYNCED (TheHomeGuy). the endpoint
    returned a hardcoded literal — the field matched spotify's shape but never
    carried a value. sync ran, tracks downloaded, the card never updated
  • loading a big deezer playlist looked broken (TheHomeGuy). "seems to
    hang... sit here for several minutes doing nothing". a 1200-track playlist
    resolves ~900 albums for release dates and real track numbers, and the
    spinner said nothing. that work can't go away — it's what the download picker
    uses to choose and verify a candidate — so it narrates now: "release dates
    340/877 (39%)"
  • every deezer caller now shares one rate budget. there were three numbers
    and none agreed: a decorator enforcing 1/s on nine methods, two playlist
    loops obeying nothing and counted by nothing, and an undocumented ceiling.
    eleven bypassing call sites found, all routed through one throttle at 8/s
  • [Bug] Multiple issues in the playlist sync matching system #1159 tracks with punctuation were unfindable in the manual-match search
    (AfonsoG6). the fuzzy fallback split on whitespace only, so "Would've,
    Could've, Should've" asked the database for %would've,% — matching only a
    title that has the comma too. a file tagged without them matched one word and
    ranked third behind unrelated songs
  • the other four [Bug] Multiple issues in the playlist sync matching system #1159 issues, same report (AfonsoG6). cached auto-matches
    pinned the compare view and shadowed a saved manual match; replace-track
    didn't persist the pairing, so a corrected bad match reverted on the next
    compare; jellyfin edits 404'd on stale playlist ids where plex re-resolved
    by name; and jellyfin thumbs came from a raw unauthenticated url that
    answered 200 with an empty body — they go through the token-safe image
    proxy now. all five items in that report are closed
  • Bug: a slow Prowlarr indexer discards responsive torrent results #1151 a slow prowlarr indexer threw away everyone else's results
    (Zombiehamser). one search listing every indexer meant a single slow one held
    the reply past the 75s timeout, and a timed out request returns nothing. it's
    one request per indexer now, run together, and you keep whatever comes back
  • Reorganize / Download doesn't fuzz Capitalization #1091 folders differing only in capitalization made two albums
    (TomOdellSheetMusic). on linux you got two real folders and jellyfin showed
    the album twice; on windows and mac the recorded path didn't match the disk
  • quality check upgrades always failed with "no matched track in finding"
    (Lil-Uzi-Chimp and one other). the fix handler was gated on an entity id the
    scanner never records for an unmatched file, so it never read the finding's
    own details
  • "replace the original file" did nothing on a manual import (Urethra
    Franklin). the hint was only ever read by the auto-import worker
  • recently played showed 12 tracks and neither dashboard rail updated
    without a reload.
    25 now, both refresh on a timer

community PRs

  • fix: prevent gradual file_path corruption during library scans #1158 gradual file_path corruption during library scans (shkarlsson).
    when the subsonic api omits path, the code fabricated a bare filename and
    the UPDATE overwrote the real one — while every other nullable column was
    COALESCE-guarded. both halves were needed: the fabricated value is non-null,
    so COALESCE alone couldn't see it
  • fix(wing-it): a stale flag is not an unresolved guess #1160 a stale wing-it flag is not an unresolved guess (nstrelow). the
    flag is only ever set, so a track that wing-it'd once and matched properly
    later carried it forever. 317 of 445 rows in the pool were already resolved
  • fix(discovery): canonicalize YouTube titles before matching, not only after #1162 canonicalize youtube titles before matching, not only after
    (nstrelow). youtube music restates a localized title in latin next to the
    original, and providers index only the plain form — so the raw string
    searched for nothing. 29 of 69 wing-it stubs resolve at confidence 1.00
  • feat(discovery): resolve artist aliases before giving up on a track #1161 resolve artist aliases before giving up on a track (nstrelow).
    "mgk" and "Machine Gun Kelly" share no substring, so a title matching at
    1.00 was discarded on the 0.5 artist-similarity floor and written off as a
    wing-it stub. on a miss, discovery now asks musicbrainz for the artist's
    aliases and retries under each — machinery Alternative names of artists not being matched #442 already built, never
    offered to discovery. six sampled mgk tracks go 0.00 → 0.99, and 25 of one
    playlist's 77 stubs were a single artist alias
  • feat(wishlist): optionally search unverified Wing It guesses #1147 optionally search unverified Wing It guesses (nstrelow). his
    install had 185 stubs across 22 playlists and 77 were findable at the
    provider — guesses, but mostly right ones. new setting, off by default; a
    stub the source left nameless stays out either way. follow-up rounds closed
    the wing-it batch paths that bypassed the gate and a retry-stamp gap that
    kept a failed stub at retry_count 0 forever
  • fix(matching): give the download query ladder a version-stripped last resort #1163 the download query ladder gets a version-stripped last resort
    (nstrelow). every rung preserved the edition suffix, so "Sweet Dreams [2005
    Remaster]" with no peer carrying that exact edition never resolved at all.
    a stripped query now sits at the END of the ladder, and it strips edition
    words only — never live/remix/acoustic — so it can change which pressing
    you get, never which take. measured against the real network: 5 of 11
    stuck edition-decorated wishlist tracks rescued at 0.91+
  • feat(youtube): Improve quality matching and support YT Premium formats #1154 youtube premium quality (lucasssvaz) has its own section up top

tested

full backend suite green, ruff clean, frontend 1,656 tests across the sync
routes, bundle builds. each fix since that run landed with its own targeted
tests; worth one more full-suite pass on the final head before merging.

two caveats worth carrying:

#1151 is still inference. every test stubs the http layer, so prowlarr's
real behaviour under a hung indexer is unverified. worth confirming with the
reporter before closing.

#1154's premium path rests on one account. the format ranking and catalog
auth are covered by tests, but the premium itags (774/141) only exist for a
premium subscriber — that leg is verified on lucasssvaz's install, not
reproduced here.

Nezreka and others added 30 commits August 15, 2026 15:47
…ack"

Reported twice (Lil-Uzi-Chimp, and a second user): the Quality Check tool
found plenty to upgrade, then failed on every single one with "No matched
track in finding".

The scanner records `entity_id=None` for any file it could not match to a
library track row (entity_type='file'). The fix handler was gated on it:

    if not track_data and entity_id:
        track_data = self._track_identity_for_redownload(entity_id, details)

so for those findings the resolver was never called at all — even though the
finding's own details carry the title, artist and album. Every time, not
occasionally, which is what "always" in both reports meant.

The resolver also bailed on a missing DB row, discarding those same usable
details. That is a second, rarer trigger: a full refresh calls
clear_server_data, which DELETEs every track for the server and re-inserts it
with new autoincrement ids, orphaning every finding written beforehand.

Both closed. The resolver now treats the details as a valid source rather than
a per-field fallback, and reads BOTH vocabularies — the Quality Check scanner
writes expected_title/expected_artist, the Quality Upgrade job writes
track_title/artist, and reading only one set left the other's findings
unresolvable.

A finding with neither a row nor usable details is still refused, because a
wishlist entry for "Unknown - Unknown" searches for nothing and sits there
forever. Failing loudly beats queueing something unsatisfiable.

Also fixes a collision this would otherwise have introduced: with no
entity_id the fallback wishlist id was a literal "redownload_None", identical
for every unmatched file, so all but the first would be deduped away and
silently never downloaded. It is now a stable hash of artist+title+path —
unique per file, and unchanged across re-scans so a repeat scan does not queue
duplicates.

16 tests, at the resolver AND at the action the Upgrade button actually calls;
an isolated resolver test proves nothing if the caller still refuses. Mutation
-verified: restoring the entity_id gate fails the end-to-end test with the
exact error users reported. Full suite 13,357 passed.
Reported by TomOdellSheetMusic: reorganize and download rebuild every
destination from metadata, and nothing ever checked whether that folder
already existed under a different case. There was no case-insensitive
directory reuse anywhere in core/.

It breaks differently per platform and both halves are bad:

  * case-SENSITIVE (his Linux/proxmox setup): two real directories, so the
    album shows twice in Jellyfin and SoulSync cannot tell which tracks belong
    together;
  * case-INSENSITIVE (Windows, default macOS): the write lands in the FIRST
    folder but the path recorded is not how the directory is spelled, so every
    later exact-path lookup misses — the "broken browsing" half.

Each path component now resolves to the casing already on disk. Read from the
directory LISTING rather than os.path.isdir, because isdir is exactly the
check that cannot answer this: on a case-insensitive filesystem it returns
True for a name spelled differently, so an earlier draft of this fix helped
only the case-sensitive half while claiming both.

The filename is never folded — two tracks differing only in case are two
files, and folding would overwrite one with the other and lose audio. Several
existing cases resolve deterministically (lexicographically first) or a
reorganize would move files back and forth on every pass.

One fix covers both halves of the report because reorganize and the download
pipeline share build_final_path_for_track (reorganize calls it with
create_dirs=False to preview), so preview and apply cannot disagree either.
It also heals an existing split: on a reorganize every track resolves to the
same surviving folder. It does not merge anything by itself — it only steers
new writes — but running Reorganize is the merge.

Listings are cached against parent mtime so a large library does not re-scan
per track; a MISS forces a fresh read, because a stale miss is the answer that
creates a duplicate folder and mtime is too coarse to trust for that.

Distinct from _keep_user_casing, which stops reorganize CHURNING a folder's
name. That is about not renaming; this is about not duplicating.

NOT addressed: the deluxe-edition splitting raised by QT3496 in the same
thread — that is the canonical-album-version problem, separately deferred.

21 tests: the resolver's rules, plus the wiring through the real builder
asserting no duplicate directory appears on disk. Mutation-verified — bypassing
the wiring fails exactly the three integration tests while the unit tests keep
passing, which is why both layers exist. Full suite 13,357 passed.
Boulder: recently played showed 12, and "if i listen to something it doesn't
appear on recently played unless i refresh page".

Two different causes, not one:

  * Recently Added loaded ONCE on mount and never again, so a finished
    download was invisible until the page was reloaded;
  * Recently Played DID poll every 60s, but skipped while the tab was hidden
    with no catch-up on return — so listening in another tab and coming back
    meant waiting out the rest of the interval, which reads exactly like "only
    updates if I refresh".

A shared useLiveRefresh hook does both: polls while visible, pauses while
hidden, and refetches immediately on visibilitychange AND focus (some browsers
fire only focus when switching windows rather than tabs). Pausing while hidden
is right; pausing WITHOUT catching up just moves the staleness to the moment
the user is actually looking.

The callback lives in a ref so a redefined callback does not tear down and
rebuild the timer every render — at a short interval that would mean it never
fires at all.

WORTH KNOWING: this cannot make a Plex/Jellyfin play appear sooner. A play
reaches listening_history either when the web player records it (immediate) or
when the listening-stats worker polls the media server — every 30 minutes by
default (listening_stats.poll_interval). No amount of front-end polling
surfaces a row that is not in the table yet.

Recently Added refreshes on a slower 2-minute timer: a library gains albums
far less often than it gains plays, and that rail costs two queries. A failed
poll keeps what is on screen rather than blanking a populated rail.

11 tests, including the catch-up and the stale-closure trap.
Reported by Urethra Franklin: re-identify with "Replace the original file"
ticked staged the copy as expected, but importing it by hand from the Import
page left the old file and its library row in place.

The rematch hint was consumed in exactly ONE place — the auto-import worker.
Import the staged file yourself and nothing ever looked for it, so the
checkbox silently did nothing. They were not doing anything wrong.

Only the REPLACE half of the hint is honoured here. On this path the user
picked the release themselves in the UI, so the identification half is already
handled; re-applying it would fight the choice they just made.

Guards, in the order they matter:

  * a REJECTED import deletes nothing. If the file quarantined, the
    replacement never landed and removing the original would destroy the only
    copy;
  * `_final_processed_path` is read BEFORE `_final_path` — it is the canonical
    key (side_effects.py and auto_import_worker.py both read it first) because
    post-processing can move a file after `_final_path` was recorded. Reading
    only the older key left the same-home guard blind, which is a data-loss
    path: a re-identify onto a release resolving to the SAME location would
    have deleted the file the import had just written;
  * with no landing path known at all it refuses outright. Leaving a duplicate
    is recoverable; deleting the only copy is not;
  * `resolve_fn` maps the STORED path (a Docker/media-server view this process
    may not be able to open literally) to the real file. The worker passes the
    same thing; without it the row goes and the FILE is orphaned;
  * a cleanup failure never fails the import — it already succeeded.

12 tests, including one that fails if the CALL SITE is removed (the first six
all passed with it deleted) and one with the staged file deliberately absent,
since the import moves it before this runs — without that the fix would have
been a no-op in production while every test passed.
TheHomeGuy: "Is there any way to re assign an album to a different artist? ...
i have had this happen when a featured artist is taken as the album artist."

⇄ Reassign, in the album action row beside Reorganize and Redownload — an
action on the album's files, not a metadata field.

WHY IT IS NOT A DATABASE UPDATE. The library is a MIRROR of the media server,
which reads tags off the files. Repointing albums.artist_id alone survives
until the next full refresh (clear_server_data drops every row and
repopulates), and Jellyfin shows the wrong artist the whole time regardless.

WHY IT IS NOT A BESPOKE RE-TAG + MOVE EITHER. That is a second implementation
of the import pipeline, and the two would drift. Re-identify (#889) already
settled this: stage a COPY with a single-use hint and let the pipeline re-file
it. So a reassign is the same trick N times — one hint per track, all naming
the release the user picked. Tags, folder and DB rows all come from the code
that handles a fresh download.

The flow is artist → release → review, and that ORDER is the safety property:
you never type a name, you pick a real artist and then a real release of
theirs, so the identity handed to the pipeline is one the source can resolve.

The genuinely new problem is which local file becomes which target track.
Track number first (only when unambiguous on BOTH sides — a flattened
multi-disc album has two track 1s), then title similarity, strictly one-to-one.
Everything else comes back UNMAPPED and visible: an album is many files, so
"I could not place these three" beats three wrong answers.

Failure paths, which is where the bugs were:

  * a hint that cannot be written takes its staged copy back OUT. Left there,
    auto-import treats it as a new file and duplicates the track — N times for
    an album, so "harmless orphan" was wrong;
  * a failed commit does the same for every copy already staged;
  * staging nothing is NOT reported as success;
  * partial application requires explicit opt-in. Moving 8 of 12 files leaves
    4 under the old artist — the exact problem this fixes — so the API refuses
    unless the caller has shown the preview and the user accepted;
  * reassigning to the release the album already claims is refused server-side
    rather than trusted to the UI (#889's invariant, and the API is callable
    directly).

Two silent-failure bugs found by reading the clients rather than the names:
the tracklist key is 'items' (Spotify-compatible) for Discogs, iTunes,
HydraBase and MusicBrainz — reading only 'tracks' returned nothing for most
sources; and ASCII-only normalisation reduced any Japanese, Korean, Cyrillic
or Greek title to an EMPTY string, so two IDENTICAL titles scored 0.0.

55 tests. Modal rides the .reid-* chassis with hero / tabs / search / results /
footer as SIBLINGS — an earlier version nested the controls inside the scroll
region, which scrolled them away with the list.
#1151)

Reported by Zombiehamser. SoulSync sent Prowlarr ONE search request listing
every indexer; Prowlarr queries them all internally and replies once. A slow
or unreachable indexer holds that reply past the 75s read timeout, and a
timed-out request returns NOTHING, so the healthy indexers' results die with
it. His diagnosis was exactly right.

Worse than the report says: the album search tries several query variants, and
a timeout does not break that loop, it logs and moves on. A plain "Artist
Album" is one variant but anything with a year or bracket is three, so the real
wait was ~225s, not 75. That fixes itself here, the loop only retried because
it got nothing back.

His first suggestion (use partial results from the one request) is not possible
as built, Prowlarr does not stream. So: one request per indexer, run
concurrently, keep whatever comes back. A dead indexer costs only its own slot,
and the error names WHICH indexer failed, which one lumped request could never
do.

Four things the line-by-line caught before this shipped:

  * it would have been SLOWER than what it replaces. With more indexers than
    the concurrency cap the batches run back to back, so 12 dead indexers cost
    2 x 75s per variant against 75s for the single request. There is one
    overall deadline now, equal to the original budget, so this can never take
    longer than the call it replaces and whatever arrived inside it is kept;
  * it would have starved the app. `search` runs on the shared slow-I/O pool
    (16 workers, every provider uses it). Unbounded, a fan-out holds one per
    indexer for the whole timeout, and once the pool fills the requests QUEUE
    instead of running together. Capped at 6;
  * CancelledError was being recorded as an indexer failure, swallowing
    shutdown and returning partial results. Now told apart from our own
    deadline cancellations, which ARE real failures;
  * a repeated id in the allowlist (free text, not deduped upstream) meant two
    requests to the same indexer and its results counted twice. Harmless when
    every id went into one request, a real bug once each id is its own.

dd28-02 survives: a transport failure must never look like zero hits, so this
raises only when EVERY indexer fails. Unresolvable ids fall back to the single
aggregated request, because a Prowlarr that cannot list its indexers can still
search, and reading empty as "search nothing" would break those installs.

No existing test's assertions changed. The one edit to
test_prowlarr_search_hardening.py teaches the fake client the new method and
has it answer "cannot resolve", so those tests keep exercising the aggregated
path they were written for.

19 tests, mutation-verified. Full suite 13,433 passed.

NOT verified against a real Prowlarr: every test stubs the HTTP layer, so the
timing and Prowlarr's behaviour under a hung indexer are inference. Worth
confirming with the reporter before closing.
detect_version_type walks its pattern dict and stops at the first hit.
'\bedit\b' is one of the REMIX patterns and remix sat first, so "Radio
Edit" and "Clean Edit" came back classified as remixes. remix is one of
the four reject-on-sight types, so those files scored 0.0 and were
dropped - including when the source title named the radio edit itself:

    want 'Song'              vs 'Song (Radio Edit).mp3'  -> 0.0 rejected
    want 'Song - Radio Edit' vs 'Song (Radio Edit).mp3'  -> 0.0 rejected
    want 'Song (Radio Edit)' vs 'Song (Radio Edit).flac' -> 0.0 rejected

the 'radio' entry and its gentle 0.08 penalty were unreachable, and so
was the '\bclean\s*edit\b' pattern sitting inside it. #923's
prefer-explicit ladder never saw a radio edit either.

fix is an ordering one: 'radio' moves ahead of 'remix'. picked as the
smallest surface - the only strings that can change classification are
ones matching a radio pattern that used to fall through to remix. moving
'remix' later instead would have re-labelled "Live Remix", "Acoustic
Edit" and friends for no benefit. acoustid verification is unaffected
either way: it labels both sides with this same function, so the two
move together.

left alone on purpose: "(Club Mix)" still reads as 'original', which is
wrong. calling it a remix would start rejecting club mixes that download
fine today, and that is a regression traded for a cosmetic fix.
…es (#1150)

a wishlist row can carry its own quality_profile_id, and every stage
resolves it live via load_profile_by_id - candidate ordering, the import
guard, the import pipeline, the album-bundle format veto.
filter_results_by_quality_preference was the one stage that didn't. it
read the app-wide default unconditionally.

so assigning a profile to an item changed what survived IMPORT but not
what was CONSIDERED, and it broke both ways:

  strict item under a loose default - lossy candidates pass the filter,
  get downloaded, and only fail at the guard. bandwidth burnt, item fails

  loose item under a strict default - Zombiehamser's actual setup. his 10
  rows on a fallback-enabled profile were filtered by his FLAC-only
  default, so they were held to a stricter bar than he set

the id rides from the wishlist row through get_valid_candidates into the
filter. load_profile_by_id(None) already resolves to the default, so
passing no id is byte-for-byte the old behaviour - that's what keeps the
album and manual callers unchanged. the debug line now names which
profile it used, because "reading a different profile than you think" is
the whole shape of this bug and the old line couldn't tell the two apart.

note this is NOT the bug as reported. Zombiehamser withdrew that one (the
mp3s were transfers resumed after an slskd restart) but his mixed-profile
setup is what turned this up.
…oo (#1127)

the first fix for this assumed the basename was real and only the album
folder was wrong. its tests said so outright: "the artist folder matches,
the filename matches exactly, and only the ALBUM folder is wrong." that
held for the reporter's example as written, the issue was closed without
him confirming it, and two more users came back on 3.2.1 saying nothing
had changed.

Clover-Moss supplied the missing piece: in the DB path
Beck/Guero/01-01 - E-Pro.flac the leading 01 is the DISC NUMBER tag.
soulsync never writes that - $track renders as 01 and $disc is
deliberately blank on single-disc albums, "a single-disc album shouldn't
stamp '01-' on every filename" (#981, core/imports/paths.py). Guero is
single-disc, so our own template puts 01 - E-Pro.flac on disk.

and we store what navidrome hands us verbatim - music_database.py:8169
takes track_obj.path straight from the subsonic response. so the whole
path is synthesized from tags, filename included, and the exact-basename
fallback can never match.

so: compare the filename with its leading track / disc-track numbering
removed, plus the extension, across every album folder under the artist.
runs ONLY after every exact strategy has failed, so it cannot change a
path that resolves today - it can only turn a None into a hit. that also
means it covers both readings of the original report: if the basename IS
real, the existing sibling-album step already handles it and this never
runs.

conservative because Dead File Cleaner DELETES what this resolves:
exactly one file may match, the extension must be identical, and a title
that merely starts with digits ("1979", "99 Luftballons", "7 Rings")
keeps its name - stripping those would collapse two songs onto one
identity.

kettui: web_server.py::_resolve_library_file_path is a near-duplicate
that never got the sibling-album step AT ALL, so the same library
resolved from a repair job and failed from the web server. both now call
one shared resolve_via_last_resort_fallbacks.

cost is 2.3ms per unresolved track on local disk (~16s over a 7000-track
library), and only on the miss path. a slow NAS will be worse.
Flat video search has no artist/album and no formats. Catalog hits
carry structured metadata so matching is the same shape as Tidal.
Only a pasted cookies.txt can become ytmusicapi headers; a browser
store is yt-dlp's reader and is never treated as Premium.
YouTube does not serve MP3. Missing formats claim typical Opus 160,
never invented MP3 320, never Premium from cookies. A player fetch
stamps real itags before ranking. Re-encode (default on, MP3 320)
ranks the converted file and still fetches the best original stream.

Leftover DASH containers are discarded after extract so import sees
the audio file, not the .webm beside it.
Best quality dumps every source into one list. Validation used to
pick a single matcher from whoever was first, so a Soulseek peer
forced Tidal and YouTube through the P2P path, and a closer YouTube
hit dropped a Tidal FLAC via the 0.05 itag band.

Each family now uses its own checker. YouTube still bands and probes
only its own rows; other sources stay in the ranking pool. A rejected
YouTube profile no longer wipes the rest of the pool.
…wnloads

Cookies are not Premium; the Music player can return 774/141, and those
skip_download URLs 403 if reused. Document Netscape cookies.txt for Docker.
…on retry

Best-quality walks were picking Soulseek FLAC over itag 774 because Opus scored 0.3 vs FLAC 1.0, and a 403 retry stripped cookies so Premium formats vanished.
Mutagen cannot read Opus bitrate, so completed downloads showed a blank chip. Use the player-response claim when the file is still Opus/AAC, and never copy that claim onto a transcoded MP3.
… view

Ogg Opus has no header bitrate, so the album table stored 0 and showed a dash while completed-download chips already estimated from size/duration. Fill that average on import, scan, and artist-detail load, and prefix Opus/AAC/WMA with ~ so the cell reads as VBR rather than CBR.
eN1gma: spotify only carries the radio edit of a track because that's all
it has, but the extended mix is sitting on soulseek and nothing ever went
looking for it.

new setting soulseek.preferred_version, empty by default. with a version
picked, a file that really is that version OF THAT SONG ranks first.
nothing is ever skipped: if that version isn't out there the plain one
downloads exactly as it did before.

two designs failed before this one. rewriting the request to "song
(extended mix)" rejects the plain version, so nothing downloads when no
extended mix exists. gating on whole-path fuzzy confidence fired on 2 of 7
realistic soulseek filename shapes, and measured against 'song' the right
file scored 0.751 against an impostor's 0.743. so the gate reduces both
sides to the bare TITLE and requires exact equality, no threshold to get
wrong.

ranking in the matching engine turned out not to be the last word. the
download walk re-sorts the same list through order_candidates by
confidence, and the plain cut always scores higher because it matches the
source title exactly, so it went straight back on top and the setting
changed nothing. the engine now stamps the verdict on the file and both
sorts read it from there.

acoustid had to learn about it too. its version gate compares the source
title's version against the fingerprinted recording's, so a deliberately
fetched extended mix was quarantined as a wrong song. the download stamps
what it took on purpose and ONLY the version gate loosens; title and
artist still have to agree, and the library scan stays strict. same stamp
carries the peer's advertised length so the integrity check isn't measuring
a 6:20 file against a 3:20 source.

known residual, tested not hidden: a filename with no track number whose
real title starts with digits ("99 problems (extended mix).flac") reduces
to "problems" and can false-match a track called "problems". not solvable
from the string, and refusing to strip bare numbers would break the far
more common "10 song.flac". bounded by the artist gate and the confidence
cut.

verified: 13,605 full suite passed, 26/26 mutants killed, and a
4,094-case a/b against a clean checkout came back byte-identical with the
setting off.
sokhi: "after a while the songs start getting stuck on processing and that
prevents other songs from being downloaded".

nothing was hung. missing_download_executor is 3 threads and ran BOTH the
search workers and post-processing. a search holds its thread for 25-60s
per query, several queries per track, and usually finds nothing for
anything obscure. the monitor marks a task post_processing the moment the
transfer ends and only then queues the work, so a finished download sat in
line reading "Processing" while nothing processed it. post_processing
counts as active in batch healing, so its batch started nothing new the
whole time. that is the reported symptom exactly.

from his log, one track: submitted 13:28:32, worker started 13:29:24.
52 seconds in line, 3 seconds of actual work.

post-processing gets its own bounded pool, the same fix #740 made when
album bundles were starving the per-track flow off this pool. still 3
workers, which is what post-processing could already reach on the shared
one, so this decouples the queue without raising concurrency.

four wiring points, and missing any one leaves the bug alive: the pool,
the monitor's headless submit, the browser-driven status submit, and the
shutdown list (a pool left out keeps its threads alive and the process
never exits). each is pinned by a test.

test_manual_pick_no_auto_retry patched only the shared pool and expected
post-processing to land there; test_infer_candidate_source imports
web_server at collection, which wires the real pool, so the fake saw
nothing. those now patch both.

verified: 13,605 full suite passed, 8/8 mutants killed, measured 33.6s to
0.0s on his scenario. peak simultaneous post-processing is 3 before and
after, so nothing downstream sees more concurrent file moves, probes or db
writes than it already could.

not fixed here, on purpose: max_concurrent still doesn't size the pool
(1-3 work, 4-10 do nothing); the post_processing stuck-detection deadlock
is still only reachable when a batch has orphaned tasks; orphans are still
never removed from batch['queue']; 12 modules under core/downloads still
log nowhere, which is why the lifecycle decisions were invisible in his
log.
…d see

setup_logging attaches the file handler to the "soulsync" logger. twelve
modules under core/downloads used logging.getLogger(__name__), which names
them "core.downloads.*" — outside that hierarchy, so their records never
reached the handler. WARNING and above still hit stderr through python's
fallback handler, so they showed up in docker logs; INFO went nowhere at
all.

either way they were absent from app.log, which is exactly what the in-app
debug export pastes into an issue. lifecycle.py was one of them, so every
[Completion Check V2] and [Stuck Detection V2] line was invisible. in
sokhi's stuck-downloads report healing triggered 18 completion checks and
not one result was readable — we were diagnosing his stall half blind.

all twelve now go through get_logger, the factory the rest of the codebase
already uses. verified end to end: 12/12 reach app.log where 0/12 did.

pinned as a rule rather than twelve individual fixes, so a module added to
this package later can't quietly rejoin the silent set.

known cost: app.log rotates at 10MB x 3, and twelve modules that wrote
nothing now write INFO, so history gets shorter. worth it to be able to
read a download problem at all.

this is one slice of a wider debt — 49 modules elsewhere (enrichment
workers, video, database) are still silent.
…atch

three things found while diagnosing sokhi's "stuck on processing" report.
none of them were his actual bug (that was post-processing queueing behind
searches, fixed in 7f48d00) — they came out of reading the code around it.

**Concurrent Downloads stopped meaning anything above 3.** the setting
offers 1,2,3,4,5,6,8,10 and is a PER BATCH limit, but the worker pool was a
hardwired max_workers=3. a batch would start 10 workers and 7 just sat in
the executor queue. measured: settings 4,5,6,8,10 all gave a peak of 3
running at once, while the help text promised "higher values speed up large
playlists and wishlists".

the pool is now sized from the setting. floor of 3 so a setting of 1 or 2
doesn't shrink the shared pool and serialize every OTHER batch — the
per-batch limit already handles that. ceiling of 16 because the pool is
process-wide and a hand-edited config shouldn't spawn hundreds. a fresh
install, the shipped default and any value below 3 all still come out at 3,
so nothing changes unless the user deliberately raised it. help text now
says it needs a restart, which is true — the pool is built at import.

slskd is not at risk: core/slskd_throttle.py already caps searches at 35
per 220s globally with a 429 cooldown, independent of thread count.

**a batch wedged in post_processing had no rescue.** the safety valve
covers searching/queued/downloading only. the 30-minute post_processing
timeout lives inside check_batch_completion_v2, which healing called only
when the batch had ORPHANS — and a batch whose remaining tasks are all
post_processing produces none. so nothing ever asked and it held its slots.
healing now also flags a task past the window and triggers the same check.
the timeout and the rescue itself stay defined once, in lifecycle; healing
only decides WHEN to ask.

**orphans were re-reported forever.** healing found finished tasks still in
batch['queue'] and never removed them, so the same ones came back every
30s — sokhi's log has the identical 13 across the whole window, burying the
real events. only newly-seen orphans are reported and acted on now.

they are still NOT removed from the queue, despite what the old comment
claimed: queue_index is a POSITION into that list, so shrinking it
underneath would make the batch skip or repeat tasks. there's a test
asserting no removal, with the reason.

verified: 13/13 mutants killed, targeted tests green, ruff clean. the
healing tests drive the real validate_and_heal_batch_states rather than
mirroring its logic. full suite was still running (video tail) when this
landed — follow-up if it turns anything up.
Two interacting defects caused progressive path corruption for
Navidrome users:

1. The fallback that fabricates a bare filename (e.g. "My Song.flac")
   when the Subsonic API omits the 'path' field fires during transient
   API gaps (Navidrome library rescans, network hiccups).  The resulting
   path has no directory structure and never matches anything on disk.

2. The UPDATE statement in insert_or_update_media_track overwrites
   file_path unconditionally — unlike file_size, track_artist, and
   musicbrainz_recording_id which are all protected by COALESCE.

Each scan corrupts a subset of tracks whose path was transiently absent,
and the damage accumulates with every subsequent scan.

Fix:
- Remove the bare-filename fallback; leave file_path as None so the
  COALESCE guard can protect the existing value.
- Wrap file_path in COALESCE(?, file_path) in the UPDATE statement,
  matching the existing pattern for the other nullable columns.
fix: prevent gradual file_path corruption during library scans
The Wing It Pool's "needs attention" list keys on `wing_it_fallback: true`
alone. That flag is only ever SET — no writer clears it — and
`update_mirrored_track_extra_data` merges rather than replaces, so a key the
writer omits keeps its previous value. Discovery re-runs on every sync, so a
track that wing-it'd once and matched for real on a later pass carries the flag
forever and is reported as an unverified guess at confidence 1.00.

On a live library that was 317 of 445 listed rows — 71% of the pool was tracks
that had already resolved, which is enough noise to make the surface unusable
for the 128 that genuinely needed a hand.

Clearing the flag on success is the obvious fix and the wrong one: "resolved"
(`wing_it_fallback` AND `manual_match`) deliberately relies on it surviving, so
clearing it would empty that list instead. Both meanings can coexist — the flag
stays the historical marker ("was ever a guess"), and "needs attention" tests
the thing that actually regenerates each pass: whether `matched_data` is still
a stub. Stub ids carry the `wing_it_` prefix under both id schemes; a real
match replaces `matched_data` wholesale, prefix included.

Retroactive by construction — it reclassifies existing rows on read, so no
migration is needed and libraries with years of stale flags are fixed on the
next page load.

Both predicates use `json_extract`, not a substring LIKE over the raw blob.
extra_data is written exclusively by `json.dumps` (the merge writer even
self-heals a corrupt existing value back to valid JSON), so this is always
well-formed, and json_extract's house idiom already exists elsewhere in this
file (`wishlist_tracks` category filtering). Scoping to `$.matched_data.id`
rather than matching `"id": "wing_it_..."` anywhere in the document is what
rules out a same-shaped key elsewhere ever being mistaken for the stub id — the
underscore-escaping the LIKE version needed becomes unnecessary for the two
boolean flags, since json_extract compares the decoded value, not a raw string.
A NULL/missing path degrades to "not a match", not an error.

A NULL/missing path degrades to "not a match", not an error — but a
MALFORMED value (garbage that isn't JSON at all, not just an absent key) makes
json_extract raise, and SQLite evaluates that for the whole statement, not the
one offending row. The old LIKE version degraded gracefully per-row; a naive
json_extract port would have regressed that to "one corrupt row blanks the
entire pool" through the surrounding try/except. json_valid(mpt.extra_data) as
the leading clause on both predicates is what keeps a bad row's failure scoped
to that row, same as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QtnYSwu5VVWtHGwD1Ga9w
… after

`canonical_source_track` recognizes a title stated twice ("晴る - Sunny",
"神様の神様 - Kamisamano Kamisama") and a channel-prefixed one ("Nicholas Shaw -
Castaway"). The sync, playlist and playlist-reconcile paths all call it. The
YouTube discovery worker — the path that decides whether a mirrored playlist
track resolves to a release at all, and so the path that creates the Wing It
stubs — is the one discovery worker that never did.

It scored and searched with `track['name']` verbatim through all four
strategies. Providers index the transliteration, so the raw restated string
scores against nothing and, worse, *searches* for nothing: strategies 1-4 all
build their query from the raw pair, so the right release is never in the
result set to be scored.

Two behavioural changes, both additive:

  * Each result set is now scored against the raw pair and, when
    canonicalization changed something, the canonical pair too — keeping the
    higher confidence. An over-eager strip can only add a comparison, never
    remove one.
  * Strategy 5 re-queries the provider with the canonical pair when 1-4 came up
    empty. Scoring alone cannot help when the query returned no candidates.

The best-of scoring is not new: `core/discovery/playlist.py` grew it as
`_canonical_best_score` for #785, and this worker needs exactly the same thing.
Rather than carry a second copy it moves to `core/discovery/canonical.py` as
`canonical_best_score`, with the rationale that was split across two call sites
collected in the module docstring. `playlist.py` keeps the old private name as
an alias, so its callers and tests are untouched.

Measured against the live mirror (2,063 tracks across 22 YouTube playlists):
29 of 69 unique Wing It stubs resolve at confidence 1.00 with this, including
every `原題 - Romanization` entry and the `(acoustic)` suffixed ones.

`_score_best`'s title/artist/duration_ms are bound as default arguments, not
read from the enclosing scope. It's redefined every loop iteration, and a plain
closure read would be a late-binding bug: Ruff flags it (B023), and it was
failing the sanity-check CI job.

`canonical_source_track` is unchanged and still conservative — it returns its
input untouched unless the decoration is demonstrable — so a plain title takes
no detour and issues no extra search.

Test coverage: the canonical pair is TWO independent values, and every
prior test happened to change only the title. Added a case where only the
artist canonicalizes (clean_source_artist strips "- Topic" from an
auto-generated channel name, title untouched) to prove Strategy 5 actually
propagates both halves rather than one of them by coincidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QtnYSwu5VVWtHGwD1Ga9w
`_discovery_score_candidates` enforces a 0.5 artist-similarity floor and
rejects a candidate on it BEFORE looking at the title. That is the right
guard, but it assumes the source's artist name and the provider's are
variations of one string. Often they are two different names for the same
artist: YouTube Music says "mgk", Deezer indexes "Machine Gun Kelly";
"Yorushika" and "ヨルシカ" are the same band. Similarity between those is
~0.30, so a title that matches at 1.00 is discarded and the track is written
off as a Wing It stub.

No cleaning fixes this — the strings share no substring. It needs data about
artist identity, and MusicBrainz already has it. `lookup_artist_aliases` has
existed since #442 (the AcoustID verifier's cross-script problem is this same
problem) with the resolution chain, the caching, and the trust gate that stops
a fuzzy near-miss from renaming an artist. It was simply never offered to
discovery.

So this is wiring, not new machinery: on a miss, ask for the artist's aliases
and retry the search and the scoring under each one, keeping the best result.
Scoring uses the alias as the source artist — scoring under the original name
would re-fail the floor that sent us here.

Measured against Deezer with the real scorer, six sampled mgk tracks go 0.00 →
0.99, while a control ("Machine Gun Kelly" + "Bohemian Rhapsody") stays
rejected. On the library that motivated this, 25 of one playlist's 77 stubs
are a single artist alias.

Cost is bounded by design. The lookup runs only after every existing strategy
has missed, so the common path pays nothing, and `lookup_artist_aliases`
caches its misses as well as its hits (library row → musicbrainz_cache → live
MB), so an artist costs one MB request ever. The dep is optional and the
lookup is best-effort: unwired, raising, or empty all degrade to exactly the
previous behaviour.

Also canonicalizes the source title before building the alias query and
scoring against it. File/CSV-imported playlists can keep a raw "Artist - Title"
source title (#785) — without this, an alias query for "mgk - sun to me" became
"Machine Gun Kelly mgk - sun to me", which the provider has nothing to match.
Uses the same `canonical_source_track` the file/CSV query-generation block
above already calls, just applied to the alias path too.

Also adds `get_musicbrainz_service()`. MusicBrainz allows one request per
second per client and the rate limiter lives on the instance, so every extra
instance is another way to exceed it — `core.acoustid_verification` and
`core.exports.export_sources` had each already grown a private singleton.
New callers now have one to share; the existing two are left alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QtnYSwu5VVWtHGwD1Ga9w
fix(wing-it): a stale flag is not an unresolved guess
fix(discovery): canonicalize YouTube titles before matching, not only after
wishx: "The ability to collapse the sidebar down to just the icons (perhaps
with a mouse-over with the name of the tab when collapsed) would save a lot of
screen real estate." he runs soulsync in a narrow wavebox tab where a 240px
static sidebar eats most of the width.

one toggle in the sidebar header, two states: full width or 68px of icons.
persisted, off by default.

the width had FIVE consumers and three sit OUTSIDE the sidebar, so they fail
silently rather than visibly: the audio visualiser is position:fixed at its
right edge (deliberately, to escape the overflow clip), the page-particles
canvas offsets from it with calc(100vw - ...), and the bulk-actions bar starts
at it. all read --sidebar-w now. a test scans for anything still offsetting
from a hardcoded 240 — it found .enhanced-bulk-bar while being written, which
the manual grep before it had missed.

the toggle also did visibly NOTHING at first: a desktop-only rule carried
.sidebar { min-width: 240px; max-width: 240px } and min-width pins a flex item
regardless of what width says. there's a test now that scans every .sidebar
rule for a hardcoded px in width/min-width/max-width/flex-basis.

collapsed, section headers become divider rules and their items are forced
visible. a chevron with no readable label is a dead control — you would click a
nameless arrow, items would vanish, and nothing would say what you hid. the
saved per-section state is untouched in localStorage and returns on expand.

the state is applied in <head>, before the stylesheet resolves --sidebar-w, or
the sidebar paints full width and snaps — a visible jump of the whole page on
every navigation. same reason the nav-section restore is inlined.

the music/video pill turns vertical with its sliding thumb turned with it;
hiding the thumb instead just loses the active indicator. its collapsed
geometry lives in video-side.css beside the base rules, because the thumb only
makes sense read together with the track it slides in.

support/version/service-status are hidden collapsed — all prose, no icon that
could stand in. mobile.css owns the drawer below 768px, so the whole thing
stands down there.

wishx also floated a middle "compact" tier; deliberately not built. one button,
one obvious effect.

31 tests, 16/16 mutants killed.
…1159)

AfonsoG6: "When searching for the song 'Would've, Could've, Should've' by
Taylor Swift, only other songs with the word 'Should've' appear. The commas may
be interfering somehow."

he was right that commas were involved, though not where he'd expect. the basic
LIKE handles them fine. it's the FUZZY fallback, which only runs when the basic
search found nothing (`if basic_rows: return`) — so it is exactly the path you
hit when the library's tag differs from the source title.

it split on whitespace and stripped only whitespace, leaving punctuation glued
to each token:

    "Would've, Could've, Should've"  ->  %would've,%  %could've,%  %should've%

%would've,% matches only a title that has the comma too. a library file tagged
WITHOUT the commas — which taggers and filesystems routinely do — therefore
matched on %should've% alone, scoring 1, the same as every unrelated song
carrying that one word. the tie fell back to alphabetical order and the real
track landed at rank 3, behind the noise. reproduced exactly.

trimming ends only, so internal punctuation survives — N.W.A, P!nk, AC/DC and
"pepper's" are real names and have to stay intact.

it cannot make anything worse: the column keeps its own punctuation
(unidecode_lower only lowercases), so a trimmed term matches everything the
untrimmed one did and more. it can raise a row's score, never lower it. there's
a test asserting no term ever loses a match, and four control queries that
already ranked first still do.

the artist half had the identical bug and my first pass missed it — mutation
testing caught that the tests only covered titles, while
search_library_candidates queries both. comma'd band names are not exotic
("Crosby, Stills & Nash").

this is ONE of the five issues in #1159. the manual-match reversion, the
failing "remove from playlist", the empty-body images and the matches that
don't stick are all still open.

23 tests, 6/6 mutants killed, 179 tests pass across every file touching the
changed search paths.
Nezreka and others added 18 commits August 17, 2026 11:24
…, live progress

four deezer fixes that landed together because they touch the same files.

**genre names came back in the server's language (#1157, PfannkuchenWolf).**
deezer localises genre NAMES by the caller's IP, so a german-hosted server got
"Filme/Videospiele" where 173 should read "Films/Games". verified against the
live api: Accept-Language overrides the geolocation outright, 11 of deezer's 28
genres translate, and album/artist/track payloads are byte-identical between en
and de — so pinning the language is not a blunt instrument. the deezer DOWNLOAD
client already sent this header; the metadata client was the odd one out.
auditing the hardcoded fallback map against the live list turned up a second
bug nobody reported: 464 is METAL, not 'Rap'. every album falling back to that
map with genre 464 was filed as rap.

**deezer playlists always read NEVER SYNCED (TheHomeGuy).** the endpoint
returned a hardcoded literal — the field was added "to match Spotify format"
but only ever carried the shape, never the value. the write side was always
fine. took two goes to get the key right: these cards are shimmed into
spotifyPlaylists as `deezer_arl_<id>` and startPlaylistSync posts that card id
to /api/sync/start, so the status lands under the PREFIXED id. the bare
`deezer_<id>` is a different engine. reading only the bare form compiles, runs,
and fixes nothing.

**one shared budget for every deezer caller.** there were three numbers and
none agreed. @rate_limited enforced 1 req/s on nine methods; the two playlist
album loops called session.get straight past it behind their own time.sleep,
obeying nothing and counted by nothing — the call tracker read ~0/min during
the exact operation hammering the api hardest. measured 2.07 req/s against a
declared 1.

deezer publishes no limit: their FAQ says only "there is no query quota" and
links to a page with no number, and the api sends no rate-limit headers. the
community figure is 50 per 5s and there is no other. there is no paid tier to
escape to either ("There is no paid API"). so core/deezer_throttle.py budgets
40 of the 50 — 8/s — modelled on core/slskd_throttle.py, which solves the same
problem for slskd.

an audit found ELEVEN bypassing call sites, not the two I started with: six in
the download client, the two album loops, and one-offs in soulid_worker,
service_search, web_server and connection_test — the last found BY the test
while it was being written. all 20 public call sites now go through it, with a
test that fails if a 21st appears. the download client got one _api_get()
helper rather than the throttle dance copy-pasted seven times, which is how
they drifted out in the first place.

two bugs in the throttle itself, both found by testing rather than reading:
scheduling every queued caller off _TIMES[0] gave them all the same deadline
(measured 15.8 req/s against a target of 8), and dropping the min-gap
slskd_throttle uses let bursts smear across a window boundary. now 8.00 req/s
at 1, 4 and 12 threads. conftest resets it between tests for the same reason it
resets the slskd one — unreset, 400 accumulated reservations make the next
caller wait 50 SECONDS, and the suite looks hung.

**loading a big playlist looked broken (TheHomeGuy).** "seems to hang... sit
here for several minutes doing nothing". nothing was hanging: resolving a
1200-track playlist means ~1,750 rate-limited requests for release dates and
real track numbers, and the spinner said only "Loading playlist: X...". the
work cannot go away — both are what the download picker uses to choose a
candidate and verify it — so the fix is to narrate it. the server emits on the
socket, core.js re-broadcasts as an ss: CustomEvent, and the card shows
"release dates 340/877 (39%)". decoration by construction: a callback that
throws, a dead socket or an older server all leave the load working.

webui/static/dist is gitignored, so the account-tabs change ships as source and
whoever deploys has to build.

verified: 47 targeted tests, 8/8 and 13/13 mutants on the two throttle passes,
ruff clean. full suite was still running when this landed.
feat(discovery): resolve artist aliases before giving up on a track
When discovery finds no catalogue match it stores a Wing It stub built from the
source's own artist/title, and the sync deliberately keeps those off the
wishlist — a stub is a guess, and the Wing It Pool
(`MusicDatabase.get_wing_it_pool`) is the surface for resolving guesses by hand.

That works while the pool is small. On a mirrored YouTube library it is not: on
my install 22 playlists produced 185 distinct stubs, which is more than anyone
will work through by hand, and they are invisible to acquisition until someone
does. Probing them against the provider directly, 77 of the 185 are findable —
they are guesses, but they are mostly RIGHT guesses.

New `wishlist.wing_it_guesses`, OFF by default, so behavior is byte-for-byte
today's unless it is turned on. When on, a stub is wishlisted like any other
track — but only when the source gave a real artist AND title. A stub the
source left nameless ("Unknown Artist") stays out either way, which is the case
the original "wing-it tracks have no real metadata" rule was actually about.

All four sites that drop stubs now share one predicate, `should_wishlist_stub`,
so they cannot drift apart:

  * services/sync_service.py       — the sync-time add
  * core/sync/wishlist_readd.py    — the re-add path, which must agree with the
                                     sync or it stores something the sync skipped
  * core/downloads/wishlist_failed.py — failed-download re-add

The batch-level Wing It *mode* in wishlist_failed.py is a different feature and
is untouched.

Also removes the `wing_it_` early return in `record_failed_attempt`. It was
correct while stubs could never be on the wishlist, but a stub that reaches the
wishlist and is not stamped keeps retry_count at 0 forever — so retry_backoff
never escalates it and every cycle burns a fresh slskd search on a track that
has failed for months, the exact waste that module exists to stop. Stamping is
unconditional now; an id with no wishlist row simply updates nothing, which is
why the existing guard test still passes (its comment was updated — it now
holds for a different reason).
CodeRabbit review on PR #3, two findings:
- sync_service.py passed the raw spotify_track.artists[0] entry (a
  {'name': ...} dict on some endpoints) straight into should_wishlist_stub.
  A dict stringifies past the placeholder-name check instead of matching
  'unknown artist'. Route it through _artist_name() first.
- wishlist_failed.py used track_name's 'Track {i+1}' logging fallback as
  the title for the same predicate — a synthetic placeholder that isn't
  in the placeholder set, so a genuinely nameless stub read as searchable.
  Use track_data['name'] (the real source title, no fallback) instead.
- wishlist_guesses_enabled() used bool(config_manager.get(...)), so the
  string "false" (the most likely value someone would type by hand)
  evaluated truthy. Require the actual bool True.
CodeRabbit review, round 2, the most significant finding: both
sync_service's _skip_unmatched_wishlist (driven by is_wing_it) and
wishlist_failed.py's batch.get('wing_it') early return bypassed the
per-track is_stub_id()/should_wishlist_stub() gate entirely — so
wishlist.wing_it_guesses had NO effect for Wing It Sync retries or
Wing It download batches, exactly the flows most likely to touch
wing-it stubs. organize-by-playlist (skip_wishlist_add) is a separate
reason and keeps its blanket skip; only the wing-it-specific bypass
is removed.
… resort

`generate_download_queries` preserves version decoration on purpose —
priorities 1 and 2 both check the dash content and the parenthesized content
against a keyword list and keep the query faithful when it looks like a version
rather than an album name. That is the right default: a search for a specific
cut should not quietly return a different one.

But the preservation is absolute. Every rung of the ladder carries the suffix,
so when no peer happens to share that exact edition, the search has nowhere
left to go and the track never resolves at all. In a live wishlist that is what
entries pinned at high retry counts look like — "Sweet Dreams (Are Made of
This) [2005 Remaster]", "KAZENO LONELY WAY (2022 Remaster)" — tracks that are
trivially available in some other edition.

Priority 5 adds the broad, version-stripped query at the END of the ladder.
Ordering is what keeps it safe: it is only reached once every version-faithful
variant has already come up empty, so a wrong-edition result can never displace
a right-edition one.

## Scope: edition only, never a performance

`_VERSION_TOKENS` covers edition/master decoration only — remaster, mono,
stereo, anniversary, deluxe, expanded, reissue, tv size — never a performance
marker (live, remix, acoustic, radio edit, extended mix...). That boundary is
load-bearing, not a style choice: the matcher's own version gate
(`calculate_slskd_match_confidence`) rejects a CANDIDATE's version against the
source, and a plain studio file classifies as 'original', which is never
rejected. So a query stripped down to "Song" for a source titled "Song (Live)"
would happily return and download the studio cut — a silent performance
substitution. An edition strip can only change which pressing you get, never
which take.

## Two real gaps CodeRabbit's first review on #16 found in the mechanism

The token-list narrowing above was correct but not sufficient on its own —
the stripping logic still had two ways to violate it:

  * The bracket/dash-tail check only required an edition token to appear
    SOMEWHERE in the decoration, not that the whole decoration was edition
    content. "(Live at the Deluxe Anniversary Tour)" contains "deluxe" and
    "anniversary" — and used to have "live" stripped right along with them,
    reintroducing exactly the wrong-performance risk the token narrowing was
    built to prevent. Fixed by requiring the full decoration to reduce to
    edition tokens, numbers/ordinals, and light stopwords once matched tokens
    are removed, and by explicitly rejecting anything containing a
    performance-marker word even alongside a genuine edition token. This also
    closes a related false-positive: "(Stereo Hearts)" is real title text, not
    a mono/stereo mix note, and no longer gets nuked just because "stereo"
    appears as a substring.
  * The dash-tail parser only recognized an ASCII hyphen and rejected a tail
    containing its own hyphen, so "the WORLD - TV-Size" and an en/em-dash
    separator ("Nevermind – 30th Anniversary Remaster") never reached the
    fallback at all. Fixed: any of -/–/— now splits, and the tail is captured
    to the end of the string rather than up to the next hyphen.

## Two more from round 2, both in the fixes above

  * `_VERSION_TOKENS` iteration order matters for the whole-content residual
    check: 'remaster' is a prefix of 'remastered', and removing it first left
    a stray "ed" the check didn't recognize as a stopword/number, so
    "Song [2005 Remastered]" was wrongly left untouched. Tokens are now
    replaced longest-first.
  * The artist-qualified Priority 5 candidate was built as
    `f"{artist} {stripped_clean}".strip()` unconditionally. A punctuation-only
    source artist ("...", "- - -") cleans to '', and stripping the leading
    space silently turned that "qualified" candidate into the UNQUALIFIED
    broadcast form — bypassing the distinctiveness guard entirely.
    Reproduced end-to-end: `("Kid A (2009 Remaster)", ["..."])` generated the
    bare 5-character query `"kid a"`, exactly the #1102 NAT-exhaustion shape
    the guard exists to stop. Now only added when there's an actual artist to
    qualify it with.

## Round 3, two more — both real, both narrower than the last two

  * The dash-tail check required whitespace around the separator, so an
    unspaced en/em-dash ("Nevermind\u201330th Anniversary Remaster") never
    reached the fallback. Fixed by trying every -/\u2013/\u2014 position
    RIGHT TO LEFT and taking the first whose trailing segment is
    edition-only, rather than a single regex. That resolves the ambiguity a
    naive whitespace-optional regex would hit for free: "the WORLD-TV-Size"
    has a candidate split inside "TV-Size" itself (tail "Size"), which fails
    the edition-only check and falls through to the real separator, whose
    tail "TV-Size" is a recognized token.
  * `generate_download_queries` early-returns before Priority 5 entirely when
    `spotify_track.artists` is empty. CodeRabbit's observation was correct,
    but the fix it produced turned out to be wrong on inspection: the
    minimum artist gate in `calculate_slskd_match_confidence` rejects every
    non-YouTube candidate with `artist_score < 0.25`, and with no source
    artist the loop that computes `artist_score` never runs, so it is
    unconditionally 0.0. A search fired from that branch can NEVER produce
    an accepted result — pure network cost for a mathematically guaranteed
    rejection, worse than doing nothing. Also 0 of 669 real wishlist tracks
    have an empty artist list, so this was a theoretical corner case, not a
    live one. Reverted; the early return is correct as written, now with a
    comment explaining why extending it would be actively harmful, not just
    unnecessary.

## Round 4: a real gap in code this PR never touched

CodeRabbit flagged that a DASH-based edition tag ("Song - Deluxe", no
parens) never got a faithful query at all — traced it and the claim held.
Two PRE-EXISTING heuristics, both predating this PR, share the same blind
spot: `detect_album_in_title`'s `exclude_patterns` and a separate
`preserve_keywords` list in Priority 2's own dash-handling. Neither list
knew about edition words, so "Deluxe"/"Mono"/"Anniversary" (this PR's own
_VERSION_TOKENS) got silently classified as an "album name" and stripped
by Priority 1/2 — before Priority 5 ever got a chance to try the faithful
query first. Priority 5's own artist-qualified candidate then just
duplicated the already-stripped Priority 1 output, contributing nothing.
The bracket form ("Song (Deluxe)") was unaffected — this was dash-specific.

Fixed by extending both existing heuristics to also recognize
`self._VERSION_TOKENS` (reusing the one canonical list rather than adding a
third copy) — safe in this direction, since being over-inclusive in what
these two "don't strip yet" checks preserve can only mean falling through
to a later priority, never a wrong download. Verified: "Song - Live" and
"Song - Deluxe Live" (performance markers, alone or mixed with an edition
word) still correctly get NO stripped fallback at all — the safety boundary
from rounds 1-3 holds.

## Measured against a real, live backlog

Ran this against an actual production wishlist (660 tracks stuck at retry >=
2): 11 carried edition decoration. Tested live against the real Soulseek
network with the production scorer (search only, no downloads): 5 of 11
rescued outright at >=0.7 confidence (0.91-0.99), 1 borderline at 0.60, 5 had
no supply on the network under any query. Every rescued track's query was
confirmed unreachable by the existing faithful-query ladder — genuine
incremental reach, not overlap.

`_strip_version_decoration` returns '' rather than emit a bare-artist query
when stripping would leave nothing, and the unqualified (title-only) Priority
5 variant clears the same distinctiveness bar as the existing short-title
broadcast guard (#1102) — stripping only makes a title shorter, so without
this it would regenerate exactly the broadcasts that guard exists to stop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QtnYSwu5VVWtHGwD1Ga9w
…s load (#1159)

- cached auto-matches (confidence < 1.0) no longer pin the compare view
  or shadow the user's saved manual match
- replace-track now persists the pairing like find & add does, so a
  corrected bad match survives the next compare and sync
- jellyfin edits re-resolve stale playlist ids (delete-recreate) and
  return the fresh id, same as plex always did
- jellyfin thumbs go through the token-safe image proxy instead of a
  raw unauthenticated url that answered 200 with an empty body
the server tab could edit a playlist every way except delete it. new
'remove from server' pill next to export m3u — confirms, deletes the
playlist on plex/jellyfin/navidrome (soulsync-made or not), closes the
editor and drops back to the refreshed list.

same id-first name-fallback shape as the rest of the edit family:
plex and jellyfin delete-recreate on edit, so a stale page id gets
re-resolved by name instead of 404ing with the live playlist standing.
fix(matching): give the download query ladder a version-stripped last resort
…elease date

'2026-07-20' aged out of the scanner's now-relative 30-day lookback a month
after it was written, so both blind-provider tests went red on a day nothing
changed (first seen on #1147's ci run). the fixture now releases 'yesterday',
which is inside any lookback the test will ever configure.
config.settings silently disabled transcode and catalog auth on a clean
tree. Matcher-rejected catalog hits now fall through to ytsearch, and
expired cookies are dropped only on the last download retry.
Keep mixed-source YouTube ranking and catalog ytsearch fallback, and
take per-item quality profiles and preferred-version ordering from dev.
feat(wishlist): optionally search unverified Wing It guesses
feat(youtube): Improve quality matching and support YT Premium formats
…k in Processing

the verification wrapper pops task_id/batch_id out of the context before
the inner pipeline runs, so the inner quality/audio-guard branch's requeue
and failed-marking always saw task_id=None and silently did nothing. the
wrapper then returned on the claim the inner had handled it, and the task
sat in post_processing until stuck detection reaped it at 1800s, wedging a
download slot for half an hour per quarantine (sokhi's 'stuck on
processing', 8x in one log). the wrapper now does requeue-first
fail-second itself with the real ids, same stash-and-apply pattern
_mark_task_quarantined already used for the entry id.
… slskd

marking them failed only resolved OUR bundle — slskd kept the enqueue
alive, so the file sat at 'queued, remotely' for hours after the guard
logged it was handled (sassmastawillis, slskd 0.26), and a per-track retry
picking the same peer+file collided with the zombie enqueue instead of
issuing a fresh request. cancel+remove each stalled transfer, same as the
monitor's retry path.
@Nezreka
Nezreka merged commit f77e76b into main Aug 19, 2026
2 checks passed
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.

3 participants