Spaces:
Running
Running
Hermes Agent commited on
Commit ·
5abd90f
1
Parent(s): a373640
fix(ai_admin): optimize model timeout failover and expand free artwork providers with Cinemeta, TVmaze, and TMDb images
Browse files- ai_admin/config.py +10 -4
- ai_admin/handler.py +7 -5
- ai_admin/net_guard.py +6 -0
- ai_admin/omniroute_adapter.py +16 -8
- ai_admin/providers_client.py +434 -4
- ai_admin/tools_providers.py +201 -0
- tests/ai_admin/test_provider_tools.py +83 -0
- tests/ai_admin/test_stage6_e2e_integration.py +8 -3
ai_admin/config.py
CHANGED
|
@@ -10,12 +10,10 @@ logger = logging.getLogger("AIAdmin.Config")
|
|
| 10 |
|
| 11 |
DEFAULT_WORKING_MODELS: List[str] = [
|
| 12 |
"oc/gemini-3.5-flash-lite",
|
| 13 |
-
"oc/gemini-3.
|
| 14 |
"oc/gemini-3.6-flash",
|
| 15 |
-
"oc/claude-sonnet-4-6",
|
| 16 |
-
"oc/grok-4.5",
|
| 17 |
-
"oc/glm-5.3-flash",
|
| 18 |
"oc/kimi-k3",
|
|
|
|
| 19 |
]
|
| 20 |
|
| 21 |
|
|
@@ -62,6 +60,7 @@ class AIAdminConfig:
|
|
| 62 |
ai_model: str = "oc/gemini-3.5-flash-lite"
|
| 63 |
ai_models: List[str] = field(default_factory=list)
|
| 64 |
ai_max_tokens: int = 1024
|
|
|
|
| 65 |
|
| 66 |
# Storage and Lifetimes
|
| 67 |
data_dir: str = "/data/ai_admin"
|
|
@@ -126,6 +125,12 @@ class AIAdminConfig:
|
|
| 126 |
except ValueError:
|
| 127 |
max_tokens = 1024
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
data_dir = os.environ.get("AI_DATA_DIR") or "/data/ai_admin"
|
| 130 |
|
| 131 |
try:
|
|
@@ -164,6 +169,7 @@ class AIAdminConfig:
|
|
| 164 |
ai_model=primary_model,
|
| 165 |
ai_models=ai_models,
|
| 166 |
ai_max_tokens=max_tokens,
|
|
|
|
| 167 |
data_dir=data_dir,
|
| 168 |
confirm_ttl_seconds=confirm_ttl,
|
| 169 |
context_ttl_seconds=context_ttl,
|
|
|
|
| 10 |
|
| 11 |
DEFAULT_WORKING_MODELS: List[str] = [
|
| 12 |
"oc/gemini-3.5-flash-lite",
|
| 13 |
+
"oc/gemini-3.7-flash",
|
| 14 |
"oc/gemini-3.6-flash",
|
|
|
|
|
|
|
|
|
|
| 15 |
"oc/kimi-k3",
|
| 16 |
+
"oc/glm-5.3-flash",
|
| 17 |
]
|
| 18 |
|
| 19 |
|
|
|
|
| 60 |
ai_model: str = "oc/gemini-3.5-flash-lite"
|
| 61 |
ai_models: List[str] = field(default_factory=list)
|
| 62 |
ai_max_tokens: int = 1024
|
| 63 |
+
omniroute_timeout_seconds: int = 18
|
| 64 |
|
| 65 |
# Storage and Lifetimes
|
| 66 |
data_dir: str = "/data/ai_admin"
|
|
|
|
| 125 |
except ValueError:
|
| 126 |
max_tokens = 1024
|
| 127 |
|
| 128 |
+
try:
|
| 129 |
+
timeout_env = int(os.environ.get("OMNIROUTE_TIMEOUT_SECONDS", "18"))
|
| 130 |
+
omniroute_timeout = max(5, min(timeout_env, 60))
|
| 131 |
+
except ValueError:
|
| 132 |
+
omniroute_timeout = 18
|
| 133 |
+
|
| 134 |
data_dir = os.environ.get("AI_DATA_DIR") or "/data/ai_admin"
|
| 135 |
|
| 136 |
try:
|
|
|
|
| 169 |
ai_model=primary_model,
|
| 170 |
ai_models=ai_models,
|
| 171 |
ai_max_tokens=max_tokens,
|
| 172 |
+
omniroute_timeout_seconds=omniroute_timeout,
|
| 173 |
data_dir=data_dir,
|
| 174 |
confirm_ttl_seconds=confirm_ttl,
|
| 175 |
context_ttl_seconds=context_ttl,
|
ai_admin/handler.py
CHANGED
|
@@ -145,14 +145,15 @@ class AIAdminHandler:
|
|
| 145 |
"You are an intelligent, friendly, and expert Jellyfin Media Server AI Assistant.\n"
|
| 146 |
"You have direct control to inspect and manage the Jellyfin library and query metadata/artwork providers:\n"
|
| 147 |
"• Search Jellyfin library for movies, series, episodes (`jellyfin_search`, `jellyfin_get_item`, `jellyfin_get_media_info`).\n"
|
| 148 |
-
"•
|
| 149 |
-
"•
|
|
|
|
| 150 |
"• Search subtitles on OpenSubtitles (`opensubtitles_search`).\n\n"
|
| 151 |
"Workflow Guidelines:\n"
|
| 152 |
"1. When the user asks to find a movie/series and check or fix its poster/thumbnail:\n"
|
| 153 |
-
" a) FIRST search Jellyfin to locate the item
|
| 154 |
-
" b) THEN search
|
| 155 |
-
" c) PRESENT the results conversationally with titles, years, current status, and direct preview links for the best posters found.\n"
|
| 156 |
" d) Offer to apply the selected poster.\n"
|
| 157 |
"2. If the user is just chatting or asking a question that doesn't need tools, reply warmly, concisely, and conversationally.\n"
|
| 158 |
"3. Format all responses using Telegram markdown (*bold*, `code`, [link](url)). Never output raw JSON unless specifically requested."
|
|
@@ -171,6 +172,7 @@ class AIAdminHandler:
|
|
| 171 |
planned_ok, tool_calls, reply_or_err = await self.omniroute.chat_or_plan(
|
| 172 |
messages=messages,
|
| 173 |
tool_schemas=schemas,
|
|
|
|
| 174 |
)
|
| 175 |
|
| 176 |
if not planned_ok:
|
|
|
|
| 145 |
"You are an intelligent, friendly, and expert Jellyfin Media Server AI Assistant.\n"
|
| 146 |
"You have direct control to inspect and manage the Jellyfin library and query metadata/artwork providers:\n"
|
| 147 |
"• Search Jellyfin library for movies, series, episodes (`jellyfin_search`, `jellyfin_get_item`, `jellyfin_get_media_info`).\n"
|
| 148 |
+
"• Search artwork & posters across providers (`artwork_search` for multi-provider search, `tmdb_get_images`, `cinemeta_search`, `cinemeta_get_meta`, `tvmaze_search`, `fanart_get_images`).\n"
|
| 149 |
+
"• Free providers like Cinemeta (IMDb catalog) and TVmaze require zero API keys and find authentic posters/thumbnails for movies and series.\n"
|
| 150 |
+
"• Query TMDb (themoviedb) for official details and images (`tmdb_search`, `tmdb_get_details`, `tmdb_get_images`).\n"
|
| 151 |
"• Search subtitles on OpenSubtitles (`opensubtitles_search`).\n\n"
|
| 152 |
"Workflow Guidelines:\n"
|
| 153 |
"1. When the user asks to find a movie/series and check or fix its poster/thumbnail:\n"
|
| 154 |
+
" a) FIRST search Jellyfin (`jellyfin_search`) to locate the item, its ID, current metadata, and existing artwork.\n"
|
| 155 |
+
" b) THEN search artwork via `artwork_search` or `cinemeta_search` / `tmdb_get_images` using the title or IMDb/TMDb ID.\n"
|
| 156 |
+
" c) PRESENT the results conversationally with titles, years, current status, and direct preview links for the best authentic posters found.\n"
|
| 157 |
" d) Offer to apply the selected poster.\n"
|
| 158 |
"2. If the user is just chatting or asking a question that doesn't need tools, reply warmly, concisely, and conversationally.\n"
|
| 159 |
"3. Format all responses using Telegram markdown (*bold*, `code`, [link](url)). Never output raw JSON unless specifically requested."
|
|
|
|
| 172 |
planned_ok, tool_calls, reply_or_err = await self.omniroute.chat_or_plan(
|
| 173 |
messages=messages,
|
| 174 |
tool_schemas=schemas,
|
| 175 |
+
timeout_seconds=self.config.omniroute_timeout_seconds,
|
| 176 |
)
|
| 177 |
|
| 178 |
if not planned_ok:
|
ai_admin/net_guard.py
CHANGED
|
@@ -17,6 +17,12 @@ APPROVED_PROVIDER_HOSTS: Set[str] = {
|
|
| 17 |
"opensubtitles-v3.strem.io",
|
| 18 |
"webservice.fanart.tv",
|
| 19 |
"assets.fanart.tv",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
|
|
|
|
| 17 |
"opensubtitles-v3.strem.io",
|
| 18 |
"webservice.fanart.tv",
|
| 19 |
"assets.fanart.tv",
|
| 20 |
+
"v3-cinemeta.strem.io",
|
| 21 |
+
"images.metahub.space",
|
| 22 |
+
"m.media-amazon.com",
|
| 23 |
+
"ia.media-imdb.com",
|
| 24 |
+
"api.tvmaze.com",
|
| 25 |
+
"static.tvmaze.com",
|
| 26 |
}
|
| 27 |
|
| 28 |
|
ai_admin/omniroute_adapter.py
CHANGED
|
@@ -93,7 +93,7 @@ class OmniRouteAdapter:
|
|
| 93 |
context_history: Optional[List[Dict[str, str]]] = None,
|
| 94 |
tool_schemas: Optional[List[Dict[str, Any]]] = None,
|
| 95 |
messages: Optional[List[Dict[str, Any]]] = None,
|
| 96 |
-
timeout_seconds: int =
|
| 97 |
) -> Tuple[bool, List[Dict[str, Any]], str]:
|
| 98 |
"""Send instruction or pre-built messages to OmniRoute.
|
| 99 |
Returns:
|
|
@@ -105,10 +105,12 @@ class OmniRouteAdapter:
|
|
| 105 |
if not (self.config.omniroute_api_url and self.config.omniroute_api_key):
|
| 106 |
return False, [], "OmniRoute configuration is missing (OMNIROUTE_API_KEY / OMNIROUTE_API_URL)."
|
| 107 |
|
|
|
|
|
|
|
| 108 |
if messages is None:
|
| 109 |
system_prompt = (
|
| 110 |
"You are an intelligent, helpful, and friendly Jellyfin Media Server AI Assistant.\n"
|
| 111 |
-
"You have access to tools to inspect and manage the Jellyfin library and query metadata/artwork providers (
|
| 112 |
"If the user asks to find, check, compare, or fix posters, thumbnails, subtitles, or metadata, use the appropriate tools.\n"
|
| 113 |
"If the user greets you or asks general questions that do not require tool operations, answer conversationally and helpfully.\n"
|
| 114 |
"Format your answers with clean Telegram markdown (*bold*, `code`, [link](url))."
|
|
@@ -134,8 +136,8 @@ class OmniRouteAdapter:
|
|
| 134 |
if tool_schemas:
|
| 135 |
payload["tools"] = tool_schemas
|
| 136 |
|
| 137 |
-
logger.info(f"[OMNIROUTE-CHAT] Attempting model '{model}'...")
|
| 138 |
-
status, data, err_msg = await self._send_request(payload, timeout_seconds=
|
| 139 |
|
| 140 |
if status in (401, 403):
|
| 141 |
return False, [], err_msg
|
|
@@ -169,19 +171,24 @@ class OmniRouteAdapter:
|
|
| 169 |
content = (msg.get("content") or "").strip()
|
| 170 |
return True, parsed_calls, content
|
| 171 |
|
| 172 |
-
return False, [],
|
|
|
|
|
|
|
|
|
|
| 173 |
|
| 174 |
async def synthesize_response(
|
| 175 |
self,
|
| 176 |
instruction: str,
|
| 177 |
context_history: List[Dict[str, str]],
|
| 178 |
tool_results: List[Dict[str, Any]],
|
| 179 |
-
timeout_seconds: int =
|
| 180 |
) -> Tuple[bool, str]:
|
| 181 |
"""Synthesize tool execution results into a natural, conversational response."""
|
| 182 |
if not (self.config.omniroute_api_url and self.config.omniroute_api_key):
|
| 183 |
return False, ""
|
| 184 |
|
|
|
|
|
|
|
| 185 |
system_prompt = (
|
| 186 |
"You are an intelligent, friendly Jellyfin Media Server AI Assistant.\n"
|
| 187 |
"Explain the results of the operations conversationally to the user.\n"
|
|
@@ -214,7 +221,7 @@ class OmniRouteAdapter:
|
|
| 214 |
"messages": messages,
|
| 215 |
}
|
| 216 |
try:
|
| 217 |
-
status, data, err_msg = await self._send_request(payload, timeout_seconds=
|
| 218 |
if status == 200 and isinstance(data, dict):
|
| 219 |
choices = data.get("choices") or []
|
| 220 |
if choices:
|
|
@@ -291,7 +298,8 @@ class OmniRouteAdapter:
|
|
| 291 |
return 500, None, f"Malformed JSON from OmniRoute: {json_err}"
|
| 292 |
|
| 293 |
except asyncio.TimeoutError:
|
| 294 |
-
|
|
|
|
| 295 |
except Exception as e:
|
| 296 |
return 500, None, f"Network error contacting OmniRoute: {type(e).__name__}"
|
| 297 |
finally:
|
|
|
|
| 93 |
context_history: Optional[List[Dict[str, str]]] = None,
|
| 94 |
tool_schemas: Optional[List[Dict[str, Any]]] = None,
|
| 95 |
messages: Optional[List[Dict[str, Any]]] = None,
|
| 96 |
+
timeout_seconds: Optional[int] = None,
|
| 97 |
) -> Tuple[bool, List[Dict[str, Any]], str]:
|
| 98 |
"""Send instruction or pre-built messages to OmniRoute.
|
| 99 |
Returns:
|
|
|
|
| 105 |
if not (self.config.omniroute_api_url and self.config.omniroute_api_key):
|
| 106 |
return False, [], "OmniRoute configuration is missing (OMNIROUTE_API_KEY / OMNIROUTE_API_URL)."
|
| 107 |
|
| 108 |
+
effective_timeout = timeout_seconds or getattr(self.config, "omniroute_timeout_seconds", 18)
|
| 109 |
+
|
| 110 |
if messages is None:
|
| 111 |
system_prompt = (
|
| 112 |
"You are an intelligent, helpful, and friendly Jellyfin Media Server AI Assistant.\n"
|
| 113 |
+
"You have access to tools to inspect and manage the Jellyfin library and query metadata/artwork providers (TMDb, Cinemeta, TVmaze, Fanart.tv, OpenSubtitles).\n"
|
| 114 |
"If the user asks to find, check, compare, or fix posters, thumbnails, subtitles, or metadata, use the appropriate tools.\n"
|
| 115 |
"If the user greets you or asks general questions that do not require tool operations, answer conversationally and helpfully.\n"
|
| 116 |
"Format your answers with clean Telegram markdown (*bold*, `code`, [link](url))."
|
|
|
|
| 136 |
if tool_schemas:
|
| 137 |
payload["tools"] = tool_schemas
|
| 138 |
|
| 139 |
+
logger.info(f"[OMNIROUTE-CHAT] Attempting model '{model}' with {effective_timeout}s timeout...")
|
| 140 |
+
status, data, err_msg = await self._send_request(payload, timeout_seconds=effective_timeout)
|
| 141 |
|
| 142 |
if status in (401, 403):
|
| 143 |
return False, [], err_msg
|
|
|
|
| 171 |
content = (msg.get("content") or "").strip()
|
| 172 |
return True, parsed_calls, content
|
| 173 |
|
| 174 |
+
return False, [], (
|
| 175 |
+
f"All configured AI models timed out or failed to respond ({last_err})."
|
| 176 |
+
if last_err else "All configured AI models failed to respond."
|
| 177 |
+
)
|
| 178 |
|
| 179 |
async def synthesize_response(
|
| 180 |
self,
|
| 181 |
instruction: str,
|
| 182 |
context_history: List[Dict[str, str]],
|
| 183 |
tool_results: List[Dict[str, Any]],
|
| 184 |
+
timeout_seconds: Optional[int] = None,
|
| 185 |
) -> Tuple[bool, str]:
|
| 186 |
"""Synthesize tool execution results into a natural, conversational response."""
|
| 187 |
if not (self.config.omniroute_api_url and self.config.omniroute_api_key):
|
| 188 |
return False, ""
|
| 189 |
|
| 190 |
+
effective_timeout = timeout_seconds or getattr(self.config, "omniroute_timeout_seconds", 18)
|
| 191 |
+
|
| 192 |
system_prompt = (
|
| 193 |
"You are an intelligent, friendly Jellyfin Media Server AI Assistant.\n"
|
| 194 |
"Explain the results of the operations conversationally to the user.\n"
|
|
|
|
| 221 |
"messages": messages,
|
| 222 |
}
|
| 223 |
try:
|
| 224 |
+
status, data, err_msg = await self._send_request(payload, timeout_seconds=effective_timeout)
|
| 225 |
if status == 200 and isinstance(data, dict):
|
| 226 |
choices = data.get("choices") or []
|
| 227 |
if choices:
|
|
|
|
| 298 |
return 500, None, f"Malformed JSON from OmniRoute: {json_err}"
|
| 299 |
|
| 300 |
except asyncio.TimeoutError:
|
| 301 |
+
model_name = payload.get("model", "unknown")
|
| 302 |
+
return 504, None, f"OmniRoute request for '{model_name}' timed out after {timeout_seconds} seconds."
|
| 303 |
except Exception as e:
|
| 304 |
return 500, None, f"Network error contacting OmniRoute: {type(e).__name__}"
|
| 305 |
finally:
|
ai_admin/providers_client.py
CHANGED
|
@@ -184,9 +184,11 @@ class ProvidersClient:
|
|
| 184 |
year: Optional[int] = None,
|
| 185 |
language: Optional[str] = None,
|
| 186 |
limit: int = 10,
|
|
|
|
|
|
|
| 187 |
session: Optional[aiohttp.ClientSession] = None,
|
| 188 |
) -> List[Dict[str, Any]]:
|
| 189 |
-
"""Search TMDb for movie or TV titles with bounded output."""
|
| 190 |
clean_query = (query or "").strip()
|
| 191 |
if not clean_query:
|
| 192 |
return []
|
|
@@ -221,6 +223,25 @@ class ProvidersClient:
|
|
| 221 |
release_date = hit.get("release_date") or hit.get("first_air_date") or ""
|
| 222 |
parsed_year = int(release_date[:4]) if len(release_date) >= 4 and release_date[:4].isdigit() else None
|
| 223 |
overview = (hit.get("overview") or "").strip()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
|
| 225 |
results.append({
|
| 226 |
"tmdb_id": hit.get("id"),
|
|
@@ -231,8 +252,11 @@ class ProvidersClient:
|
|
| 231 |
"overview": overview[:200] + ("..." if len(overview) > 200 else ""),
|
| 232 |
"original_language": hit.get("original_language"),
|
| 233 |
"vote_average": hit.get("vote_average"),
|
| 234 |
-
"has_poster": bool(
|
| 235 |
-
"
|
|
|
|
|
|
|
|
|
|
| 236 |
})
|
| 237 |
return results
|
| 238 |
|
|
@@ -280,8 +304,414 @@ class ProvidersClient:
|
|
| 280 |
"has_backdrop": bool(data.get("backdrop_path")),
|
| 281 |
}
|
| 282 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
# ==========================================================================
|
| 284 |
-
#
|
| 285 |
# ==========================================================================
|
| 286 |
|
| 287 |
async def fanart_get_images(
|
|
|
|
| 184 |
year: Optional[int] = None,
|
| 185 |
language: Optional[str] = None,
|
| 186 |
limit: int = 10,
|
| 187 |
+
user_id: Optional[int] = None,
|
| 188 |
+
chat_id: Optional[int] = None,
|
| 189 |
session: Optional[aiohttp.ClientSession] = None,
|
| 190 |
) -> List[Dict[str, Any]]:
|
| 191 |
+
"""Search TMDb for movie or TV titles with bounded output and candidate artwork handles."""
|
| 192 |
clean_query = (query or "").strip()
|
| 193 |
if not clean_query:
|
| 194 |
return []
|
|
|
|
| 223 |
release_date = hit.get("release_date") or hit.get("first_air_date") or ""
|
| 224 |
parsed_year = int(release_date[:4]) if len(release_date) >= 4 and release_date[:4].isdigit() else None
|
| 225 |
overview = (hit.get("overview") or "").strip()
|
| 226 |
+
poster_path = hit.get("poster_path")
|
| 227 |
+
backdrop_path = hit.get("backdrop_path")
|
| 228 |
+
|
| 229 |
+
poster_url = f"https://image.tmdb.org/t/p/w500{poster_path}" if poster_path else None
|
| 230 |
+
backdrop_url = f"https://image.tmdb.org/t/p/w1280{backdrop_path}" if backdrop_path else None
|
| 231 |
+
|
| 232 |
+
poster_cand = None
|
| 233 |
+
if poster_url:
|
| 234 |
+
poster_cand = self.candidate_cache.register_candidate(
|
| 235 |
+
{
|
| 236 |
+
"provider": "themoviedb.org",
|
| 237 |
+
"raw_url": poster_url,
|
| 238 |
+
"image_type": "poster",
|
| 239 |
+
"target_id": str(hit.get("id")),
|
| 240 |
+
},
|
| 241 |
+
user_id=user_id,
|
| 242 |
+
chat_id=chat_id,
|
| 243 |
+
operation="image_artwork",
|
| 244 |
+
)
|
| 245 |
|
| 246 |
results.append({
|
| 247 |
"tmdb_id": hit.get("id"),
|
|
|
|
| 252 |
"overview": overview[:200] + ("..." if len(overview) > 200 else ""),
|
| 253 |
"original_language": hit.get("original_language"),
|
| 254 |
"vote_average": hit.get("vote_average"),
|
| 255 |
+
"has_poster": bool(poster_path),
|
| 256 |
+
"poster_url": poster_url,
|
| 257 |
+
"poster_candidate_id": poster_cand,
|
| 258 |
+
"has_backdrop": bool(backdrop_path),
|
| 259 |
+
"backdrop_url": backdrop_url,
|
| 260 |
})
|
| 261 |
return results
|
| 262 |
|
|
|
|
| 304 |
"has_backdrop": bool(data.get("backdrop_path")),
|
| 305 |
}
|
| 306 |
|
| 307 |
+
async def tmdb_get_images(
|
| 308 |
+
self,
|
| 309 |
+
tmdb_id: int,
|
| 310 |
+
media_type: str = "movie",
|
| 311 |
+
limit: int = 20,
|
| 312 |
+
user_id: Optional[int] = None,
|
| 313 |
+
chat_id: Optional[int] = None,
|
| 314 |
+
session: Optional[aiohttp.ClientSession] = None,
|
| 315 |
+
) -> List[Dict[str, Any]]:
|
| 316 |
+
"""Fetch all official posters, backdrops, and logos from TMDb."""
|
| 317 |
+
if not isinstance(tmdb_id, int) or tmdb_id <= 0:
|
| 318 |
+
raise ValueError(f"Invalid tmdb_id: {tmdb_id}")
|
| 319 |
+
|
| 320 |
+
if not self.tmdb_key:
|
| 321 |
+
raise ProviderAuthError("TMDb API key not configured (TMDB_API_KEY).")
|
| 322 |
+
|
| 323 |
+
clean_type = "tv" if str(media_type).lower() in ("tv", "series") else "movie"
|
| 324 |
+
url = f"https://api.themoviedb.org/3/{clean_type}/{tmdb_id}/images"
|
| 325 |
+
params = {"api_key": self.tmdb_key}
|
| 326 |
+
|
| 327 |
+
data = await self._safe_get(url, params=params, session=session)
|
| 328 |
+
bounded_limit = max(1, min(limit, 40))
|
| 329 |
+
candidates = []
|
| 330 |
+
|
| 331 |
+
categories = [
|
| 332 |
+
("posters", "poster"),
|
| 333 |
+
("backdrops", "backdrop"),
|
| 334 |
+
("logos", "clearlogo"),
|
| 335 |
+
]
|
| 336 |
+
|
| 337 |
+
for cat_key, canonical_type in categories:
|
| 338 |
+
items = data.get(cat_key) or []
|
| 339 |
+
for item in items:
|
| 340 |
+
if not isinstance(item, dict):
|
| 341 |
+
continue
|
| 342 |
+
file_path = item.get("file_path")
|
| 343 |
+
if not file_path:
|
| 344 |
+
continue
|
| 345 |
+
raw_url = f"https://image.tmdb.org/t/p/original{file_path}"
|
| 346 |
+
preview_url = f"https://image.tmdb.org/t/p/w500{file_path}"
|
| 347 |
+
cand_id = self.candidate_cache.register_candidate(
|
| 348 |
+
{
|
| 349 |
+
"provider": "themoviedb.org",
|
| 350 |
+
"raw_url": raw_url,
|
| 351 |
+
"image_type": canonical_type,
|
| 352 |
+
"target_id": str(tmdb_id),
|
| 353 |
+
},
|
| 354 |
+
user_id=user_id,
|
| 355 |
+
chat_id=chat_id,
|
| 356 |
+
operation="image_artwork",
|
| 357 |
+
)
|
| 358 |
+
candidates.append({
|
| 359 |
+
"candidate_id": cand_id,
|
| 360 |
+
"image_type": canonical_type,
|
| 361 |
+
"preview_url": preview_url,
|
| 362 |
+
"full_url": raw_url,
|
| 363 |
+
"width": item.get("width"),
|
| 364 |
+
"height": item.get("height"),
|
| 365 |
+
"vote_average": item.get("vote_average"),
|
| 366 |
+
"provider": "themoviedb.org",
|
| 367 |
+
})
|
| 368 |
+
if len(candidates) >= bounded_limit:
|
| 369 |
+
break
|
| 370 |
+
if len(candidates) >= bounded_limit:
|
| 371 |
+
break
|
| 372 |
+
|
| 373 |
+
return candidates
|
| 374 |
+
|
| 375 |
+
# ==========================================================================
|
| 376 |
+
# 2. Free Stremio Cinemeta (No API Key Required)
|
| 377 |
+
# ==========================================================================
|
| 378 |
+
|
| 379 |
+
async def cinemeta_search(
|
| 380 |
+
self,
|
| 381 |
+
query: str,
|
| 382 |
+
media_type: str = "movie",
|
| 383 |
+
limit: int = 10,
|
| 384 |
+
user_id: Optional[int] = None,
|
| 385 |
+
chat_id: Optional[int] = None,
|
| 386 |
+
session: Optional[aiohttp.ClientSession] = None,
|
| 387 |
+
) -> List[Dict[str, Any]]:
|
| 388 |
+
"""Search Stremio Cinemeta catalog (free, no API key required). Returns IMDb items with posters."""
|
| 389 |
+
clean_query = (query or "").strip()
|
| 390 |
+
if not clean_query:
|
| 391 |
+
return []
|
| 392 |
+
|
| 393 |
+
clean_type = "series" if str(media_type).lower() in ("tv", "series") else "movie"
|
| 394 |
+
encoded_query = urllib.parse.quote(clean_query)
|
| 395 |
+
url = f"https://v3-cinemeta.strem.io/catalog/{clean_type}/top/search={encoded_query}.json"
|
| 396 |
+
|
| 397 |
+
data = await self._safe_get(url, session=session)
|
| 398 |
+
metas = data.get("metas") or []
|
| 399 |
+
bounded_limit = max(1, min(limit, 20))
|
| 400 |
+
results = []
|
| 401 |
+
|
| 402 |
+
for m in metas[:bounded_limit]:
|
| 403 |
+
imdb_id = m.get("imdb_id") or m.get("id") or ""
|
| 404 |
+
name = m.get("name") or "Unknown"
|
| 405 |
+
year = m.get("year")
|
| 406 |
+
poster_url = m.get("poster")
|
| 407 |
+
cand_id = None
|
| 408 |
+
if poster_url:
|
| 409 |
+
cand_id = self.candidate_cache.register_candidate(
|
| 410 |
+
{
|
| 411 |
+
"provider": "cinemeta",
|
| 412 |
+
"raw_url": poster_url,
|
| 413 |
+
"image_type": "poster",
|
| 414 |
+
"target_id": imdb_id or name,
|
| 415 |
+
},
|
| 416 |
+
user_id=user_id,
|
| 417 |
+
chat_id=chat_id,
|
| 418 |
+
operation="image_artwork",
|
| 419 |
+
)
|
| 420 |
+
|
| 421 |
+
results.append({
|
| 422 |
+
"imdb_id": imdb_id,
|
| 423 |
+
"title": name,
|
| 424 |
+
"year": year,
|
| 425 |
+
"media_type": clean_type,
|
| 426 |
+
"poster_url": poster_url,
|
| 427 |
+
"candidate_id": cand_id,
|
| 428 |
+
"description": (m.get("description") or "")[:200],
|
| 429 |
+
"provider": "cinemeta (IMDb)",
|
| 430 |
+
})
|
| 431 |
+
return results
|
| 432 |
+
|
| 433 |
+
async def cinemeta_get_meta(
|
| 434 |
+
self,
|
| 435 |
+
imdb_id: str,
|
| 436 |
+
media_type: str = "movie",
|
| 437 |
+
user_id: Optional[int] = None,
|
| 438 |
+
chat_id: Optional[int] = None,
|
| 439 |
+
session: Optional[aiohttp.ClientSession] = None,
|
| 440 |
+
) -> Dict[str, Any]:
|
| 441 |
+
"""Fetch detailed metadata and high-res artwork from Cinemeta (free, no API key)."""
|
| 442 |
+
clean_id = (imdb_id or "").strip()
|
| 443 |
+
if not clean_id or not clean_id.startswith("tt"):
|
| 444 |
+
raise ValueError(f"Invalid IMDb ID: {imdb_id}. Must start with 'tt'.")
|
| 445 |
+
|
| 446 |
+
clean_type = "series" if str(media_type).lower() in ("tv", "series") else "movie"
|
| 447 |
+
url = f"https://v3-cinemeta.strem.io/meta/{clean_type}/{clean_id}.json"
|
| 448 |
+
|
| 449 |
+
data = await self._safe_get(url, session=session)
|
| 450 |
+
meta = data.get("meta") or {}
|
| 451 |
+
|
| 452 |
+
poster_url = meta.get("poster")
|
| 453 |
+
background_url = meta.get("background")
|
| 454 |
+
logo_url = meta.get("logo")
|
| 455 |
+
|
| 456 |
+
poster_cand = None
|
| 457 |
+
if poster_url:
|
| 458 |
+
poster_cand = self.candidate_cache.register_candidate(
|
| 459 |
+
{"provider": "cinemeta", "raw_url": poster_url, "image_type": "poster", "target_id": clean_id},
|
| 460 |
+
user_id=user_id,
|
| 461 |
+
chat_id=chat_id,
|
| 462 |
+
operation="image_artwork",
|
| 463 |
+
)
|
| 464 |
+
|
| 465 |
+
backdrop_cand = None
|
| 466 |
+
if background_url:
|
| 467 |
+
backdrop_cand = self.candidate_cache.register_candidate(
|
| 468 |
+
{"provider": "cinemeta", "raw_url": background_url, "image_type": "backdrop", "target_id": clean_id},
|
| 469 |
+
user_id=user_id,
|
| 470 |
+
chat_id=chat_id,
|
| 471 |
+
operation="image_artwork",
|
| 472 |
+
)
|
| 473 |
+
|
| 474 |
+
return {
|
| 475 |
+
"imdb_id": clean_id,
|
| 476 |
+
"title": meta.get("name") or "Unknown",
|
| 477 |
+
"year": meta.get("year"),
|
| 478 |
+
"media_type": clean_type,
|
| 479 |
+
"description": meta.get("description"),
|
| 480 |
+
"genres": meta.get("genres") or [],
|
| 481 |
+
"imdb_rating": meta.get("imdbRating"),
|
| 482 |
+
"poster_url": poster_url,
|
| 483 |
+
"poster_candidate_id": poster_cand,
|
| 484 |
+
"background_url": background_url,
|
| 485 |
+
"background_candidate_id": backdrop_cand,
|
| 486 |
+
"logo_url": logo_url,
|
| 487 |
+
"provider": "cinemeta (IMDb)",
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
# ==========================================================================
|
| 491 |
+
# 3. Free TVmaze (No API Key Required)
|
| 492 |
+
# ==========================================================================
|
| 493 |
+
|
| 494 |
+
async def tvmaze_search(
|
| 495 |
+
self,
|
| 496 |
+
query: str,
|
| 497 |
+
limit: int = 10,
|
| 498 |
+
user_id: Optional[int] = None,
|
| 499 |
+
chat_id: Optional[int] = None,
|
| 500 |
+
session: Optional[aiohttp.ClientSession] = None,
|
| 501 |
+
) -> List[Dict[str, Any]]:
|
| 502 |
+
"""Search TVmaze for TV shows and official artwork (free, no API key)."""
|
| 503 |
+
clean_query = (query or "").strip()
|
| 504 |
+
if not clean_query:
|
| 505 |
+
return []
|
| 506 |
+
|
| 507 |
+
encoded_query = urllib.parse.quote(clean_query)
|
| 508 |
+
url = f"https://api.tvmaze.com/search/shows?q={encoded_query}"
|
| 509 |
+
|
| 510 |
+
data = await self._safe_get(url, session=session)
|
| 511 |
+
bounded_limit = max(1, min(limit, 20))
|
| 512 |
+
results = []
|
| 513 |
+
|
| 514 |
+
if isinstance(data, list):
|
| 515 |
+
for item in data[:bounded_limit]:
|
| 516 |
+
show = item.get("show") or {}
|
| 517 |
+
image = show.get("image") or {}
|
| 518 |
+
poster_url = image.get("original") or image.get("medium")
|
| 519 |
+
cand_id = None
|
| 520 |
+
if poster_url:
|
| 521 |
+
cand_id = self.candidate_cache.register_candidate(
|
| 522 |
+
{
|
| 523 |
+
"provider": "tvmaze",
|
| 524 |
+
"raw_url": poster_url,
|
| 525 |
+
"image_type": "poster",
|
| 526 |
+
"target_id": str(show.get("id")),
|
| 527 |
+
},
|
| 528 |
+
user_id=user_id,
|
| 529 |
+
chat_id=chat_id,
|
| 530 |
+
operation="image_artwork",
|
| 531 |
+
)
|
| 532 |
+
|
| 533 |
+
results.append({
|
| 534 |
+
"show_id": show.get("id"),
|
| 535 |
+
"title": show.get("name") or "Unknown",
|
| 536 |
+
"premiered": show.get("premiered"),
|
| 537 |
+
"genres": show.get("genres") or [],
|
| 538 |
+
"rating": (show.get("rating") or {}).get("average"),
|
| 539 |
+
"poster_url": poster_url,
|
| 540 |
+
"candidate_id": cand_id,
|
| 541 |
+
"summary": (show.get("summary") or "")[:200],
|
| 542 |
+
"provider": "tvmaze",
|
| 543 |
+
})
|
| 544 |
+
return results
|
| 545 |
+
|
| 546 |
+
# ==========================================================================
|
| 547 |
+
# 4. Unified Artwork Search (Multi-Provider Fallback)
|
| 548 |
+
# ==========================================================================
|
| 549 |
+
|
| 550 |
+
async def artwork_search(
|
| 551 |
+
self,
|
| 552 |
+
query: Optional[str] = None,
|
| 553 |
+
imdb_id: Optional[str] = None,
|
| 554 |
+
tmdb_id: Optional[int] = None,
|
| 555 |
+
media_type: str = "movie",
|
| 556 |
+
limit: int = 15,
|
| 557 |
+
user_id: Optional[int] = None,
|
| 558 |
+
chat_id: Optional[int] = None,
|
| 559 |
+
session: Optional[aiohttp.ClientSession] = None,
|
| 560 |
+
) -> List[Dict[str, Any]]:
|
| 561 |
+
"""Multi-provider artwork search across TMDb, Cinemeta, TVmaze, and Fanart.tv.
|
| 562 |
+
Guarantees posters & backdrops are found even if an API key is missing.
|
| 563 |
+
"""
|
| 564 |
+
candidates: List[Dict[str, Any]] = []
|
| 565 |
+
bounded_limit = max(1, min(limit, 30))
|
| 566 |
+
|
| 567 |
+
# 1. Try TMDb images if tmdb_id or query is available and tmdb_key is configured
|
| 568 |
+
if self.tmdb_key:
|
| 569 |
+
target_tmdb_id = tmdb_id
|
| 570 |
+
if not target_tmdb_id and query:
|
| 571 |
+
try:
|
| 572 |
+
hits = await self.tmdb_search(
|
| 573 |
+
query=query,
|
| 574 |
+
media_type=media_type,
|
| 575 |
+
limit=3,
|
| 576 |
+
user_id=user_id,
|
| 577 |
+
chat_id=chat_id,
|
| 578 |
+
session=session,
|
| 579 |
+
)
|
| 580 |
+
if hits:
|
| 581 |
+
target_tmdb_id = hits[0].get("tmdb_id")
|
| 582 |
+
if hits[0].get("poster_url"):
|
| 583 |
+
candidates.append({
|
| 584 |
+
"candidate_id": hits[0].get("poster_candidate_id"),
|
| 585 |
+
"image_type": "poster",
|
| 586 |
+
"preview_url": hits[0].get("poster_url"),
|
| 587 |
+
"title": hits[0].get("title"),
|
| 588 |
+
"provider": "themoviedb.org",
|
| 589 |
+
})
|
| 590 |
+
except Exception as e:
|
| 591 |
+
logger.debug(f"[ARTWORK-SEARCH] TMDb search skipped: {e}")
|
| 592 |
+
|
| 593 |
+
if target_tmdb_id:
|
| 594 |
+
try:
|
| 595 |
+
tmdb_imgs = await self.tmdb_get_images(
|
| 596 |
+
tmdb_id=target_tmdb_id,
|
| 597 |
+
media_type=media_type,
|
| 598 |
+
limit=bounded_limit,
|
| 599 |
+
user_id=user_id,
|
| 600 |
+
chat_id=chat_id,
|
| 601 |
+
session=session,
|
| 602 |
+
)
|
| 603 |
+
candidates.extend(tmdb_imgs)
|
| 604 |
+
except Exception as e:
|
| 605 |
+
logger.debug(f"[ARTWORK-SEARCH] TMDb get_images skipped: {e}")
|
| 606 |
+
|
| 607 |
+
# 2. Try Cinemeta (Free, no key needed) if IMDb ID or query is available
|
| 608 |
+
if len(candidates) < bounded_limit:
|
| 609 |
+
if imdb_id and imdb_id.startswith("tt"):
|
| 610 |
+
try:
|
| 611 |
+
meta = await self.cinemeta_get_meta(
|
| 612 |
+
imdb_id=imdb_id,
|
| 613 |
+
media_type=media_type,
|
| 614 |
+
user_id=user_id,
|
| 615 |
+
chat_id=chat_id,
|
| 616 |
+
session=session,
|
| 617 |
+
)
|
| 618 |
+
if meta.get("poster_url"):
|
| 619 |
+
candidates.append({
|
| 620 |
+
"candidate_id": meta.get("poster_candidate_id"),
|
| 621 |
+
"image_type": "poster",
|
| 622 |
+
"preview_url": meta.get("poster_url"),
|
| 623 |
+
"title": meta.get("title"),
|
| 624 |
+
"provider": "cinemeta (IMDb)",
|
| 625 |
+
})
|
| 626 |
+
if meta.get("background_url"):
|
| 627 |
+
candidates.append({
|
| 628 |
+
"candidate_id": meta.get("background_candidate_id"),
|
| 629 |
+
"image_type": "backdrop",
|
| 630 |
+
"preview_url": meta.get("background_url"),
|
| 631 |
+
"title": meta.get("title"),
|
| 632 |
+
"provider": "cinemeta (IMDb)",
|
| 633 |
+
})
|
| 634 |
+
except Exception as e:
|
| 635 |
+
logger.debug(f"[ARTWORK-SEARCH] Cinemeta get_meta skipped: {e}")
|
| 636 |
+
|
| 637 |
+
if len(candidates) < bounded_limit and query:
|
| 638 |
+
try:
|
| 639 |
+
c_hits = await self.cinemeta_search(
|
| 640 |
+
query=query,
|
| 641 |
+
media_type=media_type,
|
| 642 |
+
limit=5,
|
| 643 |
+
user_id=user_id,
|
| 644 |
+
chat_id=chat_id,
|
| 645 |
+
session=session,
|
| 646 |
+
)
|
| 647 |
+
for ch in c_hits:
|
| 648 |
+
if ch.get("poster_url"):
|
| 649 |
+
candidates.append({
|
| 650 |
+
"candidate_id": ch.get("candidate_id"),
|
| 651 |
+
"image_type": "poster",
|
| 652 |
+
"preview_url": ch.get("poster_url"),
|
| 653 |
+
"title": ch.get("title"),
|
| 654 |
+
"provider": "cinemeta (IMDb)",
|
| 655 |
+
})
|
| 656 |
+
except Exception as e:
|
| 657 |
+
logger.debug(f"[ARTWORK-SEARCH] Cinemeta search skipped: {e}")
|
| 658 |
+
|
| 659 |
+
# 3. Try TVmaze if TV series and query is provided (Free, no key needed)
|
| 660 |
+
if len(candidates) < bounded_limit and media_type in ("tv", "series") and query:
|
| 661 |
+
try:
|
| 662 |
+
tv_hits = await self.tvmaze_search(
|
| 663 |
+
query=query,
|
| 664 |
+
limit=5,
|
| 665 |
+
user_id=user_id,
|
| 666 |
+
chat_id=chat_id,
|
| 667 |
+
session=session,
|
| 668 |
+
)
|
| 669 |
+
for th in tv_hits:
|
| 670 |
+
if th.get("poster_url"):
|
| 671 |
+
candidates.append({
|
| 672 |
+
"candidate_id": th.get("candidate_id"),
|
| 673 |
+
"image_type": "poster",
|
| 674 |
+
"preview_url": th.get("poster_url"),
|
| 675 |
+
"title": th.get("title"),
|
| 676 |
+
"provider": "tvmaze",
|
| 677 |
+
})
|
| 678 |
+
except Exception as e:
|
| 679 |
+
logger.debug(f"[ARTWORK-SEARCH] TVmaze search skipped: {e}")
|
| 680 |
+
|
| 681 |
+
# 4. Try Fanart.tv if fanart_key is configured
|
| 682 |
+
if len(candidates) < bounded_limit and self.fanart_key:
|
| 683 |
+
lookup_id = imdb_id or (str(tmdb_id) if tmdb_id else None)
|
| 684 |
+
if lookup_id:
|
| 685 |
+
try:
|
| 686 |
+
f_imgs = await self.fanart_get_images(
|
| 687 |
+
id_str=lookup_id,
|
| 688 |
+
media_type=media_type,
|
| 689 |
+
limit=bounded_limit,
|
| 690 |
+
user_id=user_id,
|
| 691 |
+
chat_id=chat_id,
|
| 692 |
+
session=session,
|
| 693 |
+
)
|
| 694 |
+
candidates.extend(f_imgs)
|
| 695 |
+
except Exception as e:
|
| 696 |
+
logger.debug(f"[ARTWORK-SEARCH] Fanart lookup skipped: {e}")
|
| 697 |
+
|
| 698 |
+
# Deduplicate candidates by preview_url
|
| 699 |
+
seen_urls = set()
|
| 700 |
+
deduped = []
|
| 701 |
+
for cand in candidates:
|
| 702 |
+
url = cand.get("preview_url") or cand.get("full_url")
|
| 703 |
+
if url and url in seen_urls:
|
| 704 |
+
continue
|
| 705 |
+
if url:
|
| 706 |
+
seen_urls.add(url)
|
| 707 |
+
deduped.append(cand)
|
| 708 |
+
if len(deduped) >= bounded_limit:
|
| 709 |
+
break
|
| 710 |
+
|
| 711 |
+
return deduped
|
| 712 |
+
|
| 713 |
# ==========================================================================
|
| 714 |
+
# 5. Fanart.tv
|
| 715 |
# ==========================================================================
|
| 716 |
|
| 717 |
async def fanart_get_images(
|
ai_admin/tools_providers.py
CHANGED
|
@@ -93,6 +93,98 @@ def make_opensubtitles_search_handler(client: ProvidersClient):
|
|
| 93 |
return handler
|
| 94 |
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
# ==============================================================================
|
| 97 |
# Tool Definitions
|
| 98 |
# ==============================================================================
|
|
@@ -145,6 +237,115 @@ def get_provider_read_tool_definitions(client: ProvidersClient) -> List[ToolDefi
|
|
| 145 |
max_output_chars=4000,
|
| 146 |
handler=make_tmdb_get_details_handler(client),
|
| 147 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 148 |
ToolDefinition(
|
| 149 |
name="fanart_get_images",
|
| 150 |
purpose="Query Fanart.tv for high-resolution posters, clearlogos, banners, and backdrops.",
|
|
|
|
| 93 |
return handler
|
| 94 |
|
| 95 |
|
| 96 |
+
def make_tmdb_get_images_handler(client: ProvidersClient):
|
| 97 |
+
async def handler(params: Dict[str, Any]) -> Dict[str, Any]:
|
| 98 |
+
tmdb_id = params.get("tmdb_id")
|
| 99 |
+
media_type = params.get("media_type", "movie")
|
| 100 |
+
limit = params.get("limit", 20)
|
| 101 |
+
candidates = await client.tmdb_get_images(
|
| 102 |
+
tmdb_id=tmdb_id,
|
| 103 |
+
media_type=media_type,
|
| 104 |
+
limit=limit,
|
| 105 |
+
)
|
| 106 |
+
return {
|
| 107 |
+
"tmdb_id": tmdb_id,
|
| 108 |
+
"media_type": media_type,
|
| 109 |
+
"count": len(candidates),
|
| 110 |
+
"candidates": candidates,
|
| 111 |
+
}
|
| 112 |
+
return handler
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def make_cinemeta_search_handler(client: ProvidersClient):
|
| 116 |
+
async def handler(params: Dict[str, Any]) -> Dict[str, Any]:
|
| 117 |
+
query = params.get("query", "")
|
| 118 |
+
media_type = params.get("media_type", "movie")
|
| 119 |
+
limit = params.get("limit", 10)
|
| 120 |
+
results = await client.cinemeta_search(
|
| 121 |
+
query=query,
|
| 122 |
+
media_type=media_type,
|
| 123 |
+
limit=limit,
|
| 124 |
+
)
|
| 125 |
+
return {
|
| 126 |
+
"query": query,
|
| 127 |
+
"media_type": media_type,
|
| 128 |
+
"count": len(results),
|
| 129 |
+
"results": results,
|
| 130 |
+
"provider": "cinemeta (IMDb)",
|
| 131 |
+
}
|
| 132 |
+
return handler
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def make_cinemeta_get_meta_handler(client: ProvidersClient):
|
| 136 |
+
async def handler(params: Dict[str, Any]) -> Dict[str, Any]:
|
| 137 |
+
imdb_id = params.get("imdb_id", "")
|
| 138 |
+
media_type = params.get("media_type", "movie")
|
| 139 |
+
details = await client.cinemeta_get_meta(
|
| 140 |
+
imdb_id=imdb_id,
|
| 141 |
+
media_type=media_type,
|
| 142 |
+
)
|
| 143 |
+
return details
|
| 144 |
+
return handler
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def make_tvmaze_search_handler(client: ProvidersClient):
|
| 148 |
+
async def handler(params: Dict[str, Any]) -> Dict[str, Any]:
|
| 149 |
+
query = params.get("query", "")
|
| 150 |
+
limit = params.get("limit", 10)
|
| 151 |
+
results = await client.tvmaze_search(
|
| 152 |
+
query=query,
|
| 153 |
+
limit=limit,
|
| 154 |
+
)
|
| 155 |
+
return {
|
| 156 |
+
"query": query,
|
| 157 |
+
"count": len(results),
|
| 158 |
+
"results": results,
|
| 159 |
+
"provider": "tvmaze",
|
| 160 |
+
}
|
| 161 |
+
return handler
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def make_artwork_search_handler(client: ProvidersClient):
|
| 165 |
+
async def handler(params: Dict[str, Any]) -> Dict[str, Any]:
|
| 166 |
+
query = params.get("query")
|
| 167 |
+
imdb_id = params.get("imdb_id")
|
| 168 |
+
tmdb_id = params.get("tmdb_id")
|
| 169 |
+
media_type = params.get("media_type", "movie")
|
| 170 |
+
limit = params.get("limit", 15)
|
| 171 |
+
candidates = await client.artwork_search(
|
| 172 |
+
query=query,
|
| 173 |
+
imdb_id=imdb_id,
|
| 174 |
+
tmdb_id=tmdb_id,
|
| 175 |
+
media_type=media_type,
|
| 176 |
+
limit=limit,
|
| 177 |
+
)
|
| 178 |
+
return {
|
| 179 |
+
"query": query,
|
| 180 |
+
"imdb_id": imdb_id,
|
| 181 |
+
"tmdb_id": tmdb_id,
|
| 182 |
+
"count": len(candidates),
|
| 183 |
+
"candidates": candidates,
|
| 184 |
+
}
|
| 185 |
+
return handler
|
| 186 |
+
|
| 187 |
+
|
| 188 |
# ==============================================================================
|
| 189 |
# Tool Definitions
|
| 190 |
# ==============================================================================
|
|
|
|
| 237 |
max_output_chars=4000,
|
| 238 |
handler=make_tmdb_get_details_handler(client),
|
| 239 |
),
|
| 240 |
+
ToolDefinition(
|
| 241 |
+
name="tmdb_get_images",
|
| 242 |
+
purpose="Fetch all official posters, backdrops, and logos from TMDb by tmdb_id.",
|
| 243 |
+
json_schema={
|
| 244 |
+
"type": "object",
|
| 245 |
+
"properties": {
|
| 246 |
+
"tmdb_id": {"type": "integer"},
|
| 247 |
+
"media_type": {"type": "string", "maxLength": 10},
|
| 248 |
+
"limit": {"type": "integer"},
|
| 249 |
+
},
|
| 250 |
+
"required": ["tmdb_id"],
|
| 251 |
+
"additionalProperties": False,
|
| 252 |
+
},
|
| 253 |
+
read_only=True,
|
| 254 |
+
mutates=False,
|
| 255 |
+
risk="low",
|
| 256 |
+
requires_confirmation="never",
|
| 257 |
+
permission="read",
|
| 258 |
+
timeout_seconds=15,
|
| 259 |
+
max_output_chars=4000,
|
| 260 |
+
handler=make_tmdb_get_images_handler(client),
|
| 261 |
+
),
|
| 262 |
+
ToolDefinition(
|
| 263 |
+
name="cinemeta_search",
|
| 264 |
+
purpose="Search Stremio Cinemeta catalog (free, no API key required) for movies or series to get IMDb posters.",
|
| 265 |
+
json_schema={
|
| 266 |
+
"type": "object",
|
| 267 |
+
"properties": {
|
| 268 |
+
"query": {"type": "string", "maxLength": 100},
|
| 269 |
+
"media_type": {"type": "string", "maxLength": 10},
|
| 270 |
+
"limit": {"type": "integer"},
|
| 271 |
+
},
|
| 272 |
+
"required": ["query"],
|
| 273 |
+
"additionalProperties": False,
|
| 274 |
+
},
|
| 275 |
+
read_only=True,
|
| 276 |
+
mutates=False,
|
| 277 |
+
risk="low",
|
| 278 |
+
requires_confirmation="never",
|
| 279 |
+
permission="read",
|
| 280 |
+
timeout_seconds=15,
|
| 281 |
+
max_output_chars=4000,
|
| 282 |
+
handler=make_cinemeta_search_handler(client),
|
| 283 |
+
),
|
| 284 |
+
ToolDefinition(
|
| 285 |
+
name="cinemeta_get_meta",
|
| 286 |
+
purpose="Fetch high-resolution poster and background from Cinemeta (free, no API key required) by IMDb ID.",
|
| 287 |
+
json_schema={
|
| 288 |
+
"type": "object",
|
| 289 |
+
"properties": {
|
| 290 |
+
"imdb_id": {"type": "string", "maxLength": 20},
|
| 291 |
+
"media_type": {"type": "string", "maxLength": 10},
|
| 292 |
+
},
|
| 293 |
+
"required": ["imdb_id"],
|
| 294 |
+
"additionalProperties": False,
|
| 295 |
+
},
|
| 296 |
+
read_only=True,
|
| 297 |
+
mutates=False,
|
| 298 |
+
risk="low",
|
| 299 |
+
requires_confirmation="never",
|
| 300 |
+
permission="read",
|
| 301 |
+
timeout_seconds=15,
|
| 302 |
+
max_output_chars=4000,
|
| 303 |
+
handler=make_cinemeta_get_meta_handler(client),
|
| 304 |
+
),
|
| 305 |
+
ToolDefinition(
|
| 306 |
+
name="tvmaze_search",
|
| 307 |
+
purpose="Search TVmaze for TV shows and official artwork (free, no API key required).",
|
| 308 |
+
json_schema={
|
| 309 |
+
"type": "object",
|
| 310 |
+
"properties": {
|
| 311 |
+
"query": {"type": "string", "maxLength": 100},
|
| 312 |
+
"limit": {"type": "integer"},
|
| 313 |
+
},
|
| 314 |
+
"required": ["query"],
|
| 315 |
+
"additionalProperties": False,
|
| 316 |
+
},
|
| 317 |
+
read_only=True,
|
| 318 |
+
mutates=False,
|
| 319 |
+
risk="low",
|
| 320 |
+
requires_confirmation="never",
|
| 321 |
+
permission="read",
|
| 322 |
+
timeout_seconds=15,
|
| 323 |
+
max_output_chars=4000,
|
| 324 |
+
handler=make_tvmaze_search_handler(client),
|
| 325 |
+
),
|
| 326 |
+
ToolDefinition(
|
| 327 |
+
name="artwork_search",
|
| 328 |
+
purpose="Search posters and backdrops across multiple free and configured providers (TMDb, Cinemeta, TVmaze, Fanart.tv) with automatic fallback.",
|
| 329 |
+
json_schema={
|
| 330 |
+
"type": "object",
|
| 331 |
+
"properties": {
|
| 332 |
+
"query": {"type": "string", "maxLength": 100},
|
| 333 |
+
"imdb_id": {"type": "string", "maxLength": 20},
|
| 334 |
+
"tmdb_id": {"type": "integer"},
|
| 335 |
+
"media_type": {"type": "string", "maxLength": 10},
|
| 336 |
+
"limit": {"type": "integer"},
|
| 337 |
+
},
|
| 338 |
+
"additionalProperties": False,
|
| 339 |
+
},
|
| 340 |
+
read_only=True,
|
| 341 |
+
mutates=False,
|
| 342 |
+
risk="low",
|
| 343 |
+
requires_confirmation="never",
|
| 344 |
+
permission="read",
|
| 345 |
+
timeout_seconds=15,
|
| 346 |
+
max_output_chars=4000,
|
| 347 |
+
handler=make_artwork_search_handler(client),
|
| 348 |
+
),
|
| 349 |
ToolDefinition(
|
| 350 |
name="fanart_get_images",
|
| 351 |
purpose="Query Fanart.tv for high-resolution posters, clearlogos, banners, and backdrops.",
|
tests/ai_admin/test_provider_tools.py
CHANGED
|
@@ -463,3 +463,86 @@ def test_candidate_cache_expired_handle_fails_closed():
|
|
| 463 |
# Entry must be pruned from cache
|
| 464 |
assert cand_id not in short_cache._cache
|
| 465 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
# Entry must be pruned from cache
|
| 464 |
assert cand_id not in short_cache._cache
|
| 465 |
|
| 466 |
+
|
| 467 |
+
@pytest.mark.asyncio
|
| 468 |
+
async def test_tmdb_get_images_mocked(mock_providers_client):
|
| 469 |
+
"""Test TMDb images lookup returns candidates and covers posters and backdrops."""
|
| 470 |
+
mock_payload = {
|
| 471 |
+
"posters": [
|
| 472 |
+
{"file_path": "/poster1.jpg", "width": 1000, "height": 1500, "vote_average": 8.5},
|
| 473 |
+
],
|
| 474 |
+
"backdrops": [
|
| 475 |
+
{"file_path": "/backdrop1.jpg", "width": 1920, "height": 1080, "vote_average": 9.0},
|
| 476 |
+
],
|
| 477 |
+
}
|
| 478 |
+
with patch.object(mock_providers_client, "_safe_get", new=AsyncMock(return_value=mock_payload)):
|
| 479 |
+
images = await mock_providers_client.tmdb_get_images(tmdb_id=550, media_type="movie", user_id=1, chat_id=1)
|
| 480 |
+
assert len(images) == 2
|
| 481 |
+
assert images[0]["image_type"] == "poster"
|
| 482 |
+
assert "poster1.jpg" in images[0]["preview_url"]
|
| 483 |
+
assert images[0]["candidate_id"].startswith("cand_")
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
@pytest.mark.asyncio
|
| 487 |
+
async def test_cinemeta_search_and_get_meta(mock_providers_client):
|
| 488 |
+
"""Test free Cinemeta provider search and metadata lookup."""
|
| 489 |
+
search_payload = {
|
| 490 |
+
"metas": [
|
| 491 |
+
{"id": "tt22797990", "name": "Khalifa", "year": "2023", "poster": "https://m.media-amazon.com/poster.jpg", "description": "Thriller"},
|
| 492 |
+
]
|
| 493 |
+
}
|
| 494 |
+
with patch.object(mock_providers_client, "_safe_get", new=AsyncMock(return_value=search_payload)):
|
| 495 |
+
results = await mock_providers_client.cinemeta_search(query="khalifa", user_id=1, chat_id=1)
|
| 496 |
+
assert len(results) == 1
|
| 497 |
+
assert results[0]["title"] == "Khalifa"
|
| 498 |
+
assert results[0]["imdb_id"] == "tt22797990"
|
| 499 |
+
assert results[0]["candidate_id"] is not None
|
| 500 |
+
|
| 501 |
+
meta_payload = {
|
| 502 |
+
"meta": {
|
| 503 |
+
"name": "Khalifa",
|
| 504 |
+
"year": "2023",
|
| 505 |
+
"poster": "https://m.media-amazon.com/poster.jpg",
|
| 506 |
+
"background": "https://images.metahub.space/bg.jpg",
|
| 507 |
+
"genres": ["Action", "Thriller"],
|
| 508 |
+
}
|
| 509 |
+
}
|
| 510 |
+
with patch.object(mock_providers_client, "_safe_get", new=AsyncMock(return_value=meta_payload)):
|
| 511 |
+
details = await mock_providers_client.cinemeta_get_meta(imdb_id="tt22797990", user_id=1, chat_id=1)
|
| 512 |
+
assert details["title"] == "Khalifa"
|
| 513 |
+
assert details["poster_candidate_id"] is not None
|
| 514 |
+
assert details["background_candidate_id"] is not None
|
| 515 |
+
|
| 516 |
+
|
| 517 |
+
@pytest.mark.asyncio
|
| 518 |
+
async def test_tvmaze_search(mock_providers_client):
|
| 519 |
+
"""Test free TVmaze provider search."""
|
| 520 |
+
tvmaze_payload = [
|
| 521 |
+
{
|
| 522 |
+
"show": {
|
| 523 |
+
"id": 169,
|
| 524 |
+
"name": "Breaking Bad",
|
| 525 |
+
"premiered": "2008-01-20",
|
| 526 |
+
"image": {"original": "https://static.tvmaze.com/bb.jpg"},
|
| 527 |
+
}
|
| 528 |
+
}
|
| 529 |
+
]
|
| 530 |
+
with patch.object(mock_providers_client, "_safe_get", new=AsyncMock(return_value=tvmaze_payload)):
|
| 531 |
+
shows = await mock_providers_client.tvmaze_search(query="breaking bad", user_id=1, chat_id=1)
|
| 532 |
+
assert len(shows) == 1
|
| 533 |
+
assert shows[0]["title"] == "Breaking Bad"
|
| 534 |
+
assert shows[0]["candidate_id"] is not None
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
@pytest.mark.asyncio
|
| 538 |
+
async def test_artwork_search_fallback(mock_providers_client):
|
| 539 |
+
"""Test unified artwork search combines TMDb and Cinemeta."""
|
| 540 |
+
with patch.object(mock_providers_client, "tmdb_search", new=AsyncMock(return_value=[])):
|
| 541 |
+
with patch.object(mock_providers_client, "cinemeta_search", new=AsyncMock(return_value=[
|
| 542 |
+
{"title": "Khalifa", "poster_url": "https://m.media-amazon.com/poster.jpg", "candidate_id": "cand_123"}
|
| 543 |
+
])):
|
| 544 |
+
candidates = await mock_providers_client.artwork_search(query="khalifa", user_id=1, chat_id=1)
|
| 545 |
+
assert len(candidates) >= 1
|
| 546 |
+
assert candidates[0]["title"] == "Khalifa"
|
| 547 |
+
assert candidates[0]["provider"] == "cinemeta (IMDb)"
|
| 548 |
+
|
tests/ai_admin/test_stage6_e2e_integration.py
CHANGED
|
@@ -120,8 +120,8 @@ def test_stage6_exact_tool_count_and_absence_of_mutations(monkeypatch, tmp_path)
|
|
| 120 |
registry = router.handler.registry
|
| 121 |
registered_tools = registry.list_tools()
|
| 122 |
|
| 123 |
-
# Must contain exactly
|
| 124 |
-
assert len(registered_tools) ==
|
| 125 |
|
| 126 |
expected_tool_names = {
|
| 127 |
"jellyfin_search",
|
|
@@ -130,13 +130,18 @@ def test_stage6_exact_tool_count_and_absence_of_mutations(monkeypatch, tmp_path)
|
|
| 130 |
"jellyfin_get_media_info",
|
| 131 |
"tmdb_search",
|
| 132 |
"tmdb_get_details",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
"fanart_get_images",
|
| 134 |
"opensubtitles_search",
|
| 135 |
}
|
| 136 |
actual_tool_names = {t.name for t in registered_tools}
|
| 137 |
assert actual_tool_names == expected_tool_names
|
| 138 |
|
| 139 |
-
# All
|
| 140 |
for tool in registered_tools:
|
| 141 |
assert tool.read_only is True
|
| 142 |
assert tool.mutates is False
|
|
|
|
| 120 |
registry = router.handler.registry
|
| 121 |
registered_tools = registry.list_tools()
|
| 122 |
|
| 123 |
+
# Must contain exactly 13 read-only tools
|
| 124 |
+
assert len(registered_tools) == 13, f"Expected 13 tools, got {len(registered_tools)}: {[t.name for t in registered_tools]}"
|
| 125 |
|
| 126 |
expected_tool_names = {
|
| 127 |
"jellyfin_search",
|
|
|
|
| 130 |
"jellyfin_get_media_info",
|
| 131 |
"tmdb_search",
|
| 132 |
"tmdb_get_details",
|
| 133 |
+
"tmdb_get_images",
|
| 134 |
+
"cinemeta_search",
|
| 135 |
+
"cinemeta_get_meta",
|
| 136 |
+
"tvmaze_search",
|
| 137 |
+
"artwork_search",
|
| 138 |
"fanart_get_images",
|
| 139 |
"opensubtitles_search",
|
| 140 |
}
|
| 141 |
actual_tool_names = {t.name for t in registered_tools}
|
| 142 |
assert actual_tool_names == expected_tool_names
|
| 143 |
|
| 144 |
+
# All tools must be strictly read-only
|
| 145 |
for tool in registered_tools:
|
| 146 |
assert tool.read_only is True
|
| 147 |
assert tool.mutates is False
|