initial commit
This commit is contained in:
66
README.md
Normal file
66
README.md
Normal file
@ -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.
|
402
main.lua
Normal file
402
main.lua
Normal file
@ -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)
|
41
utils/curl.lua
Normal file
41
utils/curl.lua
Normal 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
497
utils/jellyfin.lua
Normal 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
|
Reference in New Issue
Block a user