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 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.5-flash",
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
- "• Query TMDb (themoviedb) for official posters, backdrops, and metadata (`tmdb_search`, `tmdb_get_details`).\n"
149
- "• Query Fanart.tv for high-resolution posters, clearart, and backgrounds (`fanart_get_images`).\n"
 
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 and check its metadata.\n"
154
- " b) THEN search TMDb or Fanart using the movie name or TMDb ID to discover the best authentic posters and artwork.\n"
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 = 30,
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 (TMDB, Fanart.tv, OpenSubtitles).\n"
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=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, [], last_err or "All configured AI models failed to respond."
 
 
 
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 = 25,
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=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
- return 504, None, f"OmniRoute request timed out after {timeout_seconds} seconds."
 
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(hit.get("poster_path")),
235
- "has_backdrop": bool(hit.get("backdrop_path")),
 
 
 
236
  })
237
  return results
238
 
@@ -280,8 +304,414 @@ class ProvidersClient:
280
  "has_backdrop": bool(data.get("backdrop_path")),
281
  }
282
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
  # ==========================================================================
284
- # 2. Fanart.tv
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 8 tools
124
- assert len(registered_tools) == 8, f"Expected 8 tools, got {len(registered_tools)}: {[t.name for t in 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 8 tools must be strictly read-only
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