soulsync/core
Broque Thomas 8a6ee7a2c7 Auto-import: bounded ThreadPoolExecutor + per-candidate UI state isolation
# Concurrency model

Pre-refactor concurrency was emergent + unbounded:

- The worker's `_run` thread called `_scan_cycle` every 60s,
  processing candidates synchronously in a for-loop.
- The `/api/auto-import/scan-now` endpoint spawned a fresh
  `threading.Thread(target=_scan_cycle)` per click — extra parallel
  scan cycles on top of the timer.
- Multiple "Scan Now" clicks during in-flight processing → multiple
  threads racing on `_processing_paths` / `_folder_snapshots` state,
  no upper bound on concurrent scanners.
- `stop()` didn't wait for in-flight processing — could leave file
  moves / tag writes / DB inserts mid-flight.

Refactor to the pattern Cin uses elsewhere (`missing_download_executor`,
`sync_executor`, `import_singles_executor` all use
`ThreadPoolExecutor(max_workers=3, thread_name_prefix=...)`):

- **One scan thread** — both timer + manual triggers go through
  `trigger_scan()`, gated by a non-blocking `_scan_lock`. Duplicate
  triggers no-op instead of stacking parallel scanners.
- **Bounded executor** — `ThreadPoolExecutor` (default 3 workers,
  configurable via `auto_import.max_workers`) runs per-candidate
  work. Each candidate runs to completion in its own pool thread;
  up to N candidates run in parallel.
- `_scan_and_submit()` is fast — just enumeration + executor submit,
  returns immediately, doesn't block on per-candidate work.
- `_process_one_candidate(candidate)` holds the per-candidate logic
  identical to the old for-loop body, lifted into a method so the
  pool can run multiple instances concurrently.
- `_submitted_hashes` set + lock dedupes candidates across the
  timer + manual triggers so a candidate already queued / running
  doesn't get re-submitted.
- `stop()` calls `executor.shutdown(wait=True)` — clean shutdown,
  no orphaned file ops.

# Per-candidate UI state isolation

The executor refactor opened two concurrency holes that the old
sequential model masked. Both fixed in this commit:

1. **Scalar UI fields stomped across pool workers.** Pre-refactor
   `_current_folder` / `_current_status` / `_current_track_*` were
   safe under the sequential model — only one candidate processed
   at a time, so the fields tracked the in-flight one. With three
   pool workers writing the same fields, the polling UI saw garbage
   like "Processing AlbumA, track 7/14: SongFromAlbumB".
   Replaced with `_active_imports: Dict[hash, _ActiveImport]` keyed
   on folder_hash, gated by `_active_lock`. Each pool worker owns
   its own entry. Helpers `_register_active` / `_update_active` /
   `_unregister_active` / `_snapshot_active` are the only API.

2. **Stats counters not thread-safe.** `self._stats[k] += 1` is
   read-modify-write — under load, parallel pool workers drop
   increments. New `_stats_lock` + `_bump_stat()` helper wraps every
   mutation. `get_status()` reads under the same lock and returns
   a copy.

# Endpoint change

`/api/auto-import/scan-now` no longer spawns its own scan thread —
calls `auto_import_worker.trigger_scan()` (which routes through the
shared lock + executor). Multiple clicks while a scan is in flight
no-op deterministically. Endpoint still wraps the call in a daemon
thread so the HTTP response returns immediately even if the staging
walk is slow.

# Backward compat

The scalar `_current_folder` / `_current_status` / `_current_track_*`
fields are preserved as **read-only properties** that resolve to the
FIRST active import. The existing `get_status()` payload still
includes those fields populated from the first entry — single-import
UIs (and the test fixture) keep working unchanged. New
`active_imports` array exposes the full multi-candidate state for
parallel-aware UIs.

# Behavior preserved

- Per-candidate identify / match / process logic byte-identical
- Live-progress state preserved (per candidate now)
- Stability gate / already-processed dedup preserved
- `_record_in_progress` / `_finalize_result` UI rows preserved
- Tag-based loose-file grouping unchanged

# Behavior changes

- Multiple albums process IN PARALLEL up to `max_workers`
- "Scan Now" while scan in progress no-ops (was: spawned another)
- `stop()` waits for in-flight pool work via `shutdown(wait=True)`
- Auto-import card now lists each in-flight album (one line per
  active import) instead of a single shared progress line

# UI

`webui/static/stats-automations.js`:
- Progress widget reads `active_imports` array, renders one line
  per in-flight album with per-candidate status / track index
- Falls back to the legacy summary line when payload doesn't
  carry `active_imports` (older backend)
- Per-row "live processing" lookup now matches by `folder_hash`
  through the array instead of by `folder_name` against scalars

# Tests added (`tests/imports/test_auto_import_executor.py`)

- Pool config: default max_workers=3, configurable via constructor
  + via `auto_import.max_workers` config, floors at 1
- Scan lock: 5 concurrent `trigger_scan()` calls run only 1 scan
  while lock held; releases properly so subsequent triggers run
- Executor dispatch: 5 candidates → 5 process calls via the pool
- Bounded parallelism: max_workers=3 caps at 3 concurrent;
  max_workers=2 caps at 2
- Cross-trigger dedup: candidate submitted in scan A doesn't get
  re-submitted by scan B while still in-flight
- Graceful shutdown: `stop()` blocks until in-flight pool work
  finishes
- Per-candidate state isolation: 2 parallel workers updating their
  own candidate state don't interfere — each candidate's
  track_index / track_name / folder_name reads back exactly as
  written for that hash
- `get_status()` returns coherent `active_imports` array with
  one entry per in-flight candidate; aggregate top-level
  `current_status` is 'processing' when any entry is processing
- Unregister removes only that candidate, others stay visible
- Stats counter thread-safety: 1000 parallel bumps land at 1000
  (the read-modify-write race regresses without the lock)
- `get_status()` stats snapshot is a copy, not a live reference

# Verification

- 17 new tests pass (executor + state isolation)
- 2347 full suite passes (1 pre-existing flaky test —
  `test_watchdog_warns_about_stuck_workers` — passes in isolation,
  unrelated)
- Ruff clean
2026-05-09 17:45:42 -07:00
..
artists Surface silent exceptions in core/artists — 23 sites 2026-05-07 09:49:29 -07:00
automation Surface silent exceptions across remaining modules — ~70 sites 2026-05-07 10:28:58 -07:00
discovery Surface silent exceptions across remaining modules — ~70 sites 2026-05-07 10:28:58 -07:00
download_engine Add module logger + surface silent exceptions in 7 logger-less files — 12 sites 2026-05-07 10:27:04 -07:00
download_plugins Address Copilot doc-drift review 2026-05-05 15:46:48 -07:00
downloads Manual picks: stream results, don't auto-retry, fix stuck-at-0% 2026-05-08 15:12:58 -07:00
enrichment Surface silent exceptions across remaining modules — ~70 sites 2026-05-07 10:28:58 -07:00
imports Auto-import matching: fix Deezer source classification + bump tolerance 2026-05-09 15:53:17 -07:00
library Final silent-exception sweep + ruff S110 lint guardrail — ~45 sites 2026-05-07 11:16:06 -07:00
media_server Final review-pass nits — class docstring, dead branch, dead imports, boot resilience 2026-05-05 22:59:33 -07:00
metadata Auto-import: MBID/ISRC fast paths + duration sanity gate 2026-05-09 09:57:33 -07:00
playlists Final silent-exception sweep + ruff S110 lint guardrail — ~45 sites 2026-05-07 11:16:06 -07:00
repair_jobs Final silent-exception sweep + ruff S110 lint guardrail — ~45 sites 2026-05-07 11:16:06 -07:00
search Cin-6: Rename soulseek_client global → download_orchestrator 2026-05-04 23:23:32 -07:00
stats Surface silent exceptions across remaining modules — ~70 sites 2026-05-07 10:28:58 -07:00
streaming Cin-6: Rename soulseek_client global → download_orchestrator 2026-05-04 23:23:32 -07:00
watchlist Final silent-exception sweep + ruff S110 lint guardrail — ~45 sites 2026-05-07 11:16:06 -07:00
wishlist Surface silent exceptions across remaining modules — ~70 sites 2026-05-07 10:28:58 -07:00
workers Final silent-exception sweep + ruff S110 lint guardrail — ~45 sites 2026-05-07 11:16:06 -07:00
acoustid_client.py Clean up 286 ruff lint errors to unblock CI and fix 10 latent bugs 2026-04-21 13:30:52 -07:00
acoustid_verification.py Reject AcoustID matches whose version disagrees with the expected track 2026-05-07 13:25:30 -07:00
album_consistency.py Surface silent exceptions across remaining modules — ~70 sites 2026-05-07 10:28:58 -07:00
api_call_tracker.py Surface silent exceptions across remaining modules — ~70 sites 2026-05-07 10:28:58 -07:00
artist_source_detail.py Move metadata helpers into package modules 2026-04-29 11:28:42 +03:00
artist_source_lookup.py Make artist_name Optional in find_library_artist_for_source 2026-04-22 22:15:03 -07:00
audiodb_client.py Add API Rate Monitor dashboard with real-time speedometer gauges 2026-04-02 10:46:43 -07:00
audiodb_worker.py Surface silent exceptions in workers + repair jobs — ~30 sites 2026-05-07 10:27:24 -07:00
auto_import_worker.py Auto-import: bounded ThreadPoolExecutor + per-candidate UI state isolation 2026-05-09 17:45:42 -07:00
automation_engine.py Final silent-exception sweep + ruff S110 lint guardrail — ~45 sites 2026-05-07 11:16:06 -07:00
connection_detect.py Final silent-exception sweep + ruff S110 lint guardrail — ~45 sites 2026-05-07 11:16:06 -07:00
connection_test.py Cin-6: Rename soulseek_client global → download_orchestrator 2026-05-04 23:23:32 -07:00
database_update_worker.py Final silent-exception sweep + ruff S110 lint guardrail — ~45 sites 2026-05-07 11:16:06 -07:00
debug_info.py Surface silent exceptions across remaining modules — ~70 sites 2026-05-07 10:28:58 -07:00
deezer_client.py Add download buttons + bulk action to artist top-tracks sidebar 2026-05-07 15:44:47 -07:00
deezer_download_client.py Surface silent exceptions in metadata clients — 37 sites 2026-05-07 09:33:38 -07:00
deezer_worker.py Surface silent exceptions in workers + repair jobs — ~30 sites 2026-05-07 10:27:24 -07:00
discogs_client.py Surface silent exceptions in metadata clients — 37 sites 2026-05-07 09:33:38 -07:00
discogs_worker.py Surface silent exceptions in workers + repair jobs — ~30 sites 2026-05-07 10:27:24 -07:00
download_orchestrator.py Lift shared download dataclasses + boot via singleton factory 2026-05-05 09:08:39 -07:00
genius_client.py Add API Rate Monitor dashboard with real-time speedometer gauges 2026-04-02 10:46:43 -07:00
genius_worker.py Surface silent exceptions in workers + repair jobs — ~30 sites 2026-05-07 10:27:24 -07:00
genre_filter.py Expand default genre whitelist from 223 to 272 genres 2026-04-18 20:32:49 -07:00
hifi_client.py Surface engine-not-wired errors + exclude soulseek from monitor aggregation 2026-05-05 12:20:51 -07:00
hydrabase_client.py Surface silent exceptions across remaining modules — ~70 sites 2026-05-07 10:28:58 -07:00
hydrabase_worker.py Improve graceful shutdown and rollback safety 2026-04-12 15:17:18 +03:00
itunes_client.py Surface silent exceptions in metadata clients — 37 sites 2026-05-07 09:33:38 -07:00
itunes_worker.py Surface silent exceptions in workers + repair jobs — ~30 sites 2026-05-07 10:27:24 -07:00
jellyfin_client.py Final silent-exception sweep + ruff S110 lint guardrail — ~45 sites 2026-05-07 11:16:06 -07:00
lastfm_client.py Add Last.fm Track Radio to Discover page 2026-04-14 23:41:36 -07:00
lastfm_worker.py Surface silent exceptions in workers + repair jobs — ~30 sites 2026-05-07 10:27:24 -07:00
library_reorganize.py Final silent-exception sweep + ruff S110 lint guardrail — ~45 sites 2026-05-07 11:16:06 -07:00
lidarr_download_client.py Surface silent exceptions across remaining modules — ~70 sites 2026-05-07 10:28:58 -07:00
listenbrainz_client.py Remove emojis from all Python log and print statements 2026-04-11 21:11:02 -07:00
listenbrainz_manager.py Final silent-exception sweep + ruff S110 lint guardrail — ~45 sites 2026-05-07 11:16:06 -07:00
listening_stats_worker.py Final silent-exception sweep + ruff S110 lint guardrail — ~45 sites 2026-05-07 11:16:06 -07:00
lyrics_client.py Remove emojis from all Python log and print statements 2026-04-11 21:11:02 -07:00
matching_engine.py Merge remote-tracking branch 'origin/dev' into refactor/media-server-engine 2026-05-05 20:36:31 -07:00
metadata_service.py Move profile Spotify cache into registry 2026-04-29 12:36:37 +03:00
musicbrainz_client.py MusicBrainz: Resolve release-group MBIDs to a release on album click 2026-04-24 08:48:02 -07:00
musicbrainz_search.py MusicBrainz: Dedupe same-named homonyms in artist search results 2026-04-24 10:27:45 -07:00
musicbrainz_service.py Add metadata cache maintenance and health monitoring 2026-03-30 07:40:20 -07:00
musicbrainz_worker.py Surface silent exceptions in workers + repair jobs — ~30 sites 2026-05-07 10:27:24 -07:00
navidrome_client.py Final silent-exception sweep + ruff S110 lint guardrail — ~45 sites 2026-05-07 11:16:06 -07:00
personalized_playlists.py Discover: sharpen track selection (diversity, source-aware popularity, library dedup, SQL genre) 2026-05-08 08:49:22 -07:00
plex_client.py Surface silent exceptions across remaining modules — ~70 sites 2026-05-07 10:28:58 -07:00
qobuz_client.py Address Copilot doc-drift review 2026-05-05 15:46:48 -07:00
qobuz_worker.py Surface silent exceptions in workers + repair jobs — ~30 sites 2026-05-07 10:27:24 -07:00
reorganize_queue.py Reorganize queue: race + dedupe fixes from kettui review 2026-04-26 08:40:24 -07:00
reorganize_runner.py Final silent-exception sweep + ruff S110 lint guardrail — ~45 sites 2026-05-07 11:16:06 -07:00
repair_worker.py Surface silent exceptions in repair_worker — 16 sites 2026-05-07 09:17:51 -07:00
replaygain.py Add module logger + surface silent exceptions in 7 logger-less files — 12 sites 2026-05-07 10:27:04 -07:00
runtime_state.py Add module logger + surface silent exceptions in 7 logger-less files — 12 sites 2026-05-07 10:27:04 -07:00
seasonal_discovery.py Final silent-exception sweep + ruff S110 lint guardrail — ~45 sites 2026-05-07 11:16:06 -07:00
socketio_cors.py Socket.IO CORS: handle self-review nits 2026-04-26 19:24:43 -07:00
soulid_worker.py Surface silent exceptions in workers + repair jobs — ~30 sites 2026-05-07 10:27:24 -07:00
soulseek_client.py Final silent-exception sweep + ruff S110 lint guardrail — ~45 sites 2026-05-07 11:16:06 -07:00
soulsync_client.py Surface silent exceptions across remaining modules — ~70 sites 2026-05-07 10:28:58 -07:00
soundcloud_client.py Surface engine-not-wired errors + exclude soulseek from monitor aggregation 2026-05-05 12:20:51 -07:00
spotify_client.py Add download buttons + bulk action to artist top-tracks sidebar 2026-05-07 15:44:47 -07:00
spotify_public_scraper.py Add Spotify Link tab for public playlist/album scraping without API credentials 2026-03-15 21:25:05 -07:00
spotify_worker.py Surface silent exceptions in workers + repair jobs — ~30 sites 2026-05-07 10:27:24 -07:00
tag_writer.py Surface silent exceptions across remaining modules — ~70 sites 2026-05-07 10:28:58 -07:00
tidal_client.py Surface silent exceptions in metadata clients — 37 sites 2026-05-07 09:33:38 -07:00
tidal_download_client.py Surface engine-not-wired errors + exclude soulseek from monitor aggregation 2026-05-05 12:20:51 -07:00
tidal_worker.py Surface silent exceptions in workers + repair jobs — ~30 sites 2026-05-07 10:27:24 -07:00
watchlist_scanner.py Final silent-exception sweep + ruff S110 lint guardrail — ~45 sites 2026-05-07 11:16:06 -07:00
web_scan_manager.py MS Cin-5: Drop per-server globals — engine owns the clients 2026-05-05 18:05:45 -07:00
wishlist_service.py Extract wishlist logic into dedicated package 2026-04-28 21:17:24 +03:00
worker_utils.py Fix Album Completeness job reporting zero findings for every album 2026-04-24 12:39:41 -07:00
youtube_client.py Drop redundant import sys inside _auto_download_disabled 2026-05-08 15:51:16 -07:00