This commit is contained in:
2026-01-03 03:37:48 -08:00
parent 6e9a0c28ff
commit 73f3005393
23 changed files with 1791 additions and 442 deletions

View File

@@ -54,9 +54,18 @@ _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
# Optional overrides set by the playlist controller (.pipe/.mpv) so the lyric
# helper can resolve notes even when the local file path cannot be mapped back
# to a store via the store DB (common for Folder stores).
_ITEM_STORE_PROP = "user-data/medeia-item-store"
_ITEM_HASH_PROP = "user-data/medeia-item-hash"
# Note: We previously used `osd-overlay`, but some mpv builds return
# error='invalid parameter' for that command. We now use `show-text`, which is
# widely supported across mpv versions.
_OSD_STYLE_SAVED: Optional[Dict[str, Any]] = None
_OSD_STYLE_APPLIED: bool = False
def _single_instance_lock_path(ipc_path: str) -> Path:
@@ -70,7 +79,7 @@ def _single_instance_lock_path(ipc_path: str) -> Path:
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).
This prevents duplicate overlays (e.g. multiple lyric helpers racing to update OSD).
"""
global _SINGLE_INSTANCE_LOCK_FH
@@ -123,39 +132,29 @@ def _ass_escape(text: str) -> str:
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_set_text(client: MPVIPCClient, text: str, *, duration_ms: int = 1000) -> Optional[dict]:
# Signature: show-text <string> [<duration-ms>] [<level>]
# Duration 0 clears immediately; we generally set it to cover until next update.
try:
d = int(duration_ms)
except Exception:
d = 1000
if d < 0:
d = 0
return client.send_command({
"command": [
"show-text",
str(text or ""),
d,
]
})
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:
client.send_command(
{
"command": {
"name": "osd-overlay",
"id": _LYRIC_OSD_OVERLAY_ID,
"format": "none"
}
}
)
def _osd_clear(client: MPVIPCClient) -> None:
try:
_osd_set_text(client, "", duration_ms=0)
except Exception:
return
def _log(msg: str) -> None:
@@ -191,6 +190,104 @@ def _ipc_get_property(
return default
def _ipc_set_property(client: MPVIPCClient, name: str, value: Any) -> bool:
resp = client.send_command({
"command": ["set_property",
name,
value]
})
return bool(resp and resp.get("error") == "success")
def _osd_capture_style(client: MPVIPCClient) -> Dict[str, Any]:
keys = [
"osd-align-x",
"osd-align-y",
"osd-font-size",
"osd-margin-y",
]
out: Dict[str, Any] = {}
for k in keys:
try:
out[k] = _ipc_get_property(client, k, None)
except Exception:
out[k] = None
return out
def _osd_apply_lyric_style(client: MPVIPCClient, *, config: Dict[str, Any]) -> None:
"""Apply bottom-center + larger font for lyric show-text messages.
This modifies mpv's global OSD settings, so we save and restore them.
"""
global _OSD_STYLE_SAVED, _OSD_STYLE_APPLIED
if not _OSD_STYLE_APPLIED:
if _OSD_STYLE_SAVED is None:
_OSD_STYLE_SAVED = _osd_capture_style(client)
try:
_ipc_set_property(client, "osd-align-x", "center")
_ipc_set_property(client, "osd-align-y", "bottom")
scale = config.get("lyric_osd_font_scale", 1.15)
try:
scale_f = float(scale)
except Exception:
scale_f = 1.15
if scale_f < 1.0:
scale_f = 1.0
old_size = None
try:
if _OSD_STYLE_SAVED is not None:
old_size = _OSD_STYLE_SAVED.get("osd-font-size")
except Exception:
old_size = None
if isinstance(old_size, (int, float)):
new_size = int(max(10, round(float(old_size) * scale_f)))
else:
# mpv default is typically ~55; choose a conservative readable size.
new_size = int(config.get("lyric_osd_font_size", 64))
_ipc_set_property(client, "osd-font-size", new_size)
min_margin_y = int(config.get("lyric_osd_min_margin_y", 60))
old_margin_y = None
try:
if _OSD_STYLE_SAVED is not None:
old_margin_y = _OSD_STYLE_SAVED.get("osd-margin-y")
except Exception:
old_margin_y = None
if isinstance(old_margin_y, (int, float)):
_ipc_set_property(client, "osd-margin-y", int(max(old_margin_y, min_margin_y)))
else:
_ipc_set_property(client, "osd-margin-y", min_margin_y)
except Exception:
return
_OSD_STYLE_APPLIED = True
def _osd_restore_style(client: MPVIPCClient) -> None:
global _OSD_STYLE_SAVED, _OSD_STYLE_APPLIED
if not _OSD_STYLE_APPLIED:
return
try:
saved = _OSD_STYLE_SAVED or {}
for k, v in saved.items():
if v is None:
continue
try:
_ipc_set_property(client, k, v)
except Exception:
pass
finally:
_OSD_STYLE_APPLIED = False
def _http_get_json(url: str, *, timeout_s: float = 10.0) -> Optional[dict]:
try:
req = Request(
@@ -460,9 +557,26 @@ def _load_config_best_effort() -> dict:
try:
from SYS.config import load_config
cfg = load_config()
return cfg if isinstance(cfg,
dict) else {}
# `SYS.config.load_config()` defaults to loading `config.conf` from the
# SYS/ directory, but this repo keeps `config.conf` at the repo root.
# MPV.lyric is often spawned from mpv (not the CLI), so we must locate
# the repo root ourselves.
try:
repo_root = Path(__file__).resolve().parent.parent
except Exception:
repo_root = None
cfg = None
if repo_root is not None:
try:
cfg = load_config(config_dir=repo_root)
except Exception:
cfg = None
if cfg is None:
cfg = load_config()
return cfg if isinstance(cfg, dict) else {}
except Exception:
return {}
@@ -745,6 +859,22 @@ def _resolve_store_backend_for_target(
return name, backend
# Fallback for Folder stores:
# If the mpv target is inside a configured Folder store root and the filename
# is hash-named, accept the inferred store even if the store DB doesn't map
# hash->path (e.g. DB missing entry, external copy, etc.).
try:
inferred = _infer_store_for_target(target=target, config=config)
if inferred and inferred in backend_names:
backend = reg[inferred]
if type(backend).__name__ == "Folder":
p = Path(target)
stem = str(p.stem or "").strip().lower()
if stem and stem == str(file_hash or "").strip().lower():
return inferred, backend
except Exception:
pass
return None, None
@@ -856,6 +986,8 @@ def run_auto_overlay(
last_text: Optional[str] = None
last_visible: Optional[bool] = None
global _OSD_STYLE_SAVED, _OSD_STYLE_APPLIED
while True:
try:
# Toggle support (mpv Lua script sets this property; default to visible).
@@ -868,13 +1000,20 @@ def run_auto_overlay(
raw_path = _ipc_get_property(client, "path", None, raise_on_disconnect=True)
except ConnectionError:
try:
_osd_overlay_clear(client)
_osd_clear(client)
except Exception:
pass
try:
_osd_restore_style(client)
except Exception:
pass
try:
client.disconnect()
except Exception:
pass
# If mpv restarted, recapture baseline OSD settings on reconnect.
_OSD_STYLE_SAVED = None
_OSD_STYLE_APPLIED = False
if not client.connect():
_log("mpv IPC disconnected; exiting MPV.lyric")
return 4
@@ -888,7 +1027,11 @@ def run_auto_overlay(
elif last_visible is True and visible is False:
# Clear immediately when switching off.
try:
_osd_overlay_clear(client)
_osd_clear(client)
except Exception:
pass
try:
_osd_restore_style(client)
except Exception:
pass
# Also remove any external subtitle that may be showing lyrics so
@@ -923,7 +1066,11 @@ def run_auto_overlay(
# Non-http streams (ytdl://, edl://, rtmp://, etc.) are never valid for lyrics.
if last_loaded_key is not None:
try:
_osd_overlay_clear(client)
_osd_clear(client)
except Exception:
pass
try:
_osd_restore_style(client)
except Exception:
pass
if last_loaded_sub_path is not None:
@@ -941,6 +1088,28 @@ def run_auto_overlay(
time.sleep(poll_s)
continue
# Optional override from the playlist controller: `.mpv` can publish the
# intended store/hash in mpv user-data. We use this both on target change
# and as a late-arriving fallback (the helper may start before `.mpv`
# sets the properties).
store_override = None
hash_override = None
try:
store_override = _ipc_get_property(client, _ITEM_STORE_PROP, None)
hash_override = _ipc_get_property(client, _ITEM_HASH_PROP, None)
except Exception:
store_override = None
hash_override = None
try:
store_override = str(store_override).strip() if store_override else None
except Exception:
store_override = None
try:
hash_override = str(hash_override).strip().lower() if hash_override else None
except Exception:
hash_override = None
if target != last_target:
last_target = target
last_idx = None
@@ -953,7 +1122,11 @@ def run_auto_overlay(
entries = []
times = []
if last_loaded_key is not None:
_osd_overlay_clear(client)
_osd_clear(client)
try:
_osd_restore_style(client)
except Exception:
pass
last_loaded_key = None
last_loaded_mode = None
if last_loaded_sub_path is not None:
@@ -962,6 +1135,22 @@ def run_auto_overlay(
time.sleep(poll_s)
continue
if store_override and (not hash_override or hash_override == current_file_hash):
try:
from Store import Store as StoreRegistry
reg = StoreRegistry(cfg, suppress_debug=True)
current_backend = reg[store_override]
current_store_name = store_override
current_key = f"{current_store_name}:{current_file_hash}"
_log(
f"Resolved via mpv override store={current_store_name!r} hash={current_file_hash!r} valid=True"
)
except Exception:
current_backend = None
current_store_name = None
current_key = None
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)
@@ -977,7 +1166,11 @@ def run_auto_overlay(
entries = []
times = []
if last_loaded_key is not None:
_osd_overlay_clear(client)
_osd_clear(client)
try:
_osd_restore_style(client)
except Exception:
pass
last_loaded_key = None
last_loaded_mode = None
if last_loaded_sub_path is not None:
@@ -1002,7 +1195,11 @@ def run_auto_overlay(
entries = []
times = []
if last_loaded_key is not None:
_osd_overlay_clear(client)
_osd_clear(client)
try:
_osd_restore_style(client)
except Exception:
pass
last_loaded_key = None
last_loaded_mode = None
if last_loaded_sub_path is not None:
@@ -1026,7 +1223,11 @@ def run_auto_overlay(
entries = []
times = []
if last_loaded_key is not None:
_osd_overlay_clear(client)
_osd_clear(client)
try:
_osd_restore_style(client)
except Exception:
pass
last_loaded_key = None
last_loaded_mode = None
if last_loaded_sub_path is not None:
@@ -1042,15 +1243,16 @@ def run_auto_overlay(
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,
)
current_key = (
f"{current_store_name}:{current_file_hash}"
if current_store_name and current_file_hash else None
)
if not current_key or not current_backend:
current_store_name, current_backend = _resolve_store_backend_for_target(
target=target,
file_hash=current_file_hash,
config=cfg,
)
current_key = (
f"{current_store_name}:{current_file_hash}"
if current_store_name and current_file_hash else None
)
_log(
f"Resolved store={current_store_name!r} hash={current_file_hash!r} valid={bool(current_key)}"
@@ -1063,7 +1265,11 @@ def run_auto_overlay(
entries = []
times = []
if last_loaded_key is not None:
_osd_overlay_clear(client)
_osd_clear(client)
try:
_osd_restore_style(client)
except Exception:
pass
last_loaded_key = None
last_loaded_mode = None
if last_loaded_sub_path is not None:
@@ -1072,6 +1278,29 @@ def run_auto_overlay(
time.sleep(poll_s)
continue
# Late-arriving context fallback: if we still don't have a store/backend for a
# local file, but `.mpv` has since populated user-data overrides, apply them
# without requiring a track change.
if (not is_http) and target and (not current_key or not current_backend):
try:
current_file_hash = _infer_hash_for_target(target) or current_file_hash
except Exception:
pass
if (store_override and current_file_hash and (not hash_override or hash_override == current_file_hash)):
try:
from Store import Store as StoreRegistry
reg = StoreRegistry(cfg, suppress_debug=True)
current_backend = reg[store_override]
current_store_name = store_override
current_key = f"{current_store_name}:{current_file_hash}"
_log(
f"Resolved via mpv override store={current_store_name!r} hash={current_file_hash!r} valid=True"
)
except Exception:
pass
# 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.
if (current_key and current_key != last_loaded_key and current_store_name
@@ -1097,7 +1326,11 @@ def run_auto_overlay(
if sub_text:
# Treat subtitles as an alternative to lyrics; do not show the lyric overlay.
try:
_osd_overlay_clear(client)
_osd_clear(client)
except Exception:
pass
try:
_osd_restore_style(client)
except Exception:
pass
@@ -1194,30 +1427,56 @@ def run_auto_overlay(
entries = []
times = []
if last_loaded_key is not None:
_osd_overlay_clear(client)
_osd_clear(client)
try:
_osd_restore_style(client)
except Exception:
pass
last_loaded_key = None
last_loaded_mode = None
else:
_log(f"Loaded lyric note ({len(lrc_text)} chars)")
if not lrc_text:
# No lyric note, and we didn't run autofetch this tick.
# Clear any previous overlay and avoid crashing on None.
try:
_osd_clear(client)
except Exception:
pass
try:
_osd_restore_style(client)
except Exception:
pass
entries = []
times = []
last_loaded_key = current_key
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"
parsed = parse_lrc(lrc_text)
entries = parsed
times = [e.time_s for e in entries]
last_loaded_key = current_key
last_loaded_mode = "lyric"
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)
_osd_clear(client)
except Exception:
pass
try:
_osd_restore_style(client)
except Exception:
pass
try:
client.disconnect()
except Exception:
pass
_OSD_STYLE_SAVED = None
_OSD_STYLE_APPLIED = False
if not client.connect():
_log("mpv IPC disconnected; exiting MPV.lyric")
return 4
@@ -1229,10 +1488,34 @@ def run_auto_overlay(
continue
if not entries:
# Nothing to show; ensure any previous text is cleared.
if last_text is not None:
try:
_osd_clear(client)
except Exception:
pass
try:
_osd_restore_style(client)
except Exception:
pass
last_text = None
last_idx = None
time.sleep(poll_s)
continue
if not visible:
# User toggled lyrics off.
if last_text is not None:
try:
_osd_clear(client)
except Exception:
pass
try:
_osd_restore_style(client)
except Exception:
pass
last_text = None
last_idx = None
time.sleep(poll_s)
continue
@@ -1244,8 +1527,23 @@ def run_auto_overlay(
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))
try:
if last_loaded_mode == "lyric":
_osd_apply_lyric_style(client, config=cfg)
except Exception:
pass
# Show until the next lyric timestamp (or a sane max) to avoid flicker.
dur_ms = 1200
try:
if idx + 1 < len(times):
nxt = float(times[idx + 1])
cur = float(t)
dur_ms = int(max(250, min(8000, (nxt - cur) * 1000)))
except Exception:
dur_ms = 1200
resp = _osd_set_text(client, line.text, duration_ms=dur_ms)
if resp is None:
client.disconnect()
if not client.connect():
@@ -1253,7 +1551,7 @@ def run_auto_overlay(
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}")
_log(f"mpv show-text returned error={resp.get('error')!r}")
except Exception:
pass
last_idx = idx
@@ -1285,7 +1583,7 @@ def run_overlay(*, mpv: MPV, entries: List[LrcLine], poll_s: float = 0.15) -> in
t = _ipc_get_property(client, "time-pos", None, raise_on_disconnect=True)
except ConnectionError:
try:
_osd_overlay_clear(client)
_osd_clear(client)
except Exception:
pass
try:
@@ -1311,8 +1609,17 @@ def run_overlay(*, mpv: MPV, entries: List[LrcLine], poll_s: float = 0.15) -> in
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))
# Show until the next lyric timestamp (or a sane max) to avoid flicker.
dur_ms = 1200
try:
if idx + 1 < len(times):
nxt = float(times[idx + 1])
cur = float(t)
dur_ms = int(max(250, min(8000, (nxt - cur) * 1000)))
except Exception:
dur_ms = 1200
resp = _osd_set_text(client, line.text, duration_ms=dur_ms)
if resp is None:
client.disconnect()
if not client.connect():
@@ -1320,7 +1627,7 @@ def run_overlay(*, mpv: MPV, entries: List[LrcLine], poll_s: float = 0.15) -> in
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}")
_log(f"mpv show-text returned error={resp.get('error')!r}")
except Exception:
pass
last_idx = idx