Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
176 changes: 142 additions & 34 deletions provider/mappers/tpdb_to_plex.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Map TPDB scene data to Plex metadata format."""

import logging
from typing import Any

# Plex type mappings
Expand All @@ -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."""
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 ""


Expand All @@ -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
Comment on lines +117 to +139


def _normalize_people(items: Any) -> list[dict[str, str]]:
"""Normalize person payloads to Plex person format."""
if isinstance(items, dict):
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 []
Expand All @@ -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


Expand Down Expand Up @@ -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 []
Expand All @@ -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
1 change: 1 addition & 0 deletions provider/routes/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
],
}
}
Expand Down
32 changes: 32 additions & 0 deletions provider/routes/metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion provider/services/match_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
32 changes: 25 additions & 7 deletions provider/services/metadata_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
Loading