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:
@@ -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() == []
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user