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/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"))})
|