Commit graph

1157 commits

Author SHA1 Message Date
Broque Thomas
91978656a5 Lift enhance_artist_quality to core/artists/quality.py
Pulls the 284-line artist quality enhancement helper out of
`web_server.py` into a new `core/artists/` package. Flask route handler
split: route + request parsing stay in web_server.py, the body lifts to
a pure function returning `(payload_dict, http_status_code)`.

What `enhance_artist_quality` does:

1. Validate request: track_ids must be non-empty, artist must exist.
2. Build a `track_lookup` from `database.get_artist_full_detail` so each
   selected track resolves with its album context.
3. Per track:
   - Read current quality tier from the file extension.
   - Build `matched_track_data` for the wishlist entry, in priority
     order:
     - Spotify direct lookup via stored `spotify_track_id` (preferred).
       Uses raw API data when available; otherwise rebuilds the payload
       and pulls album images via a follow-up `get_album` call.
     - Spotify search fallback using matching_engine queries with
       artist+title similarity scoring (album-type bonus for albums,
       smaller bonus for EPs). Stops at first >= 0.9 confidence match.
     - iTunes/fallback source search with the same scoring shape.
   - Add to wishlist via `wishlist_service.add_spotify_track_to_wishlist`
     with `source_type='enhance'` and a `source_context` carrying the
     original file path, format tier, bitrate, original_tier, and
     artist_name.
   - Tally `enhanced_count` / `failed_count` / per-track failure reasons.
4. Return `{success, enhanced_count, failed_count, failed_tracks}` 200.

Dependencies injected via `ArtistQualityDeps` (7 fields) — spotify_client,
matching_engine, get_database, get_wishlist_service,
get_current_profile_id, get_quality_tier_from_extension,
get_metadata_fallback_client.

Diff vs original after `deps.X` → global X normalization is **1 line of
cosmetic drift** — the success return now uses an explicit `(payload, 200)`
tuple to keep all returns shape-consistent for the wrapper. Flask treats
`jsonify(x)` and `(jsonify(x), 200)` identically. 284 lines orig = 285
lines lifted, body otherwise byte-identical.

Tests: 10 new under tests/artists/test_quality.py covering input
validation (empty track_ids, artist not found), Spotify direct lookup
via raw_data, Spotify direct lookup with enhanced format requiring
album image rebuild, Spotify search fallback, iTunes/fallback source
match path, track-not-found and no-file-path failure modes, complete
no-match failure, and source_context payload assertions (enhance flag,
file path, format tier, bitrate, source_type).

Full suite: 1340 passing (was 1330). Ruff clean.
2026-04-29 10:05:57 -07:00
Broque Thomas
3a6597561a Lift _execute_retag to core/library/retag.py
Pulls the 258-line retag worker out of `web_server.py` into a new
`core/library/` package. Pure 1:1 lift — wrapper keeps the original
entry-point name so the retag-trigger endpoint continues to work
without changes.

What `execute_retag` does:

1. Fetch album + track metadata for the new `album_id` (Spotify or
   iTunes — the Spotify client transparently falls back).
2. Load existing files in the retag group from the DB.
3. Match each existing track to a new Spotify track:
   - Priority 1: same disc + track number.
   - Priority 2: title similarity >= 0.6 (SequenceMatcher).
4. For each matched pair:
   - Re-write metadata tags via `_enhance_file_metadata`.
   - Compute the new path via `_build_final_path_for_track` and move
     the audio file (plus .lrc / .txt sidecars) if the path changes.
   - Drop an orphaned cover.jpg if it's left in an empty directory.
   - Clean up empty parent directories left behind.
   - Download the new cover art into the new album dir.
5. Update the retag group record with new artist / album / image /
   total_tracks / release_date and the appropriate Spotify-or-iTunes
   album ID (numeric → iTunes, alphanumeric → Spotify).
6. Mark the retag state 'finished' (or 'error' on exception).

Strict 1:1 byte parity:
The original mutated `retag_state` as a module global (the function
declared `global retag_state` even though it only mutates in place).
Here `retag_state` is exposed through the `RetagDeps` proxy as a Python
property so the lifted body keeps `name[key] = value` /
`name.update(...)` syntax. The property setter rebinds the
web_server.py reference if the function ever reassigns it (currently
it doesn't, but the setter is wired for parity with the watchlist lift).

Diff vs original after `deps.X` → global X normalization is **zero
differences** apart from the dropped `global retag_state` decl and the
inline `from database.music_database import get_database` (replaced by
deps.get_database()). 258 lines orig = 258 lines lifted, byte-identical
body otherwise.

Dependencies injected via `RetagDeps` (13 fields) — config_manager,
retag_lock, spotify_client, plus 8 callable helpers
(get_audio_quality_string, enhance_file_metadata,
build_final_path_for_track, safe_move_file, cleanup_empty_directories,
download_cover_art, docker_resolve_path, get_database) and 2 property
delegates (_get_retag_state / _set_retag_state).

Tests: 11 new under tests/library/test_retag.py covering setup error
paths (no album data, no album tracks, no existing tracks),
track-number priority match, title-similarity fallback, no-match skip,
missing file skip, file move when path changes, group record update
(spotify vs iTunes ID branching by alphanumeric vs numeric album_id),
multi-disc total_discs computation.

Full suite: 1330 passing (was 1319). Ruff clean.
2026-04-29 09:03:42 -07:00
Broque Thomas
2b2003ba4c Lift _process_watchlist_scan_automatically to core/watchlist/auto_scan.py
Pulls the 390-line watchlist auto-scan orchestrator out of `web_server.py`
into a new `core/watchlist/` package. Watchlist (followed-artists scanner
that finds new releases) is a separate domain from kettui's wishlist
(failed-download retry queue), so this lift does not overlap with the
ongoing PR400-style extractions.

What `process_watchlist_scan_automatically` does:

1. Smart stuck-detection guard before acquiring the timer lock —
   prevents deadlock when a previous scan flag is dangling past the
   2-hour timeout.
2. Inside the timer lock: re-check + set the active scan flag with the
   current timestamp.
3. Per-profile expansion (or single-profile when manually triggered):
   - Watchlist count check + Spotify auth gate.
   - Backfill missing artist images.
4. Initialize a fresh `watchlist_scan_state` dict (the deps property
   setter rebinds the web_server.py module-level name so external
   sentinel checks via id() comparison still detect the swap).
5. Pause enrichment workers, then call
   `WatchlistScanner.scan_watchlist_artists` with a per-event progress
   callback that translates scanner events into automation log lines.
6. Post-scan steps (skipped if the scan was cancelled mid-flight):
   - Populate discovery pool from similar artists (per-profile).
   - Refresh ListenBrainz playlists.
   - Update current seasonal playlist (weekly cadence).
   - Generate Last.fm radio playlists.
   - Sync Spotify library cache.
   - Activity feed entry + automation_engine.emit('watchlist_scan_completed').
7. On exception: mark state['status']='error', re-raise so the
   automation wrapper records the failure.
8. Finally: resume enrichment workers, clear the scanner's rescan
   cutoff, reset the auto-scanning flag.

Strict 1:1 byte parity:
The original mutated `watchlist_auto_scanning`,
`watchlist_auto_scanning_timestamp`, and `watchlist_scan_state` as
module globals (with a leading `global` decl). Here those names are
exposed through the `WatchlistAutoScanDeps` proxy as Python properties
so the lifted body keeps the same `name = value` / `name[key] = value`
shape. Property setters fan writes back to web_server.py via callback
pairs.

Diff vs original after `deps.X` → global X normalization is **zero
differences** apart from the dropped `global` declaration line — Python
doesn't need it once the names are property accesses on the deps object.
390 lines orig = 390 lines lifted, byte-identical body otherwise.

Dependencies injected via `WatchlistAutoScanDeps` (15 fields total) —
Flask app, spotify_client, automation_engine, watchlist_timer_lock, plus
5 callable helpers and 6 property delegate callbacks (paired
get/set for each of the three globals).

Tests: 11 new under tests/watchlist/test_auto_scan.py covering
stuck-detection guard, race-check inside lock, zero-watchlist short-
circuit, unauthenticated Spotify gate, successful scan with all post-
scan steps, automation event emission, activity feed logging,
cancellation mid-scan skipping post-steps, profile-scoped trigger,
flag reset in finally, rescan cutoff clear in finally.

Full suite: 1319 passing (was 1308). Ruff clean.
2026-04-29 08:17:41 -07:00
Antti Kettunen
e6c2bee427
Move profile Spotify cache into registry
- let core.metadata.registry own per-profile Spotify client caching
- register the DB-backed profile credentials provider from web_server.py
- invalidate only the affected profile cache entry on save, delete, and auth
2026-04-29 12:36:37 +03:00
Antti Kettunen
11be8834eb
Use metadata registry for web_server clients
- make web_server.py read and refresh Spotify from core.metadata.registry
- add single-key metadata cache eviction for Spotify reauth
- export the new cache helper through the metadata package shims
2026-04-29 12:27:59 +03:00
Broque Thomas
a2e068eaba Lift _try_staging_match to core/downloads/staging.py
Pulls the 201-line staging-folder shortcut out of `web_server.py` into
its own module under the existing `core/downloads/` package. Pure 1:1
lift — wrapper keeps the original entry-point name so the task worker's
existing call site continues to work without changes.

What `try_staging_match` does:

1. Pull the per-batch staging-file cache (one filesystem scan per batch).
2. For each staging entry, compute title + artist similarity using
   SequenceMatcher and the matching engine's `normalize_string`. Require
   title >= 0.80, then a combined score >= 0.75. The weighting flips
   based on whether artist info is available on both sides:
   - both have artist: 0.55*title + 0.45*artist
   - either side missing artist: 0.80*title + 0.20*artist (lean on title)
3. Copy the matched file to the configured transfer dir (with a
   "_staging" suffix when the destination filename already exists, to
   avoid overwriting a legitimate prior download).
4. Mark the task as 'post_processing', username='staging',
   staging_match=True.
5. Build a synthetic spotify_artist / spotify_album context (mirroring
   the modal-worker logic so the file-organization template applies
   cleanly) and store it under "staging_<task_id>". Two paths:
   - Explicit context branch (track_info has _is_explicit_album_download)
     → real album/artist data copied through.
   - Fallback branch → synthesized from track + track_info, with
     `is_album_download` heuristically derived (album differs from title
     and isn't "Unknown Album").
6. Hand off to `_post_process_matched_download_with_verification` which
   does tagging, path building, AcoustID verification, and DB insertion.

Returns True if the staging shortcut won; False to fall through to the
normal Soulseek search path.

Dependencies injected via `StagingDeps` (5 fields) — config_manager,
matching_engine, get_staging_file_cache, docker_resolve_path,
post_process_matched_download_with_verification.

Diff vs original after `deps.X` → global X normalization is **zero
differences** — 201 lines orig = 201 lines lifted, byte-identical body
(including all whitespace, comments, log strings, and the inline
`from difflib import SequenceMatcher` / `import shutil` imports inside
the function body).

Tests: 9 new under tests/downloads/test_downloads_staging.py covering
no staging files / no track title / low-confidence match returning
False, exact match copying file + transitioning task state + invoking
post-processing, existing-file rename via `_staging` suffix, explicit
album context branch, fallback context synthesis (with both album-as-
album and album-equals-title cases), and copy failure (missing source
file) returning False.

Full suite: 1308 passing (was 1299). Ruff clean.
2026-04-28 23:26:15 -07:00
Broque Thomas
793593de51 Lift _run_tidal_discovery_worker to core/discovery/tidal.py
Missed worker from the PR5 discovery-workers series — Tidal sits in the
same domain as the deezer / spotify_public / listenbrainz / youtube /
beatport workers that were lifted in PR5b–PR5h, follows the same shape,
shares the same `_search_spotify_for_tidal_track` helper, and was simply
overlooked in the original inventory.

Pure 1:1 lift of the 212-line worker. Wrapper keeps the original
entry-point name so the existing call sites in web_server.py continue
to work without changes.

What `run_tidal_discovery_worker` does:

1. Pause enrichment workers (release shared resources).
2. For each Tidal track:
   - Cancellation gate (state['cancelled']).
   - Discovery cache lookup; cache hit short-circuits the search.
   - SimpleNamespace-style track passed straight to
     `_search_spotify_for_tidal_track` (the shared helper used by every
     worker in this family).
   - On Spotify match: build `match_data` preserving track_number /
     disc_number from raw API data, image extracted from album images
     or track object fallback, release_date filled from
     track.release_date when album dict is missing it.
   - On iTunes match: dict result populated as `match_data` with source
     set to discovery_source, image extracted from album images.
   - Save matched result to discovery cache.
   - On miss: Wing It stub stored as 'wing-it' status (success ticked).
3. After all tracks: phase='discovered', activity feed entry, sync
   discovery results back to mirrored playlist via
   `_sync_discovery_results_to_mirrored` with 'tidal' tag.
4. On error: state['phase']='error' + status with error string.
5. Finally: resume enrichment workers.

Dependencies injected via `TidalDiscoveryDeps` (13 fields) —
tidal_discovery_states, spotify_client, plus 11 callable helpers
(pause/resume enrichment, get_active_discovery_source,
get_metadata_fallback_client, get_discovery_cache_key, get_database,
validate_discovery_cache_artist, search_spotify_for_tidal_track,
build_discovery_wing_it_stub, add_activity_item,
sync_discovery_results_to_mirrored). Same surface as the deezer worker.

Diff vs original after `deps.X` → global X normalization is **zero
differences** — 212 lines orig = 212 lines lifted, byte-identical body
(including all whitespace, comments, log strings).

Tests: 9 new under tests/discovery/test_discovery_tidal.py covering
cache hit short-circuit, Spotify tuple match (track/disc preservation),
iTunes dict match path, Wing It fallback, cancellation, completion
phase update, activity feed entry, mirrored sync invocation, per-track
error handling.

Full suite: 1299 passing (was 1290). Ruff clean.
2026-04-28 23:02:24 -07:00
Antti Kettunen
a759f778b6
Move metadata API into package
- add package-owned metadata API, cache, registry, and lookup modules
- keep legacy metadata_service and metadata_cache paths as explicit shims
- update metadata call sites and tests to use package-owned helpers
2026-04-29 08:10:18 +03:00
Broque Thomas
1c43ca2eef PR6: lift _attempt_download_with_candidates to core/downloads/candidates.py
First lift in the new PR6 batch. Pulls the 312-line candidate-fallback
download dispatcher out of `web_server.py` into a new module under the
existing `core/downloads/` package. Pure 1:1 lift — wrapper keeps the
original entry-point name so all callers (search/match pipeline) work
unchanged.

What `attempt_download_with_candidates` does:

1. Sort candidates by descending confidence.
2. For each candidate:
   - Cancellation gates (3 points: top of loop, before download starts,
     after download_id is assigned).
   - Skip already-tried sources via the per-task `used_sources` set.
   - Skip blacklisted sources (user-flagged bad matches).
   - Race protection: bail when the task already has an active
     download_id.
   - `update_task_status('downloading')`, then `soulseek_client.download`.
3. On a successful download_id:
   - Build `matched_downloads_context` entry keyed by
     `make_context_key(username, filename)`.
   - For tracks with clean Spotify metadata, pull track_number /
     disc_number from (1) track_info → (2) track object → (3) Spotify
     API call. When local album context is incomplete, the API response
     backfills release_date / album_type / total_tracks / images / id.
   - Set `is_album_download` based on explicit context flag or
     heuristic (album differs from title, isn't "Unknown Album").
   - Store task/batch IDs and track_info on the context for post-
     processing + playlist-folder mode.
4. On a cancellation that wins the race after the download started:
   - `cancel_download(...)` to stop the in-flight Soulseek transfer.
   - `on_download_completed(batch_id, task_id, success=False)` to free
     the worker slot.
5. On exception or download-start failure: reset task status to
   'searching', continue to next candidate.

Dependencies injected via `CandidatesDeps` (7 fields) — soulseek_client,
spotify_client, run_async, get_database, update_task_status,
make_context_key, on_download_completed.

Diff vs original after `deps.X` → global X normalization is **zero
differences** — 312 lines orig = 312 lines lifted, byte-identical body
(including all whitespace, comments, log strings).

Tests: 14 new under tests/downloads/test_downloads_candidates.py
covering happy path (first candidate succeeds, confidence ordering),
used_sources dedup, blacklist skip, cancellation gates (cancelled
status, deleted task, active download_id, mid-flight cancel + cleanup
callback), failure paths (all candidates failed, exception during
download falls through to next), context payload (explicit album
context, track_number priority order, API backfill of incomplete album
metadata), and equal-confidence stable order.

Pre-existing behavior documented in tests:
`spotify_album_context['id']` initializes to a non-empty placeholder
'from_sync_modal' in the fallback path, so the API-backfill condition
`if not spotify_album_context.get('id')` never fires for the id field
specifically. Other album fields (release_date, album_type) backfill
fine because they default to empty.

Full suite: 1290 passing (was 1276). Ruff clean.
2026-04-28 22:06:33 -07:00
BoulderBadgeDad
e504099439
Merge pull request #393 from elmerohueso/hifi-fixes
fix HIFI downloads to get LOSSLESS and HI_RES again
2026-04-28 21:06:41 -07:00
Broque Thomas
8bb0459345 fix: drop dead inline copies of wishlist removal helpers shadowing imports
PR400 added imports for `check_and_remove_from_wishlist` and
`check_and_remove_track_from_wishlist_by_metadata` from
`core.wishlist.resolution` (aliased with leading underscores in
web_server.py) but left the original inline definitions of those
functions in place at L17139 and L17243. Python's later definition
wins, so the local defs were silently shadowing the imports — meaning
the new package versions were never actually called from web_server.py.
Ruff caught the redefinition (F811) and broke CI.

Deleted the inline definitions (176 lines). Imports at L143-144 now
serve all callers, and the package functions in
`core/wishlist/resolution.py` are actually exercised. Behavior is the
same: I diffed both versions before deleting and confirmed they're
functionally equivalent.

Tests: 1232 passing (no change). Ruff clean.
2026-04-28 20:40:27 -07:00
BoulderBadgeDad
8019e13a2e
Merge pull request #400 from kettui/refactor/extract-wishlist-code
Extract wishlist core logic from web_server.py
2026-04-28 20:02:39 -07:00
elmerohueso
7f94597706 validate hifi instance reorder against pre-existing instances 2026-04-28 20:19:01 -06:00
elmerohueso
ef3790d146 change hifi instance DELETE to use query string 2026-04-28 20:19:01 -06:00
elmerohueso
788b7011d0 fix hifi instance reorder and enable/disable 2026-04-28 20:19:00 -06:00
elmerohueso
7c2edeb16a add admin decorators to new hifi endpoints as necessary 2026-04-28 20:19:00 -06:00
elmerohueso
cf7fdb0eae fix Tidal and Deezer to show correct status 2026-04-28 20:19:00 -06:00
elmerohueso
6ae1cb471e user-editable hifi instances 2026-04-28 20:19:00 -06:00
Broque Thomas
a38bfcba55 PR5h: lift _run_quality_scanner to core/discovery/quality_scanner.py
Final lift in the PR5 discovery-workers series. Pulls the 328-line
library quality scanner out of `web_server.py` into its own focused
module under `core/discovery/`. Pure 1:1 lift — wrapper keeps the
original entry-point name.

What the quality scanner does:

1. Reset scanner state (counters, results), load quality profile +
   minimum acceptable tier from QUALITY_TIERS.
2. Load tracks from DB based on scope:
   - 'watchlist' → tracks for watchlisted artists only.
   - other → all library tracks.
3. For each track:
   - Stop-request gate (state['status'] != 'running').
   - Quality-tier check via _get_quality_tier_from_extension(file_path).
   - Skip tracks meeting standards (tier_num <= min_acceptable_tier).
   - For low-quality tracks: matching_engine search query gen, score
     candidates against Spotify (artist + title similarity, album-type
     bonus), pick best match >= 0.7 confidence.
   - On match: add full Spotify track to wishlist via
     `wishlist_service.add_spotify_track_to_wishlist` with
     source_type='quality_scanner' and a source_context that captures
     original file_path, format tier, bitrate, and match confidence.
4. After all tracks: status='finished', progress=100, activity feed
   entry, emit `quality_scan_completed` event for automation engine.
5. On critical exception: status='error', error message captured.

Wishlist service interaction is via the public
`add_spotify_track_to_wishlist` API only — no overlap with kettui's
planned `core/wishlist/` package extraction (the import lives inside
the function, exactly as in the original, and will follow whatever
path that package takes).

Dependencies injected via `QualityScannerDeps` (8 fields) —
quality_scanner_state dict, quality_scanner_lock, QUALITY_TIERS
constant, spotify_client, matching_engine, automation_engine, plus 2
callable helpers (get_quality_tier_from_extension, add_activity_item).

Diff vs original after `deps.X` → global X normalization is **zero
differences** — 328 lines orig = 328 lines lifted, byte-identical body
(including all whitespace, comments, log strings, and the inline
`from core.wishlist_service import get_wishlist_service` /
`from database.music_database import MusicDatabase` imports at the
top of the function).

Tests: 11 new under tests/discovery/test_discovery_quality_scanner.py
covering state init/reset, no-watchlist-artists short-circuit,
unauthenticated Spotify error, high-quality skip, low-quality search
trigger, match → wishlist add (with full source_context payload),
no-match no-add, mid-loop stop request, completion phase + progress,
automation engine event emission, all-library scope load.

Full suite: 1152 passing (was 1141). Ruff clean.

End of the PR5 series — `web_server.py` lost ~328 lines on this commit
alone; total trim across PR5a–PR5h is ~2,400 lines of discovery worker
code moved into focused `core/discovery/*.py` modules. The remaining
discovery-adjacent worker `_process_watchlist_scan_automatically` was
deliberately deferred to avoid overlap with kettui's planned wishlist
extraction.
2026-04-28 18:41:29 -07:00
Broque Thomas
c9108ef2fe PR5g: lift _run_listenbrainz_discovery_worker to core/discovery/listenbrainz.py
Seventh lift in the PR5 discovery-workers series. Pulls the 286-line
ListenBrainz discovery worker out of `web_server.py` into its own
focused module under `core/discovery/`. Pure 1:1 lift — wrapper keeps
the original entry-point name.

What the ListenBrainz discovery worker does:

1. Pause enrichment workers (release shared resources).
2. For each ListenBrainz track:
   - Cancellation gate (state['phase'] != 'discovering').
   - Discovery cache lookup; cache hit short-circuits the search.
   - Strategy 1: matching_engine search queries with confidence scoring
     against Spotify (preferred) or iTunes (fallback).
   - Strategy 2: swapped artist/title query.
   - Strategy 3: album-based query (uses album_name when available —
     unique to LB, since YouTube tracks don't have album metadata).
   - Strategy 4: extended search with limit=50.
   - On match → save to discovery cache with image extracted from album
     images or matched_track.image_url fallback.
   - On miss → Wing It stub stored as 'wing-it' status.
3. After all tracks: phase='discovered', status='complete', activity feed
   entry mentioning 'ListenBrainz Discovery Complete'.
4. On error: state['status']='error', phase='fresh'.
5. Finally: resume enrichment workers.

Dependencies injected via `ListenbrainzDiscoveryDeps` (16 fields) —
listenbrainz_playlist_states, spotify_client, matching_engine, plus 13
callable helpers (pause/resume enrichment, get_active_discovery_source,
get_metadata_fallback_client, get_discovery_cache_key, get_database,
validate_discovery_cache_artist, extract_artist_name,
spotify_rate_limited, discovery_score_candidates, get_metadata_cache,
build_discovery_wing_it_stub, add_activity_item).

Diff vs original after `deps.X` → global X normalization is **zero
differences** — 286 lines orig = 286 lines lifted, byte-identical body
(including all whitespace, comments, log strings).

Pre-existing bug preserved (not fixed): if `listenbrainz_playlist_states[
state_key]` raises KeyError on entry, the outer except handler tries to
mutate `state` which is unbound → secondary UnboundLocalError. Same bug
in the original (and the YouTube discovery worker). Documented here for
future cleanup but out of scope for the lift.

Tests: 11 new under tests/discovery/test_discovery_listenbrainz.py
covering cache hit short-circuit, Strategy 1 confidence match, Wing It
fallback, iTunes fallback (Spotify unauthenticated and rate-limited),
cancellation (phase change), completion phase update, activity feed
entry, per-track error handling, float duration_ms tolerance (regression
for the :02d format crash fixed earlier), enrichment workers resume on
finally.

Full suite: 1141 passing (was 1130). Ruff clean.
2026-04-28 16:09:02 -07:00
Broque Thomas
04647eb9f7 PR5f: lift _run_beatport_discovery_worker to core/discovery/beatport.py
Sixth lift in the PR5 discovery-workers series. Pulls the 323-line
Beatport chart discovery worker out of `web_server.py` into its own
focused module under `core/discovery/`. Pure 1:1 lift — wrapper keeps
the original entry-point name.

What the Beatport discovery worker does:

1. Pause enrichment workers (release shared resources).
2. For each Beatport track:
   - Cancellation gate (state['phase'] != 'discovering').
   - Clean Beatport text (artist/title) of common annotations via
     `clean_beatport_text` helper.
   - Single-string artist normalization for "CID,Taylr Renee"-style
     entries — split on comma, take the first.
   - Discovery cache lookup; cache hit short-circuits the search and
     normalizes cached artists from ['str'] → [{'name': 'str'}] to
     match the frontend's expected list-of-objects shape.
   - matching_engine search-query generation (with high min_confidence
     of 0.9 to avoid bad matches).
   - Strategy 1: scored candidates from initial Spotify/iTunes searches.
   - Strategy 4: extended search with limit=50 if no high-confidence
     match found.
   - On Spotify match: format artists as [{'name': str}] objects, pull
     full album object from raw cache when available, fallback to
     reconstructed album dict otherwise.
   - On iTunes match: format with image_url-derived album.images entry
     (300x300 spec), source set to discovery_source.
   - Save matched result to discovery cache when confidence >= 0.75
     (note: lower than search threshold; discovery still benefits from
     these less-confident matches as user-visible suggestions).
   - On miss: Wing It stub stored as 'wing-it' status (success ticked).
3. After all tracks: phase='discovered', activity feed entry, sync
   discovery results back to mirrored playlist via
   `_sync_discovery_results_to_mirrored` with 'beatport' tag.
4. On error: state['phase']='fresh' + status='error'.
5. Finally: resume enrichment workers.

Dependencies injected via `BeatportDiscoveryDeps` (17 fields) —
beatport_chart_states, spotify_client, matching_engine, plus 14
callable helpers (pause/resume enrichment, get_active_discovery_source,
get_metadata_fallback_client, clean_beatport_text,
get_discovery_cache_key, get_database, validate_discovery_cache_artist,
spotify_rate_limited, discovery_score_candidates, get_metadata_cache,
build_discovery_wing_it_stub, add_activity_item,
sync_discovery_results_to_mirrored).

Diff vs original after `deps.X` → global X normalization is **zero
differences** — 323 lines orig = 323 lines lifted, byte-identical body
(including all whitespace, comments, log strings).

Tests: 12 new under tests/discovery/test_discovery_beatport.py covering
cache hit short-circuit (with cached-artist normalization), Spotify
match formatting (list and string artist inputs), iTunes match
(image_url to album.images), Wing It fallback, cancellation
(phase change), completion phase update, activity feed entry, mirrored
sync invocation, top-level error handler, per-track error handling,
comma-separated artist split.

Full suite: 1130 passing (was 1118). Ruff clean.
2026-04-28 15:36:15 -07:00
Broque Thomas
c5e06691e3 PR5e: lift _run_spotify_public_discovery_worker to core/discovery/spotify_public.py
Fifth lift in the PR5 discovery-workers series. Pulls the 278-line
public-Spotify-link discovery worker out of `web_server.py` into its
own focused module under `core/discovery/`. Pure 1:1 lift — wrapper
keeps the original entry-point name.

What the Spotify Public discovery worker does:

1. Pause enrichment workers (release shared resources).
2. For each track:
   - Cancellation gate (state['cancelled']).
   - Normalize artists to plain string list (handles dict + str inputs).
   - Discovery cache lookup; cache hit short-circuits the search and
     populates display fields from the cached match.
   - SimpleNamespace duck-type → `_search_spotify_for_tidal_track`
     (shared search helper, returns tuple for Spotify or dict for iTunes).
   - On Spotify match: build `match_data` preserving track_number /
     disc_number from raw API data; image extracted from album images
     or track object fallback; release_date filled from track.release_date
     when album dict is missing it.
   - On iTunes match: dict result → match_data with source set to
     discovery_source; image extracted from album images.
   - Save matched result to discovery cache.
   - On miss: Wing It stub stored as 'wing-it' status.
3. After all tracks: phase='discovered', activity feed entry.
4. On error: state['phase']='error' + status with error string.
5. Finally: resume enrichment workers.

This worker is structurally close to the Deezer worker (see PR5d) but
intentionally diverges on:
- Track-data field names (`spotify_public_track` vs `deezer_track`).
- Artist normalization (Spotify Public can pass dicts or strings).
- No mirrored-playlist DB writeback (sync is handled separately).

Dependencies injected via `SpotifyPublicDiscoveryDeps` (12 fields) —
spotify_public_discovery_states, spotify_client, plus 10 callable
helpers (pause/resume enrichment, get_active_discovery_source,
get_metadata_fallback_client, get_discovery_cache_key, get_database,
validate_discovery_cache_artist, search_spotify_for_tidal_track,
build_discovery_wing_it_stub, add_activity_item).

Diff vs original after `deps.X` → global X normalization is **zero
differences** — 278 lines orig = 278 lines lifted, byte-identical body
(including all whitespace, comments, log strings).

Tests: 10 new under tests/discovery/test_discovery_spotify_public.py
covering cache hit short-circuit, dict-artist normalization, Spotify
tuple match (track/disc preservation), iTunes dict match path, Wing It
fallback, cancellation, completion phase update, activity feed entry,
top-level error handler, per-track error handling.

Full suite: 1118 passing (was 1108). Ruff clean.
2026-04-28 13:13:41 -07:00
Broque Thomas
2bc665e487 PR5d: lift _run_deezer_discovery_worker to core/discovery/deezer.py
Fourth lift in the PR5 discovery-workers series. Pulls the 270-line
Deezer discovery worker out of `web_server.py` into its own focused
module under `core/discovery/`. Pure 1:1 lift — wrapper keeps the
original entry-point name so the existing call sites continue to work
without changes.

What the Deezer discovery worker does:

1. Pause enrichment workers (release shared resources).
2. For each Deezer track:
   - Cancellation gate (state['cancelled']).
   - Discovery cache lookup; cache hit short-circuits the search and
     populates display fields from the cached match (artist string,
     album name).
   - SimpleNamespace duck-type → `_search_spotify_for_tidal_track`
     (shared search helper, returns tuple for Spotify or dict for iTunes).
   - On Spotify match: build `match_data` preserving track_number /
     disc_number from raw API data, image extracted from album images
     or track object fallback, release_date filled from track.release_date
     when album dict is missing it.
   - On iTunes match: dict result populated as `match_data`, source set
     to discovery_source, image extracted from album images.
   - Save matched result to discovery cache.
   - On miss: Wing It stub stored as 'wing-it' status.
3. After all tracks: phase='discovered', activity feed entry, sync
   discovery results back to mirrored playlist via
   `_sync_discovery_results_to_mirrored`.
4. On error: state['phase']='error' + status with error string.
5. Finally: resume enrichment workers.

Dependencies injected via `DeezerDiscoveryDeps` (13 fields) —
deezer_discovery_states dict, spotify_client, plus 11 callable helpers
(pause/resume enrichment, get_active_discovery_source,
get_metadata_fallback_client, get_discovery_cache_key, get_database,
validate_discovery_cache_artist, search_spotify_for_tidal_track,
build_discovery_wing_it_stub, add_activity_item,
sync_discovery_results_to_mirrored).

Diff vs original after `deps.X` → global X normalization is **zero
differences** — 270 lines orig = 270 lines lifted, byte-identical body
(including all whitespace, comments, log strings).

Tests: 10 new under tests/discovery/test_discovery_deezer.py covering
cache hit short-circuit, Spotify tuple match (track/disc number
preservation), iTunes dict match path, Wing It fallback, cancellation,
completion phase update, activity feed entry, mirrored sync invocation,
top-level error handler, per-track error handling.

Full suite: 1108 passing (was 1098). Ruff clean.
2026-04-28 12:45:18 -07:00
Broque Thomas
bda0500226 PR5c: lift _run_playlist_discovery_worker to core/discovery/playlist.py
Third lift in the PR5 discovery-workers series. Pulls the 323-line
mirrored-playlist discovery worker out of `web_server.py` into its own
focused module under `core/discovery/`. Pure 1:1 lift — wrapper keeps
the original entry-point name so the existing call site
(`_run_playlist_discovery_worker(pls, automation_id=None)` from the
automation engine) continues to work without changes.

What the playlist discovery worker does:

1. Pause enrichment workers (release shared resources).
2. Pre-compute total track count across all playlists for the automation
   progress card.
3. For each playlist:
   - Fast pre-scan separates already-discovered tracks (skipped, unless
     incomplete metadata or a Wing It stub) from undiscovered ones.
   - For each undiscovered track:
     - Cancellation gate via _playlist_discovery_cancelled set.
     - Discovery cache lookup (with artist validation).
     - matching_engine search-query generation, then Spotify (preferred)
       or iTunes (fallback) search + scoring.
     - Extended search fallback (limit=50) if no high-confidence match.
     - On match → enrich album from metadata cache (id, images,
       total_tracks, album_type, release_date, artists, plus track_number
       and disc_number), build matched_data, write to track.extra_data,
       save to discovery cache.
     - On miss → Wing It stub stored as 'wing_it_fallback' provider.
4. After all playlists: emit `discovery_completed` event when at least
   one new track was discovered, mark automation progress 'finished'.
5. On error → automation progress 'error', traceback printed.
6. Finally: resume enrichment workers.

Dependencies injected via `PlaylistDiscoveryDeps` (16 fields) —
spotify_client, matching_engine, automation_engine, the cancellation
set, plus 12 callable helpers (pause/resume enrichment,
get_active_discovery_source, get_metadata_fallback_client/source,
update_automation_progress, get_database, get_discovery_cache_key,
validate_discovery_cache_artist, discovery_score_candidates,
get_metadata_cache, build_discovery_wing_it_stub).

Diff vs original after `deps.X` → global X normalization is **zero
differences** — 323 lines orig = 323 lines lifted, byte-identical body
(including all whitespace, comments, log strings).

Tests: 15 new under tests/discovery/test_discovery_playlist.py covering
empty playlists, no-tracks playlist skip, complete-discovery skip,
incomplete-discovery re-run, Wing It always re-run, unmatched_by_user
respect, cache hit short-circuit, match above threshold (extra_data +
cache save), match below threshold falls to Wing It, iTunes fallback,
neither-provider error path, cancellation, discovery_completed event
emit, no-event on zero-discovered, multi-playlist grand_total
aggregation.

Full suite: 1098 passing (was 1083). Ruff clean.
2026-04-28 12:20:48 -07:00
Broque Thomas
3c1f614b6e fix: cast duration_ms to int before :02d format in discovery workers
yt_dlp sometimes returns float `duration_ms` for YouTube tracks. The
discovery workers format the duration with `f"{x // 60000}:{(x % 60000)
// 1000:02d}"` — and `:02d` requires an int. When the duration is a
float, the format string raises:

    Unknown format code 'd' for object of type 'float'

Caught when running YouTube discovery on a real playlist (bbno$ tracks)
— every track failed with status='Error'.

Pre-existing bug, surfaced now because of yt_dlp returning float
durations on this playlist. Fixed at all 8 sites by casting through
`int()` before the `// 60000` and `% 60000` operations:

- core/discovery/youtube.py: 2 sites in run_youtube_discovery_worker
  (cache hit + main result construction).
- web_server.py L29238/L29372: 2 sites in _run_listenbrainz_discovery_worker.
- web_server.py L40112/L40136/L40161/L40178: 4 sites in the YouTube
  retry/pre-discovered results assembly path.

The `if duration_ms` / `if dur` guard already protects against None and 0,
so `int(...)` is only called on truthy numeric values.

Tests: 1 new regression test under tests/discovery/test_discovery_youtube.py
(`test_float_duration_does_not_crash_format`) — passes a float
duration_ms and asserts the worker completes without an error result.
Ruff clean.
2026-04-28 12:07:54 -07:00
Broque Thomas
27fa96fe97 PR5b: lift _run_youtube_discovery_worker to core/discovery/youtube.py
Second lift in the PR5 discovery-workers series. Pulls the 332-line
YouTube discovery worker out of `web_server.py` into its own focused
module under `core/discovery/`. Pure 1:1 lift — wrappers keep the
original entry-point name so the two callers
(`youtube_discovery_executor.submit(_run_youtube_discovery_worker, ...)`)
continue to work without changes.

What the YouTube discovery worker does:

1. Pause enrichment workers (release shared resources).
2. For each YouTube playlist track:
   - Cancellation check (phase != 'discovering' aborts).
   - Discovery cache lookup; cache hit short-circuits the search.
   - Strategy 1: matching_engine search queries with confidence scoring
     against Spotify (preferred) or iTunes (fallback).
   - Strategy 2: swapped artist/title query.
   - Strategy 3: raw (untokenized) query.
   - Strategy 4: extended search with limit=50.
   - On match → save to discovery cache.
   - On miss → build Wing It stub from raw source data.
3. After loop: phase='discovered', sort results by index, and for mirrored
   playlists write extra_data back to the DB.
4. Activity feed entry with match summary.
5. On error → state['status']='error', phase='fresh'.
6. Finally: resume enrichment workers.

Dependencies injected via `YoutubeDiscoveryDeps` (16 fields) —
youtube_playlist_states, spotify_client, matching_engine, plus 13
callable helpers (pause/resume enrichment, get_active_discovery_source,
get_metadata_fallback_client, discovery cache key/validate, extract
artist name, spotify_rate_limited, discovery_score_candidates,
get_metadata_cache, build_discovery_wing_it_stub, get_database,
add_activity_item).

Diff vs original after `deps.X` → global X normalization is **zero
differences** — 332 lines orig = 332 lines lifted, byte-identical body
(including all whitespace).

Pre-existing bug preserved (not fixed): if `youtube_playlist_states[url_hash]`
raises KeyError on entry, the outer except handler tries to mutate
`state` which is unbound → secondary UnboundLocalError. Same bug in
the original. Documented here for future cleanup but out of scope
for the lift.

Tests: 14 new under tests/discovery/test_discovery_youtube.py covering
cache hit short-circuit, Strategy 1 confidence match, Wing It fallback,
iTunes fallback path (Spotify unauthenticated and rate-limited),
cancellation (phase changed), skip_discovery flag, completion phase
update, activity feed entry, mirrored playlist DB writeback, non-mirrored
no-writeback, enrichment workers pause/resume, error-during-loop resume,
results sorted by index after retry.

Full suite: 1082 passing (was 1068). Ruff clean.
2026-04-28 11:58:10 -07:00
Antti Kettunen
0125f478fc
Trim wishlist runtime plumbing
- remove the redundant wishlist-service injection from the runtime wrappers
- keep the package owning its own singleton service access
- simplify the route runtime API and update the wishlist tests to match
2026-04-28 21:52:21 +03:00
Antti Kettunen
f75c180cb6
Fix download cleanup after wishlist runs
- ignore unconfigured backends when clearing completed downloads
- keep the post-download cleanup route best-effort after a successful wishlist run
- add regression coverage for the orchestrator clear step
2026-04-28 21:22:21 +03:00
Broque Thomas
bdb7a3139d PR5a: lift _run_sync_task to core/discovery/sync.py
First lift in the new PR5 discovery-workers series. Pulls the 448-line
playlist sync background worker out of `web_server.py` into its own
focused module under `core/discovery/`. Pure 1:1 lift — wrappers keep
the original entry-point name so the four callers
(`sync_executor.submit(_run_sync_task, ...)`) continue to work without
changes.

What the sync worker does:

1. Convert frontend JSON tracks → SpotifyTrack/SpotifyPlaylist objects.
2. Normalize artist/album shapes for downstream wishlist parity.
3. Wire a progress_callback that updates `sync_states` + automation card.
4. Patch sync_service for database-only fallback when no media server is
   connected.
5. `run_async(sync_service.sync_playlist(...))` and capture the result.
6. Update sync_states to 'finished', push playlist poster image to
   Plex / Jellyfin / Emby, record sync history (with re-sync vs new-sync
   branching), emit `playlist_synced` event for automation engine, and
   persist sync status with a tracks_hash for smart-skip on the next
   scheduled sync.
7. On exception → mark error in sync_states + automation; finally clear
   progress callback + drop `_original_tracks_map` from sync_service.

Dependencies injected via `SyncDeps` (11 fields) — config_manager,
sync_service, plex_client, jellyfin_client, automation_engine, run_async,
record_sync_history_start, update_automation_progress,
update_and_save_sync_status, sync_states dict, sync_lock. The only
structural drift from a pure paste is the top-of-function variable
binding: original used `global sync_states, sync_service`, lifted version
rebinds them as locals from deps (`sync_states = deps.sync_states` etc.)
since the names aren't module-level in the new file. Same behaviour
otherwise — diff against the original after `deps.X` → global X
normalization is **zero differences**.

Tests: 18 new under tests/discovery/test_discovery_sync.py covering
sync history recording (new + resync), setup error path (with and
without automation_id), missing sync_service handling, sync_playlist
exception handling, successful sync state transition, unmatched-tracks
summary, playlist image upload (plex + jellyfin + zero-synced gate),
automation engine emit, automation progress finished call, sync history
DB persistence (completion + match_details), tracks_hash persistence,
and finally-block cleanup (callback clear + map drop).

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

Kicks off the PR5 series — 9 discovery workers totaling ~2,400 lines
across `_run_sync_task`, `_run_*_discovery_worker` family,
`_run_quality_scanner`, and `_process_watchlist_scan_automatically`.
Wishlist-related extractions deliberately skipped to avoid overlap with
kettui's planned `core/wishlist/` package.
2026-04-28 11:20:47 -07:00
Antti Kettunen
f5226bd5b5
Give wishlist modules their own loggers
- add module-level loggers for the wishlist package instead of threading the web server logger through runtime objects
- default wishlist helper runtimes and cleanup helpers to their package logger while still allowing test overrides
- keep web_server.py as a thin caller that no longer injects its logger into wishlist flows
2026-04-28 21:17:46 +03:00
Antti Kettunen
d2af9f8bdf
Move wishlist routes into package
- extract the remaining wishlist endpoint behavior from web_server.py into core/wishlist/routes.py
- keep web_server.py as a thin Flask adapter around the new route helpers
- add tests that cover wishlist counts, stats, track listing, clear/remove flows, cycle updates, and album-track adds
2026-04-28 21:17:24 +03:00
Antti Kettunen
f32fc9d56e
Extract wishlist logic into dedicated package
- add core/wishlist as the home for wishlist payload, resolution, state, processing, reporting, and selection helpers
- move wishlist-specific tests into tests/wishlist alongside the new package layout
- keep web_server.py and the import/search callers as thin adapters for now
2026-04-28 21:17:24 +03: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
Broque Thomas
0a6d1759b7 PR4g: lift batch lifecycle to core/downloads/lifecycle.py
Seventh sub-PR in the download orchestrator series. Strict 1:1 lift —
zero behavior change. ~570 lines moved out of web_server.py.

What moved (lifted as 3 tightly-coupled functions in one module):
- _start_next_batch_of_downloads → start_next_batch_of_downloads
- _on_download_completed → on_download_completed
- _check_batch_completion_v2 → check_batch_completion_v2

Dependencies bundled in `LifecycleDeps` (15+ refs):
- config_manager, automation_engine, download_monitor, repair_worker,
  mb_worker (live globals)
- is_shutting_down (lambda over IS_SHUTTING_DOWN flag)
- get_batch_lock (web_server helper for batch_locks dict)
- submit_download_track_worker (lambda wrapping
  missing_download_executor.submit + _download_track_worker)
- submit_failed_to_wishlist + submit_failed_to_wishlist_with_auto_completion
  (async, used by on_download_completed) AND process_failed_to_wishlist
  + process_failed_to_wishlist_with_auto_completion (sync, used by
  check_batch_completion_v2 — direct call matches original v2 behavior;
  the non-v2 path always submitted to executor)
- ensure_spotify_track_format, get_track_artist_name,
  check_and_remove_from_wishlist, regenerate_batch_m3u (web_server
  helpers — large, will lift in follow-up PRs)
- youtube_playlist_states, tidal_discovery_states,
  deezer_discovery_states, spotify_public_discovery_states
  (per-source playlist state dicts — phase transitions on batch
  completion)

Direct imports for already-lifted helpers:
- core.runtime_state.{download_tasks, download_batches, tasks_lock,
  add_activity_item}
- core.downloads.history.record_sync_history_completion (PR4a)
- core.album_consistency.run_album_consistency
- core.metadata.common.get_file_lock

Behavior parity verified line-by-line:
- start_next_batch: same batch lock acquisition, same shutdown gate,
  same V2-cancelled-task skip, same searching-status-set-before-submit,
  same submit-fails-no-ghost-worker semantics
- on_download_completed: same duplicate-call detection (skip decrement
  but still check completion), same failed-track tracking with
  spotify_track formatting + activity items + automation event emission,
  same wishlist removal on success, same active_count decrement, same
  stuck-detection (searching > 10min → not_found, post_processing >
  5min → completed), same M3U regeneration + repair worker hand-off
  + album consistency pass + wishlist failed-tracks submission
- check_batch_completion_v2: same finished-count tally, same stuck
  detection, same already-complete short-circuit returning True, same
  per-source playlist phase updates, same album consistency pass,
  same DIRECT (sync) wishlist processing call (NOT submit-to-executor
  — matches original v2 which called process_* functions directly)

CRITICAL drift caught + fixed during review:
- Initial lift had v2 routing wishlist calls through submit_* deps
  (async). Original v2 called process_* directly (sync). Added separate
  process_* deps to LifecycleDeps and routed v2 to them. Tests updated.

Two minor defensive additions documented:
- `is_auto_batch = False` initialized before conditional in v2 (Python
  scope rules made this unnecessary in original, but explicit is safer)
- Variable rename inside the queue-completion-check loop in
  on_download_completed: `task_id` → `queue_task_id` to avoid shadowing
  the outer parameter. Log output preserves the same task ID.

Tests: 28 new under tests/downloads/test_downloads_lifecycle.py
covering start-next (early-returns, shutdown gate, max_concurrent,
cancelled-task skip, searching-status-set, submit-failure-no-ghost,
orphan task), on-complete (decrement, duplicate skip, failed/cancelled
tracking, automation emit, wishlist removal, batch completion + emit
+ source phase update, stuck detection, auto vs manual routing),
check-v2 (missing batch, not-complete, complete-marking, already-
complete, auto routing, exception handling).

Full suite: 1029 passing (was 1001). Ruff clean.
2026-04-28 09:22:16 -07:00
Broque Thomas
f0955420c3 PR4f: lift _download_track_worker to core/downloads/task_worker.py
Sixth sub-PR in the download orchestrator series. Strict 1:1 lift —
zero behavior change. ~333 lines moved out of web_server.py.

What moved:
- _download_track_worker → download_track_worker

Dependencies bundled in `TaskWorkerDeps` (10 callbacks):
- soulseek_client (with .mode + .hybrid_order + subclient attrs for hybrid
  fallback: .soulseek/.youtube/.tidal/.qobuz/.hifi/.deezer_dl)
- matching_engine (.generate_download_queries)
- run_async
- try_source_reuse, store_batch_source, try_staging_match,
  get_valid_candidates, attempt_download_with_candidates,
  recover_worker_slot (web_server.py helpers — large, will lift in
  follow-up PRs)
- on_download_completed (deferred to PR4g batch lifecycle)

Direct imports for already-lifted: download_tasks, tasks_lock from
core.runtime_state. SpotifyTrack from core.spotify_client.

Behavior parity:
- Same control flow: missing-task short-circuit → cancellation
  checkpoint with V2/legacy split → SpotifyTrack reconstruction with
  artist/album normalization → source-reuse shortcut → staging-match
  shortcut → searching-state init → query generation (matching
  engine + legacy fallbacks: track+first-artist-word with The-prefix
  handling, track-only, paren/bracket-cleaned) → case-insensitive
  dedup → sequential query loop with cancellation checks before/
  during/after each search → hybrid fallback across remaining sources
  using first 2 queries → not_found marking with diagnostics → 2-tier
  exception recovery (failed marking + emergency worker_slot recovery)
- Same logger messages text-for-text (so log filters keep working)
- Same locking pattern (tasks_lock around every download_tasks read/
  write, with the 2.0s timeout fallback in the exception path)
- Same `cached_candidates` storage for retry fallback + raw-results
  storage for candidate review modal (top 20 per query without valid
  candidates)
- Same V2 detection via `playlist_id` field — V2 tasks don't trigger
  on_download_completed for cancellation (V2 atomic cancel handles
  the worker slot itself)

Tests: 19 new under tests/downloads/test_downloads_task_worker.py
covering early-return guards (missing/cancelled-V2/cancelled-legacy/
cancelled-no-batch), source reuse + staging shortcuts, search loop
happy path, no-results not_found, raw-results-stored-when-no-valid-
candidates, attempt-download-failure-falls-through, cancellation mid-
flight returns without completion, hybrid fallback (with + without
hybrid mode), critical exception with + without recovery callback,
query generation edge cases (The-prefix, paren cleanup, dedup).

Full suite: 1001 passing (was 982). Ruff clean.
2026-04-28 07:50:01 -07:00
Broque Thomas
2d271cfacf PR4e: lift status helpers + 3 routes to core/downloads/status.py
Fifth sub-PR in the download orchestrator series. Strict 1:1 lift —
zero behavior change.

What moved:
- _build_batch_status_data → build_batch_status_data
- get_batch_download_status route body → build_single_batch_status
- get_batched_download_statuses route body → build_batched_status
- get_all_downloads_unified route body → build_unified_downloads_response
- Status priority dict → module-level _STATUS_PRIORITY constant

Dependencies bundled in `StatusDeps` dataclass:
- config_manager, docker_resolve_path, find_completed_file,
  make_context_key, submit_post_processing (lambda wrapping
  missing_download_executor.submit + _run_post_processing_worker),
  get_cached_transfer_data

Direct imports from core.runtime_state for download_tasks /
download_batches / tasks_lock (already lifted by kettui).

Behavior parity:
- Same response payload shape across all 3 endpoints
- Same safety-valve mutation: stuck downloading task with file recovered
  → status='post_processing' + submit worker; stuck searching → not_found;
  stuck downloading no file → failed
- Same live transfer state mapping (Cancelled/Canceled, Failed/Errored/
  Rejected/TimedOut, Completed/Succeeded with byte-mismatch verification,
  InProgress, default queued)
- Same intermediate post_processing status promotion + single-shot worker
  submission (only when status != 'post_processing')
- Same 'Errored' handling: keeps current status to let monitor retry
- Same 17-key item dict in unified response with same field order
- Same artist/album/artwork normalization (handles string, dict, list,
  list-of-dicts, list-of-strings variants)
- Same sort: (priority asc, -timestamp desc)
- Same batch summary aggregation
- Same items[:limit] slicing
- Same logger messages text-for-text
- Same lock scope (single tasks_lock per call) — no new contention

Pre-existing bug preserved (will fix in follow-up PR):

- batched_status `debug_info` block iterates `response["batches"]` and
  guards with `if "error" not in batch_status`. Every successful
  payload includes `"error": batch.get('error')` (key always present,
  value usually None) so the guard is always False and debug_info
  never populates in production. Test documents the buggy behavior so
  the next PR can flip the check to `batch_status.get('error') is None`.

Tests: 32 new under tests/downloads/test_downloads_status.py covering
phase routing (analysis vs downloading vs unknown), task formatting +
sort + V2 fields, every live transfer state mapping (Cancelled,
Succeeded with full + partial bytes, InProgress, Errored, terminal-
not-overridden), safety valve (stuck searching → not_found, stuck
downloading recovered → post_processing, stuck downloading no file →
failed), all 3 route helpers (single, batched, unified), unified
artist/album/artwork normalization, batch summary aggregation, limit
slicing, plus debug_info bug documentation.

Full suite: 982 passing (was 950). Ruff clean.
2026-04-27 23:26:26 -07:00
Broque Thomas
a133448a6e PR4d: lift _run_post_processing_worker to core/downloads/post_processing.py
Fourth sub-PR in the download orchestrator series. Strict 1:1 lift —
zero behavior change. ~407 lines moved out of web_server.py.

What moved:
- _run_post_processing_worker → run_post_processing_worker

The lifted function is intentionally kept as one ~400-line block to
preserve byte-for-byte parity with the original. Refactoring it into
smaller helpers (context lookup, file search loop, transfer-folder
handler, downloads-folder handler) gets its own follow-up PR.

Dependencies: 9 callbacks bundled in `PostProcessDeps` dataclass.
- config_manager, soulseek_client, run_async (live refs)
- docker_resolve_path, extract_filename, make_context_key
  (small utilities still in web_server.py — will lift in a future PR
  alongside other shared utilities)
- find_completed_file (file search helper, still in web_server.py)
- enhance_file_metadata, wipe_source_tags (web_server wrappers around
  core.metadata.enrichment)
- post_process_with_verification (web_server wrapper around
  core.imports.pipeline)
- mark_task_completed (wraps runtime_state.mark_task_completed +
  session counter)
- on_download_completed (deferred to PR4g batch lifecycle)

Direct imports for already-lifted helpers (no injection needed):
- core.imports.album_naming.resolve_album_group
- core.imports.context.{get_import_clean_title, get_import_clean_album,
  get_import_original_search, get_import_context_artist,
  get_import_context_album, normalize_import_context}
- core.imports.filename.extract_track_number_from_filename
- core.metadata.enrichment (re-exported as metadata_enrichment)
- core.runtime_state.{download_tasks, tasks_lock,
  matched_downloads_context, matched_context_lock}

Behavior parity:
- Same control flow: missing-task short-circuit → cancelled/completed
  short-circuit → missing-filename failure → docker path resolution →
  context lookup with fuzzy fallback → expected filename generation →
  YouTube special-case path resolution → 5-attempt search loop with
  Strategy 1 (original filename in download+transfer) and Strategy 2
  (expected final filename in transfer) → file-not-found failure →
  transfer-folder handler with metadata enhancement → downloads-folder
  handler with full post-process verification
- Same retry count (5), sleep duration (5s), per-attempt logging
- Same album_info dict construction with is_album=True for explicit
  album downloads
- Same album grouping skip when context.is_album_download is True
- Same wipe_source_tags fallback when enhancement context missing
- Same matched_downloads_context cleanup on success
- Same exception swallowing at processing-error and critical-error
  layers, both setting status='failed' + error_message + calling
  on_download_completed(b, t, success=False)
- Every logger message text preserved verbatim (so log filters keep
  working)

Tests: 16 new under tests/downloads/test_downloads_post_processing.py
covering missing task, cancelled, already-completed, stream_processed,
missing filename + username, file-not-found-after-retries with sleep
mocked, stream-processor-completes-mid-search, transfer-folder with
metadata enhanced + with no context (wipes tags), downloads-folder
with + without context, processing exception, critical outer
exception, YouTube special path, fuzzy context matching.

Full suite: 950 passing (was 934). Ruff clean.
2026-04-27 22:57:55 -07:00
Broque Thomas
039f152f31 PR4c: lift _automatic_wishlist_cleanup_after_db_update to core/downloads/cleanup.py
Third sub-PR in the download orchestrator series. Strict 1:1 lift —
zero behavior change.

What moved:
- _automatic_wishlist_cleanup_after_db_update → cleanup_wishlist_after_db_update

The lifted fn takes config_manager as an arg (so core/downloads/cleanup.py
doesn't need to import web_server). Other deps (wishlist_service,
MusicDatabase, get_database) stay as in-function imports — matches the
original deferred-import pattern.

The single caller in web_server.py (missing_download_executor.submit at
L18028) keeps using the same wrapper name with no signature change.

Behavior parity:
- Same per-profile iteration via get_all_profiles()
- Same essential-field skip (no name / no artists / no spotify_track_id)
- Same artist normalization (string / dict / fallback to str())
- Same 0.7 confidence threshold for db match
- Same break-on-first-artist-match semantics
- Same album extraction (dict.name vs string passthrough)
- Same active_server pulled via config_manager.get_active_media_server()
- Same per-track exception swallowing inside the loops
- Same top-level exception swallow with traceback.print_exc()
- Same logger messages (exact text match for "[Auto Cleanup]" prefix)

Tests: 13 new under tests/downloads/test_downloads_cleanup.py covering
empty wishlist short-circuit, found-in-db removal, missed track stays,
low-confidence skip, missing-fields skip, dict + string artist formats,
break-on-first-match, multi-profile walk, album dict/string handling,
db check failure continuing to next artist, top-level exception swallow,
active server propagation.

Full suite: 934 passing (was 921). Ruff clean.
2026-04-27 22:23:41 -07:00
Broque Thomas
dc2835eecc PR4b: lift cancel + clear download routes to core/downloads/cancel.py
Second sub-PR in the download orchestrator series. Strict 1:1 lift —
zero behavior change.

What moved:
- cancel_download (single slskd cancel) → cancel_single_download
- cancel_all_downloads (cancel + clear + sweep) → cancel_all_active
- clear_finished_downloads (slskd clear + sweep) → clear_finished_active
- clear_completed_downloads (local task tracker prune) → clear_completed_local

Slskd-touching helpers take (soulseek_client, run_async, sweep_callback)
explicitly so the route layer wires the live client + the existing
_sweep_empty_download_directories helper. The local-state helper imports
download_tasks/download_batches/batch_locks/tasks_lock straight from
core.runtime_state since those are module-level shared globals.

Prep change: `batch_locks` dict moved from web_server.py global into
core/runtime_state.py alongside the other download globals. web_server.py
re-imports from runtime_state so the ~3 existing call sites in
web_server.py keep resolving without modification. Identity preserved
(same dict across all importers).

Out of scope (deferred to PR4g batch lifecycle):
- cancel_download_task (calls _on_download_completed)
- cancel_task_v2 + _atomic_cancel_task + _find_task_by_playlist_track
  (manipulate batch active_count directly, deeply coupled to lifecycle)

Behavior parity:
- Same response shapes + status codes on each route
- Same call order (cancel_all → clear_all_completed → sweep)
- Same conditional sweep on clear_finished (skipped on failure)
- Same sweep ALWAYS runs after cancel_all even if clear_all returns False
  (matches original — clear failure was non-fatal in cancel_all path)
- Same TERMINAL_STATUSES set: completed/failed/not_found/cancelled/skipped/
  already_owned (lifted to module-level constant)
- Same empty-batch pruning + same batch_locks cleanup
- Same lock acquisition pattern (single tasks_lock)

Tests: 14 new under tests/downloads/test_downloads_cancel.py covering
single cancel, cancel-all happy + failure paths, clear-finished + sweep
gate, local task pruning across all 7 active/terminal states, batch
queue trimming, batch_locks cleanup.

Full suite: 921 passing (was 907). Ruff clean.
2026-04-27 21:41:35 -07:00
Broque Thomas
3ce25310a3 PR4a: lift sync history recording to core/downloads/history.py
First sub-PR in the download orchestrator series. Strict 1:1 lift —
zero behavior change.

What moved:
- _record_sync_history_start → record_sync_history_start
- _record_sync_history_completion → record_sync_history_completion
- _detect_sync_source → detect_sync_source
- Source prefix map → module-level _SOURCE_PREFIX_MAP constant

What stayed:
- web_server.py keeps three thin wrappers (_detect_sync_source,
  _record_sync_history_start, _record_sync_history_completion) that
  delegate into core/downloads/history.py. ~60 callers of these names
  in web_server.py keep resolving without touching every site.

Each lifted function takes `database` as an arg (was
`db = MusicDatabase()` inline). The wrappers construct
`MusicDatabase()` per call to mirror the exact original behavior —
each invocation got a fresh DB connection.

Behavior parity:
- Same SQL UPDATE statement (preserves the in-place update path when
  a sync_history entry already exists for the playlist_id)
- Same JSON serialization with ensure_ascii=False
- Same thumb URL extraction order (album_context.images → image_url
  → first track album.images)
- Same per-track result shape (index, name, artist, album, image_url,
  duration_ms, source_track_id, status, confidence, matched_track,
  download_status)
- Same status mapping (found/not_found, completed/failed)
- Same best-effort exception swallowing (sync history failure must
  never break the actual download)
- Reads `download_tasks` from core.runtime_state (already lifted by
  kettui in PR378)

Tests: 34 new under tests/downloads/test_downloads_history.py
covering source detection (16 prefixes), start happy paths + thumb
extraction + duplicate-update + DB error swallowing, completion stats
+ per-track results JSON shape + edge cases.

Full suite: 907 passing (was 873). Ruff clean.
2026-04-27 20:37:16 -07:00
Broque Thomas
a8319156ce Lift /api/automations/blocks static config into core/automation/blocks.py
The endpoint was returning a 200-line literal dict inline. Moved the
three lists (TRIGGERS, ACTIONS, NOTIFICATIONS) to module-level constants
in core/automation/blocks.py. Route shrinks to 7 lines. Data is now
importable for tests + future docs.

Added 8 shape tests so a typo in the dict (missing 'type', wrong
field type, missing options on a select, etc.) gets caught by CI
instead of breaking the builder UI silently.

The `known_signals` field stays computed at request time via
_collect_known_signals(database) since it's dynamic.

No behavior change. Same response shape. 869 tests passing (was 861).
Ruff clean.
2026-04-27 18:31:36 -07:00
Broque Thomas
6cdcf778f3 Lift /api/automations/* into core/automation/
Routes moved to thin parse-args/jsonify handlers; logic now lives in
three focused modules under core/automation/. 436 lines deleted from
web_server.py; 53 added back as wrappers.

Module split:
- core/automation/api.py — CRUD + run + history helpers. Each function
  takes (database, automation_engine, ...) explicitly and returns
  (response_body, http_status). Includes signal cycle detection
  preflight checks for create + update.
- core/automation/progress.py — owns the in-memory progress state dict
  + lock (mirroring the original web_server.py globals as module-level
  shared state so all callers see one view), init/update/history
  helpers, and the WebSocket emit loop.
- core/automation/signals.py — collect_known_signals for the builder
  autocomplete.

Out of scope (deferred):
- _register_automation_handlers — the 23+ action handler closures stay
  in web_server.py because each one is tightly coupled to feature-
  specific implementations (wishlist, watchlist, library scan, etc.).
- Worker functions (_process_wishlist_automatically, etc.) — belong
  with their feature lifts.
- _run_sync_task / _run_playlist_discovery_worker — sync + discovery
  PRs.

Behavior preserved 1:1:
- Same route response shapes + status codes
- Same JSON field hydration (trigger_config, action_config,
  notify_config, last_result, then_actions)
- Same backward-compat: empty then_actions + notify_type set →
  synthesize then_actions from notify_type/notify_config
- Same signal cycle detection behavior on create + update
- Same system-automation protection on delete + duplicate
- Same reschedule/cancel logic on toggle + bulk-toggle + update
- Same progress state shape (status, progress, phase, current_item,
  log capped at 50, started_at/finished_at, action_type)
- Same emit-on-finish socketio push from update_progress
- Same emit loop semantics (1s tick, snapshot active states, reap
  finished after window)

Pre-existing bugs preserved (will fix in follow-up PRs):
- emit_progress_loop uses naive datetime.now() against tz-aware
  started_at/finished_at, so the timeout-zombie check raises
  TypeError → caught → never fires, and the cleanup-after-window
  check raises → caught → state is reaped on FIRST tick regardless
  of the window. Tests document this behavior so the next PR can
  flip them to the corrected expectation.

Tests: 72 new under tests/automation/ (signals 10, progress 24,
api 38). Full suite: 861 passing (was 789). Ruff clean.
2026-04-27 18:05:14 -07:00
Broque Thomas
b94cbd7dd7 Search lift: pre-merge parity polish for cin's review
Three drifts caught in line-by-line review against the pre-lift
web_server.py. All addressed for strict 1:1 behavior parity.

1. /api/enhanced-search/source/<src> now returns plain JSON
   `{"artists":[],"albums":[],"tracks":[],"available":false}` (or
   `{"videos":[],"available":false}` for youtube_videos) when the
   source's client isn't available, matching the original endpoint
   contract. Previously streamed an NDJSON `{"type":"done"}` line
   instead.

   Restructured by splitting the orchestrator into resolve+stream
   helpers:
   - `resolve_client(source_name, deps)` — already existed, used
     for /api/enhanced-search single-source mode
   - `resolve_youtube_videos_client(deps)` — new, returns the
     soulseek_client.youtube subclient or None
   - `stream_metadata_source(source_name, query, client)` — pure
     NDJSON generator, caller resolves client first
   - `stream_youtube_videos(query, youtube_client, run_async)` —
     same shape for the yt-dlp path

   The route now decides plain-JSON-vs-stream based on resolution
   result, mirroring the original control flow exactly.

2. core/search/library_check.py — reverted the defensive `(x or '')`
   and `getattr(plex_client, 'server', None) is not None` patterns
   to original byte-for-byte (`x.get('name', '')`,
   `plex_client.server`, no try/except around `get_plex_config`).
   Lift PR shouldn't change crash semantics; if the original raises
   on malformed input, mine should too. Pre-existing edge cases get
   their own follow-up PR.

3. core/search/stream.py — same revert: `soulseek_client.youtube`
   instead of `getattr(..., 'youtube', None)` etc.

Also removed the module-level `EMPTY_SOURCE` from sources.py and
moved its (per-call) duplicate into _fan_out_response as a local —
the original used a per-request local dict and the identity-check
behavior depends on that. Module-level was a footgun for future
mutations.

789 tests still pass (95 search), ruff clean.
2026-04-27 15:32:48 -07:00
Broque Thomas
fd7b56e58c Lift /api/search and /api/enhanced-search/* into core/search/
Routes moved to thin parse-args/jsonify handlers; logic now lives in
six focused modules under core/search/. 720 lines deleted from
web_server.py; 109 added back as wrappers; ~700 lines of new core code
plus ~700 lines of tests.

Module split:
- core/search/cache.py — TTL+LRU cache for enhanced-search responses,
  keyed by (query, active_server, fallback_source, hydrabase_active,
  source_tag) so config changes don't poison stale entries.
- core/search/sources.py — per-kind metadata search (artists/albums/
  tracks) and the multi-kind ThreadPoolExecutor that fans them out.
- core/search/library_check.py — library + wishlist presence check
  with Plex thumb URL resolution; profile-aware wishlist with legacy
  fallback for older DBs missing the profile_id column.
- core/search/stream.py — single-track preview search; effective stream
  mode resolution, query-variant generation, retry walk, matching
  engine integration.
- core/search/basic.py — flat Soulseek file search, quality-sorted.
- core/search/orchestrator.py — main enhanced-search dispatch
  (short-query fast path, single-source bypass, hydrabase-primary fan
  out, alternate source list builder), NDJSON streaming generator
  for /source/<src>, and the SearchDeps dataclass that bundles the
  cross-cutting deps.

Routes pass clients (spotify, hydrabase, hydrabase_worker, soulseek)
and helpers (config_manager, fix_artist_image_url,
_is_hydrabase_active, _get_metadata_fallback_*, _run_background_
comparison, run_async, dev_mode_enabled_provider) into core/search via
a SearchDeps bundle built per-request. fix_artist_image_url stays in
web_server.py because it touches 31 other call sites.

Behavior preserved 1:1:
- Same response shapes (db_artists, spotify_artists, spotify_albums,
  spotify_tracks, primary_source, metadata_source, alternate_sources,
  source_available)
- Same NDJSON line ordering (artists/albums/tracks as they finish, plus
  done marker)
- Same per-kind exception swallowing
- Same hydrabase-worker mirror on dev mode
- Same cache key shape (5-tuple) and TTL/LRU semantics
- Same stream-track effective-mode resolution including the
  Soulseek-coerce-to-YouTube edge case
- Same library-check Plex thumb URL rewriting and wishlist fallback
  for older DBs

Tests: 94 new (cache TTL/LRU/key, sources happy/partial/all-fail,
library presence with library + wishlist + thumbs, stream effective
mode + query gen + retry, orchestrator client resolution + short
query + single source + fan-out alternates + hydrabase primary +
NDJSON drain). Full suite: 788 passing (was 694).

Ruff clean.
2026-04-27 15:07:11 -07:00
Broque Thomas
f51b75da7e Lift /api/stats/* and /api/listening-stats/* into core/stats/
Stats route logic moves into core/stats/queries.py as pure-ish functions
that take dependencies (database, image-url fixer, listening worker) as
arguments. The 13 route handlers in web_server.py shrink to thin
parse-args / jsonify wrappers.

What moved to core/stats/queries.py:
- stats_cached: 3-key metadata cache lookup + image url fix-up
- stats_overview / timeline / genres / library_health / db_storage
- stats_top_artists / top_albums / top_tracks: top-N + DB enrichment
- stats_recent: listening_history readback
- stats_resolve_track: title+artist -> file_path lookup for playback
- listening_stats_sync: spawns daemon thread that runs worker._poll
- listening_stats_status: stats payload, with None-worker fallback shape

No behavior change. Same response shapes, same error handling, same
silent-except on per-row enrichment failure. fix_artist_image_url
stays in web_server.py and is passed through as a callback so we
don't have to lift its config_manager / media-server dependencies in
this PR.

Adds tests/stats/test_stats_queries.py — 27 tests covering happy
paths, edge cases, image-url plumbing, worker glue.

Ruff clean. 694 tests pass (was 667 + 27 new).
2026-04-27 14:27:03 -07:00
Broque Thomas
313b5677a5 Drop stale post-PR378 redefs and fix B009
Lifted-then-not-deleted leftovers from the PR378 merge:
- web_server.py `_resolve_album_group` and `_build_final_path_for_track`
  were already imported at module top from `core/imports/`. Removed the
  shadowing local copies.
- Mutagen reimports (FLAC/MP4/OggVorbis) at L17736-17738 shadowed the
  top-of-file imports. Picture/MP4Cover/MP4FreeForm were unused. Dropped
  the whole block.
- core/imports/context.py: `getattr(artist, "name")` -> `artist.name`
  (B009).

Ruff clean, 667 tests pass.
2026-04-27 13:39:16 -07:00
Antti Kettunen
4f236baa6d
Fix import normalization and task completion locking
- Promote legacy _source into source during import normalization.
- Keep the normalized import context neutral after stripping aliases.
- Avoid re-entering tasks_lock when marking completed download tasks.
2026-04-27 19:55:07 +03:00
Antti Kettunen
4c819681a1
Move single-track resolver; fix wishlist cleanup
- keep single-track import lookup in imports/resolution.py
- normalize simple-download search_result data before wishlist matching
- run wishlist cleanup for simple-download post-processing
- keep source-only artist detail on resolved names and MB short-circuit
2026-04-27 19:55:06 +03:00
Antti Kettunen
9321fc4ad2
Small cleanup 2026-04-27 19:55:06 +03:00
Antti Kettunen
d04573f397
Fix single import source handling
- pass the selected manual match through singles import
- keep the import context source-aware so artist and album stay correct
- avoid treating non-Spotify IDs as wishlist Spotify IDs
- make wishlist logging and local variable names source-neutral
2026-04-27 19:54:45 +03:00
Antti Kettunen
594c8c1b93
Cleanup duplicated code
Leftovers from the earlier recovery mission
2026-04-27 19:54:45 +03:00
Antti Kettunen
9b2b6d856f
Split runtime builders into owning modules
- Move the import pipeline runtime factory into core.imports.pipeline
- Move the metadata runtime factory into core.metadata.enrichment
- Keep the web server wiring thin and drop the shared glue module
- Add contract tests that keep the two runtime bundles separate
2026-04-27 19:54:45 +03:00
Antti Kettunen
9e496397da
Move shared metadata helpers into package
- Relocate the shared metadata helper module from core/metadata_common.py into core/metadata/common.py.
- Update the new metadata package, the import pipeline, and the web entrypoint to use the package-scoped helper.
- Keep the shared config, mutagen, file-lock, and tag-writing helpers centralized without touching unrelated files.
2026-04-27 19:54:44 +03:00
Antti Kettunen
9656dbd46a
Thread runtime through metadata enrichment
- Pass the live runtime bundle into the shared metadata facade so worker-backed source enrichment can actually run.
- Forward runtime from the import pipeline and web-server wrapper into embed_source_ids.
- Add a regression test that verifies the runtime object reaches the source-ID embedding path.
2026-04-27 19:54:44 +03:00
Antti Kettunen
8319c6679f
Move new metadata helpers into a package
- Keep existing metadata_cache and metadata_service at the top level for now
- Move the new branch-local metadata helpers under core/metadata
- Share MusicBrainz release cache state from core.metadata.source and update import sites
2026-04-27 19:54:44 +03:00
Antti Kettunen
bdef127dd6
Lift shared runtime state into core
- Move app-wide task and activity registries out of core/imports
- Share one runtime-state module across the web server, API, and import pipeline
- Keep import-specific helpers focused on context and post-processing
2026-04-27 19:54:44 +03:00
Antti Kettunen
e10df4caf2
Rehome import helpers into core/imports
- Move import flow modules into a dedicated package
- Update app and test imports to the new namespace
- Group the import-focused tests under tests/imports
2026-04-27 19:54:44 +03:00
Antti Kettunen
edd9048f86
Checkpoint metadata runtime cleanup
- remove runtime from metadata helper APIs where it only carried config, logger, mutagen, and database access
- keep runtime only for the source-ID enrichment path that still needs live worker handles
- add the new metadata helper modules and update the tests to match the slimmer interfaces
2026-04-27 19:54:43 +03:00
Antti Kettunen
0bbf44809f
Move the import flows and related post-processing pipelines into separate modules
- Extract the import pipeline, album import, staging, path, file ops, guards, runtime state, side effects, and metadata enrichment out of .
- Canonicalize the refactored import path around  and remove legacy , , , and  request shapes from the import endpoints.
- Make album and track metadata lookups follow the configured provider priority instead of hard-coding Spotify, while still falling back when needed.
- Update the import routes and frontend payloads to use the new core helpers.
- Add coverage for the extracted helpers and the refactored import flows.

PS. apologies to anyone who might check this commit out - the intention was to start small, but things kinda snowballed out of control at some point since the logic just kept going on and on, and everything kinda had to be changed all at once for it all to make any sense
2026-04-27 19:54:43 +03:00
Broque Thomas
f11b91a5c6 Service worker for cover art + PWA manifest
Addresses #365 (reported by JohnBaumb), parts 3 & 5. Client-side
IDB / sessionStorage data cache (part 4) deferred to its own PR.

Cover art on Library and Discover used to re-fetch from the source
CDN on every page visit. Now a service worker caches images locally
in CacheStorage with cache-first strategy — second visit serves art
instantly with zero network round-trips. PWA manifest added so the
app is installable to home screen / desktop.

Service worker (`webui/static/sw.js`):
- Cache-first for images: 10 known CDN hosts (Spotify, Last.fm,
  Apple, Deezer, Discogs, MusicBrainz CAA, YouTube thumbnails) plus
  the local `/api/image-proxy` endpoint plus same-origin .png/.jpg/
  .webp/.gif/.svg paths. Cross-origin file-extension matches are
  refused so we don't accidentally cache trackers.
- Stale-while-revalidate for `/static/*`: serve cached instantly,
  refresh in background. Combined with the existing `?v=static_v`
  cache-bust, deploys still ship live (different query → different
  cache entry, old ages out).
- HTML / API / everything else: no caching, pass through.
- Cache-versioned (CACHE_VERSION = 'v1'); activate handler wipes any
  cache whose name doesn't match the current version.
- skipWaiting + clients.claim so deploys propagate to open tabs
  without requiring a full close-and-reopen.

PWA manifest (`webui/static/manifest.json`):
- Standalone display mode, theme color #1db954 (matches --accent-rgb).
- Two icons (192, 512) with both `any` and `maskable` purpose,
  generated from favicon.png with aspect-preserving transparent
  padding so the existing logo lands inside the safe zone for
  OS-applied masks.

Wiring:
- `web_server.py` adds a `/sw.js` route that serves the file from
  root scope (a service worker only controls URLs at or below its
  served path; `/static/sw.js` would scope to `/static/*` only).
  `Cache-Control: no-cache` on the SW response so deploys propagate
  on next page load instead of being pinned by the 1yr static cache
  the rest of /static/ uses.
- `webui/index.html` adds the manifest link, theme-color meta, and
  an apple-touch-icon for iOS.
- `webui/static/init.js` registers the SW on `window.load`.
  Feature-detected — no-op on browsers without serviceWorker support
  or on non-secure origins (SW requires https or localhost).

One bug caught + fixed during line-by-line self-review:
`_staleWhileRevalidate` could return null to `respondWith()` when
both the cache miss AND the network fetch failed (the `.catch(() =>
null)` collapsed the rejection to null, which then short-circuited
through the falsy chain). Now explicitly awaits the network promise
and falls back to `Response.error()` when it resolves to null —
matches the `_cacheFirst` pattern.

Browser-verified: sw.js registers, status "activated and is running"
in DevTools. 603 tests pass.
2026-04-26 22:17:52 -07:00
Broque Thomas
5d9e5e5781 Discover cache: switch from public to private
Self-review nit on b0e7dae. Discover data is user-specific (hero
artists from your watchlist, similar artists from your taste,
recently-played derivations, etc.) — `Cache-Control: public` would let
intermediate proxies (corporate caching proxy, Cloudflare with cache
rules, Nginx with proxy_cache) store one user's response and serve
it to another. Privacy leak.

Switched to `private, max-age=300`. Browser-only cache, proxies skip.
Static assets stay `public` (shared content — everyone gets the same
library.js). Streaming and backup endpoints already correct
(`no-cache` and `no-store` respectively).

603 tests pass.
2026-04-26 21:27:46 -07:00
Broque Thomas
b0e7dae7c6 Cache static assets 1y + cache discover GETs 5min
Addresses #365 (reported by JohnBaumb), parts 1 & 2 of the proposal.
Service worker, client-side IDB/sessionStorage, and PWA manifest
deferred to follow-up PRs.

1. Static asset cache (CSS/JS/icons/fonts).
   `SEND_FILE_MAX_AGE_DEFAULT` flipped from 0 to 31536000 (1 year) in
   production. Safe because every static URL is bust-tagged with
   `?v=static_v` (computed once per process start), so each server
   restart effectively invalidates every cached asset for every user.
   Within a single deploy, repeat page loads hit zero round-trips on
   static files — was a 304 round-trip per asset before.
   Dev override (`SOULSYNC_WEB_DEV_NO_CACHE=1`) keeps it at 0 so
   iterating on JS/CSS doesn't need a server restart between edits.

   Collateral fixes from the bump:
   - Music streaming endpoint (L16140): `response.headers.add('Cache-Control',
     'no-cache')` → bracket-assign. Under the old max-age=0, send_file
     set `no-cache` and `.add()` duplicated harmlessly. Under the new
     max-age=31536000, `.add()` would APPEND a second Cache-Control
     value → two conflicting headers, browser-undefined behavior.
     Bracket-assign replaces.
   - Backup download endpoint (L25181): explicit `Cache-Control:
     no-store` on the response so DB backups don't inherit the new
     long max-age — sensitive content, must never cache.

2. Discover GET browser cache (5 min).
   New `@app.after_request` hook scoped to `/api/discover/` and
   `/api/discovery/` paths, GET method, 2xx responses only. Sets
   `Cache-Control: public, max-age=300`. Skipped when the endpoint
   already set its own Cache-Control. Toggling between Discover
   sections within 5 min serves from browser cache, no backend hit.

   Try/except wraps the hook body and logs a warning if anything
   throws — never let a header-tagging bug turn a successful response
   into a 500. (Logging instead of `pass` since silent except-pass is
   exactly the anti-pattern issue #369 is about.)

Audited every other Cache-Control set site in web_server.py — only
the two `send_file` callers needed adjustment. Range-branch streaming
uses `Response()` directly, unaffected by the config change.

603 tests pass.
2026-04-26 21:16:41 -07:00
Broque Thomas
01b7d50311 Gate /api/settings endpoints behind admin profile
Closes #370 (reported by JohnBaumb).

The /api/settings endpoint and three siblings (/log-level,
/config-status, /verify) had no auth check — any logged-in profile
could read or modify service tokens, OAuth secrets, and API keys.
Cin's "minimum" suggestion from the issue: gate to admin profile.

Added an `admin_only` decorator near `get_current_profile_id` that
returns 403 when the current profile isn't admin (id=1). Applied
to all four endpoints.

Auth model note (documented in the decorator docstring): SoulSync's
existing model is "trust local network" — single-admin / no-multi-
profile installs default `get_current_profile_id()` to 1, so the
gate is a no-op for solo users. The decorator is meaningful in
multi-profile setups where non-admin sessions exist. Tightening to
real per-request auth is out of scope.

Did NOT consolidate with api/settings.py (Cin's "better" suggestion):
that endpoint uses API-key auth (for external tools), the web_server.py
copy uses session/profile auth (for the web UI). Different consumers,
different auth models — merging would break one or the other.

603 tests pass.
2026-04-26 20:01:01 -07:00
Broque Thomas
dd4cf130d7 Socket.IO CORS: handle self-review nits
Six items from a Cin-style line-by-line pass on PR #383:

- resolve_cors_origins: list of non-string entries (`[None, 123]`) now
  drops them instead of coercing to junk strings like `'None'`/`'123'`.
- will_reject: backwards-compat shim removed. Production callers always
  pass `request.scheme` (Flask-guaranteed); the shim only existed for
  tests/non-Flask callers and made the production code path branchier
  than necessary. Tests now pass scheme explicitly.
- maybe_log: redundant `if not origin` early-return dropped. will_reject
  handles missing origin (engineio's own behavior — server.py:207).
- RejectionLogger.__init__: `int(dedup_cap)` wrapped in try/except so
  bad-type input falls back to DEFAULT_DEDUP_CAP instead of raising.
- web_server.py: docstring on the before_request hook explains why the
  hook fires on every request (Flask doesn't scope before_request to a
  path prefix; the early-return string compare is the cheapest option).
- settings.js: cors-origins URL regex tightened from `[^\s/]+` to
  `[^\s/?#]+` so query/fragment chars don't pass validation. Engineio
  would silently fail to match those anyway; better to flag at save.

Test changes:
- parametrize gained an explicit `scheme` column (12 cases updated).
- New explicit case: scheme-mismatch rejects (engineio compares full
  `{scheme}://{host}` strings).
- `test_will_reject_falls_back_to_host_only_when_no_scheme_info`
  deleted — the shim it tested is gone.
- `test_will_reject_honors_x_forwarded_host` now passes scheme info.

Net: -9 production lines, -3 test lines. Production code path is
straight-line. 603 tests pass.
2026-04-26 19:24:43 -07:00
Broque Thomas
0f24739e27 Socket.IO CORS: polish — match engineio exactly, bound dedup, validate URLs
Self-review pass on the security fix uncovered five issues, all fixed
here:

1. will_reject scheme handling. Engineio compares full {scheme}://{host}
   strings, not just hostnames. A TLS-terminating proxy can leave the
   backend seeing http while the browser's Origin is https — engineio
   rejects, but the original predictor said "allow" → no helpful log
   line. Added request_scheme + forwarded_proto params, build full
   candidate strings to match engineio.

2. EITHER-forwarded-header rule. Engineio adds the forwarded candidate
   when EITHER X-Forwarded-Proto OR X-Forwarded-Host is present (it
   falls back to HTTP_HOST for the missing one). The original predictor
   only added it when forwarded_host was set — false negative for
   misconfigs sending only X-Forwarded-Proto. Now mirrors engineio.

3. will_reject incorrectly rejected missing-Origin requests. Engineio
   (server.py:207: `if origin: validate`) skips CORS validation when
   no Origin header is sent — non-browser clients (curl etc.) are
   intentionally permitted. The original code rejected them. Test was
   asserting the wrong behavior. Both fixed.

4. RejectionLogger had unbounded dedup set growth. A hostile actor
   opening connections from many distinct fake origins would fill
   memory unboundedly. Capped at 100 unique origins (configurable);
   when cap hit, one overflow notice is emitted and further rejections
   are silently dropped until restart.

5. Lock pattern: the overflow log path called logger.warning() while
   holding the dedup lock, inconsistent with the normal path. Fixed
   to pick the message under the lock and log after release. Critical
   section is now minimal and uniform.

Plus polish:
- Stale module docstring fixed (said "empty list" instead of "None").
- settings.js validates each cors_origins line against a URL regex on
  save; toasts a one-shot warning if entries are malformed (resolver
  silently filters them, but user gets feedback now).
- web_server.py wiring passes request.scheme + X-Forwarded-Proto so
  the predictor has full proxy info.

Tests:
- 51 unit tests in tests/test_socketio_cors.py (was 45). New cases:
  * scheme comparison (5 cases including TLS-terminating proxies)
  * forwarded_proto-alone misconfig
  * missing-origin matches engineio (was asserting wrong behavior)
  * dedup cap with overflow + reset
  * default cap is reasonable (uses public DEFAULT_DEDUP_CAP constant)

Engineio behavior independently verified by reading engineio/server.py
and engineio/base_server.py source. Predictor mirrors both files.

604 tests pass.
2026-04-26 17:32:22 -07:00
Broque Thomas
013eebf350 Lock down Socket.IO CORS — same-origin default + opt-in allow-list
Closes #366 (reported by JohnBaumb).

Socket.IO was initialized with `cors_allowed_origins='*'`, accepting
WebSocket connections from any origin. A malicious site could open a
WS to a user's local SoulSync instance and exfiltrate live progress /
toast / activity events.

This commit:

- Defaults to engineio's same-origin behavior (`cors_allowed_origins=None`),
  which automatically honors X-Forwarded-Host so reverse proxies that
  send that header (Caddy / Traefik by default, properly-configured
  Nginx) work transparently.
- Adds a `security.cors_origins` config setting + Settings → Security
  textarea where users behind unusual proxies / Electron wrappers /
  cross-origin integrations can whitelist their origin. Accepts comma
  or newline separated values; `*` on its own line opts back into the
  legacy wildcard with a startup-warning log.
- Logs a clear warning the first time engineio rejects each unique
  origin, naming the rejected Origin and request Host and pointing
  users to the settings field. Without this, engineio silently 403s
  the upgrade and the user just sees a half-broken UI with no clue
  why. Threadsafe dedup so a hostile origin can't spam logs.

Logic lives in `core/socketio_cors.py` (resolver, rejection
predictor, dedup logger class, startup-status emitter) — pure
functions, no Flask dependency. `web_server.py` adds 23 lines of
wiring and imports.

Important catch during review: my first pass used `cors_allowed_origins=[]`
as the "secure default." Reading engineio's source revealed `[]` actually
means "DISABLE CORS HANDLING" (engineio/server.py:202: `if cors_allowed_origins != []:`)
— identical security to `'*'`. Fixed to use `None` (engineio's actual
same-origin sentinel) and pinned with a regression test that asserts
the resolver never returns `[]` for any input shape.

Tests:
- tests/test_socketio_cors.py — 45 unit tests covering 19 resolver shape
  cases (None, empty, whitespace, comma, newline, garbage types, lists),
  the `[]`-must-never-be-returned security regression, 12 rejection
  prediction cases, X-Forwarded-Host handling, dedup logger behavior,
  threadsafe race (8 threads × 50 hammers → exactly 1 warning), and
  startup-status emitter outputs.

Frontend:
- Settings → Security gains an "Allowed WebSocket Origins" textarea
  with help text explaining same-origin default + when to add a domain
  + the `*` opt-out.
- helper.js — new '2.4.1' WHATS_NEW block (hidden until version bump)
  with a chill-voice entry describing the change.

Conftest.py left at `'*'` — test environment, no security concern.

598 tests pass.
2026-04-26 16:27:10 -07:00
Broque Thomas
7714b51a50 Lift version modal data into helper.js, delete /api/version-info
The version modal pulled its content from /api/version-info — a 295-line
hand-curated Python dict in web_server.py. The "What's New" panel pulled
its content from WHATS_NEW in helper.js. Same release notes, two files,
two languages, hand-edited at every release — drift was inevitable
(and happened: the kettui-fix entries I added recently differed in
detail between the two surfaces).

This commit makes helper.js the single editing surface:

- Adds VERSION_MODAL_SECTIONS const in helper.js right beside WHATS_NEW,
  with a comment block documenting the relationship: WHATS_NEW is the
  per-version detailed log used by the helper popover; VERSION_MODAL_SECTIONS
  is the curated highlight reel shown by the sidebar version button. Both
  edited at release time, both in the same file.
- Rewires showVersionInfo() in downloads.js to read from those consts
  directly. No backend round-trip; the changelog content ships in the
  same JS bundle the browser already loaded.
- Deletes the /api/version-info route and its 295-line version_data dict.
- Updates the line-39 comment to drop the now-stale "version-info endpoint"
  reference.

Note: this is collocation, not true unification. WHATS_NEW and
VERSION_MODAL_SECTIONS are still two distinct structures with overlapping
content, linked by a comment convention rather than a shared schema. A
deeper refactor (e.g. a `featured` flag on WHATS_NEW entries that the
modal aggregates) was rejected as out-of-scope — the curated section
titles ("Earlier in v2.3", "Recent Fixes") aren't 1:1 mappable to
WHATS_NEW entries. Saving for a follow-up if the drift problem persists.

Risk audit:
- Load order: helper.js loads at line 7967, downloads.js at line 7873.
  Both classic scripts execute synchronously before any clickable
  interaction, so showVersionInfo (only invoked on the version-button
  onclick) always sees both consts defined.
- populateVersionModal() unchanged — receives the same {title, subtitle,
  sections: [{title, description, features, usage_note?}]} shape.
- Stale-cache window during deploy: old downloads.js hitting a 404 on
  the deleted endpoint falls through to the existing catch + toast path
  ("Failed to load version information"). Cache-buster ?v=static_v
  resolves on next page load.

553 tests pass. helper.js + downloads.js parse cleanly. No residual
references to /api/version-info anywhere in the repo.
2026-04-26 13:32:30 -07:00
Broque Thomas
edb2c2044f Delete dead historical changelog strings from web_server.py
_OLD_V22_NOTES (655 lines) and _OLD_V2_NOTES (556 lines) were
triple-quoted Python strings holding old release-notes JSON. No code
references them — `grep _OLD_V22_NOTES|_OLD_V2_NOTES` returns only
the definitions themselves. They were leftover from earlier
version-info refactors and have been sitting in the file unread.

Pure deletion. No behavior change.
2026-04-26 13:22:07 -07:00
Broque Thomas
8ed6ccbb4e Bump version to 2.4.0 for dev → main release
- _SOULSYNC_BASE_VERSION → 2.4.0 (was 2.39).
- Migrate WHATS_NEW key '2.40' → '2.4.0', strip unreleased flags off
  the 27 entries shipping in this release, set release date.
- Replace parseFloat() version compare with proper int-tuple semver
  comparator — parseFloat('2.4.0') and parseFloat('2.4.1') both return
  2.4, which would have made future patch bumps invisible to the
  What's New surfacing logic.
2026-04-26 10:18:50 -07:00
Broque Thomas
37aefd2ff1 Reorganize queue: race + dedupe fixes from kettui review
Five issues kettui flagged on PR #377:

- Worker race (reorganize_queue.py): _next_queued() picked an item and
  released the lock, then re-acquired to flip status='running'. A
  cancel() landing in that window marked the item cancelled but the
  worker still ran it. Replaced with _claim_next_or_wait() that picks
  AND flips under one lock acquisition.

- Wakeup race (reorganize_queue.py): _wakeup.clear() after the empty
  check could lose an enqueue's _wakeup.set(), parking a freshly-queued
  album for up to 60 seconds. Replaced Lock + Event with a single
  threading.Condition; cond.wait() releases and re-acquires atomically
  on notify.

- Bulk dedupe (reorganize_queue.py:enqueue_many): looped single-item
  enqueue, so a duplicate album_id later in the same batch could slip
  through if the worker finished the first copy before the loop
  reached the second. Now holds the lock for the whole batch and tracks
  a per-batch seen set, so intra-batch duplicates dedupe against each
  other and not just pre-existing items.

- Preview button stuck disabled (library.js:loadReorganizePreview):
  early returns and thrown errors skipped the re-enable line. Moved
  state into a canApply flag committed in finally, so any exit path
  lands the button correctly.

- DB helpers swallowing failures (music_database.py): get_album_display_meta
  and get_artist_albums_for_reorganize used to catch every Exception
  and return None / [], so a real DB outage masqueraded as "album not
  found" / "no albums". Now lets exceptions bubble; the route layer
  already wraps them as 500.

Tests:
- test_cancel_and_run_are_mutually_exclusive — hammers enqueue+cancel
  pairs and asserts the invariant that no successfully-cancelled item
  ever ran (catches regressions to the atomic pick).
- test_enqueue_many_dedupes_batch_internal_duplicates — pins the
  intra-batch dedupe.
- test_get_album_display_meta_propagates_db_errors and
  test_get_artist_albums_for_reorganize_propagates_db_errors — pin
  the bubble-up behavior.

Changelog updated in helper.js and version modal.
2026-04-26 08:40:24 -07:00
Broque Thomas
d6094a3587 Library reorganize: FIFO queue with live status panel
Replaces the single-slot "one reorganize at a time, return 409 on collision"
model with a per-user FIFO queue. Buttons stay clickable, "Reorganize All"
is one backend call instead of an N-call JS loop, and a status panel mounted
at the top of the artist actions bar shows live progress (active item,
queued count, recent completions) with per-item cancel buttons.

Backend
- core/reorganize_queue.py: singleton queue + worker thread, dedupe-on-
  enqueue, cancel rules (queued cancellable, running not), enqueue_many
  for bulk operations, progress fan-out via update_active_progress
- core/reorganize_runner.py: factory builds the worker's runner closure
  with injected dependencies. Reads config per-call so changing the
  download path in Settings takes effect on the next reorganize without
  a server restart
- database/music_database.py: get_album_display_meta and
  get_artist_albums_for_reorganize — moves the SQL out of route handlers
- web_server.py: thin enqueue/snapshot/cancel/clear endpoints, runner
  registration at module load. Old _reorganize_state globals + status
  endpoint deleted. Static-asset cache buster (?v=<server-start>)
  added so JS/CSS updates ship live without users clearing cache

Frontend
- webui/static/library.js: status panel mount, polling (1.5s when
  active, 8s when idle), expand/collapse, per-item cancel, debounced
  enhanced-view reload (one reload per artist batch instead of N).
  Per-album reorganize button paints with queued/running indicator
  and short-circuits to a toast when the album is already in queue
- webui/static/style.css: panel + button styling matching the existing
  glass-UI accents
- webui/static/helper.js + version modal: WHATS_NEW entry

Tests (22 new)
- tests/test_reorganize_queue.py (19 tests): FIFO order, dedupe,
  per-item source, cancel rules, continue-on-failure, snapshot
  shape, progress propagation, bulk enqueue
- tests/test_reorganize_runner.py (4 tests): per-call config reads,
  setup-failure summary, dependency injection, progress fan-out
- tests/test_reorganize_db_methods.py (7 tests): SQL JOIN behavior,
  ordering, fallback for blank strings, artist isolation

Full suite 549 passed in 27s.
2026-04-25 18:01:32 -07:00
Broque Thomas
7e1c4c26ec Reorganize: fix moved-count + status/total UX issues from PR #377 review
Four changes addressing kettui's PR #377 review comments:

1. **`_finalize_track` no longer over-counts on DB failure (🔴 bug).**
   The function previously bailed on DB-update failure but
   `_process_one_track` still incremented `summary['moved']`
   unconditionally — overstating how many tracks the UI knows are
   at their new locations. Fixed by:
   - `_finalize_track` now returns ``bool`` (True only when DB row
     was updated AND original was dealt with)
   - Caller checks the return; on False, records as a failed track
     with a clear message ("Track landed at new location but DB
     update failed — file is at both old and new paths until library
     scan re-indexes")
   - Existing `test_db_update_failure_leaves_original_in_place` now
     also asserts `moved == 0`, `failed == 1`, and that the error
     message names the cause

2. **`executeReorganize` toast no longer says "undefined tracks" (🐛
   bug).** `/reorganize` doesn't return `result.total` anymore (the
   track count is determined server-side after planning), so the
   "Reorganizing undefined tracks..." string was meaningless. Now uses
   `result.message` from the backend instead.

3. **`_pollReorganizeStatus` distinguishes completed from skipped
   (🟡 risk).** Backend now propagates the orchestrator's status
   (`completed` / `no_source_id` / `no_album` / `no_tracks` /
   `setup_failed` / `error`) into `_reorganize_state['result_status']`
   so the frontend can warn appropriately. Two new helpers:
   - `_classifyReorganizeOutcome(state)` — returns 'success' only
     when `result_status === 'completed'` AND `failed === 0`;
     'warning' otherwise
   - `_formatReorganizeResultMessage(state)` — returns a message
     specific to the outcome ("Reorganize skipped — album has no
     metadata source ID. Run enrichment first." for `no_source_id`,
     etc.)
   Zero-failure non-completed runs now show as warnings instead of
   green checkmarks.

4. **Bulk mode no longer counts skipped albums as succeeded (🟡
   risk).** `_executeReorganizeAll`'s loop was treating any HTTP
   200 response as success, ignoring the orchestrator's actual
   outcome for that album. Fixed by:
   - `_waitForReorganizeComplete()` now resolves with the final
     state object (was: void)
   - Loop checks `finalState.result_status === 'completed'` AND
     `finalState.failed === 0` before counting `succeeded++`;
     otherwise increments `skipped` (with a per-album warning
     toast) or `failed` accordingly
   - Final summary toast now reads
     "Reorganized N of M albums, K skipped, J failed" and only
     shows green when nothing was skipped or failed

All four addressed in a single commit because they form one
coherent UX-correctness fix — the bug bug (#1) and the count-
overstatement bug (#4) both made the user see "everything succeeded"
when reality was different. Together they make the UI honestly
reflect what actually happened.

Files:
- core/library_reorganize.py — `_finalize_track` returns bool,
  `_process_one_track` reads it
- web_server.py — `_reorganize_state['result_status']` populated
  from orchestrator's summary on success and on exception
- webui/static/library.js — `_classifyReorganizeOutcome` /
  `_formatReorganizeResultMessage` helpers, single-album +
  bulk-mode flows both consume them
- tests/test_library_reorganize_orchestrator.py — strengthened
  the existing DB-failure test to assert moved/failed counts

Credit: kettui — four PR #377 review comments named all of these
precisely with line numbers and severity.
2026-04-25 09:07:44 -07:00
Broque Thomas
2b15260b88 Reorganize: route library files through the post-processing pipeline
Reported on Discord by winecountrygames. The library "Reorganize" tool
had several layered bugs that all traced to the same root cause: the
endpoint reinvented every wheel post-processing already turns — its own
template engine, its own disc-number resolution from file tags, its own
sidecar sweep, its own collision detection — and each had drifted from
the canonical path used by fresh downloads. Reported symptoms:

  - 3-disc Aerosmith deluxe collapsed to a flat single-disc layout
  - Half the tracks on other albums silently skipped, no error / no count
  - Re-runs left empty leftover album folders cluttering the artist dir

Architecture: stop reinventing wheels. Route reorganize through exactly
the same pipeline downloads use. Per-album:

  1. Fetch the canonical tracklist from a metadata source (Spotify /
     iTunes / Deezer / Discogs / Hydrabase) using the album's stored
     source IDs. New `core/library_reorganize.py::plan_album_reorganize`
     does this — primary-source-first, fall through priority chain
     unless the user picked a specific source in the modal (strict mode).
  2. For each local track, find the matching API entry via a scored
     candidate matcher. Score components: exact-title (100),
     substring-with-length-ratio (40-90), track-number agreement (20).
     Hard reject when the two titles have different version
     differentiators (Remix vs no-remix means different recordings,
     not annotation drift). Below threshold = unmatched, surfaced as
     "not in source's tracklist, left in place" rather than silently
     mis-routing.
  3. Copy the file to a per-album staging directory, build the same
     context dict the import flow builds (`spotify_album` /
     `track_info` / etc. with `is_album_download=True` so the path
     builder enters ALBUM mode, not SINGLE mode), call
     `_post_process_matched_download(...)` — same function fresh
     downloads use. Post-process handles tagging, multi-disc subfolder
     decisions, sidecar regeneration, AcoustID verification.
  4. Read `context['_final_processed_path']` to learn where it landed.
     Update `tracks.file_path` in the DB BEFORE removing the original
     (DB-update failure leaves the file at both locations, recoverable
     via library scan; the reverse would orphan the row). Delete
     per-track sidecars (post-process recreates them at the new
     destination).

3 concurrent workers per album via ThreadPoolExecutor, matching the
download path's per-batch worker count. State mutations all guarded by
a single lock; staging filenames carry a UUID prefix so concurrent
copies of identically-named source files don't overwrite each other.

Source picker in the modal lets the user choose which source to read
the tracklist from. Two endpoints feed it:
  - `/api/library/album/<id>/reorganize/sources` — sources for THIS
    album that are both authed AND have a stored ID. For the per-
    album modal.
  - `/api/library/reorganize/sources` — all authed sources globally.
    For the bulk "Reorganize All" modal where per-album ID coverage
    varies.
When the user picks a specific source, the orchestrator runs in
`strict_source=True` mode (no fallback chain) — picking Spotify means
"use Spotify or fail", not "use Spotify and silently fall back."

Preview endpoint shares the same planning logic as apply via
`preview_album_reorganize` — the destination path comes from the same
`_build_final_path_for_track` post-process uses, so what you see in
the preview is exactly what you get on apply.

Empty destination folders (from earlier failed runs OR from the
current run when post-process creates a dir then fails AcoustID)
get cleaned up after each successful run: walk up to the artist
folder from any successful destination, prune empty album-sibling
folders one level deep. Bounded scope = won't touch unrelated user
dirs.

Web_server.py shrinks by ~450 net lines. The endpoint handler is now
a thin wrapper that builds injected callables (path resolver, post-
process function, DB updater, empty-dir cleaner), spawns a thread
that calls `reorganize_album()`, and returns. All actual logic lives
in `core/library_reorganize.py` where it's unit-testable without
spinning up Flask.

Frontend cleanup: the per-call template input in both reorganize
modals (per-album and bulk) was redundant — the backend always uses
the configured global download template. Removed the input and the
variables-grid reference UI it was for.

39 new unit tests pin every contract:
  - source resolution (no_source_id when album has none, fallthrough
    chain when primary returns nothing, strict mode bypasses fallback)
  - matcher scoring (exact / substring / multi-disc disambiguation /
    smart-quote tolerance / dash-vs-parens / bonus-track substring /
    Remix-vs-original differentiator rejection / "Real" doesn't false-
    match "Real Real Real" / track-number-only no longer fires)
  - file safety (DB-update failure leaves original in place, post-
    process failure leaves original in place, post-process exception
    caught and original preserved, success removes original AND
    updates DB in the right order)
  - sidecar handling (per-track .lrc/.nfo deleted on success, kept on
    failure; album-level cover.jpg/folder.jpg cleaned only when
    directory has no remaining audio)
  - staging cleanup (recreated between tracks because post-process
    nukes it, dir cleaned up on success AND on failure)
  - destination-dir prune (empty siblings removed, real album with
    files preserved, no recursive sweep)
  - source picker (only authed-with-stored-ID sources for per-album,
    all authed sources for bulk; strict mode doesn't fall back)
  - concurrency (3 workers in flight, state stays consistent under
    races, stop_check cuts off pending tasks)
  - preview parity (preview produces same destination as apply for
    multi-disc; ALBUM mode not SINGLE mode; unmatched/no-path tracks
    surfaced with reasons)

Limitations (deliberate punts, NOT in this PR):
  - Renamed local titles on multi-disc albums where track_number
    also disagrees: matcher returns nothing (track is "not in
    source"). Fixable by using duration_ms as a tertiary signal.
  - Per-track in-modal source switching with per-album track-count
    hints (would need a second API call before opening the modal).
  - UI status panel on the artist page during a run — currently
    just toasts. Documented as a follow-up PR.

Files:
  - core/library_reorganize.py — new module: plan_album_reorganize,
    preview_album_reorganize, reorganize_album, available_sources_for_album,
    authed_sources, _score_candidate, helpers for staging/post-
    processing/finalizing, sidecar + dest-dir cleanup
  - core/metadata_service.py — no changes; reused get_album_for_source,
    get_album_tracks_for_source, get_source_priority,
    get_client_for_source
  - web_server.py — three endpoints (preview / apply / sources GETs)
    are thin wrappers; -450 net lines
  - tests/test_library_reorganize_orchestrator.py — 39 tests covering
    every contract above
  - webui/static/library.js — source picker UI in both modals; dead
    template input + variables-grid removed
  - webui/static/style.css — dropdown option styling fix (white-on-
    white was unreadable)

Reported on Discord by winecountrygames — his bug report named the
trigger button (Enhanced view → Reorganize All) and both symptoms
(multi-disc collapse, half-album skip), which let the diagnosis go
straight to the architectural problem.
2026-04-24 23:00:22 -07:00
Broque Thomas
b3722449fc MusicBrainz: Fix artist images, total_tracks off-by-one, and Artist+Title queries
Three bugs from kettui's follow-up review pass on the MusicBrainz
search PR, all fixed in one commit because they share UI context.

1. Missing artist images on MB artist results

MusicBrainz doesn't store artist images directly. My earlier commit
returned `image_url=None` on every artist result and trusted the
frontend's lazy-loader — but the lazy-loader's `/api/artist/<id>/image?
source=musicbrainz` endpoint had no handler for MusicBrainz, so it
silently returned None and the emoji placeholder stayed.

Fix plumbs the artist name through:
- `renderCompactSection` stashes `data-artist-name` on artist cards.
- `search.js` and `downloads.js` lazy-loaders pass `name=<artist>` as a
  query param.
- `/api/artist/<id>/image` accepts an optional `name` param.
- `metadata_service.get_artist_image_url` has a new `musicbrainz`
  branch: since MB has no artist art, it searches fallback sources
  (iTunes/Deezer by configured priority) for the artist name and
  returns the first image found.

Verified live — Metallica/Kendrick Lamar/Daft Punk all resolve to
Deezer artist images via the name lookup.

2. total_tracks off-by-one on tracks with a release

`_recording_to_track` initialized `total_tracks = 1` and then summed
media track-counts on top. For an 11-track album, it reported 12. An
adapter-level regression introduced when the recording-projection
helper was extracted during the main MB refactor.

Fix: initialize at 0, sum normally. Standalone recordings with no
release (can happen for uncredited remixes etc.) still report 1 via
an explicit fallback — so the existing "single track" case isn't
broken.

3. "Artist Album Title" queries buried specific albums in the
   discography list

Bare-name queries like "The Beatles Abbey Road" used to resolve "The
Beatles" as the artist and then browse their full discography — Abbey
Road was buried alphabetically among 200+ releases instead of being
the top result.

Fix adds a title-hint extractor. When the query starts with the
resolved artist name followed by more words, the trailing portion is
treated as a title hint. Browse results are filtered to those whose
release-group title contains the hint. If the filter matches nothing,
falls back to text-search with the hint as the title (the "keep the
old split-by-whitespace fallback" path kettui called for). If text-
search also misses, shows the full discography rather than nothing.

10 new tests in tests/test_musicbrainz_search.py (46 total):
- Title-hint extractor: basic match, case-insensitive, whitespace
  tolerance, bare-artist-no-hint, artist-not-prefix-no-hint, word-
  boundary required (no false splits on "Metallicasomething").
- Browse filtering by title hint.
- Text-search fallback when the title hint matches nothing in browse.
- Bare-artist queries return the full discography unfiltered.
- total_tracks for single-release, multi-disc, and no-release cases.
2026-04-24 10:17:59 -07:00
Antti Kettunen
7285c6f55a
fix: more thorough handling for internal image url fixes 2026-04-24 10:12:52 +03:00
Broque Thomas
325292ce5a Treat Soulseek as configurable in source picker (require slskd_url)
Cin flagged that Soulseek was always rendered as configured in the
source picker, even on dev instances with no slskd set up — letting
users click it and fire searches that could never succeed.

Three coordinated changes:

1. web_server.py SERVICE_CONFIG_REGISTRY: add Soulseek entry requiring
   `slskd_url`. /api/settings/config-status now reports its real state
   alongside every other service.

2. shared-helpers.js _ALWAYS_CONFIGURED_SOURCES: drop 'soulseek'. The
   set is now just MusicBrainz + YouTube Music Videos (sources that
   genuinely don't need user creds). Soulseek goes through the normal
   config-status code path.

3. shared-helpers.js openSettingsForSource: special-case Soulseek to
   route to Settings → Downloads tab (where slskd URL field lives,
   gated behind the download-source-mode dropdown) and scroll to the
   #soulseek-url input. Every other source still routes to Connections
   and scrolls to its .stg-service card. Without this, Soulseek's
   "click to configure" landed on a Connections card that doesn't
   exist (Soulseek's URL/key fields are scoped to the download-source
   selection on the Downloads tab).
2026-04-23 22:28:47 -07:00
Broque Thomas
77d20e9aa8 Fix Clean Search History automation AttributeError on DownloadOrchestrator
The hourly `clean_search_history` automation was crashing with
`'DownloadOrchestrator' object has no attribute 'base_url'`. The guard
was written before the orchestrator refactor — `soulseek_client` is now
a DownloadOrchestrator that wraps individual download clients, with the
real Soulseek client sitting at `.soulseek`.

Two other call sites in web_server.py (lines 2634, 3092) already used
the correct `soulseek_client.soulseek.base_url` pattern with a getattr
guard. This call site was missed during the refactor.

Fix: reach through the orchestrator the same way the other sites do.
2026-04-23 18:01:34 -07:00
Broque Thomas
4b619951ff Fix UnboundLocalError in _check_and_remove_from_wishlist Method 4
When a completed download's track_info has neither an `id` field nor a
`wishlist_id`, Methods 1-3 of _check_and_remove_from_wishlist() all skip
without defining `wishlist_tracks`. Method 4 (fuzzy match) then hits
`if not wishlist_tracks:` and raises UnboundLocalError, which the call
sites catch + log but silently skip the wishlist removal for that track.

Path became more common after the batch-queue-system refactor started
routing non-Spotify-id completions (e.g. discover sync tracks downloaded
under a non-Spotify primary source) through the same completion handler.

Fix: initialize `wishlist_tracks = []` at the top of the try block so
Method 3's reassignment still works and Method 4's `if not wishlist_tracks`
guard always has a defined value to test.

Credit to RENOxDECEPTION (JohnBaumb) for pinpointing the variable-scope
issue during PR #357 testing.
2026-04-23 16:52:13 -07:00
Broque Thomas
14893c85a9 Extract _build_source_only_artist_detail into core/artist_source_detail.py
JohnBaumb's review: "If we're going to refactor the web_server.py soon,
might as well start moving stuff away from web_server.py in our PRs.
_build_source_only_artist_detail, make it a module, it's perfect."

This continues the pattern the prior commit started with the source-ID
lookup helpers: move the pure data-building logic to a side-effect-free
core module, leave a thin wrapper in web_server.py that bridges the
Flask response and the module-global clients.

**core/artist_source_detail.py** — pure function that takes the artist id,
name, and source plus dependency-injected per-source clients (spotify,
deezer, itunes, discogs) and a Last.fm API key. Returns
(payload_dict, http_status) so it isn't coupled to Flask.

**web_server.py wrapper** — builds the client bag from the module globals
(checks Spotify auth, constructs the Discogs client from the configured
token, reads the Last.fm API key) and wraps the core return in jsonify.
147 lines of logic go away from web_server.py; the 24-line wrapper is
purely glue.

**tests/test_artist_source_detail.py** — 21 focused tests covering the
response envelope, the source-specific ID-field stamping for all six
supported sources, the dedup_variants=False contract (the behaviour
that originally motivated the split of MetadataLookupOptions), per-source
genre/follower extraction with safe handling of missing or throwing
clients, and the Last.fm enrichment branch including the no-key and
error-path cases. Runtime 0.26s.
2026-04-23 08:09:29 -07:00
Broque Thomas
e66af77ff6 Make artist_name Optional in find_library_artist_for_source
Cin's review note: typing artist_name as plain `str` forced callers
that didn't have a name to pass `""` as a placeholder, which leaks the
parameter's emptiness contract into every call site and reads badly in
tests. Switching to `Optional[str] = None` lets callers omit it.

The function body's `if artist_name and active_server:` check already
handles None and "" identically, so no body changes were needed. Tests
that previously passed `artist_name=""` drop the argument; one new test
covers the omitted-arg path explicitly.

The web_server.py wrapper takes the same default for symmetry.
2026-04-22 22:15:03 -07:00
Broque Thomas
a097cf3d5a Extract source-artist lookup helpers from web_server.py to core module
Cin pointed out that the prior version of test_artist_source_lookup.py
AST-parsed web_server.py to verify a constant and to string-match a
function's response keys. That was a workaround for the fact that
web_server.py can't be imported at test time (it boots Spotify,
Soulseek, Plex, etc.) — the right answer is to move the logic into a
side-effect-free module so it can be imported and tested directly.

This commit:
  - adds core/artist_source_lookup.py containing the SOURCE_ID_FIELD
    map, the SOURCE_ONLY_ARTIST_SOURCES set, and find_library_artist_for_source
  - replaces the inline definitions in web_server.py with imports +
    a thin wrapper that injects the active media server
  - rewrites the tests to import from the core module directly:
      * mapping correctness is now a plain equality assertion
      * lookup behaviour is exercised against a real MusicDatabase
      * the AST parse and the string-matching contract test class are
        gone
  - drops the _build_source_only_artist_detail contract test entirely
    (the weakest of the four — it was just string-matching the function
    body); when that function moves to core/ it can get a real
    behavioural test alongside.

Test runtime drops from ~161s to ~5.8s. All 18 tests pass.
2026-04-22 22:07:23 -07:00
Broque Thomas
adcfd2db70 Fix 'no such column: deezer_artist_id' on watchlist artist config GET
The library-enrichment query inside /api/watchlist/artist/<id>/config
queries the `artists` table but used the column names from the
watchlist_artists table:

  WHERE spotify_artist_id = ? OR itunes_artist_id = ?
        OR deezer_artist_id = ? OR discogs_artist_id = ?

The `artists` table actually uses `deezer_id` and `discogs_id` for
those two columns (only `watchlist_artists` uses the `_artist_id`
suffix). The mismatch threw `no such column: deezer_artist_id` on
every config GET, which was caught by the surrounding try/except and
logged — releases came back empty and Spotify/genres etc. fell back
to defaults.

Visible side effects: the request that LOOKED slow ('1420.2ms') and
the recurring ERROR line in app.log every time a watchlist artist
overlay opened.

Watchlist-config GET now returns proper banner_url / summary / style
/ mood / label / genres for Deezer- and Discogs-source artists too.
The other watchlist queries in this endpoint (42302 / 42315 / 42379)
correctly target watchlist_artists and stay as-is.
2026-04-22 20:42:56 -07:00
Broque Thomas
9106617538 Show collection bars + Top Tracks for source artists, upgrade source clicks to library when possible
Three issues from screenshots:

1. .collection-overview was hidden for source artists (CSS rule too
   aggressive). It actually renders fine — just shows 0/N "missing"
   for each release type, which is useful info. Removed from the hide
   rules.

2. #artist-hero-sidebar (Top Tracks "Popular on Last.fm") was also
   hidden. The renderer (_loadArtistTopTracks in library.js) already
   fetches by artist name via /api/artist/0/lastfm-top-tracks, so it
   works for source artists too. Removed from the hide rules.

3. Clicking a source-artist result for someone you ALREADY have in
   the library was loading the bare source view instead of the
   library view (bug). Backend now does a "library upgrade" lookup
   in get_artist_detail: when the direct ID lookup misses but a
   source param is provided, search the artists table by the source-
   specific ID column (deezer_id / spotify_artist_id / etc.). If a
   match exists, use that library PK and the rest of the library
   path runs normally — owned releases, enrichment, completion
   bars, all the goodies you'd see if you'd clicked from Library.
   Falls back to a name match within the active server, then to the
   source-only response if nothing matches.

The remaining library-only items (artist-enrichment-coverage, Radio
button, Enhance Quality button) stay hidden for source artists since
they all require owned tracks.
2026-04-22 19:19:48 -07:00
Broque Thomas
5c94b87aea Pass lastfm api_key when enriching source-only artist response
LastFMClient() with no args has no api_key -> get_artist_info silently
returns None -> source artists never see bio/listeners/playcount even
though my previous commit was supposedly fetching them.

Now reads config_manager.get('lastfm.api_key') and only attempts the
enrichment when the key is configured. Users without Last.fm credentials
in Settings simply get image+name+badges+genres on source artists
(everything except bio + stats), which is fine.
2026-04-22 17:22:11 -07:00
Broque Thomas
f936b8cb12 Enrich source-only artist-detail response and skip discography dedup for source artists
Source artists landing on /artist-detail were rendering an almost-blank
hero — image + name + a tiny Download button — because the backend
response only had {id, name, image_url, server_source: null, genres: []}.
The library.js renderers do their best with what they have, and that
wasn't much.

Backend changes (_build_source_only_artist_detail):
  - Set the source-specific ID field (deezer_id / spotify_artist_id /
    itunes_artist_id / discogs_id / soul_id / musicbrainz_id) on
    artist_info so the corresponding service badge renders on the hero.
  - Try the source's own get_artist_info / get_artist for genres +
    followers (Spotify always; Deezer/iTunes/Discogs when available).
    Spotify also fills image_url if metadata_service.get_artist_image_url
    came up empty.
  - Last.fm enrichment by artist name — bio + listeners + playcount +
    lastfm_url. Mirrors what library artists get from the cached
    enrichment workers but on demand for source artists.
  - All enrichment lookups are wrapped in try/except so a 500 from any
    one source doesn't break the whole response.

Frontend (library.js populateArtistDetailPage):
  - Watchlist button now initialises for source artists too. Falls back
    to artist.id + artist.name when there's no canonical Spotify
    identity (which is the common case for non-library artists).

Discography dedup opt-out:
  - Added dedup_variants flag to MetadataLookupOptions (default True so
    library artists are unchanged). Source-only path now passes
    dedup_variants=False so every "Deluxe Edition" / "Remastered" /
    "Anniversary" variant the source returns is shown — matches the
    inline /artists page behaviour the user was comparing against.

Result: source artists' hero now shows badges + bio + listeners +
playcount + watchlist button + genres in addition to image and name.
Discography lists every release the source returns, not the deduped
canonical view.
2026-04-22 17:14:39 -07:00
Broque Thomas
1a8071d6ec Revert "Retire artists.js and inline Artists page, ship unification at 2.40"
This reverts commit 71ff5cb5c3.
2026-04-22 15:52:53 -07:00
Broque Thomas
71ff5cb5c3 Retire artists.js and inline Artists page, ship unification at 2.40
Part D + E of the deferred cleanup + the final version bump that
publishes the whole Search/Artists unification project.

Deletions:
  - webui/static/artists.js (1903 lines) — removed entirely. The 2
    remaining externally-referenced helpers (lazyLoadArtistImages +
    showCompletionError) moved into shared-helpers.js first.
  - webui/index.html — 140-line #artists-page HTML block and the
    <script src="artists.js"> tag both removed.

init.js wiring:
  - 'case artists:' removed from loadPageData switch (no page to init).
  - navigateToPage top-level alias extended: 'artists' → 'search'
    (same pattern as the existing 'downloads' → 'search' alias).
    Legacy /artists bookmarks land on the unified Search page, the
    natural place to find an artist now.
  - _getPageFromPath now maps artist-detail → library as its parent
    (was artists). Matches the existing library-nav-highlight at
    init.js:2161.

Version bump:
  - _SOULSYNC_BASE_VERSION 2.39 → 2.40.
  - WHATS_NEW entries lose the 'unreleased' scaffolding and gain a
    new top entry summarizing the unified artist-detail page + the
    final artists.js retirement.
  - version-info modal gets a 'Search & Artists Unification' section
    at the top.
  - The _getLatestWhatsNewVersion filter added during the unreleased-
    tracking phase is rolled back — entries now display as soon as
    they land in WHATS_NEW, matching the pre-unification behaviour.

Test suite:
  - tests/test_script_split_integrity.py SPLIT_MODULES updated:
    'artists.js' dropped, 'shared-helpers.js' added. escapeHtml's
    cross-file dupe list entry updated to reference shared-helpers.
  - 354/354 tests pass.

User-visible result after this commit:
  - Sidebar: Search, Downloads, Discover, Library, Wishlist, etc. —
    no more Artists entry.
  - Click any artist anywhere: lands on the same /artist-detail page.
  - Search page has a source dropdown; Soulseek is just another option.
  - Legacy /downloads and /artists URLs alias to /search.
  - Version button shows v2.3 (Docker major); "What's New" panel
    opens to the unification summary.

Closes the project Cin requested in Discord. Future work: source-aware
/api/artist-detail could be extended to fall back through the whole
source priority chain when a specific source is given but returns no
discography. Not needed for the current flows.
2026-04-22 15:38:32 -07:00
Broque Thomas
89754480be Source-aware /api/artist-detail: fall back to metadata source when not in library
Part A of the deferred unification cleanup. The standalone artist-
detail endpoint used to 404 whenever `artist_id` wasn't a local library
primary key, which is exactly what source artists (Deezer/Spotify/
iTunes/etc.) have. That forced the Phase 4a revert: source artists had
to use the inline Artists page because this endpoint couldn't handle
them.

New behaviour:
  - Library PK path — unchanged. Existing callers see the same response.
  - `/api/artist-detail/<id>?source=<src>&name=<name>` with source in
    (spotify, itunes, deezer, discogs, hydrabase, musicbrainz) — when
    the library DB lookup misses, synthesize a response by:
      • fetching artist image via metadata_service.get_artist_image_url
        with source_override (the helper already backing /api/artist/
        <id>/image)
      • fetching discography via metadata_service.get_artist_detail_
        discography with MetadataLookupOptions(source_override=source,
        artist_source_ids={source: artist_id})
      • returning { success, artist: {id, name, image_url, server_source:
        null, genres: []}, discography, enrichment_coverage: {} }
  - Library PK missing AND no source — preserves the 404 (caller didn't
    give enough info to fall back).

Frontend plumbing: library.js loadArtistDetailData now appends
?source=<src>&name=<name> to the fetch URL when
artistDetailPageState.currentArtistSource is set. The field is already
seeded by navigateToArtistDetail's third arg (added during the earlier
unification work), so no new state plumbing is needed.

populateArtistDetailPage gracefully handles the missing-library-data
case per earlier exploration — owned_releases empty is fine,
enrichment_coverage optional, spotify_artist_id optional.

Part B will re-route the source-artist callsites (Search / Discover /
Watchlist / etc.) back through navigateToArtistDetail so they actually
exercise this new fallback path.
2026-04-22 15:28:28 -07:00
Broque Thomas
78c14d3084 Hold version at 2.39 and fold unification changelog into one 2.40 entry
Reverts the 2.40→2.49 version spam from this session — every phase
commit was bumping the display version when the whole Search/Artists
unification project should really be a single release.

Changes:
  - _SOULSYNC_BASE_VERSION back to 2.39
  - All session-level version-info sections consolidated — the endpoint
    response is back to the pre-session 2.39 shape
  - helper.js WHATS_NEW entries for 2.40–2.49 collapsed into a single
    '2.40' block with one bullet per phase, marked unreleased
  - _getLatestWhatsNewVersion / _showOlderNotes filter out entries
    whose version is higher than the current build, so the 2.40 block
    won't fire the 'new' badge or appear in the What's New panel until
    we actually flip the build version
  - Picks up the artist-detail back-button fix from the previous turn
    (falls back to browser history when the user reached the inline
    detail from outside the Artists page)

When the unification project is done, a single commit that bumps
_SOULSYNC_BASE_VERSION to 2.40 will publish the whole folded entry.
2026-04-22 14:25:20 -07:00
Broque Thomas
19e9174866 Fix 404 on source-artist click — revert Phase 4a source migrations, bump to 2.48
Phase 4a (9361c29) mistakenly routed every artist click to
navigateToArtistDetail, which fetches /api/artist-detail/<id>. That
endpoint only knows how to look up local DB primary keys. For source
artists (Spotify/Deezer/iTunes/etc.) the id is a metadata-source id,
not a library PK — so clicks 404'd out.

Library artists (db_artists section in search results, library page
clicks, stats links, media player) continue to go to the standalone
/artist-detail page as before. Source artists now route back to the
Artists page's inline view via selectArtistForDetail, which calls
/api/artist/<id>/discography with a source param — the endpoint that
actually handles non-library IDs.

Reverted 7 migration points:
  - search.js: Enhanced Search source-artists onClick
  - downloads.js: global widget _gsClickArtist non-library branch
  - downloads.js: _navigateToArtistFromModal fallback
  - discover.js: viewRecommendedArtistDiscography
  - discover.js: viewDiscoverHeroDiscography
  - discover.js: 'Your Artists' card name-click inline HTML
  - discover.js: 'Your Artists' info-modal 'View All' button
  - discover.js: artist-map context menu
  - discover.js: genre-deep-dive artist click
  - api-monitor.js: watchlist artist discography view

Phase 4a's goal of "one artist page for everything" is deferred —
it needs backend work on /api/artist-detail to accept a source param
and fall back to metadata-source lookup when the local DB lookup
fails. Keeping the signature extension on navigateToArtistDetail
(source parameter) in place for when that lands.
2026-04-22 14:17:06 -07:00
Broque Thomas
d037643908 Clean up interactive help annotations for unified Search, bump to 2.47
Phase 4c of the Search/Artists unification — docs-only cleanup.

The click-for-help system and the 'Your First Download' guided tour
referenced elements that no longer exist (the Basic/Enhanced toggle,
the embedded download-manager toggle, the active/finished queue
panels). Updated annotations + tour steps to match the current UI.

  - New annotation for .search-source-picker-container (the dropdown)
  - Removed 6 annotations for deleted elements
  - 'first-download' tour now walks users through the source picker
    and uses page: 'search' (PAGE_TOUR_MAP accepts both 'search' and
    the legacy 'downloads' id so older bookmarks still match)
  - Retired the 'artists-browse' standalone tour — no sidebar entry
  - Dropped the dead #finished-queue detection in the setup milestone
    check (the dashboard stat card is the single source of truth)
2026-04-22 13:59:42 -07:00
Broque Thomas
09f15ce7d2 Retire Artists sidebar entry, redirect entry points to Search, bump to 2.46
Phase 4b of the Search/Artists unification. Cin flagged that 'Artists'
in the sidebar read like a library section but was actually a
dedicated artist-search page, duplicating what unified Search already
does. Removed the sidebar entry so users funnel through Search.

  - Sidebar Artists button gone
  - 'Browse Artists' on empty Watchlist now opens Search
  - 'View artist from Wishlist' opens Search pre-filled with the name
  - Profile Home Page + Page Access drop the Artists option

artists.js stays on disk: it defines ~30 shared helpers used across
the app (escapeHtml, openDownloadMissingModalForArtistAlbum, service
status, download bubbles, image helpers) that library/discover/etc.
depend on. Wholesale deletion would orphan too much. The inline
Artists page and its selectArtistForDetail flow are still there —
just unreachable from the sidebar — so /artists deep links keep
working for bookmarks.
2026-04-22 13:40:22 -07:00
Broque Thomas
9361c29965 Route all artist-detail callers to the standalone page, bump to 2.45
Phase 4a of the Search/Artists unification. The app had two artist-
detail implementations: the standalone page Library navigates to via
navigateToArtistDetail (its own route, deep-link support, highlights
Library in the sidebar), and an inline state inside the Artists page
reached via selectArtistForDetail. They rendered similar content but
were separate code paths and kept drifting apart (PR #356 just had
to fix source propagation in both).

Every external caller of selectArtistForDetail (9 sites across
api-monitor.js, discover.js, downloads.js, search.js) now calls
navigateToArtistDetail(id, name, source) directly. Removed ~63 lines
of the navigate-then-setTimeout-then-select dance. Source context
(Spotify/iTunes/Deezer/etc.) carries cleanly through via the new
third argument.

Artists sidebar entry, its inline search, and selectArtistForDetail
all still work — they just have no external callers. Phase 4b will
retire the sidebar entry and artists.js.
2026-04-22 13:36:36 -07:00
Broque Thomas
f203b3e46d Remove embedded Download Manager from Search page, bump to 2.44
Phase 3c of the Search/Artists unification. The Search page carried
a second copy of the Download Manager (active + finished queues,
clear/cancel-all buttons) that was hidden by default and duplicated
the dedicated Downloads page. That duplicate is now gone.

Removed:
  - Side-panel HTML block and the toggle button that showed/hid it
  - ~290 lines of polling + render infra in downloads.js: loadDownloads-
    Data, startDownloadPolling/stopDownloadPolling, updateDownload-
    Queues, renderQueue, updateTabCounts/updateDownloadStats,
    initializeDownloadTabs/switchDownloadTab, cancelDownloadItem,
    clearFinishedDownloads, cancelAllDownloads, and the
    activeDownloads/finishedDownloads globals
  - initializeDownloadManagerToggle and its call from init.js
  - Stopped hitting /api/downloads/status every second on the Search
    page (the dedicated Downloads page already polls its own view)

CSS grid for the Search page collapsed from '1fr 370px' to '1fr' now
that the right panel is gone. Unused .controls-panel__* / .download-
manager__* / .downloads-side-panel CSS rules kept in place — harmless,
can be pruned later.
2026-04-22 13:31:42 -07:00
Broque Thomas
6992e2e5b5 Rename Search page id from 'downloads' to 'search', bump to 2.43
Phase 3b of the Search/Artists unification. The Search page's
internal id was 'downloads', which clashed with the actual Downloads
page (id 'active-downloads') and confused anyone reading the code.
Renamed to 'search' across HTML, navigation, DOM selectors, and the
deep-link route list.

Backwards compat: navigateToPage('downloads') aliases to 'search'
at the top of the function; /downloads URL still serves index.html
and the client router resolves the page correctly; profile ACL
checks accept both 'search' and 'downloads' so existing profiles
with 'downloads' in allowed_pages keep working without migration.

Sidebar label unchanged. Zero visual change — pure internal tidy.
2026-04-22 13:22:49 -07:00
Broque Thomas
68d46c5aba Replace Enhanced/Basic toggle with source picker, bump to 2.42
Phase 3 of the Search/Artists unification. The Search page's two-mode
toggle is replaced by a single 'Search from' dropdown: All sources
(Auto), Spotify, Apple Music, Deezer, Discogs, Hydrabase, MusicBrainz,
or Soulseek (raw files). Auto keeps today's fan-out behavior for
backwards compatibility; picking a specific source hits only that
provider. 'Soulseek' routes to the raw-file basic section, so one
picker covers both old modes. Loading text and the enhanced fetch
now respect the selected source. Zero API changes — uses the source
param added in 2.40 and the shared fetch helper from 2.41.
2026-04-22 13:06:13 -07:00
Broque Thomas
377343326f Dedupe enhanced-search fetch in widget and page, bump to 2.41
Phase 2 of the Search/Artists unification: the Search page dropdown
and the global spotlight widget both POST to /api/enhanced-search
with identical boilerplate. Extracted into enhancedSearchFetch() in
search.js (loaded before downloads.js). Both callers migrated. Zero
UX change — purely sets up Phase 3 to wire a source picker in one
place instead of two.
2026-04-22 12:59:05 -07:00
Broque Thomas
952c35de39 Add source param to /api/enhanced-search, bump to 2.40
Phase 1 of the Search/Artists unification project: the endpoint now
accepts an optional `source` (spotify, itunes, deezer, discogs,
hydrabase, musicbrainz) so callers can target a single metadata source
instead of always fanning out. Omitted or `auto` preserves current
multi-source behavior — no existing callers break. Cache keys include
the source tag so per-source and fan-out results don't collide.
2026-04-22 12:49:49 -07:00
Broque Thomas
8f85b0c251 Fix silent wrong-artist track downloads (Maduk/Tom Walker bug)
User reported searching "Maduk - Leave A Light On" on Tidal silently
downloaded Tom Walker's completely different song of the same name, then
embedded Maduk's metadata into Tom Walker's audio. Three layers of
defense all failed permissively. Two of them are fixed here; the third
(score formula weights) was left alone since these two together cover it.

Layer 1 fix — candidate artist gate (web_server.py:27782)
  Old: `if _best_artist < 0.4 and confidence < 0.85: continue`
  New: `if _best_artist < 0.5 and confidence < 0.85: continue`

  SequenceMatcher returns exactly 0.400 for "maduk" vs "tom walker"
  (5-char vs 10-char strings with coincidental char matches), which
  slipped past the strict `< 0.4` check. The word-boundary containment
  check earlier in the function already short-circuits legitimate
  formatting variations to sim=1.0, so falling to SequenceMatcher means
  strings are genuinely different. 0.5 closes the fencepost AND gives
  a small safety buffer.

Layer 3 fix — AcoustID verification (acoustid_verification.py:316)
  When title matches but artist doesn't AND expected artist isn't found
  anywhere in AcoustID's returned recordings:
    Old: always SKIP (let file through, assume cover/collab)
    New: FAIL if artist_sim < 0.3 (clear mismatch)
         SKIP if artist_sim >= 0.3 (ambiguous — cover/collab/formatting)

  The 0.3 cutoff catches hard mismatches like Maduk/Tom Walker (sim ~0.2)
  while preserving benefit-of-the-doubt for borderline artist formatting
  differences. Legitimate covers and collabs where the expected artist
  appears anywhere in AcoustID's recordings still PASS via the existing
  secondary-match loop above.

Both fixes are defense-in-depth — either alone would have caught this
bug. Together they close the pre-download AND post-download gaps.

All 292 tests pass. Version bumped to 2.39 with changelog entries.
2026-04-22 10:32:55 -07:00
Broque Thomas
78fa83c8ac Add $cdnum template variable for multi-disc filenames
New smart template variable that emits "CD01" / "CD02" etc. in filenames
on multi-disc albums, and expands to empty string on single-disc albums
so mixed libraries don't end up with "CD01" on every single.

Template behaviour:
- total_discs > 1 -> "CD{disc:02d}" (zero-padded, CD prefix)
- total_discs <= 1 -> empty string
- Both $cdnum and ${cdnum} bracket form supported
- Empty value collapses cleanly via existing double-dash regex plus new
  leading-dash cleanup pass

Wiring:
- _apply_path_template in web_server.py (download pipeline)
- _apply_path_template in core/repair_jobs/library_reorganize.py
  (Reorganize repair job)
- total_discs added to every album-mode template context:
  * download pipeline album branch (uses resolved total_discs even for
    single-track downloads from search)
  * per-album Reorganize preview + apply endpoints (pre-scan all track
    tags once, take max disc_number)
  * Library Reorganize repair job (already had album_total_discs map,
    just added to context dict)

Leading-dash cleanup added to _get_file_path_from_template (web_server)
and _build_path_from_template (library_reorganize) so templates like
"$cdnum - $track - $title" don't leave "- 05 - Title" on single-disc
albums.

UI:
- Template hint in Settings -> File Organization documents $cdnum
- Template validation variable list includes $cdnum
- Reorganize modal variable reference shows $cdnum with example "CD01"

Verified:
- Multi-disc disc 1 -> "CD01 - 05 - Track"
- Multi-disc disc 2 -> "CD02 - 05 - Track"
- Single-disc      -> "05 - Track" (no leading dash)
- Templates without $cdnum behave unchanged
- 276/276 tests pass
2026-04-21 22:55:37 -07:00