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