feat: unify media recognition and music lifecycle

This commit is contained in:
jxxghp
2026-08-12 06:51:58 +08:00
parent b018646f61
commit 97dba2a8ae
96 changed files with 4677 additions and 719 deletions

View File

@@ -14,7 +14,13 @@ from app.db.site_oper import SiteOper
from app.db.systemconfig_oper import SystemConfigOper
from app.db.user_oper import get_current_active_user
from app.helper.directory import DirectoryHelper
from app.schemas.types import SystemConfigKey
from app.schemas.types import (
MUSIC_ENTITY_RECORDING,
MediaType,
MusicTargetEntityType,
SystemConfigKey,
)
from app.utils.media import is_music_media_source, normalize_music_type
from app.utils.security import SecurityUtils
router = APIRouter()
@@ -114,6 +120,7 @@ def add(
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,
downloader: Annotated[str | None, Body()] = None,
# 保存路径, 支持<storage>:<path>, 如rclone:/MP, smb:/server/share/Movies等
save_path: Annotated[str | None, Body()] = None,
@@ -122,8 +129,30 @@ def add(
"""
添加下载任务(不含媒体信息)
"""
normalized_music_type = normalize_music_type(music_type, allow_artist=False)
if music_type is not None and not normalized_music_type:
return schemas.Response(
success=False,
message="音乐实体类型无效,仅支持 recording 或 album",
)
is_music = (
torrent_in.category in (MediaType.MUSIC, MediaType.MUSIC.value, "music")
or is_music_media_source(media_source)
or normalized_music_type is not None
)
if is_music and media_source and not is_music_media_source(media_source):
return schemas.Response(
success=False,
message="音乐下载只能使用音乐元数据源",
)
if is_music and not normalized_music_type:
normalized_music_type = MUSIC_ENTITY_RECORDING
# 元数据
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
metainfo = (
MusicChain.parse_query(torrent_in.title)
if is_music
else MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
)
# 媒体信息
if tmdbid or doubanid or bangumiid or anilistid or media_id:
mediainfo = MediaChain().recognize_media(
@@ -134,12 +163,16 @@ def add(
doubanid=doubanid,
bangumiid=bangumiid,
anilistid=anilistid,
mtype=MediaType.MUSIC if is_music else None,
music_type=normalized_music_type,
)
else:
mediainfo = MediaChain().recognize_by_meta(
metainfo,
source=media_source,
obtain_images=False,
mtype=MediaType.MUSIC if is_music else None,
music_type=normalized_music_type,
)
if not mediainfo:
return schemas.Response(success=False, message="无法识别媒体信息")

View File

@@ -18,10 +18,11 @@ 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.category import CategoryConfig
from app.schemas.types import ChainEventType
from app.schemas.types import ChainEventType, MUSIC_ENTITY_RECORDING
from app.utils.media import (
MEDIA_SOURCE_ID_FIELDS,
is_music_media_source,
normalize_music_type,
parse_media_key,
)
@@ -30,13 +31,16 @@ MediaSource = str
def _is_valid_source_media_id(source: Optional[str], media_id: str) -> bool:
"""按媒体数据源校验原生 IDMusicBrainz 使用 UUID其它内置来源使用数字 ID。"""
"""按媒体数据源校验原生 ID并兼容豆瓣音乐的曲目复合 ID。"""
if source == "musicbrainz":
try:
UUID(media_id)
return True
except (TypeError, ValueError):
return False
if source == "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()
@@ -267,6 +271,7 @@ def scrape(
media_source: Optional[MediaSource] = None,
media_id: Optional[str] = None,
type_name: Optional[MediaType] = None,
music_type: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token),
) -> Any:
"""
@@ -277,6 +282,7 @@ def scrape(
:param media_source: 请求级媒体数据源
:param media_id: 数据源原生ID
:param type_name: 媒体类型
:param music_type: 音乐实体类型,支持 recording 和 album
:param _: Token校验
"""
if not fileitem or not fileitem.path:
@@ -299,11 +305,21 @@ def scrape(
return schemas.Response(success=False, message="音乐元数据源只能用于音乐刮削")
music_info: Optional[MusicInfo] = None
if normalized_media_id:
normalized_music_type = normalize_music_type(
music_type or MUSIC_ENTITY_RECORDING,
allow_artist=False,
)
if not normalized_music_type:
return schemas.Response(
success=False,
message="音乐实体类型无效,仅支持 recording 或 album",
)
# 音乐与影视共用统一识别入口,按媒体源和原生 ID 恢复音乐详情
music_info = MediaChain().recognize_media(
source=media_source or "musicbrainz",
mediaid=normalized_media_id,
mtype=MediaType.MUSIC,
music_type=normalized_music_type,
)
if not music_info:
return schemas.Response(success=False, message="刮削失败,无法识别音乐信息")

View File

@@ -63,10 +63,15 @@ async def recognize_music(
_: schemas.TokenPayload = Depends(verify_token),
) -> schemas.MusicInfo:
"""根据音乐元数据来源和媒体 ID 获取标准详情,与影视识别共用统一入口。"""
recognize_kwargs = {
"source": request.source,
"mediaid": request.media_id,
"mtype": MediaType.MUSIC,
}
if request.music_type is not None:
recognize_kwargs["music_type"] = request.music_type
info = await MediaChain().async_recognize_media(
source=request.source,
mediaid=request.media_id,
mtype=MediaType.MUSIC,
**recognize_kwargs,
)
if not info:
raise HTTPException(status_code=404, detail="未识别到音乐信息")

View File

@@ -18,7 +18,7 @@ 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 parse_media_key, resolve_media_identity
from app.utils.media import normalize_music_type, parse_media_key, resolve_media_identity
from app.utils.security import SecurityUtils
router = APIRouter()
@@ -63,14 +63,30 @@ async def _resolve_media_search_params(
title: Optional[str] = None,
year: Optional[str] = None,
media_season: Optional[int] = None,
music_type: Optional[str] = None,
) -> tuple[Optional[dict], str]:
"""将任意来源媒体键解析为 SearchChain 可直接使用的识别参数。"""
normalized_music_type = None
if music_type:
normalized_music_type = normalize_music_type(music_type, allow_artist=False)
if not normalized_music_type:
return None, "音乐实体类型无效,仅支持 recording 或 album"
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 {"source": source, "mediaid": source_media_id}, ""
return build_params(source, source_media_id), ""
event_data = MediaRecognizeConvertEventData(
mediaid=mediaid, convert_type=settings.RECOGNIZE_SOURCE
@@ -82,10 +98,7 @@ async def _resolve_media_search_params(
event_data = event.event_data
search_id = event_data.media_dict.get("id")
if search_id is not None:
return {
"source": event_data.convert_type,
"mediaid": str(search_id),
}, ""
return build_params(event_data.convert_type, str(search_id)), ""
if not title:
return None, "未知的媒体ID"
@@ -98,16 +111,16 @@ async def _resolve_media_search_params(
if media_season is not None:
meta.type = MediaType.TV
meta.begin_season = media_season
mediainfo = await MediaChain().async_recognize_by_meta(
meta,
obtain_images=False,
)
recognize_kwargs = {"obtain_images": False}
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 {"source": source, "mediaid": source_media_id}, ""
return build_params(source, source_media_id), ""
def _sse_event(data: dict, locale: Optional[str] = None) -> str:
@@ -390,6 +403,7 @@ async def search_by_id_stream(
year: Optional[str] = None,
season: Optional[str] = None,
sites: Optional[str] = None,
music_type: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_resource_token),
) -> Any:
"""
@@ -409,6 +423,7 @@ async def search_by_id_stream(
title=title,
year=year,
media_season=media_season,
music_type=music_type,
)
if not search_params:
yield {"type": "error", "success": False, "message": message}
@@ -440,6 +455,7 @@ async def search_by_id(
year: Optional[str] = None,
season: Optional[str] = None,
sites: Optional[str] = None,
music_type: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token),
) -> Any:
"""
@@ -453,6 +469,7 @@ async def search_by_id(
title=title,
year=year,
media_season=media_season,
music_type=music_type,
)
if not search_params:
return schemas.Response(success=False, message=message)

View File

@@ -6,9 +6,10 @@ 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 MUSIC_ENTITY_ALBUM, MUSIC_ENTITY_RECORDING, MediaInfo
from app.core.context import MediaInfo
from app.core.event import eventmanager
from app.core.metainfo import MetaInfo
from app.core.security import verify_token, verify_apitoken
@@ -22,7 +23,13 @@ from app.helper.server import MoviePilotServerHelper
from app.log import logger
from app.scheduler import Scheduler
from app.schemas.event import SubscribeModifiedEventData
from app.schemas.types import MediaType, EventType, SystemConfigKey
from app.schemas.types import (
MUSIC_ENTITY_ALBUM,
MUSIC_ENTITY_RECORDING,
MediaType,
EventType,
SystemConfigKey,
)
from app.utils.media import normalize_media_source, parse_media_key
router = APIRouter()
@@ -117,6 +124,14 @@ 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(
db: AsyncSession,
media_key: str,
@@ -326,18 +341,27 @@ async def subscribe_mediaid(
title_check = not result and bool(title) and source != "themoviedb"
# 使用名称检查订阅
if title_check and title:
meta = MetaInfo(title)
if season is not None:
meta.begin_season = season
subscribes = await Subscribe.async_list_by_title(
db, title=meta.name, season=meta.begin_season
)
title_season = None
if music_type:
subscribes = [
subscribe for subscribe in subscribes
if matches_subscribe_music_type(subscribe, music_type)
]
result = select_accessible_subscribe(subscribes, current_user)
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()

View File

@@ -4,16 +4,28 @@ 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
from app.core.context import MediaInfo, MusicInfo
from app.core.meta import MetaMusic
from app.core.metainfo import MetaInfo
from app.db.models import User
from app.db.user_oper import (
get_current_active_superuser,
get_current_active_superuser_async,
)
from app.schemas.types import (
MUSIC_ENTITY_RECORDING,
MediaType,
MusicTargetEntityType,
)
from app.utils.crypto import HashUtils
from app.utils.media import (
is_music_media_source,
normalize_music_type,
resolve_media_identity,
)
router = APIRouter()
@@ -41,6 +53,7 @@ async def torrents_cache(_: User = Depends(get_current_active_superuser_async)):
torrent_hash = HashUtils.md5(
f"{context.torrent_info.title}{context.torrent_info.description}"
)
media_source, media_id = resolve_media_identity(media=context.media_info)
torrent_data.append(
{
"hash": torrent_hash,
@@ -55,6 +68,9 @@ async def torrents_cache(_: User = Depends(get_current_active_superuser_async)):
else "",
"media_year": context.media_info.year if context.media_info else "",
"media_type": context.media_info.type if context.media_info else "",
"media_source": media_source,
"media_id": media_id,
"music_type": getattr(context.media_info, "music_type", None),
"season_episode": context.meta_info.season_episode
if context.meta_info
else "",
@@ -181,6 +197,7 @@ async def reidentify_cache(
anilistid: Optional[int] = None,
media_source: Optional[str] = None,
media_id: Optional[str] = None,
music_type: Optional[MusicTargetEntityType] = None,
_: User = Depends(get_current_active_superuser_async),
):
"""
@@ -193,6 +210,7 @@ async def reidentify_cache(
:param anilistid: 手动指定的 AniList ID
:param media_source: 媒体数据源
:param media_id: 数据源原生 ID
:param music_type: 音乐实体类型,仅支持单曲或专辑
:param _: 当前用户,必须是超级用户
"""
@@ -221,13 +239,53 @@ async def reidentify_cache(
if not target_context:
return schemas.Response(success=False, message="未找到指定的种子")
# 重新识别
meta = MetaInfo(
title=target_context.torrent_info.title,
subtitle=target_context.torrent_info.description,
existing_music_type = normalize_music_type(
getattr(target_context.media_info, "music_type", None),
allow_artist=False,
)
if tmdbid or doubanid or bangumiid or anilistid or media_source or media_id:
# 手动指定媒体信息
normalized_music_type = normalize_music_type(
music_type,
allow_artist=False,
)
if music_type is not None and not normalized_music_type:
return schemas.Response(
success=False,
message="音乐实体类型无效,仅支持 recording 或 album",
)
is_music = (
getattr(target_context.media_info, "type", None) == MediaType.MUSIC
or isinstance(target_context.meta_info, MetaMusic)
or target_context.torrent_info.category
in (MediaType.MUSIC, MediaType.MUSIC.value, "music")
or is_music_media_source(media_source)
or normalized_music_type is not None
)
if is_music and media_source and not is_music_media_source(media_source):
return schemas.Response(
success=False,
message="音乐重新识别只能使用音乐元数据源",
)
if is_music and not normalized_music_type:
normalized_music_type = existing_music_type or MUSIC_ENTITY_RECORDING
# 重识别沿用原媒体域;音乐标题必须使用 MetaMusic避免误入影视模块。
if is_music:
meta = (
target_context.meta_info
if isinstance(target_context.meta_info, MetaMusic)
else MusicChain.parse_query(target_context.torrent_info.title)
)
else:
meta = MetaInfo(
title=target_context.torrent_info.title,
subtitle=target_context.torrent_info.description,
)
has_explicit_id = bool(
tmdbid or doubanid or bangumiid or anilistid or media_id
)
if has_explicit_id:
# 手动指定媒体身份时执行精确识别。
mediainfo = await media_chain.async_recognize_media(
meta=meta,
tmdbid=tmdbid,
@@ -236,14 +294,27 @@ async def reidentify_cache(
anilistid=anilistid,
source=media_source,
mediaid=media_id,
mtype=MediaType.MUSIC if is_music else None,
music_type=normalized_music_type,
)
else:
# 自动重新识别
mediainfo = await media_chain.async_recognize_by_meta(meta)
# 未指定 ID 时按标题识别,请求级来源仍用于约束本次识别
mediainfo = await media_chain.async_recognize_by_meta(
meta,
source=media_source,
mtype=MediaType.MUSIC if is_music else None,
music_type=normalized_music_type,
)
if not mediainfo:
# 创建空的媒体信息
mediainfo = MediaInfo()
# 失败占位仍保留原媒体域,避免音乐缓存被误写进影视缓存文件。
mediainfo = (
MusicInfo(
music_type=normalized_music_type or MUSIC_ENTITY_RECORDING
)
if is_music
else MediaInfo()
)
else:
# 清理多余数据
mediainfo.clear()
@@ -266,6 +337,9 @@ async def reidentify_cache(
"media_type": mediainfo.type.value
if mediainfo and mediainfo.type
else "",
"media_source": getattr(mediainfo, "source", None),
"media_id": getattr(mediainfo, "media_id", None),
"music_type": getattr(mediainfo, "music_type", None),
},
)
except Exception as e:

View File

@@ -53,6 +53,7 @@ def query_name(
media_path = DirectoryHelper.get_media_root_path(
rename_format=settings.RENAME_FORMAT(context.media_info.type),
rename_path=Path(new_path),
media_type=context.media_info.type,
)
if media_path:
new_name = media_path.name
@@ -338,6 +339,9 @@ def manual_transfer(
transer_item.media_id = (
history.media_id or transer_item.media_id
)
transer_item.music_type = (
getattr(history, "music_type", None) or transer_item.music_type
)
transer_item.season = (
int(str(history.seasons).replace("S", ""))
if history.seasons
@@ -459,6 +463,7 @@ def manual_transfer(
anilistid=transer_item.anilistid,
media_source=transer_item.media_source,
media_id=transer_item.media_id,
music_type=transer_item.music_type,
mtype=mtype,
season=transer_item.season,
episode_group=transer_item.episode_group,
@@ -546,6 +551,7 @@ def manual_transfer(
anilistid=transer_item.anilistid,
media_source=transer_item.media_source,
media_id=transer_item.media_id,
music_type=transer_item.music_type,
mtype=mtype,
season=transer_item.season,
episode_group=transer_item.episode_group,