mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-22 00:32:50 +08:00
refactor(media): unify source identity and music browsing
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
from typing import Any, List, Annotated, Literal, Optional, Union
|
||||
from typing import Any, List, Annotated, Optional, Union
|
||||
|
||||
from fastapi import APIRouter, Depends, Body
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.db.user_oper import get_current_active_user
|
||||
from app.helper.directory import DirectoryHelper
|
||||
from app.schemas.types import (
|
||||
MUSIC_ENTITY_RECORDING,
|
||||
MediaSource,
|
||||
MediaType,
|
||||
MusicTargetEntityType,
|
||||
SystemConfigKey,
|
||||
@@ -24,15 +25,6 @@ from app.utils.media import is_music_media_source, normalize_music_type
|
||||
from app.utils.security import SecurityUtils
|
||||
|
||||
router = APIRouter()
|
||||
MediaSource = Literal[
|
||||
"themoviedb",
|
||||
"douban",
|
||||
"bangumi",
|
||||
"anilist",
|
||||
"musicbrainz",
|
||||
"theaudiodb",
|
||||
"doubanmusic",
|
||||
]
|
||||
|
||||
|
||||
def _prepare_subtitle_download(subtitle: SubtitleInfo) -> tuple[bool, str]:
|
||||
@@ -114,10 +106,6 @@ def download(
|
||||
)
|
||||
def add(
|
||||
torrent_in: schemas.TorrentInfo,
|
||||
tmdbid: Annotated[int | None, Body()] = None,
|
||||
doubanid: Annotated[str | None, Body()] = None,
|
||||
bangumiid: Annotated[int | None, Body()] = None,
|
||||
anilistid: Annotated[int | None, Body()] = None,
|
||||
media_source: Annotated[MediaSource | None, Body()] = None,
|
||||
media_id: Annotated[str | None, Body()] = None,
|
||||
music_type: Annotated[MusicTargetEntityType | None, Body()] = None,
|
||||
@@ -135,6 +123,11 @@ def add(
|
||||
success=False,
|
||||
message="音乐实体类型无效,仅支持 recording 或 album",
|
||||
)
|
||||
if (media_source is None) != (media_id is None):
|
||||
return schemas.Response(
|
||||
success=False,
|
||||
message="媒体来源和媒体 ID 必须同时提供",
|
||||
)
|
||||
is_music = (
|
||||
torrent_in.category in (MediaType.MUSIC, MediaType.MUSIC.value, "music")
|
||||
or is_music_media_source(media_source)
|
||||
@@ -154,22 +147,18 @@ def add(
|
||||
else MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
|
||||
)
|
||||
# 媒体信息
|
||||
if tmdbid or doubanid or bangumiid or anilistid or media_id:
|
||||
if media_source and media_id:
|
||||
mediainfo = MediaChain().recognize_media(
|
||||
meta=metainfo,
|
||||
source=media_source,
|
||||
mediaid=media_id,
|
||||
tmdbid=tmdbid,
|
||||
doubanid=doubanid,
|
||||
bangumiid=bangumiid,
|
||||
anilistid=anilistid,
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
mtype=MediaType.MUSIC if is_music else None,
|
||||
music_type=normalized_music_type,
|
||||
)
|
||||
else:
|
||||
mediainfo = MediaChain().recognize_by_meta(
|
||||
metainfo,
|
||||
source=media_source,
|
||||
media_source=media_source,
|
||||
obtain_images=False,
|
||||
mtype=MediaType.MUSIC if is_music else None,
|
||||
music_type=normalized_music_type,
|
||||
@@ -199,12 +188,8 @@ def add(
|
||||
@router.post("/subtitle", summary="下载字幕", response_model=schemas.Response)
|
||||
def download_subtitle(
|
||||
subtitle_in: schemas.SubtitleInfo,
|
||||
tmdbid: Annotated[int | None, Body()] = None,
|
||||
doubanid: Annotated[str | None, Body()] = None,
|
||||
bangumiid: Annotated[int | None, Body()] = None,
|
||||
anilistid: Annotated[int | None, Body()] = None,
|
||||
media_source: Annotated[MediaSource | None, Body()] = None,
|
||||
media_id: Annotated[str | None, Body()] = None,
|
||||
media_source: Annotated[MediaSource, Body()],
|
||||
media_id: Annotated[str, Body()],
|
||||
save_path: Annotated[str | None, Body()] = None,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
) -> Any:
|
||||
@@ -221,10 +206,6 @@ def download_subtitle(
|
||||
subtitle=subtitle_info,
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
tmdbid=tmdbid,
|
||||
doubanid=doubanid,
|
||||
bangumiid=bangumiid,
|
||||
anilistid=anilistid,
|
||||
save_path=save_path,
|
||||
username=current_user.name,
|
||||
)
|
||||
|
||||
@@ -10,38 +10,38 @@ from app.chain.music import MusicChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import Context, MusicInfo
|
||||
from app.core.event import eventmanager
|
||||
from app.core.meta import MetaBase, MetaMusic
|
||||
from app.core.metainfo import MetaInfo, MetaInfoPath
|
||||
from app.core.security 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, MediaRecognizeConvertEventData
|
||||
from app.schemas import MediaType
|
||||
from app.schemas.category import CategoryConfig
|
||||
from app.schemas.types import ChainEventType, MUSIC_ENTITY_RECORDING
|
||||
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource
|
||||
from app.utils.media import (
|
||||
MEDIA_SOURCE_ID_FIELDS,
|
||||
is_music_media_source,
|
||||
normalize_music_type,
|
||||
parse_media_key,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
MediaSource = str
|
||||
|
||||
|
||||
def _is_valid_source_media_id(source: Optional[str], media_id: str) -> bool:
|
||||
def _is_valid_source_media_id(
|
||||
media_source: Optional[MediaSource], media_id: str,
|
||||
) -> bool:
|
||||
"""按媒体数据源校验原生 ID,并兼容豆瓣音乐的曲目复合 ID。"""
|
||||
if source == "musicbrainz":
|
||||
if media_source == MediaSource.MusicBrainz:
|
||||
try:
|
||||
UUID(media_id)
|
||||
return True
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if source == "doubanmusic" and ":" in media_id:
|
||||
if media_source == MediaSource.DoubanMusic and ":" in media_id:
|
||||
album_id, track_number = media_id.split(":", 1)
|
||||
return album_id.isdigit() and track_number.isdigit()
|
||||
return media_id.isdigit()
|
||||
if media_source == MediaSource.IMDb:
|
||||
return media_id.startswith("tt") and media_id[2:].isdigit()
|
||||
return bool(media_id.strip())
|
||||
|
||||
|
||||
def _build_recognize_metainfo(
|
||||
@@ -117,7 +117,7 @@ async def recognize(
|
||||
title: str,
|
||||
subtitle: Optional[str] = None,
|
||||
custom_words: Optional[str] = None,
|
||||
source: Optional[MediaSource] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
) -> Any:
|
||||
"""
|
||||
@@ -125,17 +125,17 @@ async def recognize(
|
||||
:param title: 标题
|
||||
:param subtitle: 副标题
|
||||
:param custom_words: 临时识别词(每行一条规则),传入时仅在本次识别中生效,不会保存到系统配置
|
||||
:param source: 请求级识别数据源
|
||||
:param media_source: 请求级识别数据源
|
||||
:param _:
|
||||
"""
|
||||
# 识别媒体信息,传入临时识别词时优先于系统配置的识别词生效
|
||||
metainfo = _build_recognize_metainfo(title, subtitle, custom_words)
|
||||
# 显式音乐来源需要按音乐元数据解析,避免名称测试误入影视识别。
|
||||
if is_music_media_source(source) and not isinstance(metainfo, MetaMusic):
|
||||
if is_music_media_source(media_source) and not isinstance(metainfo, MetaMusic):
|
||||
metainfo = MusicChain.parse_query(title)
|
||||
mediainfo = await MediaChain().async_recognize_by_meta(
|
||||
metainfo,
|
||||
source=source,
|
||||
media_source=media_source,
|
||||
)
|
||||
if mediainfo:
|
||||
return Context(meta_info=metainfo, media_info=mediainfo).to_dict()
|
||||
@@ -152,13 +152,13 @@ async def recognize2(
|
||||
title: str,
|
||||
subtitle: Optional[str] = None,
|
||||
custom_words: Optional[str] = None,
|
||||
source: Optional[MediaSource] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
根据标题、副标题识别媒体信息 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
# 识别媒体信息
|
||||
return await recognize(title, subtitle, custom_words, source)
|
||||
return await recognize(title, subtitle, custom_words, media_source)
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -166,14 +166,16 @@ async def recognize2(
|
||||
)
|
||||
async def recognize_file(
|
||||
path: str,
|
||||
source: Optional[MediaSource] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
) -> Any:
|
||||
"""
|
||||
根据文件路径识别媒体信息,影视与音乐统一走媒体链路径识别入口
|
||||
"""
|
||||
# 识别媒体信息
|
||||
context = await MediaChain().async_recognize_by_path(path, source=source)
|
||||
context = await MediaChain().async_recognize_by_path(
|
||||
path, media_source=media_source
|
||||
)
|
||||
if context:
|
||||
return context.to_dict()
|
||||
return schemas.Context()
|
||||
@@ -187,13 +189,13 @@ async def recognize_file(
|
||||
async def recognize_file2(
|
||||
path: str,
|
||||
_: Annotated[str, Depends(verify_apitoken)],
|
||||
source: Optional[MediaSource] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
) -> Any:
|
||||
"""
|
||||
根据文件路径识别媒体信息 API_TOKEN认证(?token=xxx)
|
||||
"""
|
||||
# 识别媒体信息
|
||||
return await recognize_file(path, source)
|
||||
return await recognize_file(path, media_source)
|
||||
|
||||
|
||||
@router.get("/search", summary="搜索媒体/人物信息", response_model=List[dict])
|
||||
@@ -202,7 +204,7 @@ async def search(
|
||||
type: Optional[str] = "media",
|
||||
page: int = 1,
|
||||
count: int = 8,
|
||||
source: Optional[MediaSource] = None,
|
||||
media_source: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
) -> Any:
|
||||
"""
|
||||
@@ -212,7 +214,7 @@ async def search(
|
||||
:param type: 搜索类型,支持 media、music、collection、person
|
||||
:param page: 页码
|
||||
:param count: 每页数量
|
||||
:param source: 请求级搜索数据源
|
||||
:param media_source: 请求级搜索数据源,支持逗号分隔
|
||||
:param _: Token校验
|
||||
:return: 搜索结果列表
|
||||
"""
|
||||
@@ -222,33 +224,37 @@ async def search(
|
||||
获取对象属性
|
||||
"""
|
||||
if isinstance(obj, dict):
|
||||
return obj.get("source")
|
||||
return obj.source
|
||||
return obj.get("media_source")
|
||||
return obj.media_source
|
||||
|
||||
media_chain = MediaChain()
|
||||
if type == "music" or is_music_media_source(source):
|
||||
if type == "music" or is_music_media_source(media_source):
|
||||
# 音乐搜索统一入口,与影视搜索共用 /media/search
|
||||
music_search_params = {"query": title, "limit": count}
|
||||
# 未指定来源时保留既有调用契约,由 MusicChain 选择默认音乐源。
|
||||
if source:
|
||||
music_search_params["source"] = source
|
||||
if media_source:
|
||||
music_search_params["media_source"] = media_source
|
||||
music_infos = await MusicChain().async_search(**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, source=source)
|
||||
_, medias = await media_chain.async_search(
|
||||
title=title, media_source=media_source
|
||||
)
|
||||
result = [media.to_dict() for media in medias] if medias else []
|
||||
elif type == "collection":
|
||||
collections = await media_chain.async_search_collections(
|
||||
name=title, source=source
|
||||
name=title, media_source=media_source
|
||||
)
|
||||
result = (
|
||||
[collection.to_dict() for collection in collections] if collections else []
|
||||
)
|
||||
else: # person
|
||||
persons = await media_chain.async_search_persons(name=title, source=source)
|
||||
persons = await media_chain.async_search_persons(
|
||||
name=title, media_source=media_source
|
||||
)
|
||||
result = [person.model_dump() for person in persons] if persons else []
|
||||
|
||||
if not result:
|
||||
@@ -316,8 +322,8 @@ def scrape(
|
||||
)
|
||||
# 音乐与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情
|
||||
music_info = MediaChain().recognize_media(
|
||||
source=media_source or "musicbrainz",
|
||||
mediaid=normalized_media_id,
|
||||
media_source=media_source or MediaSource.MusicBrainz,
|
||||
media_id=normalized_media_id,
|
||||
mtype=MediaType.MUSIC,
|
||||
music_type=normalized_music_type,
|
||||
)
|
||||
@@ -327,7 +333,7 @@ def scrape(
|
||||
fileitem=fileitem,
|
||||
mediainfo=music_info,
|
||||
overwrite=True,
|
||||
source=media_source,
|
||||
media_source=media_source,
|
||||
)
|
||||
return schemas.Response(success=success, message=message)
|
||||
|
||||
@@ -337,8 +343,8 @@ def scrape(
|
||||
media_info = chain.recognize_media(
|
||||
meta=meta_info,
|
||||
mtype=type_name,
|
||||
source=media_source,
|
||||
mediaid=normalized_media_id,
|
||||
media_source=media_source,
|
||||
media_id=normalized_media_id,
|
||||
)
|
||||
if media_info:
|
||||
media_info.scrape_source = media_source
|
||||
@@ -346,7 +352,7 @@ def scrape(
|
||||
else:
|
||||
context = chain.recognize_by_path(
|
||||
fileitem.path,
|
||||
source=media_source,
|
||||
media_source=media_source,
|
||||
obtain_images=True,
|
||||
)
|
||||
meta_info = context.meta_info if context else None
|
||||
@@ -423,7 +429,9 @@ async def groups(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -
|
||||
查询媒体剧集组列表(themoviedb)
|
||||
"""
|
||||
mediainfo = await MediaChain().async_recognize_media(
|
||||
tmdbid=tmdbid, mtype=MediaType.TV
|
||||
media_source=MediaSource.TMDB,
|
||||
media_id=str(tmdbid),
|
||||
mtype=MediaType.TV,
|
||||
)
|
||||
if not mediainfo:
|
||||
return []
|
||||
@@ -434,7 +442,8 @@ async def groups(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -
|
||||
"/seasons", summary="查询媒体季信息", response_model=List[schemas.MediaSeason]
|
||||
)
|
||||
async def seasons(
|
||||
mediaid: Optional[str] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
year: str = None,
|
||||
season: int = None,
|
||||
@@ -443,27 +452,27 @@ async def seasons(
|
||||
"""
|
||||
查询媒体季信息
|
||||
"""
|
||||
if mediaid:
|
||||
media_source, source_media_id = parse_media_key(mediaid)
|
||||
if media_source == "themoviedb":
|
||||
tmdbid = int(source_media_id)
|
||||
if media_source or media_id:
|
||||
if not media_source or not media_id:
|
||||
return []
|
||||
if media_source == MediaSource.TMDB and media_id.isdigit():
|
||||
tmdbid = int(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
|
||||
elif media_source and source_media_id:
|
||||
else:
|
||||
mediainfo = await MediaChain().async_recognize_media(
|
||||
source=media_source,
|
||||
mediaid=source_media_id,
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
mtype=MediaType.TV,
|
||||
cache=False,
|
||||
)
|
||||
if mediainfo:
|
||||
return _build_media_seasons(mediainfo, season)
|
||||
# 明确来源的查询不能按标题切换到默认识别源,避免辅助 TMDB 信息替换主身份。
|
||||
if media_source and source_media_id:
|
||||
return []
|
||||
return []
|
||||
if title:
|
||||
meta = MetaInfo(title)
|
||||
if year:
|
||||
@@ -474,9 +483,13 @@ async def seasons(
|
||||
obtain_images=False,
|
||||
)
|
||||
if mediainfo:
|
||||
if mediainfo.source == "themoviedb" and mediainfo.tmdb_id:
|
||||
if (
|
||||
mediainfo.media_source == MediaSource.TMDB
|
||||
and mediainfo.media_id
|
||||
and mediainfo.media_id.isdigit()
|
||||
):
|
||||
seasons_info = await TmdbChain().async_tmdb_seasons(
|
||||
tmdbid=mediainfo.tmdb_id
|
||||
tmdbid=int(mediainfo.media_id)
|
||||
)
|
||||
if seasons_info:
|
||||
if season is not None:
|
||||
@@ -488,58 +501,23 @@ async def seasons(
|
||||
return []
|
||||
|
||||
|
||||
@router.get("/{mediaid}", summary="查询媒体详情", response_model=schemas.MediaInfo)
|
||||
@router.get("/{media_id}", summary="查询媒体详情", response_model=schemas.MediaInfo)
|
||||
async def detail(
|
||||
mediaid: str,
|
||||
media_id: str,
|
||||
media_source: MediaSource,
|
||||
type_name: str,
|
||||
title: Optional[str] = None,
|
||||
year: str = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
) -> Any:
|
||||
"""
|
||||
根据带来源前缀的媒体ID查询媒体信息,type_name: 电影/电视剧
|
||||
根据媒体来源和原生 ID 查询媒体信息,type_name: 电影/电视剧
|
||||
"""
|
||||
mtype = MediaType(type_name)
|
||||
mediainfo = None
|
||||
mediachain = MediaChain()
|
||||
media_source, source_media_id = parse_media_key(mediaid)
|
||||
if media_source and source_media_id:
|
||||
mediainfo = await mediachain.async_recognize_media(
|
||||
source=media_source,
|
||||
mediaid=source_media_id,
|
||||
mtype=mtype,
|
||||
)
|
||||
if not mediainfo and (
|
||||
not media_source or media_source not in MEDIA_SOURCE_ID_FIELDS
|
||||
):
|
||||
# 旧探索插件可能只提供列表或转换事件,原生 ID 直识别失败后需保留原有兼容链路。
|
||||
event_data = MediaRecognizeConvertEventData(
|
||||
mediaid=mediaid, convert_type=settings.RECOGNIZE_SOURCE
|
||||
)
|
||||
event = await eventmanager.async_send_event(
|
||||
ChainEventType.MediaRecognizeConvert, event_data
|
||||
)
|
||||
# 使用事件返回的上下文数据
|
||||
if event and event.event_data and event.event_data.media_dict:
|
||||
event_data: MediaRecognizeConvertEventData = event.event_data
|
||||
new_id = event_data.media_dict.get("id")
|
||||
if new_id is not None and event_data.convert_type:
|
||||
mediainfo = await mediachain.async_recognize_media(
|
||||
source=event_data.convert_type,
|
||||
mediaid=str(new_id),
|
||||
mtype=mtype,
|
||||
)
|
||||
if not mediainfo and title:
|
||||
# 使用名称识别兜底
|
||||
meta = MetaInfo(title)
|
||||
if year:
|
||||
meta.year = year
|
||||
if mtype:
|
||||
meta.type = mtype
|
||||
mediainfo = await mediachain.async_recognize_by_meta(
|
||||
meta,
|
||||
obtain_images=False,
|
||||
)
|
||||
mediainfo = await mediachain.async_recognize_media(
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
mtype=mtype,
|
||||
)
|
||||
# 识别
|
||||
if mediainfo:
|
||||
await mediachain.async_obtain_images(mediainfo)
|
||||
|
||||
@@ -15,7 +15,7 @@ from app.db.models import MediaServerItem
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.helper.mediaserver import MediaServerHelper
|
||||
from app.schemas import MediaType, NotExistMediaInfo
|
||||
from app.schemas.types import SystemConfigKey
|
||||
from app.schemas.types import MediaSource, SystemConfigKey
|
||||
from app.utils.media import build_media_key, resolve_media_identity
|
||||
|
||||
router = APIRouter()
|
||||
@@ -70,7 +70,8 @@ async def exists_local(
|
||||
title: Optional[str] = None,
|
||||
year: Optional[str] = None,
|
||||
mtype: Optional[str] = None,
|
||||
tmdbid: Optional[int] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
season: Optional[int] = None,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
@@ -78,14 +79,24 @@ async def exists_local(
|
||||
"""
|
||||
判断本地是否存在
|
||||
"""
|
||||
meta = MetaInfo(title)
|
||||
if bool(media_source) != bool(media_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="media_source 和 media_id 必须同时提供",
|
||||
)
|
||||
meta = MetaInfo(title) if title else None
|
||||
if season is None:
|
||||
season = meta.begin_season
|
||||
season = meta.begin_season if meta else None
|
||||
# 返回对象
|
||||
ret_info = {}
|
||||
# 本地数据库是否存在
|
||||
exist: MediaServerItem = await MediaServerOper(db).async_exists(
|
||||
title=meta.name, year=year, mtype=mtype, tmdbid=tmdbid, season=season
|
||||
title=meta.name if meta else None,
|
||||
year=year,
|
||||
mtype=mtype,
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
season=season,
|
||||
)
|
||||
if exist:
|
||||
ret_info = {"id": exist.item_id}
|
||||
|
||||
@@ -25,11 +25,16 @@ MusicSourceParam = Annotated[
|
||||
str,
|
||||
Query(pattern="^(musicbrainz|theaudiodb|doubanmusic)$"),
|
||||
]
|
||||
MusicModeParam = Annotated[str, Query(pattern="^(chart|fresh)$")]
|
||||
MusicExploreSourceParam = Annotated[
|
||||
str,
|
||||
Query(pattern="^(musicbrainz|doubanmusic)$"),
|
||||
]
|
||||
MusicModeParam = Annotated[str, Query(pattern="^(chart|fresh|tag)$")]
|
||||
MusicEntityParam = Annotated[str, Query(pattern="^(recording|album)$")]
|
||||
MusicRangeParam = Annotated[str, Query(pattern=f"^({'|'.join(LISTENBRAINZ_CHART_RANGES)})$")]
|
||||
MusicSortParam = Annotated[str, Query(pattern="^listen_count\\.(desc|asc)$")]
|
||||
MusicFreshSortParam = Annotated[str, Query(pattern=f"^({'|'.join(LISTENBRAINZ_FRESH_SORTS)})$")]
|
||||
DoubanMusicSortParam = Annotated[str, Query(pattern="^(U|S|R|O)$")]
|
||||
MusicDaysParam = Annotated[int, Query(ge=1, le=LISTENBRAINZ_FRESH_MAX_DAYS)]
|
||||
# MusicBrainz 浏览接口支持的专辑类型筛选
|
||||
MusicAlbumTypeParam = Annotated[
|
||||
@@ -64,8 +69,8 @@ async def recognize_music(
|
||||
) -> schemas.MusicInfo:
|
||||
"""根据音乐元数据来源和媒体 ID 获取标准详情,与影视识别共用统一入口。"""
|
||||
recognize_kwargs = {
|
||||
"source": request.source,
|
||||
"mediaid": request.media_id,
|
||||
"media_source": request.media_source,
|
||||
"media_id": request.media_id,
|
||||
"mtype": MediaType.MUSIC,
|
||||
}
|
||||
if request.music_type is not None:
|
||||
@@ -133,7 +138,7 @@ async def clear_music_recognition_cache(
|
||||
async def explore_music(
|
||||
page: PageParam = 1,
|
||||
count: CountParam = 30,
|
||||
source: MusicSourceParam = "musicbrainz",
|
||||
media_source: MusicExploreSourceParam = "musicbrainz",
|
||||
mode: MusicModeParam = "chart",
|
||||
entity: MusicEntityParam = "recording",
|
||||
range_name: MusicRangeParam = "this_month",
|
||||
@@ -144,18 +149,21 @@ async def explore_music(
|
||||
future: bool = True,
|
||||
min_listen_count: Annotated[int, Query(ge=0)] = 0,
|
||||
with_cover: bool = False,
|
||||
country: Annotated[str, Query(pattern="^[A-Za-z]{2}$")] = "us",
|
||||
tags: str = "",
|
||||
douban_sort: DoubanMusicSortParam = "U",
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
) -> list[schemas.MusicInfo]:
|
||||
"""按音乐来源返回可订阅的榜单或新发行候选。"""
|
||||
chain = MusicChain()
|
||||
if source != "musicbrainz":
|
||||
if media_source != "musicbrainz":
|
||||
results = await chain.async_discover(
|
||||
source=source,
|
||||
media_source=media_source,
|
||||
page=page,
|
||||
count=count,
|
||||
entity=entity,
|
||||
country=country,
|
||||
mode=mode,
|
||||
tags=tags,
|
||||
sort=douban_sort,
|
||||
)
|
||||
elif mode == "fresh":
|
||||
results = await chain.async_fresh_releases(
|
||||
@@ -177,7 +185,7 @@ async def explore_music(
|
||||
with_cover=with_cover,
|
||||
entity=entity,
|
||||
)
|
||||
if source != "musicbrainz" and with_cover:
|
||||
if media_source != "musicbrainz" and with_cover:
|
||||
results = [info for info in results if info.cover_url or info.poster_path]
|
||||
return [_serialize_music(info) for info in results]
|
||||
|
||||
@@ -189,11 +197,11 @@ async def explore_music(
|
||||
)
|
||||
async def music_album(
|
||||
album_id: str,
|
||||
source: MusicSourceParam = "musicbrainz",
|
||||
media_source: MusicSourceParam = "musicbrainz",
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
) -> schemas.MusicAlbumInfo:
|
||||
"""按专辑标准 ID 返回专辑详情、曲目列表和发行版本。"""
|
||||
info = await MusicChain().async_album(source=source, media_id=album_id)
|
||||
info = await MusicChain().async_album(media_source=media_source, media_id=album_id)
|
||||
if not info:
|
||||
raise HTTPException(status_code=404, detail="未识别到专辑信息")
|
||||
return _serialize_album(info)
|
||||
@@ -207,12 +215,12 @@ async def music_album(
|
||||
async def music_album_related(
|
||||
album_id: str,
|
||||
count: CountParam = 24,
|
||||
source: MusicSourceParam = "musicbrainz",
|
||||
media_source: MusicSourceParam = "musicbrainz",
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
) -> list[schemas.MusicInfo]:
|
||||
"""按来源和专辑 ID 返回可继续浏览的关联专辑。"""
|
||||
results = await MusicChain().async_album_related(
|
||||
source=source,
|
||||
media_source=media_source,
|
||||
media_id=album_id,
|
||||
count=count,
|
||||
)
|
||||
@@ -229,12 +237,12 @@ async def music_artist_albums(
|
||||
page: PageParam = 1,
|
||||
count: CountParam = 30,
|
||||
album_type: MusicAlbumTypeParam = None,
|
||||
source: MusicSourceParam = "musicbrainz",
|
||||
media_source: MusicSourceParam = "musicbrainz",
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
) -> list[schemas.MusicInfo]:
|
||||
"""按艺术家标准 ID 分页返回其专辑、EP 和单曲。"""
|
||||
results = await MusicChain().async_artist_albums(
|
||||
source=source,
|
||||
media_source=media_source,
|
||||
media_id=artist_id,
|
||||
page=page,
|
||||
count=count,
|
||||
@@ -251,12 +259,12 @@ async def music_artist_albums(
|
||||
async def music_artist_related(
|
||||
artist_id: str,
|
||||
count: CountParam = 24,
|
||||
source: MusicSourceParam = "musicbrainz",
|
||||
media_source: MusicSourceParam = "musicbrainz",
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
) -> list[schemas.MusicArtistInfo]:
|
||||
"""按艺术家关系返回可继续浏览的关联艺术家。"""
|
||||
results = await MusicChain().async_artist_related(
|
||||
source=source,
|
||||
media_source=media_source,
|
||||
media_id=artist_id,
|
||||
count=count,
|
||||
)
|
||||
@@ -270,11 +278,11 @@ async def music_artist_related(
|
||||
)
|
||||
async def music_artist(
|
||||
artist_id: str,
|
||||
source: MusicSourceParam = "musicbrainz",
|
||||
media_source: MusicSourceParam = "musicbrainz",
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
) -> schemas.MusicArtistInfo:
|
||||
"""按艺术家标准 ID 返回艺术家详情。"""
|
||||
info = await MusicChain().async_artist(source=source, media_id=artist_id)
|
||||
info = await MusicChain().async_artist(media_source=media_source, media_id=artist_id)
|
||||
if not info:
|
||||
raise HTTPException(status_code=404, detail="未识别到艺术家信息")
|
||||
return _serialize_artist(info)
|
||||
|
||||
@@ -74,44 +74,6 @@ async def music_weekly(
|
||||
return await RecommendChain().async_music_weekly(page=page, count=count)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/music_theaudiodb_albums",
|
||||
summary="TheAudioDB 热门专辑",
|
||||
response_model=List[schemas.MusicInfo],
|
||||
)
|
||||
async def music_theaudiodb_albums(
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
country: str = "us",
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
) -> Any:
|
||||
"""浏览 TheAudioDB 指定国家或地区的热门专辑。"""
|
||||
return await RecommendChain().async_music_theaudiodb_albums(
|
||||
page=page,
|
||||
count=count,
|
||||
country=country,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/music_theaudiodb_tracks",
|
||||
summary="TheAudioDB 热门单曲",
|
||||
response_model=List[schemas.MusicInfo],
|
||||
)
|
||||
async def music_theaudiodb_tracks(
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
country: str = "us",
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
) -> Any:
|
||||
"""浏览 TheAudioDB 指定国家或地区的热门单曲。"""
|
||||
return await RecommendChain().async_music_theaudiodb_tracks(
|
||||
page=page,
|
||||
count=count,
|
||||
country=country,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/music_douban",
|
||||
summary="豆瓣音乐推荐",
|
||||
|
||||
@@ -8,17 +8,12 @@ from fastapi import APIRouter, Depends, Body, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app import schemas
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.search import SearchChain
|
||||
from app.core.config import settings
|
||||
from app.core.event import eventmanager
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.security import verify_resource_token, verify_token
|
||||
from app.helper.locale import LocaleHelper
|
||||
from app.log import logger
|
||||
from app.schemas import MediaRecognizeConvertEventData
|
||||
from app.schemas.types import MediaType, ChainEventType
|
||||
from app.utils.media import normalize_music_type, parse_media_key, resolve_media_identity
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
from app.utils.media import normalize_music_type
|
||||
from app.utils.security import SecurityUtils
|
||||
|
||||
router = APIRouter()
|
||||
@@ -58,14 +53,15 @@ def _resolve_media_season(
|
||||
|
||||
|
||||
async def _resolve_media_search_params(
|
||||
mediaid: str,
|
||||
media_source: MediaSource,
|
||||
media_id: str,
|
||||
media_type: Optional[MediaType] = None,
|
||||
title: Optional[str] = None,
|
||||
year: Optional[str] = None,
|
||||
media_season: Optional[int] = None,
|
||||
music_type: Optional[str] = None,
|
||||
) -> tuple[Optional[dict], str]:
|
||||
"""将任意来源媒体键解析为 SearchChain 可直接使用的识别参数。"""
|
||||
"""校验统一媒体身份并构造 SearchChain 精确搜索参数。"""
|
||||
normalized_media_id = str(media_id or "").strip()
|
||||
if not normalized_media_id:
|
||||
return None, "媒体 ID 不能为空"
|
||||
normalized_music_type = None
|
||||
if music_type:
|
||||
normalized_music_type = normalize_music_type(music_type, allow_artist=False)
|
||||
@@ -74,53 +70,13 @@ async def _resolve_media_search_params(
|
||||
if media_type != MediaType.MUSIC:
|
||||
return None, "music_type 仅能用于音乐资源搜索"
|
||||
|
||||
def build_params(source: str, source_media_id: str) -> dict:
|
||||
"""构造带可选音乐实体命名空间的精确搜索参数。"""
|
||||
params = {"source": source, "mediaid": source_media_id}
|
||||
if normalized_music_type:
|
||||
params["music_type"] = normalized_music_type
|
||||
return params
|
||||
|
||||
source, source_media_id = parse_media_key(mediaid)
|
||||
if source and source_media_id:
|
||||
if source in {"themoviedb", "bangumi", "anilist"} \
|
||||
and not source_media_id.isdigit():
|
||||
return None, "媒体ID格式错误"
|
||||
return build_params(source, source_media_id), ""
|
||||
|
||||
event_data = MediaRecognizeConvertEventData(
|
||||
mediaid=mediaid, convert_type=settings.RECOGNIZE_SOURCE
|
||||
)
|
||||
event = await eventmanager.async_send_event(
|
||||
ChainEventType.MediaRecognizeConvert, event_data
|
||||
)
|
||||
if event and event.event_data and event.event_data.media_dict:
|
||||
event_data = event.event_data
|
||||
search_id = event_data.media_dict.get("id")
|
||||
if search_id is not None:
|
||||
return build_params(event_data.convert_type, str(search_id)), ""
|
||||
|
||||
if not title:
|
||||
return None, "未知的媒体ID"
|
||||
|
||||
meta = MetaInfo(title)
|
||||
if year:
|
||||
meta.year = year
|
||||
if media_type:
|
||||
meta.type = media_type
|
||||
if media_season is not None:
|
||||
meta.type = MediaType.TV
|
||||
meta.begin_season = media_season
|
||||
recognize_kwargs = {"obtain_images": False}
|
||||
params = {
|
||||
"media_source": media_source,
|
||||
"media_id": normalized_media_id,
|
||||
}
|
||||
if normalized_music_type:
|
||||
recognize_kwargs["music_type"] = normalized_music_type
|
||||
mediainfo = await MediaChain().async_recognize_by_meta(meta, **recognize_kwargs)
|
||||
if not mediainfo:
|
||||
return None, "未识别到媒体信息"
|
||||
source, source_media_id = resolve_media_identity(media=mediainfo)
|
||||
if not source or not source_media_id:
|
||||
return None, "媒体信息缺少有效ID"
|
||||
return build_params(source, source_media_id), ""
|
||||
params["music_type"] = normalized_music_type
|
||||
return params, ""
|
||||
|
||||
|
||||
def _sse_event(data: dict, locale: Optional[str] = None) -> str:
|
||||
@@ -393,21 +349,20 @@ async def search_latest_context(_: schemas.TokenPayload = Depends(verify_token))
|
||||
)
|
||||
|
||||
|
||||
@router.get("/media/{mediaid}/stream", summary="渐进式精确搜索资源")
|
||||
@router.get("/media/{media_id}/stream", summary="渐进式精确搜索资源")
|
||||
async def search_by_id_stream(
|
||||
request: Request,
|
||||
mediaid: str,
|
||||
media_id: str,
|
||||
media_source: MediaSource,
|
||||
mtype: Optional[str] = None,
|
||||
area: Optional[str] = "title",
|
||||
title: Optional[str] = None,
|
||||
year: Optional[str] = None,
|
||||
season: Optional[str] = None,
|
||||
sites: Optional[str] = None,
|
||||
music_type: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_resource_token),
|
||||
) -> Any:
|
||||
"""
|
||||
根据TMDBID/豆瓣ID渐进式搜索站点资源,返回格式为SSE
|
||||
根据媒体来源和原生 ID 渐进式搜索站点资源,返回格式为 SSE。
|
||||
"""
|
||||
|
||||
media_type = _parse_media_type(mtype)
|
||||
@@ -418,11 +373,9 @@ async def search_by_id_stream(
|
||||
async def event_source():
|
||||
"""解析媒体身份并输出精确搜索流事件。"""
|
||||
search_params, message = await _resolve_media_search_params(
|
||||
mediaid=mediaid,
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
media_type=media_type,
|
||||
title=title,
|
||||
year=year,
|
||||
media_season=media_season,
|
||||
music_type=music_type,
|
||||
)
|
||||
if not search_params:
|
||||
@@ -446,29 +399,26 @@ async def search_by_id_stream(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/media/{mediaid}", summary="精确搜索资源", response_model=schemas.Response)
|
||||
@router.get("/media/{media_id}", summary="精确搜索资源", response_model=schemas.Response)
|
||||
async def search_by_id(
|
||||
mediaid: str,
|
||||
media_id: str,
|
||||
media_source: MediaSource,
|
||||
mtype: Optional[str] = None,
|
||||
area: Optional[str] = "title",
|
||||
title: Optional[str] = None,
|
||||
year: Optional[str] = None,
|
||||
season: Optional[str] = None,
|
||||
sites: Optional[str] = None,
|
||||
music_type: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
) -> Any:
|
||||
"""
|
||||
根据带来源前缀的媒体 ID 精确搜索站点资源。
|
||||
根据媒体来源和原生 ID 精确搜索站点资源。
|
||||
"""
|
||||
media_type = _parse_media_type(mtype)
|
||||
media_season = int(season) if season else None
|
||||
search_params, message = await _resolve_media_search_params(
|
||||
mediaid=mediaid,
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
media_type=media_type,
|
||||
title=title,
|
||||
year=year,
|
||||
media_season=media_season,
|
||||
music_type=music_type,
|
||||
)
|
||||
if not search_params:
|
||||
@@ -586,17 +536,16 @@ async def search_subtitle_by_title(
|
||||
|
||||
|
||||
async def _build_subtitle_search_source(
|
||||
mediaid: str,
|
||||
media_source: MediaSource,
|
||||
media_id: str,
|
||||
mtype: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
year: Optional[str] = None,
|
||||
season: Optional[str] = None,
|
||||
episode: Optional[str] = None,
|
||||
sites: Optional[str] = None,
|
||||
stream: bool = False,
|
||||
) -> Any:
|
||||
"""
|
||||
根据媒体ID构建字幕精确搜索调用,兼容多种媒体ID来源。
|
||||
根据媒体来源和原生 ID 构建字幕精确搜索调用。
|
||||
"""
|
||||
media_type = _parse_media_type(mtype)
|
||||
media_season = int(season) if season else None
|
||||
@@ -621,37 +570,33 @@ async def _build_subtitle_search_source(
|
||||
return search_chain.async_search_subtitles_by_id(**params)
|
||||
|
||||
search_params, message = await _resolve_media_search_params(
|
||||
mediaid=mediaid,
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
media_type=media_type,
|
||||
title=title,
|
||||
year=year,
|
||||
media_season=media_season,
|
||||
)
|
||||
if not search_params:
|
||||
return None, message
|
||||
return call_search(**search_params), ""
|
||||
|
||||
|
||||
@router.get("/subtitle/media/{mediaid}/stream", summary="渐进式精确搜索字幕")
|
||||
@router.get("/subtitle/media/{media_id}/stream", summary="渐进式精确搜索字幕")
|
||||
async def search_subtitle_by_id_stream(
|
||||
request: Request,
|
||||
mediaid: str,
|
||||
media_id: str,
|
||||
media_source: MediaSource,
|
||||
mtype: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
year: Optional[str] = None,
|
||||
season: Optional[str] = None,
|
||||
episode: Optional[str] = None,
|
||||
sites: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_resource_token),
|
||||
) -> Any:
|
||||
"""
|
||||
根据带来源前缀的媒体 ID 渐进式精确搜索站点字幕资源,返回格式为SSE。
|
||||
根据媒体来源和原生 ID 渐进式精确搜索站点字幕资源,返回格式为 SSE。
|
||||
"""
|
||||
subtitles, message = await _build_subtitle_search_source(
|
||||
mediaid=mediaid,
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
mtype=mtype,
|
||||
title=title,
|
||||
year=year,
|
||||
season=season,
|
||||
episode=episode,
|
||||
sites=sites,
|
||||
@@ -678,25 +623,23 @@ async def search_subtitle_by_id_stream(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/subtitle/media/{mediaid}", summary="精确搜索字幕", response_model=schemas.Response)
|
||||
@router.get("/subtitle/media/{media_id}", summary="精确搜索字幕", response_model=schemas.Response)
|
||||
async def search_subtitle_by_id(
|
||||
mediaid: str,
|
||||
media_id: str,
|
||||
media_source: MediaSource,
|
||||
mtype: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
year: Optional[str] = None,
|
||||
season: Optional[str] = None,
|
||||
episode: Optional[str] = None,
|
||||
sites: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
) -> Any:
|
||||
"""
|
||||
根据带来源前缀的媒体 ID 精确搜索站点字幕资源。
|
||||
根据媒体来源和原生 ID 精确搜索站点字幕资源。
|
||||
"""
|
||||
subtitles, message = await _build_subtitle_search_source(
|
||||
mediaid=mediaid,
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
mtype=mtype,
|
||||
title=title,
|
||||
year=year,
|
||||
season=season,
|
||||
episode=episode,
|
||||
sites=sites,
|
||||
|
||||
@@ -6,7 +6,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import schemas
|
||||
from app.chain.music import MusicChain
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo
|
||||
@@ -26,17 +25,24 @@ from app.schemas.event import SubscribeModifiedEventData
|
||||
from app.schemas.types import (
|
||||
MUSIC_ENTITY_ALBUM,
|
||||
MUSIC_ENTITY_RECORDING,
|
||||
MediaSource,
|
||||
MediaType,
|
||||
EventType,
|
||||
SystemConfigKey,
|
||||
)
|
||||
from app.utils.media import normalize_media_source, parse_media_key
|
||||
from app.utils.media import normalize_media_source
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def start_subscribe_add(
|
||||
title: str, year: str, mtype: MediaType, tmdbid: int, season: int, username: str
|
||||
title: str,
|
||||
year: str,
|
||||
mtype: MediaType,
|
||||
media_source: MediaSource,
|
||||
media_id: str,
|
||||
season: int,
|
||||
username: str,
|
||||
):
|
||||
"""
|
||||
启动订阅任务
|
||||
@@ -45,7 +51,8 @@ def start_subscribe_add(
|
||||
title=title,
|
||||
year=year,
|
||||
mtype=mtype,
|
||||
tmdbid=tmdbid,
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
season=season,
|
||||
username=username,
|
||||
)
|
||||
@@ -124,44 +131,20 @@ def matches_subscribe_music_type(
|
||||
or (music_type == MUSIC_ENTITY_RECORDING and subscribe_music_type is None)
|
||||
|
||||
|
||||
def music_subscribe_title_candidates(title: str) -> List[str]:
|
||||
"""生成音乐订阅标题兜底候选,保留精确标题并追加音乐语义解析结果。"""
|
||||
parsed_title = MusicChain.parse_query(title).title
|
||||
return list(dict.fromkeys(
|
||||
candidate for candidate in (title, parsed_title) if candidate
|
||||
))
|
||||
|
||||
|
||||
async def list_subscribes_by_media_key(
|
||||
async def list_subscribes_by_media_identity(
|
||||
db: AsyncSession,
|
||||
media_key: str,
|
||||
media_source: MediaSource,
|
||||
media_id: str,
|
||||
season: Optional[int] = None,
|
||||
music_type: Optional[str] = None,
|
||||
) -> List[Subscribe]:
|
||||
"""按统一媒体键及音乐实体查询订阅,并兼容迁移前的专用 ID 字段。"""
|
||||
source, media_id = parse_media_key(media_key)
|
||||
if not source or not media_id:
|
||||
subscribes = list(await Subscribe.async_list_by_mediaid(db, media_key))
|
||||
return [
|
||||
subscribe for subscribe in subscribes
|
||||
if matches_subscribe_music_type(subscribe, music_type)
|
||||
]
|
||||
|
||||
"""按媒体来源、原生 ID 及音乐实体查询订阅。"""
|
||||
subscribes = list(await Subscribe.async_list_by_media_identity(
|
||||
db,
|
||||
media_source=source,
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
music_type=music_type,
|
||||
))
|
||||
if source == "themoviedb" and media_id.isdigit():
|
||||
subscribes.extend(await Subscribe.async_get_by_tmdbid(db, int(media_id), season))
|
||||
elif source == "douban":
|
||||
subscribes.extend(await Subscribe.async_list_by_doubanid(db, media_id))
|
||||
elif source == "bangumi" and media_id.isdigit():
|
||||
subscribes.extend(await Subscribe.async_list_by_bangumiid(db, int(media_id)))
|
||||
elif source == "anilist" and media_id.isdigit():
|
||||
subscribes.extend(await Subscribe.async_list_by_anilistid(db, int(media_id)))
|
||||
|
||||
unique_subscribes = {
|
||||
subscribe.id: subscribe
|
||||
for subscribe in subscribes
|
||||
@@ -215,12 +198,8 @@ async def create_subscribe(
|
||||
# 非 TMDB 来源的标题可能自带季标记,入库前统一拆分。
|
||||
if (
|
||||
mtype != MediaType.MUSIC
|
||||
and (
|
||||
subscribe_in.doubanid
|
||||
or subscribe_in.bangumiid
|
||||
or subscribe_in.anilistid
|
||||
or normalize_media_source(subscribe_in.media_source) not in (None, "themoviedb")
|
||||
)
|
||||
and normalize_media_source(subscribe_in.media_source)
|
||||
not in (None, MediaSource.TMDB)
|
||||
):
|
||||
meta = MetaInfo(subscribe_in.name)
|
||||
subscribe_in.name = meta.name
|
||||
@@ -323,9 +302,10 @@ async def update_subscribe_status(
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.get("/media/{mediaid}", summary="查询订阅", response_model=schemas.Subscribe)
|
||||
async def subscribe_mediaid(
|
||||
mediaid: str,
|
||||
@router.get("/media/{media_id}", summary="查询订阅", response_model=schemas.Subscribe)
|
||||
async def subscribe_media_identity(
|
||||
media_id: str,
|
||||
media_source: MediaSource,
|
||||
season: Optional[int] = None,
|
||||
title: Optional[str] = None,
|
||||
music_type: Optional[str] = None,
|
||||
@@ -333,36 +313,12 @@ async def subscribe_mediaid(
|
||||
current_user: User = Depends(get_current_active_user_async),
|
||||
) -> Any:
|
||||
"""
|
||||
根据 TMDB、豆瓣、Bangumi、AniList 或插件媒体键查询订阅。
|
||||
根据媒体来源和原生 ID 查询订阅。
|
||||
"""
|
||||
subscribes = await list_subscribes_by_media_key(db, mediaid, season, music_type)
|
||||
subscribes = await list_subscribes_by_media_identity(
|
||||
db, media_source, media_id, season, music_type
|
||||
)
|
||||
result = select_accessible_subscribe(subscribes, current_user)
|
||||
source, _ = parse_media_key(mediaid)
|
||||
title_check = not result and bool(title) and source != "themoviedb"
|
||||
# 使用名称检查订阅
|
||||
if title_check and title:
|
||||
title_season = None
|
||||
if music_type:
|
||||
title_candidates = music_subscribe_title_candidates(title)
|
||||
else:
|
||||
title_meta = MetaInfo(title)
|
||||
if season is not None:
|
||||
title_meta.begin_season = season
|
||||
title_season = title_meta.begin_season
|
||||
title_candidates = [title_meta.name]
|
||||
for candidate_title in title_candidates:
|
||||
subscribes = await Subscribe.async_list_by_title(
|
||||
db, title=candidate_title, season=title_season
|
||||
)
|
||||
if music_type:
|
||||
subscribes = [
|
||||
subscribe for subscribe in subscribes
|
||||
if matches_subscribe_music_type(subscribe, music_type)
|
||||
]
|
||||
result = select_accessible_subscribe(subscribes, current_user)
|
||||
if result:
|
||||
break
|
||||
|
||||
return result if result else Subscribe()
|
||||
|
||||
|
||||
@@ -491,9 +447,10 @@ async def search_subscribe(
|
||||
return schemas.Response(success=True)
|
||||
|
||||
|
||||
@router.delete("/media/{mediaid}", summary="删除订阅", response_model=schemas.Response)
|
||||
async def delete_subscribe_by_mediaid(
|
||||
mediaid: str,
|
||||
@router.delete("/media/{media_id}", summary="删除订阅", response_model=schemas.Response)
|
||||
async def delete_subscribe_by_media_identity(
|
||||
media_id: str,
|
||||
media_source: MediaSource,
|
||||
season: Optional[int] = None,
|
||||
music_type: Optional[str] = None,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
@@ -502,7 +459,9 @@ async def delete_subscribe_by_mediaid(
|
||||
"""
|
||||
根据任意媒体数据源 ID 删除订阅。
|
||||
"""
|
||||
delete_subscribes = await list_subscribes_by_media_key(db, mediaid, season, music_type)
|
||||
delete_subscribes = await list_subscribes_by_media_identity(
|
||||
db, media_source, media_id, season, music_type
|
||||
)
|
||||
delete_events = []
|
||||
for subscribe in [
|
||||
subscribe
|
||||
@@ -571,7 +530,8 @@ async def seerr_subscribe(
|
||||
background_tasks.add_task(
|
||||
start_subscribe_add,
|
||||
mtype=media_type,
|
||||
tmdbid=tmdbId,
|
||||
media_source=MediaSource.TMDB,
|
||||
media_id=str(tmdbId),
|
||||
title=subject,
|
||||
year="",
|
||||
# 电影不传季号,避免被误判为剧集(S00)并污染通知标题
|
||||
@@ -592,7 +552,8 @@ async def seerr_subscribe(
|
||||
background_tasks.add_task(
|
||||
start_subscribe_add,
|
||||
mtype=media_type,
|
||||
tmdbid=tmdbId,
|
||||
media_source=MediaSource.TMDB,
|
||||
media_id=str(tmdbId),
|
||||
title=subject,
|
||||
year="",
|
||||
season=season,
|
||||
@@ -687,23 +648,17 @@ async def popular_subscribes(
|
||||
continue
|
||||
media = MediaInfo()
|
||||
media.type = MediaType(sub.get("type"))
|
||||
media.tmdb_id = sub.get("tmdbid")
|
||||
media.media_source = normalize_media_source(sub.get("media_source"))
|
||||
media.media_id = str(sub.get("media_id")) if sub.get("media_id") is not None else None
|
||||
# 处理标题
|
||||
title = sub.get("name")
|
||||
season = sub.get("season")
|
||||
if season not in (None, "") and int(season) != 1 and media.tmdb_id:
|
||||
if season not in (None, "") and int(season) != 1:
|
||||
# 小写数据转大写
|
||||
season_str = cn2an.an2cn(season, "low")
|
||||
title = f"{title} 第{season_str}季"
|
||||
media.title = title
|
||||
media.year = sub.get("year")
|
||||
media.douban_id = sub.get("doubanid")
|
||||
media.bangumi_id = sub.get("bangumiid")
|
||||
media.anilist_id = sub.get("anilistid")
|
||||
media.source = sub.get("media_source")
|
||||
media.media_id = sub.get("media_id")
|
||||
media.tvdb_id = sub.get("tvdbid")
|
||||
media.imdb_id = sub.get("imdbid")
|
||||
media.season = sub.get("season")
|
||||
media.overview = sub.get("description")
|
||||
media.vote_average = sub.get("vote")
|
||||
@@ -929,10 +884,6 @@ async def delete_subscribe(
|
||||
# 统计订阅
|
||||
MoviePilotServerHelper.sub_done_async(
|
||||
{
|
||||
"tmdbid": subscribe_info.get("tmdbid"),
|
||||
"doubanid": subscribe_info.get("doubanid"),
|
||||
"bangumiid": subscribe_info.get("bangumiid"),
|
||||
"anilistid": subscribe_info.get("anilistid"),
|
||||
"media_source": subscribe_info.get("media_source"),
|
||||
"media_id": subscribe_info.get("media_id"),
|
||||
"season": subscribe_info.get("season"),
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.db.user_oper import (
|
||||
)
|
||||
from app.schemas.types import (
|
||||
MUSIC_ENTITY_RECORDING,
|
||||
MediaSource,
|
||||
MediaType,
|
||||
MusicTargetEntityType,
|
||||
)
|
||||
@@ -191,11 +192,7 @@ def refresh_cache(_: User = Depends(get_current_active_superuser)):
|
||||
async def reidentify_cache(
|
||||
domain: str,
|
||||
torrent_hash: str,
|
||||
tmdbid: Optional[int] = None,
|
||||
doubanid: Optional[str] = None,
|
||||
bangumiid: Optional[int] = None,
|
||||
anilistid: Optional[int] = None,
|
||||
media_source: Optional[str] = None,
|
||||
media_source: Optional[MediaSource] = None,
|
||||
media_id: Optional[str] = None,
|
||||
music_type: Optional[MusicTargetEntityType] = None,
|
||||
_: User = Depends(get_current_active_superuser_async),
|
||||
@@ -204,10 +201,6 @@ async def reidentify_cache(
|
||||
重新识别指定的种子
|
||||
:param domain: 站点域名
|
||||
:param torrent_hash: 种子hash(使用title+description的md5)
|
||||
:param tmdbid: 手动指定的TMDB ID
|
||||
:param doubanid: 手动指定的豆瓣ID
|
||||
:param bangumiid: 手动指定的 Bangumi ID
|
||||
:param anilistid: 手动指定的 AniList ID
|
||||
:param media_source: 媒体数据源
|
||||
:param media_id: 数据源原生 ID
|
||||
:param music_type: 音乐实体类型,仅支持单曲或专辑
|
||||
@@ -281,19 +274,18 @@ async def reidentify_cache(
|
||||
subtitle=target_context.torrent_info.description,
|
||||
)
|
||||
|
||||
has_explicit_id = bool(
|
||||
tmdbid or doubanid or bangumiid or anilistid or media_id
|
||||
)
|
||||
has_explicit_id = media_source is not None or media_id is not None
|
||||
if has_explicit_id and (not media_source or not media_id):
|
||||
return schemas.Response(
|
||||
success=False,
|
||||
message="媒体来源和媒体 ID 必须同时提供",
|
||||
)
|
||||
if has_explicit_id:
|
||||
# 手动指定媒体身份时执行精确识别。
|
||||
mediainfo = await media_chain.async_recognize_media(
|
||||
meta=meta,
|
||||
tmdbid=tmdbid,
|
||||
doubanid=doubanid,
|
||||
bangumiid=bangumiid,
|
||||
anilistid=anilistid,
|
||||
source=media_source,
|
||||
mediaid=media_id,
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
mtype=MediaType.MUSIC if is_music else None,
|
||||
music_type=normalized_music_type,
|
||||
)
|
||||
@@ -301,7 +293,7 @@ async def reidentify_cache(
|
||||
# 未指定 ID 时按标题识别,请求级来源仍用于约束本次识别。
|
||||
mediainfo = await media_chain.async_recognize_by_meta(
|
||||
meta,
|
||||
source=media_source,
|
||||
media_source=media_source,
|
||||
mtype=MediaType.MUSIC if is_music else None,
|
||||
music_type=normalized_music_type,
|
||||
)
|
||||
@@ -337,7 +329,7 @@ async def reidentify_cache(
|
||||
"media_type": mediainfo.type.value
|
||||
if mediainfo and mediainfo.type
|
||||
else "",
|
||||
"media_source": getattr(mediainfo, "source", None),
|
||||
"media_source": getattr(mediainfo, "media_source", None),
|
||||
"media_id": getattr(mediainfo, "media_id", None),
|
||||
"music_type": getattr(mediainfo, "music_type", None),
|
||||
},
|
||||
|
||||
@@ -325,14 +325,6 @@ def manual_transfer(
|
||||
transer_item.type_name = (
|
||||
history.type if history.type else transer_item.type_name
|
||||
)
|
||||
transer_item.tmdbid = (
|
||||
int(history.tmdbid) if history.tmdbid else transer_item.tmdbid
|
||||
)
|
||||
transer_item.doubanid = (
|
||||
str(history.doubanid) if history.doubanid else transer_item.doubanid
|
||||
)
|
||||
transer_item.bangumiid = history.bangumiid or transer_item.bangumiid
|
||||
transer_item.anilistid = history.anilistid or transer_item.anilistid
|
||||
transer_item.media_source = (
|
||||
history.media_source or transer_item.media_source
|
||||
)
|
||||
@@ -457,10 +449,6 @@ def manual_transfer(
|
||||
fileitem=src_fileitem,
|
||||
target_storage=transer_item.target_storage,
|
||||
target_path=target_path,
|
||||
tmdbid=transer_item.tmdbid,
|
||||
doubanid=transer_item.doubanid,
|
||||
bangumiid=transer_item.bangumiid,
|
||||
anilistid=transer_item.anilistid,
|
||||
media_source=transer_item.media_source,
|
||||
media_id=transer_item.media_id,
|
||||
music_type=transer_item.music_type,
|
||||
@@ -545,10 +533,6 @@ def manual_transfer(
|
||||
fileitem=src_fileitem,
|
||||
target_storage=transer_item.target_storage,
|
||||
target_path=target_path,
|
||||
tmdbid=transer_item.tmdbid,
|
||||
doubanid=transer_item.doubanid,
|
||||
bangumiid=transer_item.bangumiid,
|
||||
anilistid=transer_item.anilistid,
|
||||
media_source=transer_item.media_source,
|
||||
media_id=transer_item.media_id,
|
||||
music_type=transer_item.music_type,
|
||||
|
||||
Reference in New Issue
Block a user