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
+29 -7
View File
@@ -133,9 +133,10 @@ Variables d'environnement (préfixe `OHM_`, voir `.env.example`) :
## Intégration Sonarr / Prowlarr (*arr) ## Intégration Sonarr / Prowlarr (*arr)
OhmStreaming expose une **API Torznab** : la suite *arr le voit comme un indexeur OhmStreaming expose une **API Torznab** (indexeur) **et une API compatible
de plus, et chaque grab Sonarr déclenche l'extraction + le téléchargement dans qBittorrent** (client de téléchargement) : Sonarr peut lui déléguer toute la
la file interne (les épisodes arrivent dans la bibliothèque OhmStreaming). chaîne — recherche, téléchargement, puis **import et renommage automatiques**
dans la bibliothèque Sonarr.
### 1. OhmStreaming comme indexeur ### 1. OhmStreaming comme indexeur
@@ -150,13 +151,34 @@ Dans **Admin → Intégrations Sonarr / Prowlarr**, copier :
puis synchroniser vers Sonarr. puis synchroniser vers Sonarr.
- **Sonarr** (direct) : Settings → Indexers → Add → *Torznab* → coller URL + clé. - **Sonarr** (direct) : Settings → Indexers → Add → *Torznab* → coller URL + clé.
Catégories : TV (5000) / Anime (5070). 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 Endpoints : `t=caps`, `t=tvsearch` (q, season, ep), `t=search` — auth par
`?apikey=` ou en-tête `X-Api-Key`. `?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://<hote-ohm>: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/<Animé>/…`
(chemin conteneur). Si Sonarr tourne dans Docker sans ce montage, ajouter un
*Remote Path Mapping* : hôte = `<hote-ohm>`, 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 ### 2. « Pour toi » personnalisé par Sonarr
Toujours dans **Admin → Intégrations**, renseigner l'URL Sonarr Toujours dans **Admin → Intégrations**, renseigner l'URL Sonarr
@@ -187,6 +209,6 @@ app/
## Tests ## Tests
```bash ```bash
uv run pytest # 83 tests uv run pytest # 86 tests
uv run ruff check . # lint uv run ruff check . # lint
``` ```
+2 -1
View File
@@ -17,6 +17,7 @@ from app.routers import (
library, library,
pages, pages,
proxy, proxy,
qbit,
search, search,
system, system,
torznab, torznab,
@@ -58,7 +59,7 @@ def create_app() -> FastAPI:
settings = get_settings() settings = get_settings()
app = FastAPI(title=settings.app_name, lifespan=lifespan) app = FastAPI(title=settings.app_name, lifespan=lifespan)
app.include_router(torznab.router) app.include_router(torznab.router)
app.include_router(qbit.router)
app.include_router(system.router) app.include_router(system.router)
app.include_router(pages.router) app.include_router(pages.router)
app.include_router(pages.protected) app.include_router(pages.protected)
+253
View File
@@ -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.")
+23 -7
View File
@@ -6,12 +6,13 @@ des sessions utilisateurs — aucun cookie requis.
import logging import logging
import secrets import secrets
from urllib.parse import urlencode
from fastapi import APIRouter, Request, Response from fastapi import APIRouter, Request, Response
from app.scrapers.base import ScrapeError from app.scrapers.base import ScrapeError
from app.services.settings import get_torznab_apikey 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__) logger = logging.getLogger(__name__)
@@ -92,10 +93,12 @@ async def torznab_download(
series: str | None = None, series: str | None = None,
apikey: str | None = None, apikey: str | None = None,
) -> Response: ) -> 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 Le flux retourne un .torrent de service : avec un client « Torrent
l'épisode réel entre dans la file de téléchargements interne. 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) error = await _auth_error(request, apikey)
if error is not None: if error is not None:
@@ -106,8 +109,17 @@ async def torznab_download(
media_type="application/xml", media_type="application/xml",
status_code=400, 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: 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")) logger.info("Torznab grab OK : %s → download %s", series, result.get("id"))
except ScrapeError as exc: except ScrapeError as exc:
logger.error("Torznab grab KO : %s", exc) logger.error("Torznab grab KO : %s", exc)
@@ -116,9 +128,13 @@ async def torznab_download(
media_type="application/xml", media_type="application/xml",
status_code=502, status_code=502,
) )
stub = torrent_stub(_base_url(request) + "/torznab/api", f"{series} S{season:02d}E{ep:02d}")
return Response( return Response(
stub, stub,
media_type="application/x-bittorrent", 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"
)
},
) )
+9 -3
View File
@@ -156,9 +156,15 @@ class DownloadManager:
# ------------------------------------------------------------ API publique # ------------------------------------------------------------ API publique
async def enqueue(self, video_url: str, page_url: str, title: str) -> dict: async def enqueue(
"""Ajoute un téléchargement ; retourne la tâche existante si doublon actif.""" self, video_url: str, page_url: str, title: str, source_key: str | None = None
source_key = video_url ) -> 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:<infohash>|<url> » pour rester suivis.
"""
source_key = source_key or video_url
existing = await db.fetchone( existing = await db.fetchone(
f"SELECT * FROM downloads WHERE source_key = ? AND status IN " f"SELECT * FROM downloads WHERE source_key = ? AND status IN "
f"({','.join('?' * len(ACTIVE_STATUSES))})", f"({','.join('?' * len(ACTIVE_STATUSES))})",
+63 -9
View File
@@ -13,7 +13,9 @@ Formats :
- `t=search` → recherche libre (Prowlarr, recherche manuelle) - `t=search` → recherche libre (Prowlarr, recherche manuelle)
""" """
import asyncio import asyncio
import hashlib
import logging import logging
import time import time
from dataclasses import dataclass from dataclasses import dataclass
@@ -83,19 +85,59 @@ def _bencode(value) -> bytes:
def torrent_stub(announce_url: str, name: str) -> bytes: def torrent_stub(announce_url: str, name: str) -> bytes:
"""Fichier .torrent minimal (le vrai téléchargement est fait par OhmStreaming).""" """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, "announce": announce_url,
"created by": "OhmStreaming", "created by": "OhmStreaming",
"comment": name, "comment": name,
"info": { "info": info,
"name": name + ".mp4",
"length": 0,
"piece length": 32768,
"pieces": b"\x00" * 20,
},
} }
) )
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: class TorznabService:
@@ -179,11 +221,22 @@ class TorznabService:
# ------------------------------------------------------------ grab # ------------------------------------------------------------ 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. """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). 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. 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:<hash>| » pour rester suivis via l'API
compatible qBittorrent.
""" """
from app.scrapers.base import get_source from app.scrapers.base import get_source
@@ -202,7 +255,8 @@ class TorznabService:
link = await self._resolve_video(scraper, match.url) link = await self._resolve_video(scraper, match.url)
title = f"{series} S{season:02d}E{ep:02d}" 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"): if link.is_hls or link.headers.get("Referer"):
result["note"] = "HLS/proxy : OhmStreaming gère le téléchargement via ffmpeg" 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")) logger.info("Torznab grab %s → download id=%s", title, result.get("id"))
+6
View File
@@ -59,6 +59,12 @@
Ajoutez un indexeur « Torznab » dans Prowlarr ou Sonarr avec cette URL et cette clé — 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. les épisodes grabés par Sonarr entrent directement dans la file de téléchargements OhmStreaming.
</p> </p>
<p style="color:var(--text-dim);font-size:0.85rem;margin:0.6rem 0 0">
<strong>Encore mieux — client de téléchargement :</strong> dans Sonarr, ajoutez un client
<em>qBittorrent</em> pointant vers OhmStreaming (URL <code>http://&lt;hote-ohm&gt;:8777</code>,
utilisateur <code>ohm</code>, 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.
</p>
<table class="table"> <table class="table">
<tbody> <tbody>
<tr> <tr>
+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: class _FakeManager:
def __init__(self) -> None: 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: async def enqueue(
self.calls.append((video_url, page_url, title)) 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"} 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.status_code == 200
assert r.headers["content-type"].startswith("application/x-bittorrent") assert r.headers["content-type"].startswith("application/x-bittorrent")
assert r.content.startswith(b"d") and b"OhmStreaming" in r.content # bencode valide assert r.content.startswith(b"d") and b"OhmStreaming" in r.content # bencode valide
assert manager.calls == [ video_url, source_key = manager.calls[0]
("https://cdn.example/video.mp4", "https://fake.example/ep2", "Frieren S01E02") 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): async def test_torznab_download_rejects_bad_key(client):