refactor: split script into modules and drop queue save/load
All checks were successful
Luacheck / luacheck (push) Successful in 58s

This commit is contained in:
2026-03-08 21:35:16 -07:00
parent dd50f3eaad
commit 236f4ab39e
20 changed files with 2222 additions and 1256 deletions

126
tests/app_spec.lua Normal file
View File

@@ -0,0 +1,126 @@
local function assert_equal(actual, expected, message)
if actual ~= expected then
error(
(message or "values differ")
.. string.format("\nexpected: %s\nactual: %s", tostring(expected), tostring(actual))
)
end
end
local function assert_nil(value, message)
if value ~= nil then
error((message or "expected nil") .. string.format("\nactual: %s", tostring(value)))
end
end
local function assert_falsy(value, message)
if value then
error(message or "expected falsy value")
end
end
local function load_script()
local bindings = {}
local mp_stub = {
get_property = function()
return ""
end,
get_property_number = function(_, default)
return default
end,
set_property_number = function() end,
set_osd_ass = function() end,
osd_message = function() end,
add_periodic_timer = function()
return {
kill = function() end,
resume = function() end,
}
end,
add_timeout = function()
return {
kill = function() end,
resume = function() end,
}
end,
add_key_binding = function(_, _, name)
bindings[name] = true
end,
register_event = function() end,
register_script_message = function() end,
commandv = function() end,
command_native_async = function(_, callback)
if callback then
callback(false, nil, "not implemented in tests")
end
end,
command_native = function()
return {
status = 0,
stdout = "",
}
end,
}
package.loaded["mp"] = nil
package.loaded["mp.options"] = nil
package.loaded["mp.utils"] = nil
package.loaded["mp.assdraw"] = nil
package.loaded["app"] = nil
package.loaded["history_client"] = nil
package.loaded["input"] = nil
package.loaded["json"] = nil
package.loaded["shell"] = nil
package.loaded["state"] = nil
package.loaded["ui"] = nil
package.loaded["video_store"] = nil
package.preload["mp"] = function()
return mp_stub
end
package.preload["mp.options"] = function()
return {
read_options = function() end,
}
end
package.preload["mp.utils"] = function()
return {
file_info = function()
return nil
end,
split_path = function(path)
return path:match("^(.*[/\\])(.-)$")
end,
parse_json = function()
return nil
end,
}
end
package.preload["mp.assdraw"] = function()
return {
ass_new = function()
return {
text = "",
append = function(self, value)
self.text = self.text .. value
end,
}
end,
}
end
local chunk = assert(loadfile("mpv-youtube-queue/main.lua"))
return chunk(), bindings
end
local script, bindings = load_script()
assert_nil(script.YouTubeQueue.save_queue, "queue save API should be removed")
assert_nil(script.YouTubeQueue.load_queue, "queue load API should be removed")
assert_falsy(bindings.save_queue, "save_queue binding should be removed")
assert_falsy(bindings.save_queue_alt, "save_queue_alt binding should be removed")
assert_falsy(bindings.load_queue, "load_queue binding should be removed")
assert_equal(type(script.YouTubeQueue.add_to_queue), "function", "queue add API should remain")

View File

@@ -0,0 +1,60 @@
local function assert_equal(actual, expected, message)
if actual ~= expected then
error(
(message or "values differ")
.. string.format("\nexpected: %s\nactual: %s", tostring(expected), tostring(actual))
)
end
end
local function assert_nil(value, message)
if value ~= nil then
error((message or "expected nil") .. string.format("\nactual: %s", tostring(value)))
end
end
local function assert_truthy(value, message)
if not value then
error(message or "expected truthy value")
end
end
package.loaded["history_client"] = nil
package.loaded["json"] = nil
local calls = {}
local notices = {}
local client = require("history_client").new({
mp = {
command_native_async = function(command, callback)
table.insert(calls, command)
callback(true, { status = 0 }, nil)
end,
},
options = {
use_history_db = true,
backend_host = "http://backend.test",
backend_port = "42069",
},
notify = function(message)
table.insert(notices, message)
end,
})
assert_nil(client.save_queue, "queue save backend API should be removed")
assert_nil(client.load_queue, "queue load backend API should be removed")
local ok = client:add_video({
video_name = "Demo",
video_url = "https://example.test/watch?v=1",
})
assert_truthy(ok, "add_video should still be enabled for shared backend")
assert_equal(#calls, 1, "add_video should issue one backend request")
assert_equal(calls[1].args[1], "curl", "backend request should use curl subprocess")
assert_equal(
calls[1].args[4],
"http://backend.test:42069/add_video",
"backend request should target add_video endpoint"
)
assert_equal(notices[#notices], "Video added to history db", "successful add_video should notify")

17
tests/input_spec.lua Normal file
View File

@@ -0,0 +1,17 @@
local input = require("input")
local function eq(actual, expected, message)
assert(actual == expected, string.format("%s: expected %s, got %s", message, tostring(expected), tostring(actual)))
end
do
local sanitized = input.sanitize_source([[ "Mary's Video.mp4"
]])
eq(sanitized, "Mary's Video.mp4", "sanitize should trim wrapper quotes and whitespace without dropping apostrophes")
end
do
eq(input.is_file_info({ is_file = true }), true, "file info should accept files")
eq(input.is_file_info({ is_file = false }), false, "file info should reject directories")
eq(input.is_file_info(nil), false, "file info should reject missing paths")
end

View File

@@ -0,0 +1,265 @@
local function assert_equal(actual, expected, message)
if actual ~= expected then
error(
(message or "values differ")
.. string.format("\nexpected: %s\nactual: %s", tostring(expected), tostring(actual))
)
end
end
local function assert_truthy(value, message)
if not value then
error(message or "expected truthy value")
end
end
local function load_script(config)
config = config or {}
local events = {}
local command_native_calls = 0
local properties = config.properties or {}
local property_numbers = config.property_numbers or {}
local json_map = config.json_map or {}
local mp_stub = {
get_property = function(name)
return properties[name]
end,
get_property_number = function(name)
return property_numbers[name]
end,
set_property_number = function(name, value)
property_numbers[name] = value
end,
set_osd_ass = function() end,
osd_message = function() end,
add_periodic_timer = function()
return {
kill = function() end,
resume = function() end,
}
end,
add_timeout = function()
return {
kill = function() end,
resume = function() end,
}
end,
add_key_binding = function() end,
register_event = function(name, handler)
events[name] = handler
end,
add_hook = function() end,
register_script_message = function() end,
commandv = function() end,
command_native_async = function(_, callback)
if callback then
callback(false, nil, "not implemented in tests")
end
end,
command_native = function(command)
if command.name == "subprocess" and command.args and command.args[1] == "yt-dlp" then
command_native_calls = command_native_calls + 1
if config.subprocess_result then
return config.subprocess_result(command_native_calls, command)
end
return {
status = 1,
stdout = "",
}
end
return {
status = 0,
stdout = "",
}
end,
}
package.loaded["mp"] = nil
package.loaded["mp.options"] = nil
package.loaded["mp.utils"] = nil
package.loaded["mp.assdraw"] = nil
package.loaded["app"] = nil
package.loaded["history"] = nil
package.loaded["history_client"] = nil
package.loaded["input"] = nil
package.loaded["json"] = nil
package.loaded["shell"] = nil
package.loaded["state"] = nil
package.loaded["ui"] = nil
package.loaded["video_store"] = nil
package.preload["mp"] = function()
return mp_stub
end
package.preload["mp.options"] = function()
return {
read_options = function() end,
}
end
package.preload["mp.utils"] = function()
return {
file_info = function(path)
if path and path:match("^/") then
return { is_file = true }
end
return nil
end,
split_path = function(path)
return path:match("^(.*[/\\])(.-)$")
end,
parse_json = function(payload)
return json_map[payload]
end,
}
end
package.preload["mp.assdraw"] = function()
return {
ass_new = function()
return {
text = "",
append = function(self, value)
self.text = self.text .. value
end,
}
end,
}
end
local chunk = assert(loadfile("mpv-youtube-queue/main.lua"))
local script = chunk()
return {
events = events,
script = script,
get_ytdlp_calls = function()
return command_native_calls
end,
set_property = function(name, value)
properties[name] = value
end,
}
end
local unsupported = load_script({
properties = {
["osd-ass-cc/0"] = "",
["osd-ass-cc/1"] = "",
["path"] = "https://jellyfin.example/items/1",
["media-title"] = "Jellyfin Episode 1",
["playlist/0/filename"] = "https://jellyfin.example/items/1",
},
property_numbers = {
["playlist-count"] = 1,
},
subprocess_result = function()
return {
status = 1,
stdout = "",
}
end,
})
assert_truthy(unsupported.script and unsupported.script._test, "script test helpers should be returned")
unsupported.events["file-loaded"]()
local queue = unsupported.script._test.snapshot_queue()
assert_equal(#queue, 1, "unsupported stream should be queued")
assert_equal(queue[1].video_name, "Jellyfin Episode 1", "fallback metadata should prefer media-title")
assert_equal(unsupported.get_ytdlp_calls(), 1, "first sync should try extractor once")
assert_equal(unsupported.events["playback-restart"], nil, "playback-restart import hook should be removed")
assert_equal(unsupported.get_ytdlp_calls(), 1, "seeking should not retry extractor metadata lookup")
unsupported.script.YouTubeQueue.sync_with_playlist()
assert_equal(unsupported.get_ytdlp_calls(), 1, "cached fallback metadata should prevent repeated extractor calls")
local supported = load_script({
properties = {
["osd-ass-cc/0"] = "",
["osd-ass-cc/1"] = "",
["path"] = "https://youtube.example/watch?v=abc",
["playlist/0/filename"] = "https://youtube.example/watch?v=abc",
},
property_numbers = {
["playlist-count"] = 1,
},
json_map = {
supported = {
channel_url = "https://youtube.example/channel/demo",
uploader = "Demo Channel",
title = "Supported Video",
view_count = 42,
upload_date = "20260306",
categories = { "Music" },
thumbnail = "https://img.example/thumb.jpg",
channel_follower_count = 1000,
},
},
subprocess_result = function()
return {
status = 0,
stdout = "supported",
}
end,
})
supported.script.YouTubeQueue.sync_with_playlist()
local supported_queue = supported.script._test.snapshot_queue()
assert_equal(supported_queue[1].video_name, "Supported Video", "supported urls should keep extractor metadata")
assert_equal(supported.get_ytdlp_calls(), 1, "supported url should call extractor once")
supported.script.YouTubeQueue.sync_with_playlist()
assert_equal(supported.get_ytdlp_calls(), 1, "supported url should reuse cached extractor metadata")
local multi_remote = load_script({
properties = {
["osd-ass-cc/0"] = "",
["osd-ass-cc/1"] = "",
["path"] = "https://example.test/watch?v=first",
["playlist/0/filename"] = "https://example.test/watch?v=first",
["playlist/0/title"] = "Title A mpv",
["playlist/1/filename"] = "https://example.test/watch?v=second",
["playlist/1/title"] = "Title B mpv",
},
property_numbers = {
["playlist-count"] = 2,
},
json_map = {
first = {
channel_url = "https://example.test/channel/a",
uploader = "Channel A",
title = "Extractor A",
},
second = {
channel_url = "https://example.test/channel/b",
uploader = "Channel B",
title = "Extractor B",
},
},
subprocess_result = function(call_count)
if call_count == 1 then
return { status = 0, stdout = "first" }
end
return { status = 0, stdout = "second" }
end,
})
multi_remote.events["file-loaded"]()
local first_pass = multi_remote.script._test.snapshot_queue()
assert_equal(first_pass[1].video_name, "Extractor A", "first current item should resolve extractor metadata")
assert_equal(first_pass[2].video_name, "Title B mpv", "later items can start as placeholders")
assert_equal(multi_remote.events["playback-restart"], nil, "playback-restart import hook should stay removed")
assert_equal(multi_remote.get_ytdlp_calls(), 1, "playback restart should not trigger playlist resync")
multi_remote.set_property("path", "https://example.test/watch?v=second")
multi_remote.events["file-loaded"]()
local second_pass = multi_remote.script._test.snapshot_queue()
assert_equal(second_pass[2].video_name, "Extractor B", "current item should upgrade when it loads")
assert_equal(multi_remote.get_ytdlp_calls(), 2, "each remote item should resolve at most once when current")
print("ok")

44
tests/run.lua Normal file
View File

@@ -0,0 +1,44 @@
package.path = table.concat({
"./?.lua",
"./?/init.lua",
"./?/?.lua",
"./mpv-youtube-queue/?.lua",
"./mpv-youtube-queue/?/?.lua",
package.path,
}, ";")
local total = 0
local failed = 0
local function run_test(file)
local chunk, err = loadfile(file)
if not chunk then
error(err)
end
local ok, test_err = pcall(chunk)
total = total + 1
if ok then
io.write("PASS ", file, "\n")
return
end
failed = failed + 1
io.write("FAIL ", file, "\n", test_err, "\n")
end
local tests = {
"tests/app_spec.lua",
"tests/metadata_resolution_test.lua",
"tests/state_spec.lua",
"tests/history_client_spec.lua",
"tests/input_spec.lua",
}
for _, file in ipairs(tests) do
run_test(file)
end
if failed > 0 then
error(string.format("%d/%d tests failed", failed, total))
end
io.write(string.format("PASS %d tests\n", total))

63
tests/state_spec.lua Normal file
View File

@@ -0,0 +1,63 @@
local state = require("state")
local function eq(actual, expected, message)
assert(actual == expected, string.format("%s: expected %s, got %s", message, tostring(expected), tostring(actual)))
end
local function same_table(actual, expected, message)
eq(#actual, #expected, message .. " length")
for i, value in ipairs(expected) do
eq(actual[i], value, message .. " [" .. i .. "]")
end
end
do
local start_index, end_index = state.get_display_range(20, 20, 10)
eq(start_index, 11, "range start should backfill near queue end")
eq(end_index, 20, "range end should stop at queue end")
end
do
local result = state.remove_queue_item({
queue = { "a", "b", "c", "d" },
current_index = 2,
selected_index = 4,
marked_index = 3,
})
same_table(result.queue, { "a", "b", "c" }, "remove after current queue")
eq(result.current_index, 2, "current index should not shift when removing after current")
eq(result.selected_index, 3, "selected index should move to previous row when deleting last row")
eq(result.marked_index, 3, "marked index should remain attached to same item when removing after it")
end
do
local result = state.remove_queue_item({
queue = { "a", "b", "c", "d" },
current_index = 4,
selected_index = 2,
marked_index = 4,
})
same_table(result.queue, { "a", "c", "d" }, "remove before current queue")
eq(result.current_index, 3, "current index should shift back when removing before current")
eq(result.marked_index, 3, "marked index should rebase when its item shifts")
end
do
local result = state.reorder_queue({
queue = { "a", "b", "c", "d" },
current_index = 3,
selected_index = 1,
from_index = 1,
to_index = 3,
})
same_table(result.queue, { "b", "c", "a", "d" }, "reorder into current slot queue")
eq(result.current_index, 2, "current index should follow the current item when inserting before it")
eq(result.selected_index, 3, "selected index should follow moved item")
end
do
local ok, err = pcall(function()
state.normalize_reorder_indices("2", "4")
end)
assert(ok, "string reorder indices should be accepted: " .. tostring(err))
end