添加 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