diff --git a/app/api/apiv1.py b/app/api/apiv1.py index 0ecad8e44..43d078cb6 100644 --- a/app/api/apiv1.py +++ b/app/api/apiv1.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.endpoints import anilist, auth, login, user, webhook, message, agent, site, subscribe, \ +from app.api.endpoints import anilist, auth, login, user, webhook, message, agent, site, subscribe, music, \ media, douban, search, plugin, tmdb, history, system, download, dashboard, \ transfer, mediaserver, bangumi, storage, discover, recommend, workflow, torrent, mcp, mfa, openai, anthropic, llm, notification @@ -14,6 +14,7 @@ api_router.include_router(message.router, prefix="/message", tags=["message"]) api_router.include_router(agent.router, prefix="/message/agent", tags=["agent"]) api_router.include_router(webhook.router, prefix="/webhook", tags=["webhook"]) api_router.include_router(subscribe.router, prefix="/subscribe", tags=["subscribe"]) +api_router.include_router(music.router, prefix="/music", tags=["music"]) api_router.include_router(media.router, prefix="/media", tags=["media"]) api_router.include_router(search.router, prefix="/search", tags=["search"]) api_router.include_router(douban.router, prefix="/douban", tags=["douban"]) diff --git a/app/api/endpoints/download.py b/app/api/endpoints/download.py index df4d55b89..5aee72f60 100644 --- a/app/api/endpoints/download.py +++ b/app/api/endpoints/download.py @@ -1,12 +1,14 @@ -from typing import Any, List, Annotated, Literal, Optional +from typing import Any, List, Annotated, Literal, Optional, Union 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 MediaInfo, Context, SubtitleInfo, TorrentInfo from app.core.metainfo import MetaInfo +from app.core.music import MusicInfo from app.core.security import verify_token from app.db.models.user import User from app.db.site_oper import SiteOper @@ -17,7 +19,7 @@ from app.schemas.types import SystemConfigKey from app.utils.security import SecurityUtils router = APIRouter() -MediaSource = Literal["themoviedb", "douban", "bangumi", "anilist"] +MediaSource = Literal["themoviedb", "douban", "bangumi", "anilist", "musicbrainz"] def _prepare_subtitle_download(subtitle: SubtitleInfo) -> tuple[bool, str]: @@ -57,7 +59,7 @@ def current( @router.post("/", summary="添加下载(含媒体信息)", response_model=schemas.Response) def download( - media_in: schemas.MediaInfo, + media_in: Union[schemas.MusicInfo, schemas.MediaInfo], torrent_in: schemas.TorrentInfo, downloader: Annotated[str | None, Body()] = None, save_path: Annotated[str | None, Body()] = None, @@ -66,11 +68,14 @@ def download( """ 添加下载任务(含媒体信息) """ - # 元数据 - metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description) - # 媒体信息 - mediainfo = MediaInfo() - mediainfo.from_dict(media_in.model_dump()) + if isinstance(media_in, schemas.MusicInfo): + mediainfo = MusicInfo.from_dict(media_in.model_dump()) + metainfo = MusicChain.to_meta(mediainfo) + metainfo.org_string = torrent_in.title + else: + metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description) + mediainfo = MediaInfo() + mediainfo.from_dict(media_in.model_dump()) # 种子信息 torrentinfo = TorrentInfo() torrentinfo.from_dict(torrent_in.model_dump()) diff --git a/app/api/endpoints/music.py b/app/api/endpoints/music.py new file mode 100644 index 000000000..0946b4504 --- /dev/null +++ b/app/api/endpoints/music.py @@ -0,0 +1,70 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException, Query + +from app import schemas +from app.chain.music import MusicChain +from app.core.music import MusicInfo +from app.core.security import verify_token + +router = APIRouter() + +CountParam = Annotated[int, Query(ge=1, le=100)] + + +def _serialize_music(info: MusicInfo) -> schemas.MusicInfo: + """将内部音乐信息转换为 REST 响应模型。""" + return schemas.MusicInfo(**info.to_dict()) + + +@router.get( + "/search", + summary="搜索音乐元数据", + response_model=list[schemas.MusicInfo], +) +async def search_music( + query: str = Query(min_length=1), + count: CountParam = 20, + _: schemas.TokenPayload = Depends(verify_token), +) -> list[schemas.MusicInfo]: + """按歌曲、专辑或艺术家关键词搜索标准音乐候选。""" + results = await MusicChain().async_search(query=query, limit=count) + return [_serialize_music(info) for info in results] + + +@router.post( + "/recognize", + summary="识别音乐元数据详情", + response_model=schemas.MusicInfo, +) +async def recognize_music( + request: schemas.MusicRecognizeRequest, + _: schemas.TokenPayload = Depends(verify_token), +) -> schemas.MusicInfo: + """根据音乐元数据来源和媒体 ID 获取标准详情。""" + info = await MusicChain().async_recognize( + source=request.source, + media_id=request.media_id, + ) + if not info: + raise HTTPException(status_code=404, detail="未识别到音乐信息") + return _serialize_music(info) + + +@router.get( + "/explore", + summary="探索热门音乐", + response_model=list[schemas.MusicInfo], +) +async def explore_music( + page: Annotated[int, Query(ge=1)] = 1, + count: CountParam = 30, + _: schemas.TokenPayload = Depends(verify_token), +) -> list[schemas.MusicInfo]: + """按月度全站收听榜单分页返回可搜索和订阅的音乐候选。""" + results = await MusicChain().async_chart( + range_name="this_month", + page=page, + count=count, + ) + return [_serialize_music(info) for info in results] diff --git a/app/api/endpoints/recommend.py b/app/api/endpoints/recommend.py index dfe3ece62..0ad8485ca 100644 --- a/app/api/endpoints/recommend.py +++ b/app/api/endpoints/recommend.py @@ -60,6 +60,20 @@ async def bangumi_calendar( return await RecommendChain().async_bangumi_calendar(page=page, count=count) +@router.get( + "/music_weekly", + summary="ListenBrainz 本周热门音乐", + response_model=List[schemas.MusicInfo], +) +async def music_weekly( + page: Optional[int] = 1, + count: Optional[int] = 30, + _: schemas.TokenPayload = Depends(verify_token), +) -> Any: + """浏览本周全站热门音乐。""" + return await RecommendChain().async_music_weekly(page=page, count=count) + + @router.get( "/douban_showing", summary="豆瓣正在热映", response_model=List[schemas.MediaInfo] ) diff --git a/app/api/endpoints/search.py b/app/api/endpoints/search.py index d4477d1df..3197fb2a3 100644 --- a/app/api/endpoints/search.py +++ b/app/api/endpoints/search.py @@ -475,6 +475,7 @@ async def search_by_id( async def search_by_title_stream( request: Request, keyword: Optional[str] = None, + mtype: Optional[str] = None, page: Optional[int] = 0, sites: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_resource_token), @@ -484,7 +485,11 @@ async def search_by_title_stream( """ event_source = SearchChain().async_search_by_title_stream( - title=keyword, page=page, sites=_parse_site_list(sites), cache_local=True + title=keyword, + page=page, + sites=_parse_site_list(sites), + cache_local=True, + mtype=_parse_media_type(mtype), ) return StreamingResponse( _stream_search_events(request, event_source), @@ -496,6 +501,7 @@ async def search_by_title_stream( @router.get("/title", summary="模糊搜索资源", response_model=schemas.Response) async def search_by_title( keyword: Optional[str] = None, + mtype: Optional[str] = None, page: Optional[int] = 0, sites: Optional[str] = None, _: schemas.TokenPayload = Depends(verify_token), @@ -504,7 +510,11 @@ async def search_by_title( 根据名称模糊搜索站点资源,支持分页,关键词为空是返回首页资源 """ torrents = await SearchChain().async_search_by_title( - title=keyword, page=page, sites=_parse_site_list(sites), cache_local=True + title=keyword, + page=page, + sites=_parse_site_list(sites), + cache_local=True, + mtype=_parse_media_type(mtype), ) if not torrents: return schemas.Response(success=False, message="未搜索到任何资源") diff --git a/app/api/endpoints/site.py b/app/api/endpoints/site.py index 4d6440d4d..e8a7982df 100644 --- a/app/api/endpoints/site.py +++ b/app/api/endpoints/site.py @@ -30,7 +30,7 @@ from app.db.user_oper import ( from app.helper.sites import SitesHelper # noqa from app.log import logger from app.scheduler import Scheduler -from app.schemas.types import SystemConfigKey, EventType +from app.schemas.types import SystemConfigKey, EventType, MediaType from app.utils.string import StringUtils router = APIRouter() @@ -394,6 +394,7 @@ async def site_category( async def site_resource( site_id: int, keyword: Optional[str] = None, + mtype: Optional[str] = None, cat: Optional[str] = None, page: Optional[int] = 0, db: AsyncSession = Depends(get_async_db), @@ -409,7 +410,11 @@ async def site_resource( detail=f"站点 {site_id} 不存在", ) torrents = await TorrentsChain().async_browse( - domain=site.domain, keyword=keyword, cat=cat, page=page + domain=site.domain, + keyword=keyword, + cat=cat, + page=page, + mtype=MediaType.from_agent(mtype) or MediaType(mtype) if mtype else None, ) if not torrents: return [] diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index 8cd5df5f1..d5695562f 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -173,10 +173,13 @@ async def create_subscribe( mtype = None # 非 TMDB 来源的标题可能自带季标记,入库前统一拆分。 if ( - subscribe_in.doubanid - or subscribe_in.bangumiid - or subscribe_in.anilistid - or normalize_media_source(subscribe_in.media_source) not in (None, "themoviedb") + 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") + ) ): meta = MetaInfo(subscribe_in.name) subscribe_in.name = meta.name diff --git a/app/chain/__init__.py b/app/chain/__init__.py index 062b04d3f..0ce081589 100644 --- a/app/chain/__init__.py +++ b/app/chain/__init__.py @@ -1272,6 +1272,7 @@ class ChainBase(metaclass=ABCMeta): keyword: Optional[str] = None, cat: Optional[str] = None, page: Optional[int] = 0, + mtype: Optional[MediaType] = None, ) -> List[TorrentInfo]: """ 获取站点最新一页的种子,多个站点需要多线程处理 @@ -1279,10 +1280,11 @@ class ChainBase(metaclass=ABCMeta): :param keyword: 标题 :param cat: 分类 :param page: 页码 + :param mtype: 媒体类型 :reutrn: 种子资源列表 """ return self.run_module( - "refresh_torrents", site=site, keyword=keyword, cat=cat, page=page + "refresh_torrents", site=site, keyword=keyword, cat=cat, page=page, mtype=mtype ) async def async_refresh_torrents( @@ -1291,6 +1293,7 @@ class ChainBase(metaclass=ABCMeta): keyword: Optional[str] = None, cat: Optional[str] = None, page: Optional[int] = 0, + mtype: Optional[MediaType] = None, ) -> List[TorrentInfo]: """ 异步获取站点最新一页的种子,多个站点需要多线程处理 @@ -1298,10 +1301,11 @@ class ChainBase(metaclass=ABCMeta): :param keyword: 标题 :param cat: 分类 :param page: 页码 + :param mtype: 媒体类型 :reutrn: 种子资源列表 """ return await self.async_run_module( - "async_refresh_torrents", site=site, keyword=keyword, cat=cat, page=page + "async_refresh_torrents", site=site, keyword=keyword, cat=cat, page=page, mtype=mtype ) def filter_torrents( diff --git a/app/chain/download.py b/app/chain/download.py index 103e26843..530642456 100644 --- a/app/chain/download.py +++ b/app/chain/download.py @@ -16,6 +16,7 @@ from app.chain.storage import StorageChain from app.core.cache import FileCache from app.core.config import settings, global_vars from app.core.context import MediaInfo, SubtitleInfo, TorrentInfo, Context +from app.core.music import MusicInfo, MusicMeta from app.core.event import eventmanager, Event from app.core.meta import MetaBase from app.core.metainfo import MetaInfo @@ -61,6 +62,25 @@ class DownloadChain(ChainBase): ".rar": "rar", } + @staticmethod + def _build_download_note( + source: Optional[str], + media: MediaInfo | MusicInfo, + meta: MetaBase | MusicMeta, + ) -> dict: + """构造下载历史备注,并为音乐保存可恢复的版本化上下文。""" + note = {"source": source} + if getattr(media, "type", None) != MediaType.MUSIC: + return note + media_payload = media.to_dict() + media_payload.pop("raw_data", None) + note["music"] = { + "version": 1, + "meta": meta.to_dict(), + "media": media_payload, + } + return note + @staticmethod def _normalize_indirect_download_url(url: str, base_url: Optional[str] = None) -> str: """ @@ -963,7 +983,7 @@ class DownloadChain(ChainBase): date=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()), media_category=_media.category, episode_group=_media.episode_group, - note={"source": source}, + note=self._build_download_note(source, _media, _meta), custom_words=custom_words ) @@ -1183,6 +1203,11 @@ class DownloadChain(ChainBase): """ return _context.media_info.title_year + def __get_music_download_key(_context: Context) -> str: + """获取音乐下载去重键,同一订阅目标失败后仍可尝试后续候选。""" + media_source, media_id = resolve_media_identity(media=_context.media_info) + return build_media_key(media_source, media_id) or _context.media_info.title_year + # 发送资源选择事件,允许外部修改上下文数据 logger.debug(f"Initial contexts: {len(contexts)} items, Downloader: {downloader}") event_data = ResourceSelectionEventData( @@ -1246,6 +1271,28 @@ class DownloadChain(ChainBase): else: __remember_context_failure(context) + # 音乐与电影一样按单个订阅目标择一下载,不进入电视剧季集组合逻辑。 + downloaded_music = set() + for context in contexts: + if global_vars.is_system_stopped: + break + if context.media_info.type != MediaType.MUSIC: + continue + if __is_context_in_failure_cooldown(context): + continue + music_key = __get_music_download_key(context) + if music_key in downloaded_music: + continue + logger.info(f"开始下载音乐 {context.torrent_info.title} ...") + if self.download_single(context, save_path=save_path, channel=channel, + source=source, userid=userid, username=username, + downloader=downloader, custom_words=custom_words): + logger.info(f"{context.torrent_info.title} 添加下载成功") + downloaded_list.append(context) + downloaded_music.add(music_key) + else: + __remember_context_failure(context) + # 电视剧整季匹配 if no_exists: logger.info(f"开始匹配电视剧整季:{no_exists}") diff --git a/app/chain/media.py b/app/chain/media.py index 2c17871a2..4e0f22255 100644 --- a/app/chain/media.py +++ b/app/chain/media.py @@ -696,6 +696,8 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): """ if not mediainfo: return None + if mediainfo.type == MediaType.MUSIC: + return mediainfo if mediainfo.tmdb_id and mediainfo.tmdb_info and mediainfo.genre_ids: return mediainfo tmdb_meta = self._build_tmdb_supplement_meta(mediainfo, metainfo) diff --git a/app/chain/music.py b/app/chain/music.py new file mode 100644 index 000000000..af5b80897 --- /dev/null +++ b/app/chain/music.py @@ -0,0 +1,180 @@ +import re +from typing import Any, Iterable, Optional + +from app.chain import ChainBase +from app.core.music import MusicInfo, MusicMeta + + +class MusicChain(ChainBase): + """音乐元数据搜索、识别与站点搜索参数编排链。""" + + _artist_title_pattern = re.compile(r"^\s*(?P.+?)\s+[-–—]\s+(?P.+?)\s*$") + _spaces_pattern = re.compile(r"\s+") + + @classmethod + def parse_query(cls, query: str) -> MusicMeta: + """将用户输入解析为最小可用的音乐搜索元数据。""" + normalized = cls._normalize_text(query) + meta = MusicMeta(org_string=query, title=normalized) + match = cls._artist_title_pattern.match(normalized) + if match: + meta.artists = [match.group("artist").strip()] + meta.title = match.group("title").strip() + return meta + + @classmethod + def build_site_keywords(cls, music: MusicMeta | MusicInfo) -> list[str]: + """根据音乐元数据生成按精确度递减的站点搜索关键词。""" + artists = music.artists or [] + artist = artists[0] if artists else music.album_artist + keywords = [] + if artist and music.album: + keywords.append(f"{artist} {music.album}") + if artist and music.title: + keywords.append(f"{artist} {music.title}") + if music.album: + keywords.append(music.album) + if music.title: + keywords.append(music.title) + return cls._unique_texts(keywords) + + @classmethod + def normalize_candidates( + cls, + candidates: Optional[Iterable[MusicInfo | dict[str, Any]]], + limit: Optional[int] = None, + ) -> list[MusicInfo]: + """标准化并去重来自一个或多个音乐元数据模块的候选。""" + results: list[MusicInfo] = [] + identities: set[tuple[str, ...]] = set() + for candidate in candidates or []: + info = candidate if isinstance(candidate, MusicInfo) else MusicInfo.from_dict(candidate) + identity = cls._candidate_identity(info) + if identity in identities: + continue + identities.add(identity) + results.append(info) + if limit and len(results) >= limit: + break + return results + + def search(self, query: str, limit: int = 20) -> list[MusicInfo]: + """调用已启用的音乐元数据模块搜索候选。""" + meta = self.parse_query(query) + candidates = self.run_module("search_music", meta=meta, limit=limit) + return self.normalize_candidates(candidates, limit=limit) + + async def async_search(self, query: str, limit: int = 20) -> list[MusicInfo]: + """异步调用已启用的音乐元数据模块搜索候选。""" + meta = self.parse_query(query) + candidates = await self.async_run_module("search_music", meta=meta, limit=limit) + return self.normalize_candidates(candidates, limit=limit) + + def recognize( + self, + source: str, + media_id: str, + ) -> Optional[MusicInfo]: + """按音乐元数据源和媒体 ID 获取标准化详情。""" + result = self.run_module( + "recognize_music", + source=source, + media_id=media_id, + ) + if isinstance(result, MusicInfo): + return result + if isinstance(result, dict): + return MusicInfo.from_dict(result) + return None + + async def async_recognize( + self, + source: str, + media_id: str, + ) -> Optional[MusicInfo]: + """异步按音乐元数据源和媒体 ID 获取标准化详情。""" + result = await self.async_run_module( + "recognize_music", + source=source, + media_id=media_id, + ) + if isinstance(result, MusicInfo): + return result + if isinstance(result, dict): + return MusicInfo.from_dict(result) + return None + + def chart(self, range_name: str, page: int = 1, count: int = 30) -> list[MusicInfo]: + """读取 ListenBrainz 全站音乐榜单并标准化分页结果。""" + candidates = self.run_module( + "music_chart", + range_name=range_name, + offset=max(page - 1, 0) * count, + count=count, + ) + return self.normalize_candidates(candidates, limit=count) + + async def async_chart( + self, + range_name: str, + page: int = 1, + count: int = 30, + ) -> list[MusicInfo]: + """异步读取 ListenBrainz 全站音乐榜单并标准化分页结果。""" + candidates = await self.async_run_module( + "music_chart", + range_name=range_name, + offset=max(page - 1, 0) * count, + count=count, + ) + return self.normalize_candidates(candidates, limit=count) + + @classmethod + def to_meta(cls, info: MusicInfo) -> MusicMeta: + """将用户选中的标准音乐信息转换为下载和整理上下文元数据。""" + return MusicMeta( + title=info.title, + artists=list(info.artists), + album=info.album, + album_artist=info.album_artist, + year=info.year, + disc_number=info.disc_number, + track_number=info.track_number, + total_tracks=info.total_tracks, + version=info.version, + duration=info.duration, + isrc=info.isrc, + media_source=info.source, + media_id=info.media_id, + ) + + @classmethod + def _candidate_identity(cls, info: MusicInfo) -> tuple[str, ...]: + """构造跨来源稳定的候选去重键。""" + if info.source and info.media_id: + return "id", info.source.casefold(), info.media_id.casefold() + return ( + "metadata", + cls._normalize_text(info.title).casefold(), + cls._normalize_text(info.artist).casefold(), + cls._normalize_text(info.album).casefold(), + ) + + @classmethod + def _unique_texts(cls, values: Iterable[Optional[str]]) -> list[str]: + """按规范化文本去重并保留原始顺序。""" + results = [] + seen = set() + for value in values: + normalized = cls._normalize_text(value) + identity = normalized.casefold() + if not normalized or identity in seen: + continue + seen.add(identity) + results.append(normalized) + return results + + @classmethod + def _normalize_text(cls, value: Optional[str]) -> str: + """清理音乐检索文本中的多余空白。""" + return cls._spaces_pattern.sub(" ", str(value or "")).strip() diff --git a/app/chain/recommend.py b/app/chain/recommend.py index 579e4b70e..01277ba4f 100644 --- a/app/chain/recommend.py +++ b/app/chain/recommend.py @@ -5,6 +5,7 @@ import pillow_avif # noqa 用于自动注册AVIF支持 from app.chain import ChainBase from app.chain.bangumi import BangumiChain from app.chain.douban import DoubanChain +from app.chain.music import MusicChain from app.chain.tmdb import TmdbChain from app.core.cache import cached, fresh from app.core.config import settings, global_vars @@ -55,6 +56,7 @@ class RecommendChain(ChainBase, metaclass=Singleton): self.douban_tv_animation, self.douban_movie_hot, self.douban_tv_hot, + self.music_weekly, ] # 缓存并刷新所有推荐数据 @@ -174,6 +176,17 @@ class RecommendChain(ChainBase, metaclass=Singleton): page=page) return [movie.to_dict() for movie in movies] if movies else [] + @log_execution_time(logger=logger) + @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) + def music_weekly(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: + """返回 ListenBrainz 本周全站热门音乐。""" + medias = MusicChain().chart( + range_name="this_week", + page=page or 1, + count=count or 30, + ) + return [media.to_dict() for media in medias] + @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) def tmdb_tvs(self, sort_by: Optional[str] = "popularity.desc", @@ -392,6 +405,17 @@ class RecommendChain(ChainBase, metaclass=Singleton): movies = await DoubanChain().async_run_module("async_movie_showing", page=page, count=count) return [media.to_dict() for media in movies] if movies else [] + @log_execution_time(logger=logger) + @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) + async def async_music_weekly(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]: + """异步返回 ListenBrainz 本周全站热门音乐。""" + medias = await MusicChain().async_chart( + range_name="this_week", + page=page or 1, + count=count or 30, + ) + return [media.to_dict() for media in medias] + @log_execution_time(logger=logger) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) async def async_douban_movies(self, sort: Optional[str] = "R", tags: Optional[str] = "", diff --git a/app/chain/search.py b/app/chain/search.py index e375795e3..e88781d1d 100644 --- a/app/chain/search.py +++ b/app/chain/search.py @@ -12,9 +12,11 @@ from typing import List, Optional from fastapi.concurrency import run_in_threadpool from app.chain import ChainBase +from app.chain.music import MusicChain from app.core.config import global_vars, settings from app.core.context import Context from app.core.context import MediaInfo, SubtitleInfo, TorrentInfo +from app.core.music import MusicInfo, MusicMeta from app.core.event import eventmanager, Event from app.core.metainfo import MetaInfo from app.db.systemconfig_oper import SystemConfigOper @@ -544,10 +546,16 @@ class SearchChain(ChainBase): season=season, sites=sites, ) - mediainfo = self.recognize_media( - source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid, - bangumiid=bangumiid, anilistid=anilistid, mtype=mtype, - ) + if mtype == MediaType.MUSIC: + mediainfo = MusicChain().recognize( + source=source, + media_id=str(mediaid) if mediaid is not None else "", + ) + else: + mediainfo = self.recognize_media( + source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid, + bangumiid=bangumiid, anilistid=anilistid, mtype=mtype, + ) if not mediainfo: logger.error(f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} 媒体信息识别失败!') return [] @@ -566,18 +574,23 @@ class SearchChain(ChainBase): return results def search_by_title(self, title: str, page: Optional[int] = 0, - sites: List[int] = None, cache_local: Optional[bool] = False) -> List[Context]: + sites: List[int] = None, cache_local: Optional[bool] = False, + mtype: Optional[MediaType] = None, + rule_groups: Optional[List[str]] = None) -> List[Context]: """ 根据标题搜索资源,不识别媒体信息,按默认搜索过滤规则返回站点内容 :param title: 标题,为空时返回所有站点首页内容 :param page: 页码 :param sites: 站点ID列表 :param cache_local: 是否缓存到本地 + :param mtype: 限定站点资源分类 + :param rule_groups: 指定过滤规则组,为空时使用默认搜索过滤规则 """ if cache_local: self.cancel_ai_recommend() self.save_last_search_params( keyword=title, + mtype=mtype, area="title", sites=sites, ) @@ -586,18 +599,24 @@ class SearchChain(ChainBase): else: logger.info(f'开始浏览资源,站点:{sites} ...') # 搜索 - torrents = self.__search_all_sites(keyword=title, sites=sites, page=page) or [] + search_kwargs = {"keyword": title, "sites": sites, "page": page} + if mtype is not None: + search_kwargs["mtype"] = mtype + torrents = self.__search_all_sites(**search_kwargs) or [] if not torrents: logger.warn(f'{title} 未搜索到资源') return [] - torrents = self.__filter_title_search_torrents(torrents=torrents) + torrents = self.__filter_title_search_torrents( + torrents=torrents, + rule_groups=rule_groups, + ) if not torrents: logger.warn(f'{title} 没有符合过滤规则的资源') return [] # 组装上下文 contexts = [ Context( - meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description), + meta_info=self._build_title_search_meta(torrent, mtype), torrent_info=torrent, resource_source="search", ) for torrent in torrents @@ -860,10 +879,16 @@ class SearchChain(ChainBase): season=season, sites=sites, ) - mediainfo = await self.async_recognize_media( - source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid, - bangumiid=bangumiid, anilistid=anilistid, mtype=mtype, - ) + if mtype == MediaType.MUSIC: + mediainfo = await MusicChain().async_recognize( + source=source, + media_id=str(mediaid) if mediaid is not None else "", + ) + else: + mediainfo = await self.async_recognize_media( + source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid, + bangumiid=bangumiid, anilistid=anilistid, mtype=mtype, + ) if not mediainfo: logger.error( f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} ' @@ -885,18 +910,23 @@ class SearchChain(ChainBase): return results async def async_search_by_title(self, title: str, page: Optional[int] = 0, - sites: List[int] = None, cache_local: Optional[bool] = False) -> List[Context]: + sites: List[int] = None, cache_local: Optional[bool] = False, + mtype: Optional[MediaType] = None, + rule_groups: Optional[List[str]] = None) -> List[Context]: """ 根据标题异步搜索资源,不识别媒体信息,按默认搜索过滤规则返回站点内容 :param title: 标题,为空时返回所有站点首页内容 :param page: 页码 :param sites: 站点ID列表 :param cache_local: 是否缓存到本地 + :param mtype: 限定站点资源分类 + :param rule_groups: 指定过滤规则组,为空时使用默认搜索过滤规则 """ if cache_local: self.cancel_ai_recommend() await self.async_save_last_search_params( keyword=title, + mtype=mtype, area="title", sites=sites, ) @@ -905,18 +935,25 @@ class SearchChain(ChainBase): else: logger.info(f'开始浏览资源,站点:{sites} ...') # 搜索 - torrents = await self.__async_search_all_sites(keyword=title, sites=sites, page=page) or [] + search_kwargs = {"keyword": title, "sites": sites, "page": page} + if mtype is not None: + search_kwargs["mtype"] = mtype + torrents = await self.__async_search_all_sites(**search_kwargs) or [] if not torrents: logger.warn(f'{title} 未搜索到资源') return [] - torrents = await run_in_threadpool(self.__filter_title_search_torrents, torrents=torrents) + torrents = await run_in_threadpool( + self.__filter_title_search_torrents, + torrents=torrents, + rule_groups=rule_groups, + ) if not torrents: logger.warn(f'{title} 没有符合过滤规则的资源') return [] # 组装上下文 contexts = [ Context( - meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description), + meta_info=self._build_title_search_meta(torrent, mtype), torrent_info=torrent, resource_source="search", ) for torrent in torrents @@ -928,7 +965,9 @@ class SearchChain(ChainBase): async def async_search_by_title_stream(self, title: str, page: Optional[int] = 0, sites: List[int] = None, - cache_local: Optional[bool] = False) -> AsyncIterator[dict]: + cache_local: Optional[bool] = False, + mtype: Optional[MediaType] = None, + rule_groups: Optional[List[str]] = None) -> AsyncIterator[dict]: """ 根据标题渐进式搜索资源,不识别媒体信息,按默认搜索过滤规则返回结果 """ @@ -936,6 +975,7 @@ class SearchChain(ChainBase): self.cancel_ai_recommend() await self.async_save_last_search_params( keyword=title, + mtype=mtype, area="title", sites=sites, ) @@ -945,8 +985,10 @@ class SearchChain(ChainBase): logger.info(f'开始渐进式浏览资源,站点:{sites} ...') contexts: List[Context] = [] - rule_groups: List[str] = SystemConfigOper().get(SystemConfigKey.SearchFilterRuleGroups) or [] - async for event in self.__async_search_all_sites_stream(keyword=title, sites=sites, page=page): + if rule_groups is None: + rule_groups = SystemConfigOper().get(SystemConfigKey.SearchFilterRuleGroups) or [] + async for event in self.__async_search_all_sites_stream( + keyword=title, sites=sites, page=page, mtype=mtype): result = event.pop("items", []) or [] result = await run_in_threadpool( self.__filter_title_search_torrents, @@ -955,7 +997,7 @@ class SearchChain(ChainBase): ) batch_contexts = [ Context( - meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description), + meta_info=self._build_title_search_meta(torrent, mtype), torrent_info=torrent, resource_source="search", ) @@ -982,6 +1024,19 @@ class SearchChain(ChainBase): "total_items": len(contexts) } + @staticmethod + def _build_title_search_meta( + torrent: TorrentInfo, + mtype: Optional[MediaType], + ) -> Any: + """根据限定媒体类型构造模糊搜索结果的上下文元数据。""" + if mtype == MediaType.MUSIC: + return MusicMeta( + org_string=torrent.title, + title=torrent.title, + ) + return MetaInfo(title=torrent.title, subtitle=torrent.description) + def __filter_title_search_torrents(self, torrents: List[TorrentInfo], rule_groups: Optional[List[str]] = None) -> List[TorrentInfo]: @@ -1027,10 +1082,16 @@ class SearchChain(ChainBase): season=season, sites=sites, ) - mediainfo = await self.async_recognize_media( - source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid, - bangumiid=bangumiid, anilistid=anilistid, mtype=mtype, - ) + if mtype == MediaType.MUSIC: + mediainfo = await MusicChain().async_recognize( + source=source, + media_id=str(mediaid) if mediaid is not None else "", + ) + else: + mediainfo = await self.async_recognize_media( + source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid, + bangumiid=bangumiid, anilistid=anilistid, mtype=mtype, + ) if not mediainfo: logger.error( f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} ' @@ -1307,6 +1368,115 @@ class SearchChain(ChainBase): return list({f"{t.torrent_info.site_name}_{t.torrent_info.title}_{t.torrent_info.description}": t for t in _torrents}.values()) + def _build_music_contexts( + self, + torrents: List[TorrentInfo], + mediainfo: MusicInfo, + rule_groups: Optional[List[str]] = None, + filter_params: Optional[Dict[str, str]] = None, + ) -> List[Context]: + """过滤音乐分类资源并组装携带目标音乐身份的下载上下文。""" + torrents = [ + torrent + for torrent in torrents + if torrent.category in (MediaType.MUSIC, MediaType.MUSIC.value) + ] + if filter_params: + torrenthelper = TorrentHelper() + torrents = [ + torrent + for torrent in torrents + if torrenthelper.filter_torrent(torrent, filter_params) + ] + if rule_groups is None: + rule_groups = SystemConfigOper().get(SystemConfigKey.SearchFilterRuleGroups) or [] + if rule_groups and torrents: + torrents = self.filter_torrents( + rule_groups=rule_groups, + torrent_list=torrents, + mediainfo=mediainfo, + ) or [] + + contexts = [] + for torrent in torrents: + meta = MusicChain.to_meta(mediainfo) + meta.org_string = torrent.title + contexts.append( + Context( + torrent_info=torrent, + media_info=mediainfo, + meta_info=meta, + resource_source="search", + match_source=mediainfo.source or "title", + candidate_recognized=False, + media_info_is_target=True, + ) + ) + return self.__remove_duplicate(TorrentHelper.sort_torrents(contexts)) + + def _process_music( + self, + mediainfo: MusicInfo, + keyword: Optional[str] = None, + sites: Optional[List[int]] = None, + rule_groups: Optional[List[str]] = None, + filter_params: Optional[Dict[str, str]] = None, + ) -> List[Context]: + """按音乐元数据生成站点关键词并执行同步资源搜索。""" + keywords = [keyword] if keyword else MusicChain.build_site_keywords(mediainfo) + torrents: List[TorrentInfo] = [] + for index, search_word in enumerate(keywords or [mediainfo.title]): + if index: + time.sleep(random.randint(1, 10)) + torrents.extend( + self.__search_all_sites( + keyword=search_word, + mediainfo=mediainfo, + sites=sites, + mtype=MediaType.MUSIC, + ) or [] + ) + if torrents and not settings.SEARCH_MULTIPLE_NAME: + break + return self._build_music_contexts( + torrents=torrents, + mediainfo=mediainfo, + rule_groups=rule_groups, + filter_params=filter_params, + ) + + async def _async_process_music( + self, + mediainfo: MusicInfo, + keyword: Optional[str] = None, + sites: Optional[List[int]] = None, + rule_groups: Optional[List[str]] = None, + filter_params: Optional[Dict[str, str]] = None, + ) -> List[Context]: + """按音乐元数据生成站点关键词并执行异步资源搜索。""" + keywords = [keyword] if keyword else MusicChain.build_site_keywords(mediainfo) + torrents: List[TorrentInfo] = [] + for index, search_word in enumerate(keywords or [mediainfo.title]): + if index: + await asyncio.sleep(random.randint(1, 10)) + torrents.extend( + await self.__async_search_all_sites( + keyword=search_word, + mediainfo=mediainfo, + sites=sites, + mtype=MediaType.MUSIC, + ) or [] + ) + if torrents and not settings.SEARCH_MULTIPLE_NAME: + break + return await run_in_threadpool( + self._build_music_contexts, + torrents=torrents, + mediainfo=mediainfo, + rule_groups=rule_groups, + filter_params=filter_params, + ) + def process(self, mediainfo: MediaInfo, keyword: Optional[str] = None, no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None, @@ -1327,6 +1497,15 @@ class SearchChain(ChainBase): :param filter_params: 过滤参数 """ + if mediainfo.type == MediaType.MUSIC: + return self._process_music( + mediainfo=mediainfo, + keyword=keyword, + sites=sites, + rule_groups=rule_groups, + filter_params=filter_params, + ) + # 豆瓣标题处理 if not mediainfo.tmdb_id: meta = MetaInfo(title=mediainfo.title) @@ -1411,6 +1590,15 @@ class SearchChain(ChainBase): :param filter_params: 过滤参数 """ + if mediainfo.type == MediaType.MUSIC: + return await self._async_process_music( + mediainfo=mediainfo, + keyword=keyword, + sites=sites, + rule_groups=rule_groups, + filter_params=filter_params, + ) + # 豆瓣标题处理 if not mediainfo.tmdb_id: meta = MetaInfo(title=mediainfo.title) @@ -1484,6 +1672,33 @@ class SearchChain(ChainBase): 根据媒体信息渐进式搜索种子资源,先返回站点候选,再返回过滤匹配后的最终结果 """ + if mediainfo.type == MediaType.MUSIC: + contexts = await self._async_process_music( + mediainfo=mediainfo, + keyword=keyword, + sites=sites, + rule_groups=rule_groups, + filter_params=filter_params, + ) + items = [context.to_dict() for context in contexts] + yield { + "type": "replace", + "stage": "filtered", + "value": 100, + "text": f"过滤匹配完成,共 {len(contexts)} 个资源", + "items": items, + "total_items": len(contexts), + } + yield { + "type": "done", + "stage": "done", + "text": f"搜索完成,共 {len(contexts)} 个资源", + "items": items, + "total_items": len(contexts), + "contexts": contexts, + } + return + # 豆瓣标题处理 if not mediainfo.tmdb_id: meta = MetaInfo(title=mediainfo.title) @@ -1925,7 +2140,8 @@ class SearchChain(ChainBase): mediainfo: Optional[MediaInfo] = None, sites: List[int] = None, page: Optional[int] = 0, - area: Optional[str] = "title") -> Optional[List[TorrentInfo]]: + area: Optional[str] = "title", + mtype: Optional[MediaType] = None) -> Optional[List[TorrentInfo]]: """ 多线程搜索多个站点 :param mediainfo: 识别的媒体信息 @@ -1933,6 +2149,7 @@ class SearchChain(ChainBase): :param sites: 指定站点ID列表,如有则只搜索指定站点,否则搜索所有站点 :param page: 搜索页码 :param area: 搜索区域 title or imdbid + :param mtype: 未提供媒体详情时使用的站点资源分类 :reutrn: 资源列表 """ # 未开启的站点不搜索 @@ -1980,13 +2197,13 @@ class SearchChain(ChainBase): # 搜索IMDBID task = executor.submit(self.search_torrents, site=site, keyword=search_keyword, - mtype=mediainfo.type if mediainfo else None, + mtype=mediainfo.type if mediainfo else mtype, page=search_page) else: # 搜索标题 task = executor.submit(self.search_torrents, site=site, keyword=search_keyword, - mtype=mediainfo.type if mediainfo else None, + mtype=mediainfo.type if mediainfo else mtype, page=search_page) pending_tasks[task] = (site, page_index, search_page, search_keyword) @@ -2037,7 +2254,8 @@ class SearchChain(ChainBase): mediainfo: Optional[MediaInfo] = None, sites: List[int] = None, page: Optional[int] = 0, - area: Optional[str] = "title") -> Optional[List[TorrentInfo]]: + area: Optional[str] = "title", + mtype: Optional[MediaType] = None) -> Optional[List[TorrentInfo]]: """ 异步搜索多个站点 :param mediainfo: 识别的媒体信息 @@ -2045,6 +2263,7 @@ class SearchChain(ChainBase): :param sites: 指定站点ID列表,如有则只搜索指定站点,否则搜索所有站点 :param page: 搜索页码 :param area: 搜索区域 title or imdbid + :param mtype: 未提供媒体详情时使用的站点资源分类 :reutrn: 资源列表 """ # 未开启的站点不搜索 @@ -2088,12 +2307,12 @@ class SearchChain(ChainBase): # 搜索IMDBID return await self.async_search_torrents(site=site, keyword=mediainfo.imdb_id if mediainfo else None, - mtype=mediainfo.type if mediainfo else None, + mtype=mediainfo.type if mediainfo else mtype, page=search_page) # 搜索标题 return await self.async_search_torrents(site=site, keyword=keyword, - mtype=mediainfo.type if mediainfo else None, + mtype=mediainfo.type if mediainfo else mtype, page=search_page) pending_tasks = {} @@ -2161,7 +2380,8 @@ class SearchChain(ChainBase): mediainfo: Optional[MediaInfo] = None, sites: List[int] = None, page: Optional[int] = 0, - area: Optional[str] = "title") -> AsyncIterator[Dict[str, Any]]: + area: Optional[str] = "title", + mtype: Optional[MediaType] = None) -> AsyncIterator[Dict[str, Any]]: """ 异步搜索多个站点,按站点完成顺序渐进式返回结果 :param mediainfo: 识别的媒体信息 @@ -2169,6 +2389,7 @@ class SearchChain(ChainBase): :param sites: 指定站点ID列表,如有则只搜索指定站点,否则搜索所有站点 :param page: 搜索页码 :param area: 搜索区域 title or imdbid + :param mtype: 未提供媒体详情时使用的站点资源分类 """ indexer_sites = [] @@ -2219,12 +2440,12 @@ class SearchChain(ChainBase): if area == "imdbid": site_result = await self.async_search_torrents(site=site, keyword=mediainfo.imdb_id if mediainfo else None, - mtype=mediainfo.type if mediainfo else None, + mtype=mediainfo.type if mediainfo else mtype, page=search_page) else: site_result = await self.async_search_torrents(site=site, keyword=keyword, - mtype=mediainfo.type if mediainfo else None, + mtype=mediainfo.type if mediainfo else mtype, page=search_page) return site_result or [] diff --git a/app/chain/subscribe.py b/app/chain/subscribe.py index d9de8b3c0..efc370600 100644 --- a/app/chain/subscribe.py +++ b/app/chain/subscribe.py @@ -12,6 +12,7 @@ from app.chain import ChainBase from app.chain.download import DownloadChain from app.chain.media import MediaChain from app.chain.mediaserver import MediaServerChain +from app.chain.music import MusicChain from app.chain.search import SearchChain from app.chain.tmdb import TmdbChain from app.chain.torrents import TorrentsChain @@ -21,6 +22,7 @@ from app.core.event import eventmanager, Event from app.core.meta import MetaBase from app.core.meta.words import WordsMatcher from app.core.metainfo import MetaInfo +from app.core.music import MusicInfo, MusicMeta from app.db.downloadhistory_oper import DownloadHistoryOper from app.db.models.subscribe import Subscribe from app.db.site_oper import SiteOper @@ -53,10 +55,17 @@ from app.utils.media import ( subscribe_interaction_manager = SlashInteractionManager() -def build_subscribe_meta(subscribe: Subscribe) -> MetaBase: +def build_subscribe_meta(subscribe: Subscribe) -> Union[MetaBase, MusicMeta]: """ - 按订阅对象构造主程序链路共用的 MetaInfo。 + 按订阅对象构造主程序链路共用的媒体元数据。 """ + if subscribe.type == MediaType.MUSIC.value: + return MusicMeta( + title=subscribe.name, + year=subscribe.year, + media_source=subscribe.media_source, + media_id=str(subscribe.media_id) if subscribe.media_id is not None else None, + ) meta = MetaInfo(subscribe.name) meta.year = subscribe.year meta.begin_season = subscribe.season @@ -842,7 +851,7 @@ class SubscribeChain(ChainBase): :param key: 配置键 :return: 配置值 """ - return { + defaults = { 'quality': self.__get_default_subscribe_config(mtype, "quality") if not kwargs.get( "quality") else kwargs.get("quality"), 'resolution': self.__get_default_subscribe_config(mtype, "resolution") if not kwargs.get( @@ -868,6 +877,14 @@ class SubscribeChain(ChainBase): 'filter_groups': self.__get_default_subscribe_config(mtype, "filter_groups") if not kwargs.get( "filter_groups") else kwargs.get("filter_groups") } + if mtype == MediaType.MUSIC: + # 音乐订阅当前只负责首次获取,不复用影视洗版和 IMDB 搜索语义。 + defaults.update({ + "best_version": 0, + "best_version_full": 0, + "search_imdbid": 0, + }) + return defaults def add(self, title: str, year: str, mtype: MediaType = None, @@ -894,7 +911,7 @@ class SubscribeChain(ChainBase): logger.info(f'开始添加订阅,标题:{title} ...') mediainfo = None - metainfo = MetaInfo(title) + metainfo = MusicChain.parse_query(title) if mtype == MediaType.MUSIC else MetaInfo(title) if year: metainfo.year = year if mtype: @@ -914,7 +931,16 @@ class SubscribeChain(ChainBase): ) if resolved_source and resolved_media_id: media_source, media_id = resolved_source, resolved_media_id - if any((media_id, tmdbid, doubanid, bangumiid, anilistid)): + if mtype == MediaType.MUSIC: + if media_source and media_id: + mediainfo = MusicChain().recognize( + source=media_source, + media_id=str(media_id), + ) + if not mediainfo: + music_candidates = MusicChain().search(title, limit=1) + mediainfo = music_candidates[0] if music_candidates else None + elif any((media_id, tmdbid, doubanid, bangumiid, anilistid)): mediainfo = self.recognize_media( meta=metainfo, mtype=mtype, @@ -930,14 +956,14 @@ class SubscribeChain(ChainBase): elif mediaid: mediainfo = self.__get_event_media(mediaid, metainfo) - if mediainfo and mediainfo.source != "themoviedb": + if mtype != MediaType.MUSIC and mediainfo and mediainfo.source != "themoviedb": meta = MetaInfo(mediainfo.title) mediainfo.title = meta.name if season is None: season = meta.begin_season # 明确来源时只允许在同一来源内按名称兜底,不能切换主识别源。 - if not mediainfo: + if not mediainfo and mtype != MediaType.MUSIC: mediainfo = MediaChain().recognize_by_meta( metainfo, source=media_source, @@ -994,13 +1020,14 @@ class SubscribeChain(ChainBase): season = None # 更新媒体图片 - self.obtain_images(mediainfo=mediainfo) + if mediainfo.type != MediaType.MUSIC: + self.obtain_images(mediainfo=mediainfo) # 合并信息 - if doubanid: + if doubanid and mediainfo.type != MediaType.MUSIC: mediainfo.douban_id = doubanid - if bangumiid: + if bangumiid and mediainfo.type != MediaType.MUSIC: mediainfo.bangumi_id = bangumiid - if anilistid: + if anilistid and mediainfo.type != MediaType.MUSIC: mediainfo.anilist_id = anilistid media_source, media_id = resolve_media_identity( @@ -1029,6 +1056,8 @@ class SubscribeChain(ChainBase): elif message: if mediainfo.type == MediaType.TV: link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub') + elif mediainfo.type == MediaType.MUSIC: + link = settings.MP_DOMAIN('#/subscribe/music?tab=mysub') else: link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub') # 订阅成功按规则发送消息 @@ -1100,7 +1129,7 @@ class SubscribeChain(ChainBase): logger.info(f'开始添加订阅,标题:{title} ...') mediainfo = None - metainfo = MetaInfo(title) + metainfo = MusicChain.parse_query(title) if mtype == MediaType.MUSIC else MetaInfo(title) if year: metainfo.year = year if mtype: @@ -1120,7 +1149,16 @@ class SubscribeChain(ChainBase): ) if resolved_source and resolved_media_id: media_source, media_id = resolved_source, resolved_media_id - if any((media_id, tmdbid, doubanid, bangumiid, anilistid)): + if mtype == MediaType.MUSIC: + if media_source and media_id: + mediainfo = await MusicChain().async_recognize( + source=media_source, + media_id=str(media_id), + ) + if not mediainfo: + music_candidates = await MusicChain().async_search(title, limit=1) + mediainfo = music_candidates[0] if music_candidates else None + elif any((media_id, tmdbid, doubanid, bangumiid, anilistid)): mediainfo = await self.async_recognize_media( meta=metainfo, mtype=mtype, @@ -1136,14 +1174,14 @@ class SubscribeChain(ChainBase): elif mediaid: mediainfo = await self.__async_get_event_meida(mediaid, metainfo) - if mediainfo and mediainfo.source != "themoviedb": + if mtype != MediaType.MUSIC and mediainfo and mediainfo.source != "themoviedb": meta = MetaInfo(mediainfo.title) mediainfo.title = meta.name if season is None: season = meta.begin_season # 明确来源时只允许在同一来源内按名称兜底,不能切换主识别源。 - if not mediainfo: + if not mediainfo and mtype != MediaType.MUSIC: mediainfo = await MediaChain().async_recognize_by_meta( metainfo, source=media_source, @@ -1200,13 +1238,14 @@ class SubscribeChain(ChainBase): season = None # 更新媒体图片 - await self.async_obtain_images(mediainfo=mediainfo) + if mediainfo.type != MediaType.MUSIC: + await self.async_obtain_images(mediainfo=mediainfo) # 合并信息 - if doubanid: + if doubanid and mediainfo.type != MediaType.MUSIC: mediainfo.douban_id = doubanid - if bangumiid: + if bangumiid and mediainfo.type != MediaType.MUSIC: mediainfo.bangumi_id = bangumiid - if anilistid: + if anilistid and mediainfo.type != MediaType.MUSIC: mediainfo.anilist_id = anilistid media_source, media_id = resolve_media_identity( @@ -1235,6 +1274,8 @@ class SubscribeChain(ChainBase): elif message: if mediainfo.type == MediaType.TV: link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub') + elif mediainfo.type == MediaType.MUSIC: + link = settings.MP_DOMAIN('#/subscribe/music?tab=mysub') else: link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub') # 订阅成功按规则发送消息 @@ -1300,6 +1341,105 @@ class SubscribeChain(ChainBase): return True return False + @staticmethod + def _recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]: + """按订阅身份恢复音乐目标,缺少身份时按标题查询首个候选。""" + musicchain = MusicChain() + if subscribe.media_source and subscribe.media_id: + mediainfo = musicchain.recognize( + source=subscribe.media_source, + media_id=str(subscribe.media_id), + ) + if mediainfo: + return mediainfo + candidates = musicchain.search(subscribe.name, limit=1) + return candidates[0] if candidates else None + + @staticmethod + async def _async_recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]: + """异步按订阅身份恢复音乐目标,缺少身份时按标题查询首个候选。""" + musicchain = MusicChain() + if subscribe.media_source and subscribe.media_id: + mediainfo = await musicchain.async_recognize( + source=subscribe.media_source, + media_id=str(subscribe.media_id), + ) + if mediainfo: + return mediainfo + candidates = await musicchain.async_search(subscribe.name, limit=1) + return candidates[0] if candidates else None + + def _search_music_subscribe(self, subscribe: Subscribe) -> None: + """复用站点标题搜索、订阅过滤和批量下载完成单个音乐订阅。""" + mediainfo = self._recognize_music_subscribe(subscribe) + if not mediainfo: + logger.warning( + f"未识别到音乐订阅目标:{subscribe.name}," + f"媒体源:{subscribe.media_source},媒体ID:{subscribe.media_id}" + ) + return + + sites = self.get_sub_sites(subscribe) + rule_groups = subscribe.filter_groups \ + or SystemConfigOper().get(SystemConfigKey.SubscribeFilterRuleGroups) or [] + keywords = [subscribe.keyword] if subscribe.keyword else MusicChain.build_site_keywords(mediainfo) + if not keywords: + keywords = [subscribe.name] + + searchchain = SearchChain() + contexts: List[Context] = [] + for keyword in keywords: + contexts = searchchain.search_by_title( + title=keyword, + sites=sites, + mtype=MediaType.MUSIC, + rule_groups=rule_groups, + ) + contexts = [ + context + for context in contexts + if context.torrent_info + and context.torrent_info.category in (MediaType.MUSIC, MediaType.MUSIC.value) + and TorrentHelper().filter_torrent( + context.torrent_info, + self.get_params(subscribe), + ) + ] + if contexts: + break + + if not contexts: + logger.warning(f"音乐订阅 {subscribe.keyword or subscribe.name} 未搜索到符合条件的资源") + return + + for context in contexts: + meta = MusicChain.to_meta(mediainfo) + meta.org_string = context.torrent_info.title + context.meta_info = meta + context.media_info = mediainfo + context.match_source = mediainfo.source or "title" + context.candidate_recognized = False + context.media_info_is_target = True + if subscribe.media_category: + context.media_info.category = subscribe.media_category + + downloads, _ = DownloadChain().batch_download( + contexts=contexts, + username=subscribe.username, + save_path=subscribe.save_path, + downloader=subscribe.downloader, + source=self.get_subscribe_source_keyword(subscribe), + custom_words=subscribe.custom_words, + ) + current_subscribe = SubscribeOper().get(subscribe.id) + if current_subscribe: + self.finish_subscribe_or_not( + subscribe=current_subscribe, + meta=MusicChain.to_meta(mediainfo), + mediainfo=mediainfo, + downloads=downloads, + ) + def search( self, sid: Optional[int] = None, @@ -1375,6 +1515,9 @@ class SubscribeChain(ChainBase): try: search_attempted = True logger.info(f'开始搜索订阅,标题:{subscribe.name} ...') + if subscribe.type == MediaType.MUSIC.value: + self._search_music_subscribe(subscribe) + continue try: meta = build_subscribe_meta(subscribe) except ValueError: @@ -1603,7 +1746,7 @@ class SubscribeChain(ChainBase): scene="download", ) if ((no_lefts and meta.type == MediaType.TV) - or (downloads and meta.type == MediaType.MOVIE) + or (downloads and meta.type in (MediaType.MOVIE, MediaType.MUSIC)) or force): self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo) else: @@ -1816,6 +1959,10 @@ class SubscribeChain(ChainBase): }, ) logger.info(f'开始匹配订阅,标题:{subscribe.name} ...') + if subscribe.type == MediaType.MUSIC.value: + # 音乐不参与影视预识别缓存,直接复用音乐订阅搜索链处理。 + self._search_music_subscribe(subscribe) + continue mediakey = _subscribe_media_key(subscribe) try: meta = build_subscribe_meta(subscribe) @@ -2145,19 +2292,23 @@ class SubscribeChain(ChainBase): logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}') continue # 识别媒体信息 - mediainfo: MediaInfo = self.recognize_media( - meta=meta, - mtype=meta.type, - **_subscribe_recognize_kwargs(subscribe), - episode_group=subscribe.episode_group, - cache=False, - ) + if meta.type == MediaType.MUSIC: + mediainfo = self._recognize_music_subscribe(subscribe) + else: + mediainfo: MediaInfo = self.recognize_media( + meta=meta, + mtype=meta.type, + **_subscribe_recognize_kwargs(subscribe), + episode_group=subscribe.episode_group, + cache=False, + ) if not mediainfo: logger.warn( f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}') continue # 对于电视剧,获取当前季的总集数 - episodes = mediainfo.seasons.get(subscribe.season) or [] + episodes = (mediainfo.seasons.get(subscribe.season) or []) \ + if meta.type == MediaType.TV else [] progress_update = {} if subscribe.type == MediaType.TV.value and not subscribe.manual_total_episode and len(episodes): current_total_episode = len(episodes) @@ -2400,12 +2551,15 @@ class SubscribeChain(ChainBase): logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}') continue # 先按订阅的主媒体身份预热对应数据源,再对 TMDB 额外预热分集接口。 - mediainfo: MediaInfo = await self.async_recognize_media( - mtype=mtype, - **_subscribe_recognize_kwargs(subscribe), - episode_group=subscribe.episode_group, - cache=False, - ) + if mtype == MediaType.MUSIC: + mediainfo = await self._async_recognize_music_subscribe(subscribe) + else: + mediainfo: MediaInfo = await self.async_recognize_media( + mtype=mtype, + **_subscribe_recognize_kwargs(subscribe), + episode_group=subscribe.episode_group, + cache=False, + ) if not mediainfo: logger.warn( f'未识别到媒体信息,标题:{subscribe.name},' @@ -2471,6 +2625,9 @@ class SubscribeChain(ChainBase): elif mediainfo.type == MediaType.MOVIE: # 电影只有一个条目,设置为 [1] items = [1] + elif mediainfo.type == MediaType.MUSIC: + # 音乐订阅和电影一样,一次成功下载即记录单项完成事实。 + items = [1] if not items: continue # 合并已下载的集数或电影项(去重) @@ -2504,8 +2661,8 @@ class SubscribeChain(ChainBase): if subscribe.type == MediaType.TV.value: logger.info(f'订阅 {subscribe.name} 第{subscribe.season}季 已下载集数:{note}') return note - # 针对 Movie 类型,直接返回已下载的电影 - if subscribe.type == MediaType.MOVIE.value: + # 针对 Movie/Music 类型,直接返回已下载的单项内容 + if subscribe.type in (MediaType.MOVIE.value, MediaType.MUSIC.value): logger.info(f'订阅 {subscribe.name} 已下载内容:{note}') return note return [] @@ -2836,6 +2993,8 @@ class SubscribeChain(ChainBase): # 发送通知 if mediainfo.type == MediaType.TV: link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub') + elif mediainfo.type == MediaType.MUSIC: + link = settings.MP_DOMAIN('#/subscribe/music?tab=mysub') else: link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub') # 完成订阅按规则发送消息 @@ -3754,6 +3913,9 @@ class SubscribeChain(ChainBase): if mtype == MediaType.MOVIE: default_subscribe_key = SystemConfigKey.DefaultMovieSubscribeConfig.value + if not default_subscribe_key: + return None + # 默认订阅规则 if hasattr(settings, default_subscribe_key): value = getattr(settings, default_subscribe_key) diff --git a/app/chain/torrents.py b/app/chain/torrents.py index 155318549..26ce90a38 100644 --- a/app/chain/torrents.py +++ b/app/chain/torrents.py @@ -7,8 +7,10 @@ from app.helper.sites import SitesHelper # noqa from app.chain import ChainBase from app.chain.media import MediaChain +from app.chain.music import MusicChain from app.core.config import settings, global_vars from app.core.context import TorrentInfo, Context, MediaInfo +from app.core.music import MusicInfo from app.core.metainfo import MetaInfo from app.db.site_oper import SiteOper from app.db.systemconfig_oper import SystemConfigOper @@ -400,36 +402,44 @@ class TorrentsChain(ChainBase): logger.info(f'异步种子缓存数据清理完成') def browse(self, domain: str, keyword: Optional[str] = None, cat: Optional[str] = None, - page: Optional[int] = 0) -> List[TorrentInfo]: + page: Optional[int] = 0, + mtype: Optional[MediaType] = None) -> List[TorrentInfo]: """ 浏览站点首页内容,返回种子清单,TTL缓存5分钟 :param domain: 站点域名 :param keyword: 搜索标题 :param cat: 搜索分类 :param page: 页码 + :param mtype: 媒体类型 """ logger.info(f'开始获取站点 {domain} 最新种子 ...') site = SitesHelper().get_indexer(domain) if not site: logger.error(f'站点 {domain} 不存在!') return [] - return self.refresh_torrents(site=site, keyword=keyword, cat=cat, page=page) + return self.refresh_torrents( + site=site, keyword=keyword, cat=cat, page=page, mtype=mtype + ) async def async_browse(self, domain: str, keyword: Optional[str] = None, cat: Optional[str] = None, - page: Optional[int] = 0) -> List[TorrentInfo]: + page: Optional[int] = 0, + mtype: Optional[MediaType] = None) -> List[TorrentInfo]: """ 异步浏览站点首页内容,返回种子清单,TTL缓存5分钟 :param domain: 站点域名 :param keyword: 搜索标题 :param cat: 搜索分类 :param page: 页码 + :param mtype: 媒体类型 """ logger.info(f'开始获取站点 {domain} 最新种子 ...') site = await SitesHelper().async_get_indexer(domain) if not site: logger.error(f'站点 {domain} 不存在!') return [] - return await self.async_refresh_torrents(site=site, keyword=keyword, cat=cat, page=page) + return await self.async_refresh_torrents( + site=site, keyword=keyword, cat=cat, page=page, mtype=mtype + ) def rss(self, domain: str) -> List[TorrentInfo]: """ @@ -593,29 +603,37 @@ class TorrentsChain(ChainBase): logger.warn(f"缺少种子链接,忽略处理: {torrent.title}") continue logger.info(f'处理资源:{torrent.title} ...') - # 识别 - meta = MetaInfo(title=torrent.title, subtitle=torrent.description) - if torrent.title != meta.org_string: - logger.info(f'种子名称应用识别词后发生改变:{torrent.title} => {meta.org_string}') - # 使用站点种子分类,校正类型识别 - if meta.type != MediaType.TV \ - and torrent.category == MediaType.TV.value: - meta.type = MediaType.TV - # 识别媒体信息 - mediainfo: MediaInfo = MediaChain().recognize_by_meta( - meta, - obtain_images=False, - ) - if not mediainfo: - logger.warn(f'{torrent.title} 未识别到媒体信息') - # 存储空的媒体信息 - mediainfo = MediaInfo() - # 清理多余数据,减少内存占用 - mediainfo.clear() - candidate_recognized = bool( - mediainfo and all(resolve_media_identity(media=mediainfo)) - ) - match_source = self._get_media_id_match_source(mediainfo) + if torrent.category == MediaType.MUSIC.value: + meta = MusicChain.parse_query(torrent.title) + mediainfo = MusicInfo( + title=meta.title, + artists=list(meta.artists), + album=meta.album, + year=meta.year, + names=[meta.title] if meta.title else [], + ) + candidate_recognized = False + match_source = "unknown" + else: + meta = MetaInfo(title=torrent.title, subtitle=torrent.description) + if torrent.title != meta.org_string: + logger.info(f'种子名称应用识别词后发生改变:{torrent.title} => {meta.org_string}') + # 使用站点种子分类,校正类型识别 + if meta.type != MediaType.TV \ + and torrent.category == MediaType.TV.value: + meta.type = MediaType.TV + mediainfo = MediaChain().recognize_by_meta( + meta, + obtain_images=False, + ) + if not mediainfo: + logger.warn(f'{torrent.title} 未识别到媒体信息') + mediainfo = MediaInfo() + mediainfo.clear() + candidate_recognized = bool( + mediainfo and all(resolve_media_identity(media=mediainfo)) + ) + match_source = self._get_media_id_match_source(mediainfo) # 上下文 context = Context( meta_info=meta, @@ -698,13 +716,13 @@ class TorrentsChain(ChainBase): """ 返回候选自身识别命中的明确媒体 ID 类型。 """ - if mediainfo and mediainfo.tmdb_id: + if mediainfo and getattr(mediainfo, "tmdb_id", None): return "tmdbid" - if mediainfo and mediainfo.douban_id: + if mediainfo and getattr(mediainfo, "douban_id", None): return "doubanid" - if mediainfo and mediainfo.bangumi_id: + if mediainfo and getattr(mediainfo, "bangumi_id", None): return "bangumiid" - if mediainfo and mediainfo.anilist_id: + if mediainfo and getattr(mediainfo, "anilist_id", None): return "anilistid" if mediainfo and all(resolve_media_identity(media=mediainfo)): return "plugin" diff --git a/app/chain/transfer.py b/app/chain/transfer.py index f5b514fbc..c4598b863 100755 --- a/app/chain/transfer.py +++ b/app/chain/transfer.py @@ -13,11 +13,13 @@ from app import schemas from app.agent import ReplyMode, prompt_manager, agent_manager from app.chain import ChainBase from app.chain.media import MediaChain +from app.chain.music import MusicChain from app.chain.storage import StorageChain from app.chain.subscribe import SubscribeChain from app.chain.tmdb import TmdbChain from app.core.config import settings, global_vars from app.core.context import MediaInfo +from app.core.music import MusicInfo, MusicMeta from app.core.event import eventmanager from app.core.meta import MetaBase from app.core.metainfo import MetaInfoPath @@ -27,6 +29,7 @@ from app.db.models.transferhistory import TransferHistory from app.db.systemconfig_oper import SystemConfigOper from app.db.transferhistory_oper import TransferHistoryOper from app.helper.directory import DirectoryHelper +from app.helper.audio import AudioMetadataHelper from app.helper.format import EpisodeFormatRuleHelper, FormatParser from app.helper.progress import ProgressHelper from app.log import logger @@ -184,6 +187,8 @@ class JobManager: # 有媒体信息 mediainfo = deepcopy(task.mediainfo) mediainfo.clear() + if isinstance(mediainfo, MusicInfo): + return schemas.MusicInfo(**mediainfo.to_dict()) return schemas.MediaInfo(**mediainfo.to_dict()) else: # 没有媒体信息 @@ -200,6 +205,8 @@ class JobManager: """ 获取元数据 """ + if isinstance(task.meta, MusicMeta): + return schemas.MusicMeta(**task.meta.to_dict()) return schemas.MetaInfo(**task.meta.to_dict()) def add_task(self, task: TransferTask, state: Optional[str] = "waiting") -> bool: @@ -1009,7 +1016,11 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): return False return True if f".{fileitem.extension.lower()}" in self._audio_exts else False - def __is_media_file(self, fileitem: FileItem) -> bool: + def __is_media_file( + self, + fileitem: FileItem, + mtype: Optional[MediaType] = None, + ) -> bool: """ 判断是否为主要媒体文件 """ @@ -1018,7 +1029,107 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): return StorageChain().is_bluray_folder(fileitem) if not fileitem.extension: return False - return True if f".{fileitem.extension.lower()}" in self._media_exts else False + extension = f".{fileitem.extension.lower()}" + if extension in self._media_exts: + return True + return mtype == MediaType.MUSIC and extension in self._audio_exts + + def _is_primary_media_file( + self, + fileitem: FileItem, + mediainfo: Optional[MediaInfo | MusicInfo], + ) -> bool: + """判断文件在当前媒体上下文中是否属于主要媒体文件。""" + return self.__is_media_file( + fileitem, + getattr(mediainfo, "type", None), + ) + + @staticmethod + def _music_info_from_meta(meta: MusicMeta) -> MusicInfo: + """将音频文件标签解析结果转换为可整理的最小音乐信息。""" + return MusicInfo( + source=meta.media_source, + media_id=meta.media_id, + title=meta.title, + artists=list(meta.artists), + album=meta.album, + album_artist=meta.album_artist, + year=meta.year, + disc_number=meta.disc_number, + track_number=meta.track_number, + total_tracks=meta.total_tracks, + duration=meta.duration, + isrc=meta.isrc, + version=meta.version, + names=[name for name in (meta.title, meta.album) if name], + ) + + @classmethod + def _restore_music_download_context( + cls, + download_history: Optional[DownloadHistory], + file_path: Path, + ) -> tuple[Optional[MusicMeta], Optional[MusicInfo]]: + """从下载历史恢复音乐上下文,并用当前音频标签覆盖曲目级字段。""" + note = getattr(download_history, "note", None) + music_note = note.get("music") if isinstance(note, dict) else None + if not isinstance(music_note, dict) or music_note.get("version") != 1: + return None, None + try: + saved_meta = MusicMeta.from_dict(music_note.get("meta") or {}) + saved_info = MusicInfo.from_dict(music_note.get("media") or {}) + except (TypeError, ValueError): + return None, None + + file_tags = AudioMetadataHelper.read(file_path) if file_path.exists() else None + file_meta = deepcopy(saved_meta) + file_meta.org_string = file_path.name + if file_tags: + has_tag_identity = bool( + file_tags.artists + or file_tags.album + or file_tags.track_number + or file_tags.isrc + ) + for field_name in ( + "artists", + "album", + "album_artist", + "year", + "disc_number", + "track_number", + "total_discs", + "total_tracks", + "version", + "isrc", + ): + if getattr(file_tags, field_name, None): + setattr(file_meta, field_name, deepcopy(getattr(file_tags, field_name))) + if has_tag_identity and file_tags.title: + file_meta.title = file_tags.title + for field_name in ( + "audio_format", + "bit_depth", + "sample_rate", + "bitrate", + "duration", + ): + if getattr(file_tags, field_name, None): + setattr(file_meta, field_name, getattr(file_tags, field_name)) + elif not file_meta.audio_format: + file_meta.audio_format = file_path.suffix.lstrip(".").upper() or None + file_meta.media_source = saved_info.source or saved_meta.media_source + file_meta.media_id = saved_info.media_id or saved_meta.media_id + + file_info = cls._music_info_from_meta(file_meta) + file_info.source = saved_info.source + file_info.media_id = saved_info.media_id + file_info.cover_url = saved_info.cover_url + file_info.lyrics = saved_info.lyrics + file_info.category = saved_info.category + file_info.detail_link = saved_info.detail_link + return file_meta, file_info def __is_allowed_file(self, fileitem: FileItem) -> bool: """ @@ -1148,7 +1259,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): ) # 整理失败事件 - if self.__is_media_file(task.fileitem): + if self._is_primary_media_file(task.fileitem, task.mediainfo): # 主要媒体文件整理失败事件 self.eventmanager.send_event( EventType.TransferFailed, @@ -1262,7 +1373,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): ) # task整理完成事件 - if self.__is_media_file(task.fileitem): + if self._is_primary_media_file(task.fileitem, task.mediainfo): # 主要媒体文件整理完成事件 self.eventmanager.send_event( EventType.TransferComplete, @@ -1480,7 +1591,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): not task or not transferinfo or not transferinfo.need_scrape - or not self.__is_media_file(task.fileitem) + or not self._is_primary_media_file(task.fileitem, task.mediainfo) + or task.mediainfo.type == MediaType.MUSIC ): return @@ -1543,7 +1655,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): or not task.transfer_batch_id or not transferinfo or not transferinfo.need_scrape - or not self.__is_media_file(task.fileitem) + or not self._is_primary_media_file(task.fileitem, task.mediainfo) + or task.mediainfo.type == MediaType.MUSIC ): return @@ -2935,6 +3048,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): fileitem: FileItem, meta: MetaBase = None, mediainfo: MediaInfo = None, + mtype: Optional[MediaType] = None, media_source: Optional[str] = None, target_directory: TransferDirectoryConf = None, target_storage: Optional[str] = None, @@ -2962,6 +3076,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): :param fileitem: 文件项 :param meta: 元数据 :param mediainfo: 媒体信息 + :param mtype: 未提供媒体信息时使用的媒体类型提示 :param media_source: 请求级识别与刮削数据源 :param target_directory: 目标目录配置 :param target_storage: 目标存储器 @@ -3037,9 +3152,12 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): """ 从文件路径识别媒体信息,用于判断附加文件是否属于当前主视频。 """ - path_meta = MetaInfoPath( - source_path, custom_words=custom_word_list - ) + if mtype == MediaType.MUSIC and source_path.suffix.lower() in self._audio_exts: + path_meta = AudioMetadataHelper.read(source_path) + else: + path_meta = MetaInfoPath( + source_path, custom_words=custom_word_list + ) if not path_meta: return None return _apply_meta_overrides(path_meta, source_path) @@ -3462,12 +3580,19 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): download_hash=download_hash, ) + history_music_meta, history_music_info = self._restore_music_download_context( + download_history=download_history, + file_path=file_path, + ) + if not meta: # 文件元数据(优先使用订阅识别词) inherited_meta = inherited_meta_map.get( self.__get_file_key(file_item) ) - if inherited_meta: + if history_music_meta: + file_meta = history_music_meta + elif inherited_meta: file_meta = deepcopy(inherited_meta) else: file_meta = _build_file_meta( @@ -3492,7 +3617,9 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): _download_hash = download_hash # 自动整理预载的媒体信息来自整条下载历史;电影合集内文件年份冲突时逐文件识别。 - task_mediainfo = mediainfo + task_mediainfo = mediainfo or history_music_info + if not task_mediainfo and isinstance(file_meta, MusicMeta): + task_mediainfo = self._music_info_from_meta(file_meta) if ( not manual and self._is_movie_year_conflict(file_meta, task_mediainfo) @@ -3931,16 +4058,22 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): if tmdbid or doubanid or bangumiid or anilistid or media_id: # 有输入媒体ID时单个识别 # 识别媒体信息 - mediainfo: MediaInfo = MediaChain().recognize_media( - tmdbid=tmdbid, - doubanid=doubanid, - bangumiid=bangumiid, - anilistid=anilistid, - source=media_source, - mediaid=media_id, - mtype=mtype, - episode_group=episode_group, - ) + if mtype == MediaType.MUSIC and media_source and media_id: + mediainfo = MusicChain().recognize( + source=media_source, + media_id=media_id, + ) + else: + mediainfo = MediaChain().recognize_media( + tmdbid=tmdbid, + doubanid=doubanid, + bangumiid=bangumiid, + anilistid=anilistid, + source=media_source, + mediaid=media_id, + mtype=mtype, + episode_group=episode_group, + ) if not mediainfo: return ( False, @@ -3949,10 +4082,10 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): f"type: {mtype.value if mtype else None}", ) else: - if media_source: + if media_source and not isinstance(mediainfo, MusicInfo): mediainfo.scrape_source = media_source - # 更新媒体图片 - self.obtain_images(mediainfo=mediainfo) + if not isinstance(mediainfo, MusicInfo): + self.obtain_images(mediainfo=mediainfo) # 开始整理 state, errmsg = self.do_transfer( @@ -3960,6 +4093,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): target_storage=target_storage, target_path=target_path, mediainfo=mediainfo, + mtype=mtype, media_source=media_source, transfer_type=transfer_type, season=season, @@ -3990,6 +4124,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton): target_storage=target_storage, target_path=target_path, media_source=media_source, + mtype=mtype, transfer_type=transfer_type, season=season, epformat=epformat, diff --git a/app/core/config.py b/app/core/config.py index b38c3e0eb..d869befc6 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -406,6 +406,13 @@ class ConfigModel(BaseModel): "/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}" "{{fileExt}}" ) + # 音乐重命名格式 + MUSIC_RENAME_FORMAT: str = ( + "{{album_artist or artist or 'Unknown Artist'}}" + "/{{album or 'Unknown Album'}}{% if year %} ({{year}}){% endif %}" + "{% if total_discs and total_discs > 1 %}/Disc {{disc_number or 1}}{% endif %}" + "/{% if track %}{{track}} - {% endif %}{{title}}{{fileExt}}" + ) # 重命名时支持的S0别名 RENAME_FORMAT_S0_NAMES: list = Field(default=["Specials", "SPs"]) # 为指定默认字幕添加.default后缀 @@ -901,7 +908,12 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel): """ 版本标识,用来区分重大版本,为空则为v1,不允许外部修改 """ - return "v2" + return "v3" + + @property + def RESOURCE_VERSION_FLAG(self) -> str: + """返回站点索引和认证资源使用的重大版本标识。""" + return "v3" @property def USER_AGENT(self) -> str: @@ -1137,14 +1149,15 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel): """ 获取指定类型的重命名格式 - :param media_type: MediaType.TV 或 MediaType.Movie + :param media_type: 电影、电视剧或音乐媒体类型 :return: 重命名格式 """ - rename_format = ( - self.TV_RENAME_FORMAT - if media_type == MediaType.TV - else self.MOVIE_RENAME_FORMAT - ) + if media_type == MediaType.TV: + rename_format = self.TV_RENAME_FORMAT + elif media_type == MediaType.MUSIC: + rename_format = self.MUSIC_RENAME_FORMAT + else: + rename_format = self.MOVIE_RENAME_FORMAT # 规范重命名格式 rename_format = rename_format.replace("\\", "/") rename_format = re.sub(r"/+", "/", rename_format) diff --git a/app/core/context.py b/app/core/context.py index 13419c2da..b099767af 100644 --- a/app/core/context.py +++ b/app/core/context.py @@ -1,11 +1,12 @@ import re from dataclasses import dataclass, field from datetime import datetime -from typing import List, Dict, Any, Tuple, Optional, Set +from typing import List, Dict, Any, Tuple, Optional, Set, Union from app.core.config import settings from app.core.meta import MetaBase from app.core.metainfo import MetaInfo +from app.core.music import MusicInfo, MusicMeta from app.schemas.types import MediaType from app.utils.string import StringUtils @@ -69,7 +70,7 @@ class TorrentInfo: labels: list = field(default_factory=list) # 种子优先级 pri_order: int = 0 - # 种子分类 电影/电视剧 + # 种子分类 电影/电视剧/音乐 category: str = None def __setattr__(self, name: str, value: Any): @@ -1141,9 +1142,9 @@ class Context: """ # 识别信息 - meta_info: MetaBase = None + meta_info: Optional[Union[MetaBase, MusicMeta]] = None # 媒体信息 - media_info: MediaInfo = None + media_info: Optional[Union[MediaInfo, MusicInfo]] = None # 种子信息 torrent_info: TorrentInfo = None # 媒体识别失败次数 diff --git a/app/core/module.py b/app/core/module.py index 8f64e984e..f0fa80e59 100644 --- a/app/core/module.py +++ b/app/core/module.py @@ -6,7 +6,7 @@ from app.core.event import eventmanager from app.helper.module import ModuleHelper from app.log import logger from app.schemas.types import EventType, ModuleType, DownloaderType, MediaServerType, MessageChannel, StorageSchema, \ - OtherModulesType + OtherModulesType, MediaRecognizeType from app.utils.object import ObjectUtils from app.utils.singleton import Singleton @@ -17,7 +17,14 @@ class ModuleManager(metaclass=Singleton): """ # 子模块类型集合 - SubType = Union[DownloaderType, MediaServerType, MessageChannel, StorageSchema, OtherModulesType] + SubType = Union[ + DownloaderType, + MediaServerType, + MessageChannel, + StorageSchema, + OtherModulesType, + MediaRecognizeType, + ] def __init__(self): # 模块列表 diff --git a/app/core/music.py b/app/core/music.py new file mode 100644 index 000000000..86eca56ff --- /dev/null +++ b/app/core/music.py @@ -0,0 +1,335 @@ +from dataclasses import asdict, dataclass, field, fields +from typing import Any, Self + +from app.schemas.types import MediaType + + +def _validate_music_type(value: object) -> None: + if value in {None, MediaType.MUSIC, MediaType.MUSIC.value, "music"}: + return + raise ValueError(f"不支持的音乐媒体类型:{value}") + + +def _string_list(value: object) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + return [value] if value else [] + if isinstance(value, (list, tuple, set)): + return [str(item) for item in value if str(item)] + return [str(value)] + + +def _optional_int(value: object) -> int | None: + if value in {None, ""}: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _init_values(model: type, data: dict[str, Any]) -> dict[str, Any]: + init_names = {item.name for item in fields(model) if item.init} + return {key: value for key, value in data.items() if key in init_names} + + +@dataclass +class MusicMeta: + """音乐名称及音频文件解析结果。""" + + type: MediaType = field(default=MediaType.MUSIC, init=False) + org_string: str | None = None + title: str | None = None + artists: list[str] = field(default_factory=list) + album: str | None = None + album_artist: str | None = None + year: int | None = None + disc_number: int | None = None + track_number: int | None = None + total_discs: int | None = None + total_tracks: int | None = None + version: str | None = None + audio_format: str | None = None + bit_depth: int | None = None + sample_rate: int | None = None + bitrate: int | None = None + duration: int | None = None + isrc: str | None = None + media_source: str | None = None + media_id: str | None = None + + @property + def name(self) -> str: + """返回搜索和展示使用的音乐名称。""" + return self.album or self.title or "" + + @property + def artist(self) -> str: + """返回兼容现有展示组件的艺术家文本。""" + return " / ".join(self.artists) + + @property + def season(self) -> None: + """音乐没有季信息,兼容下载与事件链的通用访问。""" + return None + + @property + def begin_season(self) -> None: + """音乐没有起始季,兼容整理作业分组。""" + return None + + @property + def end_season(self) -> None: + """音乐没有结束季,兼容整理元数据比较。""" + return None + + @property + def begin_episode(self) -> None: + """音乐没有起始集,兼容整理预览。""" + return None + + @property + def end_episode(self) -> None: + """音乐没有结束集,兼容整理预览。""" + return None + + @property + def episode(self) -> None: + """音乐没有集信息,兼容下载与历史记录的通用访问。""" + return None + + @property + def season_list(self) -> list[int]: + """音乐返回空季列表,避免通用下载链访问视频专属字段。""" + return [] + + @property + def episode_list(self) -> list[int]: + """音乐返回空集列表,避免通用下载链访问视频专属字段。""" + return [] + + @property + def season_episode(self) -> str: + """音乐没有季集展示文本。""" + return "" + + @property + def part(self) -> None: + """音乐不使用影视分段字段。""" + return None + + @property + def apply_words(self) -> list[str]: + """音乐当前不应用影视自定义识别词。""" + return [] + + @property + def resource_team(self) -> None: + """音乐当前不使用影视制作组字段。""" + return None + + @property + def customization(self) -> None: + """音乐当前不使用影视自定义占位符。""" + return None + + @property + def tmdbid(self) -> None: + """音乐不使用 TMDB ID。""" + return None + + @property + def doubanid(self) -> None: + """音乐不使用豆瓣 ID。""" + return None + + @property + def bangumiid(self) -> None: + """音乐不使用 Bangumi ID。""" + return None + + @property + def anilistid(self) -> None: + """音乐不使用 AniList ID。""" + return None + + def to_dict(self) -> dict[str, Any]: + """转换为可持久化和传输的字典。""" + payload = asdict(self) + payload["type"] = self.type.value + payload["artist"] = self.artist + return payload + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Self: + """从字典恢复音乐解析结果。""" + _validate_music_type(data.get("type")) + values = _init_values(cls, data) + values["artists"] = _string_list(values.get("artists") or data.get("artist")) + for key in ( + "year", + "disc_number", + "track_number", + "total_discs", + "total_tracks", + "bit_depth", + "sample_rate", + "bitrate", + "duration", + ): + values[key] = _optional_int(values.get(key)) + return cls(**values) + + +@dataclass +class MusicInfo: + """标准化音乐元数据信息。""" + + type: MediaType = field(default=MediaType.MUSIC, init=False) + source: str | None = None + media_id: str | None = None + title: str | None = None + artists: list[str] = field(default_factory=list) + album: str | None = None + album_artist: str | None = None + year: int | None = None + release_date: str | None = None + disc_number: int | None = None + track_number: int | None = None + total_tracks: int | None = None + duration: int | None = None + isrc: str | None = None + cover_url: str | None = None + lyrics: str | None = None + version: str | None = None + category: str = "" + names: list[str] = field(default_factory=list) + detail_link: str | None = None + listen_count: int | None = None + raw_data: dict[str, Any] = field(default_factory=dict) + + @property + def artist(self) -> str: + """返回兼容现有展示组件的艺术家文本。""" + return " / ".join(self.artists) + + @property + def tmdb_id(self) -> None: + """音乐不使用 TMDB ID,兼容现有下载历史字段。""" + return None + + @property + def imdb_id(self) -> None: + """音乐不使用 IMDB ID,兼容现有下载历史字段。""" + return None + + @property + def tvdb_id(self) -> None: + """音乐不使用 TVDB ID,兼容现有下载历史字段。""" + return None + + @property + def douban_id(self) -> None: + """音乐不使用豆瓣 ID,兼容现有下载历史字段。""" + return None + + @property + def bangumi_id(self) -> None: + """音乐不使用 Bangumi ID,兼容现有下载历史字段。""" + return None + + @property + def anilist_id(self) -> None: + """音乐不使用 AniList ID,兼容现有下载历史字段。""" + return None + + @property + def episode_group(self) -> None: + """音乐没有剧集组,兼容现有下载历史字段。""" + return None + + @property + def season(self) -> None: + """音乐没有季信息,兼容失败冷却和目录逻辑。""" + return None + + @property + def vote_average(self) -> float: + """音乐当前没有评分字段,兼容订阅统计与持久化。""" + return 0.0 + + @property + def overview(self) -> str: + """返回兼容订阅描述字段的音乐摘要。""" + parts = [self.artist, self.album, self.version] + return " · ".join(part for part in parts if part) + + @property + def title_year(self) -> str: + """返回包含年份的展示标题。""" + if not self.title: + return "" + return f"{self.title} ({self.year})" if self.year else self.title + + @property + def poster_path(self) -> str | None: + """返回兼容现有媒体卡片的封面地址。""" + return self.cover_url + + @property + def backdrop_path(self) -> str | None: + """返回兼容现有下载卡片的背景地址。""" + return self.cover_url + + def get_message_image(self, default: bool | None = None) -> str | None: + """返回通知消息使用的音乐封面。""" + return self.cover_url + + def get_poster_image(self, default: bool | None = None) -> str | None: + """返回海报位使用的音乐封面。""" + return self.cover_url + + def get_backdrop_image(self, default: bool = False) -> str | None: + """返回背景图位使用的音乐封面。""" + return self.cover_url + + def clear(self) -> None: + """清理不参与队列展示和持久化的上游原始响应。""" + self.raw_data.clear() + + def to_dict(self) -> dict[str, Any]: + """转换为兼容现有 Context 外层结构的字典。""" + payload = asdict(self) + payload.update( + { + "type": self.type.value, + "artist": self.artist, + "title_year": self.title_year, + "poster_path": self.poster_path, + "backdrop_path": self.backdrop_path, + "mediaid_prefix": self.source, + "overview": self.overview, + "vote_average": self.vote_average, + } + ) + return payload + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> Self: + """从字典恢复标准化音乐元数据。""" + _validate_music_type(data.get("type")) + values = _init_values(cls, data) + values["artists"] = _string_list(values.get("artists") or data.get("artist")) + values["names"] = _string_list(values.get("names")) + values["raw_data"] = dict(values.get("raw_data") or {}) + for key in ( + "year", + "disc_number", + "track_number", + "total_tracks", + "duration", + "listen_count", + ): + values[key] = _optional_int(values.get(key)) + return cls(**values) diff --git a/app/db/models/downloadhistory.py b/app/db/models/downloadhistory.py index 5e12922dc..6d4783b7f 100644 --- a/app/db/models/downloadhistory.py +++ b/app/db/models/downloadhistory.py @@ -21,7 +21,7 @@ class DownloadHistory(Base): id = get_id_column() # 保存路径 path = Column(String, nullable=False, index=True) - # 类型 电影/电视剧 + # 类型 电影/电视剧/音乐 type = Column(String, nullable=False) # 标题 title = Column(String, nullable=False) diff --git a/app/helper/audio.py b/app/helper/audio.py new file mode 100644 index 000000000..0de9cc225 --- /dev/null +++ b/app/helper/audio.py @@ -0,0 +1,92 @@ +from pathlib import Path +from typing import Any, Optional + +from mutagen import File as MutagenFile + +from app.core.music import MusicMeta +from app.log import logger + + +class AudioMetadataHelper: + """读取音频标签和技术参数并转换为标准 MusicMeta。""" + + @classmethod + def read(cls, path: Path) -> MusicMeta: + """读取本地音频文件标签;读取失败时返回基于文件名的最小元数据。""" + fallback = MusicMeta( + org_string=path.name, + title=path.stem, + audio_format=path.suffix.lstrip(".").upper() or None, + ) + try: + audio = MutagenFile(path, easy=True) + except Exception as err: + logger.warning(f"读取音频标签失败:{path} - {err}") + return fallback + if not audio: + return fallback + + tags = audio.tags or {} + track_number, total_tracks = cls._number_pair(cls._first(tags, "tracknumber")) + disc_number, total_discs = cls._number_pair(cls._first(tags, "discnumber")) + info = getattr(audio, "info", None) + return MusicMeta( + org_string=path.name, + title=cls._first(tags, "title") or path.stem, + artists=cls._values(tags, "artist"), + album=cls._first(tags, "album"), + album_artist=cls._first(tags, "albumartist"), + year=cls._year(cls._first(tags, "date") or cls._first(tags, "originaldate")), + disc_number=disc_number, + track_number=track_number, + total_discs=total_discs, + total_tracks=total_tracks, + version=cls._first(tags, "version") or cls._first(tags, "subtitle"), + audio_format=path.suffix.lstrip(".").upper() or None, + bit_depth=cls._optional_int(getattr(info, "bits_per_sample", None)), + sample_rate=cls._optional_int(getattr(info, "sample_rate", None)), + bitrate=cls._optional_int(getattr(info, "bitrate", None)), + duration=round(info.length) if info and getattr(info, "length", None) else None, + isrc=cls._first(tags, "isrc"), + ) + + @staticmethod + def _values(tags: Any, key: str) -> list[str]: + """从 Mutagen Easy 标签中提取非空字符串列表。""" + value = tags.get(key) if hasattr(tags, "get") else None + if value is None: + return [] + if isinstance(value, (list, tuple)): + return [str(item).strip() for item in value if str(item).strip()] + return [str(value).strip()] if str(value).strip() else [] + + @classmethod + def _first(cls, tags: Any, key: str) -> Optional[str]: + """返回指定音频标签的第一个非空值。""" + values = cls._values(tags, key) + return values[0] if values else None + + @staticmethod + def _number_pair(value: Optional[str]) -> tuple[Optional[int], Optional[int]]: + """解析 track/disc 标签中的当前编号和总数。""" + if not value: + return None, None + parts = str(value).split("/", 1) + current = AudioMetadataHelper._optional_int(parts[0]) + total = AudioMetadataHelper._optional_int(parts[1]) if len(parts) > 1 else None + return current, total + + @staticmethod + def _year(value: Optional[str]) -> Optional[int]: + """从完整或不完整日期标签中提取四位年份。""" + if not value: + return None + return AudioMetadataHelper._optional_int(str(value)[:4]) + + @staticmethod + def _optional_int(value: Any) -> Optional[int]: + """将音频技术参数安全转换为整数。""" + try: + return int(value) if value is not None and str(value).strip() else None + except (TypeError, ValueError): + return None diff --git a/app/helper/message.py b/app/helper/message.py index 40bf20ddc..006afafea 100644 --- a/app/helper/message.py +++ b/app/helper/message.py @@ -17,6 +17,7 @@ from app.core.cache import TTLCache from app.core.config import global_vars from app.core.context import MediaInfo, TorrentInfo from app.core.meta import MetaBase +from app.core.music import MusicInfo, MusicMeta from app.db.systemconfig_oper import SystemConfigOper from app.log import logger from app.schemas.message import Notification @@ -91,6 +92,31 @@ class TemplateContextBuilder: """ if not mediainfo: return + if isinstance(mediainfo, MusicInfo): + context.update({ + "type": mediainfo.type.value, + "title": cls.__convert_invalid_characters(mediainfo.title), + "name": cls.__convert_invalid_characters(mediainfo.title), + "artists": [cls.__convert_invalid_characters(item) for item in mediainfo.artists], + "artist": cls.__convert_invalid_characters(mediainfo.artist), + "album": cls.__convert_invalid_characters(mediainfo.album), + "album_artist": cls.__convert_invalid_characters(mediainfo.album_artist), + "year": mediainfo.year or context.get("year"), + "title_year": mediainfo.title_year or context.get("title_year"), + "disc_number": mediainfo.disc_number, + "track_number": mediainfo.track_number, + "track": f"{mediainfo.track_number:02d}" if mediainfo.track_number else None, + "total_tracks": mediainfo.total_tracks, + "duration": mediainfo.duration, + "isrc": mediainfo.isrc, + "version": mediainfo.version, + "category": mediainfo.category, + "poster": mediainfo.get_poster_image(), + "backdrop": mediainfo.get_backdrop_image(), + "media_source": mediainfo.source, + "media_id": mediainfo.media_id, + }) + return season_fmt = f"S{mediainfo.season:02d}" if mediainfo.season is not None else None source_ids = { "themoviedb": mediainfo.tmdb_id, @@ -170,6 +196,30 @@ class TemplateContextBuilder: """ if not meta: return + if isinstance(meta, MusicMeta): + context.update({ + "original_name": meta.org_string or meta.title, + "name": cls.__convert_invalid_characters(meta.title), + "title": cls.__convert_invalid_characters(meta.title), + "artists": [cls.__convert_invalid_characters(item) for item in meta.artists], + "artist": cls.__convert_invalid_characters(meta.artist), + "album": cls.__convert_invalid_characters(meta.album), + "album_artist": cls.__convert_invalid_characters(meta.album_artist), + "year": meta.year, + "disc_number": meta.disc_number, + "track_number": meta.track_number, + "track": f"{meta.track_number:02d}" if meta.track_number else None, + "total_discs": meta.total_discs, + "total_tracks": meta.total_tracks, + "audio_format": meta.audio_format, + "bit_depth": meta.bit_depth, + "sample_rate": meta.sample_rate, + "bitrate": meta.bitrate, + "duration": meta.duration, + "isrc": meta.isrc, + "version": meta.version, + }) + return episode_data = {"episode_title": None, "episode_date": None} if meta.begin_episode and episodes: diff --git a/app/helper/resource.py b/app/helper/resource.py index ad4f6a90c..fa75ad673 100644 --- a/app/helper/resource.py +++ b/app/helper/resource.py @@ -17,9 +17,16 @@ class ResourceHelper: 检测和更新资源包 """ - _repo = f"{settings.GITHUB_PROXY}https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/package.v2.json" - _files_api = f"https://api.github.com/repos/jxxghp/MoviePilot-Resources/contents/resources.v2" _base_dir: Path = settings.ROOT_PATH + _version_flag = settings.RESOURCE_VERSION_FLAG + _repo = ( + f"{settings.GITHUB_PROXY}https://raw.githubusercontent.com/" + f"jxxghp/MoviePilot-Resources/main/package.{_version_flag}.json" + ) + _files_api = ( + "https://api.github.com/repos/jxxghp/" + f"MoviePilot-Resources/contents/resources.{_version_flag}" + ) def __init__(self): self.check() @@ -42,13 +49,14 @@ class ResourceHelper: return "x86_64" return machine - @staticmethod - def _get_needed_files() -> list[str]: + @classmethod + def _get_needed_files(cls) -> list[str]: + """返回 V3 资源在当前平台需要下载的文件名。""" python_version = ResourceHelper._get_python_version_tag() python_ver = python_version.replace("cp", "") system = platform.system().lower() machine = ResourceHelper._get_machine_tag() - files = ["user.sites.v2.bin"] + files = [f"user.sites.{cls._version_flag}.bin"] if system == "linux": files.append(f"sites.cpython-{python_ver}-{machine}-linux-gnu.so") elif system == "darwin": @@ -57,6 +65,15 @@ class ResourceHelper: files.append(f"sites.cp{python_ver}-win_amd64.pyd") return files + def _load_resource_info(self): + """读取 V3 资源清单。""" + response = RequestUtils( + proxies=self.proxies, + headers=settings.GITHUB_HEADERS, + timeout=10, + ).get_res(self._repo) + return response if response and response.status_code == 200 else None + def check(self): """ 检测是否有更新,如有则下载安装 @@ -66,9 +83,7 @@ class ResourceHelper: if SystemUtils.is_frozen(): return None logger.info("开始检测资源包版本...") - res = RequestUtils( - proxies=self.proxies, headers=settings.GITHUB_HEADERS, timeout=10 - ).get_res(self._repo) + res = self._load_resource_info() if res: try: resource_info = json.loads(res.text) diff --git a/app/locales/en-US.json b/app/locales/en-US.json index fdd64d252..c464eed10 100644 --- a/app/locales/en-US.json +++ b/app/locales/en-US.json @@ -109,6 +109,7 @@ "任务添加失败": "Failed to add task", "无法识别媒体信息": "Unable to recognize media information", "未识别到媒体信息": "Unable to recognize media information", + "未识别到音乐信息": "Unable to recognize music information", "记录不存在": "Record does not exist", "MoviePilot智能助手未启用": "MoviePilot Assistant is not enabled", "整理记录不存在": "Organization record does not exist", diff --git a/app/locales/zh-TW.json b/app/locales/zh-TW.json index 2a91d3093..966a649ae 100644 --- a/app/locales/zh-TW.json +++ b/app/locales/zh-TW.json @@ -109,6 +109,7 @@ "任务添加失败": "任務新增失敗", "无法识别媒体信息": "無法識別媒體資訊", "未识别到媒体信息": "未識別到媒體資訊", + "未识别到音乐信息": "未識別到音樂資訊", "记录不存在": "記錄不存在", "MoviePilot智能助手未启用": "MoviePilot 智慧助手未啟用", "整理记录不存在": "整理記錄不存在", diff --git a/app/modules/__init__.py b/app/modules/__init__.py index 5b98ee1b3..32df1f069 100644 --- a/app/modules/__init__.py +++ b/app/modules/__init__.py @@ -7,7 +7,7 @@ from app.helper.service import ServiceConfigHelper from app.log import logger from app.schemas import Notification, NotificationConf, MediaServerConf, DownloaderConf from app.schemas.types import ModuleType, DownloaderType, MediaServerType, MessageChannel, StorageSchema, \ - OtherModulesType, SystemConfigKey + OtherModulesType, SystemConfigKey, MediaRecognizeType from app.utils.mixins import ConfigReloadMixin @@ -66,7 +66,14 @@ class _ModuleBase(ConfigReloadMixin, metaclass=ABCMeta): pass @staticmethod - def get_subtype() -> Union[DownloaderType, MediaServerType, MessageChannel, StorageSchema, OtherModulesType]: + def get_subtype() -> Union[ + DownloaderType, + MediaServerType, + MessageChannel, + StorageSchema, + OtherModulesType, + MediaRecognizeType, + ]: """ 获取模块子类型(下载器、媒体服务器、消息通道、存储类型、其他杂项模块类型) """ diff --git a/app/modules/filemanager/transhandler.py b/app/modules/filemanager/transhandler.py index c78d5eca7..4bacbbc8a 100644 --- a/app/modules/filemanager/transhandler.py +++ b/app/modules/filemanager/transhandler.py @@ -184,9 +184,10 @@ class TransHandler: """ if not _fileitem.extension: return False - if f".{_fileitem.extension.lower()}" in ( - settings.RMT_SUBEXT + settings.RMT_AUDIOEXT - ): + extension = f".{_fileitem.extension.lower()}" + if extension in settings.RMT_SUBEXT: + return True + if mediainfo.type != MediaType.MUSIC and extension in settings.RMT_AUDIOEXT: return True return False diff --git a/app/modules/indexer/__init__.py b/app/modules/indexer/__init__.py index 5c0622860..808fa0264 100644 --- a/app/modules/indexer/__init__.py +++ b/app/modules/indexer/__init__.py @@ -548,30 +548,38 @@ class IndexerModule(_ModuleBase): def refresh_torrents(self, site: dict, keyword: Optional[str] = None, cat: Optional[str] = None, - page: Optional[int] = 0) -> Optional[List[TorrentInfo]]: + page: Optional[int] = 0, + mtype: Optional[MediaType] = None) -> Optional[List[TorrentInfo]]: """ 获取站点最新一页的种子,多个站点需要多线程处理 :param site: 站点 :param keyword: 关键字 :param cat: 分类 :param page: 页码 + :param mtype: 媒体类型 :reutrn: 种子资源列表 """ - return self.search_torrents(site=site, keyword=keyword, cat=cat, page=page) + return self.search_torrents( + site=site, keyword=keyword, cat=cat, page=page, mtype=mtype + ) async def async_refresh_torrents(self, site: dict, keyword: Optional[str] = None, cat: Optional[str] = None, - page: Optional[int] = 0) -> Optional[List[TorrentInfo]]: + page: Optional[int] = 0, + mtype: Optional[MediaType] = None) -> Optional[List[TorrentInfo]]: """ 异步获取站点最新一页的种子,多个站点需要多线程处理 :param site: 站点 :param keyword: 关键字 :param cat: 分类 :param page: 页码 + :param mtype: 媒体类型 :reutrn: 种子资源列表 """ - return await self.async_search_torrents(site=site, keyword=keyword, cat=cat, page=page) + return await self.async_search_torrents( + site=site, keyword=keyword, cat=cat, page=page, mtype=mtype + ) def refresh_userdata(self, site: dict) -> Optional[SiteUserData]: """ diff --git a/app/modules/indexer/spider/__init__.py b/app/modules/indexer/spider/__init__.py index 06eafae49..1cddd0ba8 100644 --- a/app/modules/indexer/spider/__init__.py +++ b/app/modules/indexer/spider/__init__.py @@ -18,6 +18,44 @@ from app.utils.string import StringUtils from app.utils.url import UrlUtils +def select_media_categories(category: Optional[dict], mtype: Optional[MediaType]) -> list[dict]: + """根据媒体类型选择站点索引配置中的分类列表。""" + if not category: + return [] + if mtype == MediaType.TV: + return category.get("tv") or [] + if mtype == MediaType.MOVIE: + return category.get("movie") or [] + if mtype == MediaType.MUSIC: + return category.get("music") or [] + return ( + (category.get("movie") or []) + + (category.get("tv") or []) + + (category.get("music") or []) + ) + + +def resolve_category_media_type(category_value: Any, category: Optional[dict]) -> MediaType: + """将站点分类 ID 映射为统一的电影、电视剧或音乐类型。""" + if category_value is None or not category: + return MediaType.UNKNOWN + category_id = str(category_value) + matches = [ + media_type + for media_type, key in ( + (MediaType.MOVIE, "movie"), + (MediaType.TV, "tv"), + (MediaType.MUSIC, "music"), + ) + if category_id in { + str(item.get("id")) + for item in category.get(key) or [] + if isinstance(item, dict) and item.get("id") is not None + } + ] + return matches[0] if len(matches) == 1 else MediaType.UNKNOWN + + class SiteSpider: """ 站点爬虫 @@ -62,6 +100,7 @@ class SiteSpider: self.search_type = search_type or "torrents" self.indexerid = indexer.get('id') self.indexername = indexer.get('name') + self.site_media_type = MediaType.from_agent(indexer.get('media_type')) if self.search_type == "subtitles": subtitle_conf = indexer.get('subtitles') or {} self.search = subtitle_conf.get('search') @@ -129,16 +168,24 @@ class SiteSpider: if len(paths) == 1: torrentspath = paths[0].get('path', '') else: + # 优先使用媒体类型专用路径;没有专用路径时回退到 all,兼容仅为某一类新增分支的站点。 + fallback_path = "" + expected_type = { + MediaType.MOVIE: "movie", + MediaType.TV: "tv", + MediaType.MUSIC: "music", + }.get(self.mtype) for path in paths: - if path.get("type") == "all" and not self.mtype: - torrentspath = path.get('path') - break - elif path.get("type") == "movie" and self.mtype == MediaType.MOVIE: - torrentspath = path.get('path') - break - elif path.get("type") == "tv" and self.mtype == MediaType.TV: - torrentspath = path.get('path') + path_type = path.get("type") + if path_type == "all" and not fallback_path: + fallback_path = path.get('path', '') + if (expected_type and path_type == expected_type) or ( + not expected_type and path_type == "all" + ): + torrentspath = path.get('path', '') break + if not torrentspath: + torrentspath = fallback_path # 精确搜索 if self.keyword: @@ -187,12 +234,7 @@ class SiteSpider: }) # 分类条件 if self.category: - if self.mtype == MediaType.TV: - cats = self.category.get("tv") or [] - elif self.mtype == MediaType.MOVIE: - cats = self.category.get("movie") or [] - else: - cats = (self.category.get("movie") or []) + (self.category.get("tv") or []) + cats = select_media_categories(self.category, self.mtype) allowed_cats = set(self.cat.split(',')) if self.cat else None for cat in cats: if allowed_cats and str(cat.get('id')) not in allowed_cats: @@ -204,9 +246,25 @@ class SiteSpider: ' ') + cat.get("id") }) else: - params.update({ - "cat%s" % cat.get("id"): 1 - }) + category_param = cat.get("param") or self.category.get("param") + if category_param: + # 某些站点(例如憨憨)使用重复的 cat[] 参数,字典值列表可由 + # UrlUtils.combine_url 以 doseq=True 正确展开。 + category_id = cat.get("value", cat.get("id")) + current_value = params.get(category_param) + if current_value is None: + params[category_param] = category_id + elif isinstance(current_value, list): + current_value.append(category_id) + else: + params[category_param] = [current_value, category_id] + else: + params.update({ + "cat%s" % cat.get("id"): 1 + }) + # 分类项可以附带站点要求的额外开关,例如音乐专用展示模式。 + if isinstance(cat, dict) and cat.get("params"): + params.update(cat.get("params")) searchurl = UrlUtils.combine_url(self.domain, torrentspath, params) else: # 变量字典 @@ -259,6 +317,8 @@ class SiteSpider: """ 开始请求 """ + if self.site_media_type and self.mtype and self.site_media_type != self.mtype: + return [] if not self.search or not self.domain: return [] @@ -288,6 +348,8 @@ class SiteSpider: """ 异步请求 """ + if self.site_media_type and self.mtype and self.site_media_type != self.mtype: + return [] if not self.search or not self.domain: return [] @@ -706,24 +768,18 @@ class SiteSpider: del hit_and_run def __get_category(self, torrent: Any): - # category 电影/电视剧 + # category 电影/电视剧/音乐 if 'category' not in self.fields: + if self.site_media_type: + self.torrents_info['category'] = self.site_media_type.value return selector = self.fields.get('category', {}) category_value = self._safe_query(torrent, selector) category_value = self.__filter_text(category_value, selector.get('filters')) - if category_value and self.category: - tv_cats = [str(cat.get("id")) for cat in self.category.get("tv") or []] - movie_cats = [str(cat.get("id")) for cat in self.category.get("movie") or []] - if category_value in tv_cats \ - and category_value not in movie_cats: - self.torrents_info['category'] = MediaType.TV.value - elif category_value in movie_cats: - self.torrents_info['category'] = MediaType.MOVIE.value - else: - self.torrents_info['category'] = MediaType.UNKNOWN.value - else: - self.torrents_info['category'] = MediaType.UNKNOWN.value + resolved_type = resolve_category_media_type(category_value, self.category) + if resolved_type == MediaType.UNKNOWN and self.site_media_type: + resolved_type = self.site_media_type + self.torrents_info['category'] = resolved_type.value def __get_subtitle_field(self, torrent: Any, field_name: str): """ diff --git a/app/modules/listenbrainz/__init__.py b/app/modules/listenbrainz/__init__.py new file mode 100644 index 000000000..2275f5e31 --- /dev/null +++ b/app/modules/listenbrainz/__init__.py @@ -0,0 +1,140 @@ +from typing import Any, Optional, Tuple, Union + +from app.core.config import settings +from app.core.music import MusicInfo +from app.log import logger +from app.modules import _ModuleBase +from app.schemas.types import ModuleType, OtherModulesType +from app.utils.http import RequestUtils + + +class ListenBrainzModule(_ModuleBase): + """通过 ListenBrainz 全站统计提供音乐推荐与探索榜单。""" + + _base_url = "https://api.listenbrainz.org/1" + _detail_url = "https://musicbrainz.org/recording" + _cover_url = "https://coverartarchive.org/release" + _source = "musicbrainz" + + def init_module(self) -> None: + """初始化无状态的 ListenBrainz 榜单模块。""" + + def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]: + """ListenBrainz 公共榜单无需独立密钥或启用开关。""" + return None + + def stop(self) -> None: + """停止模块;当前实现没有需要释放的持久资源。""" + + def test(self) -> Tuple[bool, str]: + """测试 ListenBrainz 全站榜单接口连通性。""" + result = self._request_chart(range_name="this_week", offset=0, count=1) + return (True, "") if result is not None else (False, "ListenBrainz 网络连接失败") + + @staticmethod + def get_name() -> str: + """返回模块展示名称。""" + return "ListenBrainz" + + @staticmethod + def get_type() -> ModuleType: + """返回模块所属的其它能力类型。""" + return ModuleType.Other + + @staticmethod + def get_subtype() -> OtherModulesType: + """返回 ListenBrainz 模块子类型。""" + return OtherModulesType.ListenBrainz + + @staticmethod + def get_priority() -> int: + """返回音乐榜单模块执行优先级。""" + return 5 + + def music_chart( + self, + range_name: str, + offset: int = 0, + count: int = 30, + ) -> list[MusicInfo]: + """读取指定统计周期的全站录音榜单。""" + payload = self._request_chart( + range_name=range_name, + offset=max(offset, 0), + count=max(1, min(count, 100)), + ) + recordings = ((payload or {}).get("payload") or {}).get("recordings") or [] + return [ + info + for item in recordings + if (info := self._recording_to_info(item)) + ] + + @classmethod + def _request_chart( + cls, + range_name: str, + offset: int, + count: int, + ) -> Optional[dict[str, Any]]: + """请求 ListenBrainz 全站录音统计并统一处理异常响应。""" + response = RequestUtils( + headers={ + "User-Agent": f"{settings.USER_AGENT} (https://github.com/jxxghp/MoviePilot)", + "Accept": "application/json", + }, + proxies=settings.PROXY, + timeout=20, + ).get_res( + f"{cls._base_url}/stats/sitewide/recordings", + params={ + "range": range_name, + "offset": offset, + "count": count, + }, + ) + if not response: + return None + try: + if response.status_code != 200: + logger.warning( + f"ListenBrainz 请求失败:{response.status_code} {response.text[:200]}" + ) + return None + return response.json() + except (TypeError, ValueError) as err: + logger.warning(f"ListenBrainz 响应解析失败:{err}") + return None + finally: + response.close() + + @classmethod + def _recording_to_info(cls, recording: dict[str, Any]) -> Optional[MusicInfo]: + """将 ListenBrainz 录音统计转换为标准音乐信息。""" + media_id = recording.get("recording_mbid") + title = recording.get("track_name") + if not media_id or not title: + return None + artist_name = str(recording.get("artist_name") or "").strip() + release_name = str(recording.get("release_name") or "").strip() + release_mbid = recording.get("caa_release_mbid") or recording.get("release_mbid") + return MusicInfo( + source=cls._source, + media_id=str(media_id), + title=str(title), + artists=[artist_name] if artist_name else [], + album=release_name or None, + cover_url=f"{cls._cover_url}/{release_mbid}/front-500" if release_mbid else None, + names=[name for name in (title, release_name) if name], + detail_link=f"{cls._detail_url}/{media_id}", + listen_count=cls._optional_int(recording.get("listen_count")), + raw_data=recording, + ) + + @staticmethod + def _optional_int(value: Any) -> Optional[int]: + """把 ListenBrainz 统计值转换为可选整数。""" + try: + return int(value) if value is not None else None + except (TypeError, ValueError): + return None diff --git a/app/modules/musicbrainz/__init__.py b/app/modules/musicbrainz/__init__.py new file mode 100644 index 000000000..0fcc4f102 --- /dev/null +++ b/app/modules/musicbrainz/__init__.py @@ -0,0 +1,242 @@ +import threading +import time +from datetime import datetime +from typing import Any, Optional, Tuple, Union + +from app.core.config import settings +from app.core.music import MusicInfo, MusicMeta +from app.log import logger +from app.modules import _ModuleBase +from app.schemas.types import MediaRecognizeType, ModuleType +from app.utils.http import RequestUtils + + +class MusicBrainzModule(_ModuleBase): + """通过 MusicBrainz 提供音乐元数据搜索和详情识别。""" + + _source = "musicbrainz" + _base_url = "https://musicbrainz.org/ws/2" + _detail_url = "https://musicbrainz.org/recording" + _cover_url = "https://coverartarchive.org/release-group" + _request_interval = 1.0 + _request_lock = threading.Lock() + _last_request_at = 0.0 + + def init_module(self) -> None: + """初始化无状态的 MusicBrainz 模块。""" + + def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]: + """MusicBrainz 无需独立密钥或启用开关。""" + return None + + def stop(self) -> None: + """停止模块;当前实现没有需要释放的持久资源。""" + + def test(self) -> Tuple[bool, str]: + """测试 MusicBrainz 搜索接口连通性。""" + result = self._request_json( + "/recording", + params={"query": "recording:test", "limit": 1, "fmt": "json"}, + ) + return (True, "") if result is not None else (False, "MusicBrainz 网络连接失败") + + @staticmethod + def get_name() -> str: + """返回模块展示名称。""" + return "MusicBrainz" + + @staticmethod + def get_type() -> ModuleType: + """返回模块所属的媒体识别类型。""" + return ModuleType.MediaRecognize + + @staticmethod + def get_subtype() -> MediaRecognizeType: + """返回 MusicBrainz 模块子类型。""" + return MediaRecognizeType.MusicBrainz + + @staticmethod + def get_priority() -> int: + """返回音乐元数据模块执行优先级。""" + return 5 + + def search_music(self, meta: MusicMeta, limit: int = 20) -> list[MusicInfo]: + """根据标准音乐搜索条件返回 MusicBrainz 录音候选。""" + query = self._build_query(meta) + if not query: + return [] + payload = self._request_json( + "/recording", + params={"query": query, "limit": max(1, min(limit, 100)), "fmt": "json"}, + ) + return [ + info + for item in (payload or {}).get("recordings") or [] + if (info := self._recording_to_info(item)) + ] + + def recognize_music(self, source: str, media_id: str) -> Optional[MusicInfo]: + """按 MusicBrainz Recording ID 获取标准化音乐详情。""" + if source != self._source or not media_id: + return None + payload = self._request_json( + f"/recording/{media_id}", + params={ + "inc": "artists+releases+release-groups+isrcs", + "fmt": "json", + }, + ) + return self._recording_to_info(payload) if payload else None + + @classmethod + def _build_query(cls, meta: MusicMeta) -> str: + """构造 MusicBrainz Recording 搜索表达式。""" + clauses = [] + if meta.title: + clauses.append(f'recording:"{cls._escape_query(meta.title)}"') + if meta.artists: + clauses.append(f'artist:"{cls._escape_query(meta.artists[0])}"') + if meta.album: + clauses.append(f'release:"{cls._escape_query(meta.album)}"') + if meta.isrc: + clauses.append(f'isrc:"{cls._escape_query(meta.isrc)}"') + return " AND ".join(clauses) + + @staticmethod + def _escape_query(value: str) -> str: + """转义 MusicBrainz 查询中的引号和反斜线。""" + return value.replace("\\", "\\\\").replace('"', '\\"').strip() + + @classmethod + def _recording_to_info(cls, recording: dict[str, Any]) -> Optional[MusicInfo]: + """将 MusicBrainz Recording 响应转换为标准音乐信息。""" + media_id = recording.get("id") + title = recording.get("title") + if not media_id or not title: + return None + releases = recording.get("releases") or [] + release = cls._select_release(releases) + release_group = (release or {}).get("release-group") or {} + release_date = cls._release_date(recording, release) + album = (release or {}).get("title") + artists = cls._artist_names(recording.get("artist-credit")) + album_artists = cls._artist_names((release or {}).get("artist-credit")) + category_parts = [release_group.get("primary-type")] + category_parts.extend(release_group.get("secondary-types") or []) + return MusicInfo( + source=cls._source, + media_id=str(media_id), + title=str(title), + artists=artists, + album=album, + album_artist=" / ".join(album_artists) if album_artists else None, + year=cls._year(release_date), + release_date=release_date, + duration=cls._duration_seconds(recording.get("length")), + isrc=next(iter(recording.get("isrcs") or []), None), + cover_url=cls._build_cover_url(release_group.get("id")), + version=recording.get("disambiguation") or None, + category=" / ".join(str(part) for part in category_parts if part), + names=[name for name in (title, album) if name], + detail_link=f"{cls._detail_url}/{media_id}", + raw_data=recording, + ) + + @staticmethod + def _artist_names(artist_credit: Optional[list[dict[str, Any]]]) -> list[str]: + """从 MusicBrainz artist-credit 提取有序艺术家名称。""" + results = [] + for credit in artist_credit or []: + artist = credit.get("artist") or {} + name = artist.get("name") or credit.get("name") + if name and name not in results: + results.append(str(name)) + return results + + @classmethod + def _select_release(cls, releases: list[dict[str, Any]]) -> dict[str, Any]: + """优先选择正式且日期最早的发行记录。""" + if not releases: + return {} + official = [release for release in releases if release.get("status") == "Official"] + candidates = official or releases + return min( + candidates, + key=lambda release: cls._date_sort_key(release.get("date")), + ) + + @staticmethod + def _date_sort_key(value: Optional[str]) -> tuple[int, str]: + """将完整或不完整发行日期转换为稳定排序键。""" + return (0, value) if value else (1, "") + + @staticmethod + def _release_date(recording: dict[str, Any], release: dict[str, Any]) -> Optional[str]: + """从录音和发行信息中选择最可靠的发行日期。""" + return recording.get("first-release-date") or release.get("date") + + @staticmethod + def _year(release_date: Optional[str]) -> Optional[int]: + """从 MusicBrainz 的可变精度日期提取年份。""" + if not release_date: + return None + try: + return int(release_date[:4]) + except (TypeError, ValueError): + return None + + @staticmethod + def _duration_seconds(value: Any) -> Optional[int]: + """将 MusicBrainz 毫秒时长转换为整数秒。""" + try: + return round(int(value) / 1000) if value is not None else None + except (TypeError, ValueError): + return None + + @classmethod + def _build_cover_url(cls, release_group_id: Optional[str]) -> Optional[str]: + """根据 Release Group ID 构造 Cover Art Archive 封面地址。""" + if not release_group_id: + return None + return f"{cls._cover_url}/{release_group_id}/front-500" + + @classmethod + def _wait_for_rate_limit(cls) -> None: + """串行控制 MusicBrainz 公共接口的最小请求间隔。""" + with cls._request_lock: + now = time.monotonic() + remaining = cls._request_interval - (now - cls._last_request_at) + if remaining > 0: + time.sleep(remaining) + cls._last_request_at = time.monotonic() + + @classmethod + def _request_json( + cls, + path: str, + params: Optional[dict[str, Any]] = None, + ) -> Optional[dict[str, Any]]: + """请求 MusicBrainz JSON 接口并统一处理网络和响应错误。""" + cls._wait_for_rate_limit() + response = RequestUtils( + headers={ + "User-Agent": f"{settings.USER_AGENT} (https://github.com/jxxghp/MoviePilot)", + "Accept": "application/json", + }, + proxies=settings.PROXY, + timeout=20, + ).get_res(f"{cls._base_url}{path}", params=params) + if not response: + return None + try: + if response.status_code != 200: + logger.warning( + f"MusicBrainz 请求失败:{response.status_code} {response.text[:200]}" + ) + return None + return response.json() + except (TypeError, ValueError) as err: + logger.warning(f"MusicBrainz 响应解析失败:{err}") + return None + finally: + response.close() diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index 85e8df1c8..eb867a2f9 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -8,6 +8,7 @@ from .file import * from .history import * from .mediaserver import * from .message import * +from .music import * from .monitoring import * from .plugin import * from .response import * diff --git a/app/schemas/context.py b/app/schemas/context.py index e9d8c0a99..7e20e8d45 100644 --- a/app/schemas/context.py +++ b/app/schemas/context.py @@ -2,6 +2,8 @@ from typing import Optional, Dict, List, Union, Any from pydantic import BaseModel, Field +from app.schemas.music import MusicInfo, MusicMeta + class MetaInfo(BaseModel): """ @@ -15,7 +17,7 @@ class MetaInfo(BaseModel): title: Optional[str] = None # 副标题 subtitle: Optional[str] = None - # 类型 电影、电视剧 + # 类型 电影、电视剧、音乐 type: Optional[str] = None # 名称 name: Optional[str] = None @@ -252,7 +254,7 @@ class TorrentInfo(BaseModel): labels: Optional[list] = Field(default_factory=list) # 种子优先级 pri_order: Optional[int] = 0 - # 种子分类 电影/电视剧 + # 种子分类 电影/电视剧/音乐 category: Optional[str] = None # 促销 volume_factor: Optional[str] = None @@ -319,9 +321,9 @@ class Context(BaseModel): 上下文 """ # 元数据 - meta_info: Optional[Union[MetaInfo, Any]] = None + meta_info: Optional[Union[MusicMeta, MetaInfo, Any]] = None # 媒体信息 - media_info: Optional[Union[MediaInfo, Any]] = None + media_info: Optional[Union[MusicInfo, MediaInfo, Any]] = None # 种子信息 torrent_info: Optional[TorrentInfo] = None # 候选资源来源:rss、spider、search、unknown diff --git a/app/schemas/history.py b/app/schemas/history.py index 04f14ea5b..cde2240a2 100644 --- a/app/schemas/history.py +++ b/app/schemas/history.py @@ -12,7 +12,7 @@ class DownloadHistory(BaseModel): id: int # 保存路程 path: Optional[str] = None - # 类型:电影、电视剧 + # 类型:电影、电视剧、音乐 type: Optional[str] = None # 标题 title: Optional[str] = None diff --git a/app/schemas/music.py b/app/schemas/music.py new file mode 100644 index 000000000..e59e5075a --- /dev/null +++ b/app/schemas/music.py @@ -0,0 +1,70 @@ +from typing import Any, Literal, Optional + +from pydantic import BaseModel, Field + + +class MusicMeta(BaseModel): + """音乐名称及音频文件解析结果。""" + + type: Literal["音乐"] = "音乐" + org_string: Optional[str] = None + title: Optional[str] = None + artists: list[str] = Field(default_factory=list) + artist: Optional[str] = None + album: Optional[str] = None + album_artist: Optional[str] = None + year: Optional[int] = None + disc_number: Optional[int] = None + track_number: Optional[int] = None + total_discs: Optional[int] = None + total_tracks: Optional[int] = None + version: Optional[str] = None + audio_format: Optional[str] = None + bit_depth: Optional[int] = None + sample_rate: Optional[int] = None + bitrate: Optional[int] = None + duration: Optional[int] = None + isrc: Optional[str] = None + media_source: Optional[str] = None + media_id: Optional[str] = None + + +class MusicInfo(BaseModel): + """标准化音乐元数据信息。""" + + type: Literal["音乐"] = "音乐" + source: Optional[str] = None + media_id: Optional[str] = None + title: Optional[str] = None + artists: list[str] = Field(default_factory=list) + artist: Optional[str] = None + album: Optional[str] = None + album_artist: Optional[str] = None + year: Optional[int] = None + release_date: Optional[str] = None + disc_number: Optional[int] = None + track_number: Optional[int] = None + total_tracks: Optional[int] = None + duration: Optional[int] = None + isrc: Optional[str] = None + cover_url: Optional[str] = None + lyrics: Optional[str] = None + version: Optional[str] = None + category: Optional[str] = "" + names: list[str] = Field(default_factory=list) + detail_link: Optional[str] = None + listen_count: Optional[int] = None + raw_data: dict[str, Any] = Field(default_factory=dict) + title_year: Optional[str] = None + poster_path: Optional[str] = None + backdrop_path: Optional[str] = None + mediaid_prefix: Optional[str] = None + overview: Optional[str] = None + vote_average: float = 0.0 + + +class MusicRecognizeRequest(BaseModel): + """音乐元数据详情识别请求。""" + + source: str + media_id: str diff --git a/app/schemas/transfer.py b/app/schemas/transfer.py index b27cb83fe..ffc471bf3 100644 --- a/app/schemas/transfer.py +++ b/app/schemas/transfer.py @@ -1,9 +1,10 @@ from pathlib import Path -from typing import Any, Callable, List, Optional +from typing import Any, Callable, List, Optional, Union from pydantic import BaseModel, Field from app.schemas.context import MetaInfo, MediaInfo +from app.schemas.music import MusicInfo, MusicMeta from app.schemas.file import FileItem from app.schemas.history import DownloadHistory from app.schemas.system import TransferDirectoryConf @@ -96,7 +97,7 @@ class TransferJobTask(BaseModel): 文件整理作业任务 """ fileitem: Optional[FileItem] = None - meta: Optional[MetaInfo] = None + meta: Optional[Union[MusicMeta, MetaInfo]] = None state: Optional[str] = None downloader: Optional[str] = None download_hash: Optional[str] = None @@ -106,7 +107,7 @@ class TransferJob(BaseModel): """ 文件整理作业 """ - media: Optional[MediaInfo] = None + media: Optional[Union[MusicInfo, MediaInfo]] = None season: Optional[int] = None tasks: Optional[List[TransferJobTask]] = Field(default_factory=list) diff --git a/app/schemas/types.py b/app/schemas/types.py index 79fa9c210..59f0ba3d3 100644 --- a/app/schemas/types.py +++ b/app/schemas/types.py @@ -6,22 +6,31 @@ from typing import Optional class MediaType(Enum): MOVIE = '电影' TV = '电视剧' + MUSIC = '音乐' COLLECTION = '系列' UNKNOWN = '未知' @staticmethod def from_agent(key: str) -> Optional["MediaType"]: - """'movie' -> MediaType.MOVIE, 'tv' -> MediaType.TV, 否则 None""" - _map = {"movie": MediaType.MOVIE, "tv": MediaType.TV} + """将 Agent 媒体类型转换为 MediaType。""" + _map = { + "movie": MediaType.MOVIE, + "tv": MediaType.TV, + "music": MediaType.MUSIC, + } return _map.get(key.strip().lower() if key else "") def to_agent(self) -> str: - """MediaType.MOVIE -> 'movie', MediaType.TV -> 'tv', 其他返回 .value""" - return {MediaType.MOVIE: "movie", MediaType.TV: "tv"}.get(self, self.value) + """将 MediaType 转换为 Agent 使用的媒体类型。""" + return { + MediaType.MOVIE: "movie", + MediaType.TV: "tv", + MediaType.MUSIC: "music", + }.get(self, self.value) def media_type_to_agent(value) -> Optional[str]: - """将 MediaType 枚举或中文字符串统一转为 'movie'/'tv'""" + """将 MediaType 枚举或字符串统一转换为 Agent 媒体类型。""" if isinstance(value, MediaType): return value.to_agent() if isinstance(value, str): @@ -397,6 +406,8 @@ class MediaRecognizeType(Enum): Bangumi = "Bangumi" # AniList AniList = "AniList" + # MusicBrainz + MusicBrainz = "MusicBrainz" # 用户配置Key字典 @@ -448,6 +459,8 @@ class OtherModulesType(Enum): PostgreSQL = "PostgreSQL" # Redis Redis = "Redis" + # ListenBrainz + ListenBrainz = "ListenBrainz" class NameValueEnum(Enum): diff --git a/docker/Dockerfile b/docker/Dockerfile index 21017c606..da4b84f82 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -104,11 +104,11 @@ RUN FRONTEND_VERSION=$(sed -n "s/^FRONTEND_VERSION\s*=\s*'\([^']*\)'/\1/p" /app/ && mv -f /tmp/MoviePilot-Plugins-main/plugins.v2/* /app/app/plugins/ \ && cat /tmp/MoviePilot-Plugins-main/package.json | jq -r 'to_entries[] | select(.value.v2 == true) | .key' | awk '{print tolower($0)}' | \ while read -r i; do if [ ! -d "/app/app/plugins/$i" ]; then mv "/tmp/MoviePilot-Plugins-main/plugins/$i" "/app/app/plugins/"; else echo "跳过 $i"; fi; done \ - && curl -sL "https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/resources.v2/user.sites.v2.bin" -o /app/app/helper/user.sites.v2.bin \ + && curl -fsSL "https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/resources.v3/user.sites.v3.bin" -o /app/app/helper/user.sites.v3.bin \ && python_ver=$(python3 -c 'import sys; print(f"cpython-{sys.version_info.major}{sys.version_info.minor}")') \ && ARCH=$(uname -m) \ && if [ "$ARCH" = "aarch64" ]; then SUFFIX="aarch64-linux-gnu"; else SUFFIX="x86_64-linux-gnu"; fi \ - && curl -sL "https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/resources.v2/sites.${python_ver}-${SUFFIX}.so" -o "/app/app/helper/sites.${python_ver}-${SUFFIX}.so" + && curl -fsSL "https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/resources.v3/sites.${python_ver}-${SUFFIX}.so" -o "/app/app/helper/sites.${python_ver}-${SUFFIX}.so" # final 阶段: 安装运行时依赖和配置最终镜像 FROM prepare_package AS final diff --git a/docker/update.sh b/docker/update.sh index 196207208..b55556be5 100644 --- a/docker/update.sh +++ b/docker/update.sh @@ -157,7 +157,9 @@ function install_backend_and_download_resources() { INFO "→ 正在备份站点资源目录..." rm -rf /resources_bakcup mkdir /resources_bakcup - cp -a /app/app/helper/user.sites.v2.bin /resources_bakcup + if [ -f /app/app/helper/user.sites.v3.bin ]; then + cp -a /app/app/helper/user.sites.v3.bin /resources_bakcup + fi cp -a /app/app/helper/sites.cp* /resources_bakcup # 清空程序目录 rm -rf /app @@ -181,14 +183,19 @@ function install_backend_and_download_resources() { arch_suffix="x86_64-linux-gnu" fi INFO "当前 Python 版本:${python_version},架构:${arch}" - # 下载 user.sites.v2.bin - if ! curl ${CURL_OPTIONS} "${GITHUB_PROXY}https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/resources.v2/user.sites.v2.bin" -o /app/app/helper/user.sites.v2.bin; then - cp -a /resources_bakcup/user.sites.v2.bin /app/app/helper/ - WARN "user.sites.v2.bin 下载失败,继续使用旧的资源来启动..." + # 下载 V3 站点索引 + if ! curl ${CURL_OPTIONS} "${GITHUB_PROXY}https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/resources.v3/user.sites.v3.bin" -o /app/app/helper/user.sites.v3.bin; then + if [ -f /resources_bakcup/user.sites.v3.bin ]; then + cp -a /resources_bakcup/user.sites.v3.bin /app/app/helper/ + fi + WARN "user.sites.v3.bin 下载失败,继续使用旧的资源来启动..." fi # 下载对应平台的 sites 文件 sites_file="sites.${python_version}-${arch_suffix}.so" - if ! curl ${CURL_OPTIONS} "${GITHUB_PROXY}https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/resources.v2/${sites_file}" -o "/app/app/helper/${sites_file}"; then + if ! curl ${CURL_OPTIONS} "${GITHUB_PROXY}https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/resources.v3/${sites_file}" -o "/app/app/helper/${sites_file}"; then + if [ -f "/resources_bakcup/${sites_file}" ]; then + cp -a "/resources_bakcup/${sites_file}" /app/app/helper/ + fi WARN "${sites_file} 下载失败,继续使用旧的资源来启动..." fi INFO "站点资源更新成功" diff --git a/docs/cli.md b/docs/cli.md index 90c94aa16..83070f174 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -16,7 +16,7 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootst - 安装后端依赖 - 按当前仓库 `version.py` 中的 `FRONTEND_VERSION` 下载对应前端 release 的 `dist.zip` - 下载 `MoviePilot-Resources` 主分支资源 -- 将 `resources.v2/*` 同步到后端 [app/helper](/Users/jxxghp/PycharmProjects/MoviePilot/app/helper) +- 将 `resources.v3/*` 同步到后端 [app/helper](/Users/jxxghp/PycharmProjects/MoviePilot/app/helper) - 下载本地 Node 运行时并安装前端运行依赖 - 执行初始化向导 - 创建全局 `moviepilot` 命令 @@ -185,14 +185,14 @@ moviepilot install frontend --config-dir /path/to/moviepilot-config ```shell moviepilot install resources moviepilot install resources --resources-repo /path/to/MoviePilot-Resources -moviepilot install resources --resource-dir /path/to/resources.v2 +moviepilot install resources --resource-dir /path/to/resources.v3 moviepilot install resources --config-dir /path/to/moviepilot-config ``` 说明: - 默认直接从 GitHub 下载 `MoviePilot-Resources` 主分支压缩包 -- 会将 `resources.v2/*` 整体复制到 [app/helper](/Users/jxxghp/PycharmProjects/MoviePilot/app/helper) +- 会将 `resources.v3/*` 整体复制到 [app/helper](/Users/jxxghp/PycharmProjects/MoviePilot/app/helper) - 这一步和 Docker 构建流程保持一致 ## 初始化命令 diff --git a/docs/development-setup.md b/docs/development-setup.md index 099dbcb1c..626382103 100644 --- a/docs/development-setup.md +++ b/docs/development-setup.md @@ -105,7 +105,7 @@ chmod +x scripts/start-local.sh 本地源码开发时,主程序需要读取资源文件和插件源码。相关文件需要放到主程序实际加载的目录下: -- **资源文件**:将 [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources) 仓库中 `resources.v2/` 下的文件同步到本仓库的 `app/helper/` 目录下。CLI 安装和 Docker 构建流程也会按这个位置准备资源。 +- **资源文件**:将 [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources) 仓库中 `resources.v3/` 下的文件同步到本仓库的 `app/helper/` 目录下。CLI 安装和 Docker 构建流程只读取 V3 资源。 - **插件源码**:需要开发或调试的插件放到本仓库的 `app/plugins/` 目录下,例如 `app/plugins/<插件目录>/`。主程序运行时从该目录加载插件,独立插件仓库只是源码来源。 如果资源文件没有放到 `app/helper/`,站点索引、规则和内置资源相关能力可能无法按本地开发预期工作;如果插件没有放到 `app/plugins/`,主程序也不会在本地运行时发现该插件。 diff --git a/docs/mcp-api.md b/docs/mcp-api.md index fc89c7509..13991c490 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -151,10 +151,10 @@ FastAPI 的 HTTP 异常在 v1、v2 均统一使用 `message`,不再返回顶 | 方法 | 路径 | 说明 | | :--- | :--- | :--- | -| GET | `/api/v1/search/media/{mediaid}` | 按媒体 ID 搜索站点种子资源,`mediaid` 支持 `tmdb:123`、`douban:123`、`bangumi:123`、`anilist:123` 及插件来源前缀,参数:`mtype`、`area`、`title`、`year`、`season`、`sites` | +| GET | `/api/v1/search/media/{mediaid}` | 按媒体 ID 搜索站点种子资源,`mediaid` 支持 `tmdb:123`、`douban:123`、`bangumi:123`、`anilist:123`、`musicbrainz:<recording_mbid>` 及插件来源前缀,参数:`mtype`、`area`、`title`、`year`、`season`、`sites` | | GET | `/api/v1/search/media/{mediaid}/stream` | 按媒体 ID 渐进式搜索站点种子资源,返回 SSE,参数同上 | -| GET | `/api/v1/search/title` | 按关键字模糊搜索站点种子资源,参数:`keyword`、`page`、`sites` | -| GET | `/api/v1/search/title/stream` | 按关键字渐进式搜索站点种子资源,返回 SSE,参数:`keyword`、`page`、`sites` | +| GET | `/api/v1/search/title` | 按关键字模糊搜索站点种子资源,参数:`keyword`、`page`、`sites`,可选 `mtype=音乐` 仅搜索音乐分类 | +| GET | `/api/v1/search/title/stream` | 按关键字渐进式搜索站点种子资源,返回 SSE,参数:`keyword`、`page`、`sites`,可选 `mtype=音乐` | | GET | `/api/v1/search/subtitle/title` | 按关键字搜索站点字幕资源,参数:`keyword`、`page`、`sites` | | GET | `/api/v1/search/subtitle/title/stream` | 按关键字渐进式搜索站点字幕资源,返回 SSE,参数:`keyword`、`page`、`sites` | | GET | `/api/v1/search/subtitle/media/{mediaid}` | 按媒体 ID 精确搜索站点字幕资源,`mediaid` 支持四种内置来源及插件来源前缀,参数:`mtype`、`title`、`year`、`season`、`episode`、`sites` | @@ -180,6 +180,17 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch | GET | `/api/v1/anilist/person/{person_id}` | 查询人物详情 | | GET | `/api/v1/anilist/person/credits/{person_id}` | 查询人物参与的动画作品,参数:`page`、`count` | +#### 音乐元数据 / 推荐 / 探索 + +音乐元数据使用 `MusicMeta` / `MusicInfo` 独立模型,媒体身份为 `musicbrainz:<recording_mbid>`。这些接口只负责搜索、识别、订阅和资源获取,不提供音乐库、歌单或歌手库管理。 + +| 方法 | 路径 | 说明 | +| :--- | :--- | :--- | +| GET | `/api/v1/music/search` | 按歌曲、专辑或歌手关键词搜索音乐元数据,参数:`query`、`count` | +| POST | `/api/v1/music/recognize` | 按 `source` + `media_id` 识别音乐详情,请求体:`MusicRecognizeRequest` | +| GET | `/api/v1/music/explore` | 浏览月度热门音乐,参数:`page`、`count` | +| GET | `/api/v1/recommend/music_weekly` | 浏览本周热门音乐,参数:`page`、`count` | + #### 下载 | 方法 | 路径 | 说明 | diff --git a/moviepilot b/moviepilot index 7dfed8880..ae2c9766b 100755 --- a/moviepilot +++ b/moviepilot @@ -117,7 +117,7 @@ Options: resources: --resources-repo PATH 本地 MoviePilot-Resources 仓库路径 - --resource-dir PATH 直接指定 resources.v2 目录 + --resource-dir PATH 直接指定 resources.v3 目录 --config-dir PATH 指定配置目录 -h, --help 显示帮助 diff --git a/requirements.in b/requirements.in index f71ed1857..ffe31bb56 100644 --- a/requirements.in +++ b/requirements.in @@ -1,6 +1,6 @@ moviepilot-rust~=0.2.4 pydantic>=2.13.4,<3.0.0 -pydantic-settings>=2.14.1,<3.0.0 +pydantic-settings>=2.14.2,<3.0.0 SQLAlchemy~=2.0.50 uvicorn~=0.49.0 fastapi~=0.136.3 @@ -32,6 +32,7 @@ qbittorrent-api==2026.6.0 plexapi~=4.18.1 transmission-rpc~=7.0.11 Jinja2~=3.1.6 +mutagen~=1.47.0 pyparsing~=3.3.2 beautifulsoup4~=4.15.0 pillow~=12.2.0 diff --git a/scripts/local_setup.py b/scripts/local_setup.py index 050c5da45..77fe9b99a 100644 --- a/scripts/local_setup.py +++ b/scripts/local_setup.py @@ -55,6 +55,7 @@ FRONTEND_TAG_API = ( RESOURCES_MAIN_ZIP = ( "https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip" ) +RESOURCE_VERSION_FLAG = "v3" LLM_PROVIDER_DEFAULTS = { "deepseek": { "model": "deepseek-chat", @@ -998,7 +999,7 @@ def install_frontend(frontend_version: str, node_version: str) -> dict[str, str] def local_resource_status() -> bool: - return (HELPER_DIR / "user.sites.v2.bin").exists() and bool( + return (HELPER_DIR / f"user.sites.{RESOURCE_VERSION_FLAG}.bin").exists() and bool( list(HELPER_DIR.glob("sites*")) ) @@ -1045,14 +1046,17 @@ def _get_python_version_tag() -> str: def _filter_resources_files( - source_dir: Path, platform_tag: str, python_version: str + source_dir: Path, + platform_tag: str, + python_version: str, ) -> list[Path]: + """筛选 V3 资源中与当前 Python 平台匹配的运行文件。""" matched_files: list[Path] = [] for file in source_dir.iterdir(): if not file.is_file(): continue filename = file.name - if filename == "user.sites.v2.bin": + if filename == f"user.sites.{RESOURCE_VERSION_FLAG}.bin": matched_files.append(file) continue if not filename.startswith("sites."): @@ -1083,9 +1087,13 @@ def _download_resources_dir() -> Path: print_step("下载资源包") download_file(RESOURCES_MAIN_ZIP, archive_path) extract_archive(archive_path, extract_dir) - source_dir = extract_dir / "MoviePilot-Resources-main" / "resources.v2" + source_dir = ( + extract_dir + / "MoviePilot-Resources-main" + / f"resources.{RESOURCE_VERSION_FLAG}" + ) if not source_dir.exists(): - raise RuntimeError("资源压缩包中未找到 resources.v2 目录") + raise RuntimeError(f"资源压缩包中未找到 resources.{RESOURCE_VERSION_FLAG} 目录") platform_name, machine = _get_platform_tag() python_version = _get_python_version_tag() @@ -1094,7 +1102,9 @@ def _download_resources_dir() -> Path: ) matched_files = _filter_resources_files( - source_dir, platform_name, python_version + source_dir, + platform_name, + python_version, ) if not matched_files: raise RuntimeError( @@ -1107,7 +1117,7 @@ def _download_resources_dir() -> Path: target = staging_dir / file.name shutil.copy2(file, target) - persisted = TEMP_DIR / "resources.v2" + persisted = TEMP_DIR / f"resources.{RESOURCE_VERSION_FLAG}" _remove_path(persisted) shutil.copytree(staging_dir, persisted) print_step(f"已筛选对应平台的资源文件,共 {len(matched_files)} 个") @@ -1126,15 +1136,14 @@ def _resolve_local_resource_dir( if resources_repo: repo_dir = resources_repo.expanduser().resolve() candidates = [ - repo_dir / "resources.v2", - repo_dir / "resources" / "resources.v2", - repo_dir / "resources" / "v2", - repo_dir / "resources.v2", + repo_dir / "resources.v3", + repo_dir / "resources" / "resources.v3", + repo_dir / "resources" / "v3", ] for candidate in candidates: if candidate.is_dir(): return candidate - raise FileNotFoundError(f"未在 {repo_dir} 下找到 resources.v2 目录") + raise FileNotFoundError(f"未在 {repo_dir} 下找到 resources.v3 目录") return None @@ -3728,7 +3737,7 @@ def build_parser() -> argparse.ArgumentParser: resources_parser.add_argument( "--resources-repo", help="本地 MoviePilot-Resources 仓库路径" ) - resources_parser.add_argument("--resource-dir", help="直接指定 resources.v2 目录") + resources_parser.add_argument("--resource-dir", help="直接指定 resources.v3 目录") resources_parser.add_argument( "--config-dir", help="配置目录,默认使用程序目录外的系统配置目录" ) @@ -3737,7 +3746,7 @@ def build_parser() -> argparse.ArgumentParser: init_parser.add_argument( "--resources-repo", help="本地 MoviePilot-Resources 仓库路径" ) - init_parser.add_argument("--resource-dir", help="直接指定 resources.v2 目录") + init_parser.add_argument("--resource-dir", help="直接指定 resources.v3 目录") init_parser.add_argument( "--skip-resources", action="store_true", help="只初始化配置,不同步资源文件" ) @@ -3774,7 +3783,7 @@ def build_parser() -> argparse.ArgumentParser: setup_parser.add_argument( "--resources-repo", help="本地 MoviePilot-Resources 仓库路径" ) - setup_parser.add_argument("--resource-dir", help="直接指定 resources.v2 目录") + setup_parser.add_argument("--resource-dir", help="直接指定 resources.v3 目录") setup_parser.add_argument( "--skip-resources", action="store_true", help="只初始化配置,不同步资源文件" ) diff --git a/skills/moviepilot-api/SKILL.md b/skills/moviepilot-api/SKILL.md index db53c6824..c85d22242 100644 --- a/skills/moviepilot-api/SKILL.md +++ b/skills/moviepilot-api/SKILL.md @@ -175,14 +175,27 @@ AniList endpoints prefer the `anilist-chinese` proxy and fall back to official A | GET | `/api/v1/anilist/person/{person_id}` | Staff detail | | GET | `/api/v1/anilist/person/credits/{person_id}` | Staff anime credits. Params: `page`, `count` | +### Music (3 endpoints) + +Music uses the independent `MusicMeta` / `MusicInfo` contract and a +`musicbrainz:<recording_mbid>` identity. MoviePilot searches, recognizes, +subscribes to, downloads, and organizes music; it does not manage a music +library, playlists, or an artist library. + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/v1/music/search` | Search tracks, albums, or artists. Params: `query`, `count` | +| POST | `/api/v1/music/recognize` | Resolve music metadata. Body: `source`, `media_id` | +| GET | `/api/v1/music/explore` | Explore the monthly site-wide music chart. Params: `page`, `count` | + ### Search / Torrents / Subtitles (11 endpoints) | Method | Path | Description | |--------|------|-------------| -| GET | `/api/v1/search/media/{mediaid}` | Search torrents by media ID (four built-in prefixes or a plugin-defined source prefix). Params: `mtype`, `area`, `title`, `year`, `season`, `sites` | +| GET | `/api/v1/search/media/{mediaid}` | Search torrents by media ID (video prefixes, `musicbrainz:<recording_mbid>`, or a plugin-defined source prefix). Params: `mtype`, `area`, `title`, `year`, `season`, `sites` | | GET | `/api/v1/search/media/{mediaid}/stream` | Stream torrent search by media ID with SSE. Params: `mtype`, `area`, `title`, `year`, `season`, `sites` | -| GET | `/api/v1/search/title` | Fuzzy search torrents by keyword. Params: `keyword`, `page`, `sites` | -| GET | `/api/v1/search/title/stream` | Stream fuzzy torrent search with SSE. Params: `keyword`, `page`, `sites` | +| GET | `/api/v1/search/title` | Fuzzy search torrents by keyword. Params: `keyword`, `page`, `sites`, optional `mtype=音乐` | +| GET | `/api/v1/search/title/stream` | Stream fuzzy torrent search with SSE. Params: `keyword`, `page`, `sites`, optional `mtype=音乐` | | GET | `/api/v1/search/subtitle/title` | Fuzzy search site subtitles by keyword. Params: `keyword`, `page`, `sites` | | GET | `/api/v1/search/subtitle/title/stream` | Stream fuzzy site subtitle search with SSE. Params: `keyword`, `page`, `sites` | | GET | `/api/v1/search/subtitle/media/{mediaid}` | Exact subtitle search by media ID (four built-in prefixes or a plugin-defined source prefix). Params: `mtype`, `title`, `year`, `season`, `episode`, `sites` | @@ -437,12 +450,13 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business | GET | `/api/v1/discover/tmdb_movies` | Discover TMDB movies. Params: `sort_by`, `with_genres`, `with_original_language`, `page` | | GET | `/api/v1/discover/tmdb_tvs` | Discover TMDB TV. Params: same as movies | -### Recommend (14 endpoints) +### Recommend (15 endpoints) | Method | Path | Description | |--------|------|-------------| | GET | `/api/v1/recommend/source` | Recommendation data sources | | GET | `/api/v1/recommend/bangumi_calendar` | Bangumi daily schedule. Params: `page`, `count` | +| GET | `/api/v1/recommend/music_weekly` | ListenBrainz weekly site-wide music chart. Params: `page`, `count` | | GET | `/api/v1/recommend/douban_showing` | Douban now showing. Params: `page`, `count` | | GET | `/api/v1/recommend/douban_movies` | Douban movies. Params: `sort`, `tags`, `page`, `count` | | GET | `/api/v1/recommend/douban_tvs` | Douban TV. Params: `sort`, `tags`, `page`, `count` | diff --git a/tests/test_audio_metadata.py b/tests/test_audio_metadata.py new file mode 100644 index 000000000..b399abf8c --- /dev/null +++ b/tests/test_audio_metadata.py @@ -0,0 +1,48 @@ +from pathlib import Path +from types import SimpleNamespace + +from app.helper.audio import AudioMetadataHelper + + +def test_read_audio_metadata_maps_easy_tags(monkeypatch): + """音频标签和技术参数应映射为 MusicMeta。""" + audio = SimpleNamespace( + tags={ + "title": ["Get Lucky"], + "artist": ["Daft Punk", "Pharrell Williams"], + "album": ["Random Access Memories"], + "albumartist": ["Daft Punk"], + "date": ["2013-05-17"], + "tracknumber": ["8/13"], + "discnumber": ["1/1"], + "isrc": ["USQX91300105"], + }, + info=SimpleNamespace( + length=369.4, + bitrate=1411200, + bits_per_sample=16, + sample_rate=44100, + ), + ) + monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: audio) + + meta = AudioMetadataHelper.read(Path("/music/08 - Get Lucky.flac")) + + assert meta.title == "Get Lucky" + assert meta.artists == ["Daft Punk", "Pharrell Williams"] + assert meta.album == "Random Access Memories" + assert meta.year == 2013 + assert meta.track_number == 8 + assert meta.total_tracks == 13 + assert meta.duration == 369 + assert meta.audio_format == "FLAC" + + +def test_read_audio_metadata_falls_back_to_filename(monkeypatch): + """无法读取标签时应保留可用于手动整理的文件名元数据。""" + monkeypatch.setattr("app.helper.audio.MutagenFile", lambda *_args, **_kwargs: None) + + meta = AudioMetadataHelper.read(Path("/music/Unknown Track.mp3")) + + assert meta.title == "Unknown Track" + assert meta.audio_format == "MP3" diff --git a/tests/test_docker_entrypoint_permissions.py b/tests/test_docker_entrypoint_permissions.py index 671fdd4b2..cd90b128c 100644 --- a/tests/test_docker_entrypoint_permissions.py +++ b/tests/test_docker_entrypoint_permissions.py @@ -47,7 +47,7 @@ def _run_permission_case(tmp_path: Path, body: str, env: dict[str, str] | None = (home_dir / ".cloakbrowser").mkdir(parents=True) (home_dir / "runtime").mkdir() (app_dir / "app" / "plugins" / "plugin.py").write_text("# plugin\n", encoding="utf-8") - (helper_dir / "user.sites.v2.bin").write_text("resources\n", encoding="utf-8") + (helper_dir / "user.sites.v3.bin").write_text("resources\n", encoding="utf-8") (helper_dir / "sites.cpython-312-x86_64-linux-gnu.so").write_text("plugin\n", encoding="utf-8") (public_dir / "index.html").write_text("<!doctype html>\n", encoding="utf-8") (home_dir / ".cloakbrowser" / "chrome").write_text("browser cache\n", encoding="utf-8") diff --git a/tests/test_downloader_path_mapping.py b/tests/test_downloader_path_mapping.py index 8a340281c..ba3053949 100644 --- a/tests/test_downloader_path_mapping.py +++ b/tests/test_downloader_path_mapping.py @@ -46,6 +46,9 @@ def _load_downloader_base(): schema_types_module.MediaServerType = Enum("MediaServerType", {"Emby": "Emby"}) schema_types_module.MessageChannel = Enum("MessageChannel", {"Telegram": "telegram"}) schema_types_module.OtherModulesType = Enum("OtherModulesType", {"Subtitle": "subtitle"}) + schema_types_module.MediaRecognizeType = Enum( + "MediaRecognizeType", {"TheMovieDb": "themoviedb"} + ) schema_types_module.SystemConfigKey = Enum( "SystemConfigKey", { diff --git a/tests/test_indexer_spider_search_url.py b/tests/test_indexer_spider_search_url.py index d5837707f..a1edf871d 100644 --- a/tests/test_indexer_spider_search_url.py +++ b/tests/test_indexer_spider_search_url.py @@ -167,6 +167,78 @@ def test_ttg_title_search_does_not_format_keyword(): assert query["search_field"] == ["The Movie 分类:电影DVDRip"] +def test_music_search_uses_dedicated_path_and_repeated_category_parameter(): + """ + 音乐分支使用独立页面时应选择 music 路径,并按配置生成可重复的分类参数。 + """ + indexer = _build_indexer( + id="hhanclub", + domain="https://hhanclub.net/", + search={ + "paths": [ + {"path": "torrents.php", "type": "all"}, + {"path": "special.php", "type": "music"}, + ], + "params": {"search": "{keyword}"}, + }, + category={ + "param": "cat[]", + "music": [ + {"id": 410, "cat": "Music"}, + {"id": 411, "cat": "MusicVideo"}, + ], + }, + ) + + parsed_url = urlparse(_get_search_url(indexer, "周杰伦", MediaType.MUSIC)) + query = parse_qs(parsed_url.query) + + assert parsed_url.path == "/special.php" + assert query["cat[]"] == ["410", "411"] + assert query["search"] == ["周杰伦"] + + +def test_typed_search_path_falls_back_to_all_path(): + """ + 站点只为音乐定义专用路径时,影视搜索仍应回退到通用路径。 + """ + indexer = _build_indexer( + search={ + "paths": [ + {"path": "torrents.php", "type": "all"}, + {"path": "special.php", "type": "music"}, + ], + "params": {"search": "{keyword}"}, + }, + ) + + parsed_url = urlparse(_get_search_url(indexer, "电影", MediaType.MOVIE)) + + assert parsed_url.path == "/torrents.php" + + +def test_category_item_can_use_distinct_search_parameter_value(): + """ + DiscuzX 子分类可以用展示分类 ID 解析结果,同时用父分类值构造搜索参数。 + """ + indexer = _build_indexer( + search={ + "paths": [{"path": "forum.php?mod=torrents&cat=1"}], + "params": {"search": "{keyword}"}, + }, + category={ + "music": [ + {"id": 20, "value": 1, "param": "cat_1_18", "cat": "Music"}, + {"id": 21, "value": 1, "param": "cat_1_18", "cat": "Music"}, + ] + }, + ) + + query = parse_qs(urlparse(_get_search_url(indexer, "FLAC", MediaType.MUSIC)).query) + + assert query["cat_1_18"] == ["1", "1"] + + def test_haidan_empty_keyword_uses_blank_search_value(): """ 海胆空关键词浏览不能把 Python None 编码进 search 参数。 diff --git a/tests/test_listenbrainz_module.py b/tests/test_listenbrainz_module.py new file mode 100644 index 000000000..63ff5dafa --- /dev/null +++ b/tests/test_listenbrainz_module.py @@ -0,0 +1,52 @@ +from app.modules.listenbrainz import ListenBrainzModule + + +def test_recording_to_info_maps_listenbrainz_payload(): + """ListenBrainz 榜单录音应转换为可搜索和订阅的 MusicInfo。""" + info = ListenBrainzModule._recording_to_info( + { + "artist_name": "Daft Punk", + "listen_count": 12345, + "recording_mbid": "recording-1", + "release_name": "Random Access Memories", + "caa_release_mbid": "release-1", + "track_name": "Get Lucky", + } + ) + + assert info is not None + assert info.source == "musicbrainz" + assert info.media_id == "recording-1" + assert info.artists == ["Daft Punk"] + assert info.album == "Random Access Memories" + assert info.listen_count == 12345 + assert info.cover_url.endswith("/release-1/front-500") + + +def test_music_chart_requests_requested_page(monkeypatch): + """音乐榜单模块应传递周期、偏移量和数量并过滤无身份记录。""" + module = ListenBrainzModule() + requested = {} + + def fake_request(range_name, offset, count): + """记录榜单请求参数并返回一条有效录音。""" + requested.update(range_name=range_name, offset=offset, count=count) + return { + "payload": { + "recordings": [ + { + "artist_name": "周杰伦", + "recording_mbid": "recording-1", + "track_name": "晴天", + }, + {"track_name": "缺少 ID"}, + ] + } + } + + monkeypatch.setattr(module, "_request_chart", fake_request) + + results = module.music_chart(range_name="this_month", offset=30, count=30) + + assert requested == {"range_name": "this_month", "offset": 30, "count": 30} + assert [item.media_id for item in results] == ["recording-1"] diff --git a/tests/test_local_setup_uninstall.py b/tests/test_local_setup_uninstall.py index 6a1504633..b9d06d4f7 100644 --- a/tests/test_local_setup_uninstall.py +++ b/tests/test_local_setup_uninstall.py @@ -45,7 +45,7 @@ class LocalSetupUninstallTests(unittest.TestCase): install_env_file.write_text("CONFIG_DIR=/tmp/moviepilot-config\n", encoding="utf-8") (root_dir / "moviepilot").write_text("#!/usr/bin/env bash\n", encoding="utf-8") (helper_dir / "sites.py").write_text("generated\n", encoding="utf-8") - (helper_dir / "user.sites.v2.bin").write_bytes(b"binary") + (helper_dir / "user.sites.v3.bin").write_bytes(b"binary") (temp_config_dir / "moviepilot.runtime.json").write_text("{}", encoding="utf-8") (temp_config_dir / "moviepilot.frontend.runtime.json").write_text( "{}", encoding="utf-8" @@ -110,7 +110,7 @@ class LocalSetupUninstallTests(unittest.TestCase): self.assertFalse((root_dir / ".runtime").exists()) self.assertFalse((root_dir / "public").exists()) self.assertFalse((root_dir / "app" / "helper" / "sites.py").exists()) - self.assertFalse((root_dir / "app" / "helper" / "user.sites.v2.bin").exists()) + self.assertFalse((root_dir / "app" / "helper" / "user.sites.v3.bin").exists()) self.assertFalse(cli_link.exists()) def test_uninstall_deletes_external_config_when_requested(self): diff --git a/tests/test_music_chain.py b/tests/test_music_chain.py new file mode 100644 index 000000000..b690b5d18 --- /dev/null +++ b/tests/test_music_chain.py @@ -0,0 +1,112 @@ +from app.chain.music import MusicChain +from app.core.music import MusicInfo + + +def test_parse_query_supports_artist_title_format(): + """艺术家与标题格式应拆分为结构化搜索条件。""" + meta = MusicChain.parse_query(" 周杰伦 - 晴天 ") + + assert meta.artists == ["周杰伦"] + assert meta.title == "晴天" + assert meta.org_string == " 周杰伦 - 晴天 " + + +def test_parse_query_keeps_plain_title(): + """普通文本应保留为歌曲或专辑标题。""" + meta = MusicChain.parse_query(" Random Access Memories ") + + assert meta.artists == [] + assert meta.title == "Random Access Memories" + + +def test_build_site_keywords_prefers_artist_album(): + """站点关键词应优先使用艺术家和专辑组合。""" + info = MusicInfo( + title="Get Lucky", + artists=["Daft Punk"], + album="Random Access Memories", + ) + + assert MusicChain.build_site_keywords(info) == [ + "Daft Punk Random Access Memories", + "Daft Punk Get Lucky", + "Random Access Memories", + "Get Lucky", + ] + + +def test_normalize_candidates_deduplicates_source_identity(): + """同一来源和媒体 ID 的音乐候选应只保留一次。""" + results = MusicChain.normalize_candidates( + [ + MusicInfo(source="musicbrainz", media_id="recording-1", title="A"), + { + "type": "音乐", + "source": "musicbrainz", + "media_id": "recording-1", + "title": "A duplicate", + }, + ] + ) + + assert len(results) == 1 + assert results[0].title == "A" + + +def test_normalize_candidates_deduplicates_metadata_without_id(): + """缺少来源 ID 时应按标题、艺术家和专辑去重。""" + results = MusicChain.normalize_candidates( + [ + MusicInfo(title="One More Time", artists=["Daft Punk"], album="Discovery"), + MusicInfo(title=" one more time ", artists=["daft punk"], album="DISCOVERY"), + ] + ) + + assert len(results) == 1 + + +def test_to_meta_preserves_selected_identity(): + """候选转换后应保留下载和整理所需的标准身份。""" + info = MusicInfo( + source="musicbrainz", + media_id="recording-1", + title="晴天", + artists=["周杰伦"], + album="叶惠美", + year=2003, + track_number=3, + ) + + meta = MusicChain.to_meta(info) + + assert meta.media_source == "musicbrainz" + assert meta.media_id == "recording-1" + assert meta.artists == ["周杰伦"] + assert meta.album == "叶惠美" + assert meta.track_number == 3 + + +def test_chart_converts_page_to_listenbrainz_offset(monkeypatch): + """音乐榜单处理链应将页码转换为模块需要的偏移量。""" + chain = MusicChain() + requested = {} + + def fake_run_module(method, **kwargs): + """记录榜单模块调用并返回重复候选。""" + requested.update(method=method, **kwargs) + return [ + MusicInfo(source="musicbrainz", media_id="recording-1", title="晴天"), + MusicInfo(source="musicbrainz", media_id="recording-1", title="晴天"), + ] + + monkeypatch.setattr(chain, "run_module", fake_run_module) + + results = chain.chart(range_name="this_week", page=2, count=30) + + assert requested == { + "method": "music_chart", + "range_name": "this_week", + "offset": 30, + "count": 30, + } + assert len(results) == 1 diff --git a/tests/test_music_context.py b/tests/test_music_context.py new file mode 100644 index 000000000..24357ac2a --- /dev/null +++ b/tests/test_music_context.py @@ -0,0 +1,140 @@ +from app.core.context import Context, MediaInfo +from app.core.music import MusicInfo, MusicMeta +from app.schemas.context import Context as ContextSchema +from app.schemas.context import MediaInfo as MediaInfoSchema +from app.schemas.music import MusicInfo as MusicInfoSchema +from app.schemas.music import MusicMeta as MusicMetaSchema +from app.schemas.types import MediaType, media_type_to_agent + + +def test_media_type_supports_music_agent_conversion(): + """音乐媒体类型应支持 Agent 标识双向转换。""" + assert MediaType.from_agent("music") == MediaType.MUSIC + assert MediaType.MUSIC.to_agent() == "music" + assert media_type_to_agent(MediaType.MUSIC) == "music" + assert media_type_to_agent("music") == "music" + + +def test_music_meta_round_trip_preserves_list_isolation(): + """MusicMeta 字典往返后应保留字段且不共享可变列表。""" + meta = MusicMeta( + org_string="Jay Chou - Common Jasmin Orange", + title="七里香", + artists=["周杰伦"], + album="七里香", + year=2004, + audio_format="FLAC", + ) + + payload = meta.to_dict() + restored = MusicMeta.from_dict(payload) + restored.artists.append("Jay Chou") + + assert payload["type"] == "音乐" + assert restored.title == "七里香" + assert restored.album == "七里香" + assert restored.year == 2004 + assert meta.artists == ["周杰伦"] + + +def test_music_info_serializes_shared_media_display_fields(): + """MusicInfo 应输出现有媒体卡片可复用的展示字段。""" + info = MusicInfo( + source="musicbrainz", + media_id="release-1", + title="七里香", + artists=["周杰伦"], + album="七里香", + year=2004, + cover_url="https://example.invalid/cover.jpg", + ) + + payload = info.to_dict() + + assert payload["type"] == "音乐" + assert payload["artist"] == "周杰伦" + assert payload["title_year"] == "七里香 (2004)" + assert payload["poster_path"] == "https://example.invalid/cover.jpg" + assert payload["mediaid_prefix"] == "musicbrainz" + assert payload["media_id"] == "release-1" + + +def test_core_context_serializes_music_models_without_video_fields(): + """核心 Context 应使用既有外层结构序列化音乐对象。""" + context = Context( + meta_info=MusicMeta(title="七里香", artists=["周杰伦"]), + media_info=MusicInfo( + source="musicbrainz", + media_id="release-1", + title="七里香", + artists=["周杰伦"], + ), + ) + + payload = context.to_dict() + + assert payload["meta_info"]["type"] == "音乐" + assert payload["media_info"]["type"] == "音乐" + assert payload["media_info"]["artists"] == ["周杰伦"] + assert "tmdb_id" not in payload["media_info"] + + +def test_schema_context_uses_music_models_for_music_payload(): + """API Context 应将音乐负载解析为音乐专属 Schema。""" + context = ContextSchema.model_validate( + { + "meta_info": { + "type": "音乐", + "title": "七里香", + "artists": ["周杰伦"], + }, + "media_info": { + "type": "音乐", + "source": "musicbrainz", + "media_id": "release-1", + "title": "七里香", + "artists": ["周杰伦"], + "album": "七里香", + "year": 2004, + }, + } + ) + + assert isinstance(context.meta_info, MusicMetaSchema) + assert isinstance(context.media_info, MusicInfoSchema) + assert context.media_info.artists == ["周杰伦"] + + +def test_schema_context_keeps_video_payload_on_existing_model(): + """电影负载应继续使用现有 MediaInfo Schema。""" + context = ContextSchema.model_validate( + { + "media_info": { + "type": "电影", + "source": "themoviedb", + "title": "Interstellar", + "tmdb_id": 157336, + } + } + ) + + assert isinstance(context.media_info, MediaInfoSchema) + assert context.media_info.tmdb_id == 157336 + + +def test_core_video_context_remains_compatible(): + """新增音乐模型后现有电影 Context 序列化应保持兼容。""" + context = Context( + media_info=MediaInfo( + source="themoviedb", + type=MediaType.MOVIE, + title="Interstellar", + year="2014", + tmdb_id=157336, + ) + ) + + payload = context.to_dict() + + assert payload["media_info"]["type"] == "电影" + assert payload["media_info"]["tmdb_id"] == 157336 diff --git a/tests/test_music_download.py b/tests/test_music_download.py new file mode 100644 index 000000000..39d6cea1c --- /dev/null +++ b/tests/test_music_download.py @@ -0,0 +1,76 @@ +from unittest.mock import Mock, patch + +from app.api.endpoints.download import download +from app.chain.download import DownloadChain +from app.chain.music import MusicChain +from app.core.music import MusicInfo +from app.schemas.context import TorrentInfo +from app.schemas.music import MusicInfo as MusicInfoSchema +from app.schemas.types import MediaType + + +def _music_info() -> MusicInfo: + """构造下载测试使用的标准音乐信息。""" + return MusicInfo( + source="musicbrainz", + media_id="recording-1", + title="晴天", + artists=["周杰伦"], + album="叶惠美", + year=2003, + cover_url="https://example.com/cover.jpg", + raw_data={"large": "payload"}, + ) + + +def test_music_info_exposes_download_chain_compatibility_fields(): + """音乐信息应安全兼容下载链现有的视频身份字段访问。""" + info = _music_info() + meta = MusicChain.to_meta(info) + + assert info.type == MediaType.MUSIC + assert info.tmdb_id is None + assert info.episode_group is None + assert meta.episode_list == [] + assert meta.season_episode == "" + + +def test_download_note_keeps_versioned_music_context(): + """音乐下载历史备注应保存可恢复且不含上游原始大对象的上下文。""" + info = _music_info() + meta = MusicChain.to_meta(info) + + note = DownloadChain._build_download_note("Manual", info, meta) + + assert note["source"] == "Manual" + assert note["music"]["version"] == 1 + assert note["music"]["meta"]["album"] == "叶惠美" + assert note["music"]["media"]["media_id"] == "recording-1" + assert "raw_data" not in note["music"]["media"] + + +def test_download_endpoint_builds_music_context(): + """现有添加下载接口应使用 MusicInfo 和 MusicMeta 构造音乐上下文。""" + chain = Mock() + chain.download_single.return_value = "hash-1" + current_user = Mock(name="admin") + + with patch("app.api.endpoints.download.DownloadChain", return_value=chain): + response = download( + media_in=MusicInfoSchema(**_music_info().to_dict()), + torrent_in=TorrentInfo( + title="周杰伦 - 叶惠美 FLAC", + enclosure="https://example.com/download?id=1", + category="音乐", + ), + downloader="qb", + save_path=None, + current_user=current_user, + ) + + assert response.success is True + context = chain.download_single.call_args.kwargs["context"] + assert isinstance(context.media_info, MusicInfo) + assert context.media_info.media_id == "recording-1" + assert context.meta_info.type == MediaType.MUSIC + assert context.meta_info.org_string == "周杰伦 - 叶惠美 FLAC" diff --git a/tests/test_music_endpoint.py b/tests/test_music_endpoint.py new file mode 100644 index 000000000..73b8e6084 --- /dev/null +++ b/tests/test_music_endpoint.py @@ -0,0 +1,116 @@ +import asyncio +from unittest.mock import AsyncMock, Mock, patch + +import pytest +from fastapi import HTTPException + +from app.api.apiv1 import api_router +from app.api.endpoints.music import explore_music, recognize_music, search_music +from app.core.music import MusicInfo +from app.schemas.music import MusicRecognizeRequest + + +def test_music_routes_are_registered(): + """V1 API 应注册音乐搜索和详情识别路由。""" + routes = {(route.path, tuple(route.methods or [])) for route in api_router.routes} + + assert any(path == "/music/search" and "GET" in methods for path, methods in routes) + assert any(path == "/music/recognize" and "POST" in methods for path, methods in routes) + assert any(path == "/music/explore" and "GET" in methods for path, methods in routes) + + +def test_search_music_serializes_chain_results(): + """音乐搜索接口应返回统一的 MusicInfo 响应。""" + chain = Mock() + chain.async_search = AsyncMock( + return_value=[ + MusicInfo( + source="musicbrainz", + media_id="recording-1", + title="晴天", + artists=["周杰伦"], + ) + ] + ) + + with patch("app.api.endpoints.music.MusicChain", return_value=chain): + result = asyncio.run(search_music(query="晴天", count=10, _=Mock())) + + assert len(result) == 1 + assert result[0].title == "晴天" + assert result[0].artist == "周杰伦" + chain.async_search.assert_awaited_once_with(query="晴天", limit=10) + + +def test_recognize_music_returns_detail(): + """音乐识别接口应按来源和 ID 返回详情。""" + chain = Mock() + chain.async_recognize = AsyncMock( + return_value=MusicInfo( + source="musicbrainz", + media_id="recording-1", + title="晴天", + ) + ) + + with patch("app.api.endpoints.music.MusicChain", return_value=chain): + result = asyncio.run( + recognize_music( + request=MusicRecognizeRequest( + source="musicbrainz", + media_id="recording-1", + ), + _=Mock(), + ) + ) + + assert result.media_id == "recording-1" + chain.async_recognize.assert_awaited_once_with( + source="musicbrainz", + media_id="recording-1", + ) + + +def test_recognize_music_returns_404_for_unknown_item(): + """音乐详情不存在时接口应返回 404。""" + chain = Mock() + chain.async_recognize = AsyncMock(return_value=None) + + with ( + patch("app.api.endpoints.music.MusicChain", return_value=chain), + pytest.raises(HTTPException) as error, + ): + asyncio.run( + recognize_music( + request=MusicRecognizeRequest(source="musicbrainz", media_id="missing"), + _=Mock(), + ) + ) + + assert error.value.status_code == 404 + + +def test_explore_music_serializes_monthly_chart(): + """音乐探索接口应按月度榜单分页并保留收听统计。""" + chain = Mock() + chain.async_chart = AsyncMock( + return_value=[ + MusicInfo( + source="musicbrainz", + media_id="recording-1", + title="晴天", + artists=["周杰伦"], + listen_count=123, + ) + ] + ) + + with patch("app.api.endpoints.music.MusicChain", return_value=chain): + result = asyncio.run(explore_music(page=2, count=20, _=Mock())) + + assert result[0].listen_count == 123 + chain.async_chart.assert_awaited_once_with( + range_name="this_month", + page=2, + count=20, + ) diff --git a/tests/test_music_indexer_category.py b/tests/test_music_indexer_category.py new file mode 100644 index 000000000..4502080cc --- /dev/null +++ b/tests/test_music_indexer_category.py @@ -0,0 +1,63 @@ +from app.modules.indexer.spider import ( + SiteSpider, + resolve_category_media_type, + select_media_categories, +) +from app.schemas.types import MediaType + + +def _category_config() -> dict: + """构造包含电影、电视剧和音乐分类的站点配置。""" + return { + "movie": [{"id": "1", "name": "电影"}], + "tv": [{"id": "2", "name": "电视剧"}], + "music": [{"id": "3", "name": "音乐"}], + } + + +def test_select_media_categories_supports_music(): + """站点搜索限定音乐类型时应只提交音乐分类。""" + assert select_media_categories(_category_config(), MediaType.MUSIC) == [ + {"id": "3", "name": "音乐"} + ] + + +def test_select_media_categories_includes_music_when_type_unspecified(): + """未限定媒体类型的浏览应继续包含全部已配置分类。""" + assert [item["id"] for item in select_media_categories(_category_config(), None)] == [ + "1", + "2", + "3", + ] + + +def test_resolve_category_media_type_supports_music(): + """音乐分类 ID 应映射为统一音乐媒体类型。""" + assert resolve_category_media_type("3", _category_config()) == MediaType.MUSIC + + +def test_resolve_category_media_type_rejects_ambiguous_category(): + """一个分类同时归属多个媒体类型时应保持未知,避免误整理。""" + category = _category_config() + category["movie"].append({"id": "3", "name": "综合"}) + + assert resolve_category_media_type("3", category) == MediaType.UNKNOWN + + +def test_site_level_music_type_fills_missing_torrent_category(): + """音乐专属站点缺少逐条分类字段时应使用站点级音乐类型。""" + spider = SiteSpider( + indexer={ + "id": "music", + "name": "Music", + "domain": "https://music.example/", + "media_type": "music", + "search": {}, + "torrents": {"fields": {}}, + }, + mtype=MediaType.MUSIC, + ) + + spider._SiteSpider__get_category(None) + + assert spider.torrents_info["category"] == MediaType.MUSIC.value diff --git a/tests/test_music_search.py b/tests/test_music_search.py new file mode 100644 index 000000000..4a8c8ede0 --- /dev/null +++ b/tests/test_music_search.py @@ -0,0 +1,79 @@ +from unittest.mock import Mock, patch + +from app.chain.search import SearchChain +from app.core.music import MusicInfo, MusicMeta +from app.schemas.context import TorrentInfo +from app.schemas.types import MediaType + + +def test_music_context_builder_keeps_only_music_category(): + """精确音乐搜索只应保留明确标记为音乐分类的站点资源。""" + chain = SearchChain() + music = MusicInfo( + source="musicbrainz", + media_id="recording-1", + title="Get Lucky", + artists=["Daft Punk"], + album="Random Access Memories", + ) + torrents = [ + TorrentInfo( + title="Daft Punk - Random Access Memories FLAC", + category=MediaType.MUSIC.value, + site_name="MusicSite", + ), + TorrentInfo( + title="Unrelated Movie", + category=MediaType.MOVIE.value, + site_name="VideoSite", + ), + ] + + with patch.object(chain, "filter_torrents", return_value=torrents[:1]): + contexts = chain._build_music_contexts( + torrents=torrents, + mediainfo=music, + rule_groups=["music"], + ) + + assert len(contexts) == 1 + assert contexts[0].media_info is music + assert isinstance(contexts[0].meta_info, MusicMeta) + assert contexts[0].meta_info.media_id == "recording-1" + assert contexts[0].torrent_info.category == MediaType.MUSIC.value + + +def test_search_by_id_routes_music_identity_to_music_chain(): + """MusicBrainz 精确身份搜索应使用 MusicChain 识别并进入现有搜索处理链。""" + chain = SearchChain() + music = MusicInfo( + source="musicbrainz", + media_id="recording-1", + title="晴天", + artists=["周杰伦"], + ) + expected = [Mock()] + + with ( + patch("app.chain.search.MusicChain") as music_chain, + patch.object(chain, "process", return_value=expected) as process, + ): + music_chain.return_value.recognize.return_value = music + result = chain.search_by_id( + source="musicbrainz", + mediaid="recording-1", + mtype=MediaType.MUSIC, + sites=[1], + ) + + assert result == expected + music_chain.return_value.recognize.assert_called_once_with( + source="musicbrainz", + media_id="recording-1", + ) + process.assert_called_once_with( + mediainfo=music, + sites=[1], + area="title", + no_exists=None, + ) diff --git a/tests/test_music_subscribe.py b/tests/test_music_subscribe.py new file mode 100644 index 000000000..4323a1167 --- /dev/null +++ b/tests/test_music_subscribe.py @@ -0,0 +1,122 @@ +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from app.chain.subscribe import SubscribeChain, build_subscribe_meta +from app.core.context import Context, TorrentInfo +from app.core.music import MusicInfo, MusicMeta +from app.schemas.types import MediaType + + +def _music_info() -> MusicInfo: + """构造音乐订阅测试使用的标准目标。""" + return MusicInfo( + source="musicbrainz", + media_id="recording-1", + title="晴天", + artists=["周杰伦"], + album="叶惠美", + year=2003, + ) + + +def _subscribe() -> SimpleNamespace: + """构造不依赖数据库的音乐订阅对象。""" + return SimpleNamespace( + id=7, + name="晴天", + year="2003", + type=MediaType.MUSIC.value, + keyword=None, + media_source="musicbrainz", + media_id="recording-1", + season=None, + episode_group=None, + tmdbid=None, + imdbid=None, + tvdbid=None, + doubanid=None, + bangumiid=None, + anilistid=None, + sites=[], + filter_groups=[], + quality=None, + resolution=None, + effect=None, + include=None, + exclude=None, + username="admin", + save_path=None, + downloader=None, + custom_words=None, + media_category=None, + best_version=0, + state="R", + note=None, + ) + + +def test_build_subscribe_meta_returns_music_meta(): + """音乐订阅应构造 MusicMeta,而不是交给影视标题解析器。""" + meta = build_subscribe_meta(_subscribe()) + + assert isinstance(meta, MusicMeta) + assert meta.type == MediaType.MUSIC + assert meta.media_id == "recording-1" + + +def test_music_subscribe_reuses_search_download_and_finish_flow(): + """音乐订阅应复用站点搜索、批量下载和订阅完成主流程。""" + subscribe = _subscribe() + target = _music_info() + context = Context( + torrent_info=TorrentInfo( + title="周杰伦 - 叶惠美 FLAC", + category=MediaType.MUSIC.value, + ) + ) + search_chain = Mock() + search_chain.search_by_title.return_value = [context] + download_chain = Mock() + download_chain.batch_download.return_value = ([context], None) + chain = SubscribeChain() + chain.finish_subscribe_or_not = Mock() + + with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=target), \ + patch("app.chain.subscribe.SearchChain", return_value=search_chain), \ + patch("app.chain.subscribe.DownloadChain", return_value=download_chain), \ + patch("app.chain.subscribe.SubscribeOper") as subscribe_oper: + subscribe_oper.return_value.get.return_value = subscribe + chain._search_music_subscribe(subscribe) + + search_chain.search_by_title.assert_called_once_with( + title="周杰伦 叶惠美", + sites=[], + mtype=MediaType.MUSIC, + rule_groups=[], + ) + assert context.media_info is target + assert isinstance(context.meta_info, MusicMeta) + assert context.meta_info.org_string == "周杰伦 - 叶惠美 FLAC" + download_chain.batch_download.assert_called_once() + chain.finish_subscribe_or_not.assert_called_once() + + +def test_music_subscribe_ignores_non_music_category(): + """音乐订阅不得自动下载未被站点分类为音乐的资源。""" + subscribe = _subscribe() + context = Context( + torrent_info=TorrentInfo( + title="周杰伦 - 叶惠美 FLAC", + category=MediaType.MOVIE.value, + ) + ) + search_chain = Mock() + search_chain.search_by_title.return_value = [context] + chain = SubscribeChain() + + with patch.object(SubscribeChain, "_recognize_music_subscribe", return_value=_music_info()), \ + patch("app.chain.subscribe.SearchChain", return_value=search_chain), \ + patch("app.chain.subscribe.DownloadChain") as download_chain: + chain._search_music_subscribe(subscribe) + + download_chain.assert_not_called() diff --git a/tests/test_music_torrents.py b/tests/test_music_torrents.py new file mode 100644 index 000000000..07e404694 --- /dev/null +++ b/tests/test_music_torrents.py @@ -0,0 +1,68 @@ +import asyncio +from unittest.mock import AsyncMock, Mock, patch + +from app.chain.torrents import TorrentsChain +from app.core.music import MusicInfo, MusicMeta +from app.schemas.types import MediaType + + +def test_async_browse_passes_music_type_to_indexer(): + """站点浏览应把音乐类型传入现有索引刷新接口。""" + chain = TorrentsChain() + chain.async_refresh_torrents = AsyncMock(return_value=[]) + sites_helper = Mock() + sites_helper.async_get_indexer = AsyncMock( + return_value={"id": 1, "domain": "example.com"} + ) + + with patch("app.chain.torrents.SitesHelper", return_value=sites_helper): + asyncio.run( + chain.async_browse( + domain="example.com", + keyword="Daft Punk", + mtype=MediaType.MUSIC, + ) + ) + + chain.async_refresh_torrents.assert_awaited_once_with( + site={"id": 1, "domain": "example.com"}, + keyword="Daft Punk", + cat=None, + page=0, + mtype=MediaType.MUSIC, + ) + + +def test_music_cache_context_uses_music_models(): + """站点缓存识别到音乐分类时不应进入影视识别链。""" + chain = TorrentsChain() + torrent = Mock( + title="Daft Punk - Get Lucky", + description=None, + enclosure="https://example.com/download?id=1", + category=MediaType.MUSIC.value, + pubdate="2026-08-07 00:00:00", + ) + sites_helper = Mock() + sites_helper.get_indexers.return_value = [{ + "id": 1, + "name": "Test", + "domain": "https://example.com", + }] + + with ( + patch.object(chain, "get_torrents", return_value={}), + patch.object(chain, "browse", return_value=[torrent]), + patch.object(chain, "save_cache"), + patch("app.chain.torrents.SitesHelper", return_value=sites_helper), + patch("app.chain.torrents.MediaChain") as media_chain, + ): + result = chain.refresh(stype="spider", sites=[1]) + + context = result["example.com"][0] + assert isinstance(context.meta_info, MusicMeta) + assert isinstance(context.media_info, MusicInfo) + assert context.meta_info.artists == ["Daft Punk"] + assert context.media_info.title == "Get Lucky" + assert context.candidate_recognized is False + media_chain.assert_not_called() diff --git a/tests/test_music_transfer.py b/tests/test_music_transfer.py new file mode 100644 index 000000000..8a59f8153 --- /dev/null +++ b/tests/test_music_transfer.py @@ -0,0 +1,117 @@ +from pathlib import Path +from types import SimpleNamespace + +from app.chain.music import MusicChain +from app.chain.transfer import JobManager, TransferChain +from app.core.config import settings +from app.core.music import MusicInfo, MusicMeta +from app.helper.message import TemplateHelper +from app.schemas.file import FileItem +from app.schemas.transfer import TransferTask +from app.schemas.types import MediaType + + +def _music_context() -> tuple[MusicMeta, MusicInfo]: + """构造整理测试使用的音乐元数据和媒体信息。""" + info = MusicInfo( + source="musicbrainz", + media_id="recording-1", + title="Get Lucky", + artists=["Daft Punk", "Pharrell Williams"], + album="Random Access Memories", + album_artist="Daft Punk", + year=2013, + track_number=8, + total_tracks=13, + category="Album", + ) + return MusicChain.to_meta(info), info + + +def test_music_rename_context_contains_audio_fields(): + """重命名模板上下文应提供艺术家、专辑、盘号和曲序字段。""" + meta, info = _music_context() + + context = TemplateHelper().builder.build( + meta=meta, + mediainfo=info, + file_extension=".flac", + include_raw_objects=False, + ) + + assert context["artist"] == "Daft Punk / Pharrell Williams" + assert context["album"] == "Random Access Memories" + assert context["track"] == "08" + assert context["fileExt"] == ".flac" + + +def test_music_rename_format_is_independent_from_movie_format(): + """音乐应使用独立重命名模板且保持影视模板不变。""" + assert settings.RENAME_FORMAT(MediaType.MUSIC) == settings.MUSIC_RENAME_FORMAT + assert settings.RENAME_FORMAT(MediaType.MOVIE) == settings.MOVIE_RENAME_FORMAT + + +def test_audio_is_primary_only_in_music_context(): + """音频文件只在音乐上下文中作为主要媒体文件。""" + chain = TransferChain() + audio = FileItem( + storage="local", + path="/music/track.flac", + name="track.flac", + basename="track", + type="file", + extension="flac", + ) + + assert chain._is_primary_media_file(audio, MusicInfo(title="Track")) is True + assert chain._is_primary_media_file(audio, None) is False + + +def test_restore_music_context_from_download_history(): + """自动整理应从下载历史备注恢复标准音乐身份。""" + meta, info = _music_context() + history = SimpleNamespace( + note={ + "music": { + "version": 1, + "meta": meta.to_dict(), + "media": info.to_dict(), + } + } + ) + + restored_meta, restored_info = TransferChain._restore_music_download_context( + history, + Path("/remote/08 - Get Lucky.flac"), + ) + + assert restored_meta is not None + assert restored_info is not None + assert restored_meta.org_string == "08 - Get Lucky.flac" + assert restored_info.source == "musicbrainz" + assert restored_info.media_id == "recording-1" + assert restored_info.album == "Random Access Memories" + + +def test_job_manager_serializes_music_queue_models(): + """整理队列应使用音乐专属 Schema 序列化任务。""" + meta, info = _music_context() + task = TransferTask( + fileitem=FileItem( + storage="local", + path="/music/track.flac", + name="track.flac", + basename="track", + type="file", + extension="flac", + ), + meta=meta, + mediainfo=info, + ) + manager = JobManager() + + assert manager.add_task(task) is True + job = manager.list_jobs()[0] + assert job.media.type == "音乐" + assert job.media.album == "Random Access Memories" + assert job.tasks[0].meta.type == "音乐" diff --git a/tests/test_musicbrainz_module.py b/tests/test_musicbrainz_module.py new file mode 100644 index 000000000..ddcac7488 --- /dev/null +++ b/tests/test_musicbrainz_module.py @@ -0,0 +1,111 @@ +from app.core.music import MusicMeta +from app.modules.musicbrainz import MusicBrainzModule + + +def test_build_query_uses_structured_music_fields(): + """MusicBrainz 查询应同时使用歌曲、艺术家和专辑条件。""" + query = MusicBrainzModule._build_query( + MusicMeta( + title='Love "Story"', + artists=["Taylor Swift"], + album="Fearless", + ) + ) + + assert query == ( + 'recording:"Love \\"Story\\"" AND ' + 'artist:"Taylor Swift" AND release:"Fearless"' + ) + + +def test_recording_to_info_maps_musicbrainz_payload(): + """MusicBrainz Recording 应映射为统一 MusicInfo。""" + info = MusicBrainzModule._recording_to_info( + { + "id": "recording-1", + "title": "Get Lucky", + "length": 369000, + "first-release-date": "2013-04-19", + "isrcs": ["USQX91300105"], + "artist-credit": [ + {"artist": {"name": "Daft Punk"}}, + {"artist": {"name": "Pharrell Williams"}}, + ], + "releases": [ + { + "title": "Random Access Memories", + "status": "Official", + "date": "2013-05-17", + "artist-credit": [{"artist": {"name": "Daft Punk"}}], + "release-group": { + "id": "release-group-1", + "primary-type": "Album", + "secondary-types": [], + }, + } + ], + } + ) + + assert info is not None + assert info.source == "musicbrainz" + assert info.media_id == "recording-1" + assert info.artists == ["Daft Punk", "Pharrell Williams"] + assert info.album == "Random Access Memories" + assert info.album_artist == "Daft Punk" + assert info.year == 2013 + assert info.duration == 369 + assert info.cover_url.endswith("/release-group-1/front-500") + + +def test_search_music_normalizes_candidates(monkeypatch): + """搜索接口应把 MusicBrainz 列表转换为 MusicInfo 候选。""" + module = MusicBrainzModule() + monkeypatch.setattr( + module, + "_request_json", + lambda *_args, **_kwargs: { + "recordings": [{"id": "recording-1", "title": "晴天"}] + }, + ) + + results = module.search_music(MusicMeta(title="晴天"), limit=5) + + assert len(results) == 1 + assert results[0].title == "晴天" + + +def test_recognize_music_ignores_other_sources(monkeypatch): + """MusicBrainz 模块不应处理其他元数据源的详情请求。""" + module = MusicBrainzModule() + called = False + + def fake_request(*_args, **_kwargs): + """记录测试中是否发生了不应出现的网络调用。""" + nonlocal called + called = True + return None + + monkeypatch.setattr(module, "_request_json", fake_request) + + assert module.recognize_music("netease", "song-1") is None + assert called is False + + +def test_recognize_music_fetches_recording_detail(monkeypatch): + """MusicBrainz 详情请求应按 Recording ID 返回标准音乐信息。""" + module = MusicBrainzModule() + monkeypatch.setattr( + module, + "_request_json", + lambda path, params=None: { + "id": path.rsplit("/", 1)[-1], + "title": "晴天", + }, + ) + + result = module.recognize_music("musicbrainz", "recording-1") + + assert result is not None + assert result.media_id == "recording-1" + assert result.title == "晴天" diff --git a/tests/test_recommend_chain.py b/tests/test_recommend_chain.py index 165e23cb8..462f42045 100644 --- a/tests/test_recommend_chain.py +++ b/tests/test_recommend_chain.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch import pytest from app.chain.recommend import RecommendChain +from app.core.music import MusicInfo from app.core.cache import TTLCache @@ -93,3 +94,41 @@ def test_async_recommend_methods_do_not_cache_empty_result( assert asyncio.run(recommend_method(page=1)) == [] assert backend_chain.return_value.async_run_module.call_count == 2 + + +def test_music_weekly_uses_music_chart(): + """同步推荐缓存应从本周音乐榜单生成通用媒体字典。""" + chain = RecommendChain() + with patch("app.chain.recommend.MusicChain") as music_chain: + music_chain.return_value.chart.return_value = [ + MusicInfo(source="musicbrainz", media_id="recording-1", title="晴天") + ] + + result = chain.music_weekly(page=2, count=10) + + assert result[0]["media_id"] == "recording-1" + music_chain.return_value.chart.assert_called_once_with( + range_name="this_week", + page=2, + count=10, + ) + + +def test_async_music_weekly_uses_music_chart(): + """异步推荐接口应从本周音乐榜单返回统一媒体字典。""" + chain = RecommendChain() + with patch("app.chain.recommend.MusicChain") as music_chain: + music_chain.return_value.async_chart = AsyncMock( + return_value=[ + MusicInfo(source="musicbrainz", media_id="recording-1", title="晴天") + ] + ) + + result = asyncio.run(chain.async_music_weekly(page=1, count=30)) + + assert result[0]["type"] == "音乐" + music_chain.return_value.async_chart.assert_awaited_once_with( + range_name="this_week", + page=1, + count=30, + ) diff --git a/tests/test_resource_v3.py b/tests/test_resource_v3.py new file mode 100644 index 000000000..aa20ee46d --- /dev/null +++ b/tests/test_resource_v3.py @@ -0,0 +1,31 @@ +from pathlib import Path + +from app.core.config import settings +from app.helper.resource import ResourceHelper + + +ROOT_DIR = Path(__file__).resolve().parents[1] + + +def test_resource_helper_uses_v3_only(): + """在线资源更新器必须只请求 V3 清单、目录和站点索引文件。""" + assert settings.VERSION_FLAG == "v3" + assert settings.RESOURCE_VERSION_FLAG == "v3" + assert ResourceHelper._repo.endswith("/package.v3.json") + assert ResourceHelper._files_api.endswith("/resources.v3") + assert ResourceHelper._get_needed_files()[0] == "user.sites.v3.bin" + + +def test_install_and_docker_paths_do_not_reference_v2_resources(): + """本地安装和 Docker 资源流程不得包含 V2 资源回退。""" + paths = [ + ROOT_DIR / "scripts" / "local_setup.py", + ROOT_DIR / "docker" / "Dockerfile", + ROOT_DIR / "docker" / "update.sh", + ] + + for path in paths: + content = path.read_text(encoding="utf-8") + assert "resources.v2" not in content + assert "user.sites.v2.bin" not in content + assert "resources.v3" in content