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.
- {unit3dprep-1.2.0/unit3dprep.egg-info → unit3dprep-1.3.0}/PKG-INFO +1 -1
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/pyproject.toml +1 -1
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/i18n.py +20 -0
- unit3dprep-1.3.0/unit3dprep/web/api/arr.py +192 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/settings.py +8 -1
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/app.py +2 -0
- unit3dprep-1.3.0/unit3dprep/web/arr.py +425 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/config.py +21 -2
- unit3dprep-1.3.0/unit3dprep/web/dist/assets/index-D__l7DJs.js +290 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/dist/index.html +1 -1
- unit3dprep-1.3.0/unit3dprep/web/duplicate_check.py +398 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/logbuf.py +3 -1
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/reseed.py +1 -9
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/webup_orchestrator.py +281 -53
- {unit3dprep-1.2.0 → unit3dprep-1.3.0/unit3dprep.egg-info}/PKG-INFO +1 -1
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep.egg-info/SOURCES.txt +3 -1
- unit3dprep-1.2.0/unit3dprep/web/dist/assets/index-CPw9mcxL.js +0 -280
- unit3dprep-1.2.0/unit3dprep/web/duplicate_check.py +0 -168
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/LICENSE +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/MANIFEST.in +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/README.md +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/setup.cfg +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/__init__.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/cli.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/core.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/media.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/upload.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/__init__.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/_env.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/__init__.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/auth.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/fs.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/library.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/logs.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/queue.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/quickupload.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/reseed.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/search.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/tmdb.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/trackers.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/uploaded.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/version.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/webup.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/api/wizard.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/auth.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/clients.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/db.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/dist/assets/JetBrainsMono-Italic-VariableFont_wght-CZO9PUqx.ttf +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/dist/assets/JetBrainsMono-VariableFont_wght-BrlcHZ7m.ttf +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/dist/assets/SpaceGrotesk-VariableFont_wght-DIScfSlK.ttf +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/dist/assets/index-FFoOmpDN.css +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/lang_cache.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/tmdb_cache.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/tocheck.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/trackers.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/webup_client.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/webup_job_fix.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/webup_logclass.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep/web/webup_ws.py +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep.egg-info/dependency_links.txt +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep.egg-info/entry_points.txt +0 -0
- {unit3dprep-1.2.0 → unit3dprep-1.3.0}/unit3dprep.egg-info/requires.txt +0 -0
- {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.
|
|
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.
|
|
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,
|