mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-15 19:14:01 +08:00
623 lines
22 KiB
Python
623 lines
22 KiB
Python
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 import schemas
|
||
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.runtime.config import settings
|
||
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.application.security.access import verify_token, verify_apitoken
|
||
from app.db.models import User
|
||
from app.db.user_oper import get_current_active_user, get_current_active_superuser
|
||
from app.schemas import MediaType
|
||
from app.schemas.category import CategoryConfig
|
||
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource
|
||
from app.domain.media import (
|
||
is_music_media_source,
|
||
normalize_media_source,
|
||
normalize_music_type,
|
||
parse_media_source_selection,
|
||
resolve_media_identity,
|
||
)
|
||
|
||
router = ResponseAPIRouter()
|
||
|
||
|
||
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 settings.RMT_MEDIAEXT
|
||
):
|
||
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[schemas.MediaSeason]:
|
||
"""将任意数据源的统一媒体信息转换为季信息响应。"""
|
||
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(schemas.MediaSeason(
|
||
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 [
|
||
schemas.MediaSeason(
|
||
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=schemas.Context
|
||
)
|
||
async def recognize(
|
||
title: str,
|
||
subtitle: Optional[str] = None,
|
||
custom_words: Optional[str] = None,
|
||
media_source: Optional[MediaSource] = None,
|
||
_: schemas.TokenPayload = 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 schemas.Context()
|
||
|
||
|
||
@router.get(
|
||
"/recognize2",
|
||
summary="识别种子媒体信息(API_TOKEN)",
|
||
response_model=schemas.Context,
|
||
)
|
||
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=schemas.Context
|
||
)
|
||
async def recognize_file(
|
||
path: str,
|
||
media_source: Optional[MediaSource] = None,
|
||
_: schemas.TokenPayload = Depends(verify_token),
|
||
) -> Any:
|
||
"""
|
||
根据文件路径识别媒体信息,影视与音乐统一走媒体链路径识别入口
|
||
"""
|
||
# 识别媒体信息
|
||
context = await MediaChain().async_recognize_by_path(
|
||
path, media_source=media_source
|
||
)
|
||
if context:
|
||
return context.to_dict()
|
||
return schemas.Context()
|
||
|
||
|
||
@router.get(
|
||
"/recognize_file2",
|
||
summary="识别文件媒体信息(API_TOKEN)",
|
||
response_model=schemas.Context,
|
||
)
|
||
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=schemas.MediaSearchResults,
|
||
)
|
||
async def search(
|
||
title: str,
|
||
type: Optional[str] = "media",
|
||
page: int = 1,
|
||
count: int = 8,
|
||
media_source: MediaSourceQuery = (),
|
||
_: schemas.TokenPayload = 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[schemas.MediaInfo, schemas.MediaPerson, 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 []
|
||
|
||
# 排序和分页
|
||
setting_order = settings.SEARCH_SOURCE.split(",") if settings.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.post(
|
||
"/scrape/{storage}", summary="刮削媒体信息", response_model=schemas.Response[None]
|
||
)
|
||
def scrape(
|
||
fileitem: schemas.FileItem,
|
||
storage: Optional[str] = "local",
|
||
media_source: Optional[MediaSource] = None,
|
||
media_id: Optional[str] = None,
|
||
type_name: Optional[MediaType] = None,
|
||
music_type: Optional[str] = None,
|
||
_: schemas.TokenPayload = 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 schemas.Response(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 schemas.Response(success=False, message="媒体ID格式无效")
|
||
if normalized_media_id and not media_source:
|
||
return schemas.Response(
|
||
success=False, message="指定媒体ID时必须同时指定媒体数据源"
|
||
)
|
||
if normalized_media_id and not _is_valid_source_media_id(media_source, normalized_media_id):
|
||
return schemas.Response(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 schemas.Response(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 schemas.Response(
|
||
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 schemas.Response(success=False, message="刮削失败,无法识别音乐信息")
|
||
success, message = ScrapingChain().scrape_music_metadata(
|
||
fileitem=fileitem,
|
||
mediainfo=music_info,
|
||
overwrite=True,
|
||
media_source=media_source,
|
||
)
|
||
return schemas.Response(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 schemas.Response(success=False, message="刮削失败,无法识别媒体信息")
|
||
if media_source:
|
||
media_info.scrape_source = media_source
|
||
if storage == "local":
|
||
if not Path(fileitem.path).exists():
|
||
return schemas.Response(success=False, message="刮削路径不存在")
|
||
# 手动刮削 (暂时使用同步版本,可以后续优化为异步)
|
||
ScrapingChain().scrape_metadata(
|
||
fileitem=fileitem,
|
||
meta=meta_info,
|
||
mediainfo=media_info,
|
||
overwrite=True,
|
||
)
|
||
return schemas.Response(success=True, message=f"{fileitem.path} 刮削完成")
|
||
|
||
|
||
@router.get(
|
||
"/category/config",
|
||
summary="获取分类策略配置",
|
||
response_model=schemas.Response[schemas.CategoryConfig],
|
||
)
|
||
def get_category_config(_: User = Depends(get_current_active_user)):
|
||
"""
|
||
获取分类策略配置
|
||
"""
|
||
config = MediaChain().category_config()
|
||
return schemas.Response(success=True, data=config.model_dump())
|
||
|
||
|
||
@router.post(
|
||
"/category/config", summary="保存分类策略配置", response_model=schemas.Response[None]
|
||
)
|
||
def save_category_config(
|
||
config: CategoryConfig, _: User = Depends(get_current_active_superuser)
|
||
):
|
||
"""
|
||
保存分类策略配置
|
||
"""
|
||
if MediaChain().save_category_config(config):
|
||
return schemas.Response(success=True, message="保存成功")
|
||
else:
|
||
return schemas.Response(success=False, message="保存失败")
|
||
|
||
|
||
@router.get(
|
||
"/category",
|
||
summary="查询自动分类配置",
|
||
response_model=schemas.MediaCategoryMap,
|
||
)
|
||
async def category(_: schemas.TokenPayload = Depends(verify_token)) -> Any:
|
||
"""
|
||
查询自动分类配置
|
||
"""
|
||
return MediaChain().media_category() or {}
|
||
|
||
|
||
@router.get(
|
||
"/group/seasons/{episode_group}",
|
||
summary="查询剧集组季信息",
|
||
response_model=List[schemas.MediaSeason],
|
||
)
|
||
async def group_seasons(
|
||
episode_group: str, _: schemas.TokenPayload = 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[schemas.MediaEpisodeGroup],
|
||
)
|
||
async def groups(tmdbid: int, _: schemas.TokenPayload = 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[schemas.MediaSeason]
|
||
)
|
||
async def seasons(
|
||
media_source: Optional[MediaSource] = None,
|
||
media_id: Optional[str] = None,
|
||
title: Optional[str] = None,
|
||
year: str = None,
|
||
season: int = None,
|
||
_: schemas.TokenPayload = 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=schemas.MediaInfo)
|
||
async def detail(
|
||
media_id: str,
|
||
media_source: MediaSource,
|
||
type_name: str,
|
||
_: schemas.TokenPayload = 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 schemas.MediaInfo()
|
||
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 schemas.MediaInfo()
|