Commit graph

9 commits

Author SHA1 Message Date
Broque Thomas
a41eccbe3c Fix Usenet settings reload without restart
Refresh registry-backed download plugins when settings are saved so cached Prowlarr clients pick up new indexer credentials immediately. This preserves active download state by reloading existing plugin instances instead of rebuilding the registry.

Add regression coverage for orchestrator reload fanout and the Usenet plugin's cached ProwlarrClient refresh path.
2026-05-22 08:28:56 -07:00
Broque Thomas
4aa6b0fcf5 Add 5 test additions JohnBaumb suggested
Pinning gaps he flagged after his second pass:

- register_plugin when set_engine() raises: registration must succeed
  + plugin stays in the registry (download() raises later, surfacing
  the error to the user via download_with_fallback). Pin so a future
  refactor can't accidentally propagate the set_engine exception and
  crash boot.
- engine.get_all_downloads exclude actually doesn't invoke the plugin:
  ID-only check would pass even if soulseek's get_all was called and
  returned []; sentinel proves the plugin isn't touched at all.
- Cancel mid-flight observable from inside _download_sync: existing
  tests pin Cancelled-preserve AFTER impl returns, this pins the
  contract plugins rely on (engine.get_record reflecting Cancelled
  state during the impl thread's polling loop).
- configured_clients() with broken is_configured(): the try/except
  guard exists but had no test — broken plugin is silently skipped,
  healthy ones still surface.
- Per-source delay independence: YouTube's 3s rate-limit delay must
  not block a Tidal download starting in parallel. Companion to the
  per-source-locks test.
2026-05-05 13:04:18 -07:00
Broque Thomas
7519c3d50c Cin-5: Drop per-source attrs from orchestrator
Removed the eight backward-compat attribute aliases on the orchestrator
(soulseek, youtube, tidal, qobuz, hifi, deezer_dl, lidarr, soundcloud).
External callers and the orchestrator's own internals now reach clients
through the generic alias-aware client(name) accessor.

- core/downloads/{master,monitor,validation}.py: migrated to client().
  Monitor's per-source aggregation loop replaced with a single
  engine.get_all_downloads() call.
- core/search/{orchestrator,stream}.py: migrated; stream.py drops the
  hand-built mode-to-client dict.
- web_server.py: migrated /api/deezer/arl-* + tidal client lookup.
- core/download_orchestrator.py: internal self.soulseek /
  self.deezer_dl reaches now route through self.client(); attr
  assignments dropped from __init__; module docstring updated.
- Test fakes (_FakeSoulseek, _FakeSoulseekWithYT) expose client(name)
  instead of stuffing per-source attributes.
- Conformance test re-pinned to the client() accessor contract.
2026-05-04 23:14:05 -07:00
Broque Thomas
d0eac87601 Cin review: alias resolution, atomic terminal write, generic accessors
Three correctness fixes from kettui's PR review plus the web_server
migration to generic accessors.

- Engine alias map: register_plugin accepts aliases tuple; get_plugin
  + cancel_download resolve through it. Fixes deezer_dl cancels
  silently routing to soulseek.
- Orchestrator hybrid_order normalization: _resolve_source_chain
  routes raw config names through registry.get_spec() so legacy
  deezer_dl entries don't drop deezer from hybrid mode.
- Atomic update_record_unless_state on the engine: holds state_lock
  across the check + write. Both _mark_terminal AND the success path
  use it now so a Cancelled state set mid-impl can't be clobbered.
- web_server.py: 30 soulseek_client.<source> reaches migrated to
  client("<source>"); shutdown-check setup migrated to generic
  registry iteration; 4 hifi reload sites use reload_instances('hifi').
- 18 new tests pin every fix.
2026-05-04 22:58:46 -07:00
Broque Thomas
6a75d656fa Cin-2: Generic accessors on orchestrator + singleton factory
Cin's review feedback: external callers reach per-source clients
via attribute access (orch.hifi.reload_instances()) — needs
generic accessors so the registry IS the single source of truth.

Adds:
- orch.client(name) — public accessor for a per-source client.
  Resolves canonical names (deezer) AND legacy aliases (deezer_dl).
- orch.configured_clients() — returns {name: client} for every
  initialized AND is_configured() == True source. Replaces the
  6+ if/hasattr/is_configured chain Cin called out:
    if hasattr(orch, 'soulseek') and orch.soulseek and \
       orch.soulseek.is_configured(): ...
- orch.reload_instances(source=None) — generic dispatch for
  source-specific reload calls. Replaces orch.hifi.reload_instances()
  with orch.reload_instances('hifi').
- get_download_orchestrator() / set_download_orchestrator()
  singleton factory matching Cin's get_metadata_engine pattern in
  PR #498. web_server.py can install the orchestrator it builds
  at boot so future callers grab via the factory instead of
  importing the legacy `soulseek_client` global.

Phase Cin-3/Cin-4 will replace existing call sites; this commit
just provides the surface so those migrations are mechanical.

Suite still green (335 download tests + 6 new generic-accessor
tests).
2026-05-04 22:35:45 -07:00
Broque Thomas
3634dca83f B3: Orchestrator delegates query methods to engine
`get_all_downloads`, `get_download_status`, `cancel_download`, and
`clear_all_completed_downloads` on the orchestrator are now thin
pass-throughs to the engine. The plugin-iteration logic lives in
one place (the engine) instead of duplicated across orchestrator
methods.

Source-hint routing semantics preserved verbatim — engine.cancel
treats streaming-source names as direct routes and unknown names
as Soulseek peer usernames, exactly like the legacy orchestrator
did. Per-plugin exceptions still get swallowed defensively.

Test fixture `_build_orchestrator` now constructs an engine and
registers every mock plugin so the helper-built orchestrators
have the same wiring as production.

Suite still green (2012 passed). Zero behavior change for users.
2026-05-04 12:59:04 -07:00
Broque Thomas
5294065fe4 Wire orchestrator through plugin registry
Every per-source dispatch site (search, download, get_all_downloads,
get_download_status, cancel_download, clear_all_completed_downloads,
cancel_all_downloads, reload_settings) now iterates
`registry.all_plugins()` instead of hand-maintained client lists.

Backward-compat `self.soulseek` / `self.youtube` / etc. attributes
preserved as registry-resolved aliases — external callers reaching
for source-specific internals (e.g. `orchestrator.soulseek._make_request`)
keep working unchanged.

Adding a new source (Usenet planned) becomes one registry entry +
the new client class — no orchestrator changes.
2026-05-04 11:16:14 -07:00
Broque Thomas
75fe04907f Wire SoundCloud as a first-class download source
Plug the previously-built SoundcloudClient (PR #478, the build-and-verify
phase) into every place a download source needs to appear. Follows the
same wiring contract as Tidal/Qobuz/HiFi/Deezer/Lidarr — orchestrator
routing, hybrid-mode picker, search dispatch, queue/cancel/clear,
provenance + library history, sidebar source label, settings UI all
work plug-and-play.

Backend wiring:
- `core/download_orchestrator.py` — import SoundcloudClient, _safe_init
  it at startup, add to _client() lookup, get_source_status(),
  check_connection's sources_to_check default, search source_names map,
  search_and_download_best _streaming_sources tuple, download
  source_map + source_names, and every iteration loop in
  reload_settings download-path-update / get_all_downloads /
  get_download_status / cancel_download (route + iterate) /
  clear_all_completed_downloads / cancel_all_downloads.
- `core/downloads/monitor.py` — added SoundCloud to the per-client
  loop that fetches active downloads outside the orchestrator (uses
  getattr fallback for older soulseek_client snapshots).
- `core/downloads/task_worker.py` — added SoundCloud (and Lidarr,
  which was missing too — bonus fix) to source_clients dict for hybrid
  fallback dispatch.
- `core/downloads/validation.py` — added 'soundcloud' to
  _streaming_sources so SoundCloud results go through the matching
  engine validation path instead of the Soulseek quality-filter path.
- `core/imports/side_effects.py` — three call sites: source_map for
  download_source label written to library_history, streaming-source
  guard for the `||`-encoded stream_id parsing, and source_service
  map for provenance recording. All three now include 'soundcloud'.
- `web_server.py` — five streaming-source detection tuples updated.
  New `/api/soundcloud/status` endpoint returns
  {available, configured, reachable} mirroring the Deezer/HiFi
  status-endpoint pattern; reachability runs a real cheap yt-dlp
  search so the settings Test Connection button gives a meaningful
  pass/fail signal.
- `config/settings.py` — added empty `soundcloud_download` defaults
  block so future tier-2 OAuth (SoundCloud Go+ session) doesn't have
  to migrate existing configs.

Frontend:
- `webui/index.html` — new `<option value="soundcloud">` in the
  download-source-mode dropdown, SoundCloud added to both hidden
  legacy hybrid-source selects, new settings container with info
  text + Test Connection button.
- `webui/static/settings.js` — HYBRID_SOURCES entry (with the
  SoundCloud cloud SVG icon), _hybridSourceEnabled default,
  updateDownloadSourceUI container display, allSources for legacy
  hybrid picker, testSoundcloudConnection function (hits the new
  status endpoint, color-codes the result), saveSettings
  soundcloud_download empty block.
- `webui/static/shared-helpers.js` — sidebar source-name map
  includes SoundCloud + Lidarr (Lidarr was also missing, bonus fix).
- `webui/static/helper.js` — WHATS_NEW entry under '2.4.2' dev cycle
  describing the user-visible change in the chill terse voice.

Tests:
- `tests/test_download_orchestrator_soundcloud.py` — 14 integration
  tests verifying the wiring: client constructed at startup, _client
  lookup resolves 'soundcloud', get_source_status includes it,
  download dispatcher routes username='soundcloud' to the SoundCloud
  client (and unknown usernames still fall back to Soulseek), hybrid
  search iterates SoundCloud when in order and skips it cleanly when
  unconfigured, get_all_downloads / get_download_status / cancel /
  clear walk SoundCloud, soundcloud-only mode dispatches only to
  SoundCloud, _streaming_sources tuple in validation includes
  'soundcloud'.
- `tests/downloads/test_download_orchestrator.py` — added
  `soundcloud` to the test fixture's _build_orchestrator helper so
  the new orchestrator attribute doesn't AttributeError in pre-
  existing tests that bypass __init__.

Verified:
- Full suite green (1728 passed, 2 deselected for soundcloud_live)
- Ruff clean
- Live SoundCloud-only mode search returns 25 SoundCloud tracks for
  "kendrick lamar luther" in <2s, returning properly-shaped
  TrackResult objects with username='soundcloud' and dispatch-key
  filename ready for the download path.

Out of scope (intentional deferrals):
- SoundCloud Go+ OAuth tier (256 kbps AAC) — anonymous-only for now.
  Adding auth later is a settings-page extension, no orchestrator
  changes needed.
- Album/playlist support — SoundCloud has playlists but they don't
  map to the album model the rest of SoulSync expects. Singles only.
2026-05-03 12:54:21 -07: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