"""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 {"save_path": "/downloads"} @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, } _categories: dict[str, dict] = {} @router.get("/api/v2/torrents/categories", dependencies=[Depends(_require_sid)]) async def torrents_categories() -> dict: return _categories @router.post("/api/v2/torrents/createCategory", dependencies=[Depends(_require_sid)]) async def torrents_create_category(category: str = Form(""), savePath: str = Form("")) -> Response: if category: _categories[category] = {"name": category, "savePath": savePath} return _plain("Ok.") @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.")