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
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Unit3DWebUp bridge endpoints.
|
|
2
|
+
|
|
3
|
+
- GET /api/webup/health → reachability + version readout
|
|
4
|
+
- POST /api/webup/sync → push shared .env mapped subset to webup runtime via /setenv
|
|
5
|
+
- GET /api/webup/setting → proxy webup's /setting (read user preferences)
|
|
6
|
+
- POST /api/webup/filter → proxy webup's /filter (search tracker for title)
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from fastapi import APIRouter, Request
|
|
11
|
+
from fastapi.responses import JSONResponse
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
from ..config import bootstrap_webup_env
|
|
15
|
+
from ..webup_client import get_client
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
router = APIRouter(prefix="/api", tags=["webup"])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@router.get("/webup/health")
|
|
22
|
+
async def health(request: Request):
|
|
23
|
+
client = getattr(request.app.state, "webup", None) or get_client()
|
|
24
|
+
info = await client.health(force=True)
|
|
25
|
+
info["base_url"] = client.base
|
|
26
|
+
ws = getattr(request.app.state, "webup_ws", None)
|
|
27
|
+
info["ws_connected"] = bool(ws and ws.connected)
|
|
28
|
+
return JSONResponse(info)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@router.post("/webup/sync")
|
|
32
|
+
async def sync(request: Request):
|
|
33
|
+
client = getattr(request.app.state, "webup", None) or get_client()
|
|
34
|
+
pushed = await bootstrap_webup_env(client)
|
|
35
|
+
return JSONResponse({"pushed": pushed, "count": len(pushed)})
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@router.get("/webup/setting")
|
|
39
|
+
async def setting(request: Request):
|
|
40
|
+
client = getattr(request.app.state, "webup", None) or get_client()
|
|
41
|
+
try:
|
|
42
|
+
data = await client.setting()
|
|
43
|
+
return JSONResponse(data)
|
|
44
|
+
except Exception as e:
|
|
45
|
+
return JSONResponse({"error": str(e)}, status_code=502)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class FilterBody(BaseModel):
|
|
49
|
+
title: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@router.post("/webup/filter")
|
|
53
|
+
async def filter_search(request: Request, body: FilterBody):
|
|
54
|
+
client = getattr(request.app.state, "webup", None) or get_client()
|
|
55
|
+
try:
|
|
56
|
+
data = await client.filter_search(body.title)
|
|
57
|
+
return JSONResponse(data)
|
|
58
|
+
except Exception as e:
|
|
59
|
+
return JSONResponse({"error": str(e)}, status_code=502)
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
"""Wizard upload flow: audio → TMDB → names → hardlink → upload (SSE)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, AsyncGenerator
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
13
|
+
from fastapi.responses import JSONResponse
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
from sse_starlette.sse import EventSourceResponse
|
|
16
|
+
|
|
17
|
+
from ...core import (
|
|
18
|
+
extract_specs,
|
|
19
|
+
has_italian_audio,
|
|
20
|
+
iter_video_files,
|
|
21
|
+
map_source,
|
|
22
|
+
tmdb_fetch_bilingual,
|
|
23
|
+
tmdb_poster_url,
|
|
24
|
+
tmdb_year,
|
|
25
|
+
build_name,
|
|
26
|
+
)
|
|
27
|
+
from ...upload import (
|
|
28
|
+
build_episode_names,
|
|
29
|
+
build_movie_name_from_file,
|
|
30
|
+
do_hardlink_movie,
|
|
31
|
+
do_hardlink_series,
|
|
32
|
+
)
|
|
33
|
+
from ...i18n import get_request_lang, t as _i18n_t
|
|
34
|
+
from .. import config as web_config
|
|
35
|
+
from ..db import record_upload, update_exit_code
|
|
36
|
+
from ..duplicate_check import find_duplicate
|
|
37
|
+
from ..logbuf import emit as log_emit
|
|
38
|
+
from ..webup_orchestrator import stream_webup
|
|
39
|
+
|
|
40
|
+
TMDB_API_KEY = os.environ.get("TMDB_API_KEY", "")
|
|
41
|
+
|
|
42
|
+
router = APIRouter(prefix="/api", tags=["wizard"])
|
|
43
|
+
|
|
44
|
+
_sessions: dict[str, dict[str, Any]] = {}
|
|
45
|
+
_created: dict[str, float] = {}
|
|
46
|
+
_TTL = 3600
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _cleanup():
|
|
50
|
+
now = time.time()
|
|
51
|
+
for t in [t for t, ct in _created.items() if now - ct > _TTL]:
|
|
52
|
+
_sessions.pop(t, None)
|
|
53
|
+
_created.pop(t, None)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _create(state: dict[str, Any]) -> str:
|
|
57
|
+
_cleanup()
|
|
58
|
+
tok = secrets.token_urlsafe(24)
|
|
59
|
+
_sessions[tok] = state
|
|
60
|
+
_created[tok] = time.time()
|
|
61
|
+
return tok
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _get(tok: str, lang: str | None = None) -> dict[str, Any]:
|
|
65
|
+
s = _sessions.get(tok)
|
|
66
|
+
if s is None:
|
|
67
|
+
raise HTTPException(404, _i18n_t("err.wizard_session_expired", lang))
|
|
68
|
+
return s
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _validate_path(p: str, lang: str | None = None) -> Path:
|
|
72
|
+
from ...media import media_root, seedings_root
|
|
73
|
+
resolved = Path(p).resolve()
|
|
74
|
+
allowed = [media_root().resolve(), seedings_root().resolve()]
|
|
75
|
+
if not any(str(resolved).startswith(str(a)) for a in allowed):
|
|
76
|
+
raise HTTPException(403, _i18n_t("err.path_outside", lang))
|
|
77
|
+
if not resolved.exists():
|
|
78
|
+
raise HTTPException(404, _i18n_t("err.path_not_found_at", lang, path=str(resolved)))
|
|
79
|
+
return resolved
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Bodies
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class StartBody(BaseModel):
|
|
88
|
+
path: str
|
|
89
|
+
category: str
|
|
90
|
+
kind: str # movie|series|episode
|
|
91
|
+
tmdb_id: str = ""
|
|
92
|
+
tmdb_kind: str = ""
|
|
93
|
+
hardlink_only: bool = False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TmdbBody(BaseModel):
|
|
97
|
+
tmdb_id: str
|
|
98
|
+
tmdb_kind: str = "movie"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class NamesBody(BaseModel):
|
|
102
|
+
final_names: dict[str, str]
|
|
103
|
+
folder_name: str = ""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
# Routes
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@router.post("/wizard/start")
|
|
112
|
+
async def wizard_start(request: Request, body: StartBody):
|
|
113
|
+
lang = get_request_lang(request)
|
|
114
|
+
p = _validate_path(body.path, lang)
|
|
115
|
+
if body.kind not in {"movie", "series", "episode"}:
|
|
116
|
+
raise HTTPException(400, _i18n_t("err.invalid_kind", lang))
|
|
117
|
+
if body.kind == "episode" and not p.is_file():
|
|
118
|
+
raise HTTPException(400, _i18n_t("err.episode_requires_file", lang))
|
|
119
|
+
state: dict[str, Any] = {
|
|
120
|
+
"path": str(p),
|
|
121
|
+
"category": body.category,
|
|
122
|
+
"kind": body.kind,
|
|
123
|
+
"step": "audio",
|
|
124
|
+
"audio_ok": False,
|
|
125
|
+
"audio_override": False,
|
|
126
|
+
"tmdb_id": body.tmdb_id.strip(),
|
|
127
|
+
"tmdb_kind": body.tmdb_kind or ("tv" if body.kind != "movie" else "movie"),
|
|
128
|
+
"tmdb_title": "",
|
|
129
|
+
"tmdb_year": "",
|
|
130
|
+
"tmdb_poster": "",
|
|
131
|
+
"tmdb_overview": "",
|
|
132
|
+
"final_names": {},
|
|
133
|
+
"folder_name": "",
|
|
134
|
+
"seeding_path": "",
|
|
135
|
+
"upload_done": False,
|
|
136
|
+
"exit_code": None,
|
|
137
|
+
"hardlink_only": body.hardlink_only,
|
|
138
|
+
"duplicate": None,
|
|
139
|
+
"duplicate_confirmed": False,
|
|
140
|
+
}
|
|
141
|
+
tok = _create(state)
|
|
142
|
+
return JSONResponse({"token": tok, "state": state})
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@router.get("/wizard/{tok}")
|
|
146
|
+
async def wizard_state(tok: str):
|
|
147
|
+
return JSONResponse(_get(tok))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@router.get("/wizard/{tok}/audio")
|
|
151
|
+
async def wizard_audio(tok: str):
|
|
152
|
+
state = _get(tok)
|
|
153
|
+
path = Path(state["path"])
|
|
154
|
+
files = [path] if path.is_file() else list(iter_video_files(path))
|
|
155
|
+
|
|
156
|
+
async def generate() -> AsyncGenerator[dict, None]:
|
|
157
|
+
loop = asyncio.get_event_loop()
|
|
158
|
+
all_ok = True
|
|
159
|
+
for f in files:
|
|
160
|
+
try:
|
|
161
|
+
ok = await loop.run_in_executor(None, has_italian_audio, f)
|
|
162
|
+
payload = {"file": f.name, "ok": ok}
|
|
163
|
+
except Exception as e:
|
|
164
|
+
ok = False
|
|
165
|
+
payload = {"file": f.name, "ok": False, "error": str(e)}
|
|
166
|
+
if not ok:
|
|
167
|
+
all_ok = False
|
|
168
|
+
yield {"event": "file_result", "data": json.dumps(payload)}
|
|
169
|
+
await asyncio.sleep(0)
|
|
170
|
+
state["audio_ok"] = all_ok
|
|
171
|
+
state["step"] = "tmdb" if all_ok else "audio_failed"
|
|
172
|
+
yield {"event": "done", "data": json.dumps({"all_ok": all_ok, "total": len(files)})}
|
|
173
|
+
|
|
174
|
+
return EventSourceResponse(generate())
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@router.post("/wizard/{tok}/audio-override")
|
|
178
|
+
async def wizard_audio_override(tok: str):
|
|
179
|
+
state = _get(tok)
|
|
180
|
+
state["audio_ok"] = True
|
|
181
|
+
state["audio_override"] = True
|
|
182
|
+
state["step"] = "tmdb"
|
|
183
|
+
return JSONResponse({"ok": True})
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@router.post("/wizard/{tok}/tmdb")
|
|
187
|
+
async def wizard_tmdb(request: Request, tok: str, body: TmdbBody):
|
|
188
|
+
lang = get_request_lang(request)
|
|
189
|
+
state = _get(tok, lang)
|
|
190
|
+
if not state["audio_ok"]:
|
|
191
|
+
raise HTTPException(400, _i18n_t("err.audio_check_not_passed", lang))
|
|
192
|
+
loop = asyncio.get_event_loop()
|
|
193
|
+
try:
|
|
194
|
+
data = await loop.run_in_executor(
|
|
195
|
+
None, tmdb_fetch_bilingual, body.tmdb_kind, body.tmdb_id, TMDB_API_KEY
|
|
196
|
+
)
|
|
197
|
+
except Exception as e:
|
|
198
|
+
raise HTTPException(502, _i18n_t("err.tmdb_fetch_failed", lang, error=str(e)))
|
|
199
|
+
title = data.get("title") or ""
|
|
200
|
+
year = tmdb_year(data, body.tmdb_kind)
|
|
201
|
+
state["tmdb_id"] = body.tmdb_id
|
|
202
|
+
state["tmdb_kind"] = body.tmdb_kind
|
|
203
|
+
state["tmdb_title"] = title
|
|
204
|
+
state["tmdb_year"] = year
|
|
205
|
+
state["tmdb_poster"] = tmdb_poster_url(data)
|
|
206
|
+
state["tmdb_overview"] = (data.get("overview") or "")[:300]
|
|
207
|
+
state["step"] = "names"
|
|
208
|
+
|
|
209
|
+
proposed = await _build_proposed_names(state)
|
|
210
|
+
state["final_names"] = proposed
|
|
211
|
+
return JSONResponse({
|
|
212
|
+
"ok": True,
|
|
213
|
+
"tmdb": {
|
|
214
|
+
"title": title,
|
|
215
|
+
"title_en": data.get("title_en", ""),
|
|
216
|
+
"original_title": data.get("original_title", ""),
|
|
217
|
+
"year": year,
|
|
218
|
+
"overview": state["tmdb_overview"],
|
|
219
|
+
"overview_en": (data.get("overview_en") or "")[:300],
|
|
220
|
+
"poster": state["tmdb_poster"],
|
|
221
|
+
},
|
|
222
|
+
"proposed": proposed,
|
|
223
|
+
"folder_name": state["folder_name"],
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
async def _build_proposed_names(state: dict[str, Any]) -> dict[str, str]:
|
|
228
|
+
from guessit import guessit as _guessit
|
|
229
|
+
loop = asyncio.get_event_loop()
|
|
230
|
+
path = Path(state["path"])
|
|
231
|
+
kind = state["kind"]
|
|
232
|
+
title = state["tmdb_title"]
|
|
233
|
+
year = state["tmdb_year"]
|
|
234
|
+
if kind == "movie":
|
|
235
|
+
files = [path] if path.is_file() else list(iter_video_files(path))
|
|
236
|
+
proposed: dict[str, str] = {}
|
|
237
|
+
for vf in files:
|
|
238
|
+
name = await loop.run_in_executor(
|
|
239
|
+
None, build_movie_name_from_file, vf, title, year
|
|
240
|
+
)
|
|
241
|
+
proposed[str(vf)] = name
|
|
242
|
+
return proposed
|
|
243
|
+
if kind == "episode":
|
|
244
|
+
files = [path] if path.is_file() else list(iter_video_files(path))
|
|
245
|
+
if not files:
|
|
246
|
+
raise HTTPException(400, _i18n_t("err.no_video_episode"))
|
|
247
|
+
episode_file = files[0]
|
|
248
|
+
season_folder = episode_file.parent
|
|
249
|
+
folder_guess = dict(_guessit(season_folder.name))
|
|
250
|
+
result = await loop.run_in_executor(
|
|
251
|
+
None,
|
|
252
|
+
lambda: {str(k): v for k, v in build_episode_names(
|
|
253
|
+
season_folder, [episode_file], title, year, folder_guess
|
|
254
|
+
).items()},
|
|
255
|
+
)
|
|
256
|
+
if not result:
|
|
257
|
+
fallback = await loop.run_in_executor(
|
|
258
|
+
None, build_movie_name_from_file, episode_file, title, ""
|
|
259
|
+
)
|
|
260
|
+
result = {str(episode_file): fallback}
|
|
261
|
+
return result
|
|
262
|
+
# series
|
|
263
|
+
folder_guess = dict(_guessit(path.name))
|
|
264
|
+
files = list(iter_video_files(path))
|
|
265
|
+
result = await loop.run_in_executor(
|
|
266
|
+
None,
|
|
267
|
+
lambda: {str(k): v for k, v in build_episode_names(
|
|
268
|
+
path, files, title, year, folder_guess
|
|
269
|
+
).items()},
|
|
270
|
+
)
|
|
271
|
+
if files:
|
|
272
|
+
first = files[0]
|
|
273
|
+
g = dict(_guessit(first.name))
|
|
274
|
+
specs = extract_specs(first)
|
|
275
|
+
source, src_type = map_source(g)
|
|
276
|
+
tag = g.get("release_group", "") or folder_guess.get("release_group", "") or ""
|
|
277
|
+
# Season-pack folder name: include "S<NN>" right after the title.
|
|
278
|
+
# Prefer the season inferred from the first episode's filename;
|
|
279
|
+
# fall back to the folder's own guessit (`Season 1`, `S01`, etc.).
|
|
280
|
+
season = g.get("season") if g.get("season") is not None else folder_guess.get("season")
|
|
281
|
+
if isinstance(season, list):
|
|
282
|
+
season = season[0] if season else None
|
|
283
|
+
season_label = f"S{int(season):02d}" if season is not None else ""
|
|
284
|
+
folder_nm = build_name(
|
|
285
|
+
title=title, year="", se=season_label,
|
|
286
|
+
specs=specs, source=source, src_type=src_type, tag=tag,
|
|
287
|
+
)
|
|
288
|
+
state["folder_name"] = folder_nm
|
|
289
|
+
return result
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@router.post("/wizard/{tok}/names")
|
|
293
|
+
async def wizard_names(tok: str, body: NamesBody):
|
|
294
|
+
state = _get(tok)
|
|
295
|
+
state["final_names"] = {k: v.strip() for k, v in body.final_names.items()}
|
|
296
|
+
if body.folder_name:
|
|
297
|
+
state["folder_name"] = body.folder_name.strip()
|
|
298
|
+
state["step"] = "duplicate_check"
|
|
299
|
+
return JSONResponse({"ok": True})
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _primary_source_size(state: dict[str, Any]) -> int | None:
|
|
303
|
+
"""Bytes of the file we're about to upload (None for season packs)."""
|
|
304
|
+
if state["kind"] not in {"movie", "episode"}:
|
|
305
|
+
return None
|
|
306
|
+
path = Path(state["path"])
|
|
307
|
+
src = path if path.is_file() else next(iter(iter_video_files(path)), None)
|
|
308
|
+
if src is None:
|
|
309
|
+
return None
|
|
310
|
+
try:
|
|
311
|
+
return src.stat().st_size
|
|
312
|
+
except OSError:
|
|
313
|
+
return None
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@router.post("/wizard/{tok}/duplicate-check")
|
|
317
|
+
async def wizard_duplicate_check(tok: str):
|
|
318
|
+
state = _get(tok)
|
|
319
|
+
cfg = web_config.load()
|
|
320
|
+
enabled = bool(cfg.get("W_DUPLICATE_CHECK", True))
|
|
321
|
+
state["duplicate"] = None
|
|
322
|
+
if not enabled or state["kind"] not in {"movie", "episode"}:
|
|
323
|
+
state["step"] = "hardlink"
|
|
324
|
+
return JSONResponse({"enabled": enabled, "duplicate": None})
|
|
325
|
+
size = _primary_source_size(state)
|
|
326
|
+
tmdb_id = state.get("tmdb_id", "")
|
|
327
|
+
tracker_url = (cfg.get("ITT_URL") or "").strip()
|
|
328
|
+
api_token = (cfg.get("ITT_APIKEY") or "").strip()
|
|
329
|
+
match = await find_duplicate(
|
|
330
|
+
tracker_url=tracker_url,
|
|
331
|
+
api_token=api_token,
|
|
332
|
+
tmdb_id=tmdb_id,
|
|
333
|
+
size_bytes=size,
|
|
334
|
+
)
|
|
335
|
+
if match is None:
|
|
336
|
+
state["step"] = "hardlink"
|
|
337
|
+
return JSONResponse({"enabled": True, "duplicate": None})
|
|
338
|
+
state["duplicate"] = match
|
|
339
|
+
return JSONResponse({"enabled": True, "duplicate": match})
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
@router.post("/wizard/{tok}/duplicate-confirm")
|
|
343
|
+
async def wizard_duplicate_confirm(tok: str):
|
|
344
|
+
state = _get(tok)
|
|
345
|
+
state["duplicate_confirmed"] = True
|
|
346
|
+
state["step"] = "hardlink"
|
|
347
|
+
return JSONResponse({"ok": True})
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@router.post("/wizard/{tok}/duplicate-skip")
|
|
351
|
+
async def wizard_duplicate_skip(tok: str):
|
|
352
|
+
"""User declined to upload after a duplicate was detected.
|
|
353
|
+
|
|
354
|
+
Records a "skipped" entry against the source path so the Library
|
|
355
|
+
hides the item (via the W_HIDE_UPLOADED filter that keys off
|
|
356
|
+
``source_path``) and the Uploaded history shows a dedicated badge.
|
|
357
|
+
The hardlink is NOT created — the wizard ends here.
|
|
358
|
+
"""
|
|
359
|
+
state = _get(tok)
|
|
360
|
+
duplicate = state.get("duplicate")
|
|
361
|
+
if not duplicate:
|
|
362
|
+
raise HTTPException(400, "no duplicate to skip")
|
|
363
|
+
path = Path(state["path"])
|
|
364
|
+
# Match the wizard_hardlink convention so the Library hide filter
|
|
365
|
+
# (which keys off uploaded_paths = {r.source_path}) picks it up:
|
|
366
|
+
# movie/series → state["path"] itself; episode → resolved video file.
|
|
367
|
+
if state["kind"] == "episode":
|
|
368
|
+
src = path if path.is_file() else next(iter(iter_video_files(path)), path)
|
|
369
|
+
source_path = str(src.resolve())
|
|
370
|
+
fallback_name = src.stem
|
|
371
|
+
else:
|
|
372
|
+
source_path = str(path.resolve())
|
|
373
|
+
fallback_name = path.name
|
|
374
|
+
final_name = next(iter(state.get("final_names", {}).values()), "") or fallback_name
|
|
375
|
+
await record_upload(
|
|
376
|
+
category=state["category"],
|
|
377
|
+
kind=state["kind"],
|
|
378
|
+
source_path=source_path,
|
|
379
|
+
seeding_path=source_path, # no hardlink — reuse src so the row is unique
|
|
380
|
+
tmdb_id=state.get("tmdb_id", ""),
|
|
381
|
+
title=state.get("tmdb_title", ""),
|
|
382
|
+
year=state.get("tmdb_year", ""),
|
|
383
|
+
final_name=final_name,
|
|
384
|
+
exit_code=0,
|
|
385
|
+
hardlink_only=False,
|
|
386
|
+
duplicate_skipped=True,
|
|
387
|
+
duplicate_info=duplicate,
|
|
388
|
+
)
|
|
389
|
+
log_emit(
|
|
390
|
+
"warn",
|
|
391
|
+
f"Duplicate skipped → {duplicate.get('name') or duplicate.get('id')}",
|
|
392
|
+
"wizard",
|
|
393
|
+
)
|
|
394
|
+
state["upload_done"] = True
|
|
395
|
+
state["exit_code"] = 0
|
|
396
|
+
state["step"] = "done"
|
|
397
|
+
return JSONResponse({"ok": True})
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
@router.post("/wizard/{tok}/hardlink")
|
|
401
|
+
async def wizard_hardlink(request: Request, tok: str):
|
|
402
|
+
lang = get_request_lang(request)
|
|
403
|
+
state = _get(tok, lang)
|
|
404
|
+
path = Path(state["path"])
|
|
405
|
+
kind = state["kind"]
|
|
406
|
+
final_names = state["final_names"]
|
|
407
|
+
loop = asyncio.get_event_loop()
|
|
408
|
+
try:
|
|
409
|
+
if kind == "movie":
|
|
410
|
+
if path.is_file():
|
|
411
|
+
src = path
|
|
412
|
+
else:
|
|
413
|
+
files = list(iter_video_files(path))
|
|
414
|
+
if not files:
|
|
415
|
+
raise HTTPException(400, _i18n_t("err.no_video", lang))
|
|
416
|
+
src = files[0]
|
|
417
|
+
final_name = next(iter(final_names.values()), src.stem)
|
|
418
|
+
target = await loop.run_in_executor(None, do_hardlink_movie, src, final_name)
|
|
419
|
+
state["seeding_path"] = str(target)
|
|
420
|
+
source_path = str(path.resolve())
|
|
421
|
+
elif kind == "episode":
|
|
422
|
+
src = path if path.is_file() else list(iter_video_files(path))[0]
|
|
423
|
+
final_name = next(iter(final_names.values()), src.stem)
|
|
424
|
+
target = await loop.run_in_executor(None, do_hardlink_movie, src, final_name)
|
|
425
|
+
state["seeding_path"] = str(target)
|
|
426
|
+
source_path = str(src.resolve())
|
|
427
|
+
else:
|
|
428
|
+
rename = {Path(k): v for k, v in final_names.items()}
|
|
429
|
+
folder = state.get("folder_name", path.name)
|
|
430
|
+
target = await loop.run_in_executor(None, do_hardlink_series, path, folder, rename)
|
|
431
|
+
state["seeding_path"] = str(target)
|
|
432
|
+
source_path = str(path.resolve())
|
|
433
|
+
state["step"] = "upload"
|
|
434
|
+
await record_upload(
|
|
435
|
+
category=state["category"], kind=kind,
|
|
436
|
+
source_path=source_path, seeding_path=state["seeding_path"],
|
|
437
|
+
tmdb_id=state.get("tmdb_id", ""),
|
|
438
|
+
title=state.get("tmdb_title", ""),
|
|
439
|
+
year=state.get("tmdb_year", ""),
|
|
440
|
+
final_name=state.get("folder_name") or next(iter(final_names.values()), ""),
|
|
441
|
+
hardlink_only=state.get("hardlink_only", False),
|
|
442
|
+
)
|
|
443
|
+
log_emit("ok", f"Hardlink done → {state['seeding_path']}", "wizard")
|
|
444
|
+
return JSONResponse({"ok": True, "seeding_path": state["seeding_path"]})
|
|
445
|
+
except HTTPException:
|
|
446
|
+
raise
|
|
447
|
+
except Exception as e:
|
|
448
|
+
log_emit("error", f"Hardlink failed: {e}", "wizard")
|
|
449
|
+
raise HTTPException(500, _i18n_t("err.hardlink_failed", lang, error=str(e)))
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
@router.get("/wizard/{tok}/upload")
|
|
453
|
+
async def wizard_upload(tok: str, request: Request):
|
|
454
|
+
state = _get(tok)
|
|
455
|
+
seeding_path = state.get("seeding_path", "")
|
|
456
|
+
if not seeding_path:
|
|
457
|
+
async def _err():
|
|
458
|
+
yield {"event": "error", "data": "No seeding path set"}
|
|
459
|
+
return EventSourceResponse(_err())
|
|
460
|
+
kind = state["kind"]
|
|
461
|
+
tmdb_id = state.get("tmdb_id", "")
|
|
462
|
+
|
|
463
|
+
app = request.app
|
|
464
|
+
|
|
465
|
+
async def generate() -> AsyncGenerator[dict, None]:
|
|
466
|
+
async for ev in stream_webup(
|
|
467
|
+
client=app.state.webup,
|
|
468
|
+
ws=app.state.webup_ws,
|
|
469
|
+
scan_lock=app.state.webup_scan_lock,
|
|
470
|
+
seeding_path=seeding_path,
|
|
471
|
+
kind=kind,
|
|
472
|
+
tmdb_id=tmdb_id,
|
|
473
|
+
):
|
|
474
|
+
et = ev["type"]
|
|
475
|
+
if et == "log":
|
|
476
|
+
ev_kind = ev.get("kind", "info")
|
|
477
|
+
event_slug = ev.get("event")
|
|
478
|
+
log_emit(ev_kind, ev["data"], "webup", source="webup", event=event_slug)
|
|
479
|
+
yield {"event": "log", "data": ev["data"]}
|
|
480
|
+
elif et == "progress":
|
|
481
|
+
yield {"event": "progress", "data": json.dumps({
|
|
482
|
+
"phase": ev.get("phase"),
|
|
483
|
+
"label": ev.get("label"),
|
|
484
|
+
"pct": ev.get("pct", 0),
|
|
485
|
+
"sub_pct": ev.get("sub_pct", 0),
|
|
486
|
+
})}
|
|
487
|
+
elif et == "error":
|
|
488
|
+
log_emit("error", ev["data"], "webup", source="webup")
|
|
489
|
+
yield {"event": "error", "data": ev["data"]}
|
|
490
|
+
elif et == "done":
|
|
491
|
+
code = ev.get("exit_code", -1)
|
|
492
|
+
state["exit_code"] = code
|
|
493
|
+
state["upload_done"] = True
|
|
494
|
+
await update_exit_code(seeding_path, code)
|
|
495
|
+
log_emit(
|
|
496
|
+
"ok" if code == 0 else "error",
|
|
497
|
+
f"webup exit {code}", "wizard",
|
|
498
|
+
)
|
|
499
|
+
yield {"event": "done", "data": json.dumps({"exit_code": code})}
|
|
500
|
+
|
|
501
|
+
return EventSourceResponse(generate())
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
@router.post("/wizard/{tok}/finish-hardlink")
|
|
505
|
+
async def wizard_finish(tok: str):
|
|
506
|
+
state = _get(tok)
|
|
507
|
+
state["upload_done"] = True
|
|
508
|
+
state["exit_code"] = 0
|
|
509
|
+
seeding_path = state.get("seeding_path", "")
|
|
510
|
+
if seeding_path:
|
|
511
|
+
await update_exit_code(seeding_path, 0)
|
|
512
|
+
return JSONResponse({"ok": True})
|