obs = obslua ---------------------------------------------------------------- -- GLOBAL SETTINGS (DEFAULTS) ---------------------------------------------------------------- local settings = { -- OBS text source settings text_source_name = "", prefix = "Next Stream", separator = " | ", suffix = "", time_format = "24h", -- "24h" or "12h" day_format = "full", -- "full"=Monday, "short"=Mon max_streams_displayed = 1, even_week_time = "20:00", -- For even ISO weeks odd_week_time = "21:00", -- For odd ISO weeks category_display_mode = "show", -- "show", "show_round", "show_square", "hide" -- File output settings file_path = "", file_use_emojis = true, file_show_category = true, file_max_streams_displayed = 1, file_separator = " | ", file_multiline = false, -- Each stream on a new line if true -- Day-specific settings days = { {enabled = true, auto_time = true, custom_time = "", category = ""}, -- Monday {enabled = false, auto_time = true, custom_time = "", category = ""}, {enabled = false, auto_time = true, custom_time = "", category = ""}, {enabled = false, auto_time = true, custom_time = "", category = ""}, {enabled = false, auto_time = true, custom_time = "", category = ""}, {enabled = false, auto_time = true, custom_time = "", category = ""}, {enabled = false, auto_time = true, custom_time = "", category = ""} } } ---------------------------------------------------------------- -- 1) ISO-8601 WEEK CALCULATION (THURSDAY RULE) ---------------------------------------------------------------- local function get_iso_week_number(day_offset) local offset_secs = (day_offset or 0) * 24 * 60 * 60 local offset_time = os.time() + offset_secs local wday = tonumber(os.date("%u", offset_time)) -- Monday=1, Sunday=7 local thursday = offset_time + (4 - wday) * 24 * 60 * 60 local year_of_thursday = os.date("%Y", thursday) local jan1 = os.time{year = year_of_thursday, month = 1, day = 1} local diff_in_days = math.floor((thursday - jan1) / (24 * 60 * 60)) return math.floor(diff_in_days / 7) + 1 end local function is_even_week(week_num) return (week_num % 2 == 0) end ---------------------------------------------------------------- -- 2) TIME PARSING & FORMATTING -- Detects AM/PM (e.g. "8 AM", "8 PM") and converts to 24h for internal use. -- Then formats output as 24h or 12h, depending on settings.time_format. ---------------------------------------------------------------- local function parse_time(chosen_time) if not chosen_time then return 0, 0 end local s = chosen_time:lower() -- Detect AM/PM local is_am = s:find("am") local is_pm = s:find("pm") -- Strip non-digit characters except ":" to isolate hours/minutes local stripped = s:gsub("[^%d:]", "") local h, m = stripped:match("^(%d+):?(%d*)$") local hours = tonumber(h) or 0 local minutes = tonumber(m) or 0 -- Convert to 24-hour internally if is_am and hours == 12 then hours = 0 elseif is_pm and hours < 12 then hours = hours + 12 end return hours, minutes end local function format_time_24h(hours, minutes) return string.format("%02d:%02d", hours, minutes) end local function format_time_12h(hours, minutes) local suffix = "AM" local h = hours if h == 0 then h = 12 suffix = "AM" elseif h == 12 then suffix = "PM" elseif h > 12 then h = h - 12 suffix = "PM" end return string.format("%d:%02d %s", h, minutes, suffix) end local function convert_time_for_display(hours, minutes) if settings.time_format == "12h" then return format_time_12h(hours, minutes) else return format_time_24h(hours, minutes) end end ---------------------------------------------------------------- -- 3) WRAPPING CATEGORY -- The txt file follows exactly the same brackets as OBS, -- except if category_display_mode is "show" or "hide". ---------------------------------------------------------------- local function wrap_category(cat, mode) if cat == nil or cat == "" then return "" end if mode == "hide" then return "" elseif mode == "show" then return cat elseif mode == "show_round" then return "(" .. cat .. ")" elseif mode == "show_square" then return "[" .. cat .. "]" end return cat end ---------------------------------------------------------------- -- 4) COLLECT UPCOMING STREAMS FOR OBS ---------------------------------------------------------------- local function get_upcoming_streams_obs() local results = {} local days_full = {"Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"} local days_short = {"Mon","Tue","Wed","Thu","Fri","Sat","Sun"} local day_labels = (settings.day_format == "full") and days_full or days_short local now = os.time() local today_idx = (os.date("*t").wday + 5) % 7 + 1 local found_count = 0 local max_count = settings.max_streams_displayed for offset = 0, 13 do local idx = (today_idx + offset - 1) % 7 + 1 local day_conf = settings.days[idx] if day_conf.enabled then local w_num = get_iso_week_number(offset) local is_e = is_even_week(w_num) local chosen_time = day_conf.auto_time and (is_e and settings.even_week_time or settings.odd_week_time) or day_conf.custom_time -- Convert & format local hours, minutes = parse_time(chosen_time) local display_time = convert_time_for_display(hours, minutes) local stream_time = os.time{ year = os.date("%Y", now), month = os.date("%m", now), day = os.date("%d", now) + offset, hour = hours, min = minutes } if stream_time > now then local day_name if offset == 0 then day_name = "Today" elseif offset == 1 then day_name = "Tomorrow" else day_name = day_labels[idx] end local cat_wrapped = wrap_category(day_conf.category, settings.category_display_mode) local line if cat_wrapped == "" then line = day_name .. settings.separator .. display_time .. " " .. settings.suffix else line = day_name .. settings.separator .. cat_wrapped .. settings.separator .. display_time .. " " .. settings.suffix end table.insert(results, line) found_count = found_count + 1 if found_count >= max_count then break end end end end return results end ---------------------------------------------------------------- -- 5) COLLECT UPCOMING STREAMS FOR FILE -- The bracket style also follows OBS mode: -- - "show_round" => (cat) -- - "show_square" => [cat] -- - "show" => cat with no brackets -- - "hide" => skip category ---------------------------------------------------------------- local function get_upcoming_streams_file() local results = {} local now = os.time() local today_idx = (os.date("*t").wday + 5) % 7 + 1 local found_count = 0 local max_count = settings.file_max_streams_displayed for offset = 0, 13 do local idx = (today_idx + offset - 1) % 7 + 1 local day_conf = settings.days[idx] if day_conf.enabled then local w_num = get_iso_week_number(offset) local is_e = is_even_week(w_num) local chosen_time = day_conf.auto_time and (is_e and settings.even_week_time or settings.odd_week_time) or day_conf.custom_time local hours, minutes = parse_time(chosen_time) local display_time = convert_time_for_display(hours, minutes) local stream_time = os.time{ year = os.date("%Y", now), month = os.date("%m", now), day = os.date("%d", now) + offset, hour = hours, min = minutes } if stream_time > now then local day_name = os.date("%A", stream_time) if offset == 0 then day_name = "Today" elseif offset == 1 then day_name = "Tomorrow" end local cat_wrapped = "" if settings.file_show_category then cat_wrapped = wrap_category(day_conf.category, settings.category_display_mode) end local line if cat_wrapped == "" then line = day_name .. " " .. display_time else line = day_name .. " " .. cat_wrapped .. " " .. display_time end if settings.file_use_emojis then -- Replace first space with " ⏰ " to mimic the sample line = "📅 " .. line:gsub(" ", " ⏰ ", 1) end table.insert(results, line) found_count = found_count + 1 if found_count >= max_count then break end end end end return results end ---------------------------------------------------------------- -- 6) UPDATE OBS TEXT SOURCE & OVERWRITE .TXT ---------------------------------------------------------------- local function update_text_source() -- OBS text local obs_streams = get_upcoming_streams_obs() local text_output if #obs_streams == 0 then text_output = "No stream scheduled" else -- German script style: prefix + "\n" + lines text_output = settings.prefix .. "\n" .. table.concat(obs_streams, "\n") end local source = obs.obs_get_source_by_name(settings.text_source_name) if source then local src_settings = obs.obs_source_get_settings(source) obs.obs_data_set_string(src_settings, "text", text_output) obs.obs_source_update(source, src_settings) obs.obs_data_release(src_settings) obs.obs_source_release(source) end -- File output if settings.file_path == "" then return end local file_streams = get_upcoming_streams_file() local file_content if #file_streams == 0 then file_content = "No stream scheduled" else if settings.file_multiline then file_content = table.concat(file_streams, "\n") else file_content = table.concat(file_streams, settings.file_separator) end end local f = io.open(settings.file_path, "w") if f then f:write(file_content) f:close() else obs.script_log(obs.LOG_WARNING, "Failed to open file: " .. settings.file_path) end end ---------------------------------------------------------------- -- 7) OBS SCRIPT PROPERTIES -- We remove the user-settable update interval to enforce 60s. ---------------------------------------------------------------- local function add_text_sources(property) obs.obs_property_list_add_string(property, "No text source selected", "") local all_sources = obs.obs_enum_sources() if all_sources then for _, s in ipairs(all_sources) do local source_id = obs.obs_source_get_unversioned_id(s) if source_id == "text_gdiplus" or source_id == "text_ft2_source" then local name = obs.obs_source_get_name(s) obs.obs_property_list_add_string(property, name, name) end end obs.source_list_release(all_sources) end end function script_properties() local props = obs.obs_properties_create() ---------------------------------------------------------------- -- (1) 📝 SELECT TEXT SOURCE ---------------------------------------------------------------- local group_source = obs.obs_properties_create() local src_prop = obs.obs_properties_add_list( group_source, "text_source_name", "Select Text Source", obs.OBS_COMBO_TYPE_LIST, obs.OBS_COMBO_FORMAT_STRING ) add_text_sources(src_prop) obs.obs_properties_add_group(props, "text_source_group", "📝 Select Text Source", obs.OBS_GROUP_NORMAL, group_source) ---------------------------------------------------------------- -- (2) 🎨 FORMAT SETTINGS (FOR OBS) ---------------------------------------------------------------- local format_group = obs.obs_properties_create() obs.obs_properties_add_text(format_group, "prefix", "Prefix", obs.OBS_TEXT_DEFAULT) obs.obs_properties_add_text(format_group, "separator", "Separator", obs.OBS_TEXT_DEFAULT) obs.obs_properties_add_text(format_group, "suffix", "Suffix", obs.OBS_TEXT_DEFAULT) local df = obs.obs_properties_add_list( format_group, "day_format", "Day Format", obs.OBS_COMBO_TYPE_LIST, obs.OBS_COMBO_FORMAT_STRING ) obs.obs_property_list_add_string(df, "Full (Monday)", "full") obs.obs_property_list_add_string(df, "Short (Mon)", "short") local tf = obs.obs_properties_add_list( format_group, "time_format", "Time Format", obs.OBS_COMBO_TYPE_LIST, obs.OBS_COMBO_FORMAT_STRING ) obs.obs_property_list_add_string(tf, "24-hour", "24h") obs.obs_property_list_add_string(tf, "12-hour (AM/PM)", "12h") obs.obs_properties_add_int(format_group, "max_streams_displayed", "How many streams to display (OBS)", 1, 14, 1) local cat_mode = obs.obs_properties_add_list( format_group, "category_display_mode", "Category Display (OBS)", obs.OBS_COMBO_TYPE_LIST, obs.OBS_COMBO_FORMAT_STRING ) obs.obs_property_list_add_string(cat_mode, "Show", "show") obs.obs_property_list_add_string(cat_mode, "Show in ( )", "show_round") obs.obs_property_list_add_string(cat_mode, "Show in [ ]", "show_square") obs.obs_property_list_add_string(cat_mode, "Hide", "hide") obs.obs_properties_add_group(props, "format_settings_group", "🎨 Format Settings (OBS)", obs.OBS_GROUP_NORMAL, format_group) ---------------------------------------------------------------- -- (3) 🕒 STREAM START SETTINGS -- (Automatic detection of "AM"/"PM" is handled in parse_time) ---------------------------------------------------------------- local sgroup = obs.obs_properties_create() obs.obs_properties_add_text(sgroup, "even_week_time", "Even Week Start", obs.OBS_TEXT_DEFAULT) obs.obs_properties_add_text(sgroup, "odd_week_time", "Odd Week Start", obs.OBS_TEXT_DEFAULT) obs.obs_properties_add_group(props, "stream_times", "🕒 Stream Start Settings", obs.OBS_GROUP_NORMAL, sgroup) ---------------------------------------------------------------- -- (4) 📂 FILE OUTPUT SETTINGS ---------------------------------------------------------------- local file_group = obs.obs_properties_create() local fp = obs.obs_properties_add_path( file_group, "file_path", "Overwrite .txt File", obs.OBS_PATH_FILE_SAVE, "*.txt", nil ) obs.obs_property_set_long_description(fp, "Export upcoming streams to a .txt file.") obs.obs_properties_add_bool(file_group, "file_use_emojis", "Use Emojis in File?") obs.obs_properties_add_bool(file_group, "file_show_category", "Show Category in File?") obs.obs_properties_add_int(file_group, "file_max_streams_displayed", "How many streams to display (File)", 1, 14, 1) obs.obs_properties_add_text(file_group, "file_separator", "File Separator", obs.OBS_TEXT_DEFAULT) obs.obs_properties_add_bool(file_group, "file_multiline", "Write each stream on a new line?") obs.obs_properties_add_group(props, "file_settings_group", "📂 File Output Settings", obs.OBS_GROUP_NORMAL, file_group) ---------------------------------------------------------------- -- (5) 🗓️ WEEKDAY SETTINGS ---------------------------------------------------------------- local day_names = {"Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"} for i, day in ipairs(day_names) do local day_group = obs.obs_properties_create() obs.obs_properties_add_bool(day_group, day:lower() .. "_enabled", "Enable " .. day) local time_choice = obs.obs_properties_add_list( day_group, day:lower() .. "_time_choice", "Stream Time Choice", obs.OBS_COMBO_TYPE_LIST, obs.OBS_COMBO_FORMAT_STRING ) obs.obs_property_list_add_string(time_choice, "Automatic (Even/Odd Week)", "auto") obs.obs_property_list_add_string(time_choice, "Manual", "custom") obs.obs_properties_add_text(day_group, day:lower() .. "_custom_time", "Manual Time (HH:MM / 8 AM / 8 PM)", obs.OBS_TEXT_DEFAULT) obs.obs_properties_add_text(day_group, day:lower() .. "_category", "Category", obs.OBS_TEXT_DEFAULT) obs.obs_properties_add_group(props, day:lower() .. "_group", "🗓️ " .. day, obs.OBS_GROUP_NORMAL, day_group) end return props end ---------------------------------------------------------------- -- 8) SCRIPT UPDATE -- We no longer allow UI to change update interval (fixed at 60s). ---------------------------------------------------------------- function script_update(new_settings) -- OBS text source settings settings.text_source_name = obs.obs_data_get_string(new_settings, "text_source_name") or "" settings.prefix = obs.obs_data_get_string(new_settings, "prefix") or "Next Stream" settings.separator = obs.obs_data_get_string(new_settings, "separator") or " | " settings.suffix = obs.obs_data_get_string(new_settings, "suffix") or "" settings.day_format = obs.obs_data_get_string(new_settings, "day_format") or "full" settings.time_format = obs.obs_data_get_string(new_settings, "time_format") or "24h" settings.max_streams_displayed = obs.obs_data_get_int(new_settings, "max_streams_displayed") settings.category_display_mode = obs.obs_data_get_string(new_settings, "category_display_mode") or "show" settings.even_week_time = obs.obs_data_get_string(new_settings, "even_week_time") or "20:00" settings.odd_week_time = obs.obs_data_get_string(new_settings, "odd_week_time") or "21:00" -- File output settings settings.file_path = obs.obs_data_get_string(new_settings, "file_path") or "" settings.file_use_emojis = obs.obs_data_get_bool(new_settings, "file_use_emojis") settings.file_show_category = obs.obs_data_get_bool(new_settings, "file_show_category") settings.file_max_streams_displayed = obs.obs_data_get_int(new_settings, "file_max_streams_displayed") settings.file_separator = obs.obs_data_get_string(new_settings, "file_separator") or " | " settings.file_multiline = obs.obs_data_get_bool(new_settings, "file_multiline") -- Weekday-specific local day_keys = {"monday","tuesday","wednesday","thursday","friday","saturday","sunday"} for i, d in ipairs(day_keys) do settings.days[i].enabled = obs.obs_data_get_bool(new_settings, d .. "_enabled") settings.days[i].auto_time = (obs.obs_data_get_string(new_settings, d .. "_time_choice") == "auto") settings.days[i].custom_time = obs.obs_data_get_string(new_settings, d .. "_custom_time") or "" settings.days[i].category = obs.obs_data_get_string(new_settings, d .. "_category") or "" end update_text_source() end ---------------------------------------------------------------- -- 9) SCRIPT DESCRIPTION & LIFECYCLE ---------------------------------------------------------------- function script_description() return "K_STYER’s Dynamic Next Stream [2.0] is your ultimate scheduling solution for OBS. It automatically displays upcoming streams with a 60-second refresh, interprets AM/PM inputs, and supports both 24h and 12h formats. Customize day and category settings per even/odd week. Additionally, generate a .txt record—single- or multiline—complete with optional emojis. Configuration is entirely menu-driven for maximum convenience." end function script_load(new_settings) script_update(new_settings) -- Fixed 60s interval obs.timer_add(update_text_source, 60000) end function script_unload() obs.timer_remove(update_text_source) end