movie_service.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. """MovieService — TMDB HTTP 封装(D1 确定性通道)。
  2. 供 /api/movies/* 与 Agent Tool 复用;密钥仅来自环境变量。
  3. """
  4. from __future__ import annotations
  5. from typing import Any, Dict, List, Optional
  6. import httpx
  7. from ..config import Settings, get_settings
  8. from ..exceptions import MovieServiceError
  9. from ..models.schemas import CandidateMovie, MovieDetail
  10. from ..utils.logger import get_logger
  11. logger = get_logger("app.movie_service")
  12. TMDB_API_BASE = "https://api.themoviedb.org/3"
  13. # TMDB with_original_language 只认 ISO 639-1;画像常误传「好莱坞」「英语」
  14. _VALID_TMDB_LANGS = frozenset(
  15. {"zh", "en", "ja", "ko", "fr", "de", "es", "it", "hi", "th", "pt", "ru"}
  16. )
  17. _LANG_ALIASES = {
  18. "华语": "zh",
  19. "中文": "zh",
  20. "汉语": "zh",
  21. "普通话": "zh",
  22. "国语": "zh",
  23. "好莱坞": "en",
  24. "英语": "en",
  25. "英文": "en",
  26. "english": "en",
  27. "美片": "en",
  28. "日韩": "ja",
  29. "日语": "ja",
  30. "日本": "ja",
  31. "japanese": "ja",
  32. "韩语": "ko",
  33. "韩文": "ko",
  34. "韩国": "ko",
  35. "korean": "ko",
  36. "法语": "fr",
  37. "德语": "de",
  38. "西语": "es",
  39. "西班牙语": "es",
  40. }
  41. def normalize_tmdb_language(raw: Optional[str]) -> Optional[str]:
  42. """把地区/中文名归一成 TMDB 语言码;无法识别则返回 None。"""
  43. if raw is None:
  44. return None
  45. text = str(raw).strip()
  46. if not text:
  47. return None
  48. lower = text.lower()
  49. if lower in _VALID_TMDB_LANGS:
  50. return lower
  51. mapped = _LANG_ALIASES.get(text) or _LANG_ALIASES.get(lower)
  52. if mapped in _VALID_TMDB_LANGS:
  53. return mapped
  54. # 容错:en-US / zh-CN
  55. if "-" in lower or "_" in lower:
  56. primary = lower.replace("_", "-").split("-", 1)[0]
  57. if primary in _VALID_TMDB_LANGS:
  58. return primary
  59. return None
  60. class MovieService:
  61. """TMDB 电影查询服务:search / discover / 类型名 id 映射。"""
  62. def __init__(self, settings: Optional[Settings] = None) -> None:
  63. """初始化配置、类型缓存占位,以及复用的 httpx 客户端。"""
  64. self.settings = settings or get_settings()
  65. self._genre_id_to_name: Optional[Dict[int, str]] = None
  66. self._genre_name_to_id: Optional[Dict[str, int]] = None
  67. self._client = httpx.Client(timeout=30.0)
  68. def close(self) -> None:
  69. """关闭底层 HTTP 客户端(进程退出或测试 teardown 时调用)。"""
  70. self._client.close()
  71. def _auth_headers_and_params(self) -> tuple[dict, dict]:
  72. """组装鉴权:优先 Bearer Access Token,否则 query 带 api_key。"""
  73. token, api_key = self.settings.resolve_tmdb_credentials()
  74. if not token and not api_key:
  75. raise MovieServiceError(
  76. "TMDB 未配置:请在 .env 设置 TMDB_ACCESS_TOKEN 或 TMDB_API_KEY",
  77. status_code=503,
  78. )
  79. headers: dict = {"Accept": "application/json"}
  80. params: dict = {
  81. "language": self.settings.tmdb_language,
  82. "include_adult": str(self.settings.tmdb_include_adult).lower(),
  83. }
  84. if token:
  85. headers["Authorization"] = f"Bearer {token}"
  86. else:
  87. params["api_key"] = api_key # type: ignore[assignment]
  88. return headers, params
  89. def _get(self, path: str, extra_params: Optional[Dict[str, Any]] = None) -> dict:
  90. """对 TMDB 发 GET,统一处理超时、网络错误与非 2xx / 非 JSON。"""
  91. headers, params = self._auth_headers_and_params()
  92. if extra_params:
  93. for k, v in extra_params.items():
  94. if v is not None and v != "":
  95. params[k] = v
  96. url = f"{TMDB_API_BASE}{path}"
  97. try:
  98. resp = self._client.get(url, headers=headers, params=params)
  99. except httpx.TimeoutException as e:
  100. raise MovieServiceError(f"TMDB 请求超时: {e}") from e
  101. except httpx.HTTPError as e:
  102. raise MovieServiceError(f"TMDB 网络错误: {e}") from e
  103. if resp.status_code == 401:
  104. raise MovieServiceError(
  105. "TMDB 鉴权失败:请检查 Access Token / API Key",
  106. status_code=401,
  107. )
  108. if resp.status_code == 404:
  109. raise MovieServiceError("影片不存在或已下架", status_code=404)
  110. if resp.status_code >= 400:
  111. raise MovieServiceError(
  112. f"TMDB 请求失败: HTTP {resp.status_code}, {resp.text[:200]}"
  113. )
  114. try:
  115. return resp.json()
  116. except ValueError as e:
  117. raise MovieServiceError("TMDB 返回非 JSON") from e
  118. def _poster_url(self, poster_path: Optional[str]) -> Optional[str]:
  119. """把 TMDB 相对 poster_path 拼成可访问的完整图片 URL。"""
  120. if not poster_path:
  121. return None
  122. base = self.settings.tmdb_image_base_url.rstrip("/")
  123. return f"{base}{poster_path}"
  124. def _parse_year(self, release_date: Optional[str]) -> Optional[int]:
  125. """从 release_date(YYYY-MM-DD)解析上映年份。"""
  126. if not release_date or len(release_date) < 4:
  127. return None
  128. try:
  129. return int(release_date[:4])
  130. except ValueError:
  131. return None
  132. def ensure_genres(self) -> None:
  133. """拉取并缓存类型 id ↔ 中文名称(仅首次请求时打 TMDB)。"""
  134. if self._genre_id_to_name is not None:
  135. return
  136. data = self._get("/genre/movie/list")
  137. id_to_name: Dict[int, str] = {}
  138. name_to_id: Dict[str, int] = {}
  139. for g in data.get("genres") or []:
  140. gid = g.get("id")
  141. name = (g.get("name") or "").strip()
  142. if gid is None or not name:
  143. continue
  144. id_to_name[int(gid)] = name
  145. name_to_id[name] = int(gid)
  146. name_to_id[name.lower()] = int(gid)
  147. self._genre_id_to_name = id_to_name
  148. self._genre_name_to_id = name_to_id
  149. def resolve_genre_ids(self, genres: Optional[str]) -> Optional[str]:
  150. """将 '剧情,喜剧' 或 '18,35' 转为 TMDB with_genres 所需的 id 串。"""
  151. if not genres or not genres.strip():
  152. return None
  153. self.ensure_genres()
  154. assert self._genre_name_to_id is not None
  155. ids: List[str] = []
  156. for part in genres.split(","):
  157. raw = part.strip()
  158. if not raw:
  159. continue
  160. if raw.isdigit():
  161. ids.append(raw)
  162. continue
  163. gid = self._genre_name_to_id.get(raw) or self._genre_name_to_id.get(raw.lower())
  164. if gid is not None:
  165. ids.append(str(gid))
  166. return ",".join(ids) if ids else None
  167. def _map_result(self, item: dict) -> CandidateMovie:
  168. """把 TMDB 单条原始结果映射为内部 CandidateMovie。"""
  169. self.ensure_genres()
  170. assert self._genre_id_to_name is not None
  171. genre_names: List[str] = []
  172. for gid in item.get("genre_ids") or []:
  173. name = self._genre_id_to_name.get(int(gid))
  174. if name:
  175. genre_names.append(name)
  176. title = item.get("title") or item.get("original_title") or ""
  177. return CandidateMovie(
  178. id=int(item["id"]),
  179. title=title,
  180. year=self._parse_year(item.get("release_date")),
  181. genres=genre_names,
  182. runtime=None, # 列表接口通常无片长,需 detail 才有
  183. rating=item.get("vote_average"),
  184. poster_url=self._poster_url(item.get("poster_path")),
  185. overview=item.get("overview") or "",
  186. )
  187. def _map_detail(self, item: dict) -> MovieDetail:
  188. """把 TMDB /movie/{id}(可含 credits)映射为 MovieDetail。"""
  189. genre_names: List[str] = []
  190. for g in item.get("genres") or []:
  191. name = (g.get("name") or "").strip()
  192. if name:
  193. genre_names.append(name)
  194. if not genre_names and item.get("genre_ids"):
  195. self.ensure_genres()
  196. assert self._genre_id_to_name is not None
  197. for gid in item["genre_ids"]:
  198. name = self._genre_id_to_name.get(int(gid))
  199. if name:
  200. genre_names.append(name)
  201. title = item.get("title") or item.get("original_title") or ""
  202. runtime = item.get("runtime")
  203. if runtime is not None:
  204. try:
  205. runtime = int(runtime)
  206. if runtime <= 0:
  207. runtime = None
  208. except (TypeError, ValueError):
  209. runtime = None
  210. countries: List[str] = []
  211. for c in item.get("production_countries") or []:
  212. name = (c.get("name") or "").strip()
  213. if name:
  214. countries.append(name)
  215. directors: List[str] = []
  216. cast_names: List[str] = []
  217. credits = item.get("credits") or {}
  218. for person in credits.get("crew") or []:
  219. if person.get("job") == "Director":
  220. name = (person.get("name") or "").strip()
  221. if name and name not in directors:
  222. directors.append(name)
  223. for person in (credits.get("cast") or [])[:8]:
  224. name = (person.get("name") or "").strip()
  225. if name:
  226. cast_names.append(name)
  227. movie_id = int(item["id"])
  228. original_title = (item.get("original_title") or "").strip() or None
  229. if original_title and original_title == title:
  230. original_title = None
  231. vote_count = item.get("vote_count")
  232. try:
  233. vote_count = int(vote_count) if vote_count is not None else None
  234. except (TypeError, ValueError):
  235. vote_count = None
  236. return MovieDetail(
  237. id=movie_id,
  238. title=title,
  239. year=self._parse_year(item.get("release_date")),
  240. genres=genre_names,
  241. runtime=runtime,
  242. rating=item.get("vote_average"),
  243. poster_url=self._poster_url(item.get("poster_path")),
  244. overview=item.get("overview") or "",
  245. tagline=(item.get("tagline") or "").strip() or None,
  246. original_title=original_title,
  247. vote_count=vote_count,
  248. original_language=(item.get("original_language") or "").strip() or None,
  249. countries=countries,
  250. directors=directors,
  251. cast=cast_names,
  252. tmdb_url=f"https://www.themoviedb.org/movie/{movie_id}",
  253. )
  254. def get_detail(self, movie_id: int) -> MovieDetail:
  255. """按 id 取电影详情(含 credits:导演 / 主演)。"""
  256. if movie_id <= 0:
  257. raise MovieServiceError("movie_id 必须为正整数", status_code=400)
  258. data = self._get(
  259. f"/movie/{movie_id}",
  260. {"append_to_response": "credits"},
  261. )
  262. return self._map_detail(data)
  263. def search(
  264. self,
  265. q: str,
  266. year: Optional[int] = None,
  267. page: int = 1,
  268. ) -> List[CandidateMovie]:
  269. """按关键词搜索电影(TMDB GET /search/movie)。"""
  270. query = (q or "").strip()
  271. if not query:
  272. raise MovieServiceError("搜索关键词 q 不能为空", status_code=400)
  273. data = self._get(
  274. "/search/movie",
  275. {
  276. "query": query,
  277. "year": year,
  278. "page": page,
  279. },
  280. )
  281. return [self._map_result(item) for item in data.get("results") or []]
  282. def discover(
  283. self,
  284. with_genres: Optional[str] = None,
  285. year: Optional[int] = None,
  286. year_gte: Optional[int] = None,
  287. year_lte: Optional[int] = None,
  288. max_runtime: Optional[int] = None,
  289. with_original_language: Optional[str] = None,
  290. sort_by: str = "popularity.desc",
  291. page: int = 1,
  292. ) -> List[CandidateMovie]:
  293. """按条件发现电影(TMDB GET /discover/movie)。"""
  294. genre_ids = self.resolve_genre_ids(with_genres)
  295. lang = normalize_tmdb_language(with_original_language)
  296. params: Dict[str, Any] = {
  297. "sort_by": sort_by or "popularity.desc",
  298. "page": page,
  299. "with_genres": genre_ids,
  300. "with_original_language": lang,
  301. }
  302. if year is not None:
  303. params["primary_release_year"] = year
  304. if year_gte is not None:
  305. params["primary_release_date.gte"] = f"{year_gte}-01-01"
  306. if year_lte is not None:
  307. params["primary_release_date.lte"] = f"{year_lte}-12-31"
  308. if max_runtime is not None:
  309. params["with_runtime.lte"] = max_runtime
  310. if with_original_language and not lang:
  311. logger.warning(
  312. "discover 丢弃非法 language=%r",
  313. with_original_language,
  314. )
  315. logger.info(
  316. "TMDB discover params genres=%r lang=%r year=%s gte=%s lte=%s runtime_lte=%s sort=%s",
  317. genre_ids,
  318. lang,
  319. year,
  320. year_gte,
  321. year_lte,
  322. max_runtime,
  323. sort_by or "popularity.desc",
  324. )
  325. data = self._get("/discover/movie", params)
  326. return [self._map_result(item) for item in data.get("results") or []]
  327. def discover_with_relax(
  328. self,
  329. with_genres: Optional[str] = None,
  330. year: Optional[int] = None,
  331. year_gte: Optional[int] = None,
  332. year_lte: Optional[int] = None,
  333. max_runtime: Optional[int] = None,
  334. with_original_language: Optional[str] = None,
  335. sort_by: str = "popularity.desc",
  336. page: int = 1,
  337. exclude_ids: Optional[List[int]] = None,
  338. ) -> List[CandidateMovie]:
  339. """discover;若空结果则逐步放宽:去语言 → 去片长 → 去年代 → 仅类型。"""
  340. exclude = set(exclude_ids or [])
  341. attempts: List[Dict[str, Any]] = [
  342. {
  343. "with_genres": with_genres,
  344. "year": year,
  345. "year_gte": year_gte,
  346. "year_lte": year_lte,
  347. "max_runtime": max_runtime,
  348. "with_original_language": with_original_language,
  349. },
  350. {
  351. "with_genres": with_genres,
  352. "year": year,
  353. "year_gte": year_gte,
  354. "year_lte": year_lte,
  355. "max_runtime": max_runtime,
  356. "with_original_language": None,
  357. },
  358. {
  359. "with_genres": with_genres,
  360. "year": year,
  361. "year_gte": year_gte,
  362. "year_lte": year_lte,
  363. "max_runtime": None,
  364. "with_original_language": None,
  365. },
  366. {
  367. "with_genres": with_genres,
  368. "year": None,
  369. "year_gte": None,
  370. "year_lte": None,
  371. "max_runtime": None,
  372. "with_original_language": None,
  373. },
  374. ]
  375. seen_keys: set = set()
  376. for i, kwargs in enumerate(attempts):
  377. key = tuple(sorted((k, repr(v)) for k, v in kwargs.items()))
  378. if key in seen_keys:
  379. continue
  380. seen_keys.add(key)
  381. movies = self.discover(sort_by=sort_by, page=page, **kwargs)
  382. kept = [m for m in movies if m.id not in exclude]
  383. if kept:
  384. if i > 0:
  385. logger.warning(
  386. "discover 空结果已放宽(step=%d) -> %d 部 params=%s",
  387. i,
  388. len(kept),
  389. kwargs,
  390. )
  391. return kept
  392. logger.warning("discover 无结果 step=%d params=%s", i, kwargs)
  393. return []
  394. _movie_service: Optional[MovieService] = None
  395. def get_movie_service() -> MovieService:
  396. """获取进程内 MovieService 单例(REST 与 Agent Tool 共用)。"""
  397. global _movie_service
  398. if _movie_service is None:
  399. _movie_service = MovieService()
  400. return _movie_service