unit3dprep 1.0.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- unit3dprep/__init__.py +0 -0
- unit3dprep/cli.py +256 -0
- unit3dprep/core.py +556 -0
- unit3dprep/i18n.py +151 -0
- unit3dprep/media.py +278 -0
- unit3dprep/upload.py +146 -0
- unit3dprep/web/__init__.py +0 -0
- unit3dprep/web/_env.py +40 -0
- unit3dprep/web/api/__init__.py +1 -0
- unit3dprep/web/api/auth.py +36 -0
- unit3dprep/web/api/fs.py +64 -0
- unit3dprep/web/api/library.py +503 -0
- unit3dprep/web/api/logs.py +42 -0
- unit3dprep/web/api/queue.py +54 -0
- unit3dprep/web/api/quickupload.py +153 -0
- unit3dprep/web/api/search.py +40 -0
- unit3dprep/web/api/settings.py +77 -0
- unit3dprep/web/api/tmdb.py +77 -0
- unit3dprep/web/api/trackers.py +30 -0
- unit3dprep/web/api/uploaded.py +26 -0
- unit3dprep/web/api/version.py +754 -0
- unit3dprep/web/api/webup.py +59 -0
- unit3dprep/web/api/wizard.py +512 -0
- unit3dprep/web/app.py +201 -0
- unit3dprep/web/auth.py +41 -0
- unit3dprep/web/clients.py +167 -0
- unit3dprep/web/config.py +932 -0
- unit3dprep/web/db.py +184 -0
- unit3dprep/web/dist/assets/JetBrainsMono-Italic-VariableFont_wght-CZO9PUqx.ttf +0 -0
- unit3dprep/web/dist/assets/JetBrainsMono-VariableFont_wght-BrlcHZ7m.ttf +0 -0
- unit3dprep/web/dist/assets/SpaceGrotesk-VariableFont_wght-DIScfSlK.ttf +0 -0
- unit3dprep/web/dist/assets/index-BizNr_oP.js +255 -0
- unit3dprep/web/dist/assets/index-DChRHChM.css +1 -0
- unit3dprep/web/dist/index.html +14 -0
- unit3dprep/web/duplicate_check.py +92 -0
- unit3dprep/web/lang_cache.py +118 -0
- unit3dprep/web/logbuf.py +164 -0
- unit3dprep/web/tmdb_cache.py +116 -0
- unit3dprep/web/trackers.py +177 -0
- unit3dprep/web/webup_client.py +190 -0
- unit3dprep/web/webup_job_fix.py +231 -0
- unit3dprep/web/webup_logclass.py +107 -0
- unit3dprep/web/webup_orchestrator.py +796 -0
- unit3dprep/web/webup_ws.py +141 -0
- unit3dprep-1.0.4.dist-info/METADATA +819 -0
- unit3dprep-1.0.4.dist-info/RECORD +50 -0
- unit3dprep-1.0.4.dist-info/WHEEL +5 -0
- unit3dprep-1.0.4.dist-info/entry_points.txt +3 -0
- unit3dprep-1.0.4.dist-info/licenses/LICENSE +674 -0
- unit3dprep-1.0.4.dist-info/top_level.txt +1 -0
unit3dprep/web/config.py
ADDED
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
"""unit3dprep config storage — single .env file shared with Unit3DWebUp 0.0.20+.
|
|
2
|
+
|
|
3
|
+
Path resolution for the .env file:
|
|
4
|
+
1. $U3DP_ENV_PATH (full file path, explicit override for unit3dprep)
|
|
5
|
+
2. $ENVPATH (directory, à la Unit3DWebUp; file = $ENVPATH/.env)
|
|
6
|
+
3. ~/.config/unit3dprep/.env (default, XDG-style)
|
|
7
|
+
|
|
8
|
+
On disk, keys understood by Unit3DWebUp are written with their canonical
|
|
9
|
+
``TRACKER__*`` / ``TORRENT__*`` / ``PREFS__*`` names so the same file can be
|
|
10
|
+
loaded by ``unit3dwup`` at startup. In Python and in the ``/api/settings``
|
|
11
|
+
payload we keep the short historical names (``ITT_APIKEY``, ``QBIT_HOST``…) —
|
|
12
|
+
translation is confined to this module.
|
|
13
|
+
|
|
14
|
+
Migration: at first ``load()`` the legacy ``Unit3Dbot.json`` (default
|
|
15
|
+
``~/Unit3Dup_config/Unit3Dbot.json``, override via ``$UNIT3DUP_CONFIG``) is
|
|
16
|
+
read once and re-written as the new ``.env``. The original is renamed to
|
|
17
|
+
``Unit3Dbot.json.migrated-bak`` (never deleted by us).
|
|
18
|
+
|
|
19
|
+
Writes are atomic (tempfile + rename) so neither unit3dprep nor webup
|
|
20
|
+
ever sees a half-written file.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import logging
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import tempfile
|
|
29
|
+
import threading
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from ._env import env as _env_get
|
|
34
|
+
|
|
35
|
+
log = logging.getLogger("unit3dprep.config")
|
|
36
|
+
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
# Path resolution
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
_DEFAULT_ENV_PATH = Path.home() / ".config" / "unit3dprep" / ".env"
|
|
42
|
+
_LEGACY_JSON_DEFAULT = Path.home() / "Unit3Dup_config" / "Unit3Dbot.json"
|
|
43
|
+
|
|
44
|
+
_lock = threading.Lock()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _resolve_env_path() -> Path:
|
|
48
|
+
explicit = os.environ.get("U3DP_ENV_PATH")
|
|
49
|
+
if explicit:
|
|
50
|
+
return Path(explicit).expanduser()
|
|
51
|
+
envpath = os.environ.get("ENVPATH")
|
|
52
|
+
if envpath:
|
|
53
|
+
return Path(envpath).expanduser() / ".env"
|
|
54
|
+
return _DEFAULT_ENV_PATH
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _legacy_json_path() -> Path:
|
|
58
|
+
return Path(
|
|
59
|
+
os.environ.get("UNIT3DUP_CONFIG", str(_LEGACY_JSON_DEFAULT))
|
|
60
|
+
).expanduser()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def config_path() -> Path:
|
|
64
|
+
"""Path to the unified .env (replaces the old Unit3Dbot.json path)."""
|
|
65
|
+
return _resolve_env_path()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def webup_envpath_dir() -> Path:
|
|
69
|
+
"""Directory to pass as ``ENVPATH=`` when launching Unit3DWebUp."""
|
|
70
|
+
return _resolve_env_path().parent
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# Legacy key upgrade (ITA_* → U3DP_*)
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
_LEGACY_KEY_MAP = {
|
|
78
|
+
"ITA_MEDIA_ROOT": "U3DP_MEDIA_ROOT",
|
|
79
|
+
"ITA_SEEDINGS_DIR": "U3DP_SEEDINGS_DIR",
|
|
80
|
+
"ITA_DB_PATH": "U3DP_DB_PATH",
|
|
81
|
+
"ITA_TMDB_CACHE_PATH": "U3DP_TMDB_CACHE_PATH",
|
|
82
|
+
"ITA_LANG_CACHE_PATH": "U3DP_LANG_CACHE_PATH",
|
|
83
|
+
"ITA_ROOT_PATH": "U3DP_ROOT_PATH",
|
|
84
|
+
"ITA_TMDB_LANG": "U3DP_TMDB_LANG",
|
|
85
|
+
"ITA_HOST": "U3DP_HOST",
|
|
86
|
+
"ITA_PORT": "U3DP_PORT",
|
|
87
|
+
"ITA_HTTPS_ONLY": "U3DP_HTTPS_ONLY",
|
|
88
|
+
"ITA_SYSTEMD_UNIT": "U3DP_SYSTEMD_UNIT",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _upgrade_legacy_keys(data: dict[str, Any]) -> dict[str, Any]:
|
|
93
|
+
for old, new in _LEGACY_KEY_MAP.items():
|
|
94
|
+
if old in data and new not in data:
|
|
95
|
+
data[new] = data.pop(old)
|
|
96
|
+
elif old in data:
|
|
97
|
+
data.pop(old)
|
|
98
|
+
return _ensure_season_in_serie(data)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _ensure_season_in_serie(data: dict[str, Any]) -> dict[str, Any]:
|
|
102
|
+
"""Guarantee ``"season"`` is present in TAG_ORDER_SERIE.
|
|
103
|
+
|
|
104
|
+
Webup stores the ``S<NN>(E<NN>)`` label under the ``season`` tag key and,
|
|
105
|
+
when building the tracker name, only emits tag keys that appear in
|
|
106
|
+
``PREFS__TAG_POSITION_SERIE``. A series tag order missing ``season`` thus
|
|
107
|
+
silently drops the season/episode number from the uploaded name even
|
|
108
|
+
though guessit parsed it correctly. Heal configs written before
|
|
109
|
+
``season`` was added to the default so existing installs recover on the
|
|
110
|
+
next load/save (and the corrected order is then pushed to webup).
|
|
111
|
+
"""
|
|
112
|
+
order = data.get("TAG_ORDER_SERIE")
|
|
113
|
+
if isinstance(order, list) and "season" not in order:
|
|
114
|
+
healed = list(order)
|
|
115
|
+
if "year" in healed:
|
|
116
|
+
idx = healed.index("year") + 1
|
|
117
|
+
elif "title" in healed:
|
|
118
|
+
idx = healed.index("title") + 1
|
|
119
|
+
else:
|
|
120
|
+
idx = 0
|
|
121
|
+
healed.insert(idx, "season")
|
|
122
|
+
data["TAG_ORDER_SERIE"] = healed
|
|
123
|
+
return data
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# Defaults & masking
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
DEFAULT_CONFIG: dict[str, Any] = {
|
|
131
|
+
"ITT_URL": "https://itatorrents.xyz",
|
|
132
|
+
"ITT_APIKEY": "",
|
|
133
|
+
"ITT_PID": "",
|
|
134
|
+
"PTT_URL": "https://polishtorrent.top",
|
|
135
|
+
"PTT_APIKEY": "no_key",
|
|
136
|
+
"PTT_PID": "no_key",
|
|
137
|
+
"SIS_URL": "https://no_tracker.xyz",
|
|
138
|
+
"SIS_APIKEY": "no_key",
|
|
139
|
+
"SIS_PID": "no_key",
|
|
140
|
+
"MULTI_TRACKER": ["itt"],
|
|
141
|
+
|
|
142
|
+
"TMDB_APIKEY": "",
|
|
143
|
+
"TVDB_APIKEY": "",
|
|
144
|
+
"YOUTUBE_KEY": "",
|
|
145
|
+
"IGDB_CLIENT_ID": "no_key",
|
|
146
|
+
"IGDB_ID_SECRET": "no_key",
|
|
147
|
+
|
|
148
|
+
"TORRENT_CLIENT": "qbittorrent",
|
|
149
|
+
"TAG": "ItaTorrentsBot",
|
|
150
|
+
"QBIT_HOST": "127.0.0.1",
|
|
151
|
+
"QBIT_PORT": "15491",
|
|
152
|
+
"QBIT_USER": "admin",
|
|
153
|
+
"QBIT_PASS": "",
|
|
154
|
+
"SHARED_QBIT_PATH": "",
|
|
155
|
+
"TRASM_HOST": "127.0.0.1",
|
|
156
|
+
"TRASM_PORT": "9091",
|
|
157
|
+
"TRASM_USER": "admin",
|
|
158
|
+
"TRASM_PASS": "no_pass",
|
|
159
|
+
"SHARED_TRASM_PATH": "no_path",
|
|
160
|
+
"RTORR_HOST": "127.0.0.1",
|
|
161
|
+
"RTORR_PORT": "9091",
|
|
162
|
+
"RTORR_USER": "admin",
|
|
163
|
+
"RTORR_PASS": "no_pass",
|
|
164
|
+
"SHARED_RTORR_PATH": "no_path",
|
|
165
|
+
|
|
166
|
+
"DUPLICATE_ON": True,
|
|
167
|
+
"SKIP_DUPLICATE": False,
|
|
168
|
+
"SKIP_TMDB": False,
|
|
169
|
+
"SKIP_YOUTUBE": False,
|
|
170
|
+
"ANON": False,
|
|
171
|
+
"PERSONAL_RELEASE": False,
|
|
172
|
+
"WEBP_ENABLED": False,
|
|
173
|
+
"CACHE_SCR": False,
|
|
174
|
+
"CACHE_DBONLINE": False,
|
|
175
|
+
"RESIZE_SCSHOT": False,
|
|
176
|
+
"YOUTUBE_CHANNEL_ENABLE": False,
|
|
177
|
+
"NUMBER_OF_SCREENSHOTS": 4,
|
|
178
|
+
"COMPRESS_SCSHOT": 3,
|
|
179
|
+
"SIZE_TH": 2,
|
|
180
|
+
"FAST_LOAD": 0,
|
|
181
|
+
"YOUTUBE_FAV_CHANNEL_ID": "",
|
|
182
|
+
"WATCHER_INTERVAL": 60,
|
|
183
|
+
"TORRENT_COMMENT": "",
|
|
184
|
+
# Webup `tags_service.mediainfo_audio` blocks upload (`can_upload=False`)
|
|
185
|
+
# when PREFERRED_LANG is not present among the audio tracks. Webup 0.0.25
|
|
186
|
+
# expects ISO 639-1 codes ("it"), not ISO 639-2 ("ita"); mediainfo's
|
|
187
|
+
# `language` field on each audio track is the 2-letter code, so anything
|
|
188
|
+
# else silently fails the match and the upload is dropped without logging.
|
|
189
|
+
# ItaTorrents is an Italian tracker; "it" is the right pre-flight default.
|
|
190
|
+
"PREFERRED_LANG": "it",
|
|
191
|
+
"RELEASER_SIGN": "",
|
|
192
|
+
|
|
193
|
+
"PTSCREENS_KEY": "",
|
|
194
|
+
"PASSIMA_KEY": "",
|
|
195
|
+
"IMGBB_KEY": "",
|
|
196
|
+
"IMGFI_KEY": "no_key",
|
|
197
|
+
"FREE_IMAGE_KEY": "no_key",
|
|
198
|
+
"LENSDUMP_KEY": "no_key",
|
|
199
|
+
"IMARIDE_KEY": "",
|
|
200
|
+
"IMAGE_HOST_ORDER": [
|
|
201
|
+
"PTSCREENS", "PASSIMA", "IMGBB", "IMGFI",
|
|
202
|
+
"FREE_IMAGE", "LENSDUMP", "IMARIDE",
|
|
203
|
+
],
|
|
204
|
+
|
|
205
|
+
"TORRENT_ARCHIVE_PATH": "",
|
|
206
|
+
"CACHE_PATH": "",
|
|
207
|
+
"WATCHER_PATH": "",
|
|
208
|
+
"WATCHER_DESTINATION_PATH": "",
|
|
209
|
+
"FTPX_IP": "127.0.0.1",
|
|
210
|
+
"FTPX_PORT": "2121",
|
|
211
|
+
"FTPX_USER": "user",
|
|
212
|
+
"FTPX_PASS": "pass",
|
|
213
|
+
"FTPX_LOCAL_PATH": ".",
|
|
214
|
+
"FTPX_ROOT": ".",
|
|
215
|
+
"FTPX_KEEP_ALIVE": False,
|
|
216
|
+
|
|
217
|
+
"TAG_ORDER_MOVIE": [
|
|
218
|
+
"title", "year", "part", "version", "resolution", "uhd",
|
|
219
|
+
"platform", "source", "remux", "multi", "acodec", "channels",
|
|
220
|
+
"flag", "subtitle", "hdr", "vcodec", "video_encoder",
|
|
221
|
+
],
|
|
222
|
+
"TAG_ORDER_SERIE": [
|
|
223
|
+
"title", "year", "season", "part", "version", "resolution", "uhd",
|
|
224
|
+
"platform", "source", "remux", "multi", "acodec", "channels",
|
|
225
|
+
"flag", "subtitle", "hdr", "vcodec", "video_encoder",
|
|
226
|
+
],
|
|
227
|
+
|
|
228
|
+
"NORMAL_COLOR": "blue bold",
|
|
229
|
+
"ERROR_COLOR": "red bold",
|
|
230
|
+
"QUESTION_MESSAGE_COLOR": "yellow",
|
|
231
|
+
"WELCOME_MESSAGE_COLOR": "blue",
|
|
232
|
+
"WELCOME_MESSAGE_BORDER_COLOR": "yellow",
|
|
233
|
+
"PANEL_MESSAGE_COLOR": "blue",
|
|
234
|
+
"PANEL_MESSAGE_BORDER_COLOR": "yellow",
|
|
235
|
+
"WELCOME_MESSAGE": "https://itatorrents.xyz",
|
|
236
|
+
|
|
237
|
+
# Seeding Flow — runtime settings (overridable by U3DP_* env vars).
|
|
238
|
+
"U3DP_MEDIA_ROOT": "",
|
|
239
|
+
"U3DP_SEEDINGS_DIR": "",
|
|
240
|
+
"U3DP_DB_PATH": "",
|
|
241
|
+
"U3DP_TMDB_CACHE_PATH": "",
|
|
242
|
+
"U3DP_LANG_CACHE_PATH": "",
|
|
243
|
+
"U3DP_ROOT_PATH": "",
|
|
244
|
+
"U3DP_TMDB_LANG": "it-IT",
|
|
245
|
+
"U3DP_HOST": "127.0.0.1",
|
|
246
|
+
"U3DP_PORT": "8765",
|
|
247
|
+
"U3DP_HTTPS_ONLY": False,
|
|
248
|
+
"U3DP_SYSTEMD_UNIT": "",
|
|
249
|
+
"U3DP_LANG": "it",
|
|
250
|
+
"U3DP_DRY_RUN_TRACKER": "0",
|
|
251
|
+
|
|
252
|
+
# Wizard Defaults — control default UI behaviour of the upload wizard.
|
|
253
|
+
"W_AUDIO_CHECK": True,
|
|
254
|
+
"W_AUTO_TMDB": True,
|
|
255
|
+
"W_HIDE_UPLOADED": True,
|
|
256
|
+
"W_HIDE_NO_ITALIAN": False,
|
|
257
|
+
"W_HARDLINK_ONLY": False,
|
|
258
|
+
"W_CONFIRM_NAMES": True,
|
|
259
|
+
"W_DUPLICATE_CHECK": True,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
MASKED_KEYS = {
|
|
263
|
+
"ITT_APIKEY", "ITT_PID", "PTT_APIKEY", "PTT_PID", "SIS_APIKEY", "SIS_PID",
|
|
264
|
+
"TMDB_APIKEY", "TVDB_APIKEY", "YOUTUBE_KEY",
|
|
265
|
+
"IGDB_CLIENT_ID", "IGDB_ID_SECRET",
|
|
266
|
+
"QBIT_PASS", "TRASM_PASS", "RTORR_PASS", "FTPX_PASS",
|
|
267
|
+
"PTSCREENS_KEY", "PASSIMA_KEY", "IMGBB_KEY", "IMGFI_KEY",
|
|
268
|
+
"FREE_IMAGE_KEY", "LENSDUMP_KEY", "IMARIDE_KEY",
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def mask_secrets(cfg: dict[str, Any]) -> dict[str, Any]:
|
|
273
|
+
"""Return a copy with secret values replaced by a fixed marker when set."""
|
|
274
|
+
masked: dict[str, Any] = {}
|
|
275
|
+
for k, v in cfg.items():
|
|
276
|
+
if k in MASKED_KEYS and v and v not in {"no_key", "no_pass"}:
|
|
277
|
+
masked[k] = "__SET__"
|
|
278
|
+
else:
|
|
279
|
+
masked[k] = v
|
|
280
|
+
return masked
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def merge_secrets(existing: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any]:
|
|
284
|
+
"""When the client sends ``__SET__`` for a masked key it means 'leave unchanged'.
|
|
285
|
+
|
|
286
|
+
Substitute the existing value so save() doesn't overwrite secrets with the marker.
|
|
287
|
+
"""
|
|
288
|
+
out = dict(incoming)
|
|
289
|
+
for k in MASKED_KEYS:
|
|
290
|
+
if out.get(k) == "__SET__":
|
|
291
|
+
out[k] = existing.get(k, "")
|
|
292
|
+
return out
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
# Webup canonical naming (TRACKER__/TORRENT__/PREFS__) ↔ short historical names
|
|
297
|
+
# ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
WEBUP_KEY_MAP: dict[str, str] = {
|
|
300
|
+
# Trackers
|
|
301
|
+
"ITT_URL": "TRACKER__ITT_URL",
|
|
302
|
+
"ITT_APIKEY": "TRACKER__ITT_APIKEY",
|
|
303
|
+
"ITT_PID": "TRACKER__ITT_PID",
|
|
304
|
+
"SIS_URL": "TRACKER__SIS_URL",
|
|
305
|
+
"SIS_APIKEY": "TRACKER__SIS_APIKEY",
|
|
306
|
+
"SIS_PID": "TRACKER__SIS_PID",
|
|
307
|
+
"MULTI_TRACKER": "TRACKER__MULTI_TRACKER",
|
|
308
|
+
# Metadata API
|
|
309
|
+
"TMDB_APIKEY": "TRACKER__TMDB_APIKEY",
|
|
310
|
+
"TVDB_APIKEY": "TRACKER__TVDB_APIKEY",
|
|
311
|
+
"YOUTUBE_KEY": "TRACKER__YOUTUBE_KEY",
|
|
312
|
+
"IGDB_CLIENT_ID": "TRACKER__IGDB_CLIENT_ID",
|
|
313
|
+
"IGDB_ID_SECRET": "TRACKER__IGDB_ID_SECRET",
|
|
314
|
+
# Image hosts
|
|
315
|
+
"IMGBB_KEY": "TRACKER__IMGBB_KEY",
|
|
316
|
+
"IMGFI_KEY": "TRACKER__IMGFI_KEY",
|
|
317
|
+
"PTSCREENS_KEY": "TRACKER__PTSCREENS_KEY",
|
|
318
|
+
"PASSIMA_KEY": "TRACKER__PASSIMA_KEY",
|
|
319
|
+
"LENSDUMP_KEY": "TRACKER__LENSDUMP_KEY",
|
|
320
|
+
"FREE_IMAGE_KEY": "TRACKER__FREE_IMAGE_KEY",
|
|
321
|
+
"IMARIDE_KEY": "TRACKER__IMARIDE_KEY",
|
|
322
|
+
# Torrent client
|
|
323
|
+
"TORRENT_CLIENT": "TORRENT__TORRENT_CLIENT",
|
|
324
|
+
"TAG": "TORRENT__TAG",
|
|
325
|
+
"QBIT_HOST": "TORRENT__QBIT_HOST",
|
|
326
|
+
"QBIT_PORT": "TORRENT__QBIT_PORT",
|
|
327
|
+
"QBIT_USER": "TORRENT__QBIT_USER",
|
|
328
|
+
"QBIT_PASS": "TORRENT__QBIT_PASS",
|
|
329
|
+
"SHARED_QBIT_PATH": "TORRENT__SHARED_QBIT_PATH",
|
|
330
|
+
"TRASM_HOST": "TORRENT__TRASM_HOST",
|
|
331
|
+
"TRASM_PORT": "TORRENT__TRASM_PORT",
|
|
332
|
+
"TRASM_USER": "TORRENT__TRASM_USER",
|
|
333
|
+
"TRASM_PASS": "TORRENT__TRASM_PASS",
|
|
334
|
+
"SHARED_TRASM_PATH": "TORRENT__SHARED_TRASM_PATH",
|
|
335
|
+
"RTORR_HOST": "TORRENT__RTORR_HOST",
|
|
336
|
+
"RTORR_PORT": "TORRENT__RTORR_PORT",
|
|
337
|
+
"RTORR_USER": "TORRENT__RTORR_USER",
|
|
338
|
+
"RTORR_PASS": "TORRENT__RTORR_PASS",
|
|
339
|
+
"SHARED_RTORR_PATH": "TORRENT__SHARED_RTORR_PATH",
|
|
340
|
+
# Behaviour flags
|
|
341
|
+
"ANON": "PREFS__ANON",
|
|
342
|
+
"PERSONAL_RELEASE": "PREFS__PERSONAL_RELEASE",
|
|
343
|
+
"DUPLICATE_ON": "PREFS__DUPLICATE_ON",
|
|
344
|
+
"SKIP_DUPLICATE": "PREFS__SKIP_DUPLICATE",
|
|
345
|
+
"SKIP_YOUTUBE": "PREFS__SKIP_YOUTUBE",
|
|
346
|
+
"WEBP_ENABLED": "PREFS__WEBP_ENABLED",
|
|
347
|
+
"YOUTUBE_CHANNEL_ENABLE": "PREFS__YOUTUBE_CHANNEL_ENABLE",
|
|
348
|
+
# Numbers
|
|
349
|
+
"NUMBER_OF_SCREENSHOTS": "PREFS__NUMBER_OF_SCREENSHOTS",
|
|
350
|
+
"COMPRESS_SCSHOT": "PREFS__COMPRESS_SCSHOT",
|
|
351
|
+
"SIZE_TH": "PREFS__SIZE_TH",
|
|
352
|
+
"FAST_LOAD": "PREFS__FAST_LOAD",
|
|
353
|
+
"WATCHER_INTERVAL": "PREFS__WATCHER_INTERVAL",
|
|
354
|
+
# Paths
|
|
355
|
+
"TORRENT_ARCHIVE_PATH": "PREFS__TORRENT_ARCHIVE_PATH",
|
|
356
|
+
"WATCHER_PATH": "PREFS__WATCHER_PATH",
|
|
357
|
+
"WATCHER_DESTINATION_PATH": "PREFS__WATCHER_DESTINATION_PATH",
|
|
358
|
+
# Misc
|
|
359
|
+
"RELEASER_SIGN": "PREFS__RELEASER_SIGN",
|
|
360
|
+
"TORRENT_COMMENT": "PREFS__TORRENT_COMMENT",
|
|
361
|
+
"PREFERRED_LANG": "PREFS__PREFERRED_LANG",
|
|
362
|
+
# Tag ordering — local TAG_ORDER_* ↔ webup PREFS__TAG_POSITION_*
|
|
363
|
+
"TAG_ORDER_MOVIE": "PREFS__TAG_POSITION_MOVIE",
|
|
364
|
+
"TAG_ORDER_SERIE": "PREFS__TAG_POSITION_SERIE",
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
_WEBUP_TO_SHORT: dict[str, str] = {v: k for k, v in WEBUP_KEY_MAP.items()}
|
|
368
|
+
|
|
369
|
+
# Image-host priority keys are written for webup but never read back:
|
|
370
|
+
# IMAGE_HOST_ORDER (a list) is the authoritative source for the order.
|
|
371
|
+
_IMAGE_HOSTS = (
|
|
372
|
+
"PTSCREENS", "PASSIMA", "IMGBB", "IMGFI",
|
|
373
|
+
"FREE_IMAGE", "LENSDUMP", "IMARIDE",
|
|
374
|
+
)
|
|
375
|
+
_PRIORITY_KEYS = {f"PREFS__{h}_PRIORITY" for h in _IMAGE_HOSTS}
|
|
376
|
+
|
|
377
|
+
# Webup's get_settings() does Path(settings.prefs.TORRENT_ARCHIVE_PATH) and
|
|
378
|
+
# Path.exists() at startup. If the value is None or empty webup raises
|
|
379
|
+
# SystemExit(1) and lru_caches the failure — meaning every following request
|
|
380
|
+
# returns 500. Our config uses "" as "unset"; on disk we must materialize
|
|
381
|
+
# something webup can call Path() on. "." (the bot's CWD) is the same
|
|
382
|
+
# fallback the upstream .env(example) ships with.
|
|
383
|
+
_WEBUP_REQUIRED_PATH_KEYS = {
|
|
384
|
+
"TORRENT_ARCHIVE_PATH",
|
|
385
|
+
"WATCHER_PATH",
|
|
386
|
+
"WATCHER_DESTINATION_PATH",
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _stringify_value(v: Any) -> str | None:
|
|
391
|
+
"""Env-string form for a webup-mapped value, or ``None`` to skip pushing.
|
|
392
|
+
|
|
393
|
+
Webup's pydantic ``empty_to_none`` validator converts empty strings to
|
|
394
|
+
``None`` and then crashes ``Settings()`` rebuild for required ``str``
|
|
395
|
+
fields. We skip empty / ``no_key`` / ``no_pass`` / ``no_path`` /
|
|
396
|
+
``no_comment`` sentinels so webup keeps its own defaults.
|
|
397
|
+
|
|
398
|
+
Lists are JSON-serialized — pydantic-settings v2 calls ``json.loads``
|
|
399
|
+
on env-var values for ``list[str]`` fields rebuilt from ``os.environ``.
|
|
400
|
+
"""
|
|
401
|
+
if isinstance(v, bool):
|
|
402
|
+
return "true" if v else "false"
|
|
403
|
+
if v is None:
|
|
404
|
+
return None
|
|
405
|
+
if isinstance(v, (list, tuple)):
|
|
406
|
+
items = [str(x).strip() for x in v if str(x).strip()]
|
|
407
|
+
return json.dumps(items) if items else None
|
|
408
|
+
s = str(v)
|
|
409
|
+
if not s.strip():
|
|
410
|
+
return None
|
|
411
|
+
if s in {"no_key", "no_pass", "no_path", "no_comment"}:
|
|
412
|
+
return None
|
|
413
|
+
return s
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _stringify_value_passthrough(v: Any) -> str:
|
|
417
|
+
"""Env-string form for local-only keys — always emit, even if empty."""
|
|
418
|
+
if isinstance(v, bool):
|
|
419
|
+
return "true" if v else "false"
|
|
420
|
+
if v is None:
|
|
421
|
+
return ""
|
|
422
|
+
if isinstance(v, (list, tuple)):
|
|
423
|
+
return json.dumps(list(v))
|
|
424
|
+
return str(v)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _image_host_priorities(order: list[str]) -> dict[str, str]:
|
|
428
|
+
"""Project IMAGE_HOST_ORDER list → individual ``PREFS__<HOST>_PRIORITY``.
|
|
429
|
+
|
|
430
|
+
Webup picks image hosts by numeric priority (lower = tried first). Hosts
|
|
431
|
+
not in the list go to the back (priority 99) so empty-key hosts (notably
|
|
432
|
+
Lensdump, default 0) don't get tried first.
|
|
433
|
+
"""
|
|
434
|
+
out: dict[str, str] = {}
|
|
435
|
+
known = set(_IMAGE_HOSTS)
|
|
436
|
+
for i, host in enumerate(order):
|
|
437
|
+
h = str(host).strip().upper()
|
|
438
|
+
if h in known:
|
|
439
|
+
out[f"PREFS__{h}_PRIORITY"] = str(i)
|
|
440
|
+
for h in known:
|
|
441
|
+
key = f"PREFS__{h}_PRIORITY"
|
|
442
|
+
if key not in out:
|
|
443
|
+
out[key] = "99"
|
|
444
|
+
return out
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# ---------------------------------------------------------------------------
|
|
448
|
+
# .env parser / dumper
|
|
449
|
+
# ---------------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
_KV_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$")
|
|
452
|
+
_NEEDS_QUOTE_RE = re.compile(r"[\s#'\"]|^$")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _parse_env_file(path: Path) -> dict[str, str]:
|
|
456
|
+
out: dict[str, str] = {}
|
|
457
|
+
if not path.exists():
|
|
458
|
+
return out
|
|
459
|
+
try:
|
|
460
|
+
text = path.read_text(encoding="utf-8")
|
|
461
|
+
except OSError:
|
|
462
|
+
return out
|
|
463
|
+
for raw in text.splitlines():
|
|
464
|
+
line = raw.strip()
|
|
465
|
+
if not line or line.startswith("#"):
|
|
466
|
+
continue
|
|
467
|
+
m = _KV_RE.match(line)
|
|
468
|
+
if not m:
|
|
469
|
+
continue
|
|
470
|
+
k, v = m.group(1), m.group(2)
|
|
471
|
+
if len(v) >= 2 and v[0] == v[-1] and v[0] in ("'", '"'):
|
|
472
|
+
inner = v[1:-1]
|
|
473
|
+
if v[0] == '"':
|
|
474
|
+
inner = inner.replace('\\"', '"').replace("\\\\", "\\")
|
|
475
|
+
v = inner
|
|
476
|
+
out[k] = v
|
|
477
|
+
return out
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _quote_value(v: str) -> str:
|
|
481
|
+
if v == "":
|
|
482
|
+
return '""'
|
|
483
|
+
if not _NEEDS_QUOTE_RE.search(v):
|
|
484
|
+
return v
|
|
485
|
+
# Prefer single quotes when the value contains double quotes (e.g. JSON
|
|
486
|
+
# arrays) so we avoid backslash-escaping and keep the .env human-readable.
|
|
487
|
+
# python-dotenv (used by pydantic-settings v2) takes single-quoted values
|
|
488
|
+
# literally without escape processing — perfect for JSON.
|
|
489
|
+
if '"' in v and "'" not in v:
|
|
490
|
+
return f"'{v}'"
|
|
491
|
+
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
|
|
492
|
+
return f'"{escaped}"'
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
_GROUPS: list[tuple[str, list[str]]] = [
|
|
496
|
+
("Trackers", [
|
|
497
|
+
"TRACKER__ITT_URL", "TRACKER__ITT_APIKEY", "TRACKER__ITT_PID",
|
|
498
|
+
"TRACKER__SIS_URL", "TRACKER__SIS_APIKEY", "TRACKER__SIS_PID",
|
|
499
|
+
"TRACKER__MULTI_TRACKER",
|
|
500
|
+
"PTT_URL", "PTT_APIKEY", "PTT_PID",
|
|
501
|
+
]),
|
|
502
|
+
("Metadata API", [
|
|
503
|
+
"TRACKER__TMDB_APIKEY", "TRACKER__TVDB_APIKEY", "TRACKER__YOUTUBE_KEY",
|
|
504
|
+
"TRACKER__IGDB_CLIENT_ID", "TRACKER__IGDB_ID_SECRET",
|
|
505
|
+
]),
|
|
506
|
+
("Image hosts", [
|
|
507
|
+
"TRACKER__PTSCREENS_KEY", "TRACKER__PASSIMA_KEY", "TRACKER__IMGBB_KEY",
|
|
508
|
+
"TRACKER__IMGFI_KEY", "TRACKER__FREE_IMAGE_KEY", "TRACKER__LENSDUMP_KEY",
|
|
509
|
+
"TRACKER__IMARIDE_KEY",
|
|
510
|
+
"IMAGE_HOST_ORDER",
|
|
511
|
+
"PREFS__PTSCREENS_PRIORITY", "PREFS__PASSIMA_PRIORITY", "PREFS__IMGBB_PRIORITY",
|
|
512
|
+
"PREFS__IMGFI_PRIORITY", "PREFS__FREE_IMAGE_PRIORITY", "PREFS__LENSDUMP_PRIORITY",
|
|
513
|
+
"PREFS__IMARIDE_PRIORITY",
|
|
514
|
+
]),
|
|
515
|
+
("Torrent client", [
|
|
516
|
+
"TORRENT__TORRENT_CLIENT", "TORRENT__TAG",
|
|
517
|
+
"TORRENT__QBIT_HOST", "TORRENT__QBIT_PORT", "TORRENT__QBIT_USER",
|
|
518
|
+
"TORRENT__QBIT_PASS", "TORRENT__SHARED_QBIT_PATH",
|
|
519
|
+
"TORRENT__TRASM_HOST", "TORRENT__TRASM_PORT", "TORRENT__TRASM_USER",
|
|
520
|
+
"TORRENT__TRASM_PASS", "TORRENT__SHARED_TRASM_PATH",
|
|
521
|
+
"TORRENT__RTORR_HOST", "TORRENT__RTORR_PORT", "TORRENT__RTORR_USER",
|
|
522
|
+
"TORRENT__RTORR_PASS", "TORRENT__SHARED_RTORR_PATH",
|
|
523
|
+
]),
|
|
524
|
+
("Behavior", [
|
|
525
|
+
"PREFS__ANON", "PREFS__PERSONAL_RELEASE",
|
|
526
|
+
"PREFS__DUPLICATE_ON", "PREFS__SKIP_DUPLICATE", "PREFS__SKIP_YOUTUBE",
|
|
527
|
+
"PREFS__WEBP_ENABLED", "PREFS__YOUTUBE_CHANNEL_ENABLE",
|
|
528
|
+
"PREFS__NUMBER_OF_SCREENSHOTS", "PREFS__COMPRESS_SCSHOT",
|
|
529
|
+
"PREFS__SIZE_TH", "PREFS__FAST_LOAD", "PREFS__WATCHER_INTERVAL",
|
|
530
|
+
"PREFS__RELEASER_SIGN", "PREFS__TORRENT_COMMENT", "PREFS__PREFERRED_LANG",
|
|
531
|
+
"PREFS__TAG_POSITION_MOVIE", "PREFS__TAG_POSITION_SERIE",
|
|
532
|
+
"SKIP_TMDB", "CACHE_SCR", "CACHE_DBONLINE", "RESIZE_SCSHOT",
|
|
533
|
+
"YOUTUBE_FAV_CHANNEL_ID",
|
|
534
|
+
]),
|
|
535
|
+
("Paths", [
|
|
536
|
+
"PREFS__TORRENT_ARCHIVE_PATH", "PREFS__WATCHER_PATH",
|
|
537
|
+
"PREFS__WATCHER_DESTINATION_PATH",
|
|
538
|
+
"CACHE_PATH",
|
|
539
|
+
"FTPX_IP", "FTPX_PORT", "FTPX_USER", "FTPX_PASS",
|
|
540
|
+
"FTPX_LOCAL_PATH", "FTPX_ROOT", "FTPX_KEEP_ALIVE",
|
|
541
|
+
]),
|
|
542
|
+
("Console (legacy unit3dup)", [
|
|
543
|
+
"NORMAL_COLOR", "ERROR_COLOR", "QUESTION_MESSAGE_COLOR",
|
|
544
|
+
"WELCOME_MESSAGE_COLOR", "WELCOME_MESSAGE_BORDER_COLOR",
|
|
545
|
+
"PANEL_MESSAGE_COLOR", "PANEL_MESSAGE_BORDER_COLOR",
|
|
546
|
+
"WELCOME_MESSAGE",
|
|
547
|
+
]),
|
|
548
|
+
("Runtime (unit3dprep)", [
|
|
549
|
+
"U3DP_HOST", "U3DP_PORT", "U3DP_HTTPS_ONLY", "U3DP_ROOT_PATH",
|
|
550
|
+
"U3DP_LANG", "U3DP_TMDB_LANG",
|
|
551
|
+
"U3DP_MEDIA_ROOT", "U3DP_SEEDINGS_DIR",
|
|
552
|
+
"U3DP_DB_PATH", "U3DP_TMDB_CACHE_PATH", "U3DP_LANG_CACHE_PATH",
|
|
553
|
+
"U3DP_SYSTEMD_UNIT", "U3DP_DRY_RUN_TRACKER",
|
|
554
|
+
]),
|
|
555
|
+
("Wizard defaults (unit3dprep)", [
|
|
556
|
+
"W_AUDIO_CHECK", "W_AUTO_TMDB", "W_HIDE_UPLOADED",
|
|
557
|
+
"W_HIDE_NO_ITALIAN", "W_HARDLINK_ONLY", "W_CONFIRM_NAMES",
|
|
558
|
+
"W_DUPLICATE_CHECK",
|
|
559
|
+
]),
|
|
560
|
+
]
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _dump_env_file(items: dict[str, str]) -> str:
|
|
564
|
+
seen: set[str] = set()
|
|
565
|
+
lines: list[str] = ["# Generated by unit3dprep — shared with Unit3DWebUp."]
|
|
566
|
+
for header, keys in _GROUPS:
|
|
567
|
+
section_lines: list[str] = []
|
|
568
|
+
for k in keys:
|
|
569
|
+
if k not in items:
|
|
570
|
+
continue
|
|
571
|
+
section_lines.append(f"{k}={_quote_value(items[k])}")
|
|
572
|
+
seen.add(k)
|
|
573
|
+
if section_lines:
|
|
574
|
+
lines.append("")
|
|
575
|
+
lines.append(f"# {header}")
|
|
576
|
+
lines.extend(section_lines)
|
|
577
|
+
extras = sorted(set(items) - seen)
|
|
578
|
+
if extras:
|
|
579
|
+
lines.append("")
|
|
580
|
+
lines.append("# Other")
|
|
581
|
+
for k in extras:
|
|
582
|
+
lines.append(f"{k}={_quote_value(items[k])}")
|
|
583
|
+
return "\n".join(lines) + "\n"
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
# ---------------------------------------------------------------------------
|
|
587
|
+
# Canonical (on-disk) ↔ short (in-memory) translation
|
|
588
|
+
# ---------------------------------------------------------------------------
|
|
589
|
+
|
|
590
|
+
def _coerce_value(default_val: Any, raw: str) -> Any:
|
|
591
|
+
if isinstance(default_val, bool):
|
|
592
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
593
|
+
if isinstance(default_val, int) and not isinstance(default_val, bool):
|
|
594
|
+
try:
|
|
595
|
+
return int(raw)
|
|
596
|
+
except ValueError:
|
|
597
|
+
return default_val
|
|
598
|
+
if isinstance(default_val, list):
|
|
599
|
+
s = raw.strip()
|
|
600
|
+
if s.startswith("["):
|
|
601
|
+
try:
|
|
602
|
+
v = json.loads(s)
|
|
603
|
+
if isinstance(v, list):
|
|
604
|
+
return v
|
|
605
|
+
except json.JSONDecodeError:
|
|
606
|
+
pass
|
|
607
|
+
return [x.strip() for x in s.split(",") if x.strip()]
|
|
608
|
+
return raw
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _canonical_to_short(env_dict: dict[str, str]) -> dict[str, Any]:
|
|
612
|
+
out: dict[str, Any] = {}
|
|
613
|
+
for canonical, raw in env_dict.items():
|
|
614
|
+
if canonical in _PRIORITY_KEYS:
|
|
615
|
+
continue # write-only for webup; IMAGE_HOST_ORDER is authoritative
|
|
616
|
+
short = _WEBUP_TO_SHORT.get(canonical, canonical)
|
|
617
|
+
if short in DEFAULT_CONFIG:
|
|
618
|
+
out[short] = _coerce_value(DEFAULT_CONFIG[short], raw)
|
|
619
|
+
else:
|
|
620
|
+
out[short] = raw
|
|
621
|
+
return _upgrade_legacy_keys(out)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _short_to_canonical(cfg: dict[str, Any]) -> dict[str, str]:
|
|
625
|
+
out: dict[str, str] = {}
|
|
626
|
+
for short, val in cfg.items():
|
|
627
|
+
if short in WEBUP_KEY_MAP:
|
|
628
|
+
sv = _stringify_value(val)
|
|
629
|
+
if sv is None and short in _WEBUP_REQUIRED_PATH_KEYS:
|
|
630
|
+
sv = str(Path.home())
|
|
631
|
+
if sv is not None:
|
|
632
|
+
out[WEBUP_KEY_MAP[short]] = sv
|
|
633
|
+
else:
|
|
634
|
+
out[short] = _stringify_value_passthrough(val)
|
|
635
|
+
order = cfg.get("IMAGE_HOST_ORDER")
|
|
636
|
+
if isinstance(order, list) and order:
|
|
637
|
+
out.update(_image_host_priorities(order))
|
|
638
|
+
# Webup needs PREFS__SCAN_PATH at boot; the orchestrator overrides it
|
|
639
|
+
# per-upload via /setenv but the file must always carry a valid default.
|
|
640
|
+
out.setdefault("PREFS__SCAN_PATH", ".")
|
|
641
|
+
return out
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
# ---------------------------------------------------------------------------
|
|
645
|
+
# First-boot bootstrap from environment variables (e.g. Docker config.env)
|
|
646
|
+
# ---------------------------------------------------------------------------
|
|
647
|
+
|
|
648
|
+
# Config short keys that may be seeded from an identically-named env var the
|
|
649
|
+
# first time the .env is created. Kept to unambiguous, deploy-relevant keys
|
|
650
|
+
# (tracker creds, metadata APIs, qBit client) to avoid clashing with generic
|
|
651
|
+
# environment names. Anything else is configured through the web UI afterwards.
|
|
652
|
+
_BOOTSTRAP_ENV_KEYS = {
|
|
653
|
+
"ITT_URL", "ITT_APIKEY", "ITT_PID",
|
|
654
|
+
"TMDB_APIKEY", "TVDB_APIKEY",
|
|
655
|
+
"QBIT_HOST", "QBIT_PORT", "QBIT_USER", "QBIT_PASS",
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
# Friendlier / historical env names → internal short key. ``TMDB_API_KEY`` is
|
|
659
|
+
# the name unit3dprep already reads for its own TMDB calls; accept it here too
|
|
660
|
+
# so a single env var feeds both unit3dprep and webup's TMDB_APIKEY.
|
|
661
|
+
_BOOTSTRAP_ENV_ALIASES = {
|
|
662
|
+
"TMDB_API_KEY": "TMDB_APIKEY",
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def bootstrap_env_overrides() -> dict[str, Any]:
|
|
667
|
+
"""Read seedable config values from the environment (first-boot only).
|
|
668
|
+
|
|
669
|
+
Returns short-key → coerced-value for every recognised, non-empty env var.
|
|
670
|
+
"""
|
|
671
|
+
out: dict[str, Any] = {}
|
|
672
|
+
for key in _BOOTSTRAP_ENV_KEYS:
|
|
673
|
+
val = os.environ.get(key)
|
|
674
|
+
if val:
|
|
675
|
+
out[key] = _coerce_value(DEFAULT_CONFIG.get(key, ""), val)
|
|
676
|
+
for env_name, short in _BOOTSTRAP_ENV_ALIASES.items():
|
|
677
|
+
val = os.environ.get(env_name)
|
|
678
|
+
if val:
|
|
679
|
+
out[short] = _coerce_value(DEFAULT_CONFIG.get(short, ""), val)
|
|
680
|
+
return out
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def seed_initial_env() -> list[str]:
|
|
684
|
+
"""Create the shared .env from defaults + env overrides if it's absent.
|
|
685
|
+
|
|
686
|
+
Used by the Docker entrypoint on first boot so API keys / client settings
|
|
687
|
+
supplied via env vars (config.env) land in the .env that Unit3DWebUp reads
|
|
688
|
+
at startup. No-op (returns ``[]``) if the .env already exists, so it never
|
|
689
|
+
clobbers values the operator later changes through the web UI.
|
|
690
|
+
|
|
691
|
+
Returns the list of short keys seeded from the environment.
|
|
692
|
+
"""
|
|
693
|
+
if _resolve_env_path().exists():
|
|
694
|
+
return []
|
|
695
|
+
overrides = bootstrap_env_overrides()
|
|
696
|
+
cfg = dict(DEFAULT_CONFIG)
|
|
697
|
+
cfg.update(overrides)
|
|
698
|
+
save(cfg)
|
|
699
|
+
return sorted(overrides)
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
# ---------------------------------------------------------------------------
|
|
703
|
+
# load() / save() / migration
|
|
704
|
+
# ---------------------------------------------------------------------------
|
|
705
|
+
|
|
706
|
+
def _migrate_json_to_env() -> bool:
|
|
707
|
+
"""Best-effort one-shot migration of legacy Unit3Dbot.json → .env.
|
|
708
|
+
|
|
709
|
+
Returns True if a new .env was just written by us. Idempotent: if the
|
|
710
|
+
backup already exists we don't touch anything.
|
|
711
|
+
"""
|
|
712
|
+
json_path = _legacy_json_path()
|
|
713
|
+
bak_path = json_path.with_name(json_path.name + ".migrated-bak")
|
|
714
|
+
if bak_path.exists():
|
|
715
|
+
if json_path.exists():
|
|
716
|
+
log.warning(
|
|
717
|
+
"Found both %s and existing backup %s — leaving JSON in place. "
|
|
718
|
+
"Delete one to disambiguate.", json_path, bak_path,
|
|
719
|
+
)
|
|
720
|
+
return False
|
|
721
|
+
if not json_path.exists():
|
|
722
|
+
return False
|
|
723
|
+
try:
|
|
724
|
+
with json_path.open("r", encoding="utf-8") as f:
|
|
725
|
+
data = json.load(f)
|
|
726
|
+
except (json.JSONDecodeError, OSError) as e:
|
|
727
|
+
log.warning("Failed to migrate legacy JSON %s: %s", json_path, e)
|
|
728
|
+
return False
|
|
729
|
+
data = _upgrade_legacy_keys(data)
|
|
730
|
+
merged = dict(DEFAULT_CONFIG)
|
|
731
|
+
merged.update(data)
|
|
732
|
+
save(merged)
|
|
733
|
+
try:
|
|
734
|
+
os.rename(json_path, bak_path)
|
|
735
|
+
log.warning(
|
|
736
|
+
"Migrated %s → %s; backup saved at %s",
|
|
737
|
+
json_path, _resolve_env_path(), bak_path,
|
|
738
|
+
)
|
|
739
|
+
except OSError as e:
|
|
740
|
+
log.error("Migration succeeded but rename failed: %s", e)
|
|
741
|
+
return True
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
_TRACKER_URL_KEYS = {"ITT_URL", "PTT_URL", "SIS_URL"}
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _normalize_tracker_urls(cfg: dict[str, Any]) -> dict[str, Any]:
|
|
748
|
+
"""Strip trailing slashes from tracker base URLs.
|
|
749
|
+
|
|
750
|
+
Webup builds the announce URL by appending ``/announce/<pid>`` to
|
|
751
|
+
``TRACKER__<X>_URL``. A trailing slash on the configured URL produces
|
|
752
|
+
``https://tracker.tld//announce/<pid>`` which the tracker rejects with
|
|
753
|
+
404, leaving qBittorrent unable to register and the torrent silently
|
|
754
|
+
invisible on the site even though webup's ``/upload`` returned 200.
|
|
755
|
+
"""
|
|
756
|
+
for k in _TRACKER_URL_KEYS:
|
|
757
|
+
v = cfg.get(k)
|
|
758
|
+
if isinstance(v, str):
|
|
759
|
+
stripped = v.rstrip("/")
|
|
760
|
+
if stripped != v:
|
|
761
|
+
cfg[k] = stripped
|
|
762
|
+
return cfg
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def load() -> dict[str, Any]:
|
|
766
|
+
env_path = _resolve_env_path()
|
|
767
|
+
if not env_path.exists():
|
|
768
|
+
_migrate_json_to_env()
|
|
769
|
+
if not env_path.exists():
|
|
770
|
+
return dict(DEFAULT_CONFIG)
|
|
771
|
+
with _lock:
|
|
772
|
+
raw = _parse_env_file(env_path)
|
|
773
|
+
short = _canonical_to_short(raw)
|
|
774
|
+
merged = dict(DEFAULT_CONFIG)
|
|
775
|
+
merged.update(short)
|
|
776
|
+
return _normalize_tracker_urls(merged)
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def save(cfg: dict[str, Any]) -> None:
|
|
780
|
+
cfg = _upgrade_legacy_keys(dict(cfg))
|
|
781
|
+
cfg = _normalize_tracker_urls(cfg)
|
|
782
|
+
canonical = _short_to_canonical(cfg)
|
|
783
|
+
text = _dump_env_file(canonical)
|
|
784
|
+
env_path = _resolve_env_path()
|
|
785
|
+
env_path.parent.mkdir(parents=True, exist_ok=True)
|
|
786
|
+
with _lock:
|
|
787
|
+
fd, tmp = tempfile.mkstemp(prefix=".env.", suffix=".tmp", dir=str(env_path.parent))
|
|
788
|
+
try:
|
|
789
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
790
|
+
f.write(text)
|
|
791
|
+
os.replace(tmp, env_path)
|
|
792
|
+
finally:
|
|
793
|
+
if os.path.exists(tmp):
|
|
794
|
+
os.unlink(tmp)
|
|
795
|
+
# Best-effort: mirror to webup .env via /setenv. Runs in the background
|
|
796
|
+
# if an event loop is available (i.e. called from a request handler).
|
|
797
|
+
try:
|
|
798
|
+
import asyncio
|
|
799
|
+
loop = asyncio.get_event_loop()
|
|
800
|
+
if loop.is_running():
|
|
801
|
+
from .webup_client import get_client
|
|
802
|
+
asyncio.create_task(sync_to_webup(get_client(), dict(cfg)))
|
|
803
|
+
except Exception:
|
|
804
|
+
pass
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
# ---------------------------------------------------------------------------
|
|
808
|
+
# Runtime settings — env > .env file > _RUNTIME_DEFAULTS
|
|
809
|
+
# ---------------------------------------------------------------------------
|
|
810
|
+
|
|
811
|
+
_RUNTIME_DEFAULTS: dict[str, str] = {
|
|
812
|
+
"U3DP_MEDIA_ROOT": str(Path.home() / "media"),
|
|
813
|
+
"U3DP_SEEDINGS_DIR": str(Path.home() / "seedings"),
|
|
814
|
+
"U3DP_DB_PATH": str(Path.home() / ".unit3dprep_db.json"),
|
|
815
|
+
"U3DP_TMDB_CACHE_PATH": str(Path.home() / ".unit3dprep_tmdb_cache.json"),
|
|
816
|
+
"U3DP_LANG_CACHE_PATH": str(Path.home() / ".unit3dprep_lang_cache.json"),
|
|
817
|
+
"U3DP_ROOT_PATH": "",
|
|
818
|
+
"U3DP_TMDB_LANG": "it-IT",
|
|
819
|
+
"U3DP_HOST": "127.0.0.1",
|
|
820
|
+
"U3DP_PORT": "8765",
|
|
821
|
+
"U3DP_HTTPS_ONLY": "0",
|
|
822
|
+
"U3DP_SYSTEMD_UNIT": "unit3dprep-web.service",
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def _legacy_env_key(key: str) -> str | None:
|
|
827
|
+
for legacy, new in _LEGACY_KEY_MAP.items():
|
|
828
|
+
if new == key:
|
|
829
|
+
return legacy
|
|
830
|
+
return None
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def runtime_setting(key: str, default: str | None = None) -> str:
|
|
834
|
+
"""Resolve a U3DP_* runtime setting.
|
|
835
|
+
|
|
836
|
+
Precedence: env var (new → legacy ITA_*) → .env file → _RUNTIME_DEFAULTS.
|
|
837
|
+
|
|
838
|
+
Called on every access so settings saved through the web UI take effect
|
|
839
|
+
without restarting the server.
|
|
840
|
+
"""
|
|
841
|
+
env_val = _env_get(key, _legacy_env_key(key))
|
|
842
|
+
if env_val is not None and env_val != "":
|
|
843
|
+
return env_val
|
|
844
|
+
cfg = load()
|
|
845
|
+
cfg_val = cfg.get(key, "")
|
|
846
|
+
if isinstance(cfg_val, bool):
|
|
847
|
+
cfg_val = "1" if cfg_val else "0"
|
|
848
|
+
if cfg_val:
|
|
849
|
+
return str(cfg_val)
|
|
850
|
+
if default is not None:
|
|
851
|
+
return default
|
|
852
|
+
return _RUNTIME_DEFAULTS.get(key, "")
|
|
853
|
+
|
|
854
|
+
|
|
855
|
+
def env_runtime() -> dict[str, str]:
|
|
856
|
+
"""Effective values shown in the Seeding Flow settings section."""
|
|
857
|
+
p = _resolve_env_path()
|
|
858
|
+
return {
|
|
859
|
+
"U3DP_HOST": runtime_setting("U3DP_HOST"),
|
|
860
|
+
"U3DP_PORT": runtime_setting("U3DP_PORT"),
|
|
861
|
+
"U3DP_ROOT_PATH": runtime_setting("U3DP_ROOT_PATH"),
|
|
862
|
+
"U3DP_TMDB_LANG": runtime_setting("U3DP_TMDB_LANG"),
|
|
863
|
+
"U3DP_HTTPS_ONLY": runtime_setting("U3DP_HTTPS_ONLY"),
|
|
864
|
+
"U3DP_DB_PATH": runtime_setting("U3DP_DB_PATH"),
|
|
865
|
+
"U3DP_TMDB_CACHE_PATH": runtime_setting("U3DP_TMDB_CACHE_PATH"),
|
|
866
|
+
"U3DP_LANG_CACHE_PATH": runtime_setting("U3DP_LANG_CACHE_PATH"),
|
|
867
|
+
"U3DP_MEDIA_ROOT": runtime_setting("U3DP_MEDIA_ROOT"),
|
|
868
|
+
"U3DP_SEEDINGS_DIR": runtime_setting("U3DP_SEEDINGS_DIR"),
|
|
869
|
+
"U3DP_SYSTEMD_UNIT": runtime_setting("U3DP_SYSTEMD_UNIT"),
|
|
870
|
+
"U3DP_ENV_PATH": str(p),
|
|
871
|
+
"WEBUP_ENVPATH_DIR": str(p.parent),
|
|
872
|
+
"UNIT3DUP_CONFIG_LEGACY": str(_legacy_json_path()),
|
|
873
|
+
"WEBUP_URL": runtime_setting("WEBUP_URL", "http://127.0.0.1:8000"),
|
|
874
|
+
"WEBUP_REPO_PATH": runtime_setting("WEBUP_REPO_PATH", str(Path.home() / "dev" / "Unit3DWebUp")),
|
|
875
|
+
"WEBUP_SYSTEMD_UNIT": runtime_setting("WEBUP_SYSTEMD_UNIT", "unit3dwebup.service"),
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
env_readonly = env_runtime
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
# ---------------------------------------------------------------------------
|
|
883
|
+
# Webup runtime sync (live, no restart) via POST /setenv
|
|
884
|
+
# ---------------------------------------------------------------------------
|
|
885
|
+
|
|
886
|
+
def _to_webup_env_payload(cfg: dict[str, Any]) -> dict[str, str]:
|
|
887
|
+
"""Subset of cfg ready to be POSTed to webup ``/setenv`` one key at a time."""
|
|
888
|
+
out: dict[str, str] = {}
|
|
889
|
+
for k_local, k_remote in WEBUP_KEY_MAP.items():
|
|
890
|
+
if k_local not in cfg:
|
|
891
|
+
continue
|
|
892
|
+
sv = _stringify_value(cfg[k_local])
|
|
893
|
+
if sv is None:
|
|
894
|
+
continue
|
|
895
|
+
out[k_remote] = sv
|
|
896
|
+
order = cfg.get("IMAGE_HOST_ORDER")
|
|
897
|
+
if isinstance(order, list) and order:
|
|
898
|
+
out.update(_image_host_priorities(order))
|
|
899
|
+
return out
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
async def sync_to_webup(client: Any, diff: dict[str, Any] | None = None) -> dict[str, str]:
|
|
903
|
+
"""Push (a subset of) settings to webup runtime via /setenv.
|
|
904
|
+
|
|
905
|
+
The persistent .env on disk is shared, but webup only re-reads it at
|
|
906
|
+
startup. /setenv updates ``os.environ`` so the next ``Settings()``
|
|
907
|
+
rebuild picks up changes without restarting webup.
|
|
908
|
+
"""
|
|
909
|
+
cfg = load() if diff is None else diff
|
|
910
|
+
payload = _to_webup_env_payload(cfg)
|
|
911
|
+
pushed: dict[str, str] = {}
|
|
912
|
+
for k, v in payload.items():
|
|
913
|
+
try:
|
|
914
|
+
await client.setenv(k, v)
|
|
915
|
+
pushed[k] = v
|
|
916
|
+
except Exception:
|
|
917
|
+
continue
|
|
918
|
+
return pushed
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
async def bootstrap_webup_env(client: Any) -> dict[str, str]:
|
|
922
|
+
"""Wait for webup health, then push the full mapped subset once."""
|
|
923
|
+
import asyncio
|
|
924
|
+
for attempt in range(6):
|
|
925
|
+
try:
|
|
926
|
+
h = await client.health(force=True)
|
|
927
|
+
except Exception:
|
|
928
|
+
h = {"ok": False}
|
|
929
|
+
if h.get("ok"):
|
|
930
|
+
return await sync_to_webup(client)
|
|
931
|
+
await asyncio.sleep(min(2 * (attempt + 1), 30))
|
|
932
|
+
return {}
|