终端里看 QQ 音乐歌词,摸鱼神器

起因

作为一名当代大学生,写代码的时候没 BGM 简直写不出 Bug(误)。但是,每次想看歌词还得切屏到 QQ 音乐,这也太打断思路了。而且在实验室或者课堂上,频繁切屏显得我很不专心。

反正天天盯着终端,干脆把歌词塞进去得了。要能实时滚动高亮当前行,最好还能自动识别正在放什么歌,不用手动输入。

思路

说白了就三件事:

  1. 读播放状态:用 winsdk 读 Windows 的 SMTC(系统媒体控制),QQ 音乐在放什么歌、放到哪了,系统都知道
  2. 拉歌词:拿歌名去 api.vkeys.cn 查,把 LRC 歌词搞下来
  3. 终端里画:ANSI 转义序列控制光标和颜色,不用清屏也能滚动刷新

WARNING

只能在 Windows 10/11 上跑,Mac 和 Linux 的媒体接口不一样,懒得适配了

装依赖

winsdk 不是自带的,得装一下:

bash
1pip install winsdk requests
python
1import asyncio
2import winsdk.windows.media.control as media_control
3import requests
4import json
5import time
6import re
7import os
8import sys
9
10class SmoothLyrics:
11 def __init__(self):
12 self.api_base = "[https://api.vkeys.cn/v2/music/tencent](https://api.vkeys.cn/v2/music/tencent)"
13 self.current_song = None
14 self.lyrics = []
15 self.current_lyric_index = -1
16 self.last_position = -1
17 self.loading = False
18 self.last_status = -1 # 缓存上次状态
19 self.status_line = 17 # 状态显示行号
20
21 # Windows 控制台优化,开启 VT100 模式
22 if sys.platform == "win32":
23 import ctypes
24 kernel32 = ctypes.windll.kernel32
25 kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)
26
27 def move_cursor(self, x, y):
28 """移动光标到指定位置,指哪打哪"""
29 print(f"\033[{y};{x}H", end='')
30
31 def clear_line(self):
32 """清除当前行,保持界面干净"""
33 print("\033[K", end='')
34
35 def search_song(self, title, artist):
36 """去 API 搜一下这首歌的 ID"""
37 url = f"{self.api_base}/search/song"
38 params = {"word": f"{title} {artist}"}
39
40 try:
41 response = requests.get(url, params=params, timeout=5)
42 data = response.json()
43
44 if data["code"] == 200 and data["data"]:
45 for song in data["data"]:
46 singers = " ".join([s["name"] for s in song.get("singer_list", [])])
47 # 简单的模糊匹配
48 if song["song"] == title and artist in singers:
49 return song["id"]
50 return data["data"][0]["id"]
51 except:
52 pass
53 return None
54
55 def get_lyrics(self, song_id):
56 """拿着 ID 换歌词"""
57 url = f"{self.api_base}/lyric"
58 params = {"id": song_id}
59
60 try:
61 response = requests.get(url, params=params, timeout=5)
62 data = response.json()
63
64 if data["code"] == 200 and data["data"]:
65 return self.parse_lrc(data["data"].get("lrc", ""))
66 except:
67 pass
68 return []
69
70 def parse_lrc(self, lrc_text):
71 """解析 LRC 格式,正则表达式立大功"""
72 lines = lrc_text.split('\n')
73 lyrics = []
74
75 for line in lines:
76 if any(line.startswith(x) for x in ['[ti:', '[ar:', '[al:', '[by:']):
77 continue
78
79 pattern = r'\[(\d{2}):(\d{2}\.\d{2})\](.*)'
80 match = re.match(pattern, line)
81
82 if match:
83 minutes = int(match.group(1))
84 seconds = float(match.group(2))
85 text = match.group(3).strip()
86 total_seconds = minutes * 60 + seconds
87
88 if text:
89 lyrics.append({'time': total_seconds, 'text': text})
90
91 return sorted(lyrics, key=lambda x: x['time'])
92
93 async def get_current_media_info(self):
94 """核心黑科技:从 Windows 获取当前播放信息"""
95 try:
96 sessions = await media_control.GlobalSystemMediaTransportControlsSessionManager.request_async()
97 current_session = sessions.get_current_session()
98
99 # 这里锁定了 QQMusic.exe,其他播放器改改也能用
100 if current_session and "QQMusic" in current_session.source_app_user_model_id:
101 info = await current_session.try_get_media_properties_async()
102 playback = current_session.get_playback_info()
103 timeline = current_session.get_timeline_properties()
104 position = timeline.position.total_seconds() if timeline else 0
105
106 return {
107 "title": info.title,
108 "artist": info.artist,
109 "album": info.album_title,
110 "status": playback.playback_status,
111 "position": position
112 }
113 except:
114 pass
115 return None
116
117 def find_current_lyric(self, position):
118 """二分太麻烦,直接倒序遍历找当前歌词"""
119 if not self.lyrics: return -1
120 for i in range(len(self.lyrics) - 1, -1, -1):
121 if self.lyrics[i]['time'] <= position:
122 return i
123 return 0
124
125 def update_lyrics_display(self, current_index):
126 """渲染 UI,高亮当前行"""
127 if not self.lyrics:
128 self.move_cursor(1, 10)
129 self.clear_line()
130 print("正在加载歌词..." if self.loading else "暂无歌词")
131 return
132
133 # 显示7行歌词(当前行前后各3行)
134 display_lines = 7
135 center = display_lines // 2
136 start_index = max(0, current_index - center)
137 end_index = min(len(self.lyrics), start_index + display_lines)
138
139 if end_index - start_index < display_lines:
140 start_index = max(0, end_index - display_lines)
141
142 for i, line_idx in enumerate(range(start_index, end_index)):
143 self.move_cursor(1, 9 + i)
144 self.clear_line()
145
146 if line_idx < len(self.lyrics):
147 text = self.lyrics[line_idx]['text']
148 if line_idx == current_index:
149 # 高亮绿色,加箭头
150 print(f"\033[1;32m▶ {text} ◀\033[0m")
151 else:
152 # 其他行根据距离变灰
153 distance = abs(line_idx - current_index)
154 color = "\033[0;37m" if distance == 1 else "\033[0;90m"
155 print(f"{color} {text}\033[0m")
156
157 async def run(self):
158 """主循环"""
159 self.init_display()
160 lyrics_task = None
161
162 while True:
163 try:
164 media_info = await self.get_current_media_info()
165
166 if media_info and media_info["status"] == 4: # 4 代表 Playing
167 # 切歌检测
168 if (not self.current_song or
169 self.current_song["title"] != media_info["title"]):
170
171 self.current_song = media_info
172 self.lyrics = []
173 self.update_song_info(media_info)
174
175 # 异步去抓歌词,不阻塞 UI
176 if lyrics_task: lyrics_task.cancel()
177 lyrics_task = asyncio.create_task(
178 self.load_lyrics_async(media_info["title"], media_info["artist"])
179 )
180
181 # 更新进度条时间
182 if abs(media_info["position"] - self.last_position) >= 1:
183 self.update_position(media_info["position"])
184 self.last_position = media_info["position"]
185
186 # 刷新歌词
187 if self.lyrics:
188 new_index = self.find_current_lyric(media_info["position"])
189 if new_index != self.current_lyric_index:
190 self.current_lyric_index = new_index
191 self.update_lyrics_display(self.current_lyric_index)
192
193 else:
194 self.move_cursor(1, 10)
195 print("等待 QQ音乐 播放...")
196
197 await asyncio.sleep(0.1) # 0.1秒刷新率,极低占用
198
199 except KeyboardInterrupt:
200 print("\033[20;1H\n已退出,继续搬砖吧")
201 break
202 except Exception:
203 await asyncio.sleep(1)
204
205 def init_display(self):
206 print("\033[2J\033[H", end='') # 清屏
207 print("♪ QQ音乐实时歌词 - 摸鱼版 ♪")
208 print("=" * 60)
209 print("\n" * 15)
210
211 def update_song_info(self, media_info):
212 self.move_cursor(1, 4); self.clear_line()
213 print(f"歌曲: {media_info['title']} - {media_info['artist']}")
214 self.move_cursor(1, 5); self.clear_line()
215 print(f"专辑: {media_info['album']}")
216
217 def update_position(self, position):
218 self.move_cursor(1, 6); self.clear_line()
219 mins, secs = divmod(position, 60)
220 print(f"时间: {int(mins):02d}:{int(secs):02d}")
221
222 async def load_lyrics_async(self, title, artist):
223 self.loading = True
224 song_id = self.search_song(title, artist)
225 if song_id: self.lyrics = self.get_lyrics(song_id)
226 self.loading = False
227
228if __name__ == "__main__":
229 print("\033[?25l", end='') # 隐藏光标
230 try:
231 player = SmoothLyrics()
232 asyncio.run(player.run())
233 finally:
234 print("\033[?25h", end='') # 恢复光标
© 2024 - 2026 lansonsam ,采用 CC BY-NC-SA 4.0 许可
RSS / 网站地图
AstroFuwari 强力驱动
本网站代码 已开源