first commit
This commit is contained in:
585
pytorrent/routes/torrents.py
Normal file
585
pytorrent/routes/torrents.py
Normal file
@@ -0,0 +1,585 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
from ..services import torrent_creator
|
||||
|
||||
@bp.get("/torrents")
|
||||
def torrents():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return ok({"torrents": [], "summary": cached_summary(0, []), "error": "No rTorrent profile"})
|
||||
rows = torrent_cache.snapshot(profile["id"])
|
||||
return ok({
|
||||
"profile_id": profile["id"],
|
||||
"torrents": rows,
|
||||
"summary": cached_summary(profile["id"], rows),
|
||||
"error": torrent_cache.error(profile["id"]),
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
@bp.get("/trackers/summary")
|
||||
def trackers_summary():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return ok({"summary": {"hashes": {}, "trackers": [], "errors": [], "scanned": 0, "pending": 0}, "error": "No profile"})
|
||||
try:
|
||||
# Note: Tracker summary returns cached data immediately; optional warmup scans rTorrent in the background for very large libraries.
|
||||
scan_limit = min(250, max(0, int(request.args.get("scan_limit") or 0)))
|
||||
bg_limit = min(250, max(1, int(request.args.get("bg_limit") or 80)))
|
||||
warm = str(request.args.get("warm") or "").lower() in {"1", "true", "yes"}
|
||||
hashes = [t.get("hash") for t in torrent_cache.snapshot(profile["id"]) if t.get("hash")]
|
||||
prefs = preferences.get_preferences()
|
||||
include_favicons = bool(prefs and prefs.get("tracker_favicons_enabled"))
|
||||
loader = lambda h: rtorrent.torrent_trackers(profile, h)
|
||||
summary = tracker_cache.summary(profile, hashes, loader, scan_limit=scan_limit, include_favicons=include_favicons)
|
||||
if warm and int(summary.get("pending") or 0) > 0:
|
||||
summary["warming"] = tracker_cache.warm_summary_cache(profile, hashes, loader, batch_size=bg_limit)
|
||||
return ok({"summary": summary})
|
||||
except Exception as exc:
|
||||
return ok({"summary": {"hashes": {}, "trackers": [], "errors": [{"error": str(exc)}], "scanned": 0, "pending": 0}, "error": str(exc)})
|
||||
|
||||
|
||||
|
||||
@bp.get("/trackers/favicon/<path:domain>")
|
||||
|
||||
@bp.get("/tracker-favicon/<path:domain>")
|
||||
def tracker_favicon(domain: str):
|
||||
prefs = preferences.get_preferences()
|
||||
force = str(request.args.get("refresh") or "").lower() in {"1", "true", "yes", "force"}
|
||||
# Note: Manual refresh must work from CLI even when tracker favicons are disabled in Preferences.
|
||||
enabled = force or bool(prefs and prefs.get("tracker_favicons_enabled"))
|
||||
static_url = tracker_cache.favicon_public_url(domain, enabled=enabled, create=True, force=force)
|
||||
if static_url:
|
||||
# Note: The API only discovers/cache-warms the icon; the browser receives the file from /static/tracker_favicons/.
|
||||
return redirect(static_url, code=302)
|
||||
cached = tracker_cache.favicon_cache_row(domain)
|
||||
return jsonify({
|
||||
"ok": False,
|
||||
"error": "favicon not found",
|
||||
"domain": tracker_cache.tracker_domain(domain),
|
||||
"enabled": bool(enabled),
|
||||
"cached_error": (cached or {}).get("error") if cached else None,
|
||||
}), 404
|
||||
|
||||
|
||||
|
||||
@bp.get("/trackers/favicon")
|
||||
def tracker_favicon_query():
|
||||
# Note: Query-string alias makes cache warming easier from shell scripts where path routing/proxies may differ.
|
||||
domain = str(request.args.get("domain") or "").strip()
|
||||
if not domain:
|
||||
return jsonify({"ok": False, "error": "domain is required"}), 400
|
||||
return tracker_favicon(domain)
|
||||
|
||||
|
||||
@bp.get("/torrent-stats")
|
||||
def torrent_stats_get():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return ok({"stats": {}, "error": "No profile"})
|
||||
force = str(request.args.get("force") or "").lower() in {"1", "true", "yes"}
|
||||
try:
|
||||
# Note: Heavy file metadata is served from a 15-minute DB cache unless the user explicitly refreshes it.
|
||||
return ok({"stats": torrent_stats.get(profile, force=force)})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 500
|
||||
|
||||
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/files")
|
||||
def torrent_files(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
return ok({"files": rtorrent.torrent_files(profile, torrent_hash)})
|
||||
|
||||
|
||||
|
||||
@bp.post("/torrents/<torrent_hash>/files/priority")
|
||||
def torrent_file_priority(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
files = data.get("files") or []
|
||||
if not isinstance(files, list) or not files:
|
||||
return jsonify({"ok": False, "error": "No files selected"}), 400
|
||||
result = rtorrent.set_file_priorities(profile, torrent_hash, files)
|
||||
status = 207 if result.get("errors") else 200
|
||||
return ok(result), status
|
||||
|
||||
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/files/tree")
|
||||
def torrent_file_tree(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
return ok({"tree": rtorrent.torrent_file_tree(profile, torrent_hash)})
|
||||
|
||||
|
||||
|
||||
@bp.post("/torrents/<torrent_hash>/files/folder-priority")
|
||||
def torrent_folder_priority(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
result = rtorrent.set_folder_priority(profile, torrent_hash, str(data.get("path") or ""), int(data.get("priority") or 0))
|
||||
status = 207 if result.get("errors") else 200
|
||||
return ok(result), status
|
||||
|
||||
|
||||
def _attachment_headers(download_name: str, content_type: str = "application/octet-stream") -> dict:
|
||||
safe = Path(download_name or "download.bin").name or "download.bin"
|
||||
return {
|
||||
"Content-Type": content_type,
|
||||
"Content-Disposition": f"attachment; filename*=UTF-8''{quote(safe)}",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
}
|
||||
|
||||
|
||||
def _cleanup_staged_file(profile: dict, path: str, local: bool = False) -> None:
|
||||
if local:
|
||||
try:
|
||||
Path(path).unlink()
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
rtorrent._remote_remove_staged(profile, path)
|
||||
try:
|
||||
tmp_prefix = str(PYTORRENT_TMP_DIR).rstrip("/") + "/pytorrent-download-"
|
||||
if str(path).startswith(tmp_prefix) and Path(path).exists():
|
||||
Path(path).unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _read_staged_file(profile: dict, path: str, local: bool = False) -> bytes:
|
||||
if local:
|
||||
return Path(path).read_bytes()
|
||||
chunks = []
|
||||
for chunk in rtorrent.iter_remote_file_chunks(profile, path):
|
||||
if chunk:
|
||||
chunks.append(bytes(chunk))
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def _send_staged_file(profile: dict, path: str, download_name: str, local: bool = False):
|
||||
headers = _attachment_headers(download_name, "application/x-bittorrent")
|
||||
if local:
|
||||
data = Path(path).read_bytes()
|
||||
_cleanup_staged_file(profile, path, local=True)
|
||||
headers["Content-Length"] = str(len(data))
|
||||
return Response(data, headers=headers)
|
||||
|
||||
def generate():
|
||||
try:
|
||||
yield from rtorrent.iter_remote_file_chunks(profile, path)
|
||||
finally:
|
||||
_cleanup_staged_file(profile, path, local=False)
|
||||
|
||||
return Response(stream_with_context(generate()), headers=headers, direct_passthrough=True)
|
||||
|
||||
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/files/<int:file_index>/download")
|
||||
def torrent_file_download(torrent_hash: str, file_index: int):
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
item = rtorrent.torrent_download_file_info(profile, torrent_hash, file_index)
|
||||
size = int(item.get("size") or 0)
|
||||
headers = _attachment_headers(item.get("download_name") or "file.bin")
|
||||
if size > 0:
|
||||
headers["Content-Length"] = str(size)
|
||||
def generate():
|
||||
yield from rtorrent.iter_remote_file_chunks(profile, item["remote_path"], size=size or None)
|
||||
return Response(stream_with_context(generate()), headers=headers, direct_passthrough=True)
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
class _ZipStream:
|
||||
def __init__(self):
|
||||
self.queue: queue.Queue[bytes | None] = queue.Queue(maxsize=16)
|
||||
self.closed = False
|
||||
|
||||
def write(self, data):
|
||||
if not data:
|
||||
return 0
|
||||
payload = bytes(data)
|
||||
self.queue.put(payload)
|
||||
return len(payload)
|
||||
|
||||
def flush(self):
|
||||
return None
|
||||
|
||||
def close(self):
|
||||
if not self.closed:
|
||||
self.closed = True
|
||||
self.queue.put(None)
|
||||
|
||||
def writable(self):
|
||||
return True
|
||||
|
||||
|
||||
def _safe_zip_name(name: str, fallback: str) -> str:
|
||||
value = str(name or fallback).replace("\\", "/").lstrip("/")
|
||||
parts = [part for part in value.split("/") if part not in ("", ".", "..")]
|
||||
return "/".join(parts) or fallback
|
||||
|
||||
|
||||
def _stream_torrent_files_zip(profile: dict, items: list[dict]):
|
||||
writer = _ZipStream()
|
||||
errors: list[BaseException] = []
|
||||
|
||||
def produce():
|
||||
try:
|
||||
with zipfile.ZipFile(writer, "w", compression=zipfile.ZIP_STORED, allowZip64=True) as archive:
|
||||
used = set()
|
||||
for item in items:
|
||||
arcname = _safe_zip_name(str(item.get("path") or ""), f"file-{item.get('index', 0)}")
|
||||
base = arcname
|
||||
counter = 2
|
||||
while arcname in used:
|
||||
stem = Path(base).stem or "file"
|
||||
suffix = Path(base).suffix
|
||||
parent = str(Path(base).parent).replace(".", "", 1).strip("/")
|
||||
candidate = f"{stem}-{counter}{suffix}"
|
||||
arcname = f"{parent}/{candidate}" if parent else candidate
|
||||
counter += 1
|
||||
used.add(arcname)
|
||||
info = zipfile.ZipInfo(arcname)
|
||||
info.compress_type = zipfile.ZIP_STORED
|
||||
info.file_size = int(item.get("size") or 0)
|
||||
with archive.open(info, "w", force_zip64=True) as dest:
|
||||
for chunk in rtorrent.iter_remote_file_chunks(profile, item["remote_path"], size=int(item.get("size") or 0) or None):
|
||||
dest.write(chunk)
|
||||
except BaseException as exc:
|
||||
errors.append(exc)
|
||||
finally:
|
||||
writer.close()
|
||||
|
||||
threading.Thread(target=produce, name="pytorrent-zip-stream", daemon=True).start()
|
||||
while True:
|
||||
chunk = writer.queue.get()
|
||||
if chunk is None:
|
||||
break
|
||||
yield chunk
|
||||
if errors:
|
||||
raise errors[0]
|
||||
|
||||
|
||||
|
||||
@bp.post("/torrents/<torrent_hash>/files/download.zip")
|
||||
def torrent_files_download_zip(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
try:
|
||||
items = rtorrent.torrent_download_zip_items(profile, torrent_hash, data.get("indexes") or None)
|
||||
headers = _attachment_headers(f"{torrent_hash[:12]}-files.zip", "application/zip")
|
||||
headers["X-PyTorrent-Download-Mode"] = "rtorrent-stream"
|
||||
return Response(stream_with_context(_stream_torrent_files_zip(profile, items)), headers=headers, direct_passthrough=True)
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/torrent-file")
|
||||
def torrent_file_export(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
item = rtorrent.export_torrent_file(profile, torrent_hash)
|
||||
return _send_staged_file(profile, item["path"], item["download_name"], bool(item.get("local")))
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.post("/torrents/torrent-files.zip")
|
||||
def torrent_files_export_zip():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
hashes = [str(h) for h in (data.get("hashes") or []) if str(h).strip()]
|
||||
if not hashes:
|
||||
return jsonify({"ok": False, "error": "No torrents selected"}), 400
|
||||
staged_paths = []
|
||||
PYTORRENT_TMP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
tmp = tempfile.NamedTemporaryFile(prefix="pytorrent-torrents-", suffix=".zip", delete=False, dir=str(PYTORRENT_TMP_DIR))
|
||||
tmp.close()
|
||||
try:
|
||||
with zipfile.ZipFile(tmp.name, "w", compression=zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
|
||||
used_names = set()
|
||||
for h in hashes:
|
||||
item = rtorrent.export_torrent_file(profile, h)
|
||||
staged_paths.append((item["path"], bool(item.get("local"))))
|
||||
name = Path(item["download_name"]).name or f"{h}.torrent"
|
||||
base_name = name
|
||||
counter = 2
|
||||
while name in used_names:
|
||||
stem = Path(base_name).stem
|
||||
name = f"{stem}-{counter}.torrent"
|
||||
counter += 1
|
||||
used_names.add(name)
|
||||
archive.writestr(name, _read_staged_file(profile, item["path"], bool(item.get("local"))))
|
||||
response = send_file(tmp.name, as_attachment=True, download_name="pytorrent-torrents.zip")
|
||||
def cleanup():
|
||||
for path, is_local in staged_paths:
|
||||
_cleanup_staged_file(profile, path, is_local)
|
||||
try:
|
||||
Path(tmp.name).unlink()
|
||||
except Exception:
|
||||
pass
|
||||
response.call_on_close(cleanup)
|
||||
return response
|
||||
except Exception as exc:
|
||||
for path, is_local in staged_paths:
|
||||
_cleanup_staged_file(profile, path, is_local)
|
||||
try:
|
||||
Path(tmp.name).unlink()
|
||||
except Exception:
|
||||
pass
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/chunks")
|
||||
def torrent_chunks(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
max_cells = min(10000, max(64, int(request.args.get("max_cells") or 2048)))
|
||||
return ok({"chunks": rtorrent.torrent_chunks(profile, torrent_hash, max_cells=max_cells)})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@bp.post("/torrents/<torrent_hash>/chunks/<action_name>")
|
||||
def torrent_chunk_action(torrent_hash: str, action_name: str):
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
# Note: Chunk actions are intentionally limited to rTorrent-safe operations; XML-RPC has no supported single-piece redownload call.
|
||||
result = rtorrent.torrent_chunk_action(profile, torrent_hash, action_name, request.get_json(silent=True) or {})
|
||||
return ok({"result": result, "message": result.get("message") or f"Chunk action {action_name} done"})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/peers")
|
||||
def torrent_peers(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
peers = rtorrent.torrent_peers(profile, torrent_hash)
|
||||
for peer in peers:
|
||||
peer.update(lookup_ip(peer.get("ip", "")))
|
||||
return ok({"peers": peers})
|
||||
|
||||
|
||||
|
||||
@bp.get("/torrents/<torrent_hash>/trackers")
|
||||
def torrent_trackers(torrent_hash: str):
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
return ok({"trackers": rtorrent.torrent_trackers(profile, torrent_hash)})
|
||||
|
||||
|
||||
|
||||
@bp.post("/torrents/<torrent_hash>/trackers/<action_name>")
|
||||
def torrent_tracker_action(torrent_hash: str, action_name: str):
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
result = rtorrent.tracker_action(profile, torrent_hash, action_name, request.get_json(silent=True) or {})
|
||||
return ok({"result": result, "message": f"Tracker {action_name} via {result.get('method', 'XMLRPC')}"})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.post("/torrents/<action_name>")
|
||||
def torrent_action(action_name: str):
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
allowed = {"start", "pause", "unpause", "stop", "resume", "recheck", "reannounce", "remove", "move", "set_label", "set_ratio_group"}
|
||||
if action_name not in allowed:
|
||||
return jsonify({"ok": False, "error": "Unknown action"}), 400
|
||||
if action_name in {"move", "remove"}:
|
||||
# Note: Large move/remove requests are split into ordered bulk parts; smaller requests keep the old single-job response shape.
|
||||
jobs = enqueue_bulk_parts(profile, action_name, data)
|
||||
first_job_id = jobs[0]["job_id"] if jobs else None
|
||||
total_hashes = sum(int(job.get("hash_count") or 0) for job in jobs)
|
||||
return ok({
|
||||
"job_id": first_job_id,
|
||||
"job_ids": [job["job_id"] for job in jobs],
|
||||
"jobs": jobs,
|
||||
"hash_count": total_hashes,
|
||||
"bulk": total_hashes > 1,
|
||||
"bulk_parts": len(jobs),
|
||||
"chunk_size": MOVE_BULK_MAX_HASHES,
|
||||
})
|
||||
payload = enrich_bulk_payload(profile, action_name, data)
|
||||
job_id = enqueue(action_name, profile["id"], payload)
|
||||
return ok({"job_id": job_id, "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1})
|
||||
|
||||
|
||||
|
||||
@bp.post("/torrents/create")
|
||||
def torrent_create():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
form = request.form if request.content_type and request.content_type.startswith("multipart/form-data") else (request.get_json(silent=True) or {})
|
||||
try:
|
||||
created = torrent_creator.build_torrent(
|
||||
source_path=form.get("source_path", ""),
|
||||
trackers=form.get("trackers", ""),
|
||||
comment=form.get("comment", ""),
|
||||
source=form.get("source", ""),
|
||||
piece_size_kib=form.get("piece_size_kib", 256),
|
||||
private=str(form.get("private", "0")).lower() in {"1", "true", "on", "yes"},
|
||||
)
|
||||
share = str(form.get("share", "0")).lower() in {"1", "true", "on", "yes"}
|
||||
if share:
|
||||
size_check = rtorrent.validate_torrent_upload_size(profile, created["data"], True, created["source_parent"], form.get("label", ""))
|
||||
if not size_check.get("ok"):
|
||||
return jsonify({"ok": False, "error": f"Created torrent is too large for the current rTorrent XML-RPC limit: request {size_check['request_h']} > limit {size_check['limit_h']}. Change {size_check['setting']}.set to e.g. {size_check['suggested_value']} in rTorrent settings.", "xmlrpc_limit": size_check}), 413
|
||||
rtorrent.add_torrent_raw(profile, created["data"], True, created["source_parent"], form.get("label", ""))
|
||||
headers = _attachment_headers(created["filename"], "application/x-bittorrent")
|
||||
headers["Content-Length"] = str(len(created["data"]))
|
||||
headers["X-PyTorrent-Info-Hash"] = created["info_hash"]
|
||||
headers["X-PyTorrent-Create-Message"] = f"Created {created['filename']} ({created['file_count']} file(s))"
|
||||
return Response(created["data"], headers=headers)
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
@bp.post("/torrents/add")
|
||||
def torrent_add():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
job_ids = []
|
||||
if request.content_type and request.content_type.startswith("multipart/form-data"):
|
||||
start = request.form.get("start", "1") in {"1", "true", "on", "yes"}
|
||||
directory = request.form.get("directory", "") or active_default_download_path(profile)
|
||||
label = request.form.get("label", "")
|
||||
uris = [x.strip() for x in request.form.get("uris", "").splitlines() if x.strip()]
|
||||
for uri in uris:
|
||||
job_ids.append(enqueue("add_magnet", profile["id"], {"uri": uri, "start": start, "directory": directory, "label": label}))
|
||||
existing_hashes = {str(t.get("hash") or "").upper() for t in torrent_cache.snapshot(profile["id"])}
|
||||
try:
|
||||
priority_payload = json.loads(request.form.get("file_priorities") or "{}")
|
||||
except Exception:
|
||||
priority_payload = {}
|
||||
allow_duplicates = request.form.get("allow_duplicates", "0") in {"1", "true", "on", "yes"}
|
||||
skipped_duplicates = []
|
||||
for uploaded in request.files.getlist("files"):
|
||||
raw = uploaded.read()
|
||||
meta = parse_torrent(raw)
|
||||
info_hash = str(meta.get("info_hash") or "").upper()
|
||||
filename = uploaded.filename or meta.get("name") or info_hash
|
||||
if info_hash and info_hash in existing_hashes and not allow_duplicates:
|
||||
skipped_duplicates.append({"filename": filename, "info_hash": info_hash})
|
||||
continue
|
||||
file_priorities = []
|
||||
if isinstance(priority_payload, dict):
|
||||
file_priorities = priority_payload.get(filename) or priority_payload.get(info_hash) or []
|
||||
elif isinstance(priority_payload, list):
|
||||
file_priorities = priority_payload
|
||||
|
||||
size_check = rtorrent.validate_torrent_upload_size(profile, raw, start, directory, label, file_priorities or None)
|
||||
if not size_check.get("ok"):
|
||||
return jsonify({
|
||||
"ok": False,
|
||||
"error": (
|
||||
f"Torrent file is too large for the current rTorrent XML-RPC limit: "
|
||||
f"request {size_check['request_h']} > limit {size_check['limit_h']}. "
|
||||
f"Change {size_check['setting']}.set to e.g. {size_check['suggested_value']} in rTorrent settings."
|
||||
),
|
||||
"xmlrpc_limit": size_check,
|
||||
}), 413
|
||||
data_b64 = base64.b64encode(raw).decode("ascii")
|
||||
job_ids.append(enqueue("add_torrent_raw", profile["id"], {"filename": filename, "data_b64": data_b64, "start": start, "directory": directory, "label": label, "file_priorities": file_priorities or None}))
|
||||
return ok({"job_ids": job_ids, "skipped_duplicates": skipped_duplicates})
|
||||
data = request.get_json(silent=True) or {}
|
||||
uris = data.get("uris") or []
|
||||
if isinstance(uris, str):
|
||||
uris = [x.strip() for x in uris.splitlines() if x.strip()]
|
||||
for uri in uris:
|
||||
job_ids.append(enqueue("add_magnet", profile["id"], {"uri": uri, "start": data.get("start", True), "directory": data.get("directory", "") or active_default_download_path(profile), "label": data.get("label", "")}))
|
||||
return ok({"job_ids": job_ids})
|
||||
|
||||
|
||||
@bp.post("/torrents/preview")
|
||||
def torrent_preview():
|
||||
profile = preferences.active_profile()
|
||||
existing_hashes = set()
|
||||
if profile:
|
||||
try:
|
||||
existing_hashes = {str(t.get("hash") or "").upper() for t in torrent_cache.snapshot(profile["id"])}
|
||||
except Exception:
|
||||
existing_hashes = set()
|
||||
previews = []
|
||||
xmlrpc_limit = rtorrent.xmlrpc_size_limit(profile) if profile else None
|
||||
try:
|
||||
uploads = request.files.getlist("files") if request.content_type and request.content_type.startswith("multipart/form-data") else []
|
||||
for uploaded in uploads:
|
||||
raw = uploaded.read()
|
||||
meta = parse_torrent(raw)
|
||||
meta["filename"] = uploaded.filename
|
||||
meta["duplicate"] = bool(meta.get("info_hash") and meta["info_hash"].upper() in existing_hashes)
|
||||
if profile:
|
||||
size_check = rtorrent.validate_torrent_upload_size(profile, raw)
|
||||
meta["xmlrpc_request_bytes"] = size_check["request_bytes"]
|
||||
meta["xmlrpc_request_h"] = size_check["request_h"]
|
||||
meta["xmlrpc_too_large"] = not size_check.get("ok")
|
||||
previews.append(meta)
|
||||
return ok({"previews": previews, "xmlrpc_limit": xmlrpc_limit})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.post("/speed/limits")
|
||||
def speed_limits():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
data = request.get_json(silent=True) or {}
|
||||
job_id = enqueue("set_limits", profile["id"], {"down": data.get("down"), "up": data.get("up")})
|
||||
return ok({"job_id": job_id})
|
||||
|
||||
|
||||
def _user_disk_status(profile: dict) -> dict:
|
||||
# Note: Disk usage is user-preference aware, so it is read separately from the shared Socket.IO poller.
|
||||
prefs = preferences.get_disk_monitor_preferences(profile.get("id") if profile else None)
|
||||
try:
|
||||
paths = json.loads((prefs or {}).get("disk_monitor_paths_json") or "[]") if prefs else []
|
||||
except Exception:
|
||||
paths = []
|
||||
return rtorrent.disk_usage_for_paths(
|
||||
profile,
|
||||
paths,
|
||||
(prefs or {}).get("disk_monitor_mode") or "default",
|
||||
(prefs or {}).get("disk_monitor_selected_path") or "",
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user