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/cli.py ADDED
@@ -0,0 +1,814 @@
1
+ """CLI interface for Pakt."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+ import time
8
+
9
+ import click
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from pakt import __version__
14
+ from pakt.config import Config, ServerConfig, get_config_dir
15
+ from pakt.trakt import DeviceAuthStatus, TraktClient
16
+
17
+ console = Console()
18
+
19
+
20
+ @click.group()
21
+ @click.version_option(version=__version__, prog_name="pakt")
22
+ def main():
23
+ """Pakt - Fast Plex-Trakt sync using batch operations."""
24
+ pass
25
+
26
+
27
+ def _make_token_refresh_callback(config: Config):
28
+ """Create a callback that saves tokens when they're refreshed."""
29
+ def on_token_refresh(token: dict):
30
+ config.trakt.access_token = token["access_token"]
31
+ config.trakt.refresh_token = token["refresh_token"]
32
+ config.trakt.expires_at = token["created_at"] + token["expires_in"]
33
+ config.save()
34
+ return on_token_refresh
35
+
36
+
37
+ @main.command()
38
+ @click.option("--dry-run", is_flag=True, help="Show what would be synced without making changes")
39
+ @click.option("--verbose", "-v", is_flag=True, help="Show detailed list of items to sync")
40
+ @click.option("--server", "-s", "servers", multiple=True, help="Sync specific server(s) only (can specify multiple)")
41
+ def sync(dry_run: bool, verbose: bool, servers: tuple[str, ...]):
42
+ """Sync watched status and ratings between Plex and Trakt."""
43
+ from pakt.sync import run_multi_server_sync
44
+
45
+ config = Config.load()
46
+
47
+ if not config.trakt.access_token:
48
+ console.print("[red]Error:[/] Not logged in to Trakt. Run 'pakt login' first.")
49
+ sys.exit(1)
50
+
51
+ if not config.servers:
52
+ console.print("[red]Error:[/] No Plex servers configured. Run 'pakt setup' first.")
53
+ sys.exit(1)
54
+
55
+ server_names = list(servers) if servers else None
56
+ result = asyncio.run(run_multi_server_sync(
57
+ config,
58
+ server_names=server_names,
59
+ dry_run=dry_run,
60
+ verbose=verbose,
61
+ on_token_refresh=_make_token_refresh_callback(config),
62
+ ))
63
+
64
+ console.print("\n[bold green]Sync complete![/]")
65
+ console.print(f" Added to Trakt: {result.added_to_trakt}")
66
+ console.print(f" Added to Plex: {result.added_to_plex}")
67
+ console.print(f" Ratings synced: {result.ratings_synced}")
68
+ console.print(f" Duration: {result.duration_seconds:.1f}s")
69
+
70
+ if result.errors:
71
+ console.print(f"\n[yellow]Errors ({len(result.errors)}):[/]")
72
+ for error in result.errors[:10]:
73
+ console.print(f" - {error}")
74
+
75
+
76
+ @main.command()
77
+ def login():
78
+ """Authenticate with Trakt using device code flow."""
79
+ config = Config.load()
80
+
81
+ async def do_auth():
82
+ async with TraktClient(config.trakt) as client:
83
+ console.print("[cyan]Getting device code...[/]")
84
+ device = await client.device_code()
85
+
86
+ console.print(f"\n[bold]→ Go to:[/] [cyan]{device['verification_url']}[/]")
87
+ console.print(f"[bold]→ Enter:[/] [bold yellow]{device['user_code']}[/]")
88
+ console.print("\n[dim]Waiting for authorization...[/]")
89
+
90
+ result = await client.poll_device_token(
91
+ device["device_code"],
92
+ interval=device.get("interval", 5),
93
+ expires_in=device.get("expires_in", 600),
94
+ )
95
+
96
+ if result.status == DeviceAuthStatus.SUCCESS and result.token:
97
+ config.trakt.access_token = result.token["access_token"]
98
+ config.trakt.refresh_token = result.token["refresh_token"]
99
+ config.trakt.expires_at = result.token["created_at"] + result.token["expires_in"]
100
+ config.save()
101
+ console.print("\n[green]✓ Successfully authenticated with Trakt![/]")
102
+ else:
103
+ console.print(f"\n[red]✗ {result.message}[/]")
104
+ sys.exit(1)
105
+
106
+ asyncio.run(do_auth())
107
+
108
+
109
+ @main.command()
110
+ def logout():
111
+ """Revoke Trakt authentication and clear tokens."""
112
+ config = Config.load()
113
+
114
+ if not config.trakt.access_token:
115
+ console.print("[yellow]Not currently logged in to Trakt.[/]")
116
+ return
117
+
118
+ async def do_logout():
119
+ async with TraktClient(config.trakt) as client:
120
+ console.print("[cyan]Revoking Trakt access token...[/]")
121
+ success = await client.revoke_token()
122
+
123
+ if success:
124
+ console.print("[green]Token revoked successfully.[/]")
125
+ else:
126
+ console.print("[yellow]Token revocation failed, clearing local tokens anyway.[/]")
127
+
128
+ # Clear local tokens regardless
129
+ config.trakt.access_token = ""
130
+ config.trakt.refresh_token = ""
131
+ config.trakt.expires_at = 0
132
+ config.save()
133
+ console.print("[green]Logged out from Trakt.[/]")
134
+
135
+ asyncio.run(do_logout())
136
+
137
+
138
+ @main.command()
139
+ @click.option("--token", is_flag=True, help="Use manual token entry instead of PIN auth")
140
+ def setup(token: bool):
141
+ """Interactive setup wizard - configure Trakt and Plex.
142
+
143
+ By default uses Plex PIN authentication. Use --token for manual token entry.
144
+ """
145
+ config = Config.load()
146
+
147
+ console.print("\n[bold cyan]═══ Pakt Setup Wizard ═══[/]\n")
148
+
149
+ # Check what's already configured
150
+ trakt_done = bool(config.trakt.access_token)
151
+ plex_done = bool(config.servers)
152
+
153
+ if trakt_done and plex_done:
154
+ console.print("[green]✓[/] Trakt: Authenticated")
155
+ console.print("[green]✓[/] Plex: Configured")
156
+ console.print(f" Servers: {', '.join(s.name for s in config.servers)}")
157
+ console.print("\nEverything is already set up! Run [cyan]pakt sync[/] to start syncing.")
158
+ if click.confirm("\nReconfigure anyway?", default=False):
159
+ trakt_done = False
160
+ plex_done = False
161
+ else:
162
+ return
163
+
164
+ # Step 1: Trakt
165
+ if not trakt_done:
166
+ console.print("[bold]Step 1: Trakt Authentication[/]\n")
167
+
168
+ async def do_auth():
169
+ async with TraktClient(config.trakt) as client:
170
+ console.print("[cyan]Getting device code...[/]")
171
+ device = await client.device_code()
172
+
173
+ console.print(f"\n[bold]→ Go to:[/] [cyan]{device['verification_url']}[/]")
174
+ console.print(f"[bold]→ Enter:[/] [bold yellow]{device['user_code']}[/]")
175
+ console.print("\n[dim]Waiting for you to authorize...[/]")
176
+
177
+ result = await client.poll_device_token(
178
+ device["device_code"],
179
+ interval=device.get("interval", 5),
180
+ expires_in=device.get("expires_in", 600),
181
+ )
182
+
183
+ if result.status == DeviceAuthStatus.SUCCESS and result.token:
184
+ config.trakt.access_token = result.token["access_token"]
185
+ config.trakt.refresh_token = result.token["refresh_token"]
186
+ config.trakt.expires_at = result.token["created_at"] + result.token["expires_in"]
187
+ config.save()
188
+ console.print("\n[green]✓ Trakt authenticated![/]")
189
+ return True
190
+ else:
191
+ console.print(f"\n[red]✗ {result.message}[/]")
192
+ return False
193
+
194
+ if not asyncio.run(do_auth()):
195
+ console.print("\n[yellow]Setup incomplete. Run 'pakt setup' to try again.[/]")
196
+ return
197
+ else:
198
+ console.print("[green]✓[/] Trakt: Already authenticated\n")
199
+
200
+ # Step 2: Plex
201
+ if not plex_done:
202
+ console.print("\n[bold]Step 2: Plex Connection[/]\n")
203
+
204
+ if token:
205
+ # Manual token entry (legacy flow)
206
+ _setup_plex_manual(config)
207
+ else:
208
+ # PIN authentication (default)
209
+ _setup_plex_pin(config)
210
+
211
+ else:
212
+ console.print("[green]✓[/] Plex: Already configured")
213
+ console.print(f" Servers: {', '.join(s.name for s in config.servers)}")
214
+
215
+ # Done!
216
+ console.print("\n[bold green]═══ Setup Complete! ═══[/]\n")
217
+ console.print("You're ready to sync. Try these commands:")
218
+ console.print(" [cyan]pakt sync --dry-run[/] - Preview what will sync")
219
+ console.print(" [cyan]pakt sync[/] - Run the sync")
220
+ console.print(" [cyan]pakt serve[/] - Start web interface")
221
+ console.print(" [cyan]pakt servers list[/] - View configured servers")
222
+
223
+
224
+ def _setup_plex_manual(config: Config) -> None:
225
+ """Manual Plex token setup (legacy flow)."""
226
+ from pakt.plex import PlexClient
227
+
228
+ # Try to auto-detect local Plex
229
+ console.print("[dim]Checking for local Plex server...[/]")
230
+ detected_url = None
231
+ try:
232
+ import httpx
233
+ resp = httpx.get("http://localhost:32400/identity", timeout=2)
234
+ if resp.status_code == 200:
235
+ detected_url = "http://localhost:32400"
236
+ console.print(f"[green]✓[/] Found Plex at {detected_url}")
237
+ except Exception:
238
+ console.print("[dim]No local server found[/]")
239
+
240
+ default_url = detected_url or "http://localhost:32400"
241
+ plex_url = click.prompt("\nPlex server URL", default=default_url)
242
+
243
+ console.print("\n[dim]To get your Plex token:")
244
+ console.print("1. Open Plex Web App and sign in")
245
+ console.print("2. Open any media item")
246
+ console.print("3. Click ⋮ → Get Info → View XML")
247
+ console.print("4. In the URL, find X-Plex-Token=xxxxx")
248
+ console.print("5. Copy just the token part after the =[/]\n")
249
+
250
+ plex_token = click.prompt("Plex token")
251
+ # Auto-strip prefix if user pasted the whole thing
252
+ if plex_token.lower().startswith("x-plex-token="):
253
+ plex_token = plex_token[13:]
254
+ elif plex_token.startswith("="):
255
+ plex_token = plex_token[1:]
256
+
257
+ # Create a server config for testing
258
+ server_config = ServerConfig(
259
+ name="default",
260
+ url=plex_url,
261
+ token=plex_token,
262
+ enabled=True,
263
+ )
264
+
265
+ # Test connection
266
+ console.print("\n[dim]Testing connection...[/]")
267
+ try:
268
+ plex = PlexClient(server_config)
269
+ plex.connect()
270
+ console.print(f"[green]✓[/] Connected to: {plex.server.friendlyName}")
271
+ libs = plex.get_movie_libraries() + plex.get_show_libraries()
272
+ console.print(f"[green]✓[/] Libraries: {', '.join(libs)}")
273
+
274
+ # Save the server to config
275
+ config.plex_token = plex_token
276
+ config.servers.append(server_config)
277
+ config.save()
278
+ except Exception as e:
279
+ console.print(f"[red]✗ Connection failed:[/] {e}")
280
+ console.print("\n[yellow]Check your URL and token, then run 'pakt setup' again.[/]")
281
+
282
+
283
+ def _setup_plex_pin(config: Config) -> None:
284
+ """Plex PIN authentication flow with server discovery."""
285
+ from pakt.plex import (
286
+ check_plex_pin_login,
287
+ discover_servers,
288
+ start_plex_pin_login,
289
+ )
290
+
291
+ console.print("Link your Plex account to discover your servers automatically.\n")
292
+ console.print("[bold]→ Go to:[/] [cyan]https://plex.tv/link[/]")
293
+
294
+ # Start PIN login
295
+ login_obj, auth_info = start_plex_pin_login()
296
+ console.print(f"[bold]→ Enter:[/] [bold yellow]{auth_info.pin}[/]")
297
+ console.print("\n[dim]Waiting for you to authorize...[/]")
298
+
299
+ # Poll for completion
300
+ account_token = None
301
+ max_attempts = 60 # 5 minutes at 5 second intervals
302
+ for _ in range(max_attempts):
303
+ account_token = check_plex_pin_login(login_obj)
304
+ if account_token:
305
+ break
306
+ time.sleep(5)
307
+
308
+ if not account_token:
309
+ console.print("\n[red]✗ Authorization timed out[/]")
310
+ console.print("[dim]Try again with 'pakt setup' or use 'pakt setup --token' for manual entry[/]")
311
+ return
312
+
313
+ console.print("\n[green]✓ Plex account linked![/]")
314
+
315
+ # Save account token
316
+ config.plex_token = account_token
317
+ config.save()
318
+
319
+ # Discover servers
320
+ console.print("\n[dim]Discovering servers...[/]")
321
+ try:
322
+ discovered = discover_servers(account_token)
323
+ except Exception as e:
324
+ console.print(f"[red]✗ Failed to discover servers:[/] {e}")
325
+ return
326
+
327
+ if not discovered:
328
+ console.print("[yellow]No servers found on your account[/]")
329
+ return
330
+
331
+ console.print(f"\n[green]Found {len(discovered)} server(s):[/]\n")
332
+ for i, server in enumerate(discovered, 1):
333
+ owned = "[green]owned[/]" if server.owned else "[dim]shared[/]"
334
+ local = "[cyan]local[/]" if server.has_local_connection else ""
335
+ console.print(f" {i}. {server.name} ({owned}) {local}")
336
+
337
+ # Select servers to enable
338
+ console.print("\n[dim]Enter server numbers to enable (comma-separated), or 'all':[/]")
339
+ selection = click.prompt("Enable servers", default="all")
340
+
341
+ if selection.lower() == "all":
342
+ selected_servers = discovered
343
+ else:
344
+ try:
345
+ indices = [int(x.strip()) - 1 for x in selection.split(",")]
346
+ selected_servers = [discovered[i] for i in indices if 0 <= i < len(discovered)]
347
+ except (ValueError, IndexError):
348
+ console.print("[yellow]Invalid selection, enabling all servers[/]")
349
+ selected_servers = discovered
350
+
351
+ # Create ServerConfig for each selected server
352
+ for server in selected_servers:
353
+ # Check if already configured
354
+ existing = config.get_server(server.name)
355
+ if existing:
356
+ console.print(f" [dim]Server '{server.name}' already configured, updating...[/]")
357
+ existing.server_name = server.name
358
+ existing.url = server.best_connection_url or ""
359
+ existing.token = account_token
360
+ else:
361
+ server_config = ServerConfig(
362
+ name=server.name,
363
+ server_name=server.name,
364
+ url=server.best_connection_url or "",
365
+ token=account_token,
366
+ enabled=True,
367
+ )
368
+ config.servers.append(server_config)
369
+ console.print(f" [green]✓[/] Added server: {server.name}")
370
+
371
+ config.save()
372
+ console.print(f"\n[green]✓ Configured {len(selected_servers)} server(s)[/]")
373
+
374
+
375
+ @main.command()
376
+ def status():
377
+ """Show current configuration status."""
378
+ config = Config.load()
379
+ config_dir = get_config_dir()
380
+
381
+ console.print("[bold]Pakt Status[/]\n")
382
+
383
+ # Trakt status
384
+ table = Table(title="Trakt")
385
+ table.add_column("Setting", style="cyan")
386
+ table.add_column("Value")
387
+
388
+ table.add_row("Client ID", config.trakt.client_id[:20] + "..." if config.trakt.client_id else "[red]Not set[/]")
389
+ table.add_row("Authenticated", "[green]Yes[/]" if config.trakt.access_token else "[red]No[/]")
390
+ console.print(table)
391
+
392
+ # Plex servers status
393
+ servers = config.servers
394
+ if servers:
395
+ table = Table(title="Plex Servers")
396
+ table.add_column("Name", style="cyan")
397
+ table.add_column("Status")
398
+ table.add_column("Enabled")
399
+
400
+ for server in servers:
401
+ status = "[green]Configured[/]" if (server.url or server.server_name) else "[red]Not set[/]"
402
+ enabled = "[green]Yes[/]" if server.enabled else "[dim]No[/]"
403
+ table.add_row(server.name, status, enabled)
404
+ console.print(table)
405
+ else:
406
+ console.print("\n[yellow]No Plex servers configured[/]")
407
+ console.print("[dim]Run 'pakt setup' to configure[/]")
408
+
409
+ console.print(f"\n[dim]Config directory: {config_dir}[/]")
410
+
411
+
412
+ @main.command()
413
+ @click.option("--host", default="127.0.0.1", help="Host to bind to")
414
+ @click.option("--port", default=8080, help="Port to bind to")
415
+ @click.option("--tray/--no-tray", default=None, help="Enable/disable system tray icon")
416
+ def serve(host: str, port: int, tray: bool | None):
417
+ """Start the web interface."""
418
+ import os
419
+ import signal
420
+
421
+ import uvicorn
422
+
423
+ from pakt.web import create_app
424
+ from pakt.web.app import sync_state
425
+
426
+ # Only show tray when explicitly requested with --tray
427
+ show_tray = tray is True
428
+
429
+ # Suppress console output only when --tray explicitly passed (for pythonw compatibility)
430
+ silent_mode = tray is True
431
+
432
+ # Redirect stdout/stderr to devnull in silent mode (for pythonw)
433
+ if silent_mode:
434
+ try:
435
+ devnull = open(os.devnull, 'w')
436
+ sys.stdout = devnull
437
+ sys.stderr = devnull
438
+ except Exception:
439
+ pass
440
+
441
+ tray_instance = None
442
+ if show_tray:
443
+ try:
444
+ from pakt.tray import TRAY_AVAILABLE, PaktTray
445
+
446
+ if TRAY_AVAILABLE:
447
+ web_url = f"http://{host}:{port}"
448
+ tray_instance = PaktTray(
449
+ web_url=web_url,
450
+ shutdown_callback=lambda: sys.exit(0),
451
+ )
452
+ tray_instance.start()
453
+ elif not silent_mode:
454
+ console.print("[yellow]System tray requested but dependencies not installed.[/]")
455
+ console.print("[yellow]Install with: pip install pystray Pillow[/]")
456
+ except ImportError:
457
+ if not silent_mode:
458
+ console.print("[yellow]System tray requested but dependencies not installed.[/]")
459
+ console.print("[yellow]Install with: pip install pystray Pillow[/]")
460
+
461
+ if not silent_mode:
462
+ console.print("[bold]Starting Pakt web interface...[/]")
463
+ console.print(f"Open [cyan]http://{host}:{port}[/] in your browser")
464
+ console.print("[dim]Press Ctrl+C to stop[/]\n")
465
+
466
+ app = create_app()
467
+
468
+ # Handle Ctrl+C gracefully - cancel sync if running
469
+ def handle_sigint(signum, frame):
470
+ if sync_state["running"]:
471
+ if not silent_mode:
472
+ console.print("\n[yellow]Cancelling sync...[/]")
473
+ sync_state["cancelled"] = True
474
+ else:
475
+ if not silent_mode:
476
+ console.print("\n[dim]Shutting down...[/]")
477
+ raise KeyboardInterrupt
478
+
479
+ signal.signal(signal.SIGINT, handle_sigint)
480
+
481
+ try:
482
+ log_level = "critical" if silent_mode else "warning"
483
+ uvicorn.run(app, host=host, port=port, log_level=log_level)
484
+ finally:
485
+ if tray_instance:
486
+ tray_instance.stop()
487
+
488
+
489
+ @main.command()
490
+ @click.option("--server", help="Server name (defaults to first enabled server)")
491
+ @click.option("--movie", "-m", multiple=True, help="Movie libraries to sync (can specify multiple)")
492
+ @click.option("--show", "-s", multiple=True, help="Show libraries to sync (can specify multiple)")
493
+ @click.option("--all", "sync_all", is_flag=True, help="Sync all libraries (clear selection)")
494
+ def libraries(server: str | None, movie: tuple[str, ...], show: tuple[str, ...], sync_all: bool):
495
+ """Configure which Plex libraries to sync.
496
+
497
+ Run without options to see available libraries and current selection.
498
+ """
499
+ from pakt.plex import PlexClient
500
+
501
+ config = Config.load()
502
+
503
+ # Find the target server
504
+ if server:
505
+ server_config = config.get_server(server)
506
+ if not server_config:
507
+ console.print(f"[red]Error:[/] Server '{server}' not found")
508
+ return
509
+ else:
510
+ enabled = config.get_enabled_servers()
511
+ if not enabled:
512
+ console.print("[red]Error:[/] No servers configured. Run 'pakt setup' first.")
513
+ return
514
+ server_config = enabled[0]
515
+
516
+ console.print(f"[dim]Server: {server_config.name}[/]\n")
517
+
518
+ # Connect to Plex to get available libraries
519
+ try:
520
+ plex = PlexClient(server_config)
521
+ plex.connect()
522
+ except Exception as e:
523
+ console.print(f"[red]Error connecting to Plex:[/] {e}")
524
+ return
525
+
526
+ available_movie_libs = plex.get_movie_libraries()
527
+ available_show_libs = plex.get_show_libraries()
528
+
529
+ # If options provided, update config
530
+ if movie or show or sync_all:
531
+ if sync_all:
532
+ server_config.movie_libraries = []
533
+ server_config.show_libraries = []
534
+ console.print("[green]✓[/] Set to sync all libraries")
535
+ else:
536
+ if movie:
537
+ # Validate library names
538
+ invalid = [m for m in movie if m not in available_movie_libs]
539
+ if invalid:
540
+ console.print(f"[yellow]Warning:[/] Unknown movie libraries: {', '.join(invalid)}")
541
+ server_config.movie_libraries = [m for m in movie if m in available_movie_libs]
542
+
543
+ if show:
544
+ invalid = [s for s in show if s not in available_show_libs]
545
+ if invalid:
546
+ console.print(f"[yellow]Warning:[/] Unknown show libraries: {', '.join(invalid)}")
547
+ server_config.show_libraries = [s for s in show if s in available_show_libs]
548
+
549
+ config.save()
550
+ console.print("[green]✓[/] Configuration saved")
551
+ console.print()
552
+
553
+ # Display current state
554
+ table = Table(title="Plex Libraries")
555
+ table.add_column("Type", style="cyan")
556
+ table.add_column("Library")
557
+ table.add_column("Status")
558
+
559
+ current_movie = server_config.movie_libraries or []
560
+ current_show = server_config.show_libraries or []
561
+ sync_all_movies = len(current_movie) == 0
562
+ sync_all_shows = len(current_show) == 0
563
+
564
+ for lib in available_movie_libs:
565
+ if sync_all_movies or lib in current_movie:
566
+ status = "[green]✓ Syncing[/]"
567
+ else:
568
+ status = "[dim]Skipped[/]"
569
+ table.add_row("Movie", lib, status)
570
+
571
+ for lib in available_show_libs:
572
+ if sync_all_shows or lib in current_show:
573
+ status = "[green]✓ Syncing[/]"
574
+ else:
575
+ status = "[dim]Skipped[/]"
576
+ table.add_row("Show", lib, status)
577
+
578
+ console.print(table)
579
+
580
+ if sync_all_movies and sync_all_shows:
581
+ console.print("\n[dim]All libraries selected (default)[/]")
582
+ console.print("\n[dim]Use --movie/-m and --show/-s to select specific libraries[/]")
583
+ console.print("[dim]Use --all to reset to syncing all libraries[/]")
584
+
585
+
586
+ @main.group()
587
+ def servers():
588
+ """Manage Plex server configurations."""
589
+ pass
590
+
591
+
592
+ @servers.command("list")
593
+ def servers_list():
594
+ """List configured Plex servers."""
595
+ config = Config.load()
596
+
597
+ if not config.servers:
598
+ console.print("[yellow]No servers configured.[/]")
599
+ console.print("\nRun [cyan]pakt setup[/] to configure servers via PIN auth")
600
+ console.print("Or run [cyan]pakt servers add[/] to add servers manually")
601
+ return
602
+
603
+ table = Table(title="Configured Servers")
604
+ table.add_column("Name", style="cyan")
605
+ table.add_column("Server Name")
606
+ table.add_column("URL")
607
+ table.add_column("Status")
608
+
609
+ for server in config.servers:
610
+ status = "[green]Enabled[/]" if server.enabled else "[dim]Disabled[/]"
611
+ url = server.url[:40] + "..." if len(server.url) > 40 else server.url
612
+ table.add_row(server.name, server.server_name or "-", url or "-", status)
613
+
614
+ console.print(table)
615
+
616
+ if config.plex_token:
617
+ console.print("\n[dim]Account token: configured[/]")
618
+
619
+
620
+ @servers.command("discover")
621
+ def servers_discover():
622
+ """Discover available Plex servers from your account."""
623
+ from pakt.plex import discover_servers
624
+
625
+ config = Config.load()
626
+
627
+ if not config.plex_token:
628
+ console.print("[red]Error:[/] No Plex account token configured.")
629
+ console.print("Run [cyan]pakt setup[/] to link your Plex account.")
630
+ return
631
+
632
+ console.print("[dim]Discovering servers...[/]")
633
+ try:
634
+ discovered = discover_servers(config.plex_token)
635
+ except Exception as e:
636
+ console.print(f"[red]Error:[/] {e}")
637
+ return
638
+
639
+ if not discovered:
640
+ console.print("[yellow]No servers found on your account[/]")
641
+ return
642
+
643
+ console.print(f"\n[green]Found {len(discovered)} server(s):[/]\n")
644
+
645
+ table = Table()
646
+ table.add_column("#", style="dim")
647
+ table.add_column("Name", style="cyan")
648
+ table.add_column("Owned")
649
+ table.add_column("Local")
650
+ table.add_column("Configured")
651
+
652
+ for i, server in enumerate(discovered, 1):
653
+ owned = "[green]Yes[/]" if server.owned else "[dim]No[/]"
654
+ local = "[cyan]Yes[/]" if server.has_local_connection else "[dim]No[/]"
655
+ configured = "[green]Yes[/]" if config.get_server(server.name) else "[dim]No[/]"
656
+ table.add_row(str(i), server.name, owned, local, configured)
657
+
658
+ console.print(table)
659
+ console.print("\n[dim]Use 'pakt servers add NAME' to add a discovered server[/]")
660
+
661
+
662
+ @servers.command("add")
663
+ @click.argument("name")
664
+ @click.option("--url", help="Server URL (for manual entry)")
665
+ @click.option("--token", "server_token", help="Server token (for manual entry)")
666
+ @click.option("--disabled", is_flag=True, help="Add server as disabled")
667
+ def servers_add(name: str, url: str | None, server_token: str | None, disabled: bool):
668
+ """Add a Plex server.
669
+
670
+ NAME can be:
671
+ - The name of a discovered server (from 'pakt servers discover')
672
+ - Any name if --url and --token are provided for manual entry
673
+ """
674
+ from pakt.plex import discover_servers
675
+
676
+ config = Config.load()
677
+
678
+ # Check if already exists
679
+ if config.get_server(name):
680
+ console.print(f"[yellow]Server '{name}' already configured.[/]")
681
+ console.print("Use [cyan]pakt servers remove {name}[/] to remove it first.")
682
+ return
683
+
684
+ if url and server_token:
685
+ # Manual entry
686
+ new_server = ServerConfig(
687
+ name=name,
688
+ url=url,
689
+ token=server_token,
690
+ enabled=not disabled,
691
+ )
692
+ config.servers.append(new_server)
693
+ config.save()
694
+ console.print(f"[green]✓[/] Added server: {name}")
695
+ return
696
+
697
+ # Try to find from discovered servers
698
+ if not config.plex_token:
699
+ console.print("[red]Error:[/] No account token. Provide --url and --token for manual entry.")
700
+ return
701
+
702
+ console.print("[dim]Searching for server...[/]")
703
+ try:
704
+ discovered = discover_servers(config.plex_token)
705
+ except Exception as e:
706
+ console.print(f"[red]Error discovering servers:[/] {e}")
707
+ return
708
+
709
+ # Find matching server
710
+ matching = None
711
+ for server in discovered:
712
+ if server.name.lower() == name.lower():
713
+ matching = server
714
+ break
715
+
716
+ if not matching:
717
+ console.print(f"[yellow]Server '{name}' not found in your account.[/]")
718
+ console.print("\nAvailable servers:")
719
+ for server in discovered:
720
+ console.print(f" - {server.name}")
721
+ console.print("\nOr use --url and --token for manual entry.")
722
+ return
723
+
724
+ # Add the server
725
+ new_server = ServerConfig(
726
+ name=matching.name,
727
+ server_name=matching.name,
728
+ url=matching.best_connection_url or "",
729
+ token=config.plex_token,
730
+ enabled=not disabled,
731
+ )
732
+ config.servers.append(new_server)
733
+ config.save()
734
+ console.print(f"[green]✓[/] Added server: {matching.name}")
735
+
736
+
737
+ @servers.command("remove")
738
+ @click.argument("name")
739
+ def servers_remove(name: str):
740
+ """Remove a configured Plex server."""
741
+ config = Config.load()
742
+
743
+ server = config.get_server(name)
744
+ if not server:
745
+ console.print(f"[yellow]Server '{name}' not found.[/]")
746
+ return
747
+
748
+ config.servers = [s for s in config.servers if s.name != name]
749
+ config.save()
750
+ console.print(f"[green]✓[/] Removed server: {name}")
751
+
752
+
753
+ @servers.command("test")
754
+ @click.argument("name")
755
+ def servers_test(name: str):
756
+ """Test connection to a configured Plex server."""
757
+ from pakt.plex import PlexClient
758
+
759
+ config = Config.load()
760
+ server = config.get_server(name)
761
+
762
+ if not server:
763
+ console.print(f"[yellow]Server '{name}' not found.[/]")
764
+ return
765
+
766
+ console.print(f"[dim]Testing connection to {name}...[/]")
767
+
768
+ try:
769
+ plex = PlexClient(server)
770
+ plex.connect()
771
+ console.print(f"[green]✓[/] Connected to: {plex.server.friendlyName}")
772
+
773
+ movie_libs = plex.get_movie_libraries()
774
+ show_libs = plex.get_show_libraries()
775
+ console.print(f"[green]✓[/] Movie libraries: {', '.join(movie_libs) or 'none'}")
776
+ console.print(f"[green]✓[/] Show libraries: {', '.join(show_libs) or 'none'}")
777
+ except Exception as e:
778
+ console.print(f"[red]✗ Connection failed:[/] {e}")
779
+
780
+
781
+ @servers.command("enable")
782
+ @click.argument("name")
783
+ def servers_enable(name: str):
784
+ """Enable a server for syncing."""
785
+ config = Config.load()
786
+ server = config.get_server(name)
787
+
788
+ if not server:
789
+ console.print(f"[yellow]Server '{name}' not found.[/]")
790
+ return
791
+
792
+ server.enabled = True
793
+ config.save()
794
+ console.print(f"[green]✓[/] Enabled server: {name}")
795
+
796
+
797
+ @servers.command("disable")
798
+ @click.argument("name")
799
+ def servers_disable(name: str):
800
+ """Disable a server from syncing."""
801
+ config = Config.load()
802
+ server = config.get_server(name)
803
+
804
+ if not server:
805
+ console.print(f"[yellow]Server '{name}' not found.[/]")
806
+ return
807
+
808
+ server.enabled = False
809
+ config.save()
810
+ console.print(f"[green]✓[/] Disabled server: {name}")
811
+
812
+
813
+ if __name__ == "__main__":
814
+ main()