diff --git a/check_plugins.py b/check_plugins.py index 95d0d0e..12f3d77 100644 --- a/check_plugins.py +++ b/check_plugins.py @@ -37,7 +37,7 @@ def check_all_plugins(): time.sleep(3) # 等待插件加载 print("\n2. 获取所有插件状态...") - plugins = manager.get_plugin_list() + plugins = manager.refresh_plugin_list() print(f" 总共找到 {len(plugins)} 个插件") # 分类插件状态 diff --git a/xiaomusic/api/routers/file.py b/xiaomusic/api/routers/file.py index 7f6d4d3..90685c3 100644 --- a/xiaomusic/api/routers/file.py +++ b/xiaomusic/api/routers/file.py @@ -195,7 +195,7 @@ async def upload_music(playlist: str = Form(...), file: UploadFile = File(...)): # 重新生成音乐列表索引 try: - xiaomusic._gen_all_music_list() + xiaomusic.music_library().gen_all_music_list() except Exception: pass diff --git a/xiaomusic/api/routers/music.py b/xiaomusic/api/routers/music.py index 93f5b1d..fe932d3 100644 --- a/xiaomusic/api/routers/music.py +++ b/xiaomusic/api/routers/music.py @@ -1,6 +1,7 @@ """音乐管理路由""" import json +import base64 import urllib.parse from fastapi import ( @@ -11,6 +12,8 @@ from fastapi import ( Request, ) +from fastapi.responses import RedirectResponse + from xiaomusic.api.dependencies import ( log, verification, @@ -31,6 +34,7 @@ def searchmusic(name: str = "", Verifcation=Depends(verification)): return xiaomusic.searchmusic(name) +"""======================在线搜索相关接口=============================""" @router.get("/api/search/online") async def search_online_music( keyword: str = Query(..., description="搜索关键词"), @@ -51,19 +55,69 @@ async def search_online_music( return {"success": False, "error": str(e)} -@router.get("/api/proxy/real-music-url") +@router.get("/api/proxy/real-url") async def get_real_music_url( - url: str = Query(..., description="音乐下载URL"), Verifcation=Depends(verification) + url: str = Query(..., description="原始url"), Verifcation=Depends(verification) ): - """通过服务端代理获取真实的音乐播放URL,避免CORS问题""" + """通过服务端代理获取真实的URL,不止是音频url,可能还有图片url""" try: - # 获取真实的音乐播放URL - return await xiaomusic.get_real_url_of_openapi(url) + # 获取真实的URL + real_url = await xiaomusic.get_real_url_of_openapi(url) + # 直接重定向到真实URL + return RedirectResponse(url=real_url) + except Exception as e: + log.error(f"获取真实URL失败: {e}") + # 如果代理获取失败,重定向到原始URL + return RedirectResponse(url=url) + + +@router.get("/api/proxy/plugin-url") +async def get_plugin_source_url( + data: str = Query(..., description="json对象压缩的base64"), + Verifcation=Depends(verification), +): + try: + # 获取请求数据 + # 将Base64编码的URL解码为Json字符串 + json_str = base64.b64decode(data).decode("utf-8") + # 将json字符串转换为json对象 + json_data = json.loads(json_str) + # 调用公共函数处理 + media_source = await xiaomusic.get_media_source_url(json_data) + if media_source and media_source.get("url"): + source_url = media_source.get("url") + else: + source_url = xiaomusic.default_url() + # 直接重定向到真实URL + return RedirectResponse(url=source_url) except Exception as e: log.error(f"获取真实音乐URL失败: {e}") - # 如果代理获取失败,仍然返回原始URL - return {"success": False, "realUrl": url, "error": str(e)} + # 如果代理获取失败,重定向到原始URL + source_url = xiaomusic.default_url() + return RedirectResponse(url=source_url) + + +@router.get("/api/proxy/openapi-url") +async def get_openapi_source_url( + urlb64: str = Query(..., description="原始url压缩的base64"), + Verifcation=Depends(verification), +): + try: + # 将Base64编码的URL解码为字符串 + url_bytes = base64.b64decode(urlb64) + origin_url = url_bytes.decode("utf-8") + # 获取真正地址 + source_url = await xiaomusic.get_real_url_of_openapi(origin_url) + if not source_url: + source_url = xiaomusic.default_url() + # 直接重定向到真实URL + return RedirectResponse(url=source_url) + except Exception as e: + log.error(f"获取真实音乐URL失败: {e}") + # 如果代理获取失败,重定向到原始URL + source_url = xiaomusic.default_url() + return RedirectResponse(url=source_url) @router.post("/api/play/getMediaSource") @@ -90,28 +144,44 @@ async def get_media_lyric(request: Request, Verifcation=Depends(verification)): return {"success": False, "error": str(e)} -@router.post("/api/play/online") -async def play_online_music(request: Request, Verifcation=Depends(verification)): - """设备端在线播放插件音乐""" +@router.post("/api/device/pushUrl") +async def device_push_url(request: Request, Verifcation=Depends(verification)): + """推送url给设备端播放""" try: # 获取请求数据 data = await request.json() did = data.get("did") openapi_info = xiaomusic.js_plugin_manager.get_openapi_info() if openapi_info.get("enabled", False): - media_source = await xiaomusic.get_real_url_of_openapi(data.get("url")) + url = data.get("url") else: # 调用公共函数处理,获取音乐真实播放URL - media_source = await xiaomusic.get_media_source_url(data) - if not media_source or not media_source.get("url"): - return {"success": False, "error": "Failed to get media source URL"} - url = media_source.get("url") + url = xiaomusic.get_plugin_proxy_url(data) decoded_url = urllib.parse.unquote(url) return await xiaomusic.play_url(did=did, arg1=decoded_url) except Exception as e: return {"success": False, "error": str(e)} +@router.post("/api/device/pushList") +async def device_push_list(request: Request, Verifcation=Depends(verification)): + """WEB前端推送歌单给设备端播放""" + try: + # 获取请求数据 + data = await request.json() + did = data.get("did") + song_list = data.get("songList") + list_name = data.get("playlistName") + # 调用公共函数处理,处理歌曲信息 -> 添加歌单 -> 播放歌单 + return await xiaomusic.push_music_list_play( + did=did, song_list=song_list, list_name=list_name + ) + except Exception as e: + return {"success": False, "error": str(e)} + +"""======================在线搜索相关接口END=============================""" + + @router.get("/playingmusic") def playingmusic(did: str = "", Verifcation=Depends(verification)): """当前播放音乐""" diff --git a/xiaomusic/api/routers/plugin.py b/xiaomusic/api/routers/plugin.py index a5bf0e9..859c6e9 100644 --- a/xiaomusic/api/routers/plugin.py +++ b/xiaomusic/api/routers/plugin.py @@ -37,7 +37,7 @@ def get_js_plugins( if enabled_only: plugins = xiaomusic.js_plugin_manager.get_enabled_plugins() else: - plugins = xiaomusic.js_plugin_manager.get_plugin_list() + plugins = xiaomusic.js_plugin_manager.refresh_plugin_list() return {"success": True, "data": plugins} except Exception as e: diff --git a/xiaomusic/config.py b/xiaomusic/config.py index 808dcfc..fb17c95 100644 --- a/xiaomusic/config.py +++ b/xiaomusic/config.py @@ -3,11 +3,7 @@ from __future__ import annotations import argparse import json import os -from dataclasses import ( - asdict, - dataclass, - field, -) +from dataclasses import asdict, dataclass, field from typing import get_type_hints from xiaomusic.const import ( @@ -157,6 +153,7 @@ class Config: keywords_play: str = os.getenv("XIAOMUSIC_KEYWORDS_PLAY", "播放歌曲,放歌曲") keywords_search_play: str = os.getenv("XIAOMUSIC_KEYWORDS_SEARCH_PLAY", "搜索播放") keywords_online_play: str = os.getenv("XIAOMUSIC_KEYWORDS_ONLINE_PLAY", "在线播放") + keywords_singer_play: str = os.getenv("XIAOMUSIC_KEYWORDS_SINGER_PLAY", "播放歌手") keywords_stop: str = os.getenv("XIAOMUSIC_KEYWORDS_STOP", "关机,暂停,停止,停止播放") keywords_playlist: str = os.getenv( "XIAOMUSIC_KEYWORDS_PLAYLIST", "播放列表,播放歌单" @@ -253,6 +250,7 @@ class Config: self.append_keyword(self.keywords_play, "play") self.append_keyword(self.keywords_search_play, "search_play") self.append_keyword(self.keywords_online_play, "online_play") + self.append_keyword(self.keywords_singer_play, "singer_play") self.append_keyword(self.keywords_stop, "stop") self.append_keyword(self.keywords_playlist, "play_music_list") self.append_user_keyword() diff --git a/xiaomusic/device_player.py b/xiaomusic/device_player.py index 044f657..d6237c4 100644 --- a/xiaomusic/device_player.py +++ b/xiaomusic/device_player.py @@ -75,6 +75,9 @@ class XiaoMusicDevice: self._last_cmd = None self.update_playlist() + # 添加歌曲定时器 + self._add_song_timer = None + @property def did(self): """获取设备DID""" @@ -97,9 +100,60 @@ class XiaoMusicDevice: offset = time.time() - self._start_time - self._paused_time return offset, duration - async def play_music(self, name, true_url=None): + # 自动搜歌并加入当前歌单 + async def auto_add_song(self, cur_list_name, sleep_sec=20): + # 是否启用自动添加 + auto_add_song = self.xiaomusic.js_plugin_manager.get_auto_add_song() + is_online = self.xiaomusic.is_online_music(cur_list_name) + # 歌单循环方式:播放全部 + play_all = self.device.play_type == PLAY_TYPE_ALL + # 当前播放的歌曲是歌单中的最后一曲 + is_last_song = False + cur_playlist = self._play_list + cur_music = self.get_cur_music() + play_list_len = len(cur_playlist) + if play_list_len != 0: + index = self._play_list.index(cur_music) + is_last_song = index == play_list_len - 1 + # 四个条件都满足,才自动添加下一首 + if auto_add_song and is_online and play_all and is_last_song: + await self._add_singer_song(cur_list_name, cur_music, sleep_sec) + + # 启用延时器,搜索当前歌曲歌手的其他不在歌单内的歌曲 + async def _add_singer_song(self, list_name, cur_music, sleep_sec): + # 取消之前的定时器(如果存在) + # self.cancel_add_song_timer() + # 以 '-' 分割,获取歌手名称 + singer_name = cur_music.split("-")[1] + # 创建新的定时器,20秒后执行 + self._add_song_timer = asyncio.create_task( + self._delayed_add_singer_song(list_name, singer_name, sleep_sec) + ) + + async def _delayed_add_singer_song(self, list_name, singer_name, sleep_sec): + """延迟执行添加歌手歌曲的操作""" + try: + await asyncio.sleep(sleep_sec) + await self.xiaomusic.add_singer_song(list_name, singer_name) + except asyncio.CancelledError: + return + finally: + # 执行完毕后清除定时器引用 + if self._add_song_timer: # 确保是当前任务 + self._add_song_timer = None + + def cancel_add_song_timer(self): + """取消添加歌曲的定时器""" + self.log.info("添加歌手歌曲的定时器已被取消") + if self._add_song_timer: + self._add_song_timer.cancel() + self._add_song_timer = None + return True + return False + + async def play_music(self, name): """播放音乐(外部接口)""" - return await self._playmusic(name, true_url=true_url) + return await self._playmusic(name) def update_playlist(self, reorder=True): """初始化/更新播放列表 @@ -288,18 +342,17 @@ class XiaoMusicDevice: return await self._playmusic(name) - async def _playmusic(self, name, true_url=None): + async def _playmusic(self, name): """播放音乐的核心实现""" - self.log.info(f"_playmusic. name:{name} true_url:{true_url}") # 取消组内所有的下一首歌曲的定时器 self.cancel_group_next_timer() self._playing = True self.device.cur_music = name self.device.playlist2music[self.device.cur_playlist] = name - + cur_playlist = self.device.cur_playlist self.log.info(f"cur_music {self.get_cur_music()}") - sec, url = await self.xiaomusic.get_music_sec_url(name, true_url) + sec, url = await self.xiaomusic.get_music_sec_url(name, cur_playlist) await self.group_force_stop_xiaoai() self.log.info(f"播放 {url}") @@ -325,6 +378,10 @@ class XiaoMusicDevice: if sec <= 1: self.log.info(f"【{name}】不会设置下一首歌的定时器") return + # 计算自动添加歌曲的延迟时间,为当前歌曲时长的一半,但不超过60秒 + if sec > 30: + sleep_sec = min(sec / 2, 60) + await self.auto_add_song(cur_playlist, sleep_sec) sec = sec + self.config.delay_sec self._start_time = time.time() self._duration = sec diff --git a/xiaomusic/js_plugin_manager.py b/xiaomusic/js_plugin_manager.py index e0887a1..7062b63 100644 --- a/xiaomusic/js_plugin_manager.py +++ b/xiaomusic/js_plugin_manager.py @@ -43,6 +43,11 @@ class JSPluginManager: # 加载插件 self._load_plugins() + # ... 配置文件相关 ... + self._config_cache = None + self._config_cache_time = 0 + self._config_cache_ttl = 3 * 60 # 缓存有效期5秒,可根据需要调整 + def _start_node_process(self): """启动 Node.js 子进程""" runner_path = os.path.join(os.path.dirname(__file__), "js_plugin_runner.js") @@ -202,6 +207,22 @@ class JSPluginManager: """------------------------------开放接口相关函数----------------------------------------""" + def get_aiapi_info(self) -> dict[str, Any]: + """获取AI接口配置信息 + Returns: + Dict[str, Any]: 包含 OpenAPI 配置信息的字典,包括启用状态和搜索 URL + """ + try: + # 读取配置文件中的 OpenAPI 配置信息 + config_data = self._get_config_data() + if config_data: + return config_data.get("aiapi_info", {}) + else: + return {"enabled": False} + except Exception as e: + self.log.error(f"Failed to read OpenAPI info from config: {e}") + return {} + def get_openapi_info(self) -> dict[str, Any]: """获取开放接口配置信息 Returns: @@ -209,9 +230,8 @@ class JSPluginManager: """ try: # 读取配置文件中的 OpenAPI 配置信息 - if os.path.exists(self.plugins_config_path): - with open(self.plugins_config_path, encoding="utf-8") as f: - config_data = json.load(f) + config_data = self._get_config_data() + if config_data: # 返回 openapi_info 配置项 return config_data.get("openapi_info", {}) else: @@ -221,68 +241,80 @@ class JSPluginManager: return {} def toggle_openapi(self) -> dict[str, Any]: - """切换开放接口配置状态 - Returns: 切换后的配置信息 - """ + """切换开放接口配置状态""" try: - # 读取配置文件中的 OpenAPI 配置信息 if os.path.exists(self.plugins_config_path): with open(self.plugins_config_path, encoding="utf-8") as f: config_data = json.load(f) - # 获取当前的 openapi_info 配置,如果没有则初始化 openapi_info = config_data.get("openapi_info", {}) - - # 切换启用状态:和当前状态取反 current_enabled = openapi_info.get("enabled", False) openapi_info["enabled"] = not current_enabled - - # 更新配置数据 config_data["openapi_info"] = openapi_info - # 写回配置文件 + with open(self.plugins_config_path, "w", encoding="utf-8") as f: json.dump(config_data, f, ensure_ascii=False, indent=2) + # 使缓存失效 + self._invalidate_config_cache() return {"success": True} else: return {"success": False} except Exception as e: self.log.error(f"Failed to toggle OpenAPI config: {e}") - # 出错时返回默认配置 return {"success": False, "error": str(e)} def update_openapi_url(self, openapi_url: str) -> dict[str, Any]: - """更新开放接口地址 - Returns: 更新后的配置信息 - :type openapi_url: 新的接口地址 - """ + """更新开放接口地址""" try: - # 读取配置文件中的 OpenAPI 配置信息 if os.path.exists(self.plugins_config_path): with open(self.plugins_config_path, encoding="utf-8") as f: config_data = json.load(f) - # 获取当前的 openapi_info 配置,如果没有则初始化 openapi_info = config_data.get("openapi_info", {}) - - # 切换启用状态:和当前状态取反 - # current_url = openapi_info.get("search_url", "") openapi_info["search_url"] = openapi_url - - # 更新配置数据 config_data["openapi_info"] = openapi_info - # 写回配置文件 + with open(self.plugins_config_path, "w", encoding="utf-8") as f: json.dump(config_data, f, ensure_ascii=False, indent=2) + + # 使缓存失效 + self._invalidate_config_cache() return {"success": True} else: return {"success": False} except Exception as e: - self.log.error(f"Failed to toggle OpenAPI config: {e}") - # 出错时返回默认配置 + self.log.error(f"Failed to update OpenAPI config: {e}") return {"success": False, "error": str(e)} """----------------------------------------------------------------------""" + def _get_config_data(self): + """获取配置数据,使用缓存机制""" + current_time = time.time() + # 检查缓存是否有效 + if ( + self._config_cache is not None + and current_time - self._config_cache_time < self._config_cache_ttl + ): + return self._config_cache + + # 重新读取配置文件 + if os.path.exists(self.plugins_config_path): + with open(self.plugins_config_path, encoding="utf-8") as f: + config_data = json.load(f) + else: + config_data = {} + + # 更新缓存 + self._config_cache = config_data + self._config_cache_time = current_time + return config_data + + def _invalidate_config_cache(self): + """使配置缓存失效""" + self._config_cache = None + self._config_cache_time = 0 + def _load_plugins(self): """加载所有插件""" if not os.path.exists(self.plugins_dir): @@ -315,8 +347,8 @@ class JSPluginManager: enabled_plugins = self.get_enabled_plugins() for filename in os.listdir(self.plugins_dir): if filename.endswith(".js"): + plugin_name = os.path.splitext(filename)[0] try: - plugin_name = os.path.splitext(filename)[0] # 如果是重要插件或没有指定重要插件列表,则加载 if not enabled_plugins or plugin_name in enabled_plugins: try: @@ -387,14 +419,20 @@ class JSPluginManager: self.log.error(f"Failed to load JS plugin {plugin_name}: {e}") return False + def refresh_plugin_list(self) -> list[dict[str, Any]]: + """刷新插件列表,强制重新加载配置数据""" + # 强制使缓存失效,重新加载配置 + self._invalidate_config_cache() + # 返回最新的插件列表 + return self.get_plugin_list() + def get_plugin_list(self) -> list[dict[str, Any]]: """获取启用的插件列表""" result = [] try: # 读取配置文件中的启用插件列表 - if os.path.exists(self.plugins_config_path): - with open(self.plugins_config_path, encoding="utf-8") as f: - config_data = json.load(f) + config_data = self._get_config_data() + if config_data: plugin_infos = config_data.get("plugins_info", []) enabled_plugins = config_data.get("enabled_plugins", []) @@ -424,9 +462,8 @@ class JSPluginManager: """获取启用的插件列表""" try: # 读取配置文件中的启用插件列表 - if os.path.exists(self.plugins_config_path): - with open(self.plugins_config_path, encoding="utf-8") as f: - config_data = json.load(f) + config_data = self._get_config_data() + if config_data: return config_data.get("enabled_plugins", []) else: return [] @@ -434,6 +471,19 @@ class JSPluginManager: self.log.error(f"Failed to read enabled plugins from config: {e}") return [] + def get_auto_add_song(self) -> bool: + """获取是否启用自动添加歌曲""" + try: + # 读取配置文件 + config_data = self._get_config_data() + if config_data: + return config_data.get("auto_add_song", False) + else: + return False + except Exception as e: + self.log.error(f"Failed to read enabled plugins from config: {e}") + return False + def search(self, plugin_name: str, keyword: str, page: int = 1, limit: int = 20): """搜索音乐""" if plugin_name not in self.plugins: @@ -483,12 +533,15 @@ class JSPluginManager: ) return result_data - async def openapi_search(self, url: str, keyword: str, limit: int = 10): + async def openapi_search( + self, url: str, keyword: str, artist: str, limit: int = 20 + ): """直接调用在线接口进行音乐搜索 Args: url (str): 在线搜索接口地址 - keyword (str): 搜索关键词,支持: 歌曲名-歌手名 搜索 + keyword (str): 搜索关键词,歌名/歌手名 + artist (str): 搜索的歌手名,可能为空 limit (int): 每页数量,默认为5 Returns: Dict[str, Any]: 搜索结果,数据结构与search函数一致 @@ -498,13 +551,6 @@ class JSPluginManager: import aiohttp try: - # 如果关键词包含 '-',则提取歌手名、歌名 - if "-" in keyword: - parts = keyword.split("-") - keyword = parts[0] - artist = parts[1] - else: - artist = "" # 构造请求参数 params = {"type": "aggregateSearch", "keyword": keyword, "limit": limit} # 使用aiohttp发起异步HTTP GET请求 @@ -538,7 +584,7 @@ class JSPluginManager: "album": item.get("album", ""), "platform": "OpenAPI-" + item.get("platform"), "isOpenAPI": True, - "url": item.get("url", ""), + "url": self.xiaomusic.get_openapi_proxy_url(item.get("url", "")), "artwork": item.get("pic", ""), "lrc": item.get("lrc", ""), } @@ -556,6 +602,7 @@ class JSPluginManager: # 返回统一格式的数据 return { "success": True, + "isOpenAPI": True, "data": results, "total": len(results), "sources": {"OpenAPI": len(results)}, @@ -567,6 +614,7 @@ class JSPluginManager: self.log.error(f"OpenAPI search timeout at URL {url}: {e}") return { "success": False, + "isOpenAPI": True, "error": f"OpenAPI search timeout: {str(e)}", "data": [], "total": 0, @@ -578,6 +626,7 @@ class JSPluginManager: self.log.error(f"OpenAPI search error at URL {url}: {e}") return { "success": False, + "isOpenAPI": True, "error": f"OpenAPI search error: {str(e)}", "data": [], "total": 0, @@ -950,6 +999,8 @@ class JSPluginManager: with open(config_file_path, "w", encoding="utf-8") as f: json.dump(config_data, f, ensure_ascii=False, indent=2) + # 清空缓存: + self._invalidate_config_cache() self.log.info( f"Plugin config updated for enabled plugin {plugin_name}" ) @@ -994,7 +1045,8 @@ class JSPluginManager: # 写回配置文件 with open(config_file_path, "w", encoding="utf-8") as f: json.dump(config_data, f, ensure_ascii=False, indent=2) - + # 清空缓存: + self._invalidate_config_cache() self.log.info( f"Plugin config updated for enabled plugin {plugin_name}" ) @@ -1041,7 +1093,8 @@ class JSPluginManager: # 回写配置文件 with open(config_file_path, "w", encoding="utf-8") as f: json.dump(config_data, f, ensure_ascii=False, indent=2) - + # 清空缓存: + self._invalidate_config_cache() self.log.info( f"Plugin config updated for uninstalled plugin {plugin_name}" ) @@ -1117,24 +1170,6 @@ class JSPluginManager: def shutdown(self): """关闭插件管理器""" - self.log.info("Shutting down JS Plugin Manager...") - self._is_shutting_down = True - if self.node_process: - try: - # 先尝试优雅关闭 - self.node_process.terminate() - # 等待最多 3 秒 - try: - self.node_process.wait(timeout=3) - self.log.info("Node.js process terminated gracefully") - except subprocess.TimeoutExpired: - # 如果超时,强制杀死 - self.log.warning("Node.js process did not terminate, killing...") - self.node_process.kill() - self.node_process.wait() - self.log.info("Node.js process killed") - except Exception as e: - self.log.error(f"Error during shutdown: {e}") - - self.log.info("JS Plugin Manager shutdown complete") + self.node_process.terminate() + self.node_process.wait() diff --git a/xiaomusic/music_library.py b/xiaomusic/music_library.py index 05ad9b5..17fc4ae 100644 --- a/xiaomusic/music_library.py +++ b/xiaomusic/music_library.py @@ -378,6 +378,62 @@ class MusicLibrary: self.save_custom_play_list(save_config_callback) return True + def update_music_list_json(self, list_name, update_list, append=False): + """ + 更新配置的音乐歌单Json,如果歌单存在则根据 append:False:覆盖; True:追加 + Args: + list_name: 更新的歌单名称 + update_list: 更新的歌单列表 + append: 追加歌曲,默认 False + + Returns: + list: 转换后的音乐项目列表 + """ + # 更新配置中的音乐列表 + if self.config.music_list_json: + music_list = json.loads(self.config.music_list_json) + else: + music_list = [] + + # 检查是否已存在同名歌单 + existing_index = None + for i, item in enumerate(music_list): + if item.get("name") == list_name: + existing_index = i + break + + # 构建新歌单数据 + new_music_items = [ + {"name": item["name"], "url": item["url"], "type": item["type"]} + for item in update_list + ] + + if existing_index is not None: + if append: + # 追加模式:将新项目添加到现有歌单中,避免重复 + existing_musics = music_list[existing_index]["musics"] + existing_names = {music["name"] for music in existing_musics} + + # 只添加不存在的项目 + for new_item in new_music_items: + if new_item["name"] not in existing_names: + existing_musics.append(new_item) + + music_list[existing_index]["musics"] = existing_musics + else: + # 覆盖模式:替换整个歌单 + music_list[existing_index] = { + "name": list_name, + "musics": new_music_items, + } + else: + # 添加新歌单 + new_music_list = {"name": list_name, "musics": new_music_items} + music_list.append(new_music_list) + + # 保存更新后的配置 + self.config.music_list_json = json.dumps(music_list, ensure_ascii=False) + def play_list_add_music(self, name, music_list, save_config_callback): """歌单新增歌曲 @@ -566,6 +622,12 @@ class MusicLibrary: """ return name in self._all_radio + # 是否是在线音乐 + @staticmethod + def is_online_music(cur_playlist): + # cur_playlist 开头是 '_online_' 则表示online + return cur_playlist.startswith("_online_") + def is_web_music(self, name): """是否是网络歌曲 diff --git a/xiaomusic/music_url.py b/xiaomusic/music_url.py index 91fa86e..3e50306 100644 --- a/xiaomusic/music_url.py +++ b/xiaomusic/music_url.py @@ -4,6 +4,7 @@ """ import base64 +import json import math import urllib.parse @@ -31,6 +32,7 @@ class MusicUrlHandler: url_cache, get_filename_func, is_web_music_func, + is_online_music_func, is_web_radio_music_func, is_need_use_play_music_api_func, ): @@ -46,6 +48,7 @@ class MusicUrlHandler: url_cache: URL缓存对象 get_filename_func: 获取文件名的函数 is_web_music_func: 判断是否为网络音乐的函数 + is_online_music_func: 判断是否为在线音乐的函数 is_web_radio_music_func: 判断是否为网络电台的函数 is_need_use_play_music_api_func: 判断是否需要使用API的函数 """ @@ -60,39 +63,34 @@ class MusicUrlHandler: # 回调函数 self.get_filename = get_filename_func self.is_web_music = is_web_music_func + self.is_online_music = is_online_music_func self.is_web_radio_music = is_web_radio_music_func self.is_need_use_play_music_api = is_need_use_play_music_api_func - async def get_music_sec_url(self, name, true_url=None): + async def get_music_sec_url(self, name, cur_playlist): """获取歌曲播放时长和播放地址 Args: name: 歌曲名称 - true_url: 真实播放URL(可选) - + cur_playlist: 当前歌单名称 Returns: tuple: (播放时长(秒), 播放地址) """ - # 获取播放时长 - if true_url is not None: - url = true_url - sec = await self._get_online_music_duration(name, true_url) - self.log.info(f"在线歌曲时长获取::{name} ;sec::{sec}") + url, origin_url = await self.get_music_url(name) + self.log.info( + f"get_music_sec_url. name:{name} url:{url} origin_url:{origin_url}" + ) + # 电台直接返回 + if self.is_web_radio_music(name): + self.log.info("电台不会有播放时长") + return 0, url + # 在线歌曲:时长、播放链接获取 + if self.is_online_music(cur_playlist): + return await self._get_online_music_sec_url(name, url) + if self.is_web_music(name): + sec = await self._get_web_music_duration(name, url, origin_url) else: - url, origin_url = await self.get_music_url(name) - self.log.info( - f"get_music_sec_url. name:{name} url:{url} origin_url:{origin_url}" - ) - - # 电台直接返回 - if self.is_web_radio_music(name): - self.log.info("电台不会有播放时长") - return 0, url - - if self.is_web_music(name): - sec = await self._get_web_music_duration(name, url, origin_url) - else: - sec = await self._get_local_music_duration(name, url) + sec = await self._get_local_music_duration(name, url) if sec <= 0: self.log.warning(f"获取歌曲时长失败 {name} {url}") @@ -193,6 +191,28 @@ class MusicUrlHandler: url = f"{self.hostname}:{self.public_port}/music/{encoded_name}" return try_add_access_control_param(self.config, url) + @staticmethod + async def get_play_url(proxy_url): + import aiohttp + + async with aiohttp.ClientSession() as session: + async with session.get(proxy_url) as response: + # 获取最终重定向的 URL + return str(response.url) + + async def _get_online_music_sec_url(self, name, proxy_url): + """获取在线音乐的时长和播放地址 + + Args: + name: 歌曲名称 + proxy_url: 代理的播放url + Returns: + tuple: (播放时长, 原始地址) + """ + source_url = await self.get_play_url(proxy_url) + sec = await self._get_online_music_duration(name, source_url) + return sec, source_url + async def _get_web_music_duration(self, name, url, origin_url): """获取网络音乐时长 diff --git a/xiaomusic/online_music.py b/xiaomusic/online_music.py index aa82951..77acdec 100644 --- a/xiaomusic/online_music.py +++ b/xiaomusic/online_music.py @@ -4,6 +4,8 @@ """ import asyncio +import json +import base64 import ipaddress import socket from urllib.parse import urlparse @@ -11,13 +13,40 @@ from urllib.parse import urlparse import aiohttp +def _build_keyword(song_name, artist): + """ + 根据歌名和艺术家构建关键词 + + Args: + song_name: 歌名 + artist: 艺术家 + + Returns: + str: 构建后的关键词 + """ + if song_name and artist: + return f"{song_name}-{artist}" + elif song_name: + return song_name + elif artist: + return artist + return "" + + +def _parse_keyword_by_dash(keyword): + if "-" in keyword: + parts = keyword.split("-", 1) # 只分割第一个 `-` + return parts[0].strip(), parts[1].strip() + return keyword, "" + + class OnlineMusicService: """在线音乐服务 负责处理在线音乐搜索、插件调用和播放链接获取。 """ - def __init__(self, log, js_plugin_manager): + def __init__(self, log, js_plugin_manager, xiaomusic_instance=None): """初始化在线音乐服务 Args: @@ -26,6 +55,7 @@ class OnlineMusicService: """ self.log = log self.js_plugin_manager = js_plugin_manager + self.xiaomusic = xiaomusic_instance async def get_music_list_online( self, plugin="all", keyword="", page=1, limit=20, **kwargs @@ -43,26 +73,42 @@ class OnlineMusicService: dict: 搜索结果 """ self.log.info("在线获取歌曲列表!") + if not self.js_plugin_manager: + return {"success": False, "error": "JS Plugin Manager not available"} + # 初始化 artist 变量 + artist = "" + # 解析关键词,可能通过AI或直接分割 + parsed_keyword, parsed_artist = await self._parse_keyword_with_ai(keyword) + keyword = parsed_keyword or keyword + artist = parsed_artist or artist + # 获取API配置信息 openapi_info = self.js_plugin_manager.get_openapi_info() if ( openapi_info.get("enabled", False) and openapi_info.get("search_url", "") != "" ): # 开放接口获取 - return await self.js_plugin_manager.openapi_search( - openapi_info.get("search_url"), keyword + result_data = await self.js_plugin_manager.openapi_search( + url=openapi_info.get("search_url"), keyword=keyword, artist=artist ) + result_data["isOpenAPI"] = True else: - if not self.js_plugin_manager: - return {"success": False, "error": "JS Plugin Manager not available"} # 插件在线搜索 - return await self.get_music_list_mf(plugin, keyword, page, limit) + result_data = await self.get_music_list_mf( + plugin, keyword=keyword, artist=artist, page=page, limit=limit + ) + result_data["isOpenAPI"] = False + # 将歌手名当作附加值,用于歌手搜索 + result_data["artist"] = artist or "佚名" + return result_data async def get_music_list_mf( - self, plugin="all", keyword="", page=1, limit=20, **kwargs + self, plugin="all", keyword="", artist="", page=1, limit=20, **kwargs ): - """通过MusicFree插件搜索音乐列表 + self.log.info("通过MusicFree插件搜索音乐列表!") + """ + 通过MusicFree插件搜索音乐列表 Args: plugin: 插件名称,"all"表示所有插件 @@ -74,20 +120,9 @@ class OnlineMusicService: Returns: dict: 搜索结果 """ - self.log.info("通过MusicFree插件搜索音乐列表!") - # 检查JS插件管理器是否可用 if not self.js_plugin_manager: return {"success": False, "error": "JS插件管理器不可用"} - - # 如果关键词包含 '-',则提取歌手名、歌名 - if "-" in keyword: - parts = keyword.split("-") - keyword = parts[0] - artist = parts[1] - else: - artist = "" - try: if plugin == "all": # 搜索所有启用的插件 @@ -101,6 +136,287 @@ class OnlineMusicService: self.log.error(f"搜索音乐时发生错误: {e}") return {"success": False, "error": str(e)} + # 调用在线搜索歌手,添加歌手歌单并播放 + async def search_singer_play(self, did, search_key, name): + try: + # 解析歌手名,可能通过AI或直接分割 + parsed_keyword, parsed_artist = await self._parse_keyword_with_ai(name) + list_name = "_online_" + parsed_artist + artist_song_list = self.xiaomusic.get_music_list().get(list_name, []) + if len(artist_song_list) > 0: + # 如果歌单存在,则直接播放 + song_name = artist_song_list[0] + await self.xiaomusic.do_play_music_list(did, list_name, song_name) + else: + # 获取歌曲列表 + result = await self.get_music_list_online(keyword=name, limit=10) + self.log.info(f"在线搜索歌手的歌曲列表: {result}") + + if result.get("success") and result.get("total") > 0: + # 打印输出 result.data + self.log.info(f"歌曲列表: {result.get('data')}") + list_name = "_online_" + result.get("artist") + # 调用公共函数,处理歌曲信息 -> 添加歌单 -> 播放歌单 + return await self.push_music_list_play( + did=did, song_list=result.get("data"), list_name=list_name + ) + else: + return {"success": False, "error": "未找到歌曲"} + + except Exception as e: + # 记录错误日志 + self.log.error(f"searchKey {search_key} get media source failed: {e}") + return {"success": False, "error": str(e)} + + # 调用在线搜索歌手,追加歌手歌曲 + async def add_singer_song(self, list_name, name): + try: + # 获取歌曲列表 + result = await self.get_music_list_online(keyword=name, limit=10) + if result.get("success") and result.get("total") > 0: + self._handle_music_list(result.get("data"), list_name, True) + else: + return {"success": False, "error": "未找到歌曲"} + except Exception as e: + # 记录错误日志 + return {"success": False, "error": str(e)} + + """------------------------私有--------------------------""" + + async def _parse_keyword_with_ai(self, keyword): + """ + 使用AI解析关键词,如果AI不可用则使用传统分割方式 + Args: + keyword: 原始关键词 + Returns: + tuple: (parsed_keyword, parsed_artist) + """ + # 获取AI配置信息 + ai_info = self.js_plugin_manager.get_aiapi_info() + # 如果AI启用且配置完整 + if ai_info.get("enabled", False) and ai_info.get("api_key", "") != "": + try: + from xiaomusic.utils.openai_utils import ( + analyze_music_command as utils_analyze_music_command, + ) + + params = {"command": keyword, "api_key": ai_info.get("api_key")} + + # 添加可选参数 + if "base_url" in ai_info: + params["base_url"] = ai_info["base_url"] + if "model" in ai_info: + params["model"] = ai_info["model"] + + result = await utils_analyze_music_command(**params) + + if result and (result.get("name") or result.get("artist")): + song_name = result.get("name", "") + artist = result.get("artist", "") + # 构建新的关键词 + keyword = _build_keyword(song_name, artist) + self.log.info(f"AI提取到的信息: {result}") + return keyword, artist + + except Exception as e: + self.log.error(f"AI提取报错: {e}") + + # 如果AI不可用或处理失败,使用传统分割方式 + return _parse_keyword_by_dash(keyword) + + # 处理推送的歌单 + def _handle_music_list( + self, song_list=None, list_name="_online_play", append=False + ): + """ + 数据转换:将外部歌单格式转换为后端支持的格式 + 保存配置:将歌单数据保存到配置中 + 更新列表:触发后端重新生成音乐列表 + Args: + song_list: 歌曲列表 + list_name: 列表名称 + append: 是否追加 + Returns: + dict: 操作结果 + """ + try: + if len(song_list) > 1: + # 对歌单 歌名+歌手名进行去重 + song_list = self._deduplicate_song_list(song_list) + # 转换外部歌单格式为内部支持的格式 + converted_music_list = self._convert_song_list_to_music_items(song_list) + if not converted_music_list: + return {"success": False, "error": "没有有效的歌曲可以添加"} + music_library = self.xiaomusic.music_library() + # 更新配置中的音乐歌单Json + music_library.update_music_list_json(list_name, converted_music_list, append) + # 重新生成音乐列表 + music_library.gen_all_music_list() + except Exception as e: + self.log.error(f"推送歌单失败: {e}") + return {"success": False, "error": str(e)} + + # 在线播放:在线搜索、播放 + async def online_play(self, did="", arg1="", **kwargs): + await self._before_play() + # 获取搜索关键词 + parts = arg1.split("|") + search_key = parts[0] + name = parts[1] if len(parts) > 1 else search_key + if not name: + name = search_key + self.log.info(f"搜索关键字{search_key},提取的歌名{name}") + await self.search_top_one_play(did, search_key, name) + + # 播放歌手:在线搜索歌手并存为列表播放 + async def singer_play(self, did="", arg1="", **kwargs): + await self._before_play() + # 获取搜索关键词 + parts = arg1.split("|") + search_key = parts[0] + name = parts[1] if len(parts) > 1 else search_key + if not name: + name = search_key + self.log.info(f"搜索关键字{search_key},搜索歌手名{name}") + await self.search_singer_play(did, search_key, name) + + # 处理推送的歌单并播放 + async def push_music_list_play( + self, did="web_device", song_list=None, list_name="_online_play", **kwargs + ): + """ + 处理推送的歌单信息 -> 添加歌单 -> 播放歌单 + + Args: + did: 设备ID + song_list: 歌曲列表 + list_name: 列表名称 + **kwargs: 其他参数 + Returns: + dict: 操作结果 + """ + if song_list is None: + song_list = [] + + self.log.info( + f"推送歌单播放, 歌单名称: {list_name}, 歌曲数量: {len(song_list)}, 设备ID: {did}" + ) + # 验证输入参数 + if not song_list and len(song_list) > 0: + return {"success": False, "error": "歌曲列表不能为空"} + try: + self._handle_music_list(song_list, list_name) + # 如果指定了特定设备,播放歌单 + if did != "web_device" and self.xiaomusic.did_exist(did): + # 歌单推送应该是全部播放,不随机打乱 + await self.xiaomusic.set_play_type_all(did) + push_playlist = self.xiaomusic.get_music_list()[list_name] + song_name = push_playlist[0] + await self.xiaomusic.do_play_music_list(did, list_name, song_name) + return { + "success": True, + "message": f"成功推送歌单 {list_name}", + "list_name": list_name, + } + else: + return {"success": False, "error": "设备不存在!"} + except Exception as e: + self.log.error(f"推送歌单播放失败: {e}") + return {"success": False, "error": str(e)} + + # 在线搜索搜索最符合的一首歌并播放 + async def search_top_one_play(self, did, search_key, name): + try: + # 获取歌曲列表 + result = await self.get_music_list_online(keyword=name, limit=10) + + if result.get("success") and result.get("total") > 0: + # 打印输出 result.data + self.log.info(f"在线搜索的歌曲列表: {result.get('data')}") + # 根据搜素关键字,智能搜索出最符合的一条music_item + top_one_list = await self._search_top_one( + result.get("data"), search_key, name + ) + list_name = "_online_play" + # 调用公共函数,处理歌曲信息 -> 添加歌单 -> 播放歌单 + return await self.push_music_list_play( + did=did, song_list=top_one_list, list_name=list_name + ) + else: + return {"success": False, "error": "未找到歌曲"} + except Exception as e: + # 记录错误日志 + self.log.error(f"searchKey {search_key} get media source failed: {e}") + return {"success": False, "error": str(e)} + + def default_url(self): + # 先推送默认【搜索中】音频,搜索到播放url后推送给小爱 + config = self.xiaomusic.config + if config and hasattr(config, "hostname") and hasattr(config, "public_port"): + proxy_base = f"{config.hostname}:{config.public_port}" + else: + proxy_base = "http://192.168.31.241:8090" + # return proxy_base + "/static/search.mp3" + return proxy_base + "/static/silence.mp3" + + async def _before_play(self): + # 先推送默认【搜索中】音频,搜索到播放url后推送给小爱 + before_url = self.default_url() + await self.xiaomusic.play_url(self.xiaomusic.get_cur_did(), before_url) + + def _convert_song_list_to_music_items(self, song_list): + """ + 将外部歌单格式转换为内部支持的格式 + + Args: + song_list: 外部歌单数据 + + Returns: + list: 转换后的音乐项目列表 + """ + converted_music_list = [] + for item in song_list: + if isinstance(item, dict): + source_url = item.get("url", "") + is_open_api = item.get("isOpenAPI", False) + music_item = {} + # 如果是开放接口,可能需要额外处理 + if is_open_api and source_url: + # 使用代理url + music_item["url"] = self._get_openapi_proxy_url(source_url) + else: + # 返回插件源的代理接口 + music_item["url"] = self._get_plugin_proxy_url(item) + # 其他信息 + music_item["name"] = item.get("title") + "-" + item.get("artist") + music_item["type"] = item.get("type", "music") + else: + continue + + if music_item["name"]: + converted_music_list.append(music_item) + + return converted_music_list + + def _get_openapi_proxy_url(self, origin_url): + """获取OpenApi源代理URL""" + urlb64 = base64.b64encode(origin_url.encode("utf-8")).decode("utf-8") + proxy_url = ( + f"{self.xiaomusic.hostname}:{self.xiaomusic.public_port}/api/proxy/openapi-url?urlb64={urlb64}" + ) + self.log.info(f"Using proxy url: {proxy_url}") + return proxy_url + + def _get_plugin_proxy_url(self, origin_data): + """获取插件源代理URL""" + origin_data = json.dumps(origin_data) + datab64 = base64.b64encode(origin_data.encode("utf-8")).decode("utf-8") + plugin_source_url = ( + f"{self.xiaomusic.hostname}:{self.xiaomusic.public_port}/api/proxy/plugin-url?data={datab64}" + ) + self.log.info(f"plugin_source_url : {plugin_source_url}") + return plugin_source_url + async def _search_all_plugins(self, keyword, artist, page, limit): """搜索所有启用的插件 @@ -216,33 +532,19 @@ class OnlineMusicService: return {"success": False, "error": str(e)} async def _search_plugin_task(self, plugin_name, keyword, page, limit): - """单个插件搜索任务 - - Args: - plugin_name: 插件名称 - keyword: 搜索关键词 - page: 页码 - limit: 每页数量 - - Returns: - dict: 搜索结果 - - Raises: - Exception: 搜索失败时抛出异常 - """ + """单个插件搜索任务""" try: return self.js_plugin_manager.search(plugin_name, keyword, page, limit) except Exception as e: # 直接抛出异常,让 asyncio.gather 处理 raise e + # 调用MusicFree插件获取真实播放url async def get_media_source_url(self, music_item, quality: str = "standard"): """获取音乐项的媒体源URL - Args: - music_item: MusicFree插件定义的 IMusicItem + music_item : MusicFree插件定义的 IMusicItem quality: 音质参数 - Returns: dict: 包含成功状态和URL信息的字典 """ @@ -274,83 +576,56 @@ class OnlineMusicService: required_field="rawLrc", ) - async def search_music_online(self, search_key, name): - """调用MusicFree插件搜索歌曲 - - Args: - search_key: 搜索关键词 - name: 歌曲名 - - Returns: - dict: 包含成功状态和URL信息的字典 + def _deduplicate_song_list(self, song_list): """ - try: - # 获取歌曲列表 - result = await self.get_music_list_online(keyword=name, limit=10) - self.log.info(f"在线搜索歌曲列表: {result}") + 根据歌名+歌手名对歌单中歌曲进行去重 + Args: + song_list: 原始歌曲列表 + Returns: + unique_songs: 去重后的歌曲列表 + """ + seen = set() + unique_songs = [] - if result.get("success") and result.get("total") > 0: - # 打印输出 result.data - self.log.info(f"歌曲列表: {result.get('data')}") - # 根据搜索关键字,智能搜索出最符合的一条music_item - music_item = await self._search_top_one( - result.get("data"), search_key, name - ) - # 验证 music_item 是否为字典类型 - if not isinstance(music_item, dict): - self.log.error( - f"music_item should be a dict, but got {type(music_item)}: {music_item}" - ) - return {"success": False, "error": "Invalid music item format"} + for song in song_list: + # 构建唯一标识:歌名+歌手名 + song_title = song.get("title", "") + song_artist = song.get("artist", "") - # 如果是OpenAPI,则需要转换播放链接 - openapi_info = self.js_plugin_manager.get_openapi_info() - if openapi_info.get("enabled", False): - return await self.get_real_url_of_openapi(music_item.get("url")) - else: - media_source = await self.get_media_source_url(music_item) - if media_source.get("success"): - return {"success": True, "url": media_source.get("url")} - else: - return {"success": False, "error": media_source.get("error")} - else: - return {"success": False, "error": "未找到歌曲"} + # 创建唯一标识符 + unique_key = f"{song_title.lower()}_{song_artist.lower()}" - except Exception as e: - # 记录错误日志 - self.log.error(f"searchKey {search_key} get media source failed: {e}") - return {"success": False, "error": str(e)} + # 如果未见过此唯一标识,则添加到结果中 + if unique_key not in seen: + seen.add(unique_key) + unique_songs.append(song) + + self.log.info( + f"歌单去重完成,原始数量: {len(song_list)}, 去重后数量: {len(unique_songs)}" + ) + return unique_songs async def _search_top_one(self, music_items, search_key, name): - """智能搜索出最符合的一条music_item - - Args: - music_items: 音乐项目列表 - search_key: 搜索关键词 - name: 歌曲名 - - Returns: - dict: 最匹配的音乐项目,如果没有则返回None - """ + """智能搜索出最符合的一条music_item""" try: - # 如果没有音乐项目,返回None if not music_items: - return None + return [] self.log.info(f"搜索关键字: {search_key};歌名:{name}") - # 如果只有一个项目,直接返回 + + # 使用更高效的算法进行匹配 if len(music_items) == 1: - return music_items[0] + return music_items # 计算每个项目的匹配分数 + keyword = search_key.lower().strip() + if not keyword: + return [music_items[0]] # 如果没有搜索词,返回第一首 + def calculate_match_score(item): """计算匹配分数""" - title = item.get("title", "").lower() if item.get("title") else "" - artist = item.get("artist", "").lower() if item.get("artist") else "" - keyword = search_key.lower() - - if not keyword: - return 0 + title = (item.get("title", "") or "").lower() + artist = (item.get("artist", "") or "").lower() score = 0 # 歌曲名匹配权重 @@ -393,12 +668,12 @@ class OnlineMusicService: # 按匹配分数排序,返回分数最高的项目 sorted_items = sorted(music_items, key=calculate_match_score, reverse=True) - return sorted_items[0] + return [sorted_items[0]] except Exception as e: self.log.error(f"_search_top_one error: {e}") # 出现异常时返回第一个项目 - return music_items[0] if music_items else None + return [music_items[0]] if music_items else [] async def _call_plugin_method( self, diff --git a/xiaomusic/static/default/setting.html b/xiaomusic/static/default/setting.html index 473e599..548d7f4 100644 --- a/xiaomusic/static/default/setting.html +++ b/xiaomusic/static/default/setting.html @@ -233,6 +233,8 @@ var vConsole = new window.VConsole(); + + + - +
+ + + +
- 请输入关键词开始搜索 + 输入关键词,支持 '歌曲名-艺术家名' 格式搜索!
@@ -460,6 +796,12 @@
+ + + + - - + +
`).join(''); + songList = results; resultsDiv.innerHTML = html; } @@ -647,52 +1015,84 @@ return localStorage.getItem('cur_did') || ''; } - // 播放音乐(设备播放) - async function playMusic(mediaItem) { + // 推送音乐(设备播放) + async function pushMusic(mediaItem) { try { const deviceId = getCurrentDeviceId(); if (!deviceId || deviceId === '') { alert('请先设置设备!'); return; } - // 构造正确的请求数据 - const requestData = {...mediaItem, did: deviceId}; - - const response = await fetch('/api/play/online', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(requestData) - }); - - const data = await response.json(); - // 根据新返回结构调整处理逻辑 - if (data && data[0]) { - let itemResult = data[0]; - if (itemResult.code === 0) { - // 显示服务端返回的具体消息而不是通用提示 - alert('推送成功!'); - } else { - alert('播放失败: '+itemResult.msg); - } - } else { - alert('播放失败: ' + (data.error || '未知错误')); + if (!mediaItem) { + alert('歌曲不存在!'); + return; } + const mediaItemList = [mediaItem]; + let playlistName = '_online_webPush'; + // 批量推送音乐到设备 + await pushList(playlistName, deviceId, mediaItemList); } catch (error) { alert('播放出错: ' + error.message); } } - // 网页播放音乐 + // 全部推送音乐到设备 + async function pushAllMusic() { + try { + const deviceId = getCurrentDeviceId(); + if (!deviceId || deviceId === '') { + alert('请先设置设备!'); + return; + } + if (songList.length === 0) { + alert('请先搜索歌曲!'); + return; + } + console.log("songList",songList) + // 格式化musicList + let playlistName = '_online_webPush'; + // 批量推送音乐到设备 + await pushList(playlistName, deviceId, songList); + } catch (error) { + alert('播放出错: ' + error.message); + } + } + + // 推送音乐列表到设备 + async function pushList(playlistName, deviceId, songList){ + const response = await fetch('/api/device/pushList', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify( + { playlistName: playlistName,did: deviceId, songList: songList} + ) + }); + const data = await response.json(); + // 根据新返回结构调整处理逻辑 + if (data) { + if (data.success) { + // 显示服务端返回的具体消息而不是通用提示 + alert('推送成功!'); + } else { + alert('推送失败: '+data.error); + } + } else { + alert('播放失败: ' + ('未知错误')); + } + } + + /*=====================网页播放音乐==========================*/ + + // 网页播放音乐 - 优化版本 async function webPlayMusic(mediaItem) { try { let playUrl; - if (mediaItem && mediaItem.isOpenAPI) {//在线接口 - //playUrl = mediaItem.url - // 嗅探真实的音乐URL - playUrl = await sniffRealMusicUrl(mediaItem.url); - } else {//插件方式 + if (mediaItem && isOpenAPI) { + // playUrl = await sniffRealMusicUrl(mediaItem.url); + playUrl = mediaItem.url; + } else { const response = await fetch('/api/play/getMediaSource', { method: 'POST', headers: { @@ -701,14 +1101,14 @@ body: JSON.stringify(mediaItem) }); const data = await response.json(); - playUrl = data.url + playUrl = data.url; } if (playUrl) { // 显示播放器 const player = document.getElementById('musicPlayer'); player.style.display = 'block'; - // 指定底部高度,解决播放器显示问题 + const container = document.querySelector('.container'); container.style.paddingBottom = '80px'; @@ -722,24 +1122,38 @@ if (mediaItem.artwork) { coverPlaceholder.textContent = ''; coverImage.src = mediaItem.artwork; - coverImage.style.display = 'block'; // 显示图片 + coverImage.style.display = 'block'; } else { coverPlaceholder.textContent = '暂无封面'; - coverImage.style.display = 'none'; // 隐藏图片,显示默认文本 + coverImage.style.display = 'none'; } // 设置音频源 const audio = document.getElementById('audioPlayer'); audio.src = playUrl; + // 重置播放按钮图标 document.getElementById('playIcon').style.display = 'none'; document.getElementById('pauseIcon').style.display = 'block'; - // 获取并显示歌词 - await fetchAndDisplayLyrics(mediaItem); + + // 移动端不显示歌词 + if (!isMobile()) { + // 获取并显示歌词 + await fetchAndDisplayLyrics(mediaItem); + } else { + // 移动端隐藏歌词容器 + document.getElementById('lyricContainer').style.display = 'none'; + } + + // 监听元数据加载完成,获取时长信息 + audio.onloadedmetadata = function() { + console.log('音频时长:', audio.duration + '秒'); + // 可以在这里更新UI显示时长信息 + }; // 开始播放 - audio.play(); + await audio.play(); } else { - alert('插件获取播放链接失败: '); + alert('插件获取播放链接失败'); } } catch (error) { alert('获取播放链接出错: ' + error.message); @@ -747,6 +1161,7 @@ } + // 关闭播放器 function closePlayer() { const player = document.getElementById('musicPlayer'); @@ -759,6 +1174,7 @@ document.getElementById('lyricContent').innerHTML = ''; currentLyrics = []; lyricLines = []; + songList = []; // 移除音频时间更新监听器 audio.removeEventListener('timeupdate', updateLyricHighlight); @@ -777,18 +1193,34 @@ return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`; } - // 回车键搜索 - document.getElementById('searchInput').addEventListener('keypress', function(e) { - if (e.key === 'Enter') { + // 综合处理多种事件 + const searchInput = document.getElementById('searchInput'); + const handleSearch = (e) => { + if (e.key === 'Enter' || e.type === 'input') { + e.preventDefault(); searchMusic(); + // 确保键盘关闭 + searchInput.blur(); } - }); + }; + + searchInput.addEventListener('keydown', handleSearch); + searchInput.addEventListener('keypress', handleSearch); + + // 检查是否为移动端 + function isMobile() { + return window.innerWidth <= 768; + } // 获取并显示歌词 async function fetchAndDisplayLyrics(mediaItem) { + // 移动端不加载歌词 + if (isMobile()) { + return; + } try { let lrcText - if (mediaItem && mediaItem.isOpenAPI) {//在线接口 + if (mediaItem && isOpenAPI) {//在线接口 // 调用OpenApi接口 GET获取歌词 const response = await fetch(mediaItem.lrc, { method: 'GET', @@ -934,6 +1366,86 @@ } } + // 添加播放队列和状态管理 + let playQueue = []; // 播放队列 + let currentPlayIndex = 0; // 当前播放索引 + + // Web 端播放全部功能 + async function webPlayAllMusic() { + if (songList.length === 0) { + alert('请先搜索歌曲!'); + return; + } + // 设置播放队列 + playQueue = [...songList]; + currentPlayIndex = -1; + // 开始播放第一首 + await playNextInWeb(); + } + + // 播放上一曲 + async function playPrevInWeb() { + if (playQueue.length === 0) { + return; + } + + currentPlayIndex--; + if (currentPlayIndex < 0) { + currentPlayIndex = playQueue.length - 1; // 循环到最后一首 + } + + const currentSong = playQueue[currentPlayIndex]; + + try { + await webPlayMusic(currentSong); + const audio = document.getElementById('audioPlayer'); + audio.onended = handleTrackEnd; + } catch (error) { + console.error('播放失败:', error); + currentPlayIndex++; + if (currentPlayIndex >= playQueue.length) currentPlayIndex = 0; + } + } + + // 播放下一曲函数,添加边界检查 + async function playNextInWeb() { + if (playQueue.length === 0) { + return; + } + + currentPlayIndex++; + if (currentPlayIndex >= playQueue.length) { + currentPlayIndex = 0; // 循环播放 + } + + const currentSong = playQueue[currentPlayIndex]; + + try { + await webPlayMusic(currentSong); + const audio = document.getElementById('audioPlayer'); + audio.onended = handleTrackEnd; + } catch (error) { + console.error('播放失败:', error); + currentPlayIndex++; + if (currentPlayIndex >= playQueue.length) currentPlayIndex = -1; + await playNextInWeb(); + } + } + + // 处理单曲播放结束 + function handleTrackEnd() { + currentPlayIndex++; + if (currentPlayIndex < playQueue.length) { + playNextInWeb(); + } else { + console.log('所有歌曲播放完毕'); + // 可选:重置播放器或显示播放完成提示 + currentPlayIndex = 0; + } + } + + + diff --git a/xiaomusic/static/onlineSearch/setting.html b/xiaomusic/static/onlineSearch/setting.html index a8e6c58..74d3f2d 100644 --- a/xiaomusic/static/onlineSearch/setting.html +++ b/xiaomusic/static/onlineSearch/setting.html @@ -3,13 +3,15 @@ - MusicFree 插件设置 + 在线搜索配置 @@ -283,7 +268,7 @@

插件&接口设置

管理您的插件和在线接口

- +
返回搜索 @@ -297,9 +282,12 @@ 刷新插件 - OpenAPI + TuneHub API + MusicFreePlugins + + diff --git a/xiaomusic/static/tailwind/setting.html b/xiaomusic/static/tailwind/setting.html index 189bd2d..6b34330 100644 --- a/xiaomusic/static/tailwind/setting.html +++ b/xiaomusic/static/tailwind/setting.html @@ -275,6 +275,18 @@
+
+ + +
+
+ + +
@@ -655,4 +667,4 @@ - \ No newline at end of file + diff --git a/xiaomusic/utils/openai_utils.py b/xiaomusic/utils/openai_utils.py new file mode 100644 index 0000000..8becd47 --- /dev/null +++ b/xiaomusic/utils/openai_utils.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +"""用于AI大模型调用的工具类""" +import asyncio +import json +import logging +from typing import Any + +import aiohttp + +log = logging.getLogger(__package__) + +# 简化的音乐分析提示,专注于快速提取 +MUSIC_ANALYSIS_PROMPT = """ +你是一个音乐播放口令分析师,专门负责从用户指令中提取歌曲名和歌手名信息。 + +任务要求: +1. 识别用户指令中的歌曲名和歌手名 +2. 按照JSON格式返回结果:{"name": "歌曲名", "artist": "歌手名"} +3. 如果只识别到歌曲名,返回:{"name": "歌曲名", "artist": ""} +4. 如果只识别到歌手名,返回:{"name": "", "artist": "歌手名"} +5. 如果识别出多个歌名名,返回:{"name": "", "artist": "歌手名1,歌手名2"} +6. 如果都没有识别到,返回:{} +7. 不要添加任何额外解释或文字,只返回JSON格式结果 +8. 特别要注意一些歌曲名称中包含'的'字的歌,不要识别错误了。如用户指令:你的答案,应返回:{"name": "你的答案", "artist": ""} +""" + + +def create_openai_client(base_url: str, api_key: str) -> dict: + """ + 创建API客户端配置,返回包含base_url和api_key的字典 + + Args: + base_url: API的基础URL + api_key: API密钥 + + Returns: + 包含API配置信息的字典 + """ + return { + "base_url": base_url, + "api_key": api_key, + } + + +# 默认使用通义千问API【阿里云百炼】: qwen-flash +async def call_openai_chat( + client: dict, + messages: list[dict[str, str]], + model: str = "qwen-flash", + temperature: float = 0.1, # 更低的温度值以获得更一致的结果 + max_tokens: int | None = 100, # 限制输出长度以提高速度 + timeout: int = 10, # 减少超时时间 + extra_body: dict[str, Any] | None = None, +) -> str | None: + """ + 异步调用API聊天接口 + + Args: + client: 包含base_url和api_key的API配置字典 + messages: 消息列表,每个消息包含role和content + model: 使用的模型名称 + temperature: 控制输出随机性的参数 + max_tokens: 最大输出token数 + timeout: 请求超时时间(秒) + extra_body: 额外的请求体参数 + + Returns: + 模型返回的内容,失败时返回None + """ + try: + base_url = client["base_url"] + api_key = client["api_key"] + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + # 准备请求数据 + data = { + "model": model, + "messages": messages, + "temperature": temperature, + } + + if max_tokens: + data["max_tokens"] = max_tokens + + # 如果有额外参数,合并到请求数据中 + if extra_body: + data.update(extra_body) + + # 使用aiohttp进行异步请求 + async with aiohttp.ClientSession() as session: + async with session.post( + f"{base_url}/chat/completions", + headers=headers, + json=data, + timeout=aiohttp.ClientTimeout(total=timeout), + ) as response: + if response.status == 200: + result = await response.json() + content = result["choices"][0]["message"]["content"] + log.debug( + f"API call successful, response length: {len(content) if content else 0}" + ) + return content + else: + log.warning( + f"API call failed with status {response.status}: {await response.text()}" + ) + return None + + except asyncio.TimeoutError: + log.warning(f"API call timed out after {timeout} seconds") + return None + except Exception as e: + log.warning(f"Error calling API: {e}") + return None + + +async def analyze_music_command( + command: str, + # 默认使用通义千问API【阿里云百炼】: qwen-flash + base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1", + api_key: str = "", + model: str = "qwen-flash", + temperature: float = 0.1, # 更低的温度值以获得更一致、更快的结果 +) -> dict[str, str]: + """ + 快速分析音乐播放口令,提取歌曲名和歌手名 + + Args: + command: 用户的音乐播放指令 + base_url: API的基础URL + api_key: API密钥 + model: 使用的模型名称 + temperature: 控制输出随机性的参数(较低值保持一致性) + + Returns: + 包含歌曲名和歌手名的字典,格式为 {"name": "歌曲名", "artist": "歌手名"} + """ + try: + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + # 准备请求数据 + data = { + "model": model, + "messages": [ + {"role": "system", "content": MUSIC_ANALYSIS_PROMPT}, + {"role": "user", "content": f"用户指令:{command}"}, + ], + "temperature": temperature, + "max_tokens": 100, # 限制输出长度以提高速度 + } + + # 使用aiohttp进行异步请求 + async with aiohttp.ClientSession() as session: + async with session.post( + f"{base_url}/chat/completions", + headers=headers, + json=data, + timeout=aiohttp.ClientTimeout(total=10), # 减少超时时间 + ) as response: + if response.status == 200: + result = await response.json() + content = result["choices"][0]["message"]["content"] + + # 快速提取JSON部分 + start = content.find("{") + end = content.rfind("}") + 1 + if start != -1 and end != 0: + json_str = content[start:end] + result = json.loads(json_str) + return { + "name": result.get("name", ""), + "artist": result.get("artist", ""), + } + else: + log.debug( + f"API call failed with status {response.status}: {await response.text()}" + ) + except (asyncio.TimeoutError, json.JSONDecodeError, Exception) as e: + log.debug(f"Music command analysis failed: {e}") + + return {} + + +def format_openai_messages(conversation_history: list[str]) -> list[dict[str, str]]: + """ + 将对话历史格式化为API所需的格式 + + Args: + conversation_history: 对话历史列表,交替包含用户和助手的消息 + + Returns: + 格式化后的消息列表 + """ + messages = [] + for i, msg in enumerate(conversation_history): + role = "user" if i % 2 == 0 else "assistant" + messages.append({"role": role, "content": msg}) + return messages + + +async def stream_openai_chat( + client: dict, + messages: list[dict[str, str]], + model: str = "TBStars2-200B-A13B", + temperature: float = 0.7, +) -> str | None: + """ + 流式调用API聊天接口 + + Args: + client: 包含base_url和api_key的API配置字典 + messages: 消息列表 + model: 使用的模型名称 + temperature: 控制输出随机性的参数 + + Returns: + 完整的流式响应内容,失败时返回None + """ + try: + base_url = client["base_url"] + api_key = client["api_key"] + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + # 准备请求数据 + data = { + "model": model, + "messages": messages, + "temperature": temperature, + "stream": True, # 启用流式响应 + } + + # 使用aiohttp进行异步请求 + async with aiohttp.ClientSession() as session: + async with session.post( + f"{base_url}/chat/completions", headers=headers, json=data + ) as response: + if response.status == 200: + full_content = "" + # 逐行读取流式响应 + async for line in response.content: + line_str = line.decode("utf-8").strip() + + if line_str.startswith("data: ") and line_str != "data: [DONE]": + data_str = line_str[6:] # 移除 'data: ' 前缀 + try: + chunk_data = json.loads(data_str) + if chunk_data["choices"] and chunk_data["choices"][0][ + "delta" + ].get("content"): + content_piece = chunk_data["choices"][0]["delta"][ + "content" + ] + full_content += content_piece + # 可以在这里实时处理流式返回的内容 + print(content_piece, end="", flush=True) + except json.JSONDecodeError: + continue + + print() # 换行 + return full_content + else: + log.error( + f"Stream API call failed with status {response.status}: {await response.text()}" + ) + return None + + except Exception as e: + log.error(f"Error in stream_openai_chat: {e}") + return None diff --git a/xiaomusic/xiaomusic.py b/xiaomusic/xiaomusic.py index 7b9fd4c..88b0afa 100644 --- a/xiaomusic/xiaomusic.py +++ b/xiaomusic/xiaomusic.py @@ -145,6 +145,7 @@ class XiaoMusic: url_cache=self.url_cache, get_filename_func=self._music_library.get_filename, is_web_music_func=self._music_library.is_web_music, + is_online_music_func=self._music_library.is_online_music, is_web_radio_music_func=self._music_library.is_web_radio_music, is_need_use_play_music_api_func=self._music_library.is_need_use_play_music_api, ) @@ -153,6 +154,7 @@ class XiaoMusic: self._online_music_service = OnlineMusicService( log=self.log, js_plugin_manager=self.js_plugin_manager, + xiaomusic_instance=self, # 传递xiaomusic实例 ) # 初始化设备管理器(在配置准备好之后) @@ -220,6 +222,10 @@ class XiaoMusic: plugin_name, method_name, music_item, result_key, required_field, **kwargs ) + def music_library(self): + """获取音乐库管理器实例""" + return self._music_library + def init_config(self): self.music_path = self.config.music_path self.download_path = self.config.download_path @@ -392,10 +398,12 @@ class XiaoMusic: """获取音乐文件路径(委托给 music_library)""" return self._music_library.get_filename(name) + # 判断本地音乐是否存在,网络歌曲不判断 def is_music_exist(self, name): """判断本地音乐是否存在(委托给 music_library)""" return self._music_library.is_music_exist(name) + # 是否是网络电台 def is_web_radio_music(self, name): """是否是网络电台(委托给 music_library)""" return self._music_library.is_web_radio_music(name) @@ -404,6 +412,7 @@ class XiaoMusic: """是否是网络歌曲(委托给 music_library)""" return self._music_library.is_web_music(name) + # 是否是需要通过api获取播放链接的网络歌曲 def is_need_use_play_music_api(self, name): """是否需要通过API获取播放链接(委托给 music_library)""" return self._music_library.is_need_use_play_music_api(name) @@ -412,13 +421,14 @@ class XiaoMusic: """获取音乐标签信息(委托给 music_library)""" return self._music_library.get_music_tags(name) + # 修改标签信息 def set_music_tag(self, name, info): """修改标签信息(委托给 music_library)""" return self._music_library.set_music_tag(name, info) - async def get_music_sec_url(self, name, true_url=None): + async def get_music_sec_url(self, name, cur_playlist): """获取歌曲播放时长和播放地址(委托给 music_url_handler)""" - return await self._music_url_handler.get_music_sec_url(name, true_url) + return await self._music_url_handler.get_music_sec_url(name, cur_playlist) async def get_music_url(self, name): """获取音乐播放地址(委托给 music_url_handler)""" @@ -499,6 +509,7 @@ class XiaoMusic: session, self.do_check_cmd, self.reset_timer_when_answer ) + # 匹配命令 async def do_check_cmd(self, did="", query="", ctrl_panel=True, **kwargs): """检查并执行命令(委托给 command_handler)""" return await self._command_handler.do_check_cmd( @@ -533,10 +544,12 @@ class XiaoMusic: async def check_replay(self, did): return await self.devices[did].check_replay() + # 检查是否匹配到完全一样的指令 def check_full_match_cmd(self, did, query, ctrl_panel): """检查是否完全匹配命令(委托给 command_handler)""" return self._command_handler.check_full_match_cmd(did, query, ctrl_panel) + # 匹配命令 def match_cmd(self, did, query, ctrl_panel): """匹配命令(委托给 command_handler)""" return self._command_handler.match_cmd(did, query, ctrl_panel) @@ -550,7 +563,7 @@ class XiaoMusic: # 播放一个 url async def play_url(self, did="", arg1="", **kwargs): - self.log.info(f"手动播放链接:{arg1}") + self.log.info(f"手动推送链接:{arg1}") url = arg1 return await self.devices[did].group_player_play(url) @@ -620,7 +633,13 @@ class XiaoMusic: self._music_library.gen_all_music_list() self.update_all_playlist() - # ===========================MusicFree插件函数================================ + + # ===========================在线搜索函数================================ + + + def default_url(self): + """委托给 online_music_service""" + return self._online_music_service.default_url() # 在线获取歌曲列表(委托给 online_music_service) async def get_music_list_online( @@ -638,11 +657,11 @@ class XiaoMusic: # 调用MusicFree插件获取歌曲列表(委托给 online_music_service) async def get_music_list_mf( - self, plugin="all", keyword="", page=1, limit=20, **kwargs + self, plugin="all", keyword="", artist="", page=1, limit=20, **kwargs ): """委托给 online_music_service""" return await self._online_music_service.get_music_list_mf( - plugin, keyword, page, limit, **kwargs + plugin, keyword, artist, page, limit, **kwargs ) # 调用MusicFree插件获取真实播放url(委托给 online_music_service) @@ -657,10 +676,47 @@ class XiaoMusic: """委托给 online_music_service""" return await self._online_music_service.get_media_lyric(music_item) - # 调用在线搜索歌曲(委托给 online_music_service) - async def search_music_online(self, search_key, name): + # 在线搜索歌手,添加歌手歌单并播放 + async def search_singer_play(self, did, search_key, name): """委托给 online_music_service""" - return await self._online_music_service.search_music_online(search_key, name) + return await self._online_music_service.search_singer_play( + did, search_key, name + ) + + # 追加歌手歌曲 + async def add_singer_song(self, list_name, name): + """委托给 online_music_service""" + return await self._online_music_service.add_singer_song( + list_name, name + ) + + # 在线搜索搜索最符合的一首歌并播放 + async def search_top_one_play(self, did, search_key, name): + """委托给 online_music_service""" + return await self._online_music_service.search_top_one_play( + did, search_key, name + ) + + # 在线播放:在线搜索、播放 + async def online_play(self, did="", arg1="", **kwargs): + """委托给 online_music_service""" + return await self._online_music_service.online_play( + did, arg1, **kwargs + ) + + # 播放歌手:在线搜索歌手并存为列表播放 + async def singer_play(self, did="", arg1="", **kwargs): + """委托给 online_music_service""" + return await self._online_music_service.singer_play( + did, arg1, **kwargs + ) + + # 处理推送的歌单并播放 + async def push_music_list_play(self, did, song_list, list_name): + """委托给 online_music_service""" + return await self._online_music_service.push_music_list_play( + did, song_list, list_name + ) # =========================================================== @@ -739,32 +795,6 @@ class XiaoMusic: did, name, search_key, exact=False, update_cur_list=False ) - # 在线播放:在线搜索、播放 - async def online_play(self, did="", arg1="", **kwargs): - # 先推送默认【搜索中】音频,搜索到播放url后推送给小爱 - config = self.config - if config and hasattr(config, "hostname") and hasattr(config, "public_port"): - proxy_base = f"{config.hostname}:{config.public_port}" - else: - proxy_base = "http://192.168.31.241:8090" - search_audio = proxy_base + "/static/search.mp3" - await self.play_url(self.get_cur_did(), search_audio) - - # TODO 添加一个定时器,4秒后触发 - - # 获取搜索关键词 - parts = arg1.split("|") - search_key = parts[0] - name = parts[1] if len(parts) > 1 else search_key - if not name: - name = search_key - self.log.info(f"搜索关键字{search_key},搜索歌名{name}") - result = await self.search_music_online(search_key, name) - # 搜索成功,则直接推送url播放 - if result.get("success", False): - url = result.get("url", "") - # 播放歌曲 - await self.devices[did].play_music(name, true_url=url) # 后台搜索播放 async def do_play( @@ -836,24 +866,29 @@ class XiaoMusic: """保存自定义播放列表(委托给 music_library)""" self._music_library.save_custom_play_list(self.save_cur_config) + # 新增歌单 def play_list_add(self, name): """新增歌单(委托给 music_library)""" return self._music_library.play_list_add(name, self.save_cur_config) + # 移除歌单 def play_list_del(self, name): """移除歌单(委托给 music_library)""" return self._music_library.play_list_del(name, self.save_cur_config) + # 修改歌单名字 def play_list_update_name(self, oldname, newname): """修改歌单名字(委托给 music_library)""" return self._music_library.play_list_update_name( oldname, newname, self.save_cur_config ) + # 获取所有自定义歌单 def get_play_list_names(self): """获取所有自定义歌单(委托给 music_library)""" return self._music_library.get_play_list_names() + # 获取歌单中所有歌曲 def play_list_musics(self, name): """获取歌单中所有歌曲(委托给 music_library)""" return self._music_library.play_list_musics(name) @@ -864,12 +899,14 @@ class XiaoMusic: name, music_list, self.save_cur_config ) + # 歌单新增歌曲 def play_list_add_music(self, name, music_list): """歌单新增歌曲(委托给 music_library)""" return self._music_library.play_list_add_music( name, music_list, self.save_cur_config ) + # 歌单移除歌曲 def play_list_del_music(self, name, music_list): """歌单移除歌曲(委托给 music_library)""" return self._music_library.play_list_del_music( @@ -888,10 +925,12 @@ class XiaoMusic: volume = int(arg1) return await self.devices[did].set_volume(volume) + # 搜索音乐 def searchmusic(self, name): """搜索音乐(委托给 music_library)""" return self._music_library.searchmusic(name) + # 获取播放列表 def get_music_list(self): """获取播放列表(委托给 music_library)""" return self._music_library.get_music_list()