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:
@@ -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")
|
||||
|
||||
@@ -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)) # 歌曲访问端口
|
||||
|
||||
@@ -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):
|
||||
"""清空设备字典并取消所有定时器"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
9
xiaomusic/static/default/setting.html
vendored
9
xiaomusic/static/default/setting.html
vendored
@@ -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" />
|
||||
|
||||
|
||||
198
xiaomusic/static/iwebplayer/iwebplayer.html
vendored
198
xiaomusic/static/iwebplayer/iwebplayer.html
vendored
@@ -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');
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user