Files
ohm_streaming/tests/test_qbit.py
T
Roman 4e1faf4cd9 Ohm comme client de téléchargement Sonarr (API compatible qBittorrent)
- /api/v2/* : login SID (mot de passe = clé Torznab), app/version,
  torrents/info (progression temps réel), properties (content_path),
  add (rejoue le grab encodé dans le .torrent de service, dédupliqué
  par infohash SHA-1), delete (± fichiers), pause/resume
- Les grabs Sonarr sont marqués « sonarr:<hash>| » dans source_key →
  suivis de bout en bout : Sonarr importe, renomme et range les épisodes
  dans sa bibliothèque, puis retire le torrent de la file Ohm
- L'indexeur Torznab embarque les paramètres du grab dans l'announce
- README : nouveau mode « client de téléchargement » recommandé (Remote
  Path Mapping documenté), blackhole en variante minimale
- 3 nouveaux tests (flux complet add → suivi → import → delete)
2026-09-22 16:42:12 +00:00

144 lines
5.1 KiB
Python

"""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() == []