From acb22ce9bb54a09a542e1a587181eb731700243e Mon Sep 17 00:00:00 2001 From: Davide Marcoli <69892203+davidemarcoli@users.noreply.github.com> Date: Sat, 26 Oct 2024 18:22:01 +0200 Subject: [PATCH] feat: add manual torrent adding (#785) * feat: add manual torrent adding * feat: add cache check when manually adding torrent chore: remove not needed state transition * fix: wrong import --------- Co-authored-by: Filip Trplan --- src/program/downloaders/__init__.py | 3 + src/program/downloaders/torbox.py | 6 +- src/program/indexers/trakt.py | 2 +- src/routers/secure/items.py | 90 ++++++++++++++++++++++++++++- src/utils/torrent.py | 49 ++++++++++++++++ 5 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 src/utils/torrent.py diff --git a/src/program/downloaders/__init__.py b/src/program/downloaders/__init__.py index 754caeca..850cf60e 100644 --- a/src/program/downloaders/__init__.py +++ b/src/program/downloaders/__init__.py @@ -135,6 +135,9 @@ def download(self, item, active_stream: dict) -> str: "DEBRID", f"Downloaded {item.log_string} from '{item.active_stream['name']}' [{item.active_stream['infohash']}]", ) + + def add_torrent(self, infohash: str): + return self.service.add_torrent(infohash) def add_torrent_magnet(self, magnet_link: str): return self.service.add_torrent_magnet(magnet_link) diff --git a/src/program/downloaders/torbox.py b/src/program/downloaders/torbox.py index f59310b9..e698f35b 100644 --- a/src/program/downloaders/torbox.py +++ b/src/program/downloaders/torbox.py @@ -268,7 +268,7 @@ def download(self, item: MediaItem): # If it doesnt, lets download it and refresh the torrent_list if not exists: - id = self.create_torrent(item.active_stream["hash"]) + id = self.add_torrent(item.active_stream["hash"]) torrent_list = self.get_torrent_list() # Find the torrent, correct file and we gucci @@ -316,8 +316,8 @@ def get_torrent_cached(self, hash_list): ) return response.data["data"] - def create_torrent(self, hash) -> int: - magnet_url = f"magnet:?xt=urn:btih:{hash}&dn=&tr=" + def add_torrent(self, infohash) -> int: + magnet_url = f"magnet:?xt=urn:btih:{infohash}&dn=&tr=" response = post( f"{self.base_url}/torrents/createtorrent", data={"magnet": magnet_url, "seed": 1, "allow_zip": False}, diff --git a/src/program/indexers/trakt.py b/src/program/indexers/trakt.py index ae6541c4..5e0c4927 100644 --- a/src/program/indexers/trakt.py +++ b/src/program/indexers/trakt.py @@ -27,7 +27,7 @@ def __init__(self): @staticmethod def copy_attributes(source, target): """Copy attributes from source to target.""" - attributes = ["file", "folder", "update_folder", "symlinked", "is_anime", "symlink_path", "subtitles", "requested_by", "requested_at", "overseerr_id", "active_stream", "requested_id"] + attributes = ["file", "folder", "update_folder", "symlinked", "is_anime", "symlink_path", "subtitles", "requested_by", "requested_at", "overseerr_id", "active_stream", "requested_id", "streams"] for attr in attributes: target.set(attr, getattr(source, attr, None)) diff --git a/src/routers/secure/items.py b/src/routers/secure/items.py index 4b8ee20e..11975030 100644 --- a/src/routers/secure/items.py +++ b/src/routers/secure/items.py @@ -4,6 +4,12 @@ import Levenshtein from fastapi import APIRouter, HTTPException, Request, status +from RTN import ParsedData, Torrent +from fastapi import APIRouter, HTTPException, Request +from sqlalchemy import func, select +from sqlalchemy.exc import NoResultFound + +from fastapi import APIRouter, HTTPException, Request from program.content import Overseerr from program.db.db import db from program.db.db_functions import ( @@ -27,6 +33,11 @@ from sqlalchemy.exc import NoResultFound from loguru import logger +from program.indexers.trakt import TraktIndexer + +from ..models.shared import MessageResponse +from utils.torrent import get_type_and_infohash + from ..models.shared import MessageResponse router = APIRouter( @@ -222,6 +233,82 @@ async def add_items(request: Request, imdb_ids: str = None) -> MessageResponse: return {"message": f"Added {len(valid_ids)} item(s) to the queue"} +@router.post( + "/add-manually", + summary="Add Media Items Manually", + description="Add media item manually with a magnet link or infohash", + operation_id="add_item_manually", +) +async def add_item_manually(request: Request, imdb_id: str = None, input: str = None) -> MessageResponse: + if not imdb_id: + raise HTTPException(status_code=400, detail="No IMDb ID provided") + + if not imdb_id.startswith("tt"): + raise HTTPException(status_code=400, detail="No valid IMDb ID provided") + + type, infohash = get_type_and_infohash(input) + + if not infohash: + raise HTTPException(status_code=400, detail="No valid input provided") + + trakt: TraktIndexer = request.app.program.services.get(TraktIndexer) + downloader: Downloader = request.app.program.services.get(Downloader) + with db.Session() as session: + item = MediaItem( + {"imdb_id": imdb_id, "requested_by": "user", "requested_at": datetime.now()} + ) + item = next(trakt.run(item), None) + if item is None: + raise HTTPException(status_code=500, detail="Failed to index item") + + needed_media = get_needed_media(item) + cached_streams = downloader.get_cached_streams([infohash], needed_media) + + if len(cached_streams) == 0: + session.rollback() + raise HTTPException( + status_code=400, + detail=f"The selected torrent is not cached for {item.log_string}", + ) + + + raw_title = f"Manual Torrent for {imdb_id}" + torrent = Torrent( + raw_title=raw_title, + infohash=infohash, + data=ParsedData(raw_title=raw_title), + ) + stream = Stream(torrent) + session.add(stream) + item.streams = [stream] + item.active_stream = cached_streams[0] + session.add(item) + session.commit() + + try: + downloader.download(item, item.active_stream) + except Exception as e: + logger.error(f"Failed to download {item.log_string}: {e}") + if item.active_stream.get("infohash", None): + downloader._delete_and_reset_active_stream(item) + session.rollback() + raise HTTPException( + status_code=500, detail=f"Failed to download {item.log_string}: {e}" + ) from e + + item.last_state = States.Downloaded + session.commit() + + request.app.program.em.add_event(Event("Symlinker", item._id)) + + return { + "success": True, + "message": f"Added {imdb_id} manually to the database (format was {type})", + "item_id": item._id, + "torrent_id": input, + } + + @router.get( "/{id}", summary="Retrieve Media Item", @@ -382,7 +469,8 @@ def add_torrent(request: Request, id: int, magnet: str) -> SetTorrentRDResponse: torrent_id = "" downloader: Downloader = request.app.program.services.get(Downloader).service try: - torrent_id = downloader.add_torrent_magnet(magnet) + _, infohash = get_type_and_infohash(magnet) + torrent_id = downloader.add_torrent(infohash) except Exception: raise HTTPException(status_code=500, detail="Failed to add torrent.") from None diff --git a/src/utils/torrent.py b/src/utils/torrent.py new file mode 100644 index 00000000..9b66c09e --- /dev/null +++ b/src/utils/torrent.py @@ -0,0 +1,49 @@ +import re +from urllib.parse import urlparse, parse_qs + +def extract_infohash(magnet_link): + # Check if the input is a valid magnet link + if not magnet_link.startswith('magnet:?'): + raise ValueError("Invalid magnet link format") + + # Parse the magnet link + parsed = urlparse(magnet_link) + params = parse_qs(parsed.query) + + # Look for the 'xt' parameter + xt_params = params.get('xt', []) + for xt in xt_params: + # Check if it's a BitTorrent info hash + if xt.startswith('urn:btih:'): + # Extract the info hash + infohash = xt.split(':')[-1].lower() + + # Validate the infohash + if re.match(r'^[0-9a-f]{40}$', infohash): + return infohash + elif re.match(r'^[0-9a-z]{32}$', infohash): + # It's a base32 encoded infohash + return infohash + + raise ValueError("No valid BitTorrent info hash found in the magnet link") + + +def get_type_and_infohash(input_string): + # Check if it's a magnet link + if input_string.startswith('magnet:?'): + try: + infohash = extract_infohash(input_string) + return "Magnet Link", infohash + except ValueError: + return "Invalid Magnet Link", None + + # Check if it's a SHA-1 infohash (40 hexadecimal characters) + if re.match(r'^[0-9a-fA-F]{40}$', input_string): + return "Infohash (SHA-1)", input_string.lower() + + # Check if it's a base32 encoded infohash (32 alphanumeric characters) + if re.match(r'^[0-9a-zA-Z]{32}$', input_string): + return "Infohash (Base32)", input_string.lower() + + # If it's neither + return "Unknown", None \ No newline at end of file