This commit is contained in:
nose
2025-12-19 02:29:42 -08:00
parent d637532237
commit 52cf3f5c9f
24 changed files with 1284 additions and 176 deletions

View File

@@ -268,6 +268,9 @@ local _cached_store_names = {}
local _store_cache_loaded = false
local _pipeline_helper_started = false
local _last_ipc_error = ''
local _last_ipc_last_req_json = ''
local _last_ipc_last_resp_json = ''
local function _is_pipeline_helper_ready()
local ready = mp.get_property_native(PIPELINE_READY_PROP)
@@ -431,8 +434,10 @@ end
local ensure_pipeline_helper_running
local function _run_helper_request_response(req, timeout_seconds)
_last_ipc_error = ''
if not ensure_pipeline_helper_running() then
_lua_log('ipc: helper not running; cannot execute request')
_last_ipc_error = 'helper not running'
return nil
end
@@ -445,7 +450,9 @@ local function _run_helper_request_response(req, timeout_seconds)
mp.wait_event(0.05)
end
if not _is_pipeline_helper_ready() then
_lua_log('ipc: helper not ready; ready=' .. tostring(mp.get_property_native(PIPELINE_READY_PROP)))
local rv = tostring(mp.get_property_native(PIPELINE_READY_PROP))
_lua_log('ipc: helper not ready; ready=' .. rv)
_last_ipc_error = 'helper not ready (ready=' .. rv .. ')'
_pipeline_helper_started = false
return nil
end
@@ -471,13 +478,21 @@ local function _run_helper_request_response(req, timeout_seconds)
end
_lua_log('ipc: send request id=' .. tostring(id) .. ' ' .. label)
local req_json = utils.format_json(req)
_last_ipc_last_req_json = req_json
mp.set_property(PIPELINE_RESP_PROP, '')
mp.set_property(PIPELINE_REQ_PROP, utils.format_json(req))
mp.set_property(PIPELINE_REQ_PROP, req_json)
-- Read-back for debugging: confirms MPV accepted the property write.
local echoed = mp.get_property(PIPELINE_REQ_PROP) or ''
if echoed == '' then
_lua_log('ipc: WARNING request property echoed empty after set')
end
local deadline = mp.get_time() + (timeout_seconds or 5)
while mp.get_time() < deadline do
local resp_json = mp.get_property(PIPELINE_RESP_PROP)
if resp_json and resp_json ~= '' then
_last_ipc_last_resp_json = resp_json
local ok, resp = pcall(utils.parse_json, resp_json)
if ok and resp and resp.id == id then
_lua_log('ipc: got response id=' .. tostring(id) .. ' success=' .. tostring(resp.success))
@@ -488,6 +503,7 @@ local function _run_helper_request_response(req, timeout_seconds)
end
_lua_log('ipc: timeout waiting response; ' .. label)
_last_ipc_error = 'timeout waiting response (' .. label .. ')'
_pipeline_helper_started = false
return nil
end
@@ -593,12 +609,39 @@ end)
local _pending_download = nil
local _pending_format_change = nil
-- Per-file state (class-like) for format caching.
local FileState = {}
FileState.__index = FileState
function FileState.new()
return setmetatable({
url = nil,
formats = nil,
formats_table = nil, -- back-compat alias
}, FileState)
end
function FileState:has_formats()
return type(self.formats) == 'table'
and type(self.formats.rows) == 'table'
and #self.formats.rows > 0
end
function FileState:set_formats(url, tbl)
self.url = url
self.formats = tbl
self.formats_table = tbl
end
M.file = M.file or FileState.new()
-- Cache yt-dlp format lists per URL so Change Format is instant.
M.file = M.file or {}
M.file.formats_table = nil
M.file.url = nil
local _formats_cache = {}
local _formats_inflight = {}
local _formats_waiters = {}
local _ipc_async_busy = false
local _ipc_async_queue = {}
local function _is_http_url(u)
if type(u) ~= 'string' then
@@ -615,8 +658,13 @@ local function _cache_formats_for_url(url, tbl)
return
end
_formats_cache[url] = { table = tbl, ts = mp.get_time() }
M.file.url = url
M.file.formats_table = tbl
if type(M.file) == 'table' and M.file.set_formats then
M.file:set_formats(url, tbl)
else
M.file.url = url
M.file.formats = tbl
M.file.formats_table = tbl
end
end
local function _get_cached_formats_table(url)
@@ -630,42 +678,175 @@ local function _get_cached_formats_table(url)
return nil
end
local function _prefetch_formats_for_url(url)
url = tostring(url or '')
local function _run_helper_request_async(req, timeout_seconds, cb)
cb = cb or function() end
if _ipc_async_busy then
_ipc_async_queue[#_ipc_async_queue + 1] = { req = req, timeout = timeout_seconds, cb = cb }
return
end
_ipc_async_busy = true
local function done(resp, err)
_ipc_async_busy = false
cb(resp, err)
if #_ipc_async_queue > 0 then
local next_job = table.remove(_ipc_async_queue, 1)
-- Schedule next job slightly later to let mpv deliver any pending events.
mp.add_timeout(0.01, function()
_run_helper_request_async(next_job.req, next_job.timeout, next_job.cb)
end)
end
end
if type(req) ~= 'table' then
done(nil, 'invalid request')
return
end
ensure_mpv_ipc_server()
if not ensure_pipeline_helper_running() then
done(nil, 'helper not running')
return
end
-- Assign id.
local id = tostring(req.id or '')
if id == '' then
id = tostring(math.floor(mp.get_time() * 1000)) .. '-' .. tostring(math.random(100000, 999999))
req.id = id
end
local label = ''
if req.op then
label = 'op=' .. tostring(req.op)
elseif req.pipeline then
label = 'cmd=' .. tostring(req.pipeline)
else
label = '(unknown)'
end
-- Wait for helper READY without blocking the UI.
local ready_deadline = mp.get_time() + 3.0
local ready_timer
ready_timer = mp.add_periodic_timer(0.05, function()
if _is_pipeline_helper_ready() then
ready_timer:kill()
_lua_log('ipc-async: send request id=' .. tostring(id) .. ' ' .. label)
local req_json = utils.format_json(req)
_last_ipc_last_req_json = req_json
mp.set_property(PIPELINE_RESP_PROP, '')
mp.set_property(PIPELINE_REQ_PROP, req_json)
local deadline = mp.get_time() + (timeout_seconds or 5)
local poll_timer
poll_timer = mp.add_periodic_timer(0.05, function()
if mp.get_time() >= deadline then
poll_timer:kill()
done(nil, 'timeout waiting response (' .. label .. ')')
return
end
local resp_json = mp.get_property(PIPELINE_RESP_PROP)
if resp_json and resp_json ~= '' then
_last_ipc_last_resp_json = resp_json
local ok, resp = pcall(utils.parse_json, resp_json)
if ok and resp and resp.id == id then
poll_timer:kill()
_lua_log('ipc-async: got response id=' .. tostring(id) .. ' success=' .. tostring(resp.success))
done(resp, nil)
end
end
end)
return
end
if mp.get_time() >= ready_deadline then
ready_timer:kill()
done(nil, 'helper not ready')
return
end
end)
end
function FileState:fetch_formats(cb)
local url = tostring(self.url or '')
if url == '' or not _is_http_url(url) then
if cb then cb(false, 'not a url') end
return
end
-- Only applies to plain URLs (not store hash URLs).
if _extract_store_hash(url) then
if cb then cb(false, 'store-hash url') end
return
end
if _get_cached_formats_table(url) then
-- Cache hit.
local cached = _get_cached_formats_table(url)
if type(cached) == 'table' then
self:set_formats(url, cached)
if cb then cb(true, nil) end
return
end
-- In-flight: register waiter.
if _formats_inflight[url] then
_formats_waiters[url] = _formats_waiters[url] or {}
if cb then table.insert(_formats_waiters[url], cb) end
return
end
_formats_inflight[url] = true
_formats_waiters[url] = _formats_waiters[url] or {}
if cb then table.insert(_formats_waiters[url], cb) end
mp.add_timeout(0.01, function()
if _get_cached_formats_table(url) then
_formats_inflight[url] = nil
return
end
ensure_mpv_ipc_server()
local resp = _run_helper_request_response({ op = 'ytdlp-formats', data = { url = url } }, 20)
-- Async request so the UI never blocks.
_run_helper_request_async({ op = 'ytdlp-formats', data = { url = url } }, 90, function(resp, err)
_formats_inflight[url] = nil
local ok = false
local reason = err
if resp and resp.success and type(resp.table) == 'table' then
ok = true
reason = nil
self:set_formats(url, resp.table)
_cache_formats_for_url(url, resp.table)
_lua_log('formats: cached ' .. tostring((resp.table.rows and #resp.table.rows) or 0) .. ' rows for url')
else
if type(resp) == 'table' then
if resp.error and tostring(resp.error) ~= '' then
reason = tostring(resp.error)
elseif resp.stderr and tostring(resp.stderr) ~= '' then
reason = tostring(resp.stderr)
end
end
end
local waiters = _formats_waiters[url] or {}
_formats_waiters[url] = nil
for _, fn in ipairs(waiters) do
pcall(fn, ok, reason)
end
end)
end
local function _prefetch_formats_for_url(url)
url = tostring(url or '')
if url == '' or not _is_http_url(url) then
return
end
if type(M.file) == 'table' then
M.file.url = url
if M.file.fetch_formats then
M.file:fetch_formats(nil)
end
end
end
local function _open_loading_formats_menu(title)
_uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, title or 'Pick format', {
{
@@ -676,6 +857,34 @@ local function _open_loading_formats_menu(title)
})
end
local function _debug_dump_formatted_formats(url, tbl, items)
local row_count = 0
if type(tbl) == 'table' and type(tbl.rows) == 'table' then
row_count = #tbl.rows
end
local item_count = 0
if type(items) == 'table' then
item_count = #items
end
_lua_log('formats-dump: url=' .. tostring(url or '') .. ' rows=' .. tostring(row_count) .. ' menu_items=' .. tostring(item_count))
-- Dump the formatted picker items (first 30) so we can confirm the
-- list is being built and looks sane.
if type(items) == 'table' then
local limit = 30
for i = 1, math.min(#items, limit) do
local it = items[i] or {}
local title = tostring(it.title or '')
local hint = tostring(it.hint or '')
_lua_log('formats-item[' .. tostring(i) .. ']: ' .. title .. (hint ~= '' and (' | ' .. hint) or ''))
end
if #items > limit then
_lua_log('formats-dump: (truncated; total=' .. tostring(#items) .. ')')
end
end
end
local function _current_ytdl_format_string()
-- Preferred: mpv exposes the active ytdl format string.
local fmt = trim(tostring(mp.get_property_native('ytdl-format') or ''))
@@ -857,8 +1066,18 @@ mp.register_script_message('medios-change-format-current', function()
local url = tostring(target)
-- Ensure file state is tracking the current URL.
if type(M.file) == 'table' then
M.file.url = url
end
-- If formats were already prefetched for this URL, open instantly.
local cached_tbl = _get_cached_formats_table(url)
local cached_tbl = nil
if type(M.file) == 'table' and type(M.file.formats) == 'table' then
cached_tbl = M.file.formats
else
cached_tbl = _get_cached_formats_table(url)
end
if type(cached_tbl) == 'table' and type(cached_tbl.rows) == 'table' and #cached_tbl.rows > 0 then
_pending_format_change = { url = url, token = 'cached', formats_table = cached_tbl }
@@ -890,6 +1109,7 @@ mp.register_script_message('medios-change-format-current', function()
}
end
_debug_dump_formatted_formats(url, cached_tbl, items)
_uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, 'Change format', items)
return
end
@@ -898,77 +1118,69 @@ mp.register_script_message('medios-change-format-current', function()
_pending_format_change = { url = url, token = token }
_open_loading_formats_menu('Change format')
mp.add_timeout(0.05, function()
if type(_pending_format_change) ~= 'table' or _pending_format_change.token ~= token then
return
end
ensure_mpv_ipc_server()
_lua_log('change-format: requesting formats via helper op for url')
local resp = _run_helper_request_response({ op = 'ytdlp-formats', data = { url = url } }, 30)
if type(_pending_format_change) ~= 'table' or _pending_format_change.token ~= token then
return
end
if not resp or not resp.success or type(resp.table) ~= 'table' then
local err = ''
if type(resp) == 'table' then
if resp.error and tostring(resp.error) ~= '' then err = tostring(resp.error) end
if resp.stderr and tostring(resp.stderr) ~= '' then
err = (err ~= '' and (err .. ' | ') or '') .. tostring(resp.stderr)
-- Non-blocking: ask the per-file state to fetch formats in the background.
if type(M.file) == 'table' and M.file.fetch_formats then
_lua_log('change-format: formats not cached yet; fetching in background')
M.file:fetch_formats(function(ok, err)
if type(_pending_format_change) ~= 'table' or _pending_format_change.token ~= token then
return
end
if not ok then
local msg2 = tostring(err or '')
if msg2 == '' then
msg2 = 'unknown'
end
_lua_log('change-format: formats failed: ' .. msg2)
mp.osd_message('Failed to load format list: ' .. msg2, 7)
_uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, 'Change format', {
{
title = 'Failed to load format list',
hint = msg2,
value = { 'script-message-to', mp.get_script_name(), 'medios-nop', '{}' },
},
})
return
end
_lua_log('change-format: formats failed: ' .. (err ~= '' and err or '(no details)'))
mp.osd_message('Failed to load format list', 5)
_uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, 'Change format', {
{
title = 'Failed to load format list',
hint = 'Check logs (medeia-mpv-lua.log / medeia-mpv-helper.log)',
value = { 'script-message-to', mp.get_script_name(), 'medios-nop', '{}' },
},
})
return
end
local tbl = resp.table
if type(tbl.rows) ~= 'table' or #tbl.rows == 0 then
mp.osd_message('No formats available', 4)
return
end
local items = {}
for idx, row in ipairs(tbl.rows) do
local cols = row.columns or {}
local id_val = ''
local res_val = ''
local ext_val = ''
local size_val = ''
for _, c in ipairs(cols) do
if c.name == 'ID' then id_val = tostring(c.value or '') end
if c.name == 'Resolution' then res_val = tostring(c.value or '') end
if c.name == 'Ext' then ext_val = tostring(c.value or '') end
if c.name == 'Size' then size_val = tostring(c.value or '') end
local tbl = (type(M.file.formats) == 'table') and M.file.formats or _get_cached_formats_table(url)
if type(tbl) ~= 'table' or type(tbl.rows) ~= 'table' or #tbl.rows == 0 then
mp.osd_message('No formats available', 4)
return
end
local label = id_val ~= '' and id_val or ('Format ' .. tostring(idx))
local hint_parts = {}
if res_val ~= '' and res_val ~= 'N/A' then table.insert(hint_parts, res_val) end
if ext_val ~= '' then table.insert(hint_parts, ext_val) end
if size_val ~= '' and size_val ~= 'N/A' then table.insert(hint_parts, size_val) end
local hint = table.concat(hint_parts, ' | ')
local payload = { index = idx }
items[#items + 1] = {
title = label,
hint = hint,
value = { 'script-message-to', mp.get_script_name(), 'medios-change-format-pick', utils.format_json(payload) },
}
end
local items = {}
for idx, row in ipairs(tbl.rows) do
local cols = row.columns or {}
local id_val = ''
local res_val = ''
local ext_val = ''
local size_val = ''
for _, c in ipairs(cols) do
if c.name == 'ID' then id_val = tostring(c.value or '') end
if c.name == 'Resolution' then res_val = tostring(c.value or '') end
if c.name == 'Ext' then ext_val = tostring(c.value or '') end
if c.name == 'Size' then size_val = tostring(c.value or '') end
end
local label = id_val ~= '' and id_val or ('Format ' .. tostring(idx))
local hint_parts = {}
if res_val ~= '' and res_val ~= 'N/A' then table.insert(hint_parts, res_val) end
if ext_val ~= '' then table.insert(hint_parts, ext_val) end
if size_val ~= '' and size_val ~= 'N/A' then table.insert(hint_parts, size_val) end
local hint = table.concat(hint_parts, ' | ')
_pending_format_change.formats_table = tbl
_cache_formats_for_url(url, tbl)
_uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, 'Change format', items)
end)
local payload = { index = idx }
items[#items + 1] = {
title = label,
hint = hint,
value = { 'script-message-to', mp.get_script_name(), 'medios-change-format-pick', utils.format_json(payload) },
}
end
_pending_format_change.formats_table = tbl
_debug_dump_formatted_formats(url, tbl, items)
_uosc_open_list_picker(DOWNLOAD_FORMAT_MENU_TYPE, 'Change format', items)
end)
end
end)
-- Prefetch formats for yt-dlp-supported URLs on load so Change Format is instant.

View File

@@ -575,6 +575,46 @@ class MPV:
debug("Starting MPV")
subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, **kwargs)
# Start the persistent pipeline helper eagerly so MPV Lua can issue
# non-blocking requests (e.g., format list prefetch) without needing
# to spawn the helper on-demand from inside mpv.
try:
helper_path = (repo_root / "MPV" / "pipeline_helper.py").resolve()
if helper_path.exists():
py = sys.executable or "python"
helper_cmd = [
py,
str(helper_path),
"--ipc",
str(self.ipc_path),
"--timeout",
"30",
]
helper_kwargs: Dict[str, Any] = {}
if platform.system() == "Windows":
flags = 0
try:
flags |= int(getattr(subprocess, "DETACHED_PROCESS", 0x00000008))
except Exception:
flags |= 0x00000008
try:
flags |= int(getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000))
except Exception:
flags |= 0x08000000
helper_kwargs["creationflags"] = flags
helper_kwargs.update({k: v for k, v in _windows_hidden_subprocess_kwargs().items() if k != "creationflags"})
subprocess.Popen(
helper_cmd,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
**helper_kwargs,
)
except Exception:
pass
def get_ipc_pipe_path() -> str:
"""Get the fixed IPC pipe/socket path for persistent MPV connection.

View File

@@ -29,6 +29,7 @@ import tempfile
import time
import logging
import re
import hashlib
from pathlib import Path
from typing import Any, Dict, Optional
@@ -259,6 +260,53 @@ def _run_op(op: str, data: Any) -> Dict[str, Any]:
with yt_dlp.YoutubeDL(ydl_opts) as ydl: # type: ignore[attr-defined]
info = ydl.extract_info(url, download=False)
# Debug: dump a short summary of the format list to the helper log.
try:
formats_any = info.get("formats") if isinstance(info, dict) else None
count = len(formats_any) if isinstance(formats_any, list) else 0
_append_helper_log(f"[ytdlp-formats] extracted formats count={count} url={url}")
if isinstance(formats_any, list) and formats_any:
limit = 60
for i, f in enumerate(formats_any[:limit], start=1):
if not isinstance(f, dict):
continue
fid = str(f.get("format_id") or "")
ext = str(f.get("ext") or "")
note = f.get("format_note") or f.get("format") or ""
vcodec = str(f.get("vcodec") or "")
acodec = str(f.get("acodec") or "")
size = f.get("filesize") or f.get("filesize_approx")
res = str(f.get("resolution") or "")
if not res:
try:
w = f.get("width")
h = f.get("height")
if w and h:
res = f"{int(w)}x{int(h)}"
elif h:
res = f"{int(h)}p"
except Exception:
res = ""
_append_helper_log(
f"[ytdlp-format {i:02d}] id={fid} ext={ext} res={res} note={note} codecs={vcodec}/{acodec} size={size}"
)
if count > limit:
_append_helper_log(f"[ytdlp-formats] (truncated; total={count})")
except Exception:
pass
# Optional: dump the full extracted JSON for inspection.
try:
dump = os.environ.get("MEDEIA_MPV_YTDLP_DUMP", "").strip()
if dump and dump != "0" and isinstance(info, dict):
h = hashlib.sha1(url.encode("utf-8", errors="replace")).hexdigest()[:10]
out_path = _repo_root() / "Log" / f"ytdlp-probe-{h}.json"
out_path.write_text(json.dumps(info, ensure_ascii=False, indent=2), encoding="utf-8", errors="replace")
_append_helper_log(f"[ytdlp-formats] wrote probe json: {out_path}")
except Exception:
pass
if not isinstance(info, dict):
return {
"success": False,
@@ -577,7 +625,9 @@ def main(argv: Optional[list[str]] = None) -> int:
# Mirror mpv's own log messages into our helper log file so debugging does
# not depend on the mpv on-screen console or mpv's log-file.
try:
level = "debug" if debug_enabled else "warn"
# IMPORTANT: mpv debug logs can be extremely chatty (especially ytdl_hook)
# and can starve request handling. Default to warn unless explicitly overridden.
level = os.environ.get("MEDEIA_MPV_HELPER_MPVLOG", "").strip() or "warn"
client.send_command_no_wait(["request_log_messages", level])
_append_helper_log(f"[helper] requested mpv log messages level={level}")
except Exception:
@@ -666,8 +716,17 @@ def main(argv: Optional[list[str]] = None) -> int:
if msg.get("id") != OBS_ID_REQUEST:
continue
req = _parse_request(msg.get("data"))
raw = msg.get("data")
req = _parse_request(raw)
if not req:
try:
if isinstance(raw, str) and raw.strip():
snippet = raw.strip().replace("\r", "").replace("\n", " ")
if len(snippet) > 220:
snippet = snippet[:220] + ""
_append_helper_log(f"[request-raw] could not parse request json: {snippet}")
except Exception:
pass
continue
req_id = str(req.get("id") or "")