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