initial commit

This commit is contained in:
2024-08-03 22:42:15 -04:00
commit d400da457e
4 changed files with 1006 additions and 0 deletions

41
utils/curl.lua Normal file
View File

@ -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

497
utils/jellyfin.lua Normal file
View File

@ -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