From 37bb4ca68562a76f5e2cafd0858f27d43f0d6214 Mon Sep 17 00:00:00 2001 From: Nose Date: Thu, 26 Mar 2026 23:00:25 -0700 Subject: [PATCH] k --- API/data/alldebrid.json | 4 +- MPV/LUA/main.lua | 200 ++++++++++++++++++-- MPV/lyric.py | 33 +++- MPV/mpv_ipc.py | 70 ------- MPV/pipeline_helper.py | 82 ++++++-- MPV/portable_config/mpv.conf | 12 +- MPV/portable_config/script-opts/medeia.conf | 1 - SYS/result_table.py | 89 ++++++++- 8 files changed, 368 insertions(+), 123 deletions(-) diff --git a/API/data/alldebrid.json b/API/data/alldebrid.json index 95e5710..d8f7aae 100644 --- a/API/data/alldebrid.json +++ b/API/data/alldebrid.json @@ -71,7 +71,7 @@ "(wayupload\\.com/[a-z0-9]{12}\\.html)" ], "regexp": "(turbobit5?a?\\.(net|cc|com)/([a-z0-9]{12}))|(turbobif\\.(net|cc|com)/([a-z0-9]{12}))|(turb[o]?\\.(to|cc|pw)\\/([a-z0-9]{12}))|(turbobit\\.(net|cc)/download/free/([a-z0-9]{12}))|((trbbt|tourbobit|torbobit|tbit|turbobita|trbt)\\.(net|cc|com|to)/([a-z0-9]{12}))|((turbobit\\.cloud/turbo/[a-z0-9]+))|((wayupload\\.com/[a-z0-9]{12}\\.html))", - "status": true + "status": false }, "hitfile": { "name": "hitfile", @@ -494,7 +494,7 @@ "mediafire\\.com/(\\?|download/|file/|download\\.php\\?)([0-9a-z]{15})" ], "regexp": "mediafire\\.com/(\\?|download/|file/|download\\.php\\?)([0-9a-z]{15})", - "status": true + "status": false }, "mixdrop": { "name": "mixdrop", diff --git a/MPV/LUA/main.lua b/MPV/LUA/main.lua index cf8a46f..a1bf680 100644 --- a/MPV/LUA/main.lua +++ b/MPV/LUA/main.lua @@ -961,6 +961,14 @@ local function _get_selected_store_conf_path() return utils.join_path(dir, 'medeia.conf') end +function M._get_selected_store_state_path() + local dir = _get_script_opts_dir() + if not dir then + return nil + end + return utils.join_path(dir, 'medeia-selected-store.json') +end + function M._get_store_cache_path() local dir = _get_script_opts_dir() if not dir then @@ -1030,6 +1038,25 @@ function M._prime_store_cache_from_disk() end local function _load_selected_store_from_disk() + local state_path = M._get_selected_store_state_path() + if state_path then + local fh = io.open(state_path, 'r') + if fh then + local raw = fh:read('*a') + fh:close() + raw = trim(tostring(raw or '')) + if raw ~= '' then + local ok, payload = pcall(utils.parse_json, raw) + if ok and type(payload) == 'table' then + local value = _normalize_store_name(payload.store) + if value ~= '' then + return value + end + end + end + end + end + local path = _get_selected_store_conf_path() if not path then return nil @@ -1054,7 +1081,7 @@ local function _load_selected_store_from_disk() end local function _save_selected_store_to_disk(store) - local path = _get_selected_store_conf_path() + local path = M._get_selected_store_state_path() if not path then return false end @@ -1062,8 +1089,7 @@ local function _save_selected_store_to_disk(store) if not fh then return false end - fh:write('# Medeia MPV script options\n') - fh:write('store=' .. tostring(store or '') .. '\n') + fh:write(utils.format_json({ store = _normalize_store_name(store) })) fh:close() return true end @@ -1095,6 +1121,42 @@ local function _ensure_selected_store_loaded() if disk ~= '' then pcall(mp.set_property, SELECTED_STORE_PROP, disk) end + pcall(function() + local legacy_path = _get_selected_store_conf_path() + if not legacy_path then + return + end + local fh = io.open(legacy_path, 'r') + if not fh then + return + end + local raw = fh:read('*a') + fh:close() + raw = tostring(raw or '') + if raw == '' or not raw:lower():find('store%s*=') then + return + end + + local lines = {} + for line in raw:gmatch('[^\r\n]+') do + local s = trim(tostring(line or '')) + local k = s:match('^([%w_%-]+)%s*=') + if not (k and k:lower() == 'store') then + lines[#lines + 1] = line + end + end + + local out = table.concat(lines, '\n') + if out ~= '' then + out = out .. '\n' + end + local writer = io.open(legacy_path, 'w') + if not writer then + return + end + writer:write(out) + writer:close() + end) pcall(M._prime_store_cache_from_disk) end @@ -3662,7 +3724,7 @@ function M._build_web_ytdl_raw_options() extra[#extra + 1] = 'write-auto-subs=' end if not lower:find('sub%-langs=', 1) then - extra[#extra + 1] = 'sub-langs=[all,-live_chat]' + extra[#extra + 1] = 'sub-langs=[en.*,en,-live_chat]' end if #extra == 0 then @@ -3697,35 +3759,115 @@ function M._find_subtitle_track_candidate() return nil, nil, false end - local first_id = nil - local default_id = nil - local selected_id = nil + local function subtitle_track_blob(track) + local parts = {} + local fields = { 'lang', 'title', 'name', 'external-filename' } + for _, key in ipairs(fields) do + local value = '' + if type(track) == 'table' then + value = trim(tostring(track[key] or '')):lower() + end + if value ~= '' then + parts[#parts + 1] = value + end + end + return table.concat(parts, ' ') + end + + local function subtitle_track_is_english(track, blob) + local lang = '' + if type(track) == 'table' then + lang = trim(tostring(track.lang or '')):lower() + end + if lang == 'en' or lang == 'eng' or lang:match('^en[-_]') or lang:match('^eng[-_]') then + return true + end + local text = blob or subtitle_track_blob(track) + if text:match('%f[%a]english%f[%A]') then + return true + end + return false + end + + local function subtitle_track_is_autogenerated(track, blob) + local text = blob or subtitle_track_blob(track) + local markers = { + 'auto-generated', + 'auto generated', + 'autogenerated', + 'automatic captions', + 'automatic subtitles', + 'generated automatically', + 'asr', + } + for _, marker in ipairs(markers) do + if text:find(marker, 1, true) then + return true + end + end + return false + end + + local best_id = nil + local best_source = nil + local best_selected = false + local best_score = nil for _, track in ipairs(tracks) do if type(track) == 'table' and tostring(track.type or '') == 'sub' and not track.albumart then local id = tonumber(track.id) if id then - if first_id == nil then - first_id = id + local blob = subtitle_track_blob(track) + local selected = track.selected and true or false + local source = 'fallback' + local score = 100 + + if blob:find('medeia-sub', 1, true) then + source = 'medeia-note' + score = 1000 + else + local english = subtitle_track_is_english(track, blob) + local autogenerated = subtitle_track_is_autogenerated(track, blob) + if english and not autogenerated then + source = 'english-manual' + score = 800 + elseif english and autogenerated then + source = 'english-auto' + score = 700 + elseif selected then + source = 'selected' + score = 300 + elseif track.default then + source = 'default' + score = 200 + else + source = 'first' + score = 100 + end end - if track.selected then - selected_id = id + + if selected then + score = score + 50 end - if default_id == nil and track.default then - default_id = id + if track.default then + score = score + 25 + end + if type(track.external) == 'boolean' and track.external then + score = score + 10 + end + + if best_score == nil or score > best_score then + best_score = score + best_id = id + best_source = source + best_selected = selected end end end end - if selected_id ~= nil then - return selected_id, 'selected', true - end - if default_id ~= nil then - return default_id, 'default', false - end - if first_id ~= nil then - return first_id, 'first', false + if best_id ~= nil then + return best_id, best_source, best_selected end return nil, nil, false end @@ -4302,6 +4444,12 @@ function FileState:fetch_formats(cb) return end + if not _is_ytdlp_url(url) then + _lua_log('fetch-formats: skipped (yt-dlp unsupported)') + if cb then cb(false, 'yt-dlp unsupported url') end + return + end + local cached = _get_cached_formats_table(url) if type(cached) == 'table' then _lua_log('fetch-formats: using cached table') @@ -4373,6 +4521,11 @@ local function _prefetch_formats_for_url(url, attempt) if url == '' or not _is_http_url(url) then return end + if not _is_ytdlp_url(url) then + _formats_prefetch_retries[url] = nil + _lua_log('prefetch-formats: skipped (yt-dlp unsupported) url=' .. url) + return + end attempt = tonumber(attempt or 1) or 1 local cached = _get_cached_formats_table(url) @@ -5795,6 +5948,11 @@ mp.register_script_message('medios-load-url-event', function(json) _log_all('INFO', 'Load URL started: ' .. url) _lua_log('[LOAD-URL] Starting to load: ' .. url) _set_current_web_url(url) + _pending_format_change = nil + pcall(mp.set_property, 'options/ytdl-format', '') + pcall(mp.set_property, 'file-local-options/ytdl-format', '') + pcall(mp.set_property, 'ytdl-format', '') + _lua_log('load-url: cleared stale ytdl format reason=load-url') local function close_menu() _lua_log('[LOAD-URL] Closing menu and resetting input state') diff --git a/MPV/lyric.py b/MPV/lyric.py index 60ecd0d..51ce481 100644 --- a/MPV/lyric.py +++ b/MPV/lyric.py @@ -71,6 +71,7 @@ _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 +_SUBTITLE_NOTE_ALIASES = ("subtitle", "subtitles", "transcript", "transcription") def _single_instance_lock_path(ipc_path: str) -> Path: @@ -794,16 +795,42 @@ def _extract_lrc_from_notes(notes: Dict[str, str]) -> Optional[str]: return _extract_note_text(notes, "lyric") +def _looks_like_subtitle_text(text: str) -> bool: + t = (text or "").lstrip("\ufeff\r\n").lstrip() + if not t: + return False + upper = t.upper() + if upper.startswith("WEBVTT"): + return True + if upper.startswith("[SCRIPT INFO]"): + return True + if "-->" in t: + return True + if re.search(r"(?m)^Dialogue:\s*", t): + return True + return False + + def _extract_sub_from_notes(notes: Dict[str, str]) -> Optional[str]: - """Return raw subtitle text from the note named 'sub'.""" - return _extract_note_text(notes, "sub") + """Return raw subtitle text from note-backed subtitle/transcript keys.""" + primary = _extract_note_text(notes, "sub") + if primary: + return primary + for note_name in _SUBTITLE_NOTE_ALIASES: + candidate = _extract_note_text(notes, note_name) + if candidate and _looks_like_subtitle_text(candidate): + return candidate + return 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"): + upper = t.upper() + if upper.startswith("WEBVTT"): return ".vtt" + if upper.startswith("[SCRIPT INFO]") or re.search(r"(?m)^Dialogue:\s*", t): + return ".ass" 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): diff --git a/MPV/mpv_ipc.py b/MPV/mpv_ipc.py index 6c9afed..48e56b3 100644 --- a/MPV/mpv_ipc.py +++ b/MPV/mpv_ipc.py @@ -769,76 +769,6 @@ class MPV: **kwargs, ) - # Start the persistent pipeline helper eagerly so MPV Lua can issue - # non-blocking requests (e.g., format list prefetch) without needing - # to spawn the helper on-demand from inside mpv. - try: - helper_path = (repo_root / "MPV" / "pipeline_helper.py").resolve() - if helper_path.exists(): - py = sys.executable or "python" - if platform.system() == "Windows": - py = _windows_pythonw_exe(py) or py - helper_cmd = [ - py, - str(helper_path), - "--ipc", - str(self.ipc_path), - "--timeout", - "30", - ] - - helper_env = os.environ.copy() - try: - existing_pp = helper_env.get("PYTHONPATH") - helper_env["PYTHONPATH"] = ( - str(repo_root) if not existing_pp else - (str(repo_root) + os.pathsep + str(existing_pp)) - ) - except Exception: - pass - - helper_kwargs: Dict[str, - Any] = {} - if platform.system() == "Windows": - flags = 0 - try: - flags |= int( - getattr(subprocess, - "DETACHED_PROCESS", - 0x00000008) - ) - except Exception: - flags |= 0x00000008 - try: - flags |= int( - getattr(subprocess, - "CREATE_NO_WINDOW", - 0x08000000) - ) - except Exception: - flags |= 0x08000000 - helper_kwargs["creationflags"] = flags - helper_kwargs.update( - { - k: v - for k, v in _windows_hidden_subprocess_kwargs().items() - if k != "creationflags" - } - ) - - helper_kwargs["cwd"] = str(repo_root) - helper_kwargs["env"] = helper_env - - subprocess.Popen( - helper_cmd, - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - **helper_kwargs, - ) - except Exception: - pass - def get_ipc_pipe_path() -> str: """Get the fixed IPC pipe/socket path for persistent MPV connection. diff --git a/MPV/pipeline_helper.py b/MPV/pipeline_helper.py index 8c803a1..af55b65 100644 --- a/MPV/pipeline_helper.py +++ b/MPV/pipeline_helper.py @@ -1172,6 +1172,11 @@ def _get_ipc_lock_path(ipc_path: str) -> Path: return lock_dir / f"medeia-mpv-helper-{safe}.lock" +def _get_ipc_lock_meta_path(ipc_path: str) -> Path: + lock_path = _get_ipc_lock_path(ipc_path) + return lock_path.with_suffix(lock_path.suffix + ".json") + + def _read_lock_file_pid(ipc_path: str) -> Optional[int]: """Return the PID recorded in the lock file by the current holder, or None. @@ -1181,7 +1186,7 @@ def _read_lock_file_pid(ipc_path: str) -> Optional[int]: avoiding the race where concurrent sibling helpers all kill each other. """ try: - lock_path = _get_ipc_lock_path(ipc_path) + lock_path = _get_ipc_lock_meta_path(ipc_path) with open(str(lock_path), "r", encoding="utf-8", errors="replace") as fh: content = fh.read().strip() if not content: @@ -1193,6 +1198,57 @@ def _read_lock_file_pid(ipc_path: str) -> Optional[int]: return None +def _write_lock_file_metadata(ipc_path: str) -> None: + meta_path = _get_ipc_lock_meta_path(ipc_path) + meta_path.write_text( + json.dumps( + { + "pid": os.getpid(), + "version": MEDEIA_MPV_HELPER_VERSION, + "ipc": str(ipc_path), + "started_at": int(time.time()), + }, + ensure_ascii=False, + ), + encoding="utf-8", + errors="replace", + ) + + +def _release_ipc_lock(fh: Any, ipc_path: Optional[str] = None) -> None: + if fh is None: + if ipc_path: + try: + _get_ipc_lock_meta_path(ipc_path).unlink(missing_ok=True) + except Exception: + pass + return + try: + if os.name == "nt": + import msvcrt # type: ignore + + try: + fh.seek(0) + except Exception: + pass + msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1) + else: + import fcntl # type: ignore + + fcntl.flock(fh.fileno(), fcntl.LOCK_UN) + except Exception: + pass + try: + fh.close() + except Exception: + pass + if ipc_path: + try: + _get_ipc_lock_meta_path(ipc_path).unlink(missing_ok=True) + except Exception: + pass + + def _acquire_ipc_lock(ipc_path: str) -> Optional[Any]: """Best-effort singleton lock per IPC path. @@ -1238,6 +1294,11 @@ def _acquire_ipc_lock(ipc_path: str) -> Optional[Any]: pass return None + try: + _write_lock_file_metadata(ipc_path) + except Exception: + pass + return fh except Exception: return None @@ -1433,20 +1494,7 @@ def main(argv: Optional[list[str]] = None) -> int: f"[helper] version={MEDEIA_MPV_HELPER_VERSION} started ipc={args.ipc}" ) try: - _lock_fh.seek(0) - _lock_fh.truncate() - _lock_fh.write( - json.dumps( - { - "pid": os.getpid(), - "version": MEDEIA_MPV_HELPER_VERSION, - "ipc": str(args.ipc), - "started_at": int(time.time()), - }, - ensure_ascii=False, - ) - ) - _lock_fh.flush() + _write_lock_file_metadata(str(args.ipc)) except Exception: pass try: @@ -2103,6 +2151,10 @@ def main(argv: Optional[list[str]] = None) -> int: client.disconnect() except Exception: pass + try: + _release_ipc_lock(_lock_fh, str(args.ipc)) + except Exception: + pass return 0 diff --git a/MPV/portable_config/mpv.conf b/MPV/portable_config/mpv.conf index e33814d..cb51b7c 100644 --- a/MPV/portable_config/mpv.conf +++ b/MPV/portable_config/mpv.conf @@ -5,9 +5,15 @@ ytdl=yes # uosc will draw its own window controls and border if you disable window border border=no cache=yes -cache-secs=30 -demuxer-max-bytes=200MiB -demuxer-max-back-bytes=100MiB +# Give HTTP store streams more room to absorb Hydrus/network jitter before +# mpv restarts audio after an underrun. +cache-secs=90 +cache-pause=yes +cache-pause-wait=12 +demuxer-readahead-secs=90 +demuxer-max-bytes=512MiB +demuxer-max-back-bytes=256MiB +audio-buffer=1.0 # Ensure uosc texture/icon fonts are discoverable by libass. osd-fonts-dir=~~/scripts/uosc/fonts diff --git a/MPV/portable_config/script-opts/medeia.conf b/MPV/portable_config/script-opts/medeia.conf index 3d30edf..1e4663e 100644 --- a/MPV/portable_config/script-opts/medeia.conf +++ b/MPV/portable_config/script-opts/medeia.conf @@ -1,2 +1 @@ # Medeia MPV script options -store=local diff --git a/SYS/result_table.py b/SYS/result_table.py index 60d6484..2f1b0e7 100644 --- a/SYS/result_table.py +++ b/SYS/result_table.py @@ -47,6 +47,60 @@ import logging logger = logging.getLogger(__name__) +def _normalize_detail_tags(tags: Any) -> List[str]: + if not tags: + return [] + if isinstance(tags, str): + source = [part.strip() for part in tags.split(",")] + elif isinstance(tags, (list, tuple, set)): + source = [str(part or "").strip() for part in tags] + else: + source = [str(tags).strip()] + + seen: set[str] = set() + normalized: List[str] = [] + for tag in source: + text = str(tag or "").strip() + if not text: + continue + key = text.casefold() + if key in seen: + continue + seen.add(key) + normalized.append(text) + return normalized + + +def _partition_detail_tags(tags: Any) -> tuple[List[str], List[str]]: + normalized = _normalize_detail_tags(tags) + namespace_tags: List[str] = [] + freeform_tags: List[str] = [] + for tag in normalized: + namespace, sep, value = str(tag).partition(":") + if sep and namespace.strip() and value.strip(): + namespace_tags.append(tag) + else: + freeform_tags.append(tag) + + namespace_tags.sort( + key=lambda value: ( + str(value).partition(":")[0].casefold(), + str(value).partition(":")[2].casefold(), + str(value).casefold(), + ) + ) + freeform_tags.sort(key=lambda value: str(value).casefold()) + return namespace_tags, freeform_tags + + +def _chunk_detail_tags(tags: List[str], columns: int) -> List[List[str]]: + column_count = max(1, int(columns or 1)) + rows: List[List[str]] = [] + for index in range(0, len(tags), column_count): + rows.append(tags[index:index + column_count]) + return rows + + _RESULT_TABLE_ROW_STYLE_LOOP: List[tuple[str, str]] = [ ("#ff0000", "#8f00ff"), ("#ffa500", "#800080"), @@ -2213,7 +2267,6 @@ class ItemDetailView(Table): from rich.table import Table as RichTable from rich.panel import Panel from rich.console import Group - from rich.columns import Columns from rich.text import Text # 1. Create Detail Grid (matching rich_display.py style) @@ -2236,6 +2289,28 @@ class ItemDetailView(Table): tag_text.append(raw, style="green") return tag_text + def _build_tag_renderable(tags: Any) -> Optional[Any]: + namespace_tags, freeform_tags = _partition_detail_tags(tags) + if not namespace_tags and not freeform_tags: + return None + + renderables: List[Any] = [] + for tag in namespace_tags: + renderables.append(_render_tag_text(tag)) + + if freeform_tags: + freeform_grid = RichTable.grid(expand=True, padding=(0, 2)) + for _ in range(3): + freeform_grid.add_column(ratio=1) + for row_values in _chunk_detail_tags(freeform_tags, 3): + cells = [_render_tag_text(tag) for tag in row_values] + while len(cells) < 3: + cells.append(Text("")) + freeform_grid.add_row(*cells) + renderables.append(freeform_grid) + + return Group(*renderables) + # Canonical display order for metadata order = ["Title", "Hash", "Store", "Path", "Ext", "Size", "Duration", "Url", "Relations"] @@ -2280,13 +2355,11 @@ class ItemDetailView(Table): # Tags Summary tags = self.item_metadata.get("Tags") or self.item_metadata.get("tags") or self.item_metadata.get("tag") if not self.exclude_tags and tags and isinstance(tags, (list, str)): - if isinstance(tags, str): - tags = [t.strip() for t in tags.split(",") if t.strip()] - tags_sorted = sorted(map(str, tags)) - tag_cols = Columns([_render_tag_text(t) for t in tags_sorted], equal=True, expand=True) - details_table.add_row("", "") # Spacer - details_table.add_row("Tags:", tag_cols) - has_details = True + tag_renderable = _build_tag_renderable(tags) + if tag_renderable is not None: + details_table.add_row("", "") + details_table.add_row("Tags:", tag_renderable) + has_details = True # 2. Get the standard table render (if there are rows or a specific title) original_title = self.title