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..709e9a7 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.""" @@ -19,7 +22,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 +48,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 @@ -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,9 +84,61 @@ 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 "" +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 = [] + 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, + "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): @@ -133,6 +193,68 @@ def _get_studio(scene: dict[str, Any]) -> str: return "" +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. @@ -194,22 +316,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"), - ) - 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 [] @@ -233,6 +342,12 @@ def map_scene_to_match(scene: dict[str, Any], score: int = 100, media_type: int if collections: match_result["Collection"] = collections + match_result["isAdult"] = 1 + + guid_entries = _get_guid_entries(scene) + if guid_entries: + match_result["Guid"] = guid_entries + return match_result @@ -290,22 +405,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"), - ) - 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 [] @@ -329,4 +431,10 @@ def map_scene_to_metadata(scene: dict[str, Any], media_type: int = 1) -> dict[st if collections: metadata["Collection"] = collections + metadata["isAdult"] = 1 + + guid_entries = _get_guid_entries(scene) + if guid_entries: + metadata["Guid"] = guid_entries + return metadata 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/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 a44b687..ae85591 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__) @@ -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.""" @@ -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..a530061 --- /dev/null +++ b/tests/test_metadata_routes.py @@ -0,0 +1,78 @@ +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_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 = { + "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..f0d0a2a 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 @@ -15,10 +15,12 @@ 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", + "imdb_id": "tt1234567", + "ids": {"tmdb": "98765"}, } metadata = map_scene_to_metadata(scene) @@ -30,7 +32,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.assertEqual(metadata.get("isAdult"), 1) + self.assertEqual( + metadata.get("Guid"), + [{"id": "imdb://tt1234567"}, {"id": "tmdb://98765"}, {"id": "tpdb://123"}], + ) def test_match_supports_nested_images(self): scene = { @@ -40,12 +50,72 @@ def test_match_supports_nested_images(self): "poster": {"src": "https://img/poster.jpg"}, "background": {"url": "https://img/bg.jpg"}, }, + "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.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): + 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", "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", + "title": "Scene", + "posters": {"large": "https://img/poster-large.jpg"}, + "background": {"full": "https://img/background-full.jpg"}, + "performers": [{"slug": "perf-slug", "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", "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") + + 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):