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

66
README.md Normal file
View 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
View 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
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