feat(music): 音乐订阅刷新与识别缓存

This commit is contained in:
jxxghp
2026-08-11 11:39:08 +08:00
parent 778d185c9d
commit d0edcfa7bb
12 changed files with 1364 additions and 37 deletions

View File

@@ -8,11 +8,14 @@ from app.chain.music import MusicChain
from app.schemas.types import MediaType
from app.core.context import MusicAlbumInfo, MusicArtistInfo, MusicInfo
from app.core.security import verify_token
from app.db.models.user import User
from app.db.user_oper import get_current_active_superuser_async
from app.modules.listenbrainz import (
LISTENBRAINZ_CHART_RANGES,
LISTENBRAINZ_FRESH_MAX_DAYS,
LISTENBRAINZ_FRESH_SORTS,
)
from app.modules.musicbrainz.music_cache import MusicBrainzCache
router = APIRouter()
@@ -67,6 +70,53 @@ async def recognize_music(
return _serialize_music(info)
@router.get(
"/cache", summary="查询音乐识别缓存", response_model=schemas.Response
)
async def music_recognition_cache(
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""查询可管理的 MusicBrainz 识别缓存。"""
cache_items = MusicBrainzCache().list_items()
recognized_count = sum(1 for item in cache_items if item["media_id"])
return schemas.Response(
success=True,
data={
"count": len(cache_items),
"recognized": recognized_count,
"unrecognized": len(cache_items) - recognized_count,
"data": cache_items,
},
)
@router.delete(
"/cache/{cache_key:path}",
summary="删除指定音乐识别缓存",
response_model=schemas.Response,
)
async def delete_music_recognition_cache(
cache_key: str,
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""按缓存键删除单条 MusicBrainz 识别缓存。"""
deleted_item = MusicBrainzCache().delete(cache_key)
if not deleted_item:
return schemas.Response(success=False, message="音乐识别缓存不存在")
return schemas.Response(success=True, message="音乐识别缓存删除成功")
@router.delete(
"/cache", summary="清空音乐识别缓存", response_model=schemas.Response
)
async def clear_music_recognition_cache(
_: User = Depends(get_current_active_superuser_async),
) -> schemas.Response:
"""清空全部 MusicBrainz 识别缓存。"""
MusicBrainzCache().clear()
return schemas.Response(success=True, message="音乐识别缓存清理完成")
@router.get(
"/explore",
summary="探索音乐",

View File

@@ -118,8 +118,11 @@ async def delete_cache(
if len(cache_data[domain]) == original_count:
return schemas.Response(success=False, message="未找到指定的种子")
# 保存更新后的缓存
await torrents_chain.async_save_cache(cache_data, torrents_chain.cache_file)
# 保存更新后的缓存:影视与音乐分别回写各自存储文件
video_cache, music_cache = torrents_chain.split_cache_contexts(cache_data)
video_file, music_file = torrents_chain.cache_files()
await torrents_chain.async_save_cache(video_cache, video_file)
await torrents_chain.async_save_cache(music_cache, music_file)
return schemas.Response(success=True, message="种子删除成功")
except Exception as e:
@@ -248,8 +251,11 @@ async def reidentify_cache(
# 更新上下文中的媒体信息
target_context.media_info = mediainfo
# 保存更新后的缓存
await torrents_chain.async_save_cache(cache_data, TorrentsChain().cache_file)
# 保存更新后的缓存:影视与音乐分别回写各自存储文件
video_cache, music_cache = torrents_chain.split_cache_contexts(cache_data)
video_file, music_file = torrents_chain.cache_files()
await torrents_chain.async_save_cache(video_cache, video_file)
await torrents_chain.async_save_cache(music_cache, music_file)
return schemas.Response(
success=True,

View File

@@ -2081,6 +2081,8 @@ class SubscribeChain(ChainBase):
torrents = TorrentsChain().refresh(
sites=sites,
progress_callback=_update_refresh_progress if progress_callback else None,
# 存在音乐订阅时额外抓取站点音乐专用入口,音乐不一定在默认种子首页
include_music=self.has_music_subscribe(),
)
self.match(
torrents,
@@ -2132,6 +2134,13 @@ class SubscribeChain(ChainBase):
return ret_sites
def has_music_subscribe(self) -> bool:
"""判断是否存在可搜索状态的音乐订阅,用于决定是否额外刷新站点音乐入口。"""
return any(
subscribe.type == MediaType.MUSIC.value
for subscribe in SubscribeOper().list(self.get_states_for_search('R')) or []
)
def match(
self,
torrents: Dict[str, List[Context]],

View File

@@ -30,6 +30,9 @@ class TorrentsChain(ChainBase):
_spider_file = "__torrents_cache__"
_rss_file = "__rss_cache__"
# 音乐资源独立缓存,与影视种子分开计算配额与存储,避免被影视资源挤出
_music_spider_file = "__torrents_music_cache__"
_music_rss_file = "__rss_music_cache__"
@property
def cache_file(self) -> str:
@@ -58,7 +61,7 @@ class TorrentsChain(ChainBase):
def get_torrents(self, stype: Optional[str] = None) -> Dict[str, List[Context]]:
"""
获取当前缓存的种子
获取当前缓存的种子,包含独立缓存的音乐资源
:param stype: 强制指定缓存类型spider:爬虫缓存rss:rss缓存
"""
@@ -74,11 +77,59 @@ class TorrentsChain(ChainBase):
# 兼容性处理为旧版本的Context对象补齐新增候选识别字段
self._ensure_context_compatibility(torrents_cache, stype=stype)
# 合并音乐独立缓存,供订阅匹配等消费方按站点读取完整候选
music_cache = self.get_music_torrents(stype=stype)
for domain, contexts in music_cache.items():
if contexts:
torrents_cache.setdefault(domain, []).extend(contexts)
return torrents_cache
def get_music_torrents(self, stype: Optional[str] = None) -> Dict[str, List[Context]]:
"""
获取音乐独立缓存的种子
:param stype: 强制指定缓存类型spider:爬虫缓存rss:rss缓存
"""
if not stype:
stype = settings.SUBSCRIBE_MODE
music_file = self._music_spider_file if stype == 'spider' else self._music_rss_file
music_cache = self.load_cache(music_file) or {}
# 兼容性处理为旧版本的Context对象补齐新增候选识别字段
self._ensure_context_compatibility(music_cache, stype=stype)
return music_cache
def cache_files(self, stype: Optional[str] = None) -> tuple:
"""
返回影视与音乐缓存文件名,供按当前订阅模式回写各自缓存
:param stype: 强制指定缓存类型spider:爬虫缓存rss:rss缓存
"""
if not stype:
stype = settings.SUBSCRIBE_MODE
if stype == 'spider':
return self._spider_file, self._music_spider_file
return self._rss_file, self._music_rss_file
@staticmethod
def split_cache_contexts(
torrents_cache: Dict[str, List[Context]],
) -> tuple:
"""
将合并读取的缓存按种子分类拆分为影视缓存与音乐缓存,用于分别回写各自存储文件。
"""
video_cache: Dict[str, List[Context]] = {}
music_cache: Dict[str, List[Context]] = {}
for domain, contexts in torrents_cache.items():
for context in contexts:
torrent = context.torrent_info
if torrent and torrent.category in (MediaType.MUSIC, MediaType.MUSIC.value):
music_cache.setdefault(domain, []).append(context)
else:
video_cache.setdefault(domain, []).append(context)
return video_cache, music_cache
async def async_get_torrents(self, stype: Optional[str] = None) -> Dict[str, List[Context]]:
"""
异步获取当前缓存的种子
异步获取当前缓存的种子,包含独立缓存的音乐资源
:param stype: 强制指定缓存类型spider:爬虫缓存rss:rss缓存
"""
@@ -88,11 +139,19 @@ class TorrentsChain(ChainBase):
# 异步读取缓存
if stype == 'spider':
torrents_cache = await self.async_load_cache(self._spider_file) or {}
music_cache = await self.async_load_cache(self._music_spider_file) or {}
else:
torrents_cache = await self.async_load_cache(self._rss_file) or {}
music_cache = await self.async_load_cache(self._music_rss_file) or {}
# 兼容性处理为旧版本的Context对象补齐新增候选识别字段
self._ensure_context_compatibility(torrents_cache, stype=stype)
self._ensure_context_compatibility(music_cache, stype=stype)
# 合并音乐独立缓存,供订阅匹配等消费方按站点读取完整候选
for domain, contexts in music_cache.items():
if contexts:
torrents_cache.setdefault(domain, []).extend(contexts)
return torrents_cache
@@ -385,20 +444,24 @@ class TorrentsChain(ChainBase):
def clear_torrents(self):
"""
清理种子缓存数据
清理种子缓存数据,包含音乐独立缓存
"""
logger.info(f'开始清理种子缓存数据 ...')
self.remove_cache(self._spider_file)
self.remove_cache(self._rss_file)
self.remove_cache(self._music_spider_file)
self.remove_cache(self._music_rss_file)
logger.info(f'种子缓存数据清理完成')
async def async_clear_torrents(self):
"""
异步清理种子缓存数据
异步清理种子缓存数据,包含音乐独立缓存
"""
logger.info(f'开始异步清理种子缓存数据 ...')
await self.async_remove_cache(self._spider_file)
await self.async_remove_cache(self._rss_file)
await self.async_remove_cache(self._music_spider_file)
await self.async_remove_cache(self._music_rss_file)
logger.info(f'异步种子缓存数据清理完成')
def browse(self, domain: str, keyword: Optional[str] = None, cat: Optional[str] = None,
@@ -465,6 +528,8 @@ class TorrentsChain(ChainBase):
if not rss_items:
logger.error(f'站点 {domain} 未获取到RSS数据')
return []
# 站点级媒体类型,用于给缺少分类信息的 RSS 种子补充分类
site_media_type = MediaType.from_agent(site.get("media_type"))
# 组装种子
ret_torrents: List[TorrentInfo] = []
try:
@@ -484,6 +549,8 @@ class TorrentsChain(ChainBase):
page_url=item.get("link"),
size=item.get("size"),
pubdate=item["pubdate"].strftime("%Y-%m-%d %H:%M:%S") if item.get("pubdate") else None,
# RSS 报文不带站点分类,按站点媒体类型补充,否则音乐资源无法进入音乐订阅匹配
category=site_media_type.value if site_media_type else None,
)
ret_torrents.append(torrentinfo)
finally:
@@ -491,17 +558,71 @@ class TorrentsChain(ChainBase):
del rss_items
return ret_torrents
@staticmethod
def _music_browse_paths(site: dict) -> List[str]:
"""
返回站点独立于默认浏览入口的音乐种子页面路径。
部分站点的默认种子列表只显示电影和电视剧,音乐需要单独的菜单页面进入;
这类站点在索引配置中用 type=music 的搜索路径声明音乐入口。
默认入口已覆盖音乐(音乐站点或未定义独立入口)时返回空列表。
"""
# 音乐站点全站都是音乐资源,默认浏览入口已经覆盖
if MediaType.from_agent(site.get("media_type")) == MediaType.MUSIC:
return []
paths = (site.get("search") or {}).get("paths") or []
if len(paths) <= 1:
return []
# 计算默认浏览使用的路径,与其相同的音乐入口无需重复抓取
browse_conf = site.get("browse") or {}
default_path = browse_conf.get("path")
if not default_path:
default_path = next(
(item.get("path") for item in paths if item.get("type") in (None, "all")),
paths[0].get("path"),
)
return [
item.get("path") for item in paths
if item.get("type") == "music"
and item.get("path")
and item.get("path") != default_path
]
def __append_music_browse_torrents(
self,
domain: str,
torrents: List[TorrentInfo],
) -> List[TorrentInfo]:
"""
追加抓取站点音乐专用入口的最新种子,并按种子链接去重后返回合并结果。
"""
seen = {torrent.enclosure for torrent in torrents if torrent.enclosure}
for page in range(2):
page_torrents = self.browse(domain=domain, page=page, mtype=MediaType.MUSIC)
if not page_torrents:
# 某一页没有数据,说明已经到最后一页,停止获取
break
for torrent in page_torrents:
if torrent.enclosure and torrent.enclosure in seen:
continue
if torrent.enclosure:
seen.add(torrent.enclosure)
torrents.append(torrent)
return torrents
def refresh(
self,
stype: Optional[str] = None,
sites: List[int] = None,
progress_callback: Optional[Callable[..., None]] = None,
include_music: bool = False,
) -> Dict[str, List[Context]]:
"""
刷新站点最新资源,识别并缓存起来
:param stype: 强制指定缓存类型spider:爬虫缓存rss:rss缓存
:param sites: 强制指定站点ID列表为空则读取设置的订阅站点
:param progress_callback: 资源刷新进度更新回调
:param include_music: 是否额外抓取站点的音乐专用浏览入口,服务音乐订阅
"""
def __is_no_cache_site(_domain: str) -> bool:
@@ -521,13 +642,21 @@ class TorrentsChain(ChainBase):
if not sites:
sites = SystemConfigOper().get(SystemConfigKey.RssSites) or []
# 读取缓存
torrents_cache = self.get_torrents()
# 读取缓存,影视与音乐分别独立存储
if stype == 'spider':
torrents_cache = self.load_cache(self._spider_file) or {}
music_cache = self.load_cache(self._music_spider_file) or {}
else:
torrents_cache = self.load_cache(self._rss_file) or {}
music_cache = self.load_cache(self._music_rss_file) or {}
self._ensure_context_compatibility(torrents_cache, stype=stype)
self._ensure_context_compatibility(music_cache, stype=stype)
# 缓存过滤掉无效种子
for _domain, _torrents in torrents_cache.items():
torrents_cache[_domain] = [_torrent for _torrent in _torrents
if not TorrentHelper().is_invalid(_torrent.torrent_info.enclosure)]
# 缓存过滤掉无效种子(影视与音乐缓存分别处理)
for _cache in (torrents_cache, music_cache):
for _domain, _torrents in _cache.items():
_cache[_domain] = [_torrent for _torrent in _torrents
if not TorrentHelper().is_invalid(_torrent.torrent_info.enclosure)]
# 需要刷新的站点domain
domains = []
@@ -572,31 +701,47 @@ class TorrentsChain(ChainBase):
else:
# 如果某一页没有数据,说明已经到最后一页,停止获取
break
# 存在音乐订阅时,默认首页可能不包含音乐资源,需要额外抓取音乐专用入口
if include_music and self._music_browse_paths(indexer):
torrents = self.__append_music_browse_torrents(
domain=domain, torrents=torrents
)
else:
# 刷新RSS种子
torrents: List[TorrentInfo] = self.rss(domain=domain)
# 按pubdate降序排列
torrents.sort(key=lambda x: x.pubdate or '', reverse=True)
# 取前N条
torrents = torrents[:settings.CONF.refresh]
if torrents:
# 音乐与影视按同一公共参数独立计算刷新配额,并分别写入各自缓存,音乐不会被影视资源挤出
music_torrents = [
t for t in torrents if t.category == MediaType.MUSIC.value
][:settings.CONF.refresh]
torrents = [
t for t in torrents if t.category != MediaType.MUSIC.value
][:settings.CONF.refresh]
if torrents or music_torrents:
if __is_no_cache_site(domain):
# 不需要缓存的站点,直接处理
logger.info(f'{indexer.get("name")}{len(torrents)} 个种子 (不缓存)')
logger.info(f'{indexer.get("name")}{len(torrents) + len(music_torrents)} 个种子 (不缓存)')
torrents_cache[domain] = []
music_cache[domain] = []
else:
# 过滤出没有处理过的种子 - 优化:使用集合查找,避免重复创建字符串列表
cached_signatures = {f'{t.torrent_info.title}{t.torrent_info.description}'
for t in torrents_cache.get(domain) or []}
torrents = [torrent for torrent in torrents
if f'{torrent.title}{torrent.description}' not in cached_signatures]
if torrents:
logger.info(f'{indexer.get("name")}{len(torrents)} 个新种子')
# 音乐种子对照音乐独立缓存去重
music_signatures = {f'{t.torrent_info.title}{t.torrent_info.description}'
for t in music_cache.get(domain) or []}
music_torrents = [torrent for torrent in music_torrents
if f'{torrent.title}{torrent.description}' not in music_signatures]
if torrents or music_torrents:
logger.info(f'{indexer.get("name")}{len(torrents) + len(music_torrents)} 个新种子')
else:
logger.info(f'{indexer.get("name")} 没有新种子')
continue
try:
for torrent in torrents:
for torrent in torrents + music_torrents:
if global_vars.is_system_stopped:
break
if not torrent.enclosure:
@@ -647,29 +792,39 @@ class TorrentsChain(ChainBase):
# 如果未识别到媒体信息设置初始失败次数为1
if not mediainfo or not all(resolve_media_identity(media=mediainfo)):
context.media_recognize_fail_count = 1
# 添加到缓存
if not torrents_cache.get(domain):
torrents_cache[domain] = [context]
# 添加到缓存:音乐进入独立缓存,与影视分开存储
if torrent.category == MediaType.MUSIC.value:
target_cache = music_cache
else:
torrents_cache[domain].append(context)
# 如果超过了限制条数则移除掉前面的
if len(torrents_cache[domain]) > settings.CONF.torrents:
torrents_cache[domain] = torrents_cache[domain][-settings.CONF.torrents:]
target_cache = torrents_cache
if not target_cache.get(domain):
target_cache[domain] = [context]
else:
target_cache[domain].append(context)
# 如果超过了限制条数则移除掉前面的,音乐与影视各自独立计算配额
if len(target_cache[domain]) > settings.CONF.torrents:
target_cache[domain] = target_cache[domain][-settings.CONF.torrents:]
finally:
torrents.clear()
music_torrents.clear()
del torrents
del music_torrents
else:
logger.info(f'{indexer.get("name")} 没有获取到种子')
# 保存缓存到本地
# 保存缓存到本地,影视与音乐分别存储
if stype == "spider":
self.save_cache(torrents_cache, self._spider_file)
self.save_cache(music_cache, self._music_spider_file)
else:
self.save_cache(torrents_cache, self._rss_file)
self.save_cache(music_cache, self._music_rss_file)
# 去除不在站点范围内的缓存种子
if sites and torrents_cache:
torrents_cache = {k: v for k, v in torrents_cache.items() if k in domains}
if sites and music_cache:
music_cache = {k: v for k, v in music_cache.items() if k in domains}
if progress_callback:
progress_callback(
@@ -678,6 +833,11 @@ class TorrentsChain(ChainBase):
data={"total": total_indexers, "finished": total_indexers},
)
# 订阅匹配需要完整候选,音乐独立缓存在返回值中按站点合并
for _domain, _contexts in music_cache.items():
if _contexts:
torrents_cache.setdefault(_domain, []).extend(_contexts)
return torrents_cache
@staticmethod

View File

@@ -165,6 +165,8 @@ class SiteSpider:
# 种子搜索相对路径
paths = self.search.get('paths', [])
torrentspath = ""
# 是否选中了媒体类型专用路径,浏览模式下专用路径优先于 browse 配置
typed_path_selected = False
if len(paths) == 1:
torrentspath = paths[0].get('path', '')
else:
@@ -183,6 +185,7 @@ class SiteSpider:
not expected_type and path_type == "all"
):
torrentspath = path.get('path', '')
typed_path_selected = bool(expected_type and path_type == expected_type)
break
if not torrentspath:
torrentspath = fallback_path
@@ -282,16 +285,18 @@ class SiteSpider:
"page": self.page or 0,
"keyword": ""
}
# 有单独浏览路径
if self.browse:
# 有单独浏览路径;指定了媒体类型专用路径时不覆盖,确保音乐等专用入口可达
if self.browse and not typed_path_selected:
torrentspath = self.browse.get("path")
if self.browse.get("start"):
start_page = int(self.browse.get("start")) + int(self.page or 0)
inputs_dict.update({
"page": start_page
})
elif self.page:
torrentspath = torrentspath + f"?page={self.page}"
elif self.page and "{page}" not in str(torrentspath):
# 按路径是否已带查询参数选择连接符,避免拼出两个问号的非法地址
separator = "&" if "?" in str(torrentspath) else "?"
torrentspath = torrentspath + f"{separator}page={self.page}"
# 搜索Url
searchurl = self.domain + str(torrentspath).format(**inputs_dict)

View File

@@ -19,6 +19,7 @@ from app.core.context import (
from app.core.meta import MetaBase, MetaMusic
from app.log import logger
from app.modules import _ModuleBase
from app.modules.musicbrainz.music_cache import MusicBrainzCache
from app.schemas.types import MediaRecognizeType, MediaType, ModuleType
from app.utils.http import RequestUtils
from app.utils.zhconv import convert as zhconv_convert
@@ -36,6 +37,8 @@ class MusicBrainzModule(_ModuleBase):
_request_interval = 1.0
_request_lock = threading.Lock()
_last_request_at = 0.0
# 本地识别缓存,由模块管理器初始化时挂载
cache: MusicBrainzCache = None
# 全局复用 HTTP 会话keep-alive 省去每次请求的 DNS+TLS 握手(约 6s → 0.4s
_session: Optional[Session] = None
_session_lock = threading.Lock()
@@ -72,14 +75,32 @@ class MusicBrainzModule(_ModuleBase):
)
def init_module(self) -> None:
"""初始化无状态的 MusicBrainz 模块。"""
"""初始化 MusicBrainz 模块并挂载本地识别缓存"""
self.cache = MusicBrainzCache()
def init_setting(self) -> Optional[Tuple[str, Union[str, bool]]]:
"""MusicBrainz 无需独立密钥或启用开关。"""
return None
def stop(self) -> None:
"""停止模块;当前实现没有需要释放的持久资源"""
"""停止模块,退出前持久化识别缓存"""
if self.cache:
try:
self.cache.save()
except Exception as err:
logger.error(f"保存音乐识别缓存失败:{str(err)}")
def scheduler_job(self) -> None:
"""定时任务每10分钟持久化一次音乐识别缓存。"""
if self.cache:
self.cache.save()
def clear_cache(self) -> None:
"""响应全局缓存清理事件,清空音乐识别缓存。"""
logger.info("开始清除音乐识别缓存 ...")
if self.cache:
self.cache.clear()
logger.info("音乐识别缓存清除完成")
def test(self) -> Tuple[bool, str]:
"""测试 MusicBrainz 搜索接口连通性。"""
@@ -610,11 +631,23 @@ class MusicBrainzModule(_ModuleBase):
if source == self._source and mediaid:
return self.recognize_music(source, str(mediaid))
return None
# 识别缓存命中直接响应,避免重复搜索占用 MusicBrainz 限流配额
cache_enabled = bool(kwargs.get("cache", True))
if cache_enabled and self.cache:
cached_info = self.cache.get(meta)
if cached_info:
if cached_info.media_id:
logger.info(f"{meta.title} 使用音乐识别缓存:{cached_info.title}")
else:
logger.info(f"{meta.title} 使用音乐识别缓存:无法识别")
cached_info.recognize_cache_hit = True
return cached_info
# 携带数据源与原生 ID 的请求优先按详情识别
resolved_source = source or meta.media_source
if resolved_source and (mediaid or meta.media_id):
info = self.recognize_music(resolved_source, str(mediaid or meta.media_id))
if info:
self._update_recognize_cache(meta, info)
return info
# 无身份时按标题搜索并挑选可信候选,检索不到时返回元数据兑底
# 文件识别只能从 Recording 中挑选,专辑或艺术家同名结果不能成为音轨身份。
@@ -626,7 +659,38 @@ class MusicBrainzModule(_ModuleBase):
if not matched and meta.artists:
albums = self._search_albums(meta, limit=10)
matched = self._select_album_candidate(meta, albums)
return matched or self._info_from_meta(meta)
result = matched or self._info_from_meta(meta)
# 无远端身份的兑底结果同样入缓存,避免批量识别时反复搜索同一文件
self._update_recognize_cache(meta, result)
return result
def _update_recognize_cache(self, meta: MetaMusic, info: Optional[MusicInfo]) -> None:
"""识别完成后把结果写入本地识别缓存,未挂载缓存时静默跳过。"""
if self.cache:
self.cache.update(meta, info)
def update_recognize_cache(
self,
meta: MetaBase,
mediainfo: MusicInfo,
) -> Optional[bool]:
"""回填音乐本地识别缓存,共享识别成功后避免重复回查。"""
if not meta or not mediainfo:
return None
if not isinstance(meta, MetaMusic) or not isinstance(mediainfo, MusicInfo):
return None
if mediainfo.source != self._source:
return None
self._update_recognize_cache(meta, mediainfo)
return True
async def async_update_recognize_cache(
self,
meta: MetaBase,
mediainfo: MusicInfo,
) -> Optional[bool]:
"""异步回填音乐本地识别缓存。"""
return self.update_recognize_cache(meta=meta, mediainfo=mediainfo)
async def async_recognize_media(
self,

View File

@@ -0,0 +1,234 @@
import pickle
import traceback
from math import ceil
from threading import RLock
from time import time
from typing import Optional
from app.core.cache import FileCache, TTLCache
from app.core.config import settings
from app.core.context import MusicInfo
from app.core.meta import MetaMusic
from app.log import logger
from app.utils.singleton import WeakSingleton
lock = RLock()
PERSISTENCE_VERSION = 1
PERSISTENCE_REGION = "recognize"
PERSISTENCE_KEY = "musicbrainz"
class MusicBrainzCache(metaclass=WeakSingleton):
"""
MusicBrainz识别缓存数据
{
"source": '',
"media_id": '',
"title": '',
"artists": [],
"album": '',
"year": '',
"music_type": ''
}
"""
def __init__(self):
"""初始化音乐识别缓存并恢复未过期的持久化数据。"""
self.maxsize = settings.CONF.musicbrainz
self.ttl = settings.CONF.meta
self.region = "__musicbrainz_cache__"
self._cache = TTLCache(region=self.region, maxsize=self.maxsize, ttl=self.ttl)
self._expires_at: dict[str, float] = {}
self._dirty = False
self._file_cache = None
if not self._cache.is_redis():
self._file_cache = FileCache(base=settings.CACHE_PATH, ttl=self.ttl)
self._restore()
def _restore(self) -> None:
"""从统一文件缓存恢复仍在有效期内的音乐识别数据。"""
try:
content = self._file_cache.get(PERSISTENCE_KEY, region=PERSISTENCE_REGION)
if not content:
return
payload = pickle.loads(content)
now = time()
if (
not isinstance(payload, dict)
or payload.get("version") != PERSISTENCE_VERSION
or not isinstance(payload.get("items"), dict)
):
return
for key, item in payload["items"].items():
if not isinstance(item, dict):
self._dirty = True
continue
value = item.get("value")
expires_at = item.get("expires_at")
if not isinstance(value, dict) or not isinstance(expires_at, (int, float)):
self._dirty = True
continue
remaining_ttl = expires_at - now
if remaining_ttl <= 0:
self._dirty = True
continue
self._cache.set(key, value, ttl=ceil(remaining_ttl))
self._expires_at[key] = expires_at
except Exception as err:
logger.error(f"加载音乐识别缓存失败:{str(err)} - {traceback.format_exc()}")
def _set(self, key: str, value: dict) -> None:
"""写入单条音乐识别缓存并记录其独立过期时间。"""
self._cache.set(key, value)
if not self._cache.is_redis():
self._expires_at[key] = time() + self.ttl
self._dirty = True
def clear(self):
"""
清空所有音乐识别缓存
"""
with lock:
self._cache.clear()
self._expires_at.clear()
self._dirty = True
self.save(force=True)
def list_items(self) -> list[dict]:
"""
返回可供管理界面展示的音乐识别缓存列表。
"""
with lock:
cache_items = []
for key, value in self._cache.items():
if not isinstance(value, dict):
continue
cache_items.append({
"key": key,
"media_id": value.get("media_id") or "",
"title": value.get("title") or "",
"artists": value.get("artists") or [],
"album": value.get("album") or "",
"year": value.get("year") or "",
"music_type": value.get("music_type") or "recording",
"cover_url": value.get("cover_url") or "",
})
return sorted(cache_items, key=lambda item: item["key"])
@staticmethod
def __get_key(meta: MetaMusic) -> str:
"""
获取缓存KEY携带数据源原生 ID 时以 ID 为准身份
"""
artists = "/".join(meta.artists or [])
return f"[音乐]{meta.media_id or meta.title}-{artists}-{meta.album}-{meta.year}"
def get(self, meta: MetaMusic) -> Optional[MusicInfo]:
"""
根据元数据获取缓存的音乐识别结果
@param meta: 音乐元数据
@return: 缓存命中的音乐信息,未命中返回 None
"""
key = self.__get_key(meta)
with lock:
cache_data = self._cache.get(key)
if not cache_data and self._expires_at.pop(key, None) is not None:
self._dirty = True
if not cache_data:
return None
try:
return MusicInfo.from_dict(cache_data)
except Exception as err:
logger.error(f"解析音乐识别缓存失败:{str(err)}")
return None
def delete(self, key: str) -> dict:
"""
删除缓存信息
@param key: 缓存key
@return: 被删除的缓存内容
"""
with lock:
cache_data = self._cache.get(key)
if cache_data:
self._cache.delete(key)
self._expires_at.pop(key, None)
self._dirty = True
self.save(force=True)
return cache_data
return {}
def update(self, meta: MetaMusic, info: Optional[MusicInfo]) -> None:
"""
新增或更新缓存条目,无远端身份的兜底结果也写入内存负缓存,
避免批量识别时反复请求 MusicBrainz 触发限流
"""
if not meta or not info:
return
key = self.__get_key(meta)
cache_data = info.to_dict()
# 上游原始响应体积大且不参与身份恢复,不入缓存
cache_data.pop("raw_data", None)
with lock:
self._set(key, cache_data)
def save(self, force: bool = False) -> None:
"""
使用统一文件缓存保存未过期的音乐识别数据。
"""
if self._cache.is_redis():
return
if not self._file_cache:
return
with lock:
now = time()
cache_items = dict(self._cache.items())
active_keys = set(cache_items)
stale_keys = set(self._expires_at) - active_keys
if stale_keys:
for key in stale_keys:
self._expires_at.pop(key, None)
self._dirty = True
persisted_items = {}
for key, value in cache_items.items():
expires_at = self._expires_at.get(key)
if expires_at is None:
expires_at = now + self.ttl
self._expires_at[key] = expires_at
self._dirty = True
# 负缓存只留在内存,重启后允许重新尝试识别
if expires_at <= now or not value.get("media_id"):
continue
persisted_items[key] = {
"value": value,
"expires_at": expires_at,
}
if not force and not self._dirty:
return
try:
if persisted_items:
payload = {
"version": PERSISTENCE_VERSION,
"items": persisted_items,
}
self._file_cache.set(
PERSISTENCE_KEY,
pickle.dumps(payload, pickle.HIGHEST_PROTOCOL),
region=PERSISTENCE_REGION,
)
else:
self._file_cache.delete(PERSISTENCE_KEY, region=PERSISTENCE_REGION)
self._dirty = False
except Exception as err:
logger.error(f"保存音乐识别缓存失败:{str(err)} - {traceback.format_exc()}")
def __del__(self):
"""实例释放前保存非 Redis 缓存。"""
try:
self.save()
except Exception:
pass

View File

@@ -254,10 +254,15 @@ AniList 榜单、探索、详情、人物和推荐接口优先通过 `anilist-ch
| GET | `/api/v1/tmdb/cache` | 查询 TheMovieDb 识别缓存统计、共享识别累计成功命中次数及开关状态 |
| DELETE | `/api/v1/tmdb/cache/{cache_key}` | 按缓存键删除单条 TheMovieDb 识别缓存,缓存键需要进行 URL 编码 |
| DELETE | `/api/v1/tmdb/cache` | 清空全部 TheMovieDb 识别缓存 |
| GET | `/api/v1/music/cache` | 查询 MusicBrainz 音乐识别缓存统计及条目列表 |
| DELETE | `/api/v1/music/cache/{cache_key}` | 按缓存键删除单条音乐识别缓存,缓存键需要进行 URL 编码 |
| DELETE | `/api/v1/music/cache` | 清空全部音乐识别缓存 |
TMDB 缓存查询响应的 `data` 包含 `count``recognized``unrecognized``data`,以及共享识别统计字段
`shared_recognized` 和开关字段 `shared_recognize_enabled`。共享命中次数仅在共享结果驱动的二次媒体识别成功后累计。
音乐识别缓存查询响应的 `data` 包含 `count``recognized``unrecognized``data`;条目字段包括缓存键、`media_id``title``artists``album``year``music_type``cover_url`。未携带远端身份的兜底负缓存仅保留在内存,不参与持久化。
### 插件补充接口
**GET** `/api/v1/plugin/history/{plugin_id}`

View File

@@ -32,6 +32,14 @@ def _get_search_url(indexer: dict, keyword: str | list[str], mtype: MediaType =
return spider._SiteSpider__get_search_url()
def _get_browse_url(indexer: dict, mtype: MediaType = None, page: int = 0) -> str:
"""
调用 SiteSpider 无关键词浏览的 URL 构造逻辑,模拟订阅刷新抓取首页。
"""
spider = SiteSpider(indexer=indexer, keyword=None, mtype=mtype, page=page)
return spider._SiteSpider__get_search_url()
def _get_haidan_params(keyword: str | None, mtype: MediaType = None) -> dict:
"""
调用 HaiDanSpider 私有参数构造逻辑,避免真实请求站点。
@@ -217,6 +225,64 @@ def test_typed_search_path_falls_back_to_all_path():
assert parsed_url.path == "/torrents.php"
def test_music_browse_uses_dedicated_music_entry():
"""
订阅刷新浏览音乐资源时应使用站点的音乐专用入口,而不是默认首页。
"""
indexer = _build_indexer(
id="hhanclub",
domain="https://hhanclub.net/",
search={
"paths": [
{"path": "torrents.php", "type": "all"},
{"path": "special.php", "type": "music"},
],
"params": {"search": "{keyword}"},
},
)
assert urlparse(_get_browse_url(indexer, MediaType.MUSIC)).path == "/special.php"
# 未指定媒体类型时仍浏览默认首页,保持影视刷新行为不变
assert urlparse(_get_browse_url(indexer)).path == "/torrents.php"
def test_music_browse_overrides_browse_config_path():
"""
同时配置 browse 路径和音乐专用路径时,音乐浏览应优先使用专用入口。
"""
indexer = _build_indexer(
search={
"paths": [
{"path": "torrents.php", "type": "all"},
{"path": "music.php", "type": "music"},
],
"params": {"search": "{keyword}"},
},
browse={"path": "browse.php"},
)
assert urlparse(_get_browse_url(indexer, MediaType.MUSIC)).path == "/music.php"
assert urlparse(_get_browse_url(indexer)).path == "/browse.php"
def test_browse_pagination_appends_existing_query_string():
"""
浏览路径自带查询参数时翻页应追加 & 连接符,不能拼出两个问号。
"""
indexer = _build_indexer(
search={
"paths": [{"path": "torrents.php?action=advanced&searchstr={keyword}"}],
},
)
browse_url = _get_browse_url(indexer, page=1)
assert browse_url.count("?") == 1
query = parse_qs(urlparse(browse_url).query)
assert query["page"] == ["1"]
assert query["action"] == ["advanced"]
def test_category_item_can_use_distinct_search_parameter_value():
"""
DiscuzX 子分类可以用展示分类 ID 解析结果,同时用父分类值构造搜索参数。

View File

@@ -0,0 +1,436 @@
"""MusicBrainz 音乐识别本地持久化缓存测试。
覆盖缓存键生成、读写回环、负缓存、持久化恢复与保存、管理端点
统计与权限,以及 MusicBrainzModule 识别流程对缓存的命中与回填。
"""
import asyncio
import inspect
import pickle
from unittest.mock import Mock
from app.api.endpoints import music as music_endpoint
from app.core.context import MusicInfo
from app.core.meta import MetaMusic
from app.db.user_oper import get_current_active_superuser_async
from app.modules.musicbrainz import music_cache as music_cache_module
from app.modules.musicbrainz import MusicBrainzModule
from app.modules.musicbrainz.music_cache import MusicBrainzCache
class _MemoryCacheStub:
"""提供音乐缓存管理测试所需的最小内存后端。"""
def __init__(self, data: dict):
"""使用给定字典初始化测试缓存。"""
self.data = data
@staticmethod
def is_redis() -> bool:
"""测试替身固定使用非 Redis 后端。"""
return False
def items(self):
"""返回全部缓存条目。"""
return self.data.items()
def get(self, key: str):
"""读取指定缓存条目。"""
return self.data.get(key)
def delete(self, key: str):
"""删除指定缓存条目。"""
self.data.pop(key, None)
def set(self, key: str, value, ttl=None):
"""写入指定缓存条目。"""
self.data[key] = value
def clear(self):
"""清空全部缓存条目。"""
self.data.clear()
class _TTLCacheStub(_MemoryCacheStub):
"""记录每条数据恢复时剩余 TTL 的内存缓存替身。"""
def __init__(self):
"""初始化空缓存和 TTL 记录。"""
super().__init__({})
self.ttls = {}
def set(self, key: str, value, ttl=None):
"""写入缓存并记录本次设置的 TTL。"""
super().set(key, value, ttl=ttl)
self.ttls[key] = ttl
class _FileCacheStub:
"""提供音乐缓存持久化测试所需的统一文件缓存替身。"""
def __init__(self, content: bytes = None):
"""使用预置序列化内容初始化文件缓存。"""
self.content = content
self.set_calls = []
self.delete_calls = []
def get(self, key: str, region: str):
"""读取预置缓存内容。"""
return self.content
def set(self, key: str, value: bytes, region: str):
"""记录统一文件缓存写入。"""
self.content = value
self.set_calls.append((key, region))
def delete(self, key: str, region: str):
"""记录统一文件缓存删除。"""
self.content = None
self.delete_calls.append((key, region))
def _build_music_cache(data: dict) -> MusicBrainzCache:
"""构造绕过单例初始化的音乐识别缓存测试实例。"""
cache = object.__new__(MusicBrainzCache)
cache.maxsize = 256
cache.ttl = 3600
cache.region = "__musicbrainz_cache__"
cache._cache = _MemoryCacheStub(data)
cache._expires_at = {key: float("inf") for key in data}
cache._dirty = False
cache._file_cache = None
cache.save = lambda force=False: None
return cache
def _build_initialized_music_cache(monkeypatch, file_cache: _FileCacheStub,
runtime_cache: _TTLCacheStub,
now: float = 1000) -> MusicBrainzCache:
"""使用可控时间和缓存替身初始化完整音乐识别缓存实例。"""
monkeypatch.setattr(music_cache_module, "time", lambda: now)
monkeypatch.setattr(music_cache_module, "TTLCache", lambda **kwargs: runtime_cache)
monkeypatch.setattr(music_cache_module, "FileCache", lambda **kwargs: file_cache)
cache = object.__new__(MusicBrainzCache)
cache.__init__()
return cache
def _music_info(**kwargs) -> MusicInfo:
"""构造标准音乐识别结果,默认携带远端身份。"""
defaults = {
"source": "musicbrainz",
"media_id": "rec-1",
"title": "晴天",
"artists": ["周杰伦"],
"album": "叶惠美",
"year": 2003,
}
defaults.update(kwargs)
return MusicInfo(**defaults)
def test_music_cache_endpoints_require_superuser():
"""音乐识别缓存管理接口必须仅允许超级管理员访问。"""
endpoints = [
music_endpoint.music_recognition_cache,
music_endpoint.delete_music_recognition_cache,
music_endpoint.clear_music_recognition_cache,
]
for endpoint in endpoints:
dependency = inspect.signature(endpoint).parameters["_"].default.dependency
assert dependency is get_current_active_superuser_async
def test_music_cache_key_prefers_media_id():
"""携带数据源原生 ID 的元数据应以 ID 作为缓存键主身份。"""
cache = _build_music_cache({})
meta = MetaMusic(title="晴天", artists=["周杰伦"], media_id="rec-1")
cache.update(meta, _music_info())
# 缓存键取自请求元数据,携带原生 ID 时以 ID 为主身份
assert list(cache._cache.data.keys()) == ["[音乐]rec-1-周杰伦-None-None"]
def test_music_cache_update_and_get_roundtrip():
"""识别结果入缓存后应能还原出标准音乐信息,且不保存上游原始响应。"""
cache = _build_music_cache({})
meta = MetaMusic(title="晴天", artists=["周杰伦"], album="叶惠美", year=2003)
info = _music_info(raw_data={"payload": "large"})
cache.update(meta, info)
hit = cache.get(meta)
assert hit is not None
assert hit.media_id == "rec-1"
assert hit.title == "晴天"
assert hit.artists == ["周杰伦"]
stored = next(iter(cache._cache.data.values()))
assert "raw_data" not in stored
def test_music_cache_get_miss_returns_none():
"""未命中的缓存查询应返回 None 而不是抛错。"""
cache = _build_music_cache({})
meta = MetaMusic(title="未知曲目")
assert cache.get(meta) is None
def test_music_cache_list_items_normalizes_and_sorts():
"""管理列表应输出稳定顺序和前端展示所需字段。"""
cache = _build_music_cache({
"z-key": {
"media_id": "rec-2",
"title": "Zulu",
"artists": ["歌手B"],
"album": "专辑B",
"year": 2024,
"music_type": "recording",
},
"a-key": {
"media_id": "",
"title": "Alpha",
"year": 2023,
},
})
items = cache.list_items()
assert [item["key"] for item in items] == ["a-key", "z-key"]
assert items[0]["media_id"] == ""
assert items[0]["artists"] == []
assert items[0]["music_type"] == "recording"
assert items[1]["artists"] == ["歌手B"]
def test_music_cache_delete_and_clear_persist_immediately(monkeypatch):
"""管理操作应修改运行时缓存并立即触发本地持久化。"""
cache = _build_music_cache({"first": {"media_id": "rec-1"}, "second": {"media_id": "rec-2"}})
saved_forces = []
monkeypatch.setattr(cache, "save", lambda force=False: saved_forces.append(force))
assert cache.delete("first") == {"media_id": "rec-1"}
assert cache.delete("missing") == {}
cache.clear()
assert cache.list_items() == []
assert saved_forces == [True, True]
def test_music_cache_restores_only_unexpired_persisted_items(monkeypatch):
"""持久化恢复应保留每条数据原有期限并跳过已过期条目。"""
payload = {
"version": music_cache_module.PERSISTENCE_VERSION,
"items": {
"fresh": {
"value": {"media_id": "rec-1", "title": "有效"},
"expires_at": 1030,
},
"expired": {
"value": {"media_id": "rec-2", "title": "过期"},
"expires_at": 999,
},
},
}
file_cache = _FileCacheStub(pickle.dumps(payload))
runtime_cache = _TTLCacheStub()
cache = _build_initialized_music_cache(
monkeypatch=monkeypatch,
file_cache=file_cache,
runtime_cache=runtime_cache,
)
assert runtime_cache.data == {"fresh": {"media_id": "rec-1", "title": "有效"}}
assert runtime_cache.ttls == {"fresh": 30}
assert cache._expires_at == {"fresh": 1030}
def test_music_cache_persists_only_items_with_media_id(monkeypatch):
"""持久化应跳过未识别的负缓存条目,只保存携带远端身份的结果。"""
file_cache = _FileCacheStub()
runtime_cache = _TTLCacheStub()
cache = _build_initialized_music_cache(
monkeypatch=monkeypatch,
file_cache=file_cache,
runtime_cache=runtime_cache,
)
runtime_cache.data = {
"recognized": {"media_id": "rec-1", "title": "晴天"},
"negative": {"media_id": "", "title": "未知曲目"},
}
cache._expires_at = {
"recognized": 1060,
"negative": 1070,
}
cache._dirty = True
cache.save()
payload = pickle.loads(file_cache.content)
assert file_cache.set_calls == [(
music_cache_module.PERSISTENCE_KEY,
music_cache_module.PERSISTENCE_REGION,
)]
assert payload == {
"version": music_cache_module.PERSISTENCE_VERSION,
"items": {
"recognized": {
"value": {"media_id": "rec-1", "title": "晴天"},
"expires_at": 1060,
},
},
}
def test_music_cache_save_removes_file_when_empty(monkeypatch):
"""全部条目失效后保存应删除持久化文件。"""
file_cache = _FileCacheStub(pickle.dumps({"version": 1, "items": {}}))
runtime_cache = _TTLCacheStub()
cache = _build_initialized_music_cache(
monkeypatch=monkeypatch,
file_cache=file_cache,
runtime_cache=runtime_cache,
)
cache._dirty = True
cache.save()
assert file_cache.delete_calls == [(
music_cache_module.PERSISTENCE_KEY,
music_cache_module.PERSISTENCE_REGION,
)]
def test_music_cache_endpoint_returns_management_statistics(monkeypatch):
"""查询接口应返回识别成功和失败条目的统计。"""
cache = _build_music_cache({
"recognized": {"media_id": "rec-1", "title": "晴天"},
"unrecognized": {"media_id": "", "title": "未知曲目"},
})
monkeypatch.setattr(music_endpoint, "MusicBrainzCache", lambda: cache)
response = asyncio.run(music_endpoint.music_recognition_cache(None))
assert response.success is True
assert response.data["count"] == 2
assert response.data["recognized"] == 1
assert response.data["unrecognized"] == 1
assert len(response.data["data"]) == 2
def test_music_cache_delete_endpoint_reports_missing_item(monkeypatch):
"""删除接口应区分成功删除与缓存不存在。"""
cache = _build_music_cache({"existing": {"media_id": "rec-1"}})
monkeypatch.setattr(music_endpoint, "MusicBrainzCache", lambda: cache)
deleted_response = asyncio.run(
music_endpoint.delete_music_recognition_cache("existing", None)
)
missing_response = asyncio.run(
music_endpoint.delete_music_recognition_cache("missing", None)
)
assert deleted_response.success is True
assert missing_response.success is False
def test_music_cache_clear_endpoint_removes_all_items(monkeypatch):
"""清空接口应删除全部音乐识别缓存。"""
cache = _build_music_cache({"existing": {"media_id": "rec-1"}})
monkeypatch.setattr(music_endpoint, "MusicBrainzCache", lambda: cache)
response = asyncio.run(music_endpoint.clear_music_recognition_cache(None))
assert response.success is True
assert cache.list_items() == []
def _build_module_with_cache(cache: MusicBrainzCache) -> MusicBrainzModule:
"""构造挂载测试缓存的 MusicBrainz 模块实例。"""
module = MusicBrainzModule()
module.cache = cache
return module
def test_module_recognize_media_hits_cache_without_search(monkeypatch):
"""识别缓存命中时应直接返回缓存结果,不再触发 MusicBrainz 搜索。"""
cache = _build_music_cache({})
module = _build_module_with_cache(cache)
meta = MetaMusic(title="晴天", artists=["周杰伦"], album="叶惠美", year=2003)
cache.update(meta, _music_info())
search_mock = Mock()
monkeypatch.setattr(module, "_search_recordings", search_mock)
result = module.recognize_media(meta=meta)
assert result is not None
assert result.media_id == "rec-1"
assert getattr(result, "recognize_cache_hit") is True
search_mock.assert_not_called()
def test_module_recognize_media_bypasses_cache_when_disabled(monkeypatch):
"""cache=False 时不读取缓存,重新走搜索识别流程。"""
cache = _build_music_cache({})
module = _build_module_with_cache(cache)
meta = MetaMusic(title="晴天", artists=["周杰伦"], album="叶惠美", year=2003)
cache.update(meta, _music_info())
fresh = _music_info(media_id="rec-2")
monkeypatch.setattr(module, "_search_recordings", Mock(return_value=[fresh]))
monkeypatch.setattr(module, "_select_candidate", Mock(return_value=fresh))
result = module.recognize_media(meta=meta, cache=False)
assert result is fresh
assert getattr(result, "recognize_cache_hit", False) is False
def test_module_recognize_media_writes_search_result_to_cache(monkeypatch):
"""搜索识别成功后应回填本地识别缓存。"""
cache = _build_music_cache({})
module = _build_module_with_cache(cache)
meta = MetaMusic(title="晴天", artists=["周杰伦"], album="叶惠美", year=2003)
matched = _music_info()
monkeypatch.setattr(module, "_search_recordings", Mock(return_value=[matched]))
monkeypatch.setattr(module, "_select_candidate", Mock(return_value=matched))
result = module.recognize_media(meta=meta)
assert result is matched
hit = cache.get(meta)
assert hit is not None
assert hit.media_id == "rec-1"
def test_module_recognize_media_caches_offline_fallback(monkeypatch):
"""搜索无结果的兜底信息也应进入负缓存,避免重复请求。"""
cache = _build_music_cache({})
module = _build_module_with_cache(cache)
meta = MetaMusic(title="未知曲目", artists=["未知艺术家"])
monkeypatch.setattr(module, "_search_recordings", Mock(return_value=[]))
monkeypatch.setattr(module, "_search_albums", Mock(return_value=[]))
result = module.recognize_media(meta=meta)
assert result is not None
assert result.media_id is None
cached = cache.get(meta)
assert cached is not None
assert cached.media_id is None
assert cached.title == "未知曲目"
def test_module_update_recognize_cache_only_for_musicbrainz_music():
"""共享识别回填仅处理本数据源的音乐结果。"""
cache = _build_music_cache({})
module = _build_module_with_cache(cache)
meta = MetaMusic(title="晴天", artists=["周杰伦"])
assert module.update_recognize_cache(meta=meta, mediainfo=_music_info()) is True
assert cache.get(meta) is not None
other_source = _music_info(source="other", media_id="x-1")
assert module.update_recognize_cache(meta=meta, mediainfo=other_source) is None
assert module.update_recognize_cache(meta=None, mediainfo=_music_info()) is None

View File

@@ -593,3 +593,21 @@ def test_follow_preserves_album_entity_and_track_count():
assert subscribe_oper.exist_history.call_args.kwargs["music_type"] == MUSIC_ENTITY_ALBUM
assert add.call_args.kwargs["music_type"] == MUSIC_ENTITY_ALBUM
assert add.call_args.kwargs["total_tracks"] == 11
def test_refresh_enables_music_entry_fetch_when_music_subscribe_exists():
"""存在音乐订阅时,订阅刷新应要求种子链额外抓取站点音乐专用入口。"""
chain = SubscribeChain()
subscribe_oper = Mock()
# get_subscribed_sites 不带状态查询has_music_subscribe 按可搜索状态查询
subscribe_oper.list.side_effect = lambda state=None: [_subscribe(state="R")]
torrents_chain = Mock()
torrents_chain.refresh.return_value = {}
with patch("app.chain.subscribe.SubscribeOper", return_value=subscribe_oper), \
patch("app.chain.subscribe.SystemConfigOper") as system_config, \
patch("app.chain.subscribe.TorrentsChain", return_value=torrents_chain):
system_config.return_value.get.return_value = []
chain.refresh()
assert torrents_chain.refresh.call_args.kwargs["include_music"] is True

View File

@@ -1,9 +1,12 @@
import asyncio
import copy
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
from app.chain.torrents import TorrentsChain
from app.core.meta import MetaMusic
from app.core.context import MusicInfo
from app.core.context import Context, MusicInfo, TorrentInfo
from app.modules.indexer.spider import SiteSpider
from app.schemas.types import MediaType
@@ -67,3 +70,274 @@ def test_music_cache_context_uses_music_models():
assert context.media_info.title == "Get Lucky"
assert context.candidate_recognized is False
media_chain.assert_not_called()
def test_rss_sets_music_category_from_site_media_type():
"""RSS 报文不带分类,音乐站点的种子应按站点媒体类型补充音乐分类。"""
chain = TorrentsChain()
site = {
"id": 9,
"name": "音乐站",
"domain": "https://music.example.com",
"media_type": "music",
"rss": "https://music.example.com/rss.php?passkey=key",
"proxy": False,
"timeout": 30,
"ua": None,
}
rss_items = [{
"title": "Daft Punk - Random Access Memories [FLAC]",
"enclosure": "https://music.example.com/download.php?id=1",
"link": "https://music.example.com/details.php?id=1",
"size": 1024,
"pubdate": None,
}]
sites_helper = Mock()
sites_helper.get_indexer.return_value = site
with (
patch("app.chain.torrents.SitesHelper", return_value=sites_helper),
patch("app.chain.torrents.RssHelper") as rss_helper,
):
rss_helper.return_value.parse.return_value = rss_items
torrents = chain.rss("music.example.com")
assert len(torrents) == 1
assert torrents[0].category == MediaType.MUSIC.value
def test_music_browse_paths_detects_dedicated_entry():
"""站点用 type=music 声明的独立音乐入口应被识别,默认入口已覆盖时不重复抓取。"""
mixed_site = {
"search": {
"paths": [
{"path": "torrents.php", "type": "all"},
{"path": "special.php", "type": "music"},
]
}
}
assert TorrentsChain._music_browse_paths(mixed_site) == ["special.php"]
# 音乐站点全站都是音乐,无需额外入口
music_site = {"media_type": "music", "search": {"paths": [
{"path": "torrents.php", "type": "all"},
{"path": "torrents.php", "type": "music"},
]}}
assert TorrentsChain._music_browse_paths(music_site) == []
# 音乐入口与默认入口相同无需重复抓取
same_entry_site = {"search": {"paths": [
{"path": "torrents.php", "type": "all"},
{"path": "torrents.php", "type": "music"},
]}}
assert TorrentsChain._music_browse_paths(same_entry_site) == []
def test_spider_music_entry_browse_resolves_music_category():
"""音乐入口浏览应请求专用页面,并把音乐分类种子解析为音乐类型。"""
indexer = {
"id": "hhanclub",
"name": "憨憨",
"domain": "https://hhanclub.net/",
"search": {
"paths": [
{"path": "torrents.php", "type": "all"},
{"path": "special.php", "type": "music"},
],
"params": {"search": "{keyword}"},
},
"category": {
"param": "cat",
"movie": [{"id": "401", "cat": "Movies"}],
"music": [{"id": "410", "cat": "Music"}],
},
"torrents": {
"list": {"selector": "table tr.torrent"},
"fields": {
"title": {"selector": "a.t"},
"category": {
"selector": "a.c",
"attribute": "href",
"filters": [{"name": "querystring", "args": "cat"}],
},
"download": {"selector": "a.d", "attribute": "href"},
},
},
}
html = """
<table>
<tr class="torrent">
<td><a class="c" href="torrents.php?cat=410">Music</a></td>
<td><a class="t">Daft Punk - Random Access Memories [FLAC]</a></td>
<td><a class="d" href="download.php?id=9">下载</a></td>
</tr>
</table>
"""
request_utils = Mock()
request_utils.return_value.get_res.return_value = Mock()
request_utils.get_decoded_html_content.return_value = html
with (
patch("app.modules.indexer.spider.RequestUtils", request_utils),
patch(
"app.modules.indexer.spider.rust_accel.parse_indexer_torrents",
return_value=None,
),
):
torrents = SiteSpider(indexer=indexer, mtype=MediaType.MUSIC).get_torrents()
# 请求必须命中音乐专用入口而不是默认首页
requested_url = request_utils.return_value.get_res.call_args[0][0]
assert "/special.php" in requested_url
assert len(torrents) == 1
assert torrents[0]["category"] == MediaType.MUSIC.value
assert torrents[0]["title"] == "Daft Punk - Random Access Memories [FLAC]"
def test_refresh_include_music_fetches_music_entry():
"""存在音乐订阅时spider 刷新应额外抓取音乐专用入口,并把音乐写入独立缓存。"""
chain = TorrentsChain()
default_torrent = TorrentInfo(
site=1, site_name="Test",
title="Some.Movie.2026.1080p",
enclosure="https://example.com/download?id=1",
category=MediaType.MOVIE.value,
pubdate="2026-08-07 00:00:00",
)
music_torrent = TorrentInfo(
site=1, site_name="Test",
title="Daft Punk - Get Lucky [FLAC]",
enclosure="https://example.com/download?id=2",
category=MediaType.MUSIC.value,
pubdate="2026-08-08 00:00:00",
)
duplicated_torrent = TorrentInfo(
site=1, site_name="Test",
title="Daft Punk - Get Lucky [FLAC]",
enclosure="https://example.com/download?id=2",
category=MediaType.MUSIC.value,
pubdate="2026-08-08 00:00:00",
)
def _fake_browse(domain, keyword=None, cat=None, page=None, mtype=None):
if mtype == MediaType.MUSIC:
# 第二页返回空,验证分页提前结束;首页返回音乐种子
return [] if page else [music_torrent, duplicated_torrent]
return [default_torrent] if not page else []
sites_helper = Mock()
sites_helper.get_indexers.return_value = [{
"id": 1,
"name": "Test",
"domain": "https://example.com",
"search": {
"paths": [
{"path": "torrents.php", "type": "all"},
{"path": "special.php", "type": "music"},
]
},
}]
# 保存时对入参做快照,避免后续合并返回值修改同一字典引用
saved = {}
save_cache = Mock(side_effect=lambda data, filename: saved.__setitem__(filename, copy.deepcopy(data)))
with (
patch.object(chain, "load_cache", return_value=None),
patch.object(chain, "browse", side_effect=_fake_browse),
patch.object(chain, "save_cache", save_cache),
patch("app.chain.torrents.SitesHelper", return_value=sites_helper),
patch("app.chain.torrents.MediaChain"),
):
result = chain.refresh(stype="spider", sites=[1], include_music=True)
# 返回值供订阅匹配,包含影视与音乐完整候选且去重
contexts = result["example.com"]
titles = {context.torrent_info.title for context in contexts}
assert titles == {"Some.Movie.2026.1080p", "Daft Punk - Get Lucky [FLAC]"}
music_context = next(
context for context in contexts
if context.torrent_info.title.startswith("Daft Punk")
)
assert isinstance(music_context.meta_info, MetaMusic)
assert isinstance(music_context.media_info, MusicInfo)
# 音乐不应经过影视识别链的媒体回填
assert music_context.candidate_recognized is False
# 影视与音乐分别写入各自缓存文件,音乐不占用影视缓存空间
assert set(saved) == {TorrentsChain._spider_file, TorrentsChain._music_spider_file}
video_titles = {
context.torrent_info.title for context in saved[TorrentsChain._spider_file]["example.com"]
}
music_titles = {
context.torrent_info.title for context in saved[TorrentsChain._music_spider_file]["example.com"]
}
assert video_titles == {"Some.Movie.2026.1080p"}
assert music_titles == {"Daft Punk - Get Lucky [FLAC]"}
def test_music_cache_not_evicted_by_video_torrents():
"""影视缓存按配额裁剪时,音乐独立缓存中的资源不应被挤出。"""
chain = TorrentsChain()
def _music_context(title, enclosure, pubdate):
torrent = TorrentInfo(
site=1, site_name="Test", title=title, enclosure=enclosure,
category=MediaType.MUSIC.value, pubdate=pubdate,
)
return Context(
meta_info=MetaMusic(org_string=title, title=title),
media_info=MusicInfo(title=title),
torrent_info=torrent,
)
existing_music = [
_music_context(f"Album {i}", f"https://example.com/download?id=m{i}",
f"2026-08-0{i + 1} 00:00:00")
for i in range(2)
]
video_torrents = [
TorrentInfo(
site=1, site_name="Test", title=f"Movie.{i}.1080p",
enclosure=f"https://example.com/download?id=v{i}",
category=MediaType.MOVIE.value, pubdate=f"2026-08-1{i} 00:00:00",
)
for i in range(3)
]
def _fake_load(filename):
if filename == TorrentsChain._music_spider_file:
return {"example.com": existing_music}
return None
def _fake_browse(domain, keyword=None, cat=None, page=None, mtype=None):
return video_torrents if not page else []
sites_helper = Mock()
sites_helper.get_indexers.return_value = [{
"id": 1, "name": "Test", "domain": "https://example.com",
}]
saved = {}
save_cache = Mock(side_effect=lambda data, filename: saved.__setitem__(filename, copy.deepcopy(data)))
fake_settings = Mock()
# 公共参数:缓存上限 2刷新配额 5音乐与影视各自独立计算
fake_settings.CONF = SimpleNamespace(torrents=2, refresh=5)
fake_settings.NO_CACHE_SITE_KEY = "no-cache-site.invalid"
with (
patch("app.chain.torrents.settings", fake_settings),
patch.object(chain, "load_cache", side_effect=_fake_load),
patch.object(chain, "browse", side_effect=_fake_browse),
patch.object(chain, "save_cache", save_cache),
patch("app.chain.torrents.SitesHelper", return_value=sites_helper),
patch("app.chain.torrents.MediaChain"),
):
chain.refresh(stype="spider", sites=[1])
# 影视缓存独立按配额裁剪,仅保留最新的两条
assert len(saved[TorrentsChain._spider_file]["example.com"]) == 2
# 音乐独立缓存不受影视种子大量涌入影响,既有的两条音乐完整保留
music_titles = {
context.torrent_info.title
for context in saved[TorrentsChain._music_spider_file]["example.com"]
}
assert music_titles == {"Album 0", "Album 1"}