-- Copyright 2025 David Vazgenovich Shakaryan local utils = require('mp.utils') -- font size is in units of osd height, which is scaled to 720 local font_size = 20 local colours = { bg='{\\alpha&H44&\\c&H&}', title='{\\c&999999&}', option='{\\c}', info='{\\c&666666&}', group='{\\c&H99DDFF&}', group_empty='{\\c&H446677&}', selected='{\\c&HFF00&}', selected_empty='{\\c&H337733&}', search_hl='{\\c&FFDD&}', search_hl_empty='{\\c&8877&}', search_path='{\\c&666666&}', search_path_empty='{\\c&444444&}', icon_playing='{\\c&HFF6633&}', icon_favourite='{\\c&HFF00FF&}', icon_active='{\\c&H99FF&}', } local script_dir = mp.get_script_directory() local xc_server = mp.get_opt('iptv_menu.xc_server') local xc_user = mp.get_opt('iptv_menu.xc_user') local xc_pass = mp.get_opt('iptv_menu.xc_pass') local osd = mp.create_osd_overlay('ass-events') local osd_lines = math.floor((720 / font_size) + 0.5) - 1 local osd_padding = math.floor((720 - (osd_lines * font_size)) / 2) local osd_cursor_glyph = '{\\p1\\pbo' .. math.floor(font_size / 5) .. '}' .. 'm 0 0 l ' .. math.ceil(font_size / 32) .. ' 0 ' .. math.ceil(font_size / 32) .. ' ' .. font_size .. ' 0 ' .. font_size .. '{\\p0}' local osd_bg = mp.create_osd_overlay('ass-events') osd_bg.z = -1 osd_bg.data = '{\\pos(0,0)}' .. colours.bg .. '{\\p1}m 0 0 l 7680 0 7680 720 0 720{\\p0}' local objects = {} local epg = {} local favourites local playing_id local depth = 0 local menus = {} local key_bindings = {} local function copy_table(t) local u = {} for k, v in pairs(t) do u[k] = v end return u end local function reverse(t) for i = 1, #t/2 do t[i], t[#t-i+1] = t[#t-i+1], t[i] end end local function strip(str) return (str:gsub('^%s*(.-)%s*$', '%1')) end local function utf8_seek(str, pos, n) local step = n > 0 and 1 or -1 local test = n > 0 and function() return pos > #str end or function() return pos <= 1 end while n ~= 0 and not test() do repeat pos = pos + step until test() or bit.band(str:byte(pos), 0xc0) ~= 0x80 n = n - step end return pos end -- returns table of strings wrapped at width. spaces are not removed, resulting -- in width-1 visible chars; newlines and end of string are handled similarly -- for consistency. words longer than width are not broken. local function wrap(str, width) local t = {} local start, stop = 0, 0 while stop < #str do local i = str:find('[ \n]', stop + 1) or #str + 1 if i - start >= width then t[#t+1] = str:sub(start, stop) start = stop + 1 end stop = i if str:byte(stop) == 10 or stop >= #str then t[#t+1] = str:sub(start, stop - 1) .. ' ' start = stop + 1 end end return t end local function read_json_file(fn) local f = io.open(script_dir .. '/' .. fn, 'r') if not f then return {} end local json = f:read('*all') f:close() return utils.parse_json(json) end local function write_json_file(fn, data) local f = io.open(script_dir .. '/' .. fn, 'w') f:write(utils.format_json(data), '\n') f:close() end local function add_object(obj) objects[obj.id] = obj if obj.type == 'group' then obj.children = {} end if not obj.parent_id then return end -- dump any objects referencing nonexistent categories into a single -- catchall category if not objects[obj.parent_id] then obj.parent_id = obj.section .. ':category:catchall' if not objects[obj.parent_id] then add_object({ section=obj.section, type='group', group_type='category', id=obj.parent_id, parent_id=obj.section .. ':category:0', -- non-ascii symbol to sort near end name='🞷CATCHALL🞷', }) end end local parent_children = objects[obj.parent_id].children parent_children[#parent_children+1] = obj end local function load_section(section, name) add_object({ section=section, type='group', group_type='category', id=section .. ':category:0', parent_id='root', name=name, }) local tmp = read_json_file(section .. '_categories.json') for _, v in ipairs(tmp) do v.section = section v.type = 'group' v.group_type = 'category' v.id = section .. ':category:' .. v.category_id v.parent_id = section .. ':category:' .. v.parent_id v.name = strip(v.category_name) add_object(v) end tmp = read_json_file(section .. '_streams.json') for _, v in ipairs(tmp) do v.section = section if v.series_id then v.type = 'group' v.group_type = 'series' v.id = section .. ':series:' .. v.series_id v.lazy = true else v.type = 'stream' v.id = section .. ':stream:' .. v.stream_id end v.parent_id = section .. ':category:' .. v.category_id v.name = strip(v.name) add_object(v) end end -- local (non-DST) offset from UTC local tz_offset do local t = os.time() tz_offset = os.time(os.date('*t', t)) - os.time(os.date('!*t', t)) end local function epg_parse_time(str) local y, m, d, hh, mm, ss, zsign, zh, zm = str:match( '(%d%d%d%d)(%d%d)(%d%d)(%d%d)(%d%d)(%d%d) ([+-])(%d%d)(%d%d)') local dt = {year=y, month=m, day=d, hour=hh, min=mm, sec=ss, isdst=false} return os.time(dt) + tz_offset - (tonumber(zsign..zh) * 3600 + tonumber(zsign..zm) * 60) end local function load_epg() local tmp = read_json_file('epg.json') for _, v in ipairs(tmp) do local ch = v.channel:lower() local prog = { start=epg_parse_time(v.start), stop=epg_parse_time(v.stop), title=v.title, desc=v.desc, } if epg[ch] then epg[ch][#epg[ch]+1] = prog else epg[ch] = {prog} end end for _, progs in pairs(epg) do table.sort(progs, function(a, b) return a.start < b.start end) end end local function load_data() add_object({ type='group', id='root', }) add_object({ type='group', id='favourites', parent_id='root', name='Favourites', lazy=true, }) load_section('live', 'Live TV') load_section('movie', 'Movies') load_section('series', 'Series') load_epg() favourites = read_json_file('favourites.json') -- json loading/dumping breaks when the table is empty, so we need a -- dummy value to prevent that if next(favourites) == nil then favourites = {oi=true} end end local function asscape(str) -- remove newlines before escaping, since we don't want output to -- unexpectedly span multiple lines return mp.command_native({'escape-ass', (str:gsub('\n', ' '))}) end local function osd_menu_lines() if depth > 1 then -- leaves an extra line for padding between titles and options return osd_lines - depth else return osd_lines end end local function osd_menu_title(menu) local str = asscape(menu.title) local col = menu.search_active and colours.selected or colours.title if menu.type == 'search' then str = str:gsub('', asscape(menu.search_text)) if str:find('', 1, true) then str = str:gsub('', asscape(menu.search_text:sub( 1, menu.search_cursor - 1)) .. osd_cursor_glyph .. asscape(menu.search_text:sub( menu.search_cursor))) end str = str:gsub('', colours.info) str = str:gsub('', #menu.options) str = str:gsub('', #menu.search_options) end return col .. ' » ' .. str end local function osd_option_icons(opt, info) local str = '' if info.selected then str = colours.selected .. '› ' end if opt.id and opt.id == playing_id then str = str .. colours.icon_playing .. '\226\143\186 ' end if opt.id and favourites[opt.id] then str = str .. colours.icon_favourite .. '★ ' end if opt.active then str = str .. colours.icon_active .. '\226\143\186 ' end return str end local function osd_option_text(opt, info) local str = opt.name local col = colours.option if info.selected then col = info.empty and colours.selected_empty or colours.selected elseif info.empty then col = colours.group_empty elseif opt.type == 'group' then col = colours.group end if opt.matches then local buf = '' local hl_col = info.empty and colours.search_hl_empty or colours.search_hl local n = 0 for _, match in ipairs(opt.matches) do buf = buf .. col .. asscape(str:sub(n + 1, match.start - 1)) .. hl_col .. asscape(str:sub(match.start, match.stop)) n = match.stop end str = buf .. col .. asscape(str:sub(n + 1)) else str = col .. asscape(str) end if opt.type == 'group' then str = col .. '[' .. str .. ']' end if opt.info and #opt.info > 0 then str = str .. colours.info .. ' (' .. asscape(opt.info) .. ')' end return str end local function osd_option_path(opt, info) if not opt.path or #opt.path == 0 then return '' end local str = info.empty and colours.search_path_empty or colours.search_path for i = #opt.path, 1, -1 do str = str .. ' « ' .. asscape(opt.path[i].name) end return str end local function update_osd() local out = {} if depth > 1 then for i = 2, depth do out[#out+1] = osd_menu_title(menus[i]) end out[#out+1] = ' ' -- space character for correct line height end local menu = menus[depth] for i = menu.view_top, math.min( menu.view_top + osd_menu_lines() - 1, #menu.options) do local opt = menu.options[i] local info = { selected=(i == menu.cursor and not menu.search_active), empty=(opt.type == 'group' and not opt.lazy and #opt.children == 0), } out[#out+1] = osd_option_icons(opt, info) .. osd_option_text(opt, info) .. osd_option_path(opt, info) end -- \q2 disables line wrapping osd.data = '{\\q2\\fs' .. font_size .. '\\pos(' .. osd_padding .. ',' .. osd_padding .. ')}' .. table.concat(out, '\\N') osd:update() osd_bg:update() 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 update_osd() 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 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) table.sort(options, function(a, b) local a_name = a.name local b_name = b.name if favourites[a.id] then a_name = '\0\0' .. a_name end if a.type == 'group' and a.group_type ~= 'series' then a_name = '\0' .. a_name end if favourites[b.id] then b_name = '\0\0' .. b_name end if b.type == 'group' and b.group_type ~= 'series' then b_name = '\0' .. b_name end return a_name < b_name end) end local function toggle_menu_sort() local menu = menus[depth] if not menu.sorted_options then menu.orig_options = menu.options menu.sorted_options = copy_table(menu.options) sort_options(menu.sorted_options) if menu.search_options then menu.orig_search_options = menu.search_options menu.sorted_search_options = copy_table( menu.search_options) sort_options(menu.sorted_search_options) end end menu.sorted = not menu.sorted menu.options = menu.sorted and menu.sorted_options or menu.orig_options menu.search_options = menu.sorted and menu.sorted_search_options or menu.orig_search_options update_osd() end local function epg_programme(channel, time) local progs = epg[channel] if not progs then return end for _, v in ipairs(progs) do if time >= v.start and time < v.stop then return v end end end local function add_programme(opt, time) if opt.epg_channel_id then local prog = epg_programme(opt.epg_channel_id, time) if prog then opt.info = asscape(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 -- count number of favourited items, not recursive local count = 0 for k in pairs(favourites) do if k ~= 'oi' then count = count + 1 end end return count end end local function favourites_group_menu_options(group) local options = {} local time = os.time() for id in pairs(favourites) do local obj = objects[id] if obj then local path = {} local curr = obj while curr.parent_id and curr.parent_id ~= 'root' and objects[curr.parent_id] do curr = objects[curr.parent_id] path[#path+1] = curr end obj = 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 reverse(path) obj.path = path end options[#options+1] = obj elseif id ~= 'oi' then -- ignore dummy value -- display missing favourites so that they can be -- removed options[#options+1] = { id=id, name='🞷MISSING🞷 ' .. id, } end end return options end local function get_series_info(series_id) local cmd = 'curl -sSfL \'' .. xc_server .. '/player_api.php' .. '?username=' .. xc_user .. '&password=' .. xc_pass .. '&' .. 'action=get_series_info&series_id=' .. series_id .. '\'' print('exec: ' .. cmd) local fd = io.popen(cmd) local json = fd:read('*all') fd:close() return utils.parse_json(json) end local function series_group_menu_options(series) local info = 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=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.section .. 'series:season:' .. season.id, children=episodes, name=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 = 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(objects[menus[1].group_id]) else update_osd() end end local function play_stream(stream) -- add a per-file option containing the stream id, allowing it to be -- retrieved when a start-file event is received local url if stream.stream_type == 'series' then url = xc_server .. '/series/' .. xc_user .. '/' .. xc_pass .. '/' .. stream.stream_id .. '.vod' else url = xc_server .. '/' .. xc_user .. '/' .. xc_pass .. '/' .. stream.stream_id end 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 id = opt.id if favourites[id] then favourites[id] = nil else favourites[id] = true end write_json_file('favourites.json', 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 open_epg_programme(prog) 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(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() local menu = menus[depth] local opt = menu.options[menu.cursor] if not opt then return end if menu.type == 'epg' and opt.programme then return open_epg_programme(opt.programme) elseif not opt.epg_channel_id then return end local ch = opt.epg_channel_id:lower() if not epg[ch] then return end local options = {} local curr = 0 local time = os.time() for i, v in ipairs(epg[ch]) 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 search_menu_options_build(options, t, path) local menu = menus[depth] local path = path or {} for _, v in ipairs(options) do if not t[v.type] then t[v.type] = {} end local v = copy_table(v) v.path = path t[v.type][#t[v.type]+1] = v -- contents of lazy-loaded groups should not be searchable if v.type == 'group' and not v.lazy then local path = 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 = {} search_menu_options_build(options, t) -- display categories before streams local ret = t.group or {} if t.stream then for _, v in ipairs(t.stream) do ret[#ret+1] = v end 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 = copy_table(v) t.matches = matches options[#options+1] = t end end menu.options = options update_osd() 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() end local function search_input_bs() local menu = menus[depth] if menu.search_cursor <= 1 then return end local pos = 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() 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( utf8_seek(menu.search_text, menu.search_cursor, 1)) update_search_matches() 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(utf8_seek(menu.search_text, menu.search_cursor, -1)) end local function search_cursor_right() local menu = menus[depth] set_search_cursor(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] if menu.type == 'search' then -- resume search menu.title = 'Searching: ' .. ' (/)' menu.search_active = true menu.search_cursor = #menu.search_text + 1 menu.cursor = 1 menu.view_top = 1 update_osd() else push_menu({ title='Searching: ' .. ' (/)', type='search', search_active=true, search_options=search_menu_options(menu.options), search_text='', search_cursor=1, }) update_search_matches() end 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() menus[depth].search_active = false depth = depth - 1 update_osd() bind_menu_keys() 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_epg) 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}) end local function toggle_menu() osd.hidden = not osd.hidden osd:update() osd_bg.hidden = osd.hidden osd_bg:update() if osd.hidden then unbind_keys() elseif menus[depth].search_active then bind_search_keys() else bind_menu_keys() end 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) mp.add_forced_key_binding('TAB', 'toggle-menu', toggle_menu) bind_menu_keys() load_data() push_group_menu(objects['root'])