This commit is contained in:
nose
2025-12-23 16:36:39 -08:00
parent 16316bb3fd
commit 8bf04c6b71
25 changed files with 3165 additions and 234 deletions

View File

@@ -467,6 +467,66 @@ def _extract_lrc_from_notes(notes: Dict[str, str]) -> Optional[str]:
return text if text.strip() else None
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)
digest = hashlib.sha1((key + "\n" + (text or "")).encode("utf-8", errors="ignore")).hexdigest()[:16]
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
def _is_stream_target(target: str) -> bool:
"""Return True when mpv's 'path' is not a local filesystem file.
@@ -726,7 +786,7 @@ def _infer_hash_for_target(target: str) -> Optional[str]:
def run_auto_overlay(*, mpv: MPV, poll_s: float = 0.15, config: Optional[dict] = None) -> int:
"""Auto mode: track mpv's current file and render lyrics from store notes (note name: 'lyric')."""
"""Auto mode: track mpv's current file and render lyrics (note: 'lyric') or load subtitles (note: 'sub')."""
cfg = config or {}
client = mpv.client()
@@ -742,6 +802,8 @@ def run_auto_overlay(*, mpv: MPV, poll_s: float = 0.15, config: Optional[dict] =
current_key: Optional[str] = None
current_backend: Optional[Any] = None
last_loaded_key: Optional[str] = None
last_loaded_mode: Optional[str] = None # 'lyric' | 'sub'
last_loaded_sub_path: Optional[Path] = None
last_fetch_attempt_key: Optional[str] = None
last_fetch_attempt_at: float = 0.0
@@ -808,6 +870,9 @@ def run_auto_overlay(*, mpv: MPV, poll_s: float = 0.15, config: Optional[dict] =
_osd_overlay_clear(client)
except Exception:
pass
if last_loaded_sub_path is not None:
_try_remove_selected_external_sub(client)
last_loaded_sub_path = None
last_target = target
current_store_name = None
current_file_hash = None
@@ -816,6 +881,7 @@ def run_auto_overlay(*, mpv: MPV, poll_s: float = 0.15, config: Optional[dict] =
entries = []
times = []
last_loaded_key = None
last_loaded_mode = None
time.sleep(poll_s)
continue
@@ -833,6 +899,10 @@ def run_auto_overlay(*, mpv: MPV, poll_s: float = 0.15, config: Optional[dict] =
if last_loaded_key is not None:
_osd_overlay_clear(client)
last_loaded_key = None
last_loaded_mode = None
if last_loaded_sub_path is not None:
_try_remove_selected_external_sub(client)
last_loaded_sub_path = None
time.sleep(poll_s)
continue
@@ -850,6 +920,10 @@ def run_auto_overlay(*, mpv: MPV, poll_s: float = 0.15, config: Optional[dict] =
if last_loaded_key is not None:
_osd_overlay_clear(client)
last_loaded_key = None
last_loaded_mode = None
if last_loaded_sub_path is not None:
_try_remove_selected_external_sub(client)
last_loaded_sub_path = None
time.sleep(poll_s)
continue
@@ -869,6 +943,10 @@ def run_auto_overlay(*, mpv: MPV, poll_s: float = 0.15, config: Optional[dict] =
if last_loaded_key is not None:
_osd_overlay_clear(client)
last_loaded_key = None
last_loaded_mode = None
if last_loaded_sub_path is not None:
_try_remove_selected_external_sub(client)
last_loaded_sub_path = None
time.sleep(poll_s)
continue
@@ -887,6 +965,10 @@ def run_auto_overlay(*, mpv: MPV, poll_s: float = 0.15, config: Optional[dict] =
if last_loaded_key is not None:
_osd_overlay_clear(client)
last_loaded_key = None
last_loaded_mode = None
if last_loaded_sub_path is not None:
_try_remove_selected_external_sub(client)
last_loaded_sub_path = None
time.sleep(poll_s)
continue
@@ -913,6 +995,10 @@ def run_auto_overlay(*, mpv: MPV, poll_s: float = 0.15, config: Optional[dict] =
if last_loaded_key is not None:
_osd_overlay_clear(client)
last_loaded_key = None
last_loaded_mode = None
if last_loaded_sub_path is not None:
_try_remove_selected_external_sub(client)
last_loaded_sub_path = None
time.sleep(poll_s)
continue
@@ -930,9 +1016,41 @@ def run_auto_overlay(*, mpv: MPV, poll_s: float = 0.15, config: Optional[dict] =
except Exception:
_log("Loaded notes keys: <error>")
lrc_text = _extract_lrc_from_notes(notes)
if not lrc_text:
_log("No lyric note found (note name: 'lyric')")
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')")
# Auto-fetch path: fetch and persist lyrics into the note named 'lyric'.
# Throttle attempts per key to avoid hammering APIs.
@@ -981,18 +1099,20 @@ def run_auto_overlay(*, mpv: MPV, poll_s: float = 0.15, config: Optional[dict] =
else:
_log("Autofetch: no lyrics found")
entries = []
times = []
if last_loaded_key is not None:
_osd_overlay_clear(client)
last_loaded_key = None
else:
_log(f"Loaded lyric note ({len(lrc_text)} chars)")
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
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.