pakt 0.2.1__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.
- pakt/__init__.py +3 -0
- pakt/__main__.py +6 -0
- pakt/assets/icon.png +0 -0
- pakt/assets/icon.svg +10 -0
- pakt/assets/logo.png +0 -0
- pakt/cli.py +814 -0
- pakt/config.py +222 -0
- pakt/models.py +109 -0
- pakt/plex.py +758 -0
- pakt/scheduler.py +153 -0
- pakt/sync.py +1490 -0
- pakt/trakt.py +575 -0
- pakt/tray.py +137 -0
- pakt/web/__init__.py +5 -0
- pakt/web/app.py +991 -0
- pakt/web/templates/index.html +2327 -0
- pakt-0.2.1.dist-info/METADATA +207 -0
- pakt-0.2.1.dist-info/RECORD +20 -0
- pakt-0.2.1.dist-info/WHEEL +4 -0
- pakt-0.2.1.dist-info/entry_points.txt +2 -0
pakt/web/app.py
ADDED
|
@@ -0,0 +1,991 @@
|
|
|
1
|
+
"""FastAPI application for Pakt web interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from contextlib import asynccontextmanager
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from fastapi import BackgroundTasks, FastAPI, Request
|
|
14
|
+
from fastapi.responses import FileResponse, HTMLResponse
|
|
15
|
+
from fastapi.templating import Jinja2Templates
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
from pakt.config import Config, ServerConfig, get_config_dir
|
|
19
|
+
from pakt.sync import run_multi_server_sync
|
|
20
|
+
|
|
21
|
+
# Global state for sync status
|
|
22
|
+
sync_state = {
|
|
23
|
+
"running": False,
|
|
24
|
+
"cancelled": False,
|
|
25
|
+
"last_run": None,
|
|
26
|
+
"last_result": None,
|
|
27
|
+
"logs": [],
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# Global scheduler instance
|
|
31
|
+
_scheduler = None
|
|
32
|
+
|
|
33
|
+
# Cache for expensive API calls (Trakt account, Plex libraries)
|
|
34
|
+
_cache: dict[str, tuple[float, Any]] = {}
|
|
35
|
+
CACHE_TTL = 300 # 5 minutes
|
|
36
|
+
CONFIG_CACHE_TTL = 2 # 2 seconds for config (avoids repeated disk reads during page load)
|
|
37
|
+
|
|
38
|
+
_config_cache: tuple[float, Config | None] = (0, None)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_cached(key: str) -> Any | None:
|
|
42
|
+
"""Get cached value if not expired."""
|
|
43
|
+
if key in _cache:
|
|
44
|
+
ts, value = _cache[key]
|
|
45
|
+
if time.time() - ts < CACHE_TTL:
|
|
46
|
+
return value
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def set_cached(key: str, value: Any) -> None:
|
|
51
|
+
"""Cache a value."""
|
|
52
|
+
_cache[key] = (time.time(), value)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_config_cached() -> Config:
|
|
56
|
+
"""Get config with short-term caching to avoid repeated disk reads."""
|
|
57
|
+
global _config_cache
|
|
58
|
+
ts, config = _config_cache
|
|
59
|
+
if config is not None and time.time() - ts < CONFIG_CACHE_TTL:
|
|
60
|
+
return config
|
|
61
|
+
config = Config.load()
|
|
62
|
+
_config_cache = (time.time(), config)
|
|
63
|
+
return config
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def invalidate_config_cache() -> None:
|
|
67
|
+
"""Invalidate config cache after saves."""
|
|
68
|
+
global _config_cache
|
|
69
|
+
_config_cache = (0, None)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ConfigUpdate(BaseModel):
|
|
73
|
+
"""Configuration update request."""
|
|
74
|
+
|
|
75
|
+
trakt_client_id: str | None = None
|
|
76
|
+
trakt_client_secret: str | None = None
|
|
77
|
+
plex_url: str | None = None
|
|
78
|
+
plex_token: str | None = None
|
|
79
|
+
watched_plex_to_trakt: bool | None = None
|
|
80
|
+
watched_trakt_to_plex: bool | None = None
|
|
81
|
+
ratings_plex_to_trakt: bool | None = None
|
|
82
|
+
ratings_trakt_to_plex: bool | None = None
|
|
83
|
+
collection_plex_to_trakt: bool | None = None
|
|
84
|
+
watchlist_plex_to_trakt: bool | None = None
|
|
85
|
+
watchlist_trakt_to_plex: bool | None = None
|
|
86
|
+
# Scheduler
|
|
87
|
+
scheduler_enabled: bool | None = None
|
|
88
|
+
scheduler_interval_hours: int | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class SyncRequest(BaseModel):
|
|
92
|
+
"""Sync request options."""
|
|
93
|
+
|
|
94
|
+
dry_run: bool = False
|
|
95
|
+
verbose: bool = False
|
|
96
|
+
servers: list[str] | None = None # Optional list of server names to sync
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class SyncOverrideUpdate(BaseModel):
|
|
100
|
+
"""Per-server sync option overrides. None = use global."""
|
|
101
|
+
|
|
102
|
+
watched_plex_to_trakt: bool | None = None
|
|
103
|
+
watched_trakt_to_plex: bool | None = None
|
|
104
|
+
ratings_plex_to_trakt: bool | None = None
|
|
105
|
+
ratings_trakt_to_plex: bool | None = None
|
|
106
|
+
collection_plex_to_trakt: bool | None = None
|
|
107
|
+
watchlist_plex_to_trakt: bool | None = None
|
|
108
|
+
watchlist_trakt_to_plex: bool | None = None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class ServerUpdate(BaseModel):
|
|
112
|
+
"""Server configuration update."""
|
|
113
|
+
|
|
114
|
+
enabled: bool | None = None
|
|
115
|
+
movie_libraries: list[str] | None = None
|
|
116
|
+
show_libraries: list[str] | None = None
|
|
117
|
+
sync: SyncOverrideUpdate | None = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class ServerCreate(BaseModel):
|
|
121
|
+
"""Create server request."""
|
|
122
|
+
|
|
123
|
+
name: str
|
|
124
|
+
url: str | None = None
|
|
125
|
+
token: str | None = None
|
|
126
|
+
server_name: str | None = None # For discovered servers
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def _scheduled_sync() -> None:
|
|
130
|
+
"""Run sync for scheduler (no dry run, no UI feedback)."""
|
|
131
|
+
global sync_state
|
|
132
|
+
if sync_state["running"]:
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
sync_state["running"] = True
|
|
136
|
+
sync_state["cancelled"] = False
|
|
137
|
+
sync_state["logs"] = ["Scheduled sync started"]
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
config = Config.load()
|
|
141
|
+
|
|
142
|
+
def on_token_refresh(token: dict):
|
|
143
|
+
config.trakt.access_token = token["access_token"]
|
|
144
|
+
config.trakt.refresh_token = token["refresh_token"]
|
|
145
|
+
config.trakt.expires_at = token["created_at"] + token["expires_in"]
|
|
146
|
+
config.save()
|
|
147
|
+
|
|
148
|
+
result = await run_multi_server_sync(
|
|
149
|
+
config,
|
|
150
|
+
dry_run=False,
|
|
151
|
+
on_token_refresh=on_token_refresh,
|
|
152
|
+
log_callback=lambda msg: sync_state["logs"].append(msg),
|
|
153
|
+
cancel_check=lambda: sync_state["cancelled"],
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
sync_state["last_result"] = {
|
|
157
|
+
"added_to_trakt": result.added_to_trakt,
|
|
158
|
+
"added_to_plex": result.added_to_plex,
|
|
159
|
+
"ratings_synced": result.ratings_synced,
|
|
160
|
+
"collection_added": result.collection_added,
|
|
161
|
+
"watchlist_added_trakt": result.watchlist_added_trakt,
|
|
162
|
+
"watchlist_added_plex": result.watchlist_added_plex,
|
|
163
|
+
"duration": result.duration_seconds,
|
|
164
|
+
"errors": result.errors[:10],
|
|
165
|
+
}
|
|
166
|
+
sync_state["last_run"] = datetime.now().isoformat()
|
|
167
|
+
except Exception as e:
|
|
168
|
+
sync_state["logs"].append(f"ERROR:{str(e)}")
|
|
169
|
+
sync_state["last_result"] = {"error": str(e)}
|
|
170
|
+
finally:
|
|
171
|
+
sync_state["running"] = False
|
|
172
|
+
sync_state["cancelled"] = False
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@asynccontextmanager
|
|
176
|
+
async def lifespan(app: FastAPI):
|
|
177
|
+
"""Application lifespan handler."""
|
|
178
|
+
global _scheduler
|
|
179
|
+
|
|
180
|
+
config = Config.load()
|
|
181
|
+
if config.scheduler.enabled and config.scheduler.interval_hours > 0:
|
|
182
|
+
try:
|
|
183
|
+
from pakt.scheduler import SyncScheduler
|
|
184
|
+
|
|
185
|
+
_scheduler = SyncScheduler(
|
|
186
|
+
config,
|
|
187
|
+
sync_func=_scheduled_sync,
|
|
188
|
+
is_running_func=lambda: sync_state["running"],
|
|
189
|
+
)
|
|
190
|
+
_scheduler.start()
|
|
191
|
+
except ImportError:
|
|
192
|
+
pass # APScheduler not installed
|
|
193
|
+
|
|
194
|
+
yield
|
|
195
|
+
|
|
196
|
+
if _scheduler:
|
|
197
|
+
_scheduler.stop()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def create_app() -> FastAPI:
|
|
201
|
+
"""Create the FastAPI application."""
|
|
202
|
+
app = FastAPI(
|
|
203
|
+
title="Pakt",
|
|
204
|
+
description="Plex-Trakt sync",
|
|
205
|
+
version="0.1.0",
|
|
206
|
+
lifespan=lifespan,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
# Templates and assets
|
|
210
|
+
template_dir = Path(__file__).parent / "templates"
|
|
211
|
+
templates = Jinja2Templates(directory=str(template_dir))
|
|
212
|
+
assets_dir = Path(__file__).parent.parent / "assets"
|
|
213
|
+
|
|
214
|
+
# =========================================================================
|
|
215
|
+
# Web UI Routes
|
|
216
|
+
# =========================================================================
|
|
217
|
+
|
|
218
|
+
@app.get("/favicon.ico")
|
|
219
|
+
async def favicon():
|
|
220
|
+
"""Serve favicon."""
|
|
221
|
+
icon_path = assets_dir / "icon.png"
|
|
222
|
+
if icon_path.exists():
|
|
223
|
+
return FileResponse(icon_path, media_type="image/png")
|
|
224
|
+
return FileResponse(assets_dir / "icon.svg", media_type="image/svg+xml")
|
|
225
|
+
|
|
226
|
+
@app.get("/assets/{filename}")
|
|
227
|
+
async def serve_asset(filename: str):
|
|
228
|
+
"""Serve static assets."""
|
|
229
|
+
file_path = assets_dir / filename
|
|
230
|
+
if file_path.exists() and file_path.is_file():
|
|
231
|
+
media_type = "image/png" if filename.endswith(".png") else "image/svg+xml"
|
|
232
|
+
return FileResponse(file_path, media_type=media_type)
|
|
233
|
+
return HTMLResponse(status_code=404, content="Not found")
|
|
234
|
+
|
|
235
|
+
@app.get("/", response_class=HTMLResponse)
|
|
236
|
+
async def index(request: Request):
|
|
237
|
+
"""Main dashboard."""
|
|
238
|
+
config = Config.load()
|
|
239
|
+
return templates.TemplateResponse(
|
|
240
|
+
"index.html",
|
|
241
|
+
{
|
|
242
|
+
"request": request,
|
|
243
|
+
"config": config,
|
|
244
|
+
"sync_state": sync_state,
|
|
245
|
+
"config_dir": str(get_config_dir()),
|
|
246
|
+
},
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# =========================================================================
|
|
250
|
+
# API Routes
|
|
251
|
+
# =========================================================================
|
|
252
|
+
|
|
253
|
+
@app.get("/api/status")
|
|
254
|
+
async def get_status() -> dict[str, Any]:
|
|
255
|
+
"""Get current status."""
|
|
256
|
+
config = load_config_cached()
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
"trakt_configured": bool(config.trakt.client_id),
|
|
260
|
+
"trakt_authenticated": bool(config.trakt.access_token),
|
|
261
|
+
"plex_configured": bool(config.servers),
|
|
262
|
+
"sync_running": sync_state["running"],
|
|
263
|
+
"last_run": sync_state["last_run"],
|
|
264
|
+
"last_result": sync_state["last_result"],
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
@app.get("/api/init")
|
|
268
|
+
async def get_init() -> dict[str, Any]:
|
|
269
|
+
"""Get all data needed for page load in one request."""
|
|
270
|
+
from pakt.plex import PlexClient
|
|
271
|
+
from pakt.trakt import TraktClient
|
|
272
|
+
|
|
273
|
+
config = load_config_cached()
|
|
274
|
+
|
|
275
|
+
result: dict[str, Any] = {
|
|
276
|
+
"status": {
|
|
277
|
+
"trakt_configured": bool(config.trakt.client_id),
|
|
278
|
+
"trakt_authenticated": bool(config.trakt.access_token),
|
|
279
|
+
"plex_configured": bool(config.servers),
|
|
280
|
+
"sync_running": sync_state["running"],
|
|
281
|
+
"last_run": sync_state["last_run"],
|
|
282
|
+
"last_result": sync_state["last_result"],
|
|
283
|
+
},
|
|
284
|
+
"config": {
|
|
285
|
+
"sync": {
|
|
286
|
+
"watched_plex_to_trakt": config.sync.watched_plex_to_trakt,
|
|
287
|
+
"watched_trakt_to_plex": config.sync.watched_trakt_to_plex,
|
|
288
|
+
"ratings_plex_to_trakt": config.sync.ratings_plex_to_trakt,
|
|
289
|
+
"ratings_trakt_to_plex": config.sync.ratings_trakt_to_plex,
|
|
290
|
+
"collection_plex_to_trakt": config.sync.collection_plex_to_trakt,
|
|
291
|
+
"watchlist_plex_to_trakt": config.sync.watchlist_plex_to_trakt,
|
|
292
|
+
"watchlist_trakt_to_plex": config.sync.watchlist_trakt_to_plex,
|
|
293
|
+
},
|
|
294
|
+
"scheduler": {
|
|
295
|
+
"enabled": config.scheduler.enabled,
|
|
296
|
+
"interval_hours": config.scheduler.interval_hours,
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
"servers": [],
|
|
300
|
+
"trakt_account": None,
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
# Add servers with library info
|
|
304
|
+
for s in config.servers:
|
|
305
|
+
server_data: dict[str, Any] = {
|
|
306
|
+
"name": s.name,
|
|
307
|
+
"server_name": s.server_name,
|
|
308
|
+
"url": s.url,
|
|
309
|
+
"enabled": s.enabled,
|
|
310
|
+
"movie_libraries": s.movie_libraries,
|
|
311
|
+
"show_libraries": s.show_libraries,
|
|
312
|
+
"sync": None,
|
|
313
|
+
"libraries": None,
|
|
314
|
+
}
|
|
315
|
+
if s.sync:
|
|
316
|
+
server_data["sync"] = {
|
|
317
|
+
"watched_plex_to_trakt": s.sync.watched_plex_to_trakt,
|
|
318
|
+
"watched_trakt_to_plex": s.sync.watched_trakt_to_plex,
|
|
319
|
+
"ratings_plex_to_trakt": s.sync.ratings_plex_to_trakt,
|
|
320
|
+
"ratings_trakt_to_plex": s.sync.ratings_trakt_to_plex,
|
|
321
|
+
"collection_plex_to_trakt": s.sync.collection_plex_to_trakt,
|
|
322
|
+
"watchlist_plex_to_trakt": s.sync.watchlist_plex_to_trakt,
|
|
323
|
+
"watchlist_trakt_to_plex": s.sync.watchlist_trakt_to_plex,
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
# Get libraries (cached)
|
|
327
|
+
cache_key = f"plex_libraries_{s.name}"
|
|
328
|
+
cached_libs = get_cached(cache_key)
|
|
329
|
+
if cached_libs:
|
|
330
|
+
server_data["libraries"] = cached_libs
|
|
331
|
+
else:
|
|
332
|
+
try:
|
|
333
|
+
plex = PlexClient(s)
|
|
334
|
+
plex.connect()
|
|
335
|
+
libs = {
|
|
336
|
+
"movie": plex.get_movie_libraries(),
|
|
337
|
+
"show": plex.get_show_libraries(),
|
|
338
|
+
}
|
|
339
|
+
set_cached(cache_key, libs)
|
|
340
|
+
server_data["libraries"] = libs
|
|
341
|
+
except Exception:
|
|
342
|
+
server_data["libraries"] = {"movie": [], "show": []}
|
|
343
|
+
|
|
344
|
+
result["servers"].append(server_data)
|
|
345
|
+
|
|
346
|
+
# Get Trakt account info (cached)
|
|
347
|
+
if config.trakt.access_token:
|
|
348
|
+
cached_trakt = get_cached("trakt_account")
|
|
349
|
+
if cached_trakt:
|
|
350
|
+
result["trakt_account"] = cached_trakt
|
|
351
|
+
else:
|
|
352
|
+
try:
|
|
353
|
+
async with TraktClient(config.trakt) as client:
|
|
354
|
+
limits = await client.get_account_limits()
|
|
355
|
+
trakt_data = {
|
|
356
|
+
"status": "ok",
|
|
357
|
+
"is_vip": limits.is_vip,
|
|
358
|
+
"limits": {
|
|
359
|
+
"collection": limits.collection_limit,
|
|
360
|
+
"watchlist": limits.watchlist_limit,
|
|
361
|
+
},
|
|
362
|
+
}
|
|
363
|
+
set_cached("trakt_account", trakt_data)
|
|
364
|
+
result["trakt_account"] = trakt_data
|
|
365
|
+
except Exception:
|
|
366
|
+
result["trakt_account"] = {"status": "error"}
|
|
367
|
+
|
|
368
|
+
return result
|
|
369
|
+
|
|
370
|
+
@app.get("/api/config")
|
|
371
|
+
async def get_config() -> dict[str, Any]:
|
|
372
|
+
"""Get current configuration (sensitive values masked)."""
|
|
373
|
+
config = Config.load()
|
|
374
|
+
first_server = config.servers[0] if config.servers else None
|
|
375
|
+
return {
|
|
376
|
+
"trakt": {
|
|
377
|
+
"client_id": config.trakt.client_id[:10] + "..." if config.trakt.client_id else None,
|
|
378
|
+
"authenticated": bool(config.trakt.access_token),
|
|
379
|
+
},
|
|
380
|
+
"plex": {
|
|
381
|
+
"url": first_server.url if first_server else "",
|
|
382
|
+
"configured": bool(config.servers),
|
|
383
|
+
},
|
|
384
|
+
"sync": {
|
|
385
|
+
"watched_plex_to_trakt": config.sync.watched_plex_to_trakt,
|
|
386
|
+
"watched_trakt_to_plex": config.sync.watched_trakt_to_plex,
|
|
387
|
+
"ratings_plex_to_trakt": config.sync.ratings_plex_to_trakt,
|
|
388
|
+
"ratings_trakt_to_plex": config.sync.ratings_trakt_to_plex,
|
|
389
|
+
"collection_plex_to_trakt": config.sync.collection_plex_to_trakt,
|
|
390
|
+
"watchlist_plex_to_trakt": config.sync.watchlist_plex_to_trakt,
|
|
391
|
+
"watchlist_trakt_to_plex": config.sync.watchlist_trakt_to_plex,
|
|
392
|
+
},
|
|
393
|
+
"scheduler": {
|
|
394
|
+
"enabled": config.scheduler.enabled,
|
|
395
|
+
"interval_hours": config.scheduler.interval_hours,
|
|
396
|
+
},
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
@app.post("/api/config")
|
|
400
|
+
async def update_config(update: ConfigUpdate) -> dict[str, str]:
|
|
401
|
+
"""Update configuration."""
|
|
402
|
+
config = Config.load()
|
|
403
|
+
|
|
404
|
+
if update.trakt_client_id is not None:
|
|
405
|
+
config.trakt.client_id = update.trakt_client_id
|
|
406
|
+
if update.trakt_client_secret is not None:
|
|
407
|
+
config.trakt.client_secret = update.trakt_client_secret
|
|
408
|
+
if update.watched_plex_to_trakt is not None:
|
|
409
|
+
config.sync.watched_plex_to_trakt = update.watched_plex_to_trakt
|
|
410
|
+
if update.watched_trakt_to_plex is not None:
|
|
411
|
+
config.sync.watched_trakt_to_plex = update.watched_trakt_to_plex
|
|
412
|
+
if update.ratings_plex_to_trakt is not None:
|
|
413
|
+
config.sync.ratings_plex_to_trakt = update.ratings_plex_to_trakt
|
|
414
|
+
if update.ratings_trakt_to_plex is not None:
|
|
415
|
+
config.sync.ratings_trakt_to_plex = update.ratings_trakt_to_plex
|
|
416
|
+
if update.collection_plex_to_trakt is not None:
|
|
417
|
+
config.sync.collection_plex_to_trakt = update.collection_plex_to_trakt
|
|
418
|
+
if update.watchlist_plex_to_trakt is not None:
|
|
419
|
+
config.sync.watchlist_plex_to_trakt = update.watchlist_plex_to_trakt
|
|
420
|
+
if update.watchlist_trakt_to_plex is not None:
|
|
421
|
+
config.sync.watchlist_trakt_to_plex = update.watchlist_trakt_to_plex
|
|
422
|
+
if update.scheduler_enabled is not None:
|
|
423
|
+
config.scheduler.enabled = update.scheduler_enabled
|
|
424
|
+
if update.scheduler_interval_hours is not None:
|
|
425
|
+
config.scheduler.interval_hours = update.scheduler_interval_hours
|
|
426
|
+
|
|
427
|
+
config.save()
|
|
428
|
+
|
|
429
|
+
# Update scheduler if settings changed
|
|
430
|
+
global _scheduler
|
|
431
|
+
if update.scheduler_enabled is not None or update.scheduler_interval_hours is not None:
|
|
432
|
+
if _scheduler:
|
|
433
|
+
_scheduler.update_config(
|
|
434
|
+
config.scheduler.enabled,
|
|
435
|
+
config.scheduler.interval_hours,
|
|
436
|
+
)
|
|
437
|
+
elif config.scheduler.enabled and config.scheduler.interval_hours > 0:
|
|
438
|
+
try:
|
|
439
|
+
from pakt.scheduler import SyncScheduler
|
|
440
|
+
|
|
441
|
+
_scheduler = SyncScheduler(
|
|
442
|
+
config,
|
|
443
|
+
sync_func=_scheduled_sync,
|
|
444
|
+
is_running_func=lambda: sync_state["running"],
|
|
445
|
+
)
|
|
446
|
+
_scheduler.start()
|
|
447
|
+
except ImportError:
|
|
448
|
+
pass
|
|
449
|
+
|
|
450
|
+
return {"status": "ok"}
|
|
451
|
+
|
|
452
|
+
@app.post("/api/sync")
|
|
453
|
+
async def start_sync(request: SyncRequest, background_tasks: BackgroundTasks) -> dict[str, Any]:
|
|
454
|
+
"""Start a sync operation."""
|
|
455
|
+
if sync_state["running"]:
|
|
456
|
+
return {"status": "error", "message": "Sync already running"}
|
|
457
|
+
|
|
458
|
+
def log(msg: str):
|
|
459
|
+
sync_state["logs"].append(msg)
|
|
460
|
+
|
|
461
|
+
def is_cancelled() -> bool:
|
|
462
|
+
return sync_state["cancelled"]
|
|
463
|
+
|
|
464
|
+
async def do_sync():
|
|
465
|
+
sync_state["running"] = True
|
|
466
|
+
sync_state["cancelled"] = False
|
|
467
|
+
sync_state["logs"] = []
|
|
468
|
+
try:
|
|
469
|
+
config = Config.load()
|
|
470
|
+
|
|
471
|
+
def on_token_refresh(token: dict):
|
|
472
|
+
config.trakt.access_token = token["access_token"]
|
|
473
|
+
config.trakt.refresh_token = token["refresh_token"]
|
|
474
|
+
config.trakt.expires_at = token["created_at"] + token["expires_in"]
|
|
475
|
+
config.save()
|
|
476
|
+
log("Token refreshed")
|
|
477
|
+
|
|
478
|
+
log("Loading configuration...")
|
|
479
|
+
w_p2t = config.sync.watched_plex_to_trakt
|
|
480
|
+
w_t2p = config.sync.watched_trakt_to_plex
|
|
481
|
+
log(f"Sync options: Plex→Trakt watched={w_p2t}, Trakt→Plex watched={w_t2p}")
|
|
482
|
+
r_p2t = config.sync.ratings_plex_to_trakt
|
|
483
|
+
r_t2p = config.sync.ratings_trakt_to_plex
|
|
484
|
+
log(f"Sync options: Plex→Trakt ratings={r_p2t}, Trakt→Plex ratings={r_t2p}")
|
|
485
|
+
|
|
486
|
+
result = await run_multi_server_sync(
|
|
487
|
+
config,
|
|
488
|
+
server_names=request.servers,
|
|
489
|
+
dry_run=request.dry_run,
|
|
490
|
+
verbose=request.verbose,
|
|
491
|
+
on_token_refresh=on_token_refresh,
|
|
492
|
+
log_callback=log,
|
|
493
|
+
cancel_check=is_cancelled,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
if sync_state["cancelled"]:
|
|
497
|
+
log("WARNING:Sync cancelled")
|
|
498
|
+
sync_state["last_result"] = {"cancelled": True}
|
|
499
|
+
else:
|
|
500
|
+
log("SUCCESS:Sync complete!")
|
|
501
|
+
log(f"DETAIL:Watched - Added to Trakt: {result.added_to_trakt}")
|
|
502
|
+
log(f"DETAIL:Watched - Added to Plex: {result.added_to_plex}")
|
|
503
|
+
log(f"DETAIL:Ratings synced: {result.ratings_synced}")
|
|
504
|
+
if result.collection_added:
|
|
505
|
+
log(f"DETAIL:Collection added: {result.collection_added}")
|
|
506
|
+
if result.watchlist_added_trakt or result.watchlist_added_plex:
|
|
507
|
+
wl_trakt = result.watchlist_added_trakt
|
|
508
|
+
wl_plex = result.watchlist_added_plex
|
|
509
|
+
log(f"DETAIL:Watchlist - Added to Trakt: {wl_trakt}, Plex: {wl_plex}")
|
|
510
|
+
log(f"DETAIL:Duration: {result.duration_seconds:.1f}s")
|
|
511
|
+
|
|
512
|
+
sync_state["last_result"] = {
|
|
513
|
+
"added_to_trakt": result.added_to_trakt,
|
|
514
|
+
"added_to_plex": result.added_to_plex,
|
|
515
|
+
"ratings_synced": result.ratings_synced,
|
|
516
|
+
"collection_added": result.collection_added,
|
|
517
|
+
"watchlist_added_trakt": result.watchlist_added_trakt,
|
|
518
|
+
"watchlist_added_plex": result.watchlist_added_plex,
|
|
519
|
+
"duration": result.duration_seconds,
|
|
520
|
+
"errors": result.errors[:10],
|
|
521
|
+
}
|
|
522
|
+
sync_state["last_run"] = datetime.now().isoformat()
|
|
523
|
+
except Exception as e:
|
|
524
|
+
log(f"ERROR:{str(e)}")
|
|
525
|
+
sync_state["last_result"] = {"error": str(e)}
|
|
526
|
+
finally:
|
|
527
|
+
sync_state["running"] = False
|
|
528
|
+
sync_state["cancelled"] = False
|
|
529
|
+
|
|
530
|
+
background_tasks.add_task(do_sync)
|
|
531
|
+
return {"status": "started"}
|
|
532
|
+
|
|
533
|
+
@app.get("/api/sync/status")
|
|
534
|
+
async def get_sync_status() -> dict[str, Any]:
|
|
535
|
+
"""Get sync status."""
|
|
536
|
+
return {
|
|
537
|
+
"running": sync_state["running"],
|
|
538
|
+
"last_run": sync_state["last_run"],
|
|
539
|
+
"last_result": sync_state["last_result"],
|
|
540
|
+
"logs": sync_state["logs"],
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
@app.post("/api/sync/cancel")
|
|
544
|
+
async def cancel_sync() -> dict[str, Any]:
|
|
545
|
+
"""Cancel a running sync."""
|
|
546
|
+
if not sync_state["running"]:
|
|
547
|
+
return {"status": "error", "message": "No sync running"}
|
|
548
|
+
sync_state["cancelled"] = True
|
|
549
|
+
return {"status": "ok"}
|
|
550
|
+
|
|
551
|
+
@app.get("/api/scheduler/status")
|
|
552
|
+
async def get_scheduler_status() -> dict[str, Any]:
|
|
553
|
+
"""Get scheduler status."""
|
|
554
|
+
global _scheduler
|
|
555
|
+
config = Config.load()
|
|
556
|
+
if _scheduler:
|
|
557
|
+
return _scheduler.get_status()
|
|
558
|
+
return {
|
|
559
|
+
"enabled": config.scheduler.enabled,
|
|
560
|
+
"interval_hours": config.scheduler.interval_hours,
|
|
561
|
+
"next_run": None,
|
|
562
|
+
"last_run": None,
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
@app.get("/api/trakt/auth")
|
|
566
|
+
async def get_trakt_auth_url() -> dict[str, Any]:
|
|
567
|
+
"""Get Trakt device auth code."""
|
|
568
|
+
from pakt.trakt import TraktClient
|
|
569
|
+
|
|
570
|
+
config = Config.load()
|
|
571
|
+
if not config.trakt.client_id:
|
|
572
|
+
return {"error": "Trakt client_id not configured"}
|
|
573
|
+
|
|
574
|
+
async with TraktClient(config.trakt) as client:
|
|
575
|
+
device = await client.device_code()
|
|
576
|
+
return {
|
|
577
|
+
"verification_url": device["verification_url"],
|
|
578
|
+
"user_code": device["user_code"],
|
|
579
|
+
"device_code": device["device_code"],
|
|
580
|
+
"expires_in": device["expires_in"],
|
|
581
|
+
"interval": device.get("interval", 5),
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
@app.post("/api/trakt/auth/poll")
|
|
585
|
+
async def poll_trakt_auth(device_code: str) -> dict[str, Any]:
|
|
586
|
+
"""Poll for Trakt auth completion."""
|
|
587
|
+
from pakt.trakt import DeviceAuthStatus, TraktClient
|
|
588
|
+
|
|
589
|
+
config = Config.load()
|
|
590
|
+
async with TraktClient(config.trakt) as client:
|
|
591
|
+
result = await client.poll_device_token(device_code, interval=5, expires_in=30)
|
|
592
|
+
if result.status == DeviceAuthStatus.SUCCESS and result.token:
|
|
593
|
+
config.trakt.access_token = result.token["access_token"]
|
|
594
|
+
config.trakt.refresh_token = result.token["refresh_token"]
|
|
595
|
+
config.trakt.expires_at = result.token["created_at"] + result.token["expires_in"]
|
|
596
|
+
config.save()
|
|
597
|
+
return {"status": "authenticated"}
|
|
598
|
+
elif result.status == DeviceAuthStatus.PENDING:
|
|
599
|
+
return {"status": "pending"}
|
|
600
|
+
else:
|
|
601
|
+
return {"status": "error", "message": result.message}
|
|
602
|
+
|
|
603
|
+
@app.post("/api/trakt/logout")
|
|
604
|
+
async def logout_trakt() -> dict[str, Any]:
|
|
605
|
+
"""Revoke Trakt token and clear authentication."""
|
|
606
|
+
from pakt.trakt import TraktClient
|
|
607
|
+
|
|
608
|
+
config = Config.load()
|
|
609
|
+
if not config.trakt.access_token:
|
|
610
|
+
return {"status": "ok", "message": "Not logged in"}
|
|
611
|
+
|
|
612
|
+
async with TraktClient(config.trakt) as client:
|
|
613
|
+
success = await client.revoke_token()
|
|
614
|
+
|
|
615
|
+
# Clear local tokens regardless of revocation success
|
|
616
|
+
config.trakt.access_token = ""
|
|
617
|
+
config.trakt.refresh_token = ""
|
|
618
|
+
config.trakt.expires_at = 0
|
|
619
|
+
config.save()
|
|
620
|
+
|
|
621
|
+
return {"status": "ok", "revoked": success}
|
|
622
|
+
|
|
623
|
+
@app.get("/api/trakt/account")
|
|
624
|
+
async def get_trakt_account() -> dict[str, Any]:
|
|
625
|
+
"""Get Trakt account info including VIP status and limits."""
|
|
626
|
+
from pakt.trakt import TraktClient
|
|
627
|
+
|
|
628
|
+
# Check cache first
|
|
629
|
+
cached = get_cached("trakt_account")
|
|
630
|
+
if cached:
|
|
631
|
+
return cached
|
|
632
|
+
|
|
633
|
+
config = Config.load()
|
|
634
|
+
if not config.trakt.access_token:
|
|
635
|
+
return {"status": "error", "message": "Not authenticated"}
|
|
636
|
+
|
|
637
|
+
try:
|
|
638
|
+
async with TraktClient(config.trakt) as client:
|
|
639
|
+
limits = await client.get_account_limits()
|
|
640
|
+
result = {
|
|
641
|
+
"status": "ok",
|
|
642
|
+
"is_vip": limits.is_vip,
|
|
643
|
+
"limits": {
|
|
644
|
+
"collection": limits.collection_limit,
|
|
645
|
+
"watchlist": limits.watchlist_limit,
|
|
646
|
+
"lists": limits.list_limit,
|
|
647
|
+
"list_items": limits.list_item_limit,
|
|
648
|
+
},
|
|
649
|
+
}
|
|
650
|
+
set_cached("trakt_account", result)
|
|
651
|
+
return result
|
|
652
|
+
except Exception as e:
|
|
653
|
+
return {"status": "error", "message": str(e)}
|
|
654
|
+
|
|
655
|
+
@app.post("/api/plex/test")
|
|
656
|
+
async def test_plex_connection() -> dict[str, Any]:
|
|
657
|
+
"""Test Plex connection (first server)."""
|
|
658
|
+
from pakt.plex import PlexClient
|
|
659
|
+
|
|
660
|
+
config = Config.load()
|
|
661
|
+
|
|
662
|
+
try:
|
|
663
|
+
if not config.servers:
|
|
664
|
+
return {"status": "error", "message": "No Plex servers configured"}
|
|
665
|
+
|
|
666
|
+
server = config.get_enabled_servers()[0] if config.get_enabled_servers() else config.servers[0]
|
|
667
|
+
plex = PlexClient(server)
|
|
668
|
+
plex.connect()
|
|
669
|
+
return {
|
|
670
|
+
"status": "ok",
|
|
671
|
+
"server_name": plex.server.friendlyName,
|
|
672
|
+
"movie_libraries": plex.get_movie_libraries(),
|
|
673
|
+
"show_libraries": plex.get_show_libraries(),
|
|
674
|
+
}
|
|
675
|
+
except Exception as e:
|
|
676
|
+
return {"status": "error", "message": str(e)}
|
|
677
|
+
|
|
678
|
+
@app.get("/api/plex/libraries")
|
|
679
|
+
async def get_plex_libraries() -> dict[str, Any]:
|
|
680
|
+
"""Get available Plex libraries and current selection (first server)."""
|
|
681
|
+
from pakt.plex import PlexClient
|
|
682
|
+
|
|
683
|
+
try:
|
|
684
|
+
config = Config.load()
|
|
685
|
+
|
|
686
|
+
if not config.servers:
|
|
687
|
+
return {"status": "error", "message": "No Plex servers configured"}
|
|
688
|
+
|
|
689
|
+
server = config.get_enabled_servers()[0] if config.get_enabled_servers() else config.servers[0]
|
|
690
|
+
plex = PlexClient(server)
|
|
691
|
+
plex.connect()
|
|
692
|
+
return {
|
|
693
|
+
"status": "ok",
|
|
694
|
+
"available": {
|
|
695
|
+
"movie": plex.get_movie_libraries(),
|
|
696
|
+
"show": plex.get_show_libraries(),
|
|
697
|
+
},
|
|
698
|
+
"selected": {
|
|
699
|
+
"movie": server.movie_libraries,
|
|
700
|
+
"show": server.show_libraries,
|
|
701
|
+
},
|
|
702
|
+
}
|
|
703
|
+
except Exception as e:
|
|
704
|
+
return {"status": "error", "message": str(e)}
|
|
705
|
+
|
|
706
|
+
# =========================================================================
|
|
707
|
+
# Plex PIN Authentication
|
|
708
|
+
# =========================================================================
|
|
709
|
+
|
|
710
|
+
# Store active PIN logins (pin_id -> login object)
|
|
711
|
+
_plex_pin_logins: dict[int, Any] = {}
|
|
712
|
+
|
|
713
|
+
@app.post("/api/plex/pin")
|
|
714
|
+
async def start_plex_pin_login() -> dict[str, Any]:
|
|
715
|
+
"""Start Plex PIN login flow."""
|
|
716
|
+
from pakt.plex import start_plex_pin_login as _start_pin
|
|
717
|
+
|
|
718
|
+
try:
|
|
719
|
+
login_obj, auth_info = _start_pin()
|
|
720
|
+
_plex_pin_logins[auth_info.pin_id] = login_obj
|
|
721
|
+
return {
|
|
722
|
+
"status": "ok",
|
|
723
|
+
"pin": auth_info.pin,
|
|
724
|
+
"pin_id": auth_info.pin_id,
|
|
725
|
+
"verification_url": auth_info.verification_url,
|
|
726
|
+
}
|
|
727
|
+
except Exception as e:
|
|
728
|
+
return {"status": "error", "message": str(e)}
|
|
729
|
+
|
|
730
|
+
@app.get("/api/plex/pin/{pin_id}")
|
|
731
|
+
async def check_plex_pin_login(pin_id: int) -> dict[str, Any]:
|
|
732
|
+
"""Check if Plex PIN login has been authorized."""
|
|
733
|
+
from pakt.plex import check_plex_pin_login as _check_pin
|
|
734
|
+
|
|
735
|
+
login_obj = _plex_pin_logins.get(pin_id)
|
|
736
|
+
if not login_obj:
|
|
737
|
+
return {"status": "error", "message": "PIN login not found or expired"}
|
|
738
|
+
|
|
739
|
+
try:
|
|
740
|
+
token = _check_pin(login_obj)
|
|
741
|
+
if token:
|
|
742
|
+
del _plex_pin_logins[pin_id]
|
|
743
|
+
config = Config.load()
|
|
744
|
+
config.plex_token = token
|
|
745
|
+
config.save()
|
|
746
|
+
return {"status": "authenticated", "token": token[:10] + "..."}
|
|
747
|
+
else:
|
|
748
|
+
return {"status": "pending"}
|
|
749
|
+
except Exception as e:
|
|
750
|
+
return {"status": "error", "message": str(e)}
|
|
751
|
+
|
|
752
|
+
@app.get("/api/plex/discover")
|
|
753
|
+
async def discover_plex_servers() -> dict[str, Any]:
|
|
754
|
+
"""Discover Plex servers from account."""
|
|
755
|
+
from pakt.plex import discover_servers
|
|
756
|
+
|
|
757
|
+
config = Config.load()
|
|
758
|
+
if not config.plex_token:
|
|
759
|
+
return {"status": "error", "message": "No Plex account token"}
|
|
760
|
+
|
|
761
|
+
try:
|
|
762
|
+
discovered = discover_servers(config.plex_token)
|
|
763
|
+
return {
|
|
764
|
+
"status": "ok",
|
|
765
|
+
"servers": [
|
|
766
|
+
{
|
|
767
|
+
"name": s.name,
|
|
768
|
+
"owned": s.owned,
|
|
769
|
+
"has_local": s.has_local_connection,
|
|
770
|
+
"url": s.best_connection_url,
|
|
771
|
+
"configured": config.get_server(s.name) is not None,
|
|
772
|
+
}
|
|
773
|
+
for s in discovered
|
|
774
|
+
],
|
|
775
|
+
}
|
|
776
|
+
except Exception as e:
|
|
777
|
+
return {"status": "error", "message": str(e)}
|
|
778
|
+
|
|
779
|
+
# =========================================================================
|
|
780
|
+
# Server Management
|
|
781
|
+
# =========================================================================
|
|
782
|
+
|
|
783
|
+
@app.get("/api/servers")
|
|
784
|
+
async def get_servers() -> dict[str, Any]:
|
|
785
|
+
"""Get configured Plex servers."""
|
|
786
|
+
config = Config.load()
|
|
787
|
+
servers = []
|
|
788
|
+
for s in config.servers:
|
|
789
|
+
server_data = {
|
|
790
|
+
"name": s.name,
|
|
791
|
+
"server_name": s.server_name,
|
|
792
|
+
"url": s.url,
|
|
793
|
+
"enabled": s.enabled,
|
|
794
|
+
"movie_libraries": s.movie_libraries,
|
|
795
|
+
"show_libraries": s.show_libraries,
|
|
796
|
+
"sync": None,
|
|
797
|
+
}
|
|
798
|
+
if s.sync:
|
|
799
|
+
server_data["sync"] = {
|
|
800
|
+
"watched_plex_to_trakt": s.sync.watched_plex_to_trakt,
|
|
801
|
+
"watched_trakt_to_plex": s.sync.watched_trakt_to_plex,
|
|
802
|
+
"ratings_plex_to_trakt": s.sync.ratings_plex_to_trakt,
|
|
803
|
+
"ratings_trakt_to_plex": s.sync.ratings_trakt_to_plex,
|
|
804
|
+
"collection_plex_to_trakt": s.sync.collection_plex_to_trakt,
|
|
805
|
+
"watchlist_plex_to_trakt": s.sync.watchlist_plex_to_trakt,
|
|
806
|
+
"watchlist_trakt_to_plex": s.sync.watchlist_trakt_to_plex,
|
|
807
|
+
}
|
|
808
|
+
servers.append(server_data)
|
|
809
|
+
return {
|
|
810
|
+
"status": "ok",
|
|
811
|
+
"has_account_token": bool(config.plex_token),
|
|
812
|
+
"servers": servers,
|
|
813
|
+
"global_sync": {
|
|
814
|
+
"watched_plex_to_trakt": config.sync.watched_plex_to_trakt,
|
|
815
|
+
"watched_trakt_to_plex": config.sync.watched_trakt_to_plex,
|
|
816
|
+
"ratings_plex_to_trakt": config.sync.ratings_plex_to_trakt,
|
|
817
|
+
"ratings_trakt_to_plex": config.sync.ratings_trakt_to_plex,
|
|
818
|
+
"collection_plex_to_trakt": config.sync.collection_plex_to_trakt,
|
|
819
|
+
"watchlist_plex_to_trakt": config.sync.watchlist_plex_to_trakt,
|
|
820
|
+
"watchlist_trakt_to_plex": config.sync.watchlist_trakt_to_plex,
|
|
821
|
+
},
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
@app.post("/api/servers")
|
|
825
|
+
async def add_server(request: ServerCreate) -> dict[str, Any]:
|
|
826
|
+
"""Add a Plex server."""
|
|
827
|
+
from pakt.plex import discover_servers
|
|
828
|
+
|
|
829
|
+
config = Config.load()
|
|
830
|
+
|
|
831
|
+
if config.get_server(request.name):
|
|
832
|
+
return {"status": "error", "message": f"Server '{request.name}' already exists"}
|
|
833
|
+
|
|
834
|
+
if request.url and request.token:
|
|
835
|
+
new_server = ServerConfig(
|
|
836
|
+
name=request.name,
|
|
837
|
+
url=request.url,
|
|
838
|
+
token=request.token,
|
|
839
|
+
enabled=True,
|
|
840
|
+
)
|
|
841
|
+
elif request.server_name:
|
|
842
|
+
if not config.plex_token:
|
|
843
|
+
return {"status": "error", "message": "No account token for discovery"}
|
|
844
|
+
|
|
845
|
+
try:
|
|
846
|
+
discovered = discover_servers(config.plex_token)
|
|
847
|
+
except Exception as e:
|
|
848
|
+
return {"status": "error", "message": f"Discovery failed: {e}"}
|
|
849
|
+
|
|
850
|
+
matching = next((s for s in discovered if s.name == request.server_name), None)
|
|
851
|
+
if not matching:
|
|
852
|
+
return {"status": "error", "message": f"Server '{request.server_name}' not found"}
|
|
853
|
+
|
|
854
|
+
new_server = ServerConfig(
|
|
855
|
+
name=request.name,
|
|
856
|
+
server_name=matching.name,
|
|
857
|
+
url=matching.best_connection_url or "",
|
|
858
|
+
token=config.plex_token,
|
|
859
|
+
enabled=True,
|
|
860
|
+
)
|
|
861
|
+
else:
|
|
862
|
+
return {"status": "error", "message": "Provide url+token or server_name"}
|
|
863
|
+
|
|
864
|
+
config.servers.append(new_server)
|
|
865
|
+
config.save()
|
|
866
|
+
return {"status": "ok", "message": f"Added server: {request.name}"}
|
|
867
|
+
|
|
868
|
+
@app.put("/api/servers/{name}")
|
|
869
|
+
async def update_server(name: str, request: ServerUpdate) -> dict[str, Any]:
|
|
870
|
+
"""Update server configuration."""
|
|
871
|
+
from pakt.config import ServerSyncOverrides
|
|
872
|
+
|
|
873
|
+
config = Config.load()
|
|
874
|
+
server = config.get_server(name)
|
|
875
|
+
|
|
876
|
+
if not server:
|
|
877
|
+
return {"status": "error", "message": f"Server '{name}' not found"}
|
|
878
|
+
|
|
879
|
+
if request.enabled is not None:
|
|
880
|
+
server.enabled = request.enabled
|
|
881
|
+
if request.movie_libraries is not None:
|
|
882
|
+
server.movie_libraries = request.movie_libraries
|
|
883
|
+
if request.show_libraries is not None:
|
|
884
|
+
server.show_libraries = request.show_libraries
|
|
885
|
+
if request.sync is not None:
|
|
886
|
+
# Update sync overrides
|
|
887
|
+
if server.sync is None:
|
|
888
|
+
server.sync = ServerSyncOverrides()
|
|
889
|
+
for field in [
|
|
890
|
+
"watched_plex_to_trakt", "watched_trakt_to_plex",
|
|
891
|
+
"ratings_plex_to_trakt", "ratings_trakt_to_plex",
|
|
892
|
+
"collection_plex_to_trakt",
|
|
893
|
+
"watchlist_plex_to_trakt", "watchlist_trakt_to_plex",
|
|
894
|
+
]:
|
|
895
|
+
val = getattr(request.sync, field, None)
|
|
896
|
+
# Allow explicit None to clear override (use global)
|
|
897
|
+
setattr(server.sync, field, val)
|
|
898
|
+
|
|
899
|
+
config.save()
|
|
900
|
+
return {"status": "ok"}
|
|
901
|
+
|
|
902
|
+
@app.delete("/api/servers/{name}")
|
|
903
|
+
async def delete_server(name: str) -> dict[str, Any]:
|
|
904
|
+
"""Remove a configured server."""
|
|
905
|
+
config = Config.load()
|
|
906
|
+
|
|
907
|
+
if not config.get_server(name):
|
|
908
|
+
return {"status": "error", "message": f"Server '{name}' not found"}
|
|
909
|
+
|
|
910
|
+
config.servers = [s for s in config.servers if s.name != name]
|
|
911
|
+
config.save()
|
|
912
|
+
return {"status": "ok", "message": f"Removed server: {name}"}
|
|
913
|
+
|
|
914
|
+
@app.post("/api/servers/{name}/test")
|
|
915
|
+
async def test_server(name: str) -> dict[str, Any]:
|
|
916
|
+
"""Test connection to a specific server."""
|
|
917
|
+
from pakt.plex import PlexClient
|
|
918
|
+
|
|
919
|
+
config = Config.load()
|
|
920
|
+
server = config.get_server(name)
|
|
921
|
+
|
|
922
|
+
if not server:
|
|
923
|
+
return {"status": "error", "message": f"Server '{name}' not found"}
|
|
924
|
+
|
|
925
|
+
try:
|
|
926
|
+
plex = PlexClient(server)
|
|
927
|
+
plex.connect()
|
|
928
|
+
return {
|
|
929
|
+
"status": "ok",
|
|
930
|
+
"server_name": plex.server.friendlyName,
|
|
931
|
+
"movie_libraries": plex.get_movie_libraries(),
|
|
932
|
+
"show_libraries": plex.get_show_libraries(),
|
|
933
|
+
}
|
|
934
|
+
except Exception as e:
|
|
935
|
+
return {"status": "error", "message": str(e)}
|
|
936
|
+
|
|
937
|
+
@app.get("/api/servers/{name}/libraries")
|
|
938
|
+
async def get_server_libraries(name: str) -> dict[str, Any]:
|
|
939
|
+
"""Get libraries for a specific server."""
|
|
940
|
+
from pakt.plex import PlexClient
|
|
941
|
+
|
|
942
|
+
config = Config.load()
|
|
943
|
+
server = config.get_server(name)
|
|
944
|
+
|
|
945
|
+
if not server:
|
|
946
|
+
return {"status": "error", "message": f"Server '{name}' not found"}
|
|
947
|
+
|
|
948
|
+
# Check cache for available libraries (selected comes from config)
|
|
949
|
+
cache_key = f"plex_libraries_{name}"
|
|
950
|
+
cached_available = get_cached(cache_key)
|
|
951
|
+
|
|
952
|
+
if cached_available:
|
|
953
|
+
return {
|
|
954
|
+
"status": "ok",
|
|
955
|
+
"available": cached_available,
|
|
956
|
+
"selected": {
|
|
957
|
+
"movie": server.movie_libraries,
|
|
958
|
+
"show": server.show_libraries,
|
|
959
|
+
},
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
try:
|
|
963
|
+
plex = PlexClient(server)
|
|
964
|
+
plex.connect()
|
|
965
|
+
available = {
|
|
966
|
+
"movie": plex.get_movie_libraries(),
|
|
967
|
+
"show": plex.get_show_libraries(),
|
|
968
|
+
}
|
|
969
|
+
set_cached(cache_key, available)
|
|
970
|
+
return {
|
|
971
|
+
"status": "ok",
|
|
972
|
+
"available": available,
|
|
973
|
+
"selected": {
|
|
974
|
+
"movie": server.movie_libraries,
|
|
975
|
+
"show": server.show_libraries,
|
|
976
|
+
},
|
|
977
|
+
}
|
|
978
|
+
except Exception as e:
|
|
979
|
+
return {"status": "error", "message": str(e)}
|
|
980
|
+
|
|
981
|
+
@app.post("/api/shutdown")
|
|
982
|
+
async def shutdown_server() -> dict[str, str]:
|
|
983
|
+
"""Shutdown the web server."""
|
|
984
|
+
async def do_shutdown():
|
|
985
|
+
await asyncio.sleep(0.5)
|
|
986
|
+
os._exit(0)
|
|
987
|
+
|
|
988
|
+
asyncio.create_task(do_shutdown())
|
|
989
|
+
return {"status": "ok", "message": "Server shutting down..."}
|
|
990
|
+
|
|
991
|
+
return app
|