aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--README.md66
-rw-r--r--main.lua402
-rw-r--r--utils/curl.lua41
-rw-r--r--utils/jellyfin.lua497
4 files changed, 1006 insertions, 0 deletions
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..fda11ce
--- /dev/null
+++ b/README.md
@@ -0,0 +1,66 @@
+# JnJ - Jellies and Jams
+A jellyfin client built into mpv
+## Configuring
+Every option in JnJ may be configured via a script-message. Example:
+```lua
+local mp = require("mp")
+local utils = require("mp.utils")
+
+-- action keybind
+mp.commandv("script-message", "jnj-bind-toggle", "Ctrl+j")
+mp.commandv("script-message", "jnj-bind-enter", "l")
+mp.commandv("script-message", "jnj-bind-leave", "h")
+mp.commandv("script-message", "jnj-bind-down", "j")
+mp.commandv("script-message", "jnj-bind-up", "k")
+mp.commandv("script-message", "jnj-bind-toggle-played", "m")
+mp.commandv("script-message", "jnj-bind-toggle-favorite", "s")
+
+-- send user login info (this should be done through a script message to ensure
+-- other scripts cannot leach your password from the script-message
+mp.commandv("script-message-to", "jellies_and_jams" "jnj-set-settings",
+ utils.format_json({
+ url = 'http://localhost:8096',
+ username = 'username',
+ password = 'password'
+ }))
+```
+
+### Available actions
+- jnj-set-settings
+- jnj-bind-toggle
+- jnj-bind-open
+- jnj-bind-close
+- jnj-bind-leave
+- jnj-bind-down
+- jnj-bind-up
+- jnj-bind-toggle-played
+- jnj-bind-toggle-favorite
+- jnj-bind-top
+- jnj-bind-bottom
+- jnj-bind-pagedown
+- jnj-bind-pageup
+
+`jnj-bind-*` takes a keybind as their only argument
+
+`jnj-set-settings` takes a json formatted string of the following structure:
+```json
+{
+ "url":"",
+ "username":"",
+ "password":"",
+ "header":{
+ "prefix": "jellyfin/",
+ "separator":"/",
+ "contents":[
+ ["watched", " ","favorite"," ","runtime"],
+ ["stars"," ","rating"," ","release"]
+ ]
+ },
+ "client":{
+ "name":"Jellyfin Lua Client",
+ "device":"Mpv",
+ "version":"1.1"
+ }
+}
+```
+All filled in values are provided by default, but may be changed by the user.
diff --git a/main.lua b/main.lua
new file mode 100644
index 0000000..b12807f
--- /dev/null
+++ b/main.lua
@@ -0,0 +1,402 @@
+local mp = require('mp')
+local msg = require('mp.msg')
+local utils = require('mp.utils')
+
+local jf = require("utils.jellyfin")
+
+-- 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
+
+-- make an instance of the list
+local original_open = list.open
+function list:open()
+ original_open(self)
+end
+
+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'
+ }
+}
+
+--- table containing all info about the runtime
+---@type table
+local rt = {
+ authenticated = false, -- are we connected to the server
+ user = {},
+
+ 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
+ return
+ end
+
+ if sel.IsFolder == false then
+ msg.trace("selected a video")
+
+ 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
+ return
+ end
+
+ -- refresh the list
+ rt.menu.items = jf.getpos()
+ redraw_list(rt.menu.items)
+
+ -- open the list
+ list:open()
+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
+ end
+end)
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