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
unit3dprep/i18n.py ADDED
@@ -0,0 +1,151 @@
1
+ """Minimal i18n for user-facing API error messages.
2
+
3
+ The UI forwards the active locale in the `X-U3DP-Lang` header on every API
4
+ call; the backend falls back to the `U3DP_LANG` runtime setting when the
5
+ header is absent. Only API error `detail` strings are localized here — free
6
+ text coming from external processes (unit3dup, TMDB, hardlink OS errors) is
7
+ always echoed back untouched.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ from typing import Any
12
+
13
+ from .web import config as _config
14
+
15
+ SUPPORTED = ("it", "en")
16
+ DEFAULT = "it"
17
+
18
+ CATALOG: dict[str, dict[str, str]] = {
19
+ "err.path_not_found": {
20
+ "it": "Percorso non trovato",
21
+ "en": "Path not found",
22
+ },
23
+ "err.path_not_found_at": {
24
+ "it": "Percorso non trovato: {path}",
25
+ "en": "Path not found: {path}",
26
+ },
27
+ "err.path_not_allowed": {
28
+ "it": "Percorso non consentito",
29
+ "en": "Path not allowed",
30
+ },
31
+ "err.path_outside": {
32
+ "it": "Percorso fuori dalle directory consentite",
33
+ "en": "Path outside allowed directories",
34
+ },
35
+ "err.permission_denied": {
36
+ "it": "Permesso negato",
37
+ "en": "Permission denied",
38
+ },
39
+ "err.invalid_mode": {
40
+ "it": "Modalità non valida",
41
+ "en": "Invalid mode",
42
+ },
43
+ "err.invalid_kind": {
44
+ "it": "Tipo non valido",
45
+ "en": "Invalid kind",
46
+ },
47
+ "err.job_not_found": {
48
+ "it": "Job non trovato",
49
+ "en": "Job not found",
50
+ },
51
+ "err.no_active_process": {
52
+ "it": "Nessun processo attivo",
53
+ "en": "No active process",
54
+ },
55
+ "err.category_not_found": {
56
+ "it": "Categoria non trovata",
57
+ "en": "Category not found",
58
+ },
59
+ "err.item_not_found_in_category": {
60
+ "it": "'{name}' non trovato in {category}",
61
+ "en": "'{name}' not found in {category}",
62
+ },
63
+ "err.tmdb_api_key_missing": {
64
+ "it": "TMDB_API_KEY non impostata",
65
+ "en": "TMDB_API_KEY not set",
66
+ },
67
+ "err.tracker_unknown": {
68
+ "it": "Tracker sconosciuto '{tracker}'",
69
+ "en": "Unknown tracker '{tracker}'",
70
+ },
71
+ "err.reseed_not_implemented": {
72
+ "it": "Reseed per tracker id non implementato",
73
+ "en": "Reseed by tracker id not implemented",
74
+ },
75
+ "err.record_not_found": {
76
+ "it": "Record non trovato",
77
+ "en": "Record not found",
78
+ },
79
+ "err.wizard_session_expired": {
80
+ "it": "Sessione wizard non trovata o scaduta",
81
+ "en": "Wizard session not found or expired",
82
+ },
83
+ "err.episode_requires_file": {
84
+ "it": "Modalità episodio richiede un path di file",
85
+ "en": "Episode mode requires a file path",
86
+ },
87
+ "err.audio_check_not_passed": {
88
+ "it": "Controllo audio non superato",
89
+ "en": "Audio check not passed",
90
+ },
91
+ "err.tmdb_fetch_failed": {
92
+ "it": "Recupero TMDB fallito: {error}",
93
+ "en": "TMDB fetch failed: {error}",
94
+ },
95
+ "err.no_video_episode": {
96
+ "it": "Nessun file video trovato per modalità episodio",
97
+ "en": "No video file found for episode mode",
98
+ },
99
+ "err.no_video": {
100
+ "it": "Nessun file video trovato",
101
+ "en": "No video file found",
102
+ },
103
+ "err.hardlink_failed": {
104
+ "it": "Hardlink fallito: {error}",
105
+ "en": "Hardlink failed: {error}",
106
+ },
107
+ "err.invalid_password": {
108
+ "it": "Password non valida",
109
+ "en": "Invalid password",
110
+ },
111
+ "err.release_not_found": {
112
+ "it": "Release non trovata: {error}",
113
+ "en": "Release not found: {error}",
114
+ },
115
+ }
116
+
117
+
118
+ def _normalize(lang: str | None) -> str:
119
+ if not lang:
120
+ return DEFAULT
121
+ code = lang.strip().lower().split("-", 1)[0].split("_", 1)[0]
122
+ return code if code in SUPPORTED else DEFAULT
123
+
124
+
125
+ def t(key: str, lang: str | None = None, /, **fmt: Any) -> str:
126
+ """Resolve a catalog key to a localized string.
127
+
128
+ Falls back to `U3DP_LANG` runtime setting then to `DEFAULT` when `lang`
129
+ is None/unknown. Unknown keys return the key itself (defensive: never
130
+ crash the API over a missing translation).
131
+ """
132
+ resolved = _normalize(lang or _config.runtime_setting("U3DP_LANG", DEFAULT))
133
+ entry = CATALOG.get(key)
134
+ if entry is None:
135
+ return key.format(**fmt) if fmt else key
136
+ text = entry.get(resolved) or entry.get(DEFAULT) or key
137
+ return text.format(**fmt) if fmt else text
138
+
139
+
140
+ def get_request_lang(request: Any) -> str:
141
+ """Extract active locale from a FastAPI/Starlette Request.
142
+
143
+ Precedence: `X-U3DP-Lang` header → `U3DP_LANG` runtime setting → DEFAULT.
144
+ """
145
+ try:
146
+ hdr = request.headers.get("x-u3dp-lang") or request.headers.get("X-U3DP-Lang")
147
+ except Exception:
148
+ hdr = None
149
+ if hdr:
150
+ return _normalize(hdr)
151
+ return _normalize(_config.runtime_setting("U3DP_LANG", DEFAULT))
unit3dprep/media.py ADDED
@@ -0,0 +1,278 @@
1
+ """Library scanner for user-configured media root; categories auto-discovered."""
2
+ import re
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+
6
+ from .core import VIDEO_EXTENSIONS
7
+
8
+
9
+ def media_root() -> Path:
10
+ """Resolve media root at call time: env → shared .env → ~/media."""
11
+ from .web import config
12
+ return Path(config.runtime_setting("U3DP_MEDIA_ROOT", default=str(Path.home() / "media")))
13
+
14
+
15
+ def discover_categories() -> list[str]:
16
+ """Sorted subdirectory names of media_root(). Skips dotfiles & non-dirs."""
17
+ root = media_root()
18
+ if not root.exists() or not root.is_dir():
19
+ return []
20
+ try:
21
+ return sorted(
22
+ c.name for c in root.iterdir()
23
+ if c.is_dir() and not c.name.startswith(".")
24
+ )
25
+ except OSError:
26
+ return []
27
+
28
+
29
+ def format_size(size_bytes: int) -> str:
30
+ """Human-readable file size: GB / MB / KB."""
31
+ if size_bytes >= 1_073_741_824:
32
+ return f"{size_bytes / 1_073_741_824:.2f} GB"
33
+ if size_bytes >= 1_048_576:
34
+ return f"{size_bytes / 1_048_576:.1f} MB"
35
+ if size_bytes >= 1_024:
36
+ return f"{size_bytes / 1_024:.0f} KB"
37
+ return f"{size_bytes} B"
38
+
39
+
40
+ @dataclass
41
+ class Season:
42
+ number: int
43
+ label: str # "Season 01"
44
+ path: Path
45
+ video_files: list[Path] = field(default_factory=list)
46
+ already_uploaded: bool = False
47
+ uploaded_episode_paths: set[str] = field(default_factory=set)
48
+ # Lang cache fields (populated by routes)
49
+ available_langs: list = field(default_factory=list)
50
+ lang_scanned: bool = False
51
+
52
+ @property
53
+ def episode_count(self) -> int:
54
+ return len(self.video_files)
55
+
56
+ @property
57
+ def total_size(self) -> int:
58
+ total = 0
59
+ for f in self.video_files:
60
+ try:
61
+ total += f.stat().st_size
62
+ except OSError:
63
+ pass
64
+ return total
65
+
66
+ @property
67
+ def total_size_human(self) -> str:
68
+ return format_size(self.total_size)
69
+
70
+ @property
71
+ def has_italian(self) -> bool:
72
+ return "ITA" in self.available_langs
73
+
74
+ @property
75
+ def all_episodes_uploaded(self) -> bool:
76
+ episode_paths = {str(f.resolve()) for f in self.video_files}
77
+ return bool(episode_paths) and episode_paths.issubset(self.uploaded_episode_paths)
78
+
79
+ @property
80
+ def remaining_episode_count(self) -> int:
81
+ return max(0, self.episode_count - len(self.uploaded_episode_paths))
82
+
83
+
84
+ @dataclass
85
+ class MediaItem:
86
+ name: str
87
+ path: Path
88
+ category: str # movies | series | anime
89
+ kind: str # movie | series
90
+ seasons: list[Season] = field(default_factory=list)
91
+ video_files: list[Path] = field(default_factory=list)
92
+ # TMDB metadata (populated by routes from cache, not by scanner)
93
+ tmdb_id: str = ""
94
+ tmdb_kind: str = ""
95
+ tmdb_title: str = ""
96
+ tmdb_poster: str = ""
97
+ tmdb_overview: str = ""
98
+ # Upload status (populated by routes)
99
+ already_uploaded: bool = False
100
+ uploaded_season_numbers: list = field(default_factory=list)
101
+ # Lang cache fields (populated by routes)
102
+ available_langs: list = field(default_factory=list)
103
+ episode_langs: dict = field(default_factory=dict) # str(filepath) -> [langs]
104
+ lang_scanned: bool = False
105
+
106
+ @property
107
+ def year(self) -> str:
108
+ m = re.search(r'\((\d{4})\)', self.name)
109
+ return m.group(1) if m else ""
110
+
111
+ @property
112
+ def title(self) -> str:
113
+ return re.sub(r'\s*\(\d{4}\)\s*$', '', self.name).strip()
114
+
115
+ @property
116
+ def total_files(self) -> int:
117
+ if self.kind == "series":
118
+ return sum(s.episode_count for s in self.seasons)
119
+ return len(self.video_files)
120
+
121
+ @property
122
+ def total_size(self) -> int:
123
+ if self.kind == "series":
124
+ return sum(s.total_size for s in self.seasons)
125
+ total = 0
126
+ for f in self.video_files:
127
+ try:
128
+ total += f.stat().st_size
129
+ except OSError:
130
+ pass
131
+ return total
132
+
133
+ @property
134
+ def total_size_human(self) -> str:
135
+ return format_size(self.total_size)
136
+
137
+ @property
138
+ def has_italian(self) -> bool:
139
+ return "ITA" in self.available_langs
140
+
141
+ @property
142
+ def all_seasons_uploaded(self) -> bool:
143
+ if self.kind != "series":
144
+ return self.already_uploaded
145
+ if not self.seasons:
146
+ return False
147
+ return all(s.already_uploaded or s.all_episodes_uploaded for s in self.seasons)
148
+
149
+
150
+ def _iter_video(folder: Path) -> list[Path]:
151
+ return sorted(
152
+ f for f in folder.rglob("*")
153
+ if f.is_file() and f.suffix.lower() in VIDEO_EXTENSIONS
154
+ )
155
+
156
+
157
+ def _detect_kind(folder: Path, category: str) -> str:
158
+ if category == "series":
159
+ return "series"
160
+ if category == "movies":
161
+ return "movie"
162
+ # anime / user-added categories: has Season XX/ subfolder → series
163
+ for child in folder.iterdir():
164
+ if child.is_dir() and re.match(r'[Ss]eason\s+\d+', child.name):
165
+ return "series"
166
+ return "movie"
167
+
168
+
169
+ def _scan_seasons(folder: Path) -> list[Season]:
170
+ seasons = []
171
+ for child in sorted(folder.iterdir()):
172
+ if not child.is_dir():
173
+ continue
174
+ m = re.match(r'[Ss]eason\s+(\d+)', child.name)
175
+ if m:
176
+ num = int(m.group(1))
177
+ videos = _iter_video(child)
178
+ seasons.append(Season(
179
+ number=num,
180
+ label=child.name,
181
+ path=child,
182
+ video_files=videos,
183
+ ))
184
+ return seasons
185
+
186
+
187
+ def scan_category(category: str) -> list[MediaItem]:
188
+ base = media_root() / category
189
+ if not base.exists():
190
+ return []
191
+ items = []
192
+ for item_path in sorted(base.iterdir()):
193
+ if item_path.is_file():
194
+ if item_path.suffix.lower() not in VIDEO_EXTENSIONS:
195
+ continue
196
+ items.append(MediaItem(
197
+ name=item_path.stem,
198
+ path=item_path,
199
+ category=category,
200
+ kind="movie",
201
+ video_files=[item_path],
202
+ ))
203
+ elif item_path.is_dir():
204
+ kind = _detect_kind(item_path, category)
205
+ if kind == "series":
206
+ seasons = _scan_seasons(item_path)
207
+ items.append(MediaItem(
208
+ name=item_path.name,
209
+ path=item_path,
210
+ category=category,
211
+ kind="series",
212
+ seasons=seasons,
213
+ ))
214
+ else:
215
+ items.append(MediaItem(
216
+ name=item_path.name,
217
+ path=item_path,
218
+ category=category,
219
+ kind="movie",
220
+ video_files=_iter_video(item_path),
221
+ ))
222
+ return items
223
+
224
+
225
+ def get_item(category: str, item_name: str) -> MediaItem | None:
226
+ """Fetch single item by category + folder/file name."""
227
+ base = media_root() / category
228
+ target = base / item_name
229
+ if not target.exists():
230
+ # Try as file without extension
231
+ for ext in VIDEO_EXTENSIONS:
232
+ candidate = base / (item_name + ext)
233
+ if candidate.exists():
234
+ return MediaItem(
235
+ name=candidate.stem,
236
+ path=candidate,
237
+ category=category,
238
+ kind="movie",
239
+ video_files=[candidate],
240
+ )
241
+ return None
242
+ if target.is_file():
243
+ return MediaItem(
244
+ name=target.stem,
245
+ path=target,
246
+ category=category,
247
+ kind="movie",
248
+ video_files=[target],
249
+ )
250
+ kind = _detect_kind(target, category)
251
+ if kind == "series":
252
+ return MediaItem(
253
+ name=target.name,
254
+ path=target,
255
+ category=category,
256
+ kind="series",
257
+ seasons=_scan_seasons(target),
258
+ )
259
+ return MediaItem(
260
+ name=target.name,
261
+ path=target,
262
+ category=category,
263
+ kind="movie",
264
+ video_files=_iter_video(target),
265
+ )
266
+
267
+
268
+ def seedings_root() -> Path:
269
+ from .web import config
270
+ return Path(config.runtime_setting("U3DP_SEEDINGS_DIR", default=str(Path.home() / "seedings")))
271
+
272
+
273
+ def scan_seedings() -> list[Path]:
274
+ """List top-level items in the configured seedings dir."""
275
+ seedings = seedings_root()
276
+ if not seedings.exists():
277
+ return []
278
+ return sorted(seedings.iterdir())
unit3dprep/upload.py ADDED
@@ -0,0 +1,146 @@
1
+ """Pre-flight helpers used by the wizard + CLI.
2
+
3
+ Holds audio-check results, name-build helpers, and hardlink utilities. The
4
+ actual upload step is handled by `unit3dprep.web.webup_orchestrator` (HTTP
5
+ calls to Unit3DWebUp) — there is no longer any unit3dup CLI subprocess.
6
+
7
+ Hardlink layout: every upload lives in its own per-job sandbox directory
8
+ under `<seedings>/.unit3dprep/<jobid>/...`. This isolates each upload from
9
+ the rest of the seedings tree so Unit3DWebUp's `/scan` (which always
10
+ processes the entire SCAN_PATH) never re-scans unrelated files. The
11
+ sandbox path is deterministic from the final name, so re-uploading the
12
+ same item overwrites its sandbox cleanly.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import hashlib
17
+ import shutil
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ from guessit import guessit
22
+
23
+ from .core import (
24
+ seedings_dir,
25
+ build_name,
26
+ extract_specs,
27
+ format_se,
28
+ hardlink_file,
29
+ hardlink_tree,
30
+ has_italian_audio,
31
+ map_source,
32
+ )
33
+
34
+
35
+ _SANDBOX_PARENT = ".unit3dprep"
36
+
37
+
38
+ def _sandbox_id(key: str) -> str:
39
+ """Stable 8-char id derived from `key` (the final name).
40
+
41
+ Same final_name → same sandbox dir → re-uploads overwrite cleanly.
42
+ """
43
+ return hashlib.sha256(key.encode("utf-8")).hexdigest()[:8]
44
+
45
+
46
+ def _sandbox_dir(key: str) -> Path:
47
+ """Resolve the per-job sandbox directory inside the configured seedings root."""
48
+ seed = seedings_dir()
49
+ return seed / _SANDBOX_PARENT / _sandbox_id(key)
50
+
51
+
52
+ @dataclass
53
+ class AudioResult:
54
+ path: Path
55
+ has_italian: bool
56
+ error: str = ""
57
+
58
+
59
+ def check_audio_files(paths: list[Path]) -> list[AudioResult]:
60
+ results = []
61
+ for p in paths:
62
+ try:
63
+ ok = has_italian_audio(p)
64
+ results.append(AudioResult(path=p, has_italian=ok))
65
+ except Exception as e:
66
+ results.append(AudioResult(path=p, has_italian=False, error=str(e)))
67
+ return results
68
+
69
+
70
+ def build_episode_names(
71
+ series_folder: Path,
72
+ video_files: list[Path],
73
+ series_title: str,
74
+ year: str,
75
+ folder_guess: dict,
76
+ ) -> dict[Path, str]:
77
+ """Returns mapping file_path → new_base_name (no extension)."""
78
+ episode_rename: dict[Path, str] = {}
79
+ for f in video_files:
80
+ g = dict(guessit(f.name))
81
+ season = g.get("season")
82
+ if isinstance(season, list):
83
+ season = season[0]
84
+ episode = g.get("episode")
85
+ se = format_se(season, episode)
86
+ if not se:
87
+ continue
88
+ specs = extract_specs(f)
89
+ source, src_type = map_source(g)
90
+ tag = g.get("release_group", "") or folder_guess.get("release_group", "") or ""
91
+ new_name = build_name(
92
+ title=series_title, year="", se=se,
93
+ specs=specs, source=source, src_type=src_type, tag=tag,
94
+ )
95
+ episode_rename[f] = new_name
96
+ return episode_rename
97
+
98
+
99
+ def build_movie_name_from_file(
100
+ video_file: Path,
101
+ movie_title: str,
102
+ year: str,
103
+ ) -> str:
104
+ g = dict(guessit(video_file.name))
105
+ specs = extract_specs(video_file)
106
+ source, src_type = map_source(g)
107
+ tag = g.get("release_group", "") or ""
108
+ repack = "REPACK" if g.get("proper_count") else ""
109
+ return build_name(
110
+ title=movie_title, year=year, se="",
111
+ specs=specs, source=source, src_type=src_type, tag=tag, repack=repack,
112
+ )
113
+
114
+
115
+ def do_hardlink_movie(src: Path, final_name: str) -> Path:
116
+ """Hardlink a single movie file into its dedicated sandbox.
117
+
118
+ Layout: `<seedings>/.unit3dprep/<jobid>/<final_name>.<ext>`.
119
+ SCAN_PATH for webup will be the sandbox dir; webup sees one file → one
120
+ Media → no concurrent scan of unrelated seedings entries.
121
+ """
122
+ sandbox = _sandbox_dir(final_name)
123
+ sandbox.mkdir(parents=True, exist_ok=True)
124
+ target = sandbox / f"{final_name}{src.suffix.lower()}"
125
+ hardlink_file(src, target, overwrite=True)
126
+ return target
127
+
128
+
129
+ def do_hardlink_series(
130
+ src_dir: Path,
131
+ folder_name: str,
132
+ episode_rename: dict[Path, str],
133
+ ) -> Path:
134
+ """Hardlink an entire series/season pack into its dedicated sandbox.
135
+
136
+ Layout: `<seedings>/.unit3dprep/<jobid>/<folder_name>/<episodes>`.
137
+ SCAN_PATH for webup will be the sandbox dir; webup sees one subfolder
138
+ → one Media (recognized as a pack).
139
+ """
140
+ sandbox = _sandbox_dir(folder_name)
141
+ sandbox.mkdir(parents=True, exist_ok=True)
142
+ target_dir = sandbox / folder_name
143
+ if target_dir.exists():
144
+ shutil.rmtree(target_dir)
145
+ hardlink_tree(src_dir, target_dir, episode_rename)
146
+ return target_dir
File without changes
unit3dprep/web/_env.py ADDED
@@ -0,0 +1,40 @@
1
+ import logging
2
+ import os
3
+ from pathlib import Path
4
+
5
+ log = logging.getLogger("unit3dprep.env")
6
+
7
+ _warned: set[str] = set()
8
+
9
+
10
+ def env(new_key: str, legacy_key: str | None = None, default: str | None = None) -> str | None:
11
+ v = os.getenv(new_key)
12
+ if v is not None:
13
+ return v
14
+ if legacy_key:
15
+ v = os.getenv(legacy_key)
16
+ if v is not None:
17
+ if legacy_key not in _warned:
18
+ log.warning("Using legacy env var %s; rename to %s", legacy_key, new_key)
19
+ _warned.add(legacy_key)
20
+ return v
21
+ return default
22
+
23
+
24
+ _LEGACY_DOTFILES = {
25
+ ".itatorrents_db.json": ".unit3dprep_db.json",
26
+ ".itatorrents_tmdb_cache.json": ".unit3dprep_tmdb_cache.json",
27
+ ".itatorrents_lang_cache.json": ".unit3dprep_lang_cache.json",
28
+ }
29
+
30
+
31
+ def migrate_dotfiles(root: Path) -> None:
32
+ for old, new in _LEGACY_DOTFILES.items():
33
+ old_p = root / old
34
+ new_p = root / new
35
+ if old_p.exists() and not new_p.exists():
36
+ try:
37
+ old_p.rename(new_p)
38
+ log.warning("Migrated legacy dotfile %s -> %s", old, new)
39
+ except OSError as e:
40
+ log.error("Failed to migrate %s -> %s: %s", old, new, e)
@@ -0,0 +1 @@
1
+ """JSON API routers mounted under /api."""
@@ -0,0 +1,36 @@
1
+ """Auth: POST /api/auth/login, POST /api/auth/logout, GET /api/me."""
2
+ from __future__ import annotations
3
+
4
+ import secrets
5
+
6
+ from fastapi import APIRouter, HTTPException, Request
7
+ from fastapi.responses import JSONResponse
8
+ from pydantic import BaseModel
9
+
10
+ from ...i18n import get_request_lang, t
11
+ from ..auth import login_session, logout_session, verify_password
12
+
13
+ router = APIRouter(prefix="/api", tags=["auth"])
14
+
15
+
16
+ class LoginBody(BaseModel):
17
+ password: str
18
+
19
+
20
+ @router.post("/auth/login")
21
+ async def login(request: Request, body: LoginBody):
22
+ if not verify_password(body.password):
23
+ raise HTTPException(status_code=401, detail=t("err.invalid_password", get_request_lang(request)))
24
+ login_session(request, secrets.token_urlsafe(16))
25
+ return JSONResponse({"ok": True})
26
+
27
+
28
+ @router.post("/auth/logout")
29
+ async def logout(request: Request):
30
+ logout_session(request)
31
+ return JSONResponse({"ok": True})
32
+
33
+
34
+ @router.get("/me")
35
+ async def me(request: Request):
36
+ return JSONResponse({"authenticated": bool(request.session.get("authenticated"))})