From ae0fbb7f2396e885f9ff4da039c1430f3b88bcc2 Mon Sep 17 00:00:00 2001 From: Kusanagi Date: Thu, 19 Mar 2026 01:21:06 +0100 Subject: [PATCH 1/7] Add Docker stack support for NAS deployments --- .dockerignore | 11 +++++ .gitignore | 11 ++++- Dockerfile | 31 ++++++++++++++ README.md | 100 +++++++++++++++++++++++++++++++++++++++++++++ common/settings.py | 39 ++++++++++-------- docker-compose.yml | 17 ++++++++ pyproject.toml | 2 +- 7 files changed, 191 insertions(+), 20 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f46f464 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitignore +.venv +__pycache__ +*.pyc +*.pyo +*.pyd +.pytest_cache +tests +Docker +docker-data diff --git a/.gitignore b/.gitignore index e224774..456d8e2 100644 --- a/.gitignore +++ b/.gitignore @@ -22,10 +22,19 @@ unti3dupManyx/ # Dossier de configuration local (normalement dans ~/) Unit3Dup_config/ +docker-data/ +config/ # Config personnelle (ne jamais committer) *.json !pyproject.toml +.env +.env.* +*.secret +*.secrets +*.key +*.pem +*.env # Base de données locale *.db @@ -35,4 +44,4 @@ Unit3Dup_config/ # IDE .idea/ -.vscode/ \ No newline at end of file +.vscode/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..953a734 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +FROM python:3.11-slim + +ENV PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + UNIT3DUP_CONFIG_ROOT=/config \ + HOME=/tmp/unit3dup + +WORKDIR /app + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + build-essential \ + ffmpeg \ + mediainfo \ + libmediainfo0v5 \ + poppler-utils \ + p7zip-full \ + unrar-free && \ + rm -rf /var/lib/apt/lists/* + +COPY requirements.txt pyproject.toml README.md ./ +COPY common ./common +COPY unit3dup ./unit3dup +COPY view ./view + +RUN pip install --upgrade pip && \ + pip install . + +RUN mkdir -p /config /watch /done /data "$HOME" + +ENTRYPOINT ["unit3dup"] diff --git a/README.md b/README.md index 06a6b58..38388ba 100644 --- a/README.md +++ b/README.md @@ -154,3 +154,103 @@ pip install -e . ## Projet original Ce fork est basé sur [Unit3Dup](https://github.com/31December99/Unit3Dup) — licence MIT. + +--- + +## Docker / NAS + +Une stack Docker prête à l'emploi est disponible à la racine du dépôt avec : + +- `Dockerfile` +- `docker-compose.yml` +- `.dockerignore` + +Le conteneur utilise ce fork localement et stocke sa configuration dans `/config` via la variable d'environnement `UNIT3DUP_CONFIG_ROOT`. + +### Volumes par défaut + +Le `docker-compose.yml` monte ces dossiers : + +- `./docker-data/config` → `/config` +- `./docker-data/watch` → `/watch` +- `./docker-data/done` → `/done` +- `./docker-data/media` → `/data` + +Sur un NAS, remplace de préférence ces chemins par tes partages absolus, par exemple : + +```yaml +volumes: + - /volume1/docker/unit3dup/config:/config + - /volume1/torrents/watch:/watch + - /volume1/torrents/done:/done + - /volume1/media:/data +``` + +### 1. Construire l'image + +```bash +docker compose build +``` + +### 2. Générer la configuration initiale + +Lance une première fois l'outil pour créer `/config/Unit3Dbot.json` : + +```bash +docker compose run --rm unit3dup --help +``` + +### 3. Éditer la configuration + +Modifie ensuite `Unit3Dbot.json` dans ton dossier `config` et adapte au minimum : + +```json +"WATCHER_PATH": "/watch", +"WATCHER_DESTINATION_PATH": "/done" +``` + +Renseigne aussi : + +- `Gemini_URL` +- `Gemini_APIKEY` +- `Gemini_PID` +- `TMDB_APIKEY` +- `IMGBB_KEY` + +Si tu utilises un client torrent externe sur le NAS, pense également à corriger : + +- `QBIT_HOST` / `QBIT_PORT` +- ou `TRASM_HOST` / `TRASM_PORT` +- ou `RTORR_HOST` / `RTORR_PORT` + +### 4. Lancer le watcher + +```bash +docker compose up -d +``` + +Le service démarre avec la commande `-watcher`. + +### 5. Lancer un upload manuel + +Pour envoyer un fichier déjà présent dans le volume `/data` : + +```bash +docker compose run --rm unit3dup -u /data/mon_fichier.mkv +``` + +Pour scanner un dossier : + +```bash +docker compose run --rm unit3dup -scan /data/mon_dossier +``` + +### Permissions NAS + +Le compose utilise : + +```yaml +user: "${PUID:-1000}:${PGID:-1000}" +``` + +Adapte `PUID` et `PGID` à l'utilisateur de ton NAS si besoin pour éviter les problèmes d'accès sur les partages. diff --git a/common/settings.py b/common/settings.py index dc604df..1cea9b7 100644 --- a/common/settings.py +++ b/common/settings.py @@ -15,23 +15,26 @@ config_file = "Unit3Dbot.json" version = "0.8.21" -if os.name == "nt": - PW_TORRENT_ARCHIVE_PATH: Path = Path(os.getenv("LOCALAPPDATA", ".")) / "Unit3Dup_config" / "pw_torrent_archive" - PW_DOWNLOAD_PATH: Path = Path(os.getenv("LOCALAPPDATA", ".")) / "Unit3Dup_config" / "pw_download" - WATCHER_DESTINATION_PATH: Path = Path(os.getenv("LOCALAPPDATA", ".")) / "Unit3Dup_config" / "watcher_destination_path" - WATCHER_PATH: Path = Path(os.getenv("LOCALAPPDATA", ".")) / "Unit3Dup_config" / "watcher_path" - CACHE_PATH: Path = Path(os.getenv("LOCALAPPDATA", ".")) / "Unit3Dup_config" / "cache_path" - TORRENT_ARCHIVE_PATH: Path = Path(os.getenv("LOCALAPPDATA", ".")) / "Unit3Dup_config" / "torrent_archive_path" - DEFAULT_JSON_PATH: Path = Path(os.getenv("LOCALAPPDATA", ".")) / "Unit3Dup_config" / f"{config_file}" - -else: - PW_TORRENT_ARCHIVE_PATH: Path = Path.home() / "Unit3Dup_config" / "pw_torrent_archive" - PW_DOWNLOAD_PATH: Path = Path.home() / "Unit3Dup_config" / "pw_download" - WATCHER_DESTINATION_PATH: Path = Path.home() / "Unit3Dup_config" / "watcher_destination_path" - WATCHER_PATH: Path = Path.home() / "Unit3Dup_config" / "watcher_path" - CACHE_PATH: Path = Path.home() / "Unit3Dup_config" / "cache_path" - TORRENT_ARCHIVE_PATH: Path = Path.home() / "Unit3Dup_config" / "torrent_archive_path" - DEFAULT_JSON_PATH: Path = Path.home() / "Unit3Dup_config" / f"{config_file}" +def get_config_root() -> Path: + """Allow container/runtime overrides while preserving historical defaults.""" + config_root_override = os.getenv("UNIT3DUP_CONFIG_ROOT") + if config_root_override: + return Path(config_root_override).expanduser() + + if os.name == "nt": + return Path(os.getenv("LOCALAPPDATA", ".")) / "Unit3Dup_config" + + return Path.home() / "Unit3Dup_config" + + +CONFIG_ROOT: Path = get_config_root() +PW_TORRENT_ARCHIVE_PATH: Path = CONFIG_ROOT / "pw_torrent_archive" +PW_DOWNLOAD_PATH: Path = CONFIG_ROOT / "pw_download" +WATCHER_DESTINATION_PATH: Path = CONFIG_ROOT / "watcher_destination_path" +WATCHER_PATH: Path = CONFIG_ROOT / "watcher_path" +CACHE_PATH: Path = CONFIG_ROOT / "cache_path" +TORRENT_ARCHIVE_PATH: Path = CONFIG_ROOT / "torrent_archive_path" +DEFAULT_JSON_PATH: Path = CONFIG_ROOT / f"{config_file}" def get_default_path(field: str)-> str: @@ -898,4 +901,4 @@ def aim(self, line: int, col: int): print(f"{Ccolors.WARNING} {cursor}{Ccolors.ENDC}") print(f"{line_context2}") else: - print("Line number is out of range !") \ No newline at end of file + print("Line number is out of range !") diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..647ebd1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + unit3dup: + build: + context: . + container_name: unit3dup + restart: unless-stopped + user: "${PUID:-1000}:${PGID:-1000}" + environment: + TZ: "${TZ:-Europe/Paris}" + UNIT3DUP_CONFIG_ROOT: /config + HOME: /tmp/unit3dup + volumes: + - ./docker-data/config:/config + - ./docker-data/watch:/watch + - ./docker-data/done:/done + - ./docker-data/media:/data + command: ["-watcher"] diff --git a/pyproject.toml b/pyproject.toml index c48cfc9..46a71e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ dynamic = ["dependencies"] name = "Unit3Dup" version = "0.8.21" description = "An uploader for the Unit3D torrent tracker" -readme = "README.rst" +readme = "README.md" requires-python = ">=3.10" license = "MIT" From e7c52ad981a1da437e360c72dc6c07af5a5a30e6 Mon Sep 17 00:00:00 2001 From: Kusanagi Date: Thu, 19 Mar 2026 01:52:39 +0100 Subject: [PATCH 2/7] Fix container cache and non-root runtime support --- .gitignore | 1 + Dockerfile | 3 ++- common/external_services/sessions/session.py | 7 +++++++ 3 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 456d8e2..ff8c09f 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ unti3dupManyx/ Unit3Dup_config/ docker-data/ config/ +docker-compose.override.yml # Config personnelle (ne jamais committer) *.json diff --git a/Dockerfile b/Dockerfile index 953a734..34eccac 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,6 +26,7 @@ COPY view ./view RUN pip install --upgrade pip && \ pip install . -RUN mkdir -p /config /watch /done /data "$HOME" +RUN mkdir -p /config /watch /done /data "$HOME" && \ + chmod 777 "$HOME" ENTRYPOINT ["unit3dup"] diff --git a/common/external_services/sessions/session.py b/common/external_services/sessions/session.py index 020f41a..528ceb4 100644 --- a/common/external_services/sessions/session.py +++ b/common/external_services/sessions/session.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- import json +import os import httpx import diskcache as dc @@ -15,6 +16,12 @@ class MyHttp: """Class to handle HTTP requests""" def __init__(self, headers: dict, cache_dir: str = "http_cache"): + if not os.path.isabs(cache_dir): + cache_root = os.getenv("UNIT3DUP_HTTP_CACHE_DIR") or os.path.join( + os.path.expanduser("~"), cache_dir + ) + cache_dir = cache_root + self.session = httpx.Client( timeout=httpx.Timeout(30), headers=headers, verify=False ) From c47e50903687206dc25944b8d1e9e1e4dd66e0d9 Mon Sep 17 00:00:00 2001 From: Kusanagi Date: Thu, 19 Mar 2026 01:55:43 +0100 Subject: [PATCH 3/7] Rewrite README for Docker stack usage --- README.md | 271 ++++++++++++++++++------------------------------------ 1 file changed, 91 insertions(+), 180 deletions(-) diff --git a/README.md b/README.md index 38388ba..6a6fac2 100644 --- a/README.md +++ b/README.md @@ -1,256 +1,167 @@ -# Unit3Dup — Fork G3MINI +# Unit3Dup-G3MINI Stack -Fork de [Unit3Dup](https://github.com/31December99/Unit3Dup) adapté pour **G3MINI Tracker**. +Docker stack for [Unit3Dup-G3MINI](https://github.com/lantiumBot/Unit3Dup-G3MINI), ready for local Docker use and easy deployment on NAS, Portainer, or Dockhand. -Ce fork ajoute la normalisation automatique des noms de release selon les conventions du tracker, la détection du flag `personal_release` par tag d'équipe, et le nettoyage automatique des fichiers `.nfo` orphelins. +This repository focuses on containerized deployment: ---- +- `Dockerfile` +- `docker-compose.yml` +- persistent `/config`, `/watch`, `/done`, `/data` volumes +- non-root runtime support +- container-safe HTTP cache handling -## Fonctionnalités ajoutées +## What This Repo Is For -- **Normalisation des noms de release** : les noms sont automatiquement reformatés selon les conventions G3MINI (`Titre.Année.Langue.Résolution.HDR.Source.Audio.Codec-TEAM`) -- **Détection `personal_release`** : si le tag de la release (ex: `-KFL`) correspond à un tag configuré dans `TAGS_TEAM`, le champ `personal_release` est automatiquement coché à l'upload -- **Nettoyage des `.nfo` orphelins** : le watcher supprime automatiquement les fichiers `.nfo` isolés après traitement +This repo is meant for users who want to run Unit3Dup-G3MINI with Docker instead of installing Python and dependencies manually. ---- +It keeps the original application code and adds the Docker pieces needed to: -## Installation +- build the container locally +- run the watcher in a persistent stack +- deploy more easily on a NAS -### Prérequis +## Included Files -```bash -sudo apt install ffmpeg python3 python3-pip python3-venv git -``` - -Il vous faut la version 3.13.5 de Python3 +- `Dockerfile` +- `docker-compose.yml` +- `.dockerignore` -### Cloner le repo +The container stores its config in `/config` through `UNIT3DUP_CONFIG_ROOT=/config`. -```bash -git clone https://github.com/lantiumBot/Unit3Dup-G3MINI ~/unit3dup -cd ~/unit3dup -``` +## Default Volumes -### Créer un environnement virtuel et installer +The default compose file uses: -```bash -python3 -m venv .venv -source .venv/bin/activate -pip install -e . -``` +- `./docker-data/config` -> `/config` +- `./docker-data/watch` -> `/watch` +- `./docker-data/done` -> `/done` +- `./docker-data/media` -> `/data` -L'option `-e` (editable) permet de recevoir les mises à jour du fork simplement avec un `git pull`, sans réinstaller. +For a NAS, replace them with your real shared folders. -### Vérifier l'installation +Example: -```bash -unit3dup --help -``` - -Si la commande n'est pas trouvée, active d'abord le venv manuellement : - -```bash -source .venv/bin/activate -unit3dup --help +```yaml +volumes: + - /volume1/docker/unit3dup/config:/config + - /volume1/torrents/watch:/watch + - /volume1/torrents/done:/done + - /volume1/media:/data ``` ---- - -### Wrapper (optionnel mais recommandé) - -Le wrapper permet d'utiliser `unit3dup` depuis n'importe où **sans activer le venv manuellement** à chaque fois. Il détecte automatiquement son emplacement — peu importe où tu as cloné le repo. +## Quick Start -Rends-le exécutable et crée le symlink : +### 1. Build the image ```bash -chmod +x ~/unit3dup/unit3dup-wrapper.sh -sudo ln -s ~/unit3dup/unit3dup-wrapper.sh /usr/local/bin/unit3dup -``` - -Vérifie que ça fonctionne : - -```bash -which unit3dup -unit3dup --help +docker compose build ``` ---- - -## Configuration - -### Étape 1 — Générer la configuration initiale - -Au premier lancement, unit3dup crée automatiquement le dossier `~/Unit3Dup_config/` avec un fichier `Unit3Dbot.json` pré-rempli : +### 2. Generate the initial config ```bash -unit3dup --help +docker compose run --rm unit3dup --help ``` -### Étape 2 — Remplir la configuration - -```bash -nano ~/Unit3Dup_config/Unit3Dbot.json -``` +This creates: -Les champs essentiels à renseigner : +- `/config/Unit3Dbot.json` +- cache and archive folders inside `/config` -| Champ | Description | Requis | -|---|---|---| -| `Gemini_URL` | URL de G3MINI | ✅ | -| `Gemini_APIKEY` | Clé API (profil G3MINI) | ✅ | -| `Gemini_PID` | Ton passkey | ✅ | -| `TMDB_APIKEY` | Clé gratuite sur [themoviedb.org](https://www.themoviedb.org/settings/api) | ✅ | -| `WATCHER_PATH` | Chemin vers ton dossier de watch (la où sont les releases à upload) | ✅ | -| `WATCHER_DESTINATION_PATH` | Chemin de destination des releases après l'upload | ✅ | -| `IMGBB_KEY` | Clé gratuite sur [imgbb.com](https://imgbb.com) pour les screenshots | ✅ | +### 3. Edit `Unit3Dbot.json` -> **Permissions :** Assure-toi que l'utilisateur qui lance unit3dup a bien les droits en lecture sur `WATCHER_PATH` et en écriture sur `WATCHER_DESTINATION_PATH`. Si ces dossiers sont sur un montage NFS ou un partage réseau, vérifie aussi que le montage est actif avant de lancer le watcher. +Minimum required fields: -### Étape 3 — Ajouter tes tags d'équipe +- `Gemini_URL` +- `Gemini_APIKEY` +- `Gemini_PID` +- `TMDB_APIKEY` +- `IMGBB_KEY` +- `WATCHER_PATH` +- `WATCHER_DESTINATION_PATH` -La section `uploader_tag` n'est **pas générée automatiquement**, il faut l'ajouter manuellement dans le JSON : +For Docker, the usual values are: ```json -"uploader_tag": { - "TAGS_TEAM": ["MONTAG"] -} +"WATCHER_PATH": "/watch", +"WATCHER_DESTINATION_PATH": "/done" ``` -Si ta release se termine par `-MONTAG`, le champ `personal_release` sera automatiquement activé à l'upload. Tu peux mettre plusieurs tags dans le tableau. - ---- - -## Utilisation - -```bash -# Uploader un fichier -unit3dup -u /chemin/vers/fichier.mkv - -# Uploader un dossier entier -unit3dup -f /chemin/vers/dossier +If you use an external torrent client, also adjust the client section: -# Scanner un dossier -unit3dup -scan /chemin/vers/dossier -``` - ---- +- `QBIT_HOST` / `QBIT_PORT` +- or `TRASM_HOST` / `TRASM_PORT` +- or `RTORR_HOST` / `RTORR_PORT` -## Mise à jour +### 4. Start the watcher ```bash -cd ~/unit3dup -git pull +docker compose up -d ``` -Pas besoin de réinstaller grâce au mode `-e`. Si des nouvelles dépendances ont été ajoutées : +### 5. Read logs ```bash -source .venv/bin/activate -pip install -e . +docker compose logs -f ``` ---- - -## Projet original - -Ce fork est basé sur [Unit3Dup](https://github.com/31December99/Unit3Dup) — licence MIT. - ---- - -## Docker / NAS - -Une stack Docker prête à l'emploi est disponible à la racine du dépôt avec : - -- `Dockerfile` -- `docker-compose.yml` -- `.dockerignore` +## Manual Commands -Le conteneur utilise ce fork localement et stocke sa configuration dans `/config` via la variable d'environnement `UNIT3DUP_CONFIG_ROOT`. +Scan a folder: -### Volumes par défaut - -Le `docker-compose.yml` monte ces dossiers : - -- `./docker-data/config` → `/config` -- `./docker-data/watch` → `/watch` -- `./docker-data/done` → `/done` -- `./docker-data/media` → `/data` - -Sur un NAS, remplace de préférence ces chemins par tes partages absolus, par exemple : - -```yaml -volumes: - - /volume1/docker/unit3dup/config:/config - - /volume1/torrents/watch:/watch - - /volume1/torrents/done:/done - - /volume1/media:/data +```bash +docker compose run --rm unit3dup -scan /data/my_folder ``` -### 1. Construire l'image +Upload a file: ```bash -docker compose build +docker compose run --rm unit3dup -u /data/my_file.mkv ``` -### 2. Générer la configuration initiale - -Lance une première fois l'outil pour créer `/config/Unit3Dbot.json` : +Generate config only: ```bash docker compose run --rm unit3dup --help ``` -### 3. Éditer la configuration - -Modifie ensuite `Unit3Dbot.json` dans ton dossier `config` et adapte au minimum : +## Portainer / Dockhand -```json -"WATCHER_PATH": "/watch", -"WATCHER_DESTINATION_PATH": "/done" -``` +This repository currently ships with a compose file using `build:`. -Renseigne aussi : +That is the simplest setup for: -- `Gemini_URL` -- `Gemini_APIKEY` -- `Gemini_PID` -- `TMDB_APIKEY` -- `IMGBB_KEY` +- local Docker +- Portainer environments that support building from a project folder +- NAS testing before publishing a prebuilt image -Si tu utilises un client torrent externe sur le NAS, pense également à corriger : +If you prefer an `image:`-only stack later, you can publish the built image and replace `build:` with your registry image tag. -- `QBIT_HOST` / `QBIT_PORT` -- ou `TRASM_HOST` / `TRASM_PORT` -- ou `RTORR_HOST` / `RTORR_PORT` +## Permissions -### 4. Lancer le watcher +The compose file uses: -```bash -docker compose up -d +```yaml +user: "${PUID:-1000}:${PGID:-1000}" ``` -Le service démarre avec la commande `-watcher`. - -### 5. Lancer un upload manuel +If your NAS uses a different user or group ID, set `PUID` and `PGID` accordingly. -Pour envoyer un fichier déjà présent dans le volume `/data` : +## Security Notes -```bash -docker compose run --rm unit3dup -u /data/mon_fichier.mkv -``` +- Do not commit your `Unit3Dbot.json` +- Do not put API keys in `docker-compose.yml` +- Do not commit `.env` files with secrets +- Keep personal paths in local overrides only -Pour scanner un dossier : +This repository ignores local config and test overrides by default. -```bash -docker compose run --rm unit3dup -scan /data/mon_dossier -``` +## Upstream Project -### Permissions NAS +Original project: -Le compose utilise : +- [Unit3Dup-G3MINI](https://github.com/lantiumBot/Unit3Dup-G3MINI) -```yaml -user: "${PUID:-1000}:${PGID:-1000}" -``` +Base project: -Adapte `PUID` et `PGID` à l'utilisateur de ton NAS si besoin pour éviter les problèmes d'accès sur les partages. +- [Unit3Dup](https://github.com/31December99/Unit3Dup) From dceb414a931d746d17ecafc8f1325c73a73ed4a3 Mon Sep 17 00:00:00 2001 From: Kusanagi Date: Thu, 19 Mar 2026 02:14:13 +0100 Subject: [PATCH 4/7] Refine README Docker documentation --- README.md | 271 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 180 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index 6a6fac2..1c6df90 100644 --- a/README.md +++ b/README.md @@ -1,167 +1,256 @@ -# Unit3Dup-G3MINI Stack +# Unit3Dup — Fork G3MINI -Docker stack for [Unit3Dup-G3MINI](https://github.com/lantiumBot/Unit3Dup-G3MINI), ready for local Docker use and easy deployment on NAS, Portainer, or Dockhand. +Fork de [Unit3Dup](https://github.com/31December99/Unit3Dup) adapté pour **G3MINI Tracker**. -This repository focuses on containerized deployment: +Ce fork ajoute la normalisation automatique des noms de release selon les conventions du tracker, la détection du flag `personal_release` par tag d'équipe, et le nettoyage automatique des fichiers `.nfo` orphelins. -- `Dockerfile` -- `docker-compose.yml` -- persistent `/config`, `/watch`, `/done`, `/data` volumes -- non-root runtime support -- container-safe HTTP cache handling +--- -## What This Repo Is For +## Fonctionnalités ajoutées -This repo is meant for users who want to run Unit3Dup-G3MINI with Docker instead of installing Python and dependencies manually. +- **Normalisation des noms de release** : les noms sont automatiquement reformatés selon les conventions G3MINI (`Titre.Année.Langue.Résolution.HDR.Source.Audio.Codec-TEAM`) +- **Détection `personal_release`** : si le tag de la release (ex: `-KFL`) correspond à un tag configuré dans `TAGS_TEAM`, le champ `personal_release` est automatiquement coché à l'upload +- **Nettoyage des `.nfo` orphelins** : le watcher supprime automatiquement les fichiers `.nfo` isolés après traitement -It keeps the original application code and adds the Docker pieces needed to: +--- -- build the container locally -- run the watcher in a persistent stack -- deploy more easily on a NAS +## Installation -## Included Files +### Prérequis -- `Dockerfile` -- `docker-compose.yml` -- `.dockerignore` +```bash +sudo apt install ffmpeg python3 python3-pip python3-venv git +``` -The container stores its config in `/config` through `UNIT3DUP_CONFIG_ROOT=/config`. +Il vous faut la version 3.13.5 de Python3 -## Default Volumes +### Cloner le repo -The default compose file uses: +```bash +git clone https://github.com/lantiumBot/Unit3Dup-G3MINI ~/unit3dup +cd ~/unit3dup +``` -- `./docker-data/config` -> `/config` -- `./docker-data/watch` -> `/watch` -- `./docker-data/done` -> `/done` -- `./docker-data/media` -> `/data` +### Créer un environnement virtuel et installer -For a NAS, replace them with your real shared folders. +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -e . +``` -Example: +L'option `-e` (editable) permet de recevoir les mises à jour du fork simplement avec un `git pull`, sans réinstaller. -```yaml -volumes: - - /volume1/docker/unit3dup/config:/config - - /volume1/torrents/watch:/watch - - /volume1/torrents/done:/done - - /volume1/media:/data +### Vérifier l'installation + +```bash +unit3dup --help +``` + +Si la commande n'est pas trouvée, active d'abord le venv manuellement : + +```bash +source .venv/bin/activate +unit3dup --help ``` -## Quick Start +--- -### 1. Build the image +### Wrapper (optionnel mais recommandé) + +Le wrapper permet d'utiliser `unit3dup` depuis n'importe où **sans activer le venv manuellement** à chaque fois. Il détecte automatiquement son emplacement — peu importe où tu as cloné le repo. + +Rends-le exécutable et crée le symlink : ```bash -docker compose build +chmod +x ~/unit3dup/unit3dup-wrapper.sh +sudo ln -s ~/unit3dup/unit3dup-wrapper.sh /usr/local/bin/unit3dup ``` -### 2. Generate the initial config +Vérifie que ça fonctionne : ```bash -docker compose run --rm unit3dup --help +which unit3dup +unit3dup --help ``` -This creates: +--- -- `/config/Unit3Dbot.json` -- cache and archive folders inside `/config` +## Configuration -### 3. Edit `Unit3Dbot.json` +### Étape 1 — Générer la configuration initiale -Minimum required fields: +Au premier lancement, unit3dup crée automatiquement le dossier `~/Unit3Dup_config/` avec un fichier `Unit3Dbot.json` pré-rempli : -- `Gemini_URL` -- `Gemini_APIKEY` -- `Gemini_PID` -- `TMDB_APIKEY` -- `IMGBB_KEY` -- `WATCHER_PATH` -- `WATCHER_DESTINATION_PATH` +```bash +unit3dup --help +``` + +### Étape 2 — Remplir la configuration + +```bash +nano ~/Unit3Dup_config/Unit3Dbot.json +``` + +Les champs essentiels à renseigner : + +| Champ | Description | Requis | +|---|---|---| +| `Gemini_URL` | URL de G3MINI | ✅ | +| `Gemini_APIKEY` | Clé API (profil G3MINI) | ✅ | +| `Gemini_PID` | Ton passkey | ✅ | +| `TMDB_APIKEY` | Clé gratuite sur [themoviedb.org](https://www.themoviedb.org/settings/api) | ✅ | +| `WATCHER_PATH` | Chemin vers ton dossier de watch (la où sont les releases à upload) | ✅ | +| `WATCHER_DESTINATION_PATH` | Chemin de destination des releases après l'upload | ✅ | +| `IMGBB_KEY` | Clé gratuite sur [imgbb.com](https://imgbb.com) pour les screenshots | ✅ | -For Docker, the usual values are: +> **Permissions :** Assure-toi que l'utilisateur qui lance unit3dup a bien les droits en lecture sur `WATCHER_PATH` et en écriture sur `WATCHER_DESTINATION_PATH`. Si ces dossiers sont sur un montage NFS ou un partage réseau, vérifie aussi que le montage est actif avant de lancer le watcher. + +### Étape 3 — Ajouter tes tags d'équipe + +La section `uploader_tag` n'est **pas générée automatiquement**, il faut l'ajouter manuellement dans le JSON : ```json -"WATCHER_PATH": "/watch", -"WATCHER_DESTINATION_PATH": "/done" +"uploader_tag": { + "TAGS_TEAM": ["MONTAG"] +} ``` -If you use an external torrent client, also adjust the client section: +Si ta release se termine par `-MONTAG`, le champ `personal_release` sera automatiquement activé à l'upload. Tu peux mettre plusieurs tags dans le tableau. -- `QBIT_HOST` / `QBIT_PORT` -- or `TRASM_HOST` / `TRASM_PORT` -- or `RTORR_HOST` / `RTORR_PORT` +--- -### 4. Start the watcher +## Utilisation ```bash -docker compose up -d +# Uploader un fichier +unit3dup -u /chemin/vers/fichier.mkv + +# Uploader un dossier entier +unit3dup -f /chemin/vers/dossier + +# Scanner un dossier +unit3dup -scan /chemin/vers/dossier +``` + +--- + +## Mise à jour + +```bash +cd ~/unit3dup +git pull ``` -### 5. Read logs +Pas besoin de réinstaller grâce au mode `-e`. Si des nouvelles dépendances ont été ajoutées : ```bash -docker compose logs -f +source .venv/bin/activate +pip install -e . ``` -## Manual Commands +--- -Scan a folder: +## Projet original -```bash -docker compose run --rm unit3dup -scan /data/my_folder +Ce fork est basé sur [Unit3Dup](https://github.com/31December99/Unit3Dup) — licence MIT. + +--- + +## Docker / NAS + +Une stack Docker prête à l'emploi est disponible à la racine du dépôt avec : + +- `Dockerfile` +- `docker-compose.yml` +- `.dockerignore` + +Le conteneur utilise ce fork localement et stocke sa configuration dans `/config` via la variable d'environnement `UNIT3DUP_CONFIG_ROOT`. + +### Volumes par défaut + +Le `docker-compose.yml` monte ces dossiers : + +- `./docker-data/config` -> `/config` +- `./docker-data/watch` -> `/watch` +- `./docker-data/done` -> `/done` +- `./docker-data/media` -> `/data` + +Sur un NAS, remplace de préférence ces chemins par tes partages absolus, par exemple : + +```yaml +volumes: + - /volume1/docker/unit3dup/config:/config + - /volume1/torrents/watch:/watch + - /volume1/torrents/done:/done + - /volume1/media:/data ``` -Upload a file: +### 1. Construire l'image ```bash -docker compose run --rm unit3dup -u /data/my_file.mkv +docker compose build ``` -Generate config only: +### 2. Générer la configuration initiale + +Lance une première fois l'outil pour créer `/config/Unit3Dbot.json` : ```bash docker compose run --rm unit3dup --help ``` -## Portainer / Dockhand +### 3. Éditer la configuration -This repository currently ships with a compose file using `build:`. +Modifie ensuite `Unit3Dbot.json` dans ton dossier `config` et adapte au minimum : -That is the simplest setup for: +```json +"WATCHER_PATH": "/watch", +"WATCHER_DESTINATION_PATH": "/done" +``` -- local Docker -- Portainer environments that support building from a project folder -- NAS testing before publishing a prebuilt image +Renseigne aussi : + +- `Gemini_URL` +- `Gemini_APIKEY` +- `Gemini_PID` +- `TMDB_APIKEY` +- `IMGBB_KEY` -If you prefer an `image:`-only stack later, you can publish the built image and replace `build:` with your registry image tag. +Si tu utilises un client torrent externe sur le NAS, pense également à corriger : -## Permissions +- `QBIT_HOST` / `QBIT_PORT` +- ou `TRASM_HOST` / `TRASM_PORT` +- ou `RTORR_HOST` / `RTORR_PORT` -The compose file uses: +### 4. Lancer le watcher -```yaml -user: "${PUID:-1000}:${PGID:-1000}" +```bash +docker compose up -d ``` -If your NAS uses a different user or group ID, set `PUID` and `PGID` accordingly. +Le service démarre avec la commande `-watcher`. -## Security Notes +### 5. Lancer un upload manuel -- Do not commit your `Unit3Dbot.json` -- Do not put API keys in `docker-compose.yml` -- Do not commit `.env` files with secrets -- Keep personal paths in local overrides only +Pour envoyer un fichier déjà présent dans le volume `/data` : -This repository ignores local config and test overrides by default. +```bash +docker compose run --rm unit3dup -u /data/mon_fichier.mkv +``` -## Upstream Project +Pour scanner un dossier : -Original project: +```bash +docker compose run --rm unit3dup -scan /data/mon_dossier +``` + +### Permissions NAS -- [Unit3Dup-G3MINI](https://github.com/lantiumBot/Unit3Dup-G3MINI) +Le compose utilise : -Base project: +```yaml +user: "${PUID:-1000}:${PGID:-1000}" +``` -- [Unit3Dup](https://github.com/31December99/Unit3Dup) +Adapte `PUID` et `PGID` à l'utilisateur de ton NAS si besoin pour éviter les problèmes d'accès sur les partages. From f20e45204fd66d057bf65c0c3b62938ee23ebaeb Mon Sep 17 00:00:00 2001 From: Kusanagi Date: Thu, 19 Mar 2026 12:43:45 +0100 Subject: [PATCH 5/7] Move watcher items after successful upload --- unit3dup/bot.py | 50 ++++++++++++++++++++++-- unit3dup/media_manager/TorrentManager.py | 8 +++- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/unit3dup/bot.py b/unit3dup/bot.py index 4b7e48a..fecf3f7 100644 --- a/unit3dup/bot.py +++ b/unit3dup/bot.py @@ -3,6 +3,7 @@ from pathlib import Path import argparse import os +import shutil import time from unit3dup.media import Media @@ -100,10 +101,15 @@ def run(self) -> bool: # We want to reseed if self.cli.reseed: torrent_manager.reseed(trackers_name_list=self.trackers_name_list) + return True else: # otherwise run the torrents creations and the upload process - torrent_manager.run(trackers_name_list=self.trackers_name_list) - return True + upload_results = torrent_manager.run(trackers_name_list=self.trackers_name_list) + + if self.cli.noup: + return False + + return self._uploads_succeeded(upload_results) def watcher(self, duration: int, watcher_path: str, destination_path: str)-> bool: @@ -160,6 +166,15 @@ def watcher(self, duration: int, watcher_path: str, destination_path: str)-> bo custom_console.bot_warning_log( f"[Watcher] Upload failed/skipped, leaving in place -> {src}" ) + continue + + moved_to = self._move_to_destination(src=src, done_root=done_root) + if moved_to: + custom_console.bot_log(f"[Watcher] Moved to destination -> {moved_to}") + else: + custom_console.bot_warning_log( + f"[Watcher] Upload succeeded but move failed, leaving in place -> {src}" + ) # Nettoyer les fichiers .nfo isolés dans le dossier watcher après avoir traité tous les fichiers du cycle self._cleanup_orphaned_nfo_files(watcher_path) @@ -181,6 +196,35 @@ def watcher(self, duration: int, watcher_path: str, destination_path: str)-> bo custom_console.bot_log("Exiting...") return True + @staticmethod + def _uploads_succeeded(results) -> bool: + if not results: + return False + + return all(result.tracker_response and not result.tracker_message for result in results) + + @staticmethod + def _next_available_destination(target: Path) -> Path: + if not target.exists(): + return target + + counter = 1 + while True: + candidate = target.with_name(f"{target.name}_{counter}") + if not candidate.exists(): + return candidate + counter += 1 + + def _move_to_destination(self, src: Path, done_root: Path) -> Path | None: + target = self._next_available_destination(done_root / src.name) + + try: + shutil.move(str(src), str(target)) + return target + except Exception as exc: + custom_console.bot_warning_log(f"[Watcher] Failed to move '{src}' -> '{target}': {exc}") + return None + def _cleanup_orphaned_nfo_files(self, watcher_path: str) -> None: """ Supprime uniquement les fichiers .nfo isolés à la RACINE du dossier watcher. @@ -272,4 +316,4 @@ def ftp(self)-> None: exit(1) self.run() - return None \ No newline at end of file + return None diff --git a/unit3dup/media_manager/TorrentManager.py b/unit3dup/media_manager/TorrentManager.py index 87faa5e..2be0d47 100644 --- a/unit3dup/media_manager/TorrentManager.py +++ b/unit3dup/media_manager/TorrentManager.py @@ -68,7 +68,7 @@ def process(self, contents: list) -> None: content for content in contents if content.category == System.category_list.get(System.DOCUMENTARY) ] - def run(self, trackers_name_list: list): + def run(self, trackers_name_list: list) -> list[BittorrentData]: """ Args: @@ -80,6 +80,7 @@ def run(self, trackers_name_list: list): game_process_results: list[BittorrentData] = [] video_process_results: list[BittorrentData] = [] docu_process_results: list[BittorrentData] = [] + all_results: list[BittorrentData] = [] for selected_tracker in trackers_name_list: # Build the torrent file and upload each GAME to the tracker @@ -89,6 +90,7 @@ def run(self, trackers_name_list: list): game_process_results = game_manager.process(selected_tracker=selected_tracker, tracker_name_list=trackers_name_list, tracker_archive=self.tracker_archive) + all_results.extend(game_process_results) # Build the torrent file and upload each VIDEO to the trackers if self.videos: @@ -97,6 +99,7 @@ def run(self, trackers_name_list: list): video_process_results = video_manager.process(selected_tracker=selected_tracker, tracker_name_list=trackers_name_list, tracker_archive=self.tracker_archive) + all_results.extend(video_process_results) # Build the torrent file and upload each DOC to the tracker if self.doc and not self.cli.reseed: @@ -105,6 +108,7 @@ def run(self, trackers_name_list: list): docu_process_results = docu_manager.process(selected_tracker=selected_tracker, tracker_name_list=trackers_name_list, tracker_archive=self.tracker_archive) + all_results.extend(docu_process_results) # No seeding if self.cli.noseed or self.cli.noup: @@ -124,6 +128,8 @@ def run(self, trackers_name_list: list): custom_console.bot_log(f"Tracker '{selected_tracker}' Done.") custom_console.rule() + return all_results + custom_console.bot_log(f"Done.") custom_console.rule() From 964862dc3c8182abb7c78af0890f632f796ea1cf Mon Sep 17 00:00:00 2001 From: Kusanagi Date: Thu, 19 Mar 2026 14:34:58 +0100 Subject: [PATCH 6/7] Add optional web UI for Docker deployments --- README.md | 29 ++ docker-compose.yml | 25 ++ pyproject.toml | 1 + requirements.txt | 2 + unit3dup/web/main.py | 692 ++++++++++++++++++++++++++++++++++++--- unit3dup/web/web/main.py | 45 +-- 6 files changed, 704 insertions(+), 90 deletions(-) diff --git a/README.md b/README.md index 1c6df90..373df24 100644 --- a/README.md +++ b/README.md @@ -254,3 +254,32 @@ user: "${PUID:-1000}:${PGID:-1000}" ``` Adapte `PUID` et `PGID` à l'utilisateur de ton NAS si besoin pour éviter les problèmes d'accès sur les partages. + +### Petite interface web + +Une interface web minimale est aussi disponible pour piloter le dossier watcher sans passer par la CLI. + +Elle permet de : + +- lister le contenu du watcher +- lancer un dry-run sur un element +- lancer un upload sans seed ou avec seed +- consulter le statut et les logs du dernier job +- modifier les principaux champs du `Unit3Dbot.json` depuis une page `Settings` + +Le service web est optionnel et utilise le profil Compose `web` : + +```bash +docker compose --profile web up -d unit3dup-web +``` + +Puis ouvre : + +```text +http://localhost:8787 +``` + +Important : + +- n'utilise pas les actions manuelles de l'interface en meme temps que le conteneur watcher sur le meme dossier +- l'interface ne remplace pas le fichier `Unit3Dbot.json`, elle s'appuie dessus diff --git a/docker-compose.yml b/docker-compose.yml index 647ebd1..f920a01 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,3 +15,28 @@ services: - ./docker-data/done:/done - ./docker-data/media:/data command: ["-watcher"] + unit3dup-web: + profiles: ["web"] + build: + context: . + container_name: unit3dup-web + restart: unless-stopped + user: "${PUID:-1000}:${PGID:-1000}" + environment: + TZ: "${TZ:-Europe/Paris}" + UNIT3DUP_CONFIG_ROOT: /config + HOME: /tmp/unit3dup + UNIT3DUP_WEB_HOST: 0.0.0.0 + UNIT3DUP_WEB_PORT: 8787 + UNIT3DUP_LOCAL_CONFIG_PATH: ./docker-data/config + UNIT3DUP_LOCAL_WATCH_PATH: ./docker-data/watch + UNIT3DUP_LOCAL_DONE_PATH: ./docker-data/done + UNIT3DUP_LOCAL_DATA_PATH: ./docker-data/media + volumes: + - ./docker-data/config:/config + - ./docker-data/watch:/watch + - ./docker-data/done:/done + - ./docker-data/media:/data + ports: + - "${UNIT3DUP_WEB_PORT:-8787}:8787" + entrypoint: ["unit3dup-web"] diff --git a/pyproject.toml b/pyproject.toml index 46a71e3..3ef9434 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,5 +29,6 @@ dependencies = {file = ["requirements.txt"]} [project.scripts] unit3dup = "unit3dup.__main__:main" +unit3dup-web = "unit3dup.web.main:serve" diff --git a/requirements.txt b/requirements.txt index b3c0a57..07009cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,6 +17,8 @@ cinemagoer==2023.5.1 pathvalidate==3.2.3 bencode2==0.3.24 rtorrent-rpc==0.9.4 +fastapi>=0.115,<1.0 +uvicorn>=0.30,<1.0 diff --git a/unit3dup/web/main.py b/unit3dup/web/main.py index e0eb713..498183d 100644 --- a/unit3dup/web/main.py +++ b/unit3dup/web/main.py @@ -1,46 +1,646 @@ -from fastapi import FastAPI, APIRouter -from random import randint -from common.torrent_clients import TransmissionClient, QbittorrentClient, RTorrentClient -from common.command import CommandLine -from common.settings import Load,DEFAULT_JSON_PATH - -from unit3dup.torrent import View -from unit3dup import pvtTracker -from unit3dup.bot import Bot -from view import custom_console - - -import uvicorn -import argparse -from random import randint - -app = FastAPI() - -# Classe che gestisce gli endpoint -class WebApp: - def __init__(self, config: Load): - self.router = APIRouter() - self.numb = randint(0, 100) - self._setup_routes() - - def _setup_routes(self): - # Add the endpoints - self.router.add_api_route("/", self.root, methods=["GET"]) - self.router.add_api_route("/upload/{name}", self.upload, methods=["GET"]) - - async def root(self): - return {"message": f"Hello World {self.numb}"} - - async def upload(self, name: str): - - - - return {"message": f"Hello {name}, numb is {self.numb}"} - - -def web(): - web_app = WebApp(config=Load().load_config()) - app.include_router(web_app.router) - uvicorn.run("unit3dup.web.main:app", host="127.0.0.1", port=8000, reload=True) - - +from __future__ import annotations + +import contextlib +import html +import io +import json +import os +import sys +import threading +import time +import traceback +from dataclasses import dataclass, field +from pathlib import Path +from urllib.parse import parse_qs, quote + +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse, RedirectResponse + +from common.command import CommandLine +from common.settings import DEFAULT_JSON_PATH, Load +from unit3dup.bot import Bot + +app = FastAPI(title="Unit3Dup Web", docs_url=None, redoc_url=None) +WEB_SETTINGS_PATH = DEFAULT_JSON_PATH.parent / "unit3dup-web.json" + + +def _fmt_size(size: int) -> str: + units = ["B", "KB", "MB", "GB", "TB"] + value = float(size) + for unit in units: + if value < 1024 or unit == units[-1]: + return f"{value:.1f} {unit}" if unit != "B" else f"{int(value)} {unit}" + value /= 1024 + return f"{size} B" + + +def _fmt_time(value: float | None) -> str: + return "-" if not value else time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(value)) + + +def _esc(value) -> str: + return html.escape("" if value is None else str(value)) + + +def _truthy(value) -> bool: + if isinstance(value, bool): + return value + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +def _checked(value) -> str: + return " checked" if _truthy(value) else "" + + +def _secret_state(value: str | None) -> str: + if not value or str(value).strip().lower() in {"", "no_key", "no_pass", "no_pid"}: + return "missing" + return "configured" + + +def _build_cli_args(argv: list[str]): + old_argv = sys.argv[:] + try: + sys.argv = ["unit3dup", *argv] + return CommandLine().args + finally: + sys.argv = old_argv + + +def _list_entries(path_str: str | None) -> list[dict]: + if not path_str: + return [] + root = Path(path_str) + if not root.exists() or not root.is_dir(): + return [] + + items = [] + for entry in sorted(root.iterdir(), key=lambda item: item.name.lower()): + if entry.name.startswith("."): + continue + stats = entry.stat() + items.append( + { + "name": entry.name, + "quoted_name": quote(entry.name, safe=""), + "type": "folder" if entry.is_dir() else "file", + "size": _fmt_size(stats.st_size), + "modified": _fmt_time(stats.st_mtime), + } + ) + return items + + +def _load_raw_config() -> dict: + with open(DEFAULT_JSON_PATH, "r", encoding="utf-8") as handle: + return json.load(handle) + + +def _write_json(path: Path, payload: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=4) + + +def _reload_config() -> None: + loaded = Load.load_config() + if Load._instance: + Load._instance.config = loaded + + +def _load_web_settings() -> dict: + defaults = { + "local_config_path": os.getenv("UNIT3DUP_LOCAL_CONFIG_PATH", ""), + "local_watch_path": os.getenv("UNIT3DUP_LOCAL_WATCH_PATH", ""), + "local_done_path": os.getenv("UNIT3DUP_LOCAL_DONE_PATH", ""), + "local_data_path": os.getenv("UNIT3DUP_LOCAL_DATA_PATH", ""), + } + if not WEB_SETTINGS_PATH.exists(): + return defaults + with open(WEB_SETTINGS_PATH, "r", encoding="utf-8") as handle: + saved = json.load(handle) + for key, value in saved.items(): + if str(value).strip(): + defaults[key] = value + return defaults + + +def _coerce_int(value, fallback: int) -> int: + try: + return int(str(value).strip()) + except (TypeError, ValueError): + return fallback + + +async def _request_data(request: Request) -> dict[str, str]: + raw = (await request.body()).decode("utf-8") + parsed = parse_qs(raw, keep_blank_values=True) + return {key: values[-1] if values else "" for key, values in parsed.items()} + + +def _trackers(config, cli_args) -> list[str]: + if cli_args.mt: + return [tracker.upper() for tracker in config.tracker_config.MULTI_TRACKER] + if cli_args.tracker: + return [cli_args.tracker.upper()] + return [config.tracker_config.MULTI_TRACKER[0].upper()] + + +@dataclass +class JobState: + label: str + status: str = "queued" + started_at: float = field(default_factory=time.time) + finished_at: float | None = None + logs: list[str] = field(default_factory=list) + lock: threading.Lock = field(default_factory=threading.Lock, repr=False) + + def append(self, message: str) -> None: + with self.lock: + for line in str(message).replace("\r", "\n").splitlines(): + if line: + self.logs.append(line) + self.logs = self.logs[-500:] + + def text(self) -> str: + with self.lock: + return "\n".join(self.logs) + + +class _Capture(io.TextIOBase): + def __init__(self, job: JobState): + self.job = job + self.buffer = "" + + def write(self, data: str) -> int: + if not data: + return 0 + self.buffer += data.replace("\r", "\n") + while "\n" in self.buffer: + line, self.buffer = self.buffer.split("\n", 1) + self.job.append(line) + return len(data) + + def flush(self) -> None: + if self.buffer: + self.job.append(self.buffer) + self.buffer = "" + + +class JobStore: + def __init__(self): + self.lock = threading.Lock() + self.job: JobState | None = None + + def get(self) -> JobState | None: + with self.lock: + return self.job + + def start(self, label: str, runner) -> None: + with self.lock: + if self.job and self.job.status in {"queued", "running"}: + raise RuntimeError("A job is already running") + job = JobState(label=label) + self.job = job + + def run() -> None: + capture = _Capture(job) + job.status = "running" + job.append(f"[Web] Starting job: {label}") + try: + with contextlib.redirect_stdout(capture), contextlib.redirect_stderr(capture): + runner(job) + except SystemExit as exc: + job.append(f"[Web] Job stopped with exit code: {exc}") + job.status = "failed" + except Exception: + job.append("[Web] Unexpected error") + job.append(traceback.format_exc()) + job.status = "failed" + else: + job.status = "completed" + job.append("[Web] Job completed") + finally: + capture.flush() + job.finished_at = time.time() + + threading.Thread(target=run, daemon=True).start() + + def clear(self) -> None: + with self.lock: + if self.job and self.job.status in {"queued", "running"}: + raise RuntimeError("Cannot clear a running job") + self.job = None + + +JOB_STORE = JobStore() + + +def _cli_args_for_mode(mode: str): + argv = ["-watcher"] + if mode == "dry-run": + argv.extend(["-noseed", "-noup"]) + elif mode == "upload-no-seed": + argv.append("-noseed") + elif mode == "upload-seed": + pass + else: + raise ValueError(f"Unknown mode: {mode}") + return _build_cli_args(argv) + + +def _mode_label(mode: str) -> str: + labels = { + "dry-run": "Dry run", + "upload-no-seed": "Upload no seed", + "upload-seed": "Upload and seed", + } + return labels.get(mode, mode) + + +def _process_entry(src: Path, mode: str, job: JobState) -> None: + config = Load.load_config() + cli_args = _cli_args_for_mode(mode) + bot = Bot( + path=str(src), + cli=cli_args, + trackers_name_list=_trackers(config, cli_args), + mode="folder" if src.is_dir() else "man", + torrent_archive_path=config.user_preferences.TORRENT_ARCHIVE_PATH or ".", + ) + job.append(f"[Web] Processing: {src.name} ({_mode_label(mode)})") + result = bot.run() + if mode == "dry-run": + job.append(f"[Web] Dry run finished: {src.name}") + return + if not result: + job.append(f"[Web] Upload skipped or failed: {src.name}") + return + done_root = Path(config.user_preferences.WATCHER_DESTINATION_PATH) + done_root.mkdir(parents=True, exist_ok=True) + moved_to = bot._move_to_destination(src=src, done_root=done_root) + job.append(f"[Web] Moved to destination: {moved_to}" if moved_to else f"[Web] Move failed: {src.name}") + + +def _run_watcher_job(job: JobState, mode: str, entry_name: str | None = None) -> None: + config = Load.load_config() + watcher_root = Path(config.user_preferences.WATCHER_PATH) + if not watcher_root.exists() or not watcher_root.is_dir(): + raise FileNotFoundError(f"Watcher path not found: {watcher_root}") + if entry_name: + entries = [watcher_root / entry_name] + else: + entries = [entry for entry in sorted(watcher_root.iterdir(), key=lambda item: item.name.lower()) if not entry.name.startswith(".")] + entries = [entry for entry in entries if entry.exists()] + if not entries: + job.append("[Web] Watcher folder is empty") + return + for entry in entries: + _process_entry(entry, mode, job) + + +def _nav(active: str) -> str: + items = [("Dashboard", "/", "dashboard"), ("Settings", "/settings", "settings")] + links = [] + for label, href, name in items: + css = "nav-link active" if active == name else "nav-link" + links.append(f"{label}") + return "".join(links) + + +def _job_panel(job: JobState | None) -> str: + if not job: + return "
No job yet.
" + clear = "" + if job.status != "running": + clear = "
" + return ( + "
" + "
" + "

Last job

" + f"

{_esc(job.label)}

{clear}
" + "
" + f"Status: {_esc(job.status)}" + f"Started: {_fmt_time(job.started_at)}" + f"Finished: {_fmt_time(job.finished_at)}" + "
" + f"
{_esc(job.text())}
" + "
" + ) + + +def _layout(title: str, active: str, body: str, message: str = "", refresh: bool = False) -> str: + meta = "" if refresh else "" + flash = f"
{_esc(message)}
" if message else "" + return f""" +{meta}{_esc(title)} +

Unit3Dup Web

Simple control panel for watcher jobs and settings.

{flash}
{body}
""" + + +def _render_dashboard(message: str = "") -> str: + config = Load.load_config() + web_settings = _load_web_settings() + watcher = _list_entries(config.user_preferences.WATCHER_PATH) + done = _list_entries(config.user_preferences.WATCHER_DESTINATION_PATH)[:10] + job = JOB_STORE.get() + + waiting_rows = "".join( + "" + f"{_esc(item['name'])}{_esc(item['type'])}{_esc(item['size'])}{_esc(item['modified'])}" + "
" + f"
" + f"
" + f"
" + "
" + for item in watcher + ) or "No entries" + + done_rows = "".join( + f"{_esc(item['name'])}{_esc(item['type'])}{_esc(item['size'])}{_esc(item['modified'])}" + for item in done + ) or "No processed entries" + + body = ( + "

Overview

Current paths, secrets state and quick actions.

" + "
" + "
" + "
" + "
" + "
" + "
" + f"
Config
{_esc(DEFAULT_JSON_PATH)}
" + f"
Watcher
{_esc(config.user_preferences.WATCHER_PATH)}
" + f"
Destination
{_esc(config.user_preferences.WATCHER_DESTINATION_PATH)}
" + f"
Waiting
{len(watcher)}
" + "
" + "
" + f"Gemini API: {_secret_state(config.tracker_config.Gemini_APIKEY)}" + f"Passkey: {_secret_state(config.tracker_config.Gemini_PID)}" + f"TMDB: {_secret_state(config.tracker_config.TMDB_APIKEY)}" + f"IMGBB: {_secret_state(config.tracker_config.IMGBB_KEY)}" + "
" + "

Local mappings

Saved for reference only.

" + "
" + f"
Local config
{_esc(web_settings['local_config_path'] or '-')}
" + f"
Local watch
{_esc(web_settings['local_watch_path'] or '-')}
" + f"
Local done
{_esc(web_settings['local_done_path'] or '-')}
" + f"
Local data
{_esc(web_settings['local_data_path'] or '-')}
" + "
" + "
" + "
" + "

Watcher queue

Top-level items waiting in the watcher folder.

" + f"{waiting_rows}
NameTypeSizeModifiedActions
" + "

Recently moved

Last entries found in the destination folder.

" + f"{done_rows}
NameTypeSizeModified
" + "
" + f"{_job_panel(job)}" + "
" + ) + return _layout("Unit3Dup Web", "dashboard", body, message, bool(job and job.status == "running")) + + +def _render_settings(message: str = "") -> str: + raw = _load_raw_config() + local = _load_web_settings() + tracker = raw["tracker_config"] + prefs = raw["user_preferences"] + torrent = raw["torrent_client_config"] + + body = ( + "
" + "

App settings

Edit the main Unit3Dbot.json fields.

" + "
" + "
" + f"
" + f"
Comma separated
" + "
" + "
" + f"
Current state: {_esc(_secret_state(tracker.get('Gemini_APIKEY')))}
" + f"
Current state: {_esc(_secret_state(tracker.get('Gemini_PID')))}
" + "
" + "
" + f"
Current state: {_esc(_secret_state(tracker.get('TMDB_APIKEY')))}
" + f"
Current state: {_esc(_secret_state(tracker.get('IMGBB_KEY')))}
" + "
" + "
" + f"
" + f"
" + "
" + "
" + f"
" + f"
" + "
" + "
" + f"
" + f"
" + "
" + "
" + "
" + f"
" + "
" + "
" + f"
" + f"
" + "
" + "
" + f"
" + f"
" + "
" + "
" + f"
" + f"
" + "
" + "
" + f"" + f"" + f"" + f"" + "
" + "

Local folders

Host-side references saved in unit3dup-web.json.

" + "
" + f"
" + f"
" + f"
" + f"
" + "
" + "

These values are informative and do not change Docker bind mounts by themselves.

" + "
" + ) + return _layout("Unit3Dup Settings", "settings", body, message) + + +def _redirect(path: str, message: str = "") -> RedirectResponse: + target = path if not message else f"{path}?message={quote(message)}" + return RedirectResponse(url=target, status_code=303) + + +@app.get("/", response_class=HTMLResponse) +async def index(message: str = "") -> HTMLResponse: + return HTMLResponse(_render_dashboard(message)) + + +@app.get("/settings", response_class=HTMLResponse) +async def settings(message: str = "") -> HTMLResponse: + return HTMLResponse(_render_settings(message)) + + +@app.post("/settings/app") +async def save_app_settings(request: Request) -> RedirectResponse: + form = await _request_data(request) + current = _load_raw_config() + backup = json.loads(json.dumps(current)) + tracker = current["tracker_config"] + prefs = current["user_preferences"] + torrent = current["torrent_client_config"] + + tracker["Gemini_URL"] = str(form.get("Gemini_URL", tracker.get("Gemini_URL", ""))).strip() + tracker["MULTI_TRACKER"] = [item.strip().lower() for item in str(form.get("MULTI_TRACKER", "")).split(",") if item.strip()] or tracker.get("MULTI_TRACKER", ["gemini"]) + for key in ("Gemini_APIKEY", "Gemini_PID", "TMDB_APIKEY", "IMGBB_KEY"): + value = str(form.get(key, "")).strip() + if value: + tracker[key] = value + + prefs["WATCHER_PATH"] = str(form.get("WATCHER_PATH", prefs.get("WATCHER_PATH", ""))).strip() + prefs["WATCHER_DESTINATION_PATH"] = str(form.get("WATCHER_DESTINATION_PATH", prefs.get("WATCHER_DESTINATION_PATH", ""))).strip() + prefs["TORRENT_ARCHIVE_PATH"] = str(form.get("TORRENT_ARCHIVE_PATH", prefs.get("TORRENT_ARCHIVE_PATH", ""))).strip() + prefs["CACHE_PATH"] = str(form.get("CACHE_PATH", prefs.get("CACHE_PATH", ""))).strip() + prefs["WATCHER_INTERVAL"] = _coerce_int(form.get("WATCHER_INTERVAL", prefs.get("WATCHER_INTERVAL", 60)), 60) + prefs["NUMBER_OF_SCREENSHOTS"] = _coerce_int(form.get("NUMBER_OF_SCREENSHOTS", prefs.get("NUMBER_OF_SCREENSHOTS", 4)), 4) + prefs["DUPLICATE_ON"] = "true" if form.get("DUPLICATE_ON") else "false" + prefs["SKIP_DUPLICATE"] = "true" if form.get("SKIP_DUPLICATE") else "false" + prefs["ANON"] = "true" if form.get("ANON") else "false" + prefs["PERSONAL_RELEASE"] = "true" if form.get("PERSONAL_RELEASE") else "false" + + torrent["TORRENT_CLIENT"] = str(form.get("TORRENT_CLIENT", torrent.get("TORRENT_CLIENT", "qbittorrent"))).strip() + torrent["TAG"] = str(form.get("TAG", torrent.get("TAG", ""))).strip() + for key in ("QBIT_HOST", "QBIT_PORT", "TRASM_HOST", "TRASM_PORT", "RTORR_HOST", "RTORR_PORT"): + torrent[key] = str(form.get(key, torrent.get(key, ""))).strip() + + try: + _write_json(DEFAULT_JSON_PATH, current) + _reload_config() + except SystemExit: + _write_json(DEFAULT_JSON_PATH, backup) + _reload_config() + return _redirect("/settings", "Invalid configuration. Previous values restored.") + except Exception: + _write_json(DEFAULT_JSON_PATH, backup) + _reload_config() + return _redirect("/settings", "Failed to save app settings.") + return _redirect("/settings", "App settings saved.") + + +@app.post("/settings/local") +async def save_local_settings(request: Request) -> RedirectResponse: + form = await _request_data(request) + _write_json( + WEB_SETTINGS_PATH, + { + "local_config_path": str(form.get("local_config_path", "")).strip(), + "local_watch_path": str(form.get("local_watch_path", "")).strip(), + "local_done_path": str(form.get("local_done_path", "")).strip(), + "local_data_path": str(form.get("local_data_path", "")).strip(), + }, + ) + return _redirect("/settings", "Local paths saved.") + + +@app.post("/jobs/watcher/dry-run") +async def start_watcher_dry_run() -> RedirectResponse: + try: + JOB_STORE.start("Dry run watcher", lambda job: _run_watcher_job(job, mode="dry-run")) + except RuntimeError as exc: + return _redirect("/", str(exc)) + return _redirect("/") + + +@app.post("/jobs/watcher/upload-no-seed") +async def start_watcher_upload_no_seed() -> RedirectResponse: + try: + JOB_STORE.start("Upload watcher without seeding", lambda job: _run_watcher_job(job, mode="upload-no-seed")) + except RuntimeError as exc: + return _redirect("/", str(exc)) + return _redirect("/") + + +@app.post("/jobs/watcher/upload-seed") +async def start_watcher_upload_seed() -> RedirectResponse: + try: + JOB_STORE.start("Upload watcher with seeding", lambda job: _run_watcher_job(job, mode="upload-seed")) + except RuntimeError as exc: + return _redirect("/", str(exc)) + return _redirect("/") + + +@app.post("/jobs/items/{entry_name}/dry-run") +async def start_entry_dry_run(entry_name: str) -> RedirectResponse: + try: + JOB_STORE.start(f"Dry run: {entry_name}", lambda job: _run_watcher_job(job, mode="dry-run", entry_name=entry_name)) + except RuntimeError as exc: + return _redirect("/", str(exc)) + return _redirect("/") + + +@app.post("/jobs/items/{entry_name}/upload-no-seed") +async def start_entry_upload_no_seed(entry_name: str) -> RedirectResponse: + try: + JOB_STORE.start(f"Upload no seed: {entry_name}", lambda job: _run_watcher_job(job, mode="upload-no-seed", entry_name=entry_name)) + except RuntimeError as exc: + return _redirect("/", str(exc)) + return _redirect("/") + + +@app.post("/jobs/items/{entry_name}/upload-seed") +async def start_entry_upload_seed(entry_name: str) -> RedirectResponse: + try: + JOB_STORE.start(f"Upload and seed: {entry_name}", lambda job: _run_watcher_job(job, mode="upload-seed", entry_name=entry_name)) + except RuntimeError as exc: + return _redirect("/", str(exc)) + return _redirect("/") + + +@app.post("/jobs/clear") +async def clear_job() -> RedirectResponse: + try: + JOB_STORE.clear() + except RuntimeError as exc: + return _redirect("/", str(exc)) + return _redirect("/") + + +@app.get("/health") +async def health() -> dict[str, str]: + return {"status": "ok"} + + +def serve() -> None: + import uvicorn + + uvicorn.run( + "unit3dup.web.main:app", + host=os.getenv("UNIT3DUP_WEB_HOST", "0.0.0.0"), + port=int(os.getenv("UNIT3DUP_WEB_PORT", "8787")), + reload=False, + access_log=False, + ) diff --git a/unit3dup/web/web/main.py b/unit3dup/web/web/main.py index e0eb713..1bb259d 100644 --- a/unit3dup/web/web/main.py +++ b/unit3dup/web/web/main.py @@ -1,46 +1,3 @@ -from fastapi import FastAPI, APIRouter -from random import randint -from common.torrent_clients import TransmissionClient, QbittorrentClient, RTorrentClient -from common.command import CommandLine -from common.settings import Load,DEFAULT_JSON_PATH - -from unit3dup.torrent import View -from unit3dup import pvtTracker -from unit3dup.bot import Bot -from view import custom_console - - -import uvicorn -import argparse -from random import randint - -app = FastAPI() - -# Classe che gestisce gli endpoint -class WebApp: - def __init__(self, config: Load): - self.router = APIRouter() - self.numb = randint(0, 100) - self._setup_routes() - - def _setup_routes(self): - # Add the endpoints - self.router.add_api_route("/", self.root, methods=["GET"]) - self.router.add_api_route("/upload/{name}", self.upload, methods=["GET"]) - - async def root(self): - return {"message": f"Hello World {self.numb}"} - - async def upload(self, name: str): - - - - return {"message": f"Hello {name}, numb is {self.numb}"} - - -def web(): - web_app = WebApp(config=Load().load_config()) - app.include_router(web_app.router) - uvicorn.run("unit3dup.web.main:app", host="127.0.0.1", port=8000, reload=True) +from unit3dup.web.main import app, serve From aae7cc1ad4c008e3b776bd4ed1944fdafdef5564 Mon Sep 17 00:00:00 2001 From: Kusanagi Date: Fri, 20 Mar 2026 12:22:40 +0100 Subject: [PATCH 7/7] Remove optional web UI from Docker PR --- README.md | 29 -- docker-compose.yml | 25 -- pyproject.toml | 1 - requirements.txt | 2 - unit3dup/web/main.py | 660 ++------------------------------------- unit3dup/web/web/main.py | 45 ++- 6 files changed, 74 insertions(+), 688 deletions(-) diff --git a/README.md b/README.md index 373df24..1c6df90 100644 --- a/README.md +++ b/README.md @@ -254,32 +254,3 @@ user: "${PUID:-1000}:${PGID:-1000}" ``` Adapte `PUID` et `PGID` à l'utilisateur de ton NAS si besoin pour éviter les problèmes d'accès sur les partages. - -### Petite interface web - -Une interface web minimale est aussi disponible pour piloter le dossier watcher sans passer par la CLI. - -Elle permet de : - -- lister le contenu du watcher -- lancer un dry-run sur un element -- lancer un upload sans seed ou avec seed -- consulter le statut et les logs du dernier job -- modifier les principaux champs du `Unit3Dbot.json` depuis une page `Settings` - -Le service web est optionnel et utilise le profil Compose `web` : - -```bash -docker compose --profile web up -d unit3dup-web -``` - -Puis ouvre : - -```text -http://localhost:8787 -``` - -Important : - -- n'utilise pas les actions manuelles de l'interface en meme temps que le conteneur watcher sur le meme dossier -- l'interface ne remplace pas le fichier `Unit3Dbot.json`, elle s'appuie dessus diff --git a/docker-compose.yml b/docker-compose.yml index f920a01..647ebd1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,28 +15,3 @@ services: - ./docker-data/done:/done - ./docker-data/media:/data command: ["-watcher"] - unit3dup-web: - profiles: ["web"] - build: - context: . - container_name: unit3dup-web - restart: unless-stopped - user: "${PUID:-1000}:${PGID:-1000}" - environment: - TZ: "${TZ:-Europe/Paris}" - UNIT3DUP_CONFIG_ROOT: /config - HOME: /tmp/unit3dup - UNIT3DUP_WEB_HOST: 0.0.0.0 - UNIT3DUP_WEB_PORT: 8787 - UNIT3DUP_LOCAL_CONFIG_PATH: ./docker-data/config - UNIT3DUP_LOCAL_WATCH_PATH: ./docker-data/watch - UNIT3DUP_LOCAL_DONE_PATH: ./docker-data/done - UNIT3DUP_LOCAL_DATA_PATH: ./docker-data/media - volumes: - - ./docker-data/config:/config - - ./docker-data/watch:/watch - - ./docker-data/done:/done - - ./docker-data/media:/data - ports: - - "${UNIT3DUP_WEB_PORT:-8787}:8787" - entrypoint: ["unit3dup-web"] diff --git a/pyproject.toml b/pyproject.toml index 3ef9434..46a71e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,5 @@ dependencies = {file = ["requirements.txt"]} [project.scripts] unit3dup = "unit3dup.__main__:main" -unit3dup-web = "unit3dup.web.main:serve" diff --git a/requirements.txt b/requirements.txt index 07009cf..b3c0a57 100644 --- a/requirements.txt +++ b/requirements.txt @@ -17,8 +17,6 @@ cinemagoer==2023.5.1 pathvalidate==3.2.3 bencode2==0.3.24 rtorrent-rpc==0.9.4 -fastapi>=0.115,<1.0 -uvicorn>=0.30,<1.0 diff --git a/unit3dup/web/main.py b/unit3dup/web/main.py index 498183d..93a229d 100644 --- a/unit3dup/web/main.py +++ b/unit3dup/web/main.py @@ -1,646 +1,46 @@ -from __future__ import annotations - -import contextlib -import html -import io -import json -import os -import sys -import threading -import time -import traceback -from dataclasses import dataclass, field -from pathlib import Path -from urllib.parse import parse_qs, quote - -from fastapi import FastAPI, Request -from fastapi.responses import HTMLResponse, RedirectResponse - +from fastapi import FastAPI, APIRouter +from random import randint +from common.torrent_clients import TransmissionClient, QbittorrentClient, RTorrentClient from common.command import CommandLine -from common.settings import DEFAULT_JSON_PATH, Load -from unit3dup.bot import Bot - -app = FastAPI(title="Unit3Dup Web", docs_url=None, redoc_url=None) -WEB_SETTINGS_PATH = DEFAULT_JSON_PATH.parent / "unit3dup-web.json" - - -def _fmt_size(size: int) -> str: - units = ["B", "KB", "MB", "GB", "TB"] - value = float(size) - for unit in units: - if value < 1024 or unit == units[-1]: - return f"{value:.1f} {unit}" if unit != "B" else f"{int(value)} {unit}" - value /= 1024 - return f"{size} B" - - -def _fmt_time(value: float | None) -> str: - return "-" if not value else time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(value)) - - -def _esc(value) -> str: - return html.escape("" if value is None else str(value)) - - -def _truthy(value) -> bool: - if isinstance(value, bool): - return value - return str(value).strip().lower() in {"1", "true", "yes", "on"} - - -def _checked(value) -> str: - return " checked" if _truthy(value) else "" - - -def _secret_state(value: str | None) -> str: - if not value or str(value).strip().lower() in {"", "no_key", "no_pass", "no_pid"}: - return "missing" - return "configured" - - -def _build_cli_args(argv: list[str]): - old_argv = sys.argv[:] - try: - sys.argv = ["unit3dup", *argv] - return CommandLine().args - finally: - sys.argv = old_argv - - -def _list_entries(path_str: str | None) -> list[dict]: - if not path_str: - return [] - root = Path(path_str) - if not root.exists() or not root.is_dir(): - return [] - - items = [] - for entry in sorted(root.iterdir(), key=lambda item: item.name.lower()): - if entry.name.startswith("."): - continue - stats = entry.stat() - items.append( - { - "name": entry.name, - "quoted_name": quote(entry.name, safe=""), - "type": "folder" if entry.is_dir() else "file", - "size": _fmt_size(stats.st_size), - "modified": _fmt_time(stats.st_mtime), - } - ) - return items - - -def _load_raw_config() -> dict: - with open(DEFAULT_JSON_PATH, "r", encoding="utf-8") as handle: - return json.load(handle) - - -def _write_json(path: Path, payload: dict) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - with open(path, "w", encoding="utf-8") as handle: - json.dump(payload, handle, ensure_ascii=False, indent=4) - - -def _reload_config() -> None: - loaded = Load.load_config() - if Load._instance: - Load._instance.config = loaded - - -def _load_web_settings() -> dict: - defaults = { - "local_config_path": os.getenv("UNIT3DUP_LOCAL_CONFIG_PATH", ""), - "local_watch_path": os.getenv("UNIT3DUP_LOCAL_WATCH_PATH", ""), - "local_done_path": os.getenv("UNIT3DUP_LOCAL_DONE_PATH", ""), - "local_data_path": os.getenv("UNIT3DUP_LOCAL_DATA_PATH", ""), - } - if not WEB_SETTINGS_PATH.exists(): - return defaults - with open(WEB_SETTINGS_PATH, "r", encoding="utf-8") as handle: - saved = json.load(handle) - for key, value in saved.items(): - if str(value).strip(): - defaults[key] = value - return defaults - - -def _coerce_int(value, fallback: int) -> int: - try: - return int(str(value).strip()) - except (TypeError, ValueError): - return fallback - - -async def _request_data(request: Request) -> dict[str, str]: - raw = (await request.body()).decode("utf-8") - parsed = parse_qs(raw, keep_blank_values=True) - return {key: values[-1] if values else "" for key, values in parsed.items()} - - -def _trackers(config, cli_args) -> list[str]: - if cli_args.mt: - return [tracker.upper() for tracker in config.tracker_config.MULTI_TRACKER] - if cli_args.tracker: - return [cli_args.tracker.upper()] - return [config.tracker_config.MULTI_TRACKER[0].upper()] - - -@dataclass -class JobState: - label: str - status: str = "queued" - started_at: float = field(default_factory=time.time) - finished_at: float | None = None - logs: list[str] = field(default_factory=list) - lock: threading.Lock = field(default_factory=threading.Lock, repr=False) - - def append(self, message: str) -> None: - with self.lock: - for line in str(message).replace("\r", "\n").splitlines(): - if line: - self.logs.append(line) - self.logs = self.logs[-500:] - - def text(self) -> str: - with self.lock: - return "\n".join(self.logs) - - -class _Capture(io.TextIOBase): - def __init__(self, job: JobState): - self.job = job - self.buffer = "" - - def write(self, data: str) -> int: - if not data: - return 0 - self.buffer += data.replace("\r", "\n") - while "\n" in self.buffer: - line, self.buffer = self.buffer.split("\n", 1) - self.job.append(line) - return len(data) - - def flush(self) -> None: - if self.buffer: - self.job.append(self.buffer) - self.buffer = "" - - -class JobStore: - def __init__(self): - self.lock = threading.Lock() - self.job: JobState | None = None - - def get(self) -> JobState | None: - with self.lock: - return self.job - - def start(self, label: str, runner) -> None: - with self.lock: - if self.job and self.job.status in {"queued", "running"}: - raise RuntimeError("A job is already running") - job = JobState(label=label) - self.job = job - - def run() -> None: - capture = _Capture(job) - job.status = "running" - job.append(f"[Web] Starting job: {label}") - try: - with contextlib.redirect_stdout(capture), contextlib.redirect_stderr(capture): - runner(job) - except SystemExit as exc: - job.append(f"[Web] Job stopped with exit code: {exc}") - job.status = "failed" - except Exception: - job.append("[Web] Unexpected error") - job.append(traceback.format_exc()) - job.status = "failed" - else: - job.status = "completed" - job.append("[Web] Job completed") - finally: - capture.flush() - job.finished_at = time.time() - - threading.Thread(target=run, daemon=True).start() - - def clear(self) -> None: - with self.lock: - if self.job and self.job.status in {"queued", "running"}: - raise RuntimeError("Cannot clear a running job") - self.job = None - - -JOB_STORE = JobStore() - - -def _cli_args_for_mode(mode: str): - argv = ["-watcher"] - if mode == "dry-run": - argv.extend(["-noseed", "-noup"]) - elif mode == "upload-no-seed": - argv.append("-noseed") - elif mode == "upload-seed": - pass - else: - raise ValueError(f"Unknown mode: {mode}") - return _build_cli_args(argv) - - -def _mode_label(mode: str) -> str: - labels = { - "dry-run": "Dry run", - "upload-no-seed": "Upload no seed", - "upload-seed": "Upload and seed", - } - return labels.get(mode, mode) - - -def _process_entry(src: Path, mode: str, job: JobState) -> None: - config = Load.load_config() - cli_args = _cli_args_for_mode(mode) - bot = Bot( - path=str(src), - cli=cli_args, - trackers_name_list=_trackers(config, cli_args), - mode="folder" if src.is_dir() else "man", - torrent_archive_path=config.user_preferences.TORRENT_ARCHIVE_PATH or ".", - ) - job.append(f"[Web] Processing: {src.name} ({_mode_label(mode)})") - result = bot.run() - if mode == "dry-run": - job.append(f"[Web] Dry run finished: {src.name}") - return - if not result: - job.append(f"[Web] Upload skipped or failed: {src.name}") - return - done_root = Path(config.user_preferences.WATCHER_DESTINATION_PATH) - done_root.mkdir(parents=True, exist_ok=True) - moved_to = bot._move_to_destination(src=src, done_root=done_root) - job.append(f"[Web] Moved to destination: {moved_to}" if moved_to else f"[Web] Move failed: {src.name}") - - -def _run_watcher_job(job: JobState, mode: str, entry_name: str | None = None) -> None: - config = Load.load_config() - watcher_root = Path(config.user_preferences.WATCHER_PATH) - if not watcher_root.exists() or not watcher_root.is_dir(): - raise FileNotFoundError(f"Watcher path not found: {watcher_root}") - if entry_name: - entries = [watcher_root / entry_name] - else: - entries = [entry for entry in sorted(watcher_root.iterdir(), key=lambda item: item.name.lower()) if not entry.name.startswith(".")] - entries = [entry for entry in entries if entry.exists()] - if not entries: - job.append("[Web] Watcher folder is empty") - return - for entry in entries: - _process_entry(entry, mode, job) - - -def _nav(active: str) -> str: - items = [("Dashboard", "/", "dashboard"), ("Settings", "/settings", "settings")] - links = [] - for label, href, name in items: - css = "nav-link active" if active == name else "nav-link" - links.append(f"{label}") - return "".join(links) - - -def _job_panel(job: JobState | None) -> str: - if not job: - return "
No job yet.
" - clear = "" - if job.status != "running": - clear = "
" - return ( - "
" - "
" - "

Last job

" - f"

{_esc(job.label)}

{clear}
" - "
" - f"Status: {_esc(job.status)}" - f"Started: {_fmt_time(job.started_at)}" - f"Finished: {_fmt_time(job.finished_at)}" - "
" - f"
{_esc(job.text())}
" - "
" - ) - - -def _layout(title: str, active: str, body: str, message: str = "", refresh: bool = False) -> str: - meta = "" if refresh else "" - flash = f"
{_esc(message)}
" if message else "" - return f""" -{meta}{_esc(title)} -

Unit3Dup Web

Simple control panel for watcher jobs and settings.

{flash}
{body}
""" - - -def _render_dashboard(message: str = "") -> str: - config = Load.load_config() - web_settings = _load_web_settings() - watcher = _list_entries(config.user_preferences.WATCHER_PATH) - done = _list_entries(config.user_preferences.WATCHER_DESTINATION_PATH)[:10] - job = JOB_STORE.get() - - waiting_rows = "".join( - "" - f"{_esc(item['name'])}{_esc(item['type'])}{_esc(item['size'])}{_esc(item['modified'])}" - "
" - f"
" - f"
" - f"
" - "
" - for item in watcher - ) or "No entries" - - done_rows = "".join( - f"{_esc(item['name'])}{_esc(item['type'])}{_esc(item['size'])}{_esc(item['modified'])}" - for item in done - ) or "No processed entries" - - body = ( - "

Overview

Current paths, secrets state and quick actions.

" - "
" - "
" - "
" - "
" - "
" - "
" - f"
Config
{_esc(DEFAULT_JSON_PATH)}
" - f"
Watcher
{_esc(config.user_preferences.WATCHER_PATH)}
" - f"
Destination
{_esc(config.user_preferences.WATCHER_DESTINATION_PATH)}
" - f"
Waiting
{len(watcher)}
" - "
" - "
" - f"Gemini API: {_secret_state(config.tracker_config.Gemini_APIKEY)}" - f"Passkey: {_secret_state(config.tracker_config.Gemini_PID)}" - f"TMDB: {_secret_state(config.tracker_config.TMDB_APIKEY)}" - f"IMGBB: {_secret_state(config.tracker_config.IMGBB_KEY)}" - "
" - "

Local mappings

Saved for reference only.

" - "
" - f"
Local config
{_esc(web_settings['local_config_path'] or '-')}
" - f"
Local watch
{_esc(web_settings['local_watch_path'] or '-')}
" - f"
Local done
{_esc(web_settings['local_done_path'] or '-')}
" - f"
Local data
{_esc(web_settings['local_data_path'] or '-')}
" - "
" - "
" - "
" - "

Watcher queue

Top-level items waiting in the watcher folder.

" - f"{waiting_rows}
NameTypeSizeModifiedActions
" - "

Recently moved

Last entries found in the destination folder.

" - f"{done_rows}
NameTypeSizeModified
" - "
" - f"{_job_panel(job)}" - "
" - ) - return _layout("Unit3Dup Web", "dashboard", body, message, bool(job and job.status == "running")) - - -def _render_settings(message: str = "") -> str: - raw = _load_raw_config() - local = _load_web_settings() - tracker = raw["tracker_config"] - prefs = raw["user_preferences"] - torrent = raw["torrent_client_config"] - - body = ( - "
" - "

App settings

Edit the main Unit3Dbot.json fields.

" - "
" - "
" - f"
" - f"
Comma separated
" - "
" - "
" - f"
Current state: {_esc(_secret_state(tracker.get('Gemini_APIKEY')))}
" - f"
Current state: {_esc(_secret_state(tracker.get('Gemini_PID')))}
" - "
" - "
" - f"
Current state: {_esc(_secret_state(tracker.get('TMDB_APIKEY')))}
" - f"
Current state: {_esc(_secret_state(tracker.get('IMGBB_KEY')))}
" - "
" - "
" - f"
" - f"
" - "
" - "
" - f"
" - f"
" - "
" - "
" - f"
" - f"
" - "
" - "
" - "
" - f"
" - "
" - "
" - f"
" - f"
" - "
" - "
" - f"
" - f"
" - "
" - "
" - f"
" - f"
" - "
" - "
" - f"" - f"" - f"" - f"" - "
" - "

Local folders

Host-side references saved in unit3dup-web.json.

" - "
" - f"
" - f"
" - f"
" - f"
" - "
" - "

These values are informative and do not change Docker bind mounts by themselves.

" - "
" - ) - return _layout("Unit3Dup Settings", "settings", body, message) - - -def _redirect(path: str, message: str = "") -> RedirectResponse: - target = path if not message else f"{path}?message={quote(message)}" - return RedirectResponse(url=target, status_code=303) - - -@app.get("/", response_class=HTMLResponse) -async def index(message: str = "") -> HTMLResponse: - return HTMLResponse(_render_dashboard(message)) - - -@app.get("/settings", response_class=HTMLResponse) -async def settings(message: str = "") -> HTMLResponse: - return HTMLResponse(_render_settings(message)) - - -@app.post("/settings/app") -async def save_app_settings(request: Request) -> RedirectResponse: - form = await _request_data(request) - current = _load_raw_config() - backup = json.loads(json.dumps(current)) - tracker = current["tracker_config"] - prefs = current["user_preferences"] - torrent = current["torrent_client_config"] - - tracker["Gemini_URL"] = str(form.get("Gemini_URL", tracker.get("Gemini_URL", ""))).strip() - tracker["MULTI_TRACKER"] = [item.strip().lower() for item in str(form.get("MULTI_TRACKER", "")).split(",") if item.strip()] or tracker.get("MULTI_TRACKER", ["gemini"]) - for key in ("Gemini_APIKEY", "Gemini_PID", "TMDB_APIKEY", "IMGBB_KEY"): - value = str(form.get(key, "")).strip() - if value: - tracker[key] = value - - prefs["WATCHER_PATH"] = str(form.get("WATCHER_PATH", prefs.get("WATCHER_PATH", ""))).strip() - prefs["WATCHER_DESTINATION_PATH"] = str(form.get("WATCHER_DESTINATION_PATH", prefs.get("WATCHER_DESTINATION_PATH", ""))).strip() - prefs["TORRENT_ARCHIVE_PATH"] = str(form.get("TORRENT_ARCHIVE_PATH", prefs.get("TORRENT_ARCHIVE_PATH", ""))).strip() - prefs["CACHE_PATH"] = str(form.get("CACHE_PATH", prefs.get("CACHE_PATH", ""))).strip() - prefs["WATCHER_INTERVAL"] = _coerce_int(form.get("WATCHER_INTERVAL", prefs.get("WATCHER_INTERVAL", 60)), 60) - prefs["NUMBER_OF_SCREENSHOTS"] = _coerce_int(form.get("NUMBER_OF_SCREENSHOTS", prefs.get("NUMBER_OF_SCREENSHOTS", 4)), 4) - prefs["DUPLICATE_ON"] = "true" if form.get("DUPLICATE_ON") else "false" - prefs["SKIP_DUPLICATE"] = "true" if form.get("SKIP_DUPLICATE") else "false" - prefs["ANON"] = "true" if form.get("ANON") else "false" - prefs["PERSONAL_RELEASE"] = "true" if form.get("PERSONAL_RELEASE") else "false" - - torrent["TORRENT_CLIENT"] = str(form.get("TORRENT_CLIENT", torrent.get("TORRENT_CLIENT", "qbittorrent"))).strip() - torrent["TAG"] = str(form.get("TAG", torrent.get("TAG", ""))).strip() - for key in ("QBIT_HOST", "QBIT_PORT", "TRASM_HOST", "TRASM_PORT", "RTORR_HOST", "RTORR_PORT"): - torrent[key] = str(form.get(key, torrent.get(key, ""))).strip() - - try: - _write_json(DEFAULT_JSON_PATH, current) - _reload_config() - except SystemExit: - _write_json(DEFAULT_JSON_PATH, backup) - _reload_config() - return _redirect("/settings", "Invalid configuration. Previous values restored.") - except Exception: - _write_json(DEFAULT_JSON_PATH, backup) - _reload_config() - return _redirect("/settings", "Failed to save app settings.") - return _redirect("/settings", "App settings saved.") - - -@app.post("/settings/local") -async def save_local_settings(request: Request) -> RedirectResponse: - form = await _request_data(request) - _write_json( - WEB_SETTINGS_PATH, - { - "local_config_path": str(form.get("local_config_path", "")).strip(), - "local_watch_path": str(form.get("local_watch_path", "")).strip(), - "local_done_path": str(form.get("local_done_path", "")).strip(), - "local_data_path": str(form.get("local_data_path", "")).strip(), - }, - ) - return _redirect("/settings", "Local paths saved.") - - -@app.post("/jobs/watcher/dry-run") -async def start_watcher_dry_run() -> RedirectResponse: - try: - JOB_STORE.start("Dry run watcher", lambda job: _run_watcher_job(job, mode="dry-run")) - except RuntimeError as exc: - return _redirect("/", str(exc)) - return _redirect("/") - - -@app.post("/jobs/watcher/upload-no-seed") -async def start_watcher_upload_no_seed() -> RedirectResponse: - try: - JOB_STORE.start("Upload watcher without seeding", lambda job: _run_watcher_job(job, mode="upload-no-seed")) - except RuntimeError as exc: - return _redirect("/", str(exc)) - return _redirect("/") +from common.settings import Load,DEFAULT_JSON_PATH +from unit3dup.torrent import View +from unit3dup import pvtTracker +from unit3dup.bot import Bot +from view import custom_console -@app.post("/jobs/watcher/upload-seed") -async def start_watcher_upload_seed() -> RedirectResponse: - try: - JOB_STORE.start("Upload watcher with seeding", lambda job: _run_watcher_job(job, mode="upload-seed")) - except RuntimeError as exc: - return _redirect("/", str(exc)) - return _redirect("/") +import uvicorn +import argparse +from random import randint -@app.post("/jobs/items/{entry_name}/dry-run") -async def start_entry_dry_run(entry_name: str) -> RedirectResponse: - try: - JOB_STORE.start(f"Dry run: {entry_name}", lambda job: _run_watcher_job(job, mode="dry-run", entry_name=entry_name)) - except RuntimeError as exc: - return _redirect("/", str(exc)) - return _redirect("/") +app = FastAPI() +# Classe che gestisce gli endpoint +class WebApp: + def __init__(self, config: Load): + self.router = APIRouter() + self.numb = randint(0, 100) + self._setup_routes() -@app.post("/jobs/items/{entry_name}/upload-no-seed") -async def start_entry_upload_no_seed(entry_name: str) -> RedirectResponse: - try: - JOB_STORE.start(f"Upload no seed: {entry_name}", lambda job: _run_watcher_job(job, mode="upload-no-seed", entry_name=entry_name)) - except RuntimeError as exc: - return _redirect("/", str(exc)) - return _redirect("/") + def _setup_routes(self): + # Add the endpoints + self.router.add_api_route("/", self.root, methods=["GET"]) + self.router.add_api_route("/upload/{name}", self.upload, methods=["GET"]) + async def root(self): + return {"message": f"Hello World {self.numb}"} -@app.post("/jobs/items/{entry_name}/upload-seed") -async def start_entry_upload_seed(entry_name: str) -> RedirectResponse: - try: - JOB_STORE.start(f"Upload and seed: {entry_name}", lambda job: _run_watcher_job(job, mode="upload-seed", entry_name=entry_name)) - except RuntimeError as exc: - return _redirect("/", str(exc)) - return _redirect("/") + async def upload(self, name: str): -@app.post("/jobs/clear") -async def clear_job() -> RedirectResponse: - try: - JOB_STORE.clear() - except RuntimeError as exc: - return _redirect("/", str(exc)) - return _redirect("/") + return {"message": f"Hello {name}, numb is {self.numb}"} -@app.get("/health") -async def health() -> dict[str, str]: - return {"status": "ok"} +def web(): + web_app = WebApp(config=Load().load_config()) + app.include_router(web_app.router) + uvicorn.run("unit3dup.web.main:app", host="127.0.0.1", port=8000, reload=True) -def serve() -> None: - import uvicorn - uvicorn.run( - "unit3dup.web.main:app", - host=os.getenv("UNIT3DUP_WEB_HOST", "0.0.0.0"), - port=int(os.getenv("UNIT3DUP_WEB_PORT", "8787")), - reload=False, - access_log=False, - ) diff --git a/unit3dup/web/web/main.py b/unit3dup/web/web/main.py index 1bb259d..e0eb713 100644 --- a/unit3dup/web/web/main.py +++ b/unit3dup/web/web/main.py @@ -1,3 +1,46 @@ -from unit3dup.web.main import app, serve +from fastapi import FastAPI, APIRouter +from random import randint +from common.torrent_clients import TransmissionClient, QbittorrentClient, RTorrentClient +from common.command import CommandLine +from common.settings import Load,DEFAULT_JSON_PATH + +from unit3dup.torrent import View +from unit3dup import pvtTracker +from unit3dup.bot import Bot +from view import custom_console + + +import uvicorn +import argparse +from random import randint + +app = FastAPI() + +# Classe che gestisce gli endpoint +class WebApp: + def __init__(self, config: Load): + self.router = APIRouter() + self.numb = randint(0, 100) + self._setup_routes() + + def _setup_routes(self): + # Add the endpoints + self.router.add_api_route("/", self.root, methods=["GET"]) + self.router.add_api_route("/upload/{name}", self.upload, methods=["GET"]) + + async def root(self): + return {"message": f"Hello World {self.numb}"} + + async def upload(self, name: str): + + + + return {"message": f"Hello {name}, numb is {self.numb}"} + + +def web(): + web_app = WebApp(config=Load().load_config()) + app.include_router(web_app.router) + uvicorn.run("unit3dup.web.main:app", host="127.0.0.1", port=8000, reload=True)