unit3dprep 1.0.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- unit3dprep/__init__.py +0 -0
- unit3dprep/cli.py +256 -0
- unit3dprep/core.py +556 -0
- unit3dprep/i18n.py +151 -0
- unit3dprep/media.py +278 -0
- unit3dprep/upload.py +146 -0
- unit3dprep/web/__init__.py +0 -0
- unit3dprep/web/_env.py +40 -0
- unit3dprep/web/api/__init__.py +1 -0
- unit3dprep/web/api/auth.py +36 -0
- unit3dprep/web/api/fs.py +64 -0
- unit3dprep/web/api/library.py +503 -0
- unit3dprep/web/api/logs.py +42 -0
- unit3dprep/web/api/queue.py +54 -0
- unit3dprep/web/api/quickupload.py +153 -0
- unit3dprep/web/api/search.py +40 -0
- unit3dprep/web/api/settings.py +77 -0
- unit3dprep/web/api/tmdb.py +77 -0
- unit3dprep/web/api/trackers.py +30 -0
- unit3dprep/web/api/uploaded.py +26 -0
- unit3dprep/web/api/version.py +754 -0
- unit3dprep/web/api/webup.py +59 -0
- unit3dprep/web/api/wizard.py +512 -0
- unit3dprep/web/app.py +201 -0
- unit3dprep/web/auth.py +41 -0
- unit3dprep/web/clients.py +167 -0
- unit3dprep/web/config.py +932 -0
- unit3dprep/web/db.py +184 -0
- unit3dprep/web/dist/assets/JetBrainsMono-Italic-VariableFont_wght-CZO9PUqx.ttf +0 -0
- unit3dprep/web/dist/assets/JetBrainsMono-VariableFont_wght-BrlcHZ7m.ttf +0 -0
- unit3dprep/web/dist/assets/SpaceGrotesk-VariableFont_wght-DIScfSlK.ttf +0 -0
- unit3dprep/web/dist/assets/index-BizNr_oP.js +255 -0
- unit3dprep/web/dist/assets/index-DChRHChM.css +1 -0
- unit3dprep/web/dist/index.html +14 -0
- unit3dprep/web/duplicate_check.py +92 -0
- unit3dprep/web/lang_cache.py +118 -0
- unit3dprep/web/logbuf.py +164 -0
- unit3dprep/web/tmdb_cache.py +116 -0
- unit3dprep/web/trackers.py +177 -0
- unit3dprep/web/webup_client.py +190 -0
- unit3dprep/web/webup_job_fix.py +231 -0
- unit3dprep/web/webup_logclass.py +107 -0
- unit3dprep/web/webup_orchestrator.py +796 -0
- unit3dprep/web/webup_ws.py +141 -0
- unit3dprep-1.0.4.dist-info/METADATA +819 -0
- unit3dprep-1.0.4.dist-info/RECORD +50 -0
- unit3dprep-1.0.4.dist-info/WHEEL +5 -0
- unit3dprep-1.0.4.dist-info/entry_points.txt +3 -0
- unit3dprep-1.0.4.dist-info/licenses/LICENSE +674 -0
- unit3dprep-1.0.4.dist-info/top_level.txt +1 -0
unit3dprep/web/api/fs.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Filesystem browser (used by UploadModal FileBrowser)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
7
|
+
from fastapi.responses import JSONResponse
|
|
8
|
+
|
|
9
|
+
from ...i18n import get_request_lang, t
|
|
10
|
+
from ...media import media_root, seedings_root
|
|
11
|
+
|
|
12
|
+
router = APIRouter(prefix="/api", tags=["fs"])
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _allowed_roots() -> tuple[Path, ...]:
|
|
16
|
+
return (media_root(), seedings_root(), Path.home())
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _is_allowed(p: Path) -> bool:
|
|
20
|
+
try:
|
|
21
|
+
rp = p.resolve()
|
|
22
|
+
except Exception:
|
|
23
|
+
return False
|
|
24
|
+
return any(str(rp).startswith(str(r.resolve())) for r in _allowed_roots())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@router.get("/fs")
|
|
28
|
+
async def listdir(request: Request, path: str = ""):
|
|
29
|
+
lang = get_request_lang(request)
|
|
30
|
+
if not path:
|
|
31
|
+
target = Path.home()
|
|
32
|
+
else:
|
|
33
|
+
target = Path(path)
|
|
34
|
+
if not _is_allowed(target):
|
|
35
|
+
raise HTTPException(403, t("err.path_not_allowed", lang))
|
|
36
|
+
if not target.exists():
|
|
37
|
+
raise HTTPException(404, t("err.path_not_found", lang))
|
|
38
|
+
if target.is_file():
|
|
39
|
+
return JSONResponse({
|
|
40
|
+
"path": str(target),
|
|
41
|
+
"parent": str(target.parent),
|
|
42
|
+
"entries": [],
|
|
43
|
+
"is_file": True,
|
|
44
|
+
})
|
|
45
|
+
entries = []
|
|
46
|
+
try:
|
|
47
|
+
for child in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())):
|
|
48
|
+
try:
|
|
49
|
+
is_dir = child.is_dir()
|
|
50
|
+
except OSError:
|
|
51
|
+
continue
|
|
52
|
+
entries.append({
|
|
53
|
+
"name": child.name,
|
|
54
|
+
"path": str(child),
|
|
55
|
+
"type": "dir" if is_dir else "file",
|
|
56
|
+
})
|
|
57
|
+
except PermissionError:
|
|
58
|
+
raise HTTPException(403, t("err.permission_denied", lang))
|
|
59
|
+
return JSONResponse({
|
|
60
|
+
"path": str(target),
|
|
61
|
+
"parent": str(target.parent) if target != target.parent else str(target),
|
|
62
|
+
"entries": entries,
|
|
63
|
+
"is_file": False,
|
|
64
|
+
})
|
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
"""Library scan + per-item TMDB + lang-cache JSON endpoints.
|
|
2
|
+
|
|
3
|
+
All response shapes are stable and consumed by the React LibraryView.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, AsyncGenerator
|
|
12
|
+
|
|
13
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
14
|
+
from fastapi.responses import JSONResponse
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
from sse_starlette.sse import EventSourceResponse
|
|
17
|
+
|
|
18
|
+
from ...core import (
|
|
19
|
+
audio_languages,
|
|
20
|
+
tmdb_fetch_bilingual,
|
|
21
|
+
tmdb_poster_url,
|
|
22
|
+
tmdb_search,
|
|
23
|
+
tmdb_year,
|
|
24
|
+
)
|
|
25
|
+
from ...media import (
|
|
26
|
+
MediaItem,
|
|
27
|
+
Season,
|
|
28
|
+
discover_categories,
|
|
29
|
+
get_item,
|
|
30
|
+
media_root,
|
|
31
|
+
scan_category,
|
|
32
|
+
)
|
|
33
|
+
from ...i18n import get_request_lang, t as _i18n_t
|
|
34
|
+
from ..db import list_uploads, record_upload
|
|
35
|
+
from ..lang_cache import get_many_langs, set_lang
|
|
36
|
+
from ..tmdb_cache import get_cache, get_many, set_cache
|
|
37
|
+
|
|
38
|
+
TMDB_API_KEY = os.environ.get("TMDB_API_KEY", "")
|
|
39
|
+
|
|
40
|
+
router = APIRouter(prefix="/api", tags=["library"])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _season_to_dict(s: Season, uploaded_paths: set[str]) -> dict[str, Any]:
|
|
44
|
+
return {
|
|
45
|
+
"number": s.number,
|
|
46
|
+
"label": s.label,
|
|
47
|
+
"path": str(s.path),
|
|
48
|
+
"episode_count": s.episode_count,
|
|
49
|
+
"size": s.total_size_human,
|
|
50
|
+
"langs": list(s.available_langs),
|
|
51
|
+
"lang_scanned": s.lang_scanned,
|
|
52
|
+
"already_uploaded": s.already_uploaded,
|
|
53
|
+
"uploaded_episodes": len(s.uploaded_episode_paths),
|
|
54
|
+
"all_episodes_uploaded": s.all_episodes_uploaded,
|
|
55
|
+
"video_files": [
|
|
56
|
+
{
|
|
57
|
+
"path": str(vf),
|
|
58
|
+
"name": vf.name,
|
|
59
|
+
"uploaded": str(vf.resolve()) in uploaded_paths,
|
|
60
|
+
}
|
|
61
|
+
for vf in s.video_files
|
|
62
|
+
],
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _item_to_dict(item: MediaItem, uploaded_paths: set[str]) -> dict[str, Any]:
|
|
67
|
+
base = {
|
|
68
|
+
"name": item.name,
|
|
69
|
+
"path": str(item.path),
|
|
70
|
+
"category": item.category,
|
|
71
|
+
"kind": item.kind,
|
|
72
|
+
"title": item.tmdb_title or item.title,
|
|
73
|
+
"year": item.year,
|
|
74
|
+
"size": item.total_size_human,
|
|
75
|
+
"total_files": item.total_files,
|
|
76
|
+
"tmdb_id": item.tmdb_id,
|
|
77
|
+
"tmdb_kind": item.tmdb_kind,
|
|
78
|
+
"tmdb_title_en": getattr(item, "tmdb_title_en", ""),
|
|
79
|
+
"tmdb_original_title": getattr(item, "tmdb_original_title", ""),
|
|
80
|
+
"tmdb_poster": item.tmdb_poster,
|
|
81
|
+
"tmdb_overview": item.tmdb_overview,
|
|
82
|
+
"tmdb_overview_en": getattr(item, "tmdb_overview_en", ""),
|
|
83
|
+
"langs": list(item.available_langs),
|
|
84
|
+
"lang_scanned": item.lang_scanned,
|
|
85
|
+
"already_uploaded": str(item.path.resolve()) in uploaded_paths,
|
|
86
|
+
}
|
|
87
|
+
if item.kind == "series":
|
|
88
|
+
base["seasons"] = [_season_to_dict(s, uploaded_paths) for s in item.seasons]
|
|
89
|
+
base["all_seasons_uploaded"] = item.all_seasons_uploaded
|
|
90
|
+
else:
|
|
91
|
+
base["video_files"] = [
|
|
92
|
+
{
|
|
93
|
+
"path": str(vf),
|
|
94
|
+
"name": vf.name,
|
|
95
|
+
"uploaded": str(vf.resolve()) in uploaded_paths,
|
|
96
|
+
}
|
|
97
|
+
for vf in item.video_files
|
|
98
|
+
]
|
|
99
|
+
return base
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def _enrich_items(items: list[MediaItem]) -> tuple[set[str], dict, dict]:
|
|
103
|
+
uploads = await list_uploads()
|
|
104
|
+
uploaded_paths = {r["source_path"] for r in uploads if r.get("source_path")}
|
|
105
|
+
|
|
106
|
+
# Fallback: records with empty source_path → match via hardlink inode
|
|
107
|
+
seeding_inodes: set[tuple[int, int]] = set()
|
|
108
|
+
for r in uploads:
|
|
109
|
+
sp = r.get("seeding_path", "")
|
|
110
|
+
if sp and not sp.startswith("__manual__"):
|
|
111
|
+
try:
|
|
112
|
+
st = Path(sp).stat()
|
|
113
|
+
if st.st_ino:
|
|
114
|
+
seeding_inodes.add((st.st_dev, st.st_ino))
|
|
115
|
+
except OSError:
|
|
116
|
+
pass
|
|
117
|
+
if seeding_inodes:
|
|
118
|
+
for item in items:
|
|
119
|
+
if item.kind == "movie":
|
|
120
|
+
for vf in item.video_files:
|
|
121
|
+
try:
|
|
122
|
+
st = vf.stat()
|
|
123
|
+
if st.st_ino and (st.st_dev, st.st_ino) in seeding_inodes:
|
|
124
|
+
uploaded_paths.add(str(item.path.resolve()))
|
|
125
|
+
uploaded_paths.add(str(vf.resolve()))
|
|
126
|
+
except OSError:
|
|
127
|
+
pass
|
|
128
|
+
else:
|
|
129
|
+
for season in item.seasons:
|
|
130
|
+
for vf in season.video_files:
|
|
131
|
+
try:
|
|
132
|
+
st = vf.stat()
|
|
133
|
+
if st.st_ino and (st.st_dev, st.st_ino) in seeding_inodes:
|
|
134
|
+
# Only mark the single episode as uploaded.
|
|
135
|
+
# `season.already_uploaded` and `all_seasons_uploaded`
|
|
136
|
+
# propagate via direct season-path / series-root
|
|
137
|
+
# records (mark-uploaded at season/series level)
|
|
138
|
+
# or via `all_episodes_uploaded` once *every*
|
|
139
|
+
# episode has been uploaded individually.
|
|
140
|
+
uploaded_paths.add(str(vf.resolve()))
|
|
141
|
+
except OSError:
|
|
142
|
+
pass
|
|
143
|
+
all_paths: list[str] = []
|
|
144
|
+
for item in items:
|
|
145
|
+
all_paths.append(str(item.path))
|
|
146
|
+
for s in item.seasons:
|
|
147
|
+
all_paths.append(str(s.path))
|
|
148
|
+
cache = await get_many(all_paths)
|
|
149
|
+
lang_cache = await get_many_langs(all_paths)
|
|
150
|
+
|
|
151
|
+
for item in items:
|
|
152
|
+
sp = str(item.path)
|
|
153
|
+
tmdb = cache.get(sp)
|
|
154
|
+
if tmdb:
|
|
155
|
+
item.tmdb_id = tmdb.get("tmdb_id", "")
|
|
156
|
+
item.tmdb_kind = tmdb.get("tmdb_kind", "") or ("tv" if item.kind == "series" else "movie")
|
|
157
|
+
item.tmdb_title = tmdb.get("title", "")
|
|
158
|
+
setattr(item, "tmdb_title_en", tmdb.get("title_en", ""))
|
|
159
|
+
setattr(item, "tmdb_original_title", tmdb.get("original_title", ""))
|
|
160
|
+
item.tmdb_poster = tmdb.get("poster", "")
|
|
161
|
+
item.tmdb_overview = tmdb.get("overview", "")
|
|
162
|
+
setattr(item, "tmdb_overview_en", tmdb.get("overview_en", ""))
|
|
163
|
+
if item.kind == "series":
|
|
164
|
+
uploaded_season_numbers: list[int] = []
|
|
165
|
+
all_langs: list[str] = []
|
|
166
|
+
any_scanned = False
|
|
167
|
+
series_root = str(item.path.resolve())
|
|
168
|
+
for season in item.seasons:
|
|
169
|
+
ssp = str(season.path)
|
|
170
|
+
ssp_resolved = str(season.path.resolve())
|
|
171
|
+
season.already_uploaded = ssp_resolved in uploaded_paths or series_root in uploaded_paths
|
|
172
|
+
uploaded_ep: set[str] = set()
|
|
173
|
+
for vf in season.video_files:
|
|
174
|
+
if str(vf.resolve()) in uploaded_paths:
|
|
175
|
+
uploaded_ep.add(str(vf.resolve()))
|
|
176
|
+
season.uploaded_episode_paths = uploaded_ep
|
|
177
|
+
if season.already_uploaded or season.all_episodes_uploaded:
|
|
178
|
+
uploaded_season_numbers.append(season.number)
|
|
179
|
+
if not item.tmdb_id:
|
|
180
|
+
s_tmdb = cache.get(ssp)
|
|
181
|
+
if s_tmdb:
|
|
182
|
+
item.tmdb_id = s_tmdb.get("tmdb_id", "")
|
|
183
|
+
item.tmdb_kind = s_tmdb.get("tmdb_kind", "") or "tv"
|
|
184
|
+
item.tmdb_title = s_tmdb.get("title", "")
|
|
185
|
+
item.tmdb_poster = s_tmdb.get("poster", "")
|
|
186
|
+
item.tmdb_overview = s_tmdb.get("overview", "")
|
|
187
|
+
lang_entry = lang_cache.get(ssp)
|
|
188
|
+
if lang_entry:
|
|
189
|
+
season.available_langs = lang_entry.get("langs", [])
|
|
190
|
+
season.lang_scanned = True
|
|
191
|
+
any_scanned = True
|
|
192
|
+
for lang in season.available_langs:
|
|
193
|
+
if lang not in all_langs:
|
|
194
|
+
all_langs.append(lang)
|
|
195
|
+
item.uploaded_season_numbers = uploaded_season_numbers
|
|
196
|
+
if any_scanned:
|
|
197
|
+
has_ita = "ITA" in all_langs
|
|
198
|
+
rest = sorted(c for c in all_langs if c != "ITA")
|
|
199
|
+
item.available_langs = (["ITA"] + rest) if has_ita else rest
|
|
200
|
+
item.lang_scanned = True
|
|
201
|
+
else:
|
|
202
|
+
lang_entry = lang_cache.get(sp)
|
|
203
|
+
if lang_entry:
|
|
204
|
+
item.available_langs = lang_entry.get("langs", [])
|
|
205
|
+
item.episode_langs = lang_entry.get("episode_langs", {})
|
|
206
|
+
item.lang_scanned = True
|
|
207
|
+
if not item.tmdb_kind:
|
|
208
|
+
item.tmdb_kind = "tv" if item.kind == "series" else "movie"
|
|
209
|
+
return uploaded_paths, cache, lang_cache
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
_CATEGORY_LABELS = {
|
|
213
|
+
"movies": "Movies",
|
|
214
|
+
"series": "Series",
|
|
215
|
+
"anime": "Anime",
|
|
216
|
+
"documentaries": "Documentaries",
|
|
217
|
+
"concerts": "Concerts",
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _count_entries(path: Path) -> int:
|
|
222
|
+
try:
|
|
223
|
+
return sum(1 for _ in path.iterdir() if not _.name.startswith("."))
|
|
224
|
+
except OSError:
|
|
225
|
+
return 0
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@router.get("/library/categories")
|
|
229
|
+
async def library_categories():
|
|
230
|
+
root = media_root()
|
|
231
|
+
cats = []
|
|
232
|
+
for name in discover_categories():
|
|
233
|
+
cats.append({
|
|
234
|
+
"id": name,
|
|
235
|
+
"label": _CATEGORY_LABELS.get(name, name.capitalize()),
|
|
236
|
+
"count": _count_entries(root / name),
|
|
237
|
+
})
|
|
238
|
+
return JSONResponse({
|
|
239
|
+
"root": str(root),
|
|
240
|
+
"root_exists": root.exists(),
|
|
241
|
+
"categories": cats,
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@router.get("/library/{category}")
|
|
246
|
+
async def library_list(request: Request, category: str):
|
|
247
|
+
if category not in discover_categories():
|
|
248
|
+
raise HTTPException(404, _i18n_t("err.category_not_found", get_request_lang(request)))
|
|
249
|
+
items = scan_category(category)
|
|
250
|
+
uploaded_paths, _cache, _lang = await _enrich_items(items)
|
|
251
|
+
return JSONResponse({
|
|
252
|
+
"category": category,
|
|
253
|
+
"items": [_item_to_dict(i, uploaded_paths) for i in items],
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@router.get("/library/{category}/enrich")
|
|
258
|
+
async def library_enrich(request: Request, category: str):
|
|
259
|
+
if category not in discover_categories():
|
|
260
|
+
raise HTTPException(404, _i18n_t("err.category_not_found", get_request_lang(request)))
|
|
261
|
+
if not TMDB_API_KEY:
|
|
262
|
+
async def _no_key():
|
|
263
|
+
yield {"event": "done", "data": "{}"}
|
|
264
|
+
return EventSourceResponse(_no_key())
|
|
265
|
+
|
|
266
|
+
async def generate() -> AsyncGenerator[dict, None]:
|
|
267
|
+
items = scan_category(category)
|
|
268
|
+
paths = [str(i.path) for i in items]
|
|
269
|
+
cache = await get_many(paths)
|
|
270
|
+
loop = asyncio.get_event_loop()
|
|
271
|
+
for item in items:
|
|
272
|
+
sp = str(item.path)
|
|
273
|
+
if sp in cache:
|
|
274
|
+
continue
|
|
275
|
+
kind = "tv" if item.kind == "series" else "movie"
|
|
276
|
+
try:
|
|
277
|
+
results = await loop.run_in_executor(
|
|
278
|
+
None, tmdb_search, kind, item.title, item.year, TMDB_API_KEY
|
|
279
|
+
)
|
|
280
|
+
except Exception:
|
|
281
|
+
await asyncio.sleep(0.25)
|
|
282
|
+
continue
|
|
283
|
+
if not results:
|
|
284
|
+
await asyncio.sleep(0.25)
|
|
285
|
+
continue
|
|
286
|
+
best = results[0]
|
|
287
|
+
if item.year and best.get("year"):
|
|
288
|
+
try:
|
|
289
|
+
if abs(int(best["year"]) - int(item.year)) > 1:
|
|
290
|
+
await asyncio.sleep(0.25)
|
|
291
|
+
continue
|
|
292
|
+
except ValueError:
|
|
293
|
+
pass
|
|
294
|
+
tmdb_id = str(best["id"])
|
|
295
|
+
try:
|
|
296
|
+
data = await loop.run_in_executor(
|
|
297
|
+
None, tmdb_fetch_bilingual, kind, tmdb_id, TMDB_API_KEY
|
|
298
|
+
)
|
|
299
|
+
except Exception:
|
|
300
|
+
await asyncio.sleep(0.25)
|
|
301
|
+
continue
|
|
302
|
+
t = data.get("title") or best["title"]
|
|
303
|
+
y = tmdb_year(data, kind) or best.get("year", "")
|
|
304
|
+
poster = tmdb_poster_url(data) or best.get("poster", "")
|
|
305
|
+
await set_cache(
|
|
306
|
+
sp,
|
|
307
|
+
tmdb_id=tmdb_id,
|
|
308
|
+
tmdb_kind=kind,
|
|
309
|
+
title=t,
|
|
310
|
+
title_en=data.get("title_en", ""),
|
|
311
|
+
original_title=data.get("original_title", ""),
|
|
312
|
+
year=y,
|
|
313
|
+
poster=poster,
|
|
314
|
+
overview=(data.get("overview") or "")[:300],
|
|
315
|
+
overview_en=(data.get("overview_en") or "")[:300],
|
|
316
|
+
)
|
|
317
|
+
yield {
|
|
318
|
+
"event": "enriched",
|
|
319
|
+
"data": json.dumps({
|
|
320
|
+
"source_path": sp,
|
|
321
|
+
"tmdb_id": tmdb_id,
|
|
322
|
+
"title": t,
|
|
323
|
+
"year": y,
|
|
324
|
+
"poster": poster,
|
|
325
|
+
}),
|
|
326
|
+
}
|
|
327
|
+
await asyncio.sleep(0.25)
|
|
328
|
+
yield {"event": "done", "data": "{}"}
|
|
329
|
+
|
|
330
|
+
return EventSourceResponse(generate())
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@router.get("/library/{category}/scan-langs")
|
|
334
|
+
async def library_scan_langs(request: Request, category: str):
|
|
335
|
+
if category not in discover_categories():
|
|
336
|
+
raise HTTPException(404, _i18n_t("err.category_not_found", get_request_lang(request)))
|
|
337
|
+
|
|
338
|
+
async def generate() -> AsyncGenerator[dict, None]:
|
|
339
|
+
items = scan_category(category)
|
|
340
|
+
all_paths: list[str] = []
|
|
341
|
+
for item in items:
|
|
342
|
+
if item.kind == "series":
|
|
343
|
+
for s in item.seasons:
|
|
344
|
+
all_paths.append(str(s.path))
|
|
345
|
+
else:
|
|
346
|
+
all_paths.append(str(item.path))
|
|
347
|
+
lang_cache = await get_many_langs(all_paths)
|
|
348
|
+
loop = asyncio.get_event_loop()
|
|
349
|
+
for item in items:
|
|
350
|
+
if item.kind == "series":
|
|
351
|
+
for season in item.seasons:
|
|
352
|
+
sp = str(season.path)
|
|
353
|
+
if sp in lang_cache:
|
|
354
|
+
continue
|
|
355
|
+
episode_langs: dict[str, list[str]] = {}
|
|
356
|
+
seen: list[str] = []
|
|
357
|
+
for vf in season.video_files:
|
|
358
|
+
try:
|
|
359
|
+
langs = await loop.run_in_executor(None, audio_languages, vf)
|
|
360
|
+
except Exception:
|
|
361
|
+
langs = []
|
|
362
|
+
episode_langs[str(vf)] = langs
|
|
363
|
+
for lang in langs:
|
|
364
|
+
if lang not in seen:
|
|
365
|
+
seen.append(lang)
|
|
366
|
+
await asyncio.sleep(0.05)
|
|
367
|
+
has_ita = "ITA" in seen
|
|
368
|
+
rest = sorted(c for c in seen if c != "ITA")
|
|
369
|
+
merged = (["ITA"] + rest) if has_ita else rest
|
|
370
|
+
await set_lang(sp, merged, episode_langs)
|
|
371
|
+
yield {
|
|
372
|
+
"event": "lang_scanned",
|
|
373
|
+
"data": json.dumps({
|
|
374
|
+
"source_path": str(item.path),
|
|
375
|
+
"season_path": sp,
|
|
376
|
+
"langs": merged,
|
|
377
|
+
"has_ita": has_ita,
|
|
378
|
+
}),
|
|
379
|
+
}
|
|
380
|
+
else:
|
|
381
|
+
sp = str(item.path)
|
|
382
|
+
if sp in lang_cache:
|
|
383
|
+
continue
|
|
384
|
+
episode_langs: dict[str, list[str]] = {}
|
|
385
|
+
seen: list[str] = []
|
|
386
|
+
for vf in item.video_files:
|
|
387
|
+
try:
|
|
388
|
+
langs = await loop.run_in_executor(None, audio_languages, vf)
|
|
389
|
+
except Exception:
|
|
390
|
+
langs = []
|
|
391
|
+
episode_langs[str(vf)] = langs
|
|
392
|
+
for lang in langs:
|
|
393
|
+
if lang not in seen:
|
|
394
|
+
seen.append(lang)
|
|
395
|
+
await asyncio.sleep(0.05)
|
|
396
|
+
has_ita = "ITA" in seen
|
|
397
|
+
rest = sorted(c for c in seen if c != "ITA")
|
|
398
|
+
merged = (["ITA"] + rest) if has_ita else rest
|
|
399
|
+
await set_lang(sp, merged, episode_langs if len(item.video_files) > 1 else None)
|
|
400
|
+
yield {
|
|
401
|
+
"event": "lang_scanned",
|
|
402
|
+
"data": json.dumps({
|
|
403
|
+
"source_path": sp,
|
|
404
|
+
"season_path": None,
|
|
405
|
+
"langs": merged,
|
|
406
|
+
"has_ita": has_ita,
|
|
407
|
+
}),
|
|
408
|
+
}
|
|
409
|
+
yield {"event": "done", "data": "{}"}
|
|
410
|
+
|
|
411
|
+
return EventSourceResponse(generate())
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
class MarkUploadedBody(BaseModel):
|
|
415
|
+
season_path: str = ""
|
|
416
|
+
episode_path: str = ""
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
@router.post("/library/{category}/{item_name:path}/mark-uploaded")
|
|
420
|
+
async def library_mark_uploaded(request: Request, category: str, item_name: str, body: MarkUploadedBody):
|
|
421
|
+
lang = get_request_lang(request)
|
|
422
|
+
if category not in discover_categories():
|
|
423
|
+
raise HTTPException(404, _i18n_t("err.category_not_found", lang))
|
|
424
|
+
item = get_item(category, item_name)
|
|
425
|
+
if item is None:
|
|
426
|
+
raise HTTPException(404, _i18n_t("err.item_not_found_in_category", lang, name=item_name, category=category))
|
|
427
|
+
if body.episode_path:
|
|
428
|
+
source_path = str(Path(body.episode_path).resolve())
|
|
429
|
+
kind = "episode"
|
|
430
|
+
elif body.season_path:
|
|
431
|
+
source_path = str(Path(body.season_path).resolve())
|
|
432
|
+
kind = "series"
|
|
433
|
+
else:
|
|
434
|
+
source_path = str(item.path.resolve())
|
|
435
|
+
kind = item.kind
|
|
436
|
+
seeding_path = f"__manual__:{source_path}"
|
|
437
|
+
cache_entry = await get_cache(str(item.path)) or await get_cache(source_path)
|
|
438
|
+
title = (cache_entry.get("title") or item.title) if cache_entry else item.title
|
|
439
|
+
year = (cache_entry.get("year") or item.year) if cache_entry else item.year
|
|
440
|
+
tmdb_id = cache_entry.get("tmdb_id", "") if cache_entry else ""
|
|
441
|
+
await record_upload(
|
|
442
|
+
category=category, kind=kind,
|
|
443
|
+
source_path=source_path, seeding_path=seeding_path,
|
|
444
|
+
tmdb_id=tmdb_id, title=title, year=year,
|
|
445
|
+
final_name="", exit_code=0, hardlink_only=True,
|
|
446
|
+
)
|
|
447
|
+
return JSONResponse({"ok": True})
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@router.post("/library/{category}/{item_name:path}/rescan-langs")
|
|
451
|
+
async def library_rescan_langs(request: Request, category: str, item_name: str):
|
|
452
|
+
lang = get_request_lang(request)
|
|
453
|
+
if category not in discover_categories():
|
|
454
|
+
raise HTTPException(404, _i18n_t("err.category_not_found", lang))
|
|
455
|
+
item = get_item(category, item_name)
|
|
456
|
+
if item is None:
|
|
457
|
+
raise HTTPException(404, _i18n_t("err.item_not_found_in_category", lang, name=item_name, category=category))
|
|
458
|
+
loop = asyncio.get_event_loop()
|
|
459
|
+
if item.kind == "series":
|
|
460
|
+
seasons_result = {}
|
|
461
|
+
all_series_langs: list[str] = []
|
|
462
|
+
for season in item.seasons:
|
|
463
|
+
sp = str(season.path)
|
|
464
|
+
episode_langs: dict[str, list[str]] = {}
|
|
465
|
+
seen: list[str] = []
|
|
466
|
+
for vf in season.video_files:
|
|
467
|
+
try:
|
|
468
|
+
langs = await loop.run_in_executor(None, audio_languages, vf)
|
|
469
|
+
except Exception:
|
|
470
|
+
langs = []
|
|
471
|
+
episode_langs[str(vf)] = langs
|
|
472
|
+
for lang in langs:
|
|
473
|
+
if lang not in seen:
|
|
474
|
+
seen.append(lang)
|
|
475
|
+
has_ita = "ITA" in seen
|
|
476
|
+
rest = sorted(c for c in seen if c != "ITA")
|
|
477
|
+
merged = (["ITA"] + rest) if has_ita else rest
|
|
478
|
+
await set_lang(sp, merged, episode_langs)
|
|
479
|
+
seasons_result[sp] = {"langs": merged, "episode_langs": episode_langs}
|
|
480
|
+
for lang in merged:
|
|
481
|
+
if lang not in all_series_langs:
|
|
482
|
+
all_series_langs.append(lang)
|
|
483
|
+
has_ita_series = "ITA" in all_series_langs
|
|
484
|
+
rest_series = sorted(c for c in all_series_langs if c != "ITA")
|
|
485
|
+
merged_series = (["ITA"] + rest_series) if has_ita_series else rest_series
|
|
486
|
+
return JSONResponse({"ok": True, "langs": merged_series, "seasons": seasons_result})
|
|
487
|
+
sp = str(item.path)
|
|
488
|
+
episode_langs: dict[str, list[str]] = {}
|
|
489
|
+
seen: list[str] = []
|
|
490
|
+
for vf in item.video_files:
|
|
491
|
+
try:
|
|
492
|
+
langs = await loop.run_in_executor(None, audio_languages, vf)
|
|
493
|
+
except Exception:
|
|
494
|
+
langs = []
|
|
495
|
+
episode_langs[str(vf)] = langs
|
|
496
|
+
for lang in langs:
|
|
497
|
+
if lang not in seen:
|
|
498
|
+
seen.append(lang)
|
|
499
|
+
has_ita = "ITA" in seen
|
|
500
|
+
rest = sorted(c for c in seen if c != "ITA")
|
|
501
|
+
merged = (["ITA"] + rest) if has_ita else rest
|
|
502
|
+
await set_lang(sp, merged, episode_langs if len(item.video_files) > 1 else None)
|
|
503
|
+
return JSONResponse({"ok": True, "langs": merged, "episode_langs": episode_langs})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Log tail SSE + history dump."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
from typing import AsyncGenerator
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, Request
|
|
9
|
+
from fastapi.responses import JSONResponse
|
|
10
|
+
from sse_starlette.sse import EventSourceResponse
|
|
11
|
+
|
|
12
|
+
from .. import logbuf
|
|
13
|
+
|
|
14
|
+
router = APIRouter(prefix="/api", tags=["logs"])
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@router.get("/logs/history")
|
|
18
|
+
async def history():
|
|
19
|
+
return JSONResponse({"lines": logbuf.history()})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@router.get("/logs/stream")
|
|
23
|
+
async def stream(request: Request):
|
|
24
|
+
q = logbuf.subscribe()
|
|
25
|
+
|
|
26
|
+
async def gen() -> AsyncGenerator[dict, None]:
|
|
27
|
+
# Replay history first so freshly opened tabs see context
|
|
28
|
+
for entry in logbuf.history():
|
|
29
|
+
yield {"event": "line", "data": json.dumps(entry)}
|
|
30
|
+
try:
|
|
31
|
+
while True:
|
|
32
|
+
if await request.is_disconnected():
|
|
33
|
+
return
|
|
34
|
+
try:
|
|
35
|
+
entry = await asyncio.wait_for(q.get(), timeout=15.0)
|
|
36
|
+
yield {"event": "line", "data": json.dumps(entry)}
|
|
37
|
+
except asyncio.TimeoutError:
|
|
38
|
+
yield {"event": "ping", "data": ""}
|
|
39
|
+
finally:
|
|
40
|
+
logbuf.unsubscribe(q)
|
|
41
|
+
|
|
42
|
+
return EventSourceResponse(gen())
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Torrent-client queue (qBittorrent/Transmission/rTorrent)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from dataclasses import asdict
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, HTTPException
|
|
7
|
+
from fastapi.responses import JSONResponse
|
|
8
|
+
|
|
9
|
+
from .. import clients, config
|
|
10
|
+
|
|
11
|
+
router = APIRouter(prefix="/api", tags=["queue"])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _client():
|
|
15
|
+
return clients.get_client(config.load())
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@router.get("/queue")
|
|
19
|
+
async def list_queue():
|
|
20
|
+
cli = _client()
|
|
21
|
+
try:
|
|
22
|
+
items = await cli.list()
|
|
23
|
+
except NotImplementedError as e:
|
|
24
|
+
return JSONResponse({"client": cli.name, "torrents": [], "error": str(e)}, status_code=200)
|
|
25
|
+
except Exception as e:
|
|
26
|
+
raise HTTPException(502, f"{cli.name} error: {e}")
|
|
27
|
+
return JSONResponse({
|
|
28
|
+
"client": cli.name,
|
|
29
|
+
"torrents": [asdict(t) for t in items],
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@router.post("/queue/{torrent_hash}/reseed")
|
|
34
|
+
async def reseed(torrent_hash: str):
|
|
35
|
+
cli = _client()
|
|
36
|
+
try:
|
|
37
|
+
await cli.reseed(torrent_hash)
|
|
38
|
+
except NotImplementedError as e:
|
|
39
|
+
raise HTTPException(501, str(e))
|
|
40
|
+
except Exception as e:
|
|
41
|
+
raise HTTPException(502, str(e))
|
|
42
|
+
return JSONResponse({"ok": True})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@router.delete("/queue/{torrent_hash}")
|
|
46
|
+
async def remove(torrent_hash: str, delete_files: bool = False):
|
|
47
|
+
cli = _client()
|
|
48
|
+
try:
|
|
49
|
+
await cli.remove(torrent_hash, delete_files=delete_files)
|
|
50
|
+
except NotImplementedError as e:
|
|
51
|
+
raise HTTPException(501, str(e))
|
|
52
|
+
except Exception as e:
|
|
53
|
+
raise HTTPException(502, str(e))
|
|
54
|
+
return JSONResponse({"ok": True})
|