refactor(media): unify source identity and music browsing

This commit is contained in:
jxxghp
2026-08-12 12:56:40 +08:00
parent 9359dcb63d
commit 8bf2f601a6
129 changed files with 3614 additions and 3983 deletions

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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}

View File

@@ -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)

View File

@@ -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="豆瓣音乐推荐",

View File

@@ -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,

View File

@@ -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"),

View File

@@ -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),
},

View File

@@ -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,