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:
push:
branches: [ main ]
branches:
- "*"
workflow_dispatch:
jobs:

View File

@ -1,81 +1,81 @@
{
"hardware": "L07A",
"account": "",
"password": "",
"mi_did": "",
"cookie": "",
"verbose": false,
"music_path": "music",
"conf_path": null,
"hostname": "192.168.2.5",
"port": 8090,
"public_port": 0,
"proxy": null,
"search_prefix": "bilisearch:",
"ffmpeg_location": "./ffmpeg/bin",
"active_cmd": "play,random_play,playlocal,play_music_list,stop",
"exclude_dirs": "@eaDir",
"music_path_depth": 10,
"disable_httpauth": true,
"httpauth_username": "admin",
"httpauth_password": "admin",
"music_list_url": "",
"music_list_json": "",
"disable_download": false,
"key_word_dict": {
"播放歌曲": "play",
"播放本地歌曲": "playlocal",
"关机": "stop",
"下一首": "play_next",
"单曲循环": "set_play_type_one",
"全部循环": "set_play_type_all",
"随机播放": "random_play",
"分钟后关机": "stop_after_minute",
"播放列表": "play_music_list",
"刷新列表": "gen_music_list",
"set_volume#": "set_volume",
"get_volume#": "get_volume",
"本地播放歌曲": "playlocal",
"放歌曲": "play",
"停": "stop",
"停止": "stop",
"停止播放": "stop",
"测试自定义口令": "exec#code1(\"hello\")",
"测试链接": "exec#httpget(\"https://github.com/hanxi/xiaomusic\")"
},
"key_match_order": [
"set_volume#",
"get_volume#",
"分钟后关机",
"播放歌曲",
"下一首",
"单曲循环",
"全部循环",
"随机播放",
"关机",
"刷新列表",
"播放列表",
"播放本地歌曲",
"本地播放歌曲",
"放歌曲",
"暂停",
"停止",
"停止播放",
"测试自定义口令",
"测试链接"
],
"use_music_api": false,
"use_music_audio_id": "1582971365183456177",
"use_music_id": "355454500",
"log_file": "/tmp/xiaomusic.txt",
"fuzzy_match_cutoff": 0.6,
"enable_fuzzy_match": true,
"stop_tts_msg": "收到,再见",
"keywords_playlocal": "播放本地歌曲,本地播放歌曲",
"keywords_play": "播放歌曲,放歌曲",
"keywords_stop": "关机,暂停,停止,停止播放",
"user_key_word_dict": {
"测试自定义口令": "exec#code1(\"hello\")",
"测试链接": "exec#httpget(\"https://github.com/hanxi/xiaomusic\")"
}
"account": "",
"password": "",
"mi_did": "",
"cookie": "",
"verbose": false,
"music_path": "music",
"download_path": "",
"conf_path": null,
"hostname": "192.168.2.5",
"port": 8090,
"public_port": 0,
"proxy": null,
"search_prefix": "bilisearch:",
"ffmpeg_location": "./ffmpeg/bin",
"active_cmd": "play,set_random_play,playlocal,play_music_list,stop",
"exclude_dirs": "@eaDir",
"music_path_depth": 10,
"disable_httpauth": true,
"httpauth_username": "",
"httpauth_password": "",
"music_list_url": "",
"music_list_json": "",
"disable_download": false,
"key_word_dict": {
"播放歌曲": "play",
"播放本地歌曲": "playlocal",
"关机": "stop",
"下一首": "play_next",
"单曲循环": "set_play_type_one",
"全部循环": "set_play_type_all",
"随机播放": "set_random_play",
"分钟后关机": "stop_after_minute",
"播放列表": "play_music_list",
"刷新列表": "gen_music_list",
"本地播放歌曲": "playlocal",
"放歌曲": "play",
"暂停": "stop",
"停止": "stop",
"止播放": "stop",
"测试自定义口令": "exec#code1(\"hello\")",
"测试链接": "exec#httpget(\"https://github.com/hanxi/xiaomusic\")"
},
"key_match_order": [
"分钟后关机",
"播放歌曲",
"下一首",
"单曲循环",
"全部循环",
"随机播放",
"关机",
"刷新列表",
"播放列表",
"播放本地歌曲",
"本地播放歌曲",
"放歌曲",
"暂停",
"停止",
"停止播放",
"测试自定义口令",
"测试链接"
],
"use_music_api": false,
"use_music_audio_id": "1582971365183456177",
"use_music_id": "355454500",
"log_file": "/tmp/xiaomusic.txt",
"fuzzy_match_cutoff": 0.6,
"enable_fuzzy_match": true,
"stop_tts_msg": "收到,再见",
"enable_config_example": true,
"keywords_playlocal": "播放本地歌曲,本地播放歌曲",
"keywords_play": "播放歌曲,放歌曲",
"keywords_stop": "关机,暂停,停止,停止播放",
"user_key_word_dict": {
"测试自定义口令": "exec#code1(\"hello\")",
"测试链接": "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",
"单曲循环": "set_play_type_one",
"全部循环": "set_play_type_all",
"随机播放": "random_play",
"随机播放": "set_random_play",
"分钟后关机": "stop_after_minute",
"播放列表": "play_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():
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
class Config:
account: str = os.getenv("MI_USER", "")
password: str = os.getenv("MI_PASS", "")
mi_did: str = os.getenv("MI_DID", "") # 逗号分割支持多设备
hardware: str = os.getenv("MI_HARDWARE", "L07A") # 逗号分割支持多设备
cookie: str = ""
verbose: bool = os.getenv("XIAOMUSIC_VERBOSE", "").lower() == "true"
music_path: str = os.getenv(
@ -79,7 +85,7 @@ class Config:
) # "bilisearch:" or "ytsearch:"
ffmpeg_location: str = os.getenv("XIAOMUSIC_FFMPEG_LOCATION", "./ffmpeg/bin")
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")
music_path_depth: int = int(os.getenv("XIAOMUSIC_MUSIC_PATH_DEPTH", "10"))
@ -123,7 +129,10 @@ class Config:
enable_force_stop: bool = (
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):
for key in keys.split(","):
@ -150,7 +159,7 @@ class Config:
if self.enable_config_example:
with open("config-example.json", "w") as f:
data = asdict(self)
json.dump(data, f, ensure_ascii=False, indent=4)
json.dump(data, f, ensure_ascii=False, indent=2)
@classmethod
def from_options(cls, options: argparse.Namespace) -> Config:
@ -171,6 +180,10 @@ class Config:
converted_value = False
if str(v).lower() == "true":
converted_value = True
elif expected_type == dict[str, Device]:
converted_value = {}
for kk, vv in v.items():
converted_value[kk] = Device(**vv)
else:
converted_value = expected_type(v)
return converted_value
@ -192,7 +205,7 @@ class Config:
return result
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():
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"
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"])
@auth.login_required
def getvolume():
volume = xiaomusic.get_volume_ret()
return {
"volume": volume,
}
async def getvolume():
did = request.args.get("did")
if not xiaomusic.did_exist(did):
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"])
@ -70,13 +88,19 @@ def searchmusic():
@app.route("/playingmusic", methods=["GET"])
@auth.login_required
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"])
@auth.login_required
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"])
@ -88,10 +112,14 @@ def index():
@auth.login_required
async def do_cmd():
data = request.get_json()
did = data.get("did")
cmd = data.get("cmd")
if not xiaomusic.did_exist(did):
return {"ret": "Did not exist"}
if len(cmd) > 0:
log.debug("docmd. cmd:%s", cmd)
xiaomusic.set_last_record(cmd)
log.info(f"docmd. did:{did} cmd:{cmd}")
xiaomusic.set_last_record(did, cmd)
return {"ret": "OK"}
return {"ret": "Unknow cmd"}
@ -101,10 +129,9 @@ async def do_cmd():
async def getsetting():
config = xiaomusic.getconfig()
data = asdict(config)
alldevices = await xiaomusic.call_main_thread_function(xiaomusic.getalldevices)
log.info(f"getsetting alldevices: {alldevices}")
data["mi_did_list"] = alldevices["did_list"]
data["mi_hardware_list"] = alldevices["hardware_list"]
device_list = await xiaomusic.call_main_thread_function(xiaomusic.getalldevices)
log.info(f"getsetting device_list: {device_list}")
data["device_list"] = device_list
return data
@ -127,7 +154,10 @@ async def musiclist():
@app.route("/curplaylist", methods=["GET"])
@auth.login_required
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"])
@ -149,7 +179,7 @@ def downloadjson():
ret = "OK"
content = downloadfile(url)
except Exception as e:
log.warning(f"downloadjson failed. url:{url} e:{e}")
log.exception(f"Execption {e}")
ret = "Download JSON file failed."
return {
"ret": ret,
@ -166,9 +196,15 @@ def downloadlog():
@app.route("/playurl", methods=["GET"])
@auth.login_required
async def playurl():
did = request.args.get("did")
url = request.args.get("url")
log.info(f"play_url:{url}")
return await xiaomusic.call_main_thread_function(xiaomusic.play_url, arg1=url)
if not xiaomusic.did_exist(did):
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"])

View File

@ -13,11 +13,40 @@ $(function(){
append_op_button_name("30分钟后关机");
append_op_button_name("60分钟后关机");
// 拉取声音
sendcmd("get_volume#");
$.get("/getvolume", function(data, status) {
console.log(data, status, data["volume"]);
$("#volume").val(data.volume);
// 拉取现有配置
$.get("/getsetting", 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"]);
$("#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);
}
});
});
// 拉取版本
@ -47,13 +76,20 @@ $(function(){
$('#music_list').trigger('change');
// 获取当前播放列表
$.get("curplaylist", function(data, status) {
$('#music_list').val(data);
$('#music_list').trigger('change');
$.get(`curplaylist?did=${did}`, function(data, status) {
if (data != "") {
$('#music_list').val(data);
$('#music_list').trigger('change');
}
})
})
// 每3秒获取下正在播放的音乐
get_playing_music();
setInterval(() => {
get_playing_music();
}, 3000);
}
refresh_music_list();
$("#play_music_list").on("click", () => {
var music_list = $("#music_list").val();
@ -84,7 +120,7 @@ $(function(){
$("#playurl").on("click", () => {
var url = $("#music-url").val();
$.get(`/playurl?url=${url}`, function(data, status) {
$.get(`/playurl?url=${url}&did=${did}`, function(data, status) {
console.log(data);
});
});
@ -115,9 +151,18 @@ $(function(){
sendcmd(cmd);
});
$("#volume").on('input', function () {
$("#volume").on('change', function () {
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) {
@ -125,7 +170,7 @@ $(function(){
type: "POST",
url: "/cmd",
contentType: "application/json; charset=utf-8",
data: JSON.stringify({cmd: cmd}),
data: JSON.stringify({did: did, cmd: cmd}),
success: () => {
if (cmd == "刷新列表") {
setTimeout(refresh_music_list, 3000);
@ -160,18 +205,12 @@ $(function(){
});
function get_playing_music() {
$.get("/playingmusic", function(data, status) {
$.get(`/playingmusic?did=${did}`, function(data, status) {
console.log(data);
$("#playering-music").text(data);
});
}
// 每3秒获取下正在播放的音乐
get_playing_music();
setInterval(() => {
get_playing_music();
}, 3000);
function custom_sort_key(a, b) {
// 使用正则表达式提取数字前缀
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/app.js"></script>
<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>
<body>
<h2>小爱音箱操控面板
@ -14,6 +20,8 @@
</a>)
</h2>
<hr>
<select id="did">
</select>
<div id="cmds">
</div>
<hr>

View File

@ -24,8 +24,8 @@ var vConsole = new window.VConsole();
<hr>
<div class="rows">
<label for="mi_did_hardware">*勾选设备(至少勾选1个):</label>
<div id="mi_did_hardware">
<label for="mi_did">*勾选设备(至少勾选1个):</label>
<div id="mi_did">
</div>
</div>
<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();
// 将 mi_did 字符串通过逗号分割转换为数组,以便于判断默认选中项
var selected_dids = mi_did.split(',');
// 遍历传入的 mi_did_list 和 mi_hardware_list
$.each(mi_did_list, function(index, did) {
// 获取硬件标识,假定列表是一一对应的
var hardware = mi_hardware_list[index];
$.each(device_list, function(index, device) {
var did = device.miotDID;
var hardware = device.hardware;
var name = device.name;
// 创建复选框元素
var checkbox = $('<input>', {
type: 'checkbox',
id: did,
value: `${did}|${hardware}`,
value: `${did}`,
class: 'custom-checkbox', // 添加样式类
// 如果mi_did中包含了该did则默认选中
checked: selected_dids.indexOf(did) !== -1
@ -42,7 +41,7 @@ $(function(){
var label = $('<label>', {
for: did,
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 selectedHardware = [];
// 仅选择给定容器中选中的复选框
$(containerSelector + ' .custom-checkbox:checked').each(function() {
// 解析当前复选框的值(值中包含了 did 和 hardware使用 '|' 分割)
var parts = this.value.split('|');
selectedDids.push(parts[0]);
selectedHardware.push(parts[1]);
var did = this.value;
selectedDids.push(did);
});
// 返回包含 did_list 和 hardware_list 的对象
return {
did_list: selectedDids.join(','),
hardware_list: selectedHardware.join(',')
};
return selectedDids.join(',');
}
// 拉取现有配置
$.get("/getsetting", function(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) {
@ -103,9 +95,8 @@ $(function(){
data[id] = $(this).val();
}
});
var selectedData = getSelectedDidsAndHardware("#mi_did_hardware");
data["mi_did"] = selectedData.did_list;
data["hardware"] = selectedData.hardware_list;
var did_list = getSelectedDids("#mi_did");
data["mi_did"] = did_list;
console.log(data)
$.ajax({

View File

@ -253,3 +253,19 @@ def deepcopy_data_no_sensitive_info(data, fields_to_anonymize=None):
setattr(copy_data, field, "******")
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