"""API compatible qBittorrent : flux complet côté Sonarr (add → suivi → import → delete).""" import asyncio from urllib.parse import urlencode import pytest from aiohttp import web from app.config import get_settings from app.scrapers.base import Episode, VideoLink from app.services.downloads import download_manager as dm from app.services.settings import get_torznab_apikey from app.services.torznab import build_stub from app.services.torznab import torznab as torznab_svc PAYLOAD = b"q" * 200_000 def _make_stub(base: str) -> tuple[bytes, str]: announce = ( "http://ohm:8777/torznab/api?" + urlencode({"source": "fake", "sid": "42", "season": 1, "ep": 2, "series": "Frieren"}) ) return build_stub(announce, "Frieren S01E02") def test_stub_deterministic(): stub, infohash = _make_stub("x") stub2, infohash2 = _make_stub("x") assert stub == stub2 and infohash == infohash2 and len(infohash) == 40 @pytest.fixture async def file_server(): async def handle(request: web.Request) -> web.StreamResponse: return web.Response(body=PAYLOAD) app = web.Application() app.router.add_get("/video.mp4", handle) runner = web.AppRunner(app) await runner.setup() site = web.TCPSite(runner, "127.0.0.1", 0) await site.start() port = site._server.sockets[0].getsockname()[1] yield f"http://127.0.0.1:{port}" await runner.cleanup() @pytest.fixture async def manager(): await dm.start() yield dm await dm.stop() async def test_qbit_sonarr_flow(client, file_server, manager, monkeypatch): apikey = await get_torznab_apikey() # -- auth : mauvais mot de passe refusé, bon mot de passe accepté r = await client.post("/api/v2/auth/login", data={"username": "ohm", "password": "mauvais"}) assert r.status_code == 403 r = await client.post("/api/v2/auth/login", data={"username": "ohm", "password": apikey}) assert r.text == "Ok." # -- les endpoints nécessitent la session SID r = await client.post("/api/v2/auth/login", data={"username": "ohm", "password": apikey}) sid = r.cookies["SID"] client.cookies.clear() r = await client.get("/api/v2/app/version") assert r.status_code == 403 client.cookies.set("SID", sid) r = await client.get("/api/v2/app/version") assert r.status_code == 200 and r.text.startswith("v") r = await client.get("/api/v2/app/webapiVersion") assert r.status_code == 200 # -- le scraping est simulé : l'épisode pointe vers notre serveur local async def fake_episodes(scraper, source_id): return [Episode(number=2, title="Épisode 2", url="http://fake/ep2", season=1)] async def fake_resolve(scraper, episode_url): return VideoLink(url=f"{file_server}/video.mp4", hoster="fake") class _FakeScraper: name = "fake" monkeypatch.setattr("app.scrapers.base.get_source", lambda name: _FakeScraper()) monkeypatch.setattr(torznab_svc, "_episodes_of", fake_episodes) monkeypatch.setattr(torznab_svc, "_resolve_video", fake_resolve) # -- Sonarr pousse le .torrent de service reçu de l'indexeur stub, infohash = _make_stub(file_server) r = await client.post( "/api/v2/torrents/add", files={"torrents": ("ohm.torrent", stub, "application/x-bittorrent")}, ) assert r.text == "Ok." # -- suivi : présent avec le bon infohash, progresse jusqu'à « terminé » item = None for _ in range(150): items = (await client.get("/api/v2/torrents/info")).json() item = next((i for i in items if i["hash"] == infohash), None) if item and item["state"] == "pausedUP": break await asyncio.sleep(0.1) assert item is not None, "téléchargement invisible dans torrents/info" assert item["state"] == "pausedUP" assert item["progress"] == 1 assert item["name"] == "Frieren S01E02" assert item["content_path"].startswith("/downloads/Frieren S01/Frieren S01E02.mp4") # -- properties : le chemin que Sonarr importera r = await client.get("/api/v2/torrents/properties", params={"hash": infohash}) assert r.status_code == 200 props = r.json() assert props["content_path"] == item["content_path"] assert props["completion_date"] > 0 # -- le fichier est bien là où Sonarr l'attend host_path = get_settings().download_dir / item["content_path"].removeprefix("/downloads/") assert host_path.read_bytes() == PAYLOAD # -- retrait après import : file vidée, fichier supprimé r = await client.post( "/api/v2/torrents/delete", data={"hashes": infohash, "deleteFiles": "true"} ) assert r.text == "Ok." items = (await client.get("/api/v2/torrents/info")).json() assert items == [] assert not host_path.exists() async def test_qbit_add_rejects_garbage(client): apikey = await get_torznab_apikey() await client.post("/api/v2/auth/login", data={"username": "ohm", "password": apikey}) r = await client.post( "/api/v2/torrents/add", files={"torrents": ("ohm.torrent", b"n'importe quoi", "application/x-bittorrent")}, ) assert r.text == "Fals." assert (await client.get("/api/v2/torrents/info")).json() == []