import re from typing import Any, List, Optional, Tuple, Union import cn2an from app import schemas from app.core.config import settings from app.core.context import ( MediaInfo, MusicAlbumInfo, MusicInfo, ) from app.core.meta import MetaBase, MetaMusic from app.core.metainfo import MetaInfo from app.log import logger from app.modules import _ModuleBase from app.modules.douban.apiv2 import DoubanApi from app.modules.douban.scraper import DoubanScraper from app.schemas import MediaPerson, APIRateLimitException from app.schemas.types import ( MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaSource, MediaSourceSelection, MediaType, ModuleType, MediaRecognizeType, ) from app.utils.common import retry from app.utils.http import RequestUtils from app.utils.limit import rate_limit_exponential from app.utils.media import is_media_source_enabled, is_media_source_selected from app.utils.zhconv import convert as zhconv_convert class DoubanModule(_ModuleBase): """提供豆瓣影视与豆瓣音乐元数据识别能力。""" _music_source = MediaSource.DoubanMusic doubanapi: DoubanApi = None scraper: DoubanScraper = None def init_module(self) -> None: self.doubanapi = DoubanApi() self.scraper = DoubanScraper() def stop(self): self.doubanapi.close() def test(self) -> Tuple[bool, str]: """ 测试模块连接性 """ ret = RequestUtils().get_res("https://movie.douban.com/") if ret is None: return False, "豆瓣网络连接失败" return True, "" def init_setting(self) -> Tuple[str, Union[str, bool]]: pass @staticmethod def get_name() -> str: return "豆瓣" @staticmethod def get_music_source() -> MediaSource: """返回音乐识别使用的数据源标识。""" return DoubanModule._music_source @staticmethod def get_type() -> ModuleType: """ 获取模块类型 """ return ModuleType.MediaRecognize @staticmethod def get_subtype() -> MediaRecognizeType: """ 获取模块子类型 """ return MediaRecognizeType.Douban @staticmethod def get_priority() -> int: """ 获取模块优先级,数字越小优先级越高,只有同一接口下优先级才生效 """ return 2 def search_music( self, meta: MetaMusic, limit: int = 20, media_source: Optional[MediaSourceSelection] = None, ) -> Optional[List[MusicInfo]]: """按请求来源搜索豆瓣音乐专辑,并转换为统一音乐候选。""" if not is_media_source_selected(media_source, self._music_source): return None keyword = meta.album or meta.title if not keyword: return [] result = self.doubanapi.music_search(keyword=keyword, count=max(1, min(limit, 100))) return self._build_music_search_results(result) def recognize_music( self, media_source: MediaSource, media_id: str, music_type: Optional[str] = None, ) -> Optional[MusicInfo]: """按豆瓣音乐原生 ID 和实体类型获取专辑或专辑内曲目详情。""" if media_source != self._music_source or not media_id: return None album_id, separator, track_id = str(media_id).partition(":") if music_type == MUSIC_ENTITY_RECORDING and not separator: return None if music_type == MUSIC_ENTITY_ALBUM and separator: return None album = self.music_album(media_source, album_id) if not album: return None if separator and track_id: return next( ( track for track in album.tracks if track.media_id == media_id or str(track.track_number or "") == track_id ), None, ) return album.to_music_info() def music_album( self, media_source: MediaSource, media_id: str, ) -> Optional[MusicAlbumInfo]: """按豆瓣音乐专辑 ID 获取标准化专辑详情和曲目。""" if media_source != self._music_source or not media_id: return None info = self.doubanapi.music_detail(subject_id=str(media_id)) return self._douban_music_to_album(info) if info else None def music_discover( self, media_source: MediaSource, page: int = 1, count: int = 30, entity: str = MUSIC_ENTITY_ALBUM, mode: str = "chart", tags: str = "", sort: str = "U", ) -> Optional[List[MusicInfo]]: """按官方新碟榜或标签交集浏览豆瓣音乐,并保留豆瓣条目原生身份。""" if media_source != self._music_source: return None del entity if mode == "chart": chart_items = self._build_music_search_results(self.doubanapi.music_chart()) start = max(page - 1, 0) * max(1, count) return chart_items[start:start + max(1, count)] selected_tags = [tag.strip() for tag in str(tags or "").split(",") if tag.strip()] if not selected_tags: selected_tags = ["流行"] if len(selected_tags) == 1: result = self.doubanapi.music_tag( tag=selected_tags[0], start=max(page - 1, 0) * max(1, count), count=max(1, count), sort=sort, ) return self._build_music_search_results(result) # 豆瓣官网的多标签 URL 会按完整文本标签匹配;分别读取后按原生 ID # 求交集,才能实现风格与地区的真实组合筛选。 scan_count = min(max(page * count * 4, 100), 300) tag_results = [ self._build_music_search_results( self.doubanapi.music_tag(tag=tag, start=0, count=scan_count, sort=sort) ) for tag in selected_tags ] if not tag_results: return [] shared_ids = set(item.media_id for item in tag_results[0]) for items in tag_results[1:]: shared_ids.intersection_update(item.media_id for item in items) matched = [item for item in tag_results[0] if item.media_id in shared_ids] start = max(page - 1, 0) * max(1, count) return matched[start:start + max(1, count)] def music_album_related( self, media_source: MediaSource, media_id: str, count: int = 24, ) -> Optional[List[MusicInfo]]: """按豆瓣音乐专辑 ID 返回相关推荐条目。""" if media_source != self._music_source or not media_id: return None result = self.doubanapi.music_recommendations( subject_id=str(media_id), start=0, count=max(1, count), ) return self._build_music_search_results(result) def _recognize_music_media( self, meta: Optional[MetaMusic], media_source: Optional[MediaSource], media_id: Optional[str], music_type: Optional[str] = None, ) -> Optional[MusicInfo]: """执行豆瓣音乐详情识别或按专辑名称匹配。""" if media_source != self._music_source: return None resolved_media_id = media_id or (meta.media_id if meta else None) if resolved_media_id: detail_kwargs = ( {"music_type": music_type} if music_type is not None else {} ) return self.recognize_music( media_source, str(resolved_media_id), **detail_kwargs ) if not meta: return None candidates = self.search_music(meta=meta, limit=20, media_source=media_source) or [] expected_title = meta.album or meta.title for candidate in candidates: if not self._same_music_text(expected_title, candidate.title): continue if meta.artists and candidate.artists and not any( self._same_music_text(expected, actual) for expected in meta.artists for actual in candidate.artists ): continue if music_type == MUSIC_ENTITY_ALBUM: return candidate if meta.album and meta.title: album = self.music_album(media_source, candidate.media_id) matched_track = self._select_douban_music_track(meta, album) if matched_track: return matched_track continue if music_type == MUSIC_ENTITY_RECORDING: continue return candidate return None async def _async_recognize_music_media( self, meta: Optional[MetaMusic], media_source: Optional[MediaSource], media_id: Optional[str], music_type: Optional[str] = None, ) -> Optional[MusicInfo]: """异步执行豆瓣音乐详情识别或按专辑名称匹配。""" if media_source != self._music_source: return None resolved_media_id = media_id or (meta.media_id if meta else None) if resolved_media_id: album_id, separator, track_id = str(resolved_media_id).partition(":") if music_type == MUSIC_ENTITY_RECORDING and not separator: return None if music_type == MUSIC_ENTITY_ALBUM and separator: return None info = await self.doubanapi.async_music_detail(subject_id=album_id) album = self._douban_music_to_album(info) if info else None if not album: return None if separator and track_id: return next( ( track for track in album.tracks if track.media_id == resolved_media_id or str(track.track_number or "") == track_id ), None, ) return album.to_music_info() if not meta: return None keyword = meta.album or meta.title if not keyword: return None result = await self.doubanapi.async_music_search(keyword=keyword, count=20) candidates = self._build_music_search_results(result) expected_title = meta.album or meta.title for candidate in candidates: if not self._same_music_text(expected_title, candidate.title): continue if meta.artists and candidate.artists and not any( self._same_music_text(expected, actual) for expected in meta.artists for actual in candidate.artists ): continue if music_type == MUSIC_ENTITY_ALBUM: return candidate if meta.album and meta.title: info = await self.doubanapi.async_music_detail( subject_id=str(candidate.media_id) ) album = self._douban_music_to_album(info) if info else None matched_track = self._select_douban_music_track(meta, album) if matched_track: return matched_track continue if music_type == MUSIC_ENTITY_RECORDING: continue return candidate return None @classmethod def _select_douban_music_track( cls, meta: MetaMusic, album: Optional[MusicAlbumInfo], ) -> Optional[MusicInfo]: """从豆瓣专辑曲目中选择与本地曲名、艺术家及曲序最一致的音轨。""" if not album: return None candidates = [ track for track in album.tracks if cls._same_music_text(meta.title, track.title) ] if meta.artists: candidates = [ track for track in candidates if any( cls._same_music_text(expected, actual) for expected in meta.artists for actual in track.artists ) ] if not candidates: return None candidates.sort( key=lambda track: ( bool(meta.track_number and track.track_number == meta.track_number), -abs((meta.duration or track.duration or 0) - (track.duration or meta.duration or 0)), ), reverse=True, ) return candidates[0] @classmethod def _build_music_search_results( cls, result: Optional[dict | list], ) -> List[MusicInfo]: """把豆瓣音乐搜索响应转换为专辑候选列表。""" payload = result or {} if isinstance(payload, list): items = payload else: items = ( payload.get("subject_collection_items") or payload.get("recommendations") or payload.get("subjects") or payload.get("items") or payload.get("musics") or [] ) candidates = [] for item in items: if not isinstance(item, dict): continue target_type = str(item.get("target_type") or "").casefold() if isinstance(item.get("target"), dict): target = item["target"] elif isinstance(item.get("subject"), dict): target = item["subject"] else: target = item type_name = str(target.get("type_name") or target.get("subtype") or "") target_subject_type = str(target.get("type") or "").casefold() if target_type and target_type not in {"music", "音乐", "subject"}: continue if target_subject_type and target_subject_type not in {"music", "音乐"}: continue if type_name and type_name not in {"音乐", "music"}: continue media_id = cls._douban_music_text( target.get("id") or item.get("target_id") or item.get("id") ) title = cls._douban_music_text(target.get("title") or target.get("name")) if not media_id or not title: continue artists = cls._douban_music_search_artists(target) release_date = cls._douban_music_date(target) cover_url = cls._douban_music_cover(target) candidate = MusicInfo( media_source=cls._music_source, media_id=media_id, music_type=MUSIC_ENTITY_ALBUM, title=title, artists=artists, album=title, album_artist=" / ".join(artists) or None, album_id=media_id, year=cls._douban_music_year(target.get("year") or release_date), release_date=release_date, cover_url=cover_url, names=[title], detail_link=f"https://music.douban.com/subject/{media_id}/", raw_data={ "rating": cls._douban_music_float(target["rating"].get("value")), "rating_votes": cls._douban_music_int(target["rating"].get("count")), } if isinstance(target.get("rating"), dict) else {}, ) candidates.append(candidate) return candidates @classmethod def _douban_music_to_album(cls, info: dict[str, Any]) -> Optional[MusicAlbumInfo]: """把豆瓣音乐详情转换为标准专辑信息和曲目。""" media_id = cls._douban_music_text(info.get("id") or info.get("subject_id")) title = cls._douban_music_text(info.get("title") or info.get("name")) if not media_id or not title: return None attrs = info.get("attrs") if isinstance(info.get("attrs"), dict) else {} artists = cls._douban_music_artists(info) release_date = cls._douban_music_date(info) tags = [ cls._douban_music_text(item.get("name") if isinstance(item, dict) else item) for item in (info.get("tags") or []) ] genres = [str(item) for item in info.get("genres") or [] if item] rating = info.get("rating") if isinstance(info.get("rating"), dict) else {} album = MusicAlbumInfo( media_source=cls._music_source, media_id=media_id, title=title, artists=artists, album_type=cls._douban_music_first( info.get("media") or attrs.get("media") ) or "Album", release_date=release_date, cover_url=cls._douban_music_cover(info), genres=genres, tags=[item for item in tags if item], rating=cls._douban_music_float(rating.get("value") or rating.get("average")), rating_votes=cls._douban_music_int( rating.get("count") or rating.get("numRaters") or info.get("ratings_count") ), detail_link=f"https://music.douban.com/subject/{media_id}/", raw_data={ "overview": cls._douban_music_text(info.get("intro") or info.get("summary")), "publisher": cls._douban_music_first( info.get("publisher") or attrs.get("publisher") ), }, ) album.tracks = cls._douban_music_tracks(info, album) return album @classmethod def _douban_music_tracks( cls, info: dict[str, Any], album: MusicAlbumInfo, ) -> List[MusicInfo]: """从豆瓣新旧响应结构中提取专辑曲目。""" attrs = info.get("attrs") if isinstance(info.get("attrs"), dict) else {} # Frodo 当前音乐详情使用 songs;tracks/attrs.tracks 兼容旧接口响应。 tracks = info.get("songs") or info.get("tracks") or attrs.get("tracks") or [] if isinstance(tracks, str): tracks = tracks.splitlines() elif not isinstance(tracks, list): tracks = [] elif len(tracks) == 1 and isinstance(tracks[0], str) and "\n" in tracks[0]: tracks = tracks[0].splitlines() results = [] for index, item in enumerate(tracks, start=1): if isinstance(item, dict): title = cls._douban_music_text(item.get("title") or item.get("name")) track_number = cls._douban_music_int(item.get("track_number") or item.get("position")) or index duration = cls._douban_music_int(item.get("duration")) duration = duration if duration and duration > 0 else None disc_number = cls._douban_music_int( item.get("disc_number") or item.get("disc") ) artists = cls._douban_music_artists(item) or list(album.artists) cover_url = cls._douban_music_text(item.get("cover_url")) or album.cover_url raw_data = { key: value for key, value in { "apple_album_id": item.get("apple_album_id"), "apple_track_id": item.get("apple_track_id"), "preview_url": item.get("preview_url"), }.items() if value not in (None, "") } else: title = cls._clean_douban_track_title(item) track_number = index duration = None disc_number = None artists = list(album.artists) cover_url = album.cover_url raw_data = {} if not title: continue results.append(MusicInfo( media_source=cls._music_source, # 豆瓣歌曲没有独立 subject ID,使用专辑内绝对顺序避免多碟曲序重复。 media_id=f"{album.media_id}:{index}", title=title, artists=artists, album=album.title, album_artist=album.artist or None, album_id=album.media_id, album_type=album.album_type, year=album.year, release_date=album.release_date, disc_number=disc_number, track_number=track_number, duration=duration, cover_url=cover_url, genres=list(album.genres), names=[title], detail_link=album.detail_link, raw_data=raw_data, )) for track in results: track.total_tracks = len(results) return results @classmethod def _douban_music_artists(cls, info: dict[str, Any]) -> List[str]: """从豆瓣新旧响应结构中提取艺术家名称。""" attrs = info.get("attrs") if isinstance(info.get("attrs"), dict) else {} values = ( info.get("artists") or info.get("artist_names") or info.get("author") or info.get("singer") or attrs.get("singer") or [] ) if isinstance(values, str): values = [values] artists = [] seen = set() for item in values: value = item.get("name") if isinstance(item, dict) else item text = cls._douban_music_text(value) identity = MetaMusic.compact_text(text) if text else "" if not text or identity in seen: continue seen.add(identity) artists.append(text) return artists @classmethod def _douban_music_search_artists(cls, info: dict[str, Any]) -> List[str]: """提取搜索候选艺术家,缺少结构化字段时回退到卡片副标题首段。""" artists = cls._douban_music_artists(info) if artists: return artists subtitle = cls._douban_music_text(info.get("card_subtitle")) if not subtitle: return [] artist = re.split(r"\s+/\s+", subtitle, maxsplit=1)[0].strip() if not artist or re.fullmatch(r"\d{4}(?:-\d{1,2}(?:-\d{1,2})?)?", artist): return [] return [artist] @classmethod def _douban_music_cover(cls, info: dict[str, Any]) -> Optional[str]: """从豆瓣多种图片字段中提取清晰封面。""" pic = info.get("pic") if isinstance(info.get("pic"), dict) else {} cover = info.get("cover") if isinstance(info.get("cover"), dict) else {} cover_img = info.get("cover_img") if isinstance(info.get("cover_img"), dict) else {} return next( ( text for value in [ pic.get("large"), cover_img.get("url"), cover.get("large"), cover.get("normal"), cover.get("url"), info.get("cover_url"), info.get("image"), ] if (text := cls._douban_music_text(value)) ), None, ) @classmethod def _douban_music_date(cls, info: dict[str, Any]) -> Optional[str]: """从豆瓣新旧响应结构中提取首个发行日期。""" attrs = info.get("attrs") if isinstance(info.get("attrs"), dict) else {} return cls._douban_music_first(info.get("pubdate") or attrs.get("pubdate")) @staticmethod def _clean_douban_track_title(value: Any) -> Optional[str]: """清理豆瓣旧接口曲目文本开头的序号。""" text = str(value or "").strip() return re.sub(r"^\s*(?:\d+[\.、)]\s*)", "", text) or None @staticmethod def _douban_music_text(value: Any) -> Optional[str]: """把豆瓣外部响应值转换为去空白文本。""" text = str(value).strip() if value is not None else "" return text or None @classmethod def _douban_music_first(cls, value: Any) -> Optional[str]: """从豆瓣列表或标量字段中提取首个文本。""" if isinstance(value, list): return next((text for item in value if (text := cls._douban_music_text(item))), None) return cls._douban_music_text(value) @staticmethod def _douban_music_int(value: Any) -> Optional[int]: """将豆瓣外部响应值安全转换为整数。""" try: return int(value) if value not in (None, "") else None except (TypeError, ValueError): return None @staticmethod def _douban_music_float(value: Any) -> float: """将豆瓣外部评分安全转换为浮点数。""" try: return float(value) if value not in (None, "") else 0.0 except (TypeError, ValueError): return 0.0 @classmethod def _douban_music_year(cls, value: Any) -> Optional[int]: """从豆瓣年份或日期文本中提取四位年份。""" text = cls._douban_music_text(value) return int(text[:4]) if text and text[:4].isdigit() else None @staticmethod def _same_music_text(left: Optional[str], right: Optional[str]) -> bool: """使用音乐元数据紧凑文本规则比较豆瓣候选。""" return bool(left and right and MetaMusic.compact_text(left) == MetaMusic.compact_text(right)) @staticmethod def _prepare_search_names(meta: MetaBase) -> List[str]: """ 准备搜索名称列表,保留中英文名称分别识别且按顺序去重的历史行为。 """ # 简体名称 zh_name = zhconv_convert(meta.cn_name, "zh-hans") if meta.cn_name else None # 使用中英文名分别识别,去重去空,但要保持顺序 return list(dict.fromkeys([k for k in [meta.cn_name, zh_name, meta.en_name] if k])) @staticmethod def _build_search_medias_result(meta: MetaBase, items: Optional[List[dict]]) -> List[MediaInfo]: """ 构建豆瓣搜索结果,并沿用原有的类型、标题包含和季信息处理规则。 """ if not items: return [] ret_medias = [] for item_obj in items: if meta.type and meta.type != MediaType.UNKNOWN and meta.type.value != item_obj.get("type_name"): continue if item_obj.get("type_name") not in (MediaType.TV.value, MediaType.MOVIE.value): continue if meta.name not in item_obj.get("target", {}).get("title"): continue ret_medias.append(MediaInfo(douban_info=item_obj.get("target"))) # 将搜索词中的季写入标题中 if ret_medias and meta.begin_season is not None: # 小写数据转大写 season_str = cn2an.an2cn(meta.begin_season, "low") for media in ret_medias: if media.type == MediaType.TV: media.title = f"{media.title} 第{season_str}季" media.season = meta.begin_season return ret_medias def _recognize_media_core(self, meta: MetaBase = None, mtype: MediaType = None, doubanid: Optional[str] = None, douban_info_func=None, match_doubaninfo_func=None, **kwargs) -> Optional[MediaInfo]: """ 识别媒体信息的核心逻辑 :param meta: 识别的元数据 :param mtype: 识别的媒体类型,与doubanid配套 :param doubanid: 豆瓣ID :param douban_info_func: 获取豆瓣信息的函数 :param match_doubaninfo_func: 匹配豆瓣信息的函数 :return: 识别的媒体信息,包括剧集信息 """ if not doubanid and not meta: return None if ( meta and not doubanid and (kwargs.get("media_source") or settings.RECOGNIZE_SOURCE) != "douban" ): return None if doubanid: info = douban_info_func( doubanid=doubanid, mtype=mtype or (meta.type if meta else None), ) elif not meta.name: logger.error("识别媒体信息时未提供元数据名称") return None else: if mtype: meta.type = mtype info = {} for name in self._prepare_search_names(meta): if meta.begin_season is not None: logger.info(f"正在识别 {name} 第{meta.begin_season}季 ...") else: logger.info(f"正在识别 {name} ...") match_info = match_doubaninfo_func( name=name, mtype=mtype or meta.type, year=meta.year, season=meta.begin_season, ) if match_info: info = douban_info_func( doubanid=match_info.get("id"), mtype=mtype or meta.type, ) if info: break if info: mediainfo = MediaInfo(douban_info=info) if meta: logger.info(f"{meta.name} 豆瓣识别结果:{mediainfo.type.value} " f"{mediainfo.title_year} " f"{mediainfo.douban_id}") else: logger.info(f"{doubanid} 豆瓣识别结果:{mediainfo.type.value} " f"{mediainfo.title_year}") return mediainfo else: logger.info(f"{meta.name if meta else doubanid} 未匹配到豆瓣媒体信息") return None async def _async_recognize_media_core(self, meta: MetaBase = None, mtype: MediaType = None, doubanid: Optional[str] = None, async_douban_info_func=None, async_match_doubaninfo_func=None, **kwargs) -> Optional[MediaInfo]: """ 识别媒体信息的核心逻辑(异步版本) :param meta: 识别的元数据 :param mtype: 识别的媒体类型,与doubanid配套 :param doubanid: 豆瓣ID :param async_douban_info_func: 获取豆瓣信息的异步函数 :param async_match_doubaninfo_func: 匹配豆瓣信息的异步函数 :return: 识别的媒体信息,包括剧集信息 """ if not doubanid and not meta: return None if ( meta and not doubanid and (kwargs.get("media_source") or settings.RECOGNIZE_SOURCE) != "douban" ): return None if doubanid: info = await async_douban_info_func( doubanid=doubanid, mtype=mtype or (meta.type if meta else None), ) elif not meta.name: logger.error("识别媒体信息时未提供元数据名称") return None else: if mtype: meta.type = mtype info = {} for name in self._prepare_search_names(meta): if meta.begin_season is not None: logger.info(f"正在识别 {name} 第{meta.begin_season}季 ...") else: logger.info(f"正在识别 {name} ...") match_info = await async_match_doubaninfo_func( name=name, mtype=mtype or meta.type, year=meta.year, season=meta.begin_season, ) if match_info: info = await async_douban_info_func( doubanid=match_info.get("id"), mtype=mtype or meta.type, ) if info: break if info: mediainfo = MediaInfo(douban_info=info) if meta: logger.info(f"{meta.name} 豆瓣识别结果:{mediainfo.type.value} " f"{mediainfo.title_year} " f"{mediainfo.douban_id}") else: logger.info(f"{doubanid} 豆瓣识别结果:{mediainfo.type.value} " f"{mediainfo.title_year}") return mediainfo else: logger.info(f"{meta.name if meta else doubanid} 未匹配到豆瓣媒体信息") return None def recognize_media(self, meta: MetaBase = None, mtype: MediaType = None, media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, **kwargs) -> Optional[MediaInfo]: """ 识别媒体信息 :param meta: 识别的元数据 :param mtype: 识别的媒体类型 :param media_source: 媒体来源 :param media_id: 媒体来源原生ID :return: 识别的媒体信息,包括剧集信息 """ if media_source == self._music_source: return self._recognize_music_media( meta=meta if isinstance(meta, MetaMusic) else None, media_source=media_source, media_id=media_id, music_type=kwargs.get("music_type"), ) # 音乐请求必须显式使用 doubanmusic,避免与影视豆瓣源混淆。 if isinstance(meta, MetaMusic) or mtype == MediaType.MUSIC: return None if media_source and media_source != MediaSource.Douban: return None doubanid = str(media_id) if media_id is not None else None return self._recognize_media_core( meta=meta, mtype=mtype, doubanid=doubanid, douban_info_func=self.douban_info, match_doubaninfo_func=self.match_doubaninfo, media_source=media_source, **kwargs ) async def async_recognize_media(self, meta: MetaBase = None, mtype: MediaType = None, media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, **kwargs) -> Optional[MediaInfo]: """ 识别媒体信息(异步版本) :param meta: 识别的元数据 :param mtype: 识别的媒体类型 :param media_source: 媒体来源 :param media_id: 媒体来源原生ID :return: 识别的媒体信息,包括剧集信息 """ if media_source == self._music_source: return await self._async_recognize_music_media( meta=meta if isinstance(meta, MetaMusic) else None, media_source=media_source, media_id=media_id, music_type=kwargs.get("music_type"), ) # 音乐请求必须显式使用 doubanmusic,避免与影视豆瓣源混淆。 if isinstance(meta, MetaMusic) or mtype == MediaType.MUSIC: return None if media_source and media_source != MediaSource.Douban: return None doubanid = str(media_id) if media_id is not None else None return await self._async_recognize_media_core( meta=meta, mtype=mtype, doubanid=doubanid, async_douban_info_func=self.async_douban_info, async_match_doubaninfo_func=self.async_match_doubaninfo, media_source=media_source, **kwargs ) @rate_limit_exponential(source="douban_info") def douban_info(self, doubanid: str, mtype: MediaType = None, raise_exception: bool = True) -> Optional[dict]: """ 获取豆瓣信息 :param doubanid: 豆瓣ID :param mtype: 媒体类型 :param raise_exception: 触发速率限制时是否抛出异常 :return: 豆瓣信息 """ """ { "rating": { "count": 287365, "max": 10, "star_count": 3.5, "value": 6.6 }, "lineticket_url": "", "controversy_reason": "", "pubdate": [ "2021-10-29(中国大陆)" ], "last_episode_number": null, "interest_control_info": null, "pic": { "large": "https://img9.doubanio.com/view/photo/m_ratio_poster/public/p2707553644.webp", "normal": "https://img9.doubanio.com/view/photo/s_ratio_poster/public/p2707553644.webp" }, "vendor_count": 6, "body_bg_color": "f4f5f9", "is_tv": false, "head_info": null, "album_no_interact": false, "ticket_price_info": "", "webisode_count": 0, "year": "2021", "card_subtitle": "2021 / 英国 美国 / 动作 惊悚 冒险 / 凯瑞·福永 / 丹尼尔·克雷格 蕾雅·赛杜", "forum_info": null, "webisode": null, "id": "20276229", "gallery_topic_count": 0, "languages": [ "英语", "法语", "意大利语", "俄语", "西班牙语" ], "genres": [ "动作", "惊悚", "冒险" ], "review_count": 926, "title": "007:无暇赴死", "intro": "世界局势波诡云谲,再度出山的邦德(丹尼尔·克雷格 饰)面临有史以来空前的危机,传奇特工007的故事在本片中达到高潮。新老角色集结亮相,蕾雅·赛杜回归,二度饰演邦女郎玛德琳。系列最恐怖反派萨芬(拉米·马雷克 饰)重磅登场,毫不留情地展示了自己狠辣的一面,不仅揭开了玛德琳身上隐藏的秘密,还酝酿着危及数百万人性命的阴谋,幽灵党的身影也似乎再次浮出水面。半路杀出的新00号特工(拉什纳·林奇 饰)与神秘女子(安娜·德·阿玛斯 饰)看似与邦德同阵作战,但其真实目的依然成谜。关乎邦德生死的新仇旧怨接踵而至,暗潮汹涌之下他能否拯救世界?", "interest_cmt_earlier_tip_title": "发布于上映前", "has_linewatch": true, "ugc_tabs": [ { "source": "reviews", "type": "review", "title": "影评" }, { "source": "forum_topics", "type": "forum", "title": "讨论" } ], "forum_topic_count": 857, "ticket_promo_text": "", "webview_info": {}, "is_released": true, "actors": [ { "name": "丹尼尔·克雷格", "roles": [ "演员", "制片人", "配音" ], "title": "丹尼尔·克雷格(同名)英国,英格兰,柴郡,切斯特影视演员", "url": "https://movie.douban.com/celebrity/1025175/", "user": null, "character": "饰 詹姆斯·邦德 James Bond 007", "uri": "douban://douban.com/celebrity/1025175?subject_id=27230907", "avatar": { "large": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p42588.jpg?imageView2/2/q/80/w/600/h/3000/format/webp", "normal": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p42588.jpg?imageView2/2/q/80/w/200/h/300/format/webp" }, "sharing_url": "https://www.douban.com/doubanapp/dispatch?uri=/celebrity/1025175/", "type": "celebrity", "id": "1025175", "latin_name": "Daniel Craig" } ], "interest": null, "vendor_icons": [ "https://img9.doubanio.com/f/frodo/fbc90f355fc45d5d2056e0d88c697f9414b56b44/pics/vendors/tencent.png", "https://img2.doubanio.com/f/frodo/8286b9b5240f35c7e59e1b1768cd2ccf0467cde5/pics/vendors/migu_video.png", "https://img9.doubanio.com/f/frodo/88a62f5e0cf9981c910e60f4421c3e66aac2c9bc/pics/vendors/bilibili.png" ], "episodes_count": 0, "color_scheme": { "is_dark": true, "primary_color_light": "868ca5", "_base_color": [ 0.6333333333333333, 0.18867924528301885, 0.20784313725490197 ], "secondary_color": "f4f5f9", "_avg_color": [ 0.059523809523809625, 0.09790209790209795, 0.5607843137254902 ], "primary_color_dark": "676c7f" }, "type": "movie", "null_rating_reason": "", "linewatches": [ { "url": "http://v.youku.com/v_show/id_XNTIwMzM2NDg5Mg==.html?tpa=dW5pb25faWQ9MzAwMDA4XzEwMDAwMl8wMl8wMQ&refer=esfhz_operation.xuka.xj_00003036_000000_FNZfau_19010900", "source": { "literal": "youku", "pic": "https://img1.doubanio.com/img/files/file-1432869267.png", "name": "优酷视频" }, "source_uri": "youku://play?vid=XNTIwMzM2NDg5Mg==&source=douban&refer=esfhz_operation.xuka.xj_00003036_000000_FNZfau_19010900", "free": false }, ], "info_url": "https://www.douban.com/doubanapp//h5/movie/20276229/desc", "tags": [], "durations": [ "163分钟" ], "comment_count": 97204, "cover": { "description": "", "author": { "loc": { "id": "108288", "name": "北京", "uid": "beijing" }, "kind": "user", "name": "雨落下", "reg_time": "2020-08-11 16:22:48", "url": "https://www.douban.com/people/221011676/", "uri": "douban://douban.com/user/221011676", "id": "221011676", "avatar_side_icon_type": 3, "avatar_side_icon_id": "234", "avatar": "https://img2.doubanio.com/icon/up221011676-2.jpg", "is_club": false, "type": "user", "avatar_side_icon": "https://img2.doubanio.com/view/files/raw/file-1683625971.png", "uid": "221011676" }, "url": "https://movie.douban.com/photos/photo/2707553644/", "image": { "large": { "url": "https://img9.doubanio.com/view/photo/l/public/p2707553644.webp", "width": 1082, "height": 1600, "size": 0 }, "raw": null, "small": { "url": "https://img9.doubanio.com/view/photo/s/public/p2707553644.webp", "width": 405, "height": 600, "size": 0 }, "normal": { "url": "https://img9.doubanio.com/view/photo/m/public/p2707553644.webp", "width": 405, "height": 600, "size": 0 }, "is_animated": false }, "uri": "douban://douban.com/photo/2707553644", "create_time": "2021-10-26 15:05:01", "position": 0, "owner_uri": "douban://douban.com/movie/20276229", "type": "photo", "id": "2707553644", "sharing_url": "https://www.douban.com/doubanapp/dispatch?uri=/photo/2707553644/" }, "cover_url": "https://img9.doubanio.com/view/photo/m_ratio_poster/public/p2707553644.webp", "restrictive_icon_url": "", "header_bg_color": "676c7f", "is_douban_intro": false, "ticket_vendor_icons": [ "https://img9.doubanio.com/view/dale-online/dale_ad/public/0589a62f2f2d7c2.jpg" ], "honor_infos": [], "sharing_url": "https://movie.douban.com/subject/20276229/", "subject_collections": [], "wechat_timeline_share": "screenshot", "countries": [ "英国", "美国" ], "url": "https://movie.douban.com/subject/20276229/", "release_date": null, "original_title": "No Time to Die", "uri": "douban://douban.com/movie/20276229", "pre_playable_date": null, "episodes_info": "", "subtype": "movie", "directors": [ { "name": "凯瑞·福永", "roles": [ "导演", "制片人", "编剧", "摄影", "演员" ], "title": "凯瑞·福永(同名)美国,加利福尼亚州,奥克兰影视演员", "url": "https://movie.douban.com/celebrity/1009531/", "user": null, "character": "导演", "uri": "douban://douban.com/celebrity/1009531?subject_id=27215222", "avatar": { "large": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p1392285899.57.jpg?imageView2/2/q/80/w/600/h/3000/format/webp", "normal": "https://qnmob3.doubanio.com/view/celebrity/raw/public/p1392285899.57.jpg?imageView2/2/q/80/w/200/h/300/format/webp" }, "sharing_url": "https://www.douban.com/doubanapp/dispatch?uri=/celebrity/1009531/", "type": "celebrity", "id": "1009531", "latin_name": "Cary Fukunaga" } ], "is_show": false, "in_blacklist": false, "pre_release_desc": "", "video": null, "aka": [ "007:生死有时(港)", "007:生死交战(台)", "007:间不容死", "邦德25", "007:没空去死(豆友译名)", "James Bond 25", "Never Dream of Dying", "Shatterhand" ], "is_restrictive": false, "trailer": { "sharing_url": "https://www.douban.com/doubanapp/dispatch?uri=/movie/20276229/trailer%3Ftrailer_id%3D282585%26trailer_type%3DA", "video_url": "https://vt1.doubanio.com/202310011325/3b1f5827e91dde7826dc20930380dfc2/view/movie/M/402820585.mp4", "title": "中国预告片:终极决战版 (中文字幕)", "uri": "douban://douban.com/movie/20276229/trailer?trailer_id=282585&trailer_type=A", "cover_url": "https://img1.doubanio.com/img/trailer/medium/2712944408.jpg", "term_num": 0, "n_comments": 21, "create_time": "2021-11-01", "subject_title": "007:无暇赴死", "file_size": 10520074, "runtime": "00:42", "type": "A", "id": "282585", "desc": "" }, "interest_cmt_earlier_tip_desc": "该短评的发布时间早于公开上映时间,作者可能通过其他渠道提前观看,请谨慎参考。其评分将不计入总评分。" } """ def __douban_tv(): """ 获取豆瓣剧集信息 """ info = self.doubanapi.tv_detail(doubanid) if info: if "subject_ip_rate_limit" in info.get("msg", ""): msg = f"触发豆瓣IP速率限制,错误信息:{info} ..." logger.warn(msg) raise APIRateLimitException(msg) celebrities = self.doubanapi.tv_celebrities(doubanid) if celebrities: info["directors"] = celebrities.get("directors") info["actors"] = celebrities.get("actors") return info def __douban_movie(): """ 获取豆瓣电影信息 """ info = self.doubanapi.movie_detail(doubanid) if info: if "subject_ip_rate_limit" in info.get("msg", ""): msg = f"触发豆瓣IP速率限制,错误信息:{info} ..." logger.warn(msg) raise APIRateLimitException(msg) celebrities = self.doubanapi.movie_celebrities(doubanid) if celebrities: info["directors"] = celebrities.get("directors") info["actors"] = celebrities.get("actors") return info if not doubanid: return None logger.info(f"开始获取豆瓣信息:{doubanid} ...") if mtype == MediaType.TV: return __douban_tv() elif mtype == MediaType.MOVIE: return __douban_movie() else: return __douban_movie() or __douban_tv() @rate_limit_exponential(source="douban_info") async def async_douban_info(self, doubanid: str, mtype: MediaType = None, raise_exception: bool = True) -> Optional[dict]: """ 获取豆瓣信息(异步版本) :param doubanid: 豆瓣ID :param mtype: 媒体类型 :param raise_exception: 触发速率限制时是否抛出异常 :return: 豆瓣信息 """ async def __async_douban_tv(): """ 获取豆瓣剧集信息(异步版本) """ info = await self.doubanapi.async_tv_detail(doubanid) if info: if "subject_ip_rate_limit" in info.get("msg", ""): msg = f"触发豆瓣IP速率限制,错误信息:{info} ..." logger.warn(msg) raise APIRateLimitException(msg) celebrities = await self.doubanapi.async_tv_celebrities(doubanid) if celebrities: info["directors"] = celebrities.get("directors") info["actors"] = celebrities.get("actors") return info async def __async_douban_movie(): """ 获取豆瓣电影信息(异步版本) """ info = await self.doubanapi.async_movie_detail(doubanid) if info: if "subject_ip_rate_limit" in info.get("msg", ""): msg = f"触发豆瓣IP速率限制,错误信息:{info} ..." logger.warn(msg) raise APIRateLimitException(msg) celebrities = await self.doubanapi.async_movie_celebrities(doubanid) if celebrities: info["directors"] = celebrities.get("directors") info["actors"] = celebrities.get("actors") return info if not doubanid: return None logger.info(f"开始获取豆瓣信息:{doubanid} ...") if mtype == MediaType.TV: return await __async_douban_tv() elif mtype == MediaType.MOVIE: return await __async_douban_movie() else: movie_result = await __async_douban_movie() if movie_result: return movie_result return await __async_douban_tv() def douban_discover(self, mtype: MediaType, sort: str, tags: str, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]: """ 发现豆瓣电影、剧集 :param mtype: 媒体类型 :param sort: 排序方式 :param tags: 标签 :param page: 页码 :param count: 数量 :return: 媒体信息列表 """ logger.info(f"开始发现豆瓣 {mtype.value} ...") if mtype == MediaType.MOVIE: infos = self.doubanapi.movie_recommend(start=(page - 1) * count, count=count, sort=sort, tags=tags) else: infos = self.doubanapi.tv_recommend(start=(page - 1) * count, count=count, sort=sort, tags=tags) if infos and infos.get("items"): medias = [MediaInfo(douban_info=info) for info in infos.get("items")] return [media for media in medias if media.poster_path and "movie_large.jpg" not in media.poster_path and "tv_normal.png" not in media.poster_path and "movie_large.jpg" not in media.poster_path and "tv_normal.jpg" not in media.poster_path and "tv_large.jpg" not in media.poster_path] return [] async def async_douban_discover(self, mtype: MediaType, sort: str, tags: str, page: int = 1, count: int = 30) -> Optional[List[MediaInfo]]: """ 发现豆瓣电影、剧集(异步版本) :param mtype: 媒体类型 :param sort: 排序方式 :param tags: 标签 :param page: 页码 :param count: 数量 :return: 媒体信息列表 """ logger.info(f"开始发现豆瓣 {mtype.value} ...") if mtype == MediaType.MOVIE: infos = await self.doubanapi.async_movie_recommend(start=(page - 1) * count, count=count, sort=sort, tags=tags) else: infos = await self.doubanapi.async_tv_recommend(start=(page - 1) * count, count=count, sort=sort, tags=tags) if infos and infos.get("items"): medias = [MediaInfo(douban_info=info) for info in infos.get("items")] return [media for media in medias if media.poster_path and "movie_large.jpg" not in media.poster_path and "tv_normal.png" not in media.poster_path and "movie_large.jpg" not in media.poster_path and "tv_normal.jpg" not in media.poster_path and "tv_large.jpg" not in media.poster_path] return [] def movie_showing(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取正在上映的电影 """ infos = self.doubanapi.movie_showing(start=(page - 1) * count, count=count) if infos and infos.get("subject_collection_items"): return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] async def async_movie_showing(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取正在上映的电影(异步版本) """ infos = await self.doubanapi.async_movie_showing(start=(page - 1) * count, count=count) if infos and infos.get("subject_collection_items"): return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] def tv_weekly_chinese(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取豆瓣本周口碑国产剧 """ infos = self.doubanapi.tv_chinese_best_weekly(start=(page - 1) * count, count=count) if infos: return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] async def async_tv_weekly_chinese(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取豆瓣本周口碑国产剧(异步版本) """ infos = await self.doubanapi.async_tv_chinese_best_weekly(start=(page - 1) * count, count=count) if infos: return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] def tv_weekly_global(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取豆瓣本周口碑外国剧 """ infos = self.doubanapi.tv_global_best_weekly(start=(page - 1) * count, count=count) if infos and infos.get("subject_collection_items"): return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] async def async_tv_weekly_global(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取豆瓣本周口碑外国剧(异步版本) """ infos = await self.doubanapi.async_tv_global_best_weekly(start=(page - 1) * count, count=count) if infos and infos.get("subject_collection_items"): return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] def tv_animation(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取豆瓣动画剧 """ infos = self.doubanapi.tv_animation(start=(page - 1) * count, count=count) if infos and infos.get("subject_collection_items"): return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] async def async_tv_animation(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取豆瓣动画剧(异步版本) """ infos = await self.doubanapi.async_tv_animation(start=(page - 1) * count, count=count) if infos and infos.get("subject_collection_items"): return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] def movie_hot(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取豆瓣热门电影 """ infos = self.doubanapi.movie_hot_gaia(start=(page - 1) * count, count=count) if infos and infos.get("subject_collection_items"): return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] async def async_movie_hot(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取豆瓣热门电影(异步版本) """ infos = await self.doubanapi.async_movie_hot_gaia(start=(page - 1) * count, count=count) if infos and infos.get("subject_collection_items"): return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] def tv_hot(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取豆瓣热门剧集 """ infos = self.doubanapi.tv_hot(start=(page - 1) * count, count=count) if infos and infos.get("subject_collection_items"): return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] async def async_tv_hot(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取豆瓣热门剧集(异步版本) """ infos = await self.doubanapi.async_tv_hot(start=(page - 1) * count, count=count) if infos and infos.get("subject_collection_items"): return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] def search_medias( self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaInfo]]: """ 搜索媒体信息 :param meta: 识别的元数据 :param media_source: 请求级搜索数据源 :return: 媒体信息 """ if not is_media_source_enabled(media_source, MediaSource.Douban): return None if not meta.name: return [] result = self.doubanapi.search(meta.name) if not result or not result.get("items"): return [] # 返回数据 return self._build_search_medias_result(meta, result.get("items")) async def async_search_medias( self, meta: MetaBase, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaInfo]]: """ 搜索媒体信息(异步版本) :param meta: 识别的元数据 :param media_source: 请求级搜索数据源 :return: 媒体信息 """ if not is_media_source_enabled(media_source, MediaSource.Douban): return None if not meta.name: return [] result = await self.doubanapi.async_search(meta.name) if not result or not result.get("items"): return [] # 返回数据 return self._build_search_medias_result(meta, result.get("items")) def search_persons( self, name: str, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaPerson]]: """ 搜索人物信息 :param name: 人物名称 :param media_source: 请求级搜索数据源 :return: 人物信息列表 """ if not is_media_source_enabled(media_source, MediaSource.Douban): return None if not name: return [] result = self.doubanapi.person_search(keyword=name) if result and result.get('items'): return [MediaPerson(source='douban', **{ 'id': item.get('target_id'), 'name': item.get('target', {}).get('title'), 'url': item.get('target', {}).get('url'), 'images': item.get('target', {}).get('cover', {}), 'avatar': (item.get('target', {}).get('cover_img', {}).get('url') or '').replace("/l/public/", "/s/public/"), }) for item in result.get('items') if name in item.get('target', {}).get('title')] return [] async def async_search_persons( self, name: str, media_source: Optional[MediaSourceSelection] = None ) -> Optional[List[MediaPerson]]: """ 搜索人物信息(异步版本) :param name: 人物名称 :param media_source: 请求级搜索数据源 :return: 人物信息列表 """ if not is_media_source_enabled(media_source, MediaSource.Douban): return None if not name: return [] result = await self.doubanapi.async_person_search(keyword=name) if result and result.get('items'): return [MediaPerson(source='douban', **{ 'id': item.get('target_id'), 'name': item.get('target', {}).get('title'), 'url': item.get('target', {}).get('url'), 'images': item.get('target', {}).get('cover', {}), 'avatar': (item.get('target', {}).get('cover_img', {}).get('url') or '').replace("/l/public/", "/s/public/"), }) for item in result.get('items') if name in item.get('target', {}).get('title')] return [] @staticmethod def _process_imdbid_result(result: dict, imdbid: str) -> Optional[dict]: """ 处理IMDBID查询结果 :param result: IMDBID查询返回的结果 :param imdbid: IMDB ID :return: 处理后的结果,None表示无结果 """ if result: doubanid = result.get("id") if doubanid: if not str(doubanid).isdigit(): doubanid = re.search(r"\d+", doubanid).group(0) result["id"] = doubanid logger.info(f"{imdbid} 查询到豆瓣信息:{result.get('title')}") return result return None return None @staticmethod def _process_search_results(result: dict, name: str, mtype: MediaType = None, year: str = None, season: int = None) -> dict: """ 处理搜索结果并进行匹配 :param result: 搜索返回的结果 :param name: 搜索名称 :param mtype: 媒体类型 :param year: 年份 :param season: 季号 :return: 匹配到的豆瓣信息 """ if not result: logger.warn(f"未找到 {name} 的豆瓣信息") return {} # 触发rate limit检查 if "search_access_rate_limit" in result.values(): msg = f"触发豆瓣API速率限制,错误信息:{result} ..." logger.warn(msg) raise APIRateLimitException(msg) if not result.get("items"): logger.warn(f"未找到 {name} 的豆瓣信息") return {} for item_obj in result.get("items"): type_name = item_obj.get("type_name") if type_name not in [MediaType.TV.value, MediaType.MOVIE.value]: continue if mtype and mtype.value != type_name: continue if mtype and mtype == MediaType.TV and season is None: season = 1 item = item_obj.get("target") title = item.get("title") if not title: continue meta = MetaInfo(title) if type_name == MediaType.TV.value: meta.type = MediaType.TV meta.begin_season = meta.begin_season if meta.begin_season is not None else 1 if meta.name == name \ and ((season is None and meta.begin_season is None) or meta.begin_season == season) \ and (not year or item.get('year') == year): logger.info(f"{name} 匹配到豆瓣信息:{item.get('id')} {item.get('title')}") return item return {} @retry(Exception, 5, 3, 3, logger=logger) @rate_limit_exponential(source="match_doubaninfo") def match_doubaninfo(self, name: str, imdbid: str = None, mtype: MediaType = None, year: str = None, season: int = None, raise_exception: bool = False) -> dict: """ 搜索和匹配豆瓣信息 :param name: 名称 :param imdbid: IMDB ID :param mtype: 类型 :param year: 年份 :param season: 季号 :param raise_exception: 触发速率限制时是否抛出异常 """ if imdbid: # 优先使用IMDBID查询 logger.info(f"开始使用IMDBID {imdbid} 查询豆瓣信息 ...") result = self.doubanapi.imdbid(imdbid) processed_result = self._process_imdbid_result(result, imdbid) if processed_result: return processed_result # 搜索 logger.info(f"开始使用名称 {name} 匹配豆瓣信息 ...") result = self.doubanapi.search(f"{name} {year or ''}".strip()) return self._process_search_results(result, name, mtype, year, season) @retry(Exception, 5, 3, 3, logger=logger) @rate_limit_exponential(source="match_doubaninfo") async def async_match_doubaninfo(self, name: str, imdbid: str = None, mtype: MediaType = None, year: str = None, season: int = None, raise_exception: bool = False) -> dict: """ 搜索和匹配豆瓣信息(异步版本) :param name: 名称 :param imdbid: IMDB ID :param mtype: 类型 :param year: 年份 :param season: 季号 :param raise_exception: 触发速率限制时是否抛出异常 """ if imdbid: # 优先使用IMDBID查询 logger.info(f"开始使用IMDBID {imdbid} 查询豆瓣信息 ...") result = await self.doubanapi.async_imdbid(imdbid) processed_result = self._process_imdbid_result(result, imdbid) if processed_result: return processed_result # 搜索 logger.info(f"开始使用名称 {name} 匹配豆瓣信息 ...") result = await self.doubanapi.async_search(f"{name} {year or ''}".strip()) return self._process_search_results(result, name, mtype, year, season) def movie_top250(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取豆瓣电影TOP250 """ infos = self.doubanapi.movie_top250(start=(page - 1) * count, count=count) if infos and infos.get("subject_collection_items"): return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] async def async_movie_top250(self, page: int = 1, count: int = 30) -> List[MediaInfo]: """ 获取豆瓣电影TOP250(异步版本) """ infos = await self.doubanapi.async_movie_top250(start=(page - 1) * count, count=count) if infos and infos.get("subject_collection_items"): return [MediaInfo(douban_info=info) for info in infos.get("subject_collection_items")] return [] def metadata_nfo(self, mediainfo: MediaInfo, season: int = None, **kwargs) -> Optional[str]: """ 获取NFO文件内容文本 :param mediainfo: 媒体信息 :param season: 季号 """ if (mediainfo.scrape_source or settings.SCRAP_SOURCE) != "douban": return None return self.scraper.get_metadata_nfo(mediainfo=mediainfo, season=season) def metadata_img(self, mediainfo: MediaInfo, season: int = None, episode: int = None) -> Optional[dict]: """ 获取图片名称和url :param mediainfo: 媒体信息 :param season: 季号 :param episode: 集号 """ if (mediainfo.scrape_source or settings.SCRAP_SOURCE) != "douban": return None return self.scraper.get_metadata_img(mediainfo=mediainfo, season=season, episode=episode) @staticmethod def _validate_douban_obtain_images_params(mediainfo: MediaInfo) -> Optional[MediaInfo]: """ 验证豆瓣 obtain_images 参数 :param mediainfo: 媒体信息 :return: None 表示不处理,MediaInfo 表示继续处理 """ if mediainfo.media_source != MediaSource.Douban and settings.RECOGNIZE_SOURCE != "douban": return None if not mediainfo.douban_id: return None if mediainfo.backdrop_path: # 没有图片缺失 return mediainfo return None @staticmethod def _process_douban_images(mediainfo: MediaInfo, info: dict) -> MediaInfo: """ 处理豆瓣图片数据 :param mediainfo: 媒体信息 :param info: 图片信息 :return: 更新后的媒体信息 """ if not info: return mediainfo images = info.get("photos") # 背景图 if images: backdrop = images[0].get("image", {}).get("large") or {} if backdrop: mediainfo.backdrop_path = backdrop.get("url") return mediainfo def obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]: """ 补充抓取媒体信息图片 :param mediainfo: 识别的媒体信息 :return: 更新后的媒体信息 """ # 验证参数 result = self._validate_douban_obtain_images_params(mediainfo) if result is not None: return result # 调用图片接口 if mediainfo.type == MediaType.MOVIE: info = self.doubanapi.movie_photos(mediainfo.douban_id) else: info = self.doubanapi.tv_photos(mediainfo.douban_id) # 处理图片数据 return self._process_douban_images(mediainfo, info) async def async_obtain_images(self, mediainfo: MediaInfo) -> Optional[MediaInfo]: """ 补充抓取媒体信息图片(异步版本) :param mediainfo: 识别的媒体信息 :return: 更新后的媒体信息 """ # 验证参数 result = self._validate_douban_obtain_images_params(mediainfo) if result is not None: return result # 调用图片接口 if mediainfo.type == MediaType.MOVIE: info = await self.doubanapi.async_movie_photos(mediainfo.douban_id) else: info = await self.doubanapi.async_tv_photos(mediainfo.douban_id) # 处理图片数据 return self._process_douban_images(mediainfo, info) def clear_cache(self): """ 清除缓存 """ logger.info("开始清除豆瓣缓存 ...") self.doubanapi.clear_cache() logger.info("豆瓣缓存清除完成") def douban_movie_credits(self, doubanid: str) -> List[schemas.MediaPerson]: """ 根据豆瓣ID查询电影演职员表 :param doubanid: 豆瓣ID """ result = self.doubanapi.movie_celebrities(subject_id=doubanid) return self._process_celebrity_data(result) def douban_tv_credits(self, doubanid: str) -> List[schemas.MediaPerson]: """ 根据豆瓣ID查询电视剧演职员表 :param doubanid: 豆瓣ID """ result = self.doubanapi.tv_celebrities(subject_id=doubanid) return self._process_celebrity_data(result) def douban_movie_recommend(self, doubanid: str) -> List[MediaInfo]: """ 根据豆瓣ID查询推荐电影 :param doubanid: 豆瓣ID """ recommend = self.doubanapi.movie_recommendations(subject_id=doubanid) if recommend: return [MediaInfo(douban_info=info) for info in recommend] return [] def douban_tv_recommend(self, doubanid: str) -> List[MediaInfo]: """ 根据豆瓣ID查询推荐电视剧 :param doubanid: 豆瓣ID """ recommend = self.doubanapi.tv_recommendations(subject_id=doubanid) if recommend: return [MediaInfo(douban_info=info) for info in recommend] return [] def douban_person_detail(self, person_id: int) -> schemas.MediaPerson: """ 获取人物详细信息 :param person_id: 豆瓣人物ID """ detail = self.doubanapi.person_detail(person_id) if detail: also_known_as = [] infos = detail.get("extra", {}).get("info") if infos: also_known_as = [":".join(info) for info in infos] image = detail.get("cover_img", {}).get("url") if image: image = image.replace("/l/public/", "/s/public/") return schemas.MediaPerson(source='douban', **{ "id": detail.get("id"), "name": detail.get("title"), "avatar": image, "biography": detail.get("extra", {}).get("short_info"), "also_known_as": also_known_as, }) return schemas.MediaPerson(source='douban') def douban_person_credits(self, person_id: int, page: int = 1) -> List[MediaInfo]: """ 根据TMDBID查询人物参演作品 :param person_id: 人物ID :param page: 页码 """ # 获取人物参演作品集 personinfo = self.doubanapi.person_detail(person_id) if not personinfo: return [] collection_id = None for module in personinfo.get("modules"): if module.get("type") == "work_collections": collection_id = module.get("payload", {}).get("id") # 查询作品集内容 if collection_id: collections = self.doubanapi.person_work(subject_id=collection_id, start=(page - 1) * 20, count=20) if collections: works = collections.get("works") return [MediaInfo(douban_info=work.get("subject")) for work in works] return [] @staticmethod def _process_celebrity_data(result: dict) -> List[schemas.MediaPerson]: """ 处理演职员表数据的公共方法 :param result: API返回的演职员表数据 :return: 处理后的演员列表 """ if not result: return [] ret_list = result.get("actors") or [] if ret_list: # 更新豆瓣演员信息中的ID,从URI中提取'douban://douban.com/celebrity/1316132?subject_id=27503705' subject_id for doubaninfo in ret_list: doubaninfo['id'] = doubaninfo.get('uri', '').split('?subject_id=')[-1] return [schemas.MediaPerson(source='douban', **doubaninfo) for doubaninfo in ret_list] return [] async def async_douban_movie_credits(self, doubanid: str) -> List[schemas.MediaPerson]: """ 根据豆瓣ID查询电影演职员表(异步版本) :param doubanid: 豆瓣ID """ result = await self.doubanapi.async_movie_celebrities(subject_id=doubanid) return self._process_celebrity_data(result) async def async_douban_tv_credits(self, doubanid: str) -> List[schemas.MediaPerson]: """ 根据豆瓣ID查询电视剧演职员表(异步版本) :param doubanid: 豆瓣ID """ result = await self.doubanapi.async_tv_celebrities(subject_id=doubanid) return self._process_celebrity_data(result) async def async_douban_movie_recommend(self, doubanid: str) -> List[MediaInfo]: """ 根据豆瓣ID查询推荐电影(异步版本) :param doubanid: 豆瓣ID """ recommend = await self.doubanapi.async_movie_recommendations(subject_id=doubanid) if recommend: return [MediaInfo(douban_info=info) for info in recommend] return [] async def async_douban_tv_recommend(self, doubanid: str) -> List[MediaInfo]: """ 根据豆瓣ID查询推荐电视剧(异步版本) :param doubanid: 豆瓣ID """ recommend = await self.doubanapi.async_tv_recommendations(subject_id=doubanid) if recommend: return [MediaInfo(douban_info=info) for info in recommend] return [] async def async_douban_person_detail(self, person_id: int) -> schemas.MediaPerson: """ 获取人物详细信息(异步版本) :param person_id: 豆瓣人物ID """ detail = await self.doubanapi.async_person_detail(person_id) if detail: also_known_as = [] infos = detail.get("extra", {}).get("info") if infos: also_known_as = [":".join(info) for info in infos] image = detail.get("cover_img", {}).get("url") if image: image = image.replace("/l/public/", "/s/public/") return schemas.MediaPerson(source='douban', **{ "id": detail.get("id"), "name": detail.get("title"), "avatar": image, "biography": detail.get("extra", {}).get("short_info"), "also_known_as": also_known_as, }) return schemas.MediaPerson(source='douban') async def async_douban_person_credits(self, person_id: int, page: int = 1) -> List[MediaInfo]: """ 根据豆瓣ID查询人物参演作品(异步版本) :param person_id: 人物ID :param page: 页码 """ # 获取人物参演作品集 personinfo = await self.doubanapi.async_person_detail(person_id) if not personinfo: return [] collection_id = None for module in personinfo.get("modules"): if module.get("type") == "work_collections": collection_id = module.get("payload", {}).get("id") # 查询作品集内容 if collection_id: collections = await self.doubanapi.async_person_work(subject_id=collection_id, start=(page - 1) * 20, count=20) if collections: works = collections.get("works") return [MediaInfo(douban_info=work.get("subject")) for work in works] return []