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.
298 lines
10 KiB
Python
298 lines
10 KiB
Python
from core.download_engine import DownloadEngine
|
|
from core.download_orchestrator import DownloadOrchestrator
|
|
from core.download_plugins.registry import DownloadPluginRegistry, PluginSpec
|
|
|
|
|
|
class _FakeClient:
|
|
def __init__(self, configured=True, clear_result=True):
|
|
self.configured = configured
|
|
self.clear_result = clear_result
|
|
self.clear_calls = 0
|
|
|
|
def is_configured(self):
|
|
return self.configured
|
|
|
|
async def clear_all_completed_downloads(self):
|
|
self.clear_calls += 1
|
|
return self.clear_result
|
|
|
|
|
|
def _build_orchestrator(**clients):
|
|
"""Build an orchestrator with mock clients via the registry.
|
|
|
|
The orchestrator iterates `self.registry.all_plugins()` to drive
|
|
every per-source operation, so the test must set up a real
|
|
registry with mock plugins (not just stuff attributes on the
|
|
orchestrator). Source slots not provided in `clients` are
|
|
skipped — registry only holds the ones the test cares about.
|
|
"""
|
|
registry = DownloadPluginRegistry()
|
|
name_to_display = {
|
|
'soulseek': 'Soulseek', 'youtube': 'YouTube', 'tidal': 'Tidal',
|
|
'qobuz': 'Qobuz', 'hifi': 'HiFi', 'deezer_dl': 'Deezer',
|
|
'lidarr': 'Lidarr', 'soundcloud': 'SoundCloud',
|
|
}
|
|
# 'deezer_dl' is the legacy attr name; canonical registry name is 'deezer'.
|
|
aliases_for = {'deezer_dl': ('deezer_dl',)}
|
|
canonical_for = {'deezer_dl': 'deezer'}
|
|
|
|
for slot, client in clients.items():
|
|
if client is None:
|
|
continue
|
|
canonical_name = canonical_for.get(slot, slot)
|
|
registry.register(PluginSpec(
|
|
name=canonical_name,
|
|
factory=lambda c=client: c,
|
|
display_name=name_to_display.get(slot, slot),
|
|
aliases=aliases_for.get(slot, ()),
|
|
))
|
|
registry.initialize()
|
|
|
|
orch = DownloadOrchestrator.__new__(DownloadOrchestrator)
|
|
orch.registry = registry
|
|
orch._init_failures = registry.init_failures
|
|
# Engine — orchestrator delegates per-source query/cancel
|
|
# methods to it, so the test fixture must build one and
|
|
# register every mock plugin under its canonical name.
|
|
orch.engine = DownloadEngine()
|
|
for source_name, plugin in registry.all_plugins():
|
|
orch.engine.register_plugin(source_name, plugin)
|
|
return orch
|
|
|
|
|
|
def _run_async(coro):
|
|
import asyncio
|
|
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
return loop.run_until_complete(coro)
|
|
finally:
|
|
loop.close()
|
|
|
|
|
|
def test_clear_all_completed_downloads_ignores_unconfigured_clients():
|
|
orch = _build_orchestrator(
|
|
soulseek=_FakeClient(configured=True, clear_result=True),
|
|
youtube=_FakeClient(configured=False, clear_result=False),
|
|
)
|
|
|
|
result = _run_async(orch.clear_all_completed_downloads())
|
|
|
|
assert result is True
|
|
assert orch.client('soulseek').clear_calls == 1
|
|
assert orch.client('youtube').clear_calls == 0
|
|
|
|
|
|
def test_clear_all_completed_downloads_propagates_configured_failures():
|
|
orch = _build_orchestrator(
|
|
soulseek=_FakeClient(configured=True, clear_result=False),
|
|
)
|
|
|
|
result = _run_async(orch.clear_all_completed_downloads())
|
|
|
|
assert result is False
|
|
assert orch.client('soulseek').clear_calls == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cin-2 generic accessors
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_client_returns_registered_client_by_name():
|
|
"""Cin's review feedback: orch.client('hifi') is the canonical
|
|
way to reach a per-source client, replacing orch.hifi attribute
|
|
access."""
|
|
soulseek = _FakeClient()
|
|
youtube = _FakeClient()
|
|
orch = _build_orchestrator(soulseek=soulseek, youtube=youtube)
|
|
|
|
assert orch.client('soulseek') is soulseek
|
|
assert orch.client('youtube') is youtube
|
|
assert orch.client('made_up') is None
|
|
|
|
|
|
def test_configured_clients_excludes_unconfigured_sources():
|
|
"""Replaces the legacy iteration pattern: 6+ if/hasattr/is_configured
|
|
checks per source. Single call returns dict of configured clients."""
|
|
configured = _FakeClient(configured=True)
|
|
unconfigured = _FakeClient(configured=False)
|
|
orch = _build_orchestrator(
|
|
soulseek=configured,
|
|
youtube=unconfigured,
|
|
)
|
|
result = orch.configured_clients()
|
|
assert 'soulseek' in result
|
|
assert 'youtube' not in result
|
|
assert result['soulseek'] is configured
|
|
|
|
|
|
def test_configured_clients_skips_clients_whose_is_configured_raises():
|
|
"""Per JohnBaumb: configured_clients() has a try/except so a single
|
|
broken is_configured() call doesn't crash the whole iteration —
|
|
pin it so a future refactor can't quietly drop the guard. The
|
|
broken plugin is skipped; the rest still come back."""
|
|
|
|
class _BrokenIsConfigured(_FakeClient):
|
|
def is_configured(self):
|
|
raise RuntimeError("is_configured blew up")
|
|
|
|
broken = _BrokenIsConfigured()
|
|
healthy = _FakeClient(configured=True)
|
|
orch = _build_orchestrator(soulseek=healthy, youtube=broken)
|
|
|
|
result = orch.configured_clients()
|
|
# Healthy plugin still surfaces; broken one is silently skipped.
|
|
assert 'soulseek' in result
|
|
assert result['soulseek'] is healthy
|
|
assert 'youtube' not in result
|
|
|
|
|
|
def test_reload_instances_dispatches_to_named_source():
|
|
"""Generic dispatch — caller passes source name instead of
|
|
reaching for orch.hifi.reload_instances() directly."""
|
|
|
|
class _ReloadableClient(_FakeClient):
|
|
def __init__(self):
|
|
super().__init__(configured=True)
|
|
self.reload_called = False
|
|
|
|
def reload_instances(self):
|
|
self.reload_called = True
|
|
|
|
hifi = _ReloadableClient()
|
|
soulseek = _FakeClient() # No reload_instances method
|
|
orch = _build_orchestrator(soulseek=soulseek, hifi=hifi)
|
|
|
|
assert orch.reload_instances('hifi') is True
|
|
assert hifi.reload_called is True
|
|
|
|
|
|
def test_reload_instances_skips_clients_without_method():
|
|
"""Sources that don't expose reload_instances are skipped, not
|
|
treated as failures."""
|
|
soulseek = _FakeClient() # No reload_instances method
|
|
orch = _build_orchestrator(soulseek=soulseek)
|
|
# Calling on a source without the method = silent no-op
|
|
assert orch.reload_instances('soulseek') is True
|
|
|
|
|
|
def test_reload_instances_with_no_args_reloads_every_source():
|
|
"""When called with no source argument, hits every registered
|
|
source that exposes reload_instances."""
|
|
|
|
class _ReloadableClient(_FakeClient):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.reload_called = False
|
|
|
|
def reload_instances(self):
|
|
self.reload_called = True
|
|
|
|
a = _ReloadableClient()
|
|
b = _ReloadableClient()
|
|
orch = _build_orchestrator(soulseek=a, hifi=b)
|
|
|
|
orch.reload_instances()
|
|
assert a.reload_called is True
|
|
assert b.reload_called is True
|
|
|
|
|
|
def test_reload_settings_refreshes_registry_plugins(monkeypatch):
|
|
"""Settings saves should refresh plugins that cache config at init.
|
|
|
|
Prowlarr-backed torrent / usenet clients keep a ProwlarrClient
|
|
instance, so without this hook newly-saved indexer settings only
|
|
took effect after process restart.
|
|
"""
|
|
|
|
class _ReloadSettingsClient(_FakeClient):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.reload_calls = 0
|
|
|
|
def reload_settings(self):
|
|
self.reload_calls += 1
|
|
|
|
torrent = _ReloadSettingsClient()
|
|
usenet = _ReloadSettingsClient()
|
|
orch = _build_orchestrator(torrent=torrent, usenet=usenet)
|
|
|
|
monkeypatch.setattr(
|
|
'core.download_orchestrator.config_manager.get',
|
|
lambda _key, default=None: default,
|
|
)
|
|
|
|
orch.reload_settings()
|
|
|
|
assert torrent.reload_calls == 1
|
|
assert usenet.reload_calls == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Singleton factory (matches Cin's get_metadata_engine pattern)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cin bug 2: hybrid_order alias normalization
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_resolve_source_chain_normalizes_legacy_aliases():
|
|
"""Cin's bug 2: hybrid_order config containing the legacy alias
|
|
'deezer_dl' was silently dropped because the canonical-name
|
|
membership check rejected it. Orchestrator must normalize via
|
|
the registry alias map first."""
|
|
orch = _build_orchestrator(
|
|
soulseek=_FakeClient(),
|
|
deezer_dl=_FakeClient(),
|
|
youtube=_FakeClient(),
|
|
)
|
|
orch.hybrid_order = ['deezer_dl', 'soulseek', 'youtube']
|
|
orch.hybrid_primary = None
|
|
orch.hybrid_secondary = None
|
|
|
|
chain = orch._resolve_source_chain()
|
|
assert chain == ['deezer', 'soulseek', 'youtube']
|
|
|
|
|
|
def test_resolve_source_chain_dedupes_alias_and_canonical():
|
|
"""If both 'deezer' and 'deezer_dl' appear, dedupe to single entry."""
|
|
orch = _build_orchestrator(
|
|
soulseek=_FakeClient(),
|
|
deezer_dl=_FakeClient(),
|
|
)
|
|
orch.hybrid_order = ['deezer_dl', 'deezer', 'soulseek']
|
|
orch.hybrid_primary = None
|
|
orch.hybrid_secondary = None
|
|
|
|
chain = orch._resolve_source_chain()
|
|
assert chain == ['deezer', 'soulseek']
|
|
|
|
|
|
def test_resolve_source_chain_drops_unknown_names():
|
|
orch = _build_orchestrator(soulseek=_FakeClient(), youtube=_FakeClient())
|
|
orch.hybrid_order = ['nonsense', 'soulseek', 'also_fake', 'youtube']
|
|
orch.hybrid_primary = None
|
|
orch.hybrid_secondary = None
|
|
|
|
chain = orch._resolve_source_chain()
|
|
assert chain == ['soulseek', 'youtube']
|
|
|
|
|
|
def test_get_download_orchestrator_returns_set_singleton():
|
|
"""When set_download_orchestrator has been called (web_server.py
|
|
does this at boot), get_download_orchestrator returns the
|
|
installed instance instead of building a fresh one."""
|
|
from core.download_orchestrator import (
|
|
get_download_orchestrator,
|
|
set_download_orchestrator,
|
|
)
|
|
|
|
orch = _build_orchestrator(soulseek=_FakeClient())
|
|
set_download_orchestrator(orch)
|
|
try:
|
|
assert get_download_orchestrator() is orch
|
|
finally:
|
|
set_download_orchestrator(None)
|