summaryrefslogtreecommitdiff
path: root/rx.lua
diff options
context:
space:
mode:
authorDavid Vazgenovich Shakaryan <dvshakaryan@gmail.com>2026-01-29 21:48:19 -0800
committerDavid Vazgenovich Shakaryan <dvshakaryan@gmail.com>2026-01-29 21:48:19 -0800
commit28068fb57dc1e0aff22f6de3c79ee3f9d8ffa65d (patch)
tree16d25a670867ba56a53ea07afacd780b07275d8c /rx.lua
parentd59d49480e2981345fe173f741d6b0ad4a2d2320 (diff)
downloadmpv-iptv-menu-28068fb57dc1e0aff22f6de3c79ee3f9d8ffa65d.tar.gz
mpv-iptv-menu-28068fb57dc1e0aff22f6de3c79ee3f9d8ffa65d.tar.xz
move application-aware utility functions to separate module
Diffstat (limited to 'rx.lua')
-rw-r--r--rx.lua374
1 files changed, 374 insertions, 0 deletions
diff --git a/rx.lua b/rx.lua
new file mode 100644
index 0000000..007ca13
--- /dev/null
+++ b/rx.lua
@@ -0,0 +1,374 @@
+-- Copyright 2025 David Vazgenovich Shakaryan
+
+local util = require('util')
+
+local rx = {}
+
+local state
+local osd
+local ctx
+
+function rx.init(_state, _osd, _ctx)
+ state = _state
+ osd = _osd
+ ctx = _ctx
+end
+
+function rx.series_children(series, info)
+ if not info or not info.seasons then
+ return {}
+ end
+
+ local seasons = {}
+ for _, season in pairs(info.seasons) do
+ local episodes = {}
+ local season_num = tostring(season.season_number)
+ if info.episodes and info.episodes[season_num] then
+ for i, episode in pairs(info.episodes[season_num]) do
+ local epinfo = episode.info or {}
+ local t = {
+ name = util.strip(episode.title),
+ type = 'stream',
+ stream_type = 'series',
+ id = series.section .. ':stream:' ..
+ episode.id,
+ stream_id = episode.id,
+ img_url = util.strip_ne(
+ epinfo.movie_image),
+ }
+ t.info_data = {
+ name = t.name,
+ cover = t.img_url,
+ description = epinfo.plot,
+ releasedate = epinfo.releasedate,
+ duration = epinfo.duration,
+ video = epinfo.video,
+ audio = epinfo.audio,
+ }
+ episodes[#episodes+1] = t
+ end
+ end
+
+ local count = tostring(#episodes)
+ local tmp = util.strip_ne(season.episode_count)
+ if tmp then
+ count = count .. '/' .. tmp
+ end
+ local t = {
+ type = 'group',
+ group_type = 'season',
+ id = series.id .. ':season:' .. season.id,
+ children = episodes,
+ name = util.strip(season.name),
+ info = count,
+ img_url = util.strip_ne(season.cover_big) or
+ util.strip_ne(season.cover),
+ }
+ t.info_data = {
+ name = t.name,
+ cover = t.img_url,
+ description = season.overview,
+ releasedate = season.air_date,
+ num_episodes = count,
+ }
+ seasons[#seasons+1] = t
+ end
+
+ return seasons
+end
+
+local entry_mt = {
+ __index = function(t, k)
+ local v = t._v
+
+ if k == 'info' then
+ if v.type == 'group' and not v.hide_count then
+ local c = ctx.catalogue:group_count(v)
+ local ret = c and tostring(c) or ''
+ if not v.count_f then
+ rawset(t, 'info', ret)
+ end
+ return ret
+ elseif v.epg_channel_id then
+ local time = osd.redraw_time
+ if t._exp and time < t._exp then
+ return t._info
+ end
+
+ local prog = ctx.epg:scheduled_programme(
+ v.epg_channel_id, time)
+ local ret = prog and prog.title or ''
+ local exp = prog and prog.stop
+ rawset(t, 'active_programme', prog)
+
+ if not prog then
+ prog = ctx.epg:next_programme(
+ v.epg_channel_id, time)
+ exp = prog and prog.start
+ end
+
+ rawset(t, exp and '_info' or 'info', ret)
+ rawset(t, '_exp', exp)
+ return ret
+ end
+ end
+
+ return v[k]
+ end,
+}
+
+function rx.menu_options_group(group)
+ local options = {}
+ for i, v in ipairs(ctx.catalogue:group_children(group)) do
+ local t = setmetatable({_v = v}, entry_mt)
+
+ if group.id == 'favourites' then
+ local path = ctx.catalogue:path_from_root(v)
+ if path then
+ t.path = path
+ end
+ end
+
+ options[i] = t
+ end
+ return options
+end
+
+local function add_info_field(dst, k, v, fmt)
+ local str = util.strip_ne(v)
+ if not str then
+ return
+ end
+ if fmt then
+ str = string.format(fmt, str)
+ end
+ if k then
+ str = k .. ': ' .. str
+ end
+
+ -- continuation lines are 4 chars shorter and indented with 2 em spaces
+ for i, v in ipairs(util.wrap(str, 80, 76)) do
+ if i > 1 then
+ v = '\xe2\x80\x83\xe2\x80\x83' .. v
+ end
+ dst[#dst+1] = {name = v}
+ end
+end
+
+local function add_info_set(options, set)
+ if #set > 0 then
+ options[#options+1] = {name = ''}
+ for _, v in ipairs(set) do
+ options[#options+1] = v
+ end
+ end
+end
+
+function rx.menu_options_entry_info(info)
+ if not info or not info.info then
+ return
+ end
+ local info = info.info
+
+ local options = {}
+ add_info_field(options, nil, info.name)
+ add_info_field(options, 'Directed by', info.director)
+ add_info_field(options, 'Starring', info.cast)
+
+ local desc = util.strip_ne(info.description) or
+ util.strip_ne(info.plot)
+ if desc then
+ options[#options+1] = {name = ''}
+ for _, v in ipairs(util.wrap(desc, 80)) do
+ options[#options+1] = {name = v}
+ end
+ end
+
+ local set = {}
+ add_info_field(set, 'Genre', info.genre)
+ local date = util.strip_ne(info.releasedate) or
+ util.strip_ne(info.releaseDate)
+ if date then
+ local y, m, d = date:match('(%d+)-(%d+)-(%d+)')
+ if y then
+ local dt = {year = y, month = m, day = d}
+ add_info_field(set, 'Release date',
+ os.date('%d %B %Y', os.time(dt)))
+ end
+ end
+ add_info_field(set, 'Episode count', info.num_episodes)
+ add_info_field(set, 'Running time', info.duration)
+ add_info_set(options, set)
+
+ local set = {}
+ if info.video then
+ local w = util.strip_ne(info.video.width)
+ local h = util.strip_ne(info.video.height)
+ if w and h then
+ local res = w .. 'x' .. h
+ local ar = util.strip_ne(
+ info.video.display_aspect_ratio)
+ if ar then
+ res = res .. ' (' .. ar .. ')'
+ end
+ add_info_field(set, 'Resolution', res)
+ end
+ add_info_field(set, 'Video codec', info.video.codec_long_name)
+ end
+ if info.audio then
+ add_info_field(set, 'Audio codec', info.audio.codec_long_name)
+ end
+ if info.bitrate ~= 0 then
+ add_info_field(set, 'Bitrate', info.bitrate, '%s Kbps')
+ end
+ add_info_set(options, set)
+
+ local ytid = util.strip_ne(info.youtube_trailer)
+ if ytid then
+ local url = 'https://youtu.be/' .. ytid
+ add_info_set(options, {{
+ name = 'Trailer: ' .. url,
+ type = 'stream',
+ id = 'youtube:' .. ytid,
+ stream_url = url,
+ }})
+ end
+
+ local img = util.strip_ne(info.cover_big) or util.strip_ne(info.cover)
+ return options, img
+end
+
+local programme_mt = {
+ __index = function(t, k)
+ if k == 'active' then
+ local time = osd.redraw_time
+ if t._exp and time < t._exp then
+ return t._active
+ end
+
+ local v = t.programme
+ local ret = time >= v.start and time < v.stop
+ local exp = time < v.start and v.start or
+ ret and v.stop or nil
+ rawset(t, exp and '_active' or 'active', ret)
+ rawset(t, '_exp', exp)
+ return ret
+ end
+ end,
+}
+
+function rx.menu_options_channel_epg(ch)
+ local progs = ctx.epg:channel_programmes(ch)
+ if not progs then
+ return
+ end
+
+ local options = {}
+ local idx
+ local time = os.time()
+ for i, v in ipairs(progs) do
+ local prog = {
+ name = os.date('%a %d %b %H:%M', v.start) .. ' ' ..
+ os.date('%H:%M', v.stop) .. ' ' .. v.title,
+ type = 'programme',
+ info = v.desc,
+ programme = v,
+ }
+
+ if not idx and time < v.stop then
+ idx = i
+ end
+
+ options[i] = setmetatable(prog, programme_mt)
+ end
+
+ return options, idx or #options
+end
+
+function rx.menu_options_programme_info(prog)
+ local options = {
+ {name = 'Title: ' .. prog.title},
+ {name = 'Start: ' .. os.date('%a %d %b %H:%M', prog.start)},
+ {name = 'Stop: ' .. os.date('%a %d %b %H:%M', prog.stop)},
+ }
+
+ if prog.desc then
+ options[#options+1] = {name = ''}
+ for _, v in ipairs(util.wrap(prog.desc, 80)) do
+ options[#options+1] = {name = v}
+ end
+ end
+
+ return options
+end
+
+local function menu_options_search_build(options, t, path)
+ for _, v in ipairs(options) do
+ v.path = path
+
+ if v.type == 'group' and v.group_type ~= 'series' then
+ t.categories[#t.categories+1] = v
+ else
+ t.elements[#t.elements+1] = v
+ end
+
+ -- contents of lazy-loaded groups should not be searchable
+ if v.type == 'group' and v.children then
+ local path = util.copy_table(path)
+ path[#path+1] = v
+ menu_options_search_build(
+ rx.menu_options_group(v), t, path)
+ end
+ end
+end
+
+function rx.menu_options_search(opts)
+ local t = {categories = {}, elements = {}}
+
+ local options = {}
+ for i, v in ipairs(opts) do
+ -- menu options may contain dynamic data that is updated on
+ -- redraw. using a proxy table instead of copying allows a
+ -- single update to target both the source and search menus.
+ options[i] = setmetatable({}, {__index = v})
+ end
+
+ -- empty table is needed to shadow existing path from proxied table
+ menu_options_search_build(options, t, {})
+
+ -- display categories first
+ options = t.categories
+ for _, v in ipairs(t.elements) do
+ options[#options+1] = v
+ end
+ return options
+end
+
+function rx.sort_menu_options(opts)
+ local scores = {}
+ for _, v in ipairs(opts) do
+ local score = 0
+ if v.missing then
+ score = score - 4
+ end
+ if state:favourited(v.id) then
+ score = score + 2
+ end
+ if v.type == 'group' and v.group_type ~= 'series' then
+ score = score + 1
+ end
+ scores[v] = score
+ end
+
+ table.sort(opts, function(a, b)
+ local sa = scores[a]
+ local sb = scores[b]
+ if sa ~= sb then
+ return sa > sb
+ else
+ return a.name < b.name
+ end
+ end)
+end
+
+return rx