feat: rewrite the whole code

- Now stores the different lists in a tree which caches each path to
  enable the minimum amount of re-fetching
- Every request is async allowing input while waiting for the server to
  respond
- No longer requires a list library as a list implementation is built in

Currently I've yet to implement the video handling, but that's next!
This commit is contained in:
2025-10-10 14:55:20 -04:00
parent d8da2e8f5a
commit b153a46b87
13 changed files with 756 additions and 1678 deletions

538
main.lua
View File

@@ -1,402 +1,172 @@
local mp = require('mp')
local msg = require('mp.msg')
local utils = require('mp.utils')
local mp = require("mp")
local msg = require("mp.msg")
local utils = require("mp.utils")
local jf = require("utils.jellyfin")
local list = require("src.list")
local auth = require("src.auth")
local api = require("src.api")
local cache = require("src.cache")
local tree = require("src.utils.tree")
-- require mpv scroll list library
package.path = mp.command_native({"expand-path", "~~/script-modules/?.lua;"})
..package.path
local lok, list = pcall(require, "scroll-list")
if not lok then
msg.info("Install: https://github.com/CogentRedTester/mpv-scroll-list")
return 1
end
---@type auth
local jf = nil
-- make an instance of the list
local original_open = list.open
function list:open()
original_open(self)
end
-- keeps track of all the info available
local state = {
---@type tree<cache> the currently selected node in the tree
pos = nil,
---@type tree<cache> the head of the tree
tree = nil,
local settings = {
url = '',
username = '',
password = '',
header = {
prefix = "jellyfin/",
separator = "/",
contents = {
{ "watched", " ", "favorite", " ", "runtime" },
{ "stars", " ", "rating", " ", "release" }
}
},
client = {
name = 'Jellyfin Lua Client',
device = 'Mpv',
version = '1.1'
}
-- when we're navigating (in the middle of loading) we shouldn't allow more
-- navigations
navigating = false
}
--- table containing all info about the runtime
---@type table
local rt = {
authenticated = false, -- are we connected to the server
user = {},
-- create the main list
local l = list:new()
l:update()
local timer = l:set_fade_animation(0.02, "Authenticating")
library = jf.library(), -- current library
title = jf.library(), -- current movie or show
season = jf.library(), -- current season of show
video = jf.video(), -- current video (movie/episode)
nextup = jf.video(), -- next video
-- our menu to render
menu = {
items = {} -- contains table of strings to display (with ass styling)
}
}
--- draw list to screen
---@param items table list of elements to draw to the screen
local function redraw_list(items)
-- clear out the render list
list.list = {}
-- style each line
for i = 1, #items do
local item = {}
item.style = ""
if items[i].UserData then
-- if the user has started watching it make it blue
if items[i].UserData.PlayedPercentage then
if items[i].UserData.PlayedPercentage > 0 then
item.style = [[{\c&Hdca400&}]]..item.style
end
-- if the user has finished watching it make it green
elseif items[i].UserData.Played then
item.style = [[{\c&H33ff66&}]]..item.style
end
end
item.style = [[{\c&Hffffff&}]]..item.style
item.ass = rt.menu.items[i].Name
list:insert(item)
end
list:update()
end
function list:format_header_string()
---@type string
local header
-- shorten the selected item
local sel = rt.menu.items[list.selected]
-- setup topheading
if not rt.library.id then
header = settings.header.prefix
elseif not rt.title.id then
header = settings.header.prefix..rt.library.name..settings.header.separator
elseif not rt.season.id then
header = settings.header.prefix..rt.library.name..settings.header.separator
..rt.title.name
else
header = settings.header.prefix..rt.library.name..settings.header.separator
..rt.title.name..settings.header.separator..rt.season.name
end
-- generate x subheadings based on user config
for i, v in pairs(settings.header.contents) do
-- if no selected item exit
if not rt.menu.items or not sel then
goto finish
end
-- if this isn't the first line or the last line add a newline symbol and
-- reset colors
if i == 1 or i <= #settings.header.contents then
header = header..[[\N\h{\q2\fs20\c&Hffffff&}]]
end
for _, c in pairs(v) do
if not sel or not sel.UserData then
goto continue
end
if type(c) == "function" then
header = header..c(rt.menu.items, list.selected)
elseif c == "watched" then
if sel.UserData.PlayedPercentage then
if sel.UserData.PlayedPercentage > 0 then
header = header..[[{\c&Hdca400&}]]..
math.floor(sel.UserData.PlayedPercentage)..[[%{\c&Hffffff&}]]
end
elseif sel.UserData.Played then
header = header..[[{\c&H33ff66&}✔️{\c&Hffffff&}]]
elseif sel.UserData.UnplayedItemCount then
header = header..[[{\c&Hdca400&}]]..sel.UserData.UnplayedItemCount..
[[{\c&Hffffff&}]]
else
header = header..[[{\c&H444444&}✔️{\c&Hffffff&}]]
end
elseif c == "favorite" then
if sel.UserData.IsFavorite then
header = header..[[{\c&H0000ff&}♥️{\c&Hffffff&}]]
else
header = header..[[{\c&H444444&}♥️{\c&Hffffff&}]]
end
elseif c == "runtime" then
if sel.RunTimeTicks then
---@type number
local hour
local min = math.floor(sel.RunTimeTicks / 600000000)
if min >= 60 then
hour = math.floor(min / 60)
min = min - math.floor(min / 60) * 60
header = header..hour.."h "..min.."m"
else
header = header..min.."m"
end
end
elseif c == "stars" then
if sel.CommunityRating then
local stars = math.floor(sel.CommunityRating * 10 + 0.5) / 10
header = header..[[{\c&H00ffff}★]]..stars..[[{\c&Hffffff&}]]
end
elseif c == "rating" then
if sel.OfficialRating then
header = header..[[{\c&Hffffff}[]]..sel.OfficialRating..
[[]{\c&Hffffff&}]]
end
elseif c == "release" then
if sel.ProductionYear then
header = header..sel.ProductionYear
end
elseif c == "entries" then
header = header..#rt.menu.items
else
header = header..c
end
end
::continue::
end
::finish::
return header
end
--- change position in jellyfin
local function enter()
local sel = rt.menu.items[list.selected]
if not sel then
coroutine.wrap(function()
-- attempt to authenticate with the server
jf = auth:new(
"http://192.168.50.159:8096",
"zachary",
"qwerty1"
)
if not jf.available then
timer:kill()
l:log({
style = [[{\c&H0000ff&}]],
ass = string.format(
"The jellyfin server at '%s' is not available",
jf.url
)
})
mp.add_timeout(5, function()
l:close()
end)
return
end
if sel.IsFolder == false then
msg.trace("selected a video")
-- we've connected kill the auth animation and clear the list
timer:kill()
l:set()
rt.video.id = sel.Id
-- start playing the movie
jf.play()
else
if not rt.library.id then
msg.trace("selected a library")
rt.library.id = sel.Id
rt.library.name = sel.Name
rt.library.pos = list.selected
elseif not rt.title.id then
msg.trace("selected a movie")
rt.title.id = sel.Id
rt.title.name = sel.Name
rt.title.pos = list.selected
elseif not rt.season.id then
msg.trace("selected a season")
rt.season.id = sel.Id
rt.season.name = sel.Name
rt.season.pos = list.selected
end
list.selected = 1
end
end
--- helper to addkeys to the list view
---@param keys string keybind
---@param name string action name
---@param fn function callback
---@param opts? table options
local function addkey(keys, name, fn, opts)
opts = opts or { repeatable = true }
local i = 1
for key in keys:gmatch("%S+") do
table.insert(list.keybinds, { key, name..i, fn, opts })
i = i + 1
end
end
--- wrapper to safely open the list
local function openlist()
-- setup jellyfin api on first open
if type(jf.setup) ~= "boolean" then
jf.setup(list, rt, settings)
-- mark jellyfin api as setup by replacing function with a boolean
jf.setup = true
end
-- authenticate with the server based on users settings
rt.authenticated = jf.authenticate()
if not rt.authenticated then
if not jf.authenticated then
l:log({
style = [[{\c&H0000ff&}]],
ass = "Failed to authenticate with jellyfin server. Check your credentials.",
})
mp.add_timeout(5, function()
l:close()
end)
return
end
-- refresh the list
rt.menu.items = jf.getpos()
redraw_list(rt.menu.items)
msg.trace("Welcome to jellyfin. Everything's working so far.")
-- show the first list
state.tree = tree:new(cache:new(nil, function(self)
return api.async_request(self, "head", l, jf, "/Items/", { method = "GET" })
end))
state.pos = state.tree
-- open the list
list:open()
end
l:set(api.items_to_list(state.pos.data:get().items))
l.selected = 1
l:update()
end)()
-- wrapper to safely close the list
local function closelist()
rt.authenticated = false
list:close()
end
-- register script message for users so they may define keybinds
mp.register_script_message("jnj-bind-open", function(key)
mp.add_key_binding(key, 'JNJ-BIND-OPEN', openlist)
end)
mp.register_script_message("jnj-bind-close", function(key)
addkey(key, 'close', closelist)
end)
mp.register_script_message("jnj-bind-toggle", function(key)
mp.add_key_binding(key, 'JNJ-BIND-TOGGLE', function()
if list.hidden then
openlist()
else
closelist()
end
end)
end)
mp.register_script_message("jnj-bind-enter", function(key)
addkey(key, 'enter', function()
enter()
local items = jf.getpos()
if not items then
return
end
rt.menu.items = items
redraw_list(rt.menu.items)
end)
end)
mp.register_script_message("jnj-bind-leave", function(key)
addkey(key, 'leave', function()
if not rt.library.id then
-- do nothing we're already in the root
elseif not rt.title.id then
rt.library.id = nil
list.selected = rt.library.pos
elseif not rt.season.id then
rt.title.id = nil
list.selected = rt.title.pos
else
rt.season.id = nil
list.selected = rt.season.pos
end
local items = jf.getpos()
if not items then
return
end
rt.menu.items = items
redraw_list(rt.menu.items)
end)
end)
mp.register_script_message("jnj-bind-down", function(key)
addkey(key, 'down', function()
list:scroll_down()
end)
end)
mp.register_script_message("jnj-bind-up", function(key)
addkey(key, 'up', function()
list:scroll_up()
end)
end)
mp.register_script_message("jnj-bind-pagedown", function(key)
addkey(key, 'pageup', function()
list:move_pagedown()
end)
end)
mp.register_script_message("jnj-bind-pageup", function(key)
addkey(key, 'pagedown', function()
list:move_pageup()
end)
end)
mp.register_script_message("jnj-bind-top", function(key)
addkey(key, 'top', function()
list:move_begin()
end)
end)
mp.register_script_message("jnj-bind-bottom", function(key)
addkey(key, 'bottom', function()
list:move_end()
end)
end)
mp.register_script_message("jnj-bind-toggle-played", function(key)
addkey(key, 'bottom', function()
if rt.menu.items[list.selected] and rt.menu.items[list.selected].Id and
not rt.menu.items[list.selected].isFolder then
jf.setplayed(not rt.menu.items[list.selected].UserData.Played)
redraw_list(rt.menu.items)
end
end)
end)
mp.register_script_message("jnj-bind-toggle-favorite", function(key)
addkey(key, 'bottom', function()
if rt.menu.items[list.selected] and rt.menu.items[list.selected].Id and
not rt.menu.items[list.selected].isFolder then
jf.setfavorite(not rt.menu.items[list.selected].UserData.IsFavorite)
redraw_list(rt.menu.items)
end
end)
end)
-- add script message for users to provide their settings via json
mp.register_script_message("jnj-set-settings", function(json)
local user_settings = utils.parse_json(json)
for i, v in pairs(user_settings) do
settings[i] = v
-- add keys
l:addkey("k", "up", function()
if state.navigating then
return
end
l:up()
end)
l:addkey("j", "down", function()
if state.navigating then
return
end
l:down()
end)
l:addkey("l", "enter", function()
if state.navigating then
return
end
state.navigating = true
coroutine.resume(coroutine.create(function()
if #state.pos.data:get().items < 1 then
state.navigating = false
return
end
local cur_item = state.pos.data:get().items[l.selected]
if cur_item.MediaType == "Video" then
print(cur_item.Name)
-- TODO: handle playing videos
state.navigating = false
return
end
local new_pos = nil
for _, child in ipairs(state.pos.children) do
if child.data:get().id == cur_item.Name then
new_pos = child
break
end
end
if not new_pos then
new_pos = tree:new(cache:new(nil, function(self)
return api.async_request(self, cur_item.Name, l, jf, "/Items/", {
method = "GET",
paramaters = {
["parentId"] = cur_item.Id,
["sortBy"] = "SortName"
}
})
end))
state.pos:addchild(new_pos)
end
state.pos.data.data.selected = l.selected
state.pos = new_pos
l:set(api.items_to_list(state.pos.data:get().items))
l.selected = state.pos.data:get().selected
l:update()
state.navigating = false
end))
end)
l:addkey("h", "leave", function()
if state.navigating then
return
end
state.navigating = true
if not state.pos.parent then
state.navigating = false
return
end
state.pos.data.data.selected = l.selected
state.pos = state.pos.parent
coroutine.resume(coroutine.create(function()
l:set(api.items_to_list(state.pos.data:get().items))
l.selected = state.pos.data:get().selected
l:update()
state.navigating = false
end))
end)
l:addkey("esc", "close", function() l:close() end)
mp.add_key_binding("Ctrl+j", "open", function()
coroutine.resume(coroutine.create(function()
if not l.open then
if not jf:ping() then
print("hi")
end
l:update()
end
end))
end)