feat: 支持多设备分开播放 see #65

This commit is contained in:
涵曦 2024-07-10 13:26:01 +00:00
parent aa698667c9
commit 043a9303a5
11 changed files with 904 additions and 678 deletions

View File

@ -2,7 +2,8 @@ name: ci
on: on:
push: push:
branches: [ main ] branches:
- "*"
workflow_dispatch: workflow_dispatch:
jobs: jobs:

View File

@ -1,11 +1,11 @@
{ {
"hardware": "L07A",
"account": "", "account": "",
"password": "", "password": "",
"mi_did": "", "mi_did": "",
"cookie": "", "cookie": "",
"verbose": false, "verbose": false,
"music_path": "music", "music_path": "music",
"download_path": "",
"conf_path": null, "conf_path": null,
"hostname": "192.168.2.5", "hostname": "192.168.2.5",
"port": 8090, "port": 8090,
@ -13,12 +13,12 @@
"proxy": null, "proxy": null,
"search_prefix": "bilisearch:", "search_prefix": "bilisearch:",
"ffmpeg_location": "./ffmpeg/bin", "ffmpeg_location": "./ffmpeg/bin",
"active_cmd": "play,random_play,playlocal,play_music_list,stop", "active_cmd": "play,set_random_play,playlocal,play_music_list,stop",
"exclude_dirs": "@eaDir", "exclude_dirs": "@eaDir",
"music_path_depth": 10, "music_path_depth": 10,
"disable_httpauth": true, "disable_httpauth": true,
"httpauth_username": "admin", "httpauth_username": "",
"httpauth_password": "admin", "httpauth_password": "",
"music_list_url": "", "music_list_url": "",
"music_list_json": "", "music_list_json": "",
"disable_download": false, "disable_download": false,
@ -29,12 +29,10 @@
"下一首": "play_next", "下一首": "play_next",
"单曲循环": "set_play_type_one", "单曲循环": "set_play_type_one",
"全部循环": "set_play_type_all", "全部循环": "set_play_type_all",
"随机播放": "random_play", "随机播放": "set_random_play",
"分钟后关机": "stop_after_minute", "分钟后关机": "stop_after_minute",
"播放列表": "play_music_list", "播放列表": "play_music_list",
"刷新列表": "gen_music_list", "刷新列表": "gen_music_list",
"set_volume#": "set_volume",
"get_volume#": "get_volume",
"本地播放歌曲": "playlocal", "本地播放歌曲": "playlocal",
"放歌曲": "play", "放歌曲": "play",
"暂停": "stop", "暂停": "stop",
@ -44,8 +42,6 @@
"测试链接": "exec#httpget(\"https://github.com/hanxi/xiaomusic\")" "测试链接": "exec#httpget(\"https://github.com/hanxi/xiaomusic\")"
}, },
"key_match_order": [ "key_match_order": [
"set_volume#",
"get_volume#",
"分钟后关机", "分钟后关机",
"播放歌曲", "播放歌曲",
"下一首", "下一首",
@ -71,11 +67,15 @@
"fuzzy_match_cutoff": 0.6, "fuzzy_match_cutoff": 0.6,
"enable_fuzzy_match": true, "enable_fuzzy_match": true,
"stop_tts_msg": "收到,再见", "stop_tts_msg": "收到,再见",
"enable_config_example": true,
"keywords_playlocal": "播放本地歌曲,本地播放歌曲", "keywords_playlocal": "播放本地歌曲,本地播放歌曲",
"keywords_play": "播放歌曲,放歌曲", "keywords_play": "播放歌曲,放歌曲",
"keywords_stop": "关机,暂停,停止,停止播放", "keywords_stop": "关机,暂停,停止,停止播放",
"user_key_word_dict": { "user_key_word_dict": {
"测试自定义口令": "exec#code1(\"hello\")", "测试自定义口令": "exec#code1(\"hello\")",
"测试链接": "exec#httpget(\"https://github.com/hanxi/xiaomusic\")" "测试链接": "exec#httpget(\"https://github.com/hanxi/xiaomusic\")"
} },
"enable_force_stop": false,
"devices": {},
"group_list": ""
} }

View File

@ -18,12 +18,10 @@ def default_key_word_dict():
"下一首": "play_next", "下一首": "play_next",
"单曲循环": "set_play_type_one", "单曲循环": "set_play_type_one",
"全部循环": "set_play_type_all", "全部循环": "set_play_type_all",
"随机播放": "random_play", "随机播放": "set_random_play",
"分钟后关机": "stop_after_minute", "分钟后关机": "stop_after_minute",
"播放列表": "play_music_list", "播放列表": "play_music_list",
"刷新列表": "gen_music_list", "刷新列表": "gen_music_list",
"set_volume#": "set_volume",
"get_volume#": "get_volume",
} }
@ -43,8 +41,6 @@ KEY_WORD_ARG_BEFORE_DICT = {
# 口令匹配优先级 # 口令匹配优先级
def default_key_match_order(): def default_key_match_order():
return [ return [
"set_volume#",
"get_volume#",
"分钟后关机", "分钟后关机",
"播放歌曲", "播放歌曲",
"下一首", "下一首",
@ -57,12 +53,22 @@ def default_key_match_order():
] ]
@dataclass
class Device:
did: str = ""
device_id: str = ""
hardware: str = ""
name: str = ""
play_type: int = ""
cur_music: str = ""
cur_playlist: str = ""
@dataclass @dataclass
class Config: class Config:
account: str = os.getenv("MI_USER", "") account: str = os.getenv("MI_USER", "")
password: str = os.getenv("MI_PASS", "") password: str = os.getenv("MI_PASS", "")
mi_did: str = os.getenv("MI_DID", "") # 逗号分割支持多设备 mi_did: str = os.getenv("MI_DID", "") # 逗号分割支持多设备
hardware: str = os.getenv("MI_HARDWARE", "L07A") # 逗号分割支持多设备
cookie: str = "" cookie: str = ""
verbose: bool = os.getenv("XIAOMUSIC_VERBOSE", "").lower() == "true" verbose: bool = os.getenv("XIAOMUSIC_VERBOSE", "").lower() == "true"
music_path: str = os.getenv( music_path: str = os.getenv(
@ -79,7 +85,7 @@ class Config:
) # "bilisearch:" or "ytsearch:" ) # "bilisearch:" or "ytsearch:"
ffmpeg_location: str = os.getenv("XIAOMUSIC_FFMPEG_LOCATION", "./ffmpeg/bin") ffmpeg_location: str = os.getenv("XIAOMUSIC_FFMPEG_LOCATION", "./ffmpeg/bin")
active_cmd: str = os.getenv( active_cmd: str = os.getenv(
"XIAOMUSIC_ACTIVE_CMD", "play,random_play,playlocal,play_music_list,stop" "XIAOMUSIC_ACTIVE_CMD", "play,set_random_play,playlocal,play_music_list,stop"
) )
exclude_dirs: str = os.getenv("XIAOMUSIC_EXCLUDE_DIRS", "@eaDir") exclude_dirs: str = os.getenv("XIAOMUSIC_EXCLUDE_DIRS", "@eaDir")
music_path_depth: int = int(os.getenv("XIAOMUSIC_MUSIC_PATH_DEPTH", "10")) music_path_depth: int = int(os.getenv("XIAOMUSIC_MUSIC_PATH_DEPTH", "10"))
@ -123,7 +129,10 @@ class Config:
enable_force_stop: bool = ( enable_force_stop: bool = (
os.getenv("XIAOMUSIC_ENABLE_FORCE_STOP", "false").lower() == "true" os.getenv("XIAOMUSIC_ENABLE_FORCE_STOP", "false").lower() == "true"
) )
play_type: int = int(os.getenv("XIAOMUSIC_PLAY_TYPE", "2")) devices: dict[str, Device] = field(default_factory=dict)
group_list: str = os.getenv(
"XIAOMUSIC_GROUP_LIST", ""
) # did2:group_name,did2:group_name
def append_keyword(self, keys, action): def append_keyword(self, keys, action):
for key in keys.split(","): for key in keys.split(","):
@ -150,7 +159,7 @@ class Config:
if self.enable_config_example: if self.enable_config_example:
with open("config-example.json", "w") as f: with open("config-example.json", "w") as f:
data = asdict(self) data = asdict(self)
json.dump(data, f, ensure_ascii=False, indent=4) json.dump(data, f, ensure_ascii=False, indent=2)
@classmethod @classmethod
def from_options(cls, options: argparse.Namespace) -> Config: def from_options(cls, options: argparse.Namespace) -> Config:
@ -171,6 +180,10 @@ class Config:
converted_value = False converted_value = False
if str(v).lower() == "true": if str(v).lower() == "true":
converted_value = True converted_value = True
elif expected_type == dict[str, Device]:
converted_value = {}
for kk, vv in v.items():
converted_value[kk] = Device(**vv)
else: else:
converted_value = expected_type(v) converted_value = expected_type(v)
return converted_value return converted_value
@ -192,7 +205,7 @@ class Config:
return result return result
def update_config(self, data): def update_config(self, data):
type_hints = get_type_hints(self) type_hints = get_type_hints(self, globals(), locals())
for k, v in data.items(): for k, v in data.items():
converted_value = self.convert_value(k, v, type_hints) converted_value = self.convert_value(k, v, type_hints)

View File

@ -9,3 +9,13 @@ SUPPORT_MUSIC_TYPE = [
LATEST_ASK_API = "https://userprofile.mina.mi.com/device_profile/v2/conversation?source=dialogu&hardware={hardware}&timestamp={timestamp}&limit=2" LATEST_ASK_API = "https://userprofile.mina.mi.com/device_profile/v2/conversation?source=dialogu&hardware={hardware}&timestamp={timestamp}&limit=2"
COOKIE_TEMPLATE = "deviceId={device_id}; serviceToken={service_token}; userId={user_id}" COOKIE_TEMPLATE = "deviceId={device_id}; serviceToken={service_token}; userId={user_id}"
PLAY_TYPE_ONE = 0 # 单曲循环
PLAY_TYPE_ALL = 1 # 全部循环
PLAY_TYPE_RND = 2 # 随机播放
PLAY_TYPE_TTS = {
PLAY_TYPE_ONE: "已经设置为单曲循环",
PLAY_TYPE_ALL: "已经设置为全部循环",
PLAY_TYPE_RND: "已经设置为随机播放",
}

View File

@ -53,11 +53,29 @@ def getversion():
@app.route("/getvolume", methods=["GET"]) @app.route("/getvolume", methods=["GET"])
@auth.login_required @auth.login_required
def getvolume(): async def getvolume():
volume = xiaomusic.get_volume_ret() did = request.args.get("did")
return { if not xiaomusic.did_exist(did):
"volume": volume, return {"volume": 0}
}
volume = await xiaomusic.call_main_thread_function(xiaomusic.get_volume, did=did)
return {"volume": volume}
@app.route("/setvolume", methods=["POST"])
@auth.login_required
async def setvolume():
data = request.get_json()
did = data.get("did")
volume = data.get("volume")
if not xiaomusic.did_exist(did):
return {"ret": "Did not exist"}
log.info(f"set_volume {did} {volume}")
await xiaomusic.call_main_thread_function(
xiaomusic.set_volume, did=did, arg1=volume
)
return {"ret": "OK", "volume": volume}
@app.route("/searchmusic", methods=["GET"]) @app.route("/searchmusic", methods=["GET"])
@ -70,13 +88,19 @@ def searchmusic():
@app.route("/playingmusic", methods=["GET"]) @app.route("/playingmusic", methods=["GET"])
@auth.login_required @auth.login_required
def playingmusic(): def playingmusic():
return xiaomusic.playingmusic() did = request.args.get("did")
if not xiaomusic.did_exist(did):
return ""
return xiaomusic.playingmusic(did)
@app.route("/isplaying", methods=["GET"]) @app.route("/isplaying", methods=["GET"])
@auth.login_required @auth.login_required
def isplaying(): def isplaying():
return xiaomusic.isplaying() did = request.args.get("did")
if not xiaomusic.did_exist(did):
return False
return xiaomusic.isplaying(did)
@app.route("/", methods=["GET"]) @app.route("/", methods=["GET"])
@ -88,10 +112,14 @@ def index():
@auth.login_required @auth.login_required
async def do_cmd(): async def do_cmd():
data = request.get_json() data = request.get_json()
did = data.get("did")
cmd = data.get("cmd") cmd = data.get("cmd")
if not xiaomusic.did_exist(did):
return {"ret": "Did not exist"}
if len(cmd) > 0: if len(cmd) > 0:
log.debug("docmd. cmd:%s", cmd) log.info(f"docmd. did:{did} cmd:{cmd}")
xiaomusic.set_last_record(cmd) xiaomusic.set_last_record(did, cmd)
return {"ret": "OK"} return {"ret": "OK"}
return {"ret": "Unknow cmd"} return {"ret": "Unknow cmd"}
@ -101,10 +129,9 @@ async def do_cmd():
async def getsetting(): async def getsetting():
config = xiaomusic.getconfig() config = xiaomusic.getconfig()
data = asdict(config) data = asdict(config)
alldevices = await xiaomusic.call_main_thread_function(xiaomusic.getalldevices) device_list = await xiaomusic.call_main_thread_function(xiaomusic.getalldevices)
log.info(f"getsetting alldevices: {alldevices}") log.info(f"getsetting device_list: {device_list}")
data["mi_did_list"] = alldevices["did_list"] data["device_list"] = device_list
data["mi_hardware_list"] = alldevices["hardware_list"]
return data return data
@ -127,7 +154,10 @@ async def musiclist():
@app.route("/curplaylist", methods=["GET"]) @app.route("/curplaylist", methods=["GET"])
@auth.login_required @auth.login_required
async def curplaylist(): async def curplaylist():
return xiaomusic.get_cur_play_list() did = request.args.get("did")
if not xiaomusic.did_exist(did):
return ""
return xiaomusic.get_cur_play_list(did)
@app.route("/delmusic", methods=["POST"]) @app.route("/delmusic", methods=["POST"])
@ -149,7 +179,7 @@ def downloadjson():
ret = "OK" ret = "OK"
content = downloadfile(url) content = downloadfile(url)
except Exception as e: except Exception as e:
log.warning(f"downloadjson failed. url:{url} e:{e}") log.exception(f"Execption {e}")
ret = "Download JSON file failed." ret = "Download JSON file failed."
return { return {
"ret": ret, "ret": ret,
@ -166,9 +196,15 @@ def downloadlog():
@app.route("/playurl", methods=["GET"]) @app.route("/playurl", methods=["GET"])
@auth.login_required @auth.login_required
async def playurl(): async def playurl():
did = request.args.get("did")
url = request.args.get("url") url = request.args.get("url")
log.info(f"play_url:{url}") if not xiaomusic.did_exist(did):
return await xiaomusic.call_main_thread_function(xiaomusic.play_url, arg1=url) return {"ret": "Did not exist"}
log.info(f"playurl did: {did} url: {url}")
return await xiaomusic.call_main_thread_function(
xiaomusic.play_url, did=did, arg1=url
)
@app.route("/debug_play_by_music_url", methods=["POST"]) @app.route("/debug_play_by_music_url", methods=["POST"])

View File

@ -13,12 +13,41 @@ $(function(){
append_op_button_name("30分钟后关机"); append_op_button_name("30分钟后关机");
append_op_button_name("60分钟后关机"); append_op_button_name("60分钟后关机");
// 拉取声音 // 拉取现有配置
sendcmd("get_volume#"); $.get("/getsetting", function(data, status) {
$.get("/getvolume", function(data, status) { console.log(data, status);
localStorage.setItem('mi_did', data.mi_did);
var did = localStorage.getItem('cur_did');
if ((did == null || did == "") && data.mi_did != null) {
var dids = data.mi_did.split(',');
did = dids[0];
localStorage.setItem('cur_did', did);
}
window.did = did;
$.get(`/getvolume?did=${did}`, function(data, status) {
console.log(data, status, data["volume"]); console.log(data, status, data["volume"]);
$("#volume").val(data.volume); $("#volume").val(data.volume);
}); });
refresh_music_list();
$("#did").empty();
var dids = data.mi_did.split(',');
$.each(dids, function(index, value) {
var device = data.device_list.find(function(device) {
return device.miotDID == value;
});
if (device) {
var option = $('<option></option>')
.val(value)
.text(device.name)
.prop('selected', value === did);
$("#did").append(option);
}
});
});
// 拉取版本 // 拉取版本
$.get("/getversion", function(data, status) { $.get("/getversion", function(data, status) {
@ -47,13 +76,20 @@ $(function(){
$('#music_list').trigger('change'); $('#music_list').trigger('change');
// 获取当前播放列表 // 获取当前播放列表
$.get("curplaylist", function(data, status) { $.get(`curplaylist?did=${did}`, function(data, status) {
if (data != "") {
$('#music_list').val(data); $('#music_list').val(data);
$('#music_list').trigger('change'); $('#music_list').trigger('change');
})
})
} }
refresh_music_list(); })
})
// 每3秒获取下正在播放的音乐
get_playing_music();
setInterval(() => {
get_playing_music();
}, 3000);
}
$("#play_music_list").on("click", () => { $("#play_music_list").on("click", () => {
var music_list = $("#music_list").val(); var music_list = $("#music_list").val();
@ -84,7 +120,7 @@ $(function(){
$("#playurl").on("click", () => { $("#playurl").on("click", () => {
var url = $("#music-url").val(); var url = $("#music-url").val();
$.get(`/playurl?url=${url}`, function(data, status) { $.get(`/playurl?url=${url}&did=${did}`, function(data, status) {
console.log(data); console.log(data);
}); });
}); });
@ -115,9 +151,18 @@ $(function(){
sendcmd(cmd); sendcmd(cmd);
}); });
$("#volume").on('input', function () { $("#volume").on('change', function () {
var value = $(this).val(); var value = $(this).val();
sendcmd("set_volume#"+value); $.ajax({
type: "POST",
url: "/setvolume",
contentType: "application/json; charset=utf-8",
data: JSON.stringify({did: did, volume: value}),
success: () => {
},
error: () => {
}
});
}); });
function sendcmd(cmd) { function sendcmd(cmd) {
@ -125,7 +170,7 @@ $(function(){
type: "POST", type: "POST",
url: "/cmd", url: "/cmd",
contentType: "application/json; charset=utf-8", contentType: "application/json; charset=utf-8",
data: JSON.stringify({cmd: cmd}), data: JSON.stringify({did: did, cmd: cmd}),
success: () => { success: () => {
if (cmd == "刷新列表") { if (cmd == "刷新列表") {
setTimeout(refresh_music_list, 3000); setTimeout(refresh_music_list, 3000);
@ -160,18 +205,12 @@ $(function(){
}); });
function get_playing_music() { function get_playing_music() {
$.get("/playingmusic", function(data, status) { $.get(`/playingmusic?did=${did}`, function(data, status) {
console.log(data); console.log(data);
$("#playering-music").text(data); $("#playering-music").text(data);
}); });
} }
// 每3秒获取下正在播放的音乐
get_playing_music();
setInterval(() => {
get_playing_music();
}, 3000);
function custom_sort_key(a, b) { function custom_sort_key(a, b) {
// 使用正则表达式提取数字前缀 // 使用正则表达式提取数字前缀
const numericPrefixA = a.match(/^(\d+)/) ? parseInt(a.match(/^(\d+)/)[1], 10) : null; const numericPrefixA = a.match(/^(\d+)/) ? parseInt(a.match(/^(\d+)/)[1], 10) : null;

View File

@ -6,6 +6,12 @@
<script src="/static/jquery-3.7.1.min.js"></script> <script src="/static/jquery-3.7.1.min.js"></script>
<script src="/static/app.js"></script> <script src="/static/app.js"></script>
<link rel="stylesheet" type="text/css" href="/static/style.css"> <link rel="stylesheet" type="text/css" href="/static/style.css">
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
<script>
var vConsole = new window.VConsole();
</script>
</head> </head>
<body> <body>
<h2>小爱音箱操控面板 <h2>小爱音箱操控面板
@ -14,6 +20,8 @@
</a>) </a>)
</h2> </h2>
<hr> <hr>
<select id="did">
</select>
<div id="cmds"> <div id="cmds">
</div> </div>
<hr> <hr>

View File

@ -24,8 +24,8 @@ var vConsole = new window.VConsole();
<hr> <hr>
<div class="rows"> <div class="rows">
<label for="mi_did_hardware">*勾选设备(至少勾选1个):</label> <label for="mi_did">*勾选设备(至少勾选1个):</label>
<div id="mi_did_hardware"> <div id="mi_did">
</div> </div>
</div> </div>
<br> <br>

View File

@ -16,23 +16,22 @@ $(function(){
}); });
}; };
function updateCheckbox(selector, mi_did_list, mi_did, mi_hardware_list) { function updateCheckbox(selector, mi_did, device_list) {
// 清除现有的内容 // 清除现有的内容
$(selector).empty(); $(selector).empty();
// 将 mi_did 字符串通过逗号分割转换为数组,以便于判断默认选中项 // 将 mi_did 字符串通过逗号分割转换为数组,以便于判断默认选中项
var selected_dids = mi_did.split(','); var selected_dids = mi_did.split(',');
// 遍历传入的 mi_did_list 和 mi_hardware_list $.each(device_list, function(index, device) {
$.each(mi_did_list, function(index, did) { var did = device.miotDID;
// 获取硬件标识,假定列表是一一对应的 var hardware = device.hardware;
var hardware = mi_hardware_list[index]; var name = device.name;
// 创建复选框元素 // 创建复选框元素
var checkbox = $('<input>', { var checkbox = $('<input>', {
type: 'checkbox', type: 'checkbox',
id: did, id: did,
value: `${did}|${hardware}`, value: `${did}`,
class: 'custom-checkbox', // 添加样式类 class: 'custom-checkbox', // 添加样式类
// 如果mi_did中包含了该did则默认选中 // 如果mi_did中包含了该did则默认选中
checked: selected_dids.indexOf(did) !== -1 checked: selected_dids.indexOf(did) !== -1
@ -42,7 +41,7 @@ $(function(){
var label = $('<label>', { var label = $('<label>', {
for: did, for: did,
class: 'checkbox-label', // 添加样式类 class: 'checkbox-label', // 添加样式类
text: `${hardware} ${did}` // 设定标签内容为did和hardware的拼接 text: `${hardware}${name}` // 设定标签内容
}); });
// 将复选框和标签添加到目标选择器元素中 // 将复选框和标签添加到目标选择器元素中
@ -50,29 +49,22 @@ $(function(){
}); });
} }
function getSelectedDidsAndHardware(containerSelector) { function getSelectedDids(containerSelector) {
var selectedDids = []; var selectedDids = [];
var selectedHardware = [];
// 仅选择给定容器中选中的复选框 // 仅选择给定容器中选中的复选框
$(containerSelector + ' .custom-checkbox:checked').each(function() { $(containerSelector + ' .custom-checkbox:checked').each(function() {
// 解析当前复选框的值(值中包含了 did 和 hardware使用 '|' 分割) var did = this.value;
var parts = this.value.split('|'); selectedDids.push(did);
selectedDids.push(parts[0]);
selectedHardware.push(parts[1]);
}); });
// 返回包含 did_list 和 hardware_list 的对象 return selectedDids.join(',');
return {
did_list: selectedDids.join(','),
hardware_list: selectedHardware.join(',')
};
} }
// 拉取现有配置 // 拉取现有配置
$.get("/getsetting", function(data, status) { $.get("/getsetting", function(data, status) {
console.log(data, status); console.log(data, status);
updateCheckbox("#mi_did_hardware", data.mi_did_list, data.mi_did, data.mi_hardware_list); updateCheckbox("#mi_did", data.mi_did, data.device_list);
// 初始化显示 // 初始化显示
for (const key in data) { for (const key in data) {
@ -103,9 +95,8 @@ $(function(){
data[id] = $(this).val(); data[id] = $(this).val();
} }
}); });
var selectedData = getSelectedDidsAndHardware("#mi_did_hardware"); var did_list = getSelectedDids("#mi_did");
data["mi_did"] = selectedData.did_list; data["mi_did"] = did_list;
data["hardware"] = selectedData.hardware_list;
console.log(data) console.log(data)
$.ajax({ $.ajax({

View File

@ -253,3 +253,19 @@ def deepcopy_data_no_sensitive_info(data, fields_to_anonymize=None):
setattr(copy_data, field, "******") setattr(copy_data, field, "******")
return copy_data return copy_data
# k1:v1,k2:v2
def parse_str_to_dict(s, d1=",", d2=":"):
# 初始化一个空字典
result = {}
parts = s.split(d1)
for part in parts:
# 根据冒号切割
subparts = part.split(d2)
if len(subparts) == 2: # 防止数据不是成对出现
k, v = subparts
result[k] = v
return result

File diff suppressed because it is too large Load Diff