From d400da457ea15c58319a9f200f49c6502787e17c Mon Sep 17 00:00:00 2001 From: Squibid Date: Sat, 3 Aug 2024 22:42:15 -0400 Subject: initial commit --- utils/curl.lua | 41 +++++ utils/jellyfin.lua | 497 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 538 insertions(+) create mode 100644 utils/curl.lua create mode 100644 utils/jellyfin.lua (limited to 'utils') diff --git a/utils/curl.lua b/utils/curl.lua new file mode 100644 index 0000000..532d0bd --- /dev/null +++ b/utils/curl.lua @@ -0,0 +1,41 @@ +local mp = require('mp') +local utils = require('mp.utils') +local msg = require('mp.msg') + +local M = {} + +--- run a curl request +---@param cmd table list of curl args +---@param method? string +---@return table? json, number? httpcode +function M.request(cmd, method) + if type(cmd) == "string" then + cmd = { cmd } + end + + table.insert(cmd, 1, '%{http_code}') + table.insert(cmd, 1, "-w") + if method then + table.insert(cmd, 1, method) + table.insert(cmd, 1, "-X") + end + table.insert(cmd, 1, "curl") + + local r = mp.command_native({ + name = "subprocess", + capture_stdout = true, + capture_stderr = true, + playback_only = false, + args = cmd + }) + + local json, err, code = utils.parse_json(r.stdout, true) + if not json then + msg.error(string.format("failed to parse json: %s", err)) + return + end + + return json, tonumber(code) +end + +return M diff --git a/utils/jellyfin.lua b/utils/jellyfin.lua new file mode 100644 index 0000000..1cb30e5 --- /dev/null +++ b/utils/jellyfin.lua @@ -0,0 +1,497 @@ +local mp = require('mp') +local utils = require('mp.utils') +local msg = require('mp.msg') + +local curl = require("utils.curl") + +local M = {} + +--- refrence to runtime table +---@type table +local rt = {} + +--- refrence to settings table +---@type table +local settings = {} + +--- refrence to list table +---@type table +local list = {} + +--- user id + apikey +---@type string +local user_api + +--- function which returns a library table (a struct) +---@return table library +function M.library() + return { + --- library id + ---@type string|nil + id = nil, + + --- name of library + ---@type string + name = "", + + --- position in library + ---@type number + pos = 1 + } +end + +---table of generic actions on http return codes +---@type table +local http = { + [401] = function() + msg.trace("return code 401 encoutered retrying authentication") + rt.authenticated = M.authenticate() + if not rt.authenticated then + msg.trace("authentication failed, good luck") + end + end, + [403] = function() + msg.trace("return code 403 encoutered retrying authentication") + rt.authenticated = M.authenticate() + if not rt.authenticated then + msg.trace("authentication failed, good luck") + end + end +} + +--- function which returns a video table (a struct) +---@return table video +function M.video() + return { + --- id of video + ---@type string|nil + id = nil, + + --- progress through video in seconds + ---@type number + time = 0 + } +end + +--- return jellyfin ticks +---@param s number seconds +---@return number ticks +function M.seconds2ticks(s) + return s * 10000000 +end + +--- setup jellyfin api +---@param _rt table runtime +---@param _settings table settings +function M.setup(_list, _rt, _settings) + list = _list + rt = _rt + settings = _settings + + msg.trace("initialized the jellyfin api") +end + +--- attempt to authenticate the user with their specified server +---@return boolean authenticated true if successfully authenticated +function M.authenticate() + local result, code = curl.request({ + settings.url.."/Users/AuthenticateByName", + "-H", "accept: application/json", + "-H", "content-type: application/json", + "-H", 'x-emby-authorization: MediaBrowser Client="' + ..settings.client.name..'", Device="'..settings.client.device + ..'", DeviceId="1", Version="'..settings.client.version..'"', + "-d", '{"username":"'..settings.username..'","Pw":"'..settings.password + ..'"}', + }) + + -- failed to get anything + if not result and not code then + msg.error(string.format( + "failed to connect to jellyfin server at: %s, no response", settings.url)) + return false + elseif not result and code then + msg.error(string.format( + "failed to connect to jellyfin server at: %s, error code %s", + settings.url, code)) + return false + end + + msg.trace(string.format("successfully connected to %s", settings.url)) + + -- load the userinfo table into the runtime table + rt.user = result + + if not rt.user then + msg.error(string.format("something weird happened: %s", utils.to_string(result))) + -- something has gone wrong, this could mean that we have not authenticated correctly + return false + end + + -- populate user api string + user_api = "?api_key="..rt.user.AccessToken.."&userID="..rt.user.User.Id + + return true +end + +--- get the current position in the library +---@return table ret new list to display +function M.getpos() + ---@type table + local ret, libraries, items, seasons, episodes + ---@type number? + local code + + -- If you're not in a library you're on the homepage + if not rt.library.id then + libraries, code = curl.request(settings.url.."/Items"..user_api, "GET") + + ret = libraries.Items + -- If you're not viewing a movie or show you're viewing a library + elseif not rt.title.id then + items, code = curl.request(settings.url.."/Items"..user_api.."&parentId=".. + rt.library.id.."&sortBy=SortName", "GET") + + ret = items.Items + + -- if you're not viewing a season of a show you're viewing a list of seasons + elseif not rt.season.id then + seasons, code = curl.request(settings.url.."/Items"..user_api.. + "&parentId="..rt.title.id, "GET") + + ret = seasons.Items + + -- you're viewing a list of episodes in a season + else + episodes, code = curl.request(settings.url.."/Items"..user_api.. + "&parentId="..rt.season.id, "GET") + + ret = episodes.Items + end + + if code == 200 then + msg.trace("successfully retrieved information at position") + elseif code == 401 then + msg.error("Unauthorizied to get information at position") + elseif code == 403 then + msg.error("Forbidden from getting information at position") + end + + return ret +end + +--- play file based on rt.video.id +function M.play() + msg.trace(string.format("playing movie with id %s", rt.video.id)) + + local ok, err = mp.commandv("loadfile", settings.url.."/Videos/"..rt.video.id + .."/stream"..user_api.."&static=true") + + if not ok then + return + end + + mp.set_property("force-media-title", rt.menu.items[list.selected].Name) + + -- we started playing this file + local _, code curl.request(settings.url.."/PlayingItems/"..rt.video.id..user_api.. + "&canSeek=true", "POST") + + if code == 204 then + msg.trace("play start recorded on server") + elseif code == 401 then + msg.error("Unauthorizied to report playback start") + elseif code == 403 then + msg.error("Forbidden from get reporting playback start") + end + + -- try some generic actions that may help. wrapped in pcall so nothing stupid + -- can happen (fingers crossed) + pcall(http[code]) + + -- close ui after playback starts + list:close() + + --- callback when the file has been loaded + local function file_loaded() + if rt.menu.items[list.selected].UserData.PlayedPercentage then + mp.commandv("seek", + rt.menu.items[list.selected].UserData.PlayedPercentage, + "absolute-percent") + end + + M.reportplayback() + + -- unregister event + mp.unregister_event(file_loaded) + end + + -- when the file has loaded seek to last positon if there is one + mp.register_event("file-loaded", file_loaded) +end + +--- set current item's played status +---@param bool boolean played +function M.setplayed(bool) + ---@type number? + local code + ---@type string? + local ret + + -- if no item selected leave + if not rt.menu.items[list.selected].Id then + return + end + + if bool == true then + -- if the video isn't already played set it played + if rt.menu.items[list.selected].UserData.Played == false then + ret, code = curl.request(settings.url.."/UserPlayedItems/" + ..rt.menu.items[list.selected].Id..user_api, "POST") + end + elseif bool == false then + -- if the video isn't unplayed set it to unplayed + if rt.menu.items[list.selected].UserData.Played == true then + ret, code = curl.request(settings.url.."/UserPlayedItems/" + ..rt.menu.items[list.selected].Id..user_api, "DELETE") + end + end + + -- check if the server has recieved the request and update accordingly + if not code then + msg.error(string.format("couldn't mark item '%s' as played", + rt.menu.items[list.selected].Name)) + return + elseif code == 200 then + msg.trace("new user data recieved updating info") + rt.menu.items[list.selected].UserData = ret + elseif code == 401 then + msg.error("unauthorizied to get user data") + elseif code == 403 then + msg.error("forbidden from getting user data") + elseif code == 404 then + msg.error(string.format("item not found on %s", + rt.menu.items[list.selected].Name)) + end + + -- try some generic actions that may help. wrapped in pcall so nothing + -- stupid can happen (fingers crossed) + pcall(http[code]) +end + +--- set current items favorite status +---@param bool boolean favorite +function M.setfavorite(bool) + ---@type number? + local code + ---@type string? + local ret + + -- if no item selected leave + if not rt.menu.items[list.selected].Id then + return + end + + if bool == true then + -- if the video isn't already favorite set it as a favorite + if rt.menu.items[list.selected].UserData.IsFavorite == false then + ret, code = curl.request(settings.url.."/UserFavoriteItems/" + ..rt.menu.items[list.selected].Id..user_api, "POST") + end + elseif bool == false then + -- if the video isn't a favorite unfavorite it + if rt.menu.items[list.selected].UserData.IsFavorite == true then + ret, code = curl.request(settings.url.."/UserFavoriteItems/" + ..rt.menu.items[list.selected].Id..user_api, "DELETE") + end + end + + -- check if the server has recieved the request and update accordingly + if not code then + msg.error(string.format("couldn't mark item '%s' as favorite", + rt.menu.items[list.selected].Name)) + return + elseif code == 200 then + msg.trace("new user data recieved updating info") + rt.menu.items[list.selected].UserData = ret + elseif code == 401 then + msg.error("unauthorizied to get user data") + elseif code == 403 then + msg.error("forbidden from getting user data") + end + + -- try some generic actions that may help. wrapped in pcall so nothing + -- stupid can happen (fingers crossed) + pcall(http[code]) +end + +--- start playback reporting +function M.reportplayback() + --- table of events used to keep the server up to date with what's happening + --- on the client + ---@type table + local A = {} + + --- last percent that we reported to the server + ---@type number + local last_reported_percent + + --- listen to an event and run a callback on change + ---@param property string mpv native property to listen to + ---@param callback function function callback + local function listen(property, callback) + A[#A + 1] = { + property = property, + callback = callback + } + end + + local function elapsedticks() + local timepos = mp.get_property_native("time-pos") + if not timepos then + return nil + end + + -- convert the elapsed seconds to ticks (not sure what ticks are I just + -- know that 1 seconds is equal to 10,000,000 ticks) + return M.seconds2ticks(math.floor(timepos)) + end + + local function paused() + if mp.get_property_native("pause") then + return "true" + else + return "false" + end + end + + listen("percent-pos", function(name) + local value = mp.get_property_native(name) + if not value then + return + end + + local percent = math.floor(value) + + if percent ~= last_reported_percent then + local ticks = elapsedticks() + if not ticks then + msg.trace("failed to report playback time to the server: unable to get time-pos") + return + end + + -- send off percent position to the server + local _, code = curl.request(settings.url.."/Users/"..rt.user.User.Id.. + "/PlayingItems/"..rt.video.id.."/Progress/?api_key=".. + rt.user.AccessToken.."&positionTicks="..ticks.."&isPaused="..paused(), + "POST") + + if code == 204 then + msg.trace("playback percent recorded on server") + elseif code == 401 then + msg.error("Unauthorizied to report playback percent") + elseif code == 403 then + msg.error("Forbidden from get reporting playback percent") + end + + -- try some generic actions that may help. wrapped in pcall so nothing + -- stupid can happen (fingers crossed) + pcall(http[code]) + + -- set our last reported percent to the current percent + last_reported_percent = percent + + msg.trace(string.format("reported %s%% as last known position to the server", + last_reported_percent)) + end + end) + + listen("pause", function(name) + ---@type number? + local code + local value = paused() + local ticks = elapsedticks() + if not ticks then + msg.trace("reported pause may be inaccurate: failed to get time-pos") + + -- fallback to just sending a pause without the current position if we're + -- unable to get the current position + _, code = curl.request(settings.url.."/Users/"..rt.user.User.Id.. + "/PlayingItems/"..rt.video.id.."/Progress/?api_key=".. + rt.user.AccessToken.."&isPaused="..value, "POST") + + goto error + end + + -- send pause with the current position + _, code = curl.request(settings.url.."/Users/"..rt.user.User.Id.. + "/PlayingItems/"..rt.video.id.."/Progress/?api_key=".. + rt.user.AccessToken.."&isPaused="..value.."&positionTicks="..ticks, + "POST") + + ::error:: + + if code == 204 then + msg.trace("pause recorded on server") + elseif code == 401 then + msg.error("Unauthorizied to report pause") + elseif code == 403 then + msg.error("Forbidden from get reporting pause") + end + + -- try some generic actions that may help. wrapped in pcall so nothing + -- stupid can happen (fingers crossed) + pcall(http[code]) + end) + + local function endfile(info) + ---@type number? + local code + local ticks = elapsedticks() + if not ticks then + -- fallback to just telling the server we stopped watching without the + -- current position if we're unable to get the current position + _, code = curl.request(settings.url.."/Users/"..rt.user.User.Id.. + "/PlayingItems/"..rt.video.id.."?api_key="..rt.user.AccessToken, + "DELETE") + + msg.trace("reported stopping time may be inaccurate: failed to get time-pos") + else + _, code = curl.request(settings.url.."/Users/"..rt.user.User.Id.. + "/PlayingItems/"..rt.video.id.."?api_key="..rt.user.AccessToken.. + "&positionTicks="..ticks, "DELETE") + + msg.trace("reported stopping time successfully") + end + + if code == 204 then + msg.trace("play end recorded on server") + elseif code == 401 then + msg.error("Unauthorizied to report play end") + elseif code == 403 then + msg.error("Forbidden from get reporting play end") + end + + -- try some generic actions that may help. wrapped in pcall so nothing + -- stupid can happen (fingers crossed) + pcall(http[code]) + + msg.trace(string.format("ending reason: %s", info.reason)) + + -- unregister all events for the video + mp.unregister_event(endfile) + for _, val in pairs(A) do + mp.unobserve_property(val.callback) + end + end + + -- register end file event + mp.register_event("end-file", endfile) + + -- register all listeners + for _, val in pairs(A) do + mp.observe_property(val.property, "native", val.callback) + end +end + +return M -- cgit v1.2.1