Initial commit: StashHandler

Bridge between qBittorrent and Stash that polls for completed torrents,
deduplicates via SHA256 hashing and SQLite, and imports new media files.
Supports daemon mode (continuous polling) and scan mode (bulk operations).
Configuration via environment variables for Docker Swarm compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 11:25:05 -05:00
commit f09d94e34b
10 changed files with 617 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
import logging
import requests
log = logging.getLogger(__name__)
class QBitClient:
def __init__(self, url, username, password):
self.url = url.rstrip("/")
self.username = username
self.password = password
self.session = requests.Session()
self._logged_in = False
def login(self):
"""Authenticate with qBittorrent and store the session cookie."""
resp = self.session.post(
f"{self.url}/api/v2/auth/login",
data={"username": self.username, "password": self.password},
)
resp.raise_for_status()
if resp.text.strip() == "Ok.":
self._logged_in = True
log.info("Logged in to qBittorrent at %s", self.url)
else:
raise RuntimeError(f"qBit login failed: {resp.text}")
def _request(self, method, endpoint, **kwargs):
"""Make an API request, re-authenticating on 403."""
if not self._logged_in:
self.login()
resp = self.session.request(method, f"{self.url}{endpoint}", **kwargs)
if resp.status_code == 403:
log.info("Session expired, re-authenticating")
self.login()
resp = self.session.request(method, f"{self.url}{endpoint}", **kwargs)
resp.raise_for_status()
return resp
def get_completed_torrents(self):
"""Return a list of completed torrent dicts from qBittorrent."""
resp = self._request("GET", "/api/v2/torrents/info", params={"filter": "completed"})
return resp.json()
def get_torrent_files(self, info_hash):
"""Return the file list for a specific torrent."""
resp = self._request("GET", "/api/v2/torrents/files", params={"hash": info_hash})
return resp.json()