feat(v3): add music automation workflow

This commit is contained in:
jxxghp
2026-08-07 21:50:22 +08:00
parent c6bd396794
commit 05d15a04f5
65 changed files with 3505 additions and 257 deletions
+2 -1
View File
@@ -1,6 +1,6 @@
from fastapi import APIRouter 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, \ media, douban, search, plugin, tmdb, history, system, download, dashboard, \
transfer, mediaserver, bangumi, storage, discover, recommend, workflow, torrent, mcp, mfa, openai, anthropic, llm, notification 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(agent.router, prefix="/message/agent", tags=["agent"])
api_router.include_router(webhook.router, prefix="/webhook", tags=["webhook"]) api_router.include_router(webhook.router, prefix="/webhook", tags=["webhook"])
api_router.include_router(subscribe.router, prefix="/subscribe", tags=["subscribe"]) 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(media.router, prefix="/media", tags=["media"])
api_router.include_router(search.router, prefix="/search", tags=["search"]) api_router.include_router(search.router, prefix="/search", tags=["search"])
api_router.include_router(douban.router, prefix="/douban", tags=["douban"]) api_router.include_router(douban.router, prefix="/douban", tags=["douban"])
+13 -8
View File
@@ -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 fastapi import APIRouter, Depends, Body
from app import schemas from app import schemas
from app.chain.download import DownloadChain from app.chain.download import DownloadChain
from app.chain.media import MediaChain from app.chain.media import MediaChain
from app.chain.music import MusicChain
from app.core.context import MediaInfo, Context, SubtitleInfo, TorrentInfo from app.core.context import MediaInfo, Context, SubtitleInfo, TorrentInfo
from app.core.metainfo import MetaInfo from app.core.metainfo import MetaInfo
from app.core.music import MusicInfo
from app.core.security import verify_token from app.core.security import verify_token
from app.db.models.user import User from app.db.models.user import User
from app.db.site_oper import SiteOper from app.db.site_oper import SiteOper
@@ -17,7 +19,7 @@ from app.schemas.types import SystemConfigKey
from app.utils.security import SecurityUtils from app.utils.security import SecurityUtils
router = APIRouter() router = APIRouter()
MediaSource = Literal["themoviedb", "douban", "bangumi", "anilist"] MediaSource = Literal["themoviedb", "douban", "bangumi", "anilist", "musicbrainz"]
def _prepare_subtitle_download(subtitle: SubtitleInfo) -> tuple[bool, str]: def _prepare_subtitle_download(subtitle: SubtitleInfo) -> tuple[bool, str]:
@@ -57,7 +59,7 @@ def current(
@router.post("/", summary="添加下载(含媒体信息)", response_model=schemas.Response) @router.post("/", summary="添加下载(含媒体信息)", response_model=schemas.Response)
def download( def download(
media_in: schemas.MediaInfo, media_in: Union[schemas.MusicInfo, schemas.MediaInfo],
torrent_in: schemas.TorrentInfo, torrent_in: schemas.TorrentInfo,
downloader: Annotated[str | None, Body()] = None, downloader: Annotated[str | None, Body()] = None,
save_path: Annotated[str | None, Body()] = None, save_path: Annotated[str | None, Body()] = None,
@@ -66,11 +68,14 @@ def download(
""" """
添加下载任务(含媒体信息) 添加下载任务(含媒体信息)
""" """
# 元数据 if isinstance(media_in, schemas.MusicInfo):
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description) mediainfo = MusicInfo.from_dict(media_in.model_dump())
# 媒体信息 metainfo = MusicChain.to_meta(mediainfo)
mediainfo = MediaInfo() metainfo.org_string = torrent_in.title
mediainfo.from_dict(media_in.model_dump()) else:
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
mediainfo = MediaInfo()
mediainfo.from_dict(media_in.model_dump())
# 种子信息 # 种子信息
torrentinfo = TorrentInfo() torrentinfo = TorrentInfo()
torrentinfo.from_dict(torrent_in.model_dump()) torrentinfo.from_dict(torrent_in.model_dump())
+70
View File
@@ -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]
+14
View File
@@ -60,6 +60,20 @@ async def bangumi_calendar(
return await RecommendChain().async_bangumi_calendar(page=page, count=count) 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( @router.get(
"/douban_showing", summary="豆瓣正在热映", response_model=List[schemas.MediaInfo] "/douban_showing", summary="豆瓣正在热映", response_model=List[schemas.MediaInfo]
) )
+12 -2
View File
@@ -475,6 +475,7 @@ async def search_by_id(
async def search_by_title_stream( async def search_by_title_stream(
request: Request, request: Request,
keyword: Optional[str] = None, keyword: Optional[str] = None,
mtype: Optional[str] = None,
page: Optional[int] = 0, page: Optional[int] = 0,
sites: Optional[str] = None, sites: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_resource_token), _: schemas.TokenPayload = Depends(verify_resource_token),
@@ -484,7 +485,11 @@ async def search_by_title_stream(
""" """
event_source = SearchChain().async_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( return StreamingResponse(
_stream_search_events(request, event_source), _stream_search_events(request, event_source),
@@ -496,6 +501,7 @@ async def search_by_title_stream(
@router.get("/title", summary="模糊搜索资源", response_model=schemas.Response) @router.get("/title", summary="模糊搜索资源", response_model=schemas.Response)
async def search_by_title( async def search_by_title(
keyword: Optional[str] = None, keyword: Optional[str] = None,
mtype: Optional[str] = None,
page: Optional[int] = 0, page: Optional[int] = 0,
sites: Optional[str] = None, sites: Optional[str] = None,
_: schemas.TokenPayload = Depends(verify_token), _: schemas.TokenPayload = Depends(verify_token),
@@ -504,7 +510,11 @@ async def search_by_title(
根据名称模糊搜索站点资源,支持分页,关键词为空是返回首页资源 根据名称模糊搜索站点资源,支持分页,关键词为空是返回首页资源
""" """
torrents = await SearchChain().async_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: if not torrents:
return schemas.Response(success=False, message="未搜索到任何资源") return schemas.Response(success=False, message="未搜索到任何资源")
+7 -2
View File
@@ -30,7 +30,7 @@ from app.db.user_oper import (
from app.helper.sites import SitesHelper # noqa from app.helper.sites import SitesHelper # noqa
from app.log import logger from app.log import logger
from app.scheduler import Scheduler 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 from app.utils.string import StringUtils
router = APIRouter() router = APIRouter()
@@ -394,6 +394,7 @@ async def site_category(
async def site_resource( async def site_resource(
site_id: int, site_id: int,
keyword: Optional[str] = None, keyword: Optional[str] = None,
mtype: Optional[str] = None,
cat: Optional[str] = None, cat: Optional[str] = None,
page: Optional[int] = 0, page: Optional[int] = 0,
db: AsyncSession = Depends(get_async_db), db: AsyncSession = Depends(get_async_db),
@@ -409,7 +410,11 @@ async def site_resource(
detail=f"站点 {site_id} 不存在", detail=f"站点 {site_id} 不存在",
) )
torrents = await TorrentsChain().async_browse( 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: if not torrents:
return [] return []
+7 -4
View File
@@ -173,10 +173,13 @@ async def create_subscribe(
mtype = None mtype = None
# 非 TMDB 来源的标题可能自带季标记,入库前统一拆分。 # 非 TMDB 来源的标题可能自带季标记,入库前统一拆分。
if ( if (
subscribe_in.doubanid mtype != MediaType.MUSIC
or subscribe_in.bangumiid and (
or subscribe_in.anilistid subscribe_in.doubanid
or normalize_media_source(subscribe_in.media_source) not in (None, "themoviedb") 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) meta = MetaInfo(subscribe_in.name)
subscribe_in.name = meta.name subscribe_in.name = meta.name
+6 -2
View File
@@ -1272,6 +1272,7 @@ class ChainBase(metaclass=ABCMeta):
keyword: Optional[str] = None, keyword: Optional[str] = None,
cat: Optional[str] = None, cat: Optional[str] = None,
page: Optional[int] = 0, page: Optional[int] = 0,
mtype: Optional[MediaType] = None,
) -> List[TorrentInfo]: ) -> List[TorrentInfo]:
""" """
获取站点最新一页的种子,多个站点需要多线程处理 获取站点最新一页的种子,多个站点需要多线程处理
@@ -1279,10 +1280,11 @@ class ChainBase(metaclass=ABCMeta):
:param keyword: 标题 :param keyword: 标题
:param cat: 分类 :param cat: 分类
:param page: 页码 :param page: 页码
:param mtype: 媒体类型
:reutrn: 种子资源列表 :reutrn: 种子资源列表
""" """
return self.run_module( 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( async def async_refresh_torrents(
@@ -1291,6 +1293,7 @@ class ChainBase(metaclass=ABCMeta):
keyword: Optional[str] = None, keyword: Optional[str] = None,
cat: Optional[str] = None, cat: Optional[str] = None,
page: Optional[int] = 0, page: Optional[int] = 0,
mtype: Optional[MediaType] = None,
) -> List[TorrentInfo]: ) -> List[TorrentInfo]:
""" """
异步获取站点最新一页的种子,多个站点需要多线程处理 异步获取站点最新一页的种子,多个站点需要多线程处理
@@ -1298,10 +1301,11 @@ class ChainBase(metaclass=ABCMeta):
:param keyword: 标题 :param keyword: 标题
:param cat: 分类 :param cat: 分类
:param page: 页码 :param page: 页码
:param mtype: 媒体类型
:reutrn: 种子资源列表 :reutrn: 种子资源列表
""" """
return await self.async_run_module( 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( def filter_torrents(
+48 -1
View File
@@ -16,6 +16,7 @@ from app.chain.storage import StorageChain
from app.core.cache import FileCache from app.core.cache import FileCache
from app.core.config import settings, global_vars from app.core.config import settings, global_vars
from app.core.context import MediaInfo, SubtitleInfo, TorrentInfo, Context 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.event import eventmanager, Event
from app.core.meta import MetaBase from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo from app.core.metainfo import MetaInfo
@@ -61,6 +62,25 @@ class DownloadChain(ChainBase):
".rar": "rar", ".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 @staticmethod
def _normalize_indirect_download_url(url: str, base_url: Optional[str] = None) -> str: 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()), date=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
media_category=_media.category, media_category=_media.category,
episode_group=_media.episode_group, episode_group=_media.episode_group,
note={"source": source}, note=self._build_download_note(source, _media, _meta),
custom_words=custom_words custom_words=custom_words
) )
@@ -1183,6 +1203,11 @@ class DownloadChain(ChainBase):
""" """
return _context.media_info.title_year 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}") logger.debug(f"Initial contexts: {len(contexts)} items, Downloader: {downloader}")
event_data = ResourceSelectionEventData( event_data = ResourceSelectionEventData(
@@ -1246,6 +1271,28 @@ class DownloadChain(ChainBase):
else: else:
__remember_context_failure(context) __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: if no_exists:
logger.info(f"开始匹配电视剧整季:{no_exists}") logger.info(f"开始匹配电视剧整季:{no_exists}")
+2
View File
@@ -696,6 +696,8 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
""" """
if not mediainfo: if not mediainfo:
return None return None
if mediainfo.type == MediaType.MUSIC:
return mediainfo
if mediainfo.tmdb_id and mediainfo.tmdb_info and mediainfo.genre_ids: if mediainfo.tmdb_id and mediainfo.tmdb_info and mediainfo.genre_ids:
return mediainfo return mediainfo
tmdb_meta = self._build_tmdb_supplement_meta(mediainfo, metainfo) tmdb_meta = self._build_tmdb_supplement_meta(mediainfo, metainfo)
+180
View File
@@ -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<artist>.+?)\s+[-–—]\s+(?P<title>.+?)\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()
+24
View File
@@ -5,6 +5,7 @@ import pillow_avif # noqa 用于自动注册AVIF支持
from app.chain import ChainBase from app.chain import ChainBase
from app.chain.bangumi import BangumiChain from app.chain.bangumi import BangumiChain
from app.chain.douban import DoubanChain from app.chain.douban import DoubanChain
from app.chain.music import MusicChain
from app.chain.tmdb import TmdbChain from app.chain.tmdb import TmdbChain
from app.core.cache import cached, fresh from app.core.cache import cached, fresh
from app.core.config import settings, global_vars from app.core.config import settings, global_vars
@@ -55,6 +56,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
self.douban_tv_animation, self.douban_tv_animation,
self.douban_movie_hot, self.douban_movie_hot,
self.douban_tv_hot, self.douban_tv_hot,
self.music_weekly,
] ]
# 缓存并刷新所有推荐数据 # 缓存并刷新所有推荐数据
@@ -174,6 +176,17 @@ class RecommendChain(ChainBase, metaclass=Singleton):
page=page) page=page)
return [movie.to_dict() for movie in movies] if movies else [] 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) @log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True)
def tmdb_tvs(self, sort_by: Optional[str] = "popularity.desc", 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) movies = await DoubanChain().async_run_module("async_movie_showing", page=page, count=count)
return [media.to_dict() for media in movies] if movies else [] 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) @log_execution_time(logger=logger)
@cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True) @cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True)
async def async_douban_movies(self, sort: Optional[str] = "R", tags: Optional[str] = "", async def async_douban_movies(self, sort: Optional[str] = "R", tags: Optional[str] = "",
+254 -33
View File
@@ -12,9 +12,11 @@ from typing import List, Optional
from fastapi.concurrency import run_in_threadpool from fastapi.concurrency import run_in_threadpool
from app.chain import ChainBase from app.chain import ChainBase
from app.chain.music import MusicChain
from app.core.config import global_vars, settings from app.core.config import global_vars, settings
from app.core.context import Context from app.core.context import Context
from app.core.context import MediaInfo, SubtitleInfo, TorrentInfo 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.event import eventmanager, Event
from app.core.metainfo import MetaInfo from app.core.metainfo import MetaInfo
from app.db.systemconfig_oper import SystemConfigOper from app.db.systemconfig_oper import SystemConfigOper
@@ -544,10 +546,16 @@ class SearchChain(ChainBase):
season=season, season=season,
sites=sites, sites=sites,
) )
mediainfo = self.recognize_media( if mtype == MediaType.MUSIC:
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid, mediainfo = MusicChain().recognize(
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype, 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: if not mediainfo:
logger.error(f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} 媒体信息识别失败!') logger.error(f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} 媒体信息识别失败!')
return [] return []
@@ -566,18 +574,23 @@ class SearchChain(ChainBase):
return results return results
def search_by_title(self, title: str, page: Optional[int] = 0, 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 title: 标题为空时返回所有站点首页内容
:param page: 页码 :param page: 页码
:param sites: 站点ID列表 :param sites: 站点ID列表
:param cache_local: 是否缓存到本地 :param cache_local: 是否缓存到本地
:param mtype: 限定站点资源分类
:param rule_groups: 指定过滤规则组为空时使用默认搜索过滤规则
""" """
if cache_local: if cache_local:
self.cancel_ai_recommend() self.cancel_ai_recommend()
self.save_last_search_params( self.save_last_search_params(
keyword=title, keyword=title,
mtype=mtype,
area="title", area="title",
sites=sites, sites=sites,
) )
@@ -586,18 +599,24 @@ class SearchChain(ChainBase):
else: else:
logger.info(f'开始浏览资源,站点:{sites} ...') 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: if not torrents:
logger.warn(f'{title} 未搜索到资源') logger.warn(f'{title} 未搜索到资源')
return [] return []
torrents = self.__filter_title_search_torrents(torrents=torrents) torrents = self.__filter_title_search_torrents(
torrents=torrents,
rule_groups=rule_groups,
)
if not torrents: if not torrents:
logger.warn(f'{title} 没有符合过滤规则的资源') logger.warn(f'{title} 没有符合过滤规则的资源')
return [] return []
# 组装上下文 # 组装上下文
contexts = [ contexts = [
Context( Context(
meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description), meta_info=self._build_title_search_meta(torrent, mtype),
torrent_info=torrent, torrent_info=torrent,
resource_source="search", resource_source="search",
) for torrent in torrents ) for torrent in torrents
@@ -860,10 +879,16 @@ class SearchChain(ChainBase):
season=season, season=season,
sites=sites, sites=sites,
) )
mediainfo = await self.async_recognize_media( if mtype == MediaType.MUSIC:
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid, mediainfo = await MusicChain().async_recognize(
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype, 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: if not mediainfo:
logger.error( logger.error(
f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} ' f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} '
@@ -885,18 +910,23 @@ class SearchChain(ChainBase):
return results return results
async def async_search_by_title(self, title: str, page: Optional[int] = 0, 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 title: 标题为空时返回所有站点首页内容
:param page: 页码 :param page: 页码
:param sites: 站点ID列表 :param sites: 站点ID列表
:param cache_local: 是否缓存到本地 :param cache_local: 是否缓存到本地
:param mtype: 限定站点资源分类
:param rule_groups: 指定过滤规则组为空时使用默认搜索过滤规则
""" """
if cache_local: if cache_local:
self.cancel_ai_recommend() self.cancel_ai_recommend()
await self.async_save_last_search_params( await self.async_save_last_search_params(
keyword=title, keyword=title,
mtype=mtype,
area="title", area="title",
sites=sites, sites=sites,
) )
@@ -905,18 +935,25 @@ class SearchChain(ChainBase):
else: else:
logger.info(f'开始浏览资源,站点:{sites} ...') 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: if not torrents:
logger.warn(f'{title} 未搜索到资源') logger.warn(f'{title} 未搜索到资源')
return [] 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: if not torrents:
logger.warn(f'{title} 没有符合过滤规则的资源') logger.warn(f'{title} 没有符合过滤规则的资源')
return [] return []
# 组装上下文 # 组装上下文
contexts = [ contexts = [
Context( Context(
meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description), meta_info=self._build_title_search_meta(torrent, mtype),
torrent_info=torrent, torrent_info=torrent,
resource_source="search", resource_source="search",
) for torrent in torrents ) 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, async def async_search_by_title_stream(self, title: str, page: Optional[int] = 0,
sites: List[int] = None, 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() self.cancel_ai_recommend()
await self.async_save_last_search_params( await self.async_save_last_search_params(
keyword=title, keyword=title,
mtype=mtype,
area="title", area="title",
sites=sites, sites=sites,
) )
@@ -945,8 +985,10 @@ class SearchChain(ChainBase):
logger.info(f'开始渐进式浏览资源,站点:{sites} ...') logger.info(f'开始渐进式浏览资源,站点:{sites} ...')
contexts: List[Context] = [] contexts: List[Context] = []
rule_groups: List[str] = SystemConfigOper().get(SystemConfigKey.SearchFilterRuleGroups) or [] if rule_groups is None:
async for event in self.__async_search_all_sites_stream(keyword=title, sites=sites, page=page): 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 = event.pop("items", []) or []
result = await run_in_threadpool( result = await run_in_threadpool(
self.__filter_title_search_torrents, self.__filter_title_search_torrents,
@@ -955,7 +997,7 @@ class SearchChain(ChainBase):
) )
batch_contexts = [ batch_contexts = [
Context( Context(
meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description), meta_info=self._build_title_search_meta(torrent, mtype),
torrent_info=torrent, torrent_info=torrent,
resource_source="search", resource_source="search",
) )
@@ -982,6 +1024,19 @@ class SearchChain(ChainBase):
"total_items": len(contexts) "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, def __filter_title_search_torrents(self,
torrents: List[TorrentInfo], torrents: List[TorrentInfo],
rule_groups: Optional[List[str]] = None) -> List[TorrentInfo]: rule_groups: Optional[List[str]] = None) -> List[TorrentInfo]:
@@ -1027,10 +1082,16 @@ class SearchChain(ChainBase):
season=season, season=season,
sites=sites, sites=sites,
) )
mediainfo = await self.async_recognize_media( if mtype == MediaType.MUSIC:
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid, mediainfo = await MusicChain().async_recognize(
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype, 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: if not mediainfo:
logger.error( logger.error(
f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} ' 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 return list({f"{t.torrent_info.site_name}_{t.torrent_info.title}_{t.torrent_info.description}": t
for t in _torrents}.values()) 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, def process(self, mediainfo: MediaInfo,
keyword: Optional[str] = None, keyword: Optional[str] = None,
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None, no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
@@ -1327,6 +1497,15 @@ class SearchChain(ChainBase):
:param filter_params: 过滤参数 :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: if not mediainfo.tmdb_id:
meta = MetaInfo(title=mediainfo.title) meta = MetaInfo(title=mediainfo.title)
@@ -1411,6 +1590,15 @@ class SearchChain(ChainBase):
:param filter_params: 过滤参数 :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: if not mediainfo.tmdb_id:
meta = MetaInfo(title=mediainfo.title) 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: if not mediainfo.tmdb_id:
meta = MetaInfo(title=mediainfo.title) meta = MetaInfo(title=mediainfo.title)
@@ -1925,7 +2140,8 @@ class SearchChain(ChainBase):
mediainfo: Optional[MediaInfo] = None, mediainfo: Optional[MediaInfo] = None,
sites: List[int] = None, sites: List[int] = None,
page: Optional[int] = 0, page: Optional[int] = 0,
area: Optional[str] = "title") -> Optional[List[TorrentInfo]]: area: Optional[str] = "title",
mtype: Optional[MediaType] = None) -> Optional[List[TorrentInfo]]:
""" """
多线程搜索多个站点 多线程搜索多个站点
:param mediainfo: 识别的媒体信息 :param mediainfo: 识别的媒体信息
@@ -1933,6 +2149,7 @@ class SearchChain(ChainBase):
:param sites: 指定站点ID列表如有则只搜索指定站点否则搜索所有站点 :param sites: 指定站点ID列表如有则只搜索指定站点否则搜索所有站点
:param page: 搜索页码 :param page: 搜索页码
:param area: 搜索区域 title or imdbid :param area: 搜索区域 title or imdbid
:param mtype: 未提供媒体详情时使用的站点资源分类
:reutrn: 资源列表 :reutrn: 资源列表
""" """
# 未开启的站点不搜索 # 未开启的站点不搜索
@@ -1980,13 +2197,13 @@ class SearchChain(ChainBase):
# 搜索IMDBID # 搜索IMDBID
task = executor.submit(self.search_torrents, site=site, task = executor.submit(self.search_torrents, site=site,
keyword=search_keyword, keyword=search_keyword,
mtype=mediainfo.type if mediainfo else None, mtype=mediainfo.type if mediainfo else mtype,
page=search_page) page=search_page)
else: else:
# 搜索标题 # 搜索标题
task = executor.submit(self.search_torrents, site=site, task = executor.submit(self.search_torrents, site=site,
keyword=search_keyword, keyword=search_keyword,
mtype=mediainfo.type if mediainfo else None, mtype=mediainfo.type if mediainfo else mtype,
page=search_page) page=search_page)
pending_tasks[task] = (site, page_index, search_page, search_keyword) pending_tasks[task] = (site, page_index, search_page, search_keyword)
@@ -2037,7 +2254,8 @@ class SearchChain(ChainBase):
mediainfo: Optional[MediaInfo] = None, mediainfo: Optional[MediaInfo] = None,
sites: List[int] = None, sites: List[int] = None,
page: Optional[int] = 0, page: Optional[int] = 0,
area: Optional[str] = "title") -> Optional[List[TorrentInfo]]: area: Optional[str] = "title",
mtype: Optional[MediaType] = None) -> Optional[List[TorrentInfo]]:
""" """
异步搜索多个站点 异步搜索多个站点
:param mediainfo: 识别的媒体信息 :param mediainfo: 识别的媒体信息
@@ -2045,6 +2263,7 @@ class SearchChain(ChainBase):
:param sites: 指定站点ID列表如有则只搜索指定站点否则搜索所有站点 :param sites: 指定站点ID列表如有则只搜索指定站点否则搜索所有站点
:param page: 搜索页码 :param page: 搜索页码
:param area: 搜索区域 title or imdbid :param area: 搜索区域 title or imdbid
:param mtype: 未提供媒体详情时使用的站点资源分类
:reutrn: 资源列表 :reutrn: 资源列表
""" """
# 未开启的站点不搜索 # 未开启的站点不搜索
@@ -2088,12 +2307,12 @@ class SearchChain(ChainBase):
# 搜索IMDBID # 搜索IMDBID
return await self.async_search_torrents(site=site, return await self.async_search_torrents(site=site,
keyword=mediainfo.imdb_id if mediainfo else None, 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) page=search_page)
# 搜索标题 # 搜索标题
return await self.async_search_torrents(site=site, return await self.async_search_torrents(site=site,
keyword=keyword, keyword=keyword,
mtype=mediainfo.type if mediainfo else None, mtype=mediainfo.type if mediainfo else mtype,
page=search_page) page=search_page)
pending_tasks = {} pending_tasks = {}
@@ -2161,7 +2380,8 @@ class SearchChain(ChainBase):
mediainfo: Optional[MediaInfo] = None, mediainfo: Optional[MediaInfo] = None,
sites: List[int] = None, sites: List[int] = None,
page: Optional[int] = 0, 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: 识别的媒体信息 :param mediainfo: 识别的媒体信息
@@ -2169,6 +2389,7 @@ class SearchChain(ChainBase):
:param sites: 指定站点ID列表如有则只搜索指定站点否则搜索所有站点 :param sites: 指定站点ID列表如有则只搜索指定站点否则搜索所有站点
:param page: 搜索页码 :param page: 搜索页码
:param area: 搜索区域 title or imdbid :param area: 搜索区域 title or imdbid
:param mtype: 未提供媒体详情时使用的站点资源分类
""" """
indexer_sites = [] indexer_sites = []
@@ -2219,12 +2440,12 @@ class SearchChain(ChainBase):
if area == "imdbid": if area == "imdbid":
site_result = await self.async_search_torrents(site=site, site_result = await self.async_search_torrents(site=site,
keyword=mediainfo.imdb_id if mediainfo else None, 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) page=search_page)
else: else:
site_result = await self.async_search_torrents(site=site, site_result = await self.async_search_torrents(site=site,
keyword=keyword, keyword=keyword,
mtype=mediainfo.type if mediainfo else None, mtype=mediainfo.type if mediainfo else mtype,
page=search_page) page=search_page)
return site_result or [] return site_result or []
+198 -36
View File
@@ -12,6 +12,7 @@ from app.chain import ChainBase
from app.chain.download import DownloadChain from app.chain.download import DownloadChain
from app.chain.media import MediaChain from app.chain.media import MediaChain
from app.chain.mediaserver import MediaServerChain from app.chain.mediaserver import MediaServerChain
from app.chain.music import MusicChain
from app.chain.search import SearchChain from app.chain.search import SearchChain
from app.chain.tmdb import TmdbChain from app.chain.tmdb import TmdbChain
from app.chain.torrents import TorrentsChain 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 import MetaBase
from app.core.meta.words import WordsMatcher from app.core.meta.words import WordsMatcher
from app.core.metainfo import MetaInfo from app.core.metainfo import MetaInfo
from app.core.music import MusicInfo, MusicMeta
from app.db.downloadhistory_oper import DownloadHistoryOper from app.db.downloadhistory_oper import DownloadHistoryOper
from app.db.models.subscribe import Subscribe from app.db.models.subscribe import Subscribe
from app.db.site_oper import SiteOper from app.db.site_oper import SiteOper
@@ -53,10 +55,17 @@ from app.utils.media import (
subscribe_interaction_manager = SlashInteractionManager() 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 = MetaInfo(subscribe.name)
meta.year = subscribe.year meta.year = subscribe.year
meta.begin_season = subscribe.season meta.begin_season = subscribe.season
@@ -842,7 +851,7 @@ class SubscribeChain(ChainBase):
:param key: 配置键 :param key: 配置键
:return: 配置值 :return: 配置值
""" """
return { defaults = {
'quality': self.__get_default_subscribe_config(mtype, "quality") if not kwargs.get( 'quality': self.__get_default_subscribe_config(mtype, "quality") if not kwargs.get(
"quality") else kwargs.get("quality"), "quality") else kwargs.get("quality"),
'resolution': self.__get_default_subscribe_config(mtype, "resolution") if not kwargs.get( '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': self.__get_default_subscribe_config(mtype, "filter_groups") if not kwargs.get(
"filter_groups") else kwargs.get("filter_groups") "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, def add(self, title: str, year: str,
mtype: MediaType = None, mtype: MediaType = None,
@@ -894,7 +911,7 @@ class SubscribeChain(ChainBase):
logger.info(f'开始添加订阅,标题:{title} ...') logger.info(f'开始添加订阅,标题:{title} ...')
mediainfo = None mediainfo = None
metainfo = MetaInfo(title) metainfo = MusicChain.parse_query(title) if mtype == MediaType.MUSIC else MetaInfo(title)
if year: if year:
metainfo.year = year metainfo.year = year
if mtype: if mtype:
@@ -914,7 +931,16 @@ class SubscribeChain(ChainBase):
) )
if resolved_source and resolved_media_id: if resolved_source and resolved_media_id:
media_source, media_id = resolved_source, 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( mediainfo = self.recognize_media(
meta=metainfo, meta=metainfo,
mtype=mtype, mtype=mtype,
@@ -930,14 +956,14 @@ class SubscribeChain(ChainBase):
elif mediaid: elif mediaid:
mediainfo = self.__get_event_media(mediaid, metainfo) 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) meta = MetaInfo(mediainfo.title)
mediainfo.title = meta.name mediainfo.title = meta.name
if season is None: if season is None:
season = meta.begin_season season = meta.begin_season
# 明确来源时只允许在同一来源内按名称兜底,不能切换主识别源。 # 明确来源时只允许在同一来源内按名称兜底,不能切换主识别源。
if not mediainfo: if not mediainfo and mtype != MediaType.MUSIC:
mediainfo = MediaChain().recognize_by_meta( mediainfo = MediaChain().recognize_by_meta(
metainfo, metainfo,
source=media_source, source=media_source,
@@ -994,13 +1020,14 @@ class SubscribeChain(ChainBase):
season = None 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 mediainfo.douban_id = doubanid
if bangumiid: if bangumiid and mediainfo.type != MediaType.MUSIC:
mediainfo.bangumi_id = bangumiid mediainfo.bangumi_id = bangumiid
if anilistid: if anilistid and mediainfo.type != MediaType.MUSIC:
mediainfo.anilist_id = anilistid mediainfo.anilist_id = anilistid
media_source, media_id = resolve_media_identity( media_source, media_id = resolve_media_identity(
@@ -1029,6 +1056,8 @@ class SubscribeChain(ChainBase):
elif message: elif message:
if mediainfo.type == MediaType.TV: if mediainfo.type == MediaType.TV:
link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub') link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub')
elif mediainfo.type == MediaType.MUSIC:
link = settings.MP_DOMAIN('#/subscribe/music?tab=mysub')
else: else:
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub') link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
# 订阅成功按规则发送消息 # 订阅成功按规则发送消息
@@ -1100,7 +1129,7 @@ class SubscribeChain(ChainBase):
logger.info(f'开始添加订阅,标题:{title} ...') logger.info(f'开始添加订阅,标题:{title} ...')
mediainfo = None mediainfo = None
metainfo = MetaInfo(title) metainfo = MusicChain.parse_query(title) if mtype == MediaType.MUSIC else MetaInfo(title)
if year: if year:
metainfo.year = year metainfo.year = year
if mtype: if mtype:
@@ -1120,7 +1149,16 @@ class SubscribeChain(ChainBase):
) )
if resolved_source and resolved_media_id: if resolved_source and resolved_media_id:
media_source, media_id = resolved_source, 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( mediainfo = await self.async_recognize_media(
meta=metainfo, meta=metainfo,
mtype=mtype, mtype=mtype,
@@ -1136,14 +1174,14 @@ class SubscribeChain(ChainBase):
elif mediaid: elif mediaid:
mediainfo = await self.__async_get_event_meida(mediaid, metainfo) 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) meta = MetaInfo(mediainfo.title)
mediainfo.title = meta.name mediainfo.title = meta.name
if season is None: if season is None:
season = meta.begin_season season = meta.begin_season
# 明确来源时只允许在同一来源内按名称兜底,不能切换主识别源。 # 明确来源时只允许在同一来源内按名称兜底,不能切换主识别源。
if not mediainfo: if not mediainfo and mtype != MediaType.MUSIC:
mediainfo = await MediaChain().async_recognize_by_meta( mediainfo = await MediaChain().async_recognize_by_meta(
metainfo, metainfo,
source=media_source, source=media_source,
@@ -1200,13 +1238,14 @@ class SubscribeChain(ChainBase):
season = None 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 mediainfo.douban_id = doubanid
if bangumiid: if bangumiid and mediainfo.type != MediaType.MUSIC:
mediainfo.bangumi_id = bangumiid mediainfo.bangumi_id = bangumiid
if anilistid: if anilistid and mediainfo.type != MediaType.MUSIC:
mediainfo.anilist_id = anilistid mediainfo.anilist_id = anilistid
media_source, media_id = resolve_media_identity( media_source, media_id = resolve_media_identity(
@@ -1235,6 +1274,8 @@ class SubscribeChain(ChainBase):
elif message: elif message:
if mediainfo.type == MediaType.TV: if mediainfo.type == MediaType.TV:
link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub') link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub')
elif mediainfo.type == MediaType.MUSIC:
link = settings.MP_DOMAIN('#/subscribe/music?tab=mysub')
else: else:
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub') link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
# 订阅成功按规则发送消息 # 订阅成功按规则发送消息
@@ -1300,6 +1341,105 @@ class SubscribeChain(ChainBase):
return True return True
return False 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( def search(
self, self,
sid: Optional[int] = None, sid: Optional[int] = None,
@@ -1375,6 +1515,9 @@ class SubscribeChain(ChainBase):
try: try:
search_attempted = True search_attempted = True
logger.info(f'开始搜索订阅,标题:{subscribe.name} ...') logger.info(f'开始搜索订阅,标题:{subscribe.name} ...')
if subscribe.type == MediaType.MUSIC.value:
self._search_music_subscribe(subscribe)
continue
try: try:
meta = build_subscribe_meta(subscribe) meta = build_subscribe_meta(subscribe)
except ValueError: except ValueError:
@@ -1603,7 +1746,7 @@ class SubscribeChain(ChainBase):
scene="download", scene="download",
) )
if ((no_lefts and meta.type == MediaType.TV) 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): or force):
self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo) self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
else: else:
@@ -1816,6 +1959,10 @@ class SubscribeChain(ChainBase):
}, },
) )
logger.info(f'开始匹配订阅,标题:{subscribe.name} ...') logger.info(f'开始匹配订阅,标题:{subscribe.name} ...')
if subscribe.type == MediaType.MUSIC.value:
# 音乐不参与影视预识别缓存,直接复用音乐订阅搜索链处理。
self._search_music_subscribe(subscribe)
continue
mediakey = _subscribe_media_key(subscribe) mediakey = _subscribe_media_key(subscribe)
try: try:
meta = build_subscribe_meta(subscribe) meta = build_subscribe_meta(subscribe)
@@ -2145,19 +2292,23 @@ class SubscribeChain(ChainBase):
logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}') logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}')
continue continue
# 识别媒体信息 # 识别媒体信息
mediainfo: MediaInfo = self.recognize_media( if meta.type == MediaType.MUSIC:
meta=meta, mediainfo = self._recognize_music_subscribe(subscribe)
mtype=meta.type, else:
**_subscribe_recognize_kwargs(subscribe), mediainfo: MediaInfo = self.recognize_media(
episode_group=subscribe.episode_group, meta=meta,
cache=False, mtype=meta.type,
) **_subscribe_recognize_kwargs(subscribe),
episode_group=subscribe.episode_group,
cache=False,
)
if not mediainfo: if not mediainfo:
logger.warn( logger.warn(
f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}doubanid{subscribe.doubanid}') f'未识别到媒体信息,标题:{subscribe.name}tmdbid{subscribe.tmdbid}doubanid{subscribe.doubanid}')
continue continue
# 对于电视剧,获取当前季的总集数 # 对于电视剧,获取当前季的总集数
episodes = mediainfo.seasons.get(subscribe.season) or [] episodes = (mediainfo.seasons.get(subscribe.season) or []) \
if meta.type == MediaType.TV else []
progress_update = {} progress_update = {}
if subscribe.type == MediaType.TV.value and not subscribe.manual_total_episode and len(episodes): if subscribe.type == MediaType.TV.value and not subscribe.manual_total_episode and len(episodes):
current_total_episode = len(episodes) current_total_episode = len(episodes)
@@ -2400,12 +2551,15 @@ class SubscribeChain(ChainBase):
logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}') logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}')
continue continue
# 先按订阅的主媒体身份预热对应数据源,再对 TMDB 额外预热分集接口。 # 先按订阅的主媒体身份预热对应数据源,再对 TMDB 额外预热分集接口。
mediainfo: MediaInfo = await self.async_recognize_media( if mtype == MediaType.MUSIC:
mtype=mtype, mediainfo = await self._async_recognize_music_subscribe(subscribe)
**_subscribe_recognize_kwargs(subscribe), else:
episode_group=subscribe.episode_group, mediainfo: MediaInfo = await self.async_recognize_media(
cache=False, mtype=mtype,
) **_subscribe_recognize_kwargs(subscribe),
episode_group=subscribe.episode_group,
cache=False,
)
if not mediainfo: if not mediainfo:
logger.warn( logger.warn(
f'未识别到媒体信息,标题:{subscribe.name}' f'未识别到媒体信息,标题:{subscribe.name}'
@@ -2471,6 +2625,9 @@ class SubscribeChain(ChainBase):
elif mediainfo.type == MediaType.MOVIE: elif mediainfo.type == MediaType.MOVIE:
# 电影只有一个条目,设置为 [1] # 电影只有一个条目,设置为 [1]
items = [1] items = [1]
elif mediainfo.type == MediaType.MUSIC:
# 音乐订阅和电影一样,一次成功下载即记录单项完成事实。
items = [1]
if not items: if not items:
continue continue
# 合并已下载的集数或电影项(去重) # 合并已下载的集数或电影项(去重)
@@ -2504,8 +2661,8 @@ class SubscribeChain(ChainBase):
if subscribe.type == MediaType.TV.value: if subscribe.type == MediaType.TV.value:
logger.info(f'订阅 {subscribe.name}{subscribe.season}季 已下载集数:{note}') logger.info(f'订阅 {subscribe.name}{subscribe.season}季 已下载集数:{note}')
return note return note
# 针对 Movie 类型,直接返回已下载的电影 # 针对 Movie/Music 类型,直接返回已下载的单项内容
if subscribe.type == MediaType.MOVIE.value: if subscribe.type in (MediaType.MOVIE.value, MediaType.MUSIC.value):
logger.info(f'订阅 {subscribe.name} 已下载内容:{note}') logger.info(f'订阅 {subscribe.name} 已下载内容:{note}')
return note return note
return [] return []
@@ -2836,6 +2993,8 @@ class SubscribeChain(ChainBase):
# 发送通知 # 发送通知
if mediainfo.type == MediaType.TV: if mediainfo.type == MediaType.TV:
link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub') link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub')
elif mediainfo.type == MediaType.MUSIC:
link = settings.MP_DOMAIN('#/subscribe/music?tab=mysub')
else: else:
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub') link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
# 完成订阅按规则发送消息 # 完成订阅按规则发送消息
@@ -3754,6 +3913,9 @@ class SubscribeChain(ChainBase):
if mtype == MediaType.MOVIE: if mtype == MediaType.MOVIE:
default_subscribe_key = SystemConfigKey.DefaultMovieSubscribeConfig.value default_subscribe_key = SystemConfigKey.DefaultMovieSubscribeConfig.value
if not default_subscribe_key:
return None
# 默认订阅规则 # 默认订阅规则
if hasattr(settings, default_subscribe_key): if hasattr(settings, default_subscribe_key):
value = getattr(settings, default_subscribe_key) value = getattr(settings, default_subscribe_key)
+49 -31
View File
@@ -7,8 +7,10 @@ from app.helper.sites import SitesHelper # noqa
from app.chain import ChainBase from app.chain import ChainBase
from app.chain.media import MediaChain from app.chain.media import MediaChain
from app.chain.music import MusicChain
from app.core.config import settings, global_vars from app.core.config import settings, global_vars
from app.core.context import TorrentInfo, Context, MediaInfo from app.core.context import TorrentInfo, Context, MediaInfo
from app.core.music import MusicInfo
from app.core.metainfo import MetaInfo from app.core.metainfo import MetaInfo
from app.db.site_oper import SiteOper from app.db.site_oper import SiteOper
from app.db.systemconfig_oper import SystemConfigOper from app.db.systemconfig_oper import SystemConfigOper
@@ -400,36 +402,44 @@ class TorrentsChain(ChainBase):
logger.info(f'异步种子缓存数据清理完成') logger.info(f'异步种子缓存数据清理完成')
def browse(self, domain: str, keyword: Optional[str] = None, cat: Optional[str] = None, 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分钟 浏览站点首页内容返回种子清单TTL缓存5分钟
:param domain: 站点域名 :param domain: 站点域名
:param keyword: 搜索标题 :param keyword: 搜索标题
:param cat: 搜索分类 :param cat: 搜索分类
:param page: 页码 :param page: 页码
:param mtype: 媒体类型
""" """
logger.info(f'开始获取站点 {domain} 最新种子 ...') logger.info(f'开始获取站点 {domain} 最新种子 ...')
site = SitesHelper().get_indexer(domain) site = SitesHelper().get_indexer(domain)
if not site: if not site:
logger.error(f'站点 {domain} 不存在!') logger.error(f'站点 {domain} 不存在!')
return [] 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, 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分钟 异步浏览站点首页内容返回种子清单TTL缓存5分钟
:param domain: 站点域名 :param domain: 站点域名
:param keyword: 搜索标题 :param keyword: 搜索标题
:param cat: 搜索分类 :param cat: 搜索分类
:param page: 页码 :param page: 页码
:param mtype: 媒体类型
""" """
logger.info(f'开始获取站点 {domain} 最新种子 ...') logger.info(f'开始获取站点 {domain} 最新种子 ...')
site = await SitesHelper().async_get_indexer(domain) site = await SitesHelper().async_get_indexer(domain)
if not site: if not site:
logger.error(f'站点 {domain} 不存在!') logger.error(f'站点 {domain} 不存在!')
return [] 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]: def rss(self, domain: str) -> List[TorrentInfo]:
""" """
@@ -593,29 +603,37 @@ class TorrentsChain(ChainBase):
logger.warn(f"缺少种子链接,忽略处理: {torrent.title}") logger.warn(f"缺少种子链接,忽略处理: {torrent.title}")
continue continue
logger.info(f'处理资源:{torrent.title} ...') logger.info(f'处理资源:{torrent.title} ...')
# 识别 if torrent.category == MediaType.MUSIC.value:
meta = MetaInfo(title=torrent.title, subtitle=torrent.description) meta = MusicChain.parse_query(torrent.title)
if torrent.title != meta.org_string: mediainfo = MusicInfo(
logger.info(f'种子名称应用识别词后发生改变:{torrent.title} => {meta.org_string}') title=meta.title,
# 使用站点种子分类,校正类型识别 artists=list(meta.artists),
if meta.type != MediaType.TV \ album=meta.album,
and torrent.category == MediaType.TV.value: year=meta.year,
meta.type = MediaType.TV names=[meta.title] if meta.title else [],
# 识别媒体信息 )
mediainfo: MediaInfo = MediaChain().recognize_by_meta( candidate_recognized = False
meta, match_source = "unknown"
obtain_images=False, else:
) meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
if not mediainfo: if torrent.title != meta.org_string:
logger.warn(f'{torrent.title} 未识别到媒体信息') logger.info(f'种子名称应用识别词后发生改变:{torrent.title} => {meta.org_string}')
# 存储空的媒体信息 # 使用站点种子分类,校正类型识别
mediainfo = MediaInfo() if meta.type != MediaType.TV \
# 清理多余数据,减少内存占用 and torrent.category == MediaType.TV.value:
mediainfo.clear() meta.type = MediaType.TV
candidate_recognized = bool( mediainfo = MediaChain().recognize_by_meta(
mediainfo and all(resolve_media_identity(media=mediainfo)) meta,
) obtain_images=False,
match_source = self._get_media_id_match_source(mediainfo) )
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( context = Context(
meta_info=meta, meta_info=meta,
@@ -698,13 +716,13 @@ class TorrentsChain(ChainBase):
""" """
返回候选自身识别命中的明确媒体 ID 类型 返回候选自身识别命中的明确媒体 ID 类型
""" """
if mediainfo and mediainfo.tmdb_id: if mediainfo and getattr(mediainfo, "tmdb_id", None):
return "tmdbid" return "tmdbid"
if mediainfo and mediainfo.douban_id: if mediainfo and getattr(mediainfo, "douban_id", None):
return "doubanid" return "doubanid"
if mediainfo and mediainfo.bangumi_id: if mediainfo and getattr(mediainfo, "bangumi_id", None):
return "bangumiid" return "bangumiid"
if mediainfo and mediainfo.anilist_id: if mediainfo and getattr(mediainfo, "anilist_id", None):
return "anilistid" return "anilistid"
if mediainfo and all(resolve_media_identity(media=mediainfo)): if mediainfo and all(resolve_media_identity(media=mediainfo)):
return "plugin" return "plugin"
+159 -24
View File
@@ -13,11 +13,13 @@ from app import schemas
from app.agent import ReplyMode, prompt_manager, agent_manager from app.agent import ReplyMode, prompt_manager, agent_manager
from app.chain import ChainBase from app.chain import ChainBase
from app.chain.media import MediaChain from app.chain.media import MediaChain
from app.chain.music import MusicChain
from app.chain.storage import StorageChain from app.chain.storage import StorageChain
from app.chain.subscribe import SubscribeChain from app.chain.subscribe import SubscribeChain
from app.chain.tmdb import TmdbChain from app.chain.tmdb import TmdbChain
from app.core.config import settings, global_vars from app.core.config import settings, global_vars
from app.core.context import MediaInfo from app.core.context import MediaInfo
from app.core.music import MusicInfo, MusicMeta
from app.core.event import eventmanager from app.core.event import eventmanager
from app.core.meta import MetaBase from app.core.meta import MetaBase
from app.core.metainfo import MetaInfoPath 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.systemconfig_oper import SystemConfigOper
from app.db.transferhistory_oper import TransferHistoryOper from app.db.transferhistory_oper import TransferHistoryOper
from app.helper.directory import DirectoryHelper from app.helper.directory import DirectoryHelper
from app.helper.audio import AudioMetadataHelper
from app.helper.format import EpisodeFormatRuleHelper, FormatParser from app.helper.format import EpisodeFormatRuleHelper, FormatParser
from app.helper.progress import ProgressHelper from app.helper.progress import ProgressHelper
from app.log import logger from app.log import logger
@@ -184,6 +187,8 @@ class JobManager:
# 有媒体信息 # 有媒体信息
mediainfo = deepcopy(task.mediainfo) mediainfo = deepcopy(task.mediainfo)
mediainfo.clear() mediainfo.clear()
if isinstance(mediainfo, MusicInfo):
return schemas.MusicInfo(**mediainfo.to_dict())
return schemas.MediaInfo(**mediainfo.to_dict()) return schemas.MediaInfo(**mediainfo.to_dict())
else: 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()) return schemas.MetaInfo(**task.meta.to_dict())
def add_task(self, task: TransferTask, state: Optional[str] = "waiting") -> bool: def add_task(self, task: TransferTask, state: Optional[str] = "waiting") -> bool:
@@ -1009,7 +1016,11 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
return False return False
return True if f".{fileitem.extension.lower()}" in self._audio_exts else 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) return StorageChain().is_bluray_folder(fileitem)
if not fileitem.extension: if not fileitem.extension:
return False 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: 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( self.eventmanager.send_event(
EventType.TransferFailed, EventType.TransferFailed,
@@ -1262,7 +1373,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
) )
# task整理完成事件 # task整理完成事件
if self.__is_media_file(task.fileitem): if self._is_primary_media_file(task.fileitem, task.mediainfo):
# 主要媒体文件整理完成事件 # 主要媒体文件整理完成事件
self.eventmanager.send_event( self.eventmanager.send_event(
EventType.TransferComplete, EventType.TransferComplete,
@@ -1480,7 +1591,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
not task not task
or not transferinfo or not transferinfo
or not transferinfo.need_scrape 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 return
@@ -1543,7 +1655,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
or not task.transfer_batch_id or not task.transfer_batch_id
or not transferinfo or not transferinfo
or not transferinfo.need_scrape 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 return
@@ -2935,6 +3048,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
fileitem: FileItem, fileitem: FileItem,
meta: MetaBase = None, meta: MetaBase = None,
mediainfo: MediaInfo = None, mediainfo: MediaInfo = None,
mtype: Optional[MediaType] = None,
media_source: Optional[str] = None, media_source: Optional[str] = None,
target_directory: TransferDirectoryConf = None, target_directory: TransferDirectoryConf = None,
target_storage: Optional[str] = None, target_storage: Optional[str] = None,
@@ -2962,6 +3076,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
:param fileitem: 文件项 :param fileitem: 文件项
:param meta: 元数据 :param meta: 元数据
:param mediainfo: 媒体信息 :param mediainfo: 媒体信息
:param mtype: 未提供媒体信息时使用的媒体类型提示
:param media_source: 请求级识别与刮削数据源 :param media_source: 请求级识别与刮削数据源
:param target_directory: 目标目录配置 :param target_directory: 目标目录配置
:param target_storage: 目标存储器 :param target_storage: 目标存储器
@@ -3037,9 +3152,12 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
""" """
从文件路径识别媒体信息用于判断附加文件是否属于当前主视频 从文件路径识别媒体信息用于判断附加文件是否属于当前主视频
""" """
path_meta = MetaInfoPath( if mtype == MediaType.MUSIC and source_path.suffix.lower() in self._audio_exts:
source_path, custom_words=custom_word_list path_meta = AudioMetadataHelper.read(source_path)
) else:
path_meta = MetaInfoPath(
source_path, custom_words=custom_word_list
)
if not path_meta: if not path_meta:
return None return None
return _apply_meta_overrides(path_meta, source_path) return _apply_meta_overrides(path_meta, source_path)
@@ -3462,12 +3580,19 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
download_hash=download_hash, 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: if not meta:
# 文件元数据(优先使用订阅识别词) # 文件元数据(优先使用订阅识别词)
inherited_meta = inherited_meta_map.get( inherited_meta = inherited_meta_map.get(
self.__get_file_key(file_item) 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) file_meta = deepcopy(inherited_meta)
else: else:
file_meta = _build_file_meta( file_meta = _build_file_meta(
@@ -3492,7 +3617,9 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
_download_hash = download_hash _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 ( if (
not manual not manual
and self._is_movie_year_conflict(file_meta, task_mediainfo) 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: if tmdbid or doubanid or bangumiid or anilistid or media_id:
# 有输入媒体ID时单个识别 # 有输入媒体ID时单个识别
# 识别媒体信息 # 识别媒体信息
mediainfo: MediaInfo = MediaChain().recognize_media( if mtype == MediaType.MUSIC and media_source and media_id:
tmdbid=tmdbid, mediainfo = MusicChain().recognize(
doubanid=doubanid, source=media_source,
bangumiid=bangumiid, media_id=media_id,
anilistid=anilistid, )
source=media_source, else:
mediaid=media_id, mediainfo = MediaChain().recognize_media(
mtype=mtype, tmdbid=tmdbid,
episode_group=episode_group, doubanid=doubanid,
) bangumiid=bangumiid,
anilistid=anilistid,
source=media_source,
mediaid=media_id,
mtype=mtype,
episode_group=episode_group,
)
if not mediainfo: if not mediainfo:
return ( return (
False, False,
@@ -3949,10 +4082,10 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
f"type: {mtype.value if mtype else None}", f"type: {mtype.value if mtype else None}",
) )
else: else:
if media_source: if media_source and not isinstance(mediainfo, MusicInfo):
mediainfo.scrape_source = media_source mediainfo.scrape_source = media_source
# 更新媒体图片 if not isinstance(mediainfo, MusicInfo):
self.obtain_images(mediainfo=mediainfo) self.obtain_images(mediainfo=mediainfo)
# 开始整理 # 开始整理
state, errmsg = self.do_transfer( state, errmsg = self.do_transfer(
@@ -3960,6 +4093,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
target_storage=target_storage, target_storage=target_storage,
target_path=target_path, target_path=target_path,
mediainfo=mediainfo, mediainfo=mediainfo,
mtype=mtype,
media_source=media_source, media_source=media_source,
transfer_type=transfer_type, transfer_type=transfer_type,
season=season, season=season,
@@ -3990,6 +4124,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
target_storage=target_storage, target_storage=target_storage,
target_path=target_path, target_path=target_path,
media_source=media_source, media_source=media_source,
mtype=mtype,
transfer_type=transfer_type, transfer_type=transfer_type,
season=season, season=season,
epformat=epformat, epformat=epformat,
+20 -7
View File
@@ -406,6 +406,13 @@ class ConfigModel(BaseModel):
"/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}" "/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}"
"{{fileExt}}" "{{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别名 # 重命名时支持的S0别名
RENAME_FORMAT_S0_NAMES: list = Field(default=["Specials", "SPs"]) RENAME_FORMAT_S0_NAMES: list = Field(default=["Specials", "SPs"])
# 为指定默认字幕添加.default后缀 # 为指定默认字幕添加.default后缀
@@ -901,7 +908,12 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
""" """
版本标识用来区分重大版本为空则为v1不允许外部修改 版本标识用来区分重大版本为空则为v1不允许外部修改
""" """
return "v2" return "v3"
@property
def RESOURCE_VERSION_FLAG(self) -> str:
"""返回站点索引和认证资源使用的重大版本标识。"""
return "v3"
@property @property
def USER_AGENT(self) -> str: 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: 重命名格式 :return: 重命名格式
""" """
rename_format = ( if media_type == MediaType.TV:
self.TV_RENAME_FORMAT rename_format = self.TV_RENAME_FORMAT
if media_type == MediaType.TV elif media_type == MediaType.MUSIC:
else self.MOVIE_RENAME_FORMAT rename_format = self.MUSIC_RENAME_FORMAT
) else:
rename_format = self.MOVIE_RENAME_FORMAT
# 规范重命名格式 # 规范重命名格式
rename_format = rename_format.replace("\\", "/") rename_format = rename_format.replace("\\", "/")
rename_format = re.sub(r"/+", "/", rename_format) rename_format = re.sub(r"/+", "/", rename_format)
+5 -4
View File
@@ -1,11 +1,12 @@
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime 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.config import settings
from app.core.meta import MetaBase from app.core.meta import MetaBase
from app.core.metainfo import MetaInfo from app.core.metainfo import MetaInfo
from app.core.music import MusicInfo, MusicMeta
from app.schemas.types import MediaType from app.schemas.types import MediaType
from app.utils.string import StringUtils from app.utils.string import StringUtils
@@ -69,7 +70,7 @@ class TorrentInfo:
labels: list = field(default_factory=list) labels: list = field(default_factory=list)
# 种子优先级 # 种子优先级
pri_order: int = 0 pri_order: int = 0
# 种子分类 电影/电视剧 # 种子分类 电影/电视剧/音乐
category: str = None category: str = None
def __setattr__(self, name: str, value: Any): 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 torrent_info: TorrentInfo = None
# 媒体识别失败次数 # 媒体识别失败次数
+9 -2
View File
@@ -6,7 +6,7 @@ from app.core.event import eventmanager
from app.helper.module import ModuleHelper from app.helper.module import ModuleHelper
from app.log import logger from app.log import logger
from app.schemas.types import EventType, ModuleType, DownloaderType, MediaServerType, MessageChannel, StorageSchema, \ from app.schemas.types import EventType, ModuleType, DownloaderType, MediaServerType, MessageChannel, StorageSchema, \
OtherModulesType OtherModulesType, MediaRecognizeType
from app.utils.object import ObjectUtils from app.utils.object import ObjectUtils
from app.utils.singleton import Singleton 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): def __init__(self):
# 模块列表 # 模块列表
+335
View File
@@ -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)
+1 -1
View File
@@ -21,7 +21,7 @@ class DownloadHistory(Base):
id = get_id_column() id = get_id_column()
# 保存路径 # 保存路径
path = Column(String, nullable=False, index=True) path = Column(String, nullable=False, index=True)
# 类型 电影/电视剧 # 类型 电影/电视剧/音乐
type = Column(String, nullable=False) type = Column(String, nullable=False)
# 标题 # 标题
title = Column(String, nullable=False) title = Column(String, nullable=False)
+92
View File
@@ -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
+50
View File
@@ -17,6 +17,7 @@ from app.core.cache import TTLCache
from app.core.config import global_vars from app.core.config import global_vars
from app.core.context import MediaInfo, TorrentInfo from app.core.context import MediaInfo, TorrentInfo
from app.core.meta import MetaBase from app.core.meta import MetaBase
from app.core.music import MusicInfo, MusicMeta
from app.db.systemconfig_oper import SystemConfigOper from app.db.systemconfig_oper import SystemConfigOper
from app.log import logger from app.log import logger
from app.schemas.message import Notification from app.schemas.message import Notification
@@ -91,6 +92,31 @@ class TemplateContextBuilder:
""" """
if not mediainfo: if not mediainfo:
return 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 season_fmt = f"S{mediainfo.season:02d}" if mediainfo.season is not None else None
source_ids = { source_ids = {
"themoviedb": mediainfo.tmdb_id, "themoviedb": mediainfo.tmdb_id,
@@ -170,6 +196,30 @@ class TemplateContextBuilder:
""" """
if not meta: if not meta:
return 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} episode_data = {"episode_title": None, "episode_date": None}
if meta.begin_episode and episodes: if meta.begin_episode and episodes:
+23 -8
View File
@@ -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 _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): def __init__(self):
self.check() self.check()
@@ -42,13 +49,14 @@ class ResourceHelper:
return "x86_64" return "x86_64"
return machine return machine
@staticmethod @classmethod
def _get_needed_files() -> list[str]: def _get_needed_files(cls) -> list[str]:
"""返回 V3 资源在当前平台需要下载的文件名。"""
python_version = ResourceHelper._get_python_version_tag() python_version = ResourceHelper._get_python_version_tag()
python_ver = python_version.replace("cp", "") python_ver = python_version.replace("cp", "")
system = platform.system().lower() system = platform.system().lower()
machine = ResourceHelper._get_machine_tag() machine = ResourceHelper._get_machine_tag()
files = ["user.sites.v2.bin"] files = [f"user.sites.{cls._version_flag}.bin"]
if system == "linux": if system == "linux":
files.append(f"sites.cpython-{python_ver}-{machine}-linux-gnu.so") files.append(f"sites.cpython-{python_ver}-{machine}-linux-gnu.so")
elif system == "darwin": elif system == "darwin":
@@ -57,6 +65,15 @@ class ResourceHelper:
files.append(f"sites.cp{python_ver}-win_amd64.pyd") files.append(f"sites.cp{python_ver}-win_amd64.pyd")
return files 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): def check(self):
""" """
检测是否有更新如有则下载安装 检测是否有更新如有则下载安装
@@ -66,9 +83,7 @@ class ResourceHelper:
if SystemUtils.is_frozen(): if SystemUtils.is_frozen():
return None return None
logger.info("开始检测资源包版本...") logger.info("开始检测资源包版本...")
res = RequestUtils( res = self._load_resource_info()
proxies=self.proxies, headers=settings.GITHUB_HEADERS, timeout=10
).get_res(self._repo)
if res: if res:
try: try:
resource_info = json.loads(res.text) resource_info = json.loads(res.text)
+1
View File
@@ -109,6 +109,7 @@
"任务添加失败": "Failed to add task", "任务添加失败": "Failed to add task",
"无法识别媒体信息": "Unable to recognize media information", "无法识别媒体信息": "Unable to recognize media information",
"未识别到媒体信息": "Unable to recognize media information", "未识别到媒体信息": "Unable to recognize media information",
"未识别到音乐信息": "Unable to recognize music information",
"记录不存在": "Record does not exist", "记录不存在": "Record does not exist",
"MoviePilot智能助手未启用": "MoviePilot Assistant is not enabled", "MoviePilot智能助手未启用": "MoviePilot Assistant is not enabled",
"整理记录不存在": "Organization record does not exist", "整理记录不存在": "Organization record does not exist",
+1
View File
@@ -109,6 +109,7 @@
"任务添加失败": "任務新增失敗", "任务添加失败": "任務新增失敗",
"无法识别媒体信息": "無法識別媒體資訊", "无法识别媒体信息": "無法識別媒體資訊",
"未识别到媒体信息": "未識別到媒體資訊", "未识别到媒体信息": "未識別到媒體資訊",
"未识别到音乐信息": "未識別到音樂資訊",
"记录不存在": "記錄不存在", "记录不存在": "記錄不存在",
"MoviePilot智能助手未启用": "MoviePilot 智慧助手未啟用", "MoviePilot智能助手未启用": "MoviePilot 智慧助手未啟用",
"整理记录不存在": "整理記錄不存在", "整理记录不存在": "整理記錄不存在",
+9 -2
View File
@@ -7,7 +7,7 @@ from app.helper.service import ServiceConfigHelper
from app.log import logger from app.log import logger
from app.schemas import Notification, NotificationConf, MediaServerConf, DownloaderConf from app.schemas import Notification, NotificationConf, MediaServerConf, DownloaderConf
from app.schemas.types import ModuleType, DownloaderType, MediaServerType, MessageChannel, StorageSchema, \ from app.schemas.types import ModuleType, DownloaderType, MediaServerType, MessageChannel, StorageSchema, \
OtherModulesType, SystemConfigKey OtherModulesType, SystemConfigKey, MediaRecognizeType
from app.utils.mixins import ConfigReloadMixin from app.utils.mixins import ConfigReloadMixin
@@ -66,7 +66,14 @@ class _ModuleBase(ConfigReloadMixin, metaclass=ABCMeta):
pass pass
@staticmethod @staticmethod
def get_subtype() -> Union[DownloaderType, MediaServerType, MessageChannel, StorageSchema, OtherModulesType]: def get_subtype() -> Union[
DownloaderType,
MediaServerType,
MessageChannel,
StorageSchema,
OtherModulesType,
MediaRecognizeType,
]:
""" """
获取模块子类型下载器媒体服务器消息通道存储类型其他杂项模块类型 获取模块子类型下载器媒体服务器消息通道存储类型其他杂项模块类型
""" """
+4 -3
View File
@@ -184,9 +184,10 @@ class TransHandler:
""" """
if not _fileitem.extension: if not _fileitem.extension:
return False return False
if f".{_fileitem.extension.lower()}" in ( extension = f".{_fileitem.extension.lower()}"
settings.RMT_SUBEXT + settings.RMT_AUDIOEXT if extension in settings.RMT_SUBEXT:
): return True
if mediainfo.type != MediaType.MUSIC and extension in settings.RMT_AUDIOEXT:
return True return True
return False return False
+12 -4
View File
@@ -548,30 +548,38 @@ class IndexerModule(_ModuleBase):
def refresh_torrents(self, site: dict, def refresh_torrents(self, site: dict,
keyword: Optional[str] = None, keyword: Optional[str] = None,
cat: 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 site: 站点
:param keyword: 关键字 :param keyword: 关键字
:param cat: 分类 :param cat: 分类
:param page: 页码 :param page: 页码
:param mtype: 媒体类型
:reutrn: 种子资源列表 :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, async def async_refresh_torrents(self, site: dict,
keyword: Optional[str] = None, keyword: Optional[str] = None,
cat: 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 site: 站点
:param keyword: 关键字 :param keyword: 关键字
:param cat: 分类 :param cat: 分类
:param page: 页码 :param page: 页码
:param mtype: 媒体类型
:reutrn: 种子资源列表 :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]: def refresh_userdata(self, site: dict) -> Optional[SiteUserData]:
""" """
+86 -30
View File
@@ -18,6 +18,44 @@ from app.utils.string import StringUtils
from app.utils.url import UrlUtils 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: class SiteSpider:
""" """
站点爬虫 站点爬虫
@@ -62,6 +100,7 @@ class SiteSpider:
self.search_type = search_type or "torrents" self.search_type = search_type or "torrents"
self.indexerid = indexer.get('id') self.indexerid = indexer.get('id')
self.indexername = indexer.get('name') self.indexername = indexer.get('name')
self.site_media_type = MediaType.from_agent(indexer.get('media_type'))
if self.search_type == "subtitles": if self.search_type == "subtitles":
subtitle_conf = indexer.get('subtitles') or {} subtitle_conf = indexer.get('subtitles') or {}
self.search = subtitle_conf.get('search') self.search = subtitle_conf.get('search')
@@ -129,16 +168,24 @@ class SiteSpider:
if len(paths) == 1: if len(paths) == 1:
torrentspath = paths[0].get('path', '') torrentspath = paths[0].get('path', '')
else: else:
# 优先使用媒体类型专用路径;没有专用路径时回退到 all,兼容仅为某一类新增分支的站点。
fallback_path = ""
expected_type = {
MediaType.MOVIE: "movie",
MediaType.TV: "tv",
MediaType.MUSIC: "music",
}.get(self.mtype)
for path in paths: for path in paths:
if path.get("type") == "all" and not self.mtype: path_type = path.get("type")
torrentspath = path.get('path') if path_type == "all" and not fallback_path:
break fallback_path = path.get('path', '')
elif path.get("type") == "movie" and self.mtype == MediaType.MOVIE: if (expected_type and path_type == expected_type) or (
torrentspath = path.get('path') not expected_type and path_type == "all"
break ):
elif path.get("type") == "tv" and self.mtype == MediaType.TV: torrentspath = path.get('path', '')
torrentspath = path.get('path')
break break
if not torrentspath:
torrentspath = fallback_path
# 精确搜索 # 精确搜索
if self.keyword: if self.keyword:
@@ -187,12 +234,7 @@ class SiteSpider:
}) })
# 分类条件 # 分类条件
if self.category: if self.category:
if self.mtype == MediaType.TV: cats = select_media_categories(self.category, self.mtype)
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 [])
allowed_cats = set(self.cat.split(',')) if self.cat else None allowed_cats = set(self.cat.split(',')) if self.cat else None
for cat in cats: for cat in cats:
if allowed_cats and str(cat.get('id')) not in allowed_cats: if allowed_cats and str(cat.get('id')) not in allowed_cats:
@@ -204,9 +246,25 @@ class SiteSpider:
' ') + cat.get("id") ' ') + cat.get("id")
}) })
else: else:
params.update({ category_param = cat.get("param") or self.category.get("param")
"cat%s" % cat.get("id"): 1 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) searchurl = UrlUtils.combine_url(self.domain, torrentspath, params)
else: 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: if not self.search or not self.domain:
return [] 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: if not self.search or not self.domain:
return [] return []
@@ -706,24 +768,18 @@ class SiteSpider:
del hit_and_run del hit_and_run
def __get_category(self, torrent: Any): def __get_category(self, torrent: Any):
# category 电影/电视剧 # category 电影/电视剧/音乐
if 'category' not in self.fields: if 'category' not in self.fields:
if self.site_media_type:
self.torrents_info['category'] = self.site_media_type.value
return return
selector = self.fields.get('category', {}) selector = self.fields.get('category', {})
category_value = self._safe_query(torrent, selector) category_value = self._safe_query(torrent, selector)
category_value = self.__filter_text(category_value, selector.get('filters')) category_value = self.__filter_text(category_value, selector.get('filters'))
if category_value and self.category: resolved_type = resolve_category_media_type(category_value, self.category)
tv_cats = [str(cat.get("id")) for cat in self.category.get("tv") or []] if resolved_type == MediaType.UNKNOWN and self.site_media_type:
movie_cats = [str(cat.get("id")) for cat in self.category.get("movie") or []] resolved_type = self.site_media_type
if category_value in tv_cats \ self.torrents_info['category'] = resolved_type.value
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
def __get_subtitle_field(self, torrent: Any, field_name: str): def __get_subtitle_field(self, torrent: Any, field_name: str):
""" """
+140
View File
@@ -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
+242
View File
@@ -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()
+1
View File
@@ -8,6 +8,7 @@ from .file import *
from .history import * from .history import *
from .mediaserver import * from .mediaserver import *
from .message import * from .message import *
from .music import *
from .monitoring import * from .monitoring import *
from .plugin import * from .plugin import *
from .response import * from .response import *
+6 -4
View File
@@ -2,6 +2,8 @@ from typing import Optional, Dict, List, Union, Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.schemas.music import MusicInfo, MusicMeta
class MetaInfo(BaseModel): class MetaInfo(BaseModel):
""" """
@@ -15,7 +17,7 @@ class MetaInfo(BaseModel):
title: Optional[str] = None title: Optional[str] = None
# 副标题 # 副标题
subtitle: Optional[str] = None subtitle: Optional[str] = None
# 类型 电影、电视剧 # 类型 电影、电视剧、音乐
type: Optional[str] = None type: Optional[str] = None
# 名称 # 名称
name: Optional[str] = None name: Optional[str] = None
@@ -252,7 +254,7 @@ class TorrentInfo(BaseModel):
labels: Optional[list] = Field(default_factory=list) labels: Optional[list] = Field(default_factory=list)
# 种子优先级 # 种子优先级
pri_order: Optional[int] = 0 pri_order: Optional[int] = 0
# 种子分类 电影/电视剧 # 种子分类 电影/电视剧/音乐
category: Optional[str] = None category: Optional[str] = None
# 促销 # 促销
volume_factor: 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 torrent_info: Optional[TorrentInfo] = None
# 候选资源来源:rss、spider、search、unknown # 候选资源来源:rss、spider、search、unknown
+1 -1
View File
@@ -12,7 +12,7 @@ class DownloadHistory(BaseModel):
id: int id: int
# 保存路程 # 保存路程
path: Optional[str] = None path: Optional[str] = None
# 类型:电影、电视剧 # 类型:电影、电视剧、音乐
type: Optional[str] = None type: Optional[str] = None
# 标题 # 标题
title: Optional[str] = None title: Optional[str] = None
+70
View File
@@ -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
+4 -3
View File
@@ -1,9 +1,10 @@
from pathlib import Path 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 pydantic import BaseModel, Field
from app.schemas.context import MetaInfo, MediaInfo from app.schemas.context import MetaInfo, MediaInfo
from app.schemas.music import MusicInfo, MusicMeta
from app.schemas.file import FileItem from app.schemas.file import FileItem
from app.schemas.history import DownloadHistory from app.schemas.history import DownloadHistory
from app.schemas.system import TransferDirectoryConf from app.schemas.system import TransferDirectoryConf
@@ -96,7 +97,7 @@ class TransferJobTask(BaseModel):
文件整理作业任务 文件整理作业任务
""" """
fileitem: Optional[FileItem] = None fileitem: Optional[FileItem] = None
meta: Optional[MetaInfo] = None meta: Optional[Union[MusicMeta, MetaInfo]] = None
state: Optional[str] = None state: Optional[str] = None
downloader: Optional[str] = None downloader: Optional[str] = None
download_hash: 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 season: Optional[int] = None
tasks: Optional[List[TransferJobTask]] = Field(default_factory=list) tasks: Optional[List[TransferJobTask]] = Field(default_factory=list)
+18 -5
View File
@@ -6,22 +6,31 @@ from typing import Optional
class MediaType(Enum): class MediaType(Enum):
MOVIE = '电影' MOVIE = '电影'
TV = '电视剧' TV = '电视剧'
MUSIC = '音乐'
COLLECTION = '系列' COLLECTION = '系列'
UNKNOWN = '未知' UNKNOWN = '未知'
@staticmethod @staticmethod
def from_agent(key: str) -> Optional["MediaType"]: def from_agent(key: str) -> Optional["MediaType"]:
"""'movie' -> MediaType.MOVIE, 'tv' -> MediaType.TV, 否则 None""" """将 Agent 媒体类型转换为 MediaType。"""
_map = {"movie": MediaType.MOVIE, "tv": MediaType.TV} _map = {
"movie": MediaType.MOVIE,
"tv": MediaType.TV,
"music": MediaType.MUSIC,
}
return _map.get(key.strip().lower() if key else "") return _map.get(key.strip().lower() if key else "")
def to_agent(self) -> str: def to_agent(self) -> str:
"""MediaType.MOVIE -> 'movie', MediaType.TV -> 'tv', 其他返回 .value""" """将 MediaType 转换为 Agent 使用的媒体类型。"""
return {MediaType.MOVIE: "movie", MediaType.TV: "tv"}.get(self, self.value) return {
MediaType.MOVIE: "movie",
MediaType.TV: "tv",
MediaType.MUSIC: "music",
}.get(self, self.value)
def media_type_to_agent(value) -> Optional[str]: def media_type_to_agent(value) -> Optional[str]:
"""将 MediaType 枚举或中文字符串统一转为 'movie'/'tv'""" """将 MediaType 枚举或字符串统一转Agent 媒体类型。"""
if isinstance(value, MediaType): if isinstance(value, MediaType):
return value.to_agent() return value.to_agent()
if isinstance(value, str): if isinstance(value, str):
@@ -397,6 +406,8 @@ class MediaRecognizeType(Enum):
Bangumi = "Bangumi" Bangumi = "Bangumi"
# AniList # AniList
AniList = "AniList" AniList = "AniList"
# MusicBrainz
MusicBrainz = "MusicBrainz"
# 用户配置Key字典 # 用户配置Key字典
@@ -448,6 +459,8 @@ class OtherModulesType(Enum):
PostgreSQL = "PostgreSQL" PostgreSQL = "PostgreSQL"
# Redis # Redis
Redis = "Redis" Redis = "Redis"
# ListenBrainz
ListenBrainz = "ListenBrainz"
class NameValueEnum(Enum): class NameValueEnum(Enum):
+2 -2
View File
@@ -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/ \ && 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)}' | \ && 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 \ 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}")') \ && python_ver=$(python3 -c 'import sys; print(f"cpython-{sys.version_info.major}{sys.version_info.minor}")') \
&& ARCH=$(uname -m) \ && ARCH=$(uname -m) \
&& if [ "$ARCH" = "aarch64" ]; then SUFFIX="aarch64-linux-gnu"; else SUFFIX="x86_64-linux-gnu"; fi \ && 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 阶段: 安装运行时依赖和配置最终镜像 # final 阶段: 安装运行时依赖和配置最终镜像
FROM prepare_package AS final FROM prepare_package AS final
+13 -6
View File
@@ -157,7 +157,9 @@ function install_backend_and_download_resources() {
INFO "→ 正在备份站点资源目录..." INFO "→ 正在备份站点资源目录..."
rm -rf /resources_bakcup rm -rf /resources_bakcup
mkdir /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 cp -a /app/app/helper/sites.cp* /resources_bakcup
# 清空程序目录 # 清空程序目录
rm -rf /app rm -rf /app
@@ -181,14 +183,19 @@ function install_backend_and_download_resources() {
arch_suffix="x86_64-linux-gnu" arch_suffix="x86_64-linux-gnu"
fi fi
INFO "当前 Python 版本:${python_version},架构:${arch}" INFO "当前 Python 版本:${python_version},架构:${arch}"
# 下载 user.sites.v2.bin # 下载 V3 站点索引
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 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
cp -a /resources_bakcup/user.sites.v2.bin /app/app/helper/ if [ -f /resources_bakcup/user.sites.v3.bin ]; then
WARN "user.sites.v2.bin 下载失败,继续使用旧的资源来启动..." cp -a /resources_bakcup/user.sites.v3.bin /app/app/helper/
fi
WARN "user.sites.v3.bin 下载失败,继续使用旧的资源来启动..."
fi fi
# 下载对应平台的 sites 文件 # 下载对应平台的 sites 文件
sites_file="sites.${python_version}-${arch_suffix}.so" 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} 下载失败,继续使用旧的资源来启动..." WARN "${sites_file} 下载失败,继续使用旧的资源来启动..."
fi fi
INFO "站点资源更新成功" INFO "站点资源更新成功"
+3 -3
View File
@@ -16,7 +16,7 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootst
- 安装后端依赖 - 安装后端依赖
- 按当前仓库 `version.py` 中的 `FRONTEND_VERSION` 下载对应前端 release 的 `dist.zip` - 按当前仓库 `version.py` 中的 `FRONTEND_VERSION` 下载对应前端 release 的 `dist.zip`
- 下载 `MoviePilot-Resources` 主分支资源 - 下载 `MoviePilot-Resources` 主分支资源
- 将 `resources.v2/*` 同步到后端 [app/helper](/Users/jxxghp/PycharmProjects/MoviePilot/app/helper) - 将 `resources.v3/*` 同步到后端 [app/helper](/Users/jxxghp/PycharmProjects/MoviePilot/app/helper)
- 下载本地 Node 运行时并安装前端运行依赖 - 下载本地 Node 运行时并安装前端运行依赖
- 执行初始化向导 - 执行初始化向导
- 创建全局 `moviepilot` 命令 - 创建全局 `moviepilot` 命令
@@ -185,14 +185,14 @@ moviepilot install frontend --config-dir /path/to/moviepilot-config
```shell ```shell
moviepilot install resources moviepilot install resources
moviepilot install resources --resources-repo /path/to/MoviePilot-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 moviepilot install resources --config-dir /path/to/moviepilot-config
``` ```
说明: 说明:
- 默认直接从 GitHub 下载 `MoviePilot-Resources` 主分支压缩包 - 默认直接从 GitHub 下载 `MoviePilot-Resources` 主分支压缩包
- 会将 `resources.v2/*` 整体复制到 [app/helper](/Users/jxxghp/PycharmProjects/MoviePilot/app/helper) - 会将 `resources.v3/*` 整体复制到 [app/helper](/Users/jxxghp/PycharmProjects/MoviePilot/app/helper)
- 这一步和 Docker 构建流程保持一致 - 这一步和 Docker 构建流程保持一致
## 初始化命令 ## 初始化命令
+1 -1
View File
@@ -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/plugins/` 目录下,例如 `app/plugins/<插件目录>/`。主程序运行时从该目录加载插件,独立插件仓库只是源码来源。
如果资源文件没有放到 `app/helper/`,站点索引、规则和内置资源相关能力可能无法按本地开发预期工作;如果插件没有放到 `app/plugins/`,主程序也不会在本地运行时发现该插件。 如果资源文件没有放到 `app/helper/`,站点索引、规则和内置资源相关能力可能无法按本地开发预期工作;如果插件没有放到 `app/plugins/`,主程序也不会在本地运行时发现该插件。
+14 -3
View File
@@ -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/media/{mediaid}/stream` | 按媒体 ID 渐进式搜索站点种子资源,返回 SSE,参数同上 |
| GET | `/api/v1/search/title` | 按关键字模糊搜索站点种子资源,参数:`keyword``page``sites` | | GET | `/api/v1/search/title` | 按关键字模糊搜索站点种子资源,参数:`keyword``page``sites`,可选 `mtype=音乐` 仅搜索音乐分类 |
| GET | `/api/v1/search/title/stream` | 按关键字渐进式搜索站点种子资源,返回 SSE,参数:`keyword``page``sites` | | 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` | 按关键字搜索站点字幕资源,参数:`keyword``page``sites` |
| GET | `/api/v1/search/subtitle/title/stream` | 按关键字渐进式搜索站点字幕资源,返回 SSE,参数:`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` | | 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/{person_id}` | 查询人物详情 |
| GET | `/api/v1/anilist/person/credits/{person_id}` | 查询人物参与的动画作品,参数:`page``count` | | 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` |
#### 下载 #### 下载
| 方法 | 路径 | 说明 | | 方法 | 路径 | 说明 |
+1 -1
View File
@@ -117,7 +117,7 @@ Options:
resources: resources:
--resources-repo PATH 本地 MoviePilot-Resources 仓库路径 --resources-repo PATH 本地 MoviePilot-Resources 仓库路径
--resource-dir PATH 直接指定 resources.v2 目录 --resource-dir PATH 直接指定 resources.v3 目录
--config-dir PATH 指定配置目录 --config-dir PATH 指定配置目录
-h, --help 显示帮助 -h, --help 显示帮助
+2 -1
View File
@@ -1,6 +1,6 @@
moviepilot-rust~=0.2.4 moviepilot-rust~=0.2.4
pydantic>=2.13.4,<3.0.0 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 SQLAlchemy~=2.0.50
uvicorn~=0.49.0 uvicorn~=0.49.0
fastapi~=0.136.3 fastapi~=0.136.3
@@ -32,6 +32,7 @@ qbittorrent-api==2026.6.0
plexapi~=4.18.1 plexapi~=4.18.1
transmission-rpc~=7.0.11 transmission-rpc~=7.0.11
Jinja2~=3.1.6 Jinja2~=3.1.6
mutagen~=1.47.0
pyparsing~=3.3.2 pyparsing~=3.3.2
beautifulsoup4~=4.15.0 beautifulsoup4~=4.15.0
pillow~=12.2.0 pillow~=12.2.0
+24 -15
View File
@@ -55,6 +55,7 @@ FRONTEND_TAG_API = (
RESOURCES_MAIN_ZIP = ( RESOURCES_MAIN_ZIP = (
"https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip" "https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip"
) )
RESOURCE_VERSION_FLAG = "v3"
LLM_PROVIDER_DEFAULTS = { LLM_PROVIDER_DEFAULTS = {
"deepseek": { "deepseek": {
"model": "deepseek-chat", "model": "deepseek-chat",
@@ -998,7 +999,7 @@ def install_frontend(frontend_version: str, node_version: str) -> dict[str, str]
def local_resource_status() -> bool: 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*")) list(HELPER_DIR.glob("sites*"))
) )
@@ -1045,14 +1046,17 @@ def _get_python_version_tag() -> str:
def _filter_resources_files( def _filter_resources_files(
source_dir: Path, platform_tag: str, python_version: str source_dir: Path,
platform_tag: str,
python_version: str,
) -> list[Path]: ) -> list[Path]:
"""筛选 V3 资源中与当前 Python 平台匹配的运行文件。"""
matched_files: list[Path] = [] matched_files: list[Path] = []
for file in source_dir.iterdir(): for file in source_dir.iterdir():
if not file.is_file(): if not file.is_file():
continue continue
filename = file.name filename = file.name
if filename == "user.sites.v2.bin": if filename == f"user.sites.{RESOURCE_VERSION_FLAG}.bin":
matched_files.append(file) matched_files.append(file)
continue continue
if not filename.startswith("sites."): if not filename.startswith("sites."):
@@ -1083,9 +1087,13 @@ def _download_resources_dir() -> Path:
print_step("下载资源包") print_step("下载资源包")
download_file(RESOURCES_MAIN_ZIP, archive_path) download_file(RESOURCES_MAIN_ZIP, archive_path)
extract_archive(archive_path, extract_dir) 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(): if not source_dir.exists():
raise RuntimeError("资源压缩包中未找到 resources.v2 目录") raise RuntimeError(f"资源压缩包中未找到 resources.{RESOURCE_VERSION_FLAG} 目录")
platform_name, machine = _get_platform_tag() platform_name, machine = _get_platform_tag()
python_version = _get_python_version_tag() python_version = _get_python_version_tag()
@@ -1094,7 +1102,9 @@ def _download_resources_dir() -> Path:
) )
matched_files = _filter_resources_files( matched_files = _filter_resources_files(
source_dir, platform_name, python_version source_dir,
platform_name,
python_version,
) )
if not matched_files: if not matched_files:
raise RuntimeError( raise RuntimeError(
@@ -1107,7 +1117,7 @@ def _download_resources_dir() -> Path:
target = staging_dir / file.name target = staging_dir / file.name
shutil.copy2(file, target) shutil.copy2(file, target)
persisted = TEMP_DIR / "resources.v2" persisted = TEMP_DIR / f"resources.{RESOURCE_VERSION_FLAG}"
_remove_path(persisted) _remove_path(persisted)
shutil.copytree(staging_dir, persisted) shutil.copytree(staging_dir, persisted)
print_step(f"已筛选对应平台的资源文件,共 {len(matched_files)}") print_step(f"已筛选对应平台的资源文件,共 {len(matched_files)}")
@@ -1126,15 +1136,14 @@ def _resolve_local_resource_dir(
if resources_repo: if resources_repo:
repo_dir = resources_repo.expanduser().resolve() repo_dir = resources_repo.expanduser().resolve()
candidates = [ candidates = [
repo_dir / "resources.v2", repo_dir / "resources.v3",
repo_dir / "resources" / "resources.v2", repo_dir / "resources" / "resources.v3",
repo_dir / "resources" / "v2", repo_dir / "resources" / "v3",
repo_dir / "resources.v2",
] ]
for candidate in candidates: for candidate in candidates:
if candidate.is_dir(): if candidate.is_dir():
return candidate return candidate
raise FileNotFoundError(f"未在 {repo_dir} 下找到 resources.v2 目录") raise FileNotFoundError(f"未在 {repo_dir} 下找到 resources.v3 目录")
return None return None
@@ -3728,7 +3737,7 @@ def build_parser() -> argparse.ArgumentParser:
resources_parser.add_argument( resources_parser.add_argument(
"--resources-repo", help="本地 MoviePilot-Resources 仓库路径" "--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( resources_parser.add_argument(
"--config-dir", help="配置目录,默认使用程序目录外的系统配置目录" "--config-dir", help="配置目录,默认使用程序目录外的系统配置目录"
) )
@@ -3737,7 +3746,7 @@ def build_parser() -> argparse.ArgumentParser:
init_parser.add_argument( init_parser.add_argument(
"--resources-repo", help="本地 MoviePilot-Resources 仓库路径" "--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( init_parser.add_argument(
"--skip-resources", action="store_true", help="只初始化配置,不同步资源文件" "--skip-resources", action="store_true", help="只初始化配置,不同步资源文件"
) )
@@ -3774,7 +3783,7 @@ def build_parser() -> argparse.ArgumentParser:
setup_parser.add_argument( setup_parser.add_argument(
"--resources-repo", help="本地 MoviePilot-Resources 仓库路径" "--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( setup_parser.add_argument(
"--skip-resources", action="store_true", help="只初始化配置,不同步资源文件" "--skip-resources", action="store_true", help="只初始化配置,不同步资源文件"
) )
+18 -4
View File
@@ -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/{person_id}` | Staff detail |
| GET | `/api/v1/anilist/person/credits/{person_id}` | Staff anime credits. Params: `page`, `count` | | 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) ### Search / Torrents / Subtitles (11 endpoints)
| Method | Path | Description | | 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/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` | 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` | | 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` | 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/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` | | 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_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 | | GET | `/api/v1/discover/tmdb_tvs` | Discover TMDB TV. Params: same as movies |
### Recommend (14 endpoints) ### Recommend (15 endpoints)
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| GET | `/api/v1/recommend/source` | Recommendation data sources | | GET | `/api/v1/recommend/source` | Recommendation data sources |
| GET | `/api/v1/recommend/bangumi_calendar` | Bangumi daily schedule. Params: `page`, `count` | | 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_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_movies` | Douban movies. Params: `sort`, `tags`, `page`, `count` |
| GET | `/api/v1/recommend/douban_tvs` | Douban TV. Params: `sort`, `tags`, `page`, `count` | | GET | `/api/v1/recommend/douban_tvs` | Douban TV. Params: `sort`, `tags`, `page`, `count` |
+48
View File
@@ -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"
+1 -1
View File
@@ -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 / ".cloakbrowser").mkdir(parents=True)
(home_dir / "runtime").mkdir() (home_dir / "runtime").mkdir()
(app_dir / "app" / "plugins" / "plugin.py").write_text("# plugin\n", encoding="utf-8") (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") (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") (public_dir / "index.html").write_text("<!doctype html>\n", encoding="utf-8")
(home_dir / ".cloakbrowser" / "chrome").write_text("browser cache\n", encoding="utf-8") (home_dir / ".cloakbrowser" / "chrome").write_text("browser cache\n", encoding="utf-8")
+3
View File
@@ -46,6 +46,9 @@ def _load_downloader_base():
schema_types_module.MediaServerType = Enum("MediaServerType", {"Emby": "Emby"}) schema_types_module.MediaServerType = Enum("MediaServerType", {"Emby": "Emby"})
schema_types_module.MessageChannel = Enum("MessageChannel", {"Telegram": "telegram"}) schema_types_module.MessageChannel = Enum("MessageChannel", {"Telegram": "telegram"})
schema_types_module.OtherModulesType = Enum("OtherModulesType", {"Subtitle": "subtitle"}) schema_types_module.OtherModulesType = Enum("OtherModulesType", {"Subtitle": "subtitle"})
schema_types_module.MediaRecognizeType = Enum(
"MediaRecognizeType", {"TheMovieDb": "themoviedb"}
)
schema_types_module.SystemConfigKey = Enum( schema_types_module.SystemConfigKey = Enum(
"SystemConfigKey", "SystemConfigKey",
{ {
+72
View File
@@ -167,6 +167,78 @@ def test_ttg_title_search_does_not_format_keyword():
assert query["search_field"] == ["The Movie 分类:电影DVDRip"] 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(): def test_haidan_empty_keyword_uses_blank_search_value():
""" """
海胆空关键词浏览不能把 Python None 编码进 search 参数 海胆空关键词浏览不能把 Python None 编码进 search 参数
+52
View File
@@ -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"]
+2 -2
View File
@@ -45,7 +45,7 @@ class LocalSetupUninstallTests(unittest.TestCase):
install_env_file.write_text("CONFIG_DIR=/tmp/moviepilot-config\n", encoding="utf-8") 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") (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 / "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.runtime.json").write_text("{}", encoding="utf-8")
(temp_config_dir / "moviepilot.frontend.runtime.json").write_text( (temp_config_dir / "moviepilot.frontend.runtime.json").write_text(
"{}", encoding="utf-8" "{}", encoding="utf-8"
@@ -110,7 +110,7 @@ class LocalSetupUninstallTests(unittest.TestCase):
self.assertFalse((root_dir / ".runtime").exists()) self.assertFalse((root_dir / ".runtime").exists())
self.assertFalse((root_dir / "public").exists()) self.assertFalse((root_dir / "public").exists())
self.assertFalse((root_dir / "app" / "helper" / "sites.py").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()) self.assertFalse(cli_link.exists())
def test_uninstall_deletes_external_config_when_requested(self): def test_uninstall_deletes_external_config_when_requested(self):
+112
View File
@@ -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
+140
View File
@@ -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
+76
View File
@@ -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"
+116
View File
@@ -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,
)
+63
View File
@@ -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
+79
View File
@@ -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,
)
+122
View File
@@ -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()
+68
View File
@@ -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()
+117
View File
@@ -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 == "音乐"
+111
View File
@@ -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 == "晴天"
+39
View File
@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch
import pytest import pytest
from app.chain.recommend import RecommendChain from app.chain.recommend import RecommendChain
from app.core.music import MusicInfo
from app.core.cache import TTLCache 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 asyncio.run(recommend_method(page=1)) == []
assert backend_chain.return_value.async_run_module.call_count == 2 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,
)
+31
View File
@@ -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