Files
MTfin/workflow.py
T
2026-03-10 16:16:22 -04:00

297 lines
11 KiB
Python

import json
import time
from pathlib import Path
from utils import DEFAULT_DL_DIR, DEFAULT_JELLYFIN_DIR
from utils_mteam import (
mteam_imdb_info,
search_mteam_torrents,
format_mteam_torrent,
generate_mteam_download_token,
)
from utils_qb import (
get_qb_client,
download_torrent,
get_torrent_file_tree,
get_torrent_hash,
rename_torrent_and_folder,
remove_tag_if_exists
)
from utils_ai import (
select_best_torrents,
generate_rename_mapping,
apply_rename_mapping
)
def format_file_tree(file_tree: list) -> str:
"""Helper to convert the qB file tree output into simple relative paths for the LLM prompt"""
lines = []
for f in file_tree:
lines.append(f.get("name", ""))
return "\n".join(lines)
def prepare_file_tree_paths(file_tree: list, new_name: str, dl_dir: str) -> Path:
"""
Strips the root directory from paths if they all start with it,
and returns the base source directory to use for renaming mapping.
Assumes POSIX separators (no Windows compatibility needed).
"""
has_root_dir = False
if file_tree:
has_root_dir = all(
f.get("name", "").startswith(f"{new_name}/")
for f in file_tree
)
if has_root_dir:
for f in file_tree:
name = f.get("name", "")
if name.startswith(f"{new_name}/"):
f["name"] = "./" + name[len(f"{new_name}/"):]
return Path(dl_dir) / new_name
else:
for f in file_tree:
name = f.get("name", "")
if not name.startswith("./"):
f["name"] = "./" + name
return Path(dl_dir)
def wait_for_download(qb, t_hash: str):
"""
Blocks until the torrent with the given hash is finished downloading.
"""
while True:
info = qb.torrents_info(hashes=t_hash)
if not info:
print("Torrent disappeared from qB!")
break
t_info = info[0]
progress = t_info.progress
state = t_info.state
print(f"Progress: {progress * 100:.1f}% (State: {state})")
# Progress of 1.0 means 100%. Alternatively, check the state.
if progress >= 1.0 or state in ('uploading', 'pausedUP', 'stalledUP', 'forcedUP'):
break
time.sleep(5)
print("Download complete!")
def check_local_filesystem(dl_dir: str, imdb_id: str):
"""
Checks the local download directory for any existing files or folders
that match the given IMDb ID. Returns the path if found.
"""
dl_path = Path(dl_dir)
if dl_path.exists():
for item in dl_path.rglob('*'):
if f"[{imdb_id}]" in item.name:
return item
return None
def check_qbittorrent(qb, imdb_id: str):
"""
Checks qBittorrent for any existing torrent that has the IMDb ID in its name.
Returns the torrent hash if found, or None.
"""
existing_torrents = qb.torrents_info()
for t in existing_torrents:
if f"[{imdb_id}]" in t.name:
return t.hash
return None
def search_and_download_mteam(qb, imdb_id: str, new_name: str, dl_dir: str) -> list:
"""
Searches M-Team for the IMDb ID, uses an LLM to select the best torrents,
downloads them, adds them to qBittorrent, and waits for them to complete.
Returns a list of tuples containing (torrent_hash, torrent_id).
"""
print(f"\n=== [1] Searching Torrents for {imdb_id} ===")
imdb_url = f"https://www.imdb.com/title/{imdb_id}/"
# Extract the torrent list
torrents = search_mteam_torrents(imdb_url)["data"]["data"]
if not torrents:
raise ValueError(f"No torrents found on M-Team for IMDb ID: {imdb_id}")
# Format the torrents text
formatted_torrents = []
for t in torrents:
if isinstance(t, dict):
formatted_torrents.append(format_mteam_torrent(t))
torrents_text = "\n\n".join(formatted_torrents)
print(f"\n=== [2] Selecting best torrents using LLM ===")
selected_ids_str = select_best_torrents(torrents_text)
selected_ids = [tid.strip() for tid in selected_ids_str.split() if tid.strip()]
print(f"Selected torrent IDs: {selected_ids}")
if not selected_ids:
raise ValueError(f"LLM did not select any torrents for IMDb ID: {imdb_id}")
hashes_to_process = []
for tid in selected_ids:
print(f"\n=== [3] Downloading .torrent for ID: {tid} ===")
torrent_bytes = generate_mteam_download_token(tid)
print(f"\n=== [4] Adding torrent to qBittorrent ===")
download_torrent(qb, torrent_bytes, dl_dir)
# Parse local hash directly instead of hoping qB orders correctly
t_hash = get_torrent_hash(torrent_bytes)
if not t_hash:
print(f"Could not compute hash for {tid}, skipping!")
continue
print(f"\n=== [5] Waiting for download to finish ===")
# Wait slightly for qB to process the adding request
time.sleep(3)
print(f"Tracking torrent Hash: {t_hash}")
rename_torrent_and_folder(qb, t_hash, new_name)
wait_for_download(qb, t_hash)
hashes_to_process.append((t_hash, tid))
return hashes_to_process
def process_qb_torrent(qb, t_hash: str, tid: str, new_name: str, dl_dir: str, title_dir: str, imdb_id: str, jellyfin_base_dir: str):
"""
Uses an LLM to generate a rename mapping for the files inside a completed qBittorrent download.
Applies tags based on TV/Movie type and creates symbolic links to the Jellyfin library.
"""
print(f"\n=== [6] Generating rename mapping using LLM ===")
file_tree = get_torrent_file_tree(qb, t_hash)
src_dir_for_mapping = prepare_file_tree_paths(file_tree, new_name, dl_dir)
file_tree_str = format_file_tree(file_tree)
prompt_text = f"Base directory: `{title_dir}`\n\n{file_tree_str}"
print(f"Sending paths to LLM...")
mapping = generate_rename_mapping(prompt_text)
print("Generated Mapping:")
is_tv = False
for src, dst in mapping.items():
print(f" {src} -->> {dst}")
if "Season " in dst or "Series " in dst:
is_tv = True
tag = "Jellyfin TV" if is_tv else "Jellyfin Movie"
opposite_tag = "Jellyfin Movie" if is_tv else "Jellyfin TV"
jellyfin_dir = f"{jellyfin_base_dir}/TV" if is_tv else f"{jellyfin_base_dir}/Movie"
jellyfin_base = Path(jellyfin_dir) / f"{title_dir} [{imdb_id}]"
print(f"\n=== [6.5] Adding '{tag}' tag to torrent ===")
qb.torrents_add_tags(tags=tag, torrent_hashes=t_hash)
remove_tag_if_exists(qb, t_hash, opposite_tag)
print(f"\n=== [7] Creating symbolic links ===")
apply_rename_mapping(mapping, base_src_dir=src_dir_for_mapping, base_dst_dir=jellyfin_base)
print(f"Finished processing torrent: {tid}")
def process_local_file(fs_path: Path, dl_dir: str, title_dir: str, imdb_id: str, jellyfin_base_dir: str):
"""
Generates an LLM rename mapping for purely local files/folders (skipping qBittorrent)
and creates symbolic links to the Jellyfin library.
"""
print(f"\n=== [6] Generating rename mapping using LLM for local path ===")
# Mock file tree logic for local files
file_tree = []
if fs_path.is_file():
file_tree.append({"name": fs_path.name})
else:
for p in fs_path.rglob('*'):
if p.is_file():
# relative to fs_path's parent so it starts with fs_path.name
rel_path = p.relative_to(fs_path.parent)
file_tree.append({"name": str(rel_path.as_posix())})
src_dir_for_mapping = prepare_file_tree_paths(file_tree, fs_path.name, dl_dir)
file_tree_str = format_file_tree(file_tree)
prompt_text = f"Base directory: `{title_dir}`\n\n{file_tree_str}"
print(f"Sending paths to LLM...")
mapping = generate_rename_mapping(prompt_text)
print("Generated Mapping:")
is_tv = False
for src, dst in mapping.items():
print(f" {src} -->> {dst}")
if "Season " in dst or "Series " in dst:
is_tv = True
jellyfin_dir = f"{jellyfin_base_dir}/TV" if is_tv else f"{jellyfin_base_dir}/Movie"
jellyfin_base = Path(jellyfin_dir) / f"{title_dir} [{imdb_id}]"
print(f"\n=== [7] Creating symbolic links ===")
apply_rename_mapping(mapping, base_src_dir=src_dir_for_mapping, base_dst_dir=jellyfin_base)
print(f"Finished processing local file: {fs_path.name}")
def process_imdb_workflow(imdb_id: str, dl_dir: str = DEFAULT_DL_DIR, jellyfin_base_dir: str = DEFAULT_JELLYFIN_DIR):
"""
Workflow to automatically find, download, and map torrents for an IMDb ID into a Jellyfin library.
"""
print(f"=== [0] Fetching IMDB info for {imdb_id} ===")
imdb_info = mteam_imdb_info(imdb_id)
if 'data' not in imdb_info:
raise ValueError(f"Failed to get IMDB info from M-Team: {imdb_info}")
title = imdb_info['data'].get('title', 'Unknown_Title')
year = imdb_info['data'].get('year', '')
title_dir = f"{title} ({year})"
print(f"Found Title: {title_dir}")
print(f"\n=== [0.2] Checking if already exists in file system ===")
fs_match_dir = check_local_filesystem(dl_dir, imdb_id)
new_name = f"{year} {title} [{imdb_id}]".strip()
if fs_match_dir:
print(f"Found existing file/directory in file system: {fs_match_dir.name}, skipping qBit check, search, and download.")
process_local_file(fs_match_dir, dl_dir, title_dir, imdb_id, jellyfin_base_dir)
return
print(f"\n=== [0.5] Checking if torrent already exists in qBittorrent ===")
qb = get_qb_client()
existing_t_hash = check_qbittorrent(qb, imdb_id)
hashes_to_process = []
if existing_t_hash:
print(f"Found existing torrent with hash {existing_t_hash}, skipping search and download.")
rename_torrent_and_folder(qb, existing_t_hash, new_name)
print(f"\n=== [0.6] Waiting for existing download to finish ===")
wait_for_download(qb, existing_t_hash)
hashes_to_process.append((existing_t_hash, "existing"))
else:
hashes_to_process = search_and_download_mteam(qb, imdb_id, new_name, dl_dir)
# Process qB torrents
for t_hash, tid in hashes_to_process:
process_qb_torrent(qb, t_hash, tid, new_name, dl_dir, title_dir, imdb_id, jellyfin_base_dir)
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Workflow to automatically find, download, and map torrents for an IMDb ID into Jellyfin.")
parser.add_argument("imdb_id", type=str, help="The IMDb ID to process (e.g., tt38872297)")
parser.add_argument("--dl-dir", type=str, default=DEFAULT_DL_DIR, help="The qBittorrent download directory")
parser.add_argument("--jellyfin-dir", type=str, default=DEFAULT_JELLYFIN_DIR, help="The base Jellyfin library directory")
args = parser.parse_args()
process_imdb_workflow(args.imdb_id, dl_dir=args.dl_dir, jellyfin_base_dir=args.jellyfin_dir)