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
+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"))