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/app.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""FastAPI application: JSON API under /{ROOT_PATH}/api + SPA served from /dist."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
9
|
+
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
|
|
10
|
+
from fastapi.staticfiles import StaticFiles
|
|
11
|
+
from starlette.middleware.sessions import SessionMiddleware
|
|
12
|
+
|
|
13
|
+
from ._env import env as _env, migrate_dotfiles
|
|
14
|
+
from .auth import SECRET_KEY
|
|
15
|
+
from .db import init_db
|
|
16
|
+
from . import logbuf
|
|
17
|
+
from .webup_client import get_client as get_webup_client, shutdown_client as shutdown_webup_client
|
|
18
|
+
from .webup_ws import WebupWSManager
|
|
19
|
+
from .api import (
|
|
20
|
+
auth as auth_api,
|
|
21
|
+
fs as fs_api,
|
|
22
|
+
library as library_api,
|
|
23
|
+
logs as logs_api,
|
|
24
|
+
quickupload as quickupload_api,
|
|
25
|
+
queue as queue_api,
|
|
26
|
+
search as search_api,
|
|
27
|
+
settings as settings_api,
|
|
28
|
+
tmdb as tmdb_api,
|
|
29
|
+
trackers as trackers_api,
|
|
30
|
+
uploaded as uploaded_api,
|
|
31
|
+
version as version_api,
|
|
32
|
+
webup as webup_api,
|
|
33
|
+
wizard as wizard_api,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
migrate_dotfiles(Path.home())
|
|
37
|
+
|
|
38
|
+
# When the reverse proxy does NOT strip the prefix (typical Ultra.cc nginx
|
|
39
|
+
# user-proxy without trailing slash on proxy_pass), routes must be registered
|
|
40
|
+
# under the prefix — FastAPI's `root_path=` only helps when the proxy strips.
|
|
41
|
+
ROOT_PATH = (_env("U3DP_ROOT_PATH", "ITA_ROOT_PATH", "") or "").rstrip("/")
|
|
42
|
+
DIST_DIR = Path(__file__).parent / "dist"
|
|
43
|
+
|
|
44
|
+
API_PREFIX = f"{ROOT_PATH}/api"
|
|
45
|
+
_OPENAPI_URL = f"{ROOT_PATH}/openapi.json"
|
|
46
|
+
AUTH_EXEMPT = {
|
|
47
|
+
f"{API_PREFIX}/auth/login",
|
|
48
|
+
f"{API_PREFIX}/me",
|
|
49
|
+
f"{API_PREFIX}/docs",
|
|
50
|
+
f"{API_PREFIX}/redoc",
|
|
51
|
+
_OPENAPI_URL,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
app = FastAPI(
|
|
55
|
+
title="Unit3DPrep Web",
|
|
56
|
+
docs_url=f"{API_PREFIX}/docs",
|
|
57
|
+
redoc_url=f"{API_PREFIX}/redoc",
|
|
58
|
+
openapi_url=_OPENAPI_URL,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@app.middleware("http")
|
|
63
|
+
async def _auth_guard(request: Request, call_next):
|
|
64
|
+
path = request.url.path.rstrip("/")
|
|
65
|
+
protected = path.startswith(API_PREFIX) or path == _OPENAPI_URL
|
|
66
|
+
if protected and path not in AUTH_EXEMPT:
|
|
67
|
+
if not request.session.get("authenticated"):
|
|
68
|
+
return JSONResponse({"detail": "Not authenticated"}, status_code=401)
|
|
69
|
+
return await call_next(request)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
app.add_middleware(
|
|
73
|
+
SessionMiddleware,
|
|
74
|
+
secret_key=SECRET_KEY,
|
|
75
|
+
https_only=(_env("U3DP_HTTPS_ONLY", "ITA_HTTPS_ONLY", "0") or "0") == "1",
|
|
76
|
+
same_site="lax",
|
|
77
|
+
max_age=86400 * 7,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.on_event("startup")
|
|
82
|
+
async def _startup():
|
|
83
|
+
await init_db()
|
|
84
|
+
logbuf.install(asyncio.get_event_loop())
|
|
85
|
+
logging.getLogger("unit3dprep").info("unit3dprep-web started")
|
|
86
|
+
|
|
87
|
+
# Webup orchestration state.
|
|
88
|
+
app.state.webup = get_webup_client()
|
|
89
|
+
app.state.webup_ws = WebupWSManager()
|
|
90
|
+
app.state.webup_ws.start()
|
|
91
|
+
app.state.webup_scan_lock = asyncio.Lock()
|
|
92
|
+
|
|
93
|
+
# Best-effort runtime bridge: push the shared .env content to webup's
|
|
94
|
+
# in-memory settings via /setenv on startup, so config changes saved while
|
|
95
|
+
# webup was offline take effect without restarting it.
|
|
96
|
+
# Non-blocking; ignore failures (webup may not be running yet).
|
|
97
|
+
try:
|
|
98
|
+
from .config import bootstrap_webup_env
|
|
99
|
+
asyncio.create_task(bootstrap_webup_env(app.state.webup), name="webup-bootstrap-env")
|
|
100
|
+
except Exception:
|
|
101
|
+
logging.getLogger("unit3dprep").exception("webup bootstrap scheduling failed")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.on_event("shutdown")
|
|
105
|
+
async def _shutdown():
|
|
106
|
+
ws_mgr = getattr(app.state, "webup_ws", None)
|
|
107
|
+
if ws_mgr is not None:
|
|
108
|
+
try:
|
|
109
|
+
await ws_mgr.stop()
|
|
110
|
+
except Exception:
|
|
111
|
+
pass
|
|
112
|
+
try:
|
|
113
|
+
await shutdown_webup_client()
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# Mount all JSON routers under ROOT_PATH (routers themselves already declare
|
|
119
|
+
# /api/... so the final path is /{ROOT_PATH}/api/...).
|
|
120
|
+
for r in (
|
|
121
|
+
auth_api.router,
|
|
122
|
+
fs_api.router,
|
|
123
|
+
library_api.router,
|
|
124
|
+
logs_api.router,
|
|
125
|
+
queue_api.router,
|
|
126
|
+
quickupload_api.router,
|
|
127
|
+
search_api.router,
|
|
128
|
+
settings_api.router,
|
|
129
|
+
tmdb_api.router,
|
|
130
|
+
trackers_api.router,
|
|
131
|
+
uploaded_api.router,
|
|
132
|
+
version_api.router,
|
|
133
|
+
webup_api.router,
|
|
134
|
+
wizard_api.router,
|
|
135
|
+
):
|
|
136
|
+
app.include_router(r, prefix=ROOT_PATH)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if (DIST_DIR / "assets").exists():
|
|
140
|
+
app.mount(
|
|
141
|
+
f"{ROOT_PATH}/assets",
|
|
142
|
+
StaticFiles(directory=str(DIST_DIR / "assets")),
|
|
143
|
+
name="assets",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _render_index() -> str:
|
|
148
|
+
idx = DIST_DIR / "index.html"
|
|
149
|
+
if not idx.is_file():
|
|
150
|
+
return ""
|
|
151
|
+
html = idx.read_text(encoding="utf-8")
|
|
152
|
+
inject = f'<script>window.__ROOT_PATH__={ROOT_PATH!r};</script>'
|
|
153
|
+
html = html.replace("</head>", f"{inject}</head>", 1)
|
|
154
|
+
# Vite builds with base='./' producing relative asset paths. When the
|
|
155
|
+
# page is served at /unit3dprep (no trailing slash) the browser resolves
|
|
156
|
+
# './' to '/' instead of '/unit3dprep/' — fix to absolute paths at serve time.
|
|
157
|
+
if ROOT_PATH:
|
|
158
|
+
html = html.replace('./assets/', f'{ROOT_PATH}/assets/')
|
|
159
|
+
return html
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
# SPA catch-all. Serves index.html for any non-API path so a deep link to a
|
|
163
|
+
# client-side route still boots the app.
|
|
164
|
+
_CATCHALL = f"{ROOT_PATH}/{{full_path:path}}" if ROOT_PATH else "/{full_path:path}"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@app.get(_CATCHALL)
|
|
168
|
+
async def spa(full_path: str, request: Request):
|
|
169
|
+
if full_path.startswith("api/") or full_path.startswith("assets/"):
|
|
170
|
+
raise HTTPException(status_code=404)
|
|
171
|
+
if full_path and not full_path.endswith("/"):
|
|
172
|
+
candidate = DIST_DIR / full_path
|
|
173
|
+
if candidate.is_file():
|
|
174
|
+
return FileResponse(candidate)
|
|
175
|
+
rendered = _render_index()
|
|
176
|
+
if rendered:
|
|
177
|
+
return HTMLResponse(rendered)
|
|
178
|
+
return JSONResponse(
|
|
179
|
+
{"detail": "Frontend not built. Run `cd frontend && npm install && npm run build`."},
|
|
180
|
+
status_code=503,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
if ROOT_PATH:
|
|
185
|
+
@app.get(ROOT_PATH)
|
|
186
|
+
async def _root_alias():
|
|
187
|
+
rendered = _render_index()
|
|
188
|
+
return HTMLResponse(rendered) if rendered else JSONResponse(
|
|
189
|
+
{"detail": "Frontend not built."}, status_code=503,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def run():
|
|
194
|
+
import uvicorn
|
|
195
|
+
host = _env("U3DP_HOST", "ITA_HOST", "127.0.0.1") or "127.0.0.1"
|
|
196
|
+
port = int(_env("U3DP_PORT", "ITA_PORT", "8765") or "8765")
|
|
197
|
+
uvicorn.run("unit3dprep.web.app:app", host=host, port=port, reload=False)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if __name__ == "__main__":
|
|
201
|
+
run()
|
unit3dprep/web/auth.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Auth helpers: bcrypt password check, session management."""
|
|
2
|
+
import bcrypt
|
|
3
|
+
from fastapi import Request
|
|
4
|
+
from starlette.responses import RedirectResponse
|
|
5
|
+
|
|
6
|
+
from ._env import env as _env
|
|
7
|
+
|
|
8
|
+
PASSWORD_HASH = _env("U3DP_PASSWORD_HASH", "ITA_PASSWORD_HASH", "") or ""
|
|
9
|
+
SECRET_KEY = _env("U3DP_SECRET", "ITA_SECRET", "changeme-set-U3DP_SECRET") or "changeme-set-U3DP_SECRET"
|
|
10
|
+
SESSION_ID_KEY = "session_id"
|
|
11
|
+
AUTH_KEY = "authenticated"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def verify_password(plain: str) -> bool:
|
|
15
|
+
if not PASSWORD_HASH:
|
|
16
|
+
return False
|
|
17
|
+
try:
|
|
18
|
+
return bcrypt.checkpw(plain.encode(), PASSWORD_HASH.encode())
|
|
19
|
+
except Exception:
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def login_session(request: Request, session_id: str):
|
|
24
|
+
request.session[AUTH_KEY] = True
|
|
25
|
+
request.session[SESSION_ID_KEY] = session_id
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def logout_session(request: Request):
|
|
29
|
+
request.session.clear()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def is_authenticated(request: Request) -> bool:
|
|
33
|
+
return bool(request.session.get(AUTH_KEY))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_session_id(request: Request) -> str:
|
|
37
|
+
return request.session.get(SESSION_ID_KEY, "")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def redirect_to_login(next_url: str = "/") -> RedirectResponse:
|
|
41
|
+
return RedirectResponse(f"/login?next={next_url}", status_code=303)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Torrent-client abstraction.
|
|
2
|
+
|
|
3
|
+
v1 wires up qBittorrent (Web API). Transmission/rTorrent surface as "not
|
|
4
|
+
implemented" so the Settings page can advertise them without crashing the
|
|
5
|
+
Queue view.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import time
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class Torrent:
|
|
20
|
+
hash: str
|
|
21
|
+
name: str
|
|
22
|
+
size: int
|
|
23
|
+
progress: float
|
|
24
|
+
state: str # seeding|uploading|queued|error|pending|paused|other
|
|
25
|
+
ratio: float
|
|
26
|
+
category: str
|
|
27
|
+
tracker: str
|
|
28
|
+
save_path: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TorrentClient(ABC):
|
|
32
|
+
name: str = "generic"
|
|
33
|
+
|
|
34
|
+
@abstractmethod
|
|
35
|
+
async def list(self) -> list[Torrent]: ...
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
async def reseed(self, torrent_hash: str) -> None: ...
|
|
39
|
+
|
|
40
|
+
@abstractmethod
|
|
41
|
+
async def remove(self, torrent_hash: str, delete_files: bool = False) -> None: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# qBittorrent Web API (v2)
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_QBIT_STATE_MAP = {
|
|
50
|
+
"uploading": "seeding",
|
|
51
|
+
"stalledUP": "seeding",
|
|
52
|
+
"forcedUP": "seeding",
|
|
53
|
+
"queuedUP": "queued",
|
|
54
|
+
"checkingUP": "seeding",
|
|
55
|
+
"downloading": "uploading",
|
|
56
|
+
"forcedDL": "uploading",
|
|
57
|
+
"stalledDL": "uploading",
|
|
58
|
+
"queuedDL": "queued",
|
|
59
|
+
"checkingDL": "uploading",
|
|
60
|
+
"metaDL": "uploading",
|
|
61
|
+
"pausedUP": "paused",
|
|
62
|
+
"pausedDL": "paused",
|
|
63
|
+
"error": "error",
|
|
64
|
+
"missingFiles": "error",
|
|
65
|
+
"unknown": "pending",
|
|
66
|
+
"allocating": "pending",
|
|
67
|
+
"moving": "pending",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class QBittorrentClient(TorrentClient):
|
|
72
|
+
name = "qbittorrent"
|
|
73
|
+
|
|
74
|
+
def __init__(self, host: str, port: str | int, user: str, password: str):
|
|
75
|
+
self.base = f"http://{host}:{port}"
|
|
76
|
+
self.user = user
|
|
77
|
+
self.password = password
|
|
78
|
+
self._client: httpx.AsyncClient | None = None
|
|
79
|
+
self._logged_in_at: float = 0.0
|
|
80
|
+
|
|
81
|
+
async def _http(self) -> httpx.AsyncClient:
|
|
82
|
+
if self._client is None:
|
|
83
|
+
self._client = httpx.AsyncClient(base_url=self.base, timeout=15.0)
|
|
84
|
+
# Re-login every 30 min to keep cookie fresh
|
|
85
|
+
if time.time() - self._logged_in_at > 1800:
|
|
86
|
+
r = await self._client.post(
|
|
87
|
+
"/api/v2/auth/login",
|
|
88
|
+
data={"username": self.user, "password": self.password},
|
|
89
|
+
headers={"Referer": self.base},
|
|
90
|
+
)
|
|
91
|
+
if r.status_code != 200 or r.text.strip() != "Ok.":
|
|
92
|
+
raise RuntimeError(f"qBittorrent login failed: {r.status_code} {r.text!r}")
|
|
93
|
+
self._logged_in_at = time.time()
|
|
94
|
+
return self._client
|
|
95
|
+
|
|
96
|
+
async def list(self) -> list[Torrent]:
|
|
97
|
+
cli = await self._http()
|
|
98
|
+
r = await cli.get("/api/v2/torrents/info")
|
|
99
|
+
r.raise_for_status()
|
|
100
|
+
out: list[Torrent] = []
|
|
101
|
+
for t in r.json():
|
|
102
|
+
state = _QBIT_STATE_MAP.get(t.get("state", "unknown"), "pending")
|
|
103
|
+
out.append(Torrent(
|
|
104
|
+
hash=t.get("hash", ""),
|
|
105
|
+
name=t.get("name", ""),
|
|
106
|
+
size=int(t.get("size", 0) or 0),
|
|
107
|
+
progress=float(t.get("progress", 0) or 0),
|
|
108
|
+
state=state,
|
|
109
|
+
ratio=float(t.get("ratio", 0) or 0),
|
|
110
|
+
category=t.get("category", ""),
|
|
111
|
+
tracker=t.get("tracker", ""),
|
|
112
|
+
save_path=t.get("save_path", ""),
|
|
113
|
+
))
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
async def reseed(self, torrent_hash: str) -> None:
|
|
117
|
+
cli = await self._http()
|
|
118
|
+
r = await cli.post("/api/v2/torrents/recheck", data={"hashes": torrent_hash})
|
|
119
|
+
r.raise_for_status()
|
|
120
|
+
r2 = await cli.post("/api/v2/torrents/resume", data={"hashes": torrent_hash})
|
|
121
|
+
r2.raise_for_status()
|
|
122
|
+
|
|
123
|
+
async def remove(self, torrent_hash: str, delete_files: bool = False) -> None:
|
|
124
|
+
cli = await self._http()
|
|
125
|
+
r = await cli.post(
|
|
126
|
+
"/api/v2/torrents/delete",
|
|
127
|
+
data={"hashes": torrent_hash, "deleteFiles": "true" if delete_files else "false"},
|
|
128
|
+
)
|
|
129
|
+
r.raise_for_status()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class _NotImplementedClient(TorrentClient):
|
|
133
|
+
def __init__(self, name: str):
|
|
134
|
+
self.name = name
|
|
135
|
+
|
|
136
|
+
async def list(self) -> list[Torrent]:
|
|
137
|
+
raise NotImplementedError(f"{self.name} client not implemented yet")
|
|
138
|
+
|
|
139
|
+
async def reseed(self, torrent_hash: str) -> None:
|
|
140
|
+
raise NotImplementedError(f"{self.name} client not implemented yet")
|
|
141
|
+
|
|
142
|
+
async def remove(self, torrent_hash: str, delete_files: bool = False) -> None:
|
|
143
|
+
raise NotImplementedError(f"{self.name} client not implemented yet")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
_client_cache: tuple[tuple, TorrentClient] | None = None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_client(cfg: dict[str, Any]) -> TorrentClient:
|
|
150
|
+
global _client_cache
|
|
151
|
+
which = (cfg.get("TORRENT_CLIENT") or "qbittorrent").lower()
|
|
152
|
+
if which == "qbittorrent":
|
|
153
|
+
host = cfg.get("QBIT_HOST", "127.0.0.1")
|
|
154
|
+
port = cfg.get("QBIT_PORT", "15491")
|
|
155
|
+
user = cfg.get("QBIT_USER", "admin")
|
|
156
|
+
password = cfg.get("QBIT_PASS", "")
|
|
157
|
+
key = ("qbittorrent", host, str(port), user, password)
|
|
158
|
+
if _client_cache is not None and _client_cache[0] == key:
|
|
159
|
+
return _client_cache[1]
|
|
160
|
+
cli = QBittorrentClient(host=host, port=port, user=user, password=password)
|
|
161
|
+
_client_cache = (key, cli)
|
|
162
|
+
return cli
|
|
163
|
+
if which == "transmission":
|
|
164
|
+
return _NotImplementedClient("transmission")
|
|
165
|
+
if which == "rtorrent":
|
|
166
|
+
return _NotImplementedClient("rtorrent")
|
|
167
|
+
return _NotImplementedClient(which)
|