This commit is contained in:
2026-03-22 22:41:56 -07:00
parent 67ba6cb3d1
commit 23a73a94e6
6 changed files with 956 additions and 179 deletions

View File

@@ -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
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)
M._pick_folder_async(function(folder, err)
if err and err ~= '' then
mp.osd_message('Folder picker failed: ' .. tostring(err), 4)
return
end
local folder = _pick_folder_windows()
if not folder or folder == '' then
return
end
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,8 +3041,141 @@ local function _extract_store_hash(target)
return nil
end
_pick_folder_windows = function()
-- Native folder picker via PowerShell + WinForms.
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
@@ -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
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
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
return nil
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,14 +4837,15 @@ 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)
M._pick_folder_async(function(folder, err)
if err and err ~= '' then
mp.osd_message('Folder picker failed: ' .. tostring(err), 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(
@@ -4382,6 +4861,7 @@ local function _start_download_flow_for_current()
},
}
)
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,12 +5167,12 @@ 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)
M._pick_folder_async(function(folder, err)
if err and err ~= '' then
mp.osd_message('Folder picker failed: ' .. tostring(err), 4)
return
end
local folder = _pick_folder_windows()
if not folder or folder == '' then
return
end
@@ -4715,6 +5204,7 @@ mp.register_script_message('medios-download-pick-path', function()
}
)
_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
@@ -5533,6 +5986,13 @@ mp.add_timeout(0, function()
_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()
_lua_log('[KEY] attempting to re-register mbtn_right after UOSC loaded')

View File

@@ -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

View File

@@ -0,0 +1 @@
{"choices":["local","rpi"]}

View File

@@ -1,2 +1,2 @@
# Medeia MPV script options
store=rpi
store=local

View File

@@ -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/<identifier>
- https://archive.org/download/<identifier>/<filename>
"""
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.

View File

@@ -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,6 +1232,8 @@ class OpenLibrary(Provider):
archive_id = _first_str(ia_candidates) or ""
if not archive_id and edition_id:
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:
@@ -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