diff --git a/source-record.c b/source-record.c index f1f60ca..6d43c45 100644 --- a/source-record.c +++ b/source-record.c @@ -56,6 +56,11 @@ struct source_record_filter_context { bool remove_after_record; long long record_max_seconds; int last_frontend_event; + /* Last recording path/format/filename applied to the live fileOutput, so a + * change while recording can be detected and the output restarted. */ + struct dstr rec_path; + struct dstr rec_format; + struct dstr rec_filename; }; DARRAY(obs_source_t *) source_record_filters; @@ -517,11 +522,28 @@ static void update_video_encoder(struct source_record_filter_context *filter, ob obs_output_set_video_encoder(filter->replayOutput, filter->encoder); } +/* True if the recording path, format or filename pattern differs from what the + * live fileOutput was started with (the cache is refreshed by start_file_output + * when the output is restarted). */ +static bool record_settings_changed(struct source_record_filter_context *filter, obs_data_t *settings) +{ + const char *path = obs_data_get_string(settings, "path"); + const char *format = obs_data_get_string(settings, "rec_format"); + const char *filename = obs_data_get_string(settings, "filename_formatting"); + return dstr_cmp(&filter->rec_path, path) != 0 || dstr_cmp(&filter->rec_format, format) != 0 || + dstr_cmp(&filter->rec_filename, filename) != 0; +} + static void start_file_output(struct source_record_filter_context *filter, obs_data_t *settings) { obs_data_t *s = obs_data_create(); char path[512]; const char *format = obs_data_get_string(settings, "rec_format"); + /* Remember what this output is being started with, so a later path/format/ + * filename change while recording can be detected (see update). */ + dstr_copy(&filter->rec_path, obs_data_get_string(settings, "path")); + dstr_copy(&filter->rec_format, format); + dstr_copy(&filter->rec_filename, obs_data_get_string(settings, "filename_formatting")); char *filename = os_generate_formatted_filename(GetFormatExt(format), true, obs_data_get_string(settings, "filename_formatting")); snprintf(path, 512, "%s/%s", obs_data_get_string(settings, "path"), filename); @@ -963,6 +985,25 @@ static void source_record_filter_update(void *data, obs_data_t *settings) } } filter->record = record; + } else if (record && filter->fileOutput && obs_source_enabled(filter->source) && !filter->closing && + record_settings_changed(filter, settings)) { + /* Apply a path/format/filename change to an already-running recording. + * A live ffmpeg muxer can't change its file mid-write, so restart splits + * the recording: the current file finalizes and a new one starts with the + * new settings. Only fires while recording; an idle filter picks up new + * settings on its next start. Mirrors the replay-duration restart below. */ + blog(LOG_INFO, + "[Source Record] recording path/format changed while active; restarting output (splits the file)"); + signal_handler_t *sh = obs_output_get_signal_handler(filter->fileOutput); + if (sh) + signal_handler_disconnect(sh, "stop", remove_filter, filter); + struct stop_output *so = bmalloc(sizeof(struct stop_output)); + so->output = filter->fileOutput; + so->context = filter; + run_queued(force_stop_output_task, so); + filter->fileOutput = NULL; + if (filter->video_output) + start_file_output(filter, settings); } if (record && filter->fileOutput && filter->last_frontend_event == OBS_FRONTEND_EVENT_RECORDING_PAUSED && @@ -1271,6 +1312,9 @@ static void source_record_filter_destroy(void *data) } context->source = NULL; + dstr_free(&context->rec_path); + dstr_free(&context->rec_format); + dstr_free(&context->rec_filename); bfree(context); } diff --git a/tests/repro_settings_restart.py b/tests/repro_settings_restart.py new file mode 100644 index 0000000..2c7094e --- /dev/null +++ b/tests/repro_settings_restart.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""Regression test: changing the recording path/format while a Source Record +filter is actively recording must apply immediately by restarting the output +(splitting the file), instead of being silently ignored until the filter is +disabled/re-enabled (forum report: settings "revert unless disabled/re-enabled"). + +Flow: record into dir A, confirm growth, then set the filter's "path" to dir B. +Expect: a new file appears in B and grows, and the file in A finalizes (moov). + +Exit: 0 pass, 1 fail (change not applied), 2 setup abort. Env: OBS_WS_PASSWORD. +""" +import glob +import logging +import os +import struct +import sys +import time + +import obsws_python as obs + +logging.getLogger("obsws_python").setLevel(logging.CRITICAL) +HOST = os.environ.get("OBS_WS_HOST", "localhost") +PORT = int(os.environ.get("OBS_WS_PORT", "4455")) +PW = os.environ.get("OBS_WS_PASSWORD", "") +VENDOR = "source-record" +SUF = str(os.getpid()) +SCENE, SRC, FILT = f"SR_RESTART_SC_{SUF}", f"SR_RESTART_SRC_{SUF}", "SR_RESTART_F" + + +def vendor(cl, rt, data=None): + r = cl.send("CallVendorRequest", {"vendorName": VENDOR, "requestType": rt, "requestData": data or {}}, raw=True) + return r.get("responseData", {}) + + +def newest(d): + fs = glob.glob(os.path.join(d, "*")) + return max(fs, key=os.path.getmtime) if fs else None + + +def grow(d, secs, step=1.5): + out, prev = [], os.path.getsize(newest(d)) if newest(d) else 0 + end = time.time() + secs + while time.time() < end: + time.sleep(step) + cur = os.path.getsize(newest(d)) if newest(d) else 0 + out.append(cur - prev) + prev = cur + return out + + +def has_moov(p): + try: + off, end = 0, os.path.getsize(p) + with open(p, "rb") as f: + while off + 8 <= end: + f.seek(off) + h = f.read(16) + sz = struct.unpack(">I", h[:4])[0] + nm = h[4:8].decode("latin1") + if sz == 1: + sz = struct.unpack(">Q", h[8:16])[0] + elif sz == 0: + sz = end - off + if nm == "moov": + return True + if sz < 8: + break + off += sz + except Exception: + pass + return False + + +def main(): + if not PW: + print("OBS_WS_PASSWORD not set", file=sys.stderr) + return 2 + base = os.environ.get("TMPDIR", "/tmp") + dirA = os.path.join(base, f"sr_restart_A_{SUF}") + dirB = os.path.join(base, f"sr_restart_B_{SUF}") + os.makedirs(dirA, exist_ok=True) + os.makedirs(dirB, exist_ok=True) + cl = obs.ReqClient(host=HOST, port=PORT, password=PW, timeout=5) + print("Connected:", cl.get_version().obs_version, flush=True) + for fn in (lambda: cl.remove_input(SRC), lambda: cl.remove_scene(SCENE)): + try: + fn() + except Exception: + pass + try: + cl.create_scene(SCENE) + kinds = cl.get_input_kind_list(False).input_kinds + ck = next((k for k in kinds if k.startswith("color_source")), "color_source_v3") + cl.create_input(SCENE, SRC, ck, {"width": 640, "height": 360}, True) + cl.create_source_filter(SRC, FILT, "source_record_filter", + {"path": dirA, "record_mode": 0, "stream_mode": 0}) + time.sleep(1.0) + vendor(cl, "record_start", {"source": SRC}) + time.sleep(2.0) + a1 = newest(dirA) + gA = grow(dirA, 6) + print(f"A growth KB={[d // 1024 for d in gA]} file={os.path.basename(a1) if a1 else None}", flush=True) + if not a1 or sum(gA) <= 0: + print("RESULT: not recording into A at baseline — abort", flush=True) + return 2 + + # --- change the path to B while recording --- + print("--- set path -> B while recording ---", flush=True) + cl.set_source_filter_settings(SRC, FILT, {"path": dirB}, overlay=True) + time.sleep(5.0) + + b = newest(dirB) + gB = grow(dirB, 6) + print(f"B growth KB={[d // 1024 for d in gB]} file={os.path.basename(b) if b else None}", flush=True) + a_final = has_moov(a1) if a1 else False + print(f"A finalized (moov)={a_final}", flush=True) + + ok = bool(b) and sum(gB) > 0 + print(f"VERDICT={'APPLIED' if ok else 'IGNORED'} (new file in B growing={ok})", flush=True) + vendor(cl, "record_stop", {"source": SRC}) + time.sleep(2) + return 0 if ok else 1 + finally: + for fn in (lambda: cl.remove_input(SRC), lambda: cl.remove_scene(SCENE)): + try: + fn() + except Exception: + pass + import shutil + shutil.rmtree(dirA, ignore_errors=True) + shutil.rmtree(dirB, ignore_errors=True) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..9f44944 --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1 @@ +obsws-python>=1.8