mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-28 10:56:52 +03:00
Cameras can now add lower quality live streams that go2rtc transcodes to H.264 on demand. `live.transcode` takes a source stream and a list of heights and bitrates, and each quality becomes a `{camera}_transcode_{height}p` stream. The config validator adds them to `live.streams` without moving any the user already placed, and drops them when transcoding is disabled or a height changes. `create_config.py` writes them into go2rtc's generated config at startup, and saving the config or deleting a camera syncs them through go2rtc's API, so no restart is needed. They use `#hardware`, so go2rtc picks a hardware encoder and falls back to the CPU when there isn't one.
The Live playback settings stream list can be reordered by drag, since its order is the auto ladder. Auto order sorts it by bitrate, measuring native streams through a new admin-only `/go2rtc/streams/{name}/bitrate` endpoint and using the configured bitrate for transcoded ones. A pure reorder wasn't saved before because RJSF, the settings form, and `update_yaml` all ignore map key order. Sections can now mark a map with `orderedMaps`, which sends the whole map with `replace_paths` so `config_set` rewrites it in order.
Transcoded streams aren't in `go2rtc.streams`, which only lists yaml streams, so the frontend treated them as not restreamed and fell back to jsmpeg. Every restream check now goes through `isRestreamedStream`.
Auto treated any stall with no bytes in the last 2 seconds as a dead camera and handed it to the error fallback, which went straight to jsmpeg. Heavy congestion can stop delivery completely, so congested viewers skipped every lower stream. Auto now declines only when stats show the camera offline, and a stall on a live camera steps down. The stream picker also has a Try highest quality button that sends auto back to the top stream.
321 lines
10 KiB
TypeScript
321 lines
10 KiB
TypeScript
/**
|
|
* Auto live stream selection in the single-camera view.
|
|
*
|
|
* go2rtc is mocked per stream. A failing stream answers MSE codec
|
|
* negotiation with go2rtc's "codecs not matched" error, the one ladder
|
|
* trigger Chromium reproduces without real media. A healthy stream
|
|
* answers negotiation and then sends nothing, so it waits quietly.
|
|
*/
|
|
import type { Locator, Page } from "@playwright/test";
|
|
import { test, expect } from "../fixtures/frigate-test";
|
|
import { LivePage } from "../pages/live.page";
|
|
|
|
// the mocked profile is admin, so useUserPersistence keys are namespaced
|
|
const STREAM_KEY = "front_door-stream:admin";
|
|
const CODEC_ERROR = "mse: streams: codecs not matched: H265 => H264";
|
|
|
|
const CONFIG = {
|
|
go2rtc: {
|
|
streams: {
|
|
front_door: ["rtsp://127.0.0.1:8554/front_door"],
|
|
front_door_sub: ["rtsp://127.0.0.1:8554/front_door_sub"],
|
|
backyard: ["rtsp://127.0.0.1:8554/backyard"],
|
|
},
|
|
webrtc: { candidates: [], ice_servers: [] },
|
|
},
|
|
cameras: {
|
|
front_door: {
|
|
live: { streams: { Sub: "front_door_sub" } },
|
|
},
|
|
},
|
|
};
|
|
|
|
test.use({ expectedErrors: [/MSE error 'mse-codec'/] });
|
|
|
|
async function writeIdb(page: Page, entries: Record<string, unknown>) {
|
|
await page.evaluate(async (data) => {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const request = indexedDB.open("keyval-store", 1);
|
|
request.onupgradeneeded = () =>
|
|
request.result.createObjectStore("keyval");
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => {
|
|
const tx = request.result.transaction("keyval", "readwrite");
|
|
const store = tx.objectStore("keyval");
|
|
Object.entries(data).forEach(([key, value]) => store.put(value, key));
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
};
|
|
});
|
|
}, entries);
|
|
}
|
|
|
|
async function readIdb(page: Page, key: string) {
|
|
return page.evaluate(async (target) => {
|
|
return new Promise((resolve, reject) => {
|
|
const request = indexedDB.open("keyval-store", 1);
|
|
request.onupgradeneeded = () =>
|
|
request.result.createObjectStore("keyval");
|
|
request.onerror = () => reject(request.error);
|
|
request.onsuccess = () => {
|
|
const tx = request.result.transaction("keyval", "readonly");
|
|
const get = tx.objectStore("keyval").get(target);
|
|
get.onsuccess = () => resolve(get.result ?? null);
|
|
get.onerror = () => reject(get.error);
|
|
};
|
|
});
|
|
}, key);
|
|
}
|
|
|
|
// records the src of every MSE socket in open order
|
|
async function mockGo2rtc(page: Page, failing: string[] = []) {
|
|
const opened: string[] = [];
|
|
|
|
await page.routeWebSocket("**/live/mse/api/ws**", (ws) => {
|
|
const src = new URL(ws.url()).searchParams.get("src") ?? "";
|
|
opened.push(src);
|
|
|
|
ws.onMessage((message) => {
|
|
const msg = JSON.parse(message.toString());
|
|
if (msg.type !== "mse") {
|
|
return;
|
|
}
|
|
|
|
ws.send(
|
|
JSON.stringify(
|
|
failing.includes(src)
|
|
? { type: "error", value: CODEC_ERROR }
|
|
: { type: "mse", value: 'video/mp4; codecs="avc1.640029"' },
|
|
),
|
|
);
|
|
});
|
|
});
|
|
|
|
await page.route("**/api/go2rtc/streams/**", (route) =>
|
|
route.fulfill({
|
|
json: {
|
|
producers: [{ medias: ["video, recvonly, H264"] }],
|
|
consumers: [],
|
|
},
|
|
}),
|
|
);
|
|
|
|
return opened;
|
|
}
|
|
|
|
async function openDesktopSettings(page: Page) {
|
|
const live = new LivePage(page, true);
|
|
await expect(live.backButton).toBeVisible({ timeout: 10_000 });
|
|
await page.locator("button:has(svg)").last().click();
|
|
const menu = page.locator('[role="menu"], [data-radix-menu-content]').first();
|
|
await expect(menu).toBeVisible({ timeout: 3_000 });
|
|
return menu;
|
|
}
|
|
|
|
function streamTrigger(menu: Locator) {
|
|
return menu
|
|
.locator('div:has(> label[for="streaming-method"]) [role="combobox"]')
|
|
.first();
|
|
}
|
|
|
|
test.describe("Auto live stream @mobile", () => {
|
|
test("a codec error on the main stream moves Auto to the sub stream", async ({
|
|
frigateApp,
|
|
}) => {
|
|
await frigateApp.installDefaults({ config: CONFIG });
|
|
const opened = await mockGo2rtc(frigateApp.page, ["front_door"]);
|
|
|
|
await frigateApp.goto("/#front_door");
|
|
|
|
await expect
|
|
.poll(() => opened, { timeout: 10_000 })
|
|
.toContain("front_door_sub");
|
|
expect(opened[0]).toBe("front_door");
|
|
});
|
|
});
|
|
|
|
test.describe("Transcoded live stream @mobile", () => {
|
|
test("a pinned transcoded stream plays over MSE", async ({ frigateApp }) => {
|
|
// go2rtc.streams lists only yaml streams; transcoded ones are generated
|
|
await frigateApp.installDefaults({
|
|
config: {
|
|
...CONFIG,
|
|
cameras: {
|
|
front_door: {
|
|
live: {
|
|
streams: {
|
|
Sub: "front_door_sub",
|
|
"720p": "front_door_transcode_720p",
|
|
},
|
|
transcode: {
|
|
enabled: true,
|
|
source: "front_door",
|
|
qualities: [{ height: 720, bitrate: 1200 }],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
const opened = await mockGo2rtc(frigateApp.page);
|
|
|
|
await frigateApp.goto("/");
|
|
await writeIdb(frigateApp.page, {
|
|
[STREAM_KEY]: "front_door_transcode_720p",
|
|
});
|
|
await frigateApp.goto("/#front_door");
|
|
|
|
await expect
|
|
.poll(() => opened, { timeout: 10_000 })
|
|
.toContain("front_door_transcode_720p");
|
|
});
|
|
});
|
|
|
|
test.describe("Auto live stream selector", () => {
|
|
test.beforeEach(({ frigateApp }) => {
|
|
test.skip(frigateApp.isMobile, "Desktop dropdown only");
|
|
});
|
|
|
|
test("no saved stream opens Auto on the first stream", async ({
|
|
frigateApp,
|
|
}) => {
|
|
await frigateApp.installDefaults({ config: CONFIG });
|
|
const opened = await mockGo2rtc(frigateApp.page);
|
|
|
|
await frigateApp.goto("/#front_door");
|
|
|
|
await expect.poll(() => opened[0], { timeout: 10_000 }).toBe("front_door");
|
|
const menu = await openDesktopSettings(frigateApp.page);
|
|
await expect(streamTrigger(menu)).toHaveText("Auto · front_door");
|
|
});
|
|
|
|
test("a codec error shows the codec reason", async ({ frigateApp }) => {
|
|
await frigateApp.installDefaults({ config: CONFIG });
|
|
const opened = await mockGo2rtc(frigateApp.page, ["front_door"]);
|
|
|
|
await frigateApp.goto("/#front_door");
|
|
|
|
await expect
|
|
.poll(() => opened, { timeout: 10_000 })
|
|
.toContain("front_door_sub");
|
|
const menu = await openDesktopSettings(frigateApp.page);
|
|
await expect(streamTrigger(menu)).toHaveText("Auto · Sub");
|
|
await expect(
|
|
menu.getByText(/because this browser can't play a higher-quality stream/),
|
|
).toBeVisible();
|
|
});
|
|
|
|
test("trying the highest quality retries the top stream", async ({
|
|
frigateApp,
|
|
}) => {
|
|
await frigateApp.installDefaults({ config: CONFIG });
|
|
const opened = await mockGo2rtc(frigateApp.page, ["front_door"]);
|
|
|
|
await frigateApp.goto("/#front_door");
|
|
|
|
await expect
|
|
.poll(() => opened, { timeout: 10_000 })
|
|
.toContain("front_door_sub");
|
|
const menu = await openDesktopSettings(frigateApp.page);
|
|
const beforeRetry = opened.length;
|
|
await menu.getByRole("button", { name: "Try highest quality" }).click();
|
|
|
|
await expect
|
|
.poll(() => opened.slice(beforeRetry), { timeout: 10_000 })
|
|
.toContain("front_door");
|
|
});
|
|
|
|
test("a pinned main takes the legacy fallback instead of stepping down", async ({
|
|
frigateApp,
|
|
}) => {
|
|
await frigateApp.installDefaults({ config: CONFIG });
|
|
const opened = await mockGo2rtc(frigateApp.page, ["front_door"]);
|
|
|
|
await frigateApp.goto("/");
|
|
await writeIdb(frigateApp.page, { [STREAM_KEY]: "front_door" });
|
|
await frigateApp.goto("/#front_door");
|
|
|
|
const menu = await openDesktopSettings(frigateApp.page);
|
|
await expect(
|
|
menu.getByText(/Live view is in low-bandwidth mode/),
|
|
).toBeVisible({ timeout: 10_000 });
|
|
expect(opened).not.toContain("front_door_sub");
|
|
});
|
|
|
|
test("selecting Auto clears the saved stream", async ({ frigateApp }) => {
|
|
await frigateApp.installDefaults({ config: CONFIG });
|
|
await mockGo2rtc(frigateApp.page);
|
|
|
|
await frigateApp.goto("/");
|
|
await writeIdb(frigateApp.page, { [STREAM_KEY]: "front_door_sub" });
|
|
await frigateApp.goto("/#front_door");
|
|
|
|
const menu = await openDesktopSettings(frigateApp.page);
|
|
await expect(streamTrigger(menu)).toHaveText("Sub");
|
|
await streamTrigger(menu).click();
|
|
await frigateApp.page.getByRole("option", { name: /^Auto/ }).click();
|
|
|
|
await expect.poll(() => readIdb(frigateApp.page, STREAM_KEY)).toBeNull();
|
|
});
|
|
|
|
test("a stale pin falls back to Auto", async ({ frigateApp }) => {
|
|
await frigateApp.installDefaults({ config: CONFIG });
|
|
const opened = await mockGo2rtc(frigateApp.page);
|
|
|
|
await frigateApp.goto("/");
|
|
await writeIdb(frigateApp.page, { [STREAM_KEY]: "removed_stream" });
|
|
await frigateApp.goto("/#front_door");
|
|
|
|
await expect.poll(() => readIdb(frigateApp.page, STREAM_KEY)).toBeNull();
|
|
await expect.poll(() => opened[0], { timeout: 10_000 }).toBe("front_door");
|
|
});
|
|
|
|
test("a single-stream camera offers no Auto", async ({ frigateApp }) => {
|
|
await frigateApp.installDefaults({ config: CONFIG });
|
|
await mockGo2rtc(frigateApp.page);
|
|
|
|
await frigateApp.goto("/#backyard");
|
|
|
|
const menu = await openDesktopSettings(frigateApp.page);
|
|
await streamTrigger(menu).click();
|
|
await expect(
|
|
frigateApp.page.getByRole("option", { name: "backyard" }),
|
|
).toBeVisible();
|
|
await expect(
|
|
frigateApp.page.getByRole("option", { name: /^Auto/ }),
|
|
).toHaveCount(0);
|
|
});
|
|
|
|
test("Reset stream at the floor returns to the top stream", async ({
|
|
frigateApp,
|
|
}) => {
|
|
await frigateApp.installDefaults({ config: CONFIG });
|
|
const opened = await mockGo2rtc(frigateApp.page, [
|
|
"front_door",
|
|
"front_door_sub",
|
|
]);
|
|
|
|
await frigateApp.goto("/#front_door");
|
|
|
|
await expect
|
|
.poll(() => opened, { timeout: 10_000 })
|
|
.toContain("front_door_sub");
|
|
expect(opened[0]).toBe("front_door");
|
|
|
|
const menu = await openDesktopSettings(frigateApp.page);
|
|
await expect(streamTrigger(menu)).toHaveText("Auto · Low bandwidth");
|
|
await expect(
|
|
menu.getByText(/Playing the low-bandwidth feed/),
|
|
).toBeVisible();
|
|
|
|
const beforeReset = opened.length;
|
|
await menu.getByRole("button", { name: "Reset stream" }).click();
|
|
|
|
// leaving the floor changes the stream and the mode in one render,
|
|
// which must still open each stream exactly once
|
|
await expect
|
|
.poll(() => opened.slice(beforeReset), { timeout: 10_000 })
|
|
.toEqual(["front_door", "front_door_sub"]);
|
|
});
|
|
});
|