pakt 0.1.0__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 +422 -0
- pakt/config.py +200 -0
- pakt/models.py +109 -0
- pakt/plex.py +519 -0
- pakt/scheduler.py +153 -0
- pakt/sync.py +1102 -0
- pakt/trakt.py +575 -0
- pakt/tray.py +132 -0
- pakt/web/__init__.py +5 -0
- pakt/web/app.py +524 -0
- pakt/web/templates/index.html +1705 -0
- pakt-0.1.0.dist-info/METADATA +121 -0
- pakt-0.1.0.dist-info/RECORD +20 -0
- pakt-0.1.0.dist-info/WHEEL +4 -0
- pakt-0.1.0.dist-info/entry_points.txt +2 -0
pakt/__init__.py
ADDED
pakt/__main__.py
ADDED
pakt/assets/icon.png
ADDED
|
Binary file
|
pakt/assets/icon.svg
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
4
|
+
<stop offset="0%" style="stop-color:#6366f1"/>
|
|
5
|
+
<stop offset="100%" style="stop-color:#8b5cf6"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<circle cx="128" cy="128" r="120" fill="url(#bg)"/>
|
|
9
|
+
<text x="128" y="165" font-family="Inter, -apple-system, sans-serif" font-size="140" font-weight="600" fill="white" text-anchor="middle">P</text>
|
|
10
|
+
</svg>
|
pakt/assets/logo.png
ADDED
|
Binary file
|
pakt/cli.py
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""CLI interface for Pakt."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from pakt import __version__
|
|
13
|
+
from pakt.config import Config, get_config_dir
|
|
14
|
+
from pakt.trakt import DeviceAuthStatus, TraktClient
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.group()
|
|
20
|
+
@click.version_option(version=__version__, prog_name="pakt")
|
|
21
|
+
def main():
|
|
22
|
+
"""Pakt - Fast Plex-Trakt sync using batch operations."""
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _make_token_refresh_callback(config: Config):
|
|
27
|
+
"""Create a callback that saves tokens when they're refreshed."""
|
|
28
|
+
def on_token_refresh(token: dict):
|
|
29
|
+
config.trakt.access_token = token["access_token"]
|
|
30
|
+
config.trakt.refresh_token = token["refresh_token"]
|
|
31
|
+
config.trakt.expires_at = token["created_at"] + token["expires_in"]
|
|
32
|
+
config.save()
|
|
33
|
+
return on_token_refresh
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@main.command()
|
|
37
|
+
@click.option("--dry-run", is_flag=True, help="Show what would be synced without making changes")
|
|
38
|
+
@click.option("--verbose", "-v", is_flag=True, help="Show detailed list of items to sync")
|
|
39
|
+
def sync(dry_run: bool, verbose: bool):
|
|
40
|
+
"""Sync watched status and ratings between Plex and Trakt."""
|
|
41
|
+
from pakt.sync import run_sync
|
|
42
|
+
|
|
43
|
+
config = Config.load()
|
|
44
|
+
|
|
45
|
+
if not config.trakt.access_token:
|
|
46
|
+
console.print("[red]Error:[/] Not logged in to Trakt. Run 'pakt login' first.")
|
|
47
|
+
sys.exit(1)
|
|
48
|
+
|
|
49
|
+
if not config.plex.url or not config.plex.token:
|
|
50
|
+
console.print("[red]Error:[/] Plex not configured. Run 'pakt setup' first.")
|
|
51
|
+
sys.exit(1)
|
|
52
|
+
|
|
53
|
+
result = asyncio.run(run_sync(config, dry_run=dry_run, verbose=verbose, on_token_refresh=_make_token_refresh_callback(config)))
|
|
54
|
+
|
|
55
|
+
console.print("\n[bold green]Sync complete![/]")
|
|
56
|
+
console.print(f" Added to Trakt: {result.added_to_trakt}")
|
|
57
|
+
console.print(f" Added to Plex: {result.added_to_plex}")
|
|
58
|
+
console.print(f" Ratings synced: {result.ratings_synced}")
|
|
59
|
+
console.print(f" Duration: {result.duration_seconds:.1f}s")
|
|
60
|
+
|
|
61
|
+
if result.errors:
|
|
62
|
+
console.print(f"\n[yellow]Errors ({len(result.errors)}):[/]")
|
|
63
|
+
for error in result.errors[:10]:
|
|
64
|
+
console.print(f" - {error}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@main.command()
|
|
68
|
+
def login():
|
|
69
|
+
"""Authenticate with Trakt using device code flow."""
|
|
70
|
+
config = Config.load()
|
|
71
|
+
|
|
72
|
+
async def do_auth():
|
|
73
|
+
async with TraktClient(config.trakt) as client:
|
|
74
|
+
console.print("[cyan]Getting device code...[/]")
|
|
75
|
+
device = await client.device_code()
|
|
76
|
+
|
|
77
|
+
console.print(f"\n[bold]→ Go to:[/] [cyan]{device['verification_url']}[/]")
|
|
78
|
+
console.print(f"[bold]→ Enter:[/] [bold yellow]{device['user_code']}[/]")
|
|
79
|
+
console.print("\n[dim]Waiting for authorization...[/]")
|
|
80
|
+
|
|
81
|
+
result = await client.poll_device_token(
|
|
82
|
+
device["device_code"],
|
|
83
|
+
interval=device.get("interval", 5),
|
|
84
|
+
expires_in=device.get("expires_in", 600),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if result.status == DeviceAuthStatus.SUCCESS and result.token:
|
|
88
|
+
config.trakt.access_token = result.token["access_token"]
|
|
89
|
+
config.trakt.refresh_token = result.token["refresh_token"]
|
|
90
|
+
config.trakt.expires_at = result.token["created_at"] + result.token["expires_in"]
|
|
91
|
+
config.save()
|
|
92
|
+
console.print("\n[green]✓ Successfully authenticated with Trakt![/]")
|
|
93
|
+
else:
|
|
94
|
+
console.print(f"\n[red]✗ {result.message}[/]")
|
|
95
|
+
sys.exit(1)
|
|
96
|
+
|
|
97
|
+
asyncio.run(do_auth())
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@main.command()
|
|
101
|
+
def logout():
|
|
102
|
+
"""Revoke Trakt authentication and clear tokens."""
|
|
103
|
+
config = Config.load()
|
|
104
|
+
|
|
105
|
+
if not config.trakt.access_token:
|
|
106
|
+
console.print("[yellow]Not currently logged in to Trakt.[/]")
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
async def do_logout():
|
|
110
|
+
async with TraktClient(config.trakt) as client:
|
|
111
|
+
console.print("[cyan]Revoking Trakt access token...[/]")
|
|
112
|
+
success = await client.revoke_token()
|
|
113
|
+
|
|
114
|
+
if success:
|
|
115
|
+
console.print("[green]Token revoked successfully.[/]")
|
|
116
|
+
else:
|
|
117
|
+
console.print("[yellow]Token revocation failed, clearing local tokens anyway.[/]")
|
|
118
|
+
|
|
119
|
+
# Clear local tokens regardless
|
|
120
|
+
config.trakt.access_token = ""
|
|
121
|
+
config.trakt.refresh_token = ""
|
|
122
|
+
config.trakt.expires_at = 0
|
|
123
|
+
config.save()
|
|
124
|
+
console.print("[green]Logged out from Trakt.[/]")
|
|
125
|
+
|
|
126
|
+
asyncio.run(do_logout())
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@main.command()
|
|
130
|
+
def setup():
|
|
131
|
+
"""Interactive setup wizard - configure Trakt and Plex in one go."""
|
|
132
|
+
from pakt.plex import PlexClient
|
|
133
|
+
|
|
134
|
+
config = Config.load()
|
|
135
|
+
|
|
136
|
+
console.print("\n[bold cyan]═══ Pakt Setup Wizard ═══[/]\n")
|
|
137
|
+
|
|
138
|
+
# Check what's already configured
|
|
139
|
+
trakt_done = bool(config.trakt.access_token)
|
|
140
|
+
plex_done = bool(config.plex.url and config.plex.token)
|
|
141
|
+
|
|
142
|
+
if trakt_done and plex_done:
|
|
143
|
+
console.print("[green]✓[/] Trakt: Authenticated")
|
|
144
|
+
console.print("[green]✓[/] Plex: Configured")
|
|
145
|
+
console.print("\nEverything is already set up! Run [cyan]pakt sync[/] to start syncing.")
|
|
146
|
+
if click.confirm("\nReconfigure anyway?", default=False):
|
|
147
|
+
trakt_done = False
|
|
148
|
+
plex_done = False
|
|
149
|
+
else:
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
# Step 1: Trakt
|
|
153
|
+
if not trakt_done:
|
|
154
|
+
console.print("[bold]Step 1: Trakt Authentication[/]\n")
|
|
155
|
+
|
|
156
|
+
async def do_auth():
|
|
157
|
+
async with TraktClient(config.trakt) as client:
|
|
158
|
+
console.print("[cyan]Getting device code...[/]")
|
|
159
|
+
device = await client.device_code()
|
|
160
|
+
|
|
161
|
+
console.print(f"\n[bold]→ Go to:[/] [cyan]{device['verification_url']}[/]")
|
|
162
|
+
console.print(f"[bold]→ Enter:[/] [bold yellow]{device['user_code']}[/]")
|
|
163
|
+
console.print("\n[dim]Waiting for you to authorize...[/]")
|
|
164
|
+
|
|
165
|
+
result = await client.poll_device_token(
|
|
166
|
+
device["device_code"],
|
|
167
|
+
interval=device.get("interval", 5),
|
|
168
|
+
expires_in=device.get("expires_in", 600),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if result.status == DeviceAuthStatus.SUCCESS and result.token:
|
|
172
|
+
config.trakt.access_token = result.token["access_token"]
|
|
173
|
+
config.trakt.refresh_token = result.token["refresh_token"]
|
|
174
|
+
config.trakt.expires_at = result.token["created_at"] + result.token["expires_in"]
|
|
175
|
+
config.save()
|
|
176
|
+
console.print("\n[green]✓ Trakt authenticated![/]")
|
|
177
|
+
return True
|
|
178
|
+
else:
|
|
179
|
+
console.print(f"\n[red]✗ {result.message}[/]")
|
|
180
|
+
return False
|
|
181
|
+
|
|
182
|
+
if not asyncio.run(do_auth()):
|
|
183
|
+
console.print("\n[yellow]Setup incomplete. Run 'pakt setup' to try again.[/]")
|
|
184
|
+
return
|
|
185
|
+
else:
|
|
186
|
+
console.print("[green]✓[/] Trakt: Already authenticated\n")
|
|
187
|
+
|
|
188
|
+
# Step 2: Plex
|
|
189
|
+
if not plex_done:
|
|
190
|
+
console.print("\n[bold]Step 2: Plex Connection[/]\n")
|
|
191
|
+
|
|
192
|
+
# Try to auto-detect local Plex
|
|
193
|
+
console.print("[dim]Checking for local Plex server...[/]")
|
|
194
|
+
detected_url = None
|
|
195
|
+
try:
|
|
196
|
+
import httpx
|
|
197
|
+
resp = httpx.get("http://localhost:32400/identity", timeout=2)
|
|
198
|
+
if resp.status_code == 200:
|
|
199
|
+
detected_url = "http://localhost:32400"
|
|
200
|
+
console.print(f"[green]✓[/] Found Plex at {detected_url}")
|
|
201
|
+
except Exception:
|
|
202
|
+
console.print("[dim]No local server found[/]")
|
|
203
|
+
|
|
204
|
+
default_url = detected_url or config.plex.url or "http://localhost:32400"
|
|
205
|
+
config.plex.url = click.prompt("\nPlex server URL", default=default_url)
|
|
206
|
+
|
|
207
|
+
console.print("\n[dim]To get your Plex token:")
|
|
208
|
+
console.print("1. Open Plex Web App and sign in")
|
|
209
|
+
console.print("2. Open any media item")
|
|
210
|
+
console.print("3. Click ⋮ → Get Info → View XML")
|
|
211
|
+
console.print("4. In the URL, find X-Plex-Token=xxxxx")
|
|
212
|
+
console.print("5. Copy just the token part after the =[/]\n")
|
|
213
|
+
|
|
214
|
+
token = click.prompt("Plex token")
|
|
215
|
+
# Auto-strip prefix if user pasted the whole thing
|
|
216
|
+
if token.lower().startswith("x-plex-token="):
|
|
217
|
+
token = token[13:]
|
|
218
|
+
elif token.startswith("="):
|
|
219
|
+
token = token[1:]
|
|
220
|
+
config.plex.token = token
|
|
221
|
+
config.save()
|
|
222
|
+
|
|
223
|
+
# Test connection
|
|
224
|
+
console.print("\n[dim]Testing connection...[/]")
|
|
225
|
+
try:
|
|
226
|
+
plex = PlexClient(config.plex)
|
|
227
|
+
plex.connect()
|
|
228
|
+
console.print(f"[green]✓[/] Connected to: {plex.server.friendlyName}")
|
|
229
|
+
libs = plex.get_movie_libraries() + plex.get_show_libraries()
|
|
230
|
+
console.print(f"[green]✓[/] Libraries: {', '.join(libs)}")
|
|
231
|
+
except Exception as e:
|
|
232
|
+
console.print(f"[red]✗ Connection failed:[/] {e}")
|
|
233
|
+
console.print("\n[yellow]Check your URL and token, then run 'pakt setup' again.[/]")
|
|
234
|
+
return
|
|
235
|
+
else:
|
|
236
|
+
console.print("[green]✓[/] Plex: Already configured")
|
|
237
|
+
|
|
238
|
+
# Done!
|
|
239
|
+
console.print("\n[bold green]═══ Setup Complete! ═══[/]\n")
|
|
240
|
+
console.print("You're ready to sync. Try these commands:")
|
|
241
|
+
console.print(" [cyan]pakt sync --dry-run[/] - Preview what will sync")
|
|
242
|
+
console.print(" [cyan]pakt sync[/] - Run the sync")
|
|
243
|
+
console.print(" [cyan]pakt serve[/] - Start web interface")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@main.command()
|
|
247
|
+
def status():
|
|
248
|
+
"""Show current configuration status."""
|
|
249
|
+
config = Config.load()
|
|
250
|
+
config_dir = get_config_dir()
|
|
251
|
+
|
|
252
|
+
console.print("[bold]Pakt Status[/]\n")
|
|
253
|
+
|
|
254
|
+
# Trakt status
|
|
255
|
+
table = Table(title="Trakt")
|
|
256
|
+
table.add_column("Setting", style="cyan")
|
|
257
|
+
table.add_column("Value")
|
|
258
|
+
|
|
259
|
+
table.add_row("Client ID", config.trakt.client_id[:20] + "..." if config.trakt.client_id else "[red]Not set[/]")
|
|
260
|
+
table.add_row("Authenticated", "[green]Yes[/]" if config.trakt.access_token else "[red]No[/]")
|
|
261
|
+
console.print(table)
|
|
262
|
+
|
|
263
|
+
# Plex status
|
|
264
|
+
table = Table(title="Plex")
|
|
265
|
+
table.add_column("Setting", style="cyan")
|
|
266
|
+
table.add_column("Value")
|
|
267
|
+
|
|
268
|
+
table.add_row("URL", config.plex.url or "[red]Not set[/]")
|
|
269
|
+
table.add_row("Token", "***" if config.plex.token else "[red]Not set[/]")
|
|
270
|
+
console.print(table)
|
|
271
|
+
|
|
272
|
+
console.print(f"\n[dim]Config directory: {config_dir}[/]")
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
@main.command()
|
|
276
|
+
@click.option("--host", default="127.0.0.1", help="Host to bind to")
|
|
277
|
+
@click.option("--port", default=8080, help="Port to bind to")
|
|
278
|
+
@click.option("--tray/--no-tray", default=None, help="Enable/disable system tray icon")
|
|
279
|
+
def serve(host: str, port: int, tray: bool | None):
|
|
280
|
+
"""Start the web interface."""
|
|
281
|
+
import os
|
|
282
|
+
import uvicorn
|
|
283
|
+
|
|
284
|
+
from pakt.web import create_app
|
|
285
|
+
|
|
286
|
+
# Only show tray when explicitly requested with --tray
|
|
287
|
+
show_tray = tray is True
|
|
288
|
+
|
|
289
|
+
# Suppress console output only when --tray explicitly passed (for pythonw compatibility)
|
|
290
|
+
silent_mode = tray is True
|
|
291
|
+
|
|
292
|
+
# Redirect stdout/stderr to devnull in silent mode (for pythonw)
|
|
293
|
+
if silent_mode:
|
|
294
|
+
try:
|
|
295
|
+
devnull = open(os.devnull, 'w')
|
|
296
|
+
sys.stdout = devnull
|
|
297
|
+
sys.stderr = devnull
|
|
298
|
+
except Exception:
|
|
299
|
+
pass
|
|
300
|
+
|
|
301
|
+
tray_instance = None
|
|
302
|
+
if show_tray:
|
|
303
|
+
try:
|
|
304
|
+
from pakt.tray import TRAY_AVAILABLE, PaktTray
|
|
305
|
+
|
|
306
|
+
if TRAY_AVAILABLE:
|
|
307
|
+
web_url = f"http://{host}:{port}"
|
|
308
|
+
tray_instance = PaktTray(
|
|
309
|
+
web_url=web_url,
|
|
310
|
+
shutdown_callback=lambda: sys.exit(0),
|
|
311
|
+
)
|
|
312
|
+
tray_instance.start()
|
|
313
|
+
elif not silent_mode:
|
|
314
|
+
console.print("[yellow]System tray requested but dependencies not installed.[/]")
|
|
315
|
+
console.print("[yellow]Install with: pip install pystray Pillow[/]")
|
|
316
|
+
except ImportError:
|
|
317
|
+
if not silent_mode:
|
|
318
|
+
console.print("[yellow]System tray requested but dependencies not installed.[/]")
|
|
319
|
+
console.print("[yellow]Install with: pip install pystray Pillow[/]")
|
|
320
|
+
|
|
321
|
+
if not silent_mode:
|
|
322
|
+
console.print("[bold]Starting Pakt web interface...[/]")
|
|
323
|
+
console.print(f"Open [cyan]http://{host}:{port}[/] in your browser")
|
|
324
|
+
console.print("[dim]Press Ctrl+C to stop[/]\n")
|
|
325
|
+
|
|
326
|
+
app = create_app()
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
log_level = "critical" if silent_mode else "warning"
|
|
330
|
+
uvicorn.run(app, host=host, port=port, log_level=log_level)
|
|
331
|
+
finally:
|
|
332
|
+
if tray_instance:
|
|
333
|
+
tray_instance.stop()
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
@main.command()
|
|
337
|
+
@click.option("--movie", "-m", multiple=True, help="Movie libraries to sync (can specify multiple)")
|
|
338
|
+
@click.option("--show", "-s", multiple=True, help="Show libraries to sync (can specify multiple)")
|
|
339
|
+
@click.option("--all", "sync_all", is_flag=True, help="Sync all libraries (clear selection)")
|
|
340
|
+
def libraries(movie: tuple[str, ...], show: tuple[str, ...], sync_all: bool):
|
|
341
|
+
"""Configure which Plex libraries to sync.
|
|
342
|
+
|
|
343
|
+
Run without options to see available libraries and current selection.
|
|
344
|
+
"""
|
|
345
|
+
from pakt.plex import PlexClient
|
|
346
|
+
|
|
347
|
+
config = Config.load()
|
|
348
|
+
|
|
349
|
+
if not config.plex.url or not config.plex.token:
|
|
350
|
+
console.print("[red]Error:[/] Plex not configured. Run 'pakt setup' first.")
|
|
351
|
+
return
|
|
352
|
+
|
|
353
|
+
# Connect to Plex to get available libraries
|
|
354
|
+
try:
|
|
355
|
+
plex = PlexClient(config.plex)
|
|
356
|
+
plex.connect()
|
|
357
|
+
except Exception as e:
|
|
358
|
+
console.print(f"[red]Error connecting to Plex:[/] {e}")
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
available_movie_libs = plex.get_movie_libraries()
|
|
362
|
+
available_show_libs = plex.get_show_libraries()
|
|
363
|
+
|
|
364
|
+
# If options provided, update config
|
|
365
|
+
if movie or show or sync_all:
|
|
366
|
+
if sync_all:
|
|
367
|
+
config.sync.movie_libraries = []
|
|
368
|
+
config.sync.show_libraries = []
|
|
369
|
+
console.print("[green]✓[/] Set to sync all libraries")
|
|
370
|
+
else:
|
|
371
|
+
if movie:
|
|
372
|
+
# Validate library names
|
|
373
|
+
invalid = [m for m in movie if m not in available_movie_libs]
|
|
374
|
+
if invalid:
|
|
375
|
+
console.print(f"[yellow]Warning:[/] Unknown movie libraries: {', '.join(invalid)}")
|
|
376
|
+
config.sync.movie_libraries = [m for m in movie if m in available_movie_libs]
|
|
377
|
+
|
|
378
|
+
if show:
|
|
379
|
+
invalid = [s for s in show if s not in available_show_libs]
|
|
380
|
+
if invalid:
|
|
381
|
+
console.print(f"[yellow]Warning:[/] Unknown show libraries: {', '.join(invalid)}")
|
|
382
|
+
config.sync.show_libraries = [s for s in show if s in available_show_libs]
|
|
383
|
+
|
|
384
|
+
config.save()
|
|
385
|
+
console.print("[green]✓[/] Configuration saved")
|
|
386
|
+
console.print()
|
|
387
|
+
|
|
388
|
+
# Display current state
|
|
389
|
+
table = Table(title="Plex Libraries")
|
|
390
|
+
table.add_column("Type", style="cyan")
|
|
391
|
+
table.add_column("Library")
|
|
392
|
+
table.add_column("Status")
|
|
393
|
+
|
|
394
|
+
current_movie = config.sync.movie_libraries or []
|
|
395
|
+
current_show = config.sync.show_libraries or []
|
|
396
|
+
sync_all_movies = len(current_movie) == 0
|
|
397
|
+
sync_all_shows = len(current_show) == 0
|
|
398
|
+
|
|
399
|
+
for lib in available_movie_libs:
|
|
400
|
+
if sync_all_movies or lib in current_movie:
|
|
401
|
+
status = "[green]✓ Syncing[/]"
|
|
402
|
+
else:
|
|
403
|
+
status = "[dim]Skipped[/]"
|
|
404
|
+
table.add_row("Movie", lib, status)
|
|
405
|
+
|
|
406
|
+
for lib in available_show_libs:
|
|
407
|
+
if sync_all_shows or lib in current_show:
|
|
408
|
+
status = "[green]✓ Syncing[/]"
|
|
409
|
+
else:
|
|
410
|
+
status = "[dim]Skipped[/]"
|
|
411
|
+
table.add_row("Show", lib, status)
|
|
412
|
+
|
|
413
|
+
console.print(table)
|
|
414
|
+
|
|
415
|
+
if sync_all_movies and sync_all_shows:
|
|
416
|
+
console.print("\n[dim]All libraries selected (default)[/]")
|
|
417
|
+
console.print("\n[dim]Use --movie/-m and --show/-s to select specific libraries[/]")
|
|
418
|
+
console.print("[dim]Use --all to reset to syncing all libraries[/]")
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
if __name__ == "__main__":
|
|
422
|
+
main()
|
pakt/config.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Configuration management for Pakt."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from pydantic import Field, field_validator
|
|
11
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def get_config_dir() -> Path:
|
|
15
|
+
"""Get the configuration directory (platform-appropriate)."""
|
|
16
|
+
if os.name == "nt": # Windows
|
|
17
|
+
base = Path(os.environ.get("APPDATA", Path.home()))
|
|
18
|
+
config_dir = base / "pakt"
|
|
19
|
+
else: # Linux/macOS
|
|
20
|
+
config_dir = Path.home() / ".config" / "pakt"
|
|
21
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
22
|
+
return config_dir
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_cache_dir() -> Path:
|
|
26
|
+
"""Get the cache directory (platform-appropriate)."""
|
|
27
|
+
if os.name == "nt": # Windows
|
|
28
|
+
base = Path(os.environ.get("LOCALAPPDATA", Path.home()))
|
|
29
|
+
cache_dir = base / "pakt" / "cache"
|
|
30
|
+
else: # Linux/macOS
|
|
31
|
+
cache_dir = Path.home() / ".cache" / "pakt"
|
|
32
|
+
cache_dir.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
return cache_dir
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TraktConfig(BaseSettings):
|
|
37
|
+
"""Trakt API configuration."""
|
|
38
|
+
|
|
39
|
+
model_config = SettingsConfigDict(env_prefix="TRAKT_")
|
|
40
|
+
|
|
41
|
+
# Default app credentials - users get their own rate limits after auth
|
|
42
|
+
client_id: str = "bc25ba5024e871104e3a090b98e44895670d66d89d5296fa6ed027d6e2a44f9d"
|
|
43
|
+
client_secret: str = "0a3cb10a368e848ef67ebcbdb0f64b437ee5abf17e07e4f216c9aa7346f587f5"
|
|
44
|
+
access_token: str = ""
|
|
45
|
+
refresh_token: str = ""
|
|
46
|
+
expires_at: int = 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PlexConfig(BaseSettings):
|
|
50
|
+
"""Plex configuration."""
|
|
51
|
+
|
|
52
|
+
model_config = SettingsConfigDict(env_prefix="PLEX_")
|
|
53
|
+
|
|
54
|
+
url: str = ""
|
|
55
|
+
token: str = ""
|
|
56
|
+
server_name: str = ""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SyncConfig(BaseSettings):
|
|
60
|
+
"""Sync behavior configuration."""
|
|
61
|
+
|
|
62
|
+
model_config = SettingsConfigDict(env_prefix="PAKT_SYNC_")
|
|
63
|
+
|
|
64
|
+
# Sync directions - watched
|
|
65
|
+
watched_plex_to_trakt: bool = True
|
|
66
|
+
watched_trakt_to_plex: bool = True
|
|
67
|
+
|
|
68
|
+
# Sync directions - ratings
|
|
69
|
+
ratings_plex_to_trakt: bool = True
|
|
70
|
+
ratings_trakt_to_plex: bool = True
|
|
71
|
+
|
|
72
|
+
# Sync directions - collection (Plex library -> Trakt collection)
|
|
73
|
+
collection_plex_to_trakt: bool = False
|
|
74
|
+
|
|
75
|
+
# Sync directions - watchlist
|
|
76
|
+
watchlist_plex_to_trakt: bool = False
|
|
77
|
+
watchlist_trakt_to_plex: bool = False
|
|
78
|
+
|
|
79
|
+
# Rating priority when both have ratings
|
|
80
|
+
rating_priority: Literal["plex", "trakt", "newest"] = "newest"
|
|
81
|
+
|
|
82
|
+
# Libraries to sync (empty = all)
|
|
83
|
+
movie_libraries: list[str] = Field(default_factory=list)
|
|
84
|
+
show_libraries: list[str] = Field(default_factory=list)
|
|
85
|
+
|
|
86
|
+
# Libraries to exclude
|
|
87
|
+
excluded_libraries: list[str] = Field(default_factory=list)
|
|
88
|
+
|
|
89
|
+
@field_validator("movie_libraries", "show_libraries", "excluded_libraries", mode="before")
|
|
90
|
+
@classmethod
|
|
91
|
+
def parse_library_list(cls, v: str | list[str]) -> list[str]:
|
|
92
|
+
"""Parse library list from env var (JSON array or comma-separated)."""
|
|
93
|
+
if isinstance(v, list):
|
|
94
|
+
return v
|
|
95
|
+
if not v or (isinstance(v, str) and v.strip() == ""):
|
|
96
|
+
return []
|
|
97
|
+
# Try JSON first (new format), fall back to comma-separated (legacy)
|
|
98
|
+
v_str = v.strip()
|
|
99
|
+
if v_str.startswith("["):
|
|
100
|
+
try:
|
|
101
|
+
parsed = json.loads(v_str)
|
|
102
|
+
return parsed if isinstance(parsed, list) else []
|
|
103
|
+
except json.JSONDecodeError:
|
|
104
|
+
pass
|
|
105
|
+
return [lib.strip() for lib in v.split(",") if lib.strip()]
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class SchedulerConfig(BaseSettings):
|
|
109
|
+
"""Scheduler configuration."""
|
|
110
|
+
|
|
111
|
+
model_config = SettingsConfigDict(env_prefix="PAKT_SCHEDULER_")
|
|
112
|
+
|
|
113
|
+
enabled: bool = False
|
|
114
|
+
interval_hours: int = 0 # 0 = disabled
|
|
115
|
+
run_on_startup: bool = False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class Config(BaseSettings):
|
|
119
|
+
"""Main configuration."""
|
|
120
|
+
|
|
121
|
+
model_config = SettingsConfigDict(
|
|
122
|
+
env_file=".env",
|
|
123
|
+
env_file_encoding="utf-8",
|
|
124
|
+
extra="ignore",
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
trakt: TraktConfig = Field(default_factory=TraktConfig)
|
|
128
|
+
plex: PlexConfig = Field(default_factory=PlexConfig)
|
|
129
|
+
sync: SyncConfig = Field(default_factory=SyncConfig)
|
|
130
|
+
scheduler: SchedulerConfig = Field(default_factory=SchedulerConfig)
|
|
131
|
+
|
|
132
|
+
@classmethod
|
|
133
|
+
def load(cls, config_dir: Path | None = None) -> Config:
|
|
134
|
+
"""Load configuration from file and environment."""
|
|
135
|
+
if config_dir is None:
|
|
136
|
+
config_dir = get_config_dir()
|
|
137
|
+
|
|
138
|
+
# Fields that need empty string -> [] conversion for pydantic-settings
|
|
139
|
+
list_fields = {
|
|
140
|
+
"PAKT_SYNC_MOVIE_LIBRARIES",
|
|
141
|
+
"PAKT_SYNC_SHOW_LIBRARIES",
|
|
142
|
+
"PAKT_SYNC_EXCLUDED_LIBRARIES",
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
# Fix any existing empty list env vars (pydantic-settings needs valid JSON)
|
|
146
|
+
for key in list_fields:
|
|
147
|
+
if key in os.environ and os.environ[key].strip() == "":
|
|
148
|
+
os.environ[key] = "[]"
|
|
149
|
+
|
|
150
|
+
env_file = config_dir / ".env"
|
|
151
|
+
if env_file.exists():
|
|
152
|
+
# Load env vars into os.environ so nested configs pick them up
|
|
153
|
+
for line in env_file.read_text().splitlines():
|
|
154
|
+
line = line.strip()
|
|
155
|
+
if line and "=" in line and not line.startswith("#"):
|
|
156
|
+
key, value = line.split("=", 1)
|
|
157
|
+
# Convert empty strings to JSON empty array for list fields
|
|
158
|
+
if key in list_fields and value.strip() == "":
|
|
159
|
+
value = "[]"
|
|
160
|
+
os.environ[key] = value
|
|
161
|
+
|
|
162
|
+
return cls()
|
|
163
|
+
|
|
164
|
+
def save(self, config_dir: Path | None = None) -> None:
|
|
165
|
+
"""Save configuration to file."""
|
|
166
|
+
if config_dir is None:
|
|
167
|
+
config_dir = get_config_dir()
|
|
168
|
+
|
|
169
|
+
env_file = config_dir / ".env"
|
|
170
|
+
lines = [
|
|
171
|
+
# Trakt
|
|
172
|
+
f"TRAKT_CLIENT_ID={self.trakt.client_id}",
|
|
173
|
+
f"TRAKT_CLIENT_SECRET={self.trakt.client_secret}",
|
|
174
|
+
f"TRAKT_ACCESS_TOKEN={self.trakt.access_token}",
|
|
175
|
+
f"TRAKT_REFRESH_TOKEN={self.trakt.refresh_token}",
|
|
176
|
+
f"TRAKT_EXPIRES_AT={self.trakt.expires_at}",
|
|
177
|
+
# Plex
|
|
178
|
+
f"PLEX_URL={self.plex.url}",
|
|
179
|
+
f"PLEX_TOKEN={self.plex.token}",
|
|
180
|
+
f"PLEX_SERVER_NAME={self.plex.server_name}",
|
|
181
|
+
# Sync - watched
|
|
182
|
+
f"PAKT_SYNC_WATCHED_PLEX_TO_TRAKT={str(self.sync.watched_plex_to_trakt).lower()}",
|
|
183
|
+
f"PAKT_SYNC_WATCHED_TRAKT_TO_PLEX={str(self.sync.watched_trakt_to_plex).lower()}",
|
|
184
|
+
# Sync - ratings
|
|
185
|
+
f"PAKT_SYNC_RATINGS_PLEX_TO_TRAKT={str(self.sync.ratings_plex_to_trakt).lower()}",
|
|
186
|
+
f"PAKT_SYNC_RATINGS_TRAKT_TO_PLEX={str(self.sync.ratings_trakt_to_plex).lower()}",
|
|
187
|
+
# Sync - collection
|
|
188
|
+
f"PAKT_SYNC_COLLECTION_PLEX_TO_TRAKT={str(self.sync.collection_plex_to_trakt).lower()}",
|
|
189
|
+
# Sync - watchlist
|
|
190
|
+
f"PAKT_SYNC_WATCHLIST_PLEX_TO_TRAKT={str(self.sync.watchlist_plex_to_trakt).lower()}",
|
|
191
|
+
f"PAKT_SYNC_WATCHLIST_TRAKT_TO_PLEX={str(self.sync.watchlist_trakt_to_plex).lower()}",
|
|
192
|
+
# Sync - libraries (JSON format for pydantic-settings)
|
|
193
|
+
f"PAKT_SYNC_MOVIE_LIBRARIES={json.dumps(self.sync.movie_libraries)}",
|
|
194
|
+
f"PAKT_SYNC_SHOW_LIBRARIES={json.dumps(self.sync.show_libraries)}",
|
|
195
|
+
# Scheduler
|
|
196
|
+
f"PAKT_SCHEDULER_ENABLED={str(self.scheduler.enabled).lower()}",
|
|
197
|
+
f"PAKT_SCHEDULER_INTERVAL_HOURS={self.scheduler.interval_hours}",
|
|
198
|
+
f"PAKT_SCHEDULER_RUN_ON_STARTUP={str(self.scheduler.run_on_startup).lower()}",
|
|
199
|
+
]
|
|
200
|
+
env_file.write_text("\n".join(lines))
|