From 369ef93d6688f0c245e38de6c333b281cf941926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?William=20S=C3=B6derberg?= Date: Wed, 11 Mar 2026 18:31:39 +0100 Subject: [PATCH] First commit --- .gitignore | 165 ++++++++++++++++++++++++++++++++++++++++ README.md | 57 ++++++++++++++ listen_port.sh | 17 +++++ mam_seedbox.py | 73 ++++++++++++++++++ qbitport.py | 81 ++++++++++++++++++++ seedbox.py | 201 +++++++++++++++++++++++++++++++++++++++++++++++++ trackers.py | 11 +++ 7 files changed, 605 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 listen_port.sh create mode 100644 mam_seedbox.py create mode 100644 qbitport.py create mode 100644 seedbox.py create mode 100644 trackers.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..387ae24 --- /dev/null +++ b/.gitignore @@ -0,0 +1,165 @@ +# Custom +*.cookies + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..04ed8da --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# UNIT3D Seedbox Manager + +For when you have a non-static IP on your seedbox and this is a script that can automagically manage it for you. + +## Usage + +```cmd +seedbox.py tracker username [ip] [options] +``` + +> It is up to **you** as user to fill in the `tracker.py` file with all your own trackers. There are two trackers in the file as an example. + +## Requirements + +You will need a `Netscape HTTP Cookie File` with the cookie `remember_web_*` from the UNIT3D tracker of your choice. Basically, login and then save the cookies to a file called `unit3d.cookies` and place it in the script folder or use the `--cookie-file` option with a custom file. + +## All options + +- `--help`, `--print | --no-print`, `--seedbox-name`, `--delete | --no-delete`, `--cookie-file` + +## Example + +```cmd +python ./seedbox.py TRACKER USERNAME 1.1.1.2 --no-delete --seedbox-name SeedExample1 --print + +| Name IP ID +| ----------------------------------------------------- +| MainBox 192.168.12.234 1448 +| SeedExample 1.1.1.1 1455 + +Successfully added Seedbox "SeedExample1" (1.1.1.2) to TRACKER +``` + +## Help + +```cmd +usage: seedbox.py tracker username [ip] [options] + +Automagically add or remove seedbox entries on any UNIT3D + +positional arguments: + tracker Tracker name or abbreviation. + username username + ip IP of the seedbox + +options: + -h, --help show this help message and exit + --print, --no-print Prints list of seedboxes (default: False) + --seedbox-name SEEDBOX_NAME + Name of the seedbox (default: PikminBox) + --delete, --no-delete + Deletes all old seedbox entries (default: False) + --cookie-file COOKIE_FILE + Cookies to be used when making requests (default: ./unit3d.cookies) + +For bugs/errors, please DM me on FnP or make an issue on GitHub +``` diff --git a/listen_port.sh b/listen_port.sh new file mode 100644 index 0000000..ed28dd9 --- /dev/null +++ b/listen_port.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +# 1. Update qBittorrent port +python3 /pia/scripts/qbitport.py "$1" + +# 2. Get WireGuard IP +ip_adress=$(wg show | grep 'endpoint: ' | awk -F ':' '{print $2}' | xargs) + +# 3. Update Unit3D seedboxes +trackers=("fnp" "sp" "rs" "ulcx" "dps" "lum") + +for tracker in "${trackers[@]}"; do + python3 /pia/scripts/seedbox.py "$tracker" swedish_wiking "$ip_adress" --delete --seedbox-name MainFrame +done + +# 4. Update MaM seedbox +python3 /pia/scripts/mam_seedbox.py \ No newline at end of file diff --git a/mam_seedbox.py b/mam_seedbox.py new file mode 100644 index 0000000..1209f97 --- /dev/null +++ b/mam_seedbox.py @@ -0,0 +1,73 @@ +# mam_seedbox.py + +import json +import urllib.error +import urllib.request +from http.cookies import SimpleCookie +from pathlib import Path + +__location__ = Path(__file__).parent.resolve() +cookie_file = __location__.joinpath("mam_id") +api_url = "https://t.myanonamouse.net/json/dynamicSeedbox.php" + + +def save_cookie(headers): + set_cookie_header = headers.get("Set-Cookie") + if set_cookie_header: + cookie = SimpleCookie(set_cookie_header) + if "mam_id" in cookie: + cookie_file.write_text(cookie["mam_id"].value) + + +def load_cookie() -> str: + if not cookie_file.exists(): + print("Warning: mam_id file not found. Proceeding without it...", flush=True) + return "" + + mam_id = cookie_file.read_text().strip() + return f"mam_id={mam_id}" + + +def main(): + print("Updating MaM Dynamic Seedbox IP...", flush=True) + + req = urllib.request.Request(api_url) + cookie_val = load_cookie() + if cookie_val: + req.add_header("Cookie", cookie_val) + + status_code = None + data = {} + headers = {} + + try: + with urllib.request.urlopen(req) as response: + status_code = response.status + data = json.loads(response.read().decode("utf-8")) + headers = response.headers + except urllib.error.HTTPError as e: + status_code = e.code + try: + data = json.loads(e.read().decode("utf-8")) + except json.JSONDecodeError: + data = {"msg": "Unknown server error"} + except urllib.error.URLError as e: + print(f"Failed to connect to MaM: {e.reason}", flush=True) + return + + msg = data.get("msg", "No message provided") + + match status_code: + case 200: + print(f"Successfully updated MaM seedbox: {msg}", flush=True) + save_cookie(headers) + case 429: + print(f"Failed to update MaM seedbox (Rate Limited): {msg}", flush=True) + case 403: + print(f"Failed to update MaM seedbox (Forbidden): {msg}", flush=True) + case _: + print(f"Something went VERY wrong... HTTP {status_code}: {msg}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/qbitport.py b/qbitport.py new file mode 100644 index 0000000..a097841 --- /dev/null +++ b/qbitport.py @@ -0,0 +1,81 @@ +# qbitport.py + +import argparse +import json +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +parser = argparse.ArgumentParser( + prog="qBittorrent Port Manager", + description="Update your qBittorrent port", + usage=f"{Path(__file__).name} port [options]", +) + +parser.add_argument("port", type=str, help="Forwarding Port") +parser.add_argument( + "--host", + type=str, + default="localhost", + help="IP/hostname of qBittorrent instance (default: %(default)s)", +) +parser.add_argument( + "--host-port", + type=int, + default=8080, + help="qBittorrent port (default: %(default)s)", +) +args = parser.parse_args() + + +def ping_until_connected(url, max_retries=12): + req = urllib.request.Request(url, method="HEAD") + retries = 0 + while retries < max_retries: + try: + with urllib.request.urlopen(req, timeout=5) as response: + if response.status == 200: + return True + except (urllib.error.URLError, TimeoutError): + print( + f"{url} is unreachable, trying again in 5 sec... ({retries + 1}/{max_retries})", + flush=True, + ) + + time.sleep(5) + retries += 1 + + print("Error: Could not connect to qBittorrent after maximum retries.", flush=True) + exit(1) + + +def main(): + base_url = f"http://{args.host}:{args.host_port}" + print("Establishing connection with qBittorrent...", flush=True) + ping_until_connected(base_url) + print("Connection established", flush=True) + + print(f"Setting qBittorrent port settings ({args.port})...", flush=True) + + prefs_data = urllib.parse.urlencode( + {"json": json.dumps({"listen_port": int(args.port)})} + ).encode("utf-8") + prefs_req = urllib.request.Request( + f"{base_url}/api/v2/app/setPreferences", data=prefs_data + ) + + try: + with urllib.request.urlopen(prefs_req, timeout=10) as response: + if response.status == 200: + print("Port has been set!", flush=True) + except urllib.error.HTTPError as e: + print( + f"HTTP Error {e.code}: Failed to set port. (Is auth bypass enabled in qBittorrent?)", + flush=True, + ) + + +if __name__ == "__main__": + main() diff --git a/seedbox.py b/seedbox.py new file mode 100644 index 0000000..efe73c3 --- /dev/null +++ b/seedbox.py @@ -0,0 +1,201 @@ +# seedbox.py + +import argparse +import re +import urllib.parse +import urllib.request +from pathlib import Path + +from trackers import trackers + +parser = argparse.ArgumentParser( + prog="Unit3D seedbox manager", + description="Automagically add or remove seedbox entries on any Unit3D", + usage=f"{Path(__file__).name} tracker username [ip] [options] ", + epilog="For bugs/errors, please DM me on FnP or make an issue on GitHub", +) + +parser.add_argument("tracker", type=str, help="Tracker") +parser.add_argument("username", type=str, help="Username") +parser.add_argument("ip", nargs="?", type=str, help="IP of the seedbox") +parser.add_argument( + "--print", + default=False, + action=argparse.BooleanOptionalAction, + help="Prints list of seedboxes", +) +parser.add_argument( + "--seedbox-name", + type=str, + default="PikminBox", + help="Name of the seedbox (default: %(default)s)", +) +parser.add_argument( + "--delete", + default=False, + action=argparse.BooleanOptionalAction, + help="Deletes all old seedbox entries", +) +parser.add_argument( + "--cookie-file", + type=Path, + default=Path(__file__).parent.joinpath("unit3d.cookies"), + help="Cookies to be used", +) +parsed_args = parser.parse_args() + +COOKIE_HEADER = "" + + +def generateHeaders(host: str) -> dict: + headers = { + "Host": host, + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.5", + "Origin": f"https://{host}", + "DNT": "1", + "Sec-GPC": "1", + "Connection": "keep-alive", + "Upgrade-Insecure-Requests": "1", + } + if COOKIE_HEADER: + headers["Cookie"] = COOKIE_HEADER + return headers + + +def parseTracker(tracker: str) -> dict: + for t in trackers: + if tracker.lower() in [x.lower() for x in t.values()]: + return t + print(f'No tracker in database matched "{tracker}"') + exit(1) + + +def parseCookieFile(file: Path, domain: str) -> None: + global COOKIE_HEADER + with open(file, "r") as f: + for line in f: + if not re.match(r"^\#", line) and not re.match(r"^\s*$", line): + lineFields = line.strip().split("\t") + if domain in lineFields[0]: + COOKIE_HEADER = f"{lineFields[5]}={lineFields[6]}" + return + print(f"No remember_web_* cookie was found in {file}") + exit(1) + + +def getSeedboxes(domain: str) -> tuple[list[dict], str]: + try: + req = urllib.request.Request( + f"https://{domain}/users/{parsed_args.username}/seedboxes", + headers=generateHeaders(domain), + ) + with urllib.request.urlopen(req) as resp: + html = resp.read().decode("utf-8") + + token_match = re.search( + r'\s*(.*?)\s*\s*\s*(.*?)\s*.*?action="[^"]+/seedboxes/(\d+)".*?name="_token"\s+value="([^"]+)"', + re.DOTALL | re.IGNORECASE, + ) + + seedboxes = [ + { + "name": m.group(1).strip(), + "ip": m.group(2).strip(), + "id": m.group(3), + "token": m.group(4), + } + for m in pattern.finditer(html) + ] + + return seedboxes, add_token + + except Exception as e: + print(f"Error while getting seedboxes: {e}", flush=True) + exit(1) + + +def addSeedbox(domain: str, token: str) -> bool: + try: + data = urllib.parse.urlencode( + { + "_token": token, + "name": parsed_args.seedbox_name, + "ip": parsed_args.ip, + } + ).encode("utf-8") + + req = urllib.request.Request( + f"https://{domain}/users/{parsed_args.username}/seedboxes", + data=data, + headers=generateHeaders(domain), + ) + with urllib.request.urlopen(req) as resp: + html = resp.read().decode("utf-8") + + error_match = re.search( + r']*id="ERROR_COPY"[^>]*>(.*?)', + html, + re.DOTALL | re.IGNORECASE, + ) + if error_match: + clean_error = re.sub(r"<[^>]+>", "", error_match.group(1)) + error_msg = " ".join( + [x.strip() for x in clean_error.split("\n") if x.strip()] + ) + raise Exception(error_msg) + + return True + + except Exception as e: + print("Failed to add seedbox", flush=True) + print(f"Error: {e}", flush=True) + return False + + +def main(): + tracker = parseTracker(parsed_args.tracker) + domain = tracker["domain"] + parseCookieFile(parsed_args.cookie_file, domain) + + seedboxes, add_token = getSeedboxes(domain) + + if parsed_args.print: + print(f"Tracker: {tracker['name']}") + print(f"\n| {'Name':<25}{'IP':<20}{'ID':<5}") + print("| " + "-" * 53) + for sbox in seedboxes: + print(f"| {sbox['name']:<25}{sbox['ip']:<20}{sbox['id']:<5}") + print() + + if parsed_args.delete: + for sbox in seedboxes: + print(f'Deleting Seedbox: "{sbox["name"]}" ({sbox["ip"]})', flush=True) + data = urllib.parse.urlencode( + {"_token": sbox["token"], "_method": "DELETE"} + ).encode("utf-8") + req = urllib.request.Request( + f"https://{domain}/users/{parsed_args.username}/seedboxes/{sbox['id']}", + data=data, + headers=generateHeaders(domain), + ) + urllib.request.urlopen(req) + + if parsed_args.ip: + if addSeedbox(domain, add_token): + print( + f"Successfully added Seedbox \"{parsed_args.seedbox_name}\" ({parsed_args.ip}) to {tracker['name']}", + flush=True, + ) + + +if __name__ == "__main__": + main() diff --git a/trackers.py b/trackers.py new file mode 100644 index 0000000..881871f --- /dev/null +++ b/trackers.py @@ -0,0 +1,11 @@ +# trackers.py + + +trackers = [ + {"name": "FearNoPeer", "abbr": "FNP", "domain": "fearnopeer.com"}, + {"name": "SeedPool", "abbr": "SP", "domain": "seedpool.org"}, + {"name": "Rastastugan", "abbr": "RS", "domain": "rastastugan.org"}, + {"name": "ULCX", "abbr": "ULCX", "domain": "upload.cx"}, + {"name": "Darkpeers", "abbr": "DPS", "domain": "darkpeers.org"}, + {"name": "Luminarr", "abbr": "LUM", "domain": "luminarr.me"}, +]