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/plex.py
ADDED
|
@@ -0,0 +1,758 @@
|
|
|
1
|
+
"""Plex API client wrapper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Iterator, Sequence
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from plexapi.myplex import MyPlexAccount, MyPlexPinLogin
|
|
11
|
+
from plexapi.server import PlexServer
|
|
12
|
+
from plexapi.video import Episode, Movie, Show
|
|
13
|
+
|
|
14
|
+
from pakt.config import ServerConfig
|
|
15
|
+
from pakt.models import MediaItem, MediaType, PlexIds
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _disable_auto_reload(items: list) -> list:
|
|
21
|
+
"""Disable PlexAPI auto-reload on items to prevent network calls for None attributes."""
|
|
22
|
+
for item in items:
|
|
23
|
+
item._autoReload = False
|
|
24
|
+
return items
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class PlexPinAuth:
|
|
29
|
+
"""Plex PIN authentication state."""
|
|
30
|
+
|
|
31
|
+
pin: str
|
|
32
|
+
pin_id: int
|
|
33
|
+
verification_url: str = "https://plex.tv/link"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def start_plex_pin_login() -> tuple[MyPlexPinLogin, PlexPinAuth]:
|
|
37
|
+
"""Start Plex PIN login flow.
|
|
38
|
+
|
|
39
|
+
Returns the login object (for polling) and auth info (to display to user).
|
|
40
|
+
"""
|
|
41
|
+
login = MyPlexPinLogin()
|
|
42
|
+
|
|
43
|
+
# Explicitly trigger PIN fetch if not already done
|
|
44
|
+
# The pin property should call _getCode() but let's be explicit
|
|
45
|
+
if hasattr(login, '_getCode'):
|
|
46
|
+
login._getCode()
|
|
47
|
+
|
|
48
|
+
pin_code = getattr(login, '_code', None) or getattr(login, 'pin', None)
|
|
49
|
+
pin_id = getattr(login, '_id', None)
|
|
50
|
+
|
|
51
|
+
if not pin_code:
|
|
52
|
+
# Try accessing the pin property which might trigger the fetch
|
|
53
|
+
try:
|
|
54
|
+
pin_code = login.pin
|
|
55
|
+
except Exception as e:
|
|
56
|
+
raise RuntimeError(f"Failed to get PIN code from Plex: {e}")
|
|
57
|
+
|
|
58
|
+
if not pin_code:
|
|
59
|
+
raise RuntimeError("Failed to get PIN code from Plex - API returned empty response")
|
|
60
|
+
|
|
61
|
+
return login, PlexPinAuth(
|
|
62
|
+
pin=str(pin_code),
|
|
63
|
+
pin_id=int(pin_id) if pin_id else 0,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def check_plex_pin_login(login: MyPlexPinLogin) -> str | None:
|
|
68
|
+
"""Check if PIN login has been authorized.
|
|
69
|
+
|
|
70
|
+
Returns the permanent account token if authorized, None if still pending.
|
|
71
|
+
The initial PIN login may return a temporary token, so we exchange it
|
|
72
|
+
for a permanent one via MyPlexAccount.
|
|
73
|
+
"""
|
|
74
|
+
if login.checkLogin():
|
|
75
|
+
# The PIN login token may be temporary - exchange it for permanent token
|
|
76
|
+
# by creating a MyPlexAccount which fetches the account's auth token
|
|
77
|
+
temp_token = login.token
|
|
78
|
+
try:
|
|
79
|
+
account = MyPlexAccount(token=temp_token)
|
|
80
|
+
# authenticationToken is the permanent account token
|
|
81
|
+
return account.authenticationToken
|
|
82
|
+
except Exception:
|
|
83
|
+
# Fall back to the PIN token if exchange fails
|
|
84
|
+
return temp_token
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class DiscoveredServer:
|
|
90
|
+
"""A Plex server discovered from the user's account."""
|
|
91
|
+
|
|
92
|
+
name: str
|
|
93
|
+
client_identifier: str
|
|
94
|
+
provides: str # "server" for media servers
|
|
95
|
+
owned: bool
|
|
96
|
+
connections: list[dict] # List of {uri, local, relay} dicts
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def has_local_connection(self) -> bool:
|
|
100
|
+
return any(c.get("local") for c in self.connections)
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def best_connection_url(self) -> str | None:
|
|
104
|
+
"""Get the best connection URL (prefer local, non-relay)."""
|
|
105
|
+
# Prefer local non-relay connections
|
|
106
|
+
for conn in self.connections:
|
|
107
|
+
if conn.get("local") and not conn.get("relay"):
|
|
108
|
+
return conn.get("uri")
|
|
109
|
+
# Then non-relay
|
|
110
|
+
for conn in self.connections:
|
|
111
|
+
if not conn.get("relay"):
|
|
112
|
+
return conn.get("uri")
|
|
113
|
+
# Fall back to any connection
|
|
114
|
+
if self.connections:
|
|
115
|
+
return self.connections[0].get("uri")
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def discover_servers(account_token: str) -> list[DiscoveredServer]:
|
|
120
|
+
"""Discover all Plex servers accessible with the given account token."""
|
|
121
|
+
account = MyPlexAccount(token=account_token)
|
|
122
|
+
servers = []
|
|
123
|
+
|
|
124
|
+
for resource in account.resources():
|
|
125
|
+
# Only include actual servers
|
|
126
|
+
if "server" not in resource.provides:
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
connections = []
|
|
130
|
+
for conn in resource.connections:
|
|
131
|
+
connections.append({
|
|
132
|
+
"uri": conn.uri,
|
|
133
|
+
"local": conn.local,
|
|
134
|
+
"relay": conn.relay,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
servers.append(DiscoveredServer(
|
|
138
|
+
name=resource.name,
|
|
139
|
+
client_identifier=resource.clientIdentifier,
|
|
140
|
+
provides=resource.provides,
|
|
141
|
+
owned=resource.owned,
|
|
142
|
+
connections=connections,
|
|
143
|
+
))
|
|
144
|
+
|
|
145
|
+
return servers
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_server_connection(account_token: str, server_name: str) -> tuple[bool, str]:
|
|
149
|
+
"""Test connection to a specific server.
|
|
150
|
+
|
|
151
|
+
Returns (success, message).
|
|
152
|
+
"""
|
|
153
|
+
try:
|
|
154
|
+
account = MyPlexAccount(token=account_token)
|
|
155
|
+
resource = account.resource(server_name)
|
|
156
|
+
server = resource.connect()
|
|
157
|
+
return True, f"Connected to {server.friendlyName}"
|
|
158
|
+
except Exception as e:
|
|
159
|
+
return False, str(e)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class PlexClient:
|
|
163
|
+
"""Plex API client optimized for batch operations."""
|
|
164
|
+
|
|
165
|
+
def __init__(self, server_config: ServerConfig):
|
|
166
|
+
"""Initialize client with server configuration."""
|
|
167
|
+
self._url = server_config.url
|
|
168
|
+
self._token = server_config.token
|
|
169
|
+
self._server_name = server_config.server_name
|
|
170
|
+
self.server_config = server_config
|
|
171
|
+
self._server: PlexServer | None = None
|
|
172
|
+
self._account: MyPlexAccount | None = None
|
|
173
|
+
|
|
174
|
+
def connect(self) -> None:
|
|
175
|
+
"""Connect to Plex server."""
|
|
176
|
+
if self._url and self._token:
|
|
177
|
+
self._server = PlexServer(self._url, self._token)
|
|
178
|
+
elif self._token and self._server_name:
|
|
179
|
+
account = MyPlexAccount(token=self._token)
|
|
180
|
+
self._server = account.resource(self._server_name).connect()
|
|
181
|
+
else:
|
|
182
|
+
raise ValueError("Need either URL+token or token+server_name")
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def account(self) -> MyPlexAccount:
|
|
186
|
+
"""Get MyPlex account for watchlist operations."""
|
|
187
|
+
if self._account is None:
|
|
188
|
+
self._account = MyPlexAccount(token=self._token)
|
|
189
|
+
return self._account
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def server(self) -> PlexServer:
|
|
193
|
+
if not self._server:
|
|
194
|
+
self.connect()
|
|
195
|
+
return self._server
|
|
196
|
+
|
|
197
|
+
def get_movie_libraries(self) -> list[str]:
|
|
198
|
+
"""Get all movie library names."""
|
|
199
|
+
return [lib.title for lib in self.server.library.sections() if lib.type == "movie"]
|
|
200
|
+
|
|
201
|
+
def get_show_libraries(self) -> list[str]:
|
|
202
|
+
"""Get all TV show library names."""
|
|
203
|
+
return [lib.title for lib in self.server.library.sections() if lib.type == "show"]
|
|
204
|
+
|
|
205
|
+
def get_all_movies(self, library_names: list[str] | None = None) -> list[Movie]:
|
|
206
|
+
"""Get all movies from specified libraries."""
|
|
207
|
+
movies, _ = self.get_all_movies_with_counts(library_names)
|
|
208
|
+
return movies
|
|
209
|
+
|
|
210
|
+
def get_all_movies_with_counts(self, library_names: list[str] | None = None) -> tuple[list[Movie], dict[str, int]]:
|
|
211
|
+
"""Get all movies from specified libraries with per-library counts."""
|
|
212
|
+
movies = []
|
|
213
|
+
lib_counts: dict[str, int] = {}
|
|
214
|
+
for section in self.server.library.sections():
|
|
215
|
+
if section.type != "movie":
|
|
216
|
+
continue
|
|
217
|
+
if library_names and section.title not in library_names:
|
|
218
|
+
continue
|
|
219
|
+
# Use large container_size to reduce HTTP requests
|
|
220
|
+
section_movies = section.all(container_size=1000)
|
|
221
|
+
_disable_auto_reload(section_movies)
|
|
222
|
+
# Handle duplicate library names by appending count
|
|
223
|
+
key = section.title
|
|
224
|
+
if key in lib_counts:
|
|
225
|
+
i = 2
|
|
226
|
+
while f"{section.title} ({i})" in lib_counts:
|
|
227
|
+
i += 1
|
|
228
|
+
key = f"{section.title} ({i})"
|
|
229
|
+
lib_counts[key] = len(section_movies)
|
|
230
|
+
movies.extend(section_movies)
|
|
231
|
+
return movies, lib_counts
|
|
232
|
+
|
|
233
|
+
def get_all_shows(self, library_names: list[str] | None = None) -> list[Show]:
|
|
234
|
+
"""Get all shows from specified libraries."""
|
|
235
|
+
shows, _ = self.get_all_shows_with_counts(library_names)
|
|
236
|
+
return shows
|
|
237
|
+
|
|
238
|
+
def get_all_shows_with_counts(self, library_names: list[str] | None = None) -> tuple[list[Show], dict[str, int]]:
|
|
239
|
+
"""Get all shows from specified libraries with per-library counts."""
|
|
240
|
+
shows = []
|
|
241
|
+
lib_counts: dict[str, int] = {}
|
|
242
|
+
for section in self.server.library.sections():
|
|
243
|
+
if section.type != "show":
|
|
244
|
+
continue
|
|
245
|
+
if library_names and section.title not in library_names:
|
|
246
|
+
continue
|
|
247
|
+
# Use large container_size to reduce HTTP requests
|
|
248
|
+
section_shows = section.all(container_size=1000)
|
|
249
|
+
_disable_auto_reload(section_shows)
|
|
250
|
+
# Handle duplicate library names
|
|
251
|
+
key = section.title
|
|
252
|
+
if key in lib_counts:
|
|
253
|
+
i = 2
|
|
254
|
+
while f"{section.title} ({i})" in lib_counts:
|
|
255
|
+
i += 1
|
|
256
|
+
key = f"{section.title} ({i})"
|
|
257
|
+
lib_counts[key] = len(section_shows)
|
|
258
|
+
shows.extend(section_shows)
|
|
259
|
+
return shows, lib_counts
|
|
260
|
+
|
|
261
|
+
def get_all_episodes(self, library_names: list[str] | None = None) -> list[Episode]:
|
|
262
|
+
"""Get ALL episodes from specified libraries in a single batch per library."""
|
|
263
|
+
episodes = []
|
|
264
|
+
for section in self.server.library.sections():
|
|
265
|
+
if section.type != "show":
|
|
266
|
+
continue
|
|
267
|
+
if library_names and section.title not in library_names:
|
|
268
|
+
continue
|
|
269
|
+
# Batch fetch all episodes - use large container_size to reduce HTTP requests
|
|
270
|
+
section_episodes = section.searchEpisodes(container_size=1000)
|
|
271
|
+
_disable_auto_reload(section_episodes)
|
|
272
|
+
episodes.extend(section_episodes)
|
|
273
|
+
return episodes
|
|
274
|
+
|
|
275
|
+
def get_all_episodes_with_counts(
|
|
276
|
+
self, library_names: list[str] | None = None
|
|
277
|
+
) -> tuple[list[Episode], dict[str, int]]:
|
|
278
|
+
"""Get ALL episodes from specified libraries with per-library counts."""
|
|
279
|
+
episodes = []
|
|
280
|
+
lib_counts: dict[str, int] = {}
|
|
281
|
+
for section in self.server.library.sections():
|
|
282
|
+
if section.type != "show":
|
|
283
|
+
continue
|
|
284
|
+
if library_names and section.title not in library_names:
|
|
285
|
+
continue
|
|
286
|
+
# Batch fetch all episodes - use large container_size to reduce HTTP requests
|
|
287
|
+
section_episodes = section.searchEpisodes(container_size=1000)
|
|
288
|
+
_disable_auto_reload(section_episodes)
|
|
289
|
+
# Handle duplicate library names
|
|
290
|
+
key = section.title
|
|
291
|
+
if key in lib_counts:
|
|
292
|
+
i = 2
|
|
293
|
+
while f"{section.title} ({i})" in lib_counts:
|
|
294
|
+
i += 1
|
|
295
|
+
key = f"{section.title} ({i})"
|
|
296
|
+
lib_counts[key] = len(section_episodes)
|
|
297
|
+
episodes.extend(section_episodes)
|
|
298
|
+
return episodes, lib_counts
|
|
299
|
+
|
|
300
|
+
def iter_movies_by_library(self, library_names: list[str] | None = None) -> Iterator[tuple[str, list[Movie]]]:
|
|
301
|
+
"""Yield movies one library at a time for memory efficiency."""
|
|
302
|
+
for section in self.server.library.sections():
|
|
303
|
+
if section.type != "movie":
|
|
304
|
+
continue
|
|
305
|
+
if library_names and section.title not in library_names:
|
|
306
|
+
continue
|
|
307
|
+
yield section.title, section.all()
|
|
308
|
+
|
|
309
|
+
def iter_episodes_by_library(self, library_names: list[str] | None = None) -> Iterator[tuple[str, list[Episode]]]:
|
|
310
|
+
"""Yield episodes one library at a time for memory efficiency."""
|
|
311
|
+
for section in self.server.library.sections():
|
|
312
|
+
if section.type != "show":
|
|
313
|
+
continue
|
|
314
|
+
if library_names and section.title not in library_names:
|
|
315
|
+
continue
|
|
316
|
+
yield section.title, section.searchEpisodes()
|
|
317
|
+
|
|
318
|
+
def get_watched_movies(self, library_names: list[str] | None = None) -> list[Movie]:
|
|
319
|
+
"""Get all watched movies."""
|
|
320
|
+
movies = []
|
|
321
|
+
for section in self.server.library.sections():
|
|
322
|
+
if section.type != "movie":
|
|
323
|
+
continue
|
|
324
|
+
if library_names and section.title not in library_names:
|
|
325
|
+
continue
|
|
326
|
+
movies.extend(section.search(unwatched=False))
|
|
327
|
+
return movies
|
|
328
|
+
|
|
329
|
+
def get_watched_episodes(self, library_names: list[str] | None = None) -> list[Episode]:
|
|
330
|
+
"""Get all watched episodes."""
|
|
331
|
+
episodes = []
|
|
332
|
+
for section in self.server.library.sections():
|
|
333
|
+
if section.type != "show":
|
|
334
|
+
continue
|
|
335
|
+
if library_names and section.title not in library_names:
|
|
336
|
+
continue
|
|
337
|
+
# Get all episodes that are watched
|
|
338
|
+
for show in section.all():
|
|
339
|
+
for episode in show.episodes():
|
|
340
|
+
if episode.isWatched:
|
|
341
|
+
episodes.append(episode)
|
|
342
|
+
return episodes
|
|
343
|
+
|
|
344
|
+
def mark_watched(self, item: Movie | Episode) -> None:
|
|
345
|
+
"""Mark an item as watched."""
|
|
346
|
+
item.markWatched()
|
|
347
|
+
|
|
348
|
+
def mark_unwatched(self, item: Movie | Episode) -> None:
|
|
349
|
+
"""Mark an item as unwatched."""
|
|
350
|
+
item.markUnwatched()
|
|
351
|
+
|
|
352
|
+
def set_rating(self, item: Movie | Show | Episode, rating: float) -> None:
|
|
353
|
+
"""Set rating for an item (1-10 scale)."""
|
|
354
|
+
item.rate(rating)
|
|
355
|
+
|
|
356
|
+
def mark_watched_batch(
|
|
357
|
+
self, items: Sequence[Movie | Episode], max_workers: int = 10
|
|
358
|
+
) -> list[tuple[Movie | Episode, Exception]]:
|
|
359
|
+
"""Mark multiple items as watched concurrently.
|
|
360
|
+
|
|
361
|
+
Returns list of (item, error) tuples for any failures.
|
|
362
|
+
"""
|
|
363
|
+
if not items:
|
|
364
|
+
return []
|
|
365
|
+
|
|
366
|
+
failed: list[tuple[Movie | Episode, Exception]] = []
|
|
367
|
+
|
|
368
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
369
|
+
future_to_item = {
|
|
370
|
+
executor.submit(item.markWatched): item
|
|
371
|
+
for item in items
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
for future in as_completed(future_to_item):
|
|
375
|
+
item = future_to_item[future]
|
|
376
|
+
try:
|
|
377
|
+
future.result()
|
|
378
|
+
except Exception as e:
|
|
379
|
+
failed.append((item, e))
|
|
380
|
+
logger.warning(f"Failed to mark watched: {item.title} - {e}")
|
|
381
|
+
|
|
382
|
+
if failed:
|
|
383
|
+
logger.error(f"Batch mark watched: {len(failed)}/{len(items)} failed")
|
|
384
|
+
|
|
385
|
+
return failed
|
|
386
|
+
|
|
387
|
+
def rate_batch(
|
|
388
|
+
self, items: Sequence[tuple[Movie | Show | Episode, int | float]], max_workers: int = 10
|
|
389
|
+
) -> list[tuple[Movie | Show | Episode, int | float, Exception]]:
|
|
390
|
+
"""Rate multiple items concurrently.
|
|
391
|
+
|
|
392
|
+
Args:
|
|
393
|
+
items: List of (item, rating) tuples
|
|
394
|
+
|
|
395
|
+
Returns list of (item, rating, error) tuples for any failures.
|
|
396
|
+
"""
|
|
397
|
+
if not items:
|
|
398
|
+
return []
|
|
399
|
+
|
|
400
|
+
failed: list[tuple[Movie | Show | Episode, int | float, Exception]] = []
|
|
401
|
+
|
|
402
|
+
def rate_item(pair: tuple[Movie | Show | Episode, int | float]) -> None:
|
|
403
|
+
item, rating = pair
|
|
404
|
+
item.rate(rating)
|
|
405
|
+
|
|
406
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
407
|
+
future_to_item = {
|
|
408
|
+
executor.submit(rate_item, pair): pair
|
|
409
|
+
for pair in items
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
for future in as_completed(future_to_item):
|
|
413
|
+
item, rating = future_to_item[future]
|
|
414
|
+
try:
|
|
415
|
+
future.result()
|
|
416
|
+
except Exception as e:
|
|
417
|
+
failed.append((item, rating, e))
|
|
418
|
+
logger.warning(f"Failed to rate: {item.title} ({rating}) - {e}")
|
|
419
|
+
|
|
420
|
+
if failed:
|
|
421
|
+
logger.error(f"Batch rate: {len(failed)}/{len(items)} failed")
|
|
422
|
+
|
|
423
|
+
return failed
|
|
424
|
+
|
|
425
|
+
def get_watchlist(self) -> list[Movie | Show]:
|
|
426
|
+
"""Get account watchlist (includes items not in library)."""
|
|
427
|
+
return self.account.watchlist()
|
|
428
|
+
|
|
429
|
+
def add_to_watchlist(self, item: Movie | Show) -> None:
|
|
430
|
+
"""Add item to account watchlist."""
|
|
431
|
+
self.account.addToWatchlist(item)
|
|
432
|
+
|
|
433
|
+
def remove_from_watchlist(self, item: Movie | Show) -> None:
|
|
434
|
+
"""Remove item from account watchlist."""
|
|
435
|
+
self.account.removeFromWatchlist(item)
|
|
436
|
+
|
|
437
|
+
def search_discover(self, query: str, libtype: str | None = None) -> list[Movie | Show]:
|
|
438
|
+
"""Search Plex Discover for items not in library."""
|
|
439
|
+
return self.account.searchDiscover(query, libtype=libtype)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
# Resolution mapping: Plex videoResolution -> Trakt resolution
|
|
443
|
+
RESOLUTION_MAP = {
|
|
444
|
+
"4k": "uhd_4k",
|
|
445
|
+
"1080": "hd_1080p",
|
|
446
|
+
"720": "hd_720p",
|
|
447
|
+
"576": "sd_576p",
|
|
448
|
+
"480": "sd_480p",
|
|
449
|
+
"sd": "sd_480p",
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
# Resolution ranking for scoring (higher = better)
|
|
453
|
+
RESOLUTION_RANK = {
|
|
454
|
+
"uhd_4k": 5,
|
|
455
|
+
"hd_1080p": 4,
|
|
456
|
+
"hd_1080i": 4,
|
|
457
|
+
"hd_720p": 3,
|
|
458
|
+
"sd_576p": 2,
|
|
459
|
+
"sd_576i": 2,
|
|
460
|
+
"sd_480p": 1,
|
|
461
|
+
"sd_480i": 1,
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
# HDR ranking (higher = better)
|
|
465
|
+
HDR_RANK = {
|
|
466
|
+
"dolby_vision": 4,
|
|
467
|
+
"hdr10_plus": 3,
|
|
468
|
+
"hdr10": 2,
|
|
469
|
+
"hlg": 1,
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
# Audio codec mapping: Plex audioCodec -> Trakt audio
|
|
473
|
+
AUDIO_CODEC_MAP = {
|
|
474
|
+
"truehd": "dolby_truehd",
|
|
475
|
+
"eac3": "dolby_digital_plus",
|
|
476
|
+
"ac3": "dolby_digital",
|
|
477
|
+
"dca": "dts", # DTS core
|
|
478
|
+
"dts": "dts",
|
|
479
|
+
"dts-hd ma": "dts_ma",
|
|
480
|
+
"dts-hd hra": "dts_hr",
|
|
481
|
+
"aac": "aac",
|
|
482
|
+
"flac": "flac",
|
|
483
|
+
"pcm": "lpcm",
|
|
484
|
+
"mp3": "mp3",
|
|
485
|
+
"mp2": "mp2",
|
|
486
|
+
"vorbis": "ogg",
|
|
487
|
+
"opus": "ogg_opus",
|
|
488
|
+
"wma": "wma",
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
# Audio codec ranking (higher = better)
|
|
492
|
+
AUDIO_RANK = {
|
|
493
|
+
"dolby_atmos": 7,
|
|
494
|
+
"dolby_digital_plus_atmos": 6,
|
|
495
|
+
"dts_x": 6,
|
|
496
|
+
"dolby_truehd": 5,
|
|
497
|
+
"dts_ma": 5,
|
|
498
|
+
"dts_hr": 4,
|
|
499
|
+
"dolby_digital_plus": 3,
|
|
500
|
+
"dts": 3,
|
|
501
|
+
"dolby_digital": 2,
|
|
502
|
+
"flac": 2,
|
|
503
|
+
"lpcm": 2,
|
|
504
|
+
"aac": 1,
|
|
505
|
+
"mp3": 0,
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
# Channel count to Trakt format
|
|
509
|
+
CHANNELS_MAP = {
|
|
510
|
+
1: "1.0",
|
|
511
|
+
2: "2.0",
|
|
512
|
+
3: "2.1",
|
|
513
|
+
6: "5.1",
|
|
514
|
+
7: "6.1",
|
|
515
|
+
8: "7.1",
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _get_video_stream(media) -> object | None:
|
|
520
|
+
"""Get the primary video stream from media."""
|
|
521
|
+
try:
|
|
522
|
+
for part in media.parts:
|
|
523
|
+
for stream in part.streams:
|
|
524
|
+
if stream.streamType == 1: # Video stream
|
|
525
|
+
return stream
|
|
526
|
+
except (AttributeError, TypeError):
|
|
527
|
+
pass
|
|
528
|
+
return None
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _get_audio_stream(media) -> object | None:
|
|
532
|
+
"""Get the primary audio stream from media."""
|
|
533
|
+
try:
|
|
534
|
+
for part in media.parts:
|
|
535
|
+
for stream in part.streams:
|
|
536
|
+
if stream.streamType == 2: # Audio stream
|
|
537
|
+
return stream
|
|
538
|
+
except (AttributeError, TypeError):
|
|
539
|
+
pass
|
|
540
|
+
return None
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _detect_hdr_type(video_stream) -> str | None:
|
|
544
|
+
"""Detect HDR type from video stream attributes."""
|
|
545
|
+
if video_stream is None:
|
|
546
|
+
return None
|
|
547
|
+
|
|
548
|
+
# Check for Dolby Vision
|
|
549
|
+
if getattr(video_stream, "DOVIPresent", False):
|
|
550
|
+
return "dolby_vision"
|
|
551
|
+
|
|
552
|
+
# Check colorTrc for HDR format
|
|
553
|
+
color_trc = getattr(video_stream, "colorTrc", None)
|
|
554
|
+
if color_trc:
|
|
555
|
+
if color_trc == "smpte2084":
|
|
556
|
+
# Could be HDR10 or HDR10+ - check displayTitle for HDR10+
|
|
557
|
+
display_title = getattr(video_stream, "displayTitle", "") or ""
|
|
558
|
+
if "HDR10+" in display_title or "HDR10Plus" in display_title.replace(" ", ""):
|
|
559
|
+
return "hdr10_plus"
|
|
560
|
+
return "hdr10"
|
|
561
|
+
elif color_trc == "arib-std-b67":
|
|
562
|
+
return "hlg"
|
|
563
|
+
|
|
564
|
+
return None
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _detect_audio_codec(media, audio_stream) -> str | None:
|
|
568
|
+
"""Detect audio codec and check for Atmos/DTS:X."""
|
|
569
|
+
codec = getattr(media, "audioCodec", None)
|
|
570
|
+
if not codec:
|
|
571
|
+
return None
|
|
572
|
+
|
|
573
|
+
codec = codec.lower()
|
|
574
|
+
|
|
575
|
+
# Check for Atmos in the audio stream
|
|
576
|
+
if audio_stream:
|
|
577
|
+
display_title = getattr(audio_stream, "displayTitle", "") or ""
|
|
578
|
+
extended_display_title = getattr(audio_stream, "extendedDisplayTitle", "") or ""
|
|
579
|
+
combined = f"{display_title} {extended_display_title}".lower()
|
|
580
|
+
|
|
581
|
+
if "atmos" in combined:
|
|
582
|
+
if codec == "truehd":
|
|
583
|
+
return "dolby_atmos"
|
|
584
|
+
elif codec == "eac3":
|
|
585
|
+
return "dolby_digital_plus_atmos"
|
|
586
|
+
|
|
587
|
+
if "dts:x" in combined or "dts-x" in combined:
|
|
588
|
+
return "dts_x"
|
|
589
|
+
|
|
590
|
+
# Fall back to standard codec mapping
|
|
591
|
+
return AUDIO_CODEC_MAP.get(codec)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def _detect_audio_channels(media, audio_stream) -> str | None:
|
|
595
|
+
"""Detect audio channel configuration."""
|
|
596
|
+
channels = getattr(media, "audioChannels", None)
|
|
597
|
+
if not channels:
|
|
598
|
+
return None
|
|
599
|
+
|
|
600
|
+
# Check for Atmos height channels in stream layout
|
|
601
|
+
if audio_stream:
|
|
602
|
+
channel_layout = getattr(audio_stream, "audioChannelLayout", "") or ""
|
|
603
|
+
display_title = getattr(audio_stream, "displayTitle", "") or ""
|
|
604
|
+
combined = f"{channel_layout} {display_title}".lower()
|
|
605
|
+
|
|
606
|
+
# Detect object-based audio with height channels
|
|
607
|
+
if "atmos" in combined or "dts:x" in combined:
|
|
608
|
+
if channels >= 8:
|
|
609
|
+
if "7.1.4" in combined:
|
|
610
|
+
return "7.1.4"
|
|
611
|
+
elif "7.1.2" in combined:
|
|
612
|
+
return "7.1.2"
|
|
613
|
+
return "7.1"
|
|
614
|
+
elif channels >= 6:
|
|
615
|
+
if "5.1.4" in combined:
|
|
616
|
+
return "5.1.4"
|
|
617
|
+
elif "5.1.2" in combined:
|
|
618
|
+
return "5.1.2"
|
|
619
|
+
return "5.1"
|
|
620
|
+
|
|
621
|
+
# Standard channel mapping
|
|
622
|
+
return CHANNELS_MAP.get(channels)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _score_media(media) -> int:
|
|
626
|
+
"""Score a media version for quality comparison. Higher = better."""
|
|
627
|
+
score = 0
|
|
628
|
+
|
|
629
|
+
# Resolution score (0-5000)
|
|
630
|
+
resolution = getattr(media, "videoResolution", None)
|
|
631
|
+
if resolution:
|
|
632
|
+
trakt_res = RESOLUTION_MAP.get(resolution.lower(), "sd_480p")
|
|
633
|
+
score += RESOLUTION_RANK.get(trakt_res, 1) * 1000
|
|
634
|
+
|
|
635
|
+
# HDR score (0-400)
|
|
636
|
+
video_stream = _get_video_stream(media)
|
|
637
|
+
hdr_type = _detect_hdr_type(video_stream)
|
|
638
|
+
if hdr_type:
|
|
639
|
+
score += HDR_RANK.get(hdr_type, 0) * 100
|
|
640
|
+
|
|
641
|
+
# Audio score (0-70)
|
|
642
|
+
audio_stream = _get_audio_stream(media)
|
|
643
|
+
audio_codec = _detect_audio_codec(media, audio_stream)
|
|
644
|
+
if audio_codec:
|
|
645
|
+
score += AUDIO_RANK.get(audio_codec, 0) * 10
|
|
646
|
+
|
|
647
|
+
# Channels score (0-8)
|
|
648
|
+
channels = getattr(media, "audioChannels", 0) or 0
|
|
649
|
+
score += min(channels, 8)
|
|
650
|
+
|
|
651
|
+
return score
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def extract_media_metadata(item: Movie | Episode) -> dict:
|
|
655
|
+
"""Extract best quality media metadata for Trakt collection.
|
|
656
|
+
|
|
657
|
+
Examines all media versions and returns metadata for the best quality one.
|
|
658
|
+
"""
|
|
659
|
+
if not hasattr(item, "media") or not item.media:
|
|
660
|
+
return {}
|
|
661
|
+
|
|
662
|
+
# Find best quality media version
|
|
663
|
+
best_media = None
|
|
664
|
+
best_score = -1
|
|
665
|
+
for media in item.media:
|
|
666
|
+
score = _score_media(media)
|
|
667
|
+
if score > best_score:
|
|
668
|
+
best_score = score
|
|
669
|
+
best_media = media
|
|
670
|
+
|
|
671
|
+
if not best_media:
|
|
672
|
+
return {}
|
|
673
|
+
|
|
674
|
+
metadata: dict = {"media_type": "digital"}
|
|
675
|
+
|
|
676
|
+
# Resolution
|
|
677
|
+
resolution = getattr(best_media, "videoResolution", None)
|
|
678
|
+
if resolution:
|
|
679
|
+
trakt_res = RESOLUTION_MAP.get(resolution.lower())
|
|
680
|
+
if trakt_res:
|
|
681
|
+
metadata["resolution"] = trakt_res
|
|
682
|
+
|
|
683
|
+
# HDR
|
|
684
|
+
video_stream = _get_video_stream(best_media)
|
|
685
|
+
hdr_type = _detect_hdr_type(video_stream)
|
|
686
|
+
if hdr_type:
|
|
687
|
+
metadata["hdr"] = hdr_type
|
|
688
|
+
|
|
689
|
+
# Audio codec
|
|
690
|
+
audio_stream = _get_audio_stream(best_media)
|
|
691
|
+
audio_codec = _detect_audio_codec(best_media, audio_stream)
|
|
692
|
+
if audio_codec:
|
|
693
|
+
metadata["audio"] = audio_codec
|
|
694
|
+
|
|
695
|
+
# Audio channels
|
|
696
|
+
audio_channels = _detect_audio_channels(best_media, audio_stream)
|
|
697
|
+
if audio_channels:
|
|
698
|
+
metadata["audio_channels"] = audio_channels
|
|
699
|
+
|
|
700
|
+
return metadata
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def extract_plex_ids(item: Movie | Show | Episode) -> PlexIds:
|
|
704
|
+
"""Extract IDs from a Plex item."""
|
|
705
|
+
plex_id = PlexIds(plex=str(item.ratingKey), guid=item.guid)
|
|
706
|
+
|
|
707
|
+
# Parse GUIDs for external IDs
|
|
708
|
+
for guid in getattr(item, "guids", []):
|
|
709
|
+
guid_str = str(guid.id)
|
|
710
|
+
if guid_str.startswith("imdb://"):
|
|
711
|
+
plex_id.imdb = guid_str.replace("imdb://", "")
|
|
712
|
+
elif guid_str.startswith("tmdb://"):
|
|
713
|
+
try:
|
|
714
|
+
plex_id.tmdb = int(guid_str.replace("tmdb://", ""))
|
|
715
|
+
except ValueError:
|
|
716
|
+
pass
|
|
717
|
+
elif guid_str.startswith("tvdb://"):
|
|
718
|
+
try:
|
|
719
|
+
plex_id.tvdb = int(guid_str.replace("tvdb://", ""))
|
|
720
|
+
except ValueError:
|
|
721
|
+
pass
|
|
722
|
+
|
|
723
|
+
return plex_id
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def plex_movie_to_media_item(movie: Movie) -> MediaItem:
|
|
727
|
+
"""Convert Plex movie to MediaItem."""
|
|
728
|
+
plex_ids = extract_plex_ids(movie)
|
|
729
|
+
|
|
730
|
+
return MediaItem(
|
|
731
|
+
title=movie.title,
|
|
732
|
+
year=movie.year,
|
|
733
|
+
media_type=MediaType.MOVIE,
|
|
734
|
+
plex_ids=plex_ids,
|
|
735
|
+
watched=movie.isWatched,
|
|
736
|
+
watched_at=movie.lastViewedAt,
|
|
737
|
+
plays=movie.viewCount or 0,
|
|
738
|
+
rating=int(movie.userRating) if movie.userRating else None,
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
def plex_episode_to_media_item(episode: Episode) -> MediaItem:
|
|
743
|
+
"""Convert Plex episode to MediaItem."""
|
|
744
|
+
plex_ids = extract_plex_ids(episode)
|
|
745
|
+
|
|
746
|
+
return MediaItem(
|
|
747
|
+
title=episode.title,
|
|
748
|
+
year=episode.year,
|
|
749
|
+
media_type=MediaType.EPISODE,
|
|
750
|
+
plex_ids=plex_ids,
|
|
751
|
+
watched=episode.isWatched,
|
|
752
|
+
watched_at=episode.lastViewedAt,
|
|
753
|
+
plays=episode.viewCount or 0,
|
|
754
|
+
rating=int(episode.userRating) if episode.userRating else None,
|
|
755
|
+
show_title=episode.grandparentTitle,
|
|
756
|
+
season=episode.seasonNumber,
|
|
757
|
+
episode=episode.episodeNumber,
|
|
758
|
+
)
|