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.
Files changed (50) hide show
  1. unit3dprep/__init__.py +0 -0
  2. unit3dprep/cli.py +256 -0
  3. unit3dprep/core.py +556 -0
  4. unit3dprep/i18n.py +151 -0
  5. unit3dprep/media.py +278 -0
  6. unit3dprep/upload.py +146 -0
  7. unit3dprep/web/__init__.py +0 -0
  8. unit3dprep/web/_env.py +40 -0
  9. unit3dprep/web/api/__init__.py +1 -0
  10. unit3dprep/web/api/auth.py +36 -0
  11. unit3dprep/web/api/fs.py +64 -0
  12. unit3dprep/web/api/library.py +503 -0
  13. unit3dprep/web/api/logs.py +42 -0
  14. unit3dprep/web/api/queue.py +54 -0
  15. unit3dprep/web/api/quickupload.py +153 -0
  16. unit3dprep/web/api/search.py +40 -0
  17. unit3dprep/web/api/settings.py +77 -0
  18. unit3dprep/web/api/tmdb.py +77 -0
  19. unit3dprep/web/api/trackers.py +30 -0
  20. unit3dprep/web/api/uploaded.py +26 -0
  21. unit3dprep/web/api/version.py +754 -0
  22. unit3dprep/web/api/webup.py +59 -0
  23. unit3dprep/web/api/wizard.py +512 -0
  24. unit3dprep/web/app.py +201 -0
  25. unit3dprep/web/auth.py +41 -0
  26. unit3dprep/web/clients.py +167 -0
  27. unit3dprep/web/config.py +932 -0
  28. unit3dprep/web/db.py +184 -0
  29. unit3dprep/web/dist/assets/JetBrainsMono-Italic-VariableFont_wght-CZO9PUqx.ttf +0 -0
  30. unit3dprep/web/dist/assets/JetBrainsMono-VariableFont_wght-BrlcHZ7m.ttf +0 -0
  31. unit3dprep/web/dist/assets/SpaceGrotesk-VariableFont_wght-DIScfSlK.ttf +0 -0
  32. unit3dprep/web/dist/assets/index-BizNr_oP.js +255 -0
  33. unit3dprep/web/dist/assets/index-DChRHChM.css +1 -0
  34. unit3dprep/web/dist/index.html +14 -0
  35. unit3dprep/web/duplicate_check.py +92 -0
  36. unit3dprep/web/lang_cache.py +118 -0
  37. unit3dprep/web/logbuf.py +164 -0
  38. unit3dprep/web/tmdb_cache.py +116 -0
  39. unit3dprep/web/trackers.py +177 -0
  40. unit3dprep/web/webup_client.py +190 -0
  41. unit3dprep/web/webup_job_fix.py +231 -0
  42. unit3dprep/web/webup_logclass.py +107 -0
  43. unit3dprep/web/webup_orchestrator.py +796 -0
  44. unit3dprep/web/webup_ws.py +141 -0
  45. unit3dprep-1.0.4.dist-info/METADATA +819 -0
  46. unit3dprep-1.0.4.dist-info/RECORD +50 -0
  47. unit3dprep-1.0.4.dist-info/WHEEL +5 -0
  48. unit3dprep-1.0.4.dist-info/entry_points.txt +3 -0
  49. unit3dprep-1.0.4.dist-info/licenses/LICENSE +674 -0
  50. unit3dprep-1.0.4.dist-info/top_level.txt +1 -0
@@ -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 {}