Conversation
…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.
…, 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
… never pass with a folder selected
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
soulsync 3.2.2:
dev→mainfive 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.
structured artist/album metadata and matching works the same shape as tidal
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
forces tidal and youtube through the P2P matcher, and a rejected youtube
profile no longer wipes the rest of the pool
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
\bedit\blived in the remix patterns and remix was checked first, so "radioedit" 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
(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
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
never pass with a music folder selected
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
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)
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
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
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
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
(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
returned a hardcoded literal — the field matched spotify's shape but never
carried a value. sync ran, tracks downloaded, the card never updated
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%)"
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
(AfonsoG6). the fuzzy fallback split on whitespace only, so "Would've,
Could've, Should've" asked the database for
%would've,%— matching only atitle that has the comma too. a file tagged without them matched one word and
ranked third behind unrelated songs
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
(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
(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
(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
Franklin). the hint was only ever read by the auto-import worker
without a reload. 25 now, both refresh on a timer
community PRs
when the subsonic api omits
path, the code fabricated a bare filename andthe 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
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
(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
"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
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
(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+
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.