- /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)
144 lines
5.1 KiB
Python
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() == []
|