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