mirror of
https://github.com/hanxi/xiaomusic.git
synced 2025-12-08 15:08:12 +08:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e5df7094c | ||
|
|
64c2f54ff0 | ||
|
|
d1b869ae43 | ||
|
|
d3895f2632 | ||
|
|
5bf62c4b1a | ||
|
|
406e09922c | ||
|
|
ae34572d13 | ||
|
|
1e3c69ea90 | ||
|
|
3c232505f8 | ||
|
|
44177db9b6 | ||
|
|
e72ae973bc | ||
|
|
4ab3c5cbee | ||
|
|
4e532d298d | ||
|
|
3372440f4e | ||
|
|
1255239912 | ||
|
|
e401a73595 | ||
|
|
cca6e47da5 | ||
|
|
415e75d4b4 | ||
|
|
3c5573a2fc | ||
|
|
7275b59d40 | ||
|
|
a8d0631c33 | ||
|
|
3cfc96b779 | ||
|
|
489f3f1d6f | ||
|
|
a5f2fc195e | ||
|
|
393dbabf4b | ||
|
|
444e697f9d | ||
|
|
cf01039b53 | ||
|
|
a8fefc6f82 | ||
|
|
ae0b6066d9 | ||
|
|
53f5e7db8c | ||
|
|
2a1fa9f8cf |
10
.github/workflows/release.yml
vendored
10
.github/workflows/release.yml
vendored
@@ -21,11 +21,6 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- uses: pdm-project/setup-pdm@v3
|
|
||||||
|
|
||||||
- name: Publish package distributions to PyPI
|
|
||||||
run: pdm publish
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v3
|
||||||
with:
|
with:
|
||||||
node-version: 16.x
|
node-version: 16.x
|
||||||
@@ -35,6 +30,11 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||||
|
|
||||||
|
- uses: pdm-project/setup-pdm@v3
|
||||||
|
|
||||||
|
- name: Publish package distributions to PyPI
|
||||||
|
run: pdm publish
|
||||||
|
|
||||||
build-image:
|
build-image:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
#needs: release-pypi
|
#needs: release-pypi
|
||||||
|
|||||||
63
CHANGELOG.md
63
CHANGELOG.md
@@ -1,3 +1,66 @@
|
|||||||
|
## v0.3.28 (2024-09-03)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- 新增歌曲收藏功能 #87
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- docker下minetypes无法判断m4a
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- ffmpeg_location 从配置里读取
|
||||||
|
|
||||||
|
## v0.3.27 (2024-09-02)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- Add feature as requested in issue #143
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- 默认下载目录修改
|
||||||
|
|
||||||
|
### Refactor
|
||||||
|
|
||||||
|
- 处理 code review 问题'
|
||||||
|
|
||||||
|
## v0.3.26 (2024-08-17)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- 删除网关模式
|
||||||
|
|
||||||
|
## v0.3.25 (2024-08-16)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- 设置页面支持配置 use_music_api 选项
|
||||||
|
|
||||||
|
## v0.3.24 (2024-08-01)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- #131 修复多设备切换时播放模式显示错误问题
|
||||||
|
|
||||||
|
## v0.3.23 (2024-08-01)
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- 修复部分文件获取不到播放时长问题
|
||||||
|
- 处理安全问题
|
||||||
|
|
||||||
|
## v0.3.22 (2024-08-01)
|
||||||
|
|
||||||
|
### Feat
|
||||||
|
|
||||||
|
- 网关模式支持配置,默认关闭
|
||||||
|
|
||||||
|
### Fix
|
||||||
|
|
||||||
|
- 继续优化延迟问题
|
||||||
|
|
||||||
## v0.3.21 (2024-07-30)
|
## v0.3.21 (2024-07-30)
|
||||||
|
|
||||||
### Feat
|
### Feat
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ services:
|
|||||||
XIAOMUSIC_PORT: 5678
|
XIAOMUSIC_PORT: 5678
|
||||||
```
|
```
|
||||||
|
|
||||||
|
如果不是首次修改端口,还需要修改 setting.json 文件里的端口。
|
||||||
|
|
||||||
遇到问题可以去 web 设置页面底部点击【下载日志文件】按钮,然后搜索一下日志文件内容确保里面没有账号密码信息后(有就删除这些敏感信息),然后在提 issues 反馈问题时把下载的日志文件带上。
|
遇到问题可以去 web 设置页面底部点击【下载日志文件】按钮,然后搜索一下日志文件内容确保里面没有账号密码信息后(有就删除这些敏感信息),然后在提 issues 反馈问题时把下载的日志文件带上。
|
||||||
|
|
||||||
> 目前除了 XIAOMUSIC_PORT 只能在启动前配置,其他参数都可以在 web 网页里配置。
|
> 目前除了 XIAOMUSIC_PORT 只能在启动前配置,其他参数都可以在 web 网页里配置。
|
||||||
|
|||||||
@@ -77,5 +77,6 @@
|
|||||||
},
|
},
|
||||||
"enable_force_stop": false,
|
"enable_force_stop": false,
|
||||||
"devices": {},
|
"devices": {},
|
||||||
"group_list": ""
|
"group_list": "",
|
||||||
}
|
"convert_to_mp3": false
|
||||||
|
}
|
||||||
|
|||||||
39
pdm.lock
generated
39
pdm.lock
generated
@@ -25,14 +25,27 @@ files = [
|
|||||||
{file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"},
|
{file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aiohappyeyeballs"
|
||||||
|
version = "2.3.4"
|
||||||
|
requires_python = "<4.0,>=3.8"
|
||||||
|
summary = "Happy Eyeballs for asyncio"
|
||||||
|
groups = ["default"]
|
||||||
|
marker = "python_full_version == \"3.10.12\""
|
||||||
|
files = [
|
||||||
|
{file = "aiohappyeyeballs-2.3.4-py3-none-any.whl", hash = "sha256:40a16ceffcf1fc9e142fd488123b2e218abc4188cf12ac20c67200e1579baa42"},
|
||||||
|
{file = "aiohappyeyeballs-2.3.4.tar.gz", hash = "sha256:7e1ae8399c320a8adec76f6c919ed5ceae6edd4c3672f4d9eae2b27e37c80ff6"},
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiohttp"
|
name = "aiohttp"
|
||||||
version = "3.9.5"
|
version = "3.10.0"
|
||||||
requires_python = ">=3.8"
|
requires_python = ">=3.8"
|
||||||
summary = "Async http client/server framework (asyncio)"
|
summary = "Async http client/server framework (asyncio)"
|
||||||
groups = ["default"]
|
groups = ["default"]
|
||||||
marker = "python_full_version == \"3.10.12\""
|
marker = "python_full_version == \"3.10.12\""
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aiohappyeyeballs>=2.3.0",
|
||||||
"aiosignal>=1.1.2",
|
"aiosignal>=1.1.2",
|
||||||
"async-timeout<5.0,>=4.0; python_version < \"3.11\"",
|
"async-timeout<5.0,>=4.0; python_version < \"3.11\"",
|
||||||
"attrs>=17.3.0",
|
"attrs>=17.3.0",
|
||||||
@@ -41,8 +54,8 @@ dependencies = [
|
|||||||
"yarl<2.0,>=1.0",
|
"yarl<2.0,>=1.0",
|
||||||
]
|
]
|
||||||
files = [
|
files = [
|
||||||
{file = "aiohttp-3.9.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c26959ca7b75ff768e2776d8055bf9582a6267e24556bb7f7bd29e677932be72"},
|
{file = "aiohttp-3.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06ef0135d7ab7fb0284342fbbf8e8ddf73b7fee8ecc55f5c3a3d0a6b765e6d8b"},
|
||||||
{file = "aiohttp-3.9.5.tar.gz", hash = "sha256:edea7d15772ceeb29db4aff55e482d4bcfb6ae160ce144f2682de02f6d693551"},
|
{file = "aiohttp-3.10.0.tar.gz", hash = "sha256:e8dd7da2609303e3574c95b0ec9f1fd49647ef29b94701a2862cceae76382e1d"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1094,7 +1107,7 @@ files = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uvicorn"
|
name = "uvicorn"
|
||||||
version = "0.30.3"
|
version = "0.30.4"
|
||||||
requires_python = ">=3.8"
|
requires_python = ">=3.8"
|
||||||
summary = "The lightning-fast ASGI server."
|
summary = "The lightning-fast ASGI server."
|
||||||
groups = ["default"]
|
groups = ["default"]
|
||||||
@@ -1105,13 +1118,13 @@ dependencies = [
|
|||||||
"typing-extensions>=4.0; python_version < \"3.11\"",
|
"typing-extensions>=4.0; python_version < \"3.11\"",
|
||||||
]
|
]
|
||||||
files = [
|
files = [
|
||||||
{file = "uvicorn-0.30.3-py3-none-any.whl", hash = "sha256:94a3608da0e530cea8f69683aa4126364ac18e3826b6630d1a65f4638aade503"},
|
{file = "uvicorn-0.30.4-py3-none-any.whl", hash = "sha256:06b00e3087e58c6865c284143c0c42f810b32ff4f265ab19d08c566f74a08728"},
|
||||||
{file = "uvicorn-0.30.3.tar.gz", hash = "sha256:0d114d0831ff1adbf231d358cbf42f17333413042552a624ea6a9b4c33dcfd81"},
|
{file = "uvicorn-0.30.4.tar.gz", hash = "sha256:00db9a9e3711a5fa59866e2b02fac69d8dc70ce0814aaec9a66d1d9e5c832a30"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uvicorn"
|
name = "uvicorn"
|
||||||
version = "0.30.3"
|
version = "0.30.4"
|
||||||
extras = ["standard"]
|
extras = ["standard"]
|
||||||
requires_python = ">=3.8"
|
requires_python = ">=3.8"
|
||||||
summary = "The lightning-fast ASGI server."
|
summary = "The lightning-fast ASGI server."
|
||||||
@@ -1122,14 +1135,14 @@ dependencies = [
|
|||||||
"httptools>=0.5.0",
|
"httptools>=0.5.0",
|
||||||
"python-dotenv>=0.13",
|
"python-dotenv>=0.13",
|
||||||
"pyyaml>=5.1",
|
"pyyaml>=5.1",
|
||||||
"uvicorn==0.30.3",
|
"uvicorn==0.30.4",
|
||||||
"uvloop!=0.15.0,!=0.15.1,>=0.14.0; (sys_platform != \"cygwin\" and sys_platform != \"win32\") and platform_python_implementation != \"PyPy\"",
|
"uvloop!=0.15.0,!=0.15.1,>=0.14.0; (sys_platform != \"cygwin\" and sys_platform != \"win32\") and platform_python_implementation != \"PyPy\"",
|
||||||
"watchfiles>=0.13",
|
"watchfiles>=0.13",
|
||||||
"websockets>=10.4",
|
"websockets>=10.4",
|
||||||
]
|
]
|
||||||
files = [
|
files = [
|
||||||
{file = "uvicorn-0.30.3-py3-none-any.whl", hash = "sha256:94a3608da0e530cea8f69683aa4126364ac18e3826b6630d1a65f4638aade503"},
|
{file = "uvicorn-0.30.4-py3-none-any.whl", hash = "sha256:06b00e3087e58c6865c284143c0c42f810b32ff4f265ab19d08c566f74a08728"},
|
||||||
{file = "uvicorn-0.30.3.tar.gz", hash = "sha256:0d114d0831ff1adbf231d358cbf42f17333413042552a624ea6a9b4c33dcfd81"},
|
{file = "uvicorn-0.30.4.tar.gz", hash = "sha256:00db9a9e3711a5fa59866e2b02fac69d8dc70ce0814aaec9a66d1d9e5c832a30"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1345,7 +1358,7 @@ files = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "yt-dlp"
|
name = "yt-dlp"
|
||||||
version = "2024.7.25"
|
version = "2024.8.1"
|
||||||
requires_python = ">=3.8"
|
requires_python = ">=3.8"
|
||||||
summary = "A feature-rich command-line audio/video downloader"
|
summary = "A feature-rich command-line audio/video downloader"
|
||||||
groups = ["default"]
|
groups = ["default"]
|
||||||
@@ -1361,6 +1374,6 @@ dependencies = [
|
|||||||
"websockets>=12.0",
|
"websockets>=12.0",
|
||||||
]
|
]
|
||||||
files = [
|
files = [
|
||||||
{file = "yt_dlp-2024.7.25-py3-none-any.whl", hash = "sha256:f44b5f33776b4f718900c670fe6e4698fb6fcd426455cd837cf25a1d6d4d9560"},
|
{file = "yt_dlp-2024.8.1-py3-none-any.whl", hash = "sha256:d0d927038e30a05f6eab26ff6189628456ea21bb159a3d9dc2e855eef2810eac"},
|
||||||
{file = "yt_dlp-2024.7.25.tar.gz", hash = "sha256:7587aa25e236cf7b14bdb9378bbffff51202d901b04202be0cf62cbb56d3b52c"},
|
{file = "yt_dlp-2024.8.1.tar.gz", hash = "sha256:4318aa523694611562f01419c8d526b662a72df34ef8ba454016b34c8366c158"},
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "xiaomusic"
|
name = "xiaomusic"
|
||||||
version = "0.3.21"
|
version = "0.3.28"
|
||||||
description = "Play Music with xiaomi AI speaker"
|
description = "Play Music with xiaomi AI speaker"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "涵曦", email = "im.hanxi@gmail.com"},
|
{name = "涵曦", email = "im.hanxi@gmail.com"},
|
||||||
|
|||||||
32
test/test_music_duration.py
Normal file
32
test/test_music_duration.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import math
|
||||||
|
|
||||||
|
from xiaomusic.const import (
|
||||||
|
SUPPORT_MUSIC_TYPE,
|
||||||
|
)
|
||||||
|
from xiaomusic.utils import (
|
||||||
|
get_local_music_duration,
|
||||||
|
traverse_music_directory,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_one_music(filename):
|
||||||
|
# 获取播放时长
|
||||||
|
duration = await get_local_music_duration(filename)
|
||||||
|
sec = math.ceil(duration)
|
||||||
|
print(f"本地歌曲 : {filename} 的时长 {duration} {sec} 秒")
|
||||||
|
|
||||||
|
|
||||||
|
async def main(directory):
|
||||||
|
# 获取所有歌曲文件
|
||||||
|
local_musics = traverse_music_directory(directory, 10, [], SUPPORT_MUSIC_TYPE)
|
||||||
|
print(local_musics)
|
||||||
|
for _, files in local_musics.items():
|
||||||
|
for file in files:
|
||||||
|
await test_one_music(file)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
directory = "./music" # 替换为你的音乐目录路径
|
||||||
|
asyncio.run(main(directory))
|
||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.3.21"
|
__version__ = "0.3.28"
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import argparse
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
import subprocess
|
|
||||||
|
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
@@ -137,45 +136,31 @@ def main():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
filename = config.getsettingfile()
|
filename = config.getsettingfile()
|
||||||
with open(filename) as f:
|
with open(filename, encoding="utf-8") as f:
|
||||||
data = json.loads(f.read())
|
data = json.loads(f.read())
|
||||||
config.update_config(data)
|
config.update_config(data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Execption {e}")
|
print(f"Execption {e}")
|
||||||
|
|
||||||
def run_server():
|
def run_server(port):
|
||||||
xiaomusic = XiaoMusic(config)
|
xiaomusic = XiaoMusic(config)
|
||||||
HttpInit(xiaomusic)
|
HttpInit(xiaomusic)
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
HttpApp,
|
HttpApp,
|
||||||
host="127.0.0.1",
|
host=["0.0.0.0", "::"],
|
||||||
port=config.port + 1,
|
port=port,
|
||||||
log_config=LOGGING_CONFIG,
|
log_config=LOGGING_CONFIG,
|
||||||
)
|
)
|
||||||
|
|
||||||
command = [
|
|
||||||
"uvicorn",
|
|
||||||
"xiaomusic.gate:app",
|
|
||||||
"--workers",
|
|
||||||
"4",
|
|
||||||
"--host",
|
|
||||||
"0.0.0.0",
|
|
||||||
"--port",
|
|
||||||
str(config.port),
|
|
||||||
]
|
|
||||||
|
|
||||||
process = subprocess.Popen(command)
|
|
||||||
def signal_handler(sig, frame):
|
def signal_handler(sig, frame):
|
||||||
print("主进程收到退出信号,准备退出...")
|
print("主进程收到退出信号,准备退出...")
|
||||||
process.terminate() # 终止子进程
|
|
||||||
process.wait() # 等待子进程退出
|
|
||||||
print("子进程已退出")
|
|
||||||
os._exit(0) # 退出主进程
|
os._exit(0) # 退出主进程
|
||||||
|
|
||||||
# 捕获主进程的退出信号
|
# 捕获主进程的退出信号
|
||||||
signal.signal(signal.SIGINT, signal_handler)
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
signal.signal(signal.SIGTERM, signal_handler)
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
run_server()
|
port = int(config.port)
|
||||||
|
run_server(port)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ def default_key_word_dict():
|
|||||||
"分钟后关机": "stop_after_minute",
|
"分钟后关机": "stop_after_minute",
|
||||||
"播放列表": "play_music_list",
|
"播放列表": "play_music_list",
|
||||||
"刷新列表": "gen_music_list",
|
"刷新列表": "gen_music_list",
|
||||||
|
"加入收藏": "add_to_favorites",
|
||||||
|
"取消收藏": "del_from_favorites",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -50,6 +52,8 @@ def default_key_match_order():
|
|||||||
"关机",
|
"关机",
|
||||||
"刷新列表",
|
"刷新列表",
|
||||||
"播放列表",
|
"播放列表",
|
||||||
|
"加入收藏",
|
||||||
|
"取消收藏",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -74,7 +78,7 @@ class Config:
|
|||||||
music_path: str = os.getenv(
|
music_path: str = os.getenv(
|
||||||
"XIAOMUSIC_MUSIC_PATH", "music"
|
"XIAOMUSIC_MUSIC_PATH", "music"
|
||||||
) # 只能是music目录下的子目录
|
) # 只能是music目录下的子目录
|
||||||
download_path: str = os.getenv("XIAOMUSIC_DOWNLOAD_PATH", "")
|
download_path: str = os.getenv("XIAOMUSIC_DOWNLOAD_PATH", "music/download")
|
||||||
conf_path: str = os.getenv("XIAOMUSIC_CONF_PATH", "conf")
|
conf_path: str = os.getenv("XIAOMUSIC_CONF_PATH", "conf")
|
||||||
hostname: str = os.getenv("XIAOMUSIC_HOSTNAME", "192.168.2.5")
|
hostname: str = os.getenv("XIAOMUSIC_HOSTNAME", "192.168.2.5")
|
||||||
port: int = int(os.getenv("XIAOMUSIC_PORT", "8090")) # 监听端口
|
port: int = int(os.getenv("XIAOMUSIC_PORT", "8090")) # 监听端口
|
||||||
@@ -96,6 +100,7 @@ class Config:
|
|||||||
httpauth_password: str = os.getenv("XIAOMUSIC_HTTPAUTH_PASSWORD", "")
|
httpauth_password: str = os.getenv("XIAOMUSIC_HTTPAUTH_PASSWORD", "")
|
||||||
music_list_url: str = os.getenv("XIAOMUSIC_MUSIC_LIST_URL", "")
|
music_list_url: str = os.getenv("XIAOMUSIC_MUSIC_LIST_URL", "")
|
||||||
music_list_json: str = os.getenv("XIAOMUSIC_MUSIC_LIST_JSON", "")
|
music_list_json: str = os.getenv("XIAOMUSIC_MUSIC_LIST_JSON", "")
|
||||||
|
custom_play_list_json: str = os.getenv("XIAOMUSIC_CUSTOM_PLAY_LIST_JSON", "")
|
||||||
disable_download: bool = (
|
disable_download: bool = (
|
||||||
os.getenv("XIAOMUSIC_DISABLE_DOWNLOAD", "false").lower() == "true"
|
os.getenv("XIAOMUSIC_DISABLE_DOWNLOAD", "false").lower() == "true"
|
||||||
)
|
)
|
||||||
@@ -136,6 +141,7 @@ class Config:
|
|||||||
remove_id3tag: bool = (
|
remove_id3tag: bool = (
|
||||||
os.getenv("XIAOMUSIC_REMOVE_ID3TAG", "false").lower() == "true"
|
os.getenv("XIAOMUSIC_REMOVE_ID3TAG", "false").lower() == "true"
|
||||||
)
|
)
|
||||||
|
convert_to_mp3: bool = os.getenv("CONVERT_TO_MP3", "false").lower() == "true"
|
||||||
delay_sec: int = int(os.getenv("XIAOMUSIC_DELAY_SEC", 3)) # 下一首歌延迟播放秒数
|
delay_sec: int = int(os.getenv("XIAOMUSIC_DELAY_SEC", 3)) # 下一首歌延迟播放秒数
|
||||||
|
|
||||||
def append_keyword(self, keys, action):
|
def append_keyword(self, keys, action):
|
||||||
@@ -152,6 +158,7 @@ class Config:
|
|||||||
|
|
||||||
def init_keyword(self):
|
def init_keyword(self):
|
||||||
self.key_match_order = default_key_match_order()
|
self.key_match_order = default_key_match_order()
|
||||||
|
self.key_word_dict = default_key_word_dict()
|
||||||
self.append_keyword(self.keywords_playlocal, "playlocal")
|
self.append_keyword(self.keywords_playlocal, "playlocal")
|
||||||
self.append_keyword(self.keywords_play, "play")
|
self.append_keyword(self.keywords_play, "play")
|
||||||
self.append_keyword(self.keywords_stop, "stop")
|
self.append_keyword(self.keywords_stop, "stop")
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import FastAPI, Request
|
|
||||||
from fastapi.responses import Response
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from starlette.background import BackgroundTask
|
|
||||||
|
|
||||||
from xiaomusic import __version__
|
|
||||||
from xiaomusic.config import Config
|
|
||||||
|
|
||||||
config = Config()
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
log = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def app_lifespan(app):
|
|
||||||
global config
|
|
||||||
try:
|
|
||||||
filename = config.getsettingfile()
|
|
||||||
with open(filename) as f:
|
|
||||||
data = json.loads(f.read())
|
|
||||||
config.update_config(data)
|
|
||||||
except Exception as e:
|
|
||||||
log.exception(f"Execption {e}")
|
|
||||||
yield
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
|
||||||
lifespan=app_lifespan,
|
|
||||||
version=__version__,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def reset_gate():
|
|
||||||
# 更新 music 链接
|
|
||||||
app.router.routes = [route for route in app.router.routes if route.path != "/music"]
|
|
||||||
app.mount(
|
|
||||||
"/music",
|
|
||||||
StaticFiles(directory=config.music_path, follow_symlink=True),
|
|
||||||
name="music",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
folder = os.path.dirname(__file__)
|
|
||||||
app.mount("/static", StaticFiles(directory=f"{folder}/static"), name="static")
|
|
||||||
reset_gate()
|
|
||||||
|
|
||||||
|
|
||||||
@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE"])
|
|
||||||
async def proxy(path: str, request: Request):
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
port = config.port + 1
|
|
||||||
url = f"http://127.0.0.1:{port}/{path}"
|
|
||||||
response = await client.request(
|
|
||||||
method=request.method,
|
|
||||||
url=url,
|
|
||||||
headers=request.headers,
|
|
||||||
params=request.query_params,
|
|
||||||
content=await request.body() if request.method in ["POST", "PUT"] else None,
|
|
||||||
)
|
|
||||||
if path == "savesetting":
|
|
||||||
# 使用BackgroundTask在响应发送完毕后执行逻辑
|
|
||||||
background_task = BackgroundTask(reset_gate)
|
|
||||||
return Response(
|
|
||||||
content=response.content,
|
|
||||||
status_code=response.status_code,
|
|
||||||
headers=dict(response.headers),
|
|
||||||
background=background_task,
|
|
||||||
)
|
|
||||||
return Response(
|
|
||||||
content=response.content,
|
|
||||||
status_code=response.status_code,
|
|
||||||
headers=dict(response.headers),
|
|
||||||
)
|
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import secrets
|
import secrets
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -8,12 +10,14 @@ from contextlib import asynccontextmanager
|
|||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
|
import aiofiles
|
||||||
from fastapi import Depends, FastAPI, HTTPException, Request, status
|
from fastapi import Depends, FastAPI, HTTPException, Request, status
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from starlette.background import BackgroundTask
|
from starlette.background import BackgroundTask
|
||||||
from starlette.responses import FileResponse
|
from starlette.responses import FileResponse, Response
|
||||||
|
|
||||||
from xiaomusic import __version__
|
from xiaomusic import __version__
|
||||||
from xiaomusic.utils import (
|
from xiaomusic.utils import (
|
||||||
@@ -79,14 +83,6 @@ def reset_http_server():
|
|||||||
else:
|
else:
|
||||||
app.dependency_overrides = {}
|
app.dependency_overrides = {}
|
||||||
|
|
||||||
# 更新 music 链接
|
|
||||||
app.router.routes = [route for route in app.router.routes if route.path != "/music"]
|
|
||||||
app.mount(
|
|
||||||
"/music",
|
|
||||||
StaticFiles(directory=config.music_path, follow_symlink=True),
|
|
||||||
name="music",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def HttpInit(_xiaomusic):
|
def HttpInit(_xiaomusic):
|
||||||
global xiaomusic, config, log
|
global xiaomusic, config, log
|
||||||
@@ -170,9 +166,12 @@ async def do_cmd(data: DidCmd):
|
|||||||
return {"ret": "Did not exist"}
|
return {"ret": "Did not exist"}
|
||||||
|
|
||||||
if len(cmd) > 0:
|
if len(cmd) > 0:
|
||||||
await xiaomusic.cancel_all_tasks()
|
try:
|
||||||
task = asyncio.create_task(xiaomusic.do_check_cmd(did=did, query=cmd))
|
await xiaomusic.cancel_all_tasks()
|
||||||
xiaomusic.append_running_task(task)
|
task = asyncio.create_task(xiaomusic.do_check_cmd(did=did, query=cmd))
|
||||||
|
xiaomusic.append_running_task(task)
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Execption {e}")
|
||||||
return {"ret": "OK"}
|
return {"ret": "OK"}
|
||||||
return {"ret": "Unknow cmd"}
|
return {"ret": "Unknow cmd"}
|
||||||
|
|
||||||
@@ -295,3 +294,66 @@ async def debug_play_by_music_url(request: Request):
|
|||||||
return await xiaomusic.debug_play_by_music_url(arg1=data_dict)
|
return await xiaomusic.debug_play_by_music_url(arg1=data_dict)
|
||||||
except json.JSONDecodeError as err:
|
except json.JSONDecodeError as err:
|
||||||
raise HTTPException(status_code=400, detail="Invalid JSON") from err
|
raise HTTPException(status_code=400, detail="Invalid JSON") from err
|
||||||
|
|
||||||
|
|
||||||
|
async def file_iterator(file_path, start, end):
|
||||||
|
async with aiofiles.open(file_path, mode="rb") as file:
|
||||||
|
await file.seek(start)
|
||||||
|
chunk_size = 1024
|
||||||
|
while start <= end:
|
||||||
|
read_size = min(chunk_size, end - start + 1)
|
||||||
|
data = await file.read(read_size)
|
||||||
|
if not data:
|
||||||
|
break
|
||||||
|
start += len(data)
|
||||||
|
yield data
|
||||||
|
|
||||||
|
|
||||||
|
range_pattern = re.compile(r"bytes=(\d+)-(\d*)")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/music/{file_path:path}")
|
||||||
|
async def music_file(request: Request, file_path: str):
|
||||||
|
absolute_path = os.path.abspath(config.music_path)
|
||||||
|
absolute_file_path = os.path.normpath(os.path.join(absolute_path, file_path))
|
||||||
|
if not absolute_file_path.startswith(absolute_path):
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
if not os.path.exists(absolute_file_path):
|
||||||
|
raise HTTPException(status_code=404, detail="File not found")
|
||||||
|
|
||||||
|
file_size = os.path.getsize(absolute_file_path)
|
||||||
|
range_start, range_end = 0, file_size - 1
|
||||||
|
|
||||||
|
range_header = request.headers.get("Range")
|
||||||
|
log.info(f"music_file range_header {range_header}")
|
||||||
|
if range_header:
|
||||||
|
range_match = range_pattern.match(range_header)
|
||||||
|
if range_match:
|
||||||
|
range_start = int(range_match.group(1))
|
||||||
|
if range_match.group(2):
|
||||||
|
range_end = int(range_match.group(2))
|
||||||
|
|
||||||
|
log.info(f"music_file in range {absolute_file_path}")
|
||||||
|
|
||||||
|
log.info(f"music_file {range_start} {range_end} {absolute_file_path}")
|
||||||
|
headers = {
|
||||||
|
"Content-Range": f"bytes {range_start}-{range_end}/{file_size}",
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
}
|
||||||
|
mime_type, _ = mimetypes.guess_type(file_path)
|
||||||
|
if mime_type is None:
|
||||||
|
mime_type = "application/octet-stream"
|
||||||
|
return StreamingResponse(
|
||||||
|
file_iterator(absolute_file_path, range_start, range_end),
|
||||||
|
headers=headers,
|
||||||
|
status_code=206 if range_header else 200,
|
||||||
|
media_type=mime_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.options("/music/{file_path:path}")
|
||||||
|
async def music_options():
|
||||||
|
headers = {
|
||||||
|
"Accept-Ranges": "bytes",
|
||||||
|
}
|
||||||
|
return Response(headers=headers)
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ $(function(){
|
|||||||
append_op_button_name("下一首");
|
append_op_button_name("下一首");
|
||||||
append_op_button_name("关机");
|
append_op_button_name("关机");
|
||||||
|
|
||||||
|
append_op_button_name("加入收藏");
|
||||||
|
append_op_button_name("取消收藏");
|
||||||
|
|
||||||
$container.append($("<hr>"));
|
$container.append($("<hr>"));
|
||||||
|
|
||||||
append_op_button_name("10分钟后关机");
|
append_op_button_name("10分钟后关机");
|
||||||
@@ -53,15 +56,17 @@ $(function(){
|
|||||||
.prop('selected', value === did);
|
.prop('selected', value === did);
|
||||||
$("#did").append(option);
|
$("#did").append(option);
|
||||||
|
|
||||||
if (cur_device.play_type == PLAY_TYPE_ALL) {
|
if (value === did) {
|
||||||
$("#play_type_all").css('background-color', '#b1a8f3');
|
if (cur_device.play_type == PLAY_TYPE_ALL) {
|
||||||
$("#play_type_all").text('✔️ 全部循环');
|
$("#play_type_all").css('background-color', '#b1a8f3');
|
||||||
} else if (cur_device.play_type == PLAY_TYPE_ONE) {
|
$("#play_type_all").text('✔️ 全部循环');
|
||||||
$("#play_type_one").css('background-color', '#b1a8f3');
|
} else if (cur_device.play_type == PLAY_TYPE_ONE) {
|
||||||
$("#play_type_one").text('✔️ 单曲循环');
|
$("#play_type_one").css('background-color', '#b1a8f3');
|
||||||
} else if (cur_device.play_type == PLAY_TYPE_RND) {
|
$("#play_type_one").text('✔️ 单曲循环');
|
||||||
$("#play_type_rnd").css('background-color', '#b1a8f3');
|
} else if (cur_device.play_type == PLAY_TYPE_RND) {
|
||||||
$("#play_type_rnd").text('✔️ 随机播放');
|
$("#play_type_rnd").css('background-color', '#b1a8f3');
|
||||||
|
$("#play_type_rnd").text('✔️ 随机播放');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -72,6 +77,7 @@ $(function(){
|
|||||||
localStorage.setItem('cur_did', did);
|
localStorage.setItem('cur_did', did);
|
||||||
window.did = did;
|
window.did = did;
|
||||||
console.log('cur_did', did);
|
console.log('cur_did', did);
|
||||||
|
location.reload();
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
<meta name="viewport" content="width=device-width">
|
<meta name="viewport" content="width=device-width">
|
||||||
<title>Debug For XiaoMusic</title>
|
<title>Debug For XiaoMusic</title>
|
||||||
|
|
||||||
<link rel="stylesheet" type="text/css" href="/static/style.css?version=1722380134">
|
<link rel="stylesheet" type="text/css" href="/static/style.css?version=1725381307">
|
||||||
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
|
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
|
||||||
<script src="/static/jquery-3.7.1.min.js?version=1722380134"></script>
|
<script src="/static/jquery-3.7.1.min.js?version=1725381307"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
var vConsole = new window.VConsole();
|
var vConsole = new window.VConsole();
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width">
|
<meta name="viewport" content="width=device-width">
|
||||||
<title>小爱音箱操控面板</title>
|
<title>小爱音箱操控面板</title>
|
||||||
<script src="/static/jquery-3.7.1.min.js?version=1722380134"></script>
|
<script src="/static/jquery-3.7.1.min.js?version=1725381307"></script>
|
||||||
<script src="/static/app.js?version=1722380134"></script>
|
<script src="/static/app.js?version=1725381307"></script>
|
||||||
<link rel="stylesheet" type="text/css" href="/static/style.css?version=1722380134">
|
<link rel="stylesheet" type="text/css" href="/static/style.css?version=1725381307">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
|
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width">
|
<meta name="viewport" content="width=device-width">
|
||||||
<title>M3U to JSON Converter</title>
|
<title>M3U to JSON Converter</title>
|
||||||
<link rel="stylesheet" type="text/css" href="/static/style.css?version=1722380134">
|
<link rel="stylesheet" type="text/css" href="/static/style.css?version=1725381307">
|
||||||
<!--
|
<!--
|
||||||
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
|
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -3,9 +3,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta name="viewport" content="width=device-width">
|
<meta name="viewport" content="width=device-width">
|
||||||
<title>小爱音箱操控面板</title>
|
<title>小爱音箱操控面板</title>
|
||||||
<script src="/static/jquery-3.7.1.min.js?version=1722380134"></script>
|
<script src="/static/jquery-3.7.1.min.js?version=1725381307"></script>
|
||||||
<script src="/static/setting.js?version=1722380134"></script>
|
<script src="/static/setting.js?version=1725381307"></script>
|
||||||
<link rel="stylesheet" type="text/css" href="/static/style.css?version=1722380134">
|
<link rel="stylesheet" type="text/css" href="/static/style.css?version=1725381307">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
|
<script src="https://unpkg.com/vconsole@latest/dist/vconsole.min.js"></script>
|
||||||
@@ -98,7 +98,13 @@ var vConsole = new window.VConsole();
|
|||||||
|
|
||||||
<label for="remove_id3tag">去除MP3 ID3v2和填充,减少播放前延迟:</label>
|
<label for="remove_id3tag">去除MP3 ID3v2和填充,减少播放前延迟:</label>
|
||||||
<select id="remove_id3tag">
|
<select id="remove_id3tag">
|
||||||
<option value="true" >true</option>
|
<option value="true">true</option>
|
||||||
|
<option value="false" selected>false</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="convert_to_mp3">转换为MP3</label>
|
||||||
|
<select id="convert_to_mp3">
|
||||||
|
<option value="true">true</option>
|
||||||
<option value="false" selected>false</option>
|
<option value="false" selected>false</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
@@ -127,6 +133,12 @@ var vConsole = new window.VConsole();
|
|||||||
<option value="false">false</option>
|
<option value="false">false</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<label for="use_music_api">触屏版兼容模式:</label>
|
||||||
|
<select id="use_music_api">
|
||||||
|
<option value="true">true</option>
|
||||||
|
<option value="false" selected>false</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
<label for="public_port">外网访问端口(0表示跟监听端口一致):</label>
|
<label for="public_port">外网访问端口(0表示跟监听端口一致):</label>
|
||||||
<input id="public_port" type="number" value="0"></input>
|
<input id="public_port" type="number" value="0"></input>
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import copy
|
import copy
|
||||||
import difflib
|
import difflib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
@@ -11,6 +12,7 @@ import random
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import string
|
import string
|
||||||
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from http.cookies import SimpleCookie
|
from http.cookies import SimpleCookie
|
||||||
@@ -141,9 +143,7 @@ def _append_files_result(result, root, joinpath, files, support_extension):
|
|||||||
result[dir_name].append(os.path.join(joinpath, file))
|
result[dir_name].append(os.path.join(joinpath, file))
|
||||||
|
|
||||||
|
|
||||||
def traverse_music_directory(
|
def traverse_music_directory(directory, depth, exclude_dirs, support_extension):
|
||||||
directory, depth=10, exclude_dirs=None, support_extension=None
|
|
||||||
):
|
|
||||||
result = {}
|
result = {}
|
||||||
for root, dirs, files in os.walk(directory, followlinks=True):
|
for root, dirs, files in os.walk(directory, followlinks=True):
|
||||||
# 忽略排除的目录
|
# 忽略排除的目录
|
||||||
@@ -190,7 +190,11 @@ def is_mp3(url):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def _get_web_music_duration(session, url, start=0, end=500):
|
def is_m4a(url):
|
||||||
|
return url.endswith(".m4a")
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_web_music_duration(session, url, ffmpeg_location, start=0, end=500):
|
||||||
duration = 0
|
duration = 0
|
||||||
headers = {"Range": f"bytes={start}-{end}"}
|
headers = {"Range": f"bytes={start}-{end}"}
|
||||||
async with session.get(url, headers=headers) as response:
|
async with session.get(url, headers=headers) as response:
|
||||||
@@ -200,6 +204,8 @@ async def _get_web_music_duration(session, url, start=0, end=500):
|
|||||||
try:
|
try:
|
||||||
if is_mp3(url):
|
if is_mp3(url):
|
||||||
m = mutagen.mp3.MP3(tmp)
|
m = mutagen.mp3.MP3(tmp)
|
||||||
|
elif is_m4a(url):
|
||||||
|
return get_duration_by_ffprobe(tmp, ffmpeg_location)
|
||||||
else:
|
else:
|
||||||
m = mutagen.File(tmp)
|
m = mutagen.File(tmp)
|
||||||
duration = m.info.length
|
duration = m.info.length
|
||||||
@@ -208,7 +214,7 @@ async def _get_web_music_duration(session, url, start=0, end=500):
|
|||||||
return duration
|
return duration
|
||||||
|
|
||||||
|
|
||||||
async def get_web_music_duration(url):
|
async def get_web_music_duration(url, ffmpeg_location="./ffmpeg/bin"):
|
||||||
duration = 0
|
duration = 0
|
||||||
try:
|
try:
|
||||||
parsed_url = urlparse(url)
|
parsed_url = urlparse(url)
|
||||||
@@ -228,10 +234,12 @@ async def get_web_music_duration(url):
|
|||||||
# 设置总超时时间为3秒
|
# 设置总超时时间为3秒
|
||||||
timeout = aiohttp.ClientTimeout(total=3)
|
timeout = aiohttp.ClientTimeout(total=3)
|
||||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
duration = await _get_web_music_duration(session, url, start=0, end=500)
|
duration = await _get_web_music_duration(
|
||||||
|
session, url, ffmpeg_location, start=0, end=500
|
||||||
|
)
|
||||||
if duration <= 0:
|
if duration <= 0:
|
||||||
duration = await _get_web_music_duration(
|
duration = await _get_web_music_duration(
|
||||||
session, url, start=0, end=3000
|
session, url, ffmpeg_location, start=0, end=3000
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error get_web_music_duration: {e}")
|
logging.error(f"Error get_web_music_duration: {e}")
|
||||||
@@ -239,18 +247,47 @@ async def get_web_music_duration(url):
|
|||||||
|
|
||||||
|
|
||||||
# 获取文件播放时长
|
# 获取文件播放时长
|
||||||
async def get_local_music_duration(filename):
|
async def get_local_music_duration(filename, ffmpeg_location="./ffmpeg/bin"):
|
||||||
loop = asyncio.get_event_loop()
|
loop = asyncio.get_event_loop()
|
||||||
duration = 0
|
duration = 0
|
||||||
try:
|
try:
|
||||||
if is_mp3(filename):
|
if is_mp3(filename):
|
||||||
m = await loop.run_in_executor(None, mutagen.mp3.MP3, filename)
|
m = await loop.run_in_executor(None, mutagen.mp3.MP3, filename)
|
||||||
|
elif is_m4a(filename):
|
||||||
|
duration = get_duration_by_ffprobe(filename, ffmpeg_location)
|
||||||
|
return duration
|
||||||
else:
|
else:
|
||||||
m = await loop.run_in_executor(None, mutagen.File, filename)
|
m = await loop.run_in_executor(None, mutagen.File, filename)
|
||||||
if m and m.info:
|
duration = m.info.length
|
||||||
duration = m.info.length
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.error(f"Error getting local music duration: {e}")
|
logging.error(f"Error getting local music {filename} duration: {e}")
|
||||||
|
return duration
|
||||||
|
|
||||||
|
|
||||||
|
def get_duration_by_ffprobe(file_path, ffmpeg_location):
|
||||||
|
# 使用 ffprobe 获取文件的元数据,并以 JSON 格式输出
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
os.path.join(ffmpeg_location, "ffprobe"),
|
||||||
|
"-v",
|
||||||
|
"error", # 只输出错误信息,避免混杂在其他输出中
|
||||||
|
"-show_entries",
|
||||||
|
"format=duration", # 仅显示时长
|
||||||
|
"-of",
|
||||||
|
"json", # 以 JSON 格式输出
|
||||||
|
file_path,
|
||||||
|
],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 解析 JSON 输出
|
||||||
|
ffprobe_output = json.loads(result.stdout)
|
||||||
|
|
||||||
|
# 获取时长
|
||||||
|
duration = float(ffprobe_output["format"]["duration"])
|
||||||
|
|
||||||
return duration
|
return duration
|
||||||
|
|
||||||
|
|
||||||
@@ -333,3 +370,35 @@ def remove_id3_tags(file_path):
|
|||||||
change = True
|
change = True
|
||||||
|
|
||||||
return change
|
return change
|
||||||
|
|
||||||
|
|
||||||
|
def convert_file_to_mp3(input_file: str, ffmpeg_location: str, music_path: str) -> str:
|
||||||
|
"""
|
||||||
|
Convert the music file to MP3 format and return the path of the temporary MP3 file.
|
||||||
|
"""
|
||||||
|
# 指定临时文件的目录为 music_path 目录下的 tmp 文件夹
|
||||||
|
temp_dir = os.path.join(music_path, "tmp")
|
||||||
|
if not os.path.exists(temp_dir):
|
||||||
|
os.makedirs(temp_dir) # 确保目录存在
|
||||||
|
|
||||||
|
out_file_name = os.path.splitext(os.path.basename(input_file))[0]
|
||||||
|
out_file_path = os.path.join(temp_dir, f"{out_file_name}.mp3")
|
||||||
|
|
||||||
|
command = [
|
||||||
|
os.path.join(ffmpeg_location, "ffmpeg"),
|
||||||
|
"-i",
|
||||||
|
input_file,
|
||||||
|
"-f",
|
||||||
|
"mp3",
|
||||||
|
"-y",
|
||||||
|
out_file_path,
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.run(command, check=True)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Error during conversion: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
relative_path = os.path.relpath(out_file_path, music_path)
|
||||||
|
return relative_path
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from xiaomusic.const import (
|
|||||||
)
|
)
|
||||||
from xiaomusic.plugin import PluginManager
|
from xiaomusic.plugin import PluginManager
|
||||||
from xiaomusic.utils import (
|
from xiaomusic.utils import (
|
||||||
|
convert_file_to_mp3,
|
||||||
custom_sort_key,
|
custom_sort_key,
|
||||||
deepcopy_data_no_sensitive_info,
|
deepcopy_data_no_sensitive_info,
|
||||||
find_best_match,
|
find_best_match,
|
||||||
@@ -107,6 +108,7 @@ class XiaoMusic:
|
|||||||
self.exclude_dirs = set(self.config.exclude_dirs.split(","))
|
self.exclude_dirs = set(self.config.exclude_dirs.split(","))
|
||||||
self.music_path_depth = self.config.music_path_depth
|
self.music_path_depth = self.config.music_path_depth
|
||||||
self.remove_id3tag = self.config.remove_id3tag
|
self.remove_id3tag = self.config.remove_id3tag
|
||||||
|
self.convert_to_mp3 = self.config.convert_to_mp3
|
||||||
|
|
||||||
def update_devices(self):
|
def update_devices(self):
|
||||||
self.device_id_did = {} # key 为 device_id
|
self.device_id_did = {} # key 为 device_id
|
||||||
@@ -222,7 +224,7 @@ class XiaoMusic:
|
|||||||
self.log.error(f"{self.mi_token_home} file not exist")
|
self.log.error(f"{self.mi_token_home} file not exist")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
with open(self.mi_token_home) as f:
|
with open(self.mi_token_home, encoding="utf-8") as f:
|
||||||
user_data = json.loads(f.read())
|
user_data = json.loads(f.read())
|
||||||
user_id = user_data.get("userId")
|
user_id = user_data.get("userId")
|
||||||
service_token = user_data.get("micoapi")[1]
|
service_token = user_data.get("micoapi")[1]
|
||||||
@@ -351,13 +353,17 @@ class XiaoMusic:
|
|||||||
|
|
||||||
if self.is_web_music(name):
|
if self.is_web_music(name):
|
||||||
origin_url = url
|
origin_url = url
|
||||||
duration, url = await get_web_music_duration(url)
|
duration, url = await get_web_music_duration(
|
||||||
|
url, self.config.ffmpeg_location
|
||||||
|
)
|
||||||
sec = math.ceil(duration)
|
sec = math.ceil(duration)
|
||||||
self.log.info(f"网络歌曲 {name} : {origin_url} {url} 的时长 {sec} 秒")
|
self.log.info(f"网络歌曲 {name} : {origin_url} {url} 的时长 {sec} 秒")
|
||||||
else:
|
else:
|
||||||
filename = self.get_filename(name)
|
filename = self.get_filename(name)
|
||||||
self.log.info(f"get_music_sec_url. name:{name} filename:{filename}")
|
self.log.info(f"get_music_sec_url. name:{name} filename:{filename}")
|
||||||
duration = await get_local_music_duration(filename)
|
duration = await get_local_music_duration(
|
||||||
|
filename, self.config.ffmpeg_location
|
||||||
|
)
|
||||||
sec = math.ceil(duration)
|
sec = math.ceil(duration)
|
||||||
self.log.info(f"本地歌曲 {name} : {filename} {url} 的时长 {sec} 秒")
|
self.log.info(f"本地歌曲 {name} : {filename} {url} 的时长 {sec} 秒")
|
||||||
|
|
||||||
@@ -381,12 +387,27 @@ class XiaoMusic:
|
|||||||
else:
|
else:
|
||||||
self.log.info("No ID3 tag remove needed")
|
self.log.info("No ID3 tag remove needed")
|
||||||
|
|
||||||
|
# 如果开启了MP3转换功能,且文件不是MP3格式,则进行转换
|
||||||
|
if self.convert_to_mp3 and not is_mp3(filename):
|
||||||
|
self.log.info(f"convert_to_mp3 is enabled. Checking file: {filename}")
|
||||||
|
temp_mp3_file = convert_file_to_mp3(
|
||||||
|
filename, self.config.ffmpeg_location, self.config.music_path
|
||||||
|
)
|
||||||
|
if temp_mp3_file:
|
||||||
|
self.log.info(f"Converted file: {filename} to {temp_mp3_file}")
|
||||||
|
filename = temp_mp3_file
|
||||||
|
else:
|
||||||
|
self.log.warning(f"Failed to convert file to MP3 format: {filename}")
|
||||||
|
|
||||||
|
# 构造音乐文件的URL
|
||||||
filename = filename.replace("\\", "/")
|
filename = filename.replace("\\", "/")
|
||||||
if filename.startswith(self.config.music_path):
|
if filename.startswith(self.config.music_path):
|
||||||
filename = filename[len(self.config.music_path) :]
|
filename = filename[len(self.config.music_path) :]
|
||||||
if filename.startswith("/"):
|
if filename.startswith("/"):
|
||||||
filename = filename[1:]
|
filename = filename[1:]
|
||||||
|
|
||||||
self.log.info(f"get_music_url local music. name:{name}, filename:{filename}")
|
self.log.info(f"get_music_url local music. name:{name}, filename:{filename}")
|
||||||
|
|
||||||
encoded_name = urllib.parse.quote(filename)
|
encoded_name = urllib.parse.quote(filename)
|
||||||
return f"http://{self.hostname}:{self.public_port}/music/{encoded_name}"
|
return f"http://{self.hostname}:{self.public_port}/music/{encoded_name}"
|
||||||
|
|
||||||
@@ -433,6 +454,8 @@ class XiaoMusic:
|
|||||||
|
|
||||||
self.music_list["全部"] = list(self.all_music.keys())
|
self.music_list["全部"] = list(self.all_music.keys())
|
||||||
|
|
||||||
|
self._append_custom_play_list()
|
||||||
|
|
||||||
# 歌单排序
|
# 歌单排序
|
||||||
for _, play_list in self.music_list.items():
|
for _, play_list in self.music_list.items():
|
||||||
play_list.sort(key=custom_sort_key)
|
play_list.sort(key=custom_sort_key)
|
||||||
@@ -441,6 +464,16 @@ class XiaoMusic:
|
|||||||
for device in self.devices.values():
|
for device in self.devices.values():
|
||||||
device.update_playlist()
|
device.update_playlist()
|
||||||
|
|
||||||
|
def _append_custom_play_list(self):
|
||||||
|
if not self.config.custom_play_list_json:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
custom_play_list = json.loads(self.config.custom_play_list_json)
|
||||||
|
self.music_list["收藏"] = list(custom_play_list["收藏"])
|
||||||
|
except Exception as e:
|
||||||
|
self.log.exception(f"Execption {e}")
|
||||||
|
|
||||||
# 给歌单里补充网络歌单
|
# 给歌单里补充网络歌单
|
||||||
def _append_music_list(self):
|
def _append_music_list(self):
|
||||||
if not self.config.music_list_json:
|
if not self.config.music_list_json:
|
||||||
@@ -694,6 +727,47 @@ class XiaoMusic:
|
|||||||
minute = int(arg1)
|
minute = int(arg1)
|
||||||
return await self.devices[did].stop_after_minute(minute)
|
return await self.devices[did].stop_after_minute(minute)
|
||||||
|
|
||||||
|
# 添加歌曲到收藏列表
|
||||||
|
async def add_to_favorites(self, did="", arg1="", **kwargs):
|
||||||
|
name = arg1 if arg1 else self.playingmusic(did)
|
||||||
|
if not name:
|
||||||
|
return
|
||||||
|
|
||||||
|
favorites = self.music_list.get("收藏", [])
|
||||||
|
if name in favorites:
|
||||||
|
return
|
||||||
|
|
||||||
|
favorites.append(name)
|
||||||
|
self.save_favorites(favorites)
|
||||||
|
|
||||||
|
# 从收藏列表中移除
|
||||||
|
async def del_from_favorites(self, did="", arg1="", **kwargs):
|
||||||
|
name = arg1 if arg1 else self.playingmusic(did)
|
||||||
|
if not name:
|
||||||
|
return
|
||||||
|
|
||||||
|
favorites = self.music_list.get("收藏", [])
|
||||||
|
if name not in favorites:
|
||||||
|
return
|
||||||
|
|
||||||
|
favorites.remove(name)
|
||||||
|
self.save_favorites(favorites)
|
||||||
|
|
||||||
|
def save_favorites(self, favorites):
|
||||||
|
self.music_list["收藏"] = favorites
|
||||||
|
custom_play_list = {}
|
||||||
|
if self.config.custom_play_list_json:
|
||||||
|
custom_play_list = json.loads(self.config.custom_play_list_json)
|
||||||
|
custom_play_list["收藏"] = favorites
|
||||||
|
self.config.custom_play_list_json = json.dumps(
|
||||||
|
custom_play_list, ensure_ascii=False
|
||||||
|
)
|
||||||
|
self.save_cur_config()
|
||||||
|
|
||||||
|
# 更新每个设备的歌单
|
||||||
|
for device in self.devices.values():
|
||||||
|
device.update_playlist()
|
||||||
|
|
||||||
# 获取音量
|
# 获取音量
|
||||||
async def get_volume(self, did="", **kwargs):
|
async def get_volume(self, did="", **kwargs):
|
||||||
return await self.devices[did].get_volume()
|
return await self.devices[did].get_volume()
|
||||||
@@ -735,7 +809,7 @@ class XiaoMusic:
|
|||||||
def try_init_setting(self):
|
def try_init_setting(self):
|
||||||
try:
|
try:
|
||||||
filename = self.config.getsettingfile()
|
filename = self.config.getsettingfile()
|
||||||
with open(filename) as f:
|
with open(filename, encoding="utf-8") as f:
|
||||||
data = json.loads(f.read())
|
data = json.loads(f.read())
|
||||||
self.update_config_from_setting(data)
|
self.update_config_from_setting(data)
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -932,7 +1006,7 @@ class XiaoMusicDevice:
|
|||||||
self.log.info(f"播放 {name} 失败")
|
self.log.info(f"播放 {name} 失败")
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
if self.isplaying() and self._last_cmd != "stop":
|
if self.isplaying() and self._last_cmd != "stop":
|
||||||
await self._play_next()
|
await self._play_next()
|
||||||
return
|
return
|
||||||
|
|
||||||
self.log.info(f"【{name}】已经开始播放了")
|
self.log.info(f"【{name}】已经开始播放了")
|
||||||
|
|||||||
Reference in New Issue
Block a user