Commit graph

4 commits

Author SHA1 Message Date
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
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