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
+2 -1
View File
@@ -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)
+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 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"
)
},
)
+9 -3
View File
@@ -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:<infohash>|<url> » 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))})",
+63 -9
View File
@@ -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:<hash>| » 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"))
+6
View File
@@ -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.
</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">
<tbody>
<tr>