添加 Musicfox Waybar 歌词模块
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
@@ -0,0 +1,78 @@
|
||||
# Musicfox Waybar Lyrics
|
||||
|
||||
在 Waybar 中显示 go-musicfox 当前播放歌曲的网易云逐行同步歌词。
|
||||
|
||||
## 行为
|
||||
|
||||
- 只匹配名称包含 `musicfox` 的 MPRIS 播放器。
|
||||
- musicfox 未运行、未播放或已经暂停时,模块自动隐藏。
|
||||
- 播放时按 MPRIS 播放位置显示当前 LRC 歌词。
|
||||
- 优先从 MPRIS URL/Track ID 提取网易云歌曲 ID,无法提取时按歌名、歌手和时长搜索。
|
||||
- 歌词缓存在 `~/.cache/musicfox-waybar-lyrics/`,断网后已缓存歌曲仍可显示。
|
||||
- 找不到同步歌词时显示当前歌曲名。
|
||||
|
||||
## 要求
|
||||
|
||||
- Python 3.11+
|
||||
- Waybar
|
||||
- playerctl
|
||||
- 启用了 MPRIS 的 go-musicfox
|
||||
|
||||
先启动 musicfox 并播放歌曲,然后确认:
|
||||
|
||||
```bash
|
||||
playerctl --list-all
|
||||
playerctl --player=musicfox metadata
|
||||
```
|
||||
|
||||
如果实际播放器名称不是 `musicfox`,可以运行:
|
||||
|
||||
```bash
|
||||
./musicfox_lyrics.py --player-pattern 实际名称
|
||||
```
|
||||
|
||||
## Waybar 配置
|
||||
|
||||
把 `custom/musicfox-lyrics` 加入 Waybar 的模块列表,例如放到中间:
|
||||
|
||||
```jsonc
|
||||
"modules-center": [
|
||||
"custom/musicfox-lyrics",
|
||||
"custom/center-clock"
|
||||
]
|
||||
```
|
||||
|
||||
再把 [waybar-module.jsonc](./waybar-module.jsonc) 中的模块配置合并到 Waybar 顶层配置,把 [waybar-style.css](./waybar-style.css) 追加到 Waybar 样式文件。
|
||||
|
||||
你的配置文件通常位于:
|
||||
|
||||
```text
|
||||
~/.config/waybar/config.jsonc
|
||||
~/.config/waybar/style.css
|
||||
```
|
||||
|
||||
重新加载 Waybar:
|
||||
|
||||
```bash
|
||||
pkill -SIGUSR2 waybar
|
||||
```
|
||||
|
||||
## 单独运行
|
||||
|
||||
```bash
|
||||
./musicfox_lyrics.py
|
||||
```
|
||||
|
||||
没有播放时会输出隐藏状态;播放后输出类似:
|
||||
|
||||
```json
|
||||
{"text":"怀着冷却了的心窝漂远方","tooltip":"海阔天空 — Beyond\n怀着冷却了的心窝漂远方","class":"playing"}
|
||||
```
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
python -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
网易云接口是网页内部接口,不提供长期稳定性保证。脚本已经包含缓存、超时和失败降级处理。
|
||||
Executable
+322
@@ -0,0 +1,322 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stream synchronized go-musicfox lyrics to a Waybar custom module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import unicodedata
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
NETEASE_SEARCH_URL = "https://music.163.com/api/search/get/web"
|
||||
NETEASE_LYRIC_URL = "https://music.163.com/api/song/lyric"
|
||||
PLAYER_FIELDS_SEPARATOR = "\x1f"
|
||||
PLAYER_FORMAT = PLAYER_FIELDS_SEPARATOR.join(
|
||||
(
|
||||
"{{status}}",
|
||||
"{{position}}",
|
||||
"{{xesam:title}}",
|
||||
"{{xesam:artist}}",
|
||||
"{{mpris:length}}",
|
||||
"{{xesam:url}}",
|
||||
"{{mpris:trackid}}",
|
||||
)
|
||||
)
|
||||
TIMESTAMP_RE = re.compile(r"\[(\d{1,3}):(\d{1,2}(?:\.\d{1,3})?)\]")
|
||||
SONG_ID_PATTERNS = (
|
||||
re.compile(r"music\.163\.com/(?:#/)?song\?id=(\d+)", re.IGNORECASE),
|
||||
re.compile(r"music\.163\.com/(?:#/)?song/(\d+)", re.IGNORECASE),
|
||||
re.compile(r"(?:^|[/:])song[_/-]?(\d{5,})(?:$|[/?#])", re.IGNORECASE),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlayerState:
|
||||
player: str
|
||||
status: str
|
||||
position: float
|
||||
title: str
|
||||
artist: str
|
||||
length: float
|
||||
url: str
|
||||
track_id: str
|
||||
|
||||
@property
|
||||
def signature(self) -> tuple[str, str, int]:
|
||||
return (self.title, self.artist, round(self.length))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LyricLine:
|
||||
timestamp: float
|
||||
text: str
|
||||
|
||||
|
||||
def normalize_text(value: str) -> str:
|
||||
normalized = unicodedata.normalize("NFKC", value).casefold()
|
||||
return "".join(character for character in normalized if character.isalnum())
|
||||
|
||||
|
||||
def parse_time_value(value: str) -> float:
|
||||
try:
|
||||
number = float(value)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
return number / 1_000_000 if number > 100_000 else number
|
||||
|
||||
|
||||
def parse_lrc(raw_lrc: str) -> list[LyricLine]:
|
||||
offset_seconds = 0.0
|
||||
offset_match = re.search(r"\[offset:([+-]?\d+)\]", raw_lrc, re.IGNORECASE)
|
||||
if offset_match:
|
||||
offset_seconds = int(offset_match.group(1)) / 1000
|
||||
|
||||
parsed: list[LyricLine] = []
|
||||
for raw_line in raw_lrc.splitlines():
|
||||
timestamps = TIMESTAMP_RE.findall(raw_line)
|
||||
if not timestamps:
|
||||
continue
|
||||
text = TIMESTAMP_RE.sub("", raw_line).strip()
|
||||
if not text:
|
||||
continue
|
||||
for minutes, seconds in timestamps:
|
||||
timestamp = max(0.0, int(minutes) * 60 + float(seconds) + offset_seconds)
|
||||
parsed.append(LyricLine(timestamp=timestamp, text=text))
|
||||
return sorted(parsed, key=lambda line: line.timestamp)
|
||||
|
||||
|
||||
def lyric_at(lines: list[LyricLine], position: float) -> str:
|
||||
current = ""
|
||||
for line in lines:
|
||||
if line.timestamp > position + 0.05:
|
||||
break
|
||||
current = line.text
|
||||
return current
|
||||
|
||||
|
||||
def extract_song_id(*values: str) -> int | None:
|
||||
for value in values:
|
||||
decoded = urllib.parse.unquote(value or "")
|
||||
for pattern in SONG_ID_PATTERNS:
|
||||
match = pattern.search(decoded)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def candidate_score(candidate: dict[str, Any], state: PlayerState) -> float:
|
||||
candidate_title = normalize_text(str(candidate.get("name", "")))
|
||||
title = normalize_text(state.title)
|
||||
artists = candidate.get("artists") or candidate.get("ar") or []
|
||||
artist_names = [str(artist.get("name", "")) for artist in artists if isinstance(artist, dict)]
|
||||
candidate_artist = normalize_text(" ".join(artist_names))
|
||||
artist = normalize_text(state.artist)
|
||||
|
||||
score = 0.0
|
||||
if candidate_title == title:
|
||||
score += 100
|
||||
elif title and (title in candidate_title or candidate_title in title):
|
||||
score += 55
|
||||
if artist and candidate_artist:
|
||||
if artist in candidate_artist or candidate_artist in artist:
|
||||
score += 60
|
||||
elif any(normalize_text(name) in artist for name in artist_names):
|
||||
score += 40
|
||||
|
||||
duration_ms = candidate.get("duration") or candidate.get("dt") or 0
|
||||
if state.length > 0 and duration_ms:
|
||||
difference = abs(float(duration_ms) / 1000 - state.length)
|
||||
score += max(0.0, 45.0 - difference * 3)
|
||||
return score
|
||||
|
||||
|
||||
class MusicfoxClient:
|
||||
def __init__(self, player_pattern: str = "musicfox") -> None:
|
||||
self.player_pattern = player_pattern.casefold()
|
||||
self.selected_player: str | None = None
|
||||
|
||||
def _run(self, arguments: list[str]) -> str:
|
||||
completed = subprocess.run(
|
||||
["playerctl", *arguments],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
if completed.returncode != 0:
|
||||
return ""
|
||||
return completed.stdout.strip()
|
||||
|
||||
def find_player(self) -> str | None:
|
||||
players = self._run(["--list-all"]).splitlines()
|
||||
matches = [player for player in players if self.player_pattern in player.casefold()]
|
||||
self.selected_player = matches[0] if matches else None
|
||||
return self.selected_player
|
||||
|
||||
def state(self) -> PlayerState | None:
|
||||
player = self.selected_player or self.find_player()
|
||||
if not player:
|
||||
return None
|
||||
output = self._run(["--player", player, "metadata", "--format", PLAYER_FORMAT])
|
||||
fields = output.split(PLAYER_FIELDS_SEPARATOR)
|
||||
if len(fields) != 7:
|
||||
self.selected_player = None
|
||||
return None
|
||||
status, position, title, artist, length, url, track_id = fields
|
||||
return PlayerState(
|
||||
player=player,
|
||||
status=status,
|
||||
position=parse_time_value(position),
|
||||
title=title.strip(),
|
||||
artist=artist.strip(),
|
||||
length=parse_time_value(length),
|
||||
url=url.strip(),
|
||||
track_id=track_id.strip(),
|
||||
)
|
||||
|
||||
|
||||
class NeteaseLyrics:
|
||||
def __init__(self, cache_dir: Path, timeout: float = 8.0) -> None:
|
||||
self.cache_dir = cache_dir
|
||||
self.timeout = timeout
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _request_json(self, url: str, parameters: dict[str, str]) -> dict[str, Any]:
|
||||
request_url = f"{url}?{urllib.parse.urlencode(parameters)}"
|
||||
request = urllib.request.Request(
|
||||
request_url,
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) musicfox-waybar-lyrics/0.1",
|
||||
"Referer": "https://music.163.com/",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
||||
return json.load(response)
|
||||
|
||||
def search_song_id(self, state: PlayerState) -> int | None:
|
||||
query = " ".join(part for part in (state.title, state.artist) if part).strip()
|
||||
if not query:
|
||||
return None
|
||||
payload = self._request_json(
|
||||
NETEASE_SEARCH_URL,
|
||||
{"s": query, "type": "1", "offset": "0", "limit": "10", "total": "true"},
|
||||
)
|
||||
candidates = payload.get("result", {}).get("songs", [])
|
||||
if not candidates:
|
||||
return None
|
||||
best = max(candidates, key=lambda candidate: candidate_score(candidate, state))
|
||||
if candidate_score(best, state) < 80:
|
||||
return None
|
||||
song_id = best.get("id")
|
||||
return int(song_id) if song_id is not None else None
|
||||
|
||||
def song_id(self, state: PlayerState) -> int | None:
|
||||
return extract_song_id(state.url, state.track_id) or self.search_song_id(state)
|
||||
|
||||
def lyrics(self, song_id: int) -> list[LyricLine]:
|
||||
cache_file = self.cache_dir / f"{song_id}.json"
|
||||
if cache_file.exists():
|
||||
try:
|
||||
cached = json.loads(cache_file.read_text(encoding="utf-8"))
|
||||
return parse_lrc(str(cached.get("lyric", "")))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
|
||||
payload = self._request_json(
|
||||
NETEASE_LYRIC_URL,
|
||||
{"id": str(song_id), "lv": "-1", "kv": "-1", "tv": "-1"},
|
||||
)
|
||||
raw_lrc = str(payload.get("lrc", {}).get("lyric", ""))
|
||||
temporary = cache_file.with_suffix(".tmp")
|
||||
temporary.write_text(
|
||||
json.dumps({"songId": song_id, "lyric": raw_lrc}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(cache_file)
|
||||
return parse_lrc(raw_lrc)
|
||||
|
||||
|
||||
def waybar_payload(text: str, state: PlayerState | None = None) -> str:
|
||||
if not text:
|
||||
payload = {"text": "", "tooltip": "", "class": "hidden"}
|
||||
else:
|
||||
tooltip = text
|
||||
if state:
|
||||
tooltip = f"{state.title} — {state.artist}\n{text}".strip(" —\n")
|
||||
payload = {"text": text, "tooltip": tooltip, "class": "playing"}
|
||||
return json.dumps(payload, ensure_ascii=False)
|
||||
|
||||
|
||||
def run(interval: float, player_pattern: str, cache_dir: Path) -> None:
|
||||
client = MusicfoxClient(player_pattern)
|
||||
provider = NeteaseLyrics(cache_dir)
|
||||
track_signature: tuple[str, str, int] | None = None
|
||||
lyrics: list[LyricLine] = []
|
||||
last_output: str | None = None
|
||||
next_player_scan = 0.0
|
||||
|
||||
while True:
|
||||
try:
|
||||
now = time.monotonic()
|
||||
if not client.selected_player and now >= next_player_scan:
|
||||
client.find_player()
|
||||
next_player_scan = now + 2
|
||||
|
||||
state = client.state() if client.selected_player else None
|
||||
output = waybar_payload("")
|
||||
if state and state.status.casefold() == "playing" and state.title:
|
||||
if state.signature != track_signature:
|
||||
track_signature = state.signature
|
||||
lyrics = []
|
||||
try:
|
||||
song_id = provider.song_id(state)
|
||||
lyrics = provider.lyrics(song_id) if song_id else []
|
||||
except (OSError, ValueError, urllib.error.URLError, TimeoutError):
|
||||
lyrics = []
|
||||
current_lyric = lyric_at(lyrics, state.position)
|
||||
output = waybar_payload(current_lyric or state.title, state)
|
||||
elif state is None:
|
||||
track_signature = None
|
||||
lyrics = []
|
||||
|
||||
if output != last_output:
|
||||
print(output, flush=True)
|
||||
last_output = output
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
client.selected_player = None
|
||||
hidden = waybar_payload("")
|
||||
if hidden != last_output:
|
||||
print(hidden, flush=True)
|
||||
last_output = hidden
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
default_cache = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache")) / (
|
||||
"musicfox-waybar-lyrics"
|
||||
)
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--interval", type=float, default=0.5, help="Polling interval in seconds")
|
||||
parser.add_argument(
|
||||
"--player-pattern", default="musicfox", help="Substring used to select the MPRIS player"
|
||||
)
|
||||
parser.add_argument("--cache-dir", type=Path, default=default_cache)
|
||||
arguments = parser.parse_args()
|
||||
run(max(arguments.interval, 0.2), arguments.player_pattern, arguments.cache_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,56 @@
|
||||
import unittest
|
||||
|
||||
from musicfox_lyrics import (
|
||||
LyricLine,
|
||||
PlayerState,
|
||||
candidate_score,
|
||||
extract_song_id,
|
||||
lyric_at,
|
||||
parse_lrc,
|
||||
)
|
||||
|
||||
|
||||
class LyricsTest(unittest.TestCase):
|
||||
def test_parses_and_sorts_lrc_with_offset(self) -> None:
|
||||
lines = parse_lrc(
|
||||
"[offset:500]\n[00:10.000]第二句\n[00:01.250][00:05.000]第一句\n[00:20.000]"
|
||||
)
|
||||
self.assertEqual(
|
||||
lines,
|
||||
[
|
||||
LyricLine(1.75, "第一句"),
|
||||
LyricLine(5.5, "第一句"),
|
||||
LyricLine(10.5, "第二句"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_selects_line_for_playback_position(self) -> None:
|
||||
lines = [LyricLine(1, "一"), LyricLine(3, "二"), LyricLine(8, "三")]
|
||||
self.assertEqual(lyric_at(lines, 0.5), "")
|
||||
self.assertEqual(lyric_at(lines, 3.2), "二")
|
||||
self.assertEqual(lyric_at(lines, 20), "三")
|
||||
|
||||
def test_extracts_netease_song_id(self) -> None:
|
||||
self.assertEqual(extract_song_id("https://music.163.com/#/song?id=347230"), 347230)
|
||||
self.assertEqual(extract_song_id("/org/mpris/MediaPlayer2/song_347230"), 347230)
|
||||
self.assertIsNone(extract_song_id("https://example.com/track/347230"))
|
||||
|
||||
def test_song_match_prefers_title_artist_and_duration(self) -> None:
|
||||
state = PlayerState("musicfox", "Playing", 10, "海阔天空", "Beyond", 326, "", "")
|
||||
correct = {
|
||||
"id": 347230,
|
||||
"name": "海阔天空",
|
||||
"artists": [{"name": "Beyond"}],
|
||||
"duration": 326000,
|
||||
}
|
||||
wrong_version = {
|
||||
"id": 1,
|
||||
"name": "海阔天空",
|
||||
"artists": [{"name": "Beyond"}],
|
||||
"duration": 239000,
|
||||
}
|
||||
self.assertGreater(candidate_score(correct, state), candidate_score(wrong_version, state))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,14 @@
|
||||
// Add custom/musicfox-lyrics to modules-left, modules-center, or modules-right.
|
||||
{
|
||||
"custom/musicfox-lyrics": {
|
||||
"exec": "/home/skyroy/Documents/code/musicfox-waybar-lyrics/musicfox_lyrics.py",
|
||||
"return-type": "json",
|
||||
"restart-interval": 5,
|
||||
"escape": true,
|
||||
"on-click": "playerctl --player=musicfox play-pause",
|
||||
"on-scroll-up": "playerctl --player=musicfox next",
|
||||
"on-scroll-down": "playerctl --player=musicfox previous",
|
||||
"max-length": 70,
|
||||
"tooltip": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
#custom-musicfox-lyrics {
|
||||
margin: 4px 3px;
|
||||
padding: 0 14px;
|
||||
border-radius: 8px;
|
||||
background: rgba(30, 30, 46, 0.55);
|
||||
color: #cdd6f4;
|
||||
}
|
||||
|
||||
#custom-musicfox-lyrics.hidden {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
background: transparent;
|
||||
}
|
||||
Reference in New Issue
Block a user