From 11f03cae3e74011da1bbe67d4885ba0d518e0c05 Mon Sep 17 00:00:00 2001 From: Nose Date: Mon, 16 Mar 2026 02:57:00 -0700 Subject: [PATCH] fix lyrics --- API/data/alldebrid.json | 4 +- MPV/lyric.py | 275 ++++++++++++++++++++++++++++++++++++++-- cmdnat/pipe.py | 100 +++++++++++++++ 3 files changed, 364 insertions(+), 15 deletions(-) diff --git a/API/data/alldebrid.json b/API/data/alldebrid.json index 52119c2..6034744 100644 --- a/API/data/alldebrid.json +++ b/API/data/alldebrid.json @@ -92,7 +92,7 @@ "(hitfile\\.net/[a-z0-9A-Z]{4,9})" ], "regexp": "(hitf\\.(to|cc)/([a-z0-9A-Z]{4,9}))|(htfl\\.(net|to|cc)/([a-z0-9A-Z]{4,9}))|(hitfile\\.(net)/download/free/([a-z0-9A-Z]{4,9}))|((hitfile\\.net/[a-z0-9A-Z]{4,9}))", - "status": false + "status": true }, "mega": { "name": "mega", @@ -595,7 +595,7 @@ "(simfileshare\\.net/download/[0-9]+/)" ], "regexp": "(simfileshare\\.net/download/[0-9]+/)", - "status": true + "status": false }, "streamtape": { "name": "streamtape", diff --git a/MPV/lyric.py b/MPV/lyric.py index 59e9dfd..b508263 100644 --- a/MPV/lyric.py +++ b/MPV/lyric.py @@ -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}" diff --git a/cmdnat/pipe.py b/cmdnat/pipe.py index fc96352..7c53747 100644 --- a/cmdnat/pipe.py +++ b/cmdnat/pipe.py @@ -5,6 +5,7 @@ import sys import json import socket import re +import threading from datetime import datetime, timedelta from urllib.parse import urlparse, parse_qs from pathlib import Path @@ -20,6 +21,8 @@ from SYS.config import get_hydrus_access_key, get_hydrus_url _ALLDEBRID_UNLOCK_CACHE: Dict[str, str] = {} +_NOTES_PREFETCH_INFLIGHT: set[str] = set() +_NOTES_PREFETCH_LOCK = threading.Lock() def _repo_root() -> Path: @@ -389,6 +392,99 @@ def _set_mpv_item_context(store: Optional[str], file_hash: Optional[str]) -> Non pass +def _get_lyric_prefetch_limit(config: Optional[Dict[str, Any]]) -> int: + try: + raw = (config or {}).get("lyric_prefetch_limit") + if raw is None: + return 5 + value = int(raw) + except Exception: + return 5 + return max(0, min(20, value)) + + +def _prefetch_notes_async( + store: Optional[str], + file_hash: Optional[str], + config: Optional[Dict[str, Any]], +) -> None: + if not store or not file_hash: + return + + key = f"{str(store).strip().lower()}:{str(file_hash).strip().lower()}" + with _NOTES_PREFETCH_LOCK: + if key in _NOTES_PREFETCH_INFLIGHT: + return + _NOTES_PREFETCH_INFLIGHT.add(key) + + cfg = dict(config or {}) + + def _worker() -> None: + try: + from MPV.lyric import ( + load_cached_notes, + set_notes_prefetch_pending, + store_cached_notes, + ) + from Store import Store + + cached = load_cached_notes(store, file_hash, config=cfg) + if cached is not None: + return + + set_notes_prefetch_pending(store, file_hash, True) + + registry = Store(cfg, suppress_debug=True) + backend = registry[str(store)] + notes = backend.get_note(str(file_hash), config=cfg) or {} + store_cached_notes(store, file_hash, notes) + try: + debug( + f"Prefetched MPV notes cache for {key} keys={sorted(str(k) for k in notes)}" + ) + except Exception: + debug(f"Prefetched MPV notes cache for {key}") + except Exception as exc: + debug(f"MPV note prefetch failed for {key}: {exc}", file=sys.stderr) + finally: + try: + from MPV.lyric import set_notes_prefetch_pending + + set_notes_prefetch_pending(store, file_hash, False) + except Exception: + pass + with _NOTES_PREFETCH_LOCK: + _NOTES_PREFETCH_INFLIGHT.discard(key) + + thread = threading.Thread( + target=_worker, + name=f"mpv-notes-prefetch-{file_hash[:8]}", + daemon=True, + ) + thread.start() + + +def _schedule_notes_prefetch(items: Sequence[Any], config: Optional[Dict[str, Any]]) -> None: + limit = _get_lyric_prefetch_limit(config) + if limit <= 0: + return + + seen: set[str] = set() + scheduled = 0 + for item in items or []: + store, file_hash = _extract_store_and_hash(item) + if not store or not file_hash: + continue + key = f"{store.lower()}:{file_hash}" + if key in seen: + continue + seen.add(key) + _prefetch_notes_async(store, file_hash, config) + scheduled += 1 + if scheduled >= limit: + break + + def _get_playlist(silent: bool = False) -> Optional[List[Dict[str, Any]]]: """Get the current playlist from MPV. Returns None if MPV is not running.""" cmd = { @@ -1143,6 +1239,8 @@ def _queue_items( except Exception as e: debug(f"Warning: Could not initialize Store registry: {e}", file=sys.stderr) + _schedule_notes_prefetch(items, config) + # Dedupe existing playlist before adding more (unless we're replacing it) existing_targets: set[str] = set() if not clear_first: @@ -2226,6 +2324,8 @@ def _start_mpv( hydrus_header = _build_hydrus_header(config or {}) ytdl_opts = _build_ytdl_options(config, hydrus_header) + _schedule_notes_prefetch(items[:1], config) + cookies_path = None try: from tool.ytdlp import YtDlpTool