// ============================================================
// Blink Multi-Camera Server + Widget
// http://<host>:8085/ → Dropdown alle Kameras
// http://<host>:8085/?camera=548730 → nur diese Kamera
// http://<host>:8085/grid → alle Kameras im Grid (Standbild)
// http://<host>:8085/blink/<file> → Video-Datei
// http://<host>:8085/cameras → JSON mit allen Kameras
// ============================================================
const http = require('http');
const fs = require('fs');
const path = require('path');
// ============= KONFIGURATION =============
const PORT = 8085;
const ROOT_DIR = '/opt/iobroker/iobroker-data/blink';
const VIDEO_BASE = '/blink/';
const CAMERA_PREFIX = 'blink.0.cameras.';
const VIDEO_STATE = '.video.file';
const NAME_STATE = '.info.name';
const TS_STATE = '.video.timestamp';
const READY_STATE = '.video.ready';
const ERROR_STATE = '.video.lastError';
const IOBROKER_PORT = 8082;
// =========================================
if (typeof globalThis.__blinkServer !== 'undefined') {
try { globalThis.__blinkServer.close(); log('Vorherigen Blink-Server gestoppt'); }
catch (e) { /* ignore */ }
}
// ---------- Kameras automatisch entdecken ----------
function discoverCameras() {
return new Promise((resolve) => {
const cams = [];
const seen = new Set();
$(`state[id=${CAMERA_PREFIX}*${NAME_STATE}]`).each((id) => {
const rest = id.slice(CAMERA_PREFIX.length);
const camId = rest.split('.')[0];
if (!seen.has(camId)) {
seen.add(camId);
cams.push({
id: camId,
datapoint: CAMERA_PREFIX + camId + VIDEO_STATE,
ts_datapoint: CAMERA_PREFIX + camId + TS_STATE,
ready_datapoint: CAMERA_PREFIX + camId + READY_STATE,
error_datapoint: CAMERA_PREFIX + camId + ERROR_STATE,
name: null
});
}
});
const promises = cams.map(c => new Promise((res) => {
const nameDp = CAMERA_PREFIX + c.id + NAME_STATE;
getState(nameDp, (err, st) => {
if (!err && st && st.val) c.name = String(st.val).trim();
res();
});
}));
Promise.all(promises).then(() => resolve(cams.sort((a, b) =>
(a.name || a.id).localeCompare(b.name || b.id)
)));
});
}
// ============================================================
// Gemeinsamer JS-Helper-Code
// ============================================================
const COMMON_JS = `
const VIDEO_PREFIX = location.protocol + '//' + location.hostname + ':__VIDEO_PORT__' + '__VIDEO_BASE__';
const IOBROKER_URL = location.protocol + '//' + location.hostname + ':__IOBROKER_PORT__';
function buildUrl(v, ts) {
if (!v) return null;
if (/^https?:\\/\\//.test(v)) return v;
const fn = encodeURIComponent(String(v).split('/').pop());
return VIDEO_PREFIX + fn + '?t=' + (ts || Date.now());
}
function tsFromName(n) {
const m = n && n.match(/(\\d{4}-\\d{2}-\\d{2})T(\\d{2})-(\\d{2})-(\\d{2})/);
return m ? \`\${m[1]} \${m[2]}:\${m[3]}:\${m[4]}\` : null;
}
function dateFromName(n) {
const m = n && n.match(/(\\d{4})-(\\d{2})-(\\d{2})T(\\d{2})-(\\d{2})-(\\d{2})-(\\d{3})Z/);
if (!m) return null;
return new Date(Date.UTC(+m[1], +m[2]-1, +m[3], +m[4], +m[5], +m[6], +m[7]));
}
function relativeTime(ms) {
if (!ms) return '';
const diff = Math.max(0, Date.now() - ms);
const sec = Math.floor(diff / 1000);
if (sec < 60) return 'gerade eben';
const min = Math.floor(sec / 60);
if (min < 60) return 'vor ' + min + ' Min';
const h = Math.floor(min / 60);
if (h < 24) return 'vor ' + h + ' Std';
const d = Math.floor(h / 24);
if (d < 30) return 'vor ' + d + ' Tag' + (d===1?'':'en');
return new Date(ms).toLocaleDateString('de-DE');
}
// Entscheidet, ob die Kamera ein gültiges Video hat.
// Der Adapter setzt video.lastError = "kein video vorhanden" und/oder
// video.ready = false, wenn die Datei nicht (mehr) gültig ist.
function isVideoValid(value, ready, lastError) {
if (!value) return false;
if (lastError && String(lastError).trim() !== '' && String(lastError).toLowerCase() !== 'null') return false;
if (ready === false) return false; // explizit false → ungültig; null/undefined → tolerieren
return true;
}
`;
// ============================================================
// Widget 1: Single/Dropdown
// ============================================================
const WIDGET_HTML = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blink Video Player</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.3.0/socket.io.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background:#1a1a1a; color:#eee; font-family:-apple-system,system-ui,sans-serif;
min-height:100vh; display:flex; flex-direction:column; align-items:center; padding:16px; }
.container { width:100%; max-width:800px; background:#2a2a2a; border-radius:12px;
overflow:hidden; box-shadow:0 4px 12px rgba(0,0,0,0.4); }
.header { padding:12px 16px; background:#333; display:flex; justify-content:space-between;
align-items:center; gap:12px; border-bottom:1px solid #444; flex-wrap:wrap; }
.title { font-size:14px; font-weight:600; flex:1; min-width:0;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.status { font-size:11px; padding:3px 8px; border-radius:10px; background:#555; flex-shrink:0; }
.status.ok { background:#2d6a3e; }
.status.err { background:#8b2d2d; }
select { background:#444; color:#eee; border:1px solid #555; padding:5px 8px;
border-radius:6px; font-size:12px; max-width:200px; }
video { width:100%; display:block; background:#000; max-height:600px; }
.info { padding:12px 16px; font-size:12px; color:#999; word-break:break-all;
border-top:1px solid #444; }
.info .ts { color:#ccc; font-weight:500; margin-bottom:4px; }
.info .err { color:#e88; font-weight:500; }
.empty { padding:60px 20px; text-align:center; color:#777; }
.empty .err-msg { color:#e88; font-size:13px; margin-top:8px; }
.controls { padding:8px 16px; border-top:1px solid #444; display:flex; gap:8px;
justify-content:space-between; align-items:center; }
.relative-ts { font-size:12px; color:#aaa; }
button { background:#444; color:#eee; border:none; padding:6px 12px; border-radius:6px;
font-size:12px; cursor:pointer; }
button:hover { background:#555; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<span class="title" id="title">📹 Blink</span>
<select id="picker" style="display:none"></select>
<span class="status" id="status">Verbinde…</span>
</div>
<div id="player"><div class="empty">Lade Kameras…</div></div>
<div class="info" id="info"></div>
<div class="controls">
<span class="relative-ts" id="reltime"></span>
<button id="reload">🔄 Neu laden</button>
</div>
</div>
<script>
__COMMON_JS__
const params = new URLSearchParams(location.search);
const fixedCamera = params.get('camera');
const $title = document.getElementById('title');
const $status = document.getElementById('status');
const $player = document.getElementById('player');
const $info = document.getElementById('info');
const $reload = document.getElementById('reload');
const $picker = document.getElementById('picker');
const $reltime = document.getElementById('reltime');
let socket, currentCam = null, cameras = [];
// state für aktuelle Kamera
let curValue = null, curTs = null, curReady = null, curError = null;
function setStatus(t, c) { $status.textContent = t; $status.className = 'status' + (c?' '+c:''); }
function updateRelativeTime() { $reltime.textContent = curTs ? relativeTime(curTs) : ''; }
function renderEmpty(msg, errMsg) {
let html = '<div class="empty">' + msg;
if (errMsg) html += '<div class="err-msg">⚠ ' + errMsg + '</div>';
html += '</div>';
$player.innerHTML = html;
$info.textContent = '';
}
function renderCurrent() {
updateRelativeTime();
if (!isVideoValid(curValue, curReady, curError)) {
const errText = curError && String(curError).trim() && String(curError).toLowerCase() !== 'null'
? String(curError) : null;
renderEmpty('Kein aktuelles Video', errText);
return;
}
const url = buildUrl(curValue, curTs);
if (!url) { renderEmpty('Kein Video verfügbar'); return; }
const fn = String(curValue).split('/').pop();
const ts = tsFromName(fn);
$player.innerHTML = '';
const video = document.createElement('video');
video.controls = true;
video.autoplay = true;
video.muted = true;
video.playsInline = true;
const source = document.createElement('source');
source.setAttribute('src', url);
source.setAttribute('type', 'video/mp4');
video.appendChild(source);
$player.appendChild(video);
video.load();
$info.innerHTML = ts ? '<div class="ts">🕒 ' + ts + '</div><div>' + fn + '</div>' : '<div>' + fn + '</div>';
}
$reload.addEventListener('click', renderCurrent);
setInterval(updateRelativeTime, 30000);
function unsubscribeCurrent() {
if (!currentCam || !socket) return;
socket.emit('unsubscribe', currentCam.datapoint);
socket.emit('unsubscribe', currentCam.ts_datapoint);
socket.emit('unsubscribe', currentCam.ready_datapoint);
socket.emit('unsubscribe', currentCam.error_datapoint);
}
function switchCamera(cam) {
unsubscribeCurrent();
currentCam = cam;
$title.textContent = '📹 ' + (cam.name || 'Kamera ' + cam.id);
$player.innerHTML = '<div class="empty">Lade Video…</div>';
$info.textContent = '';
curValue = null; curTs = null; curReady = null; curError = null;
updateRelativeTime();
// alle 4 States parallel holen, dann einmal rendern
let pending = 4;
const done = () => { if (--pending === 0) renderCurrent(); };
socket.emit('getState', cam.ts_datapoint, (e, st) => {
if (st) curTs = (st.val ? Number(st.val) : null) || st.ts || null;
done();
});
socket.emit('getState', cam.ready_datapoint, (e, st) => {
if (st) curReady = st.val;
done();
});
socket.emit('getState', cam.error_datapoint, (e, st) => {
if (st) curError = st.val;
done();
});
socket.emit('getState', cam.datapoint, (e, st) => {
if (st) curValue = st.val;
done();
});
socket.emit('subscribe', cam.datapoint);
socket.emit('subscribe', cam.ts_datapoint);
socket.emit('subscribe', cam.ready_datapoint);
socket.emit('subscribe', cam.error_datapoint);
}
fetch('/cameras').then(r => r.json()).then(list => {
cameras = list;
if (!cameras.length) {
setStatus('Keine Kameras', 'err');
$player.innerHTML = '<div class="empty">Keine Kameras gefunden</div>';
return;
}
if (fixedCamera) {
const cam = cameras.find(c => c.id === fixedCamera);
if (!cam) {
setStatus('Unbekannt', 'err');
$player.innerHTML = '<div class="empty">Kamera ' + fixedCamera + ' nicht gefunden</div>';
return;
}
connectAndStart(cam);
} else {
$picker.style.display = '';
cameras.forEach(c => {
const o = document.createElement('option');
o.value = c.id;
o.textContent = c.name || ('Kamera ' + c.id);
$picker.appendChild(o);
});
$picker.addEventListener('change', () => {
const cam = cameras.find(c => c.id === $picker.value);
if (cam) switchCamera(cam);
});
connectAndStart(cameras[0]);
}
}).catch(e => { setStatus('Server-Fehler', 'err'); console.error(e); });
function connectAndStart(cam) {
if (typeof io === 'undefined') { setStatus('Socket.IO Lib fehlt', 'err'); return; }
socket = io(IOBROKER_URL, { transports: ['websocket', 'polling'] });
socket.on('connect', () => { setStatus('Verbunden', 'ok'); switchCamera(cam); });
socket.on('disconnect', () => setStatus('Getrennt', 'err'));
socket.on('connect_error', (e) => { setStatus('Verbindungsfehler', 'err'); console.error(e); });
socket.on('stateChange', (id, state) => {
if (!state || !currentCam) return;
if (id === currentCam.datapoint) { curValue = state.val; renderCurrent(); }
else if (id === currentCam.ts_datapoint) { curTs = (state.val ? Number(state.val) : state.ts) || curTs; renderCurrent(); }
else if (id === currentCam.ready_datapoint) { curReady = state.val; renderCurrent(); }
else if (id === currentCam.error_datapoint) { curError = state.val; renderCurrent(); }
});
}
</script>
</body>
</html>`;
// ============================================================
// Widget 2: Grid (alle Kameras)
// ============================================================
const GRID_HTML = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blink Cameras</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.3.0/socket.io.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background:#1a1a1a; color:#eee; font-family:-apple-system,system-ui,sans-serif;
min-height:100vh; padding:12px; }
.topbar { display:flex; justify-content:space-between; align-items:center;
padding:0 4px 12px; gap:12px; }
.topbar .title { font-size:14px; font-weight:600; }
.status { font-size:11px; padding:3px 8px; border-radius:10px; background:#555; }
.status.ok { background:#2d6a3e; }
.status.err { background:#8b2d2d; }
.grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(280px, 1fr));
gap:12px; }
.cam { background:#2a2a2a; border-radius:10px; overflow:hidden;
box-shadow:0 2px 6px rgba(0,0,0,0.3); display:flex; flex-direction:column; }
.cam-head { padding:8px 12px; background:#333; display:flex;
justify-content:space-between; align-items:center; gap:8px; }
.cam-name { font-size:13px; font-weight:600; flex:1; min-width:0;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.cam-time { font-size:11px; color:#aaa; flex-shrink:0; }
.cam-content { display:block; }
.cam-video-wrap { position:relative; background:#000; aspect-ratio:16/9; cursor:pointer; }
.cam-video-wrap video { width:100%; height:100%; display:block; object-fit:cover; }
.cam-video-wrap .overlay { position:absolute; inset:0; display:flex;
align-items:center; justify-content:center; pointer-events:none;
background:rgba(0,0,0,0.25); transition:opacity 0.2s; }
.cam-video-wrap.playing .overlay { opacity:0; }
.cam-video-wrap .play-btn {
width:56px; height:56px; border-radius:50%;
background:rgba(0,0,0,0.6); border:2px solid rgba(255,255,255,0.8);
display:flex; align-items:center; justify-content:center;
}
.cam-video-wrap .play-btn::after {
content:''; width:0; height:0; margin-left:4px;
border-top:10px solid transparent; border-bottom:10px solid transparent;
border-left:16px solid white;
}
.cam-empty { aspect-ratio:16/9; display:flex; flex-direction:column;
align-items:center; justify-content:center; gap:6px;
color:#888; font-size:13px; background:#1a1a1a; padding:8px; text-align:center; }
.cam-empty .err-msg { color:#e88; font-size:11px; }
</style>
</head>
<body>
<div class="topbar">
<span class="title">📹 Blink Kameras</span>
<span class="status" id="status">Verbinde…</span>
</div>
<div class="grid" id="grid"></div>
<script>
__COMMON_JS__
const $status = document.getElementById('status');
const $grid = document.getElementById('grid');
let socket;
const cards = {}; // camId → {value, ts, ready, error, root, contentHost, timeEl, ...}
function setStatus(t, c) { $status.textContent = t; $status.className = 'status' + (c?' '+c:''); }
function buildCard(cam) {
const card = document.createElement('div');
card.className = 'cam';
card.dataset.cam = cam.id;
const head = document.createElement('div');
head.className = 'cam-head';
const nameSpan = document.createElement('span');
nameSpan.className = 'cam-name';
nameSpan.textContent = cam.name || ('Kamera ' + cam.id);
const timeSpan = document.createElement('span');
timeSpan.className = 'cam-time';
head.appendChild(nameSpan);
head.appendChild(timeSpan);
const contentHost = document.createElement('div');
contentHost.className = 'cam-content';
contentHost.dataset.cam = cam.id;
const empty = document.createElement('div');
empty.className = 'cam-empty';
empty.textContent = 'Lade…';
contentHost.appendChild(empty);
card.appendChild(head);
card.appendChild(contentHost);
$grid.appendChild(card);
cards[cam.id] = {
value: null, ts: null, ready: null, error: null,
root: card, head: head, contentHost: contentHost, timeEl: timeSpan,
datapoint: cam.datapoint,
ts_datapoint: cam.ts_datapoint,
ready_datapoint: cam.ready_datapoint,
error_datapoint: cam.error_datapoint,
name: cam.name || ('Kamera ' + cam.id)
};
}
function setEmpty(c, text, errMsg) {
while (c.contentHost.firstChild) c.contentHost.removeChild(c.contentHost.firstChild);
const empty = document.createElement('div');
empty.className = 'cam-empty';
const main = document.createElement('div');
main.textContent = text;
empty.appendChild(main);
if (errMsg) {
const sub = document.createElement('div');
sub.className = 'err-msg';
sub.textContent = '⚠ ' + errMsg;
empty.appendChild(sub);
}
c.contentHost.appendChild(empty);
}
function renderCard(camId) {
const c = cards[camId];
if (!c) return;
if (c.timeEl) c.timeEl.textContent = c.ts ? relativeTime(c.ts) : '';
// Wenn Adapter "kein Video" signalisiert: leeres Tile mit Hinweis
if (!isVideoValid(c.value, c.ready, c.error)) {
const errText = c.error && String(c.error).trim() && String(c.error).toLowerCase() !== 'null'
? String(c.error) : null;
setEmpty(c, 'Kein aktuelles Video', errText);
return;
}
const url = buildUrl(c.value, c.ts);
if (!url) { setEmpty(c, 'Kein Video'); return; }
const wrap = document.createElement('div');
wrap.className = 'cam-video-wrap';
wrap.dataset.cam = camId;
const video = document.createElement('video');
video.preload = 'metadata';
video.muted = true;
video.playsInline = true;
video.dataset.cam = camId;
video.dataset.name = c.name;
const source = document.createElement('source');
source.setAttribute('src', url);
source.setAttribute('type', 'video/mp4');
video.appendChild(source);
const overlay = document.createElement('div');
overlay.className = 'overlay';
const playBtn = document.createElement('div');
playBtn.className = 'play-btn';
overlay.appendChild(playBtn);
wrap.appendChild(video);
wrap.appendChild(overlay);
wrap.addEventListener('click', () => {
if (video.paused) {
video.controls = true;
wrap.classList.add('playing');
video.muted = false;
video.play().catch(() => { video.muted = true; video.play(); });
}
});
video.addEventListener('ended', () => { wrap.classList.remove('playing'); video.controls = false; });
video.addEventListener('pause', () => { if (video.ended) wrap.classList.remove('playing'); });
while (c.contentHost.firstChild) c.contentHost.removeChild(c.contentHost.firstChild);
c.contentHost.appendChild(wrap);
video.load();
}
function updateAllTimes() {
Object.keys(cards).forEach(id => {
const c = cards[id];
if (c.timeEl) c.timeEl.textContent = c.ts ? relativeTime(c.ts) : '';
});
}
setInterval(updateAllTimes, 30000);
fetch('/cameras').then(r => r.json()).then(list => {
if (!list.length) {
setStatus('Keine Kameras', 'err');
$grid.innerHTML = '<div style="padding:40px;text-align:center;color:#777">Keine Kameras gefunden</div>';
return;
}
list.forEach(buildCard);
if (typeof io === 'undefined') { setStatus('Socket.IO Lib fehlt', 'err'); return; }
socket = io(IOBROKER_URL, { transports: ['websocket', 'polling'] });
socket.on('connect', () => {
setStatus('Verbunden', 'ok');
list.forEach(cam => {
// alle 4 States parallel holen, dann rendern
let pending = 4;
const done = () => { if (--pending === 0) renderCard(cam.id); };
socket.emit('getState', cam.ts_datapoint, (e, st) => {
if (st) cards[cam.id].ts = (st.val ? Number(st.val) : null) || st.ts || (() => {
const d = st.val ? null : null; return d;
})();
done();
});
socket.emit('getState', cam.ready_datapoint, (e, st) => {
if (st) cards[cam.id].ready = st.val;
done();
});
socket.emit('getState', cam.error_datapoint, (e, st) => {
if (st) cards[cam.id].error = st.val;
done();
});
socket.emit('getState', cam.datapoint, (e, st) => {
if (st) cards[cam.id].value = st.val;
done();
});
socket.emit('subscribe', cam.datapoint);
socket.emit('subscribe', cam.ts_datapoint);
socket.emit('subscribe', cam.ready_datapoint);
socket.emit('subscribe', cam.error_datapoint);
});
});
socket.on('disconnect', () => setStatus('Getrennt', 'err'));
socket.on('connect_error', (e) => { setStatus('Verbindungsfehler', 'err'); console.error(e); });
socket.on('stateChange', (id, state) => {
if (!state) return;
// Welche Kamera & welcher Datenpunkt?
for (const cam of list) {
if (id === cam.datapoint) { cards[cam.id].value = state.val; cards[cam.id].ts = state.ts || cards[cam.id].ts; renderCard(cam.id); return; }
if (id === cam.ts_datapoint) { const tsVal = state.val ? Number(state.val) : state.ts; if (tsVal) cards[cam.id].ts = tsVal; renderCard(cam.id); return; }
if (id === cam.ready_datapoint) { cards[cam.id].ready = state.val; renderCard(cam.id); return; }
if (id === cam.error_datapoint) { cards[cam.id].error = state.val; renderCard(cam.id); return; }
}
});
}).catch(e => { setStatus('Server-Fehler', 'err'); console.error(e); });
</script>
</body>
</html>`;
function buildHTML(template) {
return template
.replace('__COMMON_JS__', COMMON_JS)
.replace(/__VIDEO_BASE__/g, VIDEO_BASE)
.replace(/__VIDEO_PORT__/g, PORT)
.replace(/__IOBROKER_PORT__/g, IOBROKER_PORT);
}
const SINGLE_PAGE = buildHTML(WIDGET_HTML);
const GRID_PAGE = buildHTML(GRID_HTML);
const MIME = {
'.mp4':'video/mp4','.webm':'video/webm','.mov':'video/quicktime',
'.jpg':'image/jpeg','.jpeg':'image/jpeg','.png':'image/png',
'.html':'text/html; charset=utf-8','.json':'application/json'
};
const server = http.createServer(async (req, res) => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
const urlPath = req.url.split('?')[0];
if (urlPath === '/cameras') {
try {
const cams = await discoverCameras();
res.writeHead(200, {
'Content-Type': 'application/json',
'Cache-Control': 'no-cache, no-store, must-revalidate'
});
res.end(JSON.stringify(cams));
} catch (e) {
log('Kamera-Discovery Fehler: ' + e.message, 'error');
res.writeHead(500); res.end('Error');
}
return;
}
if (urlPath === '/grid' || urlPath === '/grid.html') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(GRID_PAGE);
return;
}
if (urlPath === '/' || urlPath === '/index.html') {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(SINGLE_PAGE);
return;
}
if (!urlPath.startsWith(VIDEO_BASE)) { res.writeHead(404); res.end('Not Found'); return; }
const filename = decodeURIComponent(urlPath.slice(VIDEO_BASE.length));
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
res.writeHead(403); res.end('Forbidden'); return;
}
const fullPath = path.join(ROOT_DIR, filename);
if (!fullPath.startsWith(ROOT_DIR)) { res.writeHead(403); res.end('Forbidden'); return; }
fs.stat(fullPath, (err, stat) => {
if (err || !stat.isFile()) { res.writeHead(404); res.end('File Not Found'); return; }
const ext = path.extname(filename).toLowerCase();
const mimeType = MIME[ext] || 'application/octet-stream';
const range = req.headers.range;
const noCacheHeaders = mimeType.startsWith('video/')
? {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
}
: {};
if (range && mimeType.startsWith('video/')) {
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;
res.writeHead(206, {
...noCacheHeaders,
'Content-Range': `bytes ${start}-${end}/${stat.size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Type': mimeType
});
fs.createReadStream(fullPath, { start, end }).pipe(res);
} else {
res.writeHead(200, {
...noCacheHeaders,
'Content-Length': stat.size,
'Content-Type': mimeType,
'Accept-Ranges': 'bytes'
});
fs.createReadStream(fullPath).pipe(res);
}
});
});
server.listen(PORT, () => {
log(`Blink-Server läuft: http://<host>:${PORT}/ (Single) + /grid (Multi)`);
});
server.on('error', (err) => log(`Blink-Server Fehler: ${err.message}`, 'error'));
globalThis.__blinkServer = server;
onStop(() => {
if (server) { server.close(); log('Blink-Server gestoppt'); }
}, 2000);