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)
This commit is contained in:
Roman
2026-09-22 16:42:12 +00:00
parent 1f326d34dd
commit 4e1faf4cd9
9 changed files with 536 additions and 33 deletions
+143
View File
@@ -0,0 +1,143 @@
"""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() == []
+8 -6
View File
@@ -138,10 +138,12 @@ async def test_torznab_search_requires_query(client, apikey):
class _FakeManager:
def __init__(self) -> None:
self.calls: list[tuple[str, str, str]] = []
self.calls: list[tuple[str, str | None]] = []
async def enqueue(self, video_url: str, page_url: str, title: str) -> dict:
self.calls.append((video_url, page_url, title))
async def enqueue(
self, video_url: str, page_url: str, title: str, source_key: str | None = None
) -> dict:
self.calls.append((video_url, source_key))
return {"id": 7, "title": title, "status": "pending"}
@@ -172,9 +174,9 @@ async def test_torznab_download_enqueues_and_returns_torrent(client, apikey, fak
assert r.status_code == 200
assert r.headers["content-type"].startswith("application/x-bittorrent")
assert r.content.startswith(b"d") and b"OhmStreaming" in r.content # bencode valide
assert manager.calls == [
("https://cdn.example/video.mp4", "https://fake.example/ep2", "Frieren S01E02")
]
video_url, source_key = manager.calls[0]
assert video_url == "https://cdn.example/video.mp4"
assert source_key and source_key.startswith("sonarr:") and video_url in source_key
async def test_torznab_download_rejects_bad_key(client):