pakt 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pakt/__init__.py +3 -0
- pakt/__main__.py +6 -0
- pakt/assets/icon.png +0 -0
- pakt/assets/icon.svg +10 -0
- pakt/assets/logo.png +0 -0
- pakt/cli.py +814 -0
- pakt/config.py +222 -0
- pakt/models.py +109 -0
- pakt/plex.py +758 -0
- pakt/scheduler.py +153 -0
- pakt/sync.py +1490 -0
- pakt/trakt.py +575 -0
- pakt/tray.py +137 -0
- pakt/web/__init__.py +5 -0
- pakt/web/app.py +991 -0
- pakt/web/templates/index.html +2327 -0
- pakt-0.2.1.dist-info/METADATA +207 -0
- pakt-0.2.1.dist-info/RECORD +20 -0
- pakt-0.2.1.dist-info/WHEEL +4 -0
- pakt-0.2.1.dist-info/entry_points.txt +2 -0
pakt/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
|