fix lyrics

This commit is contained in:
2026-03-16 02:57:00 -07:00
parent 4dd7556e85
commit 11f03cae3e
3 changed files with 364 additions and 15 deletions

View File

@@ -26,6 +26,7 @@ from __future__ import annotations
import argparse
import bisect
import hashlib
import json
import os
import re
import sys
@@ -66,6 +67,10 @@ _ITEM_HASH_PROP = "user-data/medeia-item-hash"
_OSD_STYLE_SAVED: Optional[Dict[str, Any]] = None
_OSD_STYLE_APPLIED: bool = False
_NOTES_CACHE_VERSION = 1
_DEFAULT_NOTES_CACHE_TTL_S = 900.0
_DEFAULT_NOTES_CACHE_WAIT_S = 1.5
_DEFAULT_NOTES_PENDING_WAIT_S = 12.0
def _single_instance_lock_path(ipc_path: str) -> Path:
@@ -571,6 +576,204 @@ def _load_config_best_effort() -> dict:
return {}
def _cache_float_config(config: Optional[dict], key: str, default: float) -> float:
try:
raw = (config or {}).get(key)
if raw is None:
return float(default)
value = float(raw)
if value < 0:
return 0.0
return value
except Exception:
return float(default)
def _notes_cache_root() -> Path:
root = Path(tempfile.gettempdir()) / "medeia-mpv-notes" / "cache"
root.mkdir(parents=True, exist_ok=True)
return root
def _notes_cache_key(store: str, file_hash: str) -> str:
return hashlib.sha1(
f"{str(store or '').strip().lower()}:{str(file_hash or '').strip().lower()}".encode(
"utf-8",
errors="ignore",
)
).hexdigest()
def _notes_cache_path(store: str, file_hash: str) -> Path:
return (_notes_cache_root() / f"notes-{_notes_cache_key(store, file_hash)}.json").resolve()
def _notes_pending_path(store: str, file_hash: str) -> Path:
return (_notes_cache_root() / f"notes-{_notes_cache_key(store, file_hash)}.pending").resolve()
def _normalize_notes_payload(notes: Any) -> Dict[str, str]:
if not isinstance(notes, dict):
return {}
return {
str(k): str(v or "")
for k, v in notes.items()
if str(k).strip()
}
def load_cached_notes(
store: Optional[str],
file_hash: Optional[str],
*,
config: Optional[dict] = None,
) -> Optional[Dict[str, str]]:
if not store or not file_hash:
return None
path = _notes_cache_path(str(store), str(file_hash))
if not path.exists():
return None
ttl_s = _cache_float_config(config, "lyric_notes_cache_ttl_seconds", _DEFAULT_NOTES_CACHE_TTL_S)
if ttl_s > 0:
try:
age_s = max(0.0, time.time() - float(path.stat().st_mtime))
if age_s > ttl_s:
return None
except Exception:
return None
try:
payload = json.loads(path.read_text(encoding="utf-8", errors="replace"))
except Exception:
return None
if not isinstance(payload, dict):
return None
if int(payload.get("version") or 0) != _NOTES_CACHE_VERSION:
return None
return _normalize_notes_payload(payload.get("notes"))
def store_cached_notes(
store: Optional[str],
file_hash: Optional[str],
notes: Any,
) -> bool:
if not store or not file_hash:
return False
normalized = _normalize_notes_payload(notes)
path = _notes_cache_path(str(store), str(file_hash))
tmp_path = path.with_suffix(".tmp")
payload = {
"version": _NOTES_CACHE_VERSION,
"saved_at": time.time(),
"store": str(store),
"hash": str(file_hash),
"notes": normalized,
}
try:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8",
errors="replace",
)
tmp_path.replace(path)
return True
except Exception:
return False
def set_notes_prefetch_pending(
store: Optional[str],
file_hash: Optional[str],
pending: bool,
) -> None:
if not store or not file_hash:
return
path = _notes_pending_path(str(store), str(file_hash))
if pending:
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(str(time.time()), encoding="utf-8", errors="replace")
except Exception:
return
return
try:
if path.exists():
path.unlink()
except Exception:
return
def is_notes_prefetch_pending(
store: Optional[str],
file_hash: Optional[str],
*,
stale_after_s: float = 60.0,
) -> bool:
if not store or not file_hash:
return False
path = _notes_pending_path(str(store), str(file_hash))
if not path.exists():
return False
try:
age_s = max(0.0, time.time() - float(path.stat().st_mtime))
if stale_after_s > 0 and age_s > stale_after_s:
path.unlink(missing_ok=True)
return False
except Exception:
return False
return True
def _infer_artist_title_from_mpv(client: MPVIPCClient) -> tuple[Optional[str], Optional[str]]:
artist = None
title = None
artist_keys = [
"metadata/by-key/artist",
"metadata/by-key/Artist",
"metadata/by-key/album_artist",
"metadata/by-key/ALBUMARTIST",
]
title_keys = [
"metadata/by-key/title",
"metadata/by-key/Title",
"media-title",
]
for key in artist_keys:
try:
value = _ipc_get_property(client, key, None)
except Exception:
value = None
artist = _sanitize_query(str(value) if isinstance(value, str) else None)
if artist:
break
for key in title_keys:
try:
value = _ipc_get_property(client, key, None)
except Exception:
value = None
title = _sanitize_query(str(value) if isinstance(value, str) else None)
if title:
break
return artist, title
def _extract_note_text(notes: Dict[str, str], name: str) -> Optional[str]:
"""Return stripped text from the note named *name*, or None if absent or blank."""
if not isinstance(notes, dict) or not notes:
@@ -874,6 +1077,8 @@ class _PlaybackState:
last_target: Optional[str] = None
fetch_attempt_key: Optional[str] = None
fetch_attempt_at: float = 0.0
cache_wait_key: Optional[str] = None
cache_wait_started_at: float = 0.0
def clear(self, client: MPVIPCClient, *, clear_hash: bool = True) -> None:
"""Reset backend resolution and clean up any active OSD / external subtitle.
@@ -889,6 +1094,8 @@ class _PlaybackState:
self.file_hash = None
self.entries = []
self.times = []
self.cache_wait_key = None
self.cache_wait_started_at = 0.0
if self.loaded_key is not None:
_osd_clear_and_restore(client)
self.loaded_key = None
@@ -1035,6 +1242,8 @@ def run_auto_overlay(
state.store_name = None
state.backend = None
state.key = None
state.cache_wait_key = None
state.cache_wait_started_at = 0.0
if store_override and (not hash_override or hash_override == state.file_hash):
reg = _make_registry()
@@ -1159,11 +1368,51 @@ def run_auto_overlay(
and state.file_hash
and state.backend
):
notes: Dict[str, str] = {}
notes: Optional[Dict[str, str]] = None
cache_wait_s = _cache_float_config(
cfg,
"lyric_notes_cache_wait_seconds",
_DEFAULT_NOTES_CACHE_WAIT_S,
)
pending_wait_s = _cache_float_config(
cfg,
"lyric_notes_pending_wait_seconds",
_DEFAULT_NOTES_PENDING_WAIT_S,
)
try:
notes = state.backend.get_note(state.file_hash, config=cfg) or {}
notes = load_cached_notes(state.store_name, state.file_hash, config=cfg)
except Exception:
notes = {}
notes = None
if notes is None:
now = time.time()
if state.cache_wait_key != state.key:
state.cache_wait_key = state.key
state.cache_wait_started_at = now
pending = is_notes_prefetch_pending(state.store_name, state.file_hash)
waited_s = max(0.0, now - float(state.cache_wait_started_at or now))
if pending and waited_s < pending_wait_s:
time.sleep(min(max(poll_s, 0.05), 0.2))
continue
if waited_s < cache_wait_s:
time.sleep(min(max(poll_s, 0.05), 0.2))
continue
try:
notes = state.backend.get_note(state.file_hash, config=cfg) or {}
except Exception:
notes = {}
try:
store_cached_notes(state.store_name, state.file_hash, notes)
except Exception:
pass
state.cache_wait_key = None
state.cache_wait_started_at = 0.0
try:
_log(
@@ -1217,21 +1466,21 @@ def run_auto_overlay(
state.fetch_attempt_key = state.key
state.fetch_attempt_at = now
artist: Optional[str] = None
title: Optional[str] = None
artist, title = _infer_artist_title_from_mpv(client)
duration_s: Optional[float] = None
try:
duration_s = _ipc_get_property(client, "duration", None)
except Exception:
pass
try:
tags, _src = state.backend.get_tag(state.file_hash, config=cfg)
if isinstance(tags, list):
artist, title = _infer_artist_title_from_tags(
[str(x) for x in tags]
)
except Exception:
pass
if not artist or not title:
try:
tags, _src = state.backend.get_tag(state.file_hash, config=cfg)
if isinstance(tags, list):
artist, title = _infer_artist_title_from_tags(
[str(x) for x in tags]
)
except Exception:
pass
_log(
f"Autofetch query artist={artist!r} title={title!r}"