diff --git a/apps/web/composables/usePrinterStream.ts b/apps/web/composables/usePrinterStream.ts new file mode 100644 index 0000000..50f64ad --- /dev/null +++ b/apps/web/composables/usePrinterStream.ts @@ -0,0 +1,56 @@ +/** + * Where a printer's camera can be watched, from `GET /printers/:id/stream`. + */ +export interface PrinterStream { + streamUrl: string; + cameraId: string; +} + +/** + * Asks the backend where a printer's camera can be watched, or `null` if it + * cannot. + * + * This replaces the build-time `go2rtcStreamBase` that used to live in + * `nuxt.config.ts`. A single compiled-in URL can only ever be correct for a + * one-farm deployment: the stream lives on a gateway box inside a farm's own + * network, each farm has its own, and the gateway reports its address to the + * backend when it connects. So the address has to be asked for at runtime, + * per printer, rather than baked in. + * + * A plain function and not a `useAsyncData` wrapper despite the file it lives + * in: the caller needs one of these per printer, discovered from a list that + * itself arrives asynchronously, and Nuxt's data composables may only be + * called while a component is setting up. A loop over a resolved list is not + * that. + * + * **Absence is the normal case, not an error.** Only Bambu's X1/H2 class + * publishes a camera URL a gateway can restream; a P1 or A1 reports that it + * has a camera and gives nothing to connect to. The backend answers 404 for + * those, so this answers `null` and the caller shows no player at all - + * rendering one that can never load would be worse than rendering nothing. + * + * It is also a LAN address with no relay in front of it, so the stream plays + * only for someone on the same network as the printers. That is the right + * trade-off for an operator standing in the workshop, and a real limitation + * for anyone else. + */ +export async function fetchPrinterStream( + apiBase: string, + printerId: string, + headers: Record, +): Promise { + try { + return await $fetch(`${apiBase}/printers/${printerId}/stream`, { headers }); + } catch (err: unknown) { + // A 404 means "this printer has no watchable camera", which is expected + // often enough that treating it as a failure would put an error state on + // most cards in a mixed fleet. Anything else is a real problem: logged + // and then treated as "no stream", because a broken camera lookup should + // not take the fleet overview down with it. + const status = (err as { statusCode?: number }).statusCode; + if (status !== 404) { + console.warn(`could not look up the camera stream for printer ${printerId}`, err); + } + return null; + } +} diff --git a/apps/web/nuxt.config.ts b/apps/web/nuxt.config.ts index 51dbf33..b30622b 100644 --- a/apps/web/nuxt.config.ts +++ b/apps/web/nuxt.config.ts @@ -23,7 +23,13 @@ export default defineNuxtConfig({ public: { apiBase: process.env.NUXT_PUBLIC_API_BASE || "http://localhost:3001", wsBase: process.env.NUXT_PUBLIC_WS_BASE || "ws://localhost:3001", - go2rtcStreamBase: process.env.NUXT_PUBLIC_GO2RTC_STREAM_BASE || "http://localhost:1984", + // There is deliberately no go2rtcStreamBase here any more. A camera + // stream lives on a gateway inside a particular farm's network, each + // farm has its own, and the gateway tells the backend its address when + // it connects - so a build-time constant could only ever be right for a + // single-farm install, and its localhost:1984 default meant the UI + // quietly pointed every player at the operator's own machine. Asked for + // per printer instead: see `usePrinterStream`. }, }, diff --git a/apps/web/pages/index.vue b/apps/web/pages/index.vue index 88e8abb..3367ccc 100644 --- a/apps/web/pages/index.vue +++ b/apps/web/pages/index.vue @@ -7,6 +7,12 @@ interface Printer { name: string; model: string; farmId: string; + /** + * Set only for a printer whose camera the gateway can actually restream, + * which in a mixed fleet is the minority. Its presence is what decides + * whether to ask for a stream URL at all - see the lookup below. + */ + cameraStream?: string | null; } const telemetry = useTelemetryStore(); @@ -15,6 +21,33 @@ const config = useRuntimeConfig(); const { data: printers } = await useFetch(`${config.public.apiBase}/printers`, { headers: useRequestHeaders(["cookie"]), }); + +// Looked up per printer, because the answer is per printer, but only for the +// printers the list already says have a camera. Asking for all of them would +// mean one request per machine in the fleet, most of them 404 by design. +const streams = ref>({}); + +// Read here, in setup, rather than inside the loop below: Nuxt's request +// helpers are only valid while a component is setting up. +const streamHeaders = useRequestHeaders(["cookie"]) as Record; + +watch( + printers, + (list) => { + for (const printer of list ?? []) { + if (!printer.cameraStream || printer.id in streams.value) continue; + // Marked before the request resolves, so a re-render in the meantime + // does not fire a second lookup for the same printer. `null` renders + // no player, which is also the right thing to show while this is in + // flight. + streams.value[printer.id] = null; + void fetchPrinterStream(config.public.apiBase, printer.id, streamHeaders).then((stream) => { + streams.value[printer.id] = stream; + }); + } + }, + { immediate: true }, +);