netease-cloudmusic-detector 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- netease_cloudmusic_detector-1.0.0/LICENSE +21 -0
- netease_cloudmusic_detector-1.0.0/PKG-INFO +75 -0
- netease_cloudmusic_detector-1.0.0/README.md +58 -0
- netease_cloudmusic_detector-1.0.0/cloudmusic_detector/__init__.py +49 -0
- netease_cloudmusic_detector-1.0.0/cloudmusic_detector/_constant.py +79 -0
- netease_cloudmusic_detector-1.0.0/cloudmusic_detector/_elog_analysis.py +83 -0
- netease_cloudmusic_detector-1.0.0/cloudmusic_detector/_elog_listener.py +72 -0
- netease_cloudmusic_detector-1.0.0/cloudmusic_detector/_webdb.py +57 -0
- netease_cloudmusic_detector-1.0.0/cloudmusic_detector/api.py +398 -0
- netease_cloudmusic_detector-1.0.0/cloudmusic_detector/types.py +85 -0
- netease_cloudmusic_detector-1.0.0/examples/desktop_lyrics.py +119 -0
- netease_cloudmusic_detector-1.0.0/pyproject.toml +35 -0
- netease_cloudmusic_detector-1.0.0/test_api.py +204 -0
- netease_cloudmusic_detector-1.0.0/uv.lock +156 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 ButterCookies
|
|
4
|
+
Copyright (c) 2026 YouRan
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: netease-cloudmusic-detector
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: 使用 Python 监听网易云音乐客户端正在播放中的音乐,支持获取 SongId、暂停状态、歌曲进度
|
|
5
|
+
Project-URL: Homepage, https://github.com/YouRanCoder/netease-cloudmusic-detector
|
|
6
|
+
Project-URL: Repository, https://github.com/YouRanCoder/netease-cloudmusic-detector.git
|
|
7
|
+
Author-email: YouRan <205545151@qq.com>
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: cloudmusic,detector,music,netease
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Requires-Python: >=3.10
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# netease-cloudmusic-detector (Python 版)
|
|
19
|
+
|
|
20
|
+
*本项目基于[Coooookies/netease-cloudmusic-detector](https://github.com/Coooookies/netease-cloudmusic-detector)这一开源项目,使用GLM5.0 Turbo进行重写为Python版本,并保留了原作者的LICENSE。在此感谢原作者的开源*
|
|
21
|
+
|
|
22
|
+
**以下为AIGC内容**
|
|
23
|
+
|
|
24
|
+
使用 Python 监听网易云音乐客户端正在播放中的音乐。
|
|
25
|
+
|
|
26
|
+
支持获取 SongId、暂停状态、歌曲进度,歌曲进度跟随进度条拖拽同步。仅支持 3.0 以上客户端。
|
|
27
|
+
|
|
28
|
+
## 环境要求
|
|
29
|
+
|
|
30
|
+
- Python >= 3.10
|
|
31
|
+
- Windows 系统已安装网易云音乐客户端 3.0+
|
|
32
|
+
- 客户端至少运行过一次(以生成 elog 文件)
|
|
33
|
+
|
|
34
|
+
## 安装
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# 使用 uv
|
|
38
|
+
uv sync
|
|
39
|
+
|
|
40
|
+
# 或使用 pip
|
|
41
|
+
pip install -e .
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## 使用
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
uv run python main.py
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## 依赖
|
|
51
|
+
|
|
52
|
+
无第三方依赖,仅使用 Python 标准库。
|
|
53
|
+
|
|
54
|
+
## 文件结构
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
cloudmusic_detector/
|
|
58
|
+
├── __init__.py # 包入口
|
|
59
|
+
├── elog_analysis.py # 二进制解码 + 日志头解析
|
|
60
|
+
├── elog_listener.py # 文件轮询监听
|
|
61
|
+
├── constant.py # 路径常量 + 事件匹配规则
|
|
62
|
+
├── webdb.py # SQLite 只读查询
|
|
63
|
+
└── detector.py # 核心调度器
|
|
64
|
+
main.py # 示例入口
|
|
65
|
+
test_decode.py # 测试脚本
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## 事件
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
| 事件名 | 触发时机 | 参数 |
|
|
72
|
+
| ---------- | ------------- | ----------------- |
|
|
73
|
+
| `play` | 切歌时 | `song_id: int` |
|
|
74
|
+
| `status` | 播放/暂停切换 | `playing: bool` |
|
|
75
|
+
| `position` | 进度拖拽 | `position: float` |
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# netease-cloudmusic-detector (Python 版)
|
|
2
|
+
|
|
3
|
+
*本项目基于[Coooookies/netease-cloudmusic-detector](https://github.com/Coooookies/netease-cloudmusic-detector)这一开源项目,使用GLM5.0 Turbo进行重写为Python版本,并保留了原作者的LICENSE。在此感谢原作者的开源*
|
|
4
|
+
|
|
5
|
+
**以下为AIGC内容**
|
|
6
|
+
|
|
7
|
+
使用 Python 监听网易云音乐客户端正在播放中的音乐。
|
|
8
|
+
|
|
9
|
+
支持获取 SongId、暂停状态、歌曲进度,歌曲进度跟随进度条拖拽同步。仅支持 3.0 以上客户端。
|
|
10
|
+
|
|
11
|
+
## 环境要求
|
|
12
|
+
|
|
13
|
+
- Python >= 3.10
|
|
14
|
+
- Windows 系统已安装网易云音乐客户端 3.0+
|
|
15
|
+
- 客户端至少运行过一次(以生成 elog 文件)
|
|
16
|
+
|
|
17
|
+
## 安装
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# 使用 uv
|
|
21
|
+
uv sync
|
|
22
|
+
|
|
23
|
+
# 或使用 pip
|
|
24
|
+
pip install -e .
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## 使用
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
uv run python main.py
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## 依赖
|
|
34
|
+
|
|
35
|
+
无第三方依赖,仅使用 Python 标准库。
|
|
36
|
+
|
|
37
|
+
## 文件结构
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
cloudmusic_detector/
|
|
41
|
+
├── __init__.py # 包入口
|
|
42
|
+
├── elog_analysis.py # 二进制解码 + 日志头解析
|
|
43
|
+
├── elog_listener.py # 文件轮询监听
|
|
44
|
+
├── constant.py # 路径常量 + 事件匹配规则
|
|
45
|
+
├── webdb.py # SQLite 只读查询
|
|
46
|
+
└── detector.py # 核心调度器
|
|
47
|
+
main.py # 示例入口
|
|
48
|
+
test_decode.py # 测试脚本
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## 事件
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
| 事件名 | 触发时机 | 参数 |
|
|
55
|
+
| ---------- | ------------- | ----------------- |
|
|
56
|
+
| `play` | 切歌时 | `song_id: int` |
|
|
57
|
+
| `status` | 播放/暂停切换 | `playing: bool` |
|
|
58
|
+
| `position` | 进度拖拽 | `position: float` |
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cloudmusic_detector — 网易云音乐播放状态监听库
|
|
3
|
+
|
|
4
|
+
面向桌面歌词软件的封装接口。
|
|
5
|
+
|
|
6
|
+
快速上手::
|
|
7
|
+
|
|
8
|
+
from cloudmusic_detector import CloudMusic, Track, PlayingState, PlayState
|
|
9
|
+
|
|
10
|
+
cm = CloudMusic()
|
|
11
|
+
cm.on_track_change(lambda track: print(track.name))
|
|
12
|
+
cm.start()
|
|
13
|
+
|
|
14
|
+
state = cm.state # PlayingState 快照
|
|
15
|
+
cm.stop()
|
|
16
|
+
|
|
17
|
+
API 一览::
|
|
18
|
+
|
|
19
|
+
cm.state # PlayingState — 状态+进度+歌曲 (每次调用都是最新快照)
|
|
20
|
+
cm.track # Track — 当前歌曲信息
|
|
21
|
+
cm.is_playing # bool — 是否播放中
|
|
22
|
+
cm.position # float — 当前进度(秒)
|
|
23
|
+
|
|
24
|
+
cm.on_track_change(cb) # 切歌回调 cb(track: Track)
|
|
25
|
+
cm.on_state_change(cb) # 暂停/播放回调 cb(state: PlayingState)
|
|
26
|
+
cm.on_seek(cb) # 进度拖拽回调 cb(position: float)
|
|
27
|
+
|
|
28
|
+
cm.start() / cm.stop()
|
|
29
|
+
|
|
30
|
+
Asyncio 版本::
|
|
31
|
+
|
|
32
|
+
from cloudmusic_detector import AsyncCloudMusic
|
|
33
|
+
cm = AsyncCloudMusic()
|
|
34
|
+
await cm.start()
|
|
35
|
+
state = cm.state
|
|
36
|
+
await cm.stop()
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from .types import Track, PlayingState, PlayState
|
|
40
|
+
from .api import CloudMusic, AsyncCloudMusic
|
|
41
|
+
|
|
42
|
+
__all__ = [
|
|
43
|
+
"CloudMusic",
|
|
44
|
+
"AsyncCloudMusic",
|
|
45
|
+
"Track",
|
|
46
|
+
"PlayingState",
|
|
47
|
+
"PlayState",
|
|
48
|
+
]
|
|
49
|
+
__version__ = "2.0.0"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""内部模块:路径常量与事件匹配规则。"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _get_local_app_data() -> str:
|
|
10
|
+
if os.name != "nt":
|
|
11
|
+
return os.environ.get(
|
|
12
|
+
"CLOUDMUSIC_DIR",
|
|
13
|
+
os.path.expanduser("~/.local/share/netease-cloudmusic"),
|
|
14
|
+
)
|
|
15
|
+
return os.environ.get(
|
|
16
|
+
"LOCALAPPDATA",
|
|
17
|
+
os.path.join(os.path.expanduser("~"), "AppData", "Local"),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
CLOUDMUSIC_DIR = os.path.join(_get_local_app_data(), "NetEase", "CloudMusic")
|
|
22
|
+
CLOUDMUSIC_ELOG_PATH = os.path.join(CLOUDMUSIC_DIR, "cloudmusic.elog")
|
|
23
|
+
CLOUDMUSIC_WEBDB_PATH = os.path.join(CLOUDMUSIC_DIR, "Library", "webdb.dat")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_JSON_RE = re.compile(r"\{.*\}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _extract_json(row: str) -> Optional[dict]:
|
|
30
|
+
m = _JSON_RE.search(row)
|
|
31
|
+
return json.loads(m.group()) if m else None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _extract_group(row: str, pattern: str) -> Optional[str]:
|
|
35
|
+
m = re.search(pattern, row)
|
|
36
|
+
return m.group(1) if m else None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# 正则预编译
|
|
40
|
+
_RE_POSITION = re.compile(r"【playing】,\"setPlayingPosition\",(\d+(?:\.\d+)?)")
|
|
41
|
+
_RE_STATUS = re.compile(r"【playing】,\"native播放state\",(\d+),")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
ELOG_RULES = {
|
|
45
|
+
# 客户端退出
|
|
46
|
+
"EXIT": {
|
|
47
|
+
"rule": lambda r: "【app】,{\"actionId\":\"exitApp\"}" in r,
|
|
48
|
+
"args": lambda r: True,
|
|
49
|
+
},
|
|
50
|
+
# checkPlayPrivilege 切歌
|
|
51
|
+
"SET_PLAYING": {
|
|
52
|
+
"rule": lambda r: "【playing】,\"checkPlayPrivilege\"," in r,
|
|
53
|
+
"args": lambda r: _extract_json(r),
|
|
54
|
+
},
|
|
55
|
+
# 播放列表切歌
|
|
56
|
+
"PLAY_ONE_TRACKIN_PLAYING_LIST": {
|
|
57
|
+
"rule": lambda r: "【playing】,\"playOneTrackInPlayingList\"" in r,
|
|
58
|
+
"args": lambda r: _extract_json(r),
|
|
59
|
+
},
|
|
60
|
+
# 原生播放资源加载完成
|
|
61
|
+
"NATIVE_SONG_LOAD": {
|
|
62
|
+
"rule": lambda r: "【playing】,\"native播放资源load完成,开始播放\"" in r,
|
|
63
|
+
"args": lambda r: _extract_json(r),
|
|
64
|
+
},
|
|
65
|
+
# 进度拖拽
|
|
66
|
+
"SET_PLAYING_POSITION": {
|
|
67
|
+
"rule": lambda r: "【playing】,\"setPlayingPosition\"" in r,
|
|
68
|
+
"args": lambda r: _extract_group(r, _RE_POSITION.pattern),
|
|
69
|
+
},
|
|
70
|
+
# 播放/暂停状态切换 1=播放 2=暂停
|
|
71
|
+
"SET_PLAYING_STATUS": {
|
|
72
|
+
"rule": lambda r: "【playing】,\"native播放state\"" in r,
|
|
73
|
+
"args": lambda r: _extract_group(r, _RE_STATUS.pattern),
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_args(event_type: str, row: str) -> Any:
|
|
79
|
+
return ELOG_RULES[event_type]["args"](row)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""内部模块:elog 日志解码与解析。"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class _ElogHeader:
|
|
12
|
+
pid: str
|
|
13
|
+
tid: str
|
|
14
|
+
timestamp: int
|
|
15
|
+
log_type: str
|
|
16
|
+
src: str
|
|
17
|
+
lr: str
|
|
18
|
+
datetime: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def decode_elog(data: bytes) -> str:
|
|
22
|
+
"""解码 cloudmusic.elog 的自定义二进制编码。"""
|
|
23
|
+
decoded_bytes = []
|
|
24
|
+
for byte in data:
|
|
25
|
+
high = byte >> 4
|
|
26
|
+
low = byte & 0x0F
|
|
27
|
+
hex_digit = (high ^ ((low + 8) % 16)) % 16
|
|
28
|
+
restored = hex_digit * 16 + (byte >> 6) * 4 + (~high & 3)
|
|
29
|
+
decoded_bytes.append(restored & 0xFF)
|
|
30
|
+
|
|
31
|
+
buf = bytes(decoded_bytes)
|
|
32
|
+
while len(buf) > 0:
|
|
33
|
+
try:
|
|
34
|
+
return buf.decode("utf-8")
|
|
35
|
+
except (UnicodeDecodeError, ValueError):
|
|
36
|
+
buf = buf[1:]
|
|
37
|
+
return ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_HEADER_RE = re.compile(
|
|
41
|
+
r"^\[(\d+):(\d+):(\d{4}/\d{6}:\d+):([A-Z]+):([a-zA-Z0-9._-]+)\((\d+)\)\]\s+"
|
|
42
|
+
r"\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _get_system_uptime_ms() -> int:
|
|
47
|
+
if os.name == "nt":
|
|
48
|
+
import ctypes
|
|
49
|
+
kernel32 = ctypes.windll.kernel32
|
|
50
|
+
try:
|
|
51
|
+
fn = kernel32.GetTickCount64
|
|
52
|
+
fn.restype = ctypes.c_uint64
|
|
53
|
+
return int(fn())
|
|
54
|
+
except AttributeError:
|
|
55
|
+
return int(kernel32.GetTickCount())
|
|
56
|
+
else:
|
|
57
|
+
try:
|
|
58
|
+
with open("/proc/uptime") as f:
|
|
59
|
+
return int(float(f.read().split()[0]) * 1000)
|
|
60
|
+
except (OSError, IOError):
|
|
61
|
+
return 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_header(row: str) -> Optional[_ElogHeader]:
|
|
65
|
+
m = _HEADER_RE.match(row)
|
|
66
|
+
if not m:
|
|
67
|
+
return None
|
|
68
|
+
pid, tid, ts_str, log_type, src, lr, dt = m.groups()
|
|
69
|
+
startup_time = int(ts_str.split(":")[-1])
|
|
70
|
+
uptime_ms = _get_system_uptime_ms()
|
|
71
|
+
now_ms = int(time.time() * 1000)
|
|
72
|
+
return _ElogHeader(
|
|
73
|
+
pid=pid, tid=tid,
|
|
74
|
+
timestamp=startup_time + (now_ms - uptime_ms),
|
|
75
|
+
log_type=log_type, src=src, lr=lr, datetime=dt,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_event_type(row: str, rules: dict) -> Optional[str]:
|
|
80
|
+
for name, cfg in rules.items():
|
|
81
|
+
if cfg["rule"](row):
|
|
82
|
+
return name
|
|
83
|
+
return None
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""内部模块:elog 文件轮询监听。"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import time
|
|
5
|
+
import threading
|
|
6
|
+
from typing import Callable, Optional
|
|
7
|
+
|
|
8
|
+
from ._elog_analysis import decode_elog
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _ElogListener:
|
|
12
|
+
def __init__(self, file_path: str, poll_interval: float = 0.3):
|
|
13
|
+
self.file_path = file_path
|
|
14
|
+
self.poll_interval = poll_interval
|
|
15
|
+
self._file_size: int = 0
|
|
16
|
+
self._running: bool = False
|
|
17
|
+
self._thread: Optional[threading.Thread] = None
|
|
18
|
+
self._callbacks: list[Callable[[str], None]] = []
|
|
19
|
+
|
|
20
|
+
def on_line(self, callback: Callable[[str], None]) -> None:
|
|
21
|
+
self._callbacks.append(callback)
|
|
22
|
+
|
|
23
|
+
def _emit(self, line: str) -> None:
|
|
24
|
+
line = line.strip()
|
|
25
|
+
if line:
|
|
26
|
+
for cb in self._callbacks:
|
|
27
|
+
cb(line)
|
|
28
|
+
|
|
29
|
+
def start(self) -> list[str]:
|
|
30
|
+
if not os.path.exists(self.file_path):
|
|
31
|
+
raise FileNotFoundError(
|
|
32
|
+
f"elog 文件不存在: {self.file_path}\n"
|
|
33
|
+
f"请确认网易云音乐客户端 (3.0+) 已安装并至少运行过一次。"
|
|
34
|
+
)
|
|
35
|
+
with open(self.file_path, "rb") as f:
|
|
36
|
+
buffer = f.read()
|
|
37
|
+
lines = decode_elog(buffer).split("\n")
|
|
38
|
+
self._file_size = len(buffer)
|
|
39
|
+
self._running = True
|
|
40
|
+
self._thread = threading.Thread(target=self._poll_loop, daemon=True)
|
|
41
|
+
self._thread.start()
|
|
42
|
+
return lines
|
|
43
|
+
|
|
44
|
+
def stop(self) -> None:
|
|
45
|
+
self._running = False
|
|
46
|
+
if self._thread and self._thread.is_alive():
|
|
47
|
+
self._thread.join(timeout=2 * self.poll_interval)
|
|
48
|
+
|
|
49
|
+
def _poll_loop(self) -> None:
|
|
50
|
+
while self._running:
|
|
51
|
+
try:
|
|
52
|
+
size = os.path.getsize(self.file_path)
|
|
53
|
+
except OSError:
|
|
54
|
+
time.sleep(self.poll_interval)
|
|
55
|
+
continue
|
|
56
|
+
if size < self._file_size:
|
|
57
|
+
self._file_size = 0
|
|
58
|
+
time.sleep(self.poll_interval)
|
|
59
|
+
continue
|
|
60
|
+
if size > self._file_size:
|
|
61
|
+
try:
|
|
62
|
+
with open(self.file_path, "rb") as f:
|
|
63
|
+
f.seek(self._file_size)
|
|
64
|
+
chunk = f.read()
|
|
65
|
+
except (OSError, IOError):
|
|
66
|
+
time.sleep(self.poll_interval)
|
|
67
|
+
continue
|
|
68
|
+
if chunk:
|
|
69
|
+
for line in decode_elog(chunk).split("\n"):
|
|
70
|
+
self._emit(line)
|
|
71
|
+
self._file_size = size
|
|
72
|
+
time.sleep(self.poll_interval)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""内部模块:webdb.dat SQLite 只读查询。"""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
import os
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from ._constant import CLOUDMUSIC_WEBDB_PATH
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class _Webdb:
|
|
12
|
+
def __init__(self, db_path: Optional[str] = None):
|
|
13
|
+
self.db_path = db_path or CLOUDMUSIC_WEBDB_PATH
|
|
14
|
+
self._conn: Optional[sqlite3.Connection] = None
|
|
15
|
+
|
|
16
|
+
def _get_conn(self) -> sqlite3.Connection:
|
|
17
|
+
if self._conn is None:
|
|
18
|
+
if not os.path.exists(self.db_path):
|
|
19
|
+
raise FileNotFoundError(f"webdb.dat 不存在: {self.db_path}")
|
|
20
|
+
self._conn = sqlite3.connect(self.db_path)
|
|
21
|
+
self._conn.row_factory = sqlite3.Row
|
|
22
|
+
self._conn.execute("PRAGMA journal_mode = WAL")
|
|
23
|
+
return self._conn
|
|
24
|
+
|
|
25
|
+
def get_song_detail(self, song_id: int) -> Optional[dict]:
|
|
26
|
+
try:
|
|
27
|
+
row = self._get_conn().execute(
|
|
28
|
+
"SELECT playtime, jsonStr FROM historyTracks ORDER BY playtime DESC LIMIT 1"
|
|
29
|
+
).fetchone()
|
|
30
|
+
except (sqlite3.Error, FileNotFoundError):
|
|
31
|
+
return None
|
|
32
|
+
if not row:
|
|
33
|
+
return None
|
|
34
|
+
try:
|
|
35
|
+
detail = json.loads(row["jsonStr"])
|
|
36
|
+
except (json.JSONDecodeError, TypeError):
|
|
37
|
+
return None
|
|
38
|
+
return detail if int(detail.get("id", -1)) == song_id else None
|
|
39
|
+
|
|
40
|
+
def wait_for_song_detail(self, song_id: int, timeout: float = 2.0) -> Optional[dict]:
|
|
41
|
+
import time
|
|
42
|
+
elapsed = 0.0
|
|
43
|
+
while elapsed < timeout:
|
|
44
|
+
detail = self.get_song_detail(song_id)
|
|
45
|
+
if detail:
|
|
46
|
+
return detail
|
|
47
|
+
time.sleep(0.1)
|
|
48
|
+
elapsed += 0.1
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
def close(self):
|
|
52
|
+
if self._conn:
|
|
53
|
+
self._conn.close()
|
|
54
|
+
self._conn = None
|
|
55
|
+
|
|
56
|
+
def __del__(self):
|
|
57
|
+
self.close()
|