backup Arch and Niri configuration

This commit is contained in:
DucklingDev
2026-09-15 12:53:56 +08:00
commit 6aee617c20
34 changed files with 5948 additions and 0 deletions
@@ -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
```
网易云接口是网页内部接口,不提供长期稳定性保证。脚本已经包含缓存、超时和失败降级处理。
+322
View File
@@ -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;
}