2025-12-12 21:55:38 -08:00
|
|
|
|
r"""Timed lyric overlay for mpv via JSON IPC.
|
|
|
|
|
|
|
|
|
|
|
|
This is intentionally implemented from scratch (no vendored/copied code) while
|
|
|
|
|
|
providing the same *kind* of functionality as popular mpv lyric scripts:
|
|
|
|
|
|
- Parse LRC (timestamped lyrics)
|
|
|
|
|
|
- Track mpv playback time via IPC
|
|
|
|
|
|
- Show the current line on mpv's OSD
|
|
|
|
|
|
|
|
|
|
|
|
Primary intended usage in this repo:
|
|
|
|
|
|
- Auto mode (no stdin / no --lrc): loads lyrics from store notes.
|
|
|
|
|
|
A lyric note is stored under the note name 'lyric'.
|
|
|
|
|
|
- If the lyric note is missing, auto mode will attempt to auto-fetch synced lyrics
|
|
|
|
|
|
from a public API (LRCLIB) and store it into the 'lyric' note.
|
|
|
|
|
|
You can disable this by setting config key `lyric_autofetch` to false.
|
|
|
|
|
|
- You can still pipe LRC into this script (stdin) and it will render lyrics in mpv.
|
|
|
|
|
|
|
|
|
|
|
|
Example (PowerShell):
|
|
|
|
|
|
Get-Content .\song.lrc | python -m MPV.lyric
|
|
|
|
|
|
|
|
|
|
|
|
If you want to connect to a non-default mpv IPC server:
|
|
|
|
|
|
Get-Content .\song.lrc | python -m MPV.lyric --ipc "\\.\pipe\mpv-custom"
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
|
import bisect
|
|
|
|
|
|
import hashlib
|
|
|
|
|
|
import os
|
|
|
|
|
|
import re
|
|
|
|
|
|
import sys
|
|
|
|
|
|
import tempfile
|
|
|
|
|
|
import time
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
from typing import Any, Dict, List, Optional, TextIO
|
|
|
|
|
|
from urllib.parse import parse_qs, unquote, urlencode
|
|
|
|
|
|
from urllib.request import Request, urlopen
|
|
|
|
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
|
|
|
|
|
|
from MPV.mpv_ipc import MPV, MPVIPCClient
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_TIMESTAMP_RE = re.compile(r"\[(?P<m>\d+):(?P<s>\d{2})(?:\.(?P<frac>\d{1,3}))?\]")
|
|
|
|
|
|
_OFFSET_RE = re.compile(r"^\[offset:(?P<ms>[+-]?\d+)\]$", re.IGNORECASE)
|
|
|
|
|
|
_HASH_RE = re.compile(r"[0-9a-f]{64}", re.IGNORECASE)
|
|
|
|
|
|
_HYDRUS_HASH_QS_RE = re.compile(r"hash=([0-9a-f]{64})", re.IGNORECASE)
|
|
|
|
|
|
|
|
|
|
|
|
_WIN_DRIVE_RE = re.compile(r"^[a-zA-Z]:[\\/]")
|
|
|
|
|
|
_WIN_UNC_RE = re.compile(r"^\\\\")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_LOG_FH: Optional[TextIO] = None
|
|
|
|
|
|
_SINGLE_INSTANCE_LOCK_FH: Optional[TextIO] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_LYRIC_VISIBLE_PROP = "user-data/medeia-lyric-visible"
|
|
|
|
|
|
|
|
|
|
|
|
# mpv osd-overlay IDs are scoped to the IPC client connection.
|
|
|
|
|
|
# MPV.lyric keeps a persistent connection, so we can safely reuse a constant ID.
|
|
|
|
|
|
_LYRIC_OSD_OVERLAY_ID = 4242
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _single_instance_lock_path(ipc_path: str) -> Path:
|
|
|
|
|
|
# Key the lock to the mpv IPC target so multiple mpv instances with different
|
|
|
|
|
|
# IPC servers can still run independent lyric helpers.
|
|
|
|
|
|
key = hashlib.sha1((ipc_path or "").encode("utf-8", errors="ignore")).hexdigest()
|
|
|
|
|
|
tmp_dir = Path(tempfile.gettempdir())
|
|
|
|
|
|
return (tmp_dir / f"medeia-mpv-lyric-{key}.lock").resolve()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _acquire_single_instance_lock(ipc_path: str) -> bool:
|
|
|
|
|
|
"""Ensure only one MPV.lyric process runs per IPC server.
|
|
|
|
|
|
|
|
|
|
|
|
This prevents duplicate overlays (e.g. one old show-text overlay + one new osd-overlay).
|
|
|
|
|
|
"""
|
|
|
|
|
|
global _SINGLE_INSTANCE_LOCK_FH
|
|
|
|
|
|
|
|
|
|
|
|
if _SINGLE_INSTANCE_LOCK_FH is not None:
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
lock_path = _single_instance_lock_path(ipc_path)
|
|
|
|
|
|
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
fh = open(lock_path, "a", encoding="utf-8", errors="replace")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
# If we can't create the lock file, don't block playback; just proceed.
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
if os.name == "nt":
|
|
|
|
|
|
import msvcrt
|
|
|
|
|
|
|
|
|
|
|
|
# Lock the first byte (non-blocking).
|
|
|
|
|
|
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
|
|
|
|
|
|
else:
|
|
|
|
|
|
import fcntl
|
|
|
|
|
|
|
|
|
|
|
|
fcntl.flock(fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
|
|
|
|
|
|
|
|
|
|
_SINGLE_INSTANCE_LOCK_FH = fh
|
|
|
|
|
|
try:
|
|
|
|
|
|
fh.write(f"pid={os.getpid()} ipc={ipc_path}\n")
|
|
|
|
|
|
fh.flush()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return True
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
try:
|
|
|
|
|
|
fh.close()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ass_escape(text: str) -> str:
|
|
|
|
|
|
# Escape braces/backslashes so lyric text can't break ASS formatting.
|
|
|
|
|
|
t = str(text or "")
|
|
|
|
|
|
t = t.replace("\\", "\\\\")
|
|
|
|
|
|
t = t.replace("{", "\\{")
|
|
|
|
|
|
t = t.replace("}", "\\}")
|
|
|
|
|
|
t = t.replace("\r\n", "\n").replace("\r", "\n")
|
|
|
|
|
|
t = t.replace("\n", "\\N")
|
|
|
|
|
|
return t
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _format_lyric_as_subtitle(text: str) -> str:
|
|
|
|
|
|
# Bottom-center like a subtitle (ASS alignment 2).
|
|
|
|
|
|
# NOTE: show-text escapes ASS by default; we use osd-overlay so this is honored.
|
|
|
|
|
|
return "{\\an2}" + _ass_escape(text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _osd_overlay_set_ass(client: MPVIPCClient, ass_text: str) -> Optional[dict]:
|
|
|
|
|
|
# Use osd-overlay with ass-events so ASS override tags (e.g. {\an2}) are applied.
|
|
|
|
|
|
# Keep z low so UI scripts (like uosc) can draw above it if they use higher z.
|
|
|
|
|
|
return client.send_command(
|
|
|
|
|
|
{
|
|
|
|
|
|
"command": {
|
|
|
|
|
|
"name": "osd-overlay",
|
|
|
|
|
|
"id": _LYRIC_OSD_OVERLAY_ID,
|
|
|
|
|
|
"format": "ass-events",
|
|
|
|
|
|
"data": ass_text,
|
|
|
|
|
|
"res_y": 720,
|
|
|
|
|
|
"z": -50,
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _osd_overlay_clear(client: MPVIPCClient) -> None:
|
2025-12-29 17:05:03 -08:00
|
|
|
|
client.send_command(
|
|
|
|
|
|
{"command": {"name": "osd-overlay", "id": _LYRIC_OSD_OVERLAY_ID, "format": "none"}}
|
|
|
|
|
|
)
|
2025-12-12 21:55:38 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _log(msg: str) -> None:
|
|
|
|
|
|
line = f"[{datetime.now().isoformat(timespec='seconds')}] {msg}"
|
|
|
|
|
|
try:
|
|
|
|
|
|
if _LOG_FH is not None:
|
|
|
|
|
|
_LOG_FH.write(line + "\n")
|
|
|
|
|
|
_LOG_FH.flush()
|
|
|
|
|
|
return
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
print(line, file=sys.stderr, flush=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _ipc_get_property(
|
|
|
|
|
|
client: MPVIPCClient,
|
|
|
|
|
|
name: str,
|
|
|
|
|
|
default: object = None,
|
|
|
|
|
|
*,
|
|
|
|
|
|
raise_on_disconnect: bool = False,
|
|
|
|
|
|
) -> object:
|
|
|
|
|
|
resp = client.send_command({"command": ["get_property", name]})
|
|
|
|
|
|
if resp is None:
|
|
|
|
|
|
if raise_on_disconnect:
|
|
|
|
|
|
raise ConnectionError("Lost mpv IPC connection")
|
|
|
|
|
|
return default
|
|
|
|
|
|
if resp and resp.get("error") == "success":
|
|
|
|
|
|
return resp.get("data", default)
|
|
|
|
|
|
return default
|
2025-12-29 17:05:03 -08:00
|
|
|
|
|
|
|
|
|
|
|
2025-12-12 21:55:38 -08:00
|
|
|
|
def _http_get_json(url: str, *, timeout_s: float = 10.0) -> Optional[dict]:
|
|
|
|
|
|
try:
|
|
|
|
|
|
req = Request(
|
|
|
|
|
|
url,
|
|
|
|
|
|
headers={
|
|
|
|
|
|
"User-Agent": "medeia-macina/lyric",
|
|
|
|
|
|
"Accept": "application/json",
|
|
|
|
|
|
},
|
|
|
|
|
|
method="GET",
|
|
|
|
|
|
)
|
|
|
|
|
|
with urlopen(req, timeout=timeout_s) as resp:
|
|
|
|
|
|
data = resp.read()
|
|
|
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
|
|
obj = json.loads(data.decode("utf-8", errors="replace"))
|
|
|
|
|
|
return obj if isinstance(obj, dict) else None
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
_log(f"HTTP JSON failed: {exc} ({url})")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _http_get_json_list(url: str, *, timeout_s: float = 10.0) -> Optional[list]:
|
|
|
|
|
|
try:
|
|
|
|
|
|
req = Request(
|
|
|
|
|
|
url,
|
|
|
|
|
|
headers={
|
|
|
|
|
|
"User-Agent": "medeia-macina/lyric",
|
|
|
|
|
|
"Accept": "application/json",
|
|
|
|
|
|
},
|
|
|
|
|
|
method="GET",
|
|
|
|
|
|
)
|
|
|
|
|
|
with urlopen(req, timeout=timeout_s) as resp:
|
|
|
|
|
|
data = resp.read()
|
|
|
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
|
|
obj = json.loads(data.decode("utf-8", errors="replace"))
|
|
|
|
|
|
return obj if isinstance(obj, list) else None
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
_log(f"HTTP JSON(list) failed: {exc} ({url})")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _sanitize_query(s: Optional[str]) -> Optional[str]:
|
|
|
|
|
|
if not isinstance(s, str):
|
|
|
|
|
|
return None
|
|
|
|
|
|
t = s.strip().strip("\ufeff")
|
|
|
|
|
|
return t if t else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _infer_artist_title_from_tags(tags: List[str]) -> tuple[Optional[str], Optional[str]]:
|
|
|
|
|
|
artist = None
|
|
|
|
|
|
title = None
|
|
|
|
|
|
for t in tags or []:
|
|
|
|
|
|
ts = str(t)
|
|
|
|
|
|
low = ts.lower()
|
|
|
|
|
|
if low.startswith("artist:") and artist is None:
|
|
|
|
|
|
artist = ts.split(":", 1)[1].strip() or None
|
|
|
|
|
|
elif low.startswith("title:") and title is None:
|
|
|
|
|
|
title = ts.split(":", 1)[1].strip() or None
|
|
|
|
|
|
if artist and title:
|
|
|
|
|
|
break
|
|
|
|
|
|
return _sanitize_query(artist), _sanitize_query(title)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _wrap_plain_lyrics_as_lrc(text: str) -> str:
|
|
|
|
|
|
# Fallback: create a crude LRC that advances every 4 seconds.
|
|
|
|
|
|
# This is intentionally simple and deterministic.
|
|
|
|
|
|
lines = [ln.strip() for ln in (text or "").splitlines()]
|
|
|
|
|
|
lines = [ln for ln in lines if ln]
|
|
|
|
|
|
if not lines:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
out: List[str] = []
|
|
|
|
|
|
t_s = 0
|
|
|
|
|
|
for ln in lines:
|
|
|
|
|
|
mm = t_s // 60
|
|
|
|
|
|
ss = t_s % 60
|
|
|
|
|
|
out.append(f"[{mm:02d}:{ss:02d}.00]{ln}")
|
|
|
|
|
|
t_s += 4
|
|
|
|
|
|
return "\n".join(out) + "\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-29 17:05:03 -08:00
|
|
|
|
def _fetch_lrclib(
|
|
|
|
|
|
*, artist: Optional[str], title: Optional[str], duration_s: Optional[float] = None
|
|
|
|
|
|
) -> Optional[str]:
|
2025-12-12 21:55:38 -08:00
|
|
|
|
base = "https://lrclib.net/api"
|
|
|
|
|
|
|
|
|
|
|
|
# Require both artist and title; title-only lookups cause frequent mismatches.
|
|
|
|
|
|
if not artist or not title:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
# Try direct get.
|
|
|
|
|
|
q: Dict[str, str] = {
|
|
|
|
|
|
"artist_name": artist,
|
|
|
|
|
|
"track_name": title,
|
|
|
|
|
|
}
|
|
|
|
|
|
if isinstance(duration_s, (int, float)) and duration_s and duration_s > 0:
|
|
|
|
|
|
q["duration"] = str(int(duration_s))
|
|
|
|
|
|
url = f"{base}/get?{urlencode(q)}"
|
|
|
|
|
|
obj = _http_get_json(url)
|
|
|
|
|
|
if isinstance(obj, dict):
|
|
|
|
|
|
synced = obj.get("syncedLyrics")
|
|
|
|
|
|
if isinstance(synced, str) and synced.strip():
|
|
|
|
|
|
_log("LRCLIB: got syncedLyrics")
|
|
|
|
|
|
return synced
|
|
|
|
|
|
plain = obj.get("plainLyrics")
|
|
|
|
|
|
if isinstance(plain, str) and plain.strip():
|
|
|
|
|
|
_log("LRCLIB: only plainLyrics; wrapping")
|
|
|
|
|
|
wrapped = _wrap_plain_lyrics_as_lrc(plain)
|
|
|
|
|
|
return wrapped if wrapped.strip() else None
|
|
|
|
|
|
|
|
|
|
|
|
# Fallback: search using artist+title only.
|
|
|
|
|
|
q_text = f"{artist} {title}"
|
|
|
|
|
|
url = f"{base}/search?{urlencode({'q': q_text})}"
|
|
|
|
|
|
items = _http_get_json_list(url) or []
|
|
|
|
|
|
for item in items:
|
|
|
|
|
|
if not isinstance(item, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
synced = item.get("syncedLyrics")
|
|
|
|
|
|
if isinstance(synced, str) and synced.strip():
|
|
|
|
|
|
_log("LRCLIB: search hit with syncedLyrics")
|
|
|
|
|
|
return synced
|
|
|
|
|
|
# Plain lyrics fallback from search if available
|
|
|
|
|
|
for item in items:
|
|
|
|
|
|
if not isinstance(item, dict):
|
|
|
|
|
|
continue
|
|
|
|
|
|
plain = item.get("plainLyrics")
|
|
|
|
|
|
if isinstance(plain, str) and plain.strip():
|
|
|
|
|
|
_log("LRCLIB: search hit only plainLyrics; wrapping")
|
|
|
|
|
|
wrapped = _wrap_plain_lyrics_as_lrc(plain)
|
|
|
|
|
|
return wrapped if wrapped.strip() else None
|
|
|
|
|
|
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fetch_lyrics_ovh(*, artist: Optional[str], title: Optional[str]) -> Optional[str]:
|
|
|
|
|
|
# Public, no-auth lyrics provider (typically plain lyrics, not time-synced).
|
|
|
|
|
|
if not artist or not title:
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Endpoint uses path segments, so we urlencode each part.
|
|
|
|
|
|
from urllib.parse import quote
|
|
|
|
|
|
|
|
|
|
|
|
url = f"https://api.lyrics.ovh/v1/{quote(artist)}/{quote(title)}"
|
|
|
|
|
|
obj = _http_get_json(url)
|
|
|
|
|
|
if not isinstance(obj, dict):
|
|
|
|
|
|
return None
|
|
|
|
|
|
lyr = obj.get("lyrics")
|
|
|
|
|
|
if isinstance(lyr, str) and lyr.strip():
|
|
|
|
|
|
_log("lyrics.ovh: got plain lyrics; wrapping")
|
|
|
|
|
|
wrapped = _wrap_plain_lyrics_as_lrc(lyr)
|
|
|
|
|
|
return wrapped if wrapped.strip() else None
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
_log(f"lyrics.ovh failed: {exc}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
print(line, file=sys.stderr)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class LrcLine:
|
|
|
|
|
|
time_s: float
|
|
|
|
|
|
text: str
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _frac_to_ms(frac: str) -> int:
|
|
|
|
|
|
# LRC commonly uses centiseconds (2 digits), but can be 1–3 digits.
|
|
|
|
|
|
if not frac:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
if len(frac) == 3:
|
|
|
|
|
|
return int(frac)
|
|
|
|
|
|
if len(frac) == 2:
|
|
|
|
|
|
return int(frac) * 10
|
|
|
|
|
|
return int(frac) * 100
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def parse_lrc(text: str) -> List[LrcLine]:
|
|
|
|
|
|
"""Parse LRC into sorted timestamped lines."""
|
|
|
|
|
|
offset_ms = 0
|
|
|
|
|
|
lines: List[LrcLine] = []
|
|
|
|
|
|
|
|
|
|
|
|
for raw_line in text.splitlines():
|
|
|
|
|
|
line = raw_line.strip("\ufeff\r\n")
|
|
|
|
|
|
if not line:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# Optional global offset.
|
|
|
|
|
|
off_m = _OFFSET_RE.match(line)
|
|
|
|
|
|
if off_m:
|
|
|
|
|
|
try:
|
|
|
|
|
|
offset_ms = int(off_m.group("ms"))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
offset_ms = 0
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
matches = list(_TIMESTAMP_RE.finditer(line))
|
|
|
|
|
|
if not matches:
|
|
|
|
|
|
# Ignore non-timestamp metadata lines like [ar:], [ti:], etc.
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
lyric_text = line[matches[-1].end() :].strip()
|
|
|
|
|
|
for m in matches:
|
|
|
|
|
|
mm = int(m.group("m"))
|
|
|
|
|
|
ss = int(m.group("s"))
|
|
|
|
|
|
frac = m.group("frac") or ""
|
|
|
|
|
|
ts_ms = (mm * 60 + ss) * 1000 + _frac_to_ms(frac) + offset_ms
|
|
|
|
|
|
if ts_ms < 0:
|
|
|
|
|
|
continue
|
|
|
|
|
|
lines.append(LrcLine(time_s=ts_ms / 1000.0, text=lyric_text))
|
|
|
|
|
|
|
|
|
|
|
|
# Sort and de-dupe by timestamp (prefer last non-empty text).
|
|
|
|
|
|
lines.sort(key=lambda x: x.time_s)
|
|
|
|
|
|
deduped: List[LrcLine] = []
|
|
|
|
|
|
for item in lines:
|
|
|
|
|
|
if deduped and abs(deduped[-1].time_s - item.time_s) < 1e-6:
|
|
|
|
|
|
if item.text:
|
|
|
|
|
|
deduped[-1] = item
|
|
|
|
|
|
else:
|
|
|
|
|
|
deduped.append(item)
|
|
|
|
|
|
return deduped
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _read_all_stdin() -> str:
|
|
|
|
|
|
return sys.stdin.read()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _current_index(time_s: float, times: List[float]) -> int:
|
|
|
|
|
|
# Index of last timestamp <= time_s
|
|
|
|
|
|
return bisect.bisect_right(times, time_s) - 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _unwrap_memory_m3u(text: Optional[str]) -> Optional[str]:
|
|
|
|
|
|
"""Extract the real target URL/path from a memory:// M3U payload."""
|
|
|
|
|
|
if not isinstance(text, str) or not text.startswith("memory://"):
|
|
|
|
|
|
return text
|
|
|
|
|
|
for line in text.splitlines():
|
|
|
|
|
|
s = line.strip()
|
|
|
|
|
|
if not s or s.startswith("#") or s.startswith("memory://"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
return s
|
|
|
|
|
|
return text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_hash_from_target(target: str) -> Optional[str]:
|
|
|
|
|
|
if not isinstance(target, str):
|
|
|
|
|
|
return None
|
|
|
|
|
|
m = _HYDRUS_HASH_QS_RE.search(target)
|
|
|
|
|
|
if m:
|
|
|
|
|
|
return m.group(1).lower()
|
|
|
|
|
|
|
|
|
|
|
|
# Fallback: plain hash string
|
|
|
|
|
|
s = target.strip().lower()
|
|
|
|
|
|
if _HASH_RE.fullmatch(s):
|
|
|
|
|
|
return s
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_config_best_effort() -> dict:
|
|
|
|
|
|
try:
|
|
|
|
|
|
from config import load_config
|
|
|
|
|
|
|
|
|
|
|
|
cfg = load_config()
|
|
|
|
|
|
return cfg if isinstance(cfg, dict) else {}
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_lrc_from_notes(notes: Dict[str, str]) -> Optional[str]:
|
|
|
|
|
|
"""Return raw LRC text from the note named 'lyric'."""
|
|
|
|
|
|
if not isinstance(notes, dict) or not notes:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
raw = None
|
|
|
|
|
|
for k, v in notes.items():
|
|
|
|
|
|
if not isinstance(k, str):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if k.strip() == "lyric":
|
|
|
|
|
|
raw = v
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
if not isinstance(raw, str):
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
text = raw.strip("\ufeff\r\n")
|
|
|
|
|
|
return text if text.strip() else None
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-23 16:36:39 -08:00
|
|
|
|
def _extract_sub_from_notes(notes: Dict[str, str]) -> Optional[str]:
|
|
|
|
|
|
"""Return raw subtitle text from the note named 'sub'."""
|
|
|
|
|
|
if not isinstance(notes, dict) or not notes:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
raw = None
|
|
|
|
|
|
for k, v in notes.items():
|
|
|
|
|
|
if not isinstance(k, str):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if k.strip() == "sub":
|
|
|
|
|
|
raw = v
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
if not isinstance(raw, str):
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
text = raw.strip("\ufeff\r\n")
|
|
|
|
|
|
return text if text.strip() else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _infer_sub_extension(text: str) -> str:
|
|
|
|
|
|
# Best-effort: mpv generally understands SRT/VTT; choose based on content.
|
|
|
|
|
|
t = (text or "").lstrip("\ufeff\r\n").lstrip()
|
|
|
|
|
|
if t.upper().startswith("WEBVTT"):
|
|
|
|
|
|
return ".vtt"
|
|
|
|
|
|
if "-->" in t:
|
|
|
|
|
|
# SRT typically uses commas for milliseconds, VTT uses dots.
|
|
|
|
|
|
if re.search(r"\d\d:\d\d:\d\d,\d\d\d\s*-->\s*\d\d:\d\d:\d\d,\d\d\d", t):
|
|
|
|
|
|
return ".srt"
|
|
|
|
|
|
return ".vtt"
|
|
|
|
|
|
return ".vtt"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _write_temp_sub_file(*, key: str, text: str) -> Path:
|
|
|
|
|
|
# Write to a content-addressed temp path so updates force mpv reload.
|
|
|
|
|
|
tmp_dir = Path(tempfile.gettempdir()) / "medeia-mpv-notes"
|
|
|
|
|
|
tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
|
|
ext = _infer_sub_extension(text)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
digest = hashlib.sha1((key + "\n" + (text or "")).encode("utf-8", errors="ignore")).hexdigest()[
|
|
|
|
|
|
:16
|
|
|
|
|
|
]
|
2025-12-23 16:36:39 -08:00
|
|
|
|
safe_key = hashlib.sha1((key or "").encode("utf-8", errors="ignore")).hexdigest()[:12]
|
|
|
|
|
|
path = (tmp_dir / f"sub-{safe_key}-{digest}{ext}").resolve()
|
|
|
|
|
|
path.write_text(text or "", encoding="utf-8", errors="replace")
|
|
|
|
|
|
return path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _try_remove_selected_external_sub(client: MPVIPCClient) -> None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
client.send_command({"command": ["sub-remove"]})
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _try_add_external_sub(client: MPVIPCClient, path: Path) -> None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
client.send_command({"command": ["sub-add", str(path), "select", "medeia-sub"]})
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-12-12 21:55:38 -08:00
|
|
|
|
def _is_stream_target(target: str) -> bool:
|
|
|
|
|
|
"""Return True when mpv's 'path' is not a local filesystem file.
|
|
|
|
|
|
|
|
|
|
|
|
We intentionally treat any URL/streaming scheme as invalid for lyrics in auto mode.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not isinstance(target, str):
|
|
|
|
|
|
return False
|
|
|
|
|
|
s = target.strip()
|
|
|
|
|
|
if not s:
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# Windows local paths: drive letter or UNC.
|
|
|
|
|
|
if _WIN_DRIVE_RE.match(s) or _WIN_UNC_RE.match(s):
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
# Common streaming prefixes.
|
|
|
|
|
|
if s.startswith("http://") or s.startswith("https://"):
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
# Generic scheme:// (e.g. ytdl://, edl://, rtmp://, etc.).
|
|
|
|
|
|
if "://" in s:
|
|
|
|
|
|
try:
|
|
|
|
|
|
parsed = urlparse(s)
|
|
|
|
|
|
scheme = (parsed.scheme or "").lower()
|
|
|
|
|
|
if scheme and scheme not in {"file"}:
|
|
|
|
|
|
return True
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _normalize_file_uri_target(target: str) -> str:
|
|
|
|
|
|
"""Convert file:// URIs to a local filesystem path string when possible."""
|
|
|
|
|
|
if not isinstance(target, str):
|
|
|
|
|
|
return target
|
|
|
|
|
|
s = target.strip()
|
|
|
|
|
|
if not s:
|
|
|
|
|
|
return target
|
|
|
|
|
|
if not s.lower().startswith("file://"):
|
|
|
|
|
|
return target
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
parsed = urlparse(s)
|
|
|
|
|
|
path = unquote(parsed.path or "")
|
|
|
|
|
|
|
|
|
|
|
|
if os.name == "nt":
|
|
|
|
|
|
# UNC: file://server/share/path -> \\server\share\path
|
|
|
|
|
|
if parsed.netloc:
|
|
|
|
|
|
p = path.replace("/", "\\")
|
|
|
|
|
|
if p.startswith("\\"):
|
|
|
|
|
|
p = p.lstrip("\\")
|
|
|
|
|
|
return f"\\\\{parsed.netloc}\\{p}" if p else f"\\\\{parsed.netloc}"
|
|
|
|
|
|
|
|
|
|
|
|
# Drive letter: file:///C:/path -> C:/path
|
|
|
|
|
|
if path.startswith("/") and len(path) >= 3 and path[2] == ":":
|
|
|
|
|
|
path = path[1:]
|
|
|
|
|
|
|
|
|
|
|
|
return path or target
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return target
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_store_from_url_target(target: str) -> Optional[str]:
|
|
|
|
|
|
"""Extract explicit store name from a URL query param `store=...` (if present)."""
|
|
|
|
|
|
if not isinstance(target, str):
|
|
|
|
|
|
return None
|
|
|
|
|
|
s = target.strip()
|
|
|
|
|
|
if not (s.startswith("http://") or s.startswith("https://")):
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
parsed = urlparse(s)
|
|
|
|
|
|
if not parsed.query:
|
|
|
|
|
|
return None
|
|
|
|
|
|
qs = parse_qs(parsed.query)
|
|
|
|
|
|
raw = qs.get("store", [None])[0]
|
|
|
|
|
|
if isinstance(raw, str) and raw.strip():
|
|
|
|
|
|
return raw.strip()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _infer_hydrus_store_from_url_target(*, target: str, config: dict) -> Optional[str]:
|
|
|
|
|
|
"""Infer a Hydrus store backend by matching the URL prefix to the backend base URL."""
|
|
|
|
|
|
if not isinstance(target, str):
|
|
|
|
|
|
return None
|
|
|
|
|
|
s = target.strip()
|
|
|
|
|
|
if not (s.startswith("http://") or s.startswith("https://")):
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
from Store import Store as StoreRegistry
|
|
|
|
|
|
|
|
|
|
|
|
reg = StoreRegistry(config, suppress_debug=True)
|
|
|
|
|
|
backends = [(name, reg[name]) for name in reg.list_backends()]
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
matches: List[str] = []
|
|
|
|
|
|
for name, backend in backends:
|
|
|
|
|
|
if type(backend).__name__ != "HydrusNetwork":
|
|
|
|
|
|
continue
|
|
|
|
|
|
base_url = getattr(backend, "_url", None)
|
|
|
|
|
|
if not base_url:
|
|
|
|
|
|
client = getattr(backend, "_client", None)
|
|
|
|
|
|
base_url = getattr(client, "url", None) if client else None
|
|
|
|
|
|
if not base_url:
|
|
|
|
|
|
continue
|
|
|
|
|
|
base = str(base_url).rstrip("/")
|
|
|
|
|
|
if s.startswith(base):
|
|
|
|
|
|
matches.append(name)
|
|
|
|
|
|
|
|
|
|
|
|
if len(matches) == 1:
|
|
|
|
|
|
return matches[0]
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _resolve_store_backend_for_target(
|
|
|
|
|
|
*,
|
|
|
|
|
|
target: str,
|
|
|
|
|
|
file_hash: str,
|
|
|
|
|
|
config: dict,
|
|
|
|
|
|
) -> tuple[Optional[str], Any]:
|
|
|
|
|
|
"""Resolve a store backend for a local mpv target using the store DB.
|
|
|
|
|
|
|
|
|
|
|
|
A target is considered valid only when:
|
|
|
|
|
|
- target is a local filesystem file
|
|
|
|
|
|
- a backend's get_file(hash) returns a local file path
|
|
|
|
|
|
- that path resolves to the same target path
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
p = Path(target)
|
|
|
|
|
|
if not p.exists() or not p.is_file():
|
|
|
|
|
|
return None, None
|
|
|
|
|
|
target_resolved = p.resolve()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return None, None
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
from Store import Store as StoreRegistry
|
|
|
|
|
|
|
|
|
|
|
|
reg = StoreRegistry(config, suppress_debug=True)
|
|
|
|
|
|
backend_names = list(reg.list_backends())
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return None, None
|
|
|
|
|
|
|
|
|
|
|
|
# Prefer the inferred Folder store (fast), but still validate via get_file().
|
|
|
|
|
|
preferred = _infer_store_for_target(target=target, config=config)
|
|
|
|
|
|
if preferred and preferred in backend_names:
|
|
|
|
|
|
backend_names.remove(preferred)
|
|
|
|
|
|
backend_names.insert(0, preferred)
|
|
|
|
|
|
|
|
|
|
|
|
for name in backend_names:
|
|
|
|
|
|
try:
|
|
|
|
|
|
backend = reg[name]
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
store_file = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
store_file = backend.get_file(file_hash, config=config)
|
|
|
|
|
|
except TypeError:
|
|
|
|
|
|
try:
|
|
|
|
|
|
store_file = backend.get_file(file_hash)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
store_file = None
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
store_file = None
|
|
|
|
|
|
|
|
|
|
|
|
if not store_file:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# Only accept local files; if the backend returns a URL, it's not valid for lyrics.
|
|
|
|
|
|
try:
|
|
|
|
|
|
store_path = Path(str(store_file)).expanduser()
|
|
|
|
|
|
if not store_path.exists() or not store_path.is_file():
|
|
|
|
|
|
continue
|
|
|
|
|
|
if store_path.resolve() != target_resolved:
|
|
|
|
|
|
continue
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
return name, backend
|
|
|
|
|
|
|
|
|
|
|
|
return None, None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _infer_store_for_target(*, target: str, config: dict) -> Optional[str]:
|
|
|
|
|
|
"""Infer store name from the current mpv target (local path under a folder root).
|
|
|
|
|
|
|
|
|
|
|
|
Note: URLs/streams are intentionally not mapped to stores for lyrics.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if isinstance(target, str) and _is_stream_target(target):
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
from Store import Store as StoreRegistry
|
|
|
|
|
|
|
|
|
|
|
|
reg = StoreRegistry(config, suppress_debug=True)
|
|
|
|
|
|
backends = [(name, reg[name]) for name in reg.list_backends()]
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
backends = []
|
|
|
|
|
|
|
|
|
|
|
|
# Local file path: choose the deepest Folder root that contains it.
|
|
|
|
|
|
try:
|
|
|
|
|
|
p = Path(target)
|
|
|
|
|
|
if not p.exists() or not p.is_file():
|
|
|
|
|
|
return None
|
|
|
|
|
|
p_str = str(p.resolve()).lower()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
best: Optional[str] = None
|
|
|
|
|
|
best_len = -1
|
|
|
|
|
|
for name, backend in backends:
|
|
|
|
|
|
if type(backend).__name__ != "Folder":
|
|
|
|
|
|
continue
|
|
|
|
|
|
root = None
|
|
|
|
|
|
try:
|
2025-12-29 17:05:03 -08:00
|
|
|
|
root = (
|
|
|
|
|
|
getattr(backend, "_location", None) or getattr(backend, "location", lambda: None)()
|
|
|
|
|
|
)
|
2025-12-12 21:55:38 -08:00
|
|
|
|
except Exception:
|
|
|
|
|
|
root = None
|
|
|
|
|
|
if not root:
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
root_path = Path(str(root)).expanduser().resolve()
|
|
|
|
|
|
root_str = str(root_path).lower().rstrip("\\/")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if p_str.startswith(root_str) and len(root_str) > best_len:
|
|
|
|
|
|
best = name
|
|
|
|
|
|
best_len = len(root_str)
|
|
|
|
|
|
|
|
|
|
|
|
return best
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _infer_hash_for_target(target: str) -> Optional[str]:
|
|
|
|
|
|
"""Infer SHA256 hash from Hydrus URL query, hash-named local files, or by hashing local file content."""
|
|
|
|
|
|
h = _extract_hash_from_target(target)
|
|
|
|
|
|
if h:
|
|
|
|
|
|
return h
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
p = Path(target)
|
|
|
|
|
|
if not p.exists() or not p.is_file():
|
|
|
|
|
|
return None
|
|
|
|
|
|
stem = p.stem
|
|
|
|
|
|
if isinstance(stem, str) and _HASH_RE.fullmatch(stem.strip()):
|
|
|
|
|
|
return stem.strip().lower()
|
|
|
|
|
|
from SYS.utils import sha256_file
|
|
|
|
|
|
|
|
|
|
|
|
return sha256_file(p)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def run_auto_overlay(*, mpv: MPV, poll_s: float = 0.15, config: Optional[dict] = None) -> int:
|
2025-12-23 16:36:39 -08:00
|
|
|
|
"""Auto mode: track mpv's current file and render lyrics (note: 'lyric') or load subtitles (note: 'sub')."""
|
2025-12-12 21:55:38 -08:00
|
|
|
|
cfg = config or {}
|
|
|
|
|
|
|
|
|
|
|
|
client = mpv.client()
|
|
|
|
|
|
if not client.connect():
|
|
|
|
|
|
_log("mpv IPC is not reachable (is mpv running with --input-ipc-server?).")
|
|
|
|
|
|
return 3
|
|
|
|
|
|
|
|
|
|
|
|
_log(f"Auto overlay connected (ipc={getattr(mpv, 'ipc_path', None)})")
|
|
|
|
|
|
|
|
|
|
|
|
last_target: Optional[str] = None
|
|
|
|
|
|
current_store_name: Optional[str] = None
|
|
|
|
|
|
current_file_hash: Optional[str] = None
|
|
|
|
|
|
current_key: Optional[str] = None
|
|
|
|
|
|
current_backend: Optional[Any] = None
|
|
|
|
|
|
last_loaded_key: Optional[str] = None
|
2025-12-23 16:36:39 -08:00
|
|
|
|
last_loaded_mode: Optional[str] = None # 'lyric' | 'sub'
|
|
|
|
|
|
last_loaded_sub_path: Optional[Path] = None
|
2025-12-12 21:55:38 -08:00
|
|
|
|
last_fetch_attempt_key: Optional[str] = None
|
|
|
|
|
|
last_fetch_attempt_at: float = 0.0
|
|
|
|
|
|
|
|
|
|
|
|
entries: List[LrcLine] = []
|
|
|
|
|
|
times: List[float] = []
|
|
|
|
|
|
last_idx: Optional[int] = None
|
|
|
|
|
|
last_text: Optional[str] = None
|
|
|
|
|
|
last_visible: Optional[bool] = None
|
|
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
|
try:
|
|
|
|
|
|
# Toggle support (mpv Lua script sets this property; default to visible).
|
2025-12-29 17:05:03 -08:00
|
|
|
|
visible_raw = _ipc_get_property(
|
|
|
|
|
|
client, _LYRIC_VISIBLE_PROP, True, raise_on_disconnect=True
|
|
|
|
|
|
)
|
2025-12-12 21:55:38 -08:00
|
|
|
|
raw_path = _ipc_get_property(client, "path", None, raise_on_disconnect=True)
|
|
|
|
|
|
except ConnectionError:
|
|
|
|
|
|
try:
|
|
|
|
|
|
_osd_overlay_clear(client)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
try:
|
|
|
|
|
|
client.disconnect()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
if not client.connect():
|
|
|
|
|
|
_log("mpv IPC disconnected; exiting MPV.lyric")
|
|
|
|
|
|
return 4
|
|
|
|
|
|
time.sleep(poll_s)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
visible = bool(visible_raw) if isinstance(visible_raw, (bool, int)) else True
|
|
|
|
|
|
|
|
|
|
|
|
if last_visible is None:
|
|
|
|
|
|
last_visible = visible
|
|
|
|
|
|
elif last_visible is True and visible is False:
|
|
|
|
|
|
# Clear immediately when switching off.
|
|
|
|
|
|
try:
|
|
|
|
|
|
_osd_overlay_clear(client)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2025-12-26 18:58:48 -08:00
|
|
|
|
# Also remove any external subtitle that may be showing lyrics so
|
|
|
|
|
|
# turning lyrics "off" leaves no text on screen.
|
|
|
|
|
|
try:
|
|
|
|
|
|
_try_remove_selected_external_sub(client)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2025-12-12 21:55:38 -08:00
|
|
|
|
last_idx = None
|
|
|
|
|
|
last_text = None
|
|
|
|
|
|
last_visible = visible
|
|
|
|
|
|
elif last_visible is False and visible is True:
|
|
|
|
|
|
# Force a refresh on next tick.
|
|
|
|
|
|
last_idx = None
|
|
|
|
|
|
last_text = None
|
|
|
|
|
|
last_visible = visible
|
|
|
|
|
|
else:
|
|
|
|
|
|
last_visible = visible
|
|
|
|
|
|
|
|
|
|
|
|
target = _unwrap_memory_m3u(str(raw_path)) if isinstance(raw_path, str) else None
|
|
|
|
|
|
if isinstance(target, str):
|
|
|
|
|
|
target = _normalize_file_uri_target(target)
|
|
|
|
|
|
|
|
|
|
|
|
if not isinstance(target, str) or not target:
|
|
|
|
|
|
time.sleep(poll_s)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
is_http = target.startswith("http://") or target.startswith("https://")
|
|
|
|
|
|
if (not is_http) and _is_stream_target(target):
|
|
|
|
|
|
# Non-http streams (ytdl://, edl://, rtmp://, etc.) are never valid for lyrics.
|
|
|
|
|
|
if last_loaded_key is not None:
|
|
|
|
|
|
try:
|
|
|
|
|
|
_osd_overlay_clear(client)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
2025-12-23 16:36:39 -08:00
|
|
|
|
if last_loaded_sub_path is not None:
|
|
|
|
|
|
_try_remove_selected_external_sub(client)
|
|
|
|
|
|
last_loaded_sub_path = None
|
2025-12-12 21:55:38 -08:00
|
|
|
|
last_target = target
|
|
|
|
|
|
current_store_name = None
|
|
|
|
|
|
current_file_hash = None
|
|
|
|
|
|
current_key = None
|
|
|
|
|
|
current_backend = None
|
|
|
|
|
|
entries = []
|
|
|
|
|
|
times = []
|
|
|
|
|
|
last_loaded_key = None
|
2025-12-23 16:36:39 -08:00
|
|
|
|
last_loaded_mode = None
|
2025-12-12 21:55:38 -08:00
|
|
|
|
time.sleep(poll_s)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if target != last_target:
|
|
|
|
|
|
last_target = target
|
|
|
|
|
|
last_idx = None
|
|
|
|
|
|
last_text = None
|
|
|
|
|
|
|
|
|
|
|
|
_log(f"Target changed: {target}")
|
|
|
|
|
|
|
|
|
|
|
|
current_file_hash = _infer_hash_for_target(target)
|
|
|
|
|
|
if not current_file_hash:
|
|
|
|
|
|
entries = []
|
|
|
|
|
|
times = []
|
|
|
|
|
|
if last_loaded_key is not None:
|
|
|
|
|
|
_osd_overlay_clear(client)
|
|
|
|
|
|
last_loaded_key = None
|
2025-12-23 16:36:39 -08:00
|
|
|
|
last_loaded_mode = None
|
|
|
|
|
|
if last_loaded_sub_path is not None:
|
|
|
|
|
|
_try_remove_selected_external_sub(client)
|
|
|
|
|
|
last_loaded_sub_path = None
|
2025-12-12 21:55:38 -08:00
|
|
|
|
time.sleep(poll_s)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if is_http:
|
|
|
|
|
|
# HTTP/HTTPS targets are only valid if they map to a store backend.
|
|
|
|
|
|
store_from_url = _extract_store_from_url_target(target)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
store_name = store_from_url or _infer_hydrus_store_from_url_target(
|
|
|
|
|
|
target=target, config=cfg
|
|
|
|
|
|
)
|
2025-12-12 21:55:38 -08:00
|
|
|
|
if not store_name:
|
|
|
|
|
|
_log("HTTP target has no store mapping; lyrics disabled")
|
|
|
|
|
|
current_store_name = None
|
|
|
|
|
|
current_backend = None
|
|
|
|
|
|
current_key = None
|
|
|
|
|
|
entries = []
|
|
|
|
|
|
times = []
|
|
|
|
|
|
if last_loaded_key is not None:
|
|
|
|
|
|
_osd_overlay_clear(client)
|
|
|
|
|
|
last_loaded_key = None
|
2025-12-23 16:36:39 -08:00
|
|
|
|
last_loaded_mode = None
|
|
|
|
|
|
if last_loaded_sub_path is not None:
|
|
|
|
|
|
_try_remove_selected_external_sub(client)
|
|
|
|
|
|
last_loaded_sub_path = None
|
2025-12-12 21:55:38 -08:00
|
|
|
|
time.sleep(poll_s)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
from Store import Store as StoreRegistry
|
|
|
|
|
|
|
|
|
|
|
|
reg = StoreRegistry(cfg, suppress_debug=True)
|
|
|
|
|
|
current_backend = reg[store_name]
|
|
|
|
|
|
current_store_name = store_name
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
_log(f"HTTP target store {store_name!r} not available; lyrics disabled")
|
|
|
|
|
|
current_store_name = None
|
|
|
|
|
|
current_backend = None
|
|
|
|
|
|
current_key = None
|
|
|
|
|
|
entries = []
|
|
|
|
|
|
times = []
|
|
|
|
|
|
if last_loaded_key is not None:
|
|
|
|
|
|
_osd_overlay_clear(client)
|
|
|
|
|
|
last_loaded_key = None
|
2025-12-23 16:36:39 -08:00
|
|
|
|
last_loaded_mode = None
|
|
|
|
|
|
if last_loaded_sub_path is not None:
|
|
|
|
|
|
_try_remove_selected_external_sub(client)
|
|
|
|
|
|
last_loaded_sub_path = None
|
2025-12-12 21:55:38 -08:00
|
|
|
|
time.sleep(poll_s)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# Optional existence check: if metadata is unavailable, treat as not-a-store-item.
|
|
|
|
|
|
try:
|
|
|
|
|
|
meta = current_backend.get_metadata(current_file_hash, config=cfg)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
meta = None
|
|
|
|
|
|
if meta is None:
|
2025-12-29 17:05:03 -08:00
|
|
|
|
_log(
|
|
|
|
|
|
f"HTTP target not found in store DB (store={store_name!r} hash={current_file_hash}); lyrics disabled"
|
|
|
|
|
|
)
|
2025-12-12 21:55:38 -08:00
|
|
|
|
current_store_name = None
|
|
|
|
|
|
current_backend = None
|
|
|
|
|
|
current_key = None
|
|
|
|
|
|
entries = []
|
|
|
|
|
|
times = []
|
|
|
|
|
|
if last_loaded_key is not None:
|
|
|
|
|
|
_osd_overlay_clear(client)
|
|
|
|
|
|
last_loaded_key = None
|
2025-12-23 16:36:39 -08:00
|
|
|
|
last_loaded_mode = None
|
|
|
|
|
|
if last_loaded_sub_path is not None:
|
|
|
|
|
|
_try_remove_selected_external_sub(client)
|
|
|
|
|
|
last_loaded_sub_path = None
|
2025-12-12 21:55:38 -08:00
|
|
|
|
time.sleep(poll_s)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
current_key = f"{current_store_name}:{current_file_hash}"
|
|
|
|
|
|
_log(f"Resolved store={current_store_name!r} hash={current_file_hash!r} valid=True")
|
|
|
|
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Local files: resolve store item via store DB. If not resolvable, lyrics are disabled.
|
|
|
|
|
|
current_store_name, current_backend = _resolve_store_backend_for_target(
|
|
|
|
|
|
target=target,
|
|
|
|
|
|
file_hash=current_file_hash,
|
|
|
|
|
|
config=cfg,
|
|
|
|
|
|
)
|
2025-12-29 17:05:03 -08:00
|
|
|
|
current_key = (
|
|
|
|
|
|
f"{current_store_name}:{current_file_hash}"
|
|
|
|
|
|
if current_store_name and current_file_hash
|
|
|
|
|
|
else None
|
|
|
|
|
|
)
|
2025-12-12 21:55:38 -08:00
|
|
|
|
|
2025-12-29 17:05:03 -08:00
|
|
|
|
_log(
|
|
|
|
|
|
f"Resolved store={current_store_name!r} hash={current_file_hash!r} valid={bool(current_key)}"
|
|
|
|
|
|
)
|
2025-12-12 21:55:38 -08:00
|
|
|
|
|
|
|
|
|
|
if not current_key or not current_backend:
|
|
|
|
|
|
current_store_name = None
|
|
|
|
|
|
current_backend = None
|
|
|
|
|
|
current_key = None
|
|
|
|
|
|
entries = []
|
|
|
|
|
|
times = []
|
|
|
|
|
|
if last_loaded_key is not None:
|
|
|
|
|
|
_osd_overlay_clear(client)
|
|
|
|
|
|
last_loaded_key = None
|
2025-12-23 16:36:39 -08:00
|
|
|
|
last_loaded_mode = None
|
|
|
|
|
|
if last_loaded_sub_path is not None:
|
|
|
|
|
|
_try_remove_selected_external_sub(client)
|
|
|
|
|
|
last_loaded_sub_path = None
|
2025-12-12 21:55:38 -08:00
|
|
|
|
time.sleep(poll_s)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
# Load/reload lyrics when we have a resolvable key and it differs from what we loaded.
|
|
|
|
|
|
# This is important for the autofetch path: the note can appear without the mpv target changing.
|
2025-12-29 17:05:03 -08:00
|
|
|
|
if (
|
|
|
|
|
|
current_key
|
|
|
|
|
|
and current_key != last_loaded_key
|
|
|
|
|
|
and current_store_name
|
|
|
|
|
|
and current_file_hash
|
|
|
|
|
|
and current_backend
|
|
|
|
|
|
):
|
2025-12-12 21:55:38 -08:00
|
|
|
|
notes: Dict[str, str] = {}
|
|
|
|
|
|
try:
|
|
|
|
|
|
notes = current_backend.get_note(current_file_hash, config=cfg) or {}
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
notes = {}
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
2025-12-29 17:05:03 -08:00
|
|
|
|
_log(
|
|
|
|
|
|
f"Loaded notes keys: {sorted([str(k) for k in notes.keys()]) if isinstance(notes, dict) else 'N/A'}"
|
|
|
|
|
|
)
|
2025-12-12 21:55:38 -08:00
|
|
|
|
except Exception:
|
|
|
|
|
|
_log("Loaded notes keys: <error>")
|
|
|
|
|
|
|
2025-12-23 16:36:39 -08:00
|
|
|
|
sub_text = _extract_sub_from_notes(notes)
|
|
|
|
|
|
if sub_text:
|
|
|
|
|
|
# Treat subtitles as an alternative to lyrics; do not show the lyric overlay.
|
|
|
|
|
|
try:
|
|
|
|
|
|
_osd_overlay_clear(client)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
sub_path = _write_temp_sub_file(key=current_key, text=sub_text)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
_log(f"Failed to write sub note temp file: {exc}")
|
|
|
|
|
|
sub_path = None
|
|
|
|
|
|
|
|
|
|
|
|
if sub_path is not None:
|
|
|
|
|
|
# If we previously loaded a sub, remove it first to avoid stacking.
|
|
|
|
|
|
if last_loaded_sub_path is not None:
|
|
|
|
|
|
_try_remove_selected_external_sub(client)
|
|
|
|
|
|
_try_add_external_sub(client, sub_path)
|
|
|
|
|
|
last_loaded_sub_path = sub_path
|
|
|
|
|
|
|
|
|
|
|
|
entries = []
|
|
|
|
|
|
times = []
|
|
|
|
|
|
last_loaded_key = current_key
|
|
|
|
|
|
last_loaded_mode = "sub"
|
|
|
|
|
|
|
|
|
|
|
|
else:
|
|
|
|
|
|
# Switching away from sub-note mode: best-effort unload the selected external subtitle.
|
|
|
|
|
|
if last_loaded_mode == "sub" and last_loaded_sub_path is not None:
|
|
|
|
|
|
_try_remove_selected_external_sub(client)
|
|
|
|
|
|
last_loaded_sub_path = None
|
|
|
|
|
|
|
|
|
|
|
|
lrc_text = _extract_lrc_from_notes(notes)
|
|
|
|
|
|
if not lrc_text:
|
|
|
|
|
|
_log("No lyric note found (note name: 'lyric')")
|
2025-12-12 21:55:38 -08:00
|
|
|
|
|
|
|
|
|
|
# Auto-fetch path: fetch and persist lyrics into the note named 'lyric'.
|
|
|
|
|
|
# Throttle attempts per key to avoid hammering APIs.
|
|
|
|
|
|
autofetch_enabled = bool(cfg.get("lyric_autofetch", True))
|
|
|
|
|
|
now = time.time()
|
2025-12-29 17:05:03 -08:00
|
|
|
|
if (
|
|
|
|
|
|
autofetch_enabled
|
|
|
|
|
|
and current_key != last_fetch_attempt_key
|
|
|
|
|
|
and (now - last_fetch_attempt_at) > 2.0
|
|
|
|
|
|
):
|
2025-12-12 21:55:38 -08:00
|
|
|
|
last_fetch_attempt_key = current_key
|
|
|
|
|
|
last_fetch_attempt_at = now
|
|
|
|
|
|
|
|
|
|
|
|
artist = None
|
|
|
|
|
|
title = None
|
|
|
|
|
|
duration_s = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
duration_s = _ipc_get_property(client, "duration", None)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
duration_s = None
|
|
|
|
|
|
|
|
|
|
|
|
# Use store tags only (artist:/title:). No filename/metadata/media-title fallbacks.
|
|
|
|
|
|
try:
|
|
|
|
|
|
tags, _src = current_backend.get_tag(current_file_hash, config=cfg)
|
|
|
|
|
|
if isinstance(tags, list):
|
|
|
|
|
|
artist, title = _infer_artist_title_from_tags([str(x) for x in tags])
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
2025-12-29 17:05:03 -08:00
|
|
|
|
_log(
|
|
|
|
|
|
f"Autofetch query artist={artist!r} title={title!r} duration={duration_s!r}"
|
|
|
|
|
|
)
|
2025-12-12 21:55:38 -08:00
|
|
|
|
|
|
|
|
|
|
if not artist or not title:
|
|
|
|
|
|
_log("Autofetch skipped: requires both artist and title")
|
|
|
|
|
|
fetched = None
|
|
|
|
|
|
else:
|
|
|
|
|
|
fetched = _fetch_lrclib(
|
|
|
|
|
|
artist=artist,
|
|
|
|
|
|
title=title,
|
2025-12-29 17:05:03 -08:00
|
|
|
|
duration_s=(
|
|
|
|
|
|
float(duration_s) if isinstance(duration_s, (int, float)) else None
|
|
|
|
|
|
),
|
2025-12-12 21:55:38 -08:00
|
|
|
|
)
|
|
|
|
|
|
if not fetched or not fetched.strip():
|
|
|
|
|
|
fetched = _fetch_lyrics_ovh(artist=artist, title=title)
|
|
|
|
|
|
if fetched and fetched.strip():
|
|
|
|
|
|
try:
|
2025-12-29 17:05:03 -08:00
|
|
|
|
ok = bool(
|
|
|
|
|
|
current_backend.set_note(
|
|
|
|
|
|
current_file_hash, "lyric", fetched, config=cfg
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2025-12-12 21:55:38 -08:00
|
|
|
|
_log(f"Autofetch stored lyric note ok={ok}")
|
|
|
|
|
|
# Next loop iteration will re-load the note.
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
_log(f"Autofetch failed to store lyric note: {exc}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
_log("Autofetch: no lyrics found")
|
|
|
|
|
|
|
2025-12-23 16:36:39 -08:00
|
|
|
|
entries = []
|
|
|
|
|
|
times = []
|
|
|
|
|
|
if last_loaded_key is not None:
|
|
|
|
|
|
_osd_overlay_clear(client)
|
|
|
|
|
|
last_loaded_key = None
|
|
|
|
|
|
last_loaded_mode = None
|
|
|
|
|
|
else:
|
|
|
|
|
|
_log(f"Loaded lyric note ({len(lrc_text)} chars)")
|
|
|
|
|
|
|
|
|
|
|
|
parsed = parse_lrc(lrc_text)
|
|
|
|
|
|
entries = parsed
|
|
|
|
|
|
times = [e.time_s for e in entries]
|
|
|
|
|
|
last_loaded_key = current_key
|
|
|
|
|
|
last_loaded_mode = "lyric"
|
2025-12-12 21:55:38 -08:00
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
# mpv returns None when idle/no file.
|
|
|
|
|
|
t = _ipc_get_property(client, "time-pos", None, raise_on_disconnect=True)
|
|
|
|
|
|
except ConnectionError:
|
|
|
|
|
|
try:
|
|
|
|
|
|
_osd_overlay_clear(client)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
try:
|
|
|
|
|
|
client.disconnect()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
if not client.connect():
|
|
|
|
|
|
_log("mpv IPC disconnected; exiting MPV.lyric")
|
|
|
|
|
|
return 4
|
|
|
|
|
|
time.sleep(poll_s)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if not isinstance(t, (int, float)):
|
|
|
|
|
|
time.sleep(poll_s)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if not entries:
|
|
|
|
|
|
time.sleep(poll_s)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if not visible:
|
|
|
|
|
|
time.sleep(poll_s)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
idx = _current_index(float(t), times)
|
|
|
|
|
|
|
|
|
|
|
|
if idx < 0:
|
|
|
|
|
|
time.sleep(poll_s)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
line = entries[idx]
|
|
|
|
|
|
if idx != last_idx or line.text != last_text:
|
|
|
|
|
|
# osd-overlay has no duration; refresh periodically.
|
|
|
|
|
|
resp = _osd_overlay_set_ass(client, _format_lyric_as_subtitle(line.text))
|
|
|
|
|
|
if resp is None:
|
|
|
|
|
|
client.disconnect()
|
|
|
|
|
|
if not client.connect():
|
|
|
|
|
|
print("Lost mpv IPC connection.", file=sys.stderr)
|
|
|
|
|
|
return 4
|
|
|
|
|
|
elif isinstance(resp, dict) and resp.get("error") not in (None, "success"):
|
|
|
|
|
|
try:
|
|
|
|
|
|
_log(f"mpv osd-overlay returned error={resp.get('error')!r}")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
last_idx = idx
|
|
|
|
|
|
last_text = line.text
|
|
|
|
|
|
|
|
|
|
|
|
time.sleep(poll_s)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def run_overlay(*, mpv: MPV, entries: List[LrcLine], poll_s: float = 0.15) -> int:
|
|
|
|
|
|
if not entries:
|
|
|
|
|
|
print("No timestamped LRC lines found.", file=sys.stderr)
|
|
|
|
|
|
return 2
|
|
|
|
|
|
|
|
|
|
|
|
times = [e.time_s for e in entries]
|
|
|
|
|
|
last_idx: Optional[int] = None
|
|
|
|
|
|
last_text: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
client = mpv.client()
|
|
|
|
|
|
if not client.connect():
|
2025-12-29 17:05:03 -08:00
|
|
|
|
print(
|
|
|
|
|
|
"mpv IPC is not reachable (is mpv running with --input-ipc-server?).", file=sys.stderr
|
|
|
|
|
|
)
|
2025-12-12 21:55:38 -08:00
|
|
|
|
return 3
|
|
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
|
try:
|
|
|
|
|
|
# mpv returns None when idle/no file.
|
|
|
|
|
|
t = _ipc_get_property(client, "time-pos", None, raise_on_disconnect=True)
|
|
|
|
|
|
except ConnectionError:
|
|
|
|
|
|
try:
|
|
|
|
|
|
_osd_overlay_clear(client)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
try:
|
|
|
|
|
|
client.disconnect()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
if not client.connect():
|
|
|
|
|
|
print("Lost mpv IPC connection.", file=sys.stderr)
|
|
|
|
|
|
return 4
|
|
|
|
|
|
time.sleep(poll_s)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
if not isinstance(t, (int, float)):
|
|
|
|
|
|
time.sleep(poll_s)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
idx = _current_index(float(t), times)
|
|
|
|
|
|
|
|
|
|
|
|
if idx < 0:
|
|
|
|
|
|
# Before first lyric timestamp.
|
|
|
|
|
|
time.sleep(poll_s)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
line = entries[idx]
|
|
|
|
|
|
if idx != last_idx or line.text != last_text:
|
|
|
|
|
|
# osd-overlay has no duration; refresh periodically.
|
|
|
|
|
|
resp = _osd_overlay_set_ass(client, _format_lyric_as_subtitle(line.text))
|
|
|
|
|
|
if resp is None:
|
|
|
|
|
|
client.disconnect()
|
|
|
|
|
|
if not client.connect():
|
|
|
|
|
|
print("Lost mpv IPC connection.", file=sys.stderr)
|
|
|
|
|
|
return 4
|
|
|
|
|
|
elif isinstance(resp, dict) and resp.get("error") not in (None, "success"):
|
|
|
|
|
|
try:
|
|
|
|
|
|
_log(f"mpv osd-overlay returned error={resp.get('error')!r}")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
last_idx = idx
|
|
|
|
|
|
last_text = line.text
|
|
|
|
|
|
|
|
|
|
|
|
time.sleep(poll_s)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main(argv: Optional[List[str]] = None) -> int:
|
|
|
|
|
|
parser = argparse.ArgumentParser(prog="python -m MPV.lyric", add_help=True)
|
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"--ipc",
|
|
|
|
|
|
default=None,
|
|
|
|
|
|
help="mpv IPC path. Defaults to the repo's fixed IPC pipe name.",
|
|
|
|
|
|
)
|
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"--lrc",
|
|
|
|
|
|
default=None,
|
|
|
|
|
|
help="Path to an .lrc file. If omitted, reads LRC from stdin.",
|
|
|
|
|
|
)
|
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"--poll",
|
|
|
|
|
|
type=float,
|
|
|
|
|
|
default=0.15,
|
|
|
|
|
|
help="Polling interval in seconds for time-pos updates.",
|
|
|
|
|
|
)
|
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"--log",
|
|
|
|
|
|
default=None,
|
|
|
|
|
|
help="Optional path to a log file for diagnostics.",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
args = parser.parse_args(argv)
|
|
|
|
|
|
|
|
|
|
|
|
# Configure logging early.
|
|
|
|
|
|
global _LOG_FH
|
|
|
|
|
|
if args.log:
|
|
|
|
|
|
try:
|
|
|
|
|
|
log_path = Path(str(args.log)).expanduser().resolve()
|
|
|
|
|
|
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
_LOG_FH = open(log_path, "a", encoding="utf-8", errors="replace")
|
|
|
|
|
|
_log("MPV.lyric starting")
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
_LOG_FH = None
|
|
|
|
|
|
|
|
|
|
|
|
mpv = MPV(ipc_path=args.ipc) if args.ipc else MPV()
|
|
|
|
|
|
|
|
|
|
|
|
# Prevent multiple lyric helpers from running at once for the same mpv IPC.
|
|
|
|
|
|
if not _acquire_single_instance_lock(getattr(mpv, "ipc_path", "") or ""):
|
|
|
|
|
|
_log("Another MPV.lyric instance is already running for this IPC; exiting.")
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
# If --lrc is provided, use it.
|
|
|
|
|
|
if args.lrc:
|
|
|
|
|
|
with open(args.lrc, "r", encoding="utf-8", errors="replace") as f:
|
|
|
|
|
|
lrc_text = f.read()
|
|
|
|
|
|
entries = parse_lrc(lrc_text)
|
|
|
|
|
|
try:
|
|
|
|
|
|
return run_overlay(mpv=mpv, entries=entries, poll_s=float(args.poll))
|
|
|
|
|
|
except KeyboardInterrupt:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
# Otherwise: if stdin has content, treat it as LRC; if stdin is empty/TTY, auto-discover.
|
|
|
|
|
|
lrc_text = ""
|
|
|
|
|
|
try:
|
|
|
|
|
|
if not sys.stdin.isatty():
|
|
|
|
|
|
lrc_text = _read_all_stdin() or ""
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
lrc_text = ""
|
|
|
|
|
|
|
|
|
|
|
|
if lrc_text.strip():
|
|
|
|
|
|
entries = parse_lrc(lrc_text)
|
|
|
|
|
|
try:
|
|
|
|
|
|
return run_overlay(mpv=mpv, entries=entries, poll_s=float(args.poll))
|
|
|
|
|
|
except KeyboardInterrupt:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
cfg = _load_config_best_effort()
|
|
|
|
|
|
try:
|
|
|
|
|
|
return run_auto_overlay(mpv=mpv, poll_s=float(args.poll), config=cfg)
|
|
|
|
|
|
except KeyboardInterrupt:
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
raise SystemExit(main())
|