#include "macro-schedule.hpp" #include "log-helper.hpp" #include "macro-export-extensions.hpp" #include "macro-helpers.hpp" #include "obs-module-helper.hpp" #include "plugin-state-helpers.hpp" #include "sync-helpers.hpp" #include #include #include #include #include #include namespace advss { static std::deque scheduleEntries; static std::thread schedulerThread; static std::atomic schedulerRunning{false}; static std::mutex schedulerWaitMutex; static std::condition_variable schedulerWaitCV; static void initScheduler(); static void cleanupScheduler(); static void saveEntries(obs_data_t *obj); static void loadEntries(obs_data_t *obj); static bool setup(); static bool setupDone = setup(); static bool setup() { AddSaveStep(saveEntries); AddLoadStep(loadEntries); AddStartStep(initScheduler); AddStopStep(cleanupScheduler); AddPluginCleanupStep([]() { cleanupScheduler(); scheduleEntries.clear(); }); AddMacroExportExtension( {"AdvSceneSwitcher.macroTab.export.macroScheduleEntries", "macroScheduleEntries", [](obs_data_t *data, const QStringList &selectedIds) { OBSDataArrayAutoRelease array = obs_data_array_create(); auto lock = LockContext(); for (const auto &entry : scheduleEntries) { if (!selectedIds.isEmpty() && !selectedIds.contains( QString::fromStdString(entry.id))) continue; OBSDataAutoRelease item = obs_data_create(); entry.Save(item); obs_data_array_push_back(array, item); } obs_data_set_array(data, "macroScheduleEntries", array); }, [](obs_data_t *data, const QStringList &) { OBSDataArrayAutoRelease array = obs_data_get_array( data, "macroScheduleEntries"); if (!array) { return; } const size_t count = obs_data_array_count(array); auto lock = LockContext(); for (size_t i = 0; i < count; ++i) { OBSDataAutoRelease item = obs_data_array_item(array, i); MacroScheduleEntry entry; entry.Load(item); // Skip if an entry with this id already exists. const auto &id = entry.id; const bool exists = std::any_of( scheduleEntries.begin(), scheduleEntries.end(), [&id](const MacroScheduleEntry &e) { return e.id == id; }); if (exists) { continue; } // Reset runtime state so the entry fires fresh. entry.timesTriggered = 0; entry.lastTriggered = QDateTime(); entry.endDateActionApplied = false; scheduleEntries.emplace_back(std::move(entry)); } }, []() -> QList> { QList> items; auto lock = LockContext(); for (const auto &entry : scheduleEntries) { items.append({QString::fromStdString(entry.id), entry.GetSummary()}); } return items; }}); return true; } // --------------------------------------------------------------------------- // Data model // --------------------------------------------------------------------------- MacroScheduleEntry::MacroScheduleEntry() : id(QUuid::createUuid().toString(QUuid::WithoutBraces).toStdString()), startDateTime(QDateTime::currentDateTime()) { } static void saveDateTime(obs_data_t *obj, const char *key, const QDateTime &dt) { if (dt.isValid()) { obs_data_set_string( obj, key, dt.toString(Qt::ISODate).toUtf8().constData()); } } static QDateTime loadDateTime(obs_data_t *obj, const char *key) { const char *str = obs_data_get_string(obj, key); if (!str || !*str) { return QDateTime(); } return QDateTime::fromString(QString::fromUtf8(str), Qt::ISODate); } void MacroScheduleEntry::Save(obs_data_t *obj) const { obs_data_set_string(obj, "id", id.c_str()); obs_data_set_string(obj, "name", name.c_str()); macro.Save(obj); obs_data_set_string(obj, "color", color.name().toUtf8().constData()); obs_data_set_bool(obj, "checkConditions", checkConditions); obs_data_set_bool(obj, "runElseActionsOnConditionFailure", runElseActionsOnConditionFailure); obs_data_set_bool(obj, "enabled", enabled); saveDateTime(obj, "startDateTime", startDateTime); obs_data_set_bool(obj, "hasEndDate", hasEndDate); if (hasEndDate) { saveDateTime(obj, "endDate", endDate); obs_data_set_int(obj, "endDateAction", static_cast(endDateAction)); } obs_data_set_bool(obj, "doesRepeat", doesRepeat); repeatInterval.Save(obj, "repeatInterval"); obs_data_set_int(obj, "repeatEndType", static_cast(repeatEndType)); obs_data_set_int(obj, "repeatMaxCount", repeatMaxCount); if (repeatEndType == RepeatEndType::UNTIL_DATE && repeatUntilDate.isValid()) { saveDateTime(obj, "repeatUntilDate", repeatUntilDate); } obs_data_set_int(obj, "timesTriggered", timesTriggered); saveDateTime(obj, "lastTriggered", lastTriggered); obs_data_set_bool(obj, "endDateActionApplied", endDateActionApplied); } void MacroScheduleEntry::Load(obs_data_t *obj) { const char *idStr = obs_data_get_string(obj, "id"); if (idStr && *idStr) { id = idStr; } const char *nameStr = obs_data_get_string(obj, "name"); if (nameStr) { name = nameStr; } macro.Load(obj); const char *colorStr = obs_data_get_string(obj, "color"); if (colorStr && *colorStr) { const QColor loaded(QString::fromUtf8(colorStr)); if (loaded.isValid()) { color = loaded; } } checkConditions = obs_data_get_bool(obj, "checkConditions"); runElseActionsOnConditionFailure = obs_data_get_bool(obj, "runElseActionsOnConditionFailure"); obs_data_set_default_bool(obj, "enabled", true); enabled = obs_data_get_bool(obj, "enabled"); startDateTime = loadDateTime(obj, "startDateTime"); hasEndDate = obs_data_get_bool(obj, "hasEndDate"); if (hasEndDate) { endDate = loadDateTime(obj, "endDate"); endDateAction = static_cast( obs_data_get_int(obj, "endDateAction")); } doesRepeat = obs_data_get_bool(obj, "doesRepeat"); repeatInterval.Load(obj, "repeatInterval"); repeatEndType = static_cast( obs_data_get_int(obj, "repeatEndType")); obs_data_set_default_int(obj, "repeatMaxCount", 1); repeatMaxCount = (int)obs_data_get_int(obj, "repeatMaxCount"); repeatUntilDate = loadDateTime(obj, "repeatUntilDate"); timesTriggered = (int)obs_data_get_int(obj, "timesTriggered"); lastTriggered = loadDateTime(obj, "lastTriggered"); endDateActionApplied = obs_data_get_bool(obj, "endDateActionApplied"); } QDateTime MacroScheduleEntry::advanceFrom(const QDateTime &base) const { const qint64 ms = qRound64(repeatInterval.Seconds() * 1000.0); if (ms <= 0) { return QDateTime(); } return base.addMSecs(ms); } QDateTime MacroScheduleEntry::NextTriggerTime() const { if (!lastTriggered.isValid()) { return startDateTime; } if (!doesRepeat) { // If the start was moved beyond the last trigger, treat as not yet fired. if (startDateTime > lastTriggered) { return startDateTime; } return QDateTime(); // one-shot already triggered } QDateTime next = startDateTime; while (next <= lastTriggered) { QDateTime candidate = advanceFrom(next); if (!candidate.isValid()) { return QDateTime(); } next = candidate; } return next; } bool MacroScheduleEntry::IsExpired() const { if (!doesRepeat && lastTriggered.isValid()) { return lastTriggered >= startDateTime; } switch (repeatEndType) { case RepeatEndType::NEVER: return false; case RepeatEndType::AFTER_N_TIMES: return timesTriggered >= repeatMaxCount; case RepeatEndType::UNTIL_DATE: return repeatUntilDate.isValid() && QDateTime::currentDateTime() > repeatUntilDate; } return false; } bool MacroScheduleEntry::ShouldTrigger(const QDateTime &now) const { if (!enabled) { return false; } if (IsExpired()) { return false; } if (!startDateTime.isValid()) { return false; } const QDateTime next = NextTriggerTime(); if (!next.isValid()) { return false; } return next <= now; } void MacroScheduleEntry::MarkTriggered(const QDateTime &now) { ++timesTriggered; lastTriggered = now; } void MacroScheduleEntry::FastForwardTo(const QDateTime &now) { timesTriggered = 0; lastTriggered = QDateTime(); if (!startDateTime.isValid()) { return; } if (!doesRepeat) { // One-shot: count it as triggered if the start lies in the past. if (startDateTime <= now) { timesTriggered = 1; lastTriggered = startDateTime; } return; } // Repeating: walk intervals from startDateTime and count each one // that falls at or before 'now'. QDateTime t = startDateTime; while (t <= now) { ++timesTriggered; lastTriggered = t; const QDateTime next = advanceFrom(t); if (!next.isValid()) { break; } t = next; } } QString MacroScheduleEntry::GetRepeatDescription() const { if (!doesRepeat) { return obs_module_text( "AdvSceneSwitcher.macroScheduleEntry.repeat.once"); } return QString(obs_module_text( "AdvSceneSwitcher.macroScheduleEntry.repeat.every")) .arg(QString::fromStdString(repeatInterval.ToString())); } QString MacroScheduleEntry::GetSummary() const { if (!name.empty()) { return QString::fromStdString(name); } const std::string macroName = macro.Name(); return macroName.empty() ? QString(obs_module_text( "AdvSceneSwitcher.macroScheduleEntry.noMacro")) : QString::fromStdString(macroName); } QString MacroScheduleEntry::GetNextTriggerString() const { if (!enabled) { return obs_module_text( "AdvSceneSwitcher.macroScheduleTab.status.disabled"); } if (IsExpired()) { return obs_module_text( "AdvSceneSwitcher.macroScheduleTab.status.expired"); } const QDateTime next = NextTriggerTime(); if (!next.isValid()) { return QString("-"); } return next.toString("yyyy-MM-dd HH:mm"); } std::deque &GetScheduleEntries() { return scheduleEntries; } // --------------------------------------------------------------------------- // Persistence // --------------------------------------------------------------------------- static void saveEntries(obs_data_t *obj) { OBSDataArrayAutoRelease array = obs_data_array_create(); for (const auto &entry : scheduleEntries) { OBSDataAutoRelease item = obs_data_create(); entry.Save(item); obs_data_array_push_back(array, item); } obs_data_set_array(obj, "macroScheduleEntries", array); } static void loadEntries(obs_data_t *obj) { scheduleEntries.clear(); OBSDataArrayAutoRelease array = obs_data_get_array(obj, "macroScheduleEntries"); const size_t count = obs_data_array_count(array); for (size_t i = 0; i < count; ++i) { OBSDataAutoRelease item = obs_data_array_item(array, i); scheduleEntries.emplace_back(); scheduleEntries.back().Load(item); } } // --------------------------------------------------------------------------- // Scheduler thread // --------------------------------------------------------------------------- static void applyEndDateAction(MacroScheduleEntry &entry) { if (!entry.hasEndDate || !entry.endDate.isValid()) { return; } if (entry.endDateAction == MacroScheduleEntry::EndDateAction::NONE) { return; } switch (entry.endDateAction) { case MacroScheduleEntry::EndDateAction::DISABLE_ENTRY: entry.enabled = false; break; case MacroScheduleEntry::EndDateAction::PAUSE_MACRO: StopMacro(entry.macro.GetMacro().get()); SetMacroPaused(entry.macro.GetMacro().get(), true); break; case MacroScheduleEntry::EndDateAction::STOP_MACRO: StopMacro(entry.macro.GetMacro().get()); break; default: break; } } static void checkAndFireEntries() { const QDateTime now = QDateTime::currentDateTime(); auto lock = LockContext(); for (auto &entry : scheduleEntries) { // Apply end-date action once when the entry transitions to expired. // We detect the transition by checking whether the end date has // just passed while the entry is still nominally enabled. if (entry.hasEndDate && entry.endDate.isValid() && entry.enabled && now >= entry.endDate && entry.endDateAction != MacroScheduleEntry::EndDateAction::NONE && !entry.endDateActionApplied) { applyEndDateAction(entry); entry.endDateActionApplied = true; } if (!entry.ShouldTrigger(now)) { continue; } auto macro = entry.macro.GetMacro(); if (!macro) { // Advance state so we don't spam-check a missing macro entry.MarkTriggered(now); blog(LOG_WARNING, "[macro-schedule] Scheduled macro '%s' not found, skipping.", entry.macro.Name().c_str()); continue; } if (entry.checkConditions) { if (CheckMacroConditions(macro.get(), true)) { RunMacroActions(macro.get(), true, true); } else if (entry.runElseActionsOnConditionFailure) { RunMacroElseActions(macro.get(), true, true); } } else { RunMacroActions(macro.get(), true, true); } entry.MarkTriggered(now); } } static void initScheduler() { if (schedulerRunning.exchange(true)) { return; // already running } schedulerThread = std::thread([]() { while (schedulerRunning) { checkAndFireEntries(); std::unique_lock lock(schedulerWaitMutex); schedulerWaitCV.wait_for( lock, std::chrono::seconds(1), []() { return !schedulerRunning.load(); }); } }); } static void cleanupScheduler() { schedulerRunning = false; schedulerWaitCV.notify_all(); if (schedulerThread.joinable()) { schedulerThread.join(); } } } // namespace advss