mirror of
https://github.com/hanxi/xiaomusic.git
synced 2025-12-06 14:52:50 +08:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1938b9096 | ||
|
|
5242deea33 | ||
|
|
d06b3cd2a5 | ||
|
|
2a59d1f69c | ||
|
|
358f7ccb98 | ||
|
|
1043b6f32f | ||
|
|
8f2d26ac10 | ||
|
|
8e8a605816 | ||
|
|
63eb0c22cb | ||
|
|
ca07ed0dd3 | ||
|
|
7d158ff40e | ||
|
|
90243b395a | ||
|
|
653bd417e5 | ||
|
|
6d89a24b28 | ||
|
|
0ca2adb014 | ||
|
|
a8a9e2bc45 | ||
|
|
7e7bb256b5 | ||
|
|
35c43646cb | ||
|
|
c1a2ab791f | ||
|
|
54ebd772ce | ||
|
|
47932000be | ||
|
|
41e579d782 |
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -43,7 +43,7 @@ jobs:
|
||||
|
||||
build-image:
|
||||
runs-on: ubuntu-latest
|
||||
needs: release-pypi
|
||||
#needs: release-pypi
|
||||
# run unless event type is pull_request
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
@@ -63,4 +63,4 @@ jobs:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: yihong0618/xiaogpt:${{ github.ref_name }}
|
||||
tags: ${{ secrets.DOCKERHUB_USERNAME }}/xiaomusic:${{ github.ref_name }}, ${{ secrets.DOCKERHUB_USERNAME }}/xiaomusic:latest, ${{ secrets.DOCKERHUB_USERNAME }}/xiaomusic:stable
|
||||
|
||||
@@ -9,9 +9,9 @@ FROM python:3.10-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
COPY --from=builder /app/ffmpeg /app/ffmpeg
|
||||
COPY xiaomusic/ ./xiaomusic/
|
||||
COPY xiaomusic.py .
|
||||
COPY ffmpeg/ ./ffmpeg/
|
||||
ENV XDG_CONFIG_HOME=/config
|
||||
ENV XIAOMUSIC_HOSTNAME=192.168.2.5
|
||||
ENV XIAOMUSIC_PORT=8090
|
||||
|
||||
11
README.md
11
README.md
@@ -33,14 +33,21 @@ pdm run xiaomusic.py
|
||||
```txt
|
||||
"L07A": ("5-1", "5-5"), # Redmi小爱音箱Play(l7a)
|
||||
````
|
||||
## 支持音乐格式
|
||||
|
||||
- mp3
|
||||
- flac
|
||||
|
||||
> 本地音乐会搜索 mp3 和 flac 格式的文件,下载的歌曲是 mp3 格式的。
|
||||
|
||||
## 在 Docker 里使用
|
||||
|
||||
```shell
|
||||
docker run -e MI_USER=<your-xiaomi-account> -e MI_PASS=<your-xiaomi-password> -e MI_DID=<your-xiaomi-speaker-mid> -e XIAOMUSIC_PROXY=<proxy-for-yt-dlp> -e XIAOMUSIC_HOSTNAME=192.168.2.5 -p 8090:8090 -v ./music:/app/music xiaomusic --hardware=<L07A> -
|
||||
docker run -e MI_USER=<your-xiaomi-account> -e MI_PASS=<your-xiaomi-password> -e MI_DID=<your-xiaomi-speaker-mid> -e MI_HARDWARE='L07A' -e XIAOMUSIC_PROXY=<proxy-for-yt-dlp> -e XIAOMUSIC_HOSTNAME=192.168.2.5 -p 8090:8090 -v ./music:/app/music hanxi/xiaomusic
|
||||
```
|
||||
|
||||
- XIAOMUSIC_PROXY 用于配置代理,yt-dlp 工具下载歌曲会用到。
|
||||
- XIAOMUSIC_PROXY 用于配置代理,默认为空,yt-dlp 工具下载歌曲会用到。
|
||||
- MI_HARDWARE 是小米音箱的型号,默认为'L07A'
|
||||
- 注意端口必须映射为与容器内一致,XIAOMUSIC_HOSTNAME 需要设置为宿主机的 IP 地址,否则小爱无法正常播放。
|
||||
- 可以把 /app/music 目录映射到本地,用于保存下载的歌曲。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "xiaomusic"
|
||||
version = "0.1.2"
|
||||
version = "0.1.5"
|
||||
description = "Play Music with xiaomi AI speaker"
|
||||
authors = [
|
||||
{name = "涵曦", email = "im.hanxi@gmail.com"},
|
||||
|
||||
@@ -37,15 +37,22 @@ KEY_WORD_DICT = {
|
||||
"播放歌曲": "play",
|
||||
"放歌曲": "play",
|
||||
"下一首": "play_next",
|
||||
"单曲循环":"set_play_type_one",
|
||||
"全部循环":"set_play_type_all",
|
||||
"关机":"stop",
|
||||
"停止播放":"stop",
|
||||
"单曲循环": "set_play_type_one",
|
||||
"全部循环": "set_play_type_all",
|
||||
"随机播放": "random_play",
|
||||
"关机": "stop",
|
||||
"停止播放": "stop",
|
||||
}
|
||||
|
||||
SUPPORT_MUSIC_TYPE = [
|
||||
"mp3",
|
||||
"flac",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
hardware: str = "L07A"
|
||||
hardware: str = os.getenv("MI_HARDWARE", "L07A")
|
||||
account: str = os.getenv("MI_USER", "")
|
||||
password: str = os.getenv("MI_PASS", "")
|
||||
mi_did: str = os.getenv("MI_DID", "")
|
||||
@@ -56,7 +63,7 @@ class Config:
|
||||
music_path: str = os.getenv("XIAOMUSIC_MUSIC_PATH", "music")
|
||||
hostname: str = os.getenv("XIAOMUSIC_HOSTNAME", "192.168.2.5")
|
||||
port: int = int(os.getenv("XIAOMUSIC_PORT", "8090"))
|
||||
proxy: str = os.getenv("XIAOMUSIC_PROXY", "http://192.168.2.5:8080")
|
||||
proxy: str | None = os.getenv("XIAOMUSIC_PROXY", None)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.proxy:
|
||||
|
||||
@@ -29,6 +29,7 @@ from xiaomusic.config import (
|
||||
COOKIE_TEMPLATE,
|
||||
LATEST_ASK_API,
|
||||
KEY_WORD_DICT,
|
||||
SUPPORT_MUSIC_TYPE,
|
||||
Config,
|
||||
)
|
||||
from xiaomusic.utils import (
|
||||
@@ -38,8 +39,9 @@ from xiaomusic.utils import (
|
||||
|
||||
EOF = object()
|
||||
|
||||
PLAY_TYPE_ONE = 0 # 单曲循环
|
||||
PLAY_TYPE_ALL = 1 # 全部循环
|
||||
PLAY_TYPE_ONE = 0 # 单曲循环
|
||||
PLAY_TYPE_ALL = 1 # 全部循环
|
||||
|
||||
|
||||
class ThreadedHTTPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
|
||||
pass
|
||||
@@ -61,6 +63,7 @@ class HTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
|
||||
# ignore this or TODO find out why the error later
|
||||
pass
|
||||
|
||||
|
||||
class XiaoMusic:
|
||||
def __init__(self, config: Config):
|
||||
self.config = config
|
||||
@@ -83,7 +86,7 @@ class XiaoMusic:
|
||||
# 下载对象
|
||||
self.download_proc = None
|
||||
# 单曲循环,全部循环
|
||||
self.play_type = PLAY_TYPE_ONE
|
||||
self.play_type = PLAY_TYPE_ALL
|
||||
self.cur_music = ""
|
||||
self._next_timer = None
|
||||
self._timeout = 0
|
||||
@@ -98,16 +101,16 @@ class XiaoMusic:
|
||||
async with ClientSession() as session:
|
||||
session._cookie_jar = self.cookie_jar
|
||||
while True:
|
||||
self.log.debug(
|
||||
"Listening new message, timestamp: %s", self.last_timestamp
|
||||
)
|
||||
# self.log.debug(
|
||||
# "Listening new message, timestamp: %s", self.last_timestamp
|
||||
# )
|
||||
await self.get_latest_ask_from_xiaoai(session)
|
||||
start = time.perf_counter()
|
||||
self.log.debug("Polling_event, timestamp: %s", self.last_timestamp)
|
||||
# self.log.debug("Polling_event, timestamp: %s", self.last_timestamp)
|
||||
await self.polling_event.wait()
|
||||
if (d := time.perf_counter() - start) < 1:
|
||||
# sleep to avoid too many request
|
||||
self.log.debug("Sleep %f, timestamp: %s", d, self.last_timestamp)
|
||||
# self.log.debug("Sleep %f, timestamp: %s", d, self.last_timestamp)
|
||||
await asyncio.sleep(1 - d)
|
||||
|
||||
async def init_all_data(self, session):
|
||||
@@ -286,63 +289,86 @@ class XiaoMusic:
|
||||
def is_downloading(self):
|
||||
if not self.download_proc:
|
||||
return False
|
||||
if self.download_proc.returncode != None \
|
||||
and self.download_proc.returncode < 0:
|
||||
if self.download_proc.returncode != None and self.download_proc.returncode < 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
# 下载歌曲
|
||||
async def download(self, name):
|
||||
if self.download_proc:
|
||||
self.download_proc.kill()
|
||||
try:
|
||||
self.download_proc.kill()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
self.download_proc = await asyncio.create_subprocess_exec(
|
||||
"yt-dlp", f"ytsearch:{name}",
|
||||
"-x", "--audio-format", "mp3",
|
||||
"--paths", self.music_path,
|
||||
"-o", f"{name}.mp3",
|
||||
"--proxy", f"{self.proxy}",
|
||||
"--ffmpeg-location", "./ffmpeg/bin")
|
||||
sbp_args = (
|
||||
"yt-dlp",
|
||||
f"ytsearch:{name}",
|
||||
"-x",
|
||||
"--audio-format",
|
||||
"mp3",
|
||||
"--paths",
|
||||
self.music_path,
|
||||
"-o",
|
||||
f"{name}.mp3",
|
||||
"--ffmpeg-location",
|
||||
"./ffmpeg/bin",
|
||||
)
|
||||
|
||||
if self.proxy:
|
||||
sbp_args += ("--proxy", f"{self.proxy}")
|
||||
|
||||
self.download_proc = await asyncio.create_subprocess_exec(*sbp_args)
|
||||
await self.do_tts(f"正在下载歌曲{name}")
|
||||
|
||||
def get_filename(self, name):
|
||||
filename = os.path.join(self.music_path, f"{name}.mp3")
|
||||
filename = os.path.join(self.music_path, name)
|
||||
return filename
|
||||
|
||||
# 本地是否存在歌曲
|
||||
def local_exist(self, name):
|
||||
filename = self.get_filename(name)
|
||||
self.log.debug("local_exist. filename:%s", filename)
|
||||
return os.path.exists(filename)
|
||||
for tp in SUPPORT_MUSIC_TYPE:
|
||||
filename = self.get_filename(f"{name}.{tp}")
|
||||
self.log.debug("try local_exist. filename:%s", filename)
|
||||
if os.path.exists(filename):
|
||||
return filename
|
||||
return ""
|
||||
|
||||
# 获取歌曲播放地址
|
||||
def get_file_url(self, name):
|
||||
encoded_name = urllib.parse.quote(os.path.basename(name))
|
||||
return f"http://{self.hostname}:{self.port}/{encoded_name}.mp3"
|
||||
def get_file_url(self, filename):
|
||||
encoded_name = urllib.parse.quote(os.path.basename(filename))
|
||||
return f"http://{self.hostname}:{self.port}/{encoded_name}"
|
||||
|
||||
# 随机获取一首音乐
|
||||
def random_music(self):
|
||||
files = os.listdir(self.music_path)
|
||||
# 过滤 mp3 文件
|
||||
mp3_files = [file for file in files if file.endswith(".mp3")]
|
||||
if len(mp3_files) == 0:
|
||||
# 过滤音乐文件
|
||||
music_files = []
|
||||
for file in files:
|
||||
for tp in SUPPORT_MUSIC_TYPE:
|
||||
if file.endswith(f".{tp}"):
|
||||
music_files.append(file)
|
||||
|
||||
if len(music_files) == 0:
|
||||
self.log.warning(f"没有随机到歌曲")
|
||||
return ""
|
||||
# 随机选择一个文件
|
||||
mp3_file = random.choice(mp3_files)
|
||||
name = mp3_file[:-4]
|
||||
self.log.info(f"随机到歌曲{name}")
|
||||
return name
|
||||
music_file = random.choice(music_files)
|
||||
(filename, extension) = os.path.splitext(music_file)
|
||||
self.log.info(f"随机到歌曲{filename}{extension}")
|
||||
return filename
|
||||
|
||||
# 获取mp3文件播放时长
|
||||
def get_mp3_duration(self, name):
|
||||
filename = self.get_filename(name)
|
||||
audio = mutagen.mp3.MP3(filename)
|
||||
return audio.info.length
|
||||
# 获取文件播放时长
|
||||
def get_file_duration(self, filename):
|
||||
# 获取音频文件对象
|
||||
audio = mutagen.File(filename)
|
||||
# 获取播放时长
|
||||
duration = audio.info.length
|
||||
return duration
|
||||
|
||||
# 设置下一首歌曲的播放定时器
|
||||
def set_next_music_timeout(self):
|
||||
sec = int(self.get_mp3_duration(self.cur_music))
|
||||
sec = int(self.get_file_duration(self.cur_music))
|
||||
self.log.info(f"歌曲{self.cur_music}的时长{sec}秒")
|
||||
if self._next_timer:
|
||||
self._next_timer.cancel()
|
||||
@@ -351,7 +377,10 @@ class XiaoMusic:
|
||||
|
||||
async def _do_next():
|
||||
await asyncio.sleep(self._timeout)
|
||||
await self.play_next()
|
||||
try:
|
||||
await self.play_next()
|
||||
except Exception as e:
|
||||
self.log.warning(f"执行出错 {str(e)}\n{traceback.format_exc()}")
|
||||
|
||||
self._next_timer = asyncio.ensure_future(_do_next())
|
||||
self.log.info(f"{sec}秒后将会播放下一首")
|
||||
@@ -362,7 +391,9 @@ class XiaoMusic:
|
||||
await self.init_all_data(session)
|
||||
task = asyncio.create_task(self.poll_latest_ask())
|
||||
assert task is not None # to keep the reference to task, do not remove this
|
||||
self.log.info(f"Running xiaomusic now, 用`{'/'.join(KEY_WORD_DICT.keys())}`开头来控制")
|
||||
self.log.info(
|
||||
f"Running xiaomusic now, 用`{'/'.join(KEY_WORD_DICT.keys())}`开头来控制"
|
||||
)
|
||||
while True:
|
||||
self.polling_event.set()
|
||||
await self.new_record_event.wait()
|
||||
@@ -386,12 +417,12 @@ class XiaoMusic:
|
||||
|
||||
opkey = match.groups()[0]
|
||||
opvalue = KEY_WORD_DICT[opkey]
|
||||
oparg = query[len(opkey):]
|
||||
oparg = query[len(opkey) :]
|
||||
self.log.info("收到指令:%s %s", opkey, oparg)
|
||||
|
||||
try:
|
||||
func = getattr(self, opvalue)
|
||||
await func(name = oparg)
|
||||
await func(name=oparg)
|
||||
except Exception as e:
|
||||
self.log.warning(f"执行出错 {str(e)}\n{traceback.format_exc()}")
|
||||
|
||||
@@ -403,13 +434,15 @@ class XiaoMusic:
|
||||
return
|
||||
|
||||
await self.do_tts(f"即将播放{name}")
|
||||
if not self.local_exist(name):
|
||||
filename = self.local_exist(name)
|
||||
if len(filename) <= 0:
|
||||
await self.download(name)
|
||||
self.log.info("正在下载中 %s", name)
|
||||
filename = self.get_filename(f"{name}.mp3")
|
||||
await self.download_proc.wait()
|
||||
|
||||
self.cur_music = name
|
||||
url = self.get_file_url(name)
|
||||
self.cur_music = filename
|
||||
url = self.get_file_url(filename)
|
||||
self.log.info("播放 %s", url)
|
||||
await self.stop_if_xiaoai_is_playing()
|
||||
await self.mina_service.play_by_url(self.device_id, url)
|
||||
@@ -420,7 +453,8 @@ class XiaoMusic:
|
||||
# 下一首
|
||||
async def play_next(self, **kwargs):
|
||||
self.log.info("下一首")
|
||||
name = self.cur_music
|
||||
(name, _) = os.path.splitext(os.path.basename(self.cur_music))
|
||||
self.log.debug("play_next. name:%s, cur_music:%s", name, self.cur_music)
|
||||
if self.play_type == PLAY_TYPE_ALL or name == "":
|
||||
name = self.random_music()
|
||||
if name == "":
|
||||
@@ -438,6 +472,12 @@ class XiaoMusic:
|
||||
self.play_type = PLAY_TYPE_ALL
|
||||
await self.do_tts(f"已经设置为全部循环")
|
||||
|
||||
# 随机播放
|
||||
async def random_play(self, **kwargs):
|
||||
self.play_type = PLAY_TYPE_ALL
|
||||
await self.do_tts(f"已经设置为全部循环并随机播放")
|
||||
await self.play_next()
|
||||
|
||||
async def stop(self, **kwargs):
|
||||
if self._next_timer:
|
||||
self._next_timer.cancel()
|
||||
|
||||
Reference in New Issue
Block a user