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 ADDED
@@ -0,0 +1,3 @@
1
+ """Pakt - Fast, efficient Plex-Trakt sync."""
2
+
3
+ __version__ = "0.1.0"
pakt/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Entry point for python -m pakt."""
2
+
3
+ from pakt.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
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))