Skip to content

Commit 984c7b9

Browse files
stevezauclaude
andauthored
fix(plex): generate previews for every version of multi-version media (#268) (#269)
Plex items with multiple versions (e.g. a 1080p + a 4K file) only got a preview BIF for ONE version. Regression from the #225 multi-server rewrite, which collapsed enumeration to remote_path=str(locations[0]) (first version only); pre-rewrite the generator looped every MediaPart. list_items() now yields one MediaItem per version file (each entry in item.locations), so every version gets its own BIF. The shared bundle_metadata carries all (hash, file) pairs and PlexBundleAdapter selects each version's bundle hash by matching the per-item path — no adapter change needed. Coverage: library scans + manual generation now do all versions; Radarr/Sonarr path-based webhooks already did (each import is a specific file). Plex-native webhooks that resolve by item-id still pick one version (resolve_item_to_remote_path returns a single path) — a narrower follow-up. Tests: multi-version movie + episode each yield one MediaItem per version. Claude-Session: https://claude.ai/code/session_01YC1Z1JFBVJ5xKi5YAqYKfc Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b164cf7 commit 984c7b9

2 files changed

Lines changed: 98 additions & 14 deletions

File tree

media_preview_generator/servers/plex.py

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1522,13 +1522,23 @@ def list_items(self, library_id: str) -> Iterator[MediaItem]:
15221522
locations = _extract_item_locations(m)
15231523
if not locations:
15241524
continue
1525-
yield MediaItem(
1526-
id=_plex_item_id(m),
1527-
library_id=str(target.key),
1528-
title=_build_episode_title(m),
1529-
remote_path=str(locations[0]),
1530-
bundle_metadata=_extract_plex_bundle_metadata(m),
1531-
)
1525+
# #268: one MediaItem per version. A Plex item can have
1526+
# several versions (1080p + 4K), each a distinct file in
1527+
# ``locations``. Emitting one item per file gives every
1528+
# version its own BIF; the shared ``bundle_metadata`` holds
1529+
# all (hash, file) pairs and PlexBundleAdapter selects each
1530+
# version's bundle hash by matching the per-item path.
1531+
bundle_md = _extract_plex_bundle_metadata(m)
1532+
item_id = _plex_item_id(m)
1533+
title = _build_episode_title(m)
1534+
for location in locations:
1535+
yield MediaItem(
1536+
id=item_id,
1537+
library_id=str(target.key),
1538+
title=title,
1539+
remote_path=str(location),
1540+
bundle_metadata=bundle_md,
1541+
)
15321542
elif target.METADATA_TYPE == "movie":
15331543
logger.info(
15341544
"Plex library {!r}: requesting full movie list from server "
@@ -1545,13 +1555,18 @@ def list_items(self, library_id: str) -> Iterator[MediaItem]:
15451555
locations = _extract_item_locations(m)
15461556
if not locations:
15471557
continue
1548-
yield MediaItem(
1549-
id=_plex_item_id(m),
1550-
library_id=str(target.key),
1551-
title=str(getattr(m, "title", "") or ""),
1552-
remote_path=str(locations[0]),
1553-
bundle_metadata=_extract_plex_bundle_metadata(m),
1554-
)
1558+
# #268: one MediaItem per version (see episode branch above).
1559+
bundle_md = _extract_plex_bundle_metadata(m)
1560+
item_id = _plex_item_id(m)
1561+
title = str(getattr(m, "title", "") or "")
1562+
for location in locations:
1563+
yield MediaItem(
1564+
id=item_id,
1565+
library_id=str(target.key),
1566+
title=title,
1567+
remote_path=str(location),
1568+
bundle_metadata=bundle_md,
1569+
)
15551570
else:
15561571
logger.info(
15571572
"Skipping Plex library {} (unsupported METADATA_TYPE={})",

tests/test_servers_plex.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,75 @@ def test_captures_bundle_metadata_from_plexapi_parts(self, mock_config):
486486
"introducing _extract_plex_bundle_metadata."
487487
)
488488

489+
def test_yields_one_item_per_version_for_multi_version_movie(self, mock_config):
490+
"""#268: a movie with multiple versions (multiple files) must yield ONE
491+
MediaItem per version so each version gets its own BIF — regression from
492+
the #225 rewrite which collapsed to ``locations[0]`` (first version only)."""
493+
wrapper = PlexServer(mock_config)
494+
495+
def _media(hash_, file_):
496+
part = MagicMock()
497+
part.hash = hash_
498+
part.file = file_
499+
media = MagicMock()
500+
media.parts = [part]
501+
return media
502+
503+
movie = MagicMock(spec=["key", "ratingKey", "title", "locations", "media"])
504+
movie.key = "/library/metadata/99"
505+
movie.ratingKey = 99
506+
movie.title = "Foo (2024)"
507+
movie.locations = ["/data/Foo (2024)/Foo-1080p.mkv", "/data/Foo (2024)/Foo-2160p.mkv"]
508+
movie.media = [
509+
_media("hash1080", "/data/Foo (2024)/Foo-1080p.mkv"),
510+
_media("hash4k", "/data/Foo (2024)/Foo-2160p.mkv"),
511+
]
512+
513+
section = MagicMock()
514+
section.key = 1
515+
section.METADATA_TYPE = "movie"
516+
section.search.return_value = [movie]
517+
plex = MagicMock()
518+
plex.library.sections.return_value = [section]
519+
wrapper._plex = plex
520+
521+
items = list(wrapper.list_items("1"))
522+
assert len(items) == 2, "expected one MediaItem per version"
523+
assert {it.remote_path for it in items} == {
524+
"/data/Foo (2024)/Foo-1080p.mkv",
525+
"/data/Foo (2024)/Foo-2160p.mkv",
526+
}
527+
assert all(it.id == "99" for it in items), "all versions share the item id"
528+
529+
def test_yields_one_item_per_version_for_multi_version_episode(self, mock_config):
530+
"""#268, episode side: multi-version episodes must also fan out per version."""
531+
wrapper = PlexServer(mock_config)
532+
ep = MagicMock(
533+
spec=["key", "ratingKey", "title", "grandparentTitle", "parentIndex", "index", "locations", "media"]
534+
)
535+
ep.key = "/library/metadata/7"
536+
ep.ratingKey = 7
537+
ep.grandparentTitle = "Test Show"
538+
ep.parentIndex = 1
539+
ep.index = 1
540+
ep.locations = ["/tv/Show/S01E01-1080p.mkv", "/tv/Show/S01E01-2160p.mkv"]
541+
ep.media = []
542+
543+
section = MagicMock()
544+
section.key = 2
545+
section.METADATA_TYPE = "episode"
546+
section.search.return_value = [ep]
547+
plex = MagicMock()
548+
plex.library.sections.return_value = [section]
549+
wrapper._plex = plex
550+
551+
items = list(wrapper.list_items("2"))
552+
assert len(items) == 2
553+
assert {it.remote_path for it in items} == {
554+
"/tv/Show/S01E01-1080p.mkv",
555+
"/tv/Show/S01E01-2160p.mkv",
556+
}
557+
489558

490559
class TestResolveItemToRemotePath:
491560
def test_returns_first_part_path(self, plex_wrapper):

0 commit comments

Comments
 (0)