From 23a73a94e694a841c1d8f479bb97aa0526b664c7 Mon Sep 17 00:00:00 2001 From: Nose Date: Sun, 22 Mar 2026 22:41:56 -0700 Subject: [PATCH] fdf --- MPV/LUA/main.lua | 810 ++++++++++++++---- MPV/pipeline_helper.py | 13 +- .../script-opts/medeia-store-cache.json | 1 + MPV/portable_config/script-opts/medeia.conf | 2 +- Provider/internetarchive.py | 125 ++- Provider/openlibrary.py | 184 +++- 6 files changed, 956 insertions(+), 179 deletions(-) create mode 100644 MPV/portable_config/script-opts/medeia-store-cache.json diff --git a/MPV/LUA/main.lua b/MPV/LUA/main.lua index 6cc5944..88aef48 100644 --- a/MPV/LUA/main.lua +++ b/MPV/LUA/main.lua @@ -4,7 +4,7 @@ local msg = require 'mp.msg' local M = {} -local MEDEIA_LUA_VERSION = '2026-03-22.3' +local MEDEIA_LUA_VERSION = '2026-03-23.1' -- Expose a tiny breadcrumb for debugging which script version is loaded. pcall(mp.set_property, 'user-data/medeia-lua-version', MEDEIA_LUA_VERSION) @@ -377,6 +377,51 @@ local function ensure_uosc_loaded() return _uosc_loaded end +M._disable_input_section = function(name, reason) + local section = tostring(name or '') + if section == '' then + return + end + local ok, err = pcall(mp.commandv, 'disable-section', section) + if not ok then + _lua_log('ui: disable-section failed name=' .. section .. ' reason=' .. tostring(reason or 'unknown') .. ' err=' .. tostring(err)) + end +end + +M._reset_uosc_input_state = function(reason) + local why = tostring(reason or 'unknown') + M._disable_input_section('input_console', why) + M._disable_input_section('input_forced_console', why) + if not ensure_uosc_loaded() then + return false + end + pcall(mp.commandv, 'script-message-to', 'uosc', 'close-menu') + mp.add_timeout(0.05, function() + if ensure_uosc_loaded() then + pcall(mp.commandv, 'script-message-to', 'uosc', 'sync-cursor') + end + M._disable_input_section('input_console', why .. '@sync') + M._disable_input_section('input_forced_console', why .. '@sync') + end) + return true +end + +M._open_uosc_menu = function(menu_data, reason) + local why = tostring(reason or 'menu') + if not ensure_uosc_loaded() then + _lua_log('menu: uosc not available; cannot open-menu reason=' .. why) + return false + end + M._reset_uosc_input_state(why .. ':pre-open') + local payload = utils.format_json(menu_data or {}) + local ok, err = pcall(mp.commandv, 'script-message-to', 'uosc', 'open-menu', payload) + if not ok then + _lua_log('menu: open-menu failed reason=' .. why .. ' err=' .. tostring(err)) + return false + end + return true +end + local function write_temp_log(prefix, text) if not text or text == '' then return nil @@ -906,6 +951,74 @@ local function _get_selected_store_conf_path() return utils.join_path(dir, 'medeia.conf') end +function M._get_store_cache_path() + local dir = _get_script_opts_dir() + if not dir then + return nil + end + return utils.join_path(dir, 'medeia-store-cache.json') +end + +function M._load_store_names_from_disk() + local path = M._get_store_cache_path() + if not path then + return nil + end + local fh = io.open(path, 'r') + if not fh then + return nil + end + local raw = fh:read('*a') + fh:close() + raw = trim(tostring(raw or '')) + if raw == '' then + return nil + end + local ok, payload = pcall(utils.parse_json, raw) + if not ok or type(payload) ~= 'table' or type(payload.choices) ~= 'table' then + return nil + end + local out = {} + for _, value in ipairs(payload.choices) do + local name = _normalize_store_name(value) + if name ~= '' then + out[#out + 1] = name + end + end + return #out > 0 and out or nil +end + +function M._save_store_names_to_disk(names) + if type(names) ~= 'table' or #names == 0 then + return false + end + local path = M._get_store_cache_path() + if not path then + return false + end + local fh = io.open(path, 'w') + if not fh then + return false + end + fh:write(utils.format_json({ choices = names })) + fh:close() + return true +end + +function M._prime_store_cache_from_disk() + if type(_cached_store_names) == 'table' and #_cached_store_names > 0 then + return true + end + local names = M._load_store_names_from_disk() + if type(names) ~= 'table' or #names == 0 then + return false + end + _cached_store_names = names + _store_cache_loaded = true + _lua_log('stores: primed ' .. tostring(#names) .. ' stores from disk cache') + return true +end + local function _load_selected_store_from_disk() local path = _get_selected_store_conf_path() if not path then @@ -972,6 +1085,7 @@ local function _ensure_selected_store_loaded() if disk ~= '' then pcall(mp.set_property, SELECTED_STORE_PROP, disk) end + pcall(M._prime_store_cache_from_disk) end local _pipeline_helper_started = false @@ -988,6 +1102,8 @@ local HELPER_START_DEBOUNCE = 2.0 local _helper_ready_last_value = '' local _helper_ready_last_seen_ts = 0 local HELPER_READY_STALE_SECONDS = 10.0 +M._lyric_helper_state = M._lyric_helper_state or { last_start_ts = -1000, debounce = 3.0 } +M._subtitle_autoselect_state = M._subtitle_autoselect_state or { serial = 0, deadline = 0 } local function _is_pipeline_helper_ready() local helper_version = mp.get_property('user-data/medeia-pipeline-helper-version') @@ -995,7 +1111,7 @@ local function _is_pipeline_helper_ready() helper_version = mp.get_property_native('user-data/medeia-pipeline-helper-version') end helper_version = tostring(helper_version or '') - if helper_version ~= '2026-03-22.6' then + if helper_version ~= '2026-03-23.1' then return false end @@ -1062,7 +1178,7 @@ local function _helper_ready_diagnostics() end return 'ready=' .. tostring(ready or '') .. ' helper_version=' .. tostring(helper_version or '') - .. ' required_version=2026-03-22.6' + .. ' required_version=2026-03-23.1' .. ' last_value=' .. tostring(_helper_ready_last_value or '') .. ' last_seen_age=' .. tostring(age) end @@ -1245,6 +1361,104 @@ local function ensure_pipeline_helper_running() return _is_pipeline_helper_ready() end +function M._resolve_repo_script(relative_path) + relative_path = trim(tostring(relative_path or '')) + if relative_path == '' then + return '', '' + end + + local repo_root = _detect_repo_root() + if repo_root ~= '' then + local direct = utils.join_path(repo_root, relative_path) + if _path_exists(direct) then + return direct, repo_root + end + end + + local candidates = {} + local seen = {} + local source_dir = _get_lua_source_path():match('(.*)[/\\]') or '' + local script_dir = mp.get_script_directory() or '' + local cwd = utils.getcwd() or '' + _append_unique_path(candidates, seen, find_file_upwards(source_dir, relative_path, 8)) + _append_unique_path(candidates, seen, find_file_upwards(script_dir, relative_path, 8)) + _append_unique_path(candidates, seen, find_file_upwards(cwd, relative_path, 8)) + + for _, candidate in ipairs(candidates) do + if _path_exists(candidate) then + local launch_root = candidate:match('(.*)[/\\]MPV[/\\]') or (candidate:match('(.*)[/\\]') or '') + return candidate, launch_root + end + end + + return '', repo_root +end + +function M._attempt_start_lyric_helper_async(reason) + reason = trim(tostring(reason or 'startup')) + local state = M._lyric_helper_state or { last_start_ts = -1000, debounce = 3.0 } + M._lyric_helper_state = state + + if not ensure_mpv_ipc_server() then + _lua_log('lyric-helper: missing mpv IPC server reason=' .. tostring(reason)) + return false + end + + local now = mp.get_time() or 0 + if (state.last_start_ts or -1000) > -1 and (now - (state.last_start_ts or -1000)) < (state.debounce or 3.0) then + return false + end + state.last_start_ts = now + + local python = _resolve_python_exe(true) + if not python or python == '' then + python = _resolve_python_exe(false) + end + if not python or python == '' then + _lua_log('lyric-helper: no python executable available reason=' .. tostring(reason)) + return false + end + + local lyric_script, launch_root = M._resolve_repo_script('MPV/lyric.py') + if lyric_script == '' then + _lua_log('lyric-helper: MPV/lyric.py not found reason=' .. tostring(reason)) + return false + end + + local bootstrap = table.concat({ + 'import os, runpy, sys', + 'script = sys.argv[1]', + 'root = sys.argv[2]', + 'if root:', + ' os.chdir(root)', + ' sys.path.insert(0, root) if root not in sys.path else None', + 'sys.argv = [script] + sys.argv[3:]', + 'runpy.run_path(script, run_name="__main__")', + }, '\n') + + local args = { python, '-c', bootstrap, lyric_script, launch_root, '--ipc', get_mpv_ipc_path() } + local lyric_log = '' + if launch_root ~= '' then + lyric_log = utils.join_path(launch_root, 'Log/medeia-mpv-lyric.log') + end + if lyric_log ~= '' then + args[#args + 1] = '--log' + args[#args + 1] = lyric_log + end + + local ok, result, detail = _run_subprocess_command({ name = 'subprocess', args = args, detach = true }) + if not ok then + ok, result, detail = _run_subprocess_command({ name = 'subprocess', args = args }) + end + if not ok then + _lua_log('lyric-helper: spawn failed reason=' .. tostring(reason) .. ' detail=' .. tostring(detail or _describe_subprocess_result(result))) + return false + end + + _lua_log('lyric-helper: start requested reason=' .. tostring(reason) .. ' script=' .. tostring(lyric_script)) + return true +end + local _ipc_async_busy = false local _ipc_async_queue = {} @@ -2257,13 +2471,11 @@ local function _open_store_picker_for_pending_screenshot() local selected = _get_selected_store() local items = {} - if _is_windows() then - items[#items + 1] = { - title = 'Pick folder…', - hint = 'Save screenshot to a local folder', - value = { 'script-message-to', mp.get_script_name(), 'medeia-image-screenshot-pick-path', '{}' }, - } - end + items[#items + 1] = { + title = 'Pick folder…', + hint = 'Save screenshot to a local folder', + value = { 'script-message-to', mp.get_script_name(), 'medeia-image-screenshot-pick-path', '{}' }, + } if type(_cached_store_names) == 'table' and #_cached_store_names > 0 then for _, name in ipairs(_cached_store_names) do @@ -2423,18 +2635,19 @@ mp.register_script_message('medeia-image-screenshot-pick-path', function() if type(_pending_screenshot) ~= 'table' or not _pending_screenshot.path then return end - if not _is_windows() then - mp.osd_message('Folder picker is Windows-only', 4) - return - end - local folder = _pick_folder_windows() - if not folder or folder == '' then - return - end + M._pick_folder_async(function(folder, err) + if err and err ~= '' then + mp.osd_message('Folder picker failed: ' .. tostring(err), 4) + return + end + if not folder or folder == '' then + return + end - local out_path = tostring(_pending_screenshot.path or '') - _open_screenshot_tag_prompt(folder, out_path) + local out_path = tostring(_pending_screenshot.path or '') + _open_screenshot_tag_prompt(folder, out_path) + end) end) mp.register_script_message('medeia-image-screenshot-tags-search', function(query) @@ -2828,9 +3041,142 @@ local function _extract_store_hash(target) return nil end -_pick_folder_windows = function() - -- Native folder picker via PowerShell + WinForms. - local ps = [[ +function M._pick_folder_python_async(cb) + cb = cb or function() end + + local python = _resolve_python_exe(false) + if not python or python == '' then + cb(nil, 'no python executable available') + return + end + + local bootstrap = table.concat({ + 'import sys', + 'try:', + ' import tkinter as tk', + ' from tkinter import filedialog', + ' root = tk.Tk()', + ' root.withdraw()', + ' try:', + ' root.wm_attributes("-topmost", 1)', + ' except Exception:', + ' pass', + ' root.update()', + ' path = filedialog.askdirectory(title="Select download folder", mustexist=False)', + ' root.destroy()', + ' if path:', + ' sys.stdout.write(path)', + 'except Exception as exc:', + ' sys.stderr.write(f"{type(exc).__name__}: {exc}")', + ' raise SystemExit(2)', + }, '\n') + + _lua_log('folder-picker: spawning async dialog backend=python-tk') + mp.command_native_async( + { + name = 'subprocess', + args = { python, '-c', bootstrap }, + capture_stdout = true, + capture_stderr = true, + playback_only = false, + }, + function(success, result, err) + if not success then + cb(nil, tostring(err or 'subprocess failed')) + return + end + if type(result) ~= 'table' then + cb(nil, 'invalid subprocess result') + return + end + + local status = tonumber(result.status or 0) or 0 + local stdout = trim(tostring(result.stdout or '')) + local stderr = trim(tostring(result.stderr or '')) + if status ~= 0 then + local detail = stderr ~= '' and stderr or tostring(result.error or ('folder picker exited with status ' .. tostring(status))) + cb(nil, detail) + return + end + if stdout == '' then + cb(nil, nil) + return + end + cb(stdout, nil) + end + ) +end + +function M._pick_folder_async(cb) + cb = cb or function() end + local jit_mod = rawget(_G, 'jit') + local platform_name = tostring((type(jit_mod) == 'table' and jit_mod.os) or ''):lower() + local function handle_result(label, success, result, err, fallback_cb) + if not success then + local detail = tostring(err or 'subprocess failed') + _lua_log('folder-picker: subprocess failed backend=' .. tostring(label) .. ' err=' .. tostring(detail)) + if fallback_cb then + fallback_cb(detail) + return + end + cb(nil, detail) + return + end + if type(result) ~= 'table' then + _lua_log('folder-picker: invalid subprocess result backend=' .. tostring(label)) + if fallback_cb then + fallback_cb('invalid subprocess result') + return + end + cb(nil, 'invalid subprocess result') + return + end + + local status = tonumber(result.status or 0) or 0 + local stdout = trim(tostring(result.stdout or '')) + local stderr = trim(tostring(result.stderr or '')) + if status ~= 0 then + local detail = stderr + if detail == '' then + detail = tostring(result.error or ('folder picker exited with status ' .. tostring(status))) + end + _lua_log('folder-picker: failed backend=' .. tostring(label) .. ' detail=' .. tostring(detail)) + if fallback_cb then + fallback_cb(detail) + return + end + cb(nil, detail) + return + end + + if stdout == '' then + _lua_log('folder-picker: cancelled backend=' .. tostring(label)) + cb(nil, nil) + return + end + + _lua_log('folder-picker: selected path=' .. tostring(stdout) .. ' backend=' .. tostring(label)) + cb(stdout, nil) + end + + local function spawn_picker(label, args, fallback_cb) + _lua_log('folder-picker: spawning async dialog backend=' .. tostring(label)) + mp.command_native_async( + { + name = 'subprocess', + args = args, + capture_stdout = true, + capture_stderr = true, + playback_only = false, + }, + function(success, result, err) + handle_result(label, success, result, err, fallback_cb) + end + ) + end + + if _is_windows() then + local ps = [[ Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing [System.Windows.Forms.Application]::EnableVisualStyles() @@ -2858,20 +3204,36 @@ try { $owner.Dispose() } ]] - local res = utils.subprocess({ - -- Hide the PowerShell console window (dialog still shows). - args = { 'powershell', '-NoProfile', '-WindowStyle', 'Hidden', '-STA', '-ExecutionPolicy', 'Bypass', '-Command', ps }, - cancellable = false, - }) - if res and res.status == 0 and res.stdout then - local out = trim(tostring(res.stdout)) - if out ~= '' then - return out - end + spawn_picker('windows-winforms', { 'powershell', '-NoProfile', '-WindowStyle', 'Hidden', '-STA', '-ExecutionPolicy', 'Bypass', '-Command', ps }, function(detail) + _lua_log('folder-picker: windows native picker failed; trying python-tk detail=' .. tostring(detail or '')) + M._pick_folder_python_async(cb) + end) + return end - return nil + + if platform_name == 'osx' or platform_name == 'macos' then + spawn_picker('macos-osascript', { 'osascript', '-e', 'POSIX path of (choose folder with prompt "Select download folder")' }, function(detail) + _lua_log('folder-picker: macos native picker failed; trying python-tk detail=' .. tostring(detail or '')) + M._pick_folder_python_async(cb) + end) + return + end + + spawn_picker('linux-native', { + 'sh', + '-lc', + 'if command -v zenity >/dev/null 2>&1; then zenity --file-selection --directory --title="Select download folder"; ' .. + 'elif command -v kdialog >/dev/null 2>&1; then kdialog --getexistingdirectory "$HOME" --title "Select download folder"; ' .. + 'elif command -v yad >/dev/null 2>&1; then yad --file-selection --directory --title="Select download folder"; ' .. + 'else exit 127; fi' + }, function(detail) + _lua_log('folder-picker: linux native picker failed; trying python-tk detail=' .. tostring(detail or '')) + M._pick_folder_python_async(cb) + end) end +M._pick_folder_windows_async = M._pick_folder_async + local function _store_names_key(names) if type(names) ~= 'table' or #names == 0 then return '' @@ -2928,6 +3290,7 @@ _refresh_store_cache = function(timeout_seconds, on_complete) _cached_store_names = out _store_cache_loaded = (#out > 0) or _store_cache_loaded + pcall(M._save_store_names_to_disk, out) local payload = utils.format_json({ choices = out }) if type(payload) == 'string' and payload ~= '' then @@ -2945,38 +3308,6 @@ _refresh_store_cache = function(timeout_seconds, on_complete) return true end - local function request_helper_store_choices() - if not _is_pipeline_helper_ready() then - _lua_log('stores: helper not ready; skipping helper store refresh fallback') - if type(on_complete) == 'function' then - on_complete(false, false) - end - return false - end - - _lua_log('stores: requesting store-choices via helper fallback') - _run_helper_request_async({ op = 'store-choices' }, math.max(timeout_seconds or 0, 6.0), function(resp, err) - if apply_store_choices(resp, 'helper fallback') then - return - end - - _lua_log( - 'stores: failed to load store choices via helper; success=' - .. tostring(resp and resp.success or false) - .. ' choices_type=' - .. tostring(resp and type(resp.choices) or 'nil') - .. ' stderr=' - .. tostring(resp and resp.stderr or '') - .. ' error=' - .. tostring(resp and resp.error or err or '') - ) - if type(on_complete) == 'function' then - on_complete(false, false) - end - end) - return true - end - local cached_json = mp.get_property('user-data/medeia-store-choices-cached') _lua_log('stores: cache_read cached_json=' .. tostring(cached_json) .. ' len=' .. tostring(cached_json and #cached_json or 0)) @@ -3010,7 +3341,14 @@ _refresh_store_cache = function(timeout_seconds, on_complete) end _lua_log('stores: direct config load failed error=' .. tostring(err or 'unknown')) - request_helper_store_choices() + if had_previous then + _lua_log('stores: keeping previous store list after direct config failure count=' .. tostring(prev_count)) + if type(on_complete) == 'function' then + on_complete(true, false) + end + elseif type(on_complete) == 'function' then + on_complete(false, false) + end end) else _lua_log('stores: direct config refresh already pending') @@ -3024,9 +3362,7 @@ _uosc_open_list_picker = function(menu_type, title, items) title = title, items = items or {}, } - if ensure_uosc_loaded() then - mp.commandv('script-message-to', 'uosc', 'open-menu', utils.format_json(menu_data)) - else + if not M._open_uosc_menu(menu_data, 'list-picker:' .. tostring(menu_type or title or 'menu')) then _lua_log('menu: uosc not available; cannot open-menu') end end @@ -3299,6 +3635,138 @@ _current_url_for_web_actions = function() return tostring(target) end +function M._build_web_ytdl_raw_options() + local raw = trim(tostring(mp.get_property('ytdl-raw-options') or '')) + if raw == '' then + raw = trim(tostring(mp.get_property('options/ytdl-raw-options') or '')) + end + + local lower = raw:lower() + local extra = {} + + if not lower:find('write%-subs=', 1) then + extra[#extra + 1] = 'write-subs=' + end + if not lower:find('write%-auto%-subs=', 1) then + extra[#extra + 1] = 'write-auto-subs=' + end + if not lower:find('sub%-langs=', 1) then + extra[#extra + 1] = 'sub-langs=[all,-live_chat]' + end + + if #extra == 0 then + return raw ~= '' and raw or nil + end + if raw ~= '' then + return raw .. ',' .. table.concat(extra, ',') + end + return table.concat(extra, ',') +end + +function M._apply_web_subtitle_load_defaults(reason) + local target = trim(tostring(mp.get_property('path') or mp.get_property('stream-open-filename') or '')) + if target == '' or not _is_http_url(target) then + return false + end + + local raw = M._build_web_ytdl_raw_options() + if raw and raw ~= '' then + pcall(mp.set_property, 'file-local-options/ytdl-raw-options', raw) + end + pcall(mp.set_property, 'file-local-options/sub-visibility', 'yes') + pcall(mp.set_property, 'file-local-options/sid', 'auto') + pcall(mp.set_property, 'file-local-options/track-auto-selection', 'yes') + _lua_log('web-subtitles: prepared load defaults reason=' .. tostring(reason or 'on-load') .. ' target=' .. tostring(target)) + return true +end + +function M._find_subtitle_track_candidate() + local tracks = mp.get_property_native('track-list') + if type(tracks) ~= 'table' then + return nil, nil, false + end + + local first_id = nil + local default_id = nil + local selected_id = 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 + end + if track.selected then + selected_id = id + end + if default_id == nil and track.default then + default_id = id + 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 + end + return nil, nil, false +end + +function M._ensure_current_subtitles_visible(reason) + local state = M._subtitle_autoselect_state or { serial = 0, deadline = 0 } + M._subtitle_autoselect_state = state + if (mp.get_time() or 0) > (state.deadline or 0) then + return false + end + + local current = _get_current_web_url() + local path = trim(tostring(mp.get_property('path') or '')) + if (not current or current == '') and (path == '' or not _is_http_url(path)) then + return false + end + + local track_id, source, already_selected = M._find_subtitle_track_candidate() + if not track_id then + return false + end + + pcall(mp.set_property, 'sub-visibility', 'yes') + if already_selected then + return true + end + + local ok = pcall(mp.set_property_native, 'sid', track_id) + if ok then + _lua_log('web-subtitles: selected subtitle track id=' .. tostring(track_id) .. ' source=' .. tostring(source or 'unknown') .. ' reason=' .. tostring(reason or 'auto')) + end + return ok and true or false +end + +function M._schedule_web_subtitle_activation(reason) + local state = M._subtitle_autoselect_state or { serial = 0, deadline = 0 } + M._subtitle_autoselect_state = state + state.serial = (state.serial or 0) + 1 + local serial = state.serial + state.deadline = (mp.get_time() or 0) + 12.0 + local delays = { 0.15, 0.75, 2.0, 5.0 } + for _, delay in ipairs(delays) do + mp.add_timeout(delay, function() + local current_state = M._subtitle_autoselect_state or {} + if serial ~= current_state.serial then + return + end + M._ensure_current_subtitles_visible((reason or 'file-loaded') .. '@' .. tostring(delay)) + end) + end +end + local function _sync_current_web_url_from_playback() local target = _current_target() local target_str = trim(tostring(target or '')) @@ -3327,6 +3795,14 @@ local function _sync_current_web_url_from_playback() end end +mp.add_hook('on_load', 50, function(hook) + local ok, err = pcall(M._apply_web_subtitle_load_defaults, 'on_load') + if not ok then + _lua_log('web-subtitles: on_load setup failed err=' .. tostring(err)) + end + hook:continue() +end) + local _current_store_url_status = { generation = 0, store = '', @@ -4252,6 +4728,8 @@ local function _open_save_location_picker_for_pending_download() return end + _ensure_selected_store_loaded() + local clip_range = trim(tostring(_pending_download.clip_range or '')) local title = clip_range ~= '' and ('Save clip ' .. clip_range) or 'Save location' @@ -4359,29 +4837,31 @@ local function _start_download_flow_for_current() local store_hash = _extract_store_hash(target) if store_hash then - if not _is_windows() then - mp.osd_message('Download folder picker is Windows-only', 4) - return - end - local folder = _pick_folder_windows() - if not folder or folder == '' then - return - end - ensure_mpv_ipc_server() - local pipeline_cmd = 'get-file -store ' .. quote_pipeline_arg(store_hash.store) .. ' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) .. ' -path ' .. quote_pipeline_arg(folder) - _queue_pipeline_in_repl( - pipeline_cmd, - 'Queued in REPL: store copy', - 'REPL queue failed', - 'download-store-copy', - { - mpv_notify = { - success_text = 'Copy completed: store ' .. tostring(store_hash.store), - failure_text = 'Copy failed: store ' .. tostring(store_hash.store), - duration_ms = 3500, - }, - } - ) + M._pick_folder_async(function(folder, err) + if err and err ~= '' then + mp.osd_message('Folder picker failed: ' .. tostring(err), 4) + return + end + if not folder or folder == '' then + return + end + + ensure_mpv_ipc_server() + local pipeline_cmd = 'get-file -store ' .. quote_pipeline_arg(store_hash.store) .. ' -query ' .. quote_pipeline_arg('hash:' .. store_hash.hash) .. ' -path ' .. quote_pipeline_arg(folder) + _queue_pipeline_in_repl( + pipeline_cmd, + 'Queued in REPL: store copy', + 'REPL queue failed', + 'download-store-copy', + { + mpv_notify = { + success_text = 'Copy completed: store ' .. tostring(store_hash.store), + failure_text = 'Copy failed: store ' .. tostring(store_hash.store), + duration_ms = 3500, + }, + } + ) + end) return end @@ -4542,6 +5022,8 @@ end) -- Prefetch formats for yt-dlp-supported URLs on load so Change Format is instant. mp.register_event('file-loaded', function() _sync_current_web_url_from_playback() + M._attempt_start_lyric_helper_async('file-loaded') + M._schedule_web_subtitle_activation('file-loaded') _refresh_current_store_url_status('file-loaded') local target = _current_url_for_web_actions() or _current_target() if not target or target == '' then @@ -4551,6 +5033,9 @@ mp.register_event('file-loaded', function() if not _is_http_url(url) then return end + mp.add_timeout(0.1, function() + M._reset_uosc_input_state('file-loaded-web') + end) local ok, err = _cache_formats_from_current_playback('file-loaded') if ok then _lua_log('formats: file-loaded cache succeeded for url=' .. url) @@ -4562,6 +5047,10 @@ mp.register_event('file-loaded', function() _prefetch_formats_for_url(url) end) +mp.observe_property('track-list', 'native', function() + M._ensure_current_subtitles_visible('observe-track-list') +end) + mp.observe_property('ytdl-raw-info', 'native', function(_name, value) if type(value) ~= 'table' then return @@ -4678,43 +5167,44 @@ mp.register_script_message('medios-download-pick-path', function() if type(_pending_download) ~= 'table' or not _pending_download.url or not _pending_download.format then return end - if not _is_windows() then - mp.osd_message('Folder picker is Windows-only', 4) - return - end - local folder = _pick_folder_windows() - if not folder or folder == '' then - return - end + M._pick_folder_async(function(folder, err) + if err and err ~= '' then + mp.osd_message('Folder picker failed: ' .. tostring(err), 4) + return + end + if not folder or folder == '' then + return + end - local url = tostring(_pending_download.url) - local fmt = tostring(_pending_download.format) - local clip_range = trim(tostring(_pending_download.clip_range or '')) - local query = 'format:' .. fmt - if clip_range ~= '' then - query = query .. ',clip:' .. clip_range - end - local clip_suffix = clip_range ~= '' and (' [' .. clip_range .. ']') or '' + local url = tostring(_pending_download.url) + local fmt = tostring(_pending_download.format) + local clip_range = trim(tostring(_pending_download.clip_range or '')) + local query = 'format:' .. fmt + if clip_range ~= '' then + query = query .. ',clip:' .. clip_range + end + local clip_suffix = clip_range ~= '' and (' [' .. clip_range .. ']') or '' - local pipeline_cmd = 'download-file -url ' .. quote_pipeline_arg(url) - .. ' -query ' .. quote_pipeline_arg(query) - .. ' | add-file -path ' .. quote_pipeline_arg(folder) + local pipeline_cmd = 'download-file -url ' .. quote_pipeline_arg(url) + .. ' -query ' .. quote_pipeline_arg(query) + .. ' | add-file -path ' .. quote_pipeline_arg(folder) - _queue_pipeline_in_repl( - pipeline_cmd, - 'Queued in REPL: save to folder', - 'REPL queue failed', - 'download-folder-save', - { - mpv_notify = { - success_text = 'Download completed: folder [' .. fmt .. ']' .. clip_suffix, - failure_text = 'Download failed: folder [' .. fmt .. ']' .. clip_suffix, - duration_ms = 3500, - }, - } - ) - _pending_download = nil + _queue_pipeline_in_repl( + pipeline_cmd, + 'Queued in REPL: save to folder', + 'REPL queue failed', + 'download-folder-save', + { + mpv_notify = { + success_text = 'Download completed: folder [' .. fmt .. ']' .. clip_suffix, + failure_text = 'Download failed: folder [' .. fmt .. ']' .. clip_suffix, + duration_ms = 3500, + }, + } + ) + _pending_download = nil + end) end) local function run_pipeline_via_ipc(pipeline_cmd, seeds, timeout_seconds) @@ -4956,10 +5446,8 @@ function M.open_load_url_prompt() items = {}, } - local json = utils.format_json(menu_data) - if ensure_uosc_loaded() then + if M._open_uosc_menu(menu_data, 'load-url-prompt') then _lua_log('open_load_url_prompt: sending menu to uosc') - mp.commandv('script-message-to', 'uosc', 'open-menu', json) else _lua_log('menu: uosc not available; cannot open-menu for load-url') end @@ -4994,10 +5482,7 @@ function M.open_cmd_menu() items = items, } - local json = utils.format_json(menu_data) - if ensure_uosc_loaded() then - mp.commandv('script-message-to', 'uosc', 'open-menu', json) - else + if not M._open_uosc_menu(menu_data, 'cmd-menu') then _lua_log('menu: uosc not available; cannot open cmd menu') end end @@ -5205,10 +5690,7 @@ function M.open_trim_prompt() } } - local json = utils.format_json(menu_data) - if ensure_uosc_loaded() then - mp.commandv('script-message-to', 'uosc', 'open-menu', json) - else + if not M._open_uosc_menu(menu_data, 'trim-prompt') then _lua_log('menu: uosc not available; cannot open trim prompt') end end @@ -5248,11 +5730,8 @@ end) mp.register_script_message('medios-load-url', function() _lua_log('medios-load-url handler called') - -- Close the main menu first - if ensure_uosc_loaded() then - _lua_log('medios-load-url: closing main menu before opening Load URL prompt') - mp.commandv('script-message-to', 'uosc', 'close-menu') - end + _lua_log('medios-load-url: resetting uosc input state before opening Load URL prompt') + M._reset_uosc_input_state('medios-load-url') M.open_load_url_prompt() end) @@ -5308,17 +5787,8 @@ mp.register_script_message('medios-load-url-event', function(json) _set_current_web_url(url) local function close_menu() - _lua_log('[LOAD-URL] Closing menu') - if ensure_uosc_loaded() then - _lua_log('[LOAD-URL] Sending close-menu command to UOSC') - mp.commandv('script-message-to', 'uosc', 'close-menu', LOAD_URL_MENU_TYPE) - mp.add_timeout(0.05, function() - if ensure_uosc_loaded() then - _lua_log('[LOAD-URL] Requesting UOSC cursor sync after menu close') - pcall(mp.commandv, 'script-message-to', 'uosc', 'sync-cursor') - end - end) - else + _lua_log('[LOAD-URL] Closing menu and resetting input state') + if not M._reset_uosc_input_state('load-url-submit') then _lua_log('[LOAD-URL] UOSC not loaded, cannot close menu') end end @@ -5419,6 +5889,7 @@ end) -- Menu integration with UOSC function M.show_menu() _lua_log('[MENU] M.show_menu called') + M._reset_uosc_input_state('main-menu') local target = _current_target() local selected_store = trim(tostring(_get_selected_store() or '')) @@ -5462,29 +5933,11 @@ function M.show_menu() local json = utils.format_json(menu_data) _lua_log('[MENU] Sending menu JSON to uosc: ' .. string.sub(json, 1, 200) .. '...') - - -- Try to open menu via uosc script message - -- Note: UOSC expects JSON data as a string parameter - local ok, err = pcall(function() - -- Method 1: Try commandv with individual arguments - mp.commandv('script-message-to', 'uosc', 'open-menu', json) - end) - - if not ok then - _lua_log('[MENU] Method 1 failed: ' .. tostring(err)) - -- Method 2: Try command with full string - local cmd = 'script-message-to uosc open-menu ' .. json - local ok2, err2 = pcall(function() - mp.command(cmd) - end) - if not ok2 then - _lua_log('[MENU] Method 2 failed: ' .. tostring(err2)) - mp.osd_message('Menu error', 3) - else - _lua_log('[MENU] Menu command sent via method 2') - end - else + if M._open_uosc_menu(menu_data, 'main-menu') then _lua_log('[MENU] Menu command sent successfully') + else + _lua_log('[MENU] Failed to send menu command') + mp.osd_message('Menu error', 3) end end @@ -5532,6 +5985,13 @@ mp.add_timeout(0, function() if not ok_helper then _lua_log('helper-auto-start raised: ' .. tostring(helper_err)) end + + local ok_lyric, lyric_err = pcall(function() + M._attempt_start_lyric_helper_async('startup') + end) + if not ok_lyric then + _lua_log('lyric-helper auto-start raised: ' .. tostring(lyric_err)) + end -- Try to re-register right-click after UOSC loads (might override its binding) mp.add_timeout(1.0, function() diff --git a/MPV/pipeline_helper.py b/MPV/pipeline_helper.py index 704db71..b641f0a 100644 --- a/MPV/pipeline_helper.py +++ b/MPV/pipeline_helper.py @@ -20,7 +20,7 @@ This helper is intentionally minimal: one request at a time, last-write-wins. from __future__ import annotations -MEDEIA_MPV_HELPER_VERSION = "2026-03-22.6" +MEDEIA_MPV_HELPER_VERSION = "2026-03-23.1" import argparse import json @@ -1197,6 +1197,17 @@ def _acquire_ipc_lock(ipc_path: str) -> Optional[Any]: lock_path = _get_ipc_lock_path(ipc_path) fh = open(lock_path, "a+", encoding="utf-8", errors="replace") + # On Windows, locking a zero-length file can fail even when no process + # actually owns the lock anymore. Prime the file with a single byte so + # stale empty lock files do not wedge future helper startups. + try: + fh.seek(0, os.SEEK_END) + if fh.tell() < 1: + fh.write("\n") + fh.flush() + except Exception: + pass + if os.name == "nt": try: import msvcrt # type: ignore diff --git a/MPV/portable_config/script-opts/medeia-store-cache.json b/MPV/portable_config/script-opts/medeia-store-cache.json new file mode 100644 index 0000000..6390e69 --- /dev/null +++ b/MPV/portable_config/script-opts/medeia-store-cache.json @@ -0,0 +1 @@ +{"choices":["local","rpi"]} \ No newline at end of file diff --git a/MPV/portable_config/script-opts/medeia.conf b/MPV/portable_config/script-opts/medeia.conf index 7606177..3d30edf 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=local diff --git a/Provider/internetarchive.py b/Provider/internetarchive.py index 2f57978..26f7023 100644 --- a/Provider/internetarchive.py +++ b/Provider/internetarchive.py @@ -361,6 +361,44 @@ def is_download_file_url(url: str) -> bool: ) +def _archive_item_access(identifier: str) -> Dict[str, Any]: + ident = str(identifier or "").strip() + if not ident: + return {"mediatype": "", "lendable": False, "collection": []} + + session = requests.Session() + try: + response = session.get(f"https://archive.org/metadata/{ident}", timeout=8) + response.raise_for_status() + data = response.json() if response is not None else {} + except Exception: + return {"mediatype": "", "lendable": False, "collection": []} + finally: + try: + session.close() + except Exception: + pass + + meta = data.get("metadata", {}) if isinstance(data, dict) else {} + if not isinstance(meta, dict): + meta = {} + + mediatype = str(meta.get("mediatype") or "").strip().lower() + collection = meta.get("collection") + values: List[str] = [] + if isinstance(collection, list): + values = [str(x).strip().lower() for x in collection if str(x).strip()] + elif isinstance(collection, str) and collection.strip(): + values = [collection.strip().lower()] + + lendable = any(v in {"inlibrary", "lendinglibrary"} for v in values) + return { + "mediatype": mediatype, + "lendable": lendable, + "collection": values, + } + + def list_download_files(identifier: str) -> List[Dict[str, Any]]: """Return a sorted list of downloadable files for an IA identifier. @@ -620,6 +658,11 @@ class InternetArchive(Provider): quiet_mode: bool, ) -> Optional[int]: """Generic hook for download-file to show a selection table for IA items.""" + try: + if self._should_delegate_borrow(str(url or "")): + return None + except Exception: + pass from SYS.field_access import get_field as sh_get_field return maybe_show_formats_table( raw_urls=[url] if url else [], @@ -638,6 +681,72 @@ class InternetArchive(Provider): self._collection = conf.get("collection") or conf.get("default_collection") self._mediatype = conf.get("mediatype") or conf.get("default_mediatype") + @staticmethod + def _should_delegate_borrow(url: str) -> bool: + raw = str(url or "").strip() + if not is_details_url(raw): + return False + identifier = extract_identifier(raw) + if not identifier: + return False + access = _archive_item_access(identifier) + return bool(access.get("lendable")) and str(access.get("mediatype") or "") == "texts" + + def _download_via_openlibrary(self, url: str, output_dir: Path) -> Optional[Dict[str, Any]]: + try: + from Provider.openlibrary import OpenLibrary + except Exception as exc: + log(f"[internetarchive] OpenLibrary borrow helper unavailable: {exc}", file=sys.stderr) + return None + + provider = OpenLibrary(self.config) + try: + result = provider.download_url(url, output_dir) + finally: + try: + session = getattr(provider, "_session", None) + if session is not None: + session.close() + except Exception: + pass + + if not isinstance(result, dict): + return result + + search_result = result.get("search_result") + metadata: Dict[str, Any] = {} + title = None + tags: List[str] = [] + if search_result is not None: + try: + title = str(getattr(search_result, "title", "") or "").strip() or None + except Exception: + title = None + try: + metadata = dict(getattr(search_result, "full_metadata", {}) or {}) + except Exception: + metadata = {} + try: + tags_val = getattr(search_result, "tag", None) + if isinstance(tags_val, set): + tags = [str(t) for t in sorted(tags_val) if t] + elif isinstance(tags_val, list): + tags = [str(t) for t in tags_val if t] + except Exception: + tags = [] + + normalized: Dict[str, Any] = {"path": result.get("path")} + if metadata: + normalized["metadata"] = metadata + normalized["full_metadata"] = metadata + if title: + normalized["title"] = title + if tags: + normalized["tags"] = tags + normalized["media_kind"] = "book" + normalized["provider_action"] = "borrow" + return normalized + def validate(self) -> bool: try: _ia() @@ -824,13 +933,18 @@ class InternetArchive(Provider): return out - def download_url(self, url: str, output_dir: Path) -> Optional[Path]: + def download_url(self, url: str, output_dir: Path) -> Optional[Any]: """Download an Internet Archive URL. Supports: - https://archive.org/details/ - https://archive.org/download// """ + if self._should_delegate_borrow(url): + delegated = self._download_via_openlibrary(url, output_dir) + if delegated is not None: + return delegated + sr = SearchResult( table="internetarchive", title=str(url), @@ -842,6 +956,15 @@ class InternetArchive(Provider): def download(self, result: SearchResult, output_dir: Path) -> Optional[Path]: raw_path = str(getattr(result, "path", "") or "").strip() + if self._should_delegate_borrow(raw_path): + delegated = self._download_via_openlibrary(raw_path, output_dir) + if isinstance(delegated, dict): + delegated_path = delegated.get("path") + if delegated_path: + return Path(str(delegated_path)) + if isinstance(delegated, (str, Path)): + return Path(str(delegated)) + # Fast path for explicit IA file URLs. # This uses the shared direct downloader, which already integrates with # pipeline transfer progress bars. diff --git a/Provider/openlibrary.py b/Provider/openlibrary.py index e56173a..448094d 100644 --- a/Provider/openlibrary.py +++ b/Provider/openlibrary.py @@ -210,6 +210,135 @@ def _resolve_archive_id( return "" +def _fetch_openlibrary_edition_metadata( + session: requests.Session, + edition_id: str, +) -> Dict[str, Any]: + if not edition_id: + return {} + + try: + resp = session.get( + f"https://openlibrary.org/books/{edition_id}.json", + timeout=6, + ) + resp.raise_for_status() + data = resp.json() or {} + except Exception: + return {} + + if not isinstance(data, dict): + return {} + + identifiers = data.get("identifiers") + if not isinstance(identifiers, dict): + identifiers = {} + + def _first_clean(value: Any) -> str: + raw = _first_str(value) + return str(raw or "").strip() + + isbn_10 = _first_clean(identifiers.get("isbn_10")) + isbn_13 = _first_clean(identifiers.get("isbn_13")) + archive_id = str(data.get("ocaid") or "").strip() + if not archive_id: + archive_id = _first_clean(identifiers.get("internet_archive")) + + out: Dict[str, Any] = { + "openlibrary_id": str(edition_id).strip(), + "openlibrary": str(edition_id).strip(), + } + if isbn_10: + out["isbn_10"] = isbn_10 + if isbn_13: + out["isbn_13"] = isbn_13 + if archive_id: + out["archive_id"] = archive_id + return out + + +def _select_preferred_isbns(values: Any) -> Tuple[str, str]: + items: List[Any] + if isinstance(values, list): + items = values + elif values in (None, ""): + items = [] + else: + items = [values] + + isbn_10 = "" + isbn_13 = "" + for raw in items: + token = re.sub(r"[^0-9Xx]", "", str(raw or "")).upper().strip() + if not token: + continue + if len(token) == 13 and not isbn_13: + isbn_13 = token + elif len(token) == 10 and not isbn_10: + isbn_10 = token + return isbn_10, isbn_13 + + +def _build_pipeline_progress_callback( + progress: Any, + title: str, +) -> Callable[[str, int, Optional[int], str], None]: + transfer_label = str(title or "book").strip() or "book" + state = {"active": False, "finished": False} + + def _ensure_started(total: Optional[int]) -> None: + if state["active"]: + return + try: + progress.begin_transfer(label=transfer_label, total=total) + state["active"] = True + state["finished"] = False + except Exception: + pass + + def _finish() -> None: + if not state["active"] or state["finished"]: + return + try: + progress.finish_transfer(label=transfer_label) + except Exception: + pass + state["finished"] = True + state["active"] = False + + def _callback(kind: str, completed: int, total: Optional[int], label: str) -> None: + text = str(label or kind or "download").strip() or "download" + try: + progress.set_status(f"openlibrary: {text}") + except Exception: + pass + + if kind == "step": + if text != "download pages": + _finish() + return + + if kind in {"pages", "bytes"}: + _ensure_started(total) + try: + progress.update_transfer( + label=transfer_label, + completed=int(completed) if completed is not None else None, + total=int(total) if total is not None else None, + ) + except Exception: + pass + if total is not None: + try: + if int(completed) >= int(total): + _finish() + except Exception: + pass + + setattr(_callback, "_finish_transfer", _finish) + return _callback + + def _archive_id_from_url(url: str) -> str: """Best-effort extraction of an Archive.org item identifier from a URL.""" @@ -1082,6 +1211,12 @@ class OpenLibrary(Provider): meta = result.full_metadata or {} edition_id = str(meta.get("openlibrary_id") or "").strip() + edition_meta = _fetch_openlibrary_edition_metadata(self._session, edition_id) + if edition_meta and isinstance(meta, dict): + for key, value in edition_meta.items(): + if value and not meta.get(key): + meta[key] = value + result.full_metadata = meta # Accept direct Archive.org URLs too (details/borrow/download) even when no OL edition id is known. archive_id = str(meta.get("archive_id") or "").strip() @@ -1097,7 +1232,9 @@ class OpenLibrary(Provider): archive_id = _first_str(ia_candidates) or "" if not archive_id and edition_id: - archive_id = _resolve_archive_id(self._session, edition_id, ia_candidates) + archive_id = str(edition_meta.get("archive_id") or "").strip() + if not archive_id: + archive_id = _resolve_archive_id(self._session, edition_id, ia_candidates) if not archive_id: # Try to extract identifier from the SearchResult path (URL). @@ -1114,17 +1251,49 @@ class OpenLibrary(Provider): try: archive_meta = fetch_archive_item_metadata(archive_id) tags = archive_item_metadata_to_tags(archive_id, archive_meta) + if edition_id: + tags.append(f"openlibrary:{edition_id}") if tags: try: result.tag.update(tags) except Exception: # Fallback for callers that pass plain dicts. pass + + isbn_10 = str(meta.get("isbn_10") or edition_meta.get("isbn_10") or "").strip() + isbn_13 = str(meta.get("isbn_13") or edition_meta.get("isbn_13") or "").strip() + if not isbn_10 and not isbn_13: + isbn_10, isbn_13 = _select_preferred_isbns(archive_meta.get("isbn")) + if isinstance(meta, dict): meta["archive_id"] = archive_id if archive_meta: meta["archive_metadata"] = archive_meta + if edition_id: + meta.setdefault("openlibrary_id", edition_id) + meta.setdefault("openlibrary", edition_id) + if isbn_10: + meta.setdefault("isbn_10", isbn_10) + if isbn_13: + meta.setdefault("isbn_13", isbn_13) + if not meta.get("isbn"): + meta["isbn"] = isbn_13 or isbn_10 result.full_metadata = meta + + extra_identifier_tags: List[str] = [] + if edition_id: + extra_identifier_tags.append(f"openlibrary:{edition_id}") + if isbn_13: + extra_identifier_tags.append(f"isbn_13:{isbn_13}") + extra_identifier_tags.append(f"isbn:{isbn_13}") + elif isbn_10: + extra_identifier_tags.append(f"isbn_10:{isbn_10}") + extra_identifier_tags.append(f"isbn:{isbn_10}") + if extra_identifier_tags: + try: + result.tag.update(extra_identifier_tags) + except Exception: + pass except Exception: # Never block downloads on metadata fetch. pass @@ -1133,6 +1302,13 @@ class OpenLibrary(Provider): if not safe_title or "http" in safe_title.lower(): safe_title = sanitize_filename(archive_id) or "archive" + internal_progress_finish = None + if progress_callback is None and isinstance(self.config, dict): + pipeline_progress = self.config.get("_pipeline_progress") + if pipeline_progress is not None: + progress_callback = _build_pipeline_progress_callback(pipeline_progress, safe_title) + internal_progress_finish = getattr(progress_callback, "_finish_transfer", None) + # 1) Direct download if available. try: can_direct, pdf_url = self._archive_check_direct_download(archive_id) @@ -1318,6 +1494,12 @@ class OpenLibrary(Provider): except Exception as exc: log(f"[openlibrary] Borrow workflow error: {exc}", file=sys.stderr) return None + finally: + if callable(internal_progress_finish): + try: + internal_progress_finish() + except Exception: + pass def validate(self) -> bool: return True