unit3dprep 1.2.0__tar.gz → 1.3.0__tar.gz

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 (63) hide show
  1. {unit3dprep-1.2.0/unit3dprep.egg-info → unit3dprep-1.3.0}/PKG-INFO +1 -1
  2. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/pyproject.toml +1 -1
  3. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/i18n.py +20 -0
  4. unit3dprep-1.3.0/unit3dprep/web/api/arr.py +192 -0
  5. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/settings.py +8 -1
  6. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/app.py +2 -0
  7. unit3dprep-1.3.0/unit3dprep/web/arr.py +425 -0
  8. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/config.py +21 -2
  9. unit3dprep-1.3.0/unit3dprep/web/dist/assets/index-D__l7DJs.js +290 -0
  10. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/dist/index.html +1 -1
  11. unit3dprep-1.3.0/unit3dprep/web/duplicate_check.py +398 -0
  12. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/logbuf.py +3 -1
  13. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/reseed.py +1 -9
  14. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/webup_orchestrator.py +281 -53
  15. {unit3dprep-1.2.0 → unit3dprep-1.3.0/unit3dprep.egg-info}/PKG-INFO +1 -1
  16. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep.egg-info/SOURCES.txt +3 -1
  17. unit3dprep-1.2.0/unit3dprep/web/dist/assets/index-CPw9mcxL.js +0 -280
  18. unit3dprep-1.2.0/unit3dprep/web/duplicate_check.py +0 -168
  19. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/LICENSE +0 -0
  20. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/MANIFEST.in +0 -0
  21. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/README.md +0 -0
  22. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/setup.cfg +0 -0
  23. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/__init__.py +0 -0
  24. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/cli.py +0 -0
  25. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/core.py +0 -0
  26. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/media.py +0 -0
  27. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/upload.py +0 -0
  28. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/__init__.py +0 -0
  29. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/_env.py +0 -0
  30. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/__init__.py +0 -0
  31. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/auth.py +0 -0
  32. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/fs.py +0 -0
  33. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/library.py +0 -0
  34. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/logs.py +0 -0
  35. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/queue.py +0 -0
  36. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/quickupload.py +0 -0
  37. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/reseed.py +0 -0
  38. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/search.py +0 -0
  39. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/tmdb.py +0 -0
  40. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/trackers.py +0 -0
  41. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/uploaded.py +0 -0
  42. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/version.py +0 -0
  43. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/webup.py +0 -0
  44. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/wizard.py +0 -0
  45. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/auth.py +0 -0
  46. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/clients.py +0 -0
  47. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/db.py +0 -0
  48. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/dist/assets/JetBrainsMono-Italic-VariableFont_wght-CZO9PUqx.ttf +0 -0
  49. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/dist/assets/JetBrainsMono-VariableFont_wght-BrlcHZ7m.ttf +0 -0
  50. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/dist/assets/SpaceGrotesk-VariableFont_wght-DIScfSlK.ttf +0 -0
  51. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/dist/assets/index-FFoOmpDN.css +0 -0
  52. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/lang_cache.py +0 -0
  53. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/tmdb_cache.py +0 -0
  54. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/tocheck.py +0 -0
  55. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/trackers.py +0 -0
  56. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/webup_client.py +0 -0
  57. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/webup_job_fix.py +0 -0
  58. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/webup_logclass.py +0 -0
  59. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/webup_ws.py +0 -0
  60. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep.egg-info/dependency_links.txt +0 -0
  61. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep.egg-info/entry_points.txt +0 -0
  62. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep.egg-info/requires.txt +0 -0
  63. {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: unit3dprep
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: Web UI + CLI di pre-flight per tracker Unit3D, companion di Unit3DWebUp (audio ITA, nomenclatura ItaTorrents, hardlink, upload)
5
5
  Author: Davide Sidoti
6
6
  License: GNU GENERAL PUBLIC LICENSE
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "unit3dprep"
7
- version = "1.2.0"
7
+ version = "1.3.0"
8
8
  description = "Web UI + CLI di pre-flight per tracker Unit3D, companion di Unit3DWebUp (audio ITA, nomenclatura ItaTorrents, hardlink, upload)"
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -112,6 +112,26 @@ CATALOG: dict[str, dict[str, str]] = {
112
112
  "it": "Release non trovata: {error}",
113
113
  "en": "Release not found: {error}",
114
114
  },
115
+ "err.arr_sonarr_not_configured": {
116
+ "it": "Sonarr non configurato",
117
+ "en": "Sonarr is not configured",
118
+ },
119
+ "err.arr_movie_not_found": {
120
+ "it": "Film non trovato in Radarr",
121
+ "en": "Movie not found in Radarr",
122
+ },
123
+ "err.arr_series_not_found": {
124
+ "it": "Serie non trovata in Sonarr",
125
+ "en": "Series not found in Sonarr",
126
+ },
127
+ "err.arr_missing_field": {
128
+ "it": "Campo mancante: {field}",
129
+ "en": "Missing field: {field}",
130
+ },
131
+ "err.arr_season_not_found": {
132
+ "it": "Stagione non trovata in Sonarr",
133
+ "en": "Season not found in Sonarr",
134
+ },
115
135
  }
116
136
 
117
137
 
@@ -0,0 +1,192 @@
1
+ """Radarr / Sonarr endpoints consumed by the library view."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
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 as _i18n_t
11
+ from .. import arr, logbuf
12
+
13
+ router = APIRouter(prefix="/api", tags=["arr"])
14
+
15
+
16
+ class UnmonitorBody(BaseModel):
17
+ kind: str
18
+ path: str = ""
19
+ season_number: int | None = None
20
+ episode_ids: list[int] = []
21
+
22
+
23
+ class BulkBody(BaseModel):
24
+ paths: list[str] = []
25
+
26
+
27
+ @router.get("/arr/status")
28
+ async def arr_status(force: bool = False):
29
+ """path → monitored index for both instances, cached 60 s."""
30
+ return JSONResponse(await arr.build_index(force=force))
31
+
32
+
33
+ @router.get("/arr/test")
34
+ async def arr_test(kind: str):
35
+ return JSONResponse(await arr.test_connection(kind))
36
+
37
+
38
+ @router.get("/arr/series/{series_id}/episodes")
39
+ async def arr_series_episodes(request: Request, series_id: int):
40
+ lang = get_request_lang(request)
41
+ if not arr.configured("sonarr"):
42
+ raise HTTPException(400, _i18n_t("err.arr_sonarr_not_configured", lang))
43
+ try:
44
+ raw = await arr.fetch_episodes(series_id)
45
+ except Exception as e: # noqa: BLE001
46
+ raise HTTPException(502, arr.error_msg(e)) from e
47
+ return JSONResponse({"episodes": arr.episodes_to_dicts(raw)})
48
+
49
+
50
+ async def _resolve_and_unmonitor(body: UnmonitorBody, lang: str) -> int:
51
+ """Resolve the *arr id from the path and switch monitoring off. Returns the count."""
52
+ index = await arr.build_index()
53
+ key = arr.norm_path(body.path)
54
+
55
+ if body.kind == "movie":
56
+ entry = index["movies"].get(key)
57
+ if not entry:
58
+ raise HTTPException(404, _i18n_t("err.arr_movie_not_found", lang))
59
+ return await arr.unmonitor_movies([entry["id"]])
60
+
61
+ if body.kind in {"series", "season"}:
62
+ entry = index["series"].get(key)
63
+ if not entry:
64
+ raise HTTPException(404, _i18n_t("err.arr_series_not_found", lang))
65
+ if body.kind == "season" and body.season_number is None:
66
+ raise HTTPException(400, _i18n_t("err.arr_missing_field", lang, field="season_number"))
67
+ if body.kind == "season" and str(body.season_number) not in entry["seasons"]:
68
+ raise HTTPException(404, _i18n_t("err.arr_season_not_found", lang))
69
+ season = body.season_number if body.kind == "season" else None
70
+ return await arr.unmonitor_series(entry["id"], season)
71
+
72
+ if body.kind == "episodes":
73
+ if not body.episode_ids:
74
+ raise HTTPException(400, _i18n_t("err.arr_missing_field", lang, field="episode_ids"))
75
+ return await arr.unmonitor_episode_ids(body.episode_ids)
76
+
77
+ raise HTTPException(400, _i18n_t("err.invalid_kind", lang))
78
+
79
+
80
+ @router.post("/arr/unmonitor")
81
+ async def arr_unmonitor(request: Request, body: UnmonitorBody):
82
+ lang = get_request_lang(request)
83
+ target = body.path or f"{len(body.episode_ids)} episodi"
84
+ try:
85
+ changed = await _resolve_and_unmonitor(body, lang)
86
+ except HTTPException as e:
87
+ # Also log 400/404s, not just transport failures: the index is cached
88
+ # 60s, so the realistic failure is Radarr/Sonarr going down *after*
89
+ # the badge rendered — the click 404s and the Logs tab is the only
90
+ # place that failure is visible besides the button itself.
91
+ logbuf.emit(
92
+ "warn", f"Rimozione monitoraggio fallita ({e.status_code}): {target}",
93
+ "arr", source="arr",
94
+ )
95
+ raise
96
+ except Exception as e: # noqa: BLE001
97
+ logbuf.emit("error", f"Rimozione monitoraggio fallita: {e}", "arr", source="arr")
98
+ raise HTTPException(502, arr.error_msg(e)) from e
99
+ logbuf.emit("info", f"Monitoraggio rimosso ({body.kind}): {target}", "arr", source="arr")
100
+ return JSONResponse({"ok": True, "changed": changed})
101
+
102
+
103
+ _BULK_FAILURE_LOG_CAP = 10
104
+ _BULK_SERIES_CONCURRENCY = 6
105
+
106
+
107
+ async def _cascade_series(semaphore: asyncio.Semaphore, series_id: int) -> int | BaseException:
108
+ """Unmonitor one series under the concurrency cap.
109
+
110
+ Returns the changed-episode count, or the exception, instead of raising —
111
+ ``asyncio.gather`` preserves call order, so the caller pairs each result
112
+ back to its path positionally regardless of completion order. Deliberately
113
+ calls only ``unmonitor_series()`` here, never ``arr.build_index()``: that
114
+ helper's cache lock is not reentrant, and every task in this gather would
115
+ deadlock against a sibling holding it.
116
+ """
117
+ async with semaphore:
118
+ try:
119
+ return await arr.unmonitor_series(series_id)
120
+ except Exception as e: # noqa: BLE001
121
+ return e
122
+
123
+
124
+ @router.post("/arr/unmonitor/bulk")
125
+ async def arr_unmonitor_bulk(body: BulkBody):
126
+ """Switch monitoring off across many paths.
127
+
128
+ Movies go out in a single call to Radarr's editor endpoint. A series needs
129
+ 4 HTTP calls of its own (fetch series, PUT series, fetch episodes, PUT
130
+ episodes), so on a library with hundreds of series a one-at-a-time loop
131
+ takes minutes and outruns nginx's 60s read timeout before the client sees
132
+ a response. The cascades instead run concurrently, bounded by
133
+ ``_BULK_SERIES_CONCURRENCY`` so Radarr/Sonarr aren't hit with hundreds of
134
+ simultaneous requests. One failure does not stop the others — it lands in
135
+ ``failed`` with its own path.
136
+
137
+ ``done`` counts *targets* (each movie, each series) that succeeded, not
138
+ episodes — unlike ``/arr/unmonitor``'s ``changed``, which counts episodes
139
+ for a series/season target.
140
+ """
141
+ index = await arr.build_index()
142
+ movie_ids: list[int] = []
143
+ series_targets: list[tuple[str, int]] = []
144
+ failed: list[dict[str, str]] = []
145
+
146
+ for raw_path in dict.fromkeys(body.paths): # de-dupe, preserve first-seen order
147
+ key = arr.norm_path(raw_path)
148
+ movie = index["movies"].get(key)
149
+ if movie:
150
+ movie_ids.append(movie["id"])
151
+ continue
152
+ show = index["series"].get(key)
153
+ if show:
154
+ series_targets.append((raw_path, show["id"]))
155
+ continue
156
+ failed.append({"path": raw_path, "error": "Non trovato in Radarr/Sonarr."})
157
+
158
+ done = 0
159
+ if movie_ids:
160
+ try:
161
+ await arr.unmonitor_movies(movie_ids)
162
+ done += len(movie_ids)
163
+ except Exception as e: # noqa: BLE001
164
+ failed.append({"path": f"{len(movie_ids)} film", "error": arr.error_msg(e)})
165
+
166
+ if series_targets:
167
+ semaphore = asyncio.Semaphore(_BULK_SERIES_CONCURRENCY)
168
+ outcomes = await asyncio.gather(
169
+ *(_cascade_series(semaphore, series_id) for _, series_id in series_targets),
170
+ return_exceptions=True,
171
+ )
172
+ # zip(), not the outcome, supplies the path: gather() returns results
173
+ # positionally matched to the input awaitables, not to completion
174
+ # order, so this pairing is correct even though the cascades finish
175
+ # in whatever order their HTTP calls happen to land.
176
+ for (path, _series_id), outcome in zip(series_targets, outcomes):
177
+ if isinstance(outcome, BaseException):
178
+ failed.append({"path": path, "error": arr.error_msg(outcome)})
179
+ else:
180
+ done += 1
181
+
182
+ level = "warn" if failed else "info"
183
+ logbuf.emit(
184
+ level, f"Monitoraggio in blocco: {done} rimossi, {len(failed)} falliti",
185
+ "arr", source="arr",
186
+ )
187
+ for f in failed[:_BULK_FAILURE_LOG_CAP]:
188
+ logbuf.emit("warn", f" {f['path']}: {f['error']}", "arr", source="arr")
189
+ extra = len(failed) - _BULK_FAILURE_LOG_CAP
190
+ if extra > 0:
191
+ logbuf.emit("warn", f" ... e altri {extra} falliti", "arr", source="arr")
192
+ return JSONResponse({"ok": True, "done": done, "failed": failed})
@@ -7,7 +7,7 @@ from pathlib import Path
7
7
  from fastapi import APIRouter
8
8
  from fastapi.responses import JSONResponse
9
9
 
10
- from .. import config
10
+ from .. import arr, config
11
11
  from ...media import media_root, seedings_root
12
12
 
13
13
  router = APIRouter(prefix="/api", tags=["settings"])
@@ -26,11 +26,18 @@ async def get_settings():
26
26
  })
27
27
 
28
28
 
29
+ _ARR_KEYS = ("W_RADARR_URL", "W_RADARR_APIKEY", "W_SONARR_URL", "W_SONARR_APIKEY")
30
+
31
+
29
32
  @router.put("/settings")
30
33
  async def put_settings(incoming: dict):
31
34
  existing = config.load()
32
35
  merged = {**existing, **config.merge_secrets(existing, incoming)}
33
36
  config.save(merged)
37
+ # The *arr index caches `configured` alongside the data, so new credentials
38
+ # would stay invisible to the library for a full TTL without this.
39
+ if any(existing.get(k) != merged.get(k) for k in _ARR_KEYS):
40
+ arr.invalidate_cache()
34
41
  return JSONResponse({"ok": True, "config": config.mask_secrets(merged)})
35
42
 
36
43
 
@@ -18,6 +18,7 @@ from . import logbuf
18
18
  from .webup_client import get_client as get_webup_client, shutdown_client as shutdown_webup_client
19
19
  from .webup_ws import WebupWSManager
20
20
  from .api import (
21
+ arr as arr_api,
21
22
  auth as auth_api,
22
23
  fs as fs_api,
23
24
  library as library_api,
@@ -121,6 +122,7 @@ async def _shutdown():
121
122
  # Mount all JSON routers under ROOT_PATH (routers themselves already declare
122
123
  # /api/... so the final path is /{ROOT_PATH}/api/...).
123
124
  for r in (
125
+ arr_api.router,
124
126
  auth_api.router,
125
127
  fs_api.router,
126
128
  library_api.router,