| 1 | import json |
| 2 | import os |
| 3 | from pathlib import Path |
| 4 | import sys |
| 5 | from urllib.parse import urlsplit, urlunsplit |
| 6 | import urllib.request |
| 7 | import xml.etree.ElementTree as ET |
| 8 | |
| 9 | from studio import load |
| 10 | |
| 11 | |
| 12 | data = json.load(sys.stdin) |
| 13 | service = data["serviceId"] |
| 14 | api_key = ET.parse(Path(data["hostRoot"]) / "config/config.xml").findtext("ApiKey") |
| 15 | if not api_key: |
| 16 | raise ValueError(f"{service} has no API key in config.xml") |
| 17 | |
| 18 | nomad_headers = {"X-Nomad-Token": os.environ["NOMAD_TOKEN"]} |
| 19 | |
| 20 | |
| 21 | def internal_endpoint(name): |
| 22 | jobs = json.load(urllib.request.urlopen(urllib.request.Request( |
| 23 | f"http://127.0.0.1:4646/v1/job/{name}/allocations", headers=nomad_headers, |
| 24 | ), timeout=10)) |
| 25 | running = [job for job in jobs if job["ClientStatus"] == "running" and job["DesiredStatus"] == "run"] |
| 26 | if len(running) != 1: |
| 27 | raise ValueError(f"{name} needs one running allocation before configuring {service}") |
| 28 | allocation = json.load(urllib.request.urlopen(urllib.request.Request( |
| 29 | f"http://127.0.0.1:4646/v1/allocation/{running[0]['ID']}", headers=nomad_headers, |
| 30 | ), timeout=10)) |
| 31 | ports = [port for port in allocation["AllocatedResources"]["Shared"]["Ports"] |
| 32 | if port["Label"] == "internal"] |
| 33 | if len(ports) != 1: |
| 34 | raise ValueError(f"{name} needs one reserved internal port before configuring {service}") |
| 35 | return ports[0]["HostIP"], ports[0]["Value"] |
| 36 | |
| 37 | |
| 38 | nomad = urllib.request.Request( |
| 39 | f"http://127.0.0.1:4646/v1/service/{service}", |
| 40 | headers=nomad_headers, |
| 41 | ) |
| 42 | allocations = json.load(urllib.request.urlopen(nomad, timeout=10)) |
| 43 | if len(allocations) != 1: |
| 44 | raise ValueError(f"{service} needs one running allocation before configuring download clients") |
| 45 | allocation = allocations[0] |
| 46 | base = f"http://{allocation['Address']}:{allocation['Port']}/api/v3/downloadclient" |
| 47 | headers = {"X-Api-Key": api_key} |
| 48 | clients = json.load(urllib.request.urlopen(urllib.request.Request(base, headers=headers), timeout=10)) |
| 49 | previous_hosts = { |
| 50 | field.get("value") for client in clients if client["implementation"] == "QBittorrent" |
| 51 | for field in client["fields"] if field["name"] == "host" |
| 52 | } |
| 53 | qbittorrent_host, qbittorrent_port = internal_endpoint("qbittorrent") |
| 54 | if not any(client["implementation"] == "QBittorrent" for client in clients): |
| 55 | schemas = json.load(urllib.request.urlopen(urllib.request.Request(base + "/schema", headers=headers), timeout=10)) |
| 56 | client = next(schema for schema in schemas if schema["implementation"] == "QBittorrent") |
| 57 | client["name"] = "qBittorrent" |
| 58 | client["enable"] = not data["preview"] |
| 59 | fields = {field["name"]: field for field in client["fields"]} |
| 60 | for name, value in {"host": qbittorrent_host, "port": qbittorrent_port, "useSsl": False}.items(): |
| 61 | fields[name]["value"] = value |
| 62 | request = urllib.request.Request( |
| 63 | base, |
| 64 | data=json.dumps(client).encode(), |
| 65 | headers={**headers, "Content-Type": "application/json"}, |
| 66 | method="POST", |
| 67 | ) |
| 68 | urllib.request.urlopen(request, timeout=15).close() |
| 69 | clients = [client] |
| 70 | |
| 71 | for client in clients: |
| 72 | if client["implementation"] != "QBittorrent": |
| 73 | continue |
| 74 | fields = {field["name"]: field for field in client["fields"]} |
| 75 | if "host" not in fields or "port" not in fields or "useSsl" not in fields: |
| 76 | raise ValueError(f"{service} has an incomplete qBittorrent client") |
| 77 | expected = {"host": qbittorrent_host, "port": qbittorrent_port, "useSsl": False} |
| 78 | changed = any(fields[name]["value"] != value for name, value in expected.items()) |
| 79 | for name, value in expected.items(): |
| 80 | fields[name]["value"] = value |
| 81 | if data["preview"] and client["enable"]: |
| 82 | client["enable"] = False |
| 83 | changed = True |
| 84 | if not changed: |
| 85 | continue |
| 86 | request = urllib.request.Request( |
| 87 | f"{base}/{client['id']}", |
| 88 | data=json.dumps(client).encode(), |
| 89 | headers={**headers, "Content-Type": "application/json"}, |
| 90 | method="PUT", |
| 91 | ) |
| 92 | urllib.request.urlopen(request, timeout=15).close() |
| 93 | |
| 94 | local_volumes = load(service.split("-preview-", 1)[0], {})["containers"]["app"]["volumes"] |
| 95 | remote_volumes = load("qbittorrent", {})["containers"]["app"]["volumes"] |
| 96 | mapping_base = base.replace("/downloadclient", "/remotepathmapping") |
| 97 | mappings = json.load(urllib.request.urlopen(urllib.request.Request(mapping_base, headers=headers), timeout=10)) |
| 98 | for remote_path, remote_volume in remote_volumes.items(): |
| 99 | if not remote_volume.get("src"): |
| 100 | continue |
| 101 | source = Path(remote_volume["src"]) |
| 102 | destinations = [ |
| 103 | str(Path(local_path) / source.relative_to(volume["src"])) |
| 104 | for local_path, volume in local_volumes.items() |
| 105 | if volume.get("src") and source.is_relative_to(volume["src"]) |
| 106 | ] |
| 107 | if not destinations: |
| 108 | continue |
| 109 | if len(destinations) != 1: |
| 110 | raise ValueError(f"{service} has ambiguous mounts for qBittorrent's {remote_path}") |
| 111 | local_path = destinations[0] |
| 112 | if remote_path == local_path: |
| 113 | continue |
| 114 | expected = {"host": qbittorrent_host, "remotePath": remote_path + "/", "localPath": local_path + "/"} |
| 115 | existing = next((mapping for mapping in mappings |
| 116 | if mapping["remotePath"].rstrip("/") == remote_path |
| 117 | and mapping["host"] in previous_hosts | {qbittorrent_host}), None) |
| 118 | if existing and all(existing[key] == value for key, value in expected.items()): |
| 119 | continue |
| 120 | body = {**existing, **expected} if existing else expected |
| 121 | request = urllib.request.Request( |
| 122 | f"{mapping_base}/{existing['id']}" if existing else mapping_base, |
| 123 | data=json.dumps(body).encode(), |
| 124 | headers={**headers, "Content-Type": "application/json"}, |
| 125 | method="PUT" if existing else "POST", |
| 126 | ) |
| 127 | urllib.request.urlopen(request, timeout=15).close() |
| 128 | |
| 129 | jackett_host, jackett_port = internal_endpoint("jackett") |
| 130 | indexer_base = base.replace("/downloadclient", "/indexer") |
| 131 | indexers = json.load(urllib.request.urlopen(urllib.request.Request(indexer_base, headers=headers), timeout=10)) |
| 132 | for indexer in indexers: |
| 133 | changed = False |
| 134 | if data["preview"]: |
| 135 | for setting in ("enableRss", "enableAutomaticSearch"): |
| 136 | if indexer[setting]: |
| 137 | indexer[setting] = False |
| 138 | changed = True |
| 139 | fields = {field["name"]: field for field in indexer["fields"]} |
| 140 | if "baseUrl" in fields and isinstance(fields["baseUrl"].get("value"), str): |
| 141 | current = urlsplit(fields["baseUrl"]["value"]) |
| 142 | if current.path.startswith("/api/v2.0/indexers/"): |
| 143 | url = urlunsplit(("http", f"{jackett_host}:{jackett_port}", current.path, current.query, current.fragment)) |
| 144 | changed |= fields["baseUrl"]["value"] != url |
| 145 | fields["baseUrl"]["value"] = url |
| 146 | if not changed: |
| 147 | continue |
| 148 | request = urllib.request.Request( |
| 149 | f"{indexer_base}/{indexer['id']}?forceSave=true", |
| 150 | data=json.dumps(indexer).encode(), |
| 151 | headers={**headers, "Content-Type": "application/json"}, |
| 152 | method="PUT", |
| 153 | ) |
| 154 | urllib.request.urlopen(request, timeout=15).close() |