-- Copyright 2025 David Vazgenovich Shakaryan local cacher = require('cacher') local config = require('config') local util = require('util') local _catalogue = require('catalogue') local _downloader = require('downloader') local _epg = require('epg') local _osd = require('osd') local _xc = require('xc') local mp_utils = require('mp.utils') local script_name = mp.get_script_name() local script_dir = mp.get_script_directory() local downloader = _downloader.new() local xc = _xc.new({ server = mp.get_opt('iptv_menu.xc_server'), user = mp.get_opt('iptv_menu.xc_user'), pass = mp.get_opt('iptv_menu.xc_pass'), }) xc = cacher.wrap(xc, { directory = config.cache_dir, prefix = (xc.server:gsub('%W', '_')), time = 24*60*60, functions = { get_live_categories = true, get_live_streams = true, get_vod_categories = true, get_vod_streams = true, get_vod_info = true, get_series_categories = true, get_series = true, get_series_info = true, get_epg = true, }, }) local catalogue = _catalogue.new() local epg = _epg.new() local osd = _osd.new() local favourites local playing_id local depth = 0 local menus = {} local key_bindings = {} local function update_osd() osd:redraw(menus, depth, favourites, playing_id) end local function osd_menu_lines() return osd:menu_lines(depth) end -- FIXME leaving this here as a global for now since the image is downloaded -- during osd redraw but this function has other dependencies function get_image_path(url, dl) local path = mp_utils.join_path(config.img_dir, url:gsub('%W', '_')) local f = mp_utils.file_info(path) if f then return path end if dl then downloader:schedule(url, path, function(_, file) if osd.img and file == osd.img.path then update_osd() end end) return path end end local function load_data() local arr = { {id = 'live', name = 'Live TV', type = 'live'}, {id = 'movie', name = 'Movies', type = 'vod'}, {id = 'series', name = 'Series', type = 'series'}, } for _, v in ipairs(arr) do v.categories = xc['get_' .. v.type .. '_categories'](xc) v.elements = xc[ v.type == 'series' and 'get_series' or ('get_' .. v.type .. '_streams')](xc) end catalogue:load_xc_data(arr) epg:load_xc_data(xc:get_epg()) local t = util.read_json_file(config.favourites_file) favourites = t.favourites or {} end local function save_favourites() util.write_json_file(config.favourites_file, {favourites = favourites}) end -- returns index or nil function favourited(id) for i, v in ipairs(favourites) do if v == id then return i end end end local function set_cursor(pos, opts) local menu = menus[depth] local lines = osd_menu_lines() local pos = math.max(1, math.min(pos, #menu.options)) local top = menu.view_top if opts and opts.centre then top = pos - math.floor((osd_menu_lines() - 1) / 2) elseif opts and opts.keep_offset then top = top + pos - menu.cursor end -- move view to keep selected option visible if pos < top then top = pos elseif pos > top + lines - 1 then top = pos - lines + 1 end top = math.max(1, math.min(top, #menu.options - lines + 1)) menu.cursor = pos menu.view_top = top if not (opts and opts.skip_redraw) then update_osd() end end local function cursor_up() set_cursor(menus[depth].cursor - 1) end local function cursor_down() set_cursor(menus[depth].cursor + 1) end local function cursor_start() set_cursor(0) end local function cursor_end() set_cursor(#menus[depth].options) end local function cursor_page_up() set_cursor( menus[depth].cursor - osd_menu_lines(), {keep_offset = true}) end local function cursor_page_down() set_cursor( menus[depth].cursor + osd_menu_lines(), {keep_offset = true}) end local function cursor_to_object(id) for i, v in ipairs(menus[depth].options) do if v.id == id then set_cursor(i, {centre = true}) return end end end -- inserts the given id into the favourites array before the next favourited -- menu option, starting from the next cursor position, or the end if no such -- option is found. this is meant for in-place favouriting from the favourites -- menu. local function insert_favourite_before_next_in_menu(id) local menu = menus[depth] for i = menu.cursor+1, #menu.options do local ind = favourited(menu.options[i].id) if ind then table.insert(favourites, ind, id) return end end favourites[#favourites+1] = id end local function move_option(pos, opts) local menu = menus[depth] if menu.group_id ~= 'favourites' or menu.sorted then return end local prev_cursor = menu.cursor local opts = opts or {} opts.skip_redraw = true set_cursor(pos, opts) if menu.cursor == prev_cursor then return end local opt = table.remove(menu.options, prev_cursor) table.insert(menu.options, menu.cursor, opt) local ind = favourited(opt.id) if ind then table.remove(favourites, ind) insert_favourite_before_next_in_menu(opt.id) save_favourites() end update_osd() end local function move_option_up() move_option(menus[depth].cursor - 1) end local function move_option_down() move_option(menus[depth].cursor + 1) end local function move_option_start() move_option(0) end local function move_option_end() move_option(#menus[depth].options) end local function move_option_page_up() move_option( menus[depth].cursor - osd_menu_lines(), {keep_offset = true}) end local function move_option_page_down() move_option( menus[depth].cursor + osd_menu_lines(), {keep_offset = true}) end local function push_menu(t) local menu = { options = {}, cursor = 1, view_top = 1, } for k, v in pairs(t) do menu[k] = v end depth = depth + 1 menus[depth] = menu end local function sort_options(options) local scores = {} for _, v in ipairs(options) do local score = 0 if v.missing then score = score - 4 end if 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(options, 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 local function add_programme(opt, time) if opt.epg_channel_id then local prog = epg:scheduled_programme(opt.epg_channel_id, time) if prog then opt.info = prog.title end end end local function group_count(group) if group.children and not group.lazy then local count = 0 for _, v in ipairs(group.children) do if v.type == 'stream' or v.group_type == 'series' then count = count + 1 elseif v.type == 'group' then local c = group_count(v) if c then count = count + c end end end return count elseif group.id == 'favourites' then -- not recursive return #favourites end end local function favourites_group_menu_options(group) local options = {} local time = os.time() for _, id in ipairs(favourites) do local obj = catalogue:get(id) if obj then local path = {} local curr = obj while curr.parent_id and curr.parent_id ~= 'root' and catalogue:get(curr.parent_id) do curr = catalogue:get(curr.parent_id) path[#path+1] = curr end obj = util.copy_table(obj) add_programme(obj, time) local c = group_count(obj) if c then obj.info = tostring(c) end if #path > 0 and curr.parent_id == 'root' then util.reverse(path) obj.path = path end options[#options+1] = obj else -- display missing favourites so that they can be -- removed options[#options+1] = { id = id, name = id, missing = true, } end end return options end local function series_group_menu_options(series) local info = xc:get_series_info(series.series_id) 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 episodes[#episodes+1] = { name = util.strip(episode.title), type = 'stream', stream_type = 'series', id = series.section .. ':stream:' .. episode.id, stream_id = episode.id, } end end local count = tostring(#episodes) if season.episode_count then count = count .. '/' .. season.episode_count end seasons[#seasons+1] = { type = 'group', group_type = 'season', id = series.id .. ':season:' .. season.id, children = episodes, name = util.strip(season.name), info = count, } end return seasons end local function group_menu_options(group) if group.id == 'favourites' then return favourites_group_menu_options(group) end if group.group_type == 'series' then return series_group_menu_options(group) end local options = {} local time = os.time() for i, v in ipairs(group.children) do v = util.copy_table(v) add_programme(v, time) local c = group_count(v) if c then v.info = tostring(c) end options[i] = v end return options end local function push_group_menu(group) push_menu({ options = group_menu_options(group), title = group.name, type = 'group', group_id = group.id, }) update_osd() end local function prev_menu() depth = depth - 1 if depth == 0 then -- reset main menu push_group_menu(catalogue:get(menus[1].group_id)) else update_osd() end end local function play_stream(stream) local url = stream.stream_url or xc:stream_url(stream.stream_type, stream.stream_id) if not url then return end -- add a per-file option containing the stream id, allowing it to be -- retrieved when a start-file event is received mp.commandv('loadfile', url, 'replace', -1, 'script-opt=iptv_menu.playing_id=' .. stream.id) end local function select_option() local menu = menus[depth] local opt = menu.options[menu.cursor] if not opt or not opt.id then return end if opt.type == 'group' then push_group_menu(opt) elseif opt.type == 'stream' then play_stream(opt) end end local function favourite_option() local menu = menus[depth] local opt = menu.options[menu.cursor] if not opt or not opt.id then return end local ind = favourited(opt.id) if ind then table.remove(favourites, ind) elseif menu.group_id == 'favourites' then insert_favourite_before_next_in_menu(opt.id) else favourites[#favourites+1] = opt.id end save_favourites() update_osd() end local function goto_option() local menu = menus[depth] local opt = menu.options[menu.cursor] if not opt then return end if menu.type == 'search' or menu.group_id == 'favourites' then depth = depth - 1 end if opt.path then for i = 1, #opt.path do cursor_to_object(opt.path[i].id) push_group_menu(opt.path[i]) end end cursor_to_object(opt.id) update_osd() end local function goto_playing() if not playing_id then return end local obj = catalogue:get(playing_id) if not obj then return end local path = {} local curr = obj while curr.parent_id and curr.parent_id ~= 'root' and catalogue:get(curr.parent_id) do curr = catalogue:get(curr.parent_id) path[#path+1] = curr end if #path == 0 or curr.parent_id ~= 'root' then return end depth = 1 for i = #path, 1, -1 do cursor_to_object(path[i].id) push_group_menu(path[i]) end cursor_to_object(obj.id) update_osd() end local function open_epg_programme(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 push_menu({ options = options, title = 'Programme: ' .. prog.title, type = 'epg', }) update_osd() end local function open_option_epg(opt) local ch = opt.epg_channel_id:lower() local progs = epg:channel_programmes(ch) if not progs then return end local options = {} local curr = 0 local time = os.time() for i, v in ipairs(progs) do prog = { name = os.date('%a %d %b %H:%M', v.start) .. ' ' .. os.date('%H:%M', v.stop) .. ' ' .. v.title, info = v.desc, programme = v, } if curr == 0 and time >= v.start and time < v.stop then curr = i prog.active = true end options[i] = prog end push_menu({ options = options, title = 'EPG: ' .. opt.name .. ' (' .. ch .. ')', type = 'epg', }) set_cursor(curr, {centre = true}) end local function add_info_field(dst, k, v, fmt) if not v or v == '' then return end local str = util.strip(tostring(v)) 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 = '\226\128\131\226\128\131' .. v end dst[#dst+1] = {name = v} end end local function open_option_title_info(title, info) if not info or not info.info then return end 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 = info.description or 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 = {{name = ' '}} add_info_field(set, 'Genre', info.genre) if info.releasedate then local y, m, d = info.releasedate: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, 'Running time', info.duration) if #set > 1 then for _, v in ipairs(set) do options[#options+1] = v end end set = {{name = ' '}} if info.video then if info.video.width and info.video.height then local res = info.video.width .. 'x' .. info.video.height if info.video.display_aspect_ratio then res = res .. ' (' .. info.video.display_aspect_ratio .. ')' 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 if #set > 1 then for _, v in ipairs(set) do options[#options+1] = v end end if info.youtube_trailer and info.youtube_trailer ~= '' then local url = 'https://youtu.be/' .. info.youtube_trailer options[#options+1] = {name = ' '} options[#options+1] = { name = 'Trailer: ' .. url, type = 'stream', id = 'youtube:' .. info.youtube_trailer, stream_url = url, } end local m = { options = options, title = title, type = 'info', } if info.cover_big and info.cover_big ~= '' then m.img_url = info.cover_big elseif info.cover and info.cover ~= '' then m.img_url = info.cover end push_menu(m) update_osd() end local function open_option_movie_info(opt) open_option_title_info( 'Movie Info: ' .. opt.name, xc:get_vod_info(opt.stream_id)) end local function open_option_series_info(opt) open_option_title_info( 'Series Info: ' .. opt.name, xc:get_series_info(opt.series_id)) end local function open_option_info() local menu = menus[depth] local opt = menu.options[menu.cursor] if not opt then return end if menu.type == 'epg' and opt.programme then open_epg_programme(opt.programme) elseif opt.epg_channel_id then open_option_epg(opt) elseif opt.group_type == 'series' then open_option_series_info(opt) elseif opt.stream_id and opt.stream_type == 'movie' then open_option_movie_info(opt) end end local function search_menu_options_build(options, t, path) local menu = menus[depth] local path = path or {} for _, v in ipairs(options) do local v = util.copy_table(v) 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 not v.lazy then local path = util.copy_table(path) path[#path+1] = v search_menu_options_build( group_menu_options(v), t, path) end end end local function search_menu_options(options) local t = {categories = {}, elements = {}} search_menu_options_build(options, t) -- display categories first local ret = t.categories for _, v in ipairs(t.elements) do ret[#ret+1] = v end return ret end local function update_search_matches() local menu = menus[depth] if #menu.search_text == 0 then menu.options = menu.search_options update_osd() return end -- no utf8 :( local case_sensitive = not not menu.search_text:find('%u') local options = {} for _, v in ipairs(menu.search_options) do local matches = {} local name = v.name if not case_sensitive then name = name:lower() end local i, j = 0, 0 while true do i, j = name:find(menu.search_text, j + 1, true) if not i then break end matches[#matches+1] = {start = i, stop = j} end if #matches > 0 then local t = util.copy_table(v) t.matches = matches options[#options+1] = t end end menu.options = options end local function search_input_char(event) if event.event ~= 'down' and event.event ~= 'repeat' then return end local menu = menus[depth] menu.search_text = menu.search_text:sub(1, menu.search_cursor - 1) .. event.key_text .. menu.search_text:sub(menu.search_cursor) menu.search_cursor = menu.search_cursor + #event.key_text update_search_matches() update_osd() end local function search_input_bs() local menu = menus[depth] if menu.search_cursor <= 1 then return end local pos = util.utf8_seek(menu.search_text, menu.search_cursor, -1) menu.search_text = menu.search_text:sub(1, pos - 1) .. menu.search_text:sub(menu.search_cursor) menu.search_cursor = pos update_search_matches() update_osd() end local function search_input_del() local menu = menus[depth] if menu.search_cursor > #menu.search_text then return end menu.search_text = menu.search_text:sub(1, menu.search_cursor - 1) .. menu.search_text:sub(util.utf8_seek( menu.search_text, menu.search_cursor, 1)) update_search_matches() update_osd() end local function set_search_cursor(pos) local menu = menus[depth] local pos = math.max(1, math.min(#menu.search_text + 1, pos)) if pos == menu.search_cursor then return end menu.search_cursor = pos update_osd() end local function search_cursor_left() local menu = menus[depth] set_search_cursor(util.utf8_seek( menu.search_text, menu.search_cursor, -1)) end local function search_cursor_right() local menu = menus[depth] set_search_cursor(util.utf8_seek( menu.search_text, menu.search_cursor, 1)) end local function search_cursor_start() set_search_cursor(1) end local function search_cursor_end() set_search_cursor(#menus[depth].search_text + 1) end local bind_search_keys local bind_menu_keys local function start_search() local menu = menus[depth] local title = 'Searching: ' .. ' (/)' if menu.type == 'search' then -- resuming search, save previous state menu.prev_search_text = menu.search_text menu.prev_cursor = menu.cursor menu.prev_view_top = menu.view_top menu.title = title menu.search_active = true menu.search_cursor = #menu.search_text + 1 menu.cursor = 1 menu.view_top = 1 else push_menu({ title = title, type = 'search', search_active = true, search_options = search_menu_options(menu.options), search_text = '', search_cursor = 1, }) update_search_matches() end update_osd() bind_search_keys() end local function end_search() local menu = menus[depth] menu.search_active = false menu.title = 'Search results: ' .. ' (/)' update_osd() bind_menu_keys() end local function cancel_search() local menu = menus[depth] -- cancelling resumed search restores previous state if menu.prev_search_text then menu.search_text = menu.prev_search_text menu.cursor = menu.prev_cursor menu.view_top = menu.prev_view_top update_search_matches() end_search() return end menu.search_active = false depth = depth - 1 update_osd() bind_menu_keys() end local function toggle_menu_sort() local menu = menus[depth] if menu.type ~= 'group' and menu.type ~= 'search' then return end local key = menu.type == 'search' and 'search_options' or 'options' menu.sorted = not menu.sorted if menu.sorted then menu['orig_' .. key] = menu[key] menu[key] = util.copy_table(menu[key]) sort_options(menu[key]) else menu[key] = menu['orig_' .. key] menu['orig_' .. key] = nil end if menu.type == 'search' then update_search_matches() end update_osd() end local function bind_key(key, func, opts) -- unique name is needed for removal local i = #key_bindings+1 local name = 'key' .. i key_bindings[i] = name mp.add_forced_key_binding(key, name, func, opts) end local function unbind_keys() for _, key in ipairs(key_bindings) do mp.remove_key_binding(key) end key_bindings = {} end function bind_search_keys() unbind_keys() bind_key('ANY_UNICODE', search_input_char, {complex = true}) bind_key('BS', search_input_bs, {repeatable = true}) bind_key('DEL', search_input_del, {repeatable = true}) bind_key('ENTER', end_search) bind_key('ESC', cancel_search) bind_key('Ctrl+c', cancel_search) bind_key('LEFT', search_cursor_left, {repeatable = true}) bind_key('RIGHT', search_cursor_right, {repeatable = true}) bind_key('Ctrl+a', search_cursor_start) bind_key('Ctrl+e', search_cursor_end) end function bind_menu_keys() unbind_keys() bind_key('BS', prev_menu) bind_key('/', start_search) bind_key('Ctrl+s', toggle_menu_sort) bind_key('ENTER', select_option) bind_key('Ctrl+f', favourite_option) bind_key('g', goto_option) bind_key('?', open_option_info) bind_key('Ctrl+p', goto_playing) bind_key('j', cursor_down, {repeatable = true}) bind_key('k', cursor_up, {repeatable = true}) bind_key('UP', cursor_up, {repeatable = true}) bind_key('DOWN', cursor_down, {repeatable = true}) bind_key('HOME', cursor_start) bind_key('END', cursor_end) bind_key('PGUP', cursor_page_up, {repeatable = true}) bind_key('PGDWN', cursor_page_down, {repeatable = true}) bind_key('J', move_option_down, {repeatable = true}) bind_key('K', move_option_up, {repeatable = true}) bind_key('Shift+UP', move_option_up, {repeatable = true}) bind_key('Shift+DOWN', move_option_down, {repeatable = true}) bind_key('Shift+HOME', move_option_start) bind_key('Shift+END', move_option_end) bind_key('Shift+PGUP', move_option_page_up, {repeatable = true}) bind_key('Shift+PGDWN', move_option_page_down, {repeatable = true}) end -- uses enable-section and disable-section to disable builtin key bindings -- while the OSD is visible. these commands are technically deprecated for -- non-internal use, but they still work, and there doesn't appear to be -- another way apart from setting an override for each individual key. -- -- might eventually change this to a selective override, since some of these -- builtin keys could still be useful while the menus are open. local function set_key_bindings() if osd:is_hidden() then unbind_keys() mp.command_native({'enable-section', 'default'}) elseif menus[depth].search_active then bind_search_keys() mp.command_native({'disable-section', 'default'}) else bind_menu_keys() mp.command_native({'disable-section', 'default'}) end end local function toggle_menu() osd:toggle_hidden() set_key_bindings() end mp.observe_property('osd-dimensions', 'native', function(_, val) osd:resize(val.w, val.h) update_osd() end) mp.register_event('start-file', function() playing_id = mp.get_opt('iptv_menu.playing_id') update_osd() end) mp.register_event('end-file', function() playing_id = nil update_osd() end) load_data() push_group_menu(catalogue:get('root')) -- keys added via bind_key() are unbound when the OSD is closed, but we want -- toggle-menu to work regardless of setting. mp.add_forced_key_binding('TAB', 'toggle-menu', toggle_menu) set_key_bindings()