diff --git a/API/data/alldebrid.json b/API/data/alldebrid.json index 2361a5d..3dbbdfb 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", @@ -482,7 +482,7 @@ "(katfile\\.com/[0-9a-zA-Z]{12})" ], "regexp": "(katfile\\.(cloud|online|vip)/([0-9a-zA-Z]{12}))|((katfile\\.com/[0-9a-zA-Z]{12}))", - "status": false + "status": true }, "mediafire": { "name": "mediafire", @@ -690,7 +690,7 @@ "uploadrar\\.(net|com)/([0-9a-z]{12})" ], "regexp": "((get|cloud)\\.rahim-soft\\.com/([0-9a-z]{12}))|((fingau\\.com/([0-9a-z]{12})))|((tech|miui|cloud|flash)\\.getpczone\\.com/([0-9a-z]{12}))|(miui.rahim-soft\\.com/([0-9a-z]{12}))|(uploadrar\\.(net|com)/([0-9a-z]{12}))", - "status": false, + "status": true, "hardRedirect": [ "uploadrar.com/([0-9a-zA-Z]{12})" ] @@ -775,7 +775,7 @@ "(worldbytez\\.(net|com)/[a-zA-Z0-9]{12})" ], "regexp": "(worldbytez\\.(net|com)/[a-zA-Z0-9]{12})", - "status": false + "status": true } }, "streams": { diff --git a/MPV/lyric.py b/MPV/lyric.py index 650b5db..be2da23 100644 --- a/MPV/lyric.py +++ b/MPV/lyric.py @@ -31,7 +31,7 @@ import re import sys import tempfile import time -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, TextIO @@ -293,7 +293,14 @@ def _osd_restore_style(client: MPVIPCClient) -> None: _OSD_STYLE_APPLIED = False -def _http_get_json(url: str, *, timeout_s: float = 10.0) -> Optional[dict]: +def _osd_clear_and_restore(client: MPVIPCClient) -> None: + """Clear OSD text and restore any saved OSD style in a single call.""" + _osd_clear(client) + _osd_restore_style(client) + + +def _http_get_json_raw(url: str, *, timeout_s: float = 10.0) -> Optional[Any]: + """HTTP GET and JSON-decode; returns the parsed value (dict, list, etc.) or None on any failure.""" try: req = Request( url, @@ -306,33 +313,22 @@ def _http_get_json(url: str, *, timeout_s: float = 10.0) -> Optional[dict]: 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 + return json.loads(data.decode("utf-8", errors="replace")) 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 +def _http_get_json(url: str, *, timeout_s: float = 10.0) -> Optional[dict]: + """HTTP GET returning a JSON object (dict), or None.""" + obj = _http_get_json_raw(url, timeout_s=timeout_s) + return obj if isinstance(obj, dict) else None - 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 _http_get_json_list(url: str, *, timeout_s: float = 10.0) -> Optional[list]: + """HTTP GET returning a JSON array (list), or None.""" + obj = _http_get_json_raw(url, timeout_s=timeout_s) + return obj if isinstance(obj, list) else None def _sanitize_query(s: Optional[str]) -> Optional[str]: @@ -454,10 +450,6 @@ def _fetch_lyrics_ovh(*, artist: Optional[str], title: Optional[str]) -> Optiona except Exception as exc: _log(f"lyrics.ovh failed: {exc}") return None - try: - print(line, file=sys.stderr) - except Exception: - pass @dataclass(frozen=True) @@ -532,6 +524,16 @@ def _current_index(time_s: float, times: List[float]) -> int: return bisect.bisect_right(times, time_s) - 1 +def _lyric_duration_ms(idx: int, times: List[float], current_t: float) -> int: + """Duration in ms to display the lyric at *idx* — until the next timestamp or a safe maximum.""" + try: + if idx + 1 < len(times): + return int(max(250, min(8000, (times[idx + 1] - current_t) * 1000))) + except Exception: + pass + return 1200 + + 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://"): @@ -569,44 +571,29 @@ def _load_config_best_effort() -> dict: return {} -def _extract_lrc_from_notes(notes: Dict[str, str]) -> Optional[str]: - """Return raw LRC text from the note named 'lyric'.""" +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: return None - raw = None for k, v in notes.items(): - if not isinstance(k, str): - continue - if k.strip() == "lyric": + if isinstance(k, str) and k.strip() == name: raw = v break - if not isinstance(raw, str): return None - text = raw.strip("\ufeff\r\n") return text if text.strip() else None +def _extract_lrc_from_notes(notes: Dict[str, str]) -> Optional[str]: + """Return raw LRC text from the note named 'lyric'.""" + return _extract_note_text(notes, "lyric") + + 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 + return _extract_note_text(notes, "sub") def _infer_sub_extension(text: str) -> str: @@ -866,13 +853,62 @@ def _infer_hash_for_target(target: str) -> Optional[str]: return None +@dataclass +class _PlaybackState: + """Mutable per-track resolution state for the auto overlay loop. + + Centralising these variables in one object eliminates the repeated + 15-line 'reset everything + clear OSD + remove sub' block that + previously appeared five times inside run_auto_overlay. + """ + + store_name: Optional[str] = None + file_hash: Optional[str] = None + key: Optional[str] = None + backend: Optional[Any] = None + entries: List[LrcLine] = field(default_factory=list) + times: List[float] = field(default_factory=list) + loaded_key: Optional[str] = None + loaded_mode: Optional[str] = None # 'lyric' | 'sub' | None + loaded_sub_path: Optional[Path] = None + last_target: Optional[str] = None + fetch_attempt_key: Optional[str] = None + fetch_attempt_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. + + Pass ``clear_hash=False`` to preserve *file_hash* when the hash is + still valid but the store lookup failed (e.g. store temporarily + unavailable), so the late-arriving-context fallback can retry later. + """ + self.store_name = None + self.backend = None + self.key = None + if clear_hash: + self.file_hash = None + self.entries = [] + self.times = [] + if self.loaded_key is not None: + _osd_clear_and_restore(client) + self.loaded_key = None + self.loaded_mode = None + if self.loaded_sub_path is not None: + _try_remove_selected_external_sub(client) + self.loaded_sub_path = None + + 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 (note: 'lyric') or load subtitles (note: 'sub').""" + """Auto mode: track mpv's current file and render lyrics (note: 'lyric') or subtitles (note: 'sub'). + + State is managed via :class:`_PlaybackState` to eliminate the repeated + 15-line reset blocks of the previous implementation. + """ cfg = config or {} client = mpv.client() @@ -882,49 +918,43 @@ def run_auto_overlay( _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 - 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 - - entries: List[LrcLine] = [] - times: List[float] = [] + state = _PlaybackState() last_idx: Optional[int] = None last_text: Optional[str] = None last_visible: Optional[bool] = None global _OSD_STYLE_SAVED, _OSD_STYLE_APPLIED - while True: + # Import the Store registry once so each track change doesn't re-import the module. + try: + from Store import Store as _StoreRegistry # noqa: PLC0415 + _store_cls: Any = _StoreRegistry + except Exception: + _store_cls = None + + def _make_registry() -> Optional[Any]: + if _store_cls is None: + return None + try: + return _store_cls(cfg, suppress_debug=True) + except Exception: + return None + + while True: + # ---------------------------------------------------------------- + # 1. Read IPC properties; reconnect on disconnect. + # ---------------------------------------------------------------- try: - # Toggle support (mpv Lua script sets this property; default to visible). visible_raw = _ipc_get_property( - client, - _LYRIC_VISIBLE_PROP, - True, - raise_on_disconnect=True + client, _LYRIC_VISIBLE_PROP, True, raise_on_disconnect=True ) raw_path = _ipc_get_property(client, "path", None, raise_on_disconnect=True) except ConnectionError: - try: - _osd_clear(client) - except Exception: - pass - try: - _osd_restore_style(client) - except Exception: - pass + _osd_clear_and_restore(client) 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(): @@ -933,40 +963,29 @@ def run_auto_overlay( time.sleep(poll_s) continue + # ---------------------------------------------------------------- + # 2. Visibility toggle support. + # ---------------------------------------------------------------- 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_clear(client) - except Exception: - pass - try: - _osd_restore_style(client) - except Exception: - pass - # 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 + _osd_clear_and_restore(client) + _try_remove_selected_external_sub(client) 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 + # ---------------------------------------------------------------- + # 3. Normalise the current playback target. + # ---------------------------------------------------------------- + target = _unwrap_memory_m3u(str(raw_path)) if isinstance(raw_path, str) else None if isinstance(target, str): target = _normalize_file_uri_target(target) @@ -975,415 +994,305 @@ def run_auto_overlay( continue is_http = target.startswith("http://") or target.startswith("https://") + + # Non-HTTP streams (ytdl://, edl://, rtmp://, etc.) are never valid for lyrics. 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_clear(client) - except Exception: - pass - try: - _osd_restore_style(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 - current_key = None - current_backend = None - entries = [] - times = [] - last_loaded_key = None - last_loaded_mode = None + state.clear(client) + state.last_target = target 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 + # ---------------------------------------------------------------- + # 4. Read user-data overrides from the playlist controller. + # ---------------------------------------------------------------- + store_override: Optional[str] = None + hash_override: Optional[str] = None try: - store_override = _ipc_get_property(client, _ITEM_STORE_PROP, None) - hash_override = _ipc_get_property(client, _ITEM_HASH_PROP, None) + raw_so = _ipc_get_property(client, _ITEM_STORE_PROP, None) + raw_ho = _ipc_get_property(client, _ITEM_HASH_PROP, None) + store_override = str(raw_so).strip() if raw_so else None + hash_override = str(raw_ho).strip().lower() if raw_ho else None except Exception: - store_override = None - hash_override = None + pass - 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 + # ---------------------------------------------------------------- + # 5. Resolve store / hash on target change. + # ---------------------------------------------------------------- + if target != state.last_target: + state.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_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: - _try_remove_selected_external_sub(client) - last_loaded_sub_path = None + state.file_hash = _infer_hash_for_target(target) + if not state.file_hash: + state.clear(client, clear_hash=False) 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 + # Reset backend state; user-data override may supply it right away. + state.store_name = None + state.backend = None + state.key = None - 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 store_override and (not hash_override or hash_override == state.file_hash): + reg = _make_registry() + if reg is not None: + try: + state.backend = reg[store_override] + state.store_name = store_override + state.key = f"{state.store_name}:{state.file_hash}" + _log( + f"Resolved via mpv override" + f" store={state.store_name!r} hash={state.file_hash!r} valid=True" + ) + except Exception: + state.backend = None + state.store_name = None + state.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) store_name = store_from_url or _infer_hydrus_store_from_url_target( - target=target, - config=cfg + target=target, config=cfg ) 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_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: - _try_remove_selected_external_sub(client) - last_loaded_sub_path = None + state.clear(client, clear_hash=False) + time.sleep(poll_s) + continue + + reg = _make_registry() + if reg is None: + _log(f"HTTP target store {store_name!r} not available; lyrics disabled") + state.clear(client, clear_hash=False) 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 + state.backend = reg[store_name] + state.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_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: - _try_remove_selected_external_sub(client) - last_loaded_sub_path = None + _log(f"HTTP target store {store_name!r} not available; lyrics disabled") + state.clear(client, clear_hash=False) 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: - _log( - f"HTTP target not found in store DB (store={store_name!r} hash={current_file_hash}); lyrics disabled" - ) - current_store_name = None - current_backend = None - current_key = None - entries = [] - times = [] - if last_loaded_key is not None: - _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: - _try_remove_selected_external_sub(client) - last_loaded_sub_path = None - time.sleep(poll_s) - continue + # Existence check only when store was inferred (not explicit in ?store=…). + # When ?store= is in the URL mpv is already streaming — the file provably exists. + if not store_from_url: + try: + meta = state.backend.get_metadata(state.file_hash, config=cfg) + except Exception: + meta = None + if meta is None: + _log( + f"HTTP target not found in store DB" + f" (store={store_name!r} hash={state.file_hash}); lyrics disabled" + ) + state.clear(client, clear_hash=False) + 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" - ) + state.key = f"{state.store_name}:{state.file_hash}" + _log(f"Resolved store={state.store_name!r} hash={state.file_hash!r} valid=True") else: - # Local files: resolve store item via store DB. If not resolvable, lyrics are disabled. - if not current_key or not current_backend: - current_store_name, current_backend = _resolve_store_backend_for_target( + # Local file: resolve via store DB (skip if user-data already resolved it). + if not state.key or not state.backend: + state.store_name, state.backend = _resolve_store_backend_for_target( target=target, - file_hash=current_file_hash, + file_hash=state.file_hash, config=cfg, ) - current_key = ( - f"{current_store_name}:{current_file_hash}" - if current_store_name and current_file_hash else None + state.key = ( + f"{state.store_name}:{state.file_hash}" + if state.store_name and state.file_hash else None ) _log( - f"Resolved store={current_store_name!r} hash={current_file_hash!r} valid={bool(current_key)}" + f"Resolved store={state.store_name!r} hash={state.file_hash!r}" + f" valid={bool(state.key)}" ) - 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_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: - _try_remove_selected_external_sub(client) - last_loaded_sub_path = None + if not state.key or not state.backend: + state.clear(client, clear_hash=False) 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): + # ---------------------------------------------------------------- + # 6. Late-arriving context fallback: user-data override published + # after the track change was already processed without a backend. + # ---------------------------------------------------------------- + if (not is_http) and target and (not state.key or not state.backend): try: - current_file_hash = _infer_hash_for_target(target) or current_file_hash + state.file_hash = _infer_hash_for_target(target) or state.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 + if ( + store_override + and state.file_hash + and (not hash_override or hash_override == state.file_hash) + ): + reg = _make_registry() + if reg is not None: + try: + state.backend = reg[store_override] + state.store_name = store_override + state.key = f"{state.store_name}:{state.file_hash}" + _log( + f"Resolved via mpv override" + f" store={state.store_name!r} hash={state.file_hash!r} valid=True" + ) + except Exception: + pass - 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 - and current_file_hash and current_backend): - notes: Dict[str, - str] = {} + # ---------------------------------------------------------------- + # 7. Load / reload content when the resolved key changes. + # ---------------------------------------------------------------- + if ( + state.key + and state.key != state.loaded_key + and state.store_name + and state.file_hash + and state.backend + ): + notes: Dict[str, str] = {} try: - notes = current_backend.get_note( - current_file_hash, - config=cfg - ) or {} + notes = state.backend.get_note(state.file_hash, config=cfg) or {} except Exception: notes = {} try: _log( - f"Loaded notes keys: {sorted([str(k) for k in notes.keys()]) if isinstance(notes, dict) else 'N/A'}" + f"Loaded notes keys:" + f" {sorted(str(k) for k in notes) if isinstance(notes, dict) else 'N/A'}" ) except Exception: _log("Loaded notes keys: ") - sub_text = _extract_sub_from_notes(notes) + sub_text = _extract_note_text(notes, "sub") if sub_text: - # Treat subtitles as an alternative to lyrics; do not show the lyric overlay. + # Hand subtitles to mpv's track subsystem; suppress OSD lyric overlay. + _osd_clear_and_restore(client) + sub_path: Optional[Path] = None try: - _osd_clear(client) - except Exception: - pass - try: - _osd_restore_style(client) - except Exception: - pass - - try: - sub_path = _write_temp_sub_file(key=current_key, text=sub_text) + sub_path = _write_temp_sub_file(key=state.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: + if state.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 + state.loaded_sub_path = sub_path - entries = [] - times = [] - last_loaded_key = current_key - last_loaded_mode = "sub" + state.entries = [] + state.times = [] + state.loaded_key = state.key + state.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: + # Switching away from sub mode: unload the external subtitle. + if state.loaded_mode == "sub" and state.loaded_sub_path is not None: _try_remove_selected_external_sub(client) - last_loaded_sub_path = None + state.loaded_sub_path = None - lrc_text = _extract_lrc_from_notes(notes) + lrc_text = _extract_note_text(notes, "lyric") 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. + # Auto-fetch: throttled per key to avoid hammering APIs. autofetch_enabled = bool(cfg.get("lyric_autofetch", True)) now = time.time() - if (autofetch_enabled and current_key != last_fetch_attempt_key - and (now - last_fetch_attempt_at) > 2.0): - last_fetch_attempt_key = current_key - last_fetch_attempt_at = now + if ( + autofetch_enabled + and state.key != state.fetch_attempt_key + and (now - state.fetch_attempt_at) > 2.0 + ): + state.fetch_attempt_key = state.key + state.fetch_attempt_at = now - artist = None - title = None - duration_s = None + artist: Optional[str] = None + title: Optional[str] = None + duration_s: Optional[float] = 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. + pass try: - tags, _src = current_backend.get_tag(current_file_hash, config=cfg) + 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]) + 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} duration={duration_s!r}" + f"Autofetch query artist={artist!r} title={title!r}" + f" duration={duration_s!r}" ) if not artist or not title: _log("Autofetch skipped: requires both artist and title") - fetched = None + fetched: Optional[str] = None else: fetched = _fetch_lrclib( artist=artist, title=title, duration_s=( float(duration_s) - if isinstance(duration_s, - (int, - float)) else None + if isinstance(duration_s, (int, float)) else None ), ) if not fetched or not fetched.strip(): fetched = _fetch_lyrics_ovh(artist=artist, title=title) + if fetched and fetched.strip(): try: ok = bool( - current_backend.set_note( - current_file_hash, - "lyric", - fetched, - config=cfg + state.backend.set_note( + state.file_hash, "lyric", fetched, config=cfg ) ) _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") - entries = [] - times = [] - if last_loaded_key is not None: - _osd_clear(client) - try: - _osd_restore_style(client) - except Exception: - pass - last_loaded_key = None - last_loaded_mode = None + state.entries = [] + state.times = [] + _osd_clear_and_restore(client) + state.loaded_key = None + state.loaded_mode = None + + elif not lrc_text: + # No lyric and autofetch is throttled or disabled for this key. + _osd_clear_and_restore(client) + state.entries = [] + state.times = [] + state.loaded_key = state.key + state.loaded_mode = None + else: - 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" + _log(f"Loaded lyric note ({len(lrc_text)} chars)") + parsed = parse_lrc(lrc_text) + state.entries = parsed + state.times = [e.time_s for e in parsed] + state.loaded_key = state.key + state.loaded_mode = "lyric" + # ---------------------------------------------------------------- + # 8. Render the current lyric line. + # ---------------------------------------------------------------- try: - # mpv returns None when idle/no file. t = _ipc_get_property(client, "time-pos", None, raise_on_disconnect=True) except ConnectionError: - try: - _osd_clear(client) - except Exception: - pass - try: - _osd_restore_style(client) - except Exception: - pass + _osd_clear_and_restore(client) try: client.disconnect() except Exception: @@ -1400,62 +1309,36 @@ def run_auto_overlay( time.sleep(poll_s) continue - if not entries: - # Nothing to show; ensure any previous text is cleared. + if not state.entries: if last_text is not None: - try: - _osd_clear(client) - except Exception: - pass - try: - _osd_restore_style(client) - except Exception: - pass + _osd_clear_and_restore(client) 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 + _osd_clear_and_restore(client) last_text = None last_idx = None time.sleep(poll_s) continue - idx = _current_index(float(t), times) - + idx = _current_index(float(t), state.times) if idx < 0: time.sleep(poll_s) continue - line = entries[idx] + line = state.entries[idx] if idx != last_idx or line.text != last_text: - try: - if last_loaded_mode == "lyric": + if state.loaded_mode == "lyric": + try: _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 + except Exception: + pass + dur_ms = _lyric_duration_ms(idx, state.times, float(t)) resp = _osd_set_text(client, line.text, duration_ms=dur_ms) if resp is None: client.disconnect() @@ -1463,16 +1346,12 @@ def run_auto_overlay( 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 show-text returned error={resp.get('error')!r}") - except Exception: - pass + _log(f"mpv show-text returned error={resp.get('error')!r}") 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) @@ -1486,7 +1365,7 @@ def run_overlay(*, mpv: MPV, entries: List[LrcLine], poll_s: float = 0.15) -> in if not client.connect(): print( "mpv IPC is not reachable (is mpv running with --input-ipc-server?).", - file=sys.stderr + file=sys.stderr, ) return 3 @@ -1495,10 +1374,7 @@ def run_overlay(*, mpv: MPV, entries: List[LrcLine], poll_s: float = 0.15) -> in # mpv returns None when idle/no file. t = _ipc_get_property(client, "time-pos", None, raise_on_disconnect=True) except ConnectionError: - try: - _osd_clear(client) - except Exception: - pass + _osd_clear(client) try: client.disconnect() except Exception: @@ -1514,7 +1390,6 @@ def run_overlay(*, mpv: MPV, entries: List[LrcLine], poll_s: float = 0.15) -> in continue idx = _current_index(float(t), times) - if idx < 0: # Before first lyric timestamp. time.sleep(poll_s) @@ -1522,16 +1397,7 @@ 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: - # 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 - + dur_ms = _lyric_duration_ms(idx, times, float(t)) resp = _osd_set_text(client, line.text, duration_ms=dur_ms) if resp is None: client.disconnect() @@ -1539,16 +1405,12 @@ def run_overlay(*, mpv: MPV, entries: List[LrcLine], poll_s: float = 0.15) -> in 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 show-text returned error={resp.get('error')!r}") - except Exception: - pass + _log(f"mpv show-text returned error={resp.get('error')!r}") 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( diff --git a/MPV/portable_config/script-opts/medeia.conf b/MPV/portable_config/script-opts/medeia.conf index 7606177..cb20eb2 100644 --- a/MPV/portable_config/script-opts/medeia.conf +++ b/MPV/portable_config/script-opts/medeia.conf @@ -1,2 +1,2 @@ # Medeia MPV script options -store=rpi +store= diff --git a/Provider/Tidal.py b/Provider/Tidal.py index 4aa8d73..abb8ca3 100644 --- a/Provider/Tidal.py +++ b/Provider/Tidal.py @@ -6,7 +6,7 @@ import subprocess import time import sys from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Callable, Dict, List, Optional, Tuple from urllib.parse import urlparse from API.Tidal import ( @@ -457,15 +457,32 @@ class Tidal(Provider): if idx >= len(parts): return "", None - view = parts[idx].lower() - if view not in {"album", "track", "artist"}: + # Scan ALL (view, id) pairs in the path, e.g. + # /album/634516/track/634519 → [("album", 634516), ("track", 634519)] + # When multiple views are present, prefer the more specific one: + # track > album > artist + _VIEW_PRIORITY = {"track": 2, "album": 1, "artist": 0} + _VALID_VIEWS = set(_VIEW_PRIORITY) + found: dict[str, int] = {} + i = idx + while i < len(parts): + v = parts[i].lower() + if v in _VALID_VIEWS: + # Look ahead for the first integer following this view keyword + for j in range(i + 1, len(parts)): + cand = self._parse_int(parts[j]) + if cand is not None: + found[v] = cand + i = j # advance past the id + break + i += 1 + + if not found: return "", None - for segment in parts[idx + 1:]: - identifier = self._parse_int(segment) - if identifier is not None: - return view, identifier - return view, None + # Return the highest-priority view that was found + best_view = max(found, key=lambda v: _VIEW_PRIORITY.get(v, -1)) + return best_view, found[best_view] def _track_detail_to_result(self, detail: Optional[Dict[str, Any]], track_id: int) -> SearchResult: if isinstance(detail, dict): @@ -700,7 +717,9 @@ class Tidal(Provider): def _tracks_for_album(self, *, album_id: Optional[int], album_title: str, artist_name: str = "", limit: int = 200) -> List[SearchResult]: title = str(album_title or "").strip() - if not title: + # When album_id is provided the /album/ endpoint can resolve tracks directly — + # no title is required. Only bail out early when we have neither. + if not title and not album_id: return [] def _norm_album(text: str) -> str: @@ -1351,6 +1370,9 @@ class Tidal(Provider): subtitles = lyrics.get("subtitles") if isinstance(subtitles, str) and subtitles.strip(): md["_tidal_lyrics_subtitles"] = subtitles.strip() + # Generic key consumed by download-file._emit_local_file to + # persist lyrics as a store note without provider-specific logic. + md["_notes"] = {"lyric": subtitles.strip()} # Ensure downstream cmdlets see our enriched metadata. try: @@ -1523,6 +1545,19 @@ class Tidal(Provider): if not identifier: return False, None + # In download-file flows, return a provider action so the cmdlet can + # invoke this provider's bulk download hook and emit each track. + if output_dir is not None: + return True, { + "action": "download_items", + "path": f"tidal://album/{identifier}", + "title": f"Album {identifier}", + "metadata": { + "album_id": identifier, + }, + "media_kind": "audio", + } + try: track_results = self._tracks_for_album( album_id=identifier, @@ -1562,6 +1597,76 @@ class Tidal(Provider): return False, None + def download_items( + self, + result: SearchResult, + output_dir: Path, + *, + emit: Callable[[Path, str, str, Dict[str, Any]], None], + progress: Any, + quiet_mode: bool, + path_from_result: Callable[[Any], Path], + config: Optional[Dict[str, Any]] = None, + ) -> int: + _ = progress + _ = quiet_mode + _ = path_from_result + _ = config + + metadata = getattr(result, "full_metadata", None) + md: Dict[str, Any] = dict(metadata) if isinstance(metadata, dict) else {} + + album_id = self._parse_int(md.get("album_id") or md.get("albumId") or md.get("id")) + album_title = stringify(md.get("album_title") or md.get("title") or md.get("album")) + + artist_name = stringify(md.get("artist_name") or md.get("_artist_name") or md.get("artist")) + if not artist_name: + artist_obj = md.get("artist") + if isinstance(artist_obj, dict): + artist_name = stringify(artist_obj.get("name")) + + path_text = stringify(getattr(result, "path", "")) + if path_text: + view, identifier = self._parse_tidal_url(path_text) + if view == "album" and not album_id: + album_id = identifier + + if not album_id: + return 0 + + try: + track_results = self._tracks_for_album( + album_id=album_id, + album_title=album_title, + artist_name=artist_name, + limit=500, + ) + except Exception: + return 0 + + if not track_results: + return 0 + + downloaded_count = 0 + for track_result in track_results: + try: + downloaded = self.download(track_result, output_dir) + except Exception: + downloaded = None + + if not downloaded: + continue + + tr_md_raw = getattr(track_result, "full_metadata", None) + tr_md = dict(tr_md_raw) if isinstance(tr_md_raw, dict) else {} + source = stringify(tr_md.get("url") or getattr(track_result, "path", "")) + relpath = str(downloaded.name) + + emit(downloaded, source, relpath, tr_md) + downloaded_count += 1 + + return downloaded_count + def _get_api_client_for_base(self, base_url: str) -> Optional[TidalApiClient]: base = base_url.rstrip("/") for client in self.api_clients: diff --git a/SYS/pipeline.py b/SYS/pipeline.py index b3505de..6f9fc43 100644 --- a/SYS/pipeline.py +++ b/SYS/pipeline.py @@ -1895,7 +1895,7 @@ class PipelineExecutor: if row_args: selected_row_args.extend(row_args) - if selected_row_args: + if selected_row_args and not stages: if isinstance(source_cmd, list): cmd_list: List[str] = [str(x) for x in source_cmd if x is not None] elif isinstance(source_cmd, str): @@ -1914,11 +1914,7 @@ class PipelineExecutor: # as the positional URL and avoid this class of parsing errors. expanded_stage: List[str] = cmd_list + selected_row_args + source_args - if first_stage_had_extra_args and stages: - expanded_stage += stages[0] - stages[0] = expanded_stage - else: - stages.insert(0, expanded_stage) + stages.insert(0, expanded_stage) if pipeline_session and worker_manager: try: @@ -1928,6 +1924,8 @@ class PipelineExecutor: ) except Exception: logger.exception("Failed to record pipeline log step for @N expansion (pipeline_session=%r)", getattr(pipeline_session, 'worker_id', None)) + elif selected_row_args and stages: + debug("@N: skipping source command expansion because downstream stages exist") stage_table = None try: diff --git a/cmdlet/__init__.py b/cmdlet/__init__.py index 80210a5..cb38709 100644 --- a/cmdlet/__init__.py +++ b/cmdlet/__init__.py @@ -87,10 +87,9 @@ def _load_root_modules() -> None: def _load_helper_modules() -> None: - try: - import API.alldebrid as _alldebrid - except Exception: - pass + # Provider-specific module pre-loading removed; providers are loaded lazily + # through ProviderCore.registry when first referenced. + pass def _register_native_commands() -> None: diff --git a/cmdlet/_shared.py b/cmdlet/_shared.py index a43f149..74f968d 100644 --- a/cmdlet/_shared.py +++ b/cmdlet/_shared.py @@ -965,6 +965,48 @@ def normalize_hash(hash_hex: Optional[str]) -> Optional[str]: return text +def resolve_hash_for_cmdlet( + raw_hash: Optional[str], + raw_path: Optional[str], + override_hash: Optional[str], +) -> Optional[str]: + """Resolve a file hash for note/tag/file cmdlets. + + Shared implementation used by add-note, delete-note, get-note, and similar + cmdlets that need to identify a file by its SHA-256 hash. + + Resolution order: + 1. ``override_hash`` — explicit hash provided via *-query* (highest priority) + 2. ``raw_hash`` — positional hash argument + 3. ``raw_path`` stem — if the filename stem is a 64-char hex string it is + treated directly as the hash (Hydrus-style naming convention) + 4. SHA-256 computed from the file at ``raw_path`` + + Args: + raw_hash: Hash string from positional argument. + raw_path: Filesystem path to the file (may be None). + override_hash: Hash extracted from *-query* (takes precedence). + + Returns: + Normalised 64-char lowercase hex hash, or ``None`` if unresolvable. + """ + resolved = normalize_hash(override_hash) if override_hash else normalize_hash(raw_hash) + if resolved: + return resolved + if raw_path: + try: + p = Path(str(raw_path)) + stem = p.stem + if len(stem) == 64 and all(c in "0123456789abcdef" for c in stem.lower()): + return stem.lower() + if p.exists() and p.is_file(): + from SYS.utils import sha256_file as _sha256_file + return _sha256_file(p) + except Exception: + return None + return None + + def parse_hash_query(query: Optional[str]) -> List[str]: """Parse a unified query string for `hash:` into normalized SHA256 hashes. diff --git a/cmdlet/add_file.py b/cmdlet/add_file.py index 07e2339..b4fb8cc 100644 --- a/cmdlet/add_file.py +++ b/cmdlet/add_file.py @@ -44,6 +44,13 @@ SUPPORTED_MEDIA_EXTENSIONS = ALL_SUPPORTED_EXTENSIONS DEBUG_PIPE_NOTE_PREVIEW_LENGTH = 256 +# Protocol schemes that identify a remote resource / not a local file path. +# Used by multiple methods in this file to guard against URL strings being +# treated as local file paths. +_REMOTE_URL_PREFIXES: tuple[str, ...] = ( + "http://", "https://", "magnet:", "torrent:", "tidal:", "hydrus:", +) + def _truncate_debug_note_text(value: Any) -> str: raw = str(value or "") @@ -1203,7 +1210,7 @@ class Add_File(Cmdlet): if candidate: s = str(candidate).lower() - if s.startswith(("http://", "https://", "magnet:", "torrent:", "tidal:", "hydrus:")): + if s.startswith(_REMOTE_URL_PREFIXES): log("add-file ingests local files only. Use download-file first.", file=sys.stderr) return None, None, None @@ -1427,7 +1434,7 @@ class Add_File(Cmdlet): if not val: return False # Obvious schemes - if val.startswith(("http://", "https://", "magnet:", "torrent:", "tidal:", "hydrus:")): + if val.startswith(_REMOTE_URL_PREFIXES): return True # Domain-like patterns or local file paths (but we want URLs here) if "://" in val: diff --git a/cmdlet/add_note.py b/cmdlet/add_note.py index fced5e1..bcfb8ba 100644 --- a/cmdlet/add_note.py +++ b/cmdlet/add_note.py @@ -175,25 +175,9 @@ class Add_Note(Cmdlet): self, raw_hash: Optional[str], raw_path: Optional[str], - override_hash: Optional[str] + override_hash: Optional[str], ) -> Optional[str]: - resolved = normalize_hash(override_hash - ) if override_hash else normalize_hash(raw_hash) - if resolved: - return resolved - - if raw_path: - try: - p = Path(str(raw_path)) - stem = p.stem - if len(stem) == 64 and all(c in "0123456789abcdef" - for c in stem.lower()): - return stem.lower() - if p.exists() and p.is_file(): - return sha256_file(p) - except Exception: - return None - return None + return sh.resolve_hash_for_cmdlet(raw_hash, raw_path, override_hash) def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: if should_show_help(args): diff --git a/cmdlet/delete_note.py b/cmdlet/delete_note.py index cda7795..fd355ff 100644 --- a/cmdlet/delete_note.py +++ b/cmdlet/delete_note.py @@ -54,24 +54,9 @@ class Delete_Note(Cmdlet): self, raw_hash: Optional[str], raw_path: Optional[str], - override_hash: Optional[str] + override_hash: Optional[str], ) -> Optional[str]: - resolved = normalize_hash(override_hash - ) if override_hash else normalize_hash(raw_hash) - if resolved: - return resolved - if raw_path: - try: - p = Path(str(raw_path)) - stem = p.stem - if len(stem) == 64 and all(c in "0123456789abcdef" - for c in stem.lower()): - return stem.lower() - if p.exists() and p.is_file(): - return sha256_file(p) - except Exception: - return None - return None + return sh.resolve_hash_for_cmdlet(raw_hash, raw_path, override_hash) def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: if should_show_help(args): diff --git a/cmdlet/download_file.py b/cmdlet/download_file.py index 19ae045..6659e57 100644 --- a/cmdlet/download_file.py +++ b/cmdlet/download_file.py @@ -54,6 +54,10 @@ resolve_target_dir = sh.resolve_target_dir coerce_to_path = sh.coerce_to_path build_pipeline_preview = sh.build_pipeline_preview +# URI scheme prefixes owned by AllDebrid (magic-link and emoji shorthand). +# Defined once here so every method in this file references the same constant. +_ALLDEBRID_PREFIXES: tuple[str, ...] = ("alldebrid:", "alldebrid🧲") + class Download_File(Cmdlet): """Class-based download-file cmdlet - direct HTTP downloads.""" @@ -652,9 +656,12 @@ class Download_File(Cmdlet): notes: Optional[Dict[str, str]] = None try: if isinstance(full_metadata, dict): - subtitles = full_metadata.get("_tidal_lyrics_subtitles") - if isinstance(subtitles, str) and subtitles.strip(): - notes = {"lyric": subtitles} + # Providers attach pre-built notes under the generic "_notes" key + # (e.g. Tidal sets {"lyric": subtitles} during download enrichment). + # This keeps provider-specific metadata handling inside the provider. + _provider_notes = full_metadata.get("_notes") + if isinstance(_provider_notes, dict) and _provider_notes: + notes = {str(k): str(v) for k, v in _provider_notes.items() if k and v} except Exception: notes = None tag: List[str] = [] @@ -2787,7 +2794,9 @@ class Download_File(Cmdlet): s_val = str(value or "").strip().lower() except Exception: return False - return s_val.startswith(("http://", "https://", "magnet:", "torrent:", "alldebrid:", "alldebrid🧲")) + return s_val.startswith( + ("http://", "https://", "magnet:", "torrent:") + _ALLDEBRID_PREFIXES + ) def _extract_selection_args(item: Any) -> tuple[Optional[List[str]], Optional[str]]: selection_args: Optional[List[str]] = None @@ -2955,15 +2964,13 @@ class Download_File(Cmdlet): and (not parsed.get("path"))): candidate = str(raw_url[0] or "").strip() low = candidate.lower() - looks_like_url = low.startswith(( - "http://", "https://", "ftp://", "magnet:", "torrent:", - "alldebrid:", "alldebrid🧲" - )) + looks_like_url = low.startswith( + ("http://", "https://", "ftp://", "magnet:", "torrent:") + _ALLDEBRID_PREFIXES + ) looks_like_provider = ( - ":" in candidate and not candidate.startswith(( - "http:", "https:", "ftp:", "ftps:", "file:", - "alldebrid:" - )) + ":" in candidate and not candidate.startswith( + ("http:", "https:", "ftp:", "ftps:", "file:") + _ALLDEBRID_PREFIXES + ) ) looks_like_windows_path = ( (len(candidate) >= 2 and candidate[1] == ":") diff --git a/cmdlet/get_note.py b/cmdlet/get_note.py index 5e1b6b2..27495d7 100644 --- a/cmdlet/get_note.py +++ b/cmdlet/get_note.py @@ -49,24 +49,9 @@ class Get_Note(Cmdlet): self, raw_hash: Optional[str], raw_path: Optional[str], - override_hash: Optional[str] + override_hash: Optional[str], ) -> Optional[str]: - resolved = normalize_hash(override_hash - ) if override_hash else normalize_hash(raw_hash) - if resolved: - return resolved - if raw_path: - try: - p = Path(str(raw_path)) - stem = p.stem - if len(stem) == 64 and all(c in "0123456789abcdef" - for c in stem.lower()): - return stem.lower() - if p.exists() and p.is_file(): - return sha256_file(p) - except Exception: - return None - return None + return sh.resolve_hash_for_cmdlet(raw_hash, raw_path, override_hash) def run(self, result: Any, args: Sequence[str], config: Dict[str, Any]) -> int: if should_show_help(args):