Repository navigation
Add obs-preflight recording checks #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -384,6 +384,7 @@ export function initialize(server: McpServer, client: OBSWebSocketClient): void | |
| async ({ parameterCategory, parameterName, parameterValue }) => { | ||
| try { | ||
| await client.sendRequest("SetProfileParameter", { parameterCategory, parameterName, parameterValue }); | ||
| if (OUTPUT_PARAMETER_CATEGORIES.has(parameterCategory)) client.markOutputSettingsPending(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a caller invokes Useful? React with 馃憤聽/ 馃憥. |
||
| const warning = OUTPUT_PARAMETER_CATEGORIES.has(parameterCategory) | ||
| ? ". Warning: OBS applies output settings only when it rebuilds its outputs; restart OBS before starting an output" | ||
| : ""; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,237 @@ | ||
| /* | ||
| * Modified for the independent OBS MCP project in August 2026. | ||
| * See NOTICE.md and Git history for authorship and change dates. | ||
| * SPDX-License-Identifier: GPL-2.0-only | ||
| */ | ||
| import { mkdtempSync, rmSync } from "node:fs"; | ||
| import { tmpdir } from "node:os"; | ||
| import { join } from "node:path"; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | ||
| import { FakeOBSRequestError } from "../../test/support/fake-obs-server.js"; | ||
| import { resultText, startMcpHarness, type McpHarness } from "../../test/support/mcp-harness.js"; | ||
| import type { PreflightCheck } from "./preflight.js"; | ||
|
|
||
| type FakeInput = { | ||
| inputName: string; | ||
| inputKind: string; | ||
| muted?: boolean; // undefined means the input has no audio | ||
| tracks?: Record<string, boolean>; | ||
| }; | ||
|
|
||
| type FakeSceneItem = { | ||
| sceneItemId: number; | ||
| sourceName: string; | ||
| sceneItemEnabled: boolean; | ||
| sourceWidth: number; | ||
| sourceHeight: number; | ||
| }; | ||
|
|
||
| /** A healthy OBS that preflight should pass; each test breaks one thing. */ | ||
| type FakeState = { | ||
| recording: boolean; | ||
| recordDirectory: string; | ||
| availableDiskSpace: number; | ||
| profile: Record<string, string>; | ||
| inputs: FakeInput[]; | ||
| sceneItems: FakeSceneItem[]; | ||
| }; | ||
|
|
||
| let harness: McpHarness; | ||
| let recordDirectory: string; | ||
| let state: FakeState; | ||
|
|
||
| function healthyState(): FakeState { | ||
| return { | ||
| recording: false, | ||
| recordDirectory, | ||
| availableDiskSpace: 50_000, | ||
| profile: { | ||
| "Output/Mode": "Simple", | ||
| "SimpleOutput/RecQuality": "Small", | ||
| "SimpleOutput/RecEncoder": "apple_h264", | ||
| }, | ||
| inputs: [ | ||
| { inputName: "Chrome", inputKind: "screen_capture", muted: true, tracks: { 1: false } }, | ||
| { inputName: "Background", inputKind: "color_source_v3" }, | ||
| ], | ||
| sceneItems: [ | ||
| { sceneItemId: 1, sourceName: "Background", sceneItemEnabled: true, sourceWidth: 1920, sourceHeight: 1080 }, | ||
| { sceneItemId: 2, sourceName: "Chrome", sceneItemEnabled: true, sourceWidth: 1512, sourceHeight: 949 }, | ||
| ], | ||
| }; | ||
| } | ||
|
|
||
| function findInput(requestData: Record<string, unknown>): FakeInput { | ||
| const input = state.inputs.find(({ inputName }) => inputName === requestData.inputName); | ||
| if (!input) throw new FakeOBSRequestError(600, "No source was found"); | ||
| return input; | ||
| } | ||
|
|
||
| function wireFakeObs(): void { | ||
| const { fakeObs } = harness; | ||
| fakeObs.respondWith("GetRecordStatus", () => ({ outputActive: state.recording })); | ||
| fakeObs.respondWith("GetRecordDirectory", () => ({ recordDirectory: state.recordDirectory })); | ||
| fakeObs.respondWith("GetStats", () => ({ availableDiskSpace: state.availableDiskSpace })); | ||
| fakeObs.respondWith("GetProfileParameter", ({ parameterCategory, parameterName }) => ({ | ||
| parameterValue: state.profile[`${String(parameterCategory)}/${String(parameterName)}`] ?? null, | ||
| defaultParameterValue: null, | ||
| })); | ||
| fakeObs.respondWith("GetInputList", () => ({ | ||
| inputs: state.inputs.map(({ inputName, inputKind }) => ({ inputName, inputKind })), | ||
| })); | ||
| fakeObs.respondWith("GetInputMute", (data) => { | ||
| const input = findInput(data); | ||
| if (input.muted === undefined) throw new FakeOBSRequestError(604, "The specified input does not support audio"); | ||
| return { inputMuted: input.muted }; | ||
| }); | ||
| fakeObs.respondWith("GetInputAudioTracks", (data) => ({ inputAudioTracks: findInput(data).tracks ?? {} })); | ||
| fakeObs.respondWith("GetCurrentProgramScene", () => ({ currentProgramSceneName: "Demo" })); | ||
| fakeObs.respondWith("GetSceneItemList", () => ({ | ||
| sceneItems: state.sceneItems.map(({ sourceWidth, sourceHeight, ...item }) => ({ | ||
| ...item, | ||
| sceneItemTransform: { sourceWidth, sourceHeight }, | ||
| })), | ||
| })); | ||
| fakeObs.respondWith("SetProfileParameter", () => ({})); | ||
| } | ||
|
|
||
| async function preflight(args: Record<string, unknown> = {}) { | ||
| const result = await harness.call("obs-preflight", args); | ||
| expect(result.isError).toBeFalsy(); | ||
| const { ready, checks } = result.structuredContent as { ready: boolean; checks: PreflightCheck[] }; | ||
| const byId = (id: string) => checks.find((check) => check.id === id); | ||
| return { result, ready, checks, byId }; | ||
| } | ||
|
|
||
| beforeEach(async () => { | ||
| vi.spyOn(console, "error").mockImplementation(() => undefined); | ||
| recordDirectory = mkdtempSync(join(tmpdir(), "obs-mcp-preflight-")); | ||
| state = healthyState(); | ||
| harness = await startMcpHarness({ obsStudioVersion: "32.2.2" }); | ||
| wireFakeObs(); | ||
| }); | ||
|
|
||
| afterEach(async () => { | ||
| await harness.close(); | ||
| rmSync(recordDirectory, { recursive: true, force: true }); | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| describe("obs-preflight", () => { | ||
| it("reports ready with no failures or warnings for a healthy setup", async () => { | ||
| const { ready, checks, result } = await preflight(); | ||
|
|
||
| expect(ready).toBe(true); | ||
| expect(checks.filter(({ status }) => status === "fail" || status === "warn")).toEqual([]); | ||
| expect(resultText(result)).toMatch(/^Ready to record/); | ||
| }); | ||
|
|
||
| it("fails when recording is already active", async () => { | ||
| state.recording = true; | ||
| const { ready, byId } = await preflight(); | ||
|
|
||
| expect(ready).toBe(false); | ||
| expect(byId("record-idle")?.status).toBe("fail"); | ||
| }); | ||
|
|
||
| it("fails when the recording directory does not exist", async () => { | ||
| state.recordDirectory = join(recordDirectory, "missing"); | ||
| const { ready, byId } = await preflight(); | ||
|
|
||
| expect(ready).toBe(false); | ||
| expect(byId("record-directory")).toMatchObject({ status: "fail" }); | ||
| }); | ||
|
|
||
| it("fails below the free disk space minimum", async () => { | ||
| state.availableDiskSpace = 500; | ||
| const { ready, byId } = await preflight({ minFreeDiskMb: 1024 }); | ||
|
|
||
| expect(ready).toBe(false); | ||
| expect(byId("disk-space")?.message).toContain("500 MB free"); | ||
| }); | ||
|
|
||
| it("fails for an encoder that does not exist on the OBS platform", async () => { | ||
| state.profile["SimpleOutput/RecEncoder"] = "nvenc"; | ||
| const { ready, byId } = await preflight(); | ||
|
|
||
| expect(ready).toBe(false); | ||
| expect(byId("encoder")?.message).toContain("not available on macOS"); | ||
| }); | ||
|
|
||
| it("follows the streaming encoder when simple mode records at stream quality", async () => { | ||
| state.profile["SimpleOutput/RecQuality"] = "Stream"; | ||
| state.profile["SimpleOutput/StreamEncoder"] = "apple_h264"; | ||
| state.profile["SimpleOutput/RecEncoder"] = "nvenc"; | ||
| const { byId } = await preflight(); | ||
|
|
||
| expect(byId("encoder")).toMatchObject({ status: "pass", message: expect.stringContaining("apple_h264") }); | ||
| }); | ||
|
|
||
| it("fails after output settings were changed until OBS reconnects", async () => { | ||
| await harness.call("obs-set-profile-parameter", { | ||
| parameterCategory: "SimpleOutput", | ||
| parameterName: "RecEncoder", | ||
| parameterValue: "apple_h264", | ||
| }); | ||
| const { ready, byId } = await preflight(); | ||
|
|
||
| expect(ready).toBe(false); | ||
| expect(byId("output-settings")?.message).toContain("Restart OBS"); | ||
| }); | ||
|
|
||
| it("warns about an unmuted input on a recorded track, and fails when silence is expected", async () => { | ||
| state.inputs[0] = { inputName: "Chrome", inputKind: "screen_capture", muted: false, tracks: { 1: true } }; | ||
|
|
||
| const warned = await preflight(); | ||
| const failed = await preflight({ expectSilent: true }); | ||
|
|
||
| expect(warned.ready).toBe(true); | ||
| expect(warned.byId("audio")).toMatchObject({ status: "warn", message: expect.stringContaining("Chrome (track 1)") }); | ||
| expect(failed.ready).toBe(false); | ||
| expect(failed.byId("audio")?.status).toBe("fail"); | ||
| }); | ||
|
|
||
| it("ignores unmuted inputs whose recorded tracks are disabled", async () => { | ||
| state.profile["Output/Mode"] = "Advanced"; | ||
| state.profile["AdvOut/RecEncoder"] = "com.apple.videotoolbox.videoencoder.ave.avc"; | ||
| state.profile["AdvOut/RecTracks"] = "2"; | ||
| state.inputs[0] = { inputName: "Chrome", inputKind: "screen_capture", muted: false, tracks: { 1: true, 2: false } }; | ||
|
|
||
| const { byId } = await preflight({ expectSilent: true }); | ||
|
|
||
| expect(byId("audio")?.status).toBe("pass"); | ||
| }); | ||
|
|
||
| it("warns about visible sources that render at 0脳0", async () => { | ||
| state.sceneItems[1] = { sceneItemId: 2, sourceName: "Chrome", sceneItemEnabled: true, sourceWidth: 0, sourceHeight: 0 }; | ||
| const { ready, byId } = await preflight(); | ||
|
|
||
| expect(ready).toBe(true); | ||
| expect(byId("scene-items")).toMatchObject({ status: "warn", message: expect.stringContaining("Chrome") }); | ||
| }); | ||
|
|
||
| it("ignores hidden 0脳0 sources", async () => { | ||
| state.sceneItems[1] = { sceneItemId: 2, sourceName: "Chrome", sceneItemEnabled: false, sourceWidth: 0, sourceHeight: 0 }; | ||
| const { byId } = await preflight(); | ||
|
|
||
| expect(byId("scene-items")?.status).toBe("pass"); | ||
| }); | ||
|
|
||
| it("reports a check as unknown instead of failing when OBS rejects its request", async () => { | ||
| harness.fakeObs.respondWith("GetStats", () => { | ||
| throw new FakeOBSRequestError(500, "boom"); | ||
| }); | ||
| const { ready, byId } = await preflight(); | ||
|
|
||
| expect(ready).toBe(true); | ||
| expect(byId("disk-space")).toMatchObject({ status: "unknown", message: expect.stringContaining("boom") }); | ||
| }); | ||
|
|
||
| it("fails with a single connection check when OBS is unreachable", async () => { | ||
| await harness.fakeObs.close(); | ||
| const { ready, checks } = await preflight(); | ||
|
|
||
| expect(ready).toBe(false); | ||
| expect(checks).toEqual([expect.objectContaining({ id: "connection", status: "fail" })]); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the WebSocket reconnects without OBS restarting鈥攆or example after a network interruption or toggling the WebSocket server鈥攖his clears
outputSettingsPendingeven though OBS has not rebuilt its outputs. A subsequentobs-preflighttherefore omits theoutput-settingsfailure and can report ready while the profile change is still unapplied; the flag should only be cleared in response to a confirmed output rebuild or an explicit acknowledgement.Useful? React with 馃憤聽/ 馃憥.