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):