From 4e1faf4cd9790f5f021c086668fe7cbdd6e9851c Mon Sep 17 00:00:00 2001 From: Roman Date: Tue, 22 Sep 2026 16:42:12 +0000 Subject: [PATCH] =?UTF-8?q?Ohm=20comme=20client=20de=20t=C3=A9l=C3=A9charg?= =?UTF-8?q?ement=20Sonarr=20(API=20compatible=20qBittorrent)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /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:| » 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) --- README.md | 36 ++++-- app/main.py | 3 +- app/routers/qbit.py | 253 ++++++++++++++++++++++++++++++++++++++ app/routers/torznab.py | 30 +++-- app/services/downloads.py | 12 +- app/services/torznab.py | 72 +++++++++-- app/templates/admin.html | 6 + tests/test_qbit.py | 143 +++++++++++++++++++++ tests/test_torznab.py | 14 ++- 9 files changed, 536 insertions(+), 33 deletions(-) create mode 100644 app/routers/qbit.py create mode 100644 tests/test_qbit.py diff --git a/README.md b/README.md index 5d76053..55d9468 100644 --- a/README.md +++ b/README.md @@ -133,9 +133,10 @@ Variables d'environnement (préfixe `OHM_`, voir `.env.example`) : ## Intégration Sonarr / Prowlarr (*arr) -OhmStreaming expose une **API Torznab** : la suite *arr le voit comme un indexeur -de plus, et chaque grab Sonarr déclenche l'extraction + le téléchargement dans -la file interne (les épisodes arrivent dans la bibliothèque OhmStreaming). +OhmStreaming expose une **API Torznab** (indexeur) **et une API compatible +qBittorrent** (client de téléchargement) : Sonarr peut lui déléguer toute la +chaîne — recherche, téléchargement, puis **import et renommage automatiques** +dans la bibliothèque Sonarr. ### 1. OhmStreaming comme indexeur @@ -150,13 +151,34 @@ Dans **Admin → Intégrations Sonarr / Prowlarr**, copier : puis synchroniser vers Sonarr. - **Sonarr** (direct) : Settings → Indexers → Add → *Torznab* → coller URL + clé. Catégories : TV (5000) / Anime (5070). -- **Client de téléchargement** : « Torrent Blackhole » — Sonarr enregistre le - `.torrent` de service tandis qu'OhmStreaming télécharge réellement l'épisode - (extraction embed → HLS/HTTP → mp4 dans `downloads/`). Endpoints : `t=caps`, `t=tvsearch` (q, season, ep), `t=search` — auth par `?apikey=` ou en-tête `X-Api-Key`. +### 2. OhmStreaming comme client de téléchargement (recommandé) + +Dans Sonarr : **Settings → Download Clients → Add → qBittorrent** : + +| Champ | Valeur | +|---|---| +| Host | `http://:8777` | +| Username | `ohm` | +| Password | la clé API Torznab (Admin → Intégrations) | + +Le flux complet devient : Sonarr grab → Ohm télécharge (progression visible +dans la file Sonarr) → Sonarr **importe, renomme et range** l'épisode dans sa +bibliothèque selon ses propres règles → le « torrent » est retiré de la file +Ohm (fichier inclu si « Remove Completed » est coché). + +**Chemin d'accès** : Ohm annonce les fichiers sous `/downloads//…` +(chemin conteneur). Si Sonarr tourne dans Docker sans ce montage, ajouter un +*Remote Path Mapping* : hôte = ``, distant = `/downloads`, local = +le dossier hôte monté (ex. `/plex_videos/ohm`). + +Variante minimale sans import : client « Torrent Blackhole » — Sonarr pose le +`.torrent` de service et l'épisode reste dans la bibliothèque OhmStreaming +seulement (pas d'import/renommage Sonarr). + ### 2. « Pour toi » personnalisé par Sonarr Toujours dans **Admin → Intégrations**, renseigner l'URL Sonarr @@ -187,6 +209,6 @@ app/ ## Tests ```bash -uv run pytest # 83 tests +uv run pytest # 86 tests uv run ruff check . # lint ``` diff --git a/app/main.py b/app/main.py index 2ae3e6c..fdf5dff 100644 --- a/app/main.py +++ b/app/main.py @@ -17,6 +17,7 @@ from app.routers import ( library, pages, proxy, + qbit, search, system, torznab, @@ -58,7 +59,7 @@ def create_app() -> FastAPI: settings = get_settings() app = FastAPI(title=settings.app_name, lifespan=lifespan) app.include_router(torznab.router) - + app.include_router(qbit.router) app.include_router(system.router) app.include_router(pages.router) app.include_router(pages.protected) diff --git a/app/routers/qbit.py b/app/routers/qbit.py new file mode 100644 index 0000000..564e880 --- /dev/null +++ b/app/routers/qbit.py @@ -0,0 +1,253 @@ +"""API compatible qBittorrent Web API v2 — Ohm comme client de téléchargement Sonarr. + +Sonarr sait piloter un qBittorrent ; Ohm implémente le sous-set suffisant pour +être vu comme un client de téléchargement « torrent » : + +- login (`/auth/login`, mot de passe = clé API Torznab) + session SID +- `torrents/add` : Sonarr renvoie le .torrent de service servi par l'indexeur + Torznab → le grab est rejoué (dédupliqué par infohash) dans la file interne +- `torrents/info` / `properties` : progression temps réel, `content_path` + pointant vers le fichier dans /downloads (mapper en chemin hôte côté Sonarr + via « Remote Path Mapping » si besoin) +- `torrents/delete` : retrait de la file, avec ou sans le fichier +- `pause`/`resume` : branchés sur le gestionnaire de téléchargements +""" + +import hashlib +import logging +import secrets +import time +from datetime import UTC, datetime +from urllib.parse import parse_qsl + +from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile + +from app.db import db +from app.scrapers.base import ScrapeError +from app.services.downloads import download_manager +from app.services.settings import get_torznab_apikey +from app.services.torznab import _bencode, bdecode, torznab + +logger = logging.getLogger(__name__) + + +def _plain(text: str) -> Response: + """qBittorrent répond en texte brut, pas en JSON.""" + return Response(text, media_type="text/plain") +router = APIRouter(tags=["qbit"]) +_OPTIONAL_FILE = File(None) + +_SID_TTL = 3600.0 +_sessions: dict[str, float] = {} + +_QBIT_VERSION = "v4.6.0" +_WEBAPI_VERSION = "2.9.3" + +# status Ohm → état qBittorrent (noms compris par Sonarr v3/v4) +_QBIT_STATES = { + "pending": "queuedDL", + "downloading": "downloading", + "paused": "pausedDL", + "done": "pausedUP", # terminé → Sonarr importe + "failed": "error", + "cancelled": "error", +} + +_PREFIX = "sonarr:" + + +async def _require_sid(request: Request) -> None: + sid = request.cookies.get("SID") + if not sid or _sessions.get(sid, 0.0) < time.time(): + _sessions.pop(sid, None) + raise HTTPException(403, "Session invalide — (re)connecte-toi via /api/v2/auth/login") + + +@router.post("/api/v2/auth/login") +async def login(username: str = Form(""), password: str = Form("")) -> Response: + if password != await get_torznab_apikey(): + logger.warning("Login qBittorrent refusé (utilisateur %r)", username) + raise HTTPException(403, "Fails.") + sid = secrets.token_hex(16) + _sessions[sid] = time.time() + _SID_TTL + response = _plain("Ok.") + response.set_cookie("SID", sid, httponly=True) + logger.info("Client qBittorrent authentifié (utilisateur %r)", username) + return response + + +@router.get("/api/v2/app/version", dependencies=[Depends(_require_sid)]) +async def app_version() -> Response: + return _plain(_QBIT_VERSION) + + +@router.get("/api/v2/app/webapiVersion", dependencies=[Depends(_require_sid)]) +async def webapi_version() -> Response: + return _plain(_WEBAPI_VERSION) + + +@router.get("/api/v2/app/preferences", dependencies=[Depends(_require_sid)]) +async def app_preferences() -> dict: + return {} + + +@router.get("/api/v2/transfer/info", dependencies=[Depends(_require_sid)]) +async def transfer_info() -> dict: + return {"dl_info_speed": 0, "dl_info_data": 0, "up_info_speed": 0, "up_info_data": 0} + + +# ---------------------------------------------------------------- torrents + + +def _ts(sqlite_dt: str | None) -> int: + if not sqlite_dt: + return -1 + try: + return int( + datetime.strptime(sqlite_dt, "%Y-%m-%d %H:%M:%S") + .replace(tzinfo=UTC) + .timestamp() + ) + except ValueError: + return -1 + + +async def _sonarr_rows() -> dict[str, dict]: + """Téléchargements d'origine Sonarr, indexés par infohash (dernier par hash).""" + rows = await db.fetchall( + f"SELECT * FROM downloads WHERE source_key LIKE '{_PREFIX}%' ORDER BY id" + ) + by_hash: dict[str, dict] = {} + for row in rows: + prefix_end = row["source_key"].find("|") + infohash = row["source_key"][len(_PREFIX) : prefix_end] + by_hash[infohash] = dict(row) # le plus grand id écrase les précédents + return by_hash + + +def _content_path(row: dict) -> str: + return "/downloads/" + (row["file_path"] or row["title"]) + + +@router.get("/api/v2/torrents/info", dependencies=[Depends(_require_sid)]) +async def torrents_info() -> list[dict]: + items = [] + for infohash, row in (await _sonarr_rows()).items(): + live = await download_manager.get(row["id"]) + downloaded = live["downloaded_bytes"] + total = live["total_bytes"] + items.append( + { + "hash": infohash, + "name": row["title"], + "state": _QBIT_STATES.get(row["status"], "error"), + "progress": round(downloaded / total, 4) if total else 0.0, + "dlspeed": live["speed_bps"], + "eta": live["eta_seconds"] or 0, + "total_size": total or 0, + "completed": downloaded, + "amount_left": max(0, (total or 0) - downloaded), + "category": "", + "tags": "", + "save_path": _content_path(row).rsplit("/", 1)[0], + "content_path": _content_path(row), + "added_on": _ts(row["created_at"]), + "completion_on": _ts(row["updated_at"]) if row["status"] == "done" else -1, + "ratio": 1, + "num_seeds": 0, + } + ) + return items + + +@router.get("/api/v2/torrents/properties", dependencies=[Depends(_require_sid)]) +async def torrents_properties(hash: str) -> dict: + rows = await _sonarr_rows() + row = rows.get(hash.lower()) + if row is None: + raise HTTPException(404, "Torrent introuvable") + live = await download_manager.get(row["id"]) + return { + "name": row["title"], + "content_path": _content_path(row), + "save_path": _content_path(row).rsplit("/", 1)[0], + "total_size": live["total_bytes"] or 0, + "total_downloaded": live["downloaded_bytes"], + "addition_date": _ts(row["created_at"]), + "completion_date": _ts(row["updated_at"]) if row["status"] == "done" else -1, + "seeding_time": 0, + "share_ratio": 1, + } + + +@router.get("/api/v2/torrents/categories", dependencies=[Depends(_require_sid)]) +async def torrents_categories() -> dict: + return {} + + +@router.get("/api/v2/torrents/tags", dependencies=[Depends(_require_sid)]) +async def torrents_tags() -> list: + return [] + + +@router.post("/api/v2/torrents/setCategory", dependencies=[Depends(_require_sid)]) +async def torrents_set_category() -> Response: + return _plain("Ok.") + + +async def _add_stub(stub: bytes) -> Response: + """Rejoue le grab encodé dans un .torrent de service.""" + try: + parsed = bdecode(stub) + info = parsed[b"info"] + announce = parsed[b"announce"].decode() + params = dict(parse_qsl(announce.split("?", 1)[1])) + infohash = hashlib.sha1(_bencode(info)).hexdigest() + await torznab.grab( + params["source"], + params["sid"], + int(params["season"]), + int(params["ep"]), + params["series"], + sonarr_hash=infohash, + ) + except (ValueError, KeyError, ScrapeError) as exc: + logger.error("Ajout qBittorrent refusé : %s", exc) + return _plain("Fals.") + return _plain("Ok.") + + +@router.post("/api/v2/torrents/add", dependencies=[Depends(_require_sid)]) +async def torrents_add(torrents: UploadFile | None = _OPTIONAL_FILE) -> Response: + if torrents is None or not torrents.filename: + return _plain("Fals.") + return await _add_stub(await torrents.read()) + + +async def _ids_for_hashes(hashes: str) -> list[int]: + wanted = {h.lower() for h in hashes.split("|") if h} + rows = await _sonarr_rows() + return [row["id"] for infohash, row in rows.items() if infohash in wanted] + + +@router.post("/api/v2/torrents/delete", dependencies=[Depends(_require_sid)]) +async def torrents_delete( + hashes: str = Form(...), deleteFiles: str = Form("false") +) -> Response: + for download_id in await _ids_for_hashes(hashes): + await download_manager.delete(download_id, delete_file=deleteFiles == "true") + return _plain("Ok.") + + +@router.post("/api/v2/torrents/pause", dependencies=[Depends(_require_sid)]) +async def torrents_pause(hashes: str = Form(...)) -> Response: + for download_id in await _ids_for_hashes(hashes): + await download_manager.pause(download_id) + return _plain("Ok.") + + +@router.post("/api/v2/torrents/resume", dependencies=[Depends(_require_sid)]) +async def torrents_resume(hashes: str = Form(...)) -> Response: + for download_id in await _ids_for_hashes(hashes): + await download_manager.resume(download_id) + return _plain("Ok.") diff --git a/app/routers/torznab.py b/app/routers/torznab.py index 023cd06..bd7827f 100644 --- a/app/routers/torznab.py +++ b/app/routers/torznab.py @@ -6,12 +6,13 @@ des sessions utilisateurs — aucun cookie requis. import logging import secrets +from urllib.parse import urlencode from fastapi import APIRouter, Request, Response from app.scrapers.base import ScrapeError from app.services.settings import get_torznab_apikey -from app.services.torznab import torrent_stub, torznab +from app.services.torznab import build_stub, torznab logger = logging.getLogger(__name__) @@ -92,10 +93,12 @@ async def torznab_download( series: str | None = None, apikey: str | None = None, ) -> Response: - """Grab : Sonarr récupère le « .torrent » ; OhmStreaming télécharge l'épisode. + """Grab : Sonarr récupère le « .torrent » ; Ohm télécharge l'épisode. - Le flux retourne un .torrent de service (blackhole-friendly) pendant que - l'épisode réel entre dans la file de téléchargements interne. + Le flux retourne un .torrent de service : avec un client « Torrent + Blackhole » c'est un simple accusé de réception ; avec le client + qBittumber (l'API /api/v2 d'Ohm), Sonarr le renvoie et le grab est + rejoué/dédupliqué, puis suivi comme un téléchargement classique. """ error = await _auth_error(request, apikey) if error is not None: @@ -106,8 +109,17 @@ async def torznab_download( media_type="application/xml", status_code=400, ) + name = f"{series} S{season:02d}E{ep:02d}" + announce = ( + _base_url(request) + + "/torznab/api?" + + urlencode( + {"source": source, "sid": sid, "season": season, "ep": ep, "series": series} + ) + ) + stub, infohash = build_stub(announce, name) try: - result = await torznab.grab(source, sid, season, ep, series) + result = await torznab.grab(source, sid, season, ep, series, sonarr_hash=infohash) logger.info("Torznab grab OK : %s → download %s", series, result.get("id")) except ScrapeError as exc: logger.error("Torznab grab KO : %s", exc) @@ -116,9 +128,13 @@ async def torznab_download( media_type="application/xml", status_code=502, ) - stub = torrent_stub(_base_url(request) + "/torznab/api", f"{series} S{season:02d}E{ep:02d}") return Response( stub, media_type="application/x-bittorrent", - headers={"Content-Disposition": f'attachment; filename="ohm-{series.replace("/", "-")}-S{season:02d}E{ep:02d}.torrent"'}, + headers={ + "Content-Disposition": ( + f'attachment; filename="ohm-{series.replace("/", "-")}' + f"-S{season:02d}E{ep:02d}.torrent" + ) + }, ) diff --git a/app/services/downloads.py b/app/services/downloads.py index 37693cd..c0c8508 100644 --- a/app/services/downloads.py +++ b/app/services/downloads.py @@ -156,9 +156,15 @@ class DownloadManager: # ------------------------------------------------------------ API publique - async def enqueue(self, video_url: str, page_url: str, title: str) -> dict: - """Ajoute un téléchargement ; retourne la tâche existante si doublon actif.""" - source_key = video_url + async def enqueue( + self, video_url: str, page_url: str, title: str, source_key: str | None = None + ) -> dict: + """Ajoute un téléchargement ; retourne la tâche existante si doublon actif. + + source_key : clé de déduplication (par défaut l'URL vidéo). Les grabs + Sonarr utilisent « sonarr:| » pour rester suivis. + """ + source_key = source_key or video_url existing = await db.fetchone( f"SELECT * FROM downloads WHERE source_key = ? AND status IN " f"({','.join('?' * len(ACTIVE_STATUSES))})", diff --git a/app/services/torznab.py b/app/services/torznab.py index a6ec3a9..9c6d248 100644 --- a/app/services/torznab.py +++ b/app/services/torznab.py @@ -13,7 +13,9 @@ Formats : - `t=search` → recherche libre (Prowlarr, recherche manuelle) """ + import asyncio +import hashlib import logging import time from dataclasses import dataclass @@ -83,19 +85,59 @@ def _bencode(value) -> bytes: def torrent_stub(announce_url: str, name: str) -> bytes: """Fichier .torrent minimal (le vrai téléchargement est fait par OhmStreaming).""" - return _bencode( + return build_stub(announce_url, name)[0] + + +def build_stub(announce_url: str, name: str) -> tuple[bytes, str]: + """Fichier .torrent de service + infohash SHA-1 (identité côté Sonarr). + + L'announce embarque les paramètres du grab (source, sid, season, ep, series) : + quand Sonarr renvoie ce .torrent à l'API compatible qBittorrent d'Ohm, + le grab est rejoué à l'identique. + """ + info = {"name": name + ".mp4", "length": 0, "piece length": 32768, "pieces": b"\x00" * 20} + data = _bencode( { "announce": announce_url, "created by": "OhmStreaming", "comment": name, - "info": { - "name": name + ".mp4", - "length": 0, - "piece length": 32768, - "pieces": b"\x00" * 20, - }, + "info": info, } ) + return data, hashlib.sha1(_bencode(info)).hexdigest() + + +def bdecode(data: bytes): + """Décode un flux bencode (les clés dict reviennent en bytes).""" + + def _parse(offset: int) -> tuple[object, int]: + char = data[offset : offset + 1] + if char == b"i": + end = data.index(b"e", offset) + return int(data[offset + 1 : end]), end + 1 + if char in (b"d", b"l"): + is_dict = char == b"d" + items: dict | list = {} if is_dict else [] + offset += 1 + while data[offset : offset + 1] != b"e": + first, offset = _parse(offset) + if is_dict: + second, offset = _parse(offset) + items[first] = second + else: + items.append(first) + return items, offset + 1 + if char.isdigit(): + colon = data.index(b":", offset) + length = int(data[offset:colon]) + start = colon + 1 + return data[start : start + length], start + length + raise ValueError(f"bencode invalide à l'octet {offset}") + + value, end = _parse(0) + if end != len(data): + raise ValueError("données après la fin du flux bencode") + return value class TorznabService: @@ -179,11 +221,22 @@ class TorznabService: # ------------------------------------------------------------ grab - async def grab(self, source: str, source_id: str, season: int, ep: int, series: str) -> dict: + async def grab( + self, + source: str, + source_id: str, + season: int, + ep: int, + series: str, + sonarr_hash: str | None = None, + ) -> dict: """Résout l'épisode (embed → vidéo directe) puis l'ajoute à la file interne. Retourne le dict du téléchargement (existant si doublon actif). Lève ScrapeError si introuvable ou qu'aucun hébergeur n'a répondu. + sonarr_hash : infohash du .torrent de service — les téléchargements + Sonarr sont préfixés « sonarr:| » pour rester suivis via l'API + compatible qBittorrent. """ from app.scrapers.base import get_source @@ -202,7 +255,8 @@ class TorznabService: link = await self._resolve_video(scraper, match.url) title = f"{series} S{season:02d}E{ep:02d}" - result = await download_manager.enqueue(link.url, match.url, title) + key = f"sonarr:{sonarr_hash}|{link.url}" if sonarr_hash else link.url + result = await download_manager.enqueue(link.url, match.url, title, source_key=key) if link.is_hls or link.headers.get("Referer"): result["note"] = "HLS/proxy : OhmStreaming gère le téléchargement via ffmpeg" logger.info("Torznab grab %s → download id=%s", title, result.get("id")) diff --git a/app/templates/admin.html b/app/templates/admin.html index c545cd2..59c1113 100644 --- a/app/templates/admin.html +++ b/app/templates/admin.html @@ -59,6 +59,12 @@ Ajoutez un indexeur « Torznab » dans Prowlarr ou Sonarr avec cette URL et cette clé — les épisodes grabés par Sonarr entrent directement dans la file de téléchargements OhmStreaming.

+

+ Encore mieux — client de téléchargement : dans Sonarr, ajoutez un client + qBittorrent pointant vers OhmStreaming (URL http://<hote-ohm>:8777, + utilisateur ohm, mot de passe = la clé API ci-dessus). Les épisodes seront + suivis en temps réel puis importés et renommés par Sonarr dans votre bibliothèque. +

diff --git a/tests/test_qbit.py b/tests/test_qbit.py new file mode 100644 index 0000000..1025c1b --- /dev/null +++ b/tests/test_qbit.py @@ -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() == [] diff --git a/tests/test_torznab.py b/tests/test_torznab.py index a39cb9d..37d24d9 100644 --- a/tests/test_torznab.py +++ b/tests/test_torznab.py @@ -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):