From 259e8db910ba94d4924fe76982c71bfdfba16f60 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 07:12:11 +0000 Subject: [PATCH 1/7] Initial plan From d9421273d2d844b738b8d2c4a41e86b2d34f0220 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 07:15:51 +0000 Subject: [PATCH 2/7] Add Plex metadata images endpoint support --- README.md | 1 + provider/mappers/tpdb_to_plex.py | 37 ++++++++++++++++ provider/routes/manifest.py | 1 + provider/routes/metadata.py | 32 ++++++++++++++ provider/services/metadata_service.py | 30 ++++++++++--- tests/test_metadata_routes.py | 64 +++++++++++++++++++++++++++ tests/test_tpdb_enrichment.py | 16 ++++++- 7 files changed, 174 insertions(+), 7 deletions(-) create mode 100644 tests/test_metadata_routes.py diff --git a/README.md b/README.md index 76848cd..a195d6c 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ When Plex scans your library, it sends the filename/title to the provider. The p | `/` | GET | Provider manifest | | `/library/metadata/matches` | POST | Search for matching scenes | | `/library/metadata/{id}` | GET | Get full metadata for a scene | +| `/library/metadata/{id}/images` | GET | Get image metadata entries for a scene | ## Verification diff --git a/provider/mappers/tpdb_to_plex.py b/provider/mappers/tpdb_to_plex.py index 4e265af..cf032ec 100644 --- a/provider/mappers/tpdb_to_plex.py +++ b/provider/mappers/tpdb_to_plex.py @@ -79,6 +79,43 @@ def _get_scene_art(scene: dict[str, Any]) -> str: return "" +def extract_scene_images(scene: dict[str, Any]) -> dict[str, str]: + """Extract normalized Plex image slots from a scene payload.""" + images: dict[str, str] = {} + + poster = _get_scene_poster(scene) + if poster: + images["poster"] = poster + images["thumb"] = poster + + art = _get_scene_art(scene) + if art: + images["art"] = art + images["background"] = art + + return images + + +def map_scene_to_images(scene: dict[str, Any]) -> list[dict[str, Any]]: + """Map TPDB scene payload to Plex image metadata entries.""" + slug = scene.get("slug", scene.get("id", "")) + scene_images = extract_scene_images(scene) + + image_entries = [] + for image_type, image_url in scene_images.items(): + image_entries.append( + { + "type": image_type, + "url": image_url, + "key": f"/library/metadata/{slug}/images/{image_type}", + "ratingKey": slug, + "provider": "tv.plex.agents.custom.tpdb", + } + ) + + return image_entries + + def _normalize_people(items: Any) -> list[dict[str, str]]: """Normalize person payloads to Plex person format.""" if isinstance(items, dict): diff --git a/provider/routes/manifest.py b/provider/routes/manifest.py index 9a21720..5422c1e 100644 --- a/provider/routes/manifest.py +++ b/provider/routes/manifest.py @@ -29,6 +29,7 @@ async def get_manifest(): "Feature": [ {"type": "match", "key": "/library/metadata/matches"}, {"type": "metadata", "key": "/library/metadata"}, + {"type": "images", "key": "/library/metadata"}, ], } } diff --git a/provider/routes/metadata.py b/provider/routes/metadata.py index 4d1b6e8..4ebc70f 100644 --- a/provider/routes/metadata.py +++ b/provider/routes/metadata.py @@ -42,3 +42,35 @@ async def get_metadata(rating_key: str): } return JSONResponse(content=response) + + +@router.get("/library/metadata/{rating_key}/images") +async def get_metadata_images(rating_key: str): + """ + Get image metadata entries for a scene. + + Args: + rating_key: The TPDB scene slug + + Returns: + MediaContainer with image metadata entries + """ + logger.info("Metadata images request for: %s", rating_key) + + service = get_metadata_service() + images = service.get_images(rating_key) + + if images is None: + raise HTTPException(status_code=404, detail="Scene not found") + + response = { + "MediaContainer": { + "identifier": "tv.plex.agents.custom.tpdb", + "offset": 0, + "totalSize": len(images), + "size": len(images), + "Metadata": images, + } + } + + return JSONResponse(content=response) diff --git a/provider/services/metadata_service.py b/provider/services/metadata_service.py index a44b687..9e48d63 100644 --- a/provider/services/metadata_service.py +++ b/provider/services/metadata_service.py @@ -12,7 +12,7 @@ from metadata_tool.api import TPDBClient from provider.config import get_settings -from provider.mappers.tpdb_to_plex import map_scene_to_metadata +from provider.mappers.tpdb_to_plex import map_scene_to_images, map_scene_to_metadata logger = logging.getLogger(__name__) @@ -117,14 +117,10 @@ def get_metadata(self, rating_key: str) -> Optional[dict]: """ logger.info("Fetching metadata for: %s", rating_key) - scene = self.client.get_scene(rating_key) - + scene = self.get_scene(rating_key) if not scene: - logger.warning("Scene not found: %s", rating_key) return None - scene = self._hydrate_scene(scene) - logger.info("Found scene: %s", scene.get("title")) try: @@ -133,6 +129,28 @@ def get_metadata(self, rating_key: str) -> Optional[dict]: logger.error("Failed to map scene %s: %s", rating_key, e) return None + def get_scene(self, rating_key: str) -> Optional[dict]: + """Get and hydrate TPDB scene by rating key.""" + scene = self.client.get_scene(rating_key) + if not scene: + logger.warning("Scene not found: %s", rating_key) + return None + return self._hydrate_scene(scene) + + def get_images(self, rating_key: str) -> Optional[list[dict]]: + """Get Plex-compatible image metadata entries for a scene.""" + logger.info("Fetching images for: %s", rating_key) + + scene = self.get_scene(rating_key) + if not scene: + return None + + try: + return map_scene_to_images(scene) + except Exception as e: + logger.error("Failed to map images for scene %s: %s", rating_key, e) + return [] + # Global service instance _metadata_service: Optional[MetadataService] = None diff --git a/tests/test_metadata_routes.py b/tests/test_metadata_routes.py new file mode 100644 index 0000000..2e91076 --- /dev/null +++ b/tests/test_metadata_routes.py @@ -0,0 +1,64 @@ +import json +import unittest +from unittest.mock import Mock, patch + +from fastapi import HTTPException + +from provider.routes import metadata as metadata_routes + + +class MetadataRouteTests(unittest.IsolatedAsyncioTestCase): + async def test_get_metadata_images_returns_entries(self): + service = Mock() + service.get_images.return_value = [ + { + "type": "poster", + "url": "https://img/poster.jpg", + "key": "/library/metadata/scene-slug/images/poster", + "ratingKey": "scene-slug", + "provider": "tv.plex.agents.custom.tpdb", + } + ] + + with patch("provider.routes.metadata.get_metadata_service", return_value=service): + response = await metadata_routes.get_metadata_images("scene-slug") + + payload = json.loads(response.body) + self.assertEqual(response.status_code, 200) + self.assertEqual(payload["MediaContainer"]["size"], 1) + self.assertEqual(payload["MediaContainer"]["Metadata"][0]["type"], "poster") + service.get_images.assert_called_once_with("scene-slug") + + async def test_get_metadata_images_returns_404_for_missing_scene(self): + service = Mock() + service.get_images.return_value = None + + with patch("provider.routes.metadata.get_metadata_service", return_value=service): + with self.assertRaises(HTTPException) as context: + await metadata_routes.get_metadata_images("missing-scene") + + self.assertEqual(context.exception.status_code, 404) + service.get_images.assert_called_once_with("missing-scene") + + async def test_get_metadata_response_unchanged(self): + service = Mock() + service.get_metadata.return_value = { + "type": "movie", + "guid": "tv.plex.agents.custom.tpdb://movie/scene-slug", + "key": "/library/metadata/scene-slug", + "ratingKey": "scene-slug", + "title": "Scene", + } + + with patch("provider.routes.metadata.get_metadata_service", return_value=service): + response = await metadata_routes.get_metadata("scene-slug") + + payload = json.loads(response.body) + self.assertEqual(response.status_code, 200) + self.assertEqual(payload["MediaContainer"]["size"], 1) + self.assertEqual(payload["MediaContainer"]["Metadata"][0]["ratingKey"], "scene-slug") + service.get_metadata.assert_called_once_with("scene-slug") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_tpdb_enrichment.py b/tests/test_tpdb_enrichment.py index 2bc992f..a796413 100644 --- a/tests/test_tpdb_enrichment.py +++ b/tests/test_tpdb_enrichment.py @@ -2,7 +2,7 @@ from collections import OrderedDict from unittest.mock import Mock -from provider.mappers.tpdb_to_plex import map_scene_to_match, map_scene_to_metadata +from provider.mappers.tpdb_to_plex import map_scene_to_images, map_scene_to_match, map_scene_to_metadata from provider.services.metadata_service import MetadataService @@ -47,6 +47,20 @@ def test_match_supports_nested_images(self): self.assertEqual(match.get("thumb"), "https://img/poster.jpg") self.assertEqual(match.get("art"), "https://img/bg.jpg") + def test_map_scene_to_images_includes_plex_image_slots(self): + scene = { + "slug": "scene-slug", + "images": { + "poster": {"src": "https://img/poster.jpg"}, + "background": {"url": "https://img/bg.jpg"}, + }, + } + + images = map_scene_to_images(scene) + image_types = {image["type"] for image in images} + + self.assertSetEqual(image_types, {"poster", "thumb", "art", "background"}) + class MetadataHydrationTests(unittest.TestCase): def test_hydrates_performer_and_site_with_caching(self): From 36f252159b6e9a32461b4c8084bf9af75fbe153a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 07:26:06 +0000 Subject: [PATCH 3/7] Fix legacy TPDB image field mapping --- provider/mappers/tpdb_to_plex.py | 8 ++++---- provider/services/match_service.py | 2 +- provider/services/metadata_service.py | 2 +- tests/test_tpdb_enrichment.py | 19 +++++++++++++++++++ 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/provider/mappers/tpdb_to_plex.py b/provider/mappers/tpdb_to_plex.py index cf032ec..1e2d84a 100644 --- a/provider/mappers/tpdb_to_plex.py +++ b/provider/mappers/tpdb_to_plex.py @@ -19,7 +19,7 @@ def _extract_string(value: Any, depth: int = 0, max_depth: int = 5) -> str: if isinstance(value, str): return value if isinstance(value, dict): - for key in ("url", "src", "path", "image", "poster", "thumb"): + for key in ("url", "src", "path", "image", "poster", "thumb", "face", "full", "large"): nested = _extract_string(value.get(key), depth=depth + 1, max_depth=max_depth) if nested: return nested @@ -45,7 +45,7 @@ def _get_scene_poster(scene: dict[str, Any]) -> str: """Choose the best poster/thumb-like image from a scene payload.""" poster = _get_first_image( scene, - ("poster", "cover", "cover_image", "thumb", "image", "background", "art"), + ("poster", "posters", "cover", "cover_image", "thumb", "image", "background", "art"), ) if poster: return poster @@ -240,7 +240,7 @@ def map_scene_to_match(scene: dict[str, Any], score: int = 100, media_type: int role = {"tag": performer.get("name", "")} performer_image = _get_first_image( performer, - ("image", "poster", "thumb", "photo", "avatar"), + ("image", "poster", "thumb", "photo", "avatar", "face"), ) if performer_image: role["thumb"] = performer_image @@ -336,7 +336,7 @@ def map_scene_to_metadata(scene: dict[str, Any], media_type: int = 1) -> dict[st role = {"tag": performer.get("name", "")} performer_image = _get_first_image( performer, - ("image", "poster", "thumb", "photo", "avatar"), + ("image", "poster", "thumb", "photo", "avatar", "face"), ) if performer_image: role["thumb"] = performer_image diff --git a/provider/services/match_service.py b/provider/services/match_service.py index e8b7d60..987b070 100644 --- a/provider/services/match_service.py +++ b/provider/services/match_service.py @@ -40,7 +40,7 @@ def _first_identifier(payload: dict, keys: tuple[str, ...]) -> str: @staticmethod def _has_image(payload: dict) -> bool: """Check if payload already includes any image-like field.""" - return any(payload.get(key) for key in ("image", "poster", "thumb", "photo", "avatar")) + return any(payload.get(key) for key in ("image", "poster", "thumb", "photo", "avatar", "face")) def _get_cached_performer(self, performer_identifier: str) -> Optional[dict]: """Get performer details with lightweight in-memory cache.""" diff --git a/provider/services/metadata_service.py b/provider/services/metadata_service.py index 9e48d63..ae85591 100644 --- a/provider/services/metadata_service.py +++ b/provider/services/metadata_service.py @@ -40,7 +40,7 @@ def _first_identifier(payload: dict, keys: tuple[str, ...]) -> str: @staticmethod def _has_image(payload: dict) -> bool: """Check if payload already includes any image-like field.""" - return any(payload.get(key) for key in ("image", "poster", "thumb", "photo", "avatar")) + return any(payload.get(key) for key in ("image", "poster", "thumb", "photo", "avatar", "face")) def _get_cached_performer(self, performer_identifier: str) -> Optional[dict]: """Get performer details with lightweight in-memory cache.""" diff --git a/tests/test_tpdb_enrichment.py b/tests/test_tpdb_enrichment.py index a796413..dd19d17 100644 --- a/tests/test_tpdb_enrichment.py +++ b/tests/test_tpdb_enrichment.py @@ -61,6 +61,25 @@ def test_map_scene_to_images_includes_plex_image_slots(self): self.assertSetEqual(image_types, {"poster", "thumb", "art", "background"}) + def test_legacy_scene_and_actor_image_fields_are_mapped(self): + scene = { + "slug": "scene-slug", + "title": "Scene", + "posters": {"large": "https://img/poster-large.jpg"}, + "background": {"full": "https://img/background-full.jpg"}, + "performers": [{"name": "Performer", "face": "https://img/performer-face.jpg"}], + } + + metadata = map_scene_to_metadata(scene) + images = map_scene_to_images(scene) + image_by_type = {image["type"]: image["url"] for image in images} + + self.assertEqual(metadata.get("thumb"), "https://img/poster-large.jpg") + self.assertEqual(metadata.get("art"), "https://img/background-full.jpg") + self.assertEqual(metadata.get("Role"), [{"tag": "Performer", "thumb": "https://img/performer-face.jpg"}]) + self.assertEqual(image_by_type.get("poster"), "https://img/poster-large.jpg") + self.assertEqual(image_by_type.get("art"), "https://img/background-full.jpg") + class MetadataHydrationTests(unittest.TestCase): def test_hydrates_performer_and_site_with_caching(self): From 68649f1fd2f35e2f81f92134ef8d2eb749390d9a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 07:30:47 +0000 Subject: [PATCH 4/7] Polish image dedupe and debug logging --- provider/mappers/tpdb_to_plex.py | 23 +++++++++++++++++++++++ tests/test_tpdb_enrichment.py | 4 ++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/provider/mappers/tpdb_to_plex.py b/provider/mappers/tpdb_to_plex.py index 1e2d84a..d8b7420 100644 --- a/provider/mappers/tpdb_to_plex.py +++ b/provider/mappers/tpdb_to_plex.py @@ -1,5 +1,6 @@ """Map TPDB scene data to Plex metadata format.""" +import logging from typing import Any # Plex type mappings @@ -11,6 +12,8 @@ PLEX_TYPE_OTHER: "clip", } +logger = logging.getLogger(__name__) + def _extract_string(value: Any, depth: int = 0, max_depth: int = 5) -> str: """Extract a useful string from scalar or nested payload values.""" @@ -57,6 +60,11 @@ def _get_scene_poster(scene: dict[str, Any]) -> str: ) if isinstance(images, list): return _extract_string(images) + logger.debug( + "No scene poster image extracted for scene=%s; available_keys=%s", + scene.get("slug", scene.get("id", "")), + sorted(scene.keys()), + ) return "" @@ -76,6 +84,11 @@ def _get_scene_art(scene: dict[str, Any]) -> str: ) if isinstance(images, list): return _extract_string(images) + logger.debug( + "No scene art image extracted for scene=%s; available_keys=%s", + scene.get("slug", scene.get("id", "")), + sorted(scene.keys()), + ) return "" @@ -102,7 +115,17 @@ def map_scene_to_images(scene: dict[str, Any]) -> list[dict[str, Any]]: scene_images = extract_scene_images(scene) image_entries = [] + seen_urls: set[str] = set() for image_type, image_url in scene_images.items(): + if image_url in seen_urls: + logger.debug( + "Skipping duplicate image url for scene=%s type=%s url=%s", + slug, + image_type, + image_url, + ) + continue + seen_urls.add(image_url) image_entries.append( { "type": image_type, diff --git a/tests/test_tpdb_enrichment.py b/tests/test_tpdb_enrichment.py index dd19d17..58afb50 100644 --- a/tests/test_tpdb_enrichment.py +++ b/tests/test_tpdb_enrichment.py @@ -47,7 +47,7 @@ def test_match_supports_nested_images(self): self.assertEqual(match.get("thumb"), "https://img/poster.jpg") self.assertEqual(match.get("art"), "https://img/bg.jpg") - def test_map_scene_to_images_includes_plex_image_slots(self): + def test_map_scene_to_images_returns_unique_image_urls(self): scene = { "slug": "scene-slug", "images": { @@ -59,7 +59,7 @@ def test_map_scene_to_images_includes_plex_image_slots(self): images = map_scene_to_images(scene) image_types = {image["type"] for image in images} - self.assertSetEqual(image_types, {"poster", "thumb", "art", "background"}) + self.assertSetEqual(image_types, {"poster", "art"}) def test_legacy_scene_and_actor_image_fields_are_mapped(self): scene = { From 59ffe434a50c08432975824d2ddc3f5f6dc46bd4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 07:41:49 +0000 Subject: [PATCH 5/7] Add adult, Guid, and performer ID mappings --- provider/mappers/tpdb_to_plex.py | 141 ++++++++++++++++++++++++------- tests/test_tpdb_enrichment.py | 26 +++++- 2 files changed, 131 insertions(+), 36 deletions(-) diff --git a/provider/mappers/tpdb_to_plex.py b/provider/mappers/tpdb_to_plex.py index d8b7420..1a8c9bc 100644 --- a/provider/mappers/tpdb_to_plex.py +++ b/provider/mappers/tpdb_to_plex.py @@ -193,6 +193,93 @@ def _get_studio(scene: dict[str, Any]) -> str: return "" +def _extract_bool(value: Any) -> bool | None: + """Normalize boolean-ish values.""" + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "y", "on"}: + return True + if normalized in {"0", "false", "no", "n", "off"}: + return False + return None + + +def _get_is_adult(scene: dict[str, Any]) -> bool | None: + """Extract an adult-content flag from known TPDB-style keys.""" + for key in ("isAdult", "is_adult", "adult"): + if key in scene: + value = _extract_bool(scene.get(key)) + if value is not None: + return value + return None + + +def _extract_provider_id(payload: dict[str, Any], keys: tuple[str, ...]) -> str: + """Extract first non-empty provider ID from known key names.""" + for key in keys: + value = payload.get(key) + if value is not None and value != "": + return str(value) + return "" + + +def _get_guid_entries(scene: dict[str, Any]) -> list[dict[str, str]]: + """Build Plex Guid entries for TPDB and known external providers.""" + external_sources: list[dict[str, Any]] = [scene] + for key in ("ids", "external_ids"): + candidate = scene.get(key) + if isinstance(candidate, dict): + external_sources.append(candidate) + + provider_keys = { + "imdb": ("imdb", "imdb_id"), + "tmdb": ("tmdb", "tmdb_id"), + "tvdb": ("tvdb", "tvdb_id"), + } + + guid_ids: list[str] = [] + for provider, keys in provider_keys.items(): + for source in external_sources: + provider_id = _extract_provider_id(source, keys) + if provider_id: + guid_ids.append(f"{provider}://{provider_id}") + break + + tpdb_identifier = _extract_provider_id(scene, ("id", "slug")) + if tpdb_identifier: + guid_ids.append(f"tpdb://{tpdb_identifier}") + + return [{"id": guid_id} for guid_id in dict.fromkeys(guid_ids)] + + +def _map_roles(performers: Any) -> list[dict[str, str]]: + """Map TPDB performers to Plex Role entries.""" + if not isinstance(performers, list): + return [] + + roles: list[dict[str, str]] = [] + for performer in performers: + if not isinstance(performer, dict): + continue + role = {"tag": performer.get("name", "")} + performer_id = _extract_provider_id(performer, ("id", "slug")) + if performer_id: + role["id"] = f"tpdb://performer/{performer_id}" + performer_image = _get_first_image( + performer, + ("image", "poster", "thumb", "photo", "avatar", "face"), + ) + if performer_image: + role["thumb"] = performer_image + roles.append(role) + + return roles + + def map_scene_to_match(scene: dict[str, Any], score: int = 100, media_type: int = 1) -> dict[str, Any]: """ Map a TPDB scene to a Plex match result. @@ -254,22 +341,9 @@ def map_scene_to_match(scene: dict[str, Any], score: int = 100, media_type: int if duration: match_result["duration"] = duration - # Map performers to roles - performers = scene.get("performers") or [] - if performers: - roles = [] - for performer in performers: - if isinstance(performer, dict): - role = {"tag": performer.get("name", "")} - performer_image = _get_first_image( - performer, - ("image", "poster", "thumb", "photo", "avatar", "face"), - ) - if performer_image: - role["thumb"] = performer_image - roles.append(role) - if roles: - match_result["Role"] = roles + roles = _map_roles(scene.get("performers") or []) + if roles: + match_result["Role"] = roles # Map tags to genres tags = scene.get("tags") or [] @@ -293,6 +367,14 @@ def map_scene_to_match(scene: dict[str, Any], score: int = 100, media_type: int if collections: match_result["Collection"] = collections + is_adult = _get_is_adult(scene) + if is_adult is not None: + match_result["isAdult"] = is_adult + + guid_entries = _get_guid_entries(scene) + if guid_entries: + match_result["Guid"] = guid_entries + return match_result @@ -350,22 +432,9 @@ def map_scene_to_metadata(scene: dict[str, Any], media_type: int = 1) -> dict[st if duration: metadata["duration"] = duration - # Map performers to roles - performers = scene.get("performers") or [] - if performers: - roles = [] - for performer in performers: - if isinstance(performer, dict): - role = {"tag": performer.get("name", "")} - performer_image = _get_first_image( - performer, - ("image", "poster", "thumb", "photo", "avatar", "face"), - ) - if performer_image: - role["thumb"] = performer_image - roles.append(role) - if roles: - metadata["Role"] = roles + roles = _map_roles(scene.get("performers") or []) + if roles: + metadata["Role"] = roles # Map tags to genres tags = scene.get("tags") or [] @@ -389,4 +458,12 @@ def map_scene_to_metadata(scene: dict[str, Any], media_type: int = 1) -> dict[st if collections: metadata["Collection"] = collections + is_adult = _get_is_adult(scene) + if is_adult is not None: + metadata["isAdult"] = is_adult + + guid_entries = _get_guid_entries(scene) + if guid_entries: + metadata["Guid"] = guid_entries + return metadata diff --git a/tests/test_tpdb_enrichment.py b/tests/test_tpdb_enrichment.py index 58afb50..42bf7cb 100644 --- a/tests/test_tpdb_enrichment.py +++ b/tests/test_tpdb_enrichment.py @@ -15,10 +15,13 @@ def test_metadata_uses_image_fallback_director_and_collection(self): "image": {"url": "https://img/poster.jpg"}, "art": "https://img/art.jpg", "site": {"name": "Studio"}, - "performers": [{"name": "Performer", "thumb": "https://img/p.jpg"}], + "performers": [{"id": "p1", "name": "Performer", "thumb": "https://img/p.jpg"}], "directors": [{"name": "Director One"}, {"name": "Director Two"}], "series": [{"name": "Series A"}], "franchise": "Franchise B", + "isAdult": True, + "imdb_id": "tt1234567", + "ids": {"tmdb": "98765"}, } metadata = map_scene_to_metadata(scene) @@ -30,7 +33,15 @@ def test_metadata_uses_image_fallback_director_and_collection(self): metadata.get("Collection"), [{"tag": "Series A"}, {"tag": "Franchise B"}], ) - self.assertEqual(metadata.get("Role"), [{"tag": "Performer", "thumb": "https://img/p.jpg"}]) + self.assertEqual( + metadata.get("Role"), + [{"tag": "Performer", "id": "tpdb://performer/p1", "thumb": "https://img/p.jpg"}], + ) + self.assertTrue(metadata.get("isAdult")) + self.assertEqual( + metadata.get("Guid"), + [{"id": "imdb://tt1234567"}, {"id": "tmdb://98765"}, {"id": "tpdb://123"}], + ) def test_match_supports_nested_images(self): scene = { @@ -40,12 +51,16 @@ def test_match_supports_nested_images(self): "poster": {"src": "https://img/poster.jpg"}, "background": {"url": "https://img/bg.jpg"}, }, + "adult": "true", + "external_ids": {"tvdb_id": "321"}, } match = map_scene_to_match(scene) self.assertEqual(match.get("thumb"), "https://img/poster.jpg") self.assertEqual(match.get("art"), "https://img/bg.jpg") + self.assertTrue(match.get("isAdult")) + self.assertEqual(match.get("Guid"), [{"id": "tvdb://321"}, {"id": "tpdb://123"}]) def test_map_scene_to_images_returns_unique_image_urls(self): scene = { @@ -67,7 +82,7 @@ def test_legacy_scene_and_actor_image_fields_are_mapped(self): "title": "Scene", "posters": {"large": "https://img/poster-large.jpg"}, "background": {"full": "https://img/background-full.jpg"}, - "performers": [{"name": "Performer", "face": "https://img/performer-face.jpg"}], + "performers": [{"slug": "perf-slug", "name": "Performer", "face": "https://img/performer-face.jpg"}], } metadata = map_scene_to_metadata(scene) @@ -76,7 +91,10 @@ def test_legacy_scene_and_actor_image_fields_are_mapped(self): self.assertEqual(metadata.get("thumb"), "https://img/poster-large.jpg") self.assertEqual(metadata.get("art"), "https://img/background-full.jpg") - self.assertEqual(metadata.get("Role"), [{"tag": "Performer", "thumb": "https://img/performer-face.jpg"}]) + self.assertEqual( + metadata.get("Role"), + [{"tag": "Performer", "id": "tpdb://performer/perf-slug", "thumb": "https://img/performer-face.jpg"}], + ) self.assertEqual(image_by_type.get("poster"), "https://img/poster-large.jpg") self.assertEqual(image_by_type.get("art"), "https://img/background-full.jpg") From 2fa86ff384dc5f1084394163ec3983a620a80dd8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 07:42:54 +0000 Subject: [PATCH 6/7] Hardcode adult flag for TPDB content --- provider/mappers/tpdb_to_plex.py | 33 ++------------------------------ tests/test_tpdb_enrichment.py | 6 ++---- 2 files changed, 4 insertions(+), 35 deletions(-) diff --git a/provider/mappers/tpdb_to_plex.py b/provider/mappers/tpdb_to_plex.py index 1a8c9bc..709e9a7 100644 --- a/provider/mappers/tpdb_to_plex.py +++ b/provider/mappers/tpdb_to_plex.py @@ -193,31 +193,6 @@ def _get_studio(scene: dict[str, Any]) -> str: return "" -def _extract_bool(value: Any) -> bool | None: - """Normalize boolean-ish values.""" - if isinstance(value, bool): - return value - if isinstance(value, (int, float)): - return bool(value) - if isinstance(value, str): - normalized = value.strip().lower() - if normalized in {"1", "true", "yes", "y", "on"}: - return True - if normalized in {"0", "false", "no", "n", "off"}: - return False - return None - - -def _get_is_adult(scene: dict[str, Any]) -> bool | None: - """Extract an adult-content flag from known TPDB-style keys.""" - for key in ("isAdult", "is_adult", "adult"): - if key in scene: - value = _extract_bool(scene.get(key)) - if value is not None: - return value - return None - - def _extract_provider_id(payload: dict[str, Any], keys: tuple[str, ...]) -> str: """Extract first non-empty provider ID from known key names.""" for key in keys: @@ -367,9 +342,7 @@ def map_scene_to_match(scene: dict[str, Any], score: int = 100, media_type: int if collections: match_result["Collection"] = collections - is_adult = _get_is_adult(scene) - if is_adult is not None: - match_result["isAdult"] = is_adult + match_result["isAdult"] = 1 guid_entries = _get_guid_entries(scene) if guid_entries: @@ -458,9 +431,7 @@ def map_scene_to_metadata(scene: dict[str, Any], media_type: int = 1) -> dict[st if collections: metadata["Collection"] = collections - is_adult = _get_is_adult(scene) - if is_adult is not None: - metadata["isAdult"] = is_adult + metadata["isAdult"] = 1 guid_entries = _get_guid_entries(scene) if guid_entries: diff --git a/tests/test_tpdb_enrichment.py b/tests/test_tpdb_enrichment.py index 42bf7cb..f8a1d5e 100644 --- a/tests/test_tpdb_enrichment.py +++ b/tests/test_tpdb_enrichment.py @@ -19,7 +19,6 @@ def test_metadata_uses_image_fallback_director_and_collection(self): "directors": [{"name": "Director One"}, {"name": "Director Two"}], "series": [{"name": "Series A"}], "franchise": "Franchise B", - "isAdult": True, "imdb_id": "tt1234567", "ids": {"tmdb": "98765"}, } @@ -37,7 +36,7 @@ def test_metadata_uses_image_fallback_director_and_collection(self): metadata.get("Role"), [{"tag": "Performer", "id": "tpdb://performer/p1", "thumb": "https://img/p.jpg"}], ) - self.assertTrue(metadata.get("isAdult")) + self.assertEqual(metadata.get("isAdult"), 1) self.assertEqual( metadata.get("Guid"), [{"id": "imdb://tt1234567"}, {"id": "tmdb://98765"}, {"id": "tpdb://123"}], @@ -51,7 +50,6 @@ def test_match_supports_nested_images(self): "poster": {"src": "https://img/poster.jpg"}, "background": {"url": "https://img/bg.jpg"}, }, - "adult": "true", "external_ids": {"tvdb_id": "321"}, } @@ -59,7 +57,7 @@ def test_match_supports_nested_images(self): self.assertEqual(match.get("thumb"), "https://img/poster.jpg") self.assertEqual(match.get("art"), "https://img/bg.jpg") - self.assertTrue(match.get("isAdult")) + self.assertEqual(match.get("isAdult"), 1) self.assertEqual(match.get("Guid"), [{"id": "tvdb://321"}, {"id": "tpdb://123"}]) def test_map_scene_to_images_returns_unique_image_urls(self): From 3c107dc9c40f9ad66a66bf34d6b7ed56a5f7a12f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 07:44:30 +0000 Subject: [PATCH 7/7] Add coverage for empty images and role ID priority --- tests/test_metadata_routes.py | 14 ++++++++++++++ tests/test_tpdb_enrichment.py | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/tests/test_metadata_routes.py b/tests/test_metadata_routes.py index 2e91076..a530061 100644 --- a/tests/test_metadata_routes.py +++ b/tests/test_metadata_routes.py @@ -40,6 +40,20 @@ async def test_get_metadata_images_returns_404_for_missing_scene(self): self.assertEqual(context.exception.status_code, 404) service.get_images.assert_called_once_with("missing-scene") + async def test_get_metadata_images_returns_empty_list_for_scene_without_images(self): + service = Mock() + service.get_images.return_value = [] + + with patch("provider.routes.metadata.get_metadata_service", return_value=service): + response = await metadata_routes.get_metadata_images("scene-slug") + + payload = json.loads(response.body) + self.assertEqual(response.status_code, 200) + self.assertEqual(payload["MediaContainer"]["size"], 0) + self.assertEqual(payload["MediaContainer"]["totalSize"], 0) + self.assertEqual(payload["MediaContainer"]["Metadata"], []) + service.get_images.assert_called_once_with("scene-slug") + async def test_get_metadata_response_unchanged(self): service = Mock() service.get_metadata.return_value = { diff --git a/tests/test_tpdb_enrichment.py b/tests/test_tpdb_enrichment.py index f8a1d5e..f0d0a2a 100644 --- a/tests/test_tpdb_enrichment.py +++ b/tests/test_tpdb_enrichment.py @@ -74,6 +74,16 @@ def test_map_scene_to_images_returns_unique_image_urls(self): self.assertSetEqual(image_types, {"poster", "art"}) + def test_map_scene_to_images_returns_empty_when_no_images(self): + scene = { + "slug": "scene-slug", + "title": "Scene Without Artwork", + } + + images = map_scene_to_images(scene) + + self.assertEqual(images, []) + def test_legacy_scene_and_actor_image_fields_are_mapped(self): scene = { "slug": "scene-slug", @@ -96,6 +106,17 @@ def test_legacy_scene_and_actor_image_fields_are_mapped(self): self.assertEqual(image_by_type.get("poster"), "https://img/poster-large.jpg") self.assertEqual(image_by_type.get("art"), "https://img/background-full.jpg") + def test_role_identifier_prefers_performer_id_over_slug(self): + scene = { + "id": "123", + "title": "Scene", + "performers": [{"id": "performer-id", "slug": "performer-slug", "name": "Performer"}], + } + + metadata = map_scene_to_metadata(scene) + + self.assertEqual(metadata.get("Role"), [{"tag": "Performer", "id": "tpdb://performer/performer-id"}]) + class MetadataHydrationTests(unittest.TestCase): def test_hydrates_performer_and_site_with_caching(self):