1
0
mirror of https://github.com/hanxi/xiaomusic.git synced 2026-06-03 12:25:45 +08:00

feat: 后端增加缓存功能。默认缓存大小500M,当前歌曲20秒开始缓存下一首(支持随机播放的缓存命中) (#878)

* 后端增加缓存功能。默认缓存大小500M,当前歌曲20秒开始缓存下一首(支持随机播放的缓存命中)

* 后端增加缓存功能。默认缓存大小500M,当前歌曲20秒开始缓存下一首(支持随机播放的缓存命中)

* 后端增加缓存功能。默认缓存大小500M,当前歌曲20秒开始缓存下一首(支持随机播放的缓存命中)

* 后端增加缓存功能。默认缓存大小500M,当前歌曲20秒开始缓存下一首(支持随机播放的缓存命中)

* 把onlineSearch主题生成的歌单,UI统一搜歌搜人搜单
This commit is contained in:
birdstudy-nj
2026-05-20 15:38:56 +08:00
committed by GitHub
parent 014749dc27
commit cc01663599
10 changed files with 748 additions and 154 deletions

View File

@@ -133,16 +133,20 @@ async def get_plugin_source_url(
)
if media_source and media_source.get("url"):
source_url = media_source.get("url")
log.info(f"plugin-url 成功解析: {json_data} -> {source_url}")
return RedirectResponse(url=source_url)
else:
source_url = xiaomusic.default_url()
log.info(f"plugin-url {json_data} {source_url}")
# 直接重定向到真实URL
return RedirectResponse(url=source_url)
# 没有有效链接时,直接抛出 404 错误!
log.warning(f"plugin-url 解析失败(链接为空): {json_data}")
raise HTTPException(status_code=404, detail="获取真实音频链接为空")
except HTTPException:
# 允许 HTTPException 继续向上传递,确保 404 能被前线捕获
raise
except Exception as e:
log.error(f"获取真实音乐URL失败: {e}")
# 如果代理获取失败重定向到原始URL
source_url = xiaomusic.default_url()
return RedirectResponse(url=source_url)
# 发生其他未知异常时,同样抛出错误
raise HTTPException(status_code=404, detail=str(e)) from e
@router.post("/api/play/getMediaSource")

View File

@@ -96,6 +96,7 @@ class Config:
download_path: str = os.getenv("XIAOMUSIC_DOWNLOAD_PATH", "music/download")
conf_path: str = os.getenv("XIAOMUSIC_CONF_PATH", "conf")
cache_dir: str = os.getenv("XIAOMUSIC_CACHE_DIR", "music/cache")
cache_max_size_mb: int = int(os.getenv("XIAOMUSIC_CACHE_MAX_SIZE_MB", "500"))
hostname: str = os.getenv("XIAOMUSIC_HOSTNAME", "http://192.168.2.5")
port: int = int(os.getenv("XIAOMUSIC_PORT", "8090")) # 监听端口
public_port: int = int(os.getenv("XIAOMUSIC_PUBLIC_PORT", 58090)) # 歌曲访问端口

View File

@@ -70,7 +70,6 @@ class XiaoMusicDevice:
self._duration = 0
self._paused_time = 0
self._play_failed_cnt = 0
self._url_failed_cnt = 0 # 新增URL探路失败专属计数器
self._play_list = []
@@ -85,6 +84,8 @@ class XiaoMusicDevice:
self._add_song_timer = None
# TTS 播放定时器
self._tts_timer = None
# 用于预缓存下一首的定时器
self._prefetch_timer = None
@property
def did(self):
@@ -167,31 +168,83 @@ class XiaoMusicDevice:
"""播放音乐(外部接口)"""
return await self._playmusic(name)
def update_playlist(self):
"""初始化/更新播放列表"""
# 没有重置 list 且非初始化
def update_playlist(self, force_reshuffle=False):
"""
初始化或更新播放列表。
【核心架构特点】:
1. 状态保持 (Stateful Shuffle):随机模式下,生成一次乱序列表后永久保持,避免反复洗牌导致预缓存断链和歌曲无限循环。
2. 洗牌置顶 (Pin-to-Top):当发生全量重洗时,将当前正在播放的歌曲强行“钉”在列表最顶端(index 0),完美闭环预缓存机制。
3. 增量更新 (Incremental Update):歌单发生变化(如自动追加了歌手新歌)时,不打乱原有播放顺序,仅将新歌洗牌后追加到队尾。
Args:
force_reshuffle (bool): 是否强制彻底重新洗牌(用于切换模式、切换歌单、或一轮播放触底时)
"""
# 1. 兜底保护:如果没有重置 list 且当前歌单在系统里不存在,默认切到"全部"
if self.device.cur_playlist not in self.xiaomusic.music_library.music_list:
self.device.cur_playlist = "全部"
list_name = self.device.cur_playlist
self._play_list = copy.copy(self.xiaomusic.music_library.music_list[list_name])
# 获取大管家Library里最新鲜的歌单数据
latest_list = self.xiaomusic.music_library.music_list[list_name]
# ==========================================
# 随机播放模式 (PLAY_TYPE_RND) 的调度
# ==========================================
if self.device.play_type == PLAY_TYPE_RND:
random.shuffle(self._play_list)
self.log.info(
f"随机打乱 {list_name} {list2str(self._play_list, self.config.verbose)}"
)
# 判断是否需要【全量重洗牌】的三个条件:
# A. 外部明确要求强洗 (force_reshuffle=True)
# B. 当前播放列表是空的 (系统刚启动)
# C. 当前播放列表和最新的歌单毫无交集 (说明用户切了全新的歌单)
if (
force_reshuffle
or not self._play_list
or not set(self._play_list).intersection(set(latest_list))
):
self._play_list = copy.copy(latest_list)
random.shuffle(self._play_list)
# 2洗牌置顶 (Pin-to-Top)
# 防止洗牌后当前歌曲位置丢失,导致下一首乱跳和预缓存错位
cur_music = self.get_cur_music()
if cur_music and cur_music in self._play_list:
self._play_list.remove(cur_music)
self._play_list.insert(0, cur_music)
self.log.info(f"彻底重新洗牌 {list_name},并将当前歌曲置顶")
# 【增量更新牌库】
else:
# 3增量更新 (Incremental Update)
old_list = self._play_list
# A. 剔除云端已经被删除的歌,保留依然存在的歌(绝对不改变它们的相对顺序!)
self._play_list = [s for s in old_list if s in latest_list]
# B. 找出最新歌单里多出来的新歌(比如 auto_add_song 追加进来的)
new_songs = [s for s in latest_list if s not in old_list]
if new_songs:
# 把新来的歌单独洗乱,然后悄悄垫在牌堆的最底下
random.shuffle(new_songs)
self._play_list.extend(new_songs)
self.log.info(
f"歌单有更新,保持原顺序并追加了 {len(new_songs)} 首新歌"
)
# ==========================================
# 顺序/循环模式的处理
# ==========================================
else:
self._play_list = copy.copy(latest_list)
is_online = self.xiaomusic.music_library.is_online_music(list_name)
# 如果是本地目录歌单,且列表都是纯字符串,执行本地特定的字母自然排序
if not is_online and len(self._play_list) > 0:
has_non_str_item = any(
not isinstance(item, str) for item in self._play_list
)
if not has_non_str_item:
self._play_list.sort(key=custom_sort_key)
self.log.info(
f"没打乱 {list_name} {list2str(self._play_list, self.config.verbose)}"
)
self.log.info(f"顺序模式更新,不打乱 {list_name}")
async def play(self, name="", search_key=""):
"""播放歌曲(外部接口)"""
@@ -362,6 +415,36 @@ class XiaoMusicDevice:
self._last_cmd = "playlocal"
return await self._play_internal(name=name, search_key="", allow_download=False)
async def prefetch_next_song(self, sleep_sec):
"""延时后台预加载(缓存)下一首歌曲"""
if self._prefetch_timer:
self._prefetch_timer.cancel()
async def _do_prefetch():
try:
await asyncio.sleep(sleep_sec)
# 拿下一首歌的名字
next_music = self.get_next_music()
if not next_music:
return
# 如果是网络音乐,触发预下载
if self.xiaomusic.music_library.is_web_music(next_music):
self.log.info(f"开始后台预先缓存下一首: {next_music}")
cur_playlist = self.device.cur_playlist
# 巧妙利用我们重构过的时长函数,底层会自动走:没缓存 -> 去下载 -> 存硬盘 的闭环
await self.xiaomusic.music_library.get_music_duration(
next_music, cur_playlist
)
self.log.info(f"后台预先缓存完成: {next_music}")
except asyncio.CancelledError:
pass
except Exception as e:
self.log.error(f"预加载下一首歌曲失败: {e}")
self._prefetch_timer = asyncio.create_task(_do_prefetch())
async def _playmusic(self, name):
"""播放音乐的核心实现"""
# 取消组内所有的下一首歌曲的定时器
@@ -373,91 +456,130 @@ class XiaoMusicDevice:
cur_playlist = self.device.cur_playlist
self.log.info(f"cur_music {self.get_cur_music()}")
# 把 cur_playlist 传过去,要求拿该歌单下的专属 URL
# 获取该歌单下的播放 URL
url, _ = await self.xiaomusic.music_library.get_music_url(name, cur_playlist)
# 代理 URL 极速探路与 5 次死链熔断
if url and url.startswith("http") and "/proxy/" in url:
# 1. 命中硬盘级负向缓存墓碑url为空的秒切拦截
if not url:
self._play_failed_cnt = getattr(self, "_play_failed_cnt", 0) + 1
self.log.warning(
f"{name}】命中了死链墓碑标记,立刻拦截跳过!连续失败次数: {self._play_failed_cnt}"
)
if self._play_failed_cnt >= 5:
self.log.error("连续获取歌曲失败达到5次触发系统第一层熔断保护")
self._play_failed_cnt = 0
await self.xiaomusic.handle_fatal_error(
self.did, "连续多次获取歌曲失败,已为您停止播放。"
)
else:
await self.set_next_music_timeout(0.5)
return
# 2. 统一系统提示音/TTS 的白名单免探路、免墓碑机制
is_system_or_tts = (
"/music/tmp/" in url or "silence.mp3" in url or "xiaomusic_" in url
)
# 3. 极速探路器:帮小爱吃下所有的 404/401 炸弹
if not is_system_or_tts and url and url.startswith("http") and "/proxy/" in url:
self.log.info(f"极速探路启动,触发后端代理解析: {url}")
is_url_ok = False
try:
# 给了 3.0 秒,因为触发 JS 插件去目标平台请求真实 URL 需要一点时间
# 给了 3.0 秒超时,让本地解析服务有足够时间反应
timeout = aiohttp.ClientTimeout(total=3.0)
async with aiohttp.ClientSession(timeout=timeout) as session:
# 只要拿到 200/206 状态码,瞬间挂断不下载!
async with session.get(url) as resp:
# 200是正常206是部分内容(也是正常)
# 如果 music.py 报了 404在这里会直接被抓个正着
if resp.status in (200, 206):
is_url_ok = True
self.log.info(
f"探路成功!代理服务器存活,状态码: {resp.status}"
)
self.log.info(f"探路成功!接口畅通,状态码: {resp.status}")
else:
self.log.warning(
f"探路发现死链!代理服务器报错,状态码: {resp.status}"
f"探路发现死链!接口报错,状态码: {resp.status}"
)
except Exception as e:
self.log.warning(f"探路超时或网络异常(插件解析失败): {e}")
# --- 探路失败处理逻辑 ---
# --- 探路失败吃下404处理逻辑 ---
if not is_url_ok:
self._url_failed_cnt += 1
self.log.warning(f"当前连续死链次数: {self._url_failed_cnt}")
# 统一步调!所有的失败全部使用全局唯一的 _play_failed_cnt 累计!
self._play_failed_cnt = getattr(self, "_play_failed_cnt", 0) + 1
self.log.warning(f"当前连续失败次数: {self._play_failed_cnt}")
if self._url_failed_cnt >= 5:
self.log.error("连续 5 次获取歌曲死链,触发第一层终极熔断")
self._url_failed_cnt = 0
# 调用大管家的万能报错机制!
if self._play_failed_cnt >= 5:
self.log.error("连续 5 次获取歌曲死链,触发系统第二层熔断保护")
self._play_failed_cnt = 0
await self.xiaomusic.handle_fatal_error(
self.did, "连续多次获取歌曲失败,已为您停止"
self.did, "连续多次获取歌曲失败,已为您停止播放。"
)
return
# 没到 5 次,静默切下一首
# 没到 5 次,静默 0.5 秒直接切下一首。小爱甚至都不知道发生过什么!
if self.is_playing and self._last_cmd != "stop":
await asyncio.sleep(0.5)
await self._play_next()
return
# 4. 真正安全的下发播放阶段
await self.group_force_stop_xiaoai()
self.log.info(f"播放 {url}")
self.log.info(f"发送指令给小爱,开始播放: {url}")
# 设备指令异常拦截 (完全保持原样)
results = await self.group_player_play(url, name)
if all(ele is None for ele in results):
self.log.info(f"播放 {name} 失败. 失败次数: {self._play_failed_cnt}")
self._play_failed_cnt = getattr(self, "_play_failed_cnt", 0) + 1
self.log.info(f"播放指令发送失败. 连续失败次数: {self._play_failed_cnt}")
await asyncio.sleep(1)
if (
self.is_playing
and self._last_cmd != "stop"
and self._play_failed_cnt < 10
and self._play_failed_cnt < 5
):
self._play_failed_cnt = self._play_failed_cnt + 1
await self._play_next()
return
# 重置播放失败次数
self._play_failed_cnt = 0
self._url_failed_cnt = 0
self.log.info(f"{name}】已经开始播放了")
# 记录歌曲开始播放的时间
self._start_time = time.time()
self._paused_time = 0
# 获取时长也传 cur_playlist
# 获取音频时长
sec = await self.xiaomusic.music_library.get_music_duration(name, cur_playlist)
# 存储真实歌曲时长
self._duration = sec
await self.xiaomusic.analytics.send_play_event(name, sec, self.hardware)
# 设置下一首歌曲的播放定时器
is_radio = self.xiaomusic.music_library.is_web_radio_music(name)
# 5. 时长质检阶段:拦截下载回来的残次品
if sec <= 0.1:
self.log.info(f"{name}】不会设置下一首歌的定时器")
if is_radio:
self.log.info(f"{name}】是电台流,无限时长,免跳过")
self._play_failed_cnt = 0
else:
self._play_failed_cnt = getattr(self, "_play_failed_cnt", 0) + 1
self.log.warning(
f"{name}】资源无效(获取时长为 {sec}),触发自动跳过。连续失败次数: {self._play_failed_cnt}"
)
if self._play_failed_cnt >= 5:
self.log.error(
"连续获取歌曲失败达到 5 次,触发第一层终极熔断保护!"
)
self._play_failed_cnt = 0
asyncio.ensure_future(
self.xiaomusic.handle_fatal_error(
self.did, "连续多次获取歌曲失败,已为您停止播放。"
)
)
else:
await self.set_next_music_timeout(0.5)
return
# 计算自动添加歌曲的延迟时间为当前歌曲时长的一半但不超过60秒
# 只有通过了 404 探路存活 -> 发送指令成功 -> 质检测出时长正常,才允许重置清零!
self._play_failed_cnt = 0
# 计算自动添加歌曲的延迟时间
if sec > 30:
sleep_sec = min(sec / 2, 60)
await self.auto_add_song(cur_playlist, sleep_sec)
@@ -477,6 +599,11 @@ class XiaoMusicDevice:
if self.event_bus:
self.event_bus.publish(DEVICE_CONFIG_CHANGED)
# --- 🌟 新增:触发预缓存下一首 🌟 ---
# 如果当前歌曲大于 2 秒,则在播放 20 秒后悄悄去下载下一首歌
if sec > 20:
await self.prefetch_next_song(20)
async def do_tts(self, value):
"""执行TTS文字转语音"""
self.log.info(f"try do_tts value:{value}")
@@ -637,7 +764,13 @@ class XiaoMusicDevice:
self.log.info("顺序播放结束")
return ""
if new_index >= play_list_len:
new_index = 0
if self.device.play_type == PLAY_TYPE_RND:
self.log.info("当前随机列表已播放一轮,触发重新洗牌!")
self.update_playlist(force_reshuffle=True)
# 洗完牌后,当前歌曲被强行置顶在了 0下一首必定是 1
new_index = 1
else:
new_index = 0
elif direction == "prev":
new_index = index - 1
if new_index < 0:
@@ -818,6 +951,15 @@ class XiaoMusicDevice:
if not (self.config.use_music_api or self.config.continue_play):
return str(audio_id)
# 如果 name 为空(如播放 TTS 时),坚决不请求小米接口,会导致小米账号报错。
name = name.strip() if name else ""
if not name:
self.log.debug(
"歌名为空(可能是TTS播报),直接使用默认 audio_id跳过小米接口查询。"
)
return str(audio_id)
# 修复结束
try:
params = {
"query": name,
@@ -959,12 +1101,15 @@ class XiaoMusicDevice:
tts = self.config.get_play_type_tts(play_type)
await self.do_tts(tts)
self.update_playlist()
# 切换模式,强制重新洗牌
self.update_playlist(force_reshuffle=True)
async def play_music_list(self, list_name, music_name):
"""播放指定播放列表"""
self._last_cmd = "play_music_list"
self.device.cur_playlist = list_name
self.update_playlist()
# 切换歌单,强制重新洗牌
self.update_playlist(force_reshuffle=True)
if not music_name:
music_name = self.device.playlist2music.get(list_name, "")
self.log.info(f"开始播放列表{list_name} {music_name}")
@@ -1053,6 +1198,11 @@ class XiaoMusicDevice:
self._tts_timer = None
self.log.info("cancel_all_timer _tts_timer.cancel")
if self._prefetch_timer:
self._prefetch_timer.cancel()
self._prefetch_timer = None
self.log.info("cancel_all_timer _prefetch_timer.cancel")
@classmethod
def dict_clear(cls, d):
"""清空设备字典并取消所有定时器"""

View File

@@ -12,13 +12,19 @@ import time
import urllib.parse
from collections import OrderedDict
from dataclasses import asdict
from urllib.parse import urlparse
from urllib.parse import parse_qs, urlparse
from xiaomusic.const import SUPPORT_MUSIC_TYPE
from xiaomusic.events import CONFIG_CHANGED
from xiaomusic.utils.file_utils import not_in_dirs, traverse_music_directory
from xiaomusic.utils.file_utils import (
clean_old_caches,
is_cache_valid,
not_in_dirs,
traverse_music_directory,
)
from xiaomusic.utils.music_utils import (
Metadata,
build_cache_file_path,
extract_audio_metadata,
get_local_music_duration,
get_web_music_duration,
@@ -804,7 +810,28 @@ class MusicLibrary:
self.try_save_tag_cache()
return "OK"
# 🌟 修改参数,增加 playlist_name
def _extract_cache_path_from_url(self, url: str, name: str) -> str:
"""从代理 URL 中提取 datab64 并计算物理缓存路径"""
if not url or not url.startswith("self:///api/proxy/plugin-url"):
return ""
try:
query = urlparse(url).query
params = parse_qs(query)
datab64 = params.get("data", [""])[0]
if datab64:
actual_cache_dir = self.config.cache_dir
if not actual_cache_dir.startswith(self.config.music_path):
actual_cache_dir = os.path.join(
self.config.music_path, actual_cache_dir.lstrip("\\/")
)
# 将纠正后的 actual_cache_dir 传给底层
return build_cache_file_path(datab64, name, actual_cache_dir)
except Exception as e:
self.log.debug(f"提取缓存路径失败: {e}")
return ""
async def get_music_duration(self, name: str, playlist_name: str = None) -> float:
"""获取歌曲时长
@@ -827,26 +854,79 @@ class MusicLibrary:
self.log.info(f"电台 {name} 不会有播放时长")
return 0
# 网络音乐:使用内存缓存
# --- 优化后的网络音乐时长获取及持久化缓存 ---
if self.is_web_music(name):
# 先检查内存缓存
if name in self._web_music_duration_cache:
duration = self._web_music_duration_cache[name]
self.log.debug(f"从内存缓存读取网络音乐 {name} 时长: {duration}")
return duration
# 缓存中没有,获取时长
try:
url, _ = await self._get_web_music_url(name, playlist_name) # 传参
duration, _ = await get_web_music_duration(url, self.config)
self.log.info(f"网络音乐 {name} 时长: {duration}")
# 直接从字典里拿原始带 base64 的数据,防止被下层函数截胡
raw_url = None
if playlist_name:
raw_url = self.playlist_music_urls.get(f"{playlist_name}::{name}")
if not raw_url:
raw_url = self.all_music.get(name)
# 存入内存缓存(不持久化)
# 尝试计算缓存路径 (利用最原始的 URL)
cache_path = None
if getattr(self.config, "cache_max_size_mb", 0) > 0 and raw_url:
cache_path = self._extract_cache_path_from_url(raw_url, name)
# 分支判断:检查物理缓存
# 如果物理文件在,直接测
if cache_path:
cache_status = is_cache_valid(cache_path)
if cache_status == -1:
self.log.warning(
f"获取时长时命中负向缓存(死链)直接返回0: {name}"
)
return 0
elif cache_status == 1:
os.utime(cache_path, None)
if name in self._web_music_duration_cache:
self.log.debug(f"物理文件存在,命中内存时长: {name}")
return self._web_music_duration_cache[name]
duration = await get_local_music_duration(
cache_path, self.config
)
self.log.info(
f"命中物理缓存: {cache_path}, 测得时长: {duration}"
)
if duration > 0:
self._web_music_duration_cache[name] = duration
return duration
# 4. 如果走到这里,说明【没开启缓存】或者【文件不存在/预下载失败】
# 此时我们才需要真正去拿能够下载的最终 URL
url, origin_url = await self._get_web_music_url(name, playlist_name)
# 【情况 A】没开缓存功能走兜底临时文件下载
if not cache_path:
if name in self._web_music_duration_cache:
return self._web_music_duration_cache[name]
duration, _ = await get_web_music_duration(url, self.config, None)
if duration > 0:
self._web_music_duration_cache[name] = duration
return duration
# 【情况 B】开启了缓存但物理文件缺失强制下载并缓存
self.log.info(f"缓存文件缺失,强制触发重新下载: {name}")
duration, _ = await get_web_music_duration(url, self.config, cache_path)
# 下载完后后台清理与记账
if duration > 0:
self._web_music_duration_cache[name] = duration
self.log.info(f"已缓存网络音乐 {name} 时长到内存: {duration}")
cleanup_task = asyncio.create_task(
asyncio.to_thread(
clean_old_caches,
self.config.cache_dir,
getattr(self.config, "cache_max_size_mb", 0),
)
)
cleanup_task.add_done_callback(lambda t: t.exception())
return duration
except Exception as e:
self.log.exception(f"获取网络音乐 {name} 时长失败: {e}")
return 0
@@ -1100,6 +1180,26 @@ class MusicLibrary:
self.log.info(f"get_music_url web music. name:{name}, url:{url}")
# --- 小爱音箱端缓存截胡逻辑 ---
if getattr(self.config, "cache_max_size_mb", 0) > 0:
cache_path = self._extract_cache_path_from_url(url, name)
if cache_path:
cache_status = is_cache_valid(cache_path)
if cache_status == -1:
# 确诊死链,直接打回空链接,触发秒切
self.log.warning(
f"命中负向缓存(死链墓碑),拒绝网络请求,直接跳过: {name}"
)
return "", None
if cache_status == 1:
# 正常命中缓存
os.utime(cache_path, None) # 刷新保命时间
local_url = self._get_file_url(cache_path)
self.log.info(f"音箱端命中本地缓存,直链下发: {local_url}")
return local_url, None
# --- 音箱截胡逻辑结束 ---
# 需要通过API获取真实播放地址
if self.is_need_use_play_music_api(name):
url = await self._get_url_from_api(name, url)

View File

@@ -8,9 +8,9 @@
<!-- 预加载字体文件,减少加载延迟 -->
<link rel="preload" href="./materialicons.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="./materialiconsoutlined.woff2" as="font" type="font/woff2" crossorigin>
<script src="./jquery-3.7.1.min.js?version=1779162335"></script>
<script src="./setting.js?version=1779162335"></script>
<link rel="stylesheet" type="text/css" href="./setting.css?version=1779162335" />
<script src="./jquery-3.7.1.min.js?version=1778337085"></script>
<script src="./setting.js?version=1778337085"></script>
<link rel="stylesheet" type="text/css" href="./setting.css?version=1778337085" />
<script async src="https://www.googletagmanager.com/gtag/js?id=G-Z09NC1K7ZW"></script>
<script>
@@ -181,6 +181,9 @@
<label for="cache_dir">缓存文件目录:</label>
<input id="cache_dir" type="text" value="music/cache" />
<label for="cache_max_size_mb">在线歌曲缓存容量(MB, 0为不缓存):</label>
<input id="cache_max_size_mb" type="number" value="500" />
<label for="temp_path">临时文件目录:</label>
<input id="temp_path" type="text" value="music/tmp" />

View File

@@ -1449,6 +1449,40 @@
#fp-lyrics-wrapper, #fp-lyrics-container, .lyric-line, .now-playing-title, .header, .playlist, .player-bar, .full-player { -webkit-user-select: none; -moz-user-select: none; user-select: none; }
#fp-cover { -webkit-user-drag: none; user-drag: none; -webkit-user-select: none; user-select: none; pointer-events: auto; }
}
/* ==========================================
15. 语音搜索专属样式 (Voice Search Panel)
========================================== */
/* 1. 开关 (Switch) ➔ 高级石板灰 */
#voice-pane .ios-switch input:checked + .ios-slider { background-color: #64748b; }
#voice-pane .ios-switch input:checked + .ios-slider:before { background-color: #ffffff; }
/* 2. 搜单策略 (Strategy) ➔ 高级石板灰 */
#voice-pane input[name="voice-strategy"]:checked { border-color: #64748b; }
#voice-pane input[name="voice-strategy"]:checked::after { background-color: #64748b; }
#voice-pane .radio-item:has(input[name="voice-strategy"]:checked) .radio-label { color: #64748b; font-weight: bold; }
/* 3. 保存按钮 (Save) ➔ 石板灰 */
#voice-keywords-save {
background: #64748b;
color: #fff;
border: none;
font-weight: bold;
}
#voice-keywords-save:disabled {
background: var(--border);
color: var(--text-sub);
opacity: 0.6;
}
/* 4. 搜索引擎 (Engine) ➔ 保持特定的引擎识别色 */
#voice-pane #radio-mf:checked { border-color: var(--primary); }
#voice-pane #radio-mf:checked::after { background-color: var(--primary); }
#voice-pane .radio-item:has(#radio-mf:checked) .radio-label { color: var(--primary); }
#voice-pane #radio-lx:checked { border-color: #10b981; }
#voice-pane #radio-lx:checked::after { background-color: #10b981; }
#voice-pane .radio-item:has(#radio-lx:checked) .radio-label { color: #10b981; }
.radio-item:has(input[name="voice-strategy"]:checked) .radio-label { color: var(--primary); font-weight: bold; }
</style>
</head>
<body>
@@ -1975,40 +2009,6 @@
</div>
<div id="voice-pane" class="plugin-pane" style="display: none; padding-bottom: 10px;">
<style>
/* 1. 开关 (Switch) ➔ 高级石板灰 */
#voice-pane .ios-switch input:checked + .ios-slider { background-color: #64748b; }
#voice-pane .ios-switch input:checked + .ios-slider:before { background-color: #ffffff; }
/* 2. 搜单策略 (Strategy) ➔ 强制应用石板灰,防止被覆盖 */
input[name="voice-strategy"]:checked { border-color: #64748b !important; }
input[name="voice-strategy"]:checked::after { background-color: #64748b !important; }
.radio-item:has(input[name="voice-strategy"]:checked) .radio-label { color: #64748b !important; font-weight: bold; }
/* 3. 保存按钮 (Save) ➔ 石板灰 */
#voice-keywords-save {
background: #64748b !important;
color: #fff !important;
border: none !important;
font-weight: bold;
}
#voice-keywords-save:disabled {
background: var(--border) !important;
color: var(--text-sub) !important;
opacity: 0.6;
}
/* 4. 搜索引擎 (Engine) ➔ 保持特定的引擎识别色 */
/* MusicFree ➔ 紫红色 */
#radio-mf:checked { border-color: var(--primary) !important; }
#radio-mf:checked::after { background-color: var(--primary) !important; }
.radio-item:has(#radio-mf:checked) .radio-label { color: var(--primary) !important; }
/* LX Server ➔ 翠绿色 */
#radio-lx:checked { border-color: #10b981 !important; }
#radio-lx:checked::after { background-color: #10b981 !important; }
.radio-item:has(#radio-lx:checked) .radio-label { color: #10b981 !important; }
</style>
<div style="font-size: 13px; font-weight: bold; margin-bottom: 8px; color: var(--text-sub);">语音指令</div>
<div class="setting-group">
<div class="setting-item">
@@ -2067,10 +2067,6 @@
</div>
<div style="font-size: 13px; font-weight: bold; margin-bottom: 8px; color: var(--text-sub);">搜歌单优选策略</div>
<style>
/* 专属单选按钮高亮样式 */
.radio-item:has(input[name="voice-strategy"]:checked) .radio-label { color: var(--primary); font-weight: bold; }
</style>
<div class="setting-group" style="margin-bottom: 16px;">
<div class="setting-item" style="padding-bottom: 10px; border-bottom: 1px solid var(--border);">
<div class="setting-info" style="padding-right: 0;">
@@ -2140,7 +2136,7 @@
/* ==========================================
* 1. 核心配置与全局状态
* ========================================== */
const APP_VERSION = 'v1.7.3';
const APP_VERSION = 'v1.7.5';
const APP_LOGO = document.getElementById('app-logo')?.content || '';
const LX_PLATFORMS_MAP = { 'tx': 'QQ', 'wy': '网易云', 'kg': '酷狗', 'kw': '酷我', 'mg': '咪咕' };
const LX_BACKEND_NAMES = { 'tx': '小秋音乐', 'wy': '小芸音乐', 'kg': '小枸音乐', 'kw': '小蜗音乐', 'mg': '小蜜音乐' };
@@ -2175,6 +2171,7 @@
folder: `<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="2" fill="none"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>`,
disc: `<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="2" fill="none"><circle cx="12" cy="12" r="10"></circle><circle cx="12" cy="12" r="3"></circle><path d="M12 6a6 6 0 0 0-6 6"></path></svg>`,
globe: `<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="2" fill="none"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>`,
voice_signal: `<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"></circle><path d="M16.24 7.76a6 6 0 0 1 0 8.49m-8.48-.01a6 6 0 0 1 0-8.49m11.31-2.82a10 10 0 0 1 0 14.14m-14.14 0a10 10 0 0 1 0-14.14"></path></svg>`
};
let allPlaylists = {};
@@ -2360,9 +2357,19 @@
else if (name === '本地搜索') { icon = SVG_ICONS.search; displayName = '曲库搜索'; }
else if (name === '下载') icon = SVG_ICONS.download;
else if (name === '最近新增') { icon = SVG_ICONS.recent; displayName = '本地新增'; }
else if (name === '_online_iwebplayer_search' || name === '_online_play' || name.startsWith('🎵')) {
icon = SVG_ICONS.disc;
displayName = window.getDisplayPlaylistName(name);
else if (
name === '_online_iwebplayer_search' ||
name === '_online_play' ||
(name.startsWith('_online_') && !name.startsWith('_online_iwp_') && !name.startsWith('_online_lx_') && !name.startsWith('_online_mf_') && name !== '_online_webPush')
) {
icon = SVG_ICONS.voice_signal;
displayName = window.getDisplayPlaylistName(name);
}
// 自定义歌单保留原有的光盘图标
else if (name.startsWith('🎵')) {
icon = SVG_ICONS.disc;
displayName = window.getDisplayPlaylistName(name);
}
else if (name === '其他') icon = SVG_ICONS.folder;
@@ -3615,8 +3622,17 @@
* ========================================== */
window.getDisplayPlaylistName = function(k) {
if (k === '_online_iwebplayer_search') return '在线搜索推送歌单';
if (k === '_online_play') return '语音搜索歌单';
// 1. 搜歌单 / 推送歌单
if (k === '_online_iwebplayer_search') return '语音搜索歌单/在线搜索推送';
// 2. 搜歌曲
if (k === '_online_play') return '语音搜索歌曲';
// 3. 搜歌手 (处理 _online_佚名 或 _online_歌手名)
if (k === '_online_佚名') return '语音搜索歌手';
if (k.startsWith('_online_') && !k.startsWith('_online_iwp_') && !k.startsWith('_online_lx_') && !k.startsWith('_online_mf_') && k !== '_online_webPush') {
// 如果后端传的是 _online_周杰伦显示为 "语音搜索歌手 - 周杰伦"
return '语音搜索歌手 - ' + k.replace('_online_', '');
}
// 4. 其他自定义歌单去前缀
return k.replace(/^🎵\s*/, '');
};
@@ -4162,10 +4178,20 @@
const inputSinger = document.getElementById('voice-keywords-singer');
if (!inputOnline || !inputPlaylist || !inputSinger) return;
// 🌟 核心改进:如果为空则默认填入占位符 placeholder 的内容
const valOnline = (inputOnline.value.trim() || inputOnline.placeholder).replace(//g, ',');
const valPlaylist = (inputPlaylist.value.trim() || inputPlaylist.placeholder).replace(//g, ',');
const valSinger = (inputSinger.value.trim() || inputSinger.placeholder).replace(//g, ',');
// 如果为空则默认填入占位符 placeholder 的内容
let valOnline = (inputOnline.value.trim() || inputOnline.placeholder).replace(//g, ',');
let valPlaylist = (inputPlaylist.value.trim() || inputPlaylist.placeholder).replace(//g, ',');
let valSinger = (inputSinger.value.trim() || inputSinger.placeholder).replace(//g, ',');
if (!valOnline.includes('搜索歌曲')) {
valOnline += (valOnline ? ',' : '') + '搜索歌曲';
}
if (!valSinger.includes('搜索歌手')) {
valSinger += (valSinger ? ',' : '') + '搜索歌手';
}
if (!valPlaylist.includes('搜索歌单')) {
valPlaylist += (valPlaylist ? ',' : '') + '搜索歌单';
}
// 同步回界面显示
inputOnline.value = valOnline;
@@ -4694,10 +4720,31 @@
li.innerHTML = formatPlaylistText(key, window.getMergedSongList(key).length);
li.addEventListener('click', (e) => {
// 🌟 修改点:将点击回调改为 async 异步函数
li.addEventListener('click', async (e) => {
e.stopPropagation();
// --- 🚀 新增:特殊歌单实时同步逻辑 ---
const isVoicePlaylist = (
key === '_online_iwebplayer_search' ||
key === '_online_play' ||
(key.startsWith('_online_') && !key.startsWith('_online_iwp_') && !key.startsWith('_online_lx_') && !key.startsWith('_online_mf_') && key !== '_online_webPush')
);
if (isVoicePlaylist) {
// 如果选中的是语音相关歌单,立即从后台拉取最新数据
playlistVal.innerHTML = `<span style="display:inline-flex; align-items:center; vertical-align:-3px; margin-right:6px; opacity:0.8;">${SVG_ICONS.voice_signal}</span>同步中...`;
try {
await reloadGlobalData(); // 重新拉取 allPlaylists
console.log(`✅ 已同步最新语音歌单数据: ${key}`);
} catch (err) {
console.error("同步失败:", err);
}
}
// ------------------------------------
currentPlaylist = key;
playlistVal.innerHTML = li.innerHTML;
playlistVal.innerHTML = formatPlaylistText(key, window.getMergedSongList(key).length);
playlistOpts.classList.remove('show');
updatePushBtnState();
@@ -4712,10 +4759,12 @@
const savedKeyword = localStorage.getItem('local_search_keyword') || '';
if (typeof performLocalSearch === 'function') performLocalSearch(savedKeyword);
} else if (key !== '在线资源') {
// 这里会用到刚刚 reloadGlobalData 刷新的最新数据
songList = window.getMergedSongList(key);
renderPlaylist();
}
// 再次更新显示文本,确保数量是最新的
li.innerHTML = formatPlaylistText(key, songList.length);
playlistVal.innerHTML = li.innerHTML;
window.scrollTo({ top: 0, behavior: 'smooth' });
@@ -5019,19 +5068,29 @@
playlistEl.appendChild(li);
});
// ... 前面渲染歌曲循环的代码 ...
if (currentPlaylist === '在线资源' && songList.length > 0) {
const statusLi = document.createElement('li');
statusLi.id = 'online-load-more-li';
statusLi.style.cssText = 'text-align: center; padding: 10px 0 15px 0; color: var(--text-sub); font-size: 13px; cursor: pointer; border-radius: 12px; display: flex; justify-content: center;';
if (hasMoreOnlineSearch) {
statusLi.innerHTML = `<div style="padding: 10px 24px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 20px; color: var(--text-main); font-weight: 500; box-shadow: 0 2px 8px rgba(0,0,0,0.05); transition: background 0.2s;">点击加载下一页</div>`;
statusLi.onclick = () => window.doOnlineSearch(true);
statusLi.onmousedown = () => { if(statusLi.firstElementChild) statusLi.firstElementChild.style.background = 'var(--bg-color)'; };
statusLi.onmouseup = () => { if(statusLi.firstElementChild) statusLi.firstElementChild.style.background = 'var(--card-bg)'; };
} else {
statusLi.innerHTML = songList.length >= 500 ? `🛑 已触发 500 首熔断保护` : `<span style="opacity: 0.6;">已经到底啦</span>`;
// 检查是否处于详情页模式(即通过点击歌单进入的视图)
const backBtn = document.getElementById('mf-search-back-btn');
const isDetailView = backBtn && backBtn.style.display === 'block';
// 如果是详情页,直接跳过分页行的渲染
if (!isDetailView) {
const statusLi = document.createElement('li');
statusLi.id = 'online-load-more-li';
statusLi.style.cssText = 'text-align: center; padding: 10px 0 15px 0; color: var(--text-sub); font-size: 13px; cursor: pointer; border-radius: 12px; display: flex; justify-content: center;';
if (hasMoreOnlineSearch) {
statusLi.innerHTML = `<div style="padding: 10px 24px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 20px; color: var(--text-main); font-weight: 500; box-shadow: 0 2px 8px rgba(0,0,0,0.05); transition: background 0.2s;">点击加载下一页</div>`;
statusLi.onclick = () => window.doOnlineSearch(true);
statusLi.onmousedown = () => { if(statusLi.firstElementChild) statusLi.firstElementChild.style.background = 'var(--bg-color)'; };
statusLi.onmouseup = () => { if(statusLi.firstElementChild) statusLi.firstElementChild.style.background = 'var(--card-bg)'; };
} else {
statusLi.innerHTML = songList.length >= 500 ? `🛑 已触发 500 首熔断保护` : `<span style="opacity: 0.6;">已经到底啦</span>`;
}
playlistEl.appendChild(statusLi);
}
playlistEl.appendChild(statusLi);
}
if (!isCurrentSongInNewList) currentIndex = -1;
@@ -5429,6 +5488,14 @@
btnPrev.addEventListener('click', () => {
if (songList.length === 0) return;
// 如果连着音箱,把控制权还给后端,直接发指令!
if (currentDid !== "") {
sendRemoteCmd('上一首');
return;
}
// 本机播放的原始逻辑保持不变
let nextIdx;
if (playMode === 2 && songList.length > 1) {
do { nextIdx = Math.floor(Math.random() * songList.length); } while (nextIdx === currentIndex);
@@ -5440,6 +5507,14 @@
btnNext.addEventListener('click', () => {
if (songList.length === 0) return;
// 如果连着音箱,把控制权还给后端,直接发指令!
if (currentDid !== "") {
sendRemoteCmd('下一首');
return;
}
// 本机播放的原始逻辑保持不变
let nextIdx;
if (playMode === 2 && songList.length > 1) {
do { nextIdx = Math.floor(Math.random() * songList.length); } while (nextIdx === currentIndex);
@@ -6529,6 +6604,7 @@
// 点击特定歌单 -> 进详情
// ==========================================
window.triggerPlaylistDetail = async function(id, name, source, apiType = 2) {
hasMoreOnlineSearch = false;
localStorage.setItem('lx_grid_scroll_y', window.scrollY);
let capsule = document.getElementById('search-tag-capsule');

View File

@@ -213,3 +213,106 @@ async def clean_temp_dir(config):
log.info("定时清理临时文件完成,已删除并重建临时目录")
except Exception as e:
log.exception(f"清理临时文件异常: {e}")
def mark_audio_as_failed(file_path: str) -> None:
"""
物理处理小于10秒的音频文件追加 .failed 后缀
"""
if not file_path or not os.path.exists(file_path):
return
# 如果文件已经带了 .failed 后缀,不再重复处理
if file_path.endswith(".failed"):
return
try:
failed_path = file_path + ".failed"
# 如果已经存在 failed 文件,先删掉防止 replace 报错
if os.path.exists(failed_path):
os.remove(failed_path)
# 直接改名加后缀,完全保留里面下载下来的数据
os.replace(file_path, failed_path)
log.info(f"已物理追加失效后缀保留现场: {failed_path}")
except Exception as e:
log.error(f"处理失效音频文件时出错: {e}")
def is_cache_valid(path: str) -> int:
"""
验证缓存文件状态 (重构为 3 态返回值)。
返回:
1 : 缓存有效 (存在且非0字节且无 .failed)
-1 : 确诊死链 (存在 .failed 墓碑标记)
0 : 无有效缓存 (文件不存在或为0字节需要重新下载)
"""
if not path:
return 0
# 优先判断死链墓碑
if os.path.exists(path + ".failed"):
return -1
# 判断有效缓存
if os.path.exists(path) and os.path.getsize(path) > 0:
return 1
return 0
def clean_old_caches(cache_dir: str, max_size_mb: int) -> None:
"""
LRU 清理逻辑当缓存目录总大小超过设定阈值MB删除最旧的文件。
"""
if max_size_mb <= 0 or not cache_dir:
return
max_size_bytes = max_size_mb * 1024 * 1024
songs_dir = os.path.join(cache_dir, "songs")
if not os.path.exists(songs_dir):
return
try:
# 遍历目录获取文件列表及其最后访问时间
files = []
for f in os.listdir(songs_dir):
p = os.path.join(songs_dir, f)
if os.path.isfile(p):
files.append(
{
"path": p,
"atime": os.path.getatime(p),
"size": os.path.getsize(p),
}
)
# 按最后访问时间排序(由旧到新)
files.sort(key=lambda x: x["atime"])
current_total_size = sum(f["size"] for f in files)
# 如果超出容量,开始清理
if current_total_size > max_size_bytes:
log.info(
f"缓存容量当前为 {current_total_size / 1024 / 1024:.1f}MB超过限制 {max_size_mb}MB启动清理..."
)
target_size = max_size_bytes * 0.8 # 清理到 80% 的水位线
for file_info in files:
try:
os.remove(file_info["path"])
# 如果有对应的 .failed 文件也一并清理
failed_marker = file_info["path"] + ".failed"
if os.path.exists(failed_marker):
os.remove(failed_marker)
current_total_size -= file_info["size"]
log.debug(f"已清理旧缓存: {file_info['path']}")
except Exception as e:
log.error(f"清理文件失败 {file_info['path']}: {e}")
if current_total_size <= target_size:
break
log.info(
f"清理完成,当前缓存容量: {current_total_size / 1024 / 1024:.1f}MB"
)
except Exception as e:
log.error(f"清理缓存目录时发生异常: {e}")

View File

@@ -45,6 +45,8 @@ from mutagen.wavpack import WavPack
from PIL import Image
from xiaomusic.const import SUPPORT_MUSIC_TYPE
from xiaomusic.utils.file_utils import mark_audio_as_failed
from xiaomusic.utils.network_utils import download_plugin_audio
log = logging.getLogger(__package__)
@@ -85,9 +87,12 @@ def is_m4a(url: str) -> bool:
return url.endswith(".m4a")
async def _get_web_music_duration(session, url: str, config) -> float:
async def _get_web_music_duration(
session, url: str, config, cache_path: str = None
) -> float:
"""
异步获取网络音乐文件的完整内容并获取其时长
异步获取网络音乐文件的完整内容并获取其时长
实现:下载 -> 测速 -> 质检 -> 失败物理清理
下载完整文件,写入临时文件后调用本地工具(如 ffprobe获取音频时长
@@ -99,30 +104,74 @@ async def _get_web_music_duration(session, url: str, config) -> float:
Returns:
返回音频的持续时间(秒),如果失败则返回 0
"""
duration = 0
target_path = cache_path
is_temp = False
# 使用 aiohttp 异步发起 GET 请求,下载完整音频文件
async with session.get(url) as response:
array_buffer = await response.read() # 读取响应的完整二进制内容
# 免疫机制。如果是本地 TTS、静音文件、系统提示音绝对不建墓碑直接测本地时长
is_system_or_tts = (
"music/tmp/" in url or "silence.mp3" in url or "xiaomusic_" in url
)
if is_system_or_tts:
from urllib.parse import urlparse
# 创建一个命名的临时文件,并禁用自动删除(以便后续读取)
with tempfile.NamedTemporaryFile(delete=False) as tmp:
tmp.write(array_buffer) # 将下载的完整内容写入临时文件
tmp_path = tmp.name # 获取该临时文件的真实路径
parsed_url = urlparse(url)
# parsed_url.path 拿到的直接就是 "/music/tmp/xxx.mp3" 或 "/static/silence.mp3"
local_path = parsed_url.path.lstrip("/")
if os.path.exists(local_path):
return await get_local_music_duration(local_path, config)
return 0
# 如果没有开启缓存或未传递缓存路径,使用用完即焚的临时文件
if not target_path:
tmp_file = tempfile.NamedTemporaryFile(delete=False, suffix=".tmp")
target_path = tmp_file.name
tmp_file.close()
is_temp = True
else:
# 确保父目录一定存在
os.makedirs(os.path.dirname(target_path), exist_ok=True)
try:
# 调用 get_local_music_duration 并传入文件路径,而不是文件对象
duration = await get_local_music_duration(tmp_path, config)
# 调用 network_utils 的流式下载工具 (这里最耗时)
success = await download_plugin_audio(session, url, target_path)
if not success:
# 如果是由于网络 404/401 导致的下载失败,不在这里立物理墓碑!
# 留给它未来网络恢复后改过自新的机会,仅返回 0 时长让前线去切歌
log.warning(f"网络音频流下载失败(可能是临时故障): {url[:100]}")
return 0
# 测量本地文件时长
duration = await get_local_music_duration(target_path, config)
# 业务质检逻辑:防盗链和残次品拦截 (仅针对持久化缓存)
if not is_temp and cache_path:
# 只有网络畅通、文件成功下到本地但确诊时长小于10秒版权到期给的假静音音频
# 这才是真正的永久性下架,必须立下 .failed 墓碑。
if duration < 10:
log.warning(
f"检测到高仿无效资源(时长 {duration}s < 10s确诊版权下架触发负向缓存立碑"
)
mark_audio_as_failed(target_path)
return 0
return duration
except Exception as e:
log.error(f"Error _get_web_music_duration: {e}")
return 0
finally:
# 手动删除临时文件,避免残留
os.unlink(tmp_path)
return duration
# 无论成功失败,只要是临时文件,立刻销毁现场
if is_temp and os.path.exists(target_path):
try:
os.unlink(target_path)
except Exception as e:
log.error(f"清理临时文件失败: {e}")
async def get_web_music_duration(url: str, config) -> tuple[float, str]:
async def get_web_music_duration(
url: str, config, cache_path: str = None
) -> tuple[float, str]:
"""
获取网络音乐时长
@@ -152,9 +201,9 @@ async def get_web_music_duration(url: str, config) -> tuple[float, str]:
# 设置总超时时间为60秒
timeout = aiohttp.ClientTimeout(total=60)
async with aiohttp.ClientSession(timeout=timeout) as session:
duration = await _get_web_music_duration(session, url, config)
duration = await _get_web_music_duration(session, url, config, cache_path)
except Exception as e:
log.error(f"Error get_web_music_duration: {e}")
log.error(f"获取网络音乐时长失败: {e}")
return duration, url
@@ -744,3 +793,54 @@ def set_music_tag_to_file(file_path: str, info: Metadata) -> str:
except Exception as e:
log.exception(f"Error saving tags: {e}")
return "Error saving tags"
def get_real_audio_format(file_path: str) -> str:
"""通过读取文件头 Magic Number 瞬间嗅探真实音频格式"""
try:
with open(file_path, "rb") as f:
header = f.read(32)
if header.startswith(b"fLaC"):
return "flac"
if header.startswith(b"OggS"):
return "ogg"
if b"ftypM4A" in header or b"ftypm4a" in header or b"m4a" in header:
return "m4a"
if header.startswith(b"\xff\xf1") or header.startswith(b"\xff\xf9"):
return "aac"
if header.startswith(b"ID3") or header.startswith(b"\xff\xfb"):
return "mp3"
return "mp3" # 兜底
except Exception:
return "mp3"
def build_cache_file_path(datab64: str, name: str, cache_dir: str) -> str:
"""
根据核心字段生成唯一缓存路径,彻底免疫一切前端和插件附加的动态干扰字段。
"""
if not datab64 or not cache_dir:
return ""
try:
# 1. 解开 Base64
raw_data = json.loads(base64.b64decode(datab64).decode("utf-8"))
# 2. 提取全系统统一的“身份证号”
platform = str(raw_data.get("platform", ""))
song_id = str(raw_data.get("id", ""))
if platform and song_id:
fingerprint = f"{platform}_{song_id}"
short_hash = hashlib.md5(fingerprint.encode()).hexdigest()[:8]
else:
short_hash = hashlib.md5(name.strip().encode()).hexdigest()[:8]
except Exception:
short_hash = hashlib.md5(datab64.encode()).hexdigest()[:8]
safe_name = re.sub(r"[^\w\-_.\s()\[\]【】]", "_", name)
filename = f"{short_hash}_{safe_name}.mp3"
return os.path.join(cache_dir, "songs", filename)

View File

@@ -454,3 +454,42 @@ async def text_to_mp3(
raise RuntimeError(f"生成语音文件失败: {e}") from e
return mp3_path
async def download_plugin_audio(
session: aiohttp.ClientSession, url: str, save_path: str
) -> bool:
"""
基于插件的流式下载函数
"""
try:
# 设置300秒总超时防止死链挂起
async with session.get(
url, timeout=aiohttp.ClientTimeout(total=300)
) as response:
if response.status not in (200, 206):
log.warning(
f"download_plugin_audio HTTP status {response.status} for url: {url[:100]}"
)
return False
# 使用 .downloading 临时后缀,确保下载完整后再重命名
tmp_save_path = save_path + ".downloading"
with open(tmp_save_path, "wb") as f:
async for chunk in response.content.iter_chunked(8192):
f.write(chunk)
if os.path.exists(save_path):
os.remove(save_path)
os.rename(tmp_save_path, save_path)
log.info(f"download_plugin_audio success: {save_path}")
return True
except Exception as e:
log.error(f"download_plugin_audio failed for {url[:100]}: {e}")
# 清理残缺的临时文件
if os.path.exists(save_path + ".downloading"):
try:
os.remove(save_path + ".downloading")
except Exception:
pass
return False

View File

@@ -5,6 +5,8 @@ import os
import re
from logging.handlers import RotatingFileHandler
import aiohttp
from xiaomusic import __version__
from xiaomusic.analytics import Analytics
from xiaomusic.auth import AuthManager
@@ -26,7 +28,7 @@ from xiaomusic.file_watcher import FileWatcherManager
from xiaomusic.music_library import MusicLibrary
from xiaomusic.online_music import OnlineMusicService
from xiaomusic.plugin import PluginManager
from xiaomusic.utils.network_utils import downloadfile
from xiaomusic.utils.network_utils import download_plugin_audio, downloadfile
from xiaomusic.utils.system_utils import deepcopy_data_no_sensitive_info
from xiaomusic.utils.text_utils import chinese_to_number
@@ -173,6 +175,12 @@ class XiaoMusic:
if not os.path.exists(self.config.download_path):
os.makedirs(self.config.download_path)
songs_cache_dir = os.path.join(self.config.cache_dir, "songs")
if getattr(self.config, "cache_max_size_mb", 0) > 0 and not os.path.exists(
songs_cache_dir
):
os.makedirs(songs_cache_dir, exist_ok=True)
self.continue_play = self.config.continue_play
def setup_logger(self):
@@ -454,6 +462,16 @@ class XiaoMusic:
did, song_list, list_name
)
async def download_plugin_audio(self, url: str, save_path: str) -> bool:
"""提供给插件或外部调用的独立流式音频下载接口"""
try:
# 独立开个 session防止影响全局连接池
async with aiohttp.ClientSession() as session:
return await download_plugin_audio(session, url, save_path)
except Exception as e:
self.log.error(f"XiaoMusic.download_plugin_audio failed: {e}")
return False
# ===========================================================
def _find_real_music_list_name(self, list_name):