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:
@@ -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
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user