From de707294ec79eaa4e1292d1dec9c38709e3c6e46 Mon Sep 17 00:00:00 2001 From: Tobias Klemp Date: Wed, 11 Mar 2026 22:36:03 +0100 Subject: [PATCH] fix: capture when no stream is running --- docker-compose.yml | 22 +++ package.json | 7 +- src/hooks.server.ts | 194 +++++++++++++++++++------ src/lib/server/streaming.ts | 2 +- src/routes/api/stream/mjpeg/+server.ts | 44 +++++- 5 files changed, 218 insertions(+), 51 deletions(-) create mode 100644 docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e86220e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +services: + grown: + image: git.klemp.dev/tobi/grown:latest + container_name: grown + restart: unless-stopped + environment: + - NODE_ENV=production + volumes: + - /volume1/docker/grown/keimli2/:/app/timelapse/ + networks: + - traefik-proxy + labels: + - "traefik.enable=true" + - traefik.docker.network=traefik-proxy + - "traefik.http.routers.grown.rule=Host(`keimli.klemp.local`)" + - "traefik.http.routers.grown.entrypoints=http" + - "traefik.http.services.grown.loadbalancer.server.port=3000" + - traefik.http.routers.grown.service=grown + +networks: + traefik-proxy: + external: true diff --git a/package.json b/package.json index 0784b34..e011960 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,12 @@ "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "docker:build": "docker build -t grown:latest .", + "docker:login": "echo $REFURBILY_REGISTRY_ACCESS_TOKEN | docker login ${GITEA_REGISTRY_URL#https://} -u refurbily --password-stdin", + "docker:push": "docker tag grown:latest ${GITEA_REGISTRY_URL#https://}/tobi/grown:latest && docker push ${GITEA_REGISTRY_URL#https://}/tobi/grown:latest", + "docker:build-push": "npm run docker:build && npm run docker:login && npm run docker:push", + "deploy": "docker context create nas --docker \"host=ssh://nas\" 2>/dev/null || true && docker --context nas compose -f docker-compose.yml --env-file .env pull && docker --context nas compose -f docker-compose.yml --env-file .env up -d && docker context use default" }, "devDependencies": { "@lucide/svelte": "^0.577.0", diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 5b25142..4300b16 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -2,42 +2,46 @@ import schedule from "node-schedule"; import { spawn } from "child_process"; import { existsSync, mkdirSync } from "fs"; import { join } from "path"; -import { getAllStreams, getStreamProcess } from "$lib/server/streaming.js"; +import { + getAllStreams, + getStreamProcess, + startStream, + stopStream, +} from "$lib/server/streaming.js"; const TIMELAPSE_OUTPUT_PATH = process.env.TIMELAPSE_OUTPUT_PATH || "./timelapse"; -const CAPTURE_INTERVAL = "0 * * * *"; // Every hour at minute 0 +const CAPTURE_INTERVAL = "*/10 * * * * *"; // Every 10 seconds + +// Single camera configuration +const CAMERA_STREAM_ID = process.env.STREAM_ID || "camera-1"; +const CAMERA_RTSP_URL = + process.env.RTSP_URL || "rtsp://Kamera_1:kamera_1@192.168.175.49/stream1"; let captureJob: any = null; let initialized = false; +function getTimestamp() { + return new Date().toISOString(); +} + function ensureOutputDirectory() { if (!existsSync(TIMELAPSE_OUTPUT_PATH)) { mkdirSync(TIMELAPSE_OUTPUT_PATH, { recursive: true }); } } -function captureTimelapseFrame() { - const streams = getAllStreams(); - - streams.forEach((streamInfo) => { - const stream = getStreamProcess(streamInfo.streamId); - if (!stream || !stream.process || !stream.process.stdout) { - console.error( - `[Timelapse] Stream ${streamInfo.streamId} not available for capture`, - ); - return; - } - +function captureFrameForStream(streamId: string, rtspUrl: string) { + try { const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const filename = `${streamInfo.streamId}_${timestamp}.jpg`; + const filename = `${streamId}_${timestamp}.jpg`; const outputPath = join(TIMELAPSE_OUTPUT_PATH, filename); const ffmpegArgs = [ "-rtsp_transport", "tcp", "-i", - streamInfo.rtspUrl, + rtspUrl, "-vframes", "1", "-q:v", @@ -45,27 +49,106 @@ function captureTimelapseFrame() { outputPath, ]; - const ffmpegProcess = spawn("ffmpeg", ffmpegArgs); + try { + const ffmpegProcess = spawn("ffmpeg", ffmpegArgs); - ffmpegProcess.on("error", (err: Error) => { - console.error( - `[Timelapse] FFmpeg error for ${streamInfo.streamId}:`, - err, - ); - }); - - ffmpegProcess.on("close", (code: number | null) => { - if (code === 0) { - console.log( - `[Timelapse] Captured frame for ${streamInfo.streamId}: ${filename}`, - ); - } else { + ffmpegProcess.on("error", (err: Error) => { console.error( - `[Timelapse] FFmpeg exited with code ${code} for ${streamInfo.streamId}`, + `[${getTimestamp()}] [Timelapse] FFmpeg spawn error: ${err.message}`, ); + }); + + ffmpegProcess.on("close", (code: number | null) => { + try { + if (code === 0) { + console.log( + `[${getTimestamp()}] [Timelapse] Captured frame: ${filename}`, + ); + } else { + console.error( + `[${getTimestamp()}] [Timelapse] FFmpeg exited with code ${code}`, + ); + } + } catch (err) { + console.error( + `[${getTimestamp()}] [Timelapse] Error handling ffmpeg close event: ${err}`, + ); + } + }); + } catch (spawnErr) { + console.error( + `[${getTimestamp()}] [Timelapse] Error spawning ffmpeg: ${spawnErr}`, + ); + } + } catch (err) { + console.error( + `[${getTimestamp()}] [Timelapse] Error in captureFrameForStream: ${err}`, + ); + } +} + +function captureTimelapseFrame() { + const startTime = getTimestamp(); + console.log(`[${startTime}] [Timelapse] Starting capture frame...`); + + try { + let stream = getStreamProcess(CAMERA_STREAM_ID); + + // If no active stream, start one temporarily + if (!stream || !stream.process || !stream.process.stdout) { + console.log( + `[${getTimestamp()}] [Timelapse] No active stream, starting temporary stream`, + ); + const result = startStream(CAMERA_STREAM_ID, CAMERA_RTSP_URL); + if (!result.success) { + console.error( + `[${getTimestamp()}] [Timelapse] Failed to start stream: ${result.error}`, + ); + return; } - }); - }); + + // Give the stream a moment to start + setTimeout(() => { + const startedStream = getStreamProcess(CAMERA_STREAM_ID); + if ( + startedStream && + startedStream.process && + startedStream.process.stdout + ) { + captureFrameForStream(CAMERA_STREAM_ID, CAMERA_RTSP_URL); + + // Stop stream after capture if no clients are connected + setTimeout(() => { + const streamStatus = getStreamProcess(CAMERA_STREAM_ID); + if ( + streamStatus && + streamStatus.clients && + streamStatus.clients.size === 0 + ) { + console.log( + `[${getTimestamp()}] [Timelapse] Stopping temporary stream after capture`, + ); + stopStream(CAMERA_STREAM_ID); + } + }, 1000); + } else { + console.error( + `[${getTimestamp()}] [Timelapse] Failed to start stream process`, + ); + } + }, 500); + } else { + // Stream already active, just capture + captureFrameForStream(CAMERA_STREAM_ID, CAMERA_RTSP_URL); + } + } catch (error) { + console.error( + `[${getTimestamp()}] [Timelapse] Fatal error in captureTimelapseFrame: ${error}`, + ); + } + + const endTime = getTimestamp(); + console.log(`[${endTime}] [Timelapse] Capture frame completed`); } function initializeTimelapse() { @@ -73,17 +156,34 @@ function initializeTimelapse() { return; } - ensureOutputDirectory(); - console.log(`[Timelapse] Output directory: ${TIMELAPSE_OUTPUT_PATH}`); - console.log("[Timelapse] Starting hourly capture job..."); + try { + ensureOutputDirectory(); + console.log( + `[${getTimestamp()}] [Timelapse] Output directory: ${TIMELAPSE_OUTPUT_PATH}`, + ); + console.log( + `[${getTimestamp()}] [Timelapse] Camera: ${CAMERA_STREAM_ID} (${CAMERA_RTSP_URL})`, + ); + console.log(`[${getTimestamp()}] [Timelapse] Starting capture job...`); - captureJob = schedule.scheduleJob(CAPTURE_INTERVAL, () => { - console.log("[Timelapse] Running hourly capture job..."); - captureTimelapseFrame(); - }); + captureJob = schedule.scheduleJob(CAPTURE_INTERVAL, () => { + try { + console.log(`[${getTimestamp()}] [Timelapse] Running capture job...`); + captureTimelapseFrame(); + } catch (err) { + console.error( + `[${getTimestamp()}] [Timelapse] Error in scheduled capture job: ${err}`, + ); + } + }); - initialized = true; - console.log("[Timelapse] Hourly capture job initialized"); + initialized = true; + console.log(`[${getTimestamp()}] [Timelapse] Capture job initialized`); + } catch (error) { + console.error( + `[${getTimestamp()}] [Timelapse] Error initializing timelapse: ${error}`, + ); + } } export async function handle({ event, resolve }) { @@ -98,7 +198,13 @@ export async function handle({ event, resolve }) { // Cleanup on shutdown process.on("exit", () => { if (captureJob) { - captureJob.cancel(); - console.log("[Timelapse] Capture job cancelled"); + try { + captureJob.cancel(); + console.log(`[${getTimestamp()}] [Timelapse] Capture job cancelled`); + } catch (error) { + console.error( + `[${getTimestamp()}] [Timelapse] Error cancelling capture job: ${error}`, + ); + } } }); diff --git a/src/lib/server/streaming.ts b/src/lib/server/streaming.ts index 4b659f6..eb7b9ce 100644 --- a/src/lib/server/streaming.ts +++ b/src/lib/server/streaming.ts @@ -26,7 +26,7 @@ export function startStream( rtspUrl, // Scale video to 1920x1080 (Full HD) "-vf", - "scale=1920:1080", + "scale=2560:1440", // Video output - MJPEG format "-c:v", "mjpeg", diff --git a/src/routes/api/stream/mjpeg/+server.ts b/src/routes/api/stream/mjpeg/+server.ts index b7a0d8d..8c6803d 100644 --- a/src/routes/api/stream/mjpeg/+server.ts +++ b/src/routes/api/stream/mjpeg/+server.ts @@ -21,23 +21,57 @@ export async function GET({ url }) { // Create a web ReadableStream from the Node.js stream const nodeStream = stream.process.stdout; + let controllerClosed = false; const webReadableStream = new ReadableStream({ start(controller) { nodeStream.on("data", (chunk) => { - controller.enqueue(chunk); + try { + if (!controllerClosed) { + controller.enqueue(chunk); + } + } catch (error) { + console.error(`[Stream ${streamId}] Error enqueueing chunk:`, error); + controllerClosed = true; + } }); nodeStream.on("end", () => { - controller.close(); + try { + if (!controllerClosed) { + controller.close(); + controllerClosed = true; + } + } catch (error) { + console.error( + `[Stream ${streamId}] Error closing controller:`, + error, + ); + } }); - nodeStream.on("error", () => { - controller.close(); + nodeStream.on("error", (error) => { + console.error(`[Stream ${streamId}] Node stream error:`, error); + try { + if (!controllerClosed) { + controller.close(); + controllerClosed = true; + } + } catch (closeError) { + console.error( + `[Stream ${streamId}] Error closing controller on stream error:`, + closeError, + ); + } }); }, cancel() { - removeStreamClient(streamId, clientId); + try { + controllerClosed = true; + removeStreamClient(streamId, clientId); + } catch (error) { + console.error(`[Stream ${streamId}] Error during cancel:`, error); + } }, });