#!/usr/bin/env python3 """Drive one real transfer through the test HTTP adapter.""" from __future__ import annotations import argparse import json import sys import time import urllib.parse import urllib.request CONTROL = "http://127.0.0.1:18081/test/v1" INFO_HASH = "330be0cb7c2201135a2de63b28e77993745ff688" LOGICAL_BYTES = 31 def get_json(url: str): with urllib.request.urlopen(url, timeout=5) as response: return json.load(response) def post_json(url: str, value: object): encoded = json.dumps(value, separators=(",", ":")).encode() request = urllib.request.Request( url, data=encoded, method="POST", headers={"Content-Type": "application/json"}, ) with urllib.request.urlopen(request, timeout=5) as response: return json.load(response) def qb_json(endpoint: str, path: str, **parameters: str): query = urllib.parse.urlencode(parameters) suffix = f"?{query}" if query else "" return get_json(f"{endpoint}{path}{suffix}") def wait_for(predicate, timeout: float, detail: str): deadline = time.monotonic() + timeout last = None while time.monotonic() < deadline: try: last = predicate() if last: return last except (OSError, ValueError, KeyError, IndexError) as exc: last = f"{type(exc).__name__}: {exc}" time.sleep(0.5) raise RuntimeError(f"timed out waiting for {detail}; last={last!r}") def qb_resource_is_complete(endpoint: str): records = qb_json(endpoint, "/torrents/info", hashes=INFO_HASH) if len(records) != 1 or float(records[0].get("progress", 0)) < 1: return None files = qb_json(endpoint, "/torrents/files", hash=INFO_HASH) if ( len(files) != 1 or float(files[0].get("progress", 0)) < 1 or int(files[0].get("priority", 0)) <= 0 ): return None return records[0] def ready_route(cache_client: str, archive_client: str): for route in get_json(f"{CONTROL}/routes"): if ( route["cache_client_id"] == cache_client and route["archive_client_id"] == archive_client and route["state"] == "ready" ): return route return None def main() -> int: parser = argparse.ArgumentParser() parser.add_argument( "--operation", choices=("archive", "unarchive"), required=True ) parser.add_argument("--source-client", required=True) parser.add_argument("--target-client", required=True) parser.add_argument("--source-qb", required=True) parser.add_argument("--target-qb", required=True) parser.add_argument("--cache-client", required=True) parser.add_argument("--archive-client", required=True) args = parser.parse_args() wait_for( lambda: qb_resource_is_complete(args.source_qb), 60, "source qBittorrent verification", ) route = wait_for( lambda: ready_route(args.cache_client, args.archive_client), 60, f"{args.cache_client}/{args.archive_client} route", ) preview = post_json(f"{CONTROL}/jobs/preview", { "operation": args.operation, "source_client_id": args.source_client, "target_client_id": args.target_client, "resource_id": {"info_hash_v1_hex": INFO_HASH}, }) definition = preview["definition"] job_id = definition["job_id"] created = post_json(f"{CONTROL}/jobs", { "preview_revision": preview["preview_revision"], "definition": definition, }) if created["job_id"] != job_id: raise RuntimeError("control returned the wrong job") def finished_job(): job = get_json(f"{CONTROL}/jobs/{job_id}") if job["state"] == "JOB_STATE_FAILED": raise RuntimeError(f"{args.operation} job failed: {job}") if job["state"] == "JOB_STATE_SUCCEEDED" and job["committed"]: return job return None job = wait_for(finished_job, 180, f"committed {args.operation} job") wait_for( lambda: qb_resource_is_complete(args.target_qb), 30, "target qBittorrent placement", ) if qb_resource_is_complete(args.source_qb) is None: raise RuntimeError( f"{args.operation} transfer did not retain its source placement" ) print(json.dumps({ "operation": args.operation, "job_id": job_id, "state": job["state"], "committed": job["committed"], "route_id": route["route_id"], "source_retained": True, "target_verified": True, }, sort_keys=True)) return 0 if __name__ == "__main__": try: raise SystemExit(main()) except Exception as exc: print(f"transfer happy-path failed: {exc}", file=sys.stderr) raise