mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-22 08:43:37 +08:00
refactor(media): unify media identity and chain responsibilities
This commit is contained in:
@@ -5,8 +5,8 @@ from fastapi import APIRouter, Depends, Body
|
||||
from app import schemas
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.music import MusicChain
|
||||
from app.core.context import Context, MediaInfo, MusicInfo, SubtitleInfo, TorrentInfo
|
||||
from app.core.meta import MetaMusic
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.security import verify_token
|
||||
from app.db.models.user import User
|
||||
@@ -75,7 +75,7 @@ def download(
|
||||
"""
|
||||
if isinstance(media_in, schemas.MusicInfo):
|
||||
mediainfo = MusicInfo.from_dict(media_in.model_dump())
|
||||
metainfo = MusicChain.to_meta(mediainfo)
|
||||
metainfo = MetaMusic.from_music_info(mediainfo)
|
||||
metainfo.org_string = torrent_in.title
|
||||
else:
|
||||
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
|
||||
@@ -142,7 +142,7 @@ def add(
|
||||
normalized_music_type = MUSIC_ENTITY_RECORDING
|
||||
# 元数据
|
||||
metainfo = (
|
||||
MusicChain.parse_query(torrent_in.title)
|
||||
MetaMusic.parse_query(torrent_in.title)
|
||||
if is_music
|
||||
else MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
|
||||
)
|
||||
|
||||
@@ -2,11 +2,12 @@ from pathlib import Path
|
||||
from typing import Annotated, Any, List, Optional, Union
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BeforeValidator
|
||||
|
||||
from app import schemas
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.music import MusicChain
|
||||
from app.chain.scraping import ScrapingChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import Context, MusicInfo
|
||||
@@ -20,28 +21,64 @@ from app.schemas.category import CategoryConfig
|
||||
from app.schemas.types import MUSIC_ENTITY_RECORDING, MediaSource
|
||||
from app.utils.media import (
|
||||
is_music_media_source,
|
||||
normalize_media_source,
|
||||
normalize_music_type,
|
||||
parse_media_source_selection,
|
||||
resolve_media_identity,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
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。"""
|
||||
if media_source == MediaSource.MusicBrainz:
|
||||
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(media_id)
|
||||
UUID(normalized_media_id)
|
||||
return True
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
if media_source == MediaSource.DoubanMusic and ":" in media_id:
|
||||
album_id, track_number = media_id.split(":", 1)
|
||||
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 media_source == MediaSource.IMDb:
|
||||
return media_id.startswith("tt") and media_id[2:].isdigit()
|
||||
return bool(media_id.strip())
|
||||
if normalized_source == MediaSource.IMDb:
|
||||
return (
|
||||
normalized_media_id.startswith("tt")
|
||||
and normalized_media_id[2:].isdigit()
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _build_recognize_metainfo(
|
||||
@@ -132,7 +169,7 @@ async def recognize(
|
||||
metainfo = _build_recognize_metainfo(title, subtitle, custom_words)
|
||||
# 显式音乐来源需要按音乐元数据解析,避免名称测试误入影视识别。
|
||||
if is_music_media_source(media_source) and not isinstance(metainfo, MetaMusic):
|
||||
metainfo = MusicChain.parse_query(title)
|
||||
metainfo = MetaMusic.parse_query(title)
|
||||
mediainfo = await MediaChain().async_recognize_by_meta(
|
||||
metainfo,
|
||||
media_source=media_source,
|
||||
@@ -204,7 +241,7 @@ async def search(
|
||||
type: Optional[str] = "media",
|
||||
page: int = 1,
|
||||
count: int = 8,
|
||||
media_source: Optional[str] = None,
|
||||
media_source: MediaSourceQuery = (),
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
) -> Any:
|
||||
"""
|
||||
@@ -214,7 +251,7 @@ async def search(
|
||||
:param type: 搜索类型,支持 media、music、collection、person
|
||||
:param page: 页码
|
||||
:param count: 每页数量
|
||||
:param media_source: 请求级搜索数据源,支持逗号分隔
|
||||
:param media_source: 请求级搜索数据源枚举;可重复传入,逗号格式仅用于兼容旧客户端
|
||||
:param _: Token校验
|
||||
:return: 搜索结果列表
|
||||
"""
|
||||
@@ -227,33 +264,43 @@ async def search(
|
||||
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 is_music_media_source(media_source):
|
||||
if type == "music" or any(is_music_media_source(source) for source in selected_sources):
|
||||
# 音乐搜索统一入口,与影视搜索共用 /media/search
|
||||
music_search_params = {"query": title, "limit": count}
|
||||
# 未指定来源时保留既有调用契约,由 MusicChain 选择默认音乐源。
|
||||
if media_source:
|
||||
music_search_params["media_source"] = media_source
|
||||
music_infos = await MusicChain().async_search(**music_search_params)
|
||||
# 未指定来源时由 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=media_source
|
||||
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=media_source
|
||||
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=media_source
|
||||
name=title, media_source=source_selection
|
||||
)
|
||||
result = [person.model_dump() for person in persons] if persons else []
|
||||
|
||||
@@ -293,7 +340,10 @@ def scrape(
|
||||
"""
|
||||
if not fileitem or not fileitem.path:
|
||||
return schemas.Response(success=False, message="刮削路径无效")
|
||||
normalized_media_id = media_id.strip() if media_id else None
|
||||
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时必须同时指定媒体数据源"
|
||||
@@ -329,7 +379,7 @@ def scrape(
|
||||
)
|
||||
if not music_info:
|
||||
return schemas.Response(success=False, message="刮削失败,无法识别音乐信息")
|
||||
success, message = MediaChain().scrape_music_metadata(
|
||||
success, message = ScrapingChain().scrape_music_metadata(
|
||||
fileitem=fileitem,
|
||||
mediainfo=music_info,
|
||||
overwrite=True,
|
||||
@@ -366,7 +416,7 @@ def scrape(
|
||||
if not Path(fileitem.path).exists():
|
||||
return schemas.Response(success=False, message="刮削路径不存在")
|
||||
# 手动刮削 (暂时使用同步版本,可以后续优化为异步)
|
||||
chain.scrape_metadata(
|
||||
ScrapingChain().scrape_metadata(
|
||||
fileitem=fileitem,
|
||||
meta=meta_info,
|
||||
mediainfo=media_info,
|
||||
@@ -420,7 +470,13 @@ async def group_seasons(
|
||||
"""
|
||||
查询剧集组季信息(themoviedb)
|
||||
"""
|
||||
return await TmdbChain().async_tmdb_group_seasons(group_id=episode_group)
|
||||
_, 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[dict])
|
||||
@@ -428,9 +484,15 @@ async def groups(tmdbid: int, _: schemas.TokenPayload = Depends(verify_token)) -
|
||||
"""
|
||||
查询媒体剧集组列表(themoviedb)
|
||||
"""
|
||||
mediainfo = await MediaChain().async_recognize_media(
|
||||
media_source, media_id = resolve_media_identity(
|
||||
media_source=MediaSource.TMDB,
|
||||
media_id=str(tmdbid),
|
||||
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:
|
||||
@@ -452,11 +514,15 @@ async def seasons(
|
||||
"""
|
||||
查询媒体季信息
|
||||
"""
|
||||
if media_source or media_id:
|
||||
if not media_source or not media_id:
|
||||
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 media_source == MediaSource.TMDB and media_id.isdigit():
|
||||
tmdbid = int(media_id)
|
||||
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:
|
||||
@@ -464,8 +530,8 @@ async def seasons(
|
||||
return seasons_info
|
||||
else:
|
||||
mediainfo = await MediaChain().async_recognize_media(
|
||||
media_source=media_source,
|
||||
media_id=media_id,
|
||||
media_source=normalized_source,
|
||||
media_id=normalized_media_id,
|
||||
mtype=MediaType.TV,
|
||||
cache=False,
|
||||
)
|
||||
@@ -483,13 +549,16 @@ async def seasons(
|
||||
obtain_images=False,
|
||||
)
|
||||
if mediainfo:
|
||||
recognized_source, recognized_media_id = resolve_media_identity(
|
||||
media=mediainfo
|
||||
)
|
||||
if (
|
||||
mediainfo.media_source == MediaSource.TMDB
|
||||
and mediainfo.media_id
|
||||
and mediainfo.media_id.isdigit()
|
||||
recognized_source == MediaSource.TMDB
|
||||
and recognized_media_id
|
||||
and recognized_media_id.isdigit()
|
||||
):
|
||||
seasons_info = await TmdbChain().async_tmdb_seasons(
|
||||
tmdbid=int(mediainfo.media_id)
|
||||
tmdbid=int(recognized_media_id)
|
||||
)
|
||||
if seasons_info:
|
||||
if season is not None:
|
||||
@@ -512,10 +581,16 @@ async def detail(
|
||||
根据媒体来源和原生 ID 查询媒体信息,type_name: 电影/电视剧
|
||||
"""
|
||||
mtype = MediaType(type_name)
|
||||
mediachain = MediaChain()
|
||||
mediainfo = await mediachain.async_recognize_media(
|
||||
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,
|
||||
)
|
||||
# 识别
|
||||
|
||||
@@ -4,8 +4,8 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from app import schemas
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.music import MusicChain
|
||||
from app.schemas.types import MediaType
|
||||
from app.chain.recommend import RecommendChain
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
from app.core.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo
|
||||
from app.core.security import verify_token
|
||||
from app.db.models.user import User
|
||||
@@ -22,12 +22,12 @@ router = APIRouter()
|
||||
CountParam = Annotated[int, Query(ge=1, le=100)]
|
||||
PageParam = Annotated[int, Query(ge=1)]
|
||||
MusicSourceParam = Annotated[
|
||||
str,
|
||||
Query(pattern="^(musicbrainz|theaudiodb|doubanmusic)$"),
|
||||
MediaSource,
|
||||
Query(),
|
||||
]
|
||||
MusicExploreSourceParam = Annotated[
|
||||
str,
|
||||
Query(pattern="^(musicbrainz|doubanmusic)$"),
|
||||
MediaSource,
|
||||
Query(),
|
||||
]
|
||||
MusicModeParam = Annotated[str, Query(pattern="^(chart|fresh)$")]
|
||||
MusicEntityParam = Annotated[str, Query(pattern="^(recording|album)$")]
|
||||
@@ -41,6 +41,29 @@ MusicAlbumTypeParam = Annotated[
|
||||
Optional[str],
|
||||
Query(pattern="^(album|single|ep|broadcast|other|compilation|soundtrack|live|remix)$"),
|
||||
]
|
||||
_MUSIC_DETAIL_SOURCES = frozenset({
|
||||
MediaSource.MusicBrainz,
|
||||
MediaSource.TheAudioDB,
|
||||
MediaSource.DoubanMusic,
|
||||
})
|
||||
_MUSIC_EXPLORE_SOURCES = frozenset({
|
||||
MediaSource.MusicBrainz,
|
||||
MediaSource.DoubanMusic,
|
||||
})
|
||||
|
||||
|
||||
def _validate_music_source(
|
||||
media_source: MediaSource,
|
||||
allowed_sources: frozenset[MediaSource],
|
||||
) -> MediaSource:
|
||||
"""将 HTTP 或直接调用参数规范为音乐来源枚举,并拒绝不支持的来源。"""
|
||||
try:
|
||||
normalized_source = MediaSource(media_source)
|
||||
except (TypeError, ValueError) as err:
|
||||
raise HTTPException(status_code=422, detail="无效的媒体来源") from err
|
||||
if normalized_source not in allowed_sources:
|
||||
raise HTTPException(status_code=422, detail="该媒体来源不支持此音乐接口")
|
||||
return normalized_source
|
||||
|
||||
|
||||
def _serialize_music(info: MusicInfo) -> schemas.MusicInfo:
|
||||
@@ -138,7 +161,7 @@ async def clear_music_recognition_cache(
|
||||
async def explore_music(
|
||||
page: PageParam = 1,
|
||||
count: CountParam = 30,
|
||||
media_source: MusicExploreSourceParam = "musicbrainz",
|
||||
media_source: MusicExploreSourceParam = MediaSource.MusicBrainz,
|
||||
mode: MusicModeParam = "chart",
|
||||
entity: MusicEntityParam = "recording",
|
||||
range_name: MusicRangeParam = "this_month",
|
||||
@@ -154,9 +177,10 @@ async def explore_music(
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
) -> list[schemas.MusicInfo]:
|
||||
"""MusicBrainz 返回榜单或新发行,豆瓣音乐固定按官方标签分类浏览。"""
|
||||
chain = MusicChain()
|
||||
if media_source != "musicbrainz":
|
||||
results = await chain.async_discover(
|
||||
media_source = _validate_music_source(media_source, _MUSIC_EXPLORE_SOURCES)
|
||||
chain = RecommendChain()
|
||||
if media_source != MediaSource.MusicBrainz:
|
||||
results = await chain.async_music_discover(
|
||||
media_source=media_source,
|
||||
page=page,
|
||||
count=count,
|
||||
@@ -166,7 +190,7 @@ async def explore_music(
|
||||
sort=douban_sort,
|
||||
)
|
||||
elif mode == "fresh":
|
||||
results = await chain.async_fresh_releases(
|
||||
results = await chain.async_music_fresh_releases(
|
||||
days=days,
|
||||
sort=sort,
|
||||
past=past,
|
||||
@@ -176,7 +200,7 @@ async def explore_music(
|
||||
with_cover=with_cover,
|
||||
)
|
||||
else:
|
||||
results = await chain.async_chart(
|
||||
results = await chain.async_music_chart(
|
||||
range_name=range_name,
|
||||
page=page,
|
||||
count=count,
|
||||
@@ -185,7 +209,7 @@ async def explore_music(
|
||||
with_cover=with_cover,
|
||||
entity=entity,
|
||||
)
|
||||
if media_source != "musicbrainz" and with_cover:
|
||||
if media_source != MediaSource.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]
|
||||
|
||||
@@ -197,11 +221,14 @@ async def explore_music(
|
||||
)
|
||||
async def music_album(
|
||||
album_id: str,
|
||||
media_source: MusicSourceParam = "musicbrainz",
|
||||
media_source: MusicSourceParam = MediaSource.MusicBrainz,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
) -> schemas.MusicAlbumInfo:
|
||||
"""按专辑标准 ID 返回专辑详情、曲目列表和发行版本。"""
|
||||
info = await MusicChain().async_album(media_source=media_source, media_id=album_id)
|
||||
media_source = _validate_music_source(media_source, _MUSIC_DETAIL_SOURCES)
|
||||
info = await MediaChain().async_get_music_album(
|
||||
media_source=media_source, media_id=album_id
|
||||
)
|
||||
if not info:
|
||||
raise HTTPException(status_code=404, detail="未识别到专辑信息")
|
||||
return _serialize_album(info)
|
||||
@@ -215,11 +242,12 @@ async def music_album(
|
||||
async def music_album_related(
|
||||
album_id: str,
|
||||
count: CountParam = 24,
|
||||
media_source: MusicSourceParam = "musicbrainz",
|
||||
media_source: MusicSourceParam = MediaSource.MusicBrainz,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
) -> list[schemas.MusicInfo]:
|
||||
"""按来源和专辑 ID 返回可继续浏览的关联专辑。"""
|
||||
results = await MusicChain().async_album_related(
|
||||
media_source = _validate_music_source(media_source, _MUSIC_DETAIL_SOURCES)
|
||||
results = await MediaChain().async_get_music_album_related(
|
||||
media_source=media_source,
|
||||
media_id=album_id,
|
||||
count=count,
|
||||
@@ -237,11 +265,12 @@ async def music_artist_albums(
|
||||
page: PageParam = 1,
|
||||
count: CountParam = 30,
|
||||
album_type: MusicAlbumTypeParam = None,
|
||||
media_source: MusicSourceParam = "musicbrainz",
|
||||
media_source: MusicSourceParam = MediaSource.MusicBrainz,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
) -> list[schemas.MusicInfo]:
|
||||
"""按艺术家标准 ID 分页返回其专辑、EP 和单曲。"""
|
||||
results = await MusicChain().async_artist_albums(
|
||||
media_source = _validate_music_source(media_source, _MUSIC_DETAIL_SOURCES)
|
||||
results = await MediaChain().async_get_music_artist_albums(
|
||||
media_source=media_source,
|
||||
media_id=artist_id,
|
||||
page=page,
|
||||
@@ -259,11 +288,12 @@ async def music_artist_albums(
|
||||
async def music_artist_related(
|
||||
artist_id: str,
|
||||
count: CountParam = 24,
|
||||
media_source: MusicSourceParam = "musicbrainz",
|
||||
media_source: MusicSourceParam = MediaSource.MusicBrainz,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
) -> list[schemas.MusicArtistInfo]:
|
||||
"""按艺术家关系返回可继续浏览的关联艺术家。"""
|
||||
results = await MusicChain().async_artist_related(
|
||||
media_source = _validate_music_source(media_source, _MUSIC_DETAIL_SOURCES)
|
||||
results = await MediaChain().async_get_music_artist_related(
|
||||
media_source=media_source,
|
||||
media_id=artist_id,
|
||||
count=count,
|
||||
@@ -278,11 +308,14 @@ async def music_artist_related(
|
||||
)
|
||||
async def music_artist(
|
||||
artist_id: str,
|
||||
media_source: MusicSourceParam = "musicbrainz",
|
||||
media_source: MusicSourceParam = MediaSource.MusicBrainz,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
) -> schemas.MusicArtistInfo:
|
||||
"""按艺术家标准 ID 返回艺术家详情。"""
|
||||
info = await MusicChain().async_artist(media_source=media_source, media_id=artist_id)
|
||||
media_source = _validate_music_source(media_source, _MUSIC_DETAIL_SOURCES)
|
||||
info = await MediaChain().async_get_music_artist(
|
||||
media_source=media_source, media_id=artist_id
|
||||
)
|
||||
if not info:
|
||||
raise HTTPException(status_code=404, detail="未识别到艺术家信息")
|
||||
return _serialize_artist(info)
|
||||
|
||||
@@ -13,7 +13,7 @@ from app.core.security import verify_resource_token, verify_token
|
||||
from app.helper.locale import LocaleHelper
|
||||
from app.log import logger
|
||||
from app.schemas.types import MediaSource, MediaType
|
||||
from app.utils.media import normalize_music_type
|
||||
from app.utils.media import normalize_music_type, resolve_media_identity
|
||||
from app.utils.security import SecurityUtils
|
||||
|
||||
router = APIRouter()
|
||||
@@ -59,9 +59,12 @@ async def _resolve_media_search_params(
|
||||
music_type: Optional[str] = None,
|
||||
) -> tuple[Optional[dict], str]:
|
||||
"""校验统一媒体身份并构造 SearchChain 精确搜索参数。"""
|
||||
normalized_media_id = str(media_id or "").strip()
|
||||
if not normalized_media_id:
|
||||
return None, "媒体 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 None, "媒体ID格式无效"
|
||||
normalized_music_type = None
|
||||
if music_type:
|
||||
normalized_music_type = normalize_music_type(music_type, allow_artist=False)
|
||||
@@ -71,7 +74,7 @@ async def _resolve_media_search_params(
|
||||
return None, "music_type 仅能用于音乐资源搜索"
|
||||
|
||||
params = {
|
||||
"media_source": media_source,
|
||||
"media_source": normalized_source,
|
||||
"media_id": normalized_media_id,
|
||||
}
|
||||
if normalized_music_type:
|
||||
@@ -368,7 +371,6 @@ async def search_by_id_stream(
|
||||
media_type = _parse_media_type(mtype)
|
||||
media_season = int(season) if season else None
|
||||
site_list = _parse_site_list(sites)
|
||||
search_chain = SearchChain()
|
||||
|
||||
async def event_source():
|
||||
"""解析媒体身份并输出精确搜索流事件。"""
|
||||
@@ -381,7 +383,7 @@ async def search_by_id_stream(
|
||||
if not search_params:
|
||||
yield {"type": "error", "success": False, "message": message}
|
||||
return
|
||||
torrents = search_chain.async_search_by_id_stream(
|
||||
torrents = SearchChain().async_search_by_id_stream(
|
||||
**search_params,
|
||||
mtype=media_type,
|
||||
area=area,
|
||||
@@ -551,7 +553,6 @@ async def _build_subtitle_search_source(
|
||||
media_season = int(season) if season else None
|
||||
media_episode = int(episode) if episode else None
|
||||
site_list = _parse_site_list(sites)
|
||||
search_chain = SearchChain()
|
||||
|
||||
def call_search(**kwargs):
|
||||
"""
|
||||
@@ -576,6 +577,7 @@ async def _build_subtitle_search_source(
|
||||
)
|
||||
if not search_params:
|
||||
return None, message
|
||||
search_chain = SearchChain()
|
||||
return call_search(**search_params), ""
|
||||
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ from app.schemas.types import (
|
||||
EventType,
|
||||
SystemConfigKey,
|
||||
)
|
||||
from app.utils.media import normalize_media_source
|
||||
from app.utils.media import normalize_media_source, resolve_media_identity
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -211,6 +211,26 @@ async def create_subscribe(
|
||||
else:
|
||||
title = None
|
||||
subscribe_dict = subscribe_in.to_public_write_payload()
|
||||
identity_fields = {"media_source", "media_id"}.intersection(
|
||||
subscribe_in.model_fields_set
|
||||
)
|
||||
if identity_fields:
|
||||
media_source, media_id = resolve_media_identity(
|
||||
media_source=subscribe_in.media_source,
|
||||
media_id=subscribe_in.media_id,
|
||||
)
|
||||
if media_source and media_id:
|
||||
subscribe_dict["media_source"] = media_source
|
||||
subscribe_dict["media_id"] = media_id
|
||||
elif subscribe_in.media_source is None and subscribe_in.media_id is None:
|
||||
# 完整空对表示订阅暂无可用身份,与只提交其中一个字段语义不同。
|
||||
subscribe_dict["media_source"] = None
|
||||
subscribe_dict["media_id"] = None
|
||||
else:
|
||||
return schemas.Response(
|
||||
success=False,
|
||||
message="新增订阅时必须同时提供有效的 media_source 和 media_id",
|
||||
)
|
||||
subscribe_dict["username"] = current_user.name
|
||||
sid, message = await SubscribeChain().async_add(
|
||||
mtype=mtype,
|
||||
@@ -236,7 +256,27 @@ async def update_subscribe(
|
||||
if not subscribe:
|
||||
return schemas.Response(success=False, message="订阅不存在")
|
||||
old_subscribe_dict = subscribe.to_dict()
|
||||
subscribe_dict = subscribe_in.to_public_write_payload()
|
||||
subscribe_dict = subscribe_in.to_public_write_payload(exclude_unset=True)
|
||||
identity_fields = {"media_source", "media_id"}.intersection(
|
||||
subscribe_in.model_fields_set
|
||||
)
|
||||
if identity_fields:
|
||||
media_source, media_id = resolve_media_identity(
|
||||
media_source=subscribe_in.media_source,
|
||||
media_id=subscribe_in.media_id,
|
||||
)
|
||||
if media_source and media_id:
|
||||
subscribe_dict["media_source"] = media_source
|
||||
subscribe_dict["media_id"] = media_id
|
||||
elif subscribe_in.media_source is None and subscribe_in.media_id is None:
|
||||
# 只有两个身份键都显式为空时才清空;全部省略则保留存量身份。
|
||||
subscribe_dict["media_source"] = None
|
||||
subscribe_dict["media_id"] = None
|
||||
else:
|
||||
return schemas.Response(
|
||||
success=False,
|
||||
message="更新媒体身份时必须同时提供有效的 media_source 和 media_id",
|
||||
)
|
||||
subscribe_dict["username"] = subscribe.username
|
||||
if getattr(subscribe, "type", None) == MediaType.MUSIC.value:
|
||||
# 音乐实体与曲目总数来自识别链,编辑接口不得把专辑改成单曲而提前完成订阅。
|
||||
@@ -244,13 +284,18 @@ async def update_subscribe(
|
||||
subscribe_dict["music_type"] = subscribe.music_type
|
||||
subscribe_dict["total_tracks"] = subscribe.total_tracks \
|
||||
if subscribe.music_type == MUSIC_ENTITY_ALBUM else None
|
||||
if subscribe_in.total_episode and subscribe_in.total_episode > (subscribe.total_episode or 0):
|
||||
total_episode_updated = "total_episode" in subscribe_in.model_fields_set
|
||||
if (
|
||||
total_episode_updated
|
||||
and subscribe_in.total_episode
|
||||
and subscribe_in.total_episode > (subscribe.total_episode or 0)
|
||||
):
|
||||
# 扩大目标范围时,新增加的集数尚无下载事实,应同步计入缺失集数。
|
||||
subscribe_dict["lack_episode"] = (subscribe.lack_episode or 0) + (
|
||||
subscribe_in.total_episode - (subscribe.total_episode or 0)
|
||||
)
|
||||
# 是否手动修改过总集数
|
||||
if subscribe_in.total_episode != subscribe.total_episode:
|
||||
if total_episode_updated and subscribe_in.total_episode != subscribe.total_episode:
|
||||
subscribe_dict["manual_total_episode"] = 1
|
||||
# 更新到数据库
|
||||
await subscribe.async_update(db, subscribe_dict)
|
||||
|
||||
@@ -4,7 +4,6 @@ from fastapi import APIRouter, Depends
|
||||
|
||||
from app import schemas
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.music import MusicChain
|
||||
from app.chain.torrents import TorrentsChain
|
||||
from app.core.config import settings
|
||||
from app.core.context import MediaInfo, MusicInfo
|
||||
@@ -266,7 +265,7 @@ async def reidentify_cache(
|
||||
meta = (
|
||||
target_context.meta_info
|
||||
if isinstance(target_context.meta_info, MetaMusic)
|
||||
else MusicChain.parse_query(target_context.torrent_info.title)
|
||||
else MetaMusic.parse_query(target_context.torrent_info.title)
|
||||
)
|
||||
else:
|
||||
meta = MetaInfo(
|
||||
|
||||
Reference in New Issue
Block a user