mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 00:46:57 +08:00
feat(v3): add music automation workflow
This commit is contained in:
+2
-1
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.endpoints import anilist, auth, login, user, webhook, message, agent, site, subscribe, \
|
||||
from app.api.endpoints import anilist, auth, login, user, webhook, message, agent, site, subscribe, music, \
|
||||
media, douban, search, plugin, tmdb, history, system, download, dashboard, \
|
||||
transfer, mediaserver, bangumi, storage, discover, recommend, workflow, torrent, mcp, mfa, openai, anthropic, llm, notification
|
||||
|
||||
@@ -14,6 +14,7 @@ api_router.include_router(message.router, prefix="/message", tags=["message"])
|
||||
api_router.include_router(agent.router, prefix="/message/agent", tags=["agent"])
|
||||
api_router.include_router(webhook.router, prefix="/webhook", tags=["webhook"])
|
||||
api_router.include_router(subscribe.router, prefix="/subscribe", tags=["subscribe"])
|
||||
api_router.include_router(music.router, prefix="/music", tags=["music"])
|
||||
api_router.include_router(media.router, prefix="/media", tags=["media"])
|
||||
api_router.include_router(search.router, prefix="/search", tags=["search"])
|
||||
api_router.include_router(douban.router, prefix="/douban", tags=["douban"])
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
from typing import Any, List, Annotated, Literal, Optional
|
||||
from typing import Any, List, Annotated, Literal, Optional, Union
|
||||
|
||||
from fastapi import APIRouter, Depends, Body
|
||||
|
||||
from app import schemas
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.music import MusicChain
|
||||
from app.core.context import MediaInfo, Context, SubtitleInfo, TorrentInfo
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.music import MusicInfo
|
||||
from app.core.security import verify_token
|
||||
from app.db.models.user import User
|
||||
from app.db.site_oper import SiteOper
|
||||
@@ -17,7 +19,7 @@ from app.schemas.types import SystemConfigKey
|
||||
from app.utils.security import SecurityUtils
|
||||
|
||||
router = APIRouter()
|
||||
MediaSource = Literal["themoviedb", "douban", "bangumi", "anilist"]
|
||||
MediaSource = Literal["themoviedb", "douban", "bangumi", "anilist", "musicbrainz"]
|
||||
|
||||
|
||||
def _prepare_subtitle_download(subtitle: SubtitleInfo) -> tuple[bool, str]:
|
||||
@@ -57,7 +59,7 @@ def current(
|
||||
|
||||
@router.post("/", summary="添加下载(含媒体信息)", response_model=schemas.Response)
|
||||
def download(
|
||||
media_in: schemas.MediaInfo,
|
||||
media_in: Union[schemas.MusicInfo, schemas.MediaInfo],
|
||||
torrent_in: schemas.TorrentInfo,
|
||||
downloader: Annotated[str | None, Body()] = None,
|
||||
save_path: Annotated[str | None, Body()] = None,
|
||||
@@ -66,11 +68,14 @@ def download(
|
||||
"""
|
||||
添加下载任务(含媒体信息)
|
||||
"""
|
||||
# 元数据
|
||||
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
|
||||
# 媒体信息
|
||||
mediainfo = MediaInfo()
|
||||
mediainfo.from_dict(media_in.model_dump())
|
||||
if isinstance(media_in, schemas.MusicInfo):
|
||||
mediainfo = MusicInfo.from_dict(media_in.model_dump())
|
||||
metainfo = MusicChain.to_meta(mediainfo)
|
||||
metainfo.org_string = torrent_in.title
|
||||
else:
|
||||
metainfo = MetaInfo(title=torrent_in.title, subtitle=torrent_in.description)
|
||||
mediainfo = MediaInfo()
|
||||
mediainfo.from_dict(media_in.model_dump())
|
||||
# 种子信息
|
||||
torrentinfo = TorrentInfo()
|
||||
torrentinfo.from_dict(torrent_in.model_dump())
|
||||
|
||||
@@ -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]
|
||||
@@ -60,6 +60,20 @@ async def bangumi_calendar(
|
||||
return await RecommendChain().async_bangumi_calendar(page=page, count=count)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/music_weekly",
|
||||
summary="ListenBrainz 本周热门音乐",
|
||||
response_model=List[schemas.MusicInfo],
|
||||
)
|
||||
async def music_weekly(
|
||||
page: Optional[int] = 1,
|
||||
count: Optional[int] = 30,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
) -> Any:
|
||||
"""浏览本周全站热门音乐。"""
|
||||
return await RecommendChain().async_music_weekly(page=page, count=count)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/douban_showing", summary="豆瓣正在热映", response_model=List[schemas.MediaInfo]
|
||||
)
|
||||
|
||||
@@ -475,6 +475,7 @@ async def search_by_id(
|
||||
async def search_by_title_stream(
|
||||
request: Request,
|
||||
keyword: Optional[str] = None,
|
||||
mtype: Optional[str] = None,
|
||||
page: Optional[int] = 0,
|
||||
sites: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_resource_token),
|
||||
@@ -484,7 +485,11 @@ async def search_by_title_stream(
|
||||
"""
|
||||
|
||||
event_source = SearchChain().async_search_by_title_stream(
|
||||
title=keyword, page=page, sites=_parse_site_list(sites), cache_local=True
|
||||
title=keyword,
|
||||
page=page,
|
||||
sites=_parse_site_list(sites),
|
||||
cache_local=True,
|
||||
mtype=_parse_media_type(mtype),
|
||||
)
|
||||
return StreamingResponse(
|
||||
_stream_search_events(request, event_source),
|
||||
@@ -496,6 +501,7 @@ async def search_by_title_stream(
|
||||
@router.get("/title", summary="模糊搜索资源", response_model=schemas.Response)
|
||||
async def search_by_title(
|
||||
keyword: Optional[str] = None,
|
||||
mtype: Optional[str] = None,
|
||||
page: Optional[int] = 0,
|
||||
sites: Optional[str] = None,
|
||||
_: schemas.TokenPayload = Depends(verify_token),
|
||||
@@ -504,7 +510,11 @@ async def search_by_title(
|
||||
根据名称模糊搜索站点资源,支持分页,关键词为空是返回首页资源
|
||||
"""
|
||||
torrents = await SearchChain().async_search_by_title(
|
||||
title=keyword, page=page, sites=_parse_site_list(sites), cache_local=True
|
||||
title=keyword,
|
||||
page=page,
|
||||
sites=_parse_site_list(sites),
|
||||
cache_local=True,
|
||||
mtype=_parse_media_type(mtype),
|
||||
)
|
||||
if not torrents:
|
||||
return schemas.Response(success=False, message="未搜索到任何资源")
|
||||
|
||||
@@ -30,7 +30,7 @@ from app.db.user_oper import (
|
||||
from app.helper.sites import SitesHelper # noqa
|
||||
from app.log import logger
|
||||
from app.scheduler import Scheduler
|
||||
from app.schemas.types import SystemConfigKey, EventType
|
||||
from app.schemas.types import SystemConfigKey, EventType, MediaType
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
router = APIRouter()
|
||||
@@ -394,6 +394,7 @@ async def site_category(
|
||||
async def site_resource(
|
||||
site_id: int,
|
||||
keyword: Optional[str] = None,
|
||||
mtype: Optional[str] = None,
|
||||
cat: Optional[str] = None,
|
||||
page: Optional[int] = 0,
|
||||
db: AsyncSession = Depends(get_async_db),
|
||||
@@ -409,7 +410,11 @@ async def site_resource(
|
||||
detail=f"站点 {site_id} 不存在",
|
||||
)
|
||||
torrents = await TorrentsChain().async_browse(
|
||||
domain=site.domain, keyword=keyword, cat=cat, page=page
|
||||
domain=site.domain,
|
||||
keyword=keyword,
|
||||
cat=cat,
|
||||
page=page,
|
||||
mtype=MediaType.from_agent(mtype) or MediaType(mtype) if mtype else None,
|
||||
)
|
||||
if not torrents:
|
||||
return []
|
||||
|
||||
@@ -173,10 +173,13 @@ async def create_subscribe(
|
||||
mtype = None
|
||||
# 非 TMDB 来源的标题可能自带季标记,入库前统一拆分。
|
||||
if (
|
||||
subscribe_in.doubanid
|
||||
or subscribe_in.bangumiid
|
||||
or subscribe_in.anilistid
|
||||
or normalize_media_source(subscribe_in.media_source) not in (None, "themoviedb")
|
||||
mtype != MediaType.MUSIC
|
||||
and (
|
||||
subscribe_in.doubanid
|
||||
or subscribe_in.bangumiid
|
||||
or subscribe_in.anilistid
|
||||
or normalize_media_source(subscribe_in.media_source) not in (None, "themoviedb")
|
||||
)
|
||||
):
|
||||
meta = MetaInfo(subscribe_in.name)
|
||||
subscribe_in.name = meta.name
|
||||
|
||||
@@ -1272,6 +1272,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
keyword: Optional[str] = None,
|
||||
cat: Optional[str] = None,
|
||||
page: Optional[int] = 0,
|
||||
mtype: Optional[MediaType] = None,
|
||||
) -> List[TorrentInfo]:
|
||||
"""
|
||||
获取站点最新一页的种子,多个站点需要多线程处理
|
||||
@@ -1279,10 +1280,11 @@ class ChainBase(metaclass=ABCMeta):
|
||||
:param keyword: 标题
|
||||
:param cat: 分类
|
||||
:param page: 页码
|
||||
:param mtype: 媒体类型
|
||||
:reutrn: 种子资源列表
|
||||
"""
|
||||
return self.run_module(
|
||||
"refresh_torrents", site=site, keyword=keyword, cat=cat, page=page
|
||||
"refresh_torrents", site=site, keyword=keyword, cat=cat, page=page, mtype=mtype
|
||||
)
|
||||
|
||||
async def async_refresh_torrents(
|
||||
@@ -1291,6 +1293,7 @@ class ChainBase(metaclass=ABCMeta):
|
||||
keyword: Optional[str] = None,
|
||||
cat: Optional[str] = None,
|
||||
page: Optional[int] = 0,
|
||||
mtype: Optional[MediaType] = None,
|
||||
) -> List[TorrentInfo]:
|
||||
"""
|
||||
异步获取站点最新一页的种子,多个站点需要多线程处理
|
||||
@@ -1298,10 +1301,11 @@ class ChainBase(metaclass=ABCMeta):
|
||||
:param keyword: 标题
|
||||
:param cat: 分类
|
||||
:param page: 页码
|
||||
:param mtype: 媒体类型
|
||||
:reutrn: 种子资源列表
|
||||
"""
|
||||
return await self.async_run_module(
|
||||
"async_refresh_torrents", site=site, keyword=keyword, cat=cat, page=page
|
||||
"async_refresh_torrents", site=site, keyword=keyword, cat=cat, page=page, mtype=mtype
|
||||
)
|
||||
|
||||
def filter_torrents(
|
||||
|
||||
+48
-1
@@ -16,6 +16,7 @@ from app.chain.storage import StorageChain
|
||||
from app.core.cache import FileCache
|
||||
from app.core.config import settings, global_vars
|
||||
from app.core.context import MediaInfo, SubtitleInfo, TorrentInfo, Context
|
||||
from app.core.music import MusicInfo, MusicMeta
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfo
|
||||
@@ -61,6 +62,25 @@ class DownloadChain(ChainBase):
|
||||
".rar": "rar",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _build_download_note(
|
||||
source: Optional[str],
|
||||
media: MediaInfo | MusicInfo,
|
||||
meta: MetaBase | MusicMeta,
|
||||
) -> dict:
|
||||
"""构造下载历史备注,并为音乐保存可恢复的版本化上下文。"""
|
||||
note = {"source": source}
|
||||
if getattr(media, "type", None) != MediaType.MUSIC:
|
||||
return note
|
||||
media_payload = media.to_dict()
|
||||
media_payload.pop("raw_data", None)
|
||||
note["music"] = {
|
||||
"version": 1,
|
||||
"meta": meta.to_dict(),
|
||||
"media": media_payload,
|
||||
}
|
||||
return note
|
||||
|
||||
@staticmethod
|
||||
def _normalize_indirect_download_url(url: str, base_url: Optional[str] = None) -> str:
|
||||
"""
|
||||
@@ -963,7 +983,7 @@ class DownloadChain(ChainBase):
|
||||
date=time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
||||
media_category=_media.category,
|
||||
episode_group=_media.episode_group,
|
||||
note={"source": source},
|
||||
note=self._build_download_note(source, _media, _meta),
|
||||
custom_words=custom_words
|
||||
)
|
||||
|
||||
@@ -1183,6 +1203,11 @@ class DownloadChain(ChainBase):
|
||||
"""
|
||||
return _context.media_info.title_year
|
||||
|
||||
def __get_music_download_key(_context: Context) -> str:
|
||||
"""获取音乐下载去重键,同一订阅目标失败后仍可尝试后续候选。"""
|
||||
media_source, media_id = resolve_media_identity(media=_context.media_info)
|
||||
return build_media_key(media_source, media_id) or _context.media_info.title_year
|
||||
|
||||
# 发送资源选择事件,允许外部修改上下文数据
|
||||
logger.debug(f"Initial contexts: {len(contexts)} items, Downloader: {downloader}")
|
||||
event_data = ResourceSelectionEventData(
|
||||
@@ -1246,6 +1271,28 @@ class DownloadChain(ChainBase):
|
||||
else:
|
||||
__remember_context_failure(context)
|
||||
|
||||
# 音乐与电影一样按单个订阅目标择一下载,不进入电视剧季集组合逻辑。
|
||||
downloaded_music = set()
|
||||
for context in contexts:
|
||||
if global_vars.is_system_stopped:
|
||||
break
|
||||
if context.media_info.type != MediaType.MUSIC:
|
||||
continue
|
||||
if __is_context_in_failure_cooldown(context):
|
||||
continue
|
||||
music_key = __get_music_download_key(context)
|
||||
if music_key in downloaded_music:
|
||||
continue
|
||||
logger.info(f"开始下载音乐 {context.torrent_info.title} ...")
|
||||
if self.download_single(context, save_path=save_path, channel=channel,
|
||||
source=source, userid=userid, username=username,
|
||||
downloader=downloader, custom_words=custom_words):
|
||||
logger.info(f"{context.torrent_info.title} 添加下载成功")
|
||||
downloaded_list.append(context)
|
||||
downloaded_music.add(music_key)
|
||||
else:
|
||||
__remember_context_failure(context)
|
||||
|
||||
# 电视剧整季匹配
|
||||
if no_exists:
|
||||
logger.info(f"开始匹配电视剧整季:{no_exists}")
|
||||
|
||||
@@ -696,6 +696,8 @@ class MediaChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
"""
|
||||
if not mediainfo:
|
||||
return None
|
||||
if mediainfo.type == MediaType.MUSIC:
|
||||
return mediainfo
|
||||
if mediainfo.tmdb_id and mediainfo.tmdb_info and mediainfo.genre_ids:
|
||||
return mediainfo
|
||||
tmdb_meta = self._build_tmdb_supplement_meta(mediainfo, metainfo)
|
||||
|
||||
@@ -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()
|
||||
@@ -5,6 +5,7 @@ import pillow_avif # noqa 用于自动注册AVIF支持
|
||||
from app.chain import ChainBase
|
||||
from app.chain.bangumi import BangumiChain
|
||||
from app.chain.douban import DoubanChain
|
||||
from app.chain.music import MusicChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.core.cache import cached, fresh
|
||||
from app.core.config import settings, global_vars
|
||||
@@ -55,6 +56,7 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
self.douban_tv_animation,
|
||||
self.douban_movie_hot,
|
||||
self.douban_tv_hot,
|
||||
self.music_weekly,
|
||||
]
|
||||
|
||||
# 缓存并刷新所有推荐数据
|
||||
@@ -174,6 +176,17 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
page=page)
|
||||
return [movie.to_dict() for movie in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True)
|
||||
def music_weekly(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""返回 ListenBrainz 本周全站热门音乐。"""
|
||||
medias = MusicChain().chart(
|
||||
range_name="this_week",
|
||||
page=page or 1,
|
||||
count=count or 30,
|
||||
)
|
||||
return [media.to_dict() for media in medias]
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True)
|
||||
def tmdb_tvs(self, sort_by: Optional[str] = "popularity.desc",
|
||||
@@ -392,6 +405,17 @@ class RecommendChain(ChainBase, metaclass=Singleton):
|
||||
movies = await DoubanChain().async_run_module("async_movie_showing", page=page, count=count)
|
||||
return [media.to_dict() for media in movies] if movies else []
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True)
|
||||
async def async_music_weekly(self, page: Optional[int] = 1, count: Optional[int] = 30) -> List[dict]:
|
||||
"""异步返回 ListenBrainz 本周全站热门音乐。"""
|
||||
medias = await MusicChain().async_chart(
|
||||
range_name="this_week",
|
||||
page=page or 1,
|
||||
count=count or 30,
|
||||
)
|
||||
return [media.to_dict() for media in medias]
|
||||
|
||||
@log_execution_time(logger=logger)
|
||||
@cached(ttl=recommend_ttl, region=recommend_cache_region, skip_empty=True)
|
||||
async def async_douban_movies(self, sort: Optional[str] = "R", tags: Optional[str] = "",
|
||||
|
||||
+254
-33
@@ -12,9 +12,11 @@ from typing import List, Optional
|
||||
from fastapi.concurrency import run_in_threadpool
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.chain.music import MusicChain
|
||||
from app.core.config import global_vars, settings
|
||||
from app.core.context import Context
|
||||
from app.core.context import MediaInfo, SubtitleInfo, TorrentInfo
|
||||
from app.core.music import MusicInfo, MusicMeta
|
||||
from app.core.event import eventmanager, Event
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
@@ -544,10 +546,16 @@ class SearchChain(ChainBase):
|
||||
season=season,
|
||||
sites=sites,
|
||||
)
|
||||
mediainfo = self.recognize_media(
|
||||
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
|
||||
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
|
||||
)
|
||||
if mtype == MediaType.MUSIC:
|
||||
mediainfo = MusicChain().recognize(
|
||||
source=source,
|
||||
media_id=str(mediaid) if mediaid is not None else "",
|
||||
)
|
||||
else:
|
||||
mediainfo = self.recognize_media(
|
||||
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
|
||||
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
|
||||
)
|
||||
if not mediainfo:
|
||||
logger.error(f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} 媒体信息识别失败!')
|
||||
return []
|
||||
@@ -566,18 +574,23 @@ class SearchChain(ChainBase):
|
||||
return results
|
||||
|
||||
def search_by_title(self, title: str, page: Optional[int] = 0,
|
||||
sites: List[int] = None, cache_local: Optional[bool] = False) -> List[Context]:
|
||||
sites: List[int] = None, cache_local: Optional[bool] = False,
|
||||
mtype: Optional[MediaType] = None,
|
||||
rule_groups: Optional[List[str]] = None) -> List[Context]:
|
||||
"""
|
||||
根据标题搜索资源,不识别媒体信息,按默认搜索过滤规则返回站点内容
|
||||
:param title: 标题,为空时返回所有站点首页内容
|
||||
:param page: 页码
|
||||
:param sites: 站点ID列表
|
||||
:param cache_local: 是否缓存到本地
|
||||
:param mtype: 限定站点资源分类
|
||||
:param rule_groups: 指定过滤规则组,为空时使用默认搜索过滤规则
|
||||
"""
|
||||
if cache_local:
|
||||
self.cancel_ai_recommend()
|
||||
self.save_last_search_params(
|
||||
keyword=title,
|
||||
mtype=mtype,
|
||||
area="title",
|
||||
sites=sites,
|
||||
)
|
||||
@@ -586,18 +599,24 @@ class SearchChain(ChainBase):
|
||||
else:
|
||||
logger.info(f'开始浏览资源,站点:{sites} ...')
|
||||
# 搜索
|
||||
torrents = self.__search_all_sites(keyword=title, sites=sites, page=page) or []
|
||||
search_kwargs = {"keyword": title, "sites": sites, "page": page}
|
||||
if mtype is not None:
|
||||
search_kwargs["mtype"] = mtype
|
||||
torrents = self.__search_all_sites(**search_kwargs) or []
|
||||
if not torrents:
|
||||
logger.warn(f'{title} 未搜索到资源')
|
||||
return []
|
||||
torrents = self.__filter_title_search_torrents(torrents=torrents)
|
||||
torrents = self.__filter_title_search_torrents(
|
||||
torrents=torrents,
|
||||
rule_groups=rule_groups,
|
||||
)
|
||||
if not torrents:
|
||||
logger.warn(f'{title} 没有符合过滤规则的资源')
|
||||
return []
|
||||
# 组装上下文
|
||||
contexts = [
|
||||
Context(
|
||||
meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
|
||||
meta_info=self._build_title_search_meta(torrent, mtype),
|
||||
torrent_info=torrent,
|
||||
resource_source="search",
|
||||
) for torrent in torrents
|
||||
@@ -860,10 +879,16 @@ class SearchChain(ChainBase):
|
||||
season=season,
|
||||
sites=sites,
|
||||
)
|
||||
mediainfo = await self.async_recognize_media(
|
||||
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
|
||||
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
|
||||
)
|
||||
if mtype == MediaType.MUSIC:
|
||||
mediainfo = await MusicChain().async_recognize(
|
||||
source=source,
|
||||
media_id=str(mediaid) if mediaid is not None else "",
|
||||
)
|
||||
else:
|
||||
mediainfo = await self.async_recognize_media(
|
||||
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
|
||||
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
|
||||
)
|
||||
if not mediainfo:
|
||||
logger.error(
|
||||
f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} '
|
||||
@@ -885,18 +910,23 @@ class SearchChain(ChainBase):
|
||||
return results
|
||||
|
||||
async def async_search_by_title(self, title: str, page: Optional[int] = 0,
|
||||
sites: List[int] = None, cache_local: Optional[bool] = False) -> List[Context]:
|
||||
sites: List[int] = None, cache_local: Optional[bool] = False,
|
||||
mtype: Optional[MediaType] = None,
|
||||
rule_groups: Optional[List[str]] = None) -> List[Context]:
|
||||
"""
|
||||
根据标题异步搜索资源,不识别媒体信息,按默认搜索过滤规则返回站点内容
|
||||
:param title: 标题,为空时返回所有站点首页内容
|
||||
:param page: 页码
|
||||
:param sites: 站点ID列表
|
||||
:param cache_local: 是否缓存到本地
|
||||
:param mtype: 限定站点资源分类
|
||||
:param rule_groups: 指定过滤规则组,为空时使用默认搜索过滤规则
|
||||
"""
|
||||
if cache_local:
|
||||
self.cancel_ai_recommend()
|
||||
await self.async_save_last_search_params(
|
||||
keyword=title,
|
||||
mtype=mtype,
|
||||
area="title",
|
||||
sites=sites,
|
||||
)
|
||||
@@ -905,18 +935,25 @@ class SearchChain(ChainBase):
|
||||
else:
|
||||
logger.info(f'开始浏览资源,站点:{sites} ...')
|
||||
# 搜索
|
||||
torrents = await self.__async_search_all_sites(keyword=title, sites=sites, page=page) or []
|
||||
search_kwargs = {"keyword": title, "sites": sites, "page": page}
|
||||
if mtype is not None:
|
||||
search_kwargs["mtype"] = mtype
|
||||
torrents = await self.__async_search_all_sites(**search_kwargs) or []
|
||||
if not torrents:
|
||||
logger.warn(f'{title} 未搜索到资源')
|
||||
return []
|
||||
torrents = await run_in_threadpool(self.__filter_title_search_torrents, torrents=torrents)
|
||||
torrents = await run_in_threadpool(
|
||||
self.__filter_title_search_torrents,
|
||||
torrents=torrents,
|
||||
rule_groups=rule_groups,
|
||||
)
|
||||
if not torrents:
|
||||
logger.warn(f'{title} 没有符合过滤规则的资源')
|
||||
return []
|
||||
# 组装上下文
|
||||
contexts = [
|
||||
Context(
|
||||
meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
|
||||
meta_info=self._build_title_search_meta(torrent, mtype),
|
||||
torrent_info=torrent,
|
||||
resource_source="search",
|
||||
) for torrent in torrents
|
||||
@@ -928,7 +965,9 @@ class SearchChain(ChainBase):
|
||||
|
||||
async def async_search_by_title_stream(self, title: str, page: Optional[int] = 0,
|
||||
sites: List[int] = None,
|
||||
cache_local: Optional[bool] = False) -> AsyncIterator[dict]:
|
||||
cache_local: Optional[bool] = False,
|
||||
mtype: Optional[MediaType] = None,
|
||||
rule_groups: Optional[List[str]] = None) -> AsyncIterator[dict]:
|
||||
"""
|
||||
根据标题渐进式搜索资源,不识别媒体信息,按默认搜索过滤规则返回结果
|
||||
"""
|
||||
@@ -936,6 +975,7 @@ class SearchChain(ChainBase):
|
||||
self.cancel_ai_recommend()
|
||||
await self.async_save_last_search_params(
|
||||
keyword=title,
|
||||
mtype=mtype,
|
||||
area="title",
|
||||
sites=sites,
|
||||
)
|
||||
@@ -945,8 +985,10 @@ class SearchChain(ChainBase):
|
||||
logger.info(f'开始渐进式浏览资源,站点:{sites} ...')
|
||||
|
||||
contexts: List[Context] = []
|
||||
rule_groups: List[str] = SystemConfigOper().get(SystemConfigKey.SearchFilterRuleGroups) or []
|
||||
async for event in self.__async_search_all_sites_stream(keyword=title, sites=sites, page=page):
|
||||
if rule_groups is None:
|
||||
rule_groups = SystemConfigOper().get(SystemConfigKey.SearchFilterRuleGroups) or []
|
||||
async for event in self.__async_search_all_sites_stream(
|
||||
keyword=title, sites=sites, page=page, mtype=mtype):
|
||||
result = event.pop("items", []) or []
|
||||
result = await run_in_threadpool(
|
||||
self.__filter_title_search_torrents,
|
||||
@@ -955,7 +997,7 @@ class SearchChain(ChainBase):
|
||||
)
|
||||
batch_contexts = [
|
||||
Context(
|
||||
meta_info=MetaInfo(title=torrent.title, subtitle=torrent.description),
|
||||
meta_info=self._build_title_search_meta(torrent, mtype),
|
||||
torrent_info=torrent,
|
||||
resource_source="search",
|
||||
)
|
||||
@@ -982,6 +1024,19 @@ class SearchChain(ChainBase):
|
||||
"total_items": len(contexts)
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _build_title_search_meta(
|
||||
torrent: TorrentInfo,
|
||||
mtype: Optional[MediaType],
|
||||
) -> Any:
|
||||
"""根据限定媒体类型构造模糊搜索结果的上下文元数据。"""
|
||||
if mtype == MediaType.MUSIC:
|
||||
return MusicMeta(
|
||||
org_string=torrent.title,
|
||||
title=torrent.title,
|
||||
)
|
||||
return MetaInfo(title=torrent.title, subtitle=torrent.description)
|
||||
|
||||
def __filter_title_search_torrents(self,
|
||||
torrents: List[TorrentInfo],
|
||||
rule_groups: Optional[List[str]] = None) -> List[TorrentInfo]:
|
||||
@@ -1027,10 +1082,16 @@ class SearchChain(ChainBase):
|
||||
season=season,
|
||||
sites=sites,
|
||||
)
|
||||
mediainfo = await self.async_recognize_media(
|
||||
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
|
||||
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
|
||||
)
|
||||
if mtype == MediaType.MUSIC:
|
||||
mediainfo = await MusicChain().async_recognize(
|
||||
source=source,
|
||||
media_id=str(mediaid) if mediaid is not None else "",
|
||||
)
|
||||
else:
|
||||
mediainfo = await self.async_recognize_media(
|
||||
source=source, mediaid=mediaid, tmdbid=tmdbid, doubanid=doubanid,
|
||||
bangumiid=bangumiid, anilistid=anilistid, mtype=mtype,
|
||||
)
|
||||
if not mediainfo:
|
||||
logger.error(
|
||||
f'{self._build_search_keyword(source, mediaid, tmdbid, doubanid, bangumiid, anilistid)} '
|
||||
@@ -1307,6 +1368,115 @@ class SearchChain(ChainBase):
|
||||
return list({f"{t.torrent_info.site_name}_{t.torrent_info.title}_{t.torrent_info.description}": t
|
||||
for t in _torrents}.values())
|
||||
|
||||
def _build_music_contexts(
|
||||
self,
|
||||
torrents: List[TorrentInfo],
|
||||
mediainfo: MusicInfo,
|
||||
rule_groups: Optional[List[str]] = None,
|
||||
filter_params: Optional[Dict[str, str]] = None,
|
||||
) -> List[Context]:
|
||||
"""过滤音乐分类资源并组装携带目标音乐身份的下载上下文。"""
|
||||
torrents = [
|
||||
torrent
|
||||
for torrent in torrents
|
||||
if torrent.category in (MediaType.MUSIC, MediaType.MUSIC.value)
|
||||
]
|
||||
if filter_params:
|
||||
torrenthelper = TorrentHelper()
|
||||
torrents = [
|
||||
torrent
|
||||
for torrent in torrents
|
||||
if torrenthelper.filter_torrent(torrent, filter_params)
|
||||
]
|
||||
if rule_groups is None:
|
||||
rule_groups = SystemConfigOper().get(SystemConfigKey.SearchFilterRuleGroups) or []
|
||||
if rule_groups and torrents:
|
||||
torrents = self.filter_torrents(
|
||||
rule_groups=rule_groups,
|
||||
torrent_list=torrents,
|
||||
mediainfo=mediainfo,
|
||||
) or []
|
||||
|
||||
contexts = []
|
||||
for torrent in torrents:
|
||||
meta = MusicChain.to_meta(mediainfo)
|
||||
meta.org_string = torrent.title
|
||||
contexts.append(
|
||||
Context(
|
||||
torrent_info=torrent,
|
||||
media_info=mediainfo,
|
||||
meta_info=meta,
|
||||
resource_source="search",
|
||||
match_source=mediainfo.source or "title",
|
||||
candidate_recognized=False,
|
||||
media_info_is_target=True,
|
||||
)
|
||||
)
|
||||
return self.__remove_duplicate(TorrentHelper.sort_torrents(contexts))
|
||||
|
||||
def _process_music(
|
||||
self,
|
||||
mediainfo: MusicInfo,
|
||||
keyword: Optional[str] = None,
|
||||
sites: Optional[List[int]] = None,
|
||||
rule_groups: Optional[List[str]] = None,
|
||||
filter_params: Optional[Dict[str, str]] = None,
|
||||
) -> List[Context]:
|
||||
"""按音乐元数据生成站点关键词并执行同步资源搜索。"""
|
||||
keywords = [keyword] if keyword else MusicChain.build_site_keywords(mediainfo)
|
||||
torrents: List[TorrentInfo] = []
|
||||
for index, search_word in enumerate(keywords or [mediainfo.title]):
|
||||
if index:
|
||||
time.sleep(random.randint(1, 10))
|
||||
torrents.extend(
|
||||
self.__search_all_sites(
|
||||
keyword=search_word,
|
||||
mediainfo=mediainfo,
|
||||
sites=sites,
|
||||
mtype=MediaType.MUSIC,
|
||||
) or []
|
||||
)
|
||||
if torrents and not settings.SEARCH_MULTIPLE_NAME:
|
||||
break
|
||||
return self._build_music_contexts(
|
||||
torrents=torrents,
|
||||
mediainfo=mediainfo,
|
||||
rule_groups=rule_groups,
|
||||
filter_params=filter_params,
|
||||
)
|
||||
|
||||
async def _async_process_music(
|
||||
self,
|
||||
mediainfo: MusicInfo,
|
||||
keyword: Optional[str] = None,
|
||||
sites: Optional[List[int]] = None,
|
||||
rule_groups: Optional[List[str]] = None,
|
||||
filter_params: Optional[Dict[str, str]] = None,
|
||||
) -> List[Context]:
|
||||
"""按音乐元数据生成站点关键词并执行异步资源搜索。"""
|
||||
keywords = [keyword] if keyword else MusicChain.build_site_keywords(mediainfo)
|
||||
torrents: List[TorrentInfo] = []
|
||||
for index, search_word in enumerate(keywords or [mediainfo.title]):
|
||||
if index:
|
||||
await asyncio.sleep(random.randint(1, 10))
|
||||
torrents.extend(
|
||||
await self.__async_search_all_sites(
|
||||
keyword=search_word,
|
||||
mediainfo=mediainfo,
|
||||
sites=sites,
|
||||
mtype=MediaType.MUSIC,
|
||||
) or []
|
||||
)
|
||||
if torrents and not settings.SEARCH_MULTIPLE_NAME:
|
||||
break
|
||||
return await run_in_threadpool(
|
||||
self._build_music_contexts,
|
||||
torrents=torrents,
|
||||
mediainfo=mediainfo,
|
||||
rule_groups=rule_groups,
|
||||
filter_params=filter_params,
|
||||
)
|
||||
|
||||
def process(self, mediainfo: MediaInfo,
|
||||
keyword: Optional[str] = None,
|
||||
no_exists: Dict[int, Dict[int, NotExistMediaInfo]] = None,
|
||||
@@ -1327,6 +1497,15 @@ class SearchChain(ChainBase):
|
||||
:param filter_params: 过滤参数
|
||||
"""
|
||||
|
||||
if mediainfo.type == MediaType.MUSIC:
|
||||
return self._process_music(
|
||||
mediainfo=mediainfo,
|
||||
keyword=keyword,
|
||||
sites=sites,
|
||||
rule_groups=rule_groups,
|
||||
filter_params=filter_params,
|
||||
)
|
||||
|
||||
# 豆瓣标题处理
|
||||
if not mediainfo.tmdb_id:
|
||||
meta = MetaInfo(title=mediainfo.title)
|
||||
@@ -1411,6 +1590,15 @@ class SearchChain(ChainBase):
|
||||
:param filter_params: 过滤参数
|
||||
"""
|
||||
|
||||
if mediainfo.type == MediaType.MUSIC:
|
||||
return await self._async_process_music(
|
||||
mediainfo=mediainfo,
|
||||
keyword=keyword,
|
||||
sites=sites,
|
||||
rule_groups=rule_groups,
|
||||
filter_params=filter_params,
|
||||
)
|
||||
|
||||
# 豆瓣标题处理
|
||||
if not mediainfo.tmdb_id:
|
||||
meta = MetaInfo(title=mediainfo.title)
|
||||
@@ -1484,6 +1672,33 @@ class SearchChain(ChainBase):
|
||||
根据媒体信息渐进式搜索种子资源,先返回站点候选,再返回过滤匹配后的最终结果
|
||||
"""
|
||||
|
||||
if mediainfo.type == MediaType.MUSIC:
|
||||
contexts = await self._async_process_music(
|
||||
mediainfo=mediainfo,
|
||||
keyword=keyword,
|
||||
sites=sites,
|
||||
rule_groups=rule_groups,
|
||||
filter_params=filter_params,
|
||||
)
|
||||
items = [context.to_dict() for context in contexts]
|
||||
yield {
|
||||
"type": "replace",
|
||||
"stage": "filtered",
|
||||
"value": 100,
|
||||
"text": f"过滤匹配完成,共 {len(contexts)} 个资源",
|
||||
"items": items,
|
||||
"total_items": len(contexts),
|
||||
}
|
||||
yield {
|
||||
"type": "done",
|
||||
"stage": "done",
|
||||
"text": f"搜索完成,共 {len(contexts)} 个资源",
|
||||
"items": items,
|
||||
"total_items": len(contexts),
|
||||
"contexts": contexts,
|
||||
}
|
||||
return
|
||||
|
||||
# 豆瓣标题处理
|
||||
if not mediainfo.tmdb_id:
|
||||
meta = MetaInfo(title=mediainfo.title)
|
||||
@@ -1925,7 +2140,8 @@ class SearchChain(ChainBase):
|
||||
mediainfo: Optional[MediaInfo] = None,
|
||||
sites: List[int] = None,
|
||||
page: Optional[int] = 0,
|
||||
area: Optional[str] = "title") -> Optional[List[TorrentInfo]]:
|
||||
area: Optional[str] = "title",
|
||||
mtype: Optional[MediaType] = None) -> Optional[List[TorrentInfo]]:
|
||||
"""
|
||||
多线程搜索多个站点
|
||||
:param mediainfo: 识别的媒体信息
|
||||
@@ -1933,6 +2149,7 @@ class SearchChain(ChainBase):
|
||||
:param sites: 指定站点ID列表,如有则只搜索指定站点,否则搜索所有站点
|
||||
:param page: 搜索页码
|
||||
:param area: 搜索区域 title or imdbid
|
||||
:param mtype: 未提供媒体详情时使用的站点资源分类
|
||||
:reutrn: 资源列表
|
||||
"""
|
||||
# 未开启的站点不搜索
|
||||
@@ -1980,13 +2197,13 @@ class SearchChain(ChainBase):
|
||||
# 搜索IMDBID
|
||||
task = executor.submit(self.search_torrents, site=site,
|
||||
keyword=search_keyword,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
mtype=mediainfo.type if mediainfo else mtype,
|
||||
page=search_page)
|
||||
else:
|
||||
# 搜索标题
|
||||
task = executor.submit(self.search_torrents, site=site,
|
||||
keyword=search_keyword,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
mtype=mediainfo.type if mediainfo else mtype,
|
||||
page=search_page)
|
||||
pending_tasks[task] = (site, page_index, search_page, search_keyword)
|
||||
|
||||
@@ -2037,7 +2254,8 @@ class SearchChain(ChainBase):
|
||||
mediainfo: Optional[MediaInfo] = None,
|
||||
sites: List[int] = None,
|
||||
page: Optional[int] = 0,
|
||||
area: Optional[str] = "title") -> Optional[List[TorrentInfo]]:
|
||||
area: Optional[str] = "title",
|
||||
mtype: Optional[MediaType] = None) -> Optional[List[TorrentInfo]]:
|
||||
"""
|
||||
异步搜索多个站点
|
||||
:param mediainfo: 识别的媒体信息
|
||||
@@ -2045,6 +2263,7 @@ class SearchChain(ChainBase):
|
||||
:param sites: 指定站点ID列表,如有则只搜索指定站点,否则搜索所有站点
|
||||
:param page: 搜索页码
|
||||
:param area: 搜索区域 title or imdbid
|
||||
:param mtype: 未提供媒体详情时使用的站点资源分类
|
||||
:reutrn: 资源列表
|
||||
"""
|
||||
# 未开启的站点不搜索
|
||||
@@ -2088,12 +2307,12 @@ class SearchChain(ChainBase):
|
||||
# 搜索IMDBID
|
||||
return await self.async_search_torrents(site=site,
|
||||
keyword=mediainfo.imdb_id if mediainfo else None,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
mtype=mediainfo.type if mediainfo else mtype,
|
||||
page=search_page)
|
||||
# 搜索标题
|
||||
return await self.async_search_torrents(site=site,
|
||||
keyword=keyword,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
mtype=mediainfo.type if mediainfo else mtype,
|
||||
page=search_page)
|
||||
|
||||
pending_tasks = {}
|
||||
@@ -2161,7 +2380,8 @@ class SearchChain(ChainBase):
|
||||
mediainfo: Optional[MediaInfo] = None,
|
||||
sites: List[int] = None,
|
||||
page: Optional[int] = 0,
|
||||
area: Optional[str] = "title") -> AsyncIterator[Dict[str, Any]]:
|
||||
area: Optional[str] = "title",
|
||||
mtype: Optional[MediaType] = None) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""
|
||||
异步搜索多个站点,按站点完成顺序渐进式返回结果
|
||||
:param mediainfo: 识别的媒体信息
|
||||
@@ -2169,6 +2389,7 @@ class SearchChain(ChainBase):
|
||||
:param sites: 指定站点ID列表,如有则只搜索指定站点,否则搜索所有站点
|
||||
:param page: 搜索页码
|
||||
:param area: 搜索区域 title or imdbid
|
||||
:param mtype: 未提供媒体详情时使用的站点资源分类
|
||||
"""
|
||||
indexer_sites = []
|
||||
|
||||
@@ -2219,12 +2440,12 @@ class SearchChain(ChainBase):
|
||||
if area == "imdbid":
|
||||
site_result = await self.async_search_torrents(site=site,
|
||||
keyword=mediainfo.imdb_id if mediainfo else None,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
mtype=mediainfo.type if mediainfo else mtype,
|
||||
page=search_page)
|
||||
else:
|
||||
site_result = await self.async_search_torrents(site=site,
|
||||
keyword=keyword,
|
||||
mtype=mediainfo.type if mediainfo else None,
|
||||
mtype=mediainfo.type if mediainfo else mtype,
|
||||
page=search_page)
|
||||
return site_result or []
|
||||
|
||||
|
||||
+198
-36
@@ -12,6 +12,7 @@ from app.chain import ChainBase
|
||||
from app.chain.download import DownloadChain
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.mediaserver import MediaServerChain
|
||||
from app.chain.music import MusicChain
|
||||
from app.chain.search import SearchChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.chain.torrents import TorrentsChain
|
||||
@@ -21,6 +22,7 @@ from app.core.event import eventmanager, Event
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.meta.words import WordsMatcher
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.music import MusicInfo, MusicMeta
|
||||
from app.db.downloadhistory_oper import DownloadHistoryOper
|
||||
from app.db.models.subscribe import Subscribe
|
||||
from app.db.site_oper import SiteOper
|
||||
@@ -53,10 +55,17 @@ from app.utils.media import (
|
||||
subscribe_interaction_manager = SlashInteractionManager()
|
||||
|
||||
|
||||
def build_subscribe_meta(subscribe: Subscribe) -> MetaBase:
|
||||
def build_subscribe_meta(subscribe: Subscribe) -> Union[MetaBase, MusicMeta]:
|
||||
"""
|
||||
按订阅对象构造主程序链路共用的 MetaInfo。
|
||||
按订阅对象构造主程序链路共用的媒体元数据。
|
||||
"""
|
||||
if subscribe.type == MediaType.MUSIC.value:
|
||||
return MusicMeta(
|
||||
title=subscribe.name,
|
||||
year=subscribe.year,
|
||||
media_source=subscribe.media_source,
|
||||
media_id=str(subscribe.media_id) if subscribe.media_id is not None else None,
|
||||
)
|
||||
meta = MetaInfo(subscribe.name)
|
||||
meta.year = subscribe.year
|
||||
meta.begin_season = subscribe.season
|
||||
@@ -842,7 +851,7 @@ class SubscribeChain(ChainBase):
|
||||
:param key: 配置键
|
||||
:return: 配置值
|
||||
"""
|
||||
return {
|
||||
defaults = {
|
||||
'quality': self.__get_default_subscribe_config(mtype, "quality") if not kwargs.get(
|
||||
"quality") else kwargs.get("quality"),
|
||||
'resolution': self.__get_default_subscribe_config(mtype, "resolution") if not kwargs.get(
|
||||
@@ -868,6 +877,14 @@ class SubscribeChain(ChainBase):
|
||||
'filter_groups': self.__get_default_subscribe_config(mtype, "filter_groups") if not kwargs.get(
|
||||
"filter_groups") else kwargs.get("filter_groups")
|
||||
}
|
||||
if mtype == MediaType.MUSIC:
|
||||
# 音乐订阅当前只负责首次获取,不复用影视洗版和 IMDB 搜索语义。
|
||||
defaults.update({
|
||||
"best_version": 0,
|
||||
"best_version_full": 0,
|
||||
"search_imdbid": 0,
|
||||
})
|
||||
return defaults
|
||||
|
||||
def add(self, title: str, year: str,
|
||||
mtype: MediaType = None,
|
||||
@@ -894,7 +911,7 @@ class SubscribeChain(ChainBase):
|
||||
logger.info(f'开始添加订阅,标题:{title} ...')
|
||||
|
||||
mediainfo = None
|
||||
metainfo = MetaInfo(title)
|
||||
metainfo = MusicChain.parse_query(title) if mtype == MediaType.MUSIC else MetaInfo(title)
|
||||
if year:
|
||||
metainfo.year = year
|
||||
if mtype:
|
||||
@@ -914,7 +931,16 @@ class SubscribeChain(ChainBase):
|
||||
)
|
||||
if resolved_source and resolved_media_id:
|
||||
media_source, media_id = resolved_source, resolved_media_id
|
||||
if any((media_id, tmdbid, doubanid, bangumiid, anilistid)):
|
||||
if mtype == MediaType.MUSIC:
|
||||
if media_source and media_id:
|
||||
mediainfo = MusicChain().recognize(
|
||||
source=media_source,
|
||||
media_id=str(media_id),
|
||||
)
|
||||
if not mediainfo:
|
||||
music_candidates = MusicChain().search(title, limit=1)
|
||||
mediainfo = music_candidates[0] if music_candidates else None
|
||||
elif any((media_id, tmdbid, doubanid, bangumiid, anilistid)):
|
||||
mediainfo = self.recognize_media(
|
||||
meta=metainfo,
|
||||
mtype=mtype,
|
||||
@@ -930,14 +956,14 @@ class SubscribeChain(ChainBase):
|
||||
elif mediaid:
|
||||
mediainfo = self.__get_event_media(mediaid, metainfo)
|
||||
|
||||
if mediainfo and mediainfo.source != "themoviedb":
|
||||
if mtype != MediaType.MUSIC and mediainfo and mediainfo.source != "themoviedb":
|
||||
meta = MetaInfo(mediainfo.title)
|
||||
mediainfo.title = meta.name
|
||||
if season is None:
|
||||
season = meta.begin_season
|
||||
|
||||
# 明确来源时只允许在同一来源内按名称兜底,不能切换主识别源。
|
||||
if not mediainfo:
|
||||
if not mediainfo and mtype != MediaType.MUSIC:
|
||||
mediainfo = MediaChain().recognize_by_meta(
|
||||
metainfo,
|
||||
source=media_source,
|
||||
@@ -994,13 +1020,14 @@ class SubscribeChain(ChainBase):
|
||||
season = None
|
||||
|
||||
# 更新媒体图片
|
||||
self.obtain_images(mediainfo=mediainfo)
|
||||
if mediainfo.type != MediaType.MUSIC:
|
||||
self.obtain_images(mediainfo=mediainfo)
|
||||
# 合并信息
|
||||
if doubanid:
|
||||
if doubanid and mediainfo.type != MediaType.MUSIC:
|
||||
mediainfo.douban_id = doubanid
|
||||
if bangumiid:
|
||||
if bangumiid and mediainfo.type != MediaType.MUSIC:
|
||||
mediainfo.bangumi_id = bangumiid
|
||||
if anilistid:
|
||||
if anilistid and mediainfo.type != MediaType.MUSIC:
|
||||
mediainfo.anilist_id = anilistid
|
||||
|
||||
media_source, media_id = resolve_media_identity(
|
||||
@@ -1029,6 +1056,8 @@ class SubscribeChain(ChainBase):
|
||||
elif message:
|
||||
if mediainfo.type == MediaType.TV:
|
||||
link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub')
|
||||
elif mediainfo.type == MediaType.MUSIC:
|
||||
link = settings.MP_DOMAIN('#/subscribe/music?tab=mysub')
|
||||
else:
|
||||
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
|
||||
# 订阅成功按规则发送消息
|
||||
@@ -1100,7 +1129,7 @@ class SubscribeChain(ChainBase):
|
||||
logger.info(f'开始添加订阅,标题:{title} ...')
|
||||
|
||||
mediainfo = None
|
||||
metainfo = MetaInfo(title)
|
||||
metainfo = MusicChain.parse_query(title) if mtype == MediaType.MUSIC else MetaInfo(title)
|
||||
if year:
|
||||
metainfo.year = year
|
||||
if mtype:
|
||||
@@ -1120,7 +1149,16 @@ class SubscribeChain(ChainBase):
|
||||
)
|
||||
if resolved_source and resolved_media_id:
|
||||
media_source, media_id = resolved_source, resolved_media_id
|
||||
if any((media_id, tmdbid, doubanid, bangumiid, anilistid)):
|
||||
if mtype == MediaType.MUSIC:
|
||||
if media_source and media_id:
|
||||
mediainfo = await MusicChain().async_recognize(
|
||||
source=media_source,
|
||||
media_id=str(media_id),
|
||||
)
|
||||
if not mediainfo:
|
||||
music_candidates = await MusicChain().async_search(title, limit=1)
|
||||
mediainfo = music_candidates[0] if music_candidates else None
|
||||
elif any((media_id, tmdbid, doubanid, bangumiid, anilistid)):
|
||||
mediainfo = await self.async_recognize_media(
|
||||
meta=metainfo,
|
||||
mtype=mtype,
|
||||
@@ -1136,14 +1174,14 @@ class SubscribeChain(ChainBase):
|
||||
elif mediaid:
|
||||
mediainfo = await self.__async_get_event_meida(mediaid, metainfo)
|
||||
|
||||
if mediainfo and mediainfo.source != "themoviedb":
|
||||
if mtype != MediaType.MUSIC and mediainfo and mediainfo.source != "themoviedb":
|
||||
meta = MetaInfo(mediainfo.title)
|
||||
mediainfo.title = meta.name
|
||||
if season is None:
|
||||
season = meta.begin_season
|
||||
|
||||
# 明确来源时只允许在同一来源内按名称兜底,不能切换主识别源。
|
||||
if not mediainfo:
|
||||
if not mediainfo and mtype != MediaType.MUSIC:
|
||||
mediainfo = await MediaChain().async_recognize_by_meta(
|
||||
metainfo,
|
||||
source=media_source,
|
||||
@@ -1200,13 +1238,14 @@ class SubscribeChain(ChainBase):
|
||||
season = None
|
||||
|
||||
# 更新媒体图片
|
||||
await self.async_obtain_images(mediainfo=mediainfo)
|
||||
if mediainfo.type != MediaType.MUSIC:
|
||||
await self.async_obtain_images(mediainfo=mediainfo)
|
||||
# 合并信息
|
||||
if doubanid:
|
||||
if doubanid and mediainfo.type != MediaType.MUSIC:
|
||||
mediainfo.douban_id = doubanid
|
||||
if bangumiid:
|
||||
if bangumiid and mediainfo.type != MediaType.MUSIC:
|
||||
mediainfo.bangumi_id = bangumiid
|
||||
if anilistid:
|
||||
if anilistid and mediainfo.type != MediaType.MUSIC:
|
||||
mediainfo.anilist_id = anilistid
|
||||
|
||||
media_source, media_id = resolve_media_identity(
|
||||
@@ -1235,6 +1274,8 @@ class SubscribeChain(ChainBase):
|
||||
elif message:
|
||||
if mediainfo.type == MediaType.TV:
|
||||
link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub')
|
||||
elif mediainfo.type == MediaType.MUSIC:
|
||||
link = settings.MP_DOMAIN('#/subscribe/music?tab=mysub')
|
||||
else:
|
||||
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
|
||||
# 订阅成功按规则发送消息
|
||||
@@ -1300,6 +1341,105 @@ class SubscribeChain(ChainBase):
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]:
|
||||
"""按订阅身份恢复音乐目标,缺少身份时按标题查询首个候选。"""
|
||||
musicchain = MusicChain()
|
||||
if subscribe.media_source and subscribe.media_id:
|
||||
mediainfo = musicchain.recognize(
|
||||
source=subscribe.media_source,
|
||||
media_id=str(subscribe.media_id),
|
||||
)
|
||||
if mediainfo:
|
||||
return mediainfo
|
||||
candidates = musicchain.search(subscribe.name, limit=1)
|
||||
return candidates[0] if candidates else None
|
||||
|
||||
@staticmethod
|
||||
async def _async_recognize_music_subscribe(subscribe: Subscribe) -> Optional[MusicInfo]:
|
||||
"""异步按订阅身份恢复音乐目标,缺少身份时按标题查询首个候选。"""
|
||||
musicchain = MusicChain()
|
||||
if subscribe.media_source and subscribe.media_id:
|
||||
mediainfo = await musicchain.async_recognize(
|
||||
source=subscribe.media_source,
|
||||
media_id=str(subscribe.media_id),
|
||||
)
|
||||
if mediainfo:
|
||||
return mediainfo
|
||||
candidates = await musicchain.async_search(subscribe.name, limit=1)
|
||||
return candidates[0] if candidates else None
|
||||
|
||||
def _search_music_subscribe(self, subscribe: Subscribe) -> None:
|
||||
"""复用站点标题搜索、订阅过滤和批量下载完成单个音乐订阅。"""
|
||||
mediainfo = self._recognize_music_subscribe(subscribe)
|
||||
if not mediainfo:
|
||||
logger.warning(
|
||||
f"未识别到音乐订阅目标:{subscribe.name},"
|
||||
f"媒体源:{subscribe.media_source},媒体ID:{subscribe.media_id}"
|
||||
)
|
||||
return
|
||||
|
||||
sites = self.get_sub_sites(subscribe)
|
||||
rule_groups = subscribe.filter_groups \
|
||||
or SystemConfigOper().get(SystemConfigKey.SubscribeFilterRuleGroups) or []
|
||||
keywords = [subscribe.keyword] if subscribe.keyword else MusicChain.build_site_keywords(mediainfo)
|
||||
if not keywords:
|
||||
keywords = [subscribe.name]
|
||||
|
||||
searchchain = SearchChain()
|
||||
contexts: List[Context] = []
|
||||
for keyword in keywords:
|
||||
contexts = searchchain.search_by_title(
|
||||
title=keyword,
|
||||
sites=sites,
|
||||
mtype=MediaType.MUSIC,
|
||||
rule_groups=rule_groups,
|
||||
)
|
||||
contexts = [
|
||||
context
|
||||
for context in contexts
|
||||
if context.torrent_info
|
||||
and context.torrent_info.category in (MediaType.MUSIC, MediaType.MUSIC.value)
|
||||
and TorrentHelper().filter_torrent(
|
||||
context.torrent_info,
|
||||
self.get_params(subscribe),
|
||||
)
|
||||
]
|
||||
if contexts:
|
||||
break
|
||||
|
||||
if not contexts:
|
||||
logger.warning(f"音乐订阅 {subscribe.keyword or subscribe.name} 未搜索到符合条件的资源")
|
||||
return
|
||||
|
||||
for context in contexts:
|
||||
meta = MusicChain.to_meta(mediainfo)
|
||||
meta.org_string = context.torrent_info.title
|
||||
context.meta_info = meta
|
||||
context.media_info = mediainfo
|
||||
context.match_source = mediainfo.source or "title"
|
||||
context.candidate_recognized = False
|
||||
context.media_info_is_target = True
|
||||
if subscribe.media_category:
|
||||
context.media_info.category = subscribe.media_category
|
||||
|
||||
downloads, _ = DownloadChain().batch_download(
|
||||
contexts=contexts,
|
||||
username=subscribe.username,
|
||||
save_path=subscribe.save_path,
|
||||
downloader=subscribe.downloader,
|
||||
source=self.get_subscribe_source_keyword(subscribe),
|
||||
custom_words=subscribe.custom_words,
|
||||
)
|
||||
current_subscribe = SubscribeOper().get(subscribe.id)
|
||||
if current_subscribe:
|
||||
self.finish_subscribe_or_not(
|
||||
subscribe=current_subscribe,
|
||||
meta=MusicChain.to_meta(mediainfo),
|
||||
mediainfo=mediainfo,
|
||||
downloads=downloads,
|
||||
)
|
||||
|
||||
def search(
|
||||
self,
|
||||
sid: Optional[int] = None,
|
||||
@@ -1375,6 +1515,9 @@ class SubscribeChain(ChainBase):
|
||||
try:
|
||||
search_attempted = True
|
||||
logger.info(f'开始搜索订阅,标题:{subscribe.name} ...')
|
||||
if subscribe.type == MediaType.MUSIC.value:
|
||||
self._search_music_subscribe(subscribe)
|
||||
continue
|
||||
try:
|
||||
meta = build_subscribe_meta(subscribe)
|
||||
except ValueError:
|
||||
@@ -1603,7 +1746,7 @@ class SubscribeChain(ChainBase):
|
||||
scene="download",
|
||||
)
|
||||
if ((no_lefts and meta.type == MediaType.TV)
|
||||
or (downloads and meta.type == MediaType.MOVIE)
|
||||
or (downloads and meta.type in (MediaType.MOVIE, MediaType.MUSIC))
|
||||
or force):
|
||||
self.__finish_subscribe(subscribe=subscribe, meta=meta, mediainfo=mediainfo)
|
||||
else:
|
||||
@@ -1816,6 +1959,10 @@ class SubscribeChain(ChainBase):
|
||||
},
|
||||
)
|
||||
logger.info(f'开始匹配订阅,标题:{subscribe.name} ...')
|
||||
if subscribe.type == MediaType.MUSIC.value:
|
||||
# 音乐不参与影视预识别缓存,直接复用音乐订阅搜索链处理。
|
||||
self._search_music_subscribe(subscribe)
|
||||
continue
|
||||
mediakey = _subscribe_media_key(subscribe)
|
||||
try:
|
||||
meta = build_subscribe_meta(subscribe)
|
||||
@@ -2145,19 +2292,23 @@ class SubscribeChain(ChainBase):
|
||||
logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}')
|
||||
continue
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = self.recognize_media(
|
||||
meta=meta,
|
||||
mtype=meta.type,
|
||||
**_subscribe_recognize_kwargs(subscribe),
|
||||
episode_group=subscribe.episode_group,
|
||||
cache=False,
|
||||
)
|
||||
if meta.type == MediaType.MUSIC:
|
||||
mediainfo = self._recognize_music_subscribe(subscribe)
|
||||
else:
|
||||
mediainfo: MediaInfo = self.recognize_media(
|
||||
meta=meta,
|
||||
mtype=meta.type,
|
||||
**_subscribe_recognize_kwargs(subscribe),
|
||||
episode_group=subscribe.episode_group,
|
||||
cache=False,
|
||||
)
|
||||
if not mediainfo:
|
||||
logger.warn(
|
||||
f'未识别到媒体信息,标题:{subscribe.name},tmdbid:{subscribe.tmdbid},doubanid:{subscribe.doubanid}')
|
||||
continue
|
||||
# 对于电视剧,获取当前季的总集数
|
||||
episodes = mediainfo.seasons.get(subscribe.season) or []
|
||||
episodes = (mediainfo.seasons.get(subscribe.season) or []) \
|
||||
if meta.type == MediaType.TV else []
|
||||
progress_update = {}
|
||||
if subscribe.type == MediaType.TV.value and not subscribe.manual_total_episode and len(episodes):
|
||||
current_total_episode = len(episodes)
|
||||
@@ -2400,12 +2551,15 @@ class SubscribeChain(ChainBase):
|
||||
logger.error(f'订阅 {subscribe.name} 类型错误:{subscribe.type}')
|
||||
continue
|
||||
# 先按订阅的主媒体身份预热对应数据源,再对 TMDB 额外预热分集接口。
|
||||
mediainfo: MediaInfo = await self.async_recognize_media(
|
||||
mtype=mtype,
|
||||
**_subscribe_recognize_kwargs(subscribe),
|
||||
episode_group=subscribe.episode_group,
|
||||
cache=False,
|
||||
)
|
||||
if mtype == MediaType.MUSIC:
|
||||
mediainfo = await self._async_recognize_music_subscribe(subscribe)
|
||||
else:
|
||||
mediainfo: MediaInfo = await self.async_recognize_media(
|
||||
mtype=mtype,
|
||||
**_subscribe_recognize_kwargs(subscribe),
|
||||
episode_group=subscribe.episode_group,
|
||||
cache=False,
|
||||
)
|
||||
if not mediainfo:
|
||||
logger.warn(
|
||||
f'未识别到媒体信息,标题:{subscribe.name},'
|
||||
@@ -2471,6 +2625,9 @@ class SubscribeChain(ChainBase):
|
||||
elif mediainfo.type == MediaType.MOVIE:
|
||||
# 电影只有一个条目,设置为 [1]
|
||||
items = [1]
|
||||
elif mediainfo.type == MediaType.MUSIC:
|
||||
# 音乐订阅和电影一样,一次成功下载即记录单项完成事实。
|
||||
items = [1]
|
||||
if not items:
|
||||
continue
|
||||
# 合并已下载的集数或电影项(去重)
|
||||
@@ -2504,8 +2661,8 @@ class SubscribeChain(ChainBase):
|
||||
if subscribe.type == MediaType.TV.value:
|
||||
logger.info(f'订阅 {subscribe.name} 第{subscribe.season}季 已下载集数:{note}')
|
||||
return note
|
||||
# 针对 Movie 类型,直接返回已下载的电影
|
||||
if subscribe.type == MediaType.MOVIE.value:
|
||||
# 针对 Movie/Music 类型,直接返回已下载的单项内容
|
||||
if subscribe.type in (MediaType.MOVIE.value, MediaType.MUSIC.value):
|
||||
logger.info(f'订阅 {subscribe.name} 已下载内容:{note}')
|
||||
return note
|
||||
return []
|
||||
@@ -2836,6 +2993,8 @@ class SubscribeChain(ChainBase):
|
||||
# 发送通知
|
||||
if mediainfo.type == MediaType.TV:
|
||||
link = settings.MP_DOMAIN('#/subscribe/tv?tab=mysub')
|
||||
elif mediainfo.type == MediaType.MUSIC:
|
||||
link = settings.MP_DOMAIN('#/subscribe/music?tab=mysub')
|
||||
else:
|
||||
link = settings.MP_DOMAIN('#/subscribe/movie?tab=mysub')
|
||||
# 完成订阅按规则发送消息
|
||||
@@ -3754,6 +3913,9 @@ class SubscribeChain(ChainBase):
|
||||
if mtype == MediaType.MOVIE:
|
||||
default_subscribe_key = SystemConfigKey.DefaultMovieSubscribeConfig.value
|
||||
|
||||
if not default_subscribe_key:
|
||||
return None
|
||||
|
||||
# 默认订阅规则
|
||||
if hasattr(settings, default_subscribe_key):
|
||||
value = getattr(settings, default_subscribe_key)
|
||||
|
||||
+49
-31
@@ -7,8 +7,10 @@ from app.helper.sites import SitesHelper # noqa
|
||||
|
||||
from app.chain import ChainBase
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.music import MusicChain
|
||||
from app.core.config import settings, global_vars
|
||||
from app.core.context import TorrentInfo, Context, MediaInfo
|
||||
from app.core.music import MusicInfo
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.db.site_oper import SiteOper
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
@@ -400,36 +402,44 @@ class TorrentsChain(ChainBase):
|
||||
logger.info(f'异步种子缓存数据清理完成')
|
||||
|
||||
def browse(self, domain: str, keyword: Optional[str] = None, cat: Optional[str] = None,
|
||||
page: Optional[int] = 0) -> List[TorrentInfo]:
|
||||
page: Optional[int] = 0,
|
||||
mtype: Optional[MediaType] = None) -> List[TorrentInfo]:
|
||||
"""
|
||||
浏览站点首页内容,返回种子清单,TTL缓存5分钟
|
||||
:param domain: 站点域名
|
||||
:param keyword: 搜索标题
|
||||
:param cat: 搜索分类
|
||||
:param page: 页码
|
||||
:param mtype: 媒体类型
|
||||
"""
|
||||
logger.info(f'开始获取站点 {domain} 最新种子 ...')
|
||||
site = SitesHelper().get_indexer(domain)
|
||||
if not site:
|
||||
logger.error(f'站点 {domain} 不存在!')
|
||||
return []
|
||||
return self.refresh_torrents(site=site, keyword=keyword, cat=cat, page=page)
|
||||
return self.refresh_torrents(
|
||||
site=site, keyword=keyword, cat=cat, page=page, mtype=mtype
|
||||
)
|
||||
|
||||
async def async_browse(self, domain: str, keyword: Optional[str] = None, cat: Optional[str] = None,
|
||||
page: Optional[int] = 0) -> List[TorrentInfo]:
|
||||
page: Optional[int] = 0,
|
||||
mtype: Optional[MediaType] = None) -> List[TorrentInfo]:
|
||||
"""
|
||||
异步浏览站点首页内容,返回种子清单,TTL缓存5分钟
|
||||
:param domain: 站点域名
|
||||
:param keyword: 搜索标题
|
||||
:param cat: 搜索分类
|
||||
:param page: 页码
|
||||
:param mtype: 媒体类型
|
||||
"""
|
||||
logger.info(f'开始获取站点 {domain} 最新种子 ...')
|
||||
site = await SitesHelper().async_get_indexer(domain)
|
||||
if not site:
|
||||
logger.error(f'站点 {domain} 不存在!')
|
||||
return []
|
||||
return await self.async_refresh_torrents(site=site, keyword=keyword, cat=cat, page=page)
|
||||
return await self.async_refresh_torrents(
|
||||
site=site, keyword=keyword, cat=cat, page=page, mtype=mtype
|
||||
)
|
||||
|
||||
def rss(self, domain: str) -> List[TorrentInfo]:
|
||||
"""
|
||||
@@ -593,29 +603,37 @@ class TorrentsChain(ChainBase):
|
||||
logger.warn(f"缺少种子链接,忽略处理: {torrent.title}")
|
||||
continue
|
||||
logger.info(f'处理资源:{torrent.title} ...')
|
||||
# 识别
|
||||
meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
|
||||
if torrent.title != meta.org_string:
|
||||
logger.info(f'种子名称应用识别词后发生改变:{torrent.title} => {meta.org_string}')
|
||||
# 使用站点种子分类,校正类型识别
|
||||
if meta.type != MediaType.TV \
|
||||
and torrent.category == MediaType.TV.value:
|
||||
meta.type = MediaType.TV
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = MediaChain().recognize_by_meta(
|
||||
meta,
|
||||
obtain_images=False,
|
||||
)
|
||||
if not mediainfo:
|
||||
logger.warn(f'{torrent.title} 未识别到媒体信息')
|
||||
# 存储空的媒体信息
|
||||
mediainfo = MediaInfo()
|
||||
# 清理多余数据,减少内存占用
|
||||
mediainfo.clear()
|
||||
candidate_recognized = bool(
|
||||
mediainfo and all(resolve_media_identity(media=mediainfo))
|
||||
)
|
||||
match_source = self._get_media_id_match_source(mediainfo)
|
||||
if torrent.category == MediaType.MUSIC.value:
|
||||
meta = MusicChain.parse_query(torrent.title)
|
||||
mediainfo = MusicInfo(
|
||||
title=meta.title,
|
||||
artists=list(meta.artists),
|
||||
album=meta.album,
|
||||
year=meta.year,
|
||||
names=[meta.title] if meta.title else [],
|
||||
)
|
||||
candidate_recognized = False
|
||||
match_source = "unknown"
|
||||
else:
|
||||
meta = MetaInfo(title=torrent.title, subtitle=torrent.description)
|
||||
if torrent.title != meta.org_string:
|
||||
logger.info(f'种子名称应用识别词后发生改变:{torrent.title} => {meta.org_string}')
|
||||
# 使用站点种子分类,校正类型识别
|
||||
if meta.type != MediaType.TV \
|
||||
and torrent.category == MediaType.TV.value:
|
||||
meta.type = MediaType.TV
|
||||
mediainfo = MediaChain().recognize_by_meta(
|
||||
meta,
|
||||
obtain_images=False,
|
||||
)
|
||||
if not mediainfo:
|
||||
logger.warn(f'{torrent.title} 未识别到媒体信息')
|
||||
mediainfo = MediaInfo()
|
||||
mediainfo.clear()
|
||||
candidate_recognized = bool(
|
||||
mediainfo and all(resolve_media_identity(media=mediainfo))
|
||||
)
|
||||
match_source = self._get_media_id_match_source(mediainfo)
|
||||
# 上下文
|
||||
context = Context(
|
||||
meta_info=meta,
|
||||
@@ -698,13 +716,13 @@ class TorrentsChain(ChainBase):
|
||||
"""
|
||||
返回候选自身识别命中的明确媒体 ID 类型。
|
||||
"""
|
||||
if mediainfo and mediainfo.tmdb_id:
|
||||
if mediainfo and getattr(mediainfo, "tmdb_id", None):
|
||||
return "tmdbid"
|
||||
if mediainfo and mediainfo.douban_id:
|
||||
if mediainfo and getattr(mediainfo, "douban_id", None):
|
||||
return "doubanid"
|
||||
if mediainfo and mediainfo.bangumi_id:
|
||||
if mediainfo and getattr(mediainfo, "bangumi_id", None):
|
||||
return "bangumiid"
|
||||
if mediainfo and mediainfo.anilist_id:
|
||||
if mediainfo and getattr(mediainfo, "anilist_id", None):
|
||||
return "anilistid"
|
||||
if mediainfo and all(resolve_media_identity(media=mediainfo)):
|
||||
return "plugin"
|
||||
|
||||
+159
-24
@@ -13,11 +13,13 @@ from app import schemas
|
||||
from app.agent import ReplyMode, prompt_manager, agent_manager
|
||||
from app.chain import ChainBase
|
||||
from app.chain.media import MediaChain
|
||||
from app.chain.music import MusicChain
|
||||
from app.chain.storage import StorageChain
|
||||
from app.chain.subscribe import SubscribeChain
|
||||
from app.chain.tmdb import TmdbChain
|
||||
from app.core.config import settings, global_vars
|
||||
from app.core.context import MediaInfo
|
||||
from app.core.music import MusicInfo, MusicMeta
|
||||
from app.core.event import eventmanager
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfoPath
|
||||
@@ -27,6 +29,7 @@ from app.db.models.transferhistory import TransferHistory
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.db.transferhistory_oper import TransferHistoryOper
|
||||
from app.helper.directory import DirectoryHelper
|
||||
from app.helper.audio import AudioMetadataHelper
|
||||
from app.helper.format import EpisodeFormatRuleHelper, FormatParser
|
||||
from app.helper.progress import ProgressHelper
|
||||
from app.log import logger
|
||||
@@ -184,6 +187,8 @@ class JobManager:
|
||||
# 有媒体信息
|
||||
mediainfo = deepcopy(task.mediainfo)
|
||||
mediainfo.clear()
|
||||
if isinstance(mediainfo, MusicInfo):
|
||||
return schemas.MusicInfo(**mediainfo.to_dict())
|
||||
return schemas.MediaInfo(**mediainfo.to_dict())
|
||||
else:
|
||||
# 没有媒体信息
|
||||
@@ -200,6 +205,8 @@ class JobManager:
|
||||
"""
|
||||
获取元数据
|
||||
"""
|
||||
if isinstance(task.meta, MusicMeta):
|
||||
return schemas.MusicMeta(**task.meta.to_dict())
|
||||
return schemas.MetaInfo(**task.meta.to_dict())
|
||||
|
||||
def add_task(self, task: TransferTask, state: Optional[str] = "waiting") -> bool:
|
||||
@@ -1009,7 +1016,11 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
return False
|
||||
return True if f".{fileitem.extension.lower()}" in self._audio_exts else False
|
||||
|
||||
def __is_media_file(self, fileitem: FileItem) -> bool:
|
||||
def __is_media_file(
|
||||
self,
|
||||
fileitem: FileItem,
|
||||
mtype: Optional[MediaType] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
判断是否为主要媒体文件
|
||||
"""
|
||||
@@ -1018,7 +1029,107 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
return StorageChain().is_bluray_folder(fileitem)
|
||||
if not fileitem.extension:
|
||||
return False
|
||||
return True if f".{fileitem.extension.lower()}" in self._media_exts else False
|
||||
extension = f".{fileitem.extension.lower()}"
|
||||
if extension in self._media_exts:
|
||||
return True
|
||||
return mtype == MediaType.MUSIC and extension in self._audio_exts
|
||||
|
||||
def _is_primary_media_file(
|
||||
self,
|
||||
fileitem: FileItem,
|
||||
mediainfo: Optional[MediaInfo | MusicInfo],
|
||||
) -> bool:
|
||||
"""判断文件在当前媒体上下文中是否属于主要媒体文件。"""
|
||||
return self.__is_media_file(
|
||||
fileitem,
|
||||
getattr(mediainfo, "type", None),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _music_info_from_meta(meta: MusicMeta) -> MusicInfo:
|
||||
"""将音频文件标签解析结果转换为可整理的最小音乐信息。"""
|
||||
return MusicInfo(
|
||||
source=meta.media_source,
|
||||
media_id=meta.media_id,
|
||||
title=meta.title,
|
||||
artists=list(meta.artists),
|
||||
album=meta.album,
|
||||
album_artist=meta.album_artist,
|
||||
year=meta.year,
|
||||
disc_number=meta.disc_number,
|
||||
track_number=meta.track_number,
|
||||
total_tracks=meta.total_tracks,
|
||||
duration=meta.duration,
|
||||
isrc=meta.isrc,
|
||||
version=meta.version,
|
||||
names=[name for name in (meta.title, meta.album) if name],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _restore_music_download_context(
|
||||
cls,
|
||||
download_history: Optional[DownloadHistory],
|
||||
file_path: Path,
|
||||
) -> tuple[Optional[MusicMeta], Optional[MusicInfo]]:
|
||||
"""从下载历史恢复音乐上下文,并用当前音频标签覆盖曲目级字段。"""
|
||||
note = getattr(download_history, "note", None)
|
||||
music_note = note.get("music") if isinstance(note, dict) else None
|
||||
if not isinstance(music_note, dict) or music_note.get("version") != 1:
|
||||
return None, None
|
||||
try:
|
||||
saved_meta = MusicMeta.from_dict(music_note.get("meta") or {})
|
||||
saved_info = MusicInfo.from_dict(music_note.get("media") or {})
|
||||
except (TypeError, ValueError):
|
||||
return None, None
|
||||
|
||||
file_tags = AudioMetadataHelper.read(file_path) if file_path.exists() else None
|
||||
file_meta = deepcopy(saved_meta)
|
||||
file_meta.org_string = file_path.name
|
||||
if file_tags:
|
||||
has_tag_identity = bool(
|
||||
file_tags.artists
|
||||
or file_tags.album
|
||||
or file_tags.track_number
|
||||
or file_tags.isrc
|
||||
)
|
||||
for field_name in (
|
||||
"artists",
|
||||
"album",
|
||||
"album_artist",
|
||||
"year",
|
||||
"disc_number",
|
||||
"track_number",
|
||||
"total_discs",
|
||||
"total_tracks",
|
||||
"version",
|
||||
"isrc",
|
||||
):
|
||||
if getattr(file_tags, field_name, None):
|
||||
setattr(file_meta, field_name, deepcopy(getattr(file_tags, field_name)))
|
||||
if has_tag_identity and file_tags.title:
|
||||
file_meta.title = file_tags.title
|
||||
for field_name in (
|
||||
"audio_format",
|
||||
"bit_depth",
|
||||
"sample_rate",
|
||||
"bitrate",
|
||||
"duration",
|
||||
):
|
||||
if getattr(file_tags, field_name, None):
|
||||
setattr(file_meta, field_name, getattr(file_tags, field_name))
|
||||
elif not file_meta.audio_format:
|
||||
file_meta.audio_format = file_path.suffix.lstrip(".").upper() or None
|
||||
file_meta.media_source = saved_info.source or saved_meta.media_source
|
||||
file_meta.media_id = saved_info.media_id or saved_meta.media_id
|
||||
|
||||
file_info = cls._music_info_from_meta(file_meta)
|
||||
file_info.source = saved_info.source
|
||||
file_info.media_id = saved_info.media_id
|
||||
file_info.cover_url = saved_info.cover_url
|
||||
file_info.lyrics = saved_info.lyrics
|
||||
file_info.category = saved_info.category
|
||||
file_info.detail_link = saved_info.detail_link
|
||||
return file_meta, file_info
|
||||
|
||||
def __is_allowed_file(self, fileitem: FileItem) -> bool:
|
||||
"""
|
||||
@@ -1148,7 +1259,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
)
|
||||
|
||||
# 整理失败事件
|
||||
if self.__is_media_file(task.fileitem):
|
||||
if self._is_primary_media_file(task.fileitem, task.mediainfo):
|
||||
# 主要媒体文件整理失败事件
|
||||
self.eventmanager.send_event(
|
||||
EventType.TransferFailed,
|
||||
@@ -1262,7 +1373,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
)
|
||||
|
||||
# task整理完成事件
|
||||
if self.__is_media_file(task.fileitem):
|
||||
if self._is_primary_media_file(task.fileitem, task.mediainfo):
|
||||
# 主要媒体文件整理完成事件
|
||||
self.eventmanager.send_event(
|
||||
EventType.TransferComplete,
|
||||
@@ -1480,7 +1591,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
not task
|
||||
or not transferinfo
|
||||
or not transferinfo.need_scrape
|
||||
or not self.__is_media_file(task.fileitem)
|
||||
or not self._is_primary_media_file(task.fileitem, task.mediainfo)
|
||||
or task.mediainfo.type == MediaType.MUSIC
|
||||
):
|
||||
return
|
||||
|
||||
@@ -1543,7 +1655,8 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
or not task.transfer_batch_id
|
||||
or not transferinfo
|
||||
or not transferinfo.need_scrape
|
||||
or not self.__is_media_file(task.fileitem)
|
||||
or not self._is_primary_media_file(task.fileitem, task.mediainfo)
|
||||
or task.mediainfo.type == MediaType.MUSIC
|
||||
):
|
||||
return
|
||||
|
||||
@@ -2935,6 +3048,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
fileitem: FileItem,
|
||||
meta: MetaBase = None,
|
||||
mediainfo: MediaInfo = None,
|
||||
mtype: Optional[MediaType] = None,
|
||||
media_source: Optional[str] = None,
|
||||
target_directory: TransferDirectoryConf = None,
|
||||
target_storage: Optional[str] = None,
|
||||
@@ -2962,6 +3076,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
:param fileitem: 文件项
|
||||
:param meta: 元数据
|
||||
:param mediainfo: 媒体信息
|
||||
:param mtype: 未提供媒体信息时使用的媒体类型提示
|
||||
:param media_source: 请求级识别与刮削数据源
|
||||
:param target_directory: 目标目录配置
|
||||
:param target_storage: 目标存储器
|
||||
@@ -3037,9 +3152,12 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
"""
|
||||
从文件路径识别媒体信息,用于判断附加文件是否属于当前主视频。
|
||||
"""
|
||||
path_meta = MetaInfoPath(
|
||||
source_path, custom_words=custom_word_list
|
||||
)
|
||||
if mtype == MediaType.MUSIC and source_path.suffix.lower() in self._audio_exts:
|
||||
path_meta = AudioMetadataHelper.read(source_path)
|
||||
else:
|
||||
path_meta = MetaInfoPath(
|
||||
source_path, custom_words=custom_word_list
|
||||
)
|
||||
if not path_meta:
|
||||
return None
|
||||
return _apply_meta_overrides(path_meta, source_path)
|
||||
@@ -3462,12 +3580,19 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
download_hash=download_hash,
|
||||
)
|
||||
|
||||
history_music_meta, history_music_info = self._restore_music_download_context(
|
||||
download_history=download_history,
|
||||
file_path=file_path,
|
||||
)
|
||||
|
||||
if not meta:
|
||||
# 文件元数据(优先使用订阅识别词)
|
||||
inherited_meta = inherited_meta_map.get(
|
||||
self.__get_file_key(file_item)
|
||||
)
|
||||
if inherited_meta:
|
||||
if history_music_meta:
|
||||
file_meta = history_music_meta
|
||||
elif inherited_meta:
|
||||
file_meta = deepcopy(inherited_meta)
|
||||
else:
|
||||
file_meta = _build_file_meta(
|
||||
@@ -3492,7 +3617,9 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
_download_hash = download_hash
|
||||
|
||||
# 自动整理预载的媒体信息来自整条下载历史;电影合集内文件年份冲突时逐文件识别。
|
||||
task_mediainfo = mediainfo
|
||||
task_mediainfo = mediainfo or history_music_info
|
||||
if not task_mediainfo and isinstance(file_meta, MusicMeta):
|
||||
task_mediainfo = self._music_info_from_meta(file_meta)
|
||||
if (
|
||||
not manual
|
||||
and self._is_movie_year_conflict(file_meta, task_mediainfo)
|
||||
@@ -3931,16 +4058,22 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
if tmdbid or doubanid or bangumiid or anilistid or media_id:
|
||||
# 有输入媒体ID时单个识别
|
||||
# 识别媒体信息
|
||||
mediainfo: MediaInfo = MediaChain().recognize_media(
|
||||
tmdbid=tmdbid,
|
||||
doubanid=doubanid,
|
||||
bangumiid=bangumiid,
|
||||
anilistid=anilistid,
|
||||
source=media_source,
|
||||
mediaid=media_id,
|
||||
mtype=mtype,
|
||||
episode_group=episode_group,
|
||||
)
|
||||
if mtype == MediaType.MUSIC and media_source and media_id:
|
||||
mediainfo = MusicChain().recognize(
|
||||
source=media_source,
|
||||
media_id=media_id,
|
||||
)
|
||||
else:
|
||||
mediainfo = MediaChain().recognize_media(
|
||||
tmdbid=tmdbid,
|
||||
doubanid=doubanid,
|
||||
bangumiid=bangumiid,
|
||||
anilistid=anilistid,
|
||||
source=media_source,
|
||||
mediaid=media_id,
|
||||
mtype=mtype,
|
||||
episode_group=episode_group,
|
||||
)
|
||||
if not mediainfo:
|
||||
return (
|
||||
False,
|
||||
@@ -3949,10 +4082,10 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
f"type: {mtype.value if mtype else None}",
|
||||
)
|
||||
else:
|
||||
if media_source:
|
||||
if media_source and not isinstance(mediainfo, MusicInfo):
|
||||
mediainfo.scrape_source = media_source
|
||||
# 更新媒体图片
|
||||
self.obtain_images(mediainfo=mediainfo)
|
||||
if not isinstance(mediainfo, MusicInfo):
|
||||
self.obtain_images(mediainfo=mediainfo)
|
||||
|
||||
# 开始整理
|
||||
state, errmsg = self.do_transfer(
|
||||
@@ -3960,6 +4093,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
target_storage=target_storage,
|
||||
target_path=target_path,
|
||||
mediainfo=mediainfo,
|
||||
mtype=mtype,
|
||||
media_source=media_source,
|
||||
transfer_type=transfer_type,
|
||||
season=season,
|
||||
@@ -3990,6 +4124,7 @@ class TransferChain(ChainBase, ConfigReloadMixin, metaclass=Singleton):
|
||||
target_storage=target_storage,
|
||||
target_path=target_path,
|
||||
media_source=media_source,
|
||||
mtype=mtype,
|
||||
transfer_type=transfer_type,
|
||||
season=season,
|
||||
epformat=epformat,
|
||||
|
||||
+20
-7
@@ -406,6 +406,13 @@ class ConfigModel(BaseModel):
|
||||
"/{{title}} - {{season_episode}}{% if part %}-{{part}}{% endif %}{% if episode %} - 第 {{episode}} 集{% endif %}"
|
||||
"{{fileExt}}"
|
||||
)
|
||||
# 音乐重命名格式
|
||||
MUSIC_RENAME_FORMAT: str = (
|
||||
"{{album_artist or artist or 'Unknown Artist'}}"
|
||||
"/{{album or 'Unknown Album'}}{% if year %} ({{year}}){% endif %}"
|
||||
"{% if total_discs and total_discs > 1 %}/Disc {{disc_number or 1}}{% endif %}"
|
||||
"/{% if track %}{{track}} - {% endif %}{{title}}{{fileExt}}"
|
||||
)
|
||||
# 重命名时支持的S0别名
|
||||
RENAME_FORMAT_S0_NAMES: list = Field(default=["Specials", "SPs"])
|
||||
# 为指定默认字幕添加.default后缀
|
||||
@@ -901,7 +908,12 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
"""
|
||||
版本标识,用来区分重大版本,为空则为v1,不允许外部修改
|
||||
"""
|
||||
return "v2"
|
||||
return "v3"
|
||||
|
||||
@property
|
||||
def RESOURCE_VERSION_FLAG(self) -> str:
|
||||
"""返回站点索引和认证资源使用的重大版本标识。"""
|
||||
return "v3"
|
||||
|
||||
@property
|
||||
def USER_AGENT(self) -> str:
|
||||
@@ -1137,14 +1149,15 @@ class Settings(BaseSettings, ConfigModel, LogConfigModel):
|
||||
"""
|
||||
获取指定类型的重命名格式
|
||||
|
||||
:param media_type: MediaType.TV 或 MediaType.Movie
|
||||
:param media_type: 电影、电视剧或音乐媒体类型
|
||||
:return: 重命名格式
|
||||
"""
|
||||
rename_format = (
|
||||
self.TV_RENAME_FORMAT
|
||||
if media_type == MediaType.TV
|
||||
else self.MOVIE_RENAME_FORMAT
|
||||
)
|
||||
if media_type == MediaType.TV:
|
||||
rename_format = self.TV_RENAME_FORMAT
|
||||
elif media_type == MediaType.MUSIC:
|
||||
rename_format = self.MUSIC_RENAME_FORMAT
|
||||
else:
|
||||
rename_format = self.MOVIE_RENAME_FORMAT
|
||||
# 规范重命名格式
|
||||
rename_format = rename_format.replace("\\", "/")
|
||||
rename_format = re.sub(r"/+", "/", rename_format)
|
||||
|
||||
+5
-4
@@ -1,11 +1,12 @@
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Any, Tuple, Optional, Set
|
||||
from typing import List, Dict, Any, Tuple, Optional, Set, Union
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.metainfo import MetaInfo
|
||||
from app.core.music import MusicInfo, MusicMeta
|
||||
from app.schemas.types import MediaType
|
||||
from app.utils.string import StringUtils
|
||||
|
||||
@@ -69,7 +70,7 @@ class TorrentInfo:
|
||||
labels: list = field(default_factory=list)
|
||||
# 种子优先级
|
||||
pri_order: int = 0
|
||||
# 种子分类 电影/电视剧
|
||||
# 种子分类 电影/电视剧/音乐
|
||||
category: str = None
|
||||
|
||||
def __setattr__(self, name: str, value: Any):
|
||||
@@ -1141,9 +1142,9 @@ class Context:
|
||||
"""
|
||||
|
||||
# 识别信息
|
||||
meta_info: MetaBase = None
|
||||
meta_info: Optional[Union[MetaBase, MusicMeta]] = None
|
||||
# 媒体信息
|
||||
media_info: MediaInfo = None
|
||||
media_info: Optional[Union[MediaInfo, MusicInfo]] = None
|
||||
# 种子信息
|
||||
torrent_info: TorrentInfo = None
|
||||
# 媒体识别失败次数
|
||||
|
||||
+9
-2
@@ -6,7 +6,7 @@ from app.core.event import eventmanager
|
||||
from app.helper.module import ModuleHelper
|
||||
from app.log import logger
|
||||
from app.schemas.types import EventType, ModuleType, DownloaderType, MediaServerType, MessageChannel, StorageSchema, \
|
||||
OtherModulesType
|
||||
OtherModulesType, MediaRecognizeType
|
||||
from app.utils.object import ObjectUtils
|
||||
from app.utils.singleton import Singleton
|
||||
|
||||
@@ -17,7 +17,14 @@ class ModuleManager(metaclass=Singleton):
|
||||
"""
|
||||
|
||||
# 子模块类型集合
|
||||
SubType = Union[DownloaderType, MediaServerType, MessageChannel, StorageSchema, OtherModulesType]
|
||||
SubType = Union[
|
||||
DownloaderType,
|
||||
MediaServerType,
|
||||
MessageChannel,
|
||||
StorageSchema,
|
||||
OtherModulesType,
|
||||
MediaRecognizeType,
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
# 模块列表
|
||||
|
||||
@@ -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)
|
||||
@@ -21,7 +21,7 @@ class DownloadHistory(Base):
|
||||
id = get_id_column()
|
||||
# 保存路径
|
||||
path = Column(String, nullable=False, index=True)
|
||||
# 类型 电影/电视剧
|
||||
# 类型 电影/电视剧/音乐
|
||||
type = Column(String, nullable=False)
|
||||
# 标题
|
||||
title = Column(String, nullable=False)
|
||||
|
||||
@@ -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
|
||||
@@ -17,6 +17,7 @@ from app.core.cache import TTLCache
|
||||
from app.core.config import global_vars
|
||||
from app.core.context import MediaInfo, TorrentInfo
|
||||
from app.core.meta import MetaBase
|
||||
from app.core.music import MusicInfo, MusicMeta
|
||||
from app.db.systemconfig_oper import SystemConfigOper
|
||||
from app.log import logger
|
||||
from app.schemas.message import Notification
|
||||
@@ -91,6 +92,31 @@ class TemplateContextBuilder:
|
||||
"""
|
||||
if not mediainfo:
|
||||
return
|
||||
if isinstance(mediainfo, MusicInfo):
|
||||
context.update({
|
||||
"type": mediainfo.type.value,
|
||||
"title": cls.__convert_invalid_characters(mediainfo.title),
|
||||
"name": cls.__convert_invalid_characters(mediainfo.title),
|
||||
"artists": [cls.__convert_invalid_characters(item) for item in mediainfo.artists],
|
||||
"artist": cls.__convert_invalid_characters(mediainfo.artist),
|
||||
"album": cls.__convert_invalid_characters(mediainfo.album),
|
||||
"album_artist": cls.__convert_invalid_characters(mediainfo.album_artist),
|
||||
"year": mediainfo.year or context.get("year"),
|
||||
"title_year": mediainfo.title_year or context.get("title_year"),
|
||||
"disc_number": mediainfo.disc_number,
|
||||
"track_number": mediainfo.track_number,
|
||||
"track": f"{mediainfo.track_number:02d}" if mediainfo.track_number else None,
|
||||
"total_tracks": mediainfo.total_tracks,
|
||||
"duration": mediainfo.duration,
|
||||
"isrc": mediainfo.isrc,
|
||||
"version": mediainfo.version,
|
||||
"category": mediainfo.category,
|
||||
"poster": mediainfo.get_poster_image(),
|
||||
"backdrop": mediainfo.get_backdrop_image(),
|
||||
"media_source": mediainfo.source,
|
||||
"media_id": mediainfo.media_id,
|
||||
})
|
||||
return
|
||||
season_fmt = f"S{mediainfo.season:02d}" if mediainfo.season is not None else None
|
||||
source_ids = {
|
||||
"themoviedb": mediainfo.tmdb_id,
|
||||
@@ -170,6 +196,30 @@ class TemplateContextBuilder:
|
||||
"""
|
||||
if not meta:
|
||||
return
|
||||
if isinstance(meta, MusicMeta):
|
||||
context.update({
|
||||
"original_name": meta.org_string or meta.title,
|
||||
"name": cls.__convert_invalid_characters(meta.title),
|
||||
"title": cls.__convert_invalid_characters(meta.title),
|
||||
"artists": [cls.__convert_invalid_characters(item) for item in meta.artists],
|
||||
"artist": cls.__convert_invalid_characters(meta.artist),
|
||||
"album": cls.__convert_invalid_characters(meta.album),
|
||||
"album_artist": cls.__convert_invalid_characters(meta.album_artist),
|
||||
"year": meta.year,
|
||||
"disc_number": meta.disc_number,
|
||||
"track_number": meta.track_number,
|
||||
"track": f"{meta.track_number:02d}" if meta.track_number else None,
|
||||
"total_discs": meta.total_discs,
|
||||
"total_tracks": meta.total_tracks,
|
||||
"audio_format": meta.audio_format,
|
||||
"bit_depth": meta.bit_depth,
|
||||
"sample_rate": meta.sample_rate,
|
||||
"bitrate": meta.bitrate,
|
||||
"duration": meta.duration,
|
||||
"isrc": meta.isrc,
|
||||
"version": meta.version,
|
||||
})
|
||||
return
|
||||
|
||||
episode_data = {"episode_title": None, "episode_date": None}
|
||||
if meta.begin_episode and episodes:
|
||||
|
||||
+23
-8
@@ -17,9 +17,16 @@ class ResourceHelper:
|
||||
检测和更新资源包
|
||||
"""
|
||||
|
||||
_repo = f"{settings.GITHUB_PROXY}https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/package.v2.json"
|
||||
_files_api = f"https://api.github.com/repos/jxxghp/MoviePilot-Resources/contents/resources.v2"
|
||||
_base_dir: Path = settings.ROOT_PATH
|
||||
_version_flag = settings.RESOURCE_VERSION_FLAG
|
||||
_repo = (
|
||||
f"{settings.GITHUB_PROXY}https://raw.githubusercontent.com/"
|
||||
f"jxxghp/MoviePilot-Resources/main/package.{_version_flag}.json"
|
||||
)
|
||||
_files_api = (
|
||||
"https://api.github.com/repos/jxxghp/"
|
||||
f"MoviePilot-Resources/contents/resources.{_version_flag}"
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.check()
|
||||
@@ -42,13 +49,14 @@ class ResourceHelper:
|
||||
return "x86_64"
|
||||
return machine
|
||||
|
||||
@staticmethod
|
||||
def _get_needed_files() -> list[str]:
|
||||
@classmethod
|
||||
def _get_needed_files(cls) -> list[str]:
|
||||
"""返回 V3 资源在当前平台需要下载的文件名。"""
|
||||
python_version = ResourceHelper._get_python_version_tag()
|
||||
python_ver = python_version.replace("cp", "")
|
||||
system = platform.system().lower()
|
||||
machine = ResourceHelper._get_machine_tag()
|
||||
files = ["user.sites.v2.bin"]
|
||||
files = [f"user.sites.{cls._version_flag}.bin"]
|
||||
if system == "linux":
|
||||
files.append(f"sites.cpython-{python_ver}-{machine}-linux-gnu.so")
|
||||
elif system == "darwin":
|
||||
@@ -57,6 +65,15 @@ class ResourceHelper:
|
||||
files.append(f"sites.cp{python_ver}-win_amd64.pyd")
|
||||
return files
|
||||
|
||||
def _load_resource_info(self):
|
||||
"""读取 V3 资源清单。"""
|
||||
response = RequestUtils(
|
||||
proxies=self.proxies,
|
||||
headers=settings.GITHUB_HEADERS,
|
||||
timeout=10,
|
||||
).get_res(self._repo)
|
||||
return response if response and response.status_code == 200 else None
|
||||
|
||||
def check(self):
|
||||
"""
|
||||
检测是否有更新,如有则下载安装
|
||||
@@ -66,9 +83,7 @@ class ResourceHelper:
|
||||
if SystemUtils.is_frozen():
|
||||
return None
|
||||
logger.info("开始检测资源包版本...")
|
||||
res = RequestUtils(
|
||||
proxies=self.proxies, headers=settings.GITHUB_HEADERS, timeout=10
|
||||
).get_res(self._repo)
|
||||
res = self._load_resource_info()
|
||||
if res:
|
||||
try:
|
||||
resource_info = json.loads(res.text)
|
||||
|
||||
@@ -109,6 +109,7 @@
|
||||
"任务添加失败": "Failed to add task",
|
||||
"无法识别媒体信息": "Unable to recognize media information",
|
||||
"未识别到媒体信息": "Unable to recognize media information",
|
||||
"未识别到音乐信息": "Unable to recognize music information",
|
||||
"记录不存在": "Record does not exist",
|
||||
"MoviePilot智能助手未启用": "MoviePilot Assistant is not enabled",
|
||||
"整理记录不存在": "Organization record does not exist",
|
||||
|
||||
@@ -109,6 +109,7 @@
|
||||
"任务添加失败": "任務新增失敗",
|
||||
"无法识别媒体信息": "無法識別媒體資訊",
|
||||
"未识别到媒体信息": "未識別到媒體資訊",
|
||||
"未识别到音乐信息": "未識別到音樂資訊",
|
||||
"记录不存在": "記錄不存在",
|
||||
"MoviePilot智能助手未启用": "MoviePilot 智慧助手未啟用",
|
||||
"整理记录不存在": "整理記錄不存在",
|
||||
|
||||
@@ -7,7 +7,7 @@ from app.helper.service import ServiceConfigHelper
|
||||
from app.log import logger
|
||||
from app.schemas import Notification, NotificationConf, MediaServerConf, DownloaderConf
|
||||
from app.schemas.types import ModuleType, DownloaderType, MediaServerType, MessageChannel, StorageSchema, \
|
||||
OtherModulesType, SystemConfigKey
|
||||
OtherModulesType, SystemConfigKey, MediaRecognizeType
|
||||
from app.utils.mixins import ConfigReloadMixin
|
||||
|
||||
|
||||
@@ -66,7 +66,14 @@ class _ModuleBase(ConfigReloadMixin, metaclass=ABCMeta):
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def get_subtype() -> Union[DownloaderType, MediaServerType, MessageChannel, StorageSchema, OtherModulesType]:
|
||||
def get_subtype() -> Union[
|
||||
DownloaderType,
|
||||
MediaServerType,
|
||||
MessageChannel,
|
||||
StorageSchema,
|
||||
OtherModulesType,
|
||||
MediaRecognizeType,
|
||||
]:
|
||||
"""
|
||||
获取模块子类型(下载器、媒体服务器、消息通道、存储类型、其他杂项模块类型)
|
||||
"""
|
||||
|
||||
@@ -184,9 +184,10 @@ class TransHandler:
|
||||
"""
|
||||
if not _fileitem.extension:
|
||||
return False
|
||||
if f".{_fileitem.extension.lower()}" in (
|
||||
settings.RMT_SUBEXT + settings.RMT_AUDIOEXT
|
||||
):
|
||||
extension = f".{_fileitem.extension.lower()}"
|
||||
if extension in settings.RMT_SUBEXT:
|
||||
return True
|
||||
if mediainfo.type != MediaType.MUSIC and extension in settings.RMT_AUDIOEXT:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -548,30 +548,38 @@ class IndexerModule(_ModuleBase):
|
||||
def refresh_torrents(self, site: dict,
|
||||
keyword: Optional[str] = None,
|
||||
cat: Optional[str] = None,
|
||||
page: Optional[int] = 0) -> Optional[List[TorrentInfo]]:
|
||||
page: Optional[int] = 0,
|
||||
mtype: Optional[MediaType] = None) -> Optional[List[TorrentInfo]]:
|
||||
"""
|
||||
获取站点最新一页的种子,多个站点需要多线程处理
|
||||
:param site: 站点
|
||||
:param keyword: 关键字
|
||||
:param cat: 分类
|
||||
:param page: 页码
|
||||
:param mtype: 媒体类型
|
||||
:reutrn: 种子资源列表
|
||||
"""
|
||||
return self.search_torrents(site=site, keyword=keyword, cat=cat, page=page)
|
||||
return self.search_torrents(
|
||||
site=site, keyword=keyword, cat=cat, page=page, mtype=mtype
|
||||
)
|
||||
|
||||
async def async_refresh_torrents(self, site: dict,
|
||||
keyword: Optional[str] = None,
|
||||
cat: Optional[str] = None,
|
||||
page: Optional[int] = 0) -> Optional[List[TorrentInfo]]:
|
||||
page: Optional[int] = 0,
|
||||
mtype: Optional[MediaType] = None) -> Optional[List[TorrentInfo]]:
|
||||
"""
|
||||
异步获取站点最新一页的种子,多个站点需要多线程处理
|
||||
:param site: 站点
|
||||
:param keyword: 关键字
|
||||
:param cat: 分类
|
||||
:param page: 页码
|
||||
:param mtype: 媒体类型
|
||||
:reutrn: 种子资源列表
|
||||
"""
|
||||
return await self.async_search_torrents(site=site, keyword=keyword, cat=cat, page=page)
|
||||
return await self.async_search_torrents(
|
||||
site=site, keyword=keyword, cat=cat, page=page, mtype=mtype
|
||||
)
|
||||
|
||||
def refresh_userdata(self, site: dict) -> Optional[SiteUserData]:
|
||||
"""
|
||||
|
||||
@@ -18,6 +18,44 @@ from app.utils.string import StringUtils
|
||||
from app.utils.url import UrlUtils
|
||||
|
||||
|
||||
def select_media_categories(category: Optional[dict], mtype: Optional[MediaType]) -> list[dict]:
|
||||
"""根据媒体类型选择站点索引配置中的分类列表。"""
|
||||
if not category:
|
||||
return []
|
||||
if mtype == MediaType.TV:
|
||||
return category.get("tv") or []
|
||||
if mtype == MediaType.MOVIE:
|
||||
return category.get("movie") or []
|
||||
if mtype == MediaType.MUSIC:
|
||||
return category.get("music") or []
|
||||
return (
|
||||
(category.get("movie") or [])
|
||||
+ (category.get("tv") or [])
|
||||
+ (category.get("music") or [])
|
||||
)
|
||||
|
||||
|
||||
def resolve_category_media_type(category_value: Any, category: Optional[dict]) -> MediaType:
|
||||
"""将站点分类 ID 映射为统一的电影、电视剧或音乐类型。"""
|
||||
if category_value is None or not category:
|
||||
return MediaType.UNKNOWN
|
||||
category_id = str(category_value)
|
||||
matches = [
|
||||
media_type
|
||||
for media_type, key in (
|
||||
(MediaType.MOVIE, "movie"),
|
||||
(MediaType.TV, "tv"),
|
||||
(MediaType.MUSIC, "music"),
|
||||
)
|
||||
if category_id in {
|
||||
str(item.get("id"))
|
||||
for item in category.get(key) or []
|
||||
if isinstance(item, dict) and item.get("id") is not None
|
||||
}
|
||||
]
|
||||
return matches[0] if len(matches) == 1 else MediaType.UNKNOWN
|
||||
|
||||
|
||||
class SiteSpider:
|
||||
"""
|
||||
站点爬虫
|
||||
@@ -62,6 +100,7 @@ class SiteSpider:
|
||||
self.search_type = search_type or "torrents"
|
||||
self.indexerid = indexer.get('id')
|
||||
self.indexername = indexer.get('name')
|
||||
self.site_media_type = MediaType.from_agent(indexer.get('media_type'))
|
||||
if self.search_type == "subtitles":
|
||||
subtitle_conf = indexer.get('subtitles') or {}
|
||||
self.search = subtitle_conf.get('search')
|
||||
@@ -129,16 +168,24 @@ class SiteSpider:
|
||||
if len(paths) == 1:
|
||||
torrentspath = paths[0].get('path', '')
|
||||
else:
|
||||
# 优先使用媒体类型专用路径;没有专用路径时回退到 all,兼容仅为某一类新增分支的站点。
|
||||
fallback_path = ""
|
||||
expected_type = {
|
||||
MediaType.MOVIE: "movie",
|
||||
MediaType.TV: "tv",
|
||||
MediaType.MUSIC: "music",
|
||||
}.get(self.mtype)
|
||||
for path in paths:
|
||||
if path.get("type") == "all" and not self.mtype:
|
||||
torrentspath = path.get('path')
|
||||
break
|
||||
elif path.get("type") == "movie" and self.mtype == MediaType.MOVIE:
|
||||
torrentspath = path.get('path')
|
||||
break
|
||||
elif path.get("type") == "tv" and self.mtype == MediaType.TV:
|
||||
torrentspath = path.get('path')
|
||||
path_type = path.get("type")
|
||||
if path_type == "all" and not fallback_path:
|
||||
fallback_path = path.get('path', '')
|
||||
if (expected_type and path_type == expected_type) or (
|
||||
not expected_type and path_type == "all"
|
||||
):
|
||||
torrentspath = path.get('path', '')
|
||||
break
|
||||
if not torrentspath:
|
||||
torrentspath = fallback_path
|
||||
|
||||
# 精确搜索
|
||||
if self.keyword:
|
||||
@@ -187,12 +234,7 @@ class SiteSpider:
|
||||
})
|
||||
# 分类条件
|
||||
if self.category:
|
||||
if self.mtype == MediaType.TV:
|
||||
cats = self.category.get("tv") or []
|
||||
elif self.mtype == MediaType.MOVIE:
|
||||
cats = self.category.get("movie") or []
|
||||
else:
|
||||
cats = (self.category.get("movie") or []) + (self.category.get("tv") or [])
|
||||
cats = select_media_categories(self.category, self.mtype)
|
||||
allowed_cats = set(self.cat.split(',')) if self.cat else None
|
||||
for cat in cats:
|
||||
if allowed_cats and str(cat.get('id')) not in allowed_cats:
|
||||
@@ -204,9 +246,25 @@ class SiteSpider:
|
||||
' ') + cat.get("id")
|
||||
})
|
||||
else:
|
||||
params.update({
|
||||
"cat%s" % cat.get("id"): 1
|
||||
})
|
||||
category_param = cat.get("param") or self.category.get("param")
|
||||
if category_param:
|
||||
# 某些站点(例如憨憨)使用重复的 cat[] 参数,字典值列表可由
|
||||
# UrlUtils.combine_url 以 doseq=True 正确展开。
|
||||
category_id = cat.get("value", cat.get("id"))
|
||||
current_value = params.get(category_param)
|
||||
if current_value is None:
|
||||
params[category_param] = category_id
|
||||
elif isinstance(current_value, list):
|
||||
current_value.append(category_id)
|
||||
else:
|
||||
params[category_param] = [current_value, category_id]
|
||||
else:
|
||||
params.update({
|
||||
"cat%s" % cat.get("id"): 1
|
||||
})
|
||||
# 分类项可以附带站点要求的额外开关,例如音乐专用展示模式。
|
||||
if isinstance(cat, dict) and cat.get("params"):
|
||||
params.update(cat.get("params"))
|
||||
searchurl = UrlUtils.combine_url(self.domain, torrentspath, params)
|
||||
else:
|
||||
# 变量字典
|
||||
@@ -259,6 +317,8 @@ class SiteSpider:
|
||||
"""
|
||||
开始请求
|
||||
"""
|
||||
if self.site_media_type and self.mtype and self.site_media_type != self.mtype:
|
||||
return []
|
||||
if not self.search or not self.domain:
|
||||
return []
|
||||
|
||||
@@ -288,6 +348,8 @@ class SiteSpider:
|
||||
"""
|
||||
异步请求
|
||||
"""
|
||||
if self.site_media_type and self.mtype and self.site_media_type != self.mtype:
|
||||
return []
|
||||
if not self.search or not self.domain:
|
||||
return []
|
||||
|
||||
@@ -706,24 +768,18 @@ class SiteSpider:
|
||||
del hit_and_run
|
||||
|
||||
def __get_category(self, torrent: Any):
|
||||
# category 电影/电视剧
|
||||
# category 电影/电视剧/音乐
|
||||
if 'category' not in self.fields:
|
||||
if self.site_media_type:
|
||||
self.torrents_info['category'] = self.site_media_type.value
|
||||
return
|
||||
selector = self.fields.get('category', {})
|
||||
category_value = self._safe_query(torrent, selector)
|
||||
category_value = self.__filter_text(category_value, selector.get('filters'))
|
||||
if category_value and self.category:
|
||||
tv_cats = [str(cat.get("id")) for cat in self.category.get("tv") or []]
|
||||
movie_cats = [str(cat.get("id")) for cat in self.category.get("movie") or []]
|
||||
if category_value in tv_cats \
|
||||
and category_value not in movie_cats:
|
||||
self.torrents_info['category'] = MediaType.TV.value
|
||||
elif category_value in movie_cats:
|
||||
self.torrents_info['category'] = MediaType.MOVIE.value
|
||||
else:
|
||||
self.torrents_info['category'] = MediaType.UNKNOWN.value
|
||||
else:
|
||||
self.torrents_info['category'] = MediaType.UNKNOWN.value
|
||||
resolved_type = resolve_category_media_type(category_value, self.category)
|
||||
if resolved_type == MediaType.UNKNOWN and self.site_media_type:
|
||||
resolved_type = self.site_media_type
|
||||
self.torrents_info['category'] = resolved_type.value
|
||||
|
||||
def __get_subtitle_field(self, torrent: Any, field_name: str):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -8,6 +8,7 @@ from .file import *
|
||||
from .history import *
|
||||
from .mediaserver import *
|
||||
from .message import *
|
||||
from .music import *
|
||||
from .monitoring import *
|
||||
from .plugin import *
|
||||
from .response import *
|
||||
|
||||
@@ -2,6 +2,8 @@ from typing import Optional, Dict, List, Union, Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.music import MusicInfo, MusicMeta
|
||||
|
||||
|
||||
class MetaInfo(BaseModel):
|
||||
"""
|
||||
@@ -15,7 +17,7 @@ class MetaInfo(BaseModel):
|
||||
title: Optional[str] = None
|
||||
# 副标题
|
||||
subtitle: Optional[str] = None
|
||||
# 类型 电影、电视剧
|
||||
# 类型 电影、电视剧、音乐
|
||||
type: Optional[str] = None
|
||||
# 名称
|
||||
name: Optional[str] = None
|
||||
@@ -252,7 +254,7 @@ class TorrentInfo(BaseModel):
|
||||
labels: Optional[list] = Field(default_factory=list)
|
||||
# 种子优先级
|
||||
pri_order: Optional[int] = 0
|
||||
# 种子分类 电影/电视剧
|
||||
# 种子分类 电影/电视剧/音乐
|
||||
category: Optional[str] = None
|
||||
# 促销
|
||||
volume_factor: Optional[str] = None
|
||||
@@ -319,9 +321,9 @@ class Context(BaseModel):
|
||||
上下文
|
||||
"""
|
||||
# 元数据
|
||||
meta_info: Optional[Union[MetaInfo, Any]] = None
|
||||
meta_info: Optional[Union[MusicMeta, MetaInfo, Any]] = None
|
||||
# 媒体信息
|
||||
media_info: Optional[Union[MediaInfo, Any]] = None
|
||||
media_info: Optional[Union[MusicInfo, MediaInfo, Any]] = None
|
||||
# 种子信息
|
||||
torrent_info: Optional[TorrentInfo] = None
|
||||
# 候选资源来源:rss、spider、search、unknown
|
||||
|
||||
@@ -12,7 +12,7 @@ class DownloadHistory(BaseModel):
|
||||
id: int
|
||||
# 保存路程
|
||||
path: Optional[str] = None
|
||||
# 类型:电影、电视剧
|
||||
# 类型:电影、电视剧、音乐
|
||||
type: Optional[str] = None
|
||||
# 标题
|
||||
title: Optional[str] = None
|
||||
|
||||
@@ -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
|
||||
@@ -1,9 +1,10 @@
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, List, Optional
|
||||
from typing import Any, Callable, List, Optional, Union
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.context import MetaInfo, MediaInfo
|
||||
from app.schemas.music import MusicInfo, MusicMeta
|
||||
from app.schemas.file import FileItem
|
||||
from app.schemas.history import DownloadHistory
|
||||
from app.schemas.system import TransferDirectoryConf
|
||||
@@ -96,7 +97,7 @@ class TransferJobTask(BaseModel):
|
||||
文件整理作业任务
|
||||
"""
|
||||
fileitem: Optional[FileItem] = None
|
||||
meta: Optional[MetaInfo] = None
|
||||
meta: Optional[Union[MusicMeta, MetaInfo]] = None
|
||||
state: Optional[str] = None
|
||||
downloader: Optional[str] = None
|
||||
download_hash: Optional[str] = None
|
||||
@@ -106,7 +107,7 @@ class TransferJob(BaseModel):
|
||||
"""
|
||||
文件整理作业
|
||||
"""
|
||||
media: Optional[MediaInfo] = None
|
||||
media: Optional[Union[MusicInfo, MediaInfo]] = None
|
||||
season: Optional[int] = None
|
||||
tasks: Optional[List[TransferJobTask]] = Field(default_factory=list)
|
||||
|
||||
|
||||
+18
-5
@@ -6,22 +6,31 @@ from typing import Optional
|
||||
class MediaType(Enum):
|
||||
MOVIE = '电影'
|
||||
TV = '电视剧'
|
||||
MUSIC = '音乐'
|
||||
COLLECTION = '系列'
|
||||
UNKNOWN = '未知'
|
||||
|
||||
@staticmethod
|
||||
def from_agent(key: str) -> Optional["MediaType"]:
|
||||
"""'movie' -> MediaType.MOVIE, 'tv' -> MediaType.TV, 否则 None"""
|
||||
_map = {"movie": MediaType.MOVIE, "tv": MediaType.TV}
|
||||
"""将 Agent 媒体类型转换为 MediaType。"""
|
||||
_map = {
|
||||
"movie": MediaType.MOVIE,
|
||||
"tv": MediaType.TV,
|
||||
"music": MediaType.MUSIC,
|
||||
}
|
||||
return _map.get(key.strip().lower() if key else "")
|
||||
|
||||
def to_agent(self) -> str:
|
||||
"""MediaType.MOVIE -> 'movie', MediaType.TV -> 'tv', 其他返回 .value"""
|
||||
return {MediaType.MOVIE: "movie", MediaType.TV: "tv"}.get(self, self.value)
|
||||
"""将 MediaType 转换为 Agent 使用的媒体类型。"""
|
||||
return {
|
||||
MediaType.MOVIE: "movie",
|
||||
MediaType.TV: "tv",
|
||||
MediaType.MUSIC: "music",
|
||||
}.get(self, self.value)
|
||||
|
||||
|
||||
def media_type_to_agent(value) -> Optional[str]:
|
||||
"""将 MediaType 枚举或中文字符串统一转为 'movie'/'tv'"""
|
||||
"""将 MediaType 枚举或字符串统一转换为 Agent 媒体类型。"""
|
||||
if isinstance(value, MediaType):
|
||||
return value.to_agent()
|
||||
if isinstance(value, str):
|
||||
@@ -397,6 +406,8 @@ class MediaRecognizeType(Enum):
|
||||
Bangumi = "Bangumi"
|
||||
# AniList
|
||||
AniList = "AniList"
|
||||
# MusicBrainz
|
||||
MusicBrainz = "MusicBrainz"
|
||||
|
||||
|
||||
# 用户配置Key字典
|
||||
@@ -448,6 +459,8 @@ class OtherModulesType(Enum):
|
||||
PostgreSQL = "PostgreSQL"
|
||||
# Redis
|
||||
Redis = "Redis"
|
||||
# ListenBrainz
|
||||
ListenBrainz = "ListenBrainz"
|
||||
|
||||
|
||||
class NameValueEnum(Enum):
|
||||
|
||||
+2
-2
@@ -104,11 +104,11 @@ RUN FRONTEND_VERSION=$(sed -n "s/^FRONTEND_VERSION\s*=\s*'\([^']*\)'/\1/p" /app/
|
||||
&& mv -f /tmp/MoviePilot-Plugins-main/plugins.v2/* /app/app/plugins/ \
|
||||
&& cat /tmp/MoviePilot-Plugins-main/package.json | jq -r 'to_entries[] | select(.value.v2 == true) | .key' | awk '{print tolower($0)}' | \
|
||||
while read -r i; do if [ ! -d "/app/app/plugins/$i" ]; then mv "/tmp/MoviePilot-Plugins-main/plugins/$i" "/app/app/plugins/"; else echo "跳过 $i"; fi; done \
|
||||
&& curl -sL "https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/resources.v2/user.sites.v2.bin" -o /app/app/helper/user.sites.v2.bin \
|
||||
&& curl -fsSL "https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/resources.v3/user.sites.v3.bin" -o /app/app/helper/user.sites.v3.bin \
|
||||
&& python_ver=$(python3 -c 'import sys; print(f"cpython-{sys.version_info.major}{sys.version_info.minor}")') \
|
||||
&& ARCH=$(uname -m) \
|
||||
&& if [ "$ARCH" = "aarch64" ]; then SUFFIX="aarch64-linux-gnu"; else SUFFIX="x86_64-linux-gnu"; fi \
|
||||
&& curl -sL "https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/resources.v2/sites.${python_ver}-${SUFFIX}.so" -o "/app/app/helper/sites.${python_ver}-${SUFFIX}.so"
|
||||
&& curl -fsSL "https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/resources.v3/sites.${python_ver}-${SUFFIX}.so" -o "/app/app/helper/sites.${python_ver}-${SUFFIX}.so"
|
||||
|
||||
# final 阶段: 安装运行时依赖和配置最终镜像
|
||||
FROM prepare_package AS final
|
||||
|
||||
+13
-6
@@ -157,7 +157,9 @@ function install_backend_and_download_resources() {
|
||||
INFO "→ 正在备份站点资源目录..."
|
||||
rm -rf /resources_bakcup
|
||||
mkdir /resources_bakcup
|
||||
cp -a /app/app/helper/user.sites.v2.bin /resources_bakcup
|
||||
if [ -f /app/app/helper/user.sites.v3.bin ]; then
|
||||
cp -a /app/app/helper/user.sites.v3.bin /resources_bakcup
|
||||
fi
|
||||
cp -a /app/app/helper/sites.cp* /resources_bakcup
|
||||
# 清空程序目录
|
||||
rm -rf /app
|
||||
@@ -181,14 +183,19 @@ function install_backend_and_download_resources() {
|
||||
arch_suffix="x86_64-linux-gnu"
|
||||
fi
|
||||
INFO "当前 Python 版本:${python_version},架构:${arch}"
|
||||
# 下载 user.sites.v2.bin
|
||||
if ! curl ${CURL_OPTIONS} "${GITHUB_PROXY}https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/resources.v2/user.sites.v2.bin" -o /app/app/helper/user.sites.v2.bin; then
|
||||
cp -a /resources_bakcup/user.sites.v2.bin /app/app/helper/
|
||||
WARN "user.sites.v2.bin 下载失败,继续使用旧的资源来启动..."
|
||||
# 下载 V3 站点索引
|
||||
if ! curl ${CURL_OPTIONS} "${GITHUB_PROXY}https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/resources.v3/user.sites.v3.bin" -o /app/app/helper/user.sites.v3.bin; then
|
||||
if [ -f /resources_bakcup/user.sites.v3.bin ]; then
|
||||
cp -a /resources_bakcup/user.sites.v3.bin /app/app/helper/
|
||||
fi
|
||||
WARN "user.sites.v3.bin 下载失败,继续使用旧的资源来启动..."
|
||||
fi
|
||||
# 下载对应平台的 sites 文件
|
||||
sites_file="sites.${python_version}-${arch_suffix}.so"
|
||||
if ! curl ${CURL_OPTIONS} "${GITHUB_PROXY}https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/resources.v2/${sites_file}" -o "/app/app/helper/${sites_file}"; then
|
||||
if ! curl ${CURL_OPTIONS} "${GITHUB_PROXY}https://raw.githubusercontent.com/jxxghp/MoviePilot-Resources/main/resources.v3/${sites_file}" -o "/app/app/helper/${sites_file}"; then
|
||||
if [ -f "/resources_bakcup/${sites_file}" ]; then
|
||||
cp -a "/resources_bakcup/${sites_file}" /app/app/helper/
|
||||
fi
|
||||
WARN "${sites_file} 下载失败,继续使用旧的资源来启动..."
|
||||
fi
|
||||
INFO "站点资源更新成功"
|
||||
|
||||
+3
-3
@@ -16,7 +16,7 @@ curl -fsSL https://raw.githubusercontent.com/jxxghp/MoviePilot/v2/scripts/bootst
|
||||
- 安装后端依赖
|
||||
- 按当前仓库 `version.py` 中的 `FRONTEND_VERSION` 下载对应前端 release 的 `dist.zip`
|
||||
- 下载 `MoviePilot-Resources` 主分支资源
|
||||
- 将 `resources.v2/*` 同步到后端 [app/helper](/Users/jxxghp/PycharmProjects/MoviePilot/app/helper)
|
||||
- 将 `resources.v3/*` 同步到后端 [app/helper](/Users/jxxghp/PycharmProjects/MoviePilot/app/helper)
|
||||
- 下载本地 Node 运行时并安装前端运行依赖
|
||||
- 执行初始化向导
|
||||
- 创建全局 `moviepilot` 命令
|
||||
@@ -185,14 +185,14 @@ moviepilot install frontend --config-dir /path/to/moviepilot-config
|
||||
```shell
|
||||
moviepilot install resources
|
||||
moviepilot install resources --resources-repo /path/to/MoviePilot-Resources
|
||||
moviepilot install resources --resource-dir /path/to/resources.v2
|
||||
moviepilot install resources --resource-dir /path/to/resources.v3
|
||||
moviepilot install resources --config-dir /path/to/moviepilot-config
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 默认直接从 GitHub 下载 `MoviePilot-Resources` 主分支压缩包
|
||||
- 会将 `resources.v2/*` 整体复制到 [app/helper](/Users/jxxghp/PycharmProjects/MoviePilot/app/helper)
|
||||
- 会将 `resources.v3/*` 整体复制到 [app/helper](/Users/jxxghp/PycharmProjects/MoviePilot/app/helper)
|
||||
- 这一步和 Docker 构建流程保持一致
|
||||
|
||||
## 初始化命令
|
||||
|
||||
@@ -105,7 +105,7 @@ chmod +x scripts/start-local.sh
|
||||
|
||||
本地源码开发时,主程序需要读取资源文件和插件源码。相关文件需要放到主程序实际加载的目录下:
|
||||
|
||||
- **资源文件**:将 [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources) 仓库中 `resources.v2/` 下的文件同步到本仓库的 `app/helper/` 目录下。CLI 安装和 Docker 构建流程也会按这个位置准备资源。
|
||||
- **资源文件**:将 [MoviePilot-Resources](https://github.com/jxxghp/MoviePilot-Resources) 仓库中 `resources.v3/` 下的文件同步到本仓库的 `app/helper/` 目录下。CLI 安装和 Docker 构建流程只读取 V3 资源。
|
||||
- **插件源码**:需要开发或调试的插件放到本仓库的 `app/plugins/` 目录下,例如 `app/plugins/<插件目录>/`。主程序运行时从该目录加载插件,独立插件仓库只是源码来源。
|
||||
|
||||
如果资源文件没有放到 `app/helper/`,站点索引、规则和内置资源相关能力可能无法按本地开发预期工作;如果插件没有放到 `app/plugins/`,主程序也不会在本地运行时发现该插件。
|
||||
|
||||
+14
-3
@@ -151,10 +151,10 @@ FastAPI 的 HTTP 异常在 v1、v2 均统一使用 `message`,不再返回顶
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| :--- | :--- | :--- |
|
||||
| GET | `/api/v1/search/media/{mediaid}` | 按媒体 ID 搜索站点种子资源,`mediaid` 支持 `tmdb:123`、`douban:123`、`bangumi:123`、`anilist:123` 及插件来源前缀,参数:`mtype`、`area`、`title`、`year`、`season`、`sites` |
|
||||
| GET | `/api/v1/search/media/{mediaid}` | 按媒体 ID 搜索站点种子资源,`mediaid` 支持 `tmdb:123`、`douban:123`、`bangumi:123`、`anilist:123`、`musicbrainz:<recording_mbid>` 及插件来源前缀,参数:`mtype`、`area`、`title`、`year`、`season`、`sites` |
|
||||
| GET | `/api/v1/search/media/{mediaid}/stream` | 按媒体 ID 渐进式搜索站点种子资源,返回 SSE,参数同上 |
|
||||
| GET | `/api/v1/search/title` | 按关键字模糊搜索站点种子资源,参数:`keyword`、`page`、`sites` |
|
||||
| GET | `/api/v1/search/title/stream` | 按关键字渐进式搜索站点种子资源,返回 SSE,参数:`keyword`、`page`、`sites` |
|
||||
| GET | `/api/v1/search/title` | 按关键字模糊搜索站点种子资源,参数:`keyword`、`page`、`sites`,可选 `mtype=音乐` 仅搜索音乐分类 |
|
||||
| GET | `/api/v1/search/title/stream` | 按关键字渐进式搜索站点种子资源,返回 SSE,参数:`keyword`、`page`、`sites`,可选 `mtype=音乐` |
|
||||
| GET | `/api/v1/search/subtitle/title` | 按关键字搜索站点字幕资源,参数:`keyword`、`page`、`sites` |
|
||||
| GET | `/api/v1/search/subtitle/title/stream` | 按关键字渐进式搜索站点字幕资源,返回 SSE,参数:`keyword`、`page`、`sites` |
|
||||
| GET | `/api/v1/search/subtitle/media/{mediaid}` | 按媒体 ID 精确搜索站点字幕资源,`mediaid` 支持四种内置来源及插件来源前缀,参数:`mtype`、`title`、`year`、`season`、`episode`、`sites` |
|
||||
@@ -180,6 +180,17 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch
|
||||
| GET | `/api/v1/anilist/person/{person_id}` | 查询人物详情 |
|
||||
| GET | `/api/v1/anilist/person/credits/{person_id}` | 查询人物参与的动画作品,参数:`page`、`count` |
|
||||
|
||||
#### 音乐元数据 / 推荐 / 探索
|
||||
|
||||
音乐元数据使用 `MusicMeta` / `MusicInfo` 独立模型,媒体身份为 `musicbrainz:<recording_mbid>`。这些接口只负责搜索、识别、订阅和资源获取,不提供音乐库、歌单或歌手库管理。
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| :--- | :--- | :--- |
|
||||
| GET | `/api/v1/music/search` | 按歌曲、专辑或歌手关键词搜索音乐元数据,参数:`query`、`count` |
|
||||
| POST | `/api/v1/music/recognize` | 按 `source` + `media_id` 识别音乐详情,请求体:`MusicRecognizeRequest` |
|
||||
| GET | `/api/v1/music/explore` | 浏览月度热门音乐,参数:`page`、`count` |
|
||||
| GET | `/api/v1/recommend/music_weekly` | 浏览本周热门音乐,参数:`page`、`count` |
|
||||
|
||||
#### 下载
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|
||||
+1
-1
@@ -117,7 +117,7 @@ Options:
|
||||
|
||||
resources:
|
||||
--resources-repo PATH 本地 MoviePilot-Resources 仓库路径
|
||||
--resource-dir PATH 直接指定 resources.v2 目录
|
||||
--resource-dir PATH 直接指定 resources.v3 目录
|
||||
--config-dir PATH 指定配置目录
|
||||
|
||||
-h, --help 显示帮助
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
moviepilot-rust~=0.2.4
|
||||
pydantic>=2.13.4,<3.0.0
|
||||
pydantic-settings>=2.14.1,<3.0.0
|
||||
pydantic-settings>=2.14.2,<3.0.0
|
||||
SQLAlchemy~=2.0.50
|
||||
uvicorn~=0.49.0
|
||||
fastapi~=0.136.3
|
||||
@@ -32,6 +32,7 @@ qbittorrent-api==2026.6.0
|
||||
plexapi~=4.18.1
|
||||
transmission-rpc~=7.0.11
|
||||
Jinja2~=3.1.6
|
||||
mutagen~=1.47.0
|
||||
pyparsing~=3.3.2
|
||||
beautifulsoup4~=4.15.0
|
||||
pillow~=12.2.0
|
||||
|
||||
+24
-15
@@ -55,6 +55,7 @@ FRONTEND_TAG_API = (
|
||||
RESOURCES_MAIN_ZIP = (
|
||||
"https://github.com/jxxghp/MoviePilot-Resources/archive/refs/heads/main.zip"
|
||||
)
|
||||
RESOURCE_VERSION_FLAG = "v3"
|
||||
LLM_PROVIDER_DEFAULTS = {
|
||||
"deepseek": {
|
||||
"model": "deepseek-chat",
|
||||
@@ -998,7 +999,7 @@ def install_frontend(frontend_version: str, node_version: str) -> dict[str, str]
|
||||
|
||||
|
||||
def local_resource_status() -> bool:
|
||||
return (HELPER_DIR / "user.sites.v2.bin").exists() and bool(
|
||||
return (HELPER_DIR / f"user.sites.{RESOURCE_VERSION_FLAG}.bin").exists() and bool(
|
||||
list(HELPER_DIR.glob("sites*"))
|
||||
)
|
||||
|
||||
@@ -1045,14 +1046,17 @@ def _get_python_version_tag() -> str:
|
||||
|
||||
|
||||
def _filter_resources_files(
|
||||
source_dir: Path, platform_tag: str, python_version: str
|
||||
source_dir: Path,
|
||||
platform_tag: str,
|
||||
python_version: str,
|
||||
) -> list[Path]:
|
||||
"""筛选 V3 资源中与当前 Python 平台匹配的运行文件。"""
|
||||
matched_files: list[Path] = []
|
||||
for file in source_dir.iterdir():
|
||||
if not file.is_file():
|
||||
continue
|
||||
filename = file.name
|
||||
if filename == "user.sites.v2.bin":
|
||||
if filename == f"user.sites.{RESOURCE_VERSION_FLAG}.bin":
|
||||
matched_files.append(file)
|
||||
continue
|
||||
if not filename.startswith("sites."):
|
||||
@@ -1083,9 +1087,13 @@ def _download_resources_dir() -> Path:
|
||||
print_step("下载资源包")
|
||||
download_file(RESOURCES_MAIN_ZIP, archive_path)
|
||||
extract_archive(archive_path, extract_dir)
|
||||
source_dir = extract_dir / "MoviePilot-Resources-main" / "resources.v2"
|
||||
source_dir = (
|
||||
extract_dir
|
||||
/ "MoviePilot-Resources-main"
|
||||
/ f"resources.{RESOURCE_VERSION_FLAG}"
|
||||
)
|
||||
if not source_dir.exists():
|
||||
raise RuntimeError("资源压缩包中未找到 resources.v2 目录")
|
||||
raise RuntimeError(f"资源压缩包中未找到 resources.{RESOURCE_VERSION_FLAG} 目录")
|
||||
|
||||
platform_name, machine = _get_platform_tag()
|
||||
python_version = _get_python_version_tag()
|
||||
@@ -1094,7 +1102,9 @@ def _download_resources_dir() -> Path:
|
||||
)
|
||||
|
||||
matched_files = _filter_resources_files(
|
||||
source_dir, platform_name, python_version
|
||||
source_dir,
|
||||
platform_name,
|
||||
python_version,
|
||||
)
|
||||
if not matched_files:
|
||||
raise RuntimeError(
|
||||
@@ -1107,7 +1117,7 @@ def _download_resources_dir() -> Path:
|
||||
target = staging_dir / file.name
|
||||
shutil.copy2(file, target)
|
||||
|
||||
persisted = TEMP_DIR / "resources.v2"
|
||||
persisted = TEMP_DIR / f"resources.{RESOURCE_VERSION_FLAG}"
|
||||
_remove_path(persisted)
|
||||
shutil.copytree(staging_dir, persisted)
|
||||
print_step(f"已筛选对应平台的资源文件,共 {len(matched_files)} 个")
|
||||
@@ -1126,15 +1136,14 @@ def _resolve_local_resource_dir(
|
||||
if resources_repo:
|
||||
repo_dir = resources_repo.expanduser().resolve()
|
||||
candidates = [
|
||||
repo_dir / "resources.v2",
|
||||
repo_dir / "resources" / "resources.v2",
|
||||
repo_dir / "resources" / "v2",
|
||||
repo_dir / "resources.v2",
|
||||
repo_dir / "resources.v3",
|
||||
repo_dir / "resources" / "resources.v3",
|
||||
repo_dir / "resources" / "v3",
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
raise FileNotFoundError(f"未在 {repo_dir} 下找到 resources.v2 目录")
|
||||
raise FileNotFoundError(f"未在 {repo_dir} 下找到 resources.v3 目录")
|
||||
return None
|
||||
|
||||
|
||||
@@ -3728,7 +3737,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
resources_parser.add_argument(
|
||||
"--resources-repo", help="本地 MoviePilot-Resources 仓库路径"
|
||||
)
|
||||
resources_parser.add_argument("--resource-dir", help="直接指定 resources.v2 目录")
|
||||
resources_parser.add_argument("--resource-dir", help="直接指定 resources.v3 目录")
|
||||
resources_parser.add_argument(
|
||||
"--config-dir", help="配置目录,默认使用程序目录外的系统配置目录"
|
||||
)
|
||||
@@ -3737,7 +3746,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
init_parser.add_argument(
|
||||
"--resources-repo", help="本地 MoviePilot-Resources 仓库路径"
|
||||
)
|
||||
init_parser.add_argument("--resource-dir", help="直接指定 resources.v2 目录")
|
||||
init_parser.add_argument("--resource-dir", help="直接指定 resources.v3 目录")
|
||||
init_parser.add_argument(
|
||||
"--skip-resources", action="store_true", help="只初始化配置,不同步资源文件"
|
||||
)
|
||||
@@ -3774,7 +3783,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
setup_parser.add_argument(
|
||||
"--resources-repo", help="本地 MoviePilot-Resources 仓库路径"
|
||||
)
|
||||
setup_parser.add_argument("--resource-dir", help="直接指定 resources.v2 目录")
|
||||
setup_parser.add_argument("--resource-dir", help="直接指定 resources.v3 目录")
|
||||
setup_parser.add_argument(
|
||||
"--skip-resources", action="store_true", help="只初始化配置,不同步资源文件"
|
||||
)
|
||||
|
||||
@@ -175,14 +175,27 @@ AniList endpoints prefer the `anilist-chinese` proxy and fall back to official A
|
||||
| GET | `/api/v1/anilist/person/{person_id}` | Staff detail |
|
||||
| GET | `/api/v1/anilist/person/credits/{person_id}` | Staff anime credits. Params: `page`, `count` |
|
||||
|
||||
### Music (3 endpoints)
|
||||
|
||||
Music uses the independent `MusicMeta` / `MusicInfo` contract and a
|
||||
`musicbrainz:<recording_mbid>` identity. MoviePilot searches, recognizes,
|
||||
subscribes to, downloads, and organizes music; it does not manage a music
|
||||
library, playlists, or an artist library.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/music/search` | Search tracks, albums, or artists. Params: `query`, `count` |
|
||||
| POST | `/api/v1/music/recognize` | Resolve music metadata. Body: `source`, `media_id` |
|
||||
| GET | `/api/v1/music/explore` | Explore the monthly site-wide music chart. Params: `page`, `count` |
|
||||
|
||||
### Search / Torrents / Subtitles (11 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/search/media/{mediaid}` | Search torrents by media ID (four built-in prefixes or a plugin-defined source prefix). Params: `mtype`, `area`, `title`, `year`, `season`, `sites` |
|
||||
| GET | `/api/v1/search/media/{mediaid}` | Search torrents by media ID (video prefixes, `musicbrainz:<recording_mbid>`, or a plugin-defined source prefix). Params: `mtype`, `area`, `title`, `year`, `season`, `sites` |
|
||||
| GET | `/api/v1/search/media/{mediaid}/stream` | Stream torrent search by media ID with SSE. Params: `mtype`, `area`, `title`, `year`, `season`, `sites` |
|
||||
| GET | `/api/v1/search/title` | Fuzzy search torrents by keyword. Params: `keyword`, `page`, `sites` |
|
||||
| GET | `/api/v1/search/title/stream` | Stream fuzzy torrent search with SSE. Params: `keyword`, `page`, `sites` |
|
||||
| GET | `/api/v1/search/title` | Fuzzy search torrents by keyword. Params: `keyword`, `page`, `sites`, optional `mtype=音乐` |
|
||||
| GET | `/api/v1/search/title/stream` | Stream fuzzy torrent search with SSE. Params: `keyword`, `page`, `sites`, optional `mtype=音乐` |
|
||||
| GET | `/api/v1/search/subtitle/title` | Fuzzy search site subtitles by keyword. Params: `keyword`, `page`, `sites` |
|
||||
| GET | `/api/v1/search/subtitle/title/stream` | Stream fuzzy site subtitle search with SSE. Params: `keyword`, `page`, `sites` |
|
||||
| GET | `/api/v1/search/subtitle/media/{mediaid}` | Exact subtitle search by media ID (four built-in prefixes or a plugin-defined source prefix). Params: `mtype`, `title`, `year`, `season`, `episode`, `sites` |
|
||||
@@ -437,12 +450,13 @@ Streaming search sends `{"type":"heartbeat"}` every 15 seconds without business
|
||||
| GET | `/api/v1/discover/tmdb_movies` | Discover TMDB movies. Params: `sort_by`, `with_genres`, `with_original_language`, `page` |
|
||||
| GET | `/api/v1/discover/tmdb_tvs` | Discover TMDB TV. Params: same as movies |
|
||||
|
||||
### Recommend (14 endpoints)
|
||||
### Recommend (15 endpoints)
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/api/v1/recommend/source` | Recommendation data sources |
|
||||
| GET | `/api/v1/recommend/bangumi_calendar` | Bangumi daily schedule. Params: `page`, `count` |
|
||||
| GET | `/api/v1/recommend/music_weekly` | ListenBrainz weekly site-wide music chart. Params: `page`, `count` |
|
||||
| GET | `/api/v1/recommend/douban_showing` | Douban now showing. Params: `page`, `count` |
|
||||
| GET | `/api/v1/recommend/douban_movies` | Douban movies. Params: `sort`, `tags`, `page`, `count` |
|
||||
| GET | `/api/v1/recommend/douban_tvs` | Douban TV. Params: `sort`, `tags`, `page`, `count` |
|
||||
|
||||
@@ -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"
|
||||
@@ -47,7 +47,7 @@ def _run_permission_case(tmp_path: Path, body: str, env: dict[str, str] | None =
|
||||
(home_dir / ".cloakbrowser").mkdir(parents=True)
|
||||
(home_dir / "runtime").mkdir()
|
||||
(app_dir / "app" / "plugins" / "plugin.py").write_text("# plugin\n", encoding="utf-8")
|
||||
(helper_dir / "user.sites.v2.bin").write_text("resources\n", encoding="utf-8")
|
||||
(helper_dir / "user.sites.v3.bin").write_text("resources\n", encoding="utf-8")
|
||||
(helper_dir / "sites.cpython-312-x86_64-linux-gnu.so").write_text("plugin\n", encoding="utf-8")
|
||||
(public_dir / "index.html").write_text("<!doctype html>\n", encoding="utf-8")
|
||||
(home_dir / ".cloakbrowser" / "chrome").write_text("browser cache\n", encoding="utf-8")
|
||||
|
||||
@@ -46,6 +46,9 @@ def _load_downloader_base():
|
||||
schema_types_module.MediaServerType = Enum("MediaServerType", {"Emby": "Emby"})
|
||||
schema_types_module.MessageChannel = Enum("MessageChannel", {"Telegram": "telegram"})
|
||||
schema_types_module.OtherModulesType = Enum("OtherModulesType", {"Subtitle": "subtitle"})
|
||||
schema_types_module.MediaRecognizeType = Enum(
|
||||
"MediaRecognizeType", {"TheMovieDb": "themoviedb"}
|
||||
)
|
||||
schema_types_module.SystemConfigKey = Enum(
|
||||
"SystemConfigKey",
|
||||
{
|
||||
|
||||
@@ -167,6 +167,78 @@ def test_ttg_title_search_does_not_format_keyword():
|
||||
assert query["search_field"] == ["The Movie 分类:电影DVDRip"]
|
||||
|
||||
|
||||
def test_music_search_uses_dedicated_path_and_repeated_category_parameter():
|
||||
"""
|
||||
音乐分支使用独立页面时应选择 music 路径,并按配置生成可重复的分类参数。
|
||||
"""
|
||||
indexer = _build_indexer(
|
||||
id="hhanclub",
|
||||
domain="https://hhanclub.net/",
|
||||
search={
|
||||
"paths": [
|
||||
{"path": "torrents.php", "type": "all"},
|
||||
{"path": "special.php", "type": "music"},
|
||||
],
|
||||
"params": {"search": "{keyword}"},
|
||||
},
|
||||
category={
|
||||
"param": "cat[]",
|
||||
"music": [
|
||||
{"id": 410, "cat": "Music"},
|
||||
{"id": 411, "cat": "MusicVideo"},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
parsed_url = urlparse(_get_search_url(indexer, "周杰伦", MediaType.MUSIC))
|
||||
query = parse_qs(parsed_url.query)
|
||||
|
||||
assert parsed_url.path == "/special.php"
|
||||
assert query["cat[]"] == ["410", "411"]
|
||||
assert query["search"] == ["周杰伦"]
|
||||
|
||||
|
||||
def test_typed_search_path_falls_back_to_all_path():
|
||||
"""
|
||||
站点只为音乐定义专用路径时,影视搜索仍应回退到通用路径。
|
||||
"""
|
||||
indexer = _build_indexer(
|
||||
search={
|
||||
"paths": [
|
||||
{"path": "torrents.php", "type": "all"},
|
||||
{"path": "special.php", "type": "music"},
|
||||
],
|
||||
"params": {"search": "{keyword}"},
|
||||
},
|
||||
)
|
||||
|
||||
parsed_url = urlparse(_get_search_url(indexer, "电影", MediaType.MOVIE))
|
||||
|
||||
assert parsed_url.path == "/torrents.php"
|
||||
|
||||
|
||||
def test_category_item_can_use_distinct_search_parameter_value():
|
||||
"""
|
||||
DiscuzX 子分类可以用展示分类 ID 解析结果,同时用父分类值构造搜索参数。
|
||||
"""
|
||||
indexer = _build_indexer(
|
||||
search={
|
||||
"paths": [{"path": "forum.php?mod=torrents&cat=1"}],
|
||||
"params": {"search": "{keyword}"},
|
||||
},
|
||||
category={
|
||||
"music": [
|
||||
{"id": 20, "value": 1, "param": "cat_1_18", "cat": "Music"},
|
||||
{"id": 21, "value": 1, "param": "cat_1_18", "cat": "Music"},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
query = parse_qs(urlparse(_get_search_url(indexer, "FLAC", MediaType.MUSIC)).query)
|
||||
|
||||
assert query["cat_1_18"] == ["1", "1"]
|
||||
|
||||
|
||||
def test_haidan_empty_keyword_uses_blank_search_value():
|
||||
"""
|
||||
海胆空关键词浏览不能把 Python None 编码进 search 参数。
|
||||
|
||||
@@ -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"]
|
||||
@@ -45,7 +45,7 @@ class LocalSetupUninstallTests(unittest.TestCase):
|
||||
install_env_file.write_text("CONFIG_DIR=/tmp/moviepilot-config\n", encoding="utf-8")
|
||||
(root_dir / "moviepilot").write_text("#!/usr/bin/env bash\n", encoding="utf-8")
|
||||
(helper_dir / "sites.py").write_text("generated\n", encoding="utf-8")
|
||||
(helper_dir / "user.sites.v2.bin").write_bytes(b"binary")
|
||||
(helper_dir / "user.sites.v3.bin").write_bytes(b"binary")
|
||||
(temp_config_dir / "moviepilot.runtime.json").write_text("{}", encoding="utf-8")
|
||||
(temp_config_dir / "moviepilot.frontend.runtime.json").write_text(
|
||||
"{}", encoding="utf-8"
|
||||
@@ -110,7 +110,7 @@ class LocalSetupUninstallTests(unittest.TestCase):
|
||||
self.assertFalse((root_dir / ".runtime").exists())
|
||||
self.assertFalse((root_dir / "public").exists())
|
||||
self.assertFalse((root_dir / "app" / "helper" / "sites.py").exists())
|
||||
self.assertFalse((root_dir / "app" / "helper" / "user.sites.v2.bin").exists())
|
||||
self.assertFalse((root_dir / "app" / "helper" / "user.sites.v3.bin").exists())
|
||||
self.assertFalse(cli_link.exists())
|
||||
|
||||
def test_uninstall_deletes_external_config_when_requested(self):
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 == "音乐"
|
||||
@@ -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 == "晴天"
|
||||
@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch
|
||||
import pytest
|
||||
|
||||
from app.chain.recommend import RecommendChain
|
||||
from app.core.music import MusicInfo
|
||||
from app.core.cache import TTLCache
|
||||
|
||||
|
||||
@@ -93,3 +94,41 @@ def test_async_recommend_methods_do_not_cache_empty_result(
|
||||
assert asyncio.run(recommend_method(page=1)) == []
|
||||
|
||||
assert backend_chain.return_value.async_run_module.call_count == 2
|
||||
|
||||
|
||||
def test_music_weekly_uses_music_chart():
|
||||
"""同步推荐缓存应从本周音乐榜单生成通用媒体字典。"""
|
||||
chain = RecommendChain()
|
||||
with patch("app.chain.recommend.MusicChain") as music_chain:
|
||||
music_chain.return_value.chart.return_value = [
|
||||
MusicInfo(source="musicbrainz", media_id="recording-1", title="晴天")
|
||||
]
|
||||
|
||||
result = chain.music_weekly(page=2, count=10)
|
||||
|
||||
assert result[0]["media_id"] == "recording-1"
|
||||
music_chain.return_value.chart.assert_called_once_with(
|
||||
range_name="this_week",
|
||||
page=2,
|
||||
count=10,
|
||||
)
|
||||
|
||||
|
||||
def test_async_music_weekly_uses_music_chart():
|
||||
"""异步推荐接口应从本周音乐榜单返回统一媒体字典。"""
|
||||
chain = RecommendChain()
|
||||
with patch("app.chain.recommend.MusicChain") as music_chain:
|
||||
music_chain.return_value.async_chart = AsyncMock(
|
||||
return_value=[
|
||||
MusicInfo(source="musicbrainz", media_id="recording-1", title="晴天")
|
||||
]
|
||||
)
|
||||
|
||||
result = asyncio.run(chain.async_music_weekly(page=1, count=30))
|
||||
|
||||
assert result[0]["type"] == "音乐"
|
||||
music_chain.return_value.async_chart.assert_awaited_once_with(
|
||||
range_name="this_week",
|
||||
page=1,
|
||||
count=30,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user