diff --git a/source-record.c b/source-record.c index f1f60ca..06af2c6 100644 --- a/source-record.c +++ b/source-record.c @@ -2019,7 +2019,11 @@ obs_source_t *get_source_record_filter(obs_source_t *source, obs_data_t *request obs_source_filter_add(source, filter); } } - if (!obs_source_enabled(filter)) + /* Only (re-)enable the filter for requests that actually start an + * output. Stop/pause/split/chapter requests pass create=false and must + * not silently re-enable a filter the user disabled -> a latched + * stream/record would resume on a "stop" call. */ + if (create && !obs_source_enabled(filter)) obs_source_set_enabled(filter, true); return filter; } @@ -2063,8 +2067,11 @@ static bool pause_record_source(obs_source_t *source, obs_data_t *request_data, struct source_record_filter_context *context = obs_obj_get_data(filter); obs_source_release(filter); - if (!context->fileOutput) + if (!context->fileOutput) { + if (response_data) + obs_data_set_string(response_data, "error", "record output is not active"); return false; + } obs_output_pause(context->fileOutput, true); return true; } @@ -2077,8 +2084,11 @@ static bool unpause_record_source(obs_source_t *source, obs_data_t *request_data struct source_record_filter_context *context = obs_obj_get_data(filter); obs_source_release(filter); - if (!context->fileOutput) + if (!context->fileOutput) { + if (response_data) + obs_data_set_string(response_data, "error", "record output is not active"); return false; + } obs_output_pause(context->fileOutput, false); return true; } @@ -2091,13 +2101,18 @@ static bool split_record_source(obs_source_t *source, obs_data_t *request_data, struct source_record_filter_context *context = obs_obj_get_data(filter); obs_source_release(filter); - if (!context->fileOutput) + if (!context->fileOutput) { + if (response_data) + obs_data_set_string(response_data, "error", "record output is not active"); return false; + } proc_handler_t *ph = obs_output_get_proc_handler(context->fileOutput); struct calldata cd; calldata_init(&cd); if (!proc_handler_call(ph, "split_file", &cd)) { calldata_free(&cd); + if (response_data) + obs_data_set_string(response_data, "error", "split_file call failed"); return false; } calldata_free(&cd); @@ -2112,14 +2127,19 @@ static bool add_chapter_record_source(obs_source_t *source, obs_data_t *request_ struct source_record_filter_context *context = obs_obj_get_data(filter); obs_source_release(filter); - if (!context->fileOutput) + if (!context->fileOutput) { + if (response_data) + obs_data_set_string(response_data, "error", "record output is not active"); return false; + } proc_handler_t *ph = obs_output_get_proc_handler(context->fileOutput); struct calldata cd; calldata_init(&cd); calldata_set_string(&cd, "chapter_name", obs_data_get_string(request_data, "chapter_name")); if (!proc_handler_call(ph, "add_chapter", &cd)) { calldata_free(&cd); + if (response_data) + obs_data_set_string(response_data, "error", "add_chapter call failed"); return false; } calldata_free(&cd); @@ -2335,19 +2355,15 @@ static bool start_replay_buffer_source(obs_source_t *source, obs_data_t *request return false; obs_data_t *settings = obs_source_get_settings(filter); const char *filename = obs_data_get_string(request_data, "filename"); - struct source_record_filter_context *context = obs_obj_get_data(filter); - if (context && context->output_active) { - if (strlen(filename)) { - if (strstr(filename, "%") || strcmp(filename, obs_data_get_string(settings, "filename_formatting")) != 0) { - context->restart = true; - } - } else if (strstr(obs_data_get_string(settings, "filename_formatting"), "%")) { - context->restart = true; - } - } + /* The replay buffer uses replay_filename_formatting, not + * filename_formatting. Writing the record key here did nothing for + * replays and clobbered the record path; and the old restart-detection + * needlessly force-stopped all outputs (flushing the buffer). The + * replay format is applied hot (source_record_filter_update pushes it to + * the live output and the muxer reads it at save time), so no restart. */ if (strlen(filename)) - obs_data_set_string(settings, "filename_formatting", filename); + obs_data_set_string(settings, "replay_filename_formatting", filename); obs_data_set_bool(settings, "replay_buffer", true); 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 diff --git a/tests/smoke_websocket.py b/tests/smoke_websocket.py new file mode 100644 index 0000000..e3ee164 --- /dev/null +++ b/tests/smoke_websocket.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Smoke tests for the Source Record obs-websocket vendor API. + +Validates the Phase 2/3 fixes that touch websocket paths, by driving the +`source-record` vendor requests and inspecting filter state — no manual clicks. + +Connection via env: OBS_WS_HOST (default localhost), OBS_WS_PORT (4455), +OBS_WS_PASSWORD. Creates a throwaway scene + color source and removes it at the +end, so it does not touch your real scene collection. + +Run: OBS_WS_PASSWORD=... python tests/smoke_websocket.py +""" +import logging +import os +import sys +import time + +import obsws_python as obs + +logging.getLogger("obsws_python").setLevel(logging.CRITICAL) # quiet caught request errors + +HOST = os.environ.get("OBS_WS_HOST", "localhost") +PORT = int(os.environ.get("OBS_WS_PORT", "4455")) +PASSWORD = os.environ.get("OBS_WS_PASSWORD", "") + +# Unique per run: the plugin (B14, not yet fixed) retains the parent source via +# its private view after recording, so RemoveInput leaves a ghost. Unique names +# avoid the collision until B14 lands. +_SUFFIX = str(os.getpid()) +SCENE = "SR_SMOKE_SCENE_" + _SUFFIX +SRC = "SR_SMOKE_SRC_" + _SUFFIX +VENDOR = "source-record" + +results = [] + + +def check(name, ok, detail=""): + results.append((name, ok, detail)) + print(f" [{'PASS' if ok else 'FAIL'}] {name}" + (f" — {detail}" if detail else "")) + + +def vendor(cl, request_type, data=None): + """Call a source-record vendor request, return its nested responseData dict.""" + resp = cl.send( + "CallVendorRequest", + {"vendorName": VENDOR, "requestType": request_type, "requestData": data or {}}, + raw=True, + ) + # send(raw=True) already returns the CallVendorRequest responseData + # ({vendorName, requestType, responseData}); the vendor payload is one level in. + return resp.get("responseData", {}) + + +def sr_filter(cl, source): + """Return (name, settings, enabled) of the source_record filter on `source`, or (None,{},None).""" + lst = cl.get_source_filter_list(source).filters + for f in lst: + if f.get("filterKind") == "source_record_filter": + name = f["filterName"] + gf = cl.get_source_filter(source, name) + return name, gf.filter_settings, gf.filter_enabled + return None, {}, None + + +def _cleanup(cl): + for fn in (lambda: cl.remove_input(SRC), lambda: cl.remove_scene(SCENE)): + try: + fn() + except Exception: + pass + + +def setup(cl): + # fresh scene + color source (idempotent: removing a scene doesn't remove the + # orphan input, so clear the input explicitly) + _cleanup(cl) + cl.create_scene(SCENE) + kinds = cl.get_input_kind_list(False).input_kinds + color_kind = next((k for k in kinds if k.startswith("color_source")), "color_source_v3") + cl.create_input(SCENE, SRC, color_kind, {"width": 320, "height": 240}, True) + # record into a temp dir so we never clutter the real recordings folder + tmp = os.path.join(os.environ.get("TMPDIR", "/tmp"), "sr_smoke_rec") + os.makedirs(tmp, exist_ok=True) + # pre-create the SR filter with a temp path so any test recording lands there + cl.create_source_filter(SRC, "SR_SMOKE_FILTER", "source_record_filter", + {"path": tmp, "record_mode": 0, "stream_mode": 0}) + return tmp + + +def teardown(cl): + _cleanup(cl) + + +def main(): + if not PASSWORD: + print("OBS_WS_PASSWORD not set", file=sys.stderr) + return 2 + cl = obs.ReqClient(host=HOST, port=PORT, password=PASSWORD, timeout=5) + v = cl.get_version() + print(f"Connected: OBS {v.obs_version}, ws {v.obs_web_socket_version}\n") + + tmp = setup(cl) + try: + # --- N7: replay_buffer_start writes replay_filename_formatting, not filename_formatting + vendor(cl, "replay_buffer_start", {"source": SRC, "filename": "replay_%CCYY-%MM-%DD"}) + time.sleep(0.3) + name, st, en = sr_filter(cl, SRC) + check("N7 replay filename -> replay_filename_formatting", + st.get("replay_filename_formatting") == "replay_%CCYY-%MM-%DD", + f"replay_filename_formatting={st.get('replay_filename_formatting')!r}") + check("N7 record filename NOT clobbered", + st.get("filename_formatting") != "replay_%CCYY-%MM-%DD", + f"filename_formatting={st.get('filename_formatting')!r}") + vendor(cl, "replay_buffer_stop", {"source": SRC}) + time.sleep(0.2) + + # --- B18e: record_pause with no active recording -> success:false + error string + vd = vendor(cl, "record_pause", {"source": SRC}) + check("B18e record_pause returns success:false", vd.get("success") is False, + f"success={vd.get('success')!r}") + check("B18e record_pause sets an error message", bool(vd.get("error")), + f"error={vd.get('error')!r}") + + # --- N2: record_stop must not re-enable a disabled filter + vendor(cl, "record_start", {"source": SRC}) + time.sleep(0.3) + name, st, en = sr_filter(cl, SRC) + if name: + cl.set_source_filter_enabled(SRC, name, False) + time.sleep(0.2) + vendor(cl, "record_stop", {"source": SRC}) + time.sleep(0.3) + _, _, en2 = sr_filter(cl, SRC) + check("N2 disabled filter stays disabled after record_stop", en2 is False, + f"enabled_after_stop={en2!r}") + else: + check("N2 disabled filter stays disabled after record_stop", False, "no filter created") + + # --- N3/B4: fan-out with no 'source' exercises find_source_by_filter refs. + # WARNING: this stops EVERY active Source Record recording, so it is + # opt-in (SR_SMOKE_FANOUT=1) to avoid disrupting a real recording. + if os.environ.get("SR_SMOKE_FANOUT") == "1": + vendor(cl, "record_stop", {}) + alive = cl.get_version().obs_version + check("N3/B4 fan-out record_stop (no source) keeps OBS alive", bool(alive), + f"obs still responding: {alive}") + else: + print(" [SKIP] N3/B4 fan-out (set SR_SMOKE_FANOUT=1; would stop all SR recordings)") + # named-source path still exercised find_filter's in-callback ref above. + + finally: + teardown(cl) + + print() + passed = sum(1 for _, ok, _ in results if ok) + print(f"== {passed}/{len(results)} checks passed ==") + return 0 if passed == len(results) else 1 + + +if __name__ == "__main__": + sys.exit(main())