Commit graph

26 commits

Author SHA1 Message Date
BoulderBadgeDad
577bba30aa Playlists: all-owned trigger makes batch dict authoritative + logs when nothing rebuilt 2026-06-12 17:34:06 -07:00
BoulderBadgeDad
4d7267e906 Playlists: reconcile rebuilds touched playlists from the LIBRARY, not task fields
The reconcile read each completed task's final_file_path to find paths — but not
every import path sets it (the verification worker marks the task completed
without it), so tracks that imported via that path were silently dropped (user
saw 3 of 5 symlinks). Root cause: leaning on a fragile per-task field.

Now reconcile_batch_playlists identifies the organize playlists the batch touched
(its own + any reached via a completed track's source_info provenance) and
rebuilds each from CURRENT library ownership via _rebuild_one_from_db
(check_track_exists over membership). It just asks the library what's owned, so
it's robust to HOW a track imported (modal worker / slskd monitor / verification
worker) and still prunes tracks that left. Takes a db handle; all three callers
pass MusicDatabase().

Reconcile tests rewritten for the DB-rebuild form (organize batch, wishlist
provenance, non-organize skip, plain no-op). 973 downloads/imports/playlist
tests pass.
2026-06-12 17:08:27 -07:00
BoulderBadgeDad
997e76b6b4 Playlists: one path-independent reconcile after every batch (closes wishlist gap)
Replaces the two organize-only triggers with a single reconcile_batch_playlists
called at both batch-completion points. It groups the batch's newly-resolved
tracks by their per-track playlist provenance:
  - the batch's OWN organize playlist → full (re)build with prune, and
  - a track that completed for a DIFFERENT playlist (e.g. a WISHLIST fulfilling a
    track that belongs to an organize playlist) → ADDED to that folder, no prune.

So a late wishlist arrival now lands in its playlist folder immediately, instead
of only on the next sync/manual rebuild — the folder = the playlist's owned
members, kept true on every ownership change regardless of download path. Uses
the paths the batch already captured (no DB re-match, no waiting on the server
scan/sync). Non-fatal.

3 new reconcile tests (organize full-rebuild, wishlist add-without-prune, plain
batch no-op). 983 downloads/imports/playlist tests pass.
2026-06-12 15:30:47 -07:00
BoulderBadgeDad
aa5d747327 Playlists: wire materialize triggers + retire per-track routing flag
- Routing (step 5): organize-by-playlist tracks no longer set the per-track
  _playlist_folder_mode flag, so they import NORMALLY into Artist/Album — exactly
  what a normal download does. _playlist_name provenance is kept (origin.py).
- Triggers (step 4): build the playlist folder from the batch's own payload at
  both end-of-flow points — the all-owned path in master.py (no downloads, so the
  lifecycle never runs) and the batch-complete hook in lifecycle.py (after
  downloads). Both gated on playlist_folder_mode, both non-fatal.

Works for the all-owned case (the smack test that did nothing before) and for
mixed owned/downloaded, with no source-ID or mirrored-playlist dependency. The
materialized folder uses the default ./Playlists root + symlink mode until the
Settings UI is added.

Updated the master test to assert the new contract (provenance kept, routing
flag gone). 979 tests pass.
2026-06-12 13:37:59 -07:00
BoulderBadgeDad
37431ea82b Downloads: additively surface each track's real file path (analysis + completed)
The download analysis already matches every track to a library row via
check_track_exists / manual match, then discarded the result. Keep it: each
analysis_results entry now carries matched_file_path + matched_track_id (the
owned file's real location, or None). Symmetrically, a completed download task
now records final_file_path (where the import landed).

Purely additive, no behavior change, no new matching, zero perf cost — just
stops throwing away what the pipeline already computed. This is the foundation
for playlist materialization: owned + downloaded tracks both report where their
real file is, so the folder can be built by name match, not source IDs.
2026-06-12 13:28:26 -07:00
BoulderBadgeDad
8d133ecd60 Wishlist: serialize album-bundle downloads so they stop flooding the search pool (Sokhi #740)
Sokhi: "downloads searching for way too many tracks at once" — a wishlist run
that fanned out into ~one batch per album. Verified the actual search/download
concurrency IS capped at 3 (single shared missing_download_executor), so it
wasn't really hammering slskd — but the display showed ~20 "searching" and the
batch list was a mess.

Root cause: run_full_missing_tracks_process was supposed to "block its album-pool
worker for the whole search+download" (that's what the dedicated album_bundle_
executor is for), but it RETURNED the instant it had STARTED the downloads. So
the album pool only throttled the fast analysis phase — every album batch blew
through analysis and immediately dumped its tracks into the shared download pool,
all pre-marked 'searching'. The intended serialization never happened.

Fix: add serialize= to run_full_missing_tracks_process. Album-bundle batches
(dispatched on album_bundle_executor) pass serialize=True and now hold their pool
slot via _wait_for_batch_drain() until every task in the batch reaches a terminal
state — so only ~N albums are in flight at once. The wait is passive (downloads
are driven by the monitor + completion callbacks on other threads, so no
deadlock) and bails on shutdown, a removed batch, or a safety cap. The residual /
playlist / manual paths run on the SHARED pool and pass serialize=False (blocking
there would steal a real download worker), so they're unchanged.

Tests: _wait_for_batch_drain returns immediately when all-terminal, waits until
tasks finish, bails on shutdown, respects the cap, handles a missing batch. 975
download/wishlist tests pass (only the pre-existing soundcloud /app failures).
2026-06-09 10:06:53 -07:00
BoulderBadgeDad
8ee59c7453 Blocklist Phase 2b: gate manual downloads with a "download anyway?" confirm
Closes the last acquisition gap — user-initiated downloads. A blocklist isn't
a censor, so search + discography stay fully visible; instead the download
ACTION is gated, visibly and overridably:

- Download modal (start-missing-process): an up-front check — if the WHOLE
  album or artist being downloaded is blocklisted, return 409 {blocked:true}
  with the entity, before starting a batch. The modal shows "X is blocklisted
  — download anyway?" and re-POSTs with ignore_blocklist:true on confirm
  (threaded onto the batch so the Phase 2a per-track filter skips it).
  Scattered single-track bans still fall through to the 2a filter quietly.
- Manual /api/download (search-result download): source-file-centric, so it
  matches the blocked ARTIST by name; same 409 + confirm + override. search.js
  now sends artist/title so the guard has something to match.
- Precedence confirmed: force-download overrides "already owned", NOT a ban
  (the 2a filter runs on the force-expanded missing list).

Frontend: shared confirmBlockedDownload() helper; modal + search callers
handle the blocked response and retry with the override.

Tests: manual download blocked-by-name / unrelated-allowed / override-passes,
and the modal up-front 409 for a blocked album. 8 blocklist API tests pass.
2026-06-07 16:15:23 -07:00
BoulderBadgeDad
45badf588c Blocklist Phase 2a: gate the download queue (playlist sync / album / discography)
Phase 1 guarded the wishlist; Phase 2a closes the other auto-acquisition path.
Playlist sync, album download, and discography backfill all flow through
run_full_missing_tracks_process, which queues missing tracks at one point —
right where the explicit-content filter already drops tracks. The blocklist
filter slots in beside it: each missing track is checked and a banned
artist/album/track is dropped before queueing (logged with a count), so a
blocked item can't slip in via these flows.

Same brain as Phase 1: the wishlist guard's matcher is generalized to
db.blocklist_reason_for_track(profile_id, track_data, source=None) — the new
`source` param lets the queue path supply the batch source, since an analysis
track dict may not carry a 'provider' field (artists still match by name
fallback regardless). One method, two callers (wishlist + queue), one cascade.

Manual single-track downloads (/api/download, candidate picker, redownload)
are deliberately NOT gated here — that's Phase 2b, pending a block-vs-warn-vs-
override policy decision.

Tests: source-fallback isolation (album id-only proves source drives the ID
match; artist name still matches sourceless), and a queue-filter simulation
mirroring master.py. 35 blocklist tests pass (the only failures in the
download family are the pre-existing soundcloud /app ones).
2026-06-07 15:49:59 -07:00
BoulderBadgeDad
1f7834cc7b Download Origins: see (and delete) exactly what watchlist + playlist syncs downloaded
User ask: "a modal that lists the tracks downloaded via watchlist" — extended,
as discussed, to playlists too. One modal, two tabs, opened from the Watchlist
page (watchlist tab preselected) and the Sync page (playlists tab) — same
shared-modal-different-entry-points UX as the rest of the app.

The data: library_history recorded which SERVICE a file came from but never
what TRIGGERED it. New origin/origin_context columns (migration + index) are
written once at the import chokepoint via core/downloads/origin.py, a pure
tested deriver that reads, in priority: an explicit _dl_origin stamp (set at
batch-task creation for direct playlist batches, where the playlist context
otherwise only survived in folder mode), the wishlist provenance already
riding in track_info.source_info (watchlist_artist_name / playlist_name —
watchlist_scanner has stamped these for ages), and the folder-mode playlist
thread. Manual downloads stay unclassified by design. History starts from
now — provenance can't be conjured retroactively.

API: GET /api/download-origins?origin=watchlist|playlist (paged) and POST
/api/download-origins/delete — deletes the file on disk (resolved through the
shared container/host path resolver), the matching library track row, and the
history entries; a file that refuses deletion keeps its row and reports the
error instead of lying.

UI: webui/static/origin-history.js — tabbed modal in the revamp design
language (accent light-edge, pill tabs, entry rows reusing the
library-history-entry components), per-row delete + select-all bulk delete
with honest result toasts, empty/loading states, per-tab totals.

Tests: 8 — deriver priority/shapes (incl. the exact watchlist_scanner
source_info shape and JSON-string survival), origin filtering + counts,
row fetch/delete isolation between origins, delete-track-by-path.
2026-06-07 00:15:31 -07:00
BoulderBadgeDad
2604704a27 #797: stop AcoustID quarantining correct non-English-artist downloads
AcoustID returns a recording's title/artist in their ORIGINAL script
(e.g. "久石譲" for Joe Hisaishi) while SoulSync's expected metadata is
romanized/English. A correct download then fails verification on two
walls: the title can never clear the 0.70 similarity bar cross-script,
and the only skip path that ignores the title required a near-perfect
0.95 fingerprint plus a resolved alias. Result: every non-English
artist trips it. Two complementary fixes, per the reporter's two ideas.

Graceful fix (automatic):
- New pure core/matching/script_compat.py detects when two strings are
  in genuinely different writing systems (CJK/Hangul/Cyrillic/Greek/
  Arabic/Hebrew/Thai vs Latin). Accented Latin (Beyoncé, Sigur Rós)
  stays Latin — no false trigger.
- acoustid_verification.py: when the EXPECTED artist and the matched
  artist span scripts AND the artist is confirmed via the existing
  MusicBrainz alias bridge, SKIP instead of quarantine, without the
  0.95 floor (the 0.80 trust floor already gates the fingerprint).
- Deliberately narrow: keyed on the ARTIST spanning scripts + being
  confirmed. A same-script artist with only a cross-script title keeps
  the stricter 0.95 floor, so the #607 wrong-file protection (Kendrick
  R.O.T.C, low-fingerprint Japanese-title) is untouched.

Per-request toggle (manual escape hatch):
- New "Skip AcoustID verification" checkbox in the download-missing
  modal beside "Force Download All".
- skip_acoustid threads request -> batch -> per-track track_info ->
  download context (same path as _playlist_folder_mode), landing on
  the existing _skip_quarantine_check='acoustid' bypass. No new
  mechanism; only the AcoustID gate is bypassed (integrity/bit-depth
  still run).

Tests:
- tests/matching/test_script_compat.py — script-boundary cases.
- test_acoustid_skip_logic.py — Joe Hisaishi SKIPs at 0.85; unconfirmed
  cross-script artist still FAILs; same-script low-fingerprint still
  FAILs.
- test_downloads_candidates.py — toggle injects the bypass; absent
  toggle keeps verification.

Full suite: 5169 passed; only pre-existing soundcloud /app env failures
remain. Zero regressions.
2026-06-05 16:02:01 -07:00
BoulderBadgeDad
a977d28144 Fix #780: Deezer/non-Spotify organize-by-playlist resolved the wrong row
resolve_mirrored_playlist tried the mirrored-playlists primary key FIRST for
any all-digit ref. Deezer upstream ids are all-numeric, so a Deezer playlist id
was mistaken for the PK and the organize-by-playlist toggle resolved a wrong row
(or nothing) — the toggle silently wouldn't save / 'Open in Mirrored' missed.

Resolve by (source, source_playlist_id) first, fall back to PK only when the
source lookup misses. Thread the batch/wishlist source through the download-path
callers so numeric upstream ids resolve correctly there too. Spotify (base62
ids) is unaffected.

Seam tests: numeric Deezer id resolves by source (not PK), spotify alphanumeric
by source, PK fallback still works, profile-scoped, empty refs -> None.
2026-06-03 20:41:04 -07:00
Francesco Durighetto
9ff2e7084a Fix organize-by-playlist downloads: library entries, wishlist, and stale Spotify cache
Persist organize_by_playlist on mirrored playlists and run playlist-folder
downloads from the auto-sync pipeline instead of the global wishlist phase.
Register SoulSync library rows after playlist-folder post-processing, route
failed organize batches to the wishlist correctly, and skip sync-time
unmatched wishlist only when organize download handles retries.

Invalidate stale playlist track caches on refresh (Spotify and Deezer ARL),
re-mirror on refetch, and improve standalone playlist modals (re-analysis,
Open in Mirrored). Add filesystem missing-track detection and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 10:26:32 +02:00
Broque Thomas
a7ca7ddfad Harden album bundle fallback flow
Delay torrent and usenet album-bundle dispatch until missing-track analysis confirms there is work to do, matching the Soulseek album flow and avoiding release downloads for already-owned albums.

Clear private album-bundle staging state when a release-level source intentionally falls back to per-track mode so workers can use the normal staging/search path instead of an empty private bundle directory.

Verified by user: focused downloads master tests passed, 2 passed.
2026-05-24 16:15:36 -07:00
Broque Thomas
6c226613bf Add Soulseek album bundle downloads
Route primary Soulseek album downloads through the album-bundle staging flow, reusing preflight-selected folders when available. In hybrid mode, only the first configured source can claim whole-album bundle behavior so later Soulseek fallback keeps the existing per-track/source-reuse path.

Allow Soulseek album bundles to stage completed tracks when some same-source transfers fail or time out, and keep partial bundles from blocking per-track fallback. Add coverage for dispatcher gating, master flow ordering, task-worker staged-miss behavior, and Soulseek bundle polling.
2026-05-23 15:08:21 -07:00
Broque Thomas
ad59bf05a1 refactor(downloads): lift album-bundle gate into its own module
Per code review: ~90 lines of inline gate logic in
``run_full_missing_tracks_process`` was inflating an already-580-
line worker function and was non-testable in isolation. Lifted to
``core/downloads/album_bundle_dispatch.py`` with two entry points:

- ``is_eligible(mode, is_album, album_name, artist_name)`` — pure
  predicate, no side effects, easy to assert against. Splits the
  gate decision from the resolution + run step so tests can pin
  the gate semantics without standing up a plugin.
- ``try_dispatch(...)`` — full flow. Returns True iff the master
  worker should stop (gate fired + failed); False = engaged-and-
  succeeded OR didn't engage, both fall through to per-track.

State access is now decoupled from ``runtime_state``:
- New ``BatchStateAccess`` Protocol with two methods
  (``update_fields``, ``mark_failed``).
- Concrete impl ``_BatchStateAccessImpl`` lives in master.py and
  wraps the tasks_lock + dict ops the original inline code did.
- Injected via parameter so the dispatch module never imports
  ``download_batches`` / ``tasks_lock`` directly.

Same goes for the plugin resolver and config getter — both
injected, so the dispatcher works against any orchestrator /
config implementation (including the in-test fakes).

Behavior unchanged. The master worker call site is now 11 lines
of boilerplate instead of 90 lines of inline conditional. Plugin
contract (``download_album_to_staging`` return dict shape)
unchanged.

- core/downloads/album_bundle_dispatch.py: new module owning the
  gate + execution. ~150 lines including docstrings and the
  Protocol definition.
- core/downloads/master.py: gate call site shrunk to a single
  ``if _album_bundle_dispatch.try_dispatch(...): return``. New
  ``_BatchStateAccessImpl`` class implements the Protocol against
  the existing ``download_batches`` dict + ``tasks_lock`` so the
  dispatcher gets injected access instead of importing them.
- tests/test_album_bundle_dispatch.py: 16 new tests covering the
  pure predicate (album-required, mode allowlist, name validation,
  case insensitivity), the resolver-failure fall-through
  (plugin missing, plugin lacks method, resolver raises), the
  success path returning False so per-track flows, the failure
  path returning True with state.mark_failed called, plugin-raise
  treated as a normal failure, whitespace stripping on names,
  and progress-callback mirroring lifecycle events into batch
  state.
2026-05-20 20:29:50 -07:00
Broque Thomas
c990ce079d feat(downloads): album-bundle flow for torrent/usenet single-source mode
Fixes the core architectural mismatch between indexer-based sources
and the per-track search-and-pick contract every other download
plugin satisfies. Prowlarr returns release-level torrents and NZBs;
searching for "Luther (with SZA)" against the GNX album torrent
scores near-zero on track-title similarity. Per-track candidate
validation rejects every result, every track in the batch flips
to not_found. The album-name fallback added in an earlier commit
papers over it for some cases but doesn't fix the fundamental
behavior: the user wanted the whole album.

New album-bundle flow does what the user actually wanted:
1. Gate fires inside core/downloads/master.py BEFORE the per-track
   analysis loop, strictly when the batch has an album context AND
   download_source.mode is 'torrent' or 'usenet' (single-source —
   hybrid stays per-track to preserve fallback to Soulseek / etc.).
2. Plugin's new download_album_to_staging method searches Prowlarr
   ONCE for the album as a whole ('<artist> <album>'), filters to
   the right protocol, runs results through _pick_best_album_release.
3. Picker prefers seeded FLAC over low-seeded MP3, drops single-
   track torrents that snuck in via the 40 MB size floor (single
   tracks are typically ~10 MB), falls back to most-seeded when
   every candidate is below the floor.
4. Picked release goes to the active adapter (qBit / Transmission /
   Deluge for torrent; SAB / NZBGet for usenet). Polls until
   complete with progress mirrored into the batch state so the
   Downloads page can show meaningful status.
5. On completion the existing archive_pipeline walks the save dir
   (extracting archives if any), every audio file gets copied into
   the staging folder via _unique_staging_path so concurrent batches
   don't collide.
6. Gate exits, master worker continues into the normal per-track
   flow. Each track task hits try_staging_match early in the worker
   and finds its file by fuzzy title match — no Prowlarr search
   ever fires per-track, no candidate rejection, files flow through
   the existing post-processing pipeline (tags, AcoustID, library
   import).

Gate is strictly opt-in. Three orthogonal conditions must all hold:
batch_is_album, mode in ('torrent', 'usenet'), and the plugin must
expose download_album_to_staging. Any other source / hybrid mode /
non-album batch flows through the master worker unchanged. The
existing per-track torrent path still works for basic-search
single-track grabs.

- core/download_plugins/torrent.py: download_album_to_staging plus
  _pick_best_album_release and _unique_staging_path helpers (shared
  with the usenet plugin). _poll_album_download mirrors the existing
  poll loop with progress callback emission.
- core/download_plugins/usenet.py: parallel implementation reusing
  the picker + staging helpers. Different state set ('failed' vs
  'error') from the usenet adapter contract.
- core/downloads/master.py: ~90-line gate right after batch context
  loading. Mirrors plugin lifecycle into batch state under
  ``album_bundle_*`` keys so the Downloads page can render progress
  while the torrent/usenet job runs (per-track tasks don't exist
  yet during this phase). Failed bundle download fails the batch
  with a meaningful error; missing plugin / context falls back to
  the per-track flow with a warning.
- tests/test_torrent_usenet_plugins.py: 5 new tests pinning the
  album picker preferences (FLAC over MP3 with comparable size +
  better seeders, size floor drops singles, fallback when all
  small), staging-path collision suffix, and the not-configured
  short-circuit.
2026-05-20 18:48:48 -07:00
Broque Thomas
aaf312cd34 Honor manual library matches across source labels
Manual matches can be created from sync history as mirrored while wishlist and download flows later see the same track as wishlist or a provider source. Add a shared track-level lookup that falls back from exact source/id to source_track_id and title/artist, then use it for wishlist adds, cleanup, and download analysis so mapped tracks are not re-added or redownloaded.

Add coverage for mirrored-source matches being honored by wishlist cleanup and download batches, including the internal wishlist force-download path.
2026-05-18 16:32:38 -07:00
Broque Thomas
42f4aa5eac Add manual library track matching 2026-05-17 20:27:05 -07:00
Broque Thomas
076cf9e516 Improve Soulseek album source selection
Add a conservative Soulseek album preflight scorer so album downloads choose a coherent slskd folder before per-track enqueue. The scorer compares album title, artist, year, track count, tracklist coverage, peer quality, and penalizes unexpected deluxe/remix/live-style folders.

Preserve hybrid source priority by only running Soulseek album preflight when Soulseek is the selected source or first in the hybrid order. If Soulseek is only a fallback behind another source, the normal hybrid flow is left alone.

Reuse the richest wishlist album context across tracks in the same album group so release date, artwork, album type, and album artist stay consistent for path generation. Also preserve peer-quality tie breakers when attempting equal-confidence candidates.

Tests cover correct-folder selection over larger wrong editions, Soulseek primary vs fallback hybrid behavior, shared wishlist album context, and peer-quality candidate ordering.
2026-05-17 10:08:43 -07:00
Broque Thomas
8a3bb88678 Fix AcoustID quarantine and disc_number crash on Amazon album downloads
AcoustID verification was quarantining every Amazon track because T2Tunes
embeds [Explicit] and [feat. X] in stream tag titles/artists, but AcoustID
returns bare titles — triggering version-mismatch rejection on every track.

- get_track_details: apply _strip_edition to name/album, _primary_artist to
  artist; wire s.track_number / s.disc_number instead of hardcoded None
- get_album_tracks: apply _strip_edition to name, _primary_artist to artist

Also fix TypeError crash in album download paths when disc_number is None
(present in dict but explicitly None, so .get('disc_number', 1) returns None):
- master.py run_full_missing_tracks_process: or 1 guard on both max() and disc_num
- candidates.py track_info extraction: or 1 guard on both disc_number reads
- web_server.py enhanced + standard album download max() calls: or 1 guard
2026-05-16 16:01:11 -07:00
Broque Thomas
e7ecaca3fd Fix MTV Unplugged & live-album false-quarantine pipeline
Closes #589. Tracks from MTV Unplugged / Live At / unplugged albums
consistently failed AcoustID verification with "Version mismatch:
expected (live) but file is (original)". Two upstream bugs fed into
the false positive — the AcoustID gate itself was correctly catching
the wrong file Tidal had selected. Codex diagnosed all three layers,
this fixes the two upstream causes and leaves the verifier alone.

Bug 1 — album-scoped library check false-misses owned albums

`core/downloads/master.py:184` scored "Shy Away (MTV Unplugged Live)"
(source title from playlist) vs "Shy Away" (local DB stored title)
with raw string similarity. Massive length asymmetry → ~0.3 → below
the 0.7 threshold → marked missing. Combined with the
`allow_duplicates and batch_is_album` short-circuit that disables
the global fallback for album downloads, the user's already-owned
album re-triggered every track for download. Explains the screenshot
showing "0 found / 7 missing" on an album the user manually placed.

New pure helper `core/matching/album_context_title.py:strip_redundant_album_suffix`
strips trailing parenthetical / bracket / dash suffixes whose tokens
are fully subsumed by the album context — at least one version
marker (live / unplugged / acoustic / session / concert / tour)
overlapping with the album, and every other token is either a
known marker, a year, a tolerated noise word, or a word from the
album title. Album-context-implied "live" added when the album
mentions unplugged / concert / tour / session.

Wired into the album-confirmed scope ONLY (not global matching).
Compares both raw and normalized source titles per album track and
takes the max similarity, so the helper returning the input
unchanged (when album doesn't imply version context) preserves
the pre-fix behavior.

Bug 2 — Tidal qualifier filter only ran on fallback searches

`core/tidal_download_client.py:345` set `is_fallback = attempt_idx > 0`
and only filtered when `is_fallback and required_qualifiers`. Primary
search returned all results unfiltered, so a query for "Shy Away
(MTV Unplugged Live)" could accept the studio cut if Tidal happened
to rank it first. Now the qualifier filter applies to BOTH primary
and fallback search attempts — log message updated to indicate
which path triggered.

Bug 3 — qualifier check ignored album.name

The legacy `_track_name_contains_qualifiers` only inspected the
track name. For concert / unplugged releases the live signal
typically lives in the album title, not the track title. New
`_track_matches_qualifiers` accepts a track object and inspects
both `track.name` AND `track.album.name`. Legacy helper preserved
to keep its existing test contract.

AcoustID version-mismatch gate at core/acoustid_verification.py
left intact — it correctly catches genuinely-wrong files that slip
through upstream filters. The In My Feelings (Instrumental) test
that pins this behavior continues to pass.

19 tests on the album-context helper covering MTV Unplugged
variants, dash/parens/brackets suffix shapes, year tolerance,
plural-form markers, the implied-live set, anti-regression cases
(instrumental/remix on a studio album must NOT be stripped),
empty/none defensive paths.

13 tests on the Tidal qualifier helper covering legacy
track-name-only behavior preserved, qualifier in track name alone,
qualifier in album name alone (the MTV Unplugged scenario),
multi-qualifier requirements, no-qualifiers always passes,
defensive against missing track.album, word-boundary avoiding
substring false-matches, _extract_qualifiers picking up live +
unplugged from the user's exact reporter query.

Full suite: 3053 passed.
2026-05-14 12:14:31 -07:00
Broque Thomas
aa54bed818 Surface silent exceptions across remaining modules — ~70 sites
Final sweep. Covers:
- Downloads: candidates / lifecycle / master / monitor / wishlist_failed
- Metadata: source / registry / cache / common / artwork (+ plex_client)
- Imports: pipeline / resolution / file_ops / paths / guards
- Library: path_resolver / retag / duplicate_cleaner
- Stats / playlists / wishlist / discovery / automation / enrichment
- Misc: hydrabase_client, soulsync_client, tag_writer, debug_info,
  api_call_tracker, album_consistency, beatport_unified_scraper,
  reorganize_runner, seasonal_discovery, lidarr_download_client,
  services/sync_service.py, automation_engine, automation/progress

Two `_e` renames in imports/file_ops.py (outer scope binding `e`).
A few finally-block sites in metadata/album_mbid_cache.py,
library/track_identity.py, listening_stats_worker.py, watchlist/
auto_scan.py left silent — same reason as the rest of the sweep
(logger calls during cleanup paths can themselves raise).

Refs #369
2026-05-07 10:28:58 -07:00
Broque Thomas
61ba3a15de Cin-6: Rename soulseek_client global → download_orchestrator
The global handle in web_server.py was named soulseek_client for
historical reasons but the type has long been DownloadOrchestrator,
not SoulseekClient. Renamed the global plus every parameter/attribute
that carried the legacy name.

- web_server.py: global var renamed; all 99 references updated.
- api/, core/downloads/*, core/search/*, core/streaming/*,
  services/sync_service.py: parameter names, dataclass fields, and
  init() arg names renamed.
- Test fixtures (CandidatesDeps, MasterDeps, SearchDeps, etc.) and
  the _build_deps helpers updated accordingly.

The core.soulseek_client module path and SoulseekClient class name
(the actual soulseek-only client) are unchanged — only the orchestrator
handle renamed. Module imports of TrackResult/AlbumResult/DownloadStatus
from core.soulseek_client preserved.
2026-05-04 23:23:32 -07:00
Broque Thomas
7519c3d50c Cin-5: Drop per-source attrs from orchestrator
Removed the eight backward-compat attribute aliases on the orchestrator
(soulseek, youtube, tidal, qobuz, hifi, deezer_dl, lidarr, soundcloud).
External callers and the orchestrator's own internals now reach clients
through the generic alias-aware client(name) accessor.

- core/downloads/{master,monitor,validation}.py: migrated to client().
  Monitor's per-source aggregation loop replaced with a single
  engine.get_all_downloads() call.
- core/search/{orchestrator,stream}.py: migrated; stream.py drops the
  hand-built mode-to-client dict.
- web_server.py: migrated /api/deezer/arl-* + tidal client lookup.
- core/download_orchestrator.py: internal self.soulseek /
  self.deezer_dl reaches now route through self.client(); attr
  assignments dropped from __init__; module docstring updated.
- Test fakes (_FakeSoulseek, _FakeSoulseekWithYT) expose client(name)
  instead of stuffing per-source attributes.
- Conformance test re-pinned to the client() accessor contract.
2026-05-04 23:14:05 -07:00
Broque Thomas
1fb2f34955 PR4h fixup: restore strict 1:1 in master.py
Three drifts caught in line-by-line review against original — all reverted
so the lifted body is now byte-identical to the web_server.py original
(after deps.X → global X normalization):

1. `import traceback` was hoisted to module-level imports. Original imports
   it inline inside the except block. Moved back to inline (same behavior,
   but strict location parity).
2. Trailing whitespace on the blank line after analysis_results.append.
3. Trailing whitespace on the blank line inside the wishlist except handler.

Diff against original now produces ZERO differences. Tests still 21/21
passing, ruff clean.
2026-04-28 10:39:52 -07:00
Broque Thomas
fa29ee2195 PR4h: lift _run_full_missing_tracks_process to core/downloads/master.py
Final extraction in the download orchestrator series. Lifts the 586-line
master worker that drives the entire missing-tracks pipeline from
`web_server.py` into `core/downloads/master.py`. Pure 1:1 lift — wrappers
keep the original entry-point name so the three callers
(`missing_download_executor.submit(_run_full_missing_tracks_process, ...)`)
continue to work without changes.

What the master worker does:
1. PHASE 1 ANALYSIS — per-track DB ownership check with album fast path
   (lookup album by name+artist, match tracks within it) plus a
   MusicBrainz release-cache preflight so per-track post-processing all
   uses the same release MBID (prevents Navidrome album splits).
2. Wishlist removal for tracks already in the library.
3. Explicit-content filter.
4. PHASE 2 transition — if nothing missing, mark batch complete, update
   per-source playlist phases, kick auto-wishlist completion handler.
5. Soulseek album pre-flight — search for a complete album folder before
   falling back to track-by-track search, cache the source for reuse.
6. Wishlist album grouping — derive per-album disc counts and resolve ONE
   artist context per album so collab albums don't fold-split.
7. Task creation with explicit album/artist context injection +
   playlist-folder-mode flag propagation.
8. Hand off to download_monitor + start_next_batch_of_downloads.
9. Error handler — phase=error, reset YouTube playlist phase to
   'discovered', reset auto-wishlist globals on auto-initiated batches.

Dependencies injected via `MasterDeps` (21 fields) — wide surface
covering config, MB caches/locks, soulseek client, source-page state
dicts, multiple callbacks (wishlist removal, explicit filter, executor
+ auto-completion fn, monitor, start_next_batch). The only behaviour
difference from a pure paste is `import traceback` hoisted to module
scope (was inline in the except block) — same behaviour. Trailing
whitespace on two blank lines also got normalized away by the editor;
neither has any runtime effect.

`reset_wishlist_auto_processing` callback wraps the
`global wishlist_auto_processing, wishlist_auto_processing_timestamp`
write + `wishlist_timer_lock` since `global` can't reach back into
web_server.py from a separate module.

Tests: 21 new under tests/downloads/test_downloads_master.py covering
analysis-phase state, force_download_all, found-track wishlist removal,
explicit filter, no-missing complete + per-source state updates, auto
wishlist completion submit, album fast path (direct + fallthrough),
MB preflight (caches both keys, no-mb-worker no-op), task creation
(queue + tasks dict, explicit context for albums, wishlist album
grouping consistency, playlist folder mode), monitor + next-batch
handoff, multi-disc total_discs computation, error handler (phase set,
youtube reset, auto wishlist reset), and batch-removed-mid-flight
defensive path.

Full suite: 1050 passing (was 1029). Ruff clean.

End of the PR4 series — `web_server.py` lost ~590 lines on this commit
alone; total trim across PR4a–PR4h is ~2900 lines of orchestrator code
moved into focused `core/downloads/*.py` modules.
2026-04-28 10:34:13 -07:00