unit3dprep 1.0.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. unit3dprep/__init__.py +0 -0
  2. unit3dprep/cli.py +256 -0
  3. unit3dprep/core.py +556 -0
  4. unit3dprep/i18n.py +151 -0
  5. unit3dprep/media.py +278 -0
  6. unit3dprep/upload.py +146 -0
  7. unit3dprep/web/__init__.py +0 -0
  8. unit3dprep/web/_env.py +40 -0
  9. unit3dprep/web/api/__init__.py +1 -0
  10. unit3dprep/web/api/auth.py +36 -0
  11. unit3dprep/web/api/fs.py +64 -0
  12. unit3dprep/web/api/library.py +503 -0
  13. unit3dprep/web/api/logs.py +42 -0
  14. unit3dprep/web/api/queue.py +54 -0
  15. unit3dprep/web/api/quickupload.py +153 -0
  16. unit3dprep/web/api/search.py +40 -0
  17. unit3dprep/web/api/settings.py +77 -0
  18. unit3dprep/web/api/tmdb.py +77 -0
  19. unit3dprep/web/api/trackers.py +30 -0
  20. unit3dprep/web/api/uploaded.py +26 -0
  21. unit3dprep/web/api/version.py +754 -0
  22. unit3dprep/web/api/webup.py +59 -0
  23. unit3dprep/web/api/wizard.py +512 -0
  24. unit3dprep/web/app.py +201 -0
  25. unit3dprep/web/auth.py +41 -0
  26. unit3dprep/web/clients.py +167 -0
  27. unit3dprep/web/config.py +932 -0
  28. unit3dprep/web/db.py +184 -0
  29. unit3dprep/web/dist/assets/JetBrainsMono-Italic-VariableFont_wght-CZO9PUqx.ttf +0 -0
  30. unit3dprep/web/dist/assets/JetBrainsMono-VariableFont_wght-BrlcHZ7m.ttf +0 -0
  31. unit3dprep/web/dist/assets/SpaceGrotesk-VariableFont_wght-DIScfSlK.ttf +0 -0
  32. unit3dprep/web/dist/assets/index-BizNr_oP.js +255 -0
  33. unit3dprep/web/dist/assets/index-DChRHChM.css +1 -0
  34. unit3dprep/web/dist/index.html +14 -0
  35. unit3dprep/web/duplicate_check.py +92 -0
  36. unit3dprep/web/lang_cache.py +118 -0
  37. unit3dprep/web/logbuf.py +164 -0
  38. unit3dprep/web/tmdb_cache.py +116 -0
  39. unit3dprep/web/trackers.py +177 -0
  40. unit3dprep/web/webup_client.py +190 -0
  41. unit3dprep/web/webup_job_fix.py +231 -0
  42. unit3dprep/web/webup_logclass.py +107 -0
  43. unit3dprep/web/webup_orchestrator.py +796 -0
  44. unit3dprep/web/webup_ws.py +141 -0
  45. unit3dprep-1.0.4.dist-info/METADATA +819 -0
  46. unit3dprep-1.0.4.dist-info/RECORD +50 -0
  47. unit3dprep-1.0.4.dist-info/WHEEL +5 -0
  48. unit3dprep-1.0.4.dist-info/entry_points.txt +3 -0
  49. unit3dprep-1.0.4.dist-info/licenses/LICENSE +674 -0
  50. unit3dprep-1.0.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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})