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/sync.py ADDED
@@ -0,0 +1,1490 @@
1
+ """Sync logic for Pakt."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import gc
7
+ import logging
8
+ import re
9
+ import threading
10
+ import time
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Callable
13
+
14
+ from rich.console import Console
15
+ from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn
16
+
17
+ from pakt.config import Config, ServerConfig, get_config_dir
18
+ from pakt.models import PlexIds, RatedItem, SyncResult, WatchedItem
19
+ from pakt.plex import PlexClient, extract_media_metadata, extract_plex_ids
20
+ from pakt.trakt import AccountLimits, TraktAccountLimitError, TraktClient
21
+
22
+ console = Console()
23
+
24
+
25
+ @dataclass
26
+ class TraktCache:
27
+ """Cached Trakt data to avoid re-fetching for multiple servers."""
28
+
29
+ account_limits: AccountLimits | None = None
30
+ watched_movies: list[WatchedItem] = field(default_factory=list)
31
+ movie_ratings: list[RatedItem] = field(default_factory=list)
32
+ watched_shows: list[WatchedItem] = field(default_factory=list)
33
+ episode_ratings: list[RatedItem] = field(default_factory=list)
34
+ collection_movies: list[dict] = field(default_factory=list)
35
+ collection_shows: list[dict] = field(default_factory=list)
36
+ watchlist_movies: list[dict] = field(default_factory=list)
37
+ watchlist_shows: list[dict] = field(default_factory=list)
38
+
39
+
40
+ @dataclass
41
+ class MovieProcessingResult:
42
+ """Results from movie processing (runs in thread)."""
43
+
44
+ movies_to_mark_watched_trakt: list[dict] = field(default_factory=list)
45
+ movies_to_mark_watched_plex: list[Any] = field(default_factory=list)
46
+ movies_to_rate_trakt: list[dict] = field(default_factory=list)
47
+ movies_to_rate_plex: list[tuple[Any, int]] = field(default_factory=list)
48
+ cancelled: bool = False
49
+
50
+
51
+ @dataclass
52
+ class EpisodeProcessingResult:
53
+ """Results from episode processing (runs in thread)."""
54
+
55
+ episodes_to_mark_watched_trakt: list[dict] = field(default_factory=list)
56
+ episodes_to_mark_watched_trakt_display: list[str] = field(default_factory=list)
57
+ episodes_to_mark_watched_plex: list[Any] = field(default_factory=list)
58
+ episodes_to_rate_trakt: list[dict] = field(default_factory=list)
59
+ episodes_to_rate_trakt_display: list[str] = field(default_factory=list)
60
+ episodes_to_rate_plex: list[tuple[Any, int]] = field(default_factory=list)
61
+ skipped_no_ids: int = 0
62
+ cancelled: bool = False
63
+
64
+
65
+ def _process_episodes_in_thread(
66
+ plex_episodes: list[Any],
67
+ plex_show_ids_by_key: dict[str, PlexIds],
68
+ trakt_watched_episodes: dict[tuple, dict],
69
+ trakt_episode_ratings: dict[tuple, dict],
70
+ sync_watched_plex_to_trakt: bool,
71
+ sync_watched_trakt_to_plex: bool,
72
+ sync_ratings_plex_to_trakt: bool,
73
+ sync_ratings_trakt_to_plex: bool,
74
+ cancel_event: threading.Event,
75
+ progress_callback: Callable[[int, int], None] | None = None,
76
+ ) -> EpisodeProcessingResult:
77
+ """Process episodes in a thread to not block the event loop.
78
+
79
+ This is CPU-bound work (dict lookups, comparisons) that would otherwise
80
+ block the async event loop and prevent cancellation/UI updates.
81
+ """
82
+ result = EpisodeProcessingResult()
83
+ processed_episode_ids: set[tuple] = set()
84
+ total = len(plex_episodes)
85
+
86
+ # Pre-extract all episode data to avoid slow PlexAPI attribute access in comparison loop
87
+ # CRITICAL: PlexAPI's __getattribute__ triggers network reload if attribute is None!
88
+ # Disable auto-reload to prevent 24ms network call per episode for remote servers.
89
+ episode_data: list[tuple] = []
90
+ for i, ep in enumerate(plex_episodes):
91
+ if i % 100 == 0 and cancel_event.is_set():
92
+ result.cancelled = True
93
+ return result
94
+ if i % 500 == 0 and progress_callback:
95
+ progress_callback(i, total)
96
+
97
+ episode_data.append((
98
+ str(ep.grandparentRatingKey), # show_key
99
+ ep.parentIndex, # seasonNumber
100
+ ep.index, # episodeNumber
101
+ ep.viewCount > 0 if ep.viewCount else False, # isWatched
102
+ ep.userRating,
103
+ ep.grandparentTitle,
104
+ ep, # Keep reference for Plex operations
105
+ ))
106
+
107
+ if progress_callback:
108
+ progress_callback(total, total)
109
+
110
+ # Now iterate over extracted data (pure Python, no network calls)
111
+ for show_key, season_num, ep_num, plex_watched, plex_ep_rating, show_title, episode in episode_data:
112
+ show_ids = plex_show_ids_by_key.get(show_key)
113
+ if not show_ids or (not show_ids.tvdb and not show_ids.imdb):
114
+ result.skipped_no_ids += 1
115
+ continue
116
+
117
+ # Skip duplicates (same episode in multiple libraries)
118
+ ep_key = (show_ids.tvdb or show_ids.imdb, season_num, ep_num)
119
+ if ep_key in processed_episode_ids:
120
+ continue
121
+ processed_episode_ids.add(ep_key)
122
+
123
+ # Check watched status
124
+ trakt_watched = False
125
+ if show_ids.tvdb and (show_ids.tvdb, season_num, ep_num) in trakt_watched_episodes:
126
+ trakt_watched = True
127
+ elif show_ids.imdb and (show_ids.imdb, season_num, ep_num) in trakt_watched_episodes:
128
+ trakt_watched = True
129
+
130
+ if plex_watched and not trakt_watched and sync_watched_plex_to_trakt:
131
+ ep_ids = {}
132
+ if show_ids.tvdb:
133
+ ep_ids["tvdb"] = show_ids.tvdb
134
+ if show_ids.imdb:
135
+ ep_ids["imdb"] = show_ids.imdb
136
+ if ep_ids:
137
+ result.episodes_to_mark_watched_trakt.append({
138
+ "ids": ep_ids,
139
+ "seasons": [{"number": season_num, "episodes": [{"number": ep_num}]}]
140
+ })
141
+ result.episodes_to_mark_watched_trakt_display.append(
142
+ f"{show_title} S{season_num:02d}E{ep_num:02d}"
143
+ )
144
+ elif trakt_watched and not plex_watched and sync_watched_trakt_to_plex:
145
+ result.episodes_to_mark_watched_plex.append(episode)
146
+
147
+ # Check ratings
148
+ plex_ep_rating_int = int(plex_ep_rating) if plex_ep_rating else None
149
+ trakt_ep_rating = None
150
+ if show_ids.tvdb and (show_ids.tvdb, season_num, ep_num) in trakt_episode_ratings:
151
+ trakt_ep_rating = trakt_episode_ratings[(show_ids.tvdb, season_num, ep_num)]
152
+ elif show_ids.imdb and (show_ids.imdb, season_num, ep_num) in trakt_episode_ratings:
153
+ trakt_ep_rating = trakt_episode_ratings[(show_ids.imdb, season_num, ep_num)]
154
+
155
+ trakt_ep_rating_val = trakt_ep_rating["rating"] if trakt_ep_rating else None
156
+
157
+ if plex_ep_rating_int and not trakt_ep_rating_val and sync_ratings_plex_to_trakt:
158
+ ep_ids = {}
159
+ if show_ids.tvdb:
160
+ ep_ids["tvdb"] = show_ids.tvdb
161
+ if show_ids.imdb:
162
+ ep_ids["imdb"] = show_ids.imdb
163
+ if ep_ids:
164
+ result.episodes_to_rate_trakt.append({
165
+ "ids": ep_ids,
166
+ "seasons": [{
167
+ "number": season_num,
168
+ "episodes": [{"number": ep_num, "rating": plex_ep_rating_int}]
169
+ }]
170
+ })
171
+ result.episodes_to_rate_trakt_display.append(
172
+ f"{show_title} S{season_num:02d}E{ep_num:02d} = {plex_ep_rating_int}"
173
+ )
174
+ elif trakt_ep_rating_val and not plex_ep_rating_int and sync_ratings_trakt_to_plex:
175
+ result.episodes_to_rate_plex.append((episode, trakt_ep_rating_val))
176
+
177
+ return result
178
+
179
+
180
+ # File logger setup
181
+ _file_logger: logging.Logger | None = None
182
+
183
+
184
+ def get_file_logger() -> logging.Logger:
185
+ """Get or create the file logger."""
186
+ global _file_logger
187
+ if _file_logger is None:
188
+ log_dir = get_config_dir()
189
+ log_file = log_dir / "sync.log"
190
+
191
+ _file_logger = logging.getLogger("pakt.sync")
192
+ _file_logger.setLevel(logging.DEBUG)
193
+
194
+ # Rotate log if too big (>5MB)
195
+ if log_file.exists() and log_file.stat().st_size > 5 * 1024 * 1024:
196
+ old_log = log_dir / "sync.log.old"
197
+ if old_log.exists():
198
+ old_log.unlink()
199
+ log_file.rename(old_log)
200
+
201
+ handler = logging.FileHandler(log_file, encoding="utf-8")
202
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
203
+ _file_logger.addHandler(handler)
204
+
205
+ return _file_logger
206
+
207
+
208
+ class SyncEngine:
209
+ """Main sync engine coordinating Plex and Trakt."""
210
+
211
+ def __init__(
212
+ self,
213
+ config: Config,
214
+ trakt: TraktClient,
215
+ plex: PlexClient,
216
+ log_callback: Callable[[str], None] | None = None,
217
+ cancel_check: Callable[[], bool] | None = None,
218
+ verbose: bool = False,
219
+ server_name: str | None = None,
220
+ server_config: ServerConfig | None = None,
221
+ trakt_cache: TraktCache | None = None,
222
+ ):
223
+ self.config = config
224
+ self.trakt = trakt
225
+ self.plex = plex
226
+ self._log_callback = log_callback
227
+ self._cancel_check = cancel_check
228
+ self._verbose = verbose
229
+ self._server_name = server_name
230
+ self._server_config = server_config
231
+ self._trakt_cache = trakt_cache
232
+ self._account_limits: AccountLimits | None = None
233
+
234
+ def _get_sync_option(self, option: str) -> bool:
235
+ """Get effective sync option, checking server override first."""
236
+ if self._server_config:
237
+ return self._server_config.get_sync_option(option, self.config.sync)
238
+ return getattr(self.config.sync, option)
239
+
240
+ def _get_movie_libraries(self) -> list[str] | None:
241
+ """Get movie libraries to sync (server-specific or global)."""
242
+ if self._server_config and self._server_config.movie_libraries:
243
+ return self._server_config.movie_libraries
244
+ return None
245
+
246
+ def _get_show_libraries(self) -> list[str] | None:
247
+ """Get show libraries to sync (server-specific or global)."""
248
+ if self._server_config and self._server_config.show_libraries:
249
+ return self._server_config.show_libraries
250
+ return None
251
+
252
+ async def _get_account_limits(self) -> AccountLimits:
253
+ """Fetch and cache account limits."""
254
+ if self._trakt_cache and self._trakt_cache.account_limits:
255
+ return self._trakt_cache.account_limits
256
+ if self._account_limits is None:
257
+ self._account_limits = await self.trakt.get_account_limits()
258
+ return self._account_limits
259
+
260
+ def _is_cancelled(self) -> bool:
261
+ """Check if sync has been cancelled."""
262
+ return self._cancel_check() if self._cancel_check else False
263
+
264
+ def _log(self, msg: str) -> None:
265
+ """Log a message to console, callback, and file."""
266
+ # Add server name prefix if set
267
+ if self._server_name:
268
+ display_msg = f"[dim][{self._server_name}][/] {msg}"
269
+ else:
270
+ display_msg = msg
271
+
272
+ # Strip rich markup for clean message
273
+ clean_msg = re.sub(r'\[/?[^\]]+\]', '', display_msg)
274
+
275
+ # Always log to file
276
+ get_file_logger().info(clean_msg)
277
+
278
+ # Console and callback
279
+ console.print(display_msg)
280
+ if self._log_callback:
281
+ self._log_callback(clean_msg)
282
+
283
+ def _progress(self, phase: int, total_phases: int, percent: float, label: str = "") -> None:
284
+ """Send progress update to callback and log."""
285
+ # Calculate overall progress across all phases
286
+ phase_weight = 100 / total_phases
287
+ overall = ((phase - 1) * phase_weight) + (percent / 100 * phase_weight)
288
+
289
+ if self._log_callback:
290
+ self._log_callback(f"PROGRESS:{phase}:{overall:.1f}:{label}")
291
+
292
+ async def _sync_movies(self, result: SyncResult, dry_run: bool) -> bool:
293
+ """Sync movies. Returns False if cancelled."""
294
+ phase_start = time.time()
295
+ self._log("\n[cyan]Phase 1:[/] Syncing movies...")
296
+ self._progress(1, 4, 0, "Fetching movie data")
297
+
298
+ # Fetch Trakt movie data (use cache if available)
299
+ with Progress(
300
+ SpinnerColumn(), TextColumn("[progress.description]{task.description}"),
301
+ transient=True, console=console
302
+ ) as progress:
303
+ if self._trakt_cache:
304
+ trakt_watched_movies = self._trakt_cache.watched_movies
305
+ trakt_movie_ratings = self._trakt_cache.movie_ratings
306
+ self._progress(1, 4, 15, "Using cached Trakt data")
307
+ else:
308
+ task = progress.add_task("Fetching Trakt watched movies...", total=None)
309
+ self._progress(1, 4, 5, "Trakt watched movies")
310
+ trakt_watched_movies = await self.trakt.get_watched_movies()
311
+ progress.update(task, description=f"Got {len(trakt_watched_movies)} watched movies")
312
+
313
+ task = progress.add_task("Fetching Trakt movie ratings...", total=None)
314
+ self._progress(1, 4, 15, "Trakt movie ratings")
315
+ trakt_movie_ratings = await self.trakt.get_movie_ratings()
316
+ progress.update(task, description=f"Got {len(trakt_movie_ratings)} movie ratings")
317
+
318
+ task = progress.add_task("Fetching Plex movies...", total=None)
319
+ self._progress(1, 4, 25, "Plex movies")
320
+ # Run in thread to not block event loop (allows web UI updates)
321
+ plex_movies, movie_libs = await asyncio.to_thread(
322
+ self.plex.get_all_movies_with_counts, self._get_movie_libraries()
323
+ )
324
+ progress.update(task, description=f"Got {len(plex_movies)} movies from Plex")
325
+
326
+ self._log(f" Trakt: {len(trakt_watched_movies)} watched, {len(trakt_movie_ratings)} ratings")
327
+ for lib_name, count in movie_libs.items():
328
+ self._log(f" Plex [{lib_name}]: {count} movies")
329
+
330
+ # Build indices
331
+ trakt_watched_by_imdb: dict[str, dict] = {}
332
+ trakt_watched_by_tmdb: dict[int, dict] = {}
333
+ for item in trakt_watched_movies:
334
+ if item.movie:
335
+ ids = item.movie.get("ids", {})
336
+ data = {"item": item, "movie": item.movie}
337
+ if ids.get("imdb"):
338
+ trakt_watched_by_imdb[ids["imdb"]] = data
339
+ if ids.get("tmdb"):
340
+ trakt_watched_by_tmdb[ids["tmdb"]] = data
341
+
342
+ trakt_ratings_by_imdb: dict[str, dict] = {}
343
+ trakt_ratings_by_tmdb: dict[int, dict] = {}
344
+ for item in trakt_movie_ratings:
345
+ if item.movie:
346
+ ids = item.movie.get("ids", {})
347
+ data = {"rating": item.rating, "rated_at": item.rated_at}
348
+ if ids.get("imdb"):
349
+ trakt_ratings_by_imdb[ids["imdb"]] = data
350
+ if ids.get("tmdb"):
351
+ trakt_ratings_by_tmdb[ids["tmdb"]] = data
352
+
353
+ # Free raw Trakt data
354
+ del trakt_watched_movies, trakt_movie_ratings
355
+ gc.collect()
356
+
357
+ # Process movies (deduplicate by external ID for multi-library setups)
358
+ movies_to_mark_watched_trakt: list[dict] = []
359
+ movies_to_mark_watched_plex: list[Any] = []
360
+ movies_to_rate_trakt: list[dict] = []
361
+ movies_to_rate_plex: list[tuple[Any, int]] = []
362
+ processed_movie_ids: set[str] = set()
363
+
364
+ total_movies = len(plex_movies)
365
+ self._log(f" Processing {total_movies} movies...")
366
+ processed = 0
367
+
368
+ # Update progress display every 1%, but yield to event loop more often for cancellation
369
+ update_interval = max(1, total_movies // 100)
370
+ yield_interval = max(1, min(500, total_movies // 200))
371
+
372
+ with Progress(
373
+ SpinnerColumn(), TextColumn("[progress.description]{task.description}"),
374
+ BarColumn(), TaskProgressColumn(), console=console, transient=False
375
+ ) as progress:
376
+ task = progress.add_task(f"Movies 0/{total_movies}", total=total_movies)
377
+
378
+ while plex_movies:
379
+ chunk = plex_movies[:2000]
380
+ plex_movies = plex_movies[2000:]
381
+
382
+ for plex_movie in chunk:
383
+ # Yield to event loop frequently for responsive cancellation
384
+ if processed % yield_interval == 0:
385
+ await asyncio.sleep(0)
386
+ if self._is_cancelled():
387
+ self._log(" [yellow]Cancelled[/]")
388
+ return False
389
+
390
+ # Update display less frequently (1%)
391
+ if processed % update_interval == 0:
392
+ self._progress(1, 4, 30 + (processed / total_movies) * 40, f"Movies {processed}/{total_movies}")
393
+ progress.update(task, completed=processed, description=f"Movies {processed}/{total_movies}")
394
+
395
+ plex_ids = extract_plex_ids(plex_movie)
396
+
397
+ # Skip duplicates (same movie in multiple libraries)
398
+ movie_key = plex_ids.imdb or (f"tmdb:{plex_ids.tmdb}" if plex_ids.tmdb else None)
399
+ if movie_key:
400
+ if movie_key in processed_movie_ids:
401
+ processed += 1
402
+ continue
403
+ processed_movie_ids.add(movie_key)
404
+
405
+ trakt_data = None
406
+ if plex_ids.imdb and plex_ids.imdb in trakt_watched_by_imdb:
407
+ trakt_data = trakt_watched_by_imdb[plex_ids.imdb]
408
+ elif plex_ids.tmdb and plex_ids.tmdb in trakt_watched_by_tmdb:
409
+ trakt_data = trakt_watched_by_tmdb[plex_ids.tmdb]
410
+
411
+ trakt_rating = None
412
+ if plex_ids.imdb and plex_ids.imdb in trakt_ratings_by_imdb:
413
+ trakt_rating = trakt_ratings_by_imdb[plex_ids.imdb]
414
+ elif plex_ids.tmdb and plex_ids.tmdb in trakt_ratings_by_tmdb:
415
+ trakt_rating = trakt_ratings_by_tmdb[plex_ids.tmdb]
416
+
417
+ plex_watched = plex_movie.isWatched
418
+ trakt_watched = trakt_data is not None
419
+
420
+ if plex_watched and not trakt_watched and self._get_sync_option("watched_plex_to_trakt"):
421
+ movie_data = self._build_trakt_movie(plex_movie, plex_ids)
422
+ if movie_data:
423
+ movies_to_mark_watched_trakt.append(movie_data)
424
+ elif trakt_watched and not plex_watched and self._get_sync_option("watched_trakt_to_plex"):
425
+ movies_to_mark_watched_plex.append(plex_movie)
426
+
427
+ plex_rating = int(plex_movie.userRating) if plex_movie.userRating else None
428
+ trakt_rating_val = trakt_rating["rating"] if trakt_rating else None
429
+
430
+ if plex_rating and not trakt_rating_val and self._get_sync_option("ratings_plex_to_trakt"):
431
+ movie_data = self._build_trakt_movie(plex_movie, plex_ids)
432
+ if movie_data:
433
+ movie_data["rating"] = plex_rating
434
+ movies_to_rate_trakt.append(movie_data)
435
+ elif trakt_rating_val and not plex_rating and self._get_sync_option("ratings_trakt_to_plex"):
436
+ movies_to_rate_plex.append((plex_movie, trakt_rating_val))
437
+
438
+ processed += 1
439
+
440
+ del chunk
441
+ gc.collect()
442
+
443
+ self._log(f" Movies - To mark watched on Trakt: {len(movies_to_mark_watched_trakt)}")
444
+ self._log(f" Movies - To mark watched on Plex: {len(movies_to_mark_watched_plex)}")
445
+ self._log(f" Movies - To rate on Trakt: {len(movies_to_rate_trakt)}")
446
+ self._log(f" Movies - To rate on Plex: {len(movies_to_rate_plex)}")
447
+
448
+ if self._verbose:
449
+ for m in movies_to_mark_watched_trakt:
450
+ self._log(f" [dim]→ Trakt watched: {m.get('title')} ({m.get('year')})[/]")
451
+ for m in movies_to_mark_watched_plex:
452
+ self._log(f" [dim]→ Plex watched: {m.title} ({m.year})[/]")
453
+ for m in movies_to_rate_trakt:
454
+ self._log(f" [dim]→ Trakt rating: {m.get('title')} ({m.get('year')}) = {m.get('rating')}[/]")
455
+ for m, rating in movies_to_rate_plex:
456
+ self._log(f" [dim]→ Plex rating: {m.title} ({m.year}) = {rating}[/]")
457
+
458
+ # Apply changes
459
+ if not dry_run:
460
+ self._progress(1, 4, 75, "Applying movie changes")
461
+ if movies_to_mark_watched_trakt:
462
+ self._log(f" Adding {len(movies_to_mark_watched_trakt)} movies to Trakt history...")
463
+ response = await self.trakt.add_to_history(movies=movies_to_mark_watched_trakt)
464
+ result.added_to_trakt += response.get("added", {}).get("movies", 0)
465
+
466
+ if movies_to_rate_trakt:
467
+ self._log(f" Adding {len(movies_to_rate_trakt)} movie ratings to Trakt...")
468
+ response = await self.trakt.add_ratings(movies=movies_to_rate_trakt)
469
+ result.ratings_synced += response.get("added", {}).get("movies", 0)
470
+
471
+ if movies_to_mark_watched_plex:
472
+ self._log(f" Marking {len(movies_to_mark_watched_plex)} movies watched on Plex...")
473
+ failed = self.plex.mark_watched_batch(movies_to_mark_watched_plex)
474
+ result.added_to_plex += len(movies_to_mark_watched_plex) - len(failed)
475
+
476
+ if movies_to_rate_plex:
477
+ self._log(f" Rating {len(movies_to_rate_plex)} movies on Plex...")
478
+ failed = self.plex.rate_batch(movies_to_rate_plex)
479
+ result.ratings_synced += len(movies_to_rate_plex) - len(failed)
480
+
481
+ self._progress(1, 4, 100, "Movies complete")
482
+ self._log(f" [dim]Phase 1 completed in {time.time() - phase_start:.1f}s[/]")
483
+
484
+ # Free all movie data
485
+ del movies_to_mark_watched_trakt, movies_to_mark_watched_plex
486
+ del movies_to_rate_trakt, movies_to_rate_plex
487
+ del trakt_watched_by_imdb, trakt_watched_by_tmdb
488
+ del trakt_ratings_by_imdb, trakt_ratings_by_tmdb
489
+ gc.collect()
490
+
491
+ return True
492
+
493
+ async def _sync_episodes(self, result: SyncResult, dry_run: bool) -> bool:
494
+ """Sync episodes. Returns False if cancelled."""
495
+ phase_start = time.time()
496
+ self._log("\n[cyan]Phase 2:[/] Syncing episodes...")
497
+ self._progress(2, 4, 0, "Fetching episode data")
498
+
499
+ # Fetch Trakt episode data (use cache if available)
500
+ with Progress(
501
+ SpinnerColumn(), TextColumn("[progress.description]{task.description}"),
502
+ transient=True, console=console
503
+ ) as progress:
504
+ if self._trakt_cache:
505
+ trakt_watched_shows = self._trakt_cache.watched_shows
506
+ trakt_episode_ratings_list = self._trakt_cache.episode_ratings
507
+ self._progress(2, 4, 15, "Using cached Trakt data")
508
+ else:
509
+ task = progress.add_task("Fetching Trakt watched shows...", total=None)
510
+ self._progress(2, 4, 5, "Trakt watched shows")
511
+ trakt_watched_shows = await self.trakt.get_watched_shows()
512
+ progress.update(task, description=f"Got {len(trakt_watched_shows)} watched shows")
513
+
514
+ task = progress.add_task("Fetching Trakt episode ratings...", total=None)
515
+ self._progress(2, 4, 15, "Trakt episode ratings")
516
+ trakt_episode_ratings_list = await self.trakt.get_episode_ratings()
517
+ progress.update(task, description=f"Got {len(trakt_episode_ratings_list)} episode ratings")
518
+
519
+ task = progress.add_task("Fetching Plex shows...", total=None)
520
+ self._progress(2, 4, 25, "Plex shows")
521
+ # Run in thread to not block event loop (allows web UI updates)
522
+ plex_shows, show_libs = await asyncio.to_thread(
523
+ self.plex.get_all_shows_with_counts, self._get_show_libraries()
524
+ )
525
+ progress.update(task, description=f"Got {len(plex_shows)} shows from Plex")
526
+
527
+ task = progress.add_task("Fetching Plex episodes...", total=None)
528
+ self._progress(2, 4, 30, "Plex episodes")
529
+ # Run in thread to not block event loop (allows web UI updates)
530
+ plex_episodes, episode_libs = await asyncio.to_thread(
531
+ self.plex.get_all_episodes_with_counts, self._get_show_libraries()
532
+ )
533
+ progress.update(task, description=f"Got {len(plex_episodes)} episodes from Plex")
534
+
535
+ self._log(
536
+ f" Trakt: {len(trakt_watched_shows)} watched shows, "
537
+ f"{len(trakt_episode_ratings_list)} episode ratings"
538
+ )
539
+ for lib_name, count in show_libs.items():
540
+ ep_count = episode_libs.get(lib_name, 0)
541
+ self._log(f" Plex [{lib_name}]: {count} shows, {ep_count} episodes")
542
+
543
+ # Build indices
544
+ plex_show_ids_by_key: dict[str, PlexIds] = {}
545
+ for show in plex_shows:
546
+ plex_show_ids_by_key[str(show.ratingKey)] = extract_plex_ids(show)
547
+ del plex_shows
548
+ gc.collect()
549
+
550
+ trakt_watched_episodes: dict[tuple, dict] = {}
551
+ for show_item in trakt_watched_shows:
552
+ if not show_item.show:
553
+ continue
554
+ show_ids = show_item.show.get("ids", {})
555
+ tvdb_id = show_ids.get("tvdb")
556
+ imdb_id = show_ids.get("imdb")
557
+ for season_data in show_item.seasons or []:
558
+ season_num = season_data.get("number", 0)
559
+ for ep_data in season_data.get("episodes", []):
560
+ ep_num = ep_data.get("number", 0)
561
+ data = {"show": show_item.show, "last_watched_at": ep_data.get("last_watched_at")}
562
+ if tvdb_id:
563
+ trakt_watched_episodes[(tvdb_id, season_num, ep_num)] = data
564
+ if imdb_id:
565
+ trakt_watched_episodes[(imdb_id, season_num, ep_num)] = data
566
+ del trakt_watched_shows
567
+ gc.collect()
568
+
569
+ trakt_episode_ratings: dict[tuple, dict] = {}
570
+ for item in trakt_episode_ratings_list:
571
+ if not item.episode or not item.show:
572
+ continue
573
+ show_ids = item.show.get("ids", {})
574
+ ep_data = item.episode
575
+ season_num = ep_data.get("season", 0)
576
+ ep_num = ep_data.get("number", 0)
577
+ rating_data = {"rating": item.rating, "rated_at": item.rated_at}
578
+ if show_ids.get("tvdb"):
579
+ trakt_episode_ratings[(show_ids["tvdb"], season_num, ep_num)] = rating_data
580
+ if show_ids.get("imdb"):
581
+ trakt_episode_ratings[(show_ids["imdb"], season_num, ep_num)] = rating_data
582
+ del trakt_episode_ratings_list
583
+ gc.collect()
584
+
585
+ # Process episodes in a thread to not block the event loop
586
+ total_episodes = len(plex_episodes)
587
+ self._log(f" Processing {total_episodes} episodes...")
588
+
589
+ # Create cancellation event for thread
590
+ cancel_event = threading.Event()
591
+
592
+ # Progress state for thread callback
593
+ progress_state = {"processed": 0, "total": total_episodes}
594
+
595
+ def on_progress(processed: int, total: int) -> None:
596
+ progress_state["processed"] = processed
597
+ progress_state["total"] = total
598
+
599
+ # Start processing in thread
600
+ process_task = asyncio.create_task(
601
+ asyncio.to_thread(
602
+ _process_episodes_in_thread,
603
+ plex_episodes,
604
+ plex_show_ids_by_key,
605
+ trakt_watched_episodes,
606
+ trakt_episode_ratings,
607
+ self._get_sync_option("watched_plex_to_trakt"),
608
+ self._get_sync_option("watched_trakt_to_plex"),
609
+ self._get_sync_option("ratings_plex_to_trakt"),
610
+ self._get_sync_option("ratings_trakt_to_plex"),
611
+ cancel_event,
612
+ on_progress,
613
+ )
614
+ )
615
+
616
+ # Poll for progress and cancellation while thread runs
617
+ with Progress(
618
+ SpinnerColumn(), TextColumn("[progress.description]{task.description}"),
619
+ BarColumn(), TaskProgressColumn(), console=console, transient=False
620
+ ) as progress:
621
+ task = progress.add_task(f"Episodes 0/{total_episodes}", total=total_episodes)
622
+
623
+ while not process_task.done():
624
+ # Check for cancellation
625
+ if self._is_cancelled():
626
+ cancel_event.set()
627
+ self._log(" [yellow]Cancelling...[/]")
628
+
629
+ # Update progress display
630
+ processed = progress_state["processed"]
631
+ pct = 35 + (processed / total_episodes) * 50
632
+ self._progress(2, 4, pct, f"Episodes {processed}/{total_episodes}")
633
+ progress.update(task, completed=processed, description=f"Episodes {processed}/{total_episodes}")
634
+
635
+ # Small sleep to not busy-wait
636
+ await asyncio.sleep(0.1)
637
+
638
+ # Final progress update
639
+ progress.update(task, completed=total_episodes, description=f"Episodes {total_episodes}/{total_episodes}")
640
+
641
+ # Get result from thread
642
+ ep_result = await process_task
643
+
644
+ if ep_result.cancelled:
645
+ self._log(" [yellow]Cancelled[/]")
646
+ return False
647
+
648
+ # Extract results
649
+ episodes_to_mark_watched_trakt = ep_result.episodes_to_mark_watched_trakt
650
+ episodes_to_mark_watched_trakt_display = ep_result.episodes_to_mark_watched_trakt_display
651
+ episodes_to_mark_watched_plex = ep_result.episodes_to_mark_watched_plex
652
+ episodes_to_rate_trakt = ep_result.episodes_to_rate_trakt
653
+ episodes_to_rate_trakt_display = ep_result.episodes_to_rate_trakt_display
654
+ episodes_to_rate_plex = ep_result.episodes_to_rate_plex
655
+
656
+ if ep_result.skipped_no_ids > 0:
657
+ self._log(f" [yellow]Skipped {ep_result.skipped_no_ids} episodes without show IDs[/]")
658
+
659
+ self._log(f" Episodes - To mark watched on Trakt: {len(episodes_to_mark_watched_trakt)}")
660
+ self._log(f" Episodes - To mark watched on Plex: {len(episodes_to_mark_watched_plex)}")
661
+ self._log(f" Episodes - To rate on Trakt: {len(episodes_to_rate_trakt)}")
662
+ self._log(f" Episodes - To rate on Plex: {len(episodes_to_rate_plex)}")
663
+
664
+ if self._verbose:
665
+ for display in episodes_to_mark_watched_trakt_display:
666
+ self._log(f" [dim]→ Trakt watched: {display}[/]")
667
+ for ep in episodes_to_mark_watched_plex:
668
+ ep_code = f"S{ep.seasonNumber:02d}E{ep.episodeNumber:02d}"
669
+ self._log(f" [dim]→ Plex watched: {ep.grandparentTitle} {ep_code}[/]")
670
+ for display in episodes_to_rate_trakt_display:
671
+ self._log(f" [dim]→ Trakt rating: {display}[/]")
672
+ for ep, rating in episodes_to_rate_plex:
673
+ ep_code = f"S{ep.seasonNumber:02d}E{ep.episodeNumber:02d}"
674
+ self._log(f" [dim]→ Plex rating: {ep.grandparentTitle} {ep_code} = {rating}[/]")
675
+
676
+ # Free indices before applying
677
+ del trakt_watched_episodes, trakt_episode_ratings, plex_show_ids_by_key
678
+ gc.collect()
679
+
680
+ # Apply changes
681
+ if not dry_run:
682
+ self._progress(2, 4, 90, "Applying episode changes")
683
+ if episodes_to_mark_watched_trakt:
684
+ self._log(f" Adding {len(episodes_to_mark_watched_trakt)} shows to Trakt history...")
685
+ response = await self.trakt.add_to_history(shows=episodes_to_mark_watched_trakt)
686
+ result.added_to_trakt += response.get("added", {}).get("episodes", 0)
687
+
688
+ if episodes_to_rate_trakt:
689
+ self._log(f" Adding {len(episodes_to_rate_trakt)} episode ratings to Trakt...")
690
+ response = await self.trakt.add_ratings(shows=episodes_to_rate_trakt)
691
+ result.ratings_synced += response.get("added", {}).get("episodes", 0)
692
+
693
+ if episodes_to_mark_watched_plex:
694
+ self._log(f" Marking {len(episodes_to_mark_watched_plex)} episodes watched on Plex...")
695
+ failed = self.plex.mark_watched_batch(episodes_to_mark_watched_plex)
696
+ result.added_to_plex += len(episodes_to_mark_watched_plex) - len(failed)
697
+
698
+ if episodes_to_rate_plex:
699
+ self._log(f" Rating {len(episodes_to_rate_plex)} episodes on Plex...")
700
+ failed = self.plex.rate_batch(episodes_to_rate_plex)
701
+ result.ratings_synced += len(episodes_to_rate_plex) - len(failed)
702
+
703
+ self._progress(2, 4, 100, "Episodes complete")
704
+ self._log(f" [dim]Phase 2 completed in {time.time() - phase_start:.1f}s[/]")
705
+
706
+ # Free all episode data
707
+ del episodes_to_mark_watched_trakt, episodes_to_mark_watched_plex
708
+ del episodes_to_rate_trakt, episodes_to_rate_plex
709
+ gc.collect()
710
+
711
+ return True
712
+
713
+ async def _sync_collection(self, result: SyncResult, dry_run: bool) -> bool:
714
+ """Sync Plex library to Trakt collection. Returns False if cancelled."""
715
+ if not self._get_sync_option("collection_plex_to_trakt"):
716
+ return True
717
+
718
+ phase_start = time.time()
719
+ self._log("\n[cyan]Phase 3:[/] Syncing collection...")
720
+ self._progress(3, 4, 0, "Fetching collection data")
721
+
722
+ # Check account limits first
723
+ limits = await self._get_account_limits()
724
+ if not limits.is_vip:
725
+ self._log(f" [yellow]Note: Free Trakt account (limit: {limits.collection_limit} items)[/]")
726
+
727
+ # Fetch Trakt collection (use cache if available)
728
+ with Progress(
729
+ SpinnerColumn(), TextColumn("[progress.description]{task.description}"),
730
+ transient=True, console=console
731
+ ) as progress:
732
+ if self._trakt_cache:
733
+ trakt_collection_movies = self._trakt_cache.collection_movies
734
+ trakt_collection_shows = self._trakt_cache.collection_shows
735
+ self._progress(3, 4, 10, "Using cached Trakt data")
736
+ else:
737
+ task = progress.add_task("Fetching Trakt collection...", total=None)
738
+ self._progress(3, 4, 5, "Trakt movie collection")
739
+ trakt_collection_movies = await self.trakt.get_collection_movies()
740
+ progress.update(task, description=f"Got {len(trakt_collection_movies)} collected movies")
741
+
742
+ task = progress.add_task("Fetching Trakt show collection...", total=None)
743
+ self._progress(3, 4, 10, "Trakt show collection")
744
+ trakt_collection_shows = await self.trakt.get_collection_shows()
745
+ progress.update(task, description=f"Got {len(trakt_collection_shows)} collected shows")
746
+
747
+ task = progress.add_task("Fetching Plex movies...", total=None)
748
+ self._progress(3, 4, 15, "Plex movies")
749
+ # Run in thread to not block event loop (allows web UI updates)
750
+ plex_movies = await asyncio.to_thread(
751
+ self.plex.get_all_movies, self._get_movie_libraries()
752
+ )
753
+ progress.update(task, description=f"Got {len(plex_movies)} movies")
754
+
755
+ task = progress.add_task("Fetching Plex shows...", total=None)
756
+ self._progress(3, 4, 18, "Plex shows")
757
+ # Run in thread to not block event loop (allows web UI updates)
758
+ plex_shows = await asyncio.to_thread(
759
+ self.plex.get_all_shows, self._get_show_libraries()
760
+ )
761
+ progress.update(task, description=f"Got {len(plex_shows)} shows")
762
+
763
+ task = progress.add_task("Fetching Plex episodes...", total=None)
764
+ self._progress(3, 4, 20, "Plex episodes")
765
+ # Run in thread to not block event loop (allows web UI updates)
766
+ plex_episodes = await asyncio.to_thread(
767
+ self.plex.get_all_episodes, self._get_show_libraries()
768
+ )
769
+ progress.update(task, description=f"Got {len(plex_episodes)} episodes")
770
+
771
+ current_collection_count = len(trakt_collection_movies) + len(trakt_collection_shows)
772
+ self._log(f" Trakt collection: {len(trakt_collection_movies)} movies, {len(trakt_collection_shows)} shows")
773
+ self._log(f" Plex library: {len(plex_movies)} movies, {len(plex_shows)} shows, {len(plex_episodes)} episodes")
774
+
775
+ # Warn if non-VIP and near/at limit
776
+ if not limits.is_vip and current_collection_count >= limits.collection_limit:
777
+ limit = limits.collection_limit
778
+ self._log(f" [yellow]WARNING: Collection at limit ({current_collection_count}/{limit})[/]")
779
+ self._log(" [yellow]Upgrade to Trakt VIP for unlimited collection: https://trakt.tv/vip[/]")
780
+ self._log(" [yellow]Skipping collection sync[/]")
781
+ return True
782
+
783
+ # Build indices for Trakt movie collection
784
+ collected_movies_by_imdb: set[str] = set()
785
+ collected_movies_by_tmdb: set[int] = set()
786
+
787
+ for item in trakt_collection_movies:
788
+ ids = item.get("movie", {}).get("ids", {})
789
+ if ids.get("imdb"):
790
+ collected_movies_by_imdb.add(ids["imdb"])
791
+ if ids.get("tmdb"):
792
+ collected_movies_by_tmdb.add(ids["tmdb"])
793
+
794
+ del trakt_collection_movies
795
+ gc.collect()
796
+
797
+ # Build indices for Trakt show collection (episode-level)
798
+ # Track which shows exist and which episodes are collected
799
+ collected_shows_by_imdb: set[str] = set()
800
+ collected_shows_by_tvdb: set[int] = set()
801
+ collected_episodes: set[tuple] = set() # (show_id, season, episode)
802
+
803
+ for item in trakt_collection_shows:
804
+ show = item.get("show", {})
805
+ ids = show.get("ids", {})
806
+ imdb_id = ids.get("imdb")
807
+ tvdb_id = ids.get("tvdb")
808
+
809
+ if imdb_id:
810
+ collected_shows_by_imdb.add(imdb_id)
811
+ if tvdb_id:
812
+ collected_shows_by_tvdb.add(tvdb_id)
813
+
814
+ # Track collected episodes
815
+ for season in item.get("seasons", []):
816
+ season_num = season.get("number", 0)
817
+ for ep in season.get("episodes", []):
818
+ ep_num = ep.get("number", 0)
819
+ if imdb_id:
820
+ collected_episodes.add((imdb_id, season_num, ep_num))
821
+ if tvdb_id:
822
+ collected_episodes.add((tvdb_id, season_num, ep_num))
823
+
824
+ del trakt_collection_shows
825
+ gc.collect()
826
+
827
+ # Build Plex show ID index
828
+ plex_show_ids_by_key: dict[str, PlexIds] = {}
829
+ for show in plex_shows:
830
+ plex_show_ids_by_key[str(show.ratingKey)] = extract_plex_ids(show)
831
+
832
+ # Find movies to add to collection (deduplicate by external ID)
833
+ movies_to_collect: list[dict] = []
834
+ processed_movie_ids: set[str] = set()
835
+ total_movies = len(plex_movies)
836
+ self._log(f" Processing {total_movies} movies...")
837
+
838
+ # Update progress display every 1%, but yield to event loop more often for cancellation
839
+ movie_update_interval = max(1, total_movies // 100)
840
+ movie_yield_interval = max(1, min(500, total_movies // 200))
841
+
842
+ for i, plex_movie in enumerate(plex_movies):
843
+ # Yield to event loop frequently for responsive cancellation
844
+ if i % movie_yield_interval == 0:
845
+ await asyncio.sleep(0)
846
+ if self._is_cancelled():
847
+ self._log(" [yellow]Cancelled[/]")
848
+ return False
849
+
850
+ # Update display less frequently (1%)
851
+ if i % movie_update_interval == 0:
852
+ self._progress(3, 4, 25 + (i / total_movies) * 20, f"Movies {i}/{total_movies}")
853
+
854
+ plex_ids = extract_plex_ids(plex_movie)
855
+
856
+ # Skip duplicates (same movie in multiple libraries)
857
+ movie_key = plex_ids.imdb or (f"tmdb:{plex_ids.tmdb}" if plex_ids.tmdb else None)
858
+ if movie_key:
859
+ if movie_key in processed_movie_ids:
860
+ continue
861
+ processed_movie_ids.add(movie_key)
862
+
863
+ # Check if already in collection
864
+ in_collection = False
865
+ if plex_ids.imdb and plex_ids.imdb in collected_movies_by_imdb:
866
+ in_collection = True
867
+ elif plex_ids.tmdb and plex_ids.tmdb in collected_movies_by_tmdb:
868
+ in_collection = True
869
+
870
+ if not in_collection:
871
+ movie_data = self._build_trakt_movie(plex_movie, plex_ids)
872
+ if movie_data:
873
+ # Add media metadata
874
+ metadata = extract_media_metadata(plex_movie)
875
+ movie_data.update(metadata)
876
+ movies_to_collect.append(movie_data)
877
+
878
+ del plex_movies
879
+ gc.collect()
880
+
881
+ # Process episodes - group by show and find what's missing
882
+ # Structure: {show_key: {"ids", "title", "year", "new_show", "episodes": [(s,e,title)]}}
883
+ shows_to_update: dict[str, dict] = {}
884
+ processed_episode_ids: set[tuple] = set()
885
+ total_episodes = len(plex_episodes)
886
+ self._log(f" Processing {total_episodes} episodes...")
887
+
888
+ # Update progress display every 1%, but yield to event loop more often for cancellation
889
+ episode_update_interval = max(1, total_episodes // 100)
890
+ episode_yield_interval = max(1, min(500, total_episodes // 200))
891
+
892
+ for i, episode in enumerate(plex_episodes):
893
+ # Yield to event loop frequently for responsive cancellation
894
+ if i % episode_yield_interval == 0:
895
+ await asyncio.sleep(0)
896
+ if self._is_cancelled():
897
+ self._log(" [yellow]Cancelled[/]")
898
+ return False
899
+
900
+ # Update display less frequently (1%)
901
+ if i % episode_update_interval == 0:
902
+ self._progress(3, 4, 45 + (i / total_episodes) * 30, f"Episodes {i}/{total_episodes}")
903
+
904
+ show_key = str(episode.grandparentRatingKey)
905
+ show_ids = plex_show_ids_by_key.get(show_key)
906
+ if not show_ids or (not show_ids.tvdb and not show_ids.imdb):
907
+ continue
908
+
909
+ season_num = episode.seasonNumber
910
+ ep_num = episode.episodeNumber
911
+
912
+ # Skip duplicates
913
+ ep_key = (show_ids.tvdb or show_ids.imdb, season_num, ep_num)
914
+ if ep_key in processed_episode_ids:
915
+ continue
916
+ processed_episode_ids.add(ep_key)
917
+
918
+ # Check if episode already in collection
919
+ in_collection = False
920
+ if show_ids.imdb and (show_ids.imdb, season_num, ep_num) in collected_episodes:
921
+ in_collection = True
922
+ elif show_ids.tvdb and (show_ids.tvdb, season_num, ep_num) in collected_episodes:
923
+ in_collection = True
924
+
925
+ if not in_collection:
926
+ # Determine if this is a new show or existing show with new episodes
927
+ show_in_collection = False
928
+ if show_ids.imdb and show_ids.imdb in collected_shows_by_imdb:
929
+ show_in_collection = True
930
+ elif show_ids.tvdb and show_ids.tvdb in collected_shows_by_tvdb:
931
+ show_in_collection = True
932
+
933
+ # Group episodes by show
934
+ show_data_key = show_ids.imdb or f"tvdb:{show_ids.tvdb}"
935
+ if show_data_key not in shows_to_update:
936
+ shows_to_update[show_data_key] = {
937
+ "ids": show_ids,
938
+ "title": episode.grandparentTitle,
939
+ "year": getattr(episode, "grandparentYear", None),
940
+ "new_show": not show_in_collection,
941
+ "episodes": [],
942
+ }
943
+ shows_to_update[show_data_key]["episodes"].append(
944
+ (season_num, ep_num, episode.title)
945
+ )
946
+
947
+ del plex_episodes, plex_shows, plex_show_ids_by_key
948
+ gc.collect()
949
+
950
+ # Count new shows vs shows with new episodes
951
+ new_shows = [s for s in shows_to_update.values() if s["new_show"]]
952
+ existing_shows_with_new_eps = [s for s in shows_to_update.values() if not s["new_show"]]
953
+ total_new_episodes = sum(len(s["episodes"]) for s in shows_to_update.values())
954
+
955
+ self._log(f" Collection - Movies to add: {len(movies_to_collect)}")
956
+ self._log(f" Collection - New shows to add: {len(new_shows)}")
957
+ self._log(f" Collection - Existing shows with new episodes: {len(existing_shows_with_new_eps)}")
958
+ self._log(f" Collection - Total new episodes: {total_new_episodes}")
959
+
960
+ if self._verbose:
961
+ for m in movies_to_collect:
962
+ self._log(f" [dim]→ Collection: {m.get('title')} ({m.get('year')})[/]")
963
+ # New shows - just show name
964
+ for s in new_shows:
965
+ year_str = f" ({s['year']})" if s['year'] else ""
966
+ self._log(f" [dim]→ Collection: {s['title']}{year_str}[/]")
967
+ # Existing shows - show episode details
968
+ for s in existing_shows_with_new_eps:
969
+ episodes = s["episodes"]
970
+ if len(episodes) <= 5:
971
+ ep_list = ", ".join(f"S{se:02d}E{ep:02d}" for se, ep, _ in episodes)
972
+ else:
973
+ first_5 = ", ".join(f"S{se:02d}E{ep:02d}" for se, ep, _ in episodes[:5])
974
+ ep_list = f"{first_5} (+{len(episodes)-5} more)"
975
+ self._log(f" [dim]→ Collection: {s['title']} - {ep_list}[/]")
976
+
977
+ # Build Trakt show objects with episode data
978
+ shows_to_collect: list[dict] = []
979
+ for show_data in shows_to_update.values():
980
+ ids = {}
981
+ if show_data["ids"].imdb:
982
+ ids["imdb"] = show_data["ids"].imdb
983
+ if show_data["ids"].tvdb:
984
+ ids["tvdb"] = show_data["ids"].tvdb
985
+ if not ids:
986
+ continue
987
+
988
+ # Group episodes by season
989
+ seasons_dict: dict[int, list[dict]] = {}
990
+ for season_num, ep_num, _ in show_data["episodes"]:
991
+ if season_num not in seasons_dict:
992
+ seasons_dict[season_num] = []
993
+ seasons_dict[season_num].append({"number": ep_num})
994
+
995
+ seasons = [{"number": s, "episodes": eps} for s, eps in sorted(seasons_dict.items())]
996
+
997
+ shows_to_collect.append({
998
+ "title": show_data["title"],
999
+ "year": show_data["year"],
1000
+ "ids": ids,
1001
+ "seasons": seasons,
1002
+ })
1003
+
1004
+ # Apply changes
1005
+ if not dry_run:
1006
+ self._progress(3, 4, 80, "Adding to Trakt collection")
1007
+ try:
1008
+ if movies_to_collect:
1009
+ self._log(f" Adding {len(movies_to_collect)} movies to Trakt collection...")
1010
+ response = await self.trakt.add_to_collection(movies=movies_to_collect)
1011
+ result.collection_added += response.get("added", {}).get("movies", 0)
1012
+
1013
+ if shows_to_collect:
1014
+ n_shows = len(shows_to_collect)
1015
+ self._log(f" Adding {n_shows} shows ({total_new_episodes} episodes) to Trakt collection...")
1016
+ response = await self.trakt.add_to_collection(shows=shows_to_collect)
1017
+ result.collection_added += response.get("added", {}).get("episodes", 0)
1018
+ except TraktAccountLimitError as e:
1019
+ self._log(f" [red]ERROR: {e}[/]")
1020
+ if not e.is_vip:
1021
+ self._log(f" [yellow]Upgrade to Trakt VIP for unlimited collection: {e.upgrade_url}[/]")
1022
+ result.errors.append(f"Collection limit exceeded: {e}")
1023
+
1024
+ self._progress(3, 4, 100, "Collection complete")
1025
+ self._log(f" [dim]Phase 3 completed in {time.time() - phase_start:.1f}s[/]")
1026
+
1027
+ del movies_to_collect, shows_to_collect, shows_to_update
1028
+ del collected_movies_by_imdb, collected_movies_by_tmdb
1029
+ del collected_shows_by_imdb, collected_shows_by_tvdb, collected_episodes
1030
+ gc.collect()
1031
+
1032
+ return True
1033
+
1034
+ def _build_trakt_show(self, plex_show: Any, plex_ids: Any) -> dict | None:
1035
+ """Build Trakt show object from Plex data."""
1036
+ ids = {}
1037
+ if plex_ids.imdb:
1038
+ ids["imdb"] = plex_ids.imdb
1039
+ if plex_ids.tvdb:
1040
+ ids["tvdb"] = plex_ids.tvdb
1041
+
1042
+ if not ids:
1043
+ return None
1044
+
1045
+ return {
1046
+ "title": plex_show.title,
1047
+ "year": plex_show.year,
1048
+ "ids": ids,
1049
+ }
1050
+
1051
+ async def _sync_watchlist(self, result: SyncResult, dry_run: bool) -> bool:
1052
+ """Sync watchlists between Plex and Trakt. Returns False if cancelled."""
1053
+ if not (self._get_sync_option("watchlist_plex_to_trakt") or self._get_sync_option("watchlist_trakt_to_plex")):
1054
+ return True
1055
+
1056
+ phase_start = time.time()
1057
+ self._log("\n[cyan]Phase 4:[/] Syncing watchlist...")
1058
+ self._progress(4, 4, 0, "Fetching watchlist data")
1059
+
1060
+ # Check account limits
1061
+ limits = await self._get_account_limits()
1062
+ if not limits.is_vip and self._get_sync_option("watchlist_plex_to_trakt"):
1063
+ self._log(f" [yellow]Note: Free Trakt account (limit: {limits.watchlist_limit} items)[/]")
1064
+
1065
+ # Fetch watchlists (use cache if available for Trakt)
1066
+ with Progress(
1067
+ SpinnerColumn(), TextColumn("[progress.description]{task.description}"),
1068
+ transient=True, console=console
1069
+ ) as progress:
1070
+ task = progress.add_task("Fetching Plex watchlist...", total=None)
1071
+ self._progress(4, 4, 5, "Plex watchlist")
1072
+ plex_watchlist = self.plex.get_watchlist()
1073
+ progress.update(task, description=f"Got {len(plex_watchlist)} Plex watchlist items")
1074
+
1075
+ if self._trakt_cache:
1076
+ trakt_watchlist_movies = self._trakt_cache.watchlist_movies
1077
+ trakt_watchlist_shows = self._trakt_cache.watchlist_shows
1078
+ self._progress(4, 4, 15, "Using cached Trakt data")
1079
+ else:
1080
+ task = progress.add_task("Fetching Trakt watchlist...", total=None)
1081
+ self._progress(4, 4, 15, "Trakt watchlist")
1082
+ trakt_watchlist_movies = await self.trakt.get_watchlist_movies()
1083
+ trakt_watchlist_shows = await self.trakt.get_watchlist_shows()
1084
+ n_movies, n_shows = len(trakt_watchlist_movies), len(trakt_watchlist_shows)
1085
+ progress.update(task, description=f"Got {n_movies} movies, {n_shows} shows")
1086
+
1087
+ trakt_watchlist_count = len(trakt_watchlist_movies) + len(trakt_watchlist_shows)
1088
+ self._log(f" Plex watchlist: {len(plex_watchlist)} items")
1089
+ self._log(f" Trakt watchlist: {len(trakt_watchlist_movies)} movies, {len(trakt_watchlist_shows)} shows")
1090
+
1091
+ # Check if Trakt watchlist is at limit for Plex -> Trakt sync
1092
+ skip_plex_to_trakt = False
1093
+ if not limits.is_vip and trakt_watchlist_count >= limits.watchlist_limit:
1094
+ limit = limits.watchlist_limit
1095
+ self._log(f" [yellow]WARNING: Trakt watchlist at limit ({trakt_watchlist_count}/{limit})[/]")
1096
+ self._log(" [yellow]Upgrade to Trakt VIP for unlimited watchlist: https://trakt.tv/vip[/]")
1097
+ if self._get_sync_option("watchlist_plex_to_trakt"):
1098
+ self._log(" [yellow]Skipping Plex → Trakt watchlist sync[/]")
1099
+ skip_plex_to_trakt = True
1100
+
1101
+ # Build indices for Trakt watchlist
1102
+ trakt_watchlist_by_imdb: set[str] = set()
1103
+ trakt_watchlist_by_tmdb: set[int] = set()
1104
+ trakt_watchlist_by_tvdb: set[int] = set()
1105
+
1106
+ for item in trakt_watchlist_movies:
1107
+ ids = item.get("movie", {}).get("ids", {})
1108
+ if ids.get("imdb"):
1109
+ trakt_watchlist_by_imdb.add(ids["imdb"])
1110
+ if ids.get("tmdb"):
1111
+ trakt_watchlist_by_tmdb.add(ids["tmdb"])
1112
+
1113
+ for item in trakt_watchlist_shows:
1114
+ ids = item.get("show", {}).get("ids", {})
1115
+ if ids.get("imdb"):
1116
+ trakt_watchlist_by_imdb.add(ids["imdb"])
1117
+ if ids.get("tvdb"):
1118
+ trakt_watchlist_by_tvdb.add(ids["tvdb"])
1119
+
1120
+ # Build index for Plex watchlist
1121
+ plex_watchlist_by_imdb: set[str] = set()
1122
+ plex_watchlist_by_tmdb: set[int] = set()
1123
+ plex_watchlist_by_tvdb: set[int] = set()
1124
+
1125
+ for item in plex_watchlist:
1126
+ plex_ids = extract_plex_ids(item)
1127
+ if plex_ids.imdb:
1128
+ plex_watchlist_by_imdb.add(plex_ids.imdb)
1129
+ if plex_ids.tmdb:
1130
+ plex_watchlist_by_tmdb.add(plex_ids.tmdb)
1131
+ if plex_ids.tvdb:
1132
+ plex_watchlist_by_tvdb.add(plex_ids.tvdb)
1133
+
1134
+ # Plex -> Trakt: Find items in Plex watchlist but not Trakt
1135
+ movies_to_add_trakt: list[dict] = []
1136
+ shows_to_add_trakt: list[dict] = []
1137
+
1138
+ if self._get_sync_option("watchlist_plex_to_trakt") and not skip_plex_to_trakt:
1139
+ self._progress(4, 4, 30, "Comparing Plex → Trakt")
1140
+ for item in plex_watchlist:
1141
+ if self._is_cancelled():
1142
+ self._log(" [yellow]Cancelled[/]")
1143
+ return False
1144
+
1145
+ plex_ids = extract_plex_ids(item)
1146
+
1147
+ # Check if already on Trakt watchlist
1148
+ on_trakt = False
1149
+ if plex_ids.imdb and plex_ids.imdb in trakt_watchlist_by_imdb:
1150
+ on_trakt = True
1151
+ elif plex_ids.tmdb and plex_ids.tmdb in trakt_watchlist_by_tmdb:
1152
+ on_trakt = True
1153
+ elif plex_ids.tvdb and plex_ids.tvdb in trakt_watchlist_by_tvdb:
1154
+ on_trakt = True
1155
+
1156
+ if not on_trakt:
1157
+ # Determine if movie or show
1158
+ item_type = getattr(item, "TYPE", None) or getattr(item, "type", None)
1159
+ if item_type == "movie":
1160
+ movie_data = self._build_trakt_movie(item, plex_ids)
1161
+ if movie_data:
1162
+ movies_to_add_trakt.append(movie_data)
1163
+ elif item_type == "show":
1164
+ show_data = self._build_trakt_show(item, plex_ids)
1165
+ if show_data:
1166
+ shows_to_add_trakt.append(show_data)
1167
+
1168
+ # Trakt -> Plex: Find items in Trakt watchlist but not Plex
1169
+ items_to_add_plex: list[Any] = []
1170
+
1171
+ if self._get_sync_option("watchlist_trakt_to_plex"):
1172
+ self._progress(4, 4, 50, "Comparing Trakt → Plex")
1173
+
1174
+ # Process Trakt movies
1175
+ for item in trakt_watchlist_movies:
1176
+ if self._is_cancelled():
1177
+ self._log(" [yellow]Cancelled[/]")
1178
+ return False
1179
+
1180
+ ids = item.get("movie", {}).get("ids", {})
1181
+ title = item.get("movie", {}).get("title", "")
1182
+
1183
+ # Check if already on Plex watchlist
1184
+ on_plex = False
1185
+ if ids.get("imdb") and ids["imdb"] in plex_watchlist_by_imdb:
1186
+ on_plex = True
1187
+ elif ids.get("tmdb") and ids["tmdb"] in plex_watchlist_by_tmdb:
1188
+ on_plex = True
1189
+
1190
+ if not on_plex and title:
1191
+ # Search Plex Discover for this movie
1192
+ try:
1193
+ results = self.plex.search_discover(title, libtype="movie")
1194
+ for result in results[:5]: # Check top 5 results
1195
+ result_ids = extract_plex_ids(result)
1196
+ if (ids.get("imdb") and result_ids.imdb == ids["imdb"]) or \
1197
+ (ids.get("tmdb") and result_ids.tmdb == ids["tmdb"]):
1198
+ items_to_add_plex.append(result)
1199
+ break
1200
+ except Exception as e:
1201
+ self._log(f" [yellow]Warning: Could not search for '{title}': {e}[/]")
1202
+
1203
+ # Process Trakt shows
1204
+ for item in trakt_watchlist_shows:
1205
+ if self._is_cancelled():
1206
+ self._log(" [yellow]Cancelled[/]")
1207
+ return False
1208
+
1209
+ ids = item.get("show", {}).get("ids", {})
1210
+ title = item.get("show", {}).get("title", "")
1211
+
1212
+ # Check if already on Plex watchlist
1213
+ on_plex = False
1214
+ if ids.get("imdb") and ids["imdb"] in plex_watchlist_by_imdb:
1215
+ on_plex = True
1216
+ elif ids.get("tvdb") and ids["tvdb"] in plex_watchlist_by_tvdb:
1217
+ on_plex = True
1218
+
1219
+ if not on_plex and title:
1220
+ # Search Plex Discover for this show
1221
+ try:
1222
+ results = self.plex.search_discover(title, libtype="show")
1223
+ for result in results[:5]: # Check top 5 results
1224
+ result_ids = extract_plex_ids(result)
1225
+ if (ids.get("imdb") and result_ids.imdb == ids["imdb"]) or \
1226
+ (ids.get("tvdb") and result_ids.tvdb == ids["tvdb"]):
1227
+ items_to_add_plex.append(result)
1228
+ break
1229
+ except Exception as e:
1230
+ self._log(f" [yellow]Warning: Could not search for '{title}': {e}[/]")
1231
+
1232
+ self._log(f" Watchlist - To add to Trakt: {len(movies_to_add_trakt)} movies, {len(shows_to_add_trakt)} shows")
1233
+ self._log(f" Watchlist - To add to Plex: {len(items_to_add_plex)} items")
1234
+
1235
+ if self._verbose:
1236
+ for m in movies_to_add_trakt:
1237
+ self._log(f" [dim]→ Trakt watchlist: {m.get('title')} ({m.get('year')})[/]")
1238
+ for s in shows_to_add_trakt:
1239
+ self._log(f" [dim]→ Trakt watchlist: {s.get('title')} ({s.get('year')})[/]")
1240
+ for item in items_to_add_plex:
1241
+ self._log(f" [dim]→ Plex watchlist: {item.get('title')} ({item.get('year')})[/]")
1242
+
1243
+ # Apply changes
1244
+ if not dry_run:
1245
+ self._progress(4, 4, 80, "Applying watchlist changes")
1246
+
1247
+ if movies_to_add_trakt or shows_to_add_trakt:
1248
+ self._log(" Adding to Trakt watchlist...")
1249
+ try:
1250
+ if movies_to_add_trakt:
1251
+ response = await self.trakt.add_to_watchlist(movies=movies_to_add_trakt)
1252
+ result.watchlist_added_trakt += response.get("added", {}).get("movies", 0)
1253
+ if shows_to_add_trakt:
1254
+ response = await self.trakt.add_to_watchlist(shows=shows_to_add_trakt)
1255
+ result.watchlist_added_trakt += response.get("added", {}).get("shows", 0)
1256
+ except TraktAccountLimitError as e:
1257
+ self._log(f" [red]ERROR: {e}[/]")
1258
+ if not e.is_vip:
1259
+ self._log(f" [yellow]Upgrade to Trakt VIP for unlimited watchlist: {e.upgrade_url}[/]")
1260
+ result.errors.append(f"Watchlist limit exceeded: {e}")
1261
+
1262
+ for item in items_to_add_plex:
1263
+ try:
1264
+ self.plex.add_to_watchlist(item)
1265
+ result.watchlist_added_plex += 1
1266
+ except Exception as e:
1267
+ self._log(f" [yellow]Warning: Could not add to Plex watchlist: {e}[/]")
1268
+
1269
+ self._progress(4, 4, 100, "Watchlist complete")
1270
+ self._log(f" [dim]Phase 4 completed in {time.time() - phase_start:.1f}s[/]")
1271
+
1272
+ # Cleanup
1273
+ del plex_watchlist, trakt_watchlist_movies, trakt_watchlist_shows
1274
+ del trakt_watchlist_by_imdb, trakt_watchlist_by_tmdb, trakt_watchlist_by_tvdb
1275
+ del plex_watchlist_by_imdb, plex_watchlist_by_tmdb, plex_watchlist_by_tvdb
1276
+ gc.collect()
1277
+
1278
+ return True
1279
+
1280
+ async def sync(self, dry_run: bool = False) -> SyncResult | None:
1281
+ """Run full sync."""
1282
+ start_time = time.time()
1283
+ result = SyncResult()
1284
+
1285
+ logger = get_file_logger()
1286
+ logger.info("=" * 60)
1287
+ logger.info(f"SYNC STARTED - dry_run={dry_run}")
1288
+ logger.info("=" * 60)
1289
+
1290
+ self._log("[bold]Starting Pakt sync...[/]")
1291
+ self._log(f" Mode: {'Dry run' if dry_run else 'Live sync'}")
1292
+
1293
+ # Sync movies (fetch, compare, apply, free)
1294
+ if not await self._sync_movies(result, dry_run):
1295
+ return None # Cancelled
1296
+
1297
+ # Sync episodes (fetch, compare, apply, free)
1298
+ if not await self._sync_episodes(result, dry_run):
1299
+ return None # Cancelled
1300
+
1301
+ # Sync collection (Plex library -> Trakt collection)
1302
+ if not await self._sync_collection(result, dry_run):
1303
+ return None # Cancelled
1304
+
1305
+ # Sync watchlist (bidirectional)
1306
+ if not await self._sync_watchlist(result, dry_run):
1307
+ return None # Cancelled
1308
+
1309
+ if dry_run:
1310
+ self._log("\n[yellow]Dry run complete - no changes applied[/]")
1311
+ else:
1312
+ self._log("\n[green]Sync complete![/]")
1313
+
1314
+ result.duration_seconds = time.time() - start_time
1315
+ return result
1316
+
1317
+ def _build_trakt_movie(self, plex_movie: Any, plex_ids: Any) -> dict | None:
1318
+ """Build Trakt movie object from Plex data."""
1319
+ ids = {}
1320
+ if plex_ids.imdb:
1321
+ ids["imdb"] = plex_ids.imdb
1322
+ if plex_ids.tmdb:
1323
+ ids["tmdb"] = plex_ids.tmdb
1324
+
1325
+ if not ids:
1326
+ return None
1327
+
1328
+ return {
1329
+ "title": plex_movie.title,
1330
+ "year": plex_movie.year,
1331
+ "ids": ids,
1332
+ }
1333
+
1334
+
1335
+ async def run_multi_server_sync(
1336
+ config: Config,
1337
+ server_names: list[str] | None = None,
1338
+ dry_run: bool = False,
1339
+ verbose: bool = False,
1340
+ on_token_refresh: Callable[[dict], None] | None = None,
1341
+ log_callback: Callable[[str], None] | None = None,
1342
+ cancel_check: Callable[[], bool] | None = None,
1343
+ ) -> SyncResult:
1344
+ """Run sync across multiple Plex servers.
1345
+
1346
+ Args:
1347
+ config: Main configuration
1348
+ server_names: Optional list of server names to sync. If None, syncs all enabled servers.
1349
+ dry_run: If True, don't make changes
1350
+ verbose: Show detailed output
1351
+ on_token_refresh: Callback when Trakt token is refreshed
1352
+ log_callback: Callback for log messages
1353
+ cancel_check: Callback to check if sync was cancelled
1354
+
1355
+ Returns:
1356
+ Aggregated SyncResult from all servers
1357
+ """
1358
+ def log(msg: str):
1359
+ if log_callback:
1360
+ log_callback(msg)
1361
+
1362
+ # Determine which servers to sync
1363
+ if server_names:
1364
+ servers_to_sync = []
1365
+ for name in server_names:
1366
+ server = config.get_server(name)
1367
+ if server:
1368
+ servers_to_sync.append(server)
1369
+ else:
1370
+ log(f"WARNING:Server '{name}' not found in configuration")
1371
+ else:
1372
+ servers_to_sync = config.get_enabled_servers()
1373
+
1374
+ if not servers_to_sync:
1375
+ log("ERROR:No servers configured or enabled for sync")
1376
+ return SyncResult()
1377
+
1378
+ # Aggregate results across all servers
1379
+ total_result = SyncResult()
1380
+ start_time = time.time()
1381
+ server_count = len(servers_to_sync)
1382
+
1383
+ log(f"[bold]Starting sync across {server_count} server(s)...[/]")
1384
+
1385
+ async with TraktClient(config.trakt, on_token_refresh=on_token_refresh) as trakt:
1386
+ # Pre-fetch all Trakt data once for multi-server efficiency
1387
+ trakt_cache: TraktCache | None = None
1388
+ if server_count > 1:
1389
+ log("[bold]Pre-fetching Trakt data for all servers...[/]")
1390
+ with Progress(
1391
+ SpinnerColumn(), TextColumn("[progress.description]{task.description}"),
1392
+ transient=True, console=console
1393
+ ) as progress:
1394
+ task = progress.add_task("Fetching account limits...", total=None)
1395
+ account_limits = await trakt.get_account_limits()
1396
+
1397
+ task = progress.add_task("Fetching watched movies...", total=None)
1398
+ watched_movies = await trakt.get_watched_movies()
1399
+ progress.update(task, description=f"Got {len(watched_movies)} watched movies")
1400
+
1401
+ task = progress.add_task("Fetching movie ratings...", total=None)
1402
+ movie_ratings = await trakt.get_movie_ratings()
1403
+ progress.update(task, description=f"Got {len(movie_ratings)} movie ratings")
1404
+
1405
+ task = progress.add_task("Fetching watched shows...", total=None)
1406
+ watched_shows = await trakt.get_watched_shows()
1407
+ progress.update(task, description=f"Got {len(watched_shows)} watched shows")
1408
+
1409
+ task = progress.add_task("Fetching episode ratings...", total=None)
1410
+ episode_ratings = await trakt.get_episode_ratings()
1411
+ progress.update(task, description=f"Got {len(episode_ratings)} episode ratings")
1412
+
1413
+ task = progress.add_task("Fetching collection...", total=None)
1414
+ collection_movies = await trakt.get_collection_movies()
1415
+ collection_shows = await trakt.get_collection_shows()
1416
+ progress.update(task, description=f"Got {len(collection_movies)} movies, {len(collection_shows)} shows")
1417
+
1418
+ task = progress.add_task("Fetching watchlist...", total=None)
1419
+ watchlist_movies = await trakt.get_watchlist_movies()
1420
+ watchlist_shows = await trakt.get_watchlist_shows()
1421
+ progress.update(task, description=f"Got {len(watchlist_movies)} movies, {len(watchlist_shows)} shows")
1422
+
1423
+ trakt_cache = TraktCache(
1424
+ account_limits=account_limits,
1425
+ watched_movies=watched_movies,
1426
+ movie_ratings=movie_ratings,
1427
+ watched_shows=watched_shows,
1428
+ episode_ratings=episode_ratings,
1429
+ collection_movies=collection_movies,
1430
+ collection_shows=collection_shows,
1431
+ watchlist_movies=watchlist_movies,
1432
+ watchlist_shows=watchlist_shows,
1433
+ )
1434
+ log(f" Cached: {len(watched_movies)} movies, {len(watched_shows)} shows, "
1435
+ f"{len(collection_movies)} collection")
1436
+
1437
+ for idx, server_config in enumerate(servers_to_sync, 1):
1438
+ if cancel_check and cancel_check():
1439
+ log("WARNING:Sync cancelled")
1440
+ break
1441
+
1442
+ log(f"\n[bold cyan]═══ Server {idx}/{server_count}: {server_config.name} ═══[/]")
1443
+
1444
+ try:
1445
+ # Create PlexClient from server config
1446
+ plex = PlexClient(server_config)
1447
+ plex.connect()
1448
+ log(f"Connected to: {plex.server.friendlyName}")
1449
+
1450
+ # Create SyncEngine with server context and shared cache
1451
+ engine = SyncEngine(
1452
+ config, trakt, plex,
1453
+ log_callback=log_callback,
1454
+ cancel_check=cancel_check,
1455
+ verbose=verbose,
1456
+ server_name=server_config.name,
1457
+ server_config=server_config,
1458
+ trakt_cache=trakt_cache,
1459
+ )
1460
+
1461
+ # Run sync for this server
1462
+ result = await engine.sync(dry_run=dry_run)
1463
+
1464
+ if result:
1465
+ # Aggregate results
1466
+ total_result.added_to_trakt += result.added_to_trakt
1467
+ total_result.added_to_plex += result.added_to_plex
1468
+ total_result.ratings_synced += result.ratings_synced
1469
+ total_result.collection_added += result.collection_added
1470
+ total_result.watchlist_added_trakt += result.watchlist_added_trakt
1471
+ total_result.watchlist_added_plex += result.watchlist_added_plex
1472
+ total_result.errors.extend(result.errors)
1473
+
1474
+ except Exception as e:
1475
+ error_msg = f"[{server_config.name}] Error: {e}"
1476
+ log(f"ERROR:{error_msg}")
1477
+ total_result.errors.append(error_msg)
1478
+ # Continue with next server instead of failing entirely
1479
+
1480
+ total_result.duration_seconds = time.time() - start_time
1481
+
1482
+ if server_count > 1:
1483
+ log("\n[bold green]═══ Multi-Server Sync Complete ═══[/]")
1484
+ log(f" Servers synced: {server_count}")
1485
+ log(f" Total added to Trakt: {total_result.added_to_trakt}")
1486
+ log(f" Total added to Plex: {total_result.added_to_plex}")
1487
+ log(f" Total ratings synced: {total_result.ratings_synced}")
1488
+ log(f" Duration: {total_result.duration_seconds:.1f}s")
1489
+
1490
+ return total_result