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/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
+ )