- /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)
254 lines
8.7 KiB
Python
254 lines
8.7 KiB
Python
"""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.")
|