from pathlib import Path from typing import Annotated, Any, List, Optional, Union from uuid import UUID from fastapi import Depends, Query from pydantic import BeforeValidator from app.schemas.category import CategoryConfig as _SchemaCategoryConfig from app.schemas.category import MediaCategoryMap as _SchemaMediaCategoryMap from app.schemas.context import MediaEpisodeGroup as _SchemaMediaEpisodeGroup from app.schemas.context import MediaPerson as _SchemaMediaPerson from app.schemas.context import MediaSearchResults as _SchemaMediaSearchResults from app.schemas.context import MediaSeason as _SchemaMediaSeason from app.schemas.response import Response as _SchemaResponse from app.schemas.token import TokenPayload as _SchemaTokenPayload from app.schemas.workflow import Context as _SchemaContext from app.schemas.workflow import FileItem as _SchemaFileItem from app.schemas.workflow import MediaInfo as _SchemaMediaInfo from app.api.response import ResponseAPIRouter from app.chain.media import MediaChain from app.chain.scraping import ScrapingChain from app.chain.tmdb import TmdbChain from app.application.configuration import get_api_runtime_config_snapshot from app.domain.context import Context, MusicInfo from app.domain.meta.metabase import MetaBase from app.domain.meta.metamusic import MetaMusic from app.domain.metainfo import MetaInfo, MetaInfoPath from app.adapters.web.security.access import verify_token, verify_apitoken from app.api.dependencies.auth import ( get_current_active_superuser, get_current_active_user, ) from app.schemas.category import CategoryConfig from app.schemas.event import MediaSourceInfo as _SchemaMediaSourceInfo from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource, MediaType from app.domain.media import is_music_media_source, normalize_music_type, parse_media_source_selection from app.schemas.media import normalize_media_source, resolve_media_identity router = ResponseAPIRouter() _BUILTIN_MEDIA_SOURCES = ( _SchemaMediaSourceInfo(name="TheMovieDb", media_source=MediaSource.TMDB), _SchemaMediaSourceInfo(name="豆瓣", media_source=MediaSource.Douban), _SchemaMediaSourceInfo(name="Bangumi", media_source=MediaSource.Bangumi), _SchemaMediaSourceInfo(name="AniList", media_source=MediaSource.AniList), _SchemaMediaSourceInfo(name="IMDb", media_source=MediaSource.IMDb), _SchemaMediaSourceInfo(name="TVDB", media_source=MediaSource.TVDB), _SchemaMediaSourceInfo( name="MusicBrainz", media_source=MediaSource.MusicBrainz, media_types=[MediaType.MUSIC], ), _SchemaMediaSourceInfo( name="TheAudioDB", media_source=MediaSource.TheAudioDB, media_types=[MediaType.MUSIC], ), _SchemaMediaSourceInfo( name="豆瓣音乐", media_source=MediaSource.DoubanMusic, media_types=[MediaType.MUSIC], ), _SchemaMediaSourceInfo(name="哔哩哔哩", media_source=MediaSource.Bilibili), _SchemaMediaSourceInfo(name="芒果TV", media_source=MediaSource.MangoTV), _SchemaMediaSourceInfo(name="咪咕视频", media_source=MediaSource.MiguVideo), _SchemaMediaSourceInfo(name="腾讯视频", media_source=MediaSource.TencentVideo), _SchemaMediaSourceInfo(name="爱奇艺", media_source=MediaSource.Iqiyi), ) def _registered_media_sources() -> list[_SchemaMediaSourceInfo]: """合并内置与启用插件声明的媒体来源,并按来源标识去重。""" from app.application.plugin.runtime import get_plugin_manager result = list(_BUILTIN_MEDIA_SOURCES) seen = {source.media_source for source in result} for raw_source in get_plugin_manager().get_media_sources(): try: source = _SchemaMediaSourceInfo.model_validate(raw_source) except Exception: continue if source.media_source in seen: continue result.append(source) seen.add(source.media_source) return result def _split_media_source_query(value: object) -> tuple[str, ...]: """展开重复或逗号分隔的来源参数,并在枚举校验前规范历史别名。""" if value in (None, ""): return () values = value if isinstance(value, (list, tuple)) else (value,) sources = tuple( source.strip() for item in values for source in str(item).split(",") if source.strip() ) return tuple( normalized.value if (normalized := normalize_media_source(source)) else source for source in sources ) MediaSourceQuery = Annotated[ tuple[MediaSource, ...], BeforeValidator(_split_media_source_query), Query(), ] def _is_valid_source_media_id( media_source: Optional[MediaSource], media_id: str, ) -> bool: """按媒体数据源校验原生 ID,并兼容豆瓣音乐的曲目复合 ID。""" normalized_source, normalized_media_id = resolve_media_identity( media_source=media_source, media_id=media_id, ) if not normalized_source or not normalized_media_id: return False if normalized_source == MediaSource.MusicBrainz: try: UUID(normalized_media_id) return True except (TypeError, ValueError): return False if normalized_source == MediaSource.DoubanMusic and ":" in normalized_media_id: album_id, track_number = normalized_media_id.split(":", 1) return album_id.isdigit() and track_number.isdigit() if normalized_source == MediaSource.IMDb: return ( normalized_media_id.startswith("tt") and normalized_media_id[2:].isdigit() ) return True def _build_recognize_metainfo( title: str, subtitle: Optional[str] = None, custom_words: Optional[str] = None, ) -> MetaBase: """构造标题识别元数据,并兼容第三方客户端传入媒体文件路径。""" custom_word_list = custom_words.split("\n") if custom_words else None normalized_title = title.replace("\\", "/") title_path = Path(normalized_title) if ( ("/" in title or "\\" in title) and "://" not in title and title_path.suffix.lower() in get_api_runtime_config_snapshot().media_extensions ): metainfo = MetaInfoPath( title_path, custom_words=custom_word_list, ) metainfo.title = title return metainfo return MetaInfo(title, subtitle, custom_words=custom_word_list) def _build_media_seasons( mediainfo: Any, season: Optional[int] = None, ) -> List[_SchemaMediaSeason]: """将任意数据源的统一媒体信息转换为季信息响应。""" seasons_info = [] for item in mediainfo.season_info or []: season_number = item.get("season_number") if season is not None and season_number != season: continue seasons_info.append(_SchemaMediaSeason( air_date=item.get("air_date"), episode_count=item.get("episode_count"), name=item.get("name"), overview=item.get("overview"), poster_path=item.get("poster_path") or mediainfo.poster_path, season_number=season_number, vote_average=item.get("vote_average"), )) if seasons_info: return seasons_info season_numbers = sorted((mediainfo.seasons or {}).keys()) if season is not None: season_numbers = [season] elif not season_numbers: season_numbers = [mediainfo.season or 1] return [ _SchemaMediaSeason( season_number=season_number, poster_path=mediainfo.poster_path, name=f"第 {season_number} 季", air_date=mediainfo.release_date, overview=mediainfo.overview, vote_average=mediainfo.vote_average, episode_count=( len((mediainfo.seasons or {}).get(season_number) or []) or mediainfo.number_of_episodes ), ) for season_number in season_numbers ] @router.get( "/recognize", summary="识别媒体信息(种子)", response_model=_SchemaContext ) async def recognize( title: str, subtitle: Optional[str] = None, custom_words: Optional[str] = None, media_source: Optional[MediaSource] = None, _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 根据标题、副标题识别媒体信息 :param title: 标题 :param subtitle: 副标题 :param custom_words: 临时识别词(每行一条规则),传入时仅在本次识别中生效,不会保存到系统配置 :param media_source: 请求级识别数据源 :param _: """ # 识别媒体信息,传入临时识别词时优先于系统配置的识别词生效 metainfo = _build_recognize_metainfo(title, subtitle, custom_words) # 显式音乐来源需要按音乐元数据解析,避免名称测试误入影视识别。 if is_music_media_source(media_source) and not isinstance(metainfo, MetaMusic): metainfo = MetaMusic.parse_query(title) mediainfo = await MediaChain().async_recognize_by_meta( metainfo, media_source=media_source, ) if mediainfo: return Context(meta_info=metainfo, media_info=mediainfo).to_dict() return _SchemaContext() @router.get( "/recognize2", summary="识别种子媒体信息(API_TOKEN)", response_model=_SchemaContext, ) async def recognize2( _: Annotated[str, Depends(verify_apitoken)], title: str, subtitle: Optional[str] = None, custom_words: Optional[str] = None, media_source: Optional[MediaSource] = None, ) -> Any: """ 根据标题、副标题识别媒体信息 API_TOKEN认证(?token=xxx) """ # 识别媒体信息 return await recognize(title, subtitle, custom_words, media_source) @router.get( "/recognize_file", summary="识别媒体信息(文件)", response_model=_SchemaContext ) async def recognize_file( path: str, media_source: Optional[MediaSource] = None, _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 根据文件路径识别媒体信息,影视与音乐统一走媒体链路径识别入口 """ # 识别媒体信息 context = await MediaChain().async_recognize_by_path( path, media_source=media_source ) if context: return context.to_dict() return _SchemaContext() @router.get( "/recognize_file2", summary="识别文件媒体信息(API_TOKEN)", response_model=_SchemaContext, ) async def recognize_file2( path: str, _: Annotated[str, Depends(verify_apitoken)], media_source: Optional[MediaSource] = None, ) -> Any: """ 根据文件路径识别媒体信息 API_TOKEN认证(?token=xxx) """ # 识别媒体信息 return await recognize_file(path, media_source) @router.get( "/search", summary="搜索媒体/人物信息", response_model=_SchemaMediaSearchResults, ) async def search( title: str, type: Optional[str] = "media", page: int = 1, count: int = 8, media_source: MediaSourceQuery = (), _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 模糊搜索媒体、合集、人物或音乐信息列表。 :param title: 搜索关键词 :param type: 搜索类型,支持 media、music、collection、person :param page: 页码 :param count: 每页数量 :param media_source: 请求级搜索数据源枚举;可重复传入,逗号格式仅用于兼容旧客户端 :param _: Token校验 :return: 搜索结果列表 """ def __get_source(obj: Union[_SchemaMediaInfo, _SchemaMediaPerson, dict]): """ 获取对象属性 """ if isinstance(obj, dict): return obj.get("media_source") return obj.media_source # 直接函数调用也可能绕过 FastAPI/Pydantic,仅在该测试与内部兼容边界补一次规范化。 selected_sources = ( media_source if isinstance(media_source, tuple) and all(isinstance(source, MediaSource) for source in media_source) else parse_media_source_selection(",".join(_split_media_source_query(media_source))) ) selected_sources = tuple(dict.fromkeys(selected_sources)) source_selection = selected_sources or None media_chain = MediaChain() if type == "music" or any(is_music_media_source(source) for source in selected_sources): # 音乐搜索统一入口,与影视搜索共用 /media/search music_search_params = {"query": title, "limit": count} # 未指定来源时由 MediaChain 使用默认 MusicBrainz 来源。 if source_selection: music_search_params["media_source"] = source_selection music_infos = await media_chain.async_search_music(**music_search_params) return [ info.to_dict() for info in music_infos ] if music_infos else [] if type == "media": _, medias = await media_chain.async_search( title=title, media_source=source_selection ) result = [media.to_dict() for media in medias] if medias else [] elif type == "collection": collections = await media_chain.async_search_collections( name=title, media_source=source_selection ) result = ( [collection.to_dict() for collection in collections] if collections else [] ) else: # person persons = await media_chain.async_search_persons( name=title, media_source=source_selection ) result = [person.model_dump() for person in persons] if persons else [] if not result: return [] # 排序和分页 search_source = get_api_runtime_config_snapshot().search_source setting_order = search_source.split(",") if search_source else [] sort_order = {source: index for index, source in enumerate(setting_order)} sorted_result = sorted(result, key=lambda x: sort_order.get(__get_source(x), 4)) return sorted_result[(page - 1) * count : page * count] @router.get( "/source", summary="获取媒体数据源", response_model=list[_SchemaMediaSourceInfo], ) def source(_: _SchemaTokenPayload = Depends(verify_token)) -> list[_SchemaMediaSourceInfo]: """返回内置及启用插件注册的媒体数据源,供前端统一构造来源选项。""" return _registered_media_sources() def _scrape_impl( fileitem: _SchemaFileItem, storage: Optional[str] = "local", media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, type_name: Optional[MediaType] = None, music_type: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 刮削媒体信息,可按请求指定媒体数据源及其原生ID :param fileitem: 待刮削文件项 :param storage: 文件所在存储 :param media_source: 请求级媒体数据源 :param media_id: 数据源原生ID :param type_name: 媒体类型 :param music_type: 音乐实体类型,支持 recording 和 album :param _: Token校验 """ if not fileitem or not fileitem.path: return _SchemaResponse(success=False, message="刮削路径无效") has_explicit_media_id = media_id is not None normalized_media_id = str(media_id).strip() if has_explicit_media_id else None if has_explicit_media_id and not normalized_media_id: return _SchemaResponse(success=False, message="媒体ID格式无效") if normalized_media_id and not media_source: return _SchemaResponse( success=False, message="指定媒体ID时必须同时指定媒体数据源" ) if normalized_media_id and not _is_valid_source_media_id(media_source, normalized_media_id): return _SchemaResponse(success=False, message="媒体ID格式无效") is_music = ( type_name == MediaType.MUSIC or is_music_media_source(media_source) or MediaChain.is_audio_path(fileitem.path) ) if is_music: if type_name not in (None, MediaType.MUSIC): return _SchemaResponse(success=False, message="音乐元数据源只能用于音乐刮削") music_info: Optional[MusicInfo] = None if normalized_media_id: normalized_music_type = normalize_music_type( music_type or MUSIC_ENTITY_RECORDING, allow_artist=False, ) if not normalized_music_type: return _SchemaResponse( success=False, message="音乐实体类型无效,仅支持 recording 或 album", ) # 音乐与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情 music_info = MediaChain().recognize_media( media_source=media_source or MediaSource.MusicBrainz, media_id=normalized_media_id, mtype=MediaType.MUSIC, music_type=normalized_music_type, ) if not music_info: return _SchemaResponse(success=False, message="刮削失败,无法识别音乐信息") success, message = ScrapingChain().scrape_music_metadata( fileitem=fileitem, mediainfo=music_info, overwrite=True, media_source=media_source, ) return _SchemaResponse(success=success, message=message) chain = MediaChain() if normalized_media_id: meta_info = MetaInfoPath(Path(fileitem.path)) media_info = chain.recognize_media( meta=meta_info, mtype=type_name, media_source=media_source, media_id=normalized_media_id, ) if media_info: media_info.scrape_source = media_source chain.obtain_images(mediainfo=media_info) else: context = chain.recognize_by_path( fileitem.path, media_source=media_source, obtain_images=True, ) meta_info = context.meta_info if context else None media_info = context.media_info if context else None if not media_info: return _SchemaResponse(success=False, message="刮削失败,无法识别媒体信息") if media_source: media_info.scrape_source = media_source if storage == "local": if not Path(fileitem.path).exists(): return _SchemaResponse(success=False, message="刮削路径不存在") # 手动刮削 (暂时使用同步版本,可以后续优化为异步) ScrapingChain().scrape_metadata( fileitem=fileitem, meta=meta_info, mediainfo=media_info, overwrite=True, ) return _SchemaResponse(success=True, message=f"{fileitem.path} 刮削完成") @router.post( "/scrape/{storage}", summary="刮削媒体信息", response_model=_SchemaResponse[None] ) def scrape( fileitem: _SchemaFileItem, storage: Optional[str] = "local", media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, type_name: Optional[MediaType] = None, music_type: Optional[str] = None, _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """刮削媒体信息的兼容公开入口。""" return _scrape_impl(fileitem, storage, media_source, media_id, type_name, music_type, _) @router.get( "/category/config", summary="获取分类策略配置", response_model=_SchemaResponse[_SchemaCategoryConfig], ) def get_category_config(_: object = Depends(get_current_active_user)): """ 获取分类策略配置 """ config = MediaChain().category_config() return _SchemaResponse(success=True, data=config.model_dump()) @router.post( "/category/config", summary="保存分类策略配置", response_model=_SchemaResponse[None] ) def save_category_config( config: CategoryConfig, _: object = Depends(get_current_active_superuser) ): """ 保存分类策略配置 """ if MediaChain().save_category_config(config): return _SchemaResponse(success=True, message="保存成功") else: return _SchemaResponse(success=False, message="保存失败") @router.get( "/category", summary="查询自动分类配置", response_model=_SchemaMediaCategoryMap, ) async def category(_: _SchemaTokenPayload = Depends(verify_token)) -> Any: """ 查询自动分类配置 """ return MediaChain().media_category() or {} @router.get( "/group/seasons/{episode_group}", summary="查询剧集组季信息", response_model=List[_SchemaMediaSeason], ) async def group_seasons( episode_group: str, _: _SchemaTokenPayload = Depends(verify_token) ) -> Any: """ 查询剧集组季信息(themoviedb) """ _, normalized_group_id = resolve_media_identity( media_source=MediaSource.TMDB, media_id=episode_group, ) if not normalized_group_id: return [] return await TmdbChain().async_tmdb_group_seasons(group_id=normalized_group_id) @router.get( "/groups/{tmdbid}", summary="查询媒体剧集组", response_model=List[_SchemaMediaEpisodeGroup], ) async def groups(tmdbid: int, _: _SchemaTokenPayload = Depends(verify_token)) -> Any: """ 查询媒体剧集组列表(themoviedb) """ media_source, media_id = resolve_media_identity( media_source=MediaSource.TMDB, media_id=tmdbid, ) if not media_source or not media_id: return [] mediainfo = await MediaChain().async_recognize_media( media_source=media_source, media_id=media_id, mtype=MediaType.TV, ) if not mediainfo: return [] return mediainfo.episode_groups @router.get( "/seasons", summary="查询媒体季信息", response_model=List[_SchemaMediaSeason] ) async def seasons( media_source: Optional[MediaSource] = None, media_id: Optional[str] = None, title: Optional[str] = None, year: str = None, season: int = None, _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 查询媒体季信息 """ if media_source is not None or media_id is not None: normalized_source, normalized_media_id = resolve_media_identity( media_source=media_source, media_id=media_id, ) if not normalized_source or not normalized_media_id: return [] if normalized_source == MediaSource.TMDB and normalized_media_id.isdigit(): tmdbid = int(normalized_media_id) seasons_info = await TmdbChain().async_tmdb_seasons(tmdbid=tmdbid) if seasons_info: if season is not None: return [sea for sea in seasons_info if sea.season_number == season] return seasons_info else: mediainfo = await MediaChain().async_recognize_media( media_source=normalized_source, media_id=normalized_media_id, mtype=MediaType.TV, cache=False, ) if mediainfo: return _build_media_seasons(mediainfo, season) # 明确来源的查询不能按标题切换到默认识别源,避免辅助 TMDB 信息替换主身份。 return [] if title: meta = MetaInfo(title) if year: meta.year = year meta.type = MediaType.TV mediainfo = await MediaChain().async_recognize_by_meta( meta, obtain_images=False, ) if mediainfo: recognized_source, recognized_media_id = resolve_media_identity( media=mediainfo ) if ( recognized_source == MediaSource.TMDB and recognized_media_id and recognized_media_id.isdigit() ): seasons_info = await TmdbChain().async_tmdb_seasons( tmdbid=int(recognized_media_id) ) if seasons_info: if season is not None: return [ sea for sea in seasons_info if sea.season_number == season ] return seasons_info return _build_media_seasons(mediainfo, season) return [] @router.get("/{media_id}", summary="查询媒体详情", response_model=_SchemaMediaInfo) async def detail( media_id: str, media_source: MediaSource, type_name: str, _: _SchemaTokenPayload = Depends(verify_token), ) -> Any: """ 根据媒体来源和原生 ID 查询媒体信息,type_name: 电影/电视剧 """ mtype = MediaType(type_name) normalized_source, normalized_media_id = resolve_media_identity( media_source=media_source, media_id=media_id, ) if not normalized_source or not normalized_media_id: return _SchemaMediaInfo() mediachain = MediaChain() mediainfo = await mediachain.async_recognize_media( media_source=normalized_source, media_id=normalized_media_id, mtype=mtype, ) # 识别 if mediainfo: await mediachain.async_obtain_images(mediainfo) # 电视剧且有 TVDB ID 时,补充获取 slug 用于构建 TheTvDb 直达链接 if mediainfo.type == MediaType.TV and mediainfo.tvdb_id and not mediainfo.tvdb_slug: slug = mediachain.tvdb_slug(mediainfo.tvdb_id) if slug: mediainfo.tvdb_slug = slug return mediainfo.to_dict() return _SchemaMediaInfo()