mirror of
https://github.com/ksyasuda/mpv-youtube-queue.git
synced 2026-03-22 18:11:27 -07:00
refactor: split script into modules and drop queue save/load
All checks were successful
Luacheck / luacheck (push) Successful in 58s
All checks were successful
Luacheck / luacheck (push) Successful in 58s
This commit is contained in:
126
tests/app_spec.lua
Normal file
126
tests/app_spec.lua
Normal 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")
|
||||
60
tests/history_client_spec.lua
Normal file
60
tests/history_client_spec.lua
Normal 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
17
tests/input_spec.lua
Normal 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
|
||||
265
tests/metadata_resolution_test.lua
Normal file
265
tests/metadata_resolution_test.lua
Normal 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
44
tests/run.lua
Normal 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
63
tests/state_spec.lua
Normal 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
|
||||
Reference in New Issue
Block a user