feat(torrentify): add torrent management commands
This commit is contained in:
1 parent
0d97094092
commit
74e887db04
2 files changed
+508
-469
No files matched your search
+261
-469
@@ -1,501 +1,293 @@
|
||||
#!/usr/bin/env python3.9
|
||||
import argparse
|
||||
#!/usr/bin/env -S uv run --script
|
||||
# /// script
|
||||
# dependencies = ["click", "httpx", "torf"]
|
||||
# ///
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import select
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from turtle import back
|
||||
import typing
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import ffmpeg
|
||||
from ffmpeg_strip import clean_video
|
||||
import click
|
||||
import httpx
|
||||
import torf
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format=f"%(asctime)s {logging.BASIC_FORMAT}")
|
||||
ANNOUNCE_URLS = ("http://tracker.empornium.sx:2710/tegqucis10uanp672qh6nhn393xlkncs/announce",)
|
||||
AUTHOR = "zzzp"
|
||||
|
||||
|
||||
def force_import(module: str):
|
||||
import importlib
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
try:
|
||||
return importlib.import_module(module)
|
||||
except ModuleNotFoundError:
|
||||
subprocess.run([sys.executable, "-m", "pip", "install", module])
|
||||
importlib.invalidate_caches()
|
||||
return importlib.import_module(module)
|
||||
|
||||
|
||||
try:
|
||||
import torf
|
||||
except ImportError:
|
||||
torf = force_import("torf")
|
||||
|
||||
try:
|
||||
import httpx
|
||||
except ImportError:
|
||||
httpx = force_import("httpx")
|
||||
|
||||
try:
|
||||
import inquirer
|
||||
except ImportError:
|
||||
inquirer = force_import("inquirer")
|
||||
|
||||
TORRENT_TRACKER_URL = os.getenv(
|
||||
"TORRENT_TRACKER_URL", "http://tracker.empornium.sx:2710/tegqucis10uanp672qh6nhn393xlkncs/announce"
|
||||
)
|
||||
TORRENT_CREATED_BY = os.getenv("TORRENT_CREATED_BY", "zzzp")
|
||||
TORRENT_DIR = Path(os.getenv("TORRENT_DIR", "/mnt/box/files/_torrents/_new2/"))
|
||||
|
||||
VIDEO_EXTENSIONS = {".mp4", ".mpg", ".mkv"}
|
||||
def format_size(size: int) -> str:
|
||||
if size < 1000:
|
||||
return f"{size:,} bytes"
|
||||
scaled_size = float(size)
|
||||
for unit in ("KB", "MB", "GB", "TB", "PB", "EB"):
|
||||
scaled_size /= 1000
|
||||
if scaled_size < 1000 or unit == "EB":
|
||||
return f"{scaled_size:.2f} {unit} ({size:,} bytes)"
|
||||
raise ValueError(f"Cannot format size: {size}")
|
||||
|
||||
|
||||
def make_torrent(source_dir: Path, tracker_url: str = TORRENT_TRACKER_URL) -> Path:
|
||||
torrent_path = source_dir / f"{source_dir.name}.torrent"
|
||||
if torrent_path.is_file():
|
||||
logging.info("Torrent file is already created")
|
||||
return torrent_path
|
||||
|
||||
t = torf.Torrent(
|
||||
path=str(source_dir.resolve()),
|
||||
name=source_dir.resolve().name,
|
||||
trackers=[tracker_url],
|
||||
private=True,
|
||||
created_by=TORRENT_CREATED_BY,
|
||||
exclude_globs=["*.txt", "post.txt", "*.torrent", "*.gif"],
|
||||
def create_torrent(
|
||||
paths: tuple[Path, ...],
|
||||
output: Path | None = None,
|
||||
announce_urls: tuple[str, ...] = ANNOUNCE_URLS,
|
||||
author: str = AUTHOR,
|
||||
private: bool = True,
|
||||
name: str | None = None,
|
||||
overwrite: bool = False,
|
||||
) -> Path:
|
||||
sources = list(dict.fromkeys(path.expanduser().absolute() for path in paths))
|
||||
if not sources:
|
||||
raise ValueError("Provide at least one file or directory")
|
||||
for source in sources:
|
||||
if not source.is_file() and not source.is_dir():
|
||||
raise ValueError(f"Not a file or directory: {source}")
|
||||
if len({source.parent for source in sources}) > 1:
|
||||
raise ValueError("All inputs must be in the same directory")
|
||||
torrent = torf.Torrent(
|
||||
trackers=announce_urls,
|
||||
created_by=author,
|
||||
private=private,
|
||||
creation_date=int(time.time()),
|
||||
)
|
||||
t.generate()
|
||||
t.write(torrent_path, overwrite=True)
|
||||
if len(sources) == 1:
|
||||
torrent.path = sources[0]
|
||||
else:
|
||||
torrent.filepaths = sources
|
||||
if name is not None:
|
||||
torrent.name = name
|
||||
if torrent.size <= 0:
|
||||
raise ValueError("The selected paths contain no nonempty files")
|
||||
torrent_path = output or Path.cwd() / f"{torrent.name}.torrent"
|
||||
if torrent_path.exists() and not overwrite:
|
||||
raise FileExistsError(f"Output already exists: {torrent_path}")
|
||||
if torrent_path.absolute() in {Path(path).absolute() for path in torrent.filepaths}:
|
||||
raise ValueError("Output must not overwrite a source file")
|
||||
with click.progressbar(length=torrent.pieces, label="Hashing", show_percent=True, file=sys.stderr) as progress:
|
||||
|
||||
def report_progress(
|
||||
torrent: torf.Torrent,
|
||||
filepath: str,
|
||||
pieces_done: int,
|
||||
pieces_total: int,
|
||||
) -> None:
|
||||
progress.update(n_steps=pieces_done - progress.pos)
|
||||
|
||||
if not torrent.generate(callback=report_progress, interval=0.2):
|
||||
raise ValueError("Torrent hashing did not complete")
|
||||
torrent.write(filepath=torrent_path, overwrite=overwrite)
|
||||
return torrent_path
|
||||
|
||||
|
||||
def human_size(size: int) -> str:
|
||||
suffix = "B"
|
||||
for unit in ["", "K", "M", "G", "T", "P", "E", "Z"]:
|
||||
if abs(size) < 1024.0:
|
||||
return "%3.2f%s%s" % (size, unit, suffix)
|
||||
size /= 1024.0
|
||||
return "%.2f%s%s" % (size, "Yi", suffix)
|
||||
def modify_torrent(
|
||||
torrent_path: Path,
|
||||
output: Path | None = None,
|
||||
announce_urls: tuple[str, ...] = (),
|
||||
author: str | None = None,
|
||||
private: bool | None = None,
|
||||
name: str | None = None,
|
||||
) -> Path:
|
||||
torrent = torf.Torrent.read(filepath=torrent_path)
|
||||
if announce_urls:
|
||||
torrent.trackers = announce_urls
|
||||
if author is not None:
|
||||
torrent.created_by = author
|
||||
if private is not None:
|
||||
torrent.private = private
|
||||
if name is not None:
|
||||
torrent.name = name
|
||||
destination = output or torrent_path
|
||||
torrent.write(filepath=destination, overwrite=destination.absolute() == torrent_path.absolute())
|
||||
return destination
|
||||
|
||||
|
||||
def find_video(root_dir: Path) -> Optional[Path]:
|
||||
for ext in VIDEO_EXTENSIONS:
|
||||
for f in root_dir.rglob(f"*{ext}"):
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
def pick_name(video_path: Path) -> str:
|
||||
try:
|
||||
if parse_filename(video_path.stem):
|
||||
return video_path.stem
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
candidates = set()
|
||||
|
||||
parent = video_path.parent
|
||||
while parent.exists():
|
||||
try:
|
||||
if parse_filename(parent.name):
|
||||
candidates.add(parent.name)
|
||||
except ValueError:
|
||||
pass
|
||||
if parent.parent == parent:
|
||||
break
|
||||
parent = parent.parent
|
||||
|
||||
if len(candidates) == 1:
|
||||
return candidates.pop()
|
||||
elif len(candidates) > 1:
|
||||
questions = [
|
||||
inquirer.List(
|
||||
"best_name",
|
||||
message="Pick the best filename",
|
||||
choices=[*candidates, "<custom>"],
|
||||
),
|
||||
]
|
||||
|
||||
answers = inquirer.prompt(questions) or {}
|
||||
best_name = answers.get("best_name")
|
||||
if best_name != "<custom>":
|
||||
return best_name
|
||||
|
||||
while True:
|
||||
try:
|
||||
answers = inquirer.prompt([inquirer.Editor("best_name", message="New name", default=video_path.stem)])
|
||||
if not answers:
|
||||
raise Exception("Cancelled")
|
||||
best_name = answers["best_name"].splitlines(keepends=False)[0].strip()
|
||||
parse_filename(best_name)
|
||||
return best_name
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
|
||||
def move_video(video_path: Path, new_name: typing.Optional[str], link: bool = False) -> Path:
|
||||
"""
|
||||
Move video to the torrent directory and returns the video path
|
||||
"""
|
||||
if new_name:
|
||||
best_name = Path(new_name).name
|
||||
else:
|
||||
best_name = pick_name(video_path)
|
||||
assert not best_name.startswith('.')
|
||||
logging.info("using name: %s", best_name)
|
||||
|
||||
best_name = best_name.removesuffix('.nometadata')
|
||||
if Path(best_name).suffix in VIDEO_EXTENSIONS:
|
||||
best_name = Path(best_name).stem
|
||||
|
||||
renamed_path = video_path.with_stem(best_name)
|
||||
if link:
|
||||
video_path = video_path.link_to(renamed_path)
|
||||
else:
|
||||
video_path = video_path.rename(renamed_path)
|
||||
video_path = renamed_path
|
||||
|
||||
new_loc = TORRENT_DIR / video_path.stem
|
||||
new_loc.mkdir(parents=True, exist_ok=True)
|
||||
target_path = new_loc / video_path.name
|
||||
video_path.rename(target_path)
|
||||
|
||||
for f in video_path.parent.glob("*.jpg"):
|
||||
if f.stem.startswith(video_path.stem):
|
||||
f.rename(new_loc / f.name)
|
||||
break
|
||||
|
||||
return target_path
|
||||
|
||||
|
||||
def upload_image(image_path: Path) -> str:
|
||||
session = httpx.Client()
|
||||
res = session.get("https://jerking.empornium.ph/?agree-consent", follow_redirects=True)
|
||||
try:
|
||||
auth_token = re.search(r'name="auth_token" value="([^"]+)"', res.text).group(1)
|
||||
except AttributeError:
|
||||
auth_token = re.search(r'auth_token = "([^"]+)"', res.text).group(1)
|
||||
|
||||
with image_path.open("rb") as f:
|
||||
res = session.post(
|
||||
"https://jerking.empornium.ph/json",
|
||||
headers={"accept": "application/json"},
|
||||
data={
|
||||
"thumb_width": "160",
|
||||
"thumb_height": "160",
|
||||
"thumb_crop": "false",
|
||||
"medium_width": "500",
|
||||
"medium_crop": "false",
|
||||
"type": "file",
|
||||
"action": "upload",
|
||||
"timestamp": str(round(time.time() * 1000)),
|
||||
"auth_token": auth_token,
|
||||
"nsfw": "0",
|
||||
},
|
||||
files={
|
||||
"source": ("thumbs.jpg", f),
|
||||
},
|
||||
)
|
||||
|
||||
data = res.json()
|
||||
image_url = data["image"]["url"]
|
||||
thumbnail_url = data["image"]["display_url"]
|
||||
|
||||
return image_url
|
||||
|
||||
|
||||
ParsedFilename = typing.TypedDict(
|
||||
'ParsedFilename', {'actors': list[str], 'studio': str, 'date': str, 'title': str, 'tags': list[str]}
|
||||
)
|
||||
|
||||
|
||||
def parse_filename(filename: str) -> ParsedFilename:
|
||||
parsed = {
|
||||
"tags": [],
|
||||
}
|
||||
filename = re.sub(r"\s*\[\d+[^]]+]", "", filename)
|
||||
if m := re.search(r"(?P<actors>.+)\s+-+\s+@(?P<studio>\S+)\s+-+\s+(?P<title>.+)\s+-+\s+(?P<date>[\d-]+)", filename):
|
||||
parsed.update(m.groupdict())
|
||||
elif m := re.search(r"(?P<actors>.+)\s+-+\s+@(?P<studio>\S+)\s+-+\s+(?P<date>[\d-]+)", filename):
|
||||
parsed.update(m.groupdict())
|
||||
elif m := re.search(r"(?P<actors>.+)\s+-+\s+(?P<title>.+)\s+-+\s+(?P<date>[\d-]+)", filename):
|
||||
parsed.update(m.groupdict())
|
||||
elif m := re.search(r"(?P<actors>.+)\s+-+\s+(?P<title>\D.+)", filename):
|
||||
parsed.update(m.groupdict())
|
||||
else:
|
||||
raise ValueError("Unknown filename format")
|
||||
|
||||
if "actors" in parsed:
|
||||
names = re.split(r"\s*,\s*", parsed["actors"])
|
||||
with_aliases = []
|
||||
for it in names:
|
||||
with_aliases.extend(re.split(r"\s+aka\s+", it))
|
||||
parsed["actors"] = names
|
||||
parsed["tags"].extend([it.lower().replace(" ", ".") for it in with_aliases])
|
||||
if "studio" in parsed:
|
||||
parsed["tags"].append(parsed["studio"].lower() + ".com")
|
||||
if "date" in parsed:
|
||||
year, ym = parsed["date"][:4], parsed["date"][:7].replace("-", ".")
|
||||
parsed["tags"].append(year)
|
||||
parsed["tags"].append(ym)
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
def generate_title(parsed: ParsedFilename) -> str:
|
||||
parts = []
|
||||
if actors := parsed.get('actors'):
|
||||
parts.append(', '.join(actors))
|
||||
if studio := parsed.get('studio'):
|
||||
parts.append(f'@{studio}')
|
||||
if title := parsed.get('title'):
|
||||
parts.append(title)
|
||||
if date := parsed.get('date'):
|
||||
parts.append(date)
|
||||
return ' -- '.join(parts)
|
||||
|
||||
|
||||
def generate_post_bbcode(video_path: Path, thumbnails_path: Path) -> str:
|
||||
try:
|
||||
image_url = upload_image(thumbnails_path)
|
||||
image_bbcode = f"[img]{image_url}[/img]"
|
||||
except:
|
||||
logging.exception("Failed to upload image")
|
||||
image_bbcode = ""
|
||||
|
||||
parsed = parse_filename(video_path.stem)
|
||||
probe = ffmpeg.ffprobe(video_path)
|
||||
|
||||
if 1900 <= probe.width <= 2200:
|
||||
hd = "1080p"
|
||||
elif 1200 <= probe.width <= 1400:
|
||||
hd = "720p"
|
||||
elif 3000 <= probe.width:
|
||||
hd = "4K"
|
||||
else:
|
||||
hd = None
|
||||
|
||||
if hd:
|
||||
parsed["tags"].append(hd.lower())
|
||||
if probe.codec == "hevc":
|
||||
parsed["tags"].extend(["x265", "x265.reencode", "hevc.x265"])
|
||||
|
||||
table = {
|
||||
"Duration": probe.duration_human,
|
||||
"Format": video_path.suffix.strip("."),
|
||||
"Filesize": human_size(video_path.stat().st_size),
|
||||
"Resolution": f"{probe.width}x{probe.height}",
|
||||
"Codec": probe.codec,
|
||||
"Bit rate": f"{probe.bitrate // 1000} kbit/s",
|
||||
"FPS": probe.fps,
|
||||
}
|
||||
release_date = parsed.get("date", "")
|
||||
|
||||
if release_date:
|
||||
table = {
|
||||
"Release Date": release_date,
|
||||
**table,
|
||||
}
|
||||
|
||||
lines = [
|
||||
"[table=nball]",
|
||||
*(f"[tr][th=20]{k}[/th][td]{v}[/td][/tr]" for k, v in table.items()),
|
||||
"[/table]",
|
||||
]
|
||||
table_bbcode = "\n".join(lines)
|
||||
|
||||
m_performers = re.search(r"(.+?) -+\s", video_path.stem)
|
||||
performers = m_performers.group(1) if m_performers else ""
|
||||
|
||||
title = generate_title(parsed)
|
||||
|
||||
metadata = {
|
||||
**parsed,
|
||||
"is_hevc": probe.codec == "hevc",
|
||||
"hd": hd,
|
||||
}
|
||||
metadata_json = json.dumps(metadata)
|
||||
|
||||
return f"""
|
||||
{metadata_json}
|
||||
---
|
||||
[b]{title}[/b]
|
||||
|
||||
[cast]
|
||||
{performers}
|
||||
|
||||
[details]
|
||||
|
||||
[info]
|
||||
{table_bbcode}
|
||||
|
||||
[screens]
|
||||
{image_bbcode}
|
||||
""".strip()
|
||||
|
||||
|
||||
def add_torrent(torrent_path: Path):
|
||||
import time
|
||||
from functools import partial
|
||||
|
||||
make_id = partial(time.time_ns)
|
||||
|
||||
# log in
|
||||
logging.debug("Connecting to Deluge")
|
||||
session = httpx.Client(base_url="https://t.zzzp.win/", timeout=30)
|
||||
res = session.post("/json", json={"method": "auth.login", "params": ["xAsametk50"], "id": make_id()})
|
||||
res.raise_for_status()
|
||||
|
||||
# find first available host
|
||||
res = session.post("/json", json={"method": "web.get_hosts", "params": [], "id": make_id()})
|
||||
res.raise_for_status()
|
||||
host_id = res.json()["result"][0][0]
|
||||
|
||||
# connect to a host
|
||||
res = session.post("/json", json={"method": "web.connect", "params": [host_id], "id": make_id()})
|
||||
res.raise_for_status()
|
||||
|
||||
# upload torrent
|
||||
logging.debug("Uploading torrent file")
|
||||
with torrent_path.open("rb") as f:
|
||||
res = session.post("/upload", files={"file": f})
|
||||
res.raise_for_status()
|
||||
remote_path: str = res.json()["files"][0]
|
||||
|
||||
# add torrent
|
||||
logging.debug("Adding torrent file")
|
||||
res = session.post(
|
||||
"/json",
|
||||
json={
|
||||
"method": "web.add_torrents",
|
||||
"params": [
|
||||
[
|
||||
{
|
||||
"path": remote_path,
|
||||
"options": {
|
||||
"file_priorities": [1],
|
||||
"add_paused": True,
|
||||
"sequential_download": False,
|
||||
"pre_allocate_storage": False,
|
||||
"download_location": "/dl/_new2",
|
||||
"move_completed": False,
|
||||
"move_completed_path": "/root/Downloads",
|
||||
"prioritize_first_last_pieces": True,
|
||||
"seed_mode": True,
|
||||
"super_seeding": False,
|
||||
},
|
||||
}
|
||||
]
|
||||
],
|
||||
"id": make_id(),
|
||||
def enqueue_torrent(
|
||||
transmission_url: str,
|
||||
credentials: str | tuple[str, str] | None,
|
||||
torrent_path: Path,
|
||||
download_path: str | Path,
|
||||
) -> str:
|
||||
if isinstance(credentials, str):
|
||||
username, separator, password = credentials.partition(":")
|
||||
if not separator:
|
||||
raise ValueError("Credentials must be username:password")
|
||||
credentials = (username, password)
|
||||
auth = httpx.BasicAuth(*credentials) if credentials is not None else None
|
||||
rpc_url = transmission_url.rstrip("/")
|
||||
if not httpx.URL(url=rpc_url).path.endswith("/rpc"):
|
||||
rpc_url = f"{rpc_url}/transmission/rpc"
|
||||
payload = {
|
||||
"method": "torrent-add",
|
||||
"arguments": {
|
||||
"download-dir": str(download_path),
|
||||
"metainfo": base64.b64encode(torrent_path.read_bytes()).decode("ascii"),
|
||||
"paused": False,
|
||||
},
|
||||
)
|
||||
|
||||
res.raise_for_status()
|
||||
torrent_id = res.json()["result"][0][1]
|
||||
|
||||
# force recheck
|
||||
# logging.info("Triggering a forced recheck")
|
||||
# time.sleep(5)
|
||||
# try:
|
||||
# _ = session.post(
|
||||
# "/json", json={"method": "core.force_recheck", "params": [[torrent_id]], "id": make_id()}, timeout=0.1
|
||||
# )
|
||||
# except httpx.HTTPError:
|
||||
# pass
|
||||
|
||||
logging.debug("Torrent file has been added successfully.")
|
||||
}
|
||||
with httpx.Client(auth=auth, timeout=20) as client:
|
||||
for _ in range(3):
|
||||
response = client.post(url=rpc_url, json=payload)
|
||||
if response.status_code == 409:
|
||||
client.headers["X-Transmission-Session-Id"] = response.headers["X-Transmission-Session-Id"]
|
||||
continue
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
if result["result"] not in ("success", "duplicate-torrent"):
|
||||
raise ValueError(f"Transmission rejected torrent: {result['result']}")
|
||||
added = result["arguments"].get("torrent-added") or result["arguments"].get("torrent-duplicate")
|
||||
if not added:
|
||||
raise ValueError("Transmission returned no torrent details")
|
||||
return added["hashString"]
|
||||
raise ValueError("Transmission session negotiation failed")
|
||||
|
||||
|
||||
def parse_args(argv: list[str]):
|
||||
arger = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
||||
arger.add_argument("dir_or_video", type=Path, help="Directory or video file to add")
|
||||
arger.add_argument("--name", "-n", help="Torrent name")
|
||||
arger.add_argument("--no-metadata", action='store_true', dest='remove_metadata', help="Remove all metadata")
|
||||
arger.add_argument("--post-only", action="store_true", help="Generate only the post bbcode")
|
||||
arger.add_argument("--link", action="store_true", help="Link video file instead of moving")
|
||||
arger.add_argument("--thumb-path", dest='thumbnail_path', type=Path, help="Path to thumbnail sheet")
|
||||
if not argv:
|
||||
arger.print_help()
|
||||
sys.exit(0)
|
||||
return arger.parse_args(argv)
|
||||
@click.group()
|
||||
def main() -> None:
|
||||
"""Create, modify, inspect, and enqueue torrent files."""
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
|
||||
|
||||
|
||||
def validate_name(base_name: str):
|
||||
if len(base_name) > (128 - len(".mp4")):
|
||||
raise ValueError(f"Filename is too long: {base_name} ({len(base_name)} chars)")
|
||||
if "?" in base_name:
|
||||
raise ValueError(f"Filename contains invalid characters: {base_name}")
|
||||
@main.command(name="create")
|
||||
@click.argument("paths", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path))
|
||||
@click.option("--output", type=click.Path(dir_okay=False, path_type=Path), help="Destination; defaults to NAME.torrent in the current directory.")
|
||||
@click.option("--announce-url", "announce_urls", multiple=True, help="Tracker URL; repeat for multiple trackers. Defaults to the hardcoded URLs.")
|
||||
@click.option("--author", default=AUTHOR, show_default=True)
|
||||
@click.option("--private/--no-private", default=True, show_default=True)
|
||||
@click.option("--name", help="Torrent name; defaults to the file or common directory name.")
|
||||
@click.option("--overwrite", is_flag=True, help="Replace an existing output torrent.")
|
||||
def create(
|
||||
paths: tuple[Path, ...],
|
||||
output: Path | None,
|
||||
announce_urls: tuple[str, ...],
|
||||
author: str,
|
||||
private: bool,
|
||||
name: str | None,
|
||||
overwrite: bool,
|
||||
) -> None:
|
||||
"""Create one torrent from FILES or DIRECTORIES, retaining their relative paths."""
|
||||
try:
|
||||
destination = create_torrent(
|
||||
paths=paths,
|
||||
output=output,
|
||||
announce_urls=announce_urls or ANNOUNCE_URLS,
|
||||
author=author,
|
||||
private=private,
|
||||
name=name,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
except (OSError, ValueError, torf.TorfError) as error:
|
||||
raise click.ClickException(message=str(error)) from error
|
||||
logging.info(f"Saved torrent at {destination}")
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args(sys.argv[1:])
|
||||
dir_or_video: Path = args.dir_or_video.expanduser().resolve()
|
||||
if dir_or_video.is_dir():
|
||||
dir_path = dir_or_video
|
||||
if not dir_path.is_relative_to(TORRENT_DIR):
|
||||
target_path = TORRENT_DIR / dir_path.name
|
||||
logging.info(f"Linking to {target_path}")
|
||||
shutil.copytree(src=dir_path, dst=target_path, copy_function=os.link, symlinks=False)
|
||||
dir_path = target_path
|
||||
torrent_path = make_torrent(dir_path)
|
||||
logging.info(f"Saved torrent at {torrent_path}")
|
||||
@main.command(name="modify")
|
||||
@click.argument("torrent_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.option("--output", type=click.Path(dir_okay=False, path_type=Path), help="Destination; defaults to editing the input torrent in place.")
|
||||
@click.option("--announce-url", "announce_urls", multiple=True, help="Replace trackers; repeat for multiple URLs.")
|
||||
@click.option("--author", help="Replace the creator.")
|
||||
@click.option("--private/--no-private", default=None, help="Set or clear the private flag.")
|
||||
@click.option("--name", help="Replace the torrent name.")
|
||||
def modify(
|
||||
torrent_path: Path,
|
||||
output: Path | None,
|
||||
announce_urls: tuple[str, ...],
|
||||
author: str | None,
|
||||
private: bool | None,
|
||||
name: str | None,
|
||||
) -> None:
|
||||
"""Change metadata without rehashing the content; omitted fields stay unchanged."""
|
||||
try:
|
||||
destination = modify_torrent(
|
||||
torrent_path=torrent_path,
|
||||
output=output,
|
||||
announce_urls=announce_urls,
|
||||
author=author,
|
||||
private=private,
|
||||
name=name,
|
||||
)
|
||||
except (OSError, ValueError, torf.TorfError) as error:
|
||||
raise click.ClickException(message=str(error)) from error
|
||||
logging.info(f"Saved torrent at {destination}")
|
||||
|
||||
logging.info("Adding torrent file to Deluge")
|
||||
add_torrent(torrent_path)
|
||||
|
||||
@main.command(name="enqueue")
|
||||
@click.argument("torrent_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.option("--transmission-url", "--host", required=True, envvar="TRANSMISSION_HOST", help="Transmission host or RPC URL; also reads TRANSMISSION_HOST.")
|
||||
@click.option("--credentials", "--auth", envvar="TRANSMISSION_AUTH", help="Username:password; also reads TRANSMISSION_AUTH.")
|
||||
@click.option("--download-path", required=True, type=click.Path(path_type=Path), help="Download directory on the remote Transmission host.")
|
||||
def enqueue(
|
||||
torrent_path: Path,
|
||||
transmission_url: str,
|
||||
credentials: str | None,
|
||||
download_path: Path,
|
||||
) -> None:
|
||||
"""Add a torrent to Transmission at the given remote download directory."""
|
||||
try:
|
||||
torrent_hash = enqueue_torrent(
|
||||
transmission_url=transmission_url,
|
||||
credentials=credentials,
|
||||
torrent_path=torrent_path,
|
||||
download_path=download_path,
|
||||
)
|
||||
except (OSError, ValueError, httpx.HTTPError) as error:
|
||||
raise click.ClickException(message=str(error)) from error
|
||||
logging.info(f"Enqueued torrent {torrent_hash} at {download_path}")
|
||||
|
||||
|
||||
|
||||
|
||||
@main.command(name="inspect")
|
||||
@click.argument("torrent_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.option("--json", "json_output", is_flag=True, help="Output JSON instead of plain text.")
|
||||
def inspect(torrent_path: Path, json_output: bool) -> None:
|
||||
"""Show torrent properties and content paths with readable sizes."""
|
||||
try:
|
||||
torrent = torf.Torrent.read(filepath=torrent_path)
|
||||
except (OSError, ValueError, torf.TorfError) as error:
|
||||
raise click.ClickException(message=str(error)) from error
|
||||
properties = {
|
||||
"name": torrent.name,
|
||||
"author": torrent.created_by,
|
||||
"private": torrent.private,
|
||||
"infohash": torrent.infohash,
|
||||
"size": torrent.size,
|
||||
"piece_size": torrent.piece_size,
|
||||
"pieces": torrent.pieces,
|
||||
"creation_date": torrent.creation_date.isoformat() if torrent.creation_date else None,
|
||||
"comment": torrent.comment,
|
||||
"source": torrent.source,
|
||||
"announce_urls": [list(tier) for tier in torrent.trackers],
|
||||
"webseeds": list(torrent.webseeds),
|
||||
"httpseeds": list(torrent.httpseeds),
|
||||
}
|
||||
files = [{"path": str(file), "size": file.size} for file in torrent.files]
|
||||
if json_output:
|
||||
click.echo(message=json.dumps({"properties": properties, "files": files}, indent=2, ensure_ascii=False))
|
||||
return
|
||||
|
||||
if not dir_or_video.is_file():
|
||||
logging.error("Invalid file or directory")
|
||||
return
|
||||
|
||||
video_path = dir_or_video
|
||||
if video_path.suffix not in VIDEO_EXTENSIONS:
|
||||
logging.error('not a video')
|
||||
return
|
||||
|
||||
if args.remove_metadata:
|
||||
logging.info('Removing video metadata')
|
||||
save_path = video_path.with_stem(f'{video_path.stem}.nometadata')
|
||||
video_path = clean_video(video_path=video_path, save_path=save_path)
|
||||
|
||||
video_path = move_video(video_path, new_name=args.name, link=args.link)
|
||||
dir_path = video_path.parent
|
||||
|
||||
if p := args.thumbnail_path:
|
||||
thumbnail_path = video_path.with_suffix('.jpg')
|
||||
p.link_to(thumbnail_path)
|
||||
else:
|
||||
logging.info("Creating thumbnail tile")
|
||||
old_thumb_path = video_path.with_suffix(".thumbnail.jpg")
|
||||
thumbnail_path = video_path.with_suffix(".jpg")
|
||||
if old_thumb_path.is_file():
|
||||
old_thumb_path.rename(thumbnail_path)
|
||||
image_jpeg = ffmpeg.make_thumbnail_tile(video_path)
|
||||
thumbnail_path.write_bytes(image_jpeg)
|
||||
logging.info(f"Saved thumbnails at {thumbnail_path}")
|
||||
|
||||
post_bbcode = generate_post_bbcode(video_path, thumbnail_path)
|
||||
video_path.with_name("post.txt").write_text(post_bbcode)
|
||||
print(post_bbcode)
|
||||
|
||||
if args.post_only:
|
||||
return
|
||||
|
||||
total_files = len(list(dir_path.rglob("*")))
|
||||
logging.info(f"Creating torrent file from {dir_path}. Total files: {total_files}")
|
||||
torrent_path = make_torrent(dir_path)
|
||||
logging.info(f"Saved torrent at {torrent_path}")
|
||||
|
||||
logging.info("Adding torrent file to Deluge")
|
||||
add_torrent(torrent_path)
|
||||
for key, value in properties.items():
|
||||
label = key.replace("_", " ").capitalize()
|
||||
if isinstance(value, list):
|
||||
click.echo(message=f"{label}:" if value else f"{label}: (none)")
|
||||
for index, item in enumerate(value, start=1):
|
||||
if isinstance(item, list):
|
||||
click.echo(message=f" Tier {index}: {', '.join(item)}")
|
||||
else:
|
||||
click.echo(message=f" {item}")
|
||||
elif value is None:
|
||||
click.echo(message=f"{label}: (unset)")
|
||||
elif isinstance(value, bool):
|
||||
click.echo(message=f"{label}: {'yes' if value else 'no'}")
|
||||
elif key in ("size", "piece_size"):
|
||||
click.echo(message=f"{label}: {format_size(size=value)}")
|
||||
else:
|
||||
click.echo(message=f"{label}: {value}")
|
||||
click.echo(message=f"\nFiles ({len(files)}):")
|
||||
for file in files:
|
||||
click.echo(message=f" {file['path']}: {format_size(size=file['size'])}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in new issue
Block a user