fdf
This commit is contained in:
686
MPV/LUA/main.lua
686
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
|
||||
|
||||
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')
|
||||
|
||||
@@ -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
|
||||
|
||||
1
MPV/portable_config/script-opts/medeia-store-cache.json
Normal file
1
MPV/portable_config/script-opts/medeia-store-cache.json
Normal file
@@ -0,0 +1 @@
|
||||
{"choices":["local","rpi"]}
|
||||
@@ -1,2 +1,2 @@
|
||||
# Medeia MPV script options
|
||||
store=rpi
|
||||
store=local
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user