Skip to content
5 changes: 3 additions & 2 deletions lib/clients/base.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from abc import ABC, abstractmethod
from requests import Session

from typing import List
from lib.domain.source import Source

class BaseClient(ABC):
def __init__(self, host, notification):
Expand All @@ -9,7 +10,7 @@ def __init__(self, host, notification):
self.session = Session()

@abstractmethod
def search(self, tmdb_id, query, mode, media_type, season, episode):
def search(self, tmdb_id, query, mode, media_type, season, episode) -> List[Source]:
pass

@abstractmethod
Expand Down
4 changes: 2 additions & 2 deletions lib/clients/debrid/torbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,9 @@ def get_available_torrent(self, info_hash):

def get_torrent_instant_availability(self, torrent_hashes):
return self._make_request(
"GET",
"POST",
"/torrents/checkcached",
params={"hash": torrent_hashes, "format": "object"},
json={"hashes": torrent_hashes, "format": "object"},
)

def create_download_link(self, torrent_id, filename, user_ip):
Expand Down
187 changes: 187 additions & 0 deletions lib/clients/debrid/torrserve.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
import requests
from requests.auth import HTTPBasicAuth
from requests.exceptions import RequestException
from urllib.parse import urlparse
from lib.api.jacktook.kodi import kodilog

class TorrServeException(Exception):
pass

class TorrServeClient:
def __init__(self, base_url="http://localhost:8090", username=None, password=None):
# Validate and format base URL
parsed = urlparse(base_url)
if not parsed.scheme:
base_url = f"http://{base_url}"
elif parsed.scheme not in ("http", "https"):
raise TorrServeException("Invalid URL scheme. Use http:// or https://")

self.base_url = base_url.rstrip("/")
self.session = requests.Session()

if username and password:
self.session.auth = HTTPBasicAuth(username, password)

self.session.headers.update({
"Accept": "application/json",
"Content-Type": "application/json"
})

def _request(self, method, endpoint, data=None):
"""Improved URL handling with better error messages"""
try:
# Construct safe URL
endpoint = endpoint.lstrip("/")
url = f"{self.base_url}/{endpoint}"

# Validate URL format
parsed = urlparse(url)
if not parsed.scheme or not parsed.netloc:
raise TorrServeException(f"Invalid URL format: {url}")

response = self.session.request(method, url, json=data)
response.raise_for_status()
return response.json()
except RequestException as e:
raise TorrServeException(f"Request to {url} failed: {str(e)}")
except ValueError:
raise TorrServeException(f"Invalid JSON response from {url}")

def get_torrent_instant_availability(self, info_hashes):
"""
Check availability of torrents by their info hashes

Args:
info_hashes (list): List of torrent info hashes (strings)

Returns:
dict: Dictionary with info_hash as key and availability percentage as value
"""
kodilog(f"Checking availability for {info_hashes} torrents")
try:
# Get list of all torrents
response = self._request("POST", "/torrents", {
"action": "list"
})

available = {}
hash_map = {}

# Parse response (assuming array of TorrentStatus)
for torrent in response:
if "hash" in torrent:
t_hash = torrent["hash"].lower()
if not t_hash in info_hashes:
continue
status = torrent.get("stat", 0)

# Calculate completion percentage
size = torrent.get("torrent_size", 0)
loaded = torrent.get("preloaded_bytes", 0)
percentage = (loaded / size * 100) if size > 0 else 0

hash_map[t_hash] = round(percentage, 2) if status == 3 else 0 # Only consider working torrents

# Match requested hashes
for ih in info_hashes:
completion = hash_map.get(ih.lower(), 0)
if not completion:
continue
available[ih.lower()] = hash_map.get(ih.lower(), 0)

return available

except TorrServeException as e:
raise TorrServeException(f"Availability check failed: {str(e)}")

def add_magnet(self, magnet_uri, save_to_db=True, title=None, category=None):
"""
Add a torrent by magnet URI

Args:
magnet_uri (str): Magnet URI to add
save_to_db (bool): Save torrent to database
title (str): Custom title for the torrent
category (str): Torrent category (movie/tv/music/other)

Returns:
dict: Dictionary containing:
- status: "added", "duplicate", or "error"
- info_hash: Torrent info hash (lowercase)
- percentage: Current download completion percentage
"""
try:
# Add torrent request
payload = {
"action": "add",
"link": magnet_uri,
"save_to_db": save_to_db
}
kodilog(f"Payload: {payload}")
if title:
payload["title"] = title
if category:
payload["category"] = category

response = self._request("POST", "/torrents", payload)

# Check response status
status_code = response.get("stat", 0)
info_hash = response.get("hash", "").lower()

if not info_hash:
raise TorrServeException("Missing info hash in response")

# Determine status
if status_code == 5: # TorrentInDB
status = "duplicate"
elif status_code == 3: # TorrentWorking
status = "added"
else:
status = "unknown"

# Calculate percentage
size = response.get("torrent_size", 0)
loaded = response.get("preloaded_bytes", 0)
percentage = (loaded / size * 100) if size > 0 else 0

return {
"status": status,
"info_hash": info_hash,
"percentage": round(percentage, 2)
}

except TorrServeException as e:
return {
"status": "error",
"info_hash": "",
"percentage": 0,
"error": str(e)
}

def get_torrent_status(self, info_hash):
"""
Get detailed status of a specific torrent
"""
try:
response = self._request("POST", "/torrents", {
"action": "get",
"hash": info_hash
})
return response
except TorrServeException as e:
raise TorrServeException(f"Status check failed: {str(e)}")

def remove_torrent(self, info_hash, remove_data=False):
"""
Remove a torrent from the server
"""
try:
action = "rem" if not remove_data else "drop"
self._request("POST", "/torrents", {
"action": action,
"hash": info_hash
})
return True
except TorrServeException as e:
raise TorrServeException(f"Removal failed: {str(e)}")
120 changes: 120 additions & 0 deletions lib/clients/debrid/transmission.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import requests
from lib.utils.kodi_utils import notification
from requests.exceptions import RequestException
from lib.domain.interface.cache_provider_interface import CacheProviderInterface
from lib.domain.cached_source import CachedSource
from typing import Dict, List
from lib.api.jacktook.kodi import kodilog
from lib.domain.source import Source
from lib.utils.kodi_formats import is_video

class TransmissionException(Exception):
pass

class TransmissionClient(CacheProviderInterface):
def __init__(self, base_url: str = "http://192.168.1.130:9091", downloads_url: str = "", username: str = "", password: str = ""):
self.base_url = base_url.rstrip("/")
self.session = requests.Session()
self.session_id = None
self.downloads_url = downloads_url.rstrip("/")
self.session.headers.update({"Content-Type": "application/json", "Accept": "application/json"})
if username and password:
self.session.auth = (username, password)

def _rpc_request(self, method, arguments=None):
url = f"{self.base_url}/transmission/rpc"
payload = {"method": method, "arguments": arguments or {}}

for _ in range(2): # Allow one retry after 409
try:
response = self.session.post(url, json=payload)
if response.status_code == 409:
self.session_id = response.headers.get("X-Transmission-Session-Id")
if self.session_id:
self.session.headers["X-Transmission-Session-Id"] = self.session_id
continue # Retry with new session ID
raise TransmissionException("Missing session ID in 409 response")

response.raise_for_status()
data = response.json()
if data.get("result") != "success":
raise TransmissionException(f"RPC error: {data.get('result')}")

return data.get("arguments", {})

except (RequestException, ValueError) as e:
raise TransmissionException(f"Request failed: {str(e)}")

raise TransmissionException("Failed after session ID retry")

def get_torrent_instant_availability(self, info_hashes):
try:
torrents = self._rpc_request("torrent-get", {"fields": ["hashString", "percentDone"]}).get("torrents", [])
hash_map = {t["hashString"].lower(): t["percentDone"] for t in torrents}
return {ih: round(hash_map[ih.lower()] * 100, 2) for ih in info_hashes if ih.lower() in hash_map}
except TransmissionException as e:
notification(f"Transmission error: {str(e)}")
raise

def add_magnet(self, magnet_uri: str):
try:
response = self._rpc_request("torrent-add", {"filename": magnet_uri, "paused": False})
if "torrent-added" in response:
torrent = response["torrent-added"]
return {"status": "added", "info_hash": torrent["hashString"].lower()}
elif "torrent-duplicate" in response:
torrent = response["torrent-duplicate"]
return {
"status": "duplicate",
"info_hash": torrent["hashString"].lower(),
"percentage": round(torrent["percentDone"] * 100, 2),
}
raise TransmissionException("Unexpected response structure")
except TransmissionException as e:
notification(f"Failed to add magnet: {str(e)}")
raise

def get_cached_hashes(self, sources: List[Source]) -> Dict[str, CachedSource]:
info_hashes = {source["info_hash"]: source.get("filename", "") for source in sources if source.get("info_hash")}
cached_sources = {}

try:
torrents = self._rpc_request("torrent-get", {"fields": ["hashString", "percentDone", "files"]}).get("torrents", [])
kodilog(f"TransmissionClient: {len(torrents)} torrents found")

for t in torrents:
t_hash = t["hashString"]
if t_hash not in info_hashes:
continue

filename = info_hashes[t_hash]

t_files = [f"{self.downloads_url}/{file['name']}" for file in t.get("files", [])]

first_video = ""
playable_url = ""
for file in t_files:
if filename and file.endswith(filename):
playable_url = file
break
if not first_video and is_video(file):
first_video = file

if not playable_url:
playable_url = first_video

cached_sources[t["hashString"]] = CachedSource(
hash=t["hashString"].lower(),
cache_provider=self,
cache_provider_name="Transmission",
ratio=t["percentDone"],
instant_availability=t["percentDone"] == 1,
urls=t_files,
playable_url=playable_url,
)

return cached_sources

except TransmissionException as e:
notification(f"Transmission error: {str(e)}")
raise
2 changes: 1 addition & 1 deletion lib/clients/elfhosted.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def parse_response(self, res):
"type": "Torrent",
"indexer": "Elfhosted",
"guid": item["infoHash"],
"infoHash": item["infoHash"],
"info_hash": item["infoHash"],
"size": parsed_item["size"],
"publishDate": "",
"seeders": 0,
Expand Down
2 changes: 1 addition & 1 deletion lib/clients/jackett.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,6 @@ def extract_result(results, item):
"magnetUrl": attributes.get("magneturl", ""),
"seeders": int(attributes.get("seeders", 0)),
"peers": int(attributes.get("peers", 0)),
"infoHash": attributes.get("infohash", ""),
"info_hash": attributes.get("infohash", ""),
}
)
2 changes: 1 addition & 1 deletion lib/clients/jacktook_burst.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def parse_response(self, res):
"indexer": "Burst",
"provider": r.indexer,
"guid": r.guid,
"infoHash": None,
"info_hash": None,
"size": convert_size_to_bytes(r.size),
"seeders": int(r.seeders),
"peers": int(r.peers),
Expand Down
4 changes: 2 additions & 2 deletions lib/clients/medifusion.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ def parse_response(self, res):
"type": "Torrent",
"indexer": "MediaFusion",
"guid": info_hash,
"infoHash": info_hash,
"info_hash": info_hash,
"size": parsed_item["size"],
"seeders": parsed_item["seeders"],
"languages": parsed_item["languages"],
Expand All @@ -92,7 +92,7 @@ def extract_info_hash(self, item):
path = urlparse(item["url"]).path.split("/")
info_hash = path[path.index("stream") + 1]
else:
info_hash = item["infoHash"]
info_hash = item["info_hash"]
return info_hash

def parse_stream_title(self, item):
Expand Down
4 changes: 2 additions & 2 deletions lib/clients/peerflix.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ def parse_response(self, res):
"title": item["title"].splitlines()[0],
"type": "Torrent",
"indexer": "Peerflix",
"guid": item["infoHash"],
"infoHash": item["infoHash"],
"guid": item["info_hash"],
"info_hash": item["infoHash"],
"size":item["sizebytes"] or 0,
"seeders": item.get("seed", 0) or 0,
"languages": [item["language"]],
Expand Down
Loading