1
0
mirror of https://github.com/hanxi/xiaomusic.git synced 2025-12-08 15:08:12 +08:00

Compare commits

...

31 Commits

Author SHA1 Message Date
涵曦
8e5df7094c bump: version 0.3.27 → 0.3.28 2024-09-03 16:35:08 +00:00
涵曦
64c2f54ff0 build: update static version 2024-09-03 16:35:07 +00:00
涵曦
d1b869ae43 feat: 新增歌曲收藏功能 #87 2024-09-03 15:55:19 +00:00
涵曦
d3895f2632 refactor: ffmpeg_location 从配置里读取 2024-09-03 14:33:20 +00:00
hui
5bf62c4b1a fix: docker下minetypes无法判断m4a 2024-09-03 18:33:57 +08:00
hui
406e09922c fix:m4a无法正确获取播放时长 2024-09-03 18:33:57 +08:00
hui
ae34572d13 fix:指定文件编码,修复windows下的文件读取错误 2024-09-03 18:33:57 +08:00
涵曦
1e3c69ea90 Update release.yml 2024-09-02 09:09:49 +08:00
涵曦
3c232505f8 bump: version 0.3.26 → 0.3.27 2024-09-02 00:37:48 +00:00
涵曦
44177db9b6 build: update static version 2024-09-02 00:37:47 +00:00
涵曦
e72ae973bc refactor: 处理 code review 问题' 2024-09-01 16:09:33 +00:00
Hi-Jiajun
4ab3c5cbee feat: Add feature as requested in issue #143 2024-09-01 16:09:20 +00:00
涵曦
4e532d298d fix: 默认下载目录修改 2024-08-18 02:39:22 +00:00
涵曦
3372440f4e bump: version 0.3.25 → 0.3.26 2024-08-17 15:00:11 +00:00
涵曦
1255239912 build: update static version 2024-08-17 15:00:10 +00:00
涵曦
e401a73595 feat: 删除网关模式 2024-08-17 11:22:23 +00:00
涵曦
cca6e47da5 bump: version 0.3.24 → 0.3.25 2024-08-16 11:18:21 +00:00
涵曦
415e75d4b4 build: update static version 2024-08-16 11:18:20 +00:00
涵曦
3c5573a2fc feat: 设置页面支持配置 use_music_api 选项 2024-08-16 11:18:11 +00:00
涵曦
7275b59d40 Update README.md 2024-08-08 09:35:17 +08:00
涵曦
a8d0631c33 bump: version 0.3.23 → 0.3.24 2024-08-01 23:47:58 +00:00
涵曦
3cfc96b779 build: update static version 2024-08-01 23:47:57 +00:00
涵曦
489f3f1d6f fix: #131 修复多设备切换时播放模式显示错误问题 2024-08-01 23:21:31 +00:00
涵曦
a5f2fc195e bump: version 0.3.22 → 0.3.23 2024-08-01 16:00:06 +00:00
涵曦
393dbabf4b build: update static version 2024-08-01 16:00:05 +00:00
涵曦
444e697f9d fix: 修复部分文件获取不到播放时长问题 2024-08-01 15:52:20 +00:00
涵曦
cf01039b53 fix: 处理安全问题 2024-08-01 00:46:18 +00:00
涵曦
a8fefc6f82 bump: version 0.3.21 → 0.3.22 2024-08-01 00:23:17 +00:00
涵曦
ae0b6066d9 build: update static version 2024-08-01 00:23:17 +00:00
涵曦
53f5e7db8c feat: 网关模式支持配置,默认关闭 2024-08-01 00:22:48 +00:00
涵曦
2a1fa9f8cf fix: 继续优化延迟问题 2024-08-01 00:05:19 +00:00
19 changed files with 417 additions and 170 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -63,6 +63,8 @@ services:
XIAOMUSIC_PORT: 5678 XIAOMUSIC_PORT: 5678
``` ```
如果不是首次修改端口,还需要修改 setting.json 文件里的端口。
遇到问题可以去 web 设置页面底部点击【下载日志文件】按钮,然后搜索一下日志文件内容确保里面没有账号密码信息后(有就删除这些敏感信息),然后在提 issues 反馈问题时把下载的日志文件带上。 遇到问题可以去 web 设置页面底部点击【下载日志文件】按钮,然后搜索一下日志文件内容确保里面没有账号密码信息后(有就删除这些敏感信息),然后在提 issues 反馈问题时把下载的日志文件带上。
> 目前除了 XIAOMUSIC_PORT 只能在启动前配置,其他参数都可以在 web 网页里配置。 > 目前除了 XIAOMUSIC_PORT 只能在启动前配置,其他参数都可以在 web 网页里配置。

View File

@@ -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
View File

@@ -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"},
] ]

View File

@@ -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"},

View 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))

View File

@@ -1 +1 @@
__version__ = "0.3.21" __version__ = "0.3.28"

View File

@@ -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__":

View File

@@ -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")

View File

@@ -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),
)

View File

@@ -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)

View File

@@ -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();
}) })
}); });

View File

@@ -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();

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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}】已经开始播放了")