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
|
#!/usr/bin/env -S uv run --script
|
||||||
import argparse
|
# /// script
|
||||||
|
# dependencies = ["click", "httpx", "torf"]
|
||||||
|
# ///
|
||||||
|
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import select
|
|
||||||
import shutil
|
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from turtle import back
|
|
||||||
import typing
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
import ffmpeg
|
import click
|
||||||
from ffmpeg_strip import clean_video
|
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:
|
def format_size(size: int) -> str:
|
||||||
import torf
|
if size < 1000:
|
||||||
except ImportError:
|
return f"{size:,} bytes"
|
||||||
torf = force_import("torf")
|
scaled_size = float(size)
|
||||||
|
for unit in ("KB", "MB", "GB", "TB", "PB", "EB"):
|
||||||
try:
|
scaled_size /= 1000
|
||||||
import httpx
|
if scaled_size < 1000 or unit == "EB":
|
||||||
except ImportError:
|
return f"{scaled_size:.2f} {unit} ({size:,} bytes)"
|
||||||
httpx = force_import("httpx")
|
raise ValueError(f"Cannot format size: {size}")
|
||||||
|
|
||||||
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 make_torrent(source_dir: Path, tracker_url: str = TORRENT_TRACKER_URL) -> Path:
|
def create_torrent(
|
||||||
torrent_path = source_dir / f"{source_dir.name}.torrent"
|
paths: tuple[Path, ...],
|
||||||
if torrent_path.is_file():
|
output: Path | None = None,
|
||||||
logging.info("Torrent file is already created")
|
announce_urls: tuple[str, ...] = ANNOUNCE_URLS,
|
||||||
return torrent_path
|
author: str = AUTHOR,
|
||||||
|
private: bool = True,
|
||||||
t = torf.Torrent(
|
name: str | None = None,
|
||||||
path=str(source_dir.resolve()),
|
overwrite: bool = False,
|
||||||
name=source_dir.resolve().name,
|
) -> Path:
|
||||||
trackers=[tracker_url],
|
sources = list(dict.fromkeys(path.expanduser().absolute() for path in paths))
|
||||||
private=True,
|
if not sources:
|
||||||
created_by=TORRENT_CREATED_BY,
|
raise ValueError("Provide at least one file or directory")
|
||||||
exclude_globs=["*.txt", "post.txt", "*.torrent", "*.gif"],
|
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()
|
if len(sources) == 1:
|
||||||
t.write(torrent_path, overwrite=True)
|
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
|
return torrent_path
|
||||||
|
|
||||||
|
|
||||||
def human_size(size: int) -> str:
|
def modify_torrent(
|
||||||
suffix = "B"
|
torrent_path: Path,
|
||||||
for unit in ["", "K", "M", "G", "T", "P", "E", "Z"]:
|
output: Path | None = None,
|
||||||
if abs(size) < 1024.0:
|
announce_urls: tuple[str, ...] = (),
|
||||||
return "%3.2f%s%s" % (size, unit, suffix)
|
author: str | None = None,
|
||||||
size /= 1024.0
|
private: bool | None = None,
|
||||||
return "%.2f%s%s" % (size, "Yi", suffix)
|
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]:
|
def enqueue_torrent(
|
||||||
for ext in VIDEO_EXTENSIONS:
|
transmission_url: str,
|
||||||
for f in root_dir.rglob(f"*{ext}"):
|
credentials: str | tuple[str, str] | None,
|
||||||
return f
|
torrent_path: Path,
|
||||||
return None
|
download_path: str | Path,
|
||||||
|
) -> str:
|
||||||
|
if isinstance(credentials, str):
|
||||||
def pick_name(video_path: Path) -> str:
|
username, separator, password = credentials.partition(":")
|
||||||
try:
|
if not separator:
|
||||||
if parse_filename(video_path.stem):
|
raise ValueError("Credentials must be username:password")
|
||||||
return video_path.stem
|
credentials = (username, password)
|
||||||
except ValueError:
|
auth = httpx.BasicAuth(*credentials) if credentials is not None else None
|
||||||
pass
|
rpc_url = transmission_url.rstrip("/")
|
||||||
|
if not httpx.URL(url=rpc_url).path.endswith("/rpc"):
|
||||||
candidates = set()
|
rpc_url = f"{rpc_url}/transmission/rpc"
|
||||||
|
payload = {
|
||||||
parent = video_path.parent
|
"method": "torrent-add",
|
||||||
while parent.exists():
|
"arguments": {
|
||||||
try:
|
"download-dir": str(download_path),
|
||||||
if parse_filename(parent.name):
|
"metainfo": base64.b64encode(torrent_path.read_bytes()).decode("ascii"),
|
||||||
candidates.add(parent.name)
|
"paused": False,
|
||||||
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(),
|
|
||||||
},
|
},
|
||||||
)
|
}
|
||||||
|
with httpx.Client(auth=auth, timeout=20) as client:
|
||||||
res.raise_for_status()
|
for _ in range(3):
|
||||||
torrent_id = res.json()["result"][0][1]
|
response = client.post(url=rpc_url, json=payload)
|
||||||
|
if response.status_code == 409:
|
||||||
# force recheck
|
client.headers["X-Transmission-Session-Id"] = response.headers["X-Transmission-Session-Id"]
|
||||||
# logging.info("Triggering a forced recheck")
|
continue
|
||||||
# time.sleep(5)
|
response.raise_for_status()
|
||||||
# try:
|
result = response.json()
|
||||||
# _ = session.post(
|
if result["result"] not in ("success", "duplicate-torrent"):
|
||||||
# "/json", json={"method": "core.force_recheck", "params": [[torrent_id]], "id": make_id()}, timeout=0.1
|
raise ValueError(f"Transmission rejected torrent: {result['result']}")
|
||||||
# )
|
added = result["arguments"].get("torrent-added") or result["arguments"].get("torrent-duplicate")
|
||||||
# except httpx.HTTPError:
|
if not added:
|
||||||
# pass
|
raise ValueError("Transmission returned no torrent details")
|
||||||
|
return added["hashString"]
|
||||||
logging.debug("Torrent file has been added successfully.")
|
raise ValueError("Transmission session negotiation failed")
|
||||||
|
|
||||||
|
|
||||||
def parse_args(argv: list[str]):
|
@click.group()
|
||||||
arger = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
|
def main() -> None:
|
||||||
arger.add_argument("dir_or_video", type=Path, help="Directory or video file to add")
|
"""Create, modify, inspect, and enqueue torrent files."""
|
||||||
arger.add_argument("--name", "-n", help="Torrent name")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_name(base_name: str):
|
@main.command(name="create")
|
||||||
if len(base_name) > (128 - len(".mp4")):
|
@click.argument("paths", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path))
|
||||||
raise ValueError(f"Filename is too long: {base_name} ({len(base_name)} chars)")
|
@click.option("--output", type=click.Path(dir_okay=False, path_type=Path), help="Destination; defaults to NAME.torrent in the current directory.")
|
||||||
if "?" in base_name:
|
@click.option("--announce-url", "announce_urls", multiple=True, help="Tracker URL; repeat for multiple trackers. Defaults to the hardcoded URLs.")
|
||||||
raise ValueError(f"Filename contains invalid characters: {base_name}")
|
@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():
|
@main.command(name="modify")
|
||||||
args = parse_args(sys.argv[1:])
|
@click.argument("torrent_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||||
dir_or_video: Path = args.dir_or_video.expanduser().resolve()
|
@click.option("--output", type=click.Path(dir_okay=False, path_type=Path), help="Destination; defaults to editing the input torrent in place.")
|
||||||
if dir_or_video.is_dir():
|
@click.option("--announce-url", "announce_urls", multiple=True, help="Replace trackers; repeat for multiple URLs.")
|
||||||
dir_path = dir_or_video
|
@click.option("--author", help="Replace the creator.")
|
||||||
if not dir_path.is_relative_to(TORRENT_DIR):
|
@click.option("--private/--no-private", default=None, help="Set or clear the private flag.")
|
||||||
target_path = TORRENT_DIR / dir_path.name
|
@click.option("--name", help="Replace the torrent name.")
|
||||||
logging.info(f"Linking to {target_path}")
|
def modify(
|
||||||
shutil.copytree(src=dir_path, dst=target_path, copy_function=os.link, symlinks=False)
|
torrent_path: Path,
|
||||||
dir_path = target_path
|
output: Path | None,
|
||||||
torrent_path = make_torrent(dir_path)
|
announce_urls: tuple[str, ...],
|
||||||
logging.info(f"Saved torrent at {torrent_path}")
|
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
|
return
|
||||||
|
for key, value in properties.items():
|
||||||
if not dir_or_video.is_file():
|
label = key.replace("_", " ").capitalize()
|
||||||
logging.error("Invalid file or directory")
|
if isinstance(value, list):
|
||||||
return
|
click.echo(message=f"{label}:" if value else f"{label}: (none)")
|
||||||
|
for index, item in enumerate(value, start=1):
|
||||||
video_path = dir_or_video
|
if isinstance(item, list):
|
||||||
if video_path.suffix not in VIDEO_EXTENSIONS:
|
click.echo(message=f" Tier {index}: {', '.join(item)}")
|
||||||
logging.error('not a video')
|
else:
|
||||||
return
|
click.echo(message=f" {item}")
|
||||||
|
elif value is None:
|
||||||
if args.remove_metadata:
|
click.echo(message=f"{label}: (unset)")
|
||||||
logging.info('Removing video metadata')
|
elif isinstance(value, bool):
|
||||||
save_path = video_path.with_stem(f'{video_path.stem}.nometadata')
|
click.echo(message=f"{label}: {'yes' if value else 'no'}")
|
||||||
video_path = clean_video(video_path=video_path, save_path=save_path)
|
elif key in ("size", "piece_size"):
|
||||||
|
click.echo(message=f"{label}: {format_size(size=value)}")
|
||||||
video_path = move_video(video_path, new_name=args.name, link=args.link)
|
else:
|
||||||
dir_path = video_path.parent
|
click.echo(message=f"{label}: {value}")
|
||||||
|
click.echo(message=f"\nFiles ({len(files)}):")
|
||||||
if p := args.thumbnail_path:
|
for file in files:
|
||||||
thumbnail_path = video_path.with_suffix('.jpg')
|
click.echo(message=f" {file['path']}: {format_size(size=file['size'])}")
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
#!/usr/bin/env -S uv run --script
|
||||||
|
# /// script
|
||||||
|
# dependencies = ["click", "httpx", "torf", "pytest"]
|
||||||
|
# ///
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
import torf
|
||||||
|
|
||||||
|
import torrentify as script
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("kind", ["file", "directory", "mixed"])
|
||||||
|
def test_create_selected_content(tmp_path: Path, kind: str) -> None:
|
||||||
|
source = tmp_path / "first.txt"
|
||||||
|
source.write_bytes(b"first")
|
||||||
|
directory = tmp_path / "folder"
|
||||||
|
directory.mkdir()
|
||||||
|
(directory / "nested.txt").write_bytes(b"nested")
|
||||||
|
(tmp_path / "unselected.txt").write_bytes(b"unselected")
|
||||||
|
paths = {"file": (source,), "directory": (directory,), "mixed": (source, directory)}[kind]
|
||||||
|
output = tmp_path / "result.torrent"
|
||||||
|
result = CliRunner().invoke(cli=script.main, args=["create", *map(str, paths), "--output", str(output)])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
torrent = torf.Torrent.read(filepath=output)
|
||||||
|
assert torrent.private is True
|
||||||
|
assert torrent.created_by == script.AUTHOR
|
||||||
|
assert list(torrent.trackers.flat) == list(script.ANNOUNCE_URLS)
|
||||||
|
expected = {"file": b"first", "directory": b"nested", "mixed": b"firstnested"}[kind]
|
||||||
|
assert torrent.metainfo["info"]["pieces"] == hashlib.sha1(expected).digest()
|
||||||
|
if kind == "mixed":
|
||||||
|
assert [item["path"] for item in torrent.metainfo["info"]["files"]] == [
|
||||||
|
["first.txt"], ["folder", "nested.txt"],
|
||||||
|
]
|
||||||
|
if kind == "directory":
|
||||||
|
assert torrent.name == "folder"
|
||||||
|
assert torrent.metainfo["info"]["files"][0]["path"] == ["nested.txt"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reject_inputs_in_different_directories(tmp_path: Path) -> None:
|
||||||
|
source = tmp_path / "first.txt"
|
||||||
|
source.write_bytes(b"first")
|
||||||
|
directory = tmp_path / "folder"
|
||||||
|
directory.mkdir()
|
||||||
|
nested = directory / "nested.txt"
|
||||||
|
nested.write_bytes(b"nested")
|
||||||
|
result = CliRunner().invoke(cli=script.main, args=["create", str(source), str(nested)])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "All inputs must be in the same directory" in result.output
|
||||||
|
assert not list(tmp_path.glob("*.torrent"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_rejects_empty_and_existing_output(tmp_path: Path) -> None:
|
||||||
|
source = tmp_path / "file.txt"
|
||||||
|
source.touch()
|
||||||
|
with pytest.raises(ValueError, match="no nonempty files"):
|
||||||
|
script.create_torrent(paths=(source,), output=tmp_path / "result.torrent")
|
||||||
|
source.write_bytes(b"content")
|
||||||
|
output = script.create_torrent(paths=(source,), output=tmp_path / "result.torrent")
|
||||||
|
original = output.read_bytes()
|
||||||
|
with pytest.raises(FileExistsError):
|
||||||
|
script.create_torrent(paths=(source,), output=output)
|
||||||
|
assert output.read_bytes() == original
|
||||||
|
with pytest.raises(ValueError, match="overwrite a source"):
|
||||||
|
script.create_torrent(paths=(source,), output=source, overwrite=True)
|
||||||
|
assert source.read_bytes() == b"content"
|
||||||
|
|
||||||
|
|
||||||
|
def test_modify_metadata_without_content(tmp_path: Path) -> None:
|
||||||
|
source = tmp_path / "content.txt"
|
||||||
|
source.write_bytes(b"content")
|
||||||
|
output = script.create_torrent(paths=(source,), output=tmp_path / "original.torrent")
|
||||||
|
before = torf.Torrent.read(filepath=output)
|
||||||
|
source.unlink()
|
||||||
|
result = CliRunner().invoke(cli=script.main, args=[
|
||||||
|
"modify", str(output), "--announce-url", "https://tracker.example/announce",
|
||||||
|
"--announce-url", "udp://tracker.example:80/announce", "--author", "new author",
|
||||||
|
"--name", "new name", "--no-private",
|
||||||
|
])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
after = torf.Torrent.read(filepath=output)
|
||||||
|
assert after.name == "new name"
|
||||||
|
assert after.created_by == "new author"
|
||||||
|
assert after.private is False
|
||||||
|
assert list(after.trackers.flat) == ["https://tracker.example/announce", "udp://tracker.example:80/announce"]
|
||||||
|
assert after.metainfo["info"]["pieces"] == before.metainfo["info"]["pieces"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_modify_preserves_omitted_fields_and_original(tmp_path: Path) -> None:
|
||||||
|
source = tmp_path / "content.txt"
|
||||||
|
source.write_bytes(b"content")
|
||||||
|
original = script.create_torrent(paths=(source,), output=tmp_path / "original.torrent")
|
||||||
|
original_bytes = original.read_bytes()
|
||||||
|
output = tmp_path / "modified.torrent"
|
||||||
|
script.modify_torrent(torrent_path=original, output=output, author="replacement")
|
||||||
|
before = torf.Torrent.read(filepath=original)
|
||||||
|
after = torf.Torrent.read(filepath=output)
|
||||||
|
assert original.read_bytes() == original_bytes
|
||||||
|
assert after.infohash == before.infohash
|
||||||
|
assert after.private == before.private
|
||||||
|
assert after.trackers == before.trackers
|
||||||
|
assert after.name == before.name
|
||||||
|
with pytest.raises(torf.WriteError):
|
||||||
|
script.modify_torrent(torrent_path=original, output=output)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("added_key", ["torrent-added", "torrent-duplicate"])
|
||||||
|
@pytest.mark.parametrize("url", ["http://transmission:9091", "http://transmission:9091/transmission/rpc"])
|
||||||
|
def test_enqueue_negotiates_session_and_sets_remote_directory(tmp_path: Path, added_key: str, url: str) -> None:
|
||||||
|
torrent_path = tmp_path / "content.torrent"
|
||||||
|
torrent_path.write_bytes(b"torrent data")
|
||||||
|
requests = []
|
||||||
|
|
||||||
|
def respond(request: httpx.Request) -> httpx.Response:
|
||||||
|
requests.append(request)
|
||||||
|
if len(requests) == 1:
|
||||||
|
return httpx.Response(status_code=409, headers={"X-Transmission-Session-Id": "session-token"})
|
||||||
|
return httpx.Response(status_code=200, json={
|
||||||
|
"result": "success", "arguments": {added_key: {"hashString": "torrent-hash"}},
|
||||||
|
})
|
||||||
|
|
||||||
|
client = httpx.Client(transport=httpx.MockTransport(handler=respond), auth=httpx.BasicAuth("user", "pass:word"))
|
||||||
|
with patch.object(script.httpx, "Client", return_value=client) as factory:
|
||||||
|
result = script.enqueue_torrent(
|
||||||
|
transmission_url=url, credentials="user:pass:word",
|
||||||
|
torrent_path=torrent_path, download_path="/remote/downloads",
|
||||||
|
)
|
||||||
|
assert result == "torrent-hash"
|
||||||
|
assert factory.call_args.kwargs["auth"]._auth_header == client.auth._auth_header
|
||||||
|
assert len(requests) == 2
|
||||||
|
assert requests[1].headers["X-Transmission-Session-Id"] == "session-token"
|
||||||
|
assert str(requests[1].url) == "http://transmission:9091/transmission/rpc"
|
||||||
|
payload = json.loads(requests[1].content)
|
||||||
|
assert payload["method"] == "torrent-add"
|
||||||
|
assert payload["arguments"]["download-dir"] == "/remote/downloads"
|
||||||
|
assert payload["arguments"]["paused"] is False
|
||||||
|
assert base64.b64decode(payload["arguments"]["metainfo"]) == torrent_path.read_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status, result", [(401, "success"), (200, "invalid torrent"), (409, "success")])
|
||||||
|
def test_enqueue_reports_failures(tmp_path: Path, status: int, result: str) -> None:
|
||||||
|
torrent_path = tmp_path / "content.torrent"
|
||||||
|
torrent_path.write_bytes(b"torrent data")
|
||||||
|
|
||||||
|
def respond(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(status_code=status, headers={"X-Transmission-Session-Id": "token"}, json={"result": result})
|
||||||
|
|
||||||
|
client = httpx.Client(transport=httpx.MockTransport(handler=respond))
|
||||||
|
with patch.object(script.httpx, "Client", return_value=client):
|
||||||
|
with pytest.raises((httpx.HTTPStatusError, ValueError)):
|
||||||
|
script.enqueue_torrent(
|
||||||
|
transmission_url="http://transmission:9091", credentials=None,
|
||||||
|
torrent_path=torrent_path, download_path="/remote/downloads",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("use_environment", [False, True])
|
||||||
|
def test_enqueue_command(tmp_path: Path, use_environment: bool) -> None:
|
||||||
|
torrent_path = tmp_path / "content.torrent"
|
||||||
|
torrent_path.write_bytes(b"torrent data")
|
||||||
|
arguments = ["enqueue", str(torrent_path), "--download-path", "/remote/downloads"]
|
||||||
|
environment = {"TRANSMISSION_HOST": "http://transmission:9091", "TRANSMISSION_AUTH": "user:password"}
|
||||||
|
if not use_environment:
|
||||||
|
arguments.extend(["--transmission-url", environment["TRANSMISSION_HOST"], "--credentials", environment["TRANSMISSION_AUTH"]])
|
||||||
|
with patch.object(script, "enqueue_torrent", return_value="torrent-hash") as enqueue:
|
||||||
|
result = CliRunner().invoke(cli=script.main, args=arguments, env=environment if use_environment else {})
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
enqueue.assert_called_once_with(
|
||||||
|
transmission_url="http://transmission:9091", credentials="user:password",
|
||||||
|
torrent_path=torrent_path, download_path=Path("/remote/downloads"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_enqueue_command_reports_error(tmp_path: Path) -> None:
|
||||||
|
torrent_path = tmp_path / "content.torrent"
|
||||||
|
torrent_path.write_bytes(b"torrent data")
|
||||||
|
with patch.object(script, "enqueue_torrent", side_effect=ValueError("Transmission rejected torrent")):
|
||||||
|
result = CliRunner().invoke(cli=script.main, args=[
|
||||||
|
"enqueue", str(torrent_path), "--host", "http://transmission:9091",
|
||||||
|
"--download-path", "/remote/downloads",
|
||||||
|
])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "Error: Transmission rejected torrent" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("directory", [False, True])
|
||||||
|
@pytest.mark.parametrize("json_output", [False, True])
|
||||||
|
def test_inspect_reports_properties_and_files_without_content(
|
||||||
|
tmp_path: Path, directory: bool, json_output: bool,
|
||||||
|
) -> None:
|
||||||
|
folder = tmp_path / "content"
|
||||||
|
folder.mkdir()
|
||||||
|
source = folder / "first.txt"
|
||||||
|
source.write_bytes(b"first")
|
||||||
|
if directory:
|
||||||
|
(folder / "second.txt").write_bytes(b"second")
|
||||||
|
output = script.create_torrent(paths=(folder if directory else source,), output=tmp_path / "content.torrent")
|
||||||
|
original = output.read_bytes()
|
||||||
|
source.unlink()
|
||||||
|
arguments = ["inspect", str(output)]
|
||||||
|
if json_output:
|
||||||
|
arguments.append("--json")
|
||||||
|
result = CliRunner().invoke(cli=script.main, args=arguments)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert result.stderr == ""
|
||||||
|
if json_output:
|
||||||
|
report = json.loads(result.stdout)
|
||||||
|
assert report["properties"]["author"] == script.AUTHOR
|
||||||
|
assert report["properties"]["private"] is True
|
||||||
|
assert report["properties"]["announce_urls"] == [list(script.ANNOUNCE_URLS)]
|
||||||
|
assert report["properties"]["size"] == (11 if directory else 5)
|
||||||
|
assert report["files"] == (
|
||||||
|
[{"path": "content/first.txt", "size": 5}, {"path": "content/second.txt", "size": 6}]
|
||||||
|
if directory else [{"path": "first.txt", "size": 5}]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
assert f"Author: {script.AUTHOR}\n" in result.stdout
|
||||||
|
assert "Private: yes\n" in result.stdout
|
||||||
|
assert f"Size: {11 if directory else 5} bytes\n" in result.stdout
|
||||||
|
assert f"Tier 1: {script.ANNOUNCE_URLS[0]}\n" in result.stdout
|
||||||
|
assert f"Files ({2 if directory else 1}):\n" in result.stdout
|
||||||
|
assert f" {'content/' if directory else ''}first.txt: 5 bytes\n" in result.stdout
|
||||||
|
if directory:
|
||||||
|
assert " content/second.txt: 6 bytes\n" in result.stdout
|
||||||
|
assert output.read_bytes() == original
|
||||||
|
|
||||||
|
|
||||||
|
def test_inspect_rejects_invalid_torrent(tmp_path: Path) -> None:
|
||||||
|
torrent_path = tmp_path / "invalid.torrent"
|
||||||
|
torrent_path.write_bytes(b"invalid")
|
||||||
|
result = CliRunner().invoke(cli=script.main, args=["inspect", str(torrent_path)])
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "Error:" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(pytest.main(args=[str(Path(__file__).resolve()), *sys.argv[1:]]))
|
||||||
Reference in new issue
Block a user