From 13e2b5f8c8b1ce27b6c6adbb92861728a7a35fc9 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 2 May 2026 15:42:54 -0700 Subject: [PATCH] Handle mpv reload buffering as same media - Keep overlay alive across same-media mpv reloads - Avoid rearming startup gate and repeating AniSkip lookups - Add regression coverage for reload/end-file/file-loaded sequence --- ...-mpv-buffering-reload-overlay-lifecycle.md | 28 ++++++++++ changes/313-mpv-buffering-reload-overlay.md | 4 ++ plugin/subminer/lifecycle.lua | 49 +++++++++++++++++ plugin/subminer/state.lua | 2 + scripts/test-plugin-start-gate.lua | 53 +++++++++++++++++++ 5 files changed, 136 insertions(+) create mode 100644 backlog/tasks/task-313 - Fix-mpv-buffering-reload-overlay-lifecycle.md create mode 100644 changes/313-mpv-buffering-reload-overlay.md diff --git a/backlog/tasks/task-313 - Fix-mpv-buffering-reload-overlay-lifecycle.md b/backlog/tasks/task-313 - Fix-mpv-buffering-reload-overlay-lifecycle.md new file mode 100644 index 00000000..696922a8 --- /dev/null +++ b/backlog/tasks/task-313 - Fix-mpv-buffering-reload-overlay-lifecycle.md @@ -0,0 +1,28 @@ +--- +id: TASK-313 +title: Fix mpv buffering reload overlay lifecycle +status: To Do +assignee: [] +created_date: '2026-05-02 22:12' +labels: + - bug + - mpv + - overlay +dependencies: [] +priority: high +--- + +## Description + + +macOS local playback can emit an mpv reload/end-file/file-loaded sequence during buffering. SubMiner should treat same-media reload churn as a continuation, not a fresh playback session, so the visible overlay remains available and startup-only tokenization/AniSkip work is not repeated unnecessarily. + + +## Acceptance Criteria + +- [ ] #1 Same-media mpv reload buffering does not hide the visible overlay. +- [ ] #2 Same-media mpv reload buffering does not re-arm the pause-until-ready startup gate or wait for a second tokenization-ready signal. +- [ ] #3 Same-media mpv reload buffering does not repeat AniSkip lookup work for the already-loaded media. +- [ ] #4 Normal new-file playback still clears per-media state, applies managed subtitle defaults, auto-starts/updates the overlay, and runs needed startup checks. +- [ ] #5 Regression coverage exercises the buffering reload/end-file/file-loaded sequence in the mpv plugin lifecycle. + diff --git a/changes/313-mpv-buffering-reload-overlay.md b/changes/313-mpv-buffering-reload-overlay.md new file mode 100644 index 00000000..d3827c51 --- /dev/null +++ b/changes/313-mpv-buffering-reload-overlay.md @@ -0,0 +1,4 @@ +type: fixed +area: mpv + +- Kept the visible overlay alive across same-media mpv reloads during buffering, avoiding duplicate startup gates and AniSkip lookups. diff --git a/plugin/subminer/lifecycle.lua b/plugin/subminer/lifecycle.lua index 2ffd5de2..2a8899a5 100644 --- a/plugin/subminer/lifecycle.lua +++ b/plugin/subminer/lifecycle.lua @@ -11,6 +11,29 @@ function M.create(ctx) local subminer_log = ctx.log.subminer_log local show_osd = ctx.log.show_osd + local function resolve_media_identity() + local path = mp.get_property("path") + if type(path) == "string" and path ~= "" then + return path + end + + local filename = mp.get_property("filename") + if type(filename) == "string" and filename ~= "" then + return filename + end + + local media_title = mp.get_property("media-title") + if type(media_title) == "string" and media_title ~= "" then + return media_title + end + + return nil + end + + local function is_reload_end_file(reason) + return reason == "reload" or reason == "redirect" + end + local function schedule_aniskip_fetch(trigger_source, delay_seconds) local delay = tonumber(delay_seconds) or 0 mp.add_timeout(delay, function() @@ -41,6 +64,25 @@ function M.create(ctx) end local function on_file_loaded() + local media_identity = resolve_media_identity() + local same_media_reload = ( + media_identity ~= nil + and state.pending_reload_media_identity ~= nil + and media_identity == state.pending_reload_media_identity + ) + state.pending_reload_media_identity = nil + state.current_media_identity = media_identity + + if same_media_reload then + subminer_log("debug", "lifecycle", "Skipping startup lifecycle for same-media mpv reload") + if state.overlay_running and resolve_auto_start_enabled() and process.has_matching_mpv_ipc_socket(opts.socket_path) then + process.run_control_command_async("show-visible-overlay", { + socket_path = opts.socket_path, + }) + end + return + end + aniskip.clear_aniskip_state() process.disarm_auto_play_ready_gate() local has_matching_socket = rearm_managed_subtitle_defaults() @@ -73,6 +115,8 @@ function M.create(ctx) aniskip.clear_aniskip_state() hover.clear_hover_overlay() process.disarm_auto_play_ready_gate() + state.current_media_identity = nil + state.pending_reload_media_identity = nil end local function register_lifecycle_hooks() @@ -85,6 +129,11 @@ function M.create(ctx) process.disarm_auto_play_ready_gate() hover.clear_hover_overlay() local reason = type(event) == "table" and event.reason or nil + if is_reload_end_file(reason) then + state.pending_reload_media_identity = state.current_media_identity or resolve_media_identity() + return + end + state.pending_reload_media_identity = nil if state.overlay_running and reason ~= "quit" then process.hide_visible_overlay() end diff --git a/plugin/subminer/state.lua b/plugin/subminer/state.lua index 6573f8c0..579c86bb 100644 --- a/plugin/subminer/state.lua +++ b/plugin/subminer/state.lua @@ -33,6 +33,8 @@ function M.new() auto_play_ready_timeout = nil, auto_play_ready_osd_timer = nil, suppress_ready_overlay_restore = false, + current_media_identity = nil, + pending_reload_media_identity = nil, session_binding_generation = 0, session_binding_names = {}, session_numeric_binding_names = {}, diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index 00b50c0e..9cf0c2ac 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -461,6 +461,20 @@ local function has_async_curl_for(async_calls, needle) return false end +local function count_async_curl_for(async_calls, needle) + local count = 0 + for _, call in ipairs(async_calls) do + local args = call.args or {} + if args[1] == "curl" then + local url = args[#args] or "" + if type(url) == "string" and url:find(needle, 1, true) then + count = count + 1 + end + end + end + return count +end + local function has_property_set(property_sets, name, value) for _, call in ipairs(property_sets) do if call.name == name and call.value == value then @@ -578,6 +592,45 @@ do ) end +do + local media_path = "/media/Sample Show S01E01.mkv" + local recorded, err = run_plugin_scenario({ + process_list = "", + option_overrides = { + binary_path = binary_path, + auto_start = "yes", + auto_start_visible_overlay = "yes", + auto_start_pause_until_ready = "yes", + socket_path = "/tmp/subminer-socket", + }, + input_ipc_server = "/tmp/subminer-socket", + path = media_path, + media_title = "Sample Show S01E01", + mal_lookup_stdout = "__MAL_FOUND__", + aniskip_stdout = "__ANISKIP_FOUND__", + files = { + [binary_path] = true, + }, + }) + assert_true(recorded ~= nil, "plugin failed to load for same-media reload scenario: " .. tostring(err)) + fire_event(recorded, "file-loaded") + recorded.script_messages["subminer-autoplay-ready"]() + fire_event(recorded, "end-file", { reason = "reload" }) + fire_event(recorded, "file-loaded") + assert_true( + count_control_calls(recorded.async_calls, "--hide-visible-overlay") == 0, + "same-media reload should not hide the visible overlay" + ) + assert_true( + count_property_set(recorded.property_sets, "pause", true) == 1, + "same-media reload should not re-arm pause-until-ready" + ) + assert_true( + count_async_curl_for(recorded.async_calls, "api.aniskip.com") == 1, + "same-media reload should not repeat AniSkip lookup" + ) +end + do local recorded, err = run_plugin_scenario({ process_list = "",