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/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