{
  "type": "script",
  "common": {
    "name": "blink-video-url-server",
    "enabled": true,
    "engine": "system.adapter.javascript.0",
    "engineType": "Javascript/js",
    "source": "/* global log, exec, getObject, getState, setState, existsState, existsObject, $, onStop */\n/* eslint-disable no-empty, no-useless-escape, @typescript-eslint/no-unused-vars */\n// ============================================================\n// Blink Multi-Camera Server + Grid + History + LiveView/HLS\n//   http://<host>:8085/grid                 → All cameras in the grid including history and live\n//   http://<host>:8085/cameras              → camera JSON\n//   http://<host>:8085/live/start?camera=ID → Start LiveView\n//   http://<host>:8085/live/stop            → Stop LiveView\n//   http://<host>:8085/live/last-session    → Debug/Status\n//   http://<host>:8085/live/debug-cameras   → LiveView discovery debug\n//   http://<host>:8085/live-hls/<file>      → HLS playlist/segments\n//   http://<host>:8085/blink/<file>         → stored video files\n// ============================================================\n\nconst http = require('http');\nconst fs   = require('fs');\nconst path = require('path');\nconst { URL } = require('url');\n\n// ============= CONFIGURATION =============\nconst PORT          = 8085;\nconst ROOT_DIR      = '/opt/iobroker/iobroker-data/blink';\nconst VIDEO_BASE    = '/blink/';\nconst CAMERA_PREFIX = 'blink.0.cameras.';\nconst VIDEO_STATE   = '.video.file';\nconst NAME_STATE    = '.info.name';\nconst TS_STATE      = '.video.timestamp';\nconst READY_STATE   = '.video.ready';\nconst ERROR_STATE   = '.video.lastError';\nconst UNSUPPORTED_STATE = '.live.unsupported';\nconst HISTORY_SIZE  = 10;\nconst IOBROKER_PORT = 8082;\n\n// Blink/API/IMMI files on the host\nconst LIVEVIEW_DIR          = '/opt/iobroker/node_modules/iobroker.blink/lib';\nconst LIVEVIEW_REST_SCRIPT  = path.join(LIVEVIEW_DIR, 'blink-liveview-iobroker.js');\nconst LIVEVIEW_HLS_SCRIPT   = path.join(LIVEVIEW_DIR, 'immi-live-hls.js');\nconst HLS_DIR               = '/tmp/blink_hls';\nconst LIVEVIEW_RUNTIME_SEC  = 300;\n\n// Path to the ffmpeg binary. Leave empty if ffmpeg is available in PATH.\n// In Docker setups where ffmpeg is provided manually, enter the\n// full path here, for example '/usr/local/bin/ffmpeg' or '/opt/ffmpeg/ffmpeg'.\nconst FFMPEG_PATH           = '';\n\n// Blink credentials are read automatically from the adapter admin configuration.\n// Expected config keys in admin/jsonConfig.json: email, password, pin\nconst BLINK_INSTANCE        = 'blink.0';\n\n// The account ID is automatically discovered from ioBroker objects, adapter config or existing session files.\n// Only set this as an optional emergency fallback; normally leave it empty.\nconst DEFAULT_ACCOUNT_ID    = '';\n\n// The network ID is automatically read from blink.0.sync.<networkId>.\n// Only use this as an optional emergency fallback; do NOT set it blindly when multiple sync modules exist.\nconst DEFAULT_NETWORK_ID    = '';\n\n// Override/add special cases.\n// Regular Blink cameras no longer need an entry: a missing type is treated as \"camera\" automatically.\n// Entries are only needed for owl/doorbell devices, incorrect serial numbers or multiple sync modules without camera mapping.\nconst LIVEVIEW_CAMERA_OVERRIDES = {\n    '773578':  { type: 'owl', serial: 'G8T1940153360515', name: 'Mini - 0515' },\n};\n// =========================================\n\nif (typeof globalThis.__blinkServer !== 'undefined') {\n    try { globalThis.__blinkServer.close(); log('Previous Blink server stopped'); }\n    catch (e) { /* ignore */ }\n}\n\nlet liveStatus = {\n    enabled: true,\n    running: false,\n    pid: null,\n    playlist: false,\n    hls_url: null,\n    camera_id: null,\n    camera_name: null,\n    device_type: null,\n    session_file: null,\n    last_error: '',\n    last_log: ''\n};\n\nfunction shellQuote(s) {\n    return \"'\" + String(s == null ? '' : s).replace(/'/g, \"'\\\\''\") + \"'\";\n}\n\nfunction readBlinkCredentials() {\n    return new Promise((resolve, reject) => {\n        function pickNative(obj) {\n            const n = obj && obj.native ? obj.native : {};\n            const email = String(n.email || '').trim();\n            const password = String(n.password || '');\n            const pin = String(n.pin || '').trim();\n            return { email, password, pin };\n        }\n\n        function finish(source, creds) {\n            log(\n                'Blink config read from ' + source + ': email=' + (creds.email ? 'yes' : 'no') +\n                ', password=' + (creds.password ? 'yes' : 'no') +\n                ', pin=' + (creds.pin ? 'yes' : 'no')\n            );\n\n            if (!creds.email || !creds.password) {\n                reject(new Error(\n                    'Blink email or password is missing in system.adapter.' + BLINK_INSTANCE +\n                    '.native.email/password'\n                ));\n                return;\n            }\n\n            resolve(creds);\n        }\n\n        function tryCliFallback(reason) {\n            const cmd = 'iobroker object get ' + shellQuote('system.adapter.' + BLINK_INSTANCE);\n            exec(cmd, { timeout: 15000 }, (err, stdout, stderr) => {\n                if (err) {\n                    reject(new Error(\n                        'Blink adapter configuration could not be read. getObject: ' + reason +\n                        ' / CLI: ' + (stderr || err.message || String(err))\n                    ));\n                    return;\n                }\n\n                try {\n                    const obj = JSON.parse(stdout || '{}');\n                    finish('iobroker object get', pickNative(obj));\n                } catch (e) {\n                    reject(new Error('Blink adapter configuration could not be parsed: ' + e.message));\n                }\n            });\n        }\n\n        try {\n            getObject('system.adapter.' + BLINK_INSTANCE, (err, obj) => {\n                if (err || !obj || !obj.native) {\n                    tryCliFallback(err ? String(err) : 'no object/native');\n                    return;\n                }\n\n                const creds = pickNative(obj);\n\n                // In some JavaScript adapter contexts, system.adapter.* objects are returned without native values.\n                // In that case, read them via the ioBroker CLI as a fallback without writing the values to the log.\n                if (!creds.email || !creds.password) {\n                    tryCliFallback('native empty or incomplete via getObject');\n                    return;\n                }\n\n                finish('getObject', creds);\n            });\n        } catch (e) {\n            tryCliFallback(e.message || String(e));\n        }\n    });\n}\n\nfunction execPromise(cmd, opts) {\n    opts = opts || {};\n    return new Promise((resolve) => {\n        exec(cmd, opts, (err, stdout, stderr) => {\n            resolve({ err: err, stdout: stdout || '', stderr: stderr || '' });\n        });\n    });\n}\n\nfunction readFileSafe(file, maxLen) {\n    try {\n        let s = fs.readFileSync(file, 'utf8');\n        if (maxLen && s.length > maxLen) s = s.slice(-maxLen);\n        return s;\n    } catch (e) {\n        return '';\n    }\n}\n\nfunction safeJson(file) {\n    try { return JSON.parse(fs.readFileSync(file, 'utf8')); }\n    catch (e) { return null; }\n}\n\nfunction getFirstLiveCommand(session) {\n    try {\n        const cmds = session && session.raw_poll && Array.isArray(session.raw_poll.commands)\n            ? session.raw_poll.commands\n            : [];\n        return cmds[0] || null;\n    } catch (e) {\n        return null;\n    }\n}\n\nfunction parseCommandDebug(debugValue) {\n    const raw = String(debugValue || '');\n    const out = {\n        raw: raw,\n        hasCommandError: /command_error/i.test(raw),\n        hasLfrOk: /lfr_ok/i.test(raw),\n        lfrOk: null,\n        commandError: null\n    };\n\n    for (const part of raw.split('|')) {\n        const s = part.trim();\n        if (!s) continue;\n        try {\n            const obj = JSON.parse(s);\n            if (obj && Object.prototype.hasOwnProperty.call(obj, 'lfr_ok')) out.lfrOk = obj.lfr_ok;\n            if (obj && Object.prototype.hasOwnProperty.call(obj, 'command_error')) out.commandError = obj.command_error;\n        } catch (e) {\n            // debug is not always valid JSON; raw text detection is sufficient as a fallback.\n        }\n    }\n\n    return out;\n}\n\nfunction detectLfrLiveViewRejection(session) {\n    const cmd = getFirstLiveCommand(session);\n    if (!cmd) return null;\n\n    const debug = parseCommandDebug(cmd.debug);\n    const lfrAckZero = Number(cmd.lfr_ack || 0) === 0;\n    const isOldLfrPath =\n        !session.is_mclv &&\n        !session.first_joiner &&\n        !session.parent_command_id &&\n        (cmd.stage_lv == null || typeof cmd.stage_lv === 'undefined') &&\n        String(cmd.state_stage || session.state_stage || '') === 'vs';\n\n    if (isOldLfrPath && lfrAckZero) {\n        const hasCommandErrorText = debug.hasCommandError ? ', command_error present' : '';\n        return {\n            reason:\n                'XT2/LFR LiveView was not confirmed by Blink: lfr_ack=0, stage_lv=null' +\n                hasCommandErrorText + '. ' +\n                'This camera does not provide a usable stream via the current immis/HLS path.',\n            detail: {\n                state_stage: cmd.state_stage,\n                stage_lv: cmd.stage_lv,\n                stage_vs: cmd.stage_vs,\n                sm_ack: cmd.sm_ack,\n                lfr_ack: cmd.lfr_ack,\n                sequence: cmd.sequence,\n                debug: debug.raw,\n                lfr_ok: debug.lfrOk,\n                command_error: debug.commandError,\n                transaction: cmd.transaction,\n                player_transaction: cmd.player_transaction,\n                server: cmd.server\n            }\n        };\n    }\n\n    return null;\n}\n\nfunction getPiHost(req) {\n    const host = (req && req.headers && req.headers.host) ? String(req.headers.host).split(':')[0] : '127.0.0.1';\n    return host || '127.0.0.1';\n}\n\nfunction publicHlsUrl(req) {\n    return 'http://' + getPiHost(req) + ':' + PORT + '/live-hls/live.m3u8?t=' + Date.now();\n}\n\nfunction getStateAsync(id) {\n    return new Promise((resolve) => {\n        try {\n            getState(id, (err, st) => resolve(!err && st ? st : null));\n        } catch (e) {\n            resolve(null);\n        }\n    });\n}\n\nfunction setStateAsync(id, value, ack) {\n    return new Promise((resolve) => {\n        try {\n            setState(id, value, ack !== false, (err) => resolve(!err));\n        } catch (e) {\n            resolve(false);\n        }\n    });\n}\n\nasync function markCameraUnsupported(cameraId, reason) {\n    const id = CAMERA_PREFIX + cameraId + UNSUPPORTED_STATE;\n    if (!objectExists(id)) {\n        log('markCameraUnsupported: State ' + id + ' does not exist; restart the adapter?', 'warn');\n        return;\n    }\n    try {\n        const st = await getStateAsync(id);\n        if (st && st.val === true) {\n            return; // already marked\n        }\n        const ok = await setStateAsync(id, true, true);\n        if (ok) {\n            log('Camera ' + cameraId + ' permanently marked as unsupported for LiveView. Reason: ' + (reason || 'unknown'), 'warn');\n        }\n    } catch (e) {\n        log('markCameraUnsupported for ' + cameraId + ' failed: ' + (e.message || e), 'warn');\n    }\n}\n\nfunction objectExists(id) {\n    try {\n        if (typeof existsState === 'function') return !!existsState(id);\n    } catch (e) {}\n    try {\n        if (typeof existsObject === 'function') return !!existsObject(id);\n    } catch (e) {}\n    return true;\n}\n\nasync function readStateString(id) {\n    if (!objectExists(id)) return '';\n    const st = await getStateAsync(id);\n    if (!st || st.val === null || typeof st.val === 'undefined') return '';\n    return String(st.val).trim();\n}\n\nasync function readFirstStateString(ids) {\n    for (const id of ids) {\n        const v = await readStateString(id);\n        if (v) return v;\n    }\n    return '';\n}\n\nlet __syncNetworkIdsCache = null;\n\nfunction discoverSyncNetworkIds() {\n    if (__syncNetworkIdsCache) return __syncNetworkIdsCache;\n\n    const ids = new Set();\n    const selectors = [\n        'channel[id=blink.0.sync.*]',\n        'device[id=blink.0.sync.*]',\n        'state[id=blink.0.sync.*]'\n    ];\n\n    for (const selector of selectors) {\n        try {\n            $(selector).each((id) => {\n                const m = String(id).match(/^blink\\.0\\.sync\\.(\\d+)(?:\\.|$)/);\n                if (m) ids.add(m[1]);\n            });\n        } catch (e) {\n            // Some ioBroker installations do not support all selector types.\n        }\n    }\n\n    __syncNetworkIdsCache = Array.from(ids).sort();\n    return __syncNetworkIdsCache;\n}\n\nfunction getNetworkFallbackInfo() {\n    const syncIds = discoverSyncNetworkIds();\n\n    if (syncIds.length === 1) {\n        return {\n            networkId: syncIds[0],\n            ambiguous: false,\n            all: syncIds,\n            source: 'blink.0.sync.*'\n        };\n    }\n\n    if (syncIds.length > 1) {\n        return {\n            networkId: null,\n            ambiguous: true,\n            all: syncIds,\n            source: 'multiple blink.0.sync.*'\n        };\n    }\n\n    if (DEFAULT_NETWORK_ID) {\n        return {\n            networkId: String(DEFAULT_NETWORK_ID),\n            ambiguous: false,\n            all: [],\n            source: 'DEFAULT_NETWORK_ID'\n        };\n    }\n\n    return {\n        networkId: null,\n        ambiguous: false,\n        all: [],\n        source: 'none'\n    };\n}\n\nfunction addNumericCandidate(map, value, source) {\n    const v = String(value == null ? '' : value).trim();\n    if (!/^\\d+$/.test(v)) return;\n    if (!map[v]) map[v] = [];\n    if (source && !map[v].includes(source)) map[v].push(source);\n}\n\nfunction scanAccountIdsFromAny(value, map, source, depth) {\n    // Important: do not collect arbitrary numbers from JSON files as account IDs.\n    // Otherwise command_id, camera_id, duration, status_code and similar values become false candidates\n    // in the account auto-detection. Only explicit account fields are accepted.\n    if (depth > 6 || value == null) return;\n\n    if (Array.isArray(value)) {\n        value.forEach((v, i) => scanAccountIdsFromAny(v, map, source + '[' + i + ']', depth + 1));\n        return;\n    }\n\n    if (typeof value !== 'object') return;\n\n    for (const key of Object.keys(value)) {\n        const lower = key.toLowerCase();\n        if (lower === 'account_id' || lower === 'accountid') {\n            addNumericCandidate(map, value[key], source + '.' + key);\n        } else if ((lower === 'id' || lower === 'account') && /accounts?$/i.test(source)) {\n            addNumericCandidate(map, value[key], source + '.' + key);\n        } else if (/(^|\\.)accounts?(\\.|$)|native\\.accounts/i.test(source + '.' + key)) {\n            scanAccountIdsFromAny(value[key], map, source + '.' + key, depth + 1);\n        } else if (lower === 'account' && typeof value[key] === 'object') {\n            scanAccountIdsFromAny(value[key], map, source + '.' + key, depth + 1);\n        }\n    }\n}\n\nfunction discoverAccountIdsFromObjects(map) {\n    const selectors = [\n        'channel[id=blink.0.account.*]',\n        'device[id=blink.0.account.*]',\n        'state[id=blink.0.account.*]',\n        'channel[id=blink.0.accounts.*]',\n        'device[id=blink.0.accounts.*]',\n        'state[id=blink.0.accounts.*]'\n    ];\n\n    for (const selector of selectors) {\n        try {\n            $(selector).each((id) => {\n                const m = String(id).match(/^blink\\.0\\.accounts?\\.(\\d+)(?:\\.|$)/);\n                if (m) addNumericCandidate(map, m[1], selector);\n            });\n        } catch (e) {\n            // Some ioBroker installations do not support all selector types.\n        }\n    }\n}\n\nfunction discoverAccountIdsFromSessionFiles(map) {\n    const files = [];\n\n    try {\n        for (const f of fs.readdirSync('/tmp')) {\n            if (/^blink_liveview_session.*\\.json$/.test(f)) files.push(path.join('/tmp', f));\n        }\n    } catch (e) {}\n\n    try {\n        const cacheDir = '/tmp/blink_session_cache';\n        if (fs.existsSync(cacheDir)) {\n            for (const f of fs.readdirSync(cacheDir)) {\n                if (/\\.json$/i.test(f)) files.push(path.join(cacheDir, f));\n            }\n        }\n    } catch (e) {}\n\n    for (const file of files) {\n        try {\n            const obj = JSON.parse(fs.readFileSync(file, 'utf8'));\n            if (!obj || typeof obj !== 'object') continue;\n\n            // Only use explicit account fields. Do not perform a free-form JSON number search.\n            addNumericCandidate(map, obj.account_id, file + '.account_id');\n            addNumericCandidate(map, obj.accountId, file + '.accountId');\n            addNumericCandidate(map, obj.raw_start && obj.raw_start.account_id, file + '.raw_start.account_id');\n            addNumericCandidate(map, obj.raw_start && obj.raw_start.accountId, file + '.raw_start.accountId');\n            addNumericCandidate(map, obj.raw_poll && obj.raw_poll.account_id, file + '.raw_poll.account_id');\n            addNumericCandidate(map, obj.raw_poll && obj.raw_poll.accountId, file + '.raw_poll.accountId');\n            if (obj.raw_poll && Array.isArray(obj.raw_poll.commands)) {\n                obj.raw_poll.commands.forEach((cmd, i) => {\n                    addNumericCandidate(map, cmd && cmd.account_id, file + '.raw_poll.commands[' + i + '].account_id');\n                    addNumericCandidate(map, cmd && cmd.accountId, file + '.raw_poll.commands[' + i + '].accountId');\n                });\n            }\n\n            // If the file contains a real accounts structure, extract only from that.\n            if (obj.accounts) scanAccountIdsFromAny(obj.accounts, map, file + '.accounts', 0);\n            if (obj.account) scanAccountIdsFromAny(obj.account, map, file + '.account', 0);\n        } catch (e) {}\n    }\n}\n\nfunction getObjectAsync(id) {\n    return new Promise((resolve) => {\n        try {\n            getObject(id, (err, obj) => resolve(!err && obj ? obj : null));\n        } catch (e) {\n            resolve(null);\n        }\n    });\n}\n\nasync function getAccountFallbackInfo() {\n    const map = {};\n\n    discoverAccountIdsFromObjects(map);\n    discoverAccountIdsFromSessionFiles(map);\n\n    const adapterObj = await getObjectAsync('system.adapter.' + BLINK_INSTANCE);\n    if (adapterObj && adapterObj.native) {\n        addNumericCandidate(map, adapterObj.native.accountId, 'adapter-config.accountId');\n        addNumericCandidate(map, adapterObj.native.account_id, 'adapter-config.account_id');\n        scanAccountIdsFromAny(adapterObj.native.accounts, map, 'adapter-config.accounts', 0);\n    }\n\n    const stateAccountId = await readFirstStateString([\n        'blink.0.account_id',\n        'blink.0.accountId',\n        'blink.0.info.account_id',\n        'blink.0.info.accountId',\n        'blink.0.config.account_id',\n        'blink.0.config.accountId',\n        'blink.0.account.id'\n    ]);\n    if (stateAccountId) addNumericCandidate(map, stateAccountId, 'blink.0 account state');\n\n    if (DEFAULT_ACCOUNT_ID) addNumericCandidate(map, DEFAULT_ACCOUNT_ID, 'DEFAULT_ACCOUNT_ID');\n\n    const ids = Object.keys(map).sort();\n\n    if (ids.length === 1) {\n        return {\n            accountId: ids[0],\n            ambiguous: false,\n            all: ids,\n            sources: map\n        };\n    }\n\n    if (ids.length > 1) {\n        return {\n            accountId: null,\n            ambiguous: true,\n            all: ids,\n            sources: map\n        };\n    }\n\n    return {\n        accountId: null,\n        ambiguous: false,\n        all: [],\n        sources: map\n    };\n}\n\nasync function buildLiveviewConfigForCamera(cam) {\n    const id = String(cam.id);\n    const ov = LIVEVIEW_CAMERA_OVERRIDES[id] || {};\n\n    const serial = ov.serial || await readFirstStateString([\n        CAMERA_PREFIX + id + '.info.serial',\n        CAMERA_PREFIX + id + '.serial',\n        CAMERA_PREFIX + id + '.device.serial',\n        CAMERA_PREFIX + id + '.info.serial_number',\n        CAMERA_PREFIX + id + '.info.serialNumber'\n    ]);\n\n    // Detect type automatically, otherwise default to a regular Blink camera.\n    // Set special cases such as owl/doorbell via LIVEVIEW_CAMERA_OVERRIDES.\n    const detectedType = await readFirstStateString([\n        CAMERA_PREFIX + id + '.info.type',\n        CAMERA_PREFIX + id + '.type',\n        CAMERA_PREFIX + id + '.device.type',\n        CAMERA_PREFIX + id + '.info.device_type',\n        CAMERA_PREFIX + id + '.info.deviceType'\n    ]);\n    const type = ov.type || detectedType || 'camera';\n\n    const detectedNetworkId = await readFirstStateString([\n        CAMERA_PREFIX + id + '.info.network_id',\n        CAMERA_PREFIX + id + '.info.networkId',\n        CAMERA_PREFIX + id + '.network_id',\n        CAMERA_PREFIX + id + '.networkId',\n        CAMERA_PREFIX + id + '.network.id',\n        CAMERA_PREFIX + id + '.device.network_id',\n        CAMERA_PREFIX + id + '.device.networkId'\n    ]);\n\n    const detectedAccountId = await readFirstStateString([\n        CAMERA_PREFIX + id + '.info.account_id',\n        CAMERA_PREFIX + id + '.info.accountId',\n        CAMERA_PREFIX + id + '.account_id',\n        CAMERA_PREFIX + id + '.accountId',\n        CAMERA_PREFIX + id + '.account.id',\n        CAMERA_PREFIX + id + '.device.account_id',\n        CAMERA_PREFIX + id + '.device.accountId'\n    ]);\n\n    const accountFallback = await getAccountFallbackInfo();\n    const networkFallback = getNetworkFallbackInfo();\n    const accountId = ov.accountId || detectedAccountId || accountFallback.accountId;\n    const networkId = ov.networkId || detectedNetworkId || networkFallback.networkId;\n\n    const missing = [];\n    const warnings = [];\n    if (!accountId) missing.push('accountId');\n    if (!networkId) missing.push('networkId');\n    if (!type) missing.push('type');\n    if (!id) missing.push('id');\n    if (!serial) missing.push('serial');\n\n    if (!accountId && accountFallback.ambiguous) {\n        warnings.push('Multiple account IDs found (' + accountFallback.all.join(', ') + '), but no camera accountId. Please set accountId via LIVEVIEW_CAMERA_OVERRIDES.');\n    }\n\n    if (!networkId && networkFallback.ambiguous) {\n        warnings.push('Multiple sync modules found (' + networkFallback.all.join(', ') + '), but no camera networkId. Please set networkId via LIVEVIEW_CAMERA_OVERRIDES.');\n    }\n\n    if (missing.length) {\n        return { liveview: null, missing: missing, warnings: warnings, syncNetworks: networkFallback.all, accountCandidates: accountFallback.all };\n    }\n\n    return {\n        liveview: {\n            id: id,\n            name: ov.name || cam.name || ('Camera ' + id),\n            accountId: String(accountId),\n            networkId: String(networkId),\n            type: String(type),\n            serial: String(serial),\n            hasSerial: true,\n            accountSource: ov.accountId ? 'override' : (detectedAccountId ? 'camera-state' : (accountFallback.sources && accountFallback.sources[accountId] ? accountFallback.sources[accountId].join(', ') : 'auto')),\n            accountCandidates: accountFallback.all,\n            networkSource: ov.networkId ? 'override' : (detectedNetworkId ? 'camera-state' : networkFallback.source)\n        },\n        missing: [],\n        warnings: warnings,\n        syncNetworks: networkFallback.all,\n        accountCandidates: accountFallback.all\n    };\n}\n\n// ---------- Discover cameras automatically ----------\nasync function discoverCameras() {\n    const cams = [];\n    const seen = new Set();\n\n    $(`state[id=${CAMERA_PREFIX}*${NAME_STATE}]`).each((id) => {\n        const rest = id.slice(CAMERA_PREFIX.length);\n        const camId = rest.split('.')[0];\n        if (!seen.has(camId)) {\n            seen.add(camId);\n            const history = [];\n            for (let i = 0; i < HISTORY_SIZE; i++) {\n                history.push({\n                    slot: i,\n                    file_datapoint:      `${CAMERA_PREFIX}${camId}.video.history.${i}.file`,\n                    timestamp_datapoint: `${CAMERA_PREFIX}${camId}.video.history.${i}.timestamp`,\n                    id_datapoint:        `${CAMERA_PREFIX}${camId}.video.history.${i}.id`,\n                    source_datapoint:    `${CAMERA_PREFIX}${camId}.video.history.${i}.source`\n                });\n            }\n            cams.push({\n                id: camId,\n                datapoint:       CAMERA_PREFIX + camId + VIDEO_STATE,\n                ts_datapoint:    CAMERA_PREFIX + camId + TS_STATE,\n                ready_datapoint: CAMERA_PREFIX + camId + READY_STATE,\n                error_datapoint: CAMERA_PREFIX + camId + ERROR_STATE,\n                unsupported_datapoint: CAMERA_PREFIX + camId + UNSUPPORTED_STATE,\n                history: history,\n                name: null,\n                liveview: null,\n                liveCapable: false,\n                liveMissing: [],\n                liveWarnings: []\n            });\n        }\n    });\n\n    for (const c of cams) {\n        const name = await readStateString(CAMERA_PREFIX + c.id + NAME_STATE);\n        if (name) c.name = name;\n        const live = await buildLiveviewConfigForCamera(c);\n        c.liveview = live.liveview;\n        c.liveCapable = !!live.liveview;\n        c.liveMissing = live.missing || [];\n        c.liveWarnings = live.warnings || [];\n        c.syncNetworks = live.syncNetworks || discoverSyncNetworkIds();\n        c.accountCandidates = live.accountCandidates || [];\n\n        // Self-learning marker from the ioBroker adapter:\n        // If the adapter already detected this camera as \"LiveView not supported\"\n        // for example classic XT/XT2 without an immis:// server, disable the Live button.\n        const unsupportedStateId = CAMERA_PREFIX + c.id + UNSUPPORTED_STATE;\n        if (objectExists(unsupportedStateId)) {\n            try {\n                const st = await getStateAsync(unsupportedStateId);\n                if (st && st.val === true) {\n                    c.liveCapable = false;\n                    c.liveMissing = (c.liveMissing || []).concat([\n                        'Camera model does not support LiveView (detected by adapter)'\n                    ]);\n                }\n            } catch (e) {\n                // State cannot be read; not critical, liveCapable remains unchanged.\n            }\n        }\n    }\n\n    return cams.sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id));\n}\n\nfunction cleanupHlsDir() {\n    try {\n        if (!fs.existsSync(HLS_DIR)) fs.mkdirSync(HLS_DIR, { recursive: true });\n        const files = fs.readdirSync(HLS_DIR);\n        for (const file of files) {\n            const full = path.join(HLS_DIR, file);\n            try { fs.rmSync(full, { recursive: true, force: true }); }\n            catch (e) { log('HLS file could not be deleted: ' + full + ' / ' + e.message, 'warn'); }\n        }\n        try { fs.chmodSync(HLS_DIR, 0o777); } catch (e) {}\n    } catch (e) {\n        log('HLS cleanup error: ' + e.message, 'warn');\n    }\n}\n\nasync function stopLiveViewProcess() {\n    const pid = liveStatus.pid;\n    if (pid) {\n        await execPromise('kill ' + shellQuote(pid) + ' 2>/dev/null || true');\n    }\n    await execPromise('pkill -f immi-live-hls || true; pkill -f ffmpeg || true');\n    liveStatus.running = false;\n    liveStatus.pid = null;\n    liveStatus.playlist = fs.existsSync(path.join(HLS_DIR, 'live.m3u8'));\n}\n\nfunction waitForPlaylist(timeoutMs) {\n    const start = Date.now();\n    const playlist = path.join(HLS_DIR, 'live.m3u8');\n    return new Promise((resolve) => {\n        const t = setInterval(() => {\n            const ok = fs.existsSync(playlist) && fs.statSync(playlist).size > 0;\n            if (ok) { clearInterval(t); resolve(true); return; }\n            if (Date.now() - start > timeoutMs) { clearInterval(t); resolve(false); }\n        }, 500);\n    });\n}\n\nasync function startLiveForCamera(cameraId, req) {\n    const cams = await discoverCameras();\n    const cam = cams.find(c => String(c.id) === String(cameraId));\n    if (!cam) throw new Error('Unknown camera ID: ' + cameraId);\n    if (!cam.liveview) throw new Error('LiveView is not configured for camera ' + cameraId + ': missing ' + (cam.liveMissing || []).join(', '));\n\n    const lv = cam.liveview;\n    log('Starting LiveView for camera \"' + lv.name + '\" id=' + lv.id + ' type=' + lv.type + ' serial=' + (lv.serial ? 'yes' : 'no'));\n\n    await stopLiveViewProcess();\n    cleanupHlsDir();\n\n    const sessionFile = '/tmp/blink_liveview_session_' + lv.id + '.json';\n    const bridgeLog = '/tmp/blink_liveview_bridge_' + lv.id + '.log';\n    try { fs.rmSync(sessionFile, { force: true }); } catch (e) {}\n    try { fs.rmSync('/tmp/blink_liveview_session.json', { force: true }); } catch (e) {}\n    try { fs.rmSync(bridgeLog, { force: true }); } catch (e) {}\n\n    const creds = await readBlinkCredentials();\n\n    const restCmd =\n        'cd ' + shellQuote(LIVEVIEW_DIR) + ' && ' +\n        'BLINK_EMAIL=' + shellQuote(creds.email) + ' ' +\n        'BLINK_PASSWORD=' + shellQuote(creds.password) + ' ' +\n        'BLINK_PIN=' + shellQuote(creds.pin) + ' ' +\n        'BLINK_ACCOUNT_ID=' + shellQuote(lv.accountId) + ' ' +\n        'BLINK_NETWORK_ID=' + shellQuote(lv.networkId) + ' ' +\n        'BLINK_DEVICE_TYPE=' + shellQuote(lv.type) + ' ' +\n        'BLINK_DEVICE_ID=' + shellQuote(lv.id) + ' ' +\n        'BLINK_DEBUG=1 BLINK_POLL_ATTEMPTS=1 ' +\n        '/usr/bin/node ' + shellQuote(LIVEVIEW_REST_SCRIPT);\n\n    const rest = await execPromise(restCmd, { timeout: 180000 });\n    if (rest.err) {\n        const msg = (rest.stdout || rest.stderr || rest.err.message || 'REST LiveView failed');\n        liveStatus.last_error = msg;\n        liveStatus.last_log = msg;\n        throw new Error(msg);\n    }\n\n    if (!fs.existsSync('/tmp/blink_liveview_session.json')) {\n        const msg = 'REST LiveView did not create a session file. Log: ' + (rest.stdout || rest.stderr || '');\n        liveStatus.last_error = msg;\n        liveStatus.last_log = msg;\n        throw new Error(msg);\n    }\n    fs.copyFileSync('/tmp/blink_liveview_session.json', sessionFile);\n\n    const session = safeJson(sessionFile);\n    if (!session || !session.server || !String(session.server).startsWith('immis://')) {\n        const msg = 'Session file contains no valid immis:// server.';\n        liveStatus.last_error = msg;\n        liveStatus.last_log = JSON.stringify(session || {}, null, 2).slice(0, 2000);\n        // Classic XT/XT2 cameras do not return usable data here; mark them permanently.\n        await markCameraUnsupported(cameraId, 'no immis:// server in session');\n        throw new Error(msg);\n    }\n    if (String(session.device_id) !== String(lv.id)) {\n        const msg = 'Wrong session ID: expected ' + lv.id + ', received ' + session.device_id;\n        liveStatus.last_error = msg;\n        liveStatus.last_log = JSON.stringify(session || {}, null, 2).slice(0, 2000);\n        throw new Error(msg);\n    }\n\n    const lfrReject = detectLfrLiveViewRejection(session);\n    if (lfrReject) {\n        liveStatus.last_error = lfrReject.reason;\n        liveStatus.last_log = JSON.stringify(lfrReject.detail, null, 2).slice(0, 4000);\n        await markCameraUnsupported(cameraId, lfrReject.reason);\n        throw new Error(lfrReject.reason + '\\n' + liveStatus.last_log);\n    }\n\n    const hlsUrl = publicHlsUrl(req);\n    const bridgeCmd =\n        'cd ' + shellQuote(LIVEVIEW_DIR) + ' && ' +\n        'IMMI_SERIAL=' + shellQuote(lv.serial) + ' ' +\n        'IMMI_RUNTIME_SECONDS=' + shellQuote(LIVEVIEW_RUNTIME_SEC) + ' ' +\n        (FFMPEG_PATH ? 'IMMI_FFMPEG_PATH=' + shellQuote(FFMPEG_PATH) + ' ' : '') +\n        'NODE_TLS_REJECT_UNAUTHORIZED=0 ' +\n        'nohup /usr/bin/node ' + shellQuote(LIVEVIEW_HLS_SCRIPT) + ' ' + shellQuote(sessionFile) +\n        ' > ' + shellQuote(bridgeLog) + ' 2>&1 & echo $!';\n\n    const bridge = await execPromise(bridgeCmd, { timeout: 5000 });\n    const pid = String(bridge.stdout || '').trim().split(/\\s+/).pop();\n\n    liveStatus = {\n        enabled: true,\n        // Set to true only after the playlist check succeeded.\n        // Otherwise /live/last-session reports a running stream to the grid too early.\n        running: false,\n        pid: pid || null,\n        playlist: false,\n        hls_url: hlsUrl,\n        camera_id: lv.id,\n        camera_name: lv.name,\n        device_type: lv.type,\n        session_file: sessionFile,\n        last_error: '',\n        last_log: ''\n    };\n\n    const ok = await waitForPlaylist(90000);\n    liveStatus.playlist = ok;\n    liveStatus.running = !!ok;\n    liveStatus.last_log = readFileSafe(bridgeLog, 12000);\n\n    if (!ok) {\n        liveStatus.running = false;\n        liveStatus.last_error = 'HLS playlist was not created.';\n        // Intentionally do NOT mark the camera permanently here. \"HLS playlist was not created\"\n        // can also happen with cameras that actually support LiveView, for example when a camera\n        // only starts streaming on a second attempt, or during temporary network issues.\n        // Reliable model-based detection is already handled by the adapter during discovery.\n        throw new Error('HLS playlist was not created. Bridge log:\\n' + liveStatus.last_log);\n    }\n\n    // Success: clear a previously set unsupported marker if present.\n    const unsupportedId = CAMERA_PREFIX + cameraId + UNSUPPORTED_STATE;\n    if (objectExists(unsupportedId)) {\n        try {\n            const prev = await getStateAsync(unsupportedId);\n            if (prev && prev.val === true) {\n                await setStateAsync(unsupportedId, false, true);\n                log('Camera ' + cameraId + ': unsupported marker removed (LiveView works again).', 'info');\n            }\n        } catch (e) { /* not critical */ }\n    }\n\n    return {\n        ok: true,\n        camera_id: lv.id,\n        camera_name: lv.name,\n        hls_url: hlsUrl,\n        pid: liveStatus.pid\n    };\n}\n\nfunction serveFile(req, res, fullPath, contentType, noCache) {\n    fs.stat(fullPath, (err, stat) => {\n        if (err || !stat.isFile()) { res.writeHead(404); res.end('File Not Found'); return; }\n        const headers = {\n            'Content-Type': contentType || 'application/octet-stream',\n            'Content-Length': stat.size,\n            'Accept-Ranges': 'bytes',\n            'Access-Control-Allow-Origin': '*'\n        };\n        if (noCache) {\n            headers['Cache-Control'] = 'no-cache, no-store, must-revalidate';\n            headers['Pragma'] = 'no-cache';\n            headers['Expires'] = '0';\n        }\n        const range = req.headers.range;\n        if (range) {\n            const parts = range.replace(/bytes=/, '').split('-');\n            const start = parseInt(parts[0], 10);\n            const end = parts[1] ? parseInt(parts[1], 10) : stat.size - 1;\n            if (!isNaN(start) && !isNaN(end)) {\n                res.writeHead(206, {\n                    ...headers,\n                    'Content-Range': 'bytes ' + start + '-' + end + '/' + stat.size,\n                    'Content-Length': end - start + 1\n                });\n                fs.createReadStream(fullPath, { start, end }).pipe(res);\n                return;\n            }\n        }\n        res.writeHead(200, headers);\n        fs.createReadStream(fullPath).pipe(res);\n    });\n}\n\nconst COMMON_JS = `\nconst VIDEO_PREFIX = location.protocol + '//' + location.hostname + ':__VIDEO_PORT__' + '__VIDEO_BASE__';\nconst IOBROKER_URL = location.protocol + '//' + location.hostname + ':__IOBROKER_PORT__';\nconst HISTORY_SIZE = __HISTORY_SIZE__;\n\nfunction buildUrl(v, ts) {\n  if (!v) return null;\n  if (/^https?:\\\\/\\\\//.test(v)) return v;\n  const fn = encodeURIComponent(String(v).split('/').pop());\n  return VIDEO_PREFIX + fn + '?t=' + (ts || Date.now());\n}\nfunction formatTs(isoOrMs) {\n  if (!isoOrMs) return '';\n  const d = new Date(isoOrMs);\n  if (isNaN(d.getTime())) return '';\n  return d.toLocaleString('en-US', {\n    day: '2-digit', month: '2-digit', year: 'numeric',\n    hour: '2-digit', minute: '2-digit', second: '2-digit'\n  });\n}\nfunction relativeTime(ms) {\n  if (!ms) return '';\n  const t = typeof ms === 'string' ? new Date(ms).getTime() : ms;\n  if (!t || isNaN(t)) return '';\n  const diff = Math.max(0, Date.now() - t);\n  const sec = Math.floor(diff / 1000);\n  if (sec < 60) return 'just now';\n  const min = Math.floor(sec / 60);\n  if (min < 60) return '' + min + ' min ago';\n  const h = Math.floor(min / 60);\n  if (h < 24) return '' + h + ' h ago';\n  const d = Math.floor(h / 24);\n  if (d < 30) return '' + d + ' d ago';\n  return new Date(t).toLocaleDateString('en-US');\n}\nfunction isVideoValid(value, ready, lastError) {\n  if (!value) return false;\n  if (lastError && String(lastError).trim() !== '' && String(lastError).toLowerCase() !== 'null') return false;\n  if (ready === false) return false;\n  return true;\n}\n`;\n\nconst GRID_HTML = `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>Blink Cameras</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/socket.io/2.3.0/socket.io.js\"></script>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.20/hls.min.js\"></script>\n<style>\n* { box-sizing: border-box; margin: 0; padding: 0; }\nbody { background:#1a1a1a; color:#eee; font-family:-apple-system,system-ui,sans-serif; min-height:100vh; padding:12px; }\n.topbar { display:flex; justify-content:space-between; align-items:center; padding:0 4px 12px; gap:12px; }\n.topbar .title { font-size:14px; font-weight:600; }\n.status { font-size:11px; padding:3px 8px; border-radius:10px; background:#555; }\n.status.ok { background:#2d6a3e; }\n.status.err { background:#8b2d2d; }\n.grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(300px, 1fr)); gap:12px; }\n.cam { background:#2a2a2a; border-radius:10px; overflow:hidden; box-shadow:0 2px 6px rgba(0,0,0,0.3); display:flex; flex-direction:column; }\n.cam-head { padding:8px 12px; background:#333; display:flex; justify-content:space-between; align-items:center; gap:8px; }\n.cam-name { font-size:13px; font-weight:600; flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }\n.cam-time { font-size:11px; color:#aaa; flex-shrink:0; }\n.cam-content { display:block; position:relative; }\n.cam-video-wrap { position:relative; background:#000; aspect-ratio:16/9; cursor:pointer; }\n.cam-video-wrap video { width:100%; height:100%; display:block; object-fit:contain; background:#000; }\n.cam-video-wrap.live-playing video { object-fit:contain; }\n.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; }\n.cam-video-wrap.playing .overlay, .cam-video-wrap.live-playing .overlay { opacity:0; }\n.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; }\n.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; }\n.cam-video-wrap .slot-badge { position:absolute; top:6px; left:6px; background:rgba(0,0,0,0.7); color:white; padding:2px 8px; border-radius:4px; font-size:11px; font-weight:500; pointer-events:none; }\n.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; }\n.cam-empty .err-msg { color:#e88; font-size:11px; white-space:pre-wrap; max-height:160px; overflow:auto; }\n.cam-actions { display:flex; align-items:center; gap:8px; padding:8px 12px; border-top:1px solid #383838; background:#252525; flex-wrap:wrap; }\n.cam-actions button { border:none; border-radius:7px; padding:6px 10px; color:#fff; cursor:pointer; font-weight:600; }\n.cam-actions button.live { background:#19618a; }\n.cam-actions button.stop { background:#8b332b; }\n.cam-actions button:disabled { opacity:.4; cursor:not-allowed; }\n.cam-actions .live-label { color:#bbb; font-size:12px; }\n.cam-nav { display:flex; align-items:center; justify-content:space-between; padding:8px 8px; background:#222; gap:8px; border-top:1px solid #383838; }\n.cam-nav button { background:#444; color:#eee; border:none; padding:7px 12px; border-radius:7px; font-size:13px; cursor:pointer; min-width:44px; }\n.cam-nav button:hover { background:#555; }\n.cam-nav button:disabled { opacity:0.35; cursor:not-allowed; }\n.cam-nav .label { font-size:12px; color:#bbb; flex:1; text-align:center; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }\n.cam-nav .label .source { font-size:9px; padding:1px 4px; border-radius:3px; background:#444; margin-left:4px; vertical-align:middle; }\n.cam-nav .label .source.cloud { background:#2d4a6a; }\n.cam-nav .label .source.local_storage { background:#4a3a2d; }\n</style>\n</head>\n<body>\n<div class=\"topbar\">\n  <span class=\"title\">📹 Blink cameras</span>\n  <span class=\"status\" id=\"status\">Connecting…</span>\n</div>\n<div class=\"grid\" id=\"grid\"></div>\n<script>\n__COMMON_JS__\n\nconst $status = document.getElementById('status');\nconst $grid   = document.getElementById('grid');\nlet socket;\nconst cards = {};\nlet cameras = [];\nwindow.__blinkLiveState = { running:false, camera_id:null, hls_url:null };\nwindow.__blinkLiveStarting = null;\nwindow.__blinkLiveStartSeq = 0;\n\nfunction setStatus(t, c) { $status.textContent = t; $status.className = 'status' + (c?' '+c:''); }\nfunction esc(s) { return String(s == null ? '' : s).replace(/[&<>\\\"]/g, function(ch){ return ({'&':'&amp;','<':'&lt;','>':'&gt;','\\\"':'&quot;'})[ch]; }); }\n\nfunction attachLiveHls(video, url) {\n  if (video._hls) { try { video._hls.destroy(); } catch(e) {} video._hls = null; }\n  if (window.Hls && Hls.isSupported()) {\n    const hls = new Hls({\n      lowLatencyMode: false,\n      liveSyncDurationCount: 4,\n      liveMaxLatencyDurationCount: 8,\n      maxLiveSyncPlaybackRate: 1.0,\n      backBufferLength: 20,\n      maxBufferLength: 20,\n      maxMaxBufferLength: 30,\n      enableWorker: true\n    });\n\n    video._hls = hls;\n    video.muted = true;\n    video.controls = true;\n    video.autoplay = true;\n\n    hls.loadSource(url);\n    hls.attachMedia(video);\n\n    hls.on(Hls.Events.MANIFEST_PARSED, function () {\n      video.play().catch(function(e){ console.warn('Video play error:', e); });\n    });\n\n    hls.on(Hls.Events.ERROR, function (event, data) {\n      console.warn('HLS error:', data);\n\n      if (data && data.details === 'bufferStalledError') {\n        try {\n          video.currentTime = Math.max(video.currentTime - 0.5, 0);\n          video.play().catch(function(){});\n        } catch (e) {}\n        return;\n      }\n\n      if (data && data.fatal) {\n        if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {\n          try { hls.startLoad(); } catch (e) {}\n        } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {\n          try { hls.recoverMediaError(); } catch (e) {}\n        } else {\n          try { hls.destroy(); } catch (e) {}\n        }\n      }\n    });\n  } else if (video.canPlayType('application/vnd.apple.mpegurl')) {\n    video.src = url;\n    video.muted = true;\n    video.play().catch(function(){});\n  } else {\n    console.error('HLS is not supported by this browser');\n  }\n}\n\nfunction cleanupVideoElement(el) {\n  if (!el) return;\n  const vids = el.querySelectorAll ? el.querySelectorAll('video') : [];\n  vids.forEach(function(v){ if (v._hls) { try { v._hls.destroy(); } catch(e) {} v._hls = null; } });\n}\n\nfunction clearContent(c) {\n  while (c.contentHost.firstChild) {\n    cleanupVideoElement(c.contentHost.firstChild);\n    c.contentHost.removeChild(c.contentHost.firstChild);\n  }\n  c.currentMode = null;\n  c.liveUrl = null;\n}\n\nfunction buildCard(cam) {\n  const card = document.createElement('div');\n  card.className = 'cam';\n  card.dataset.cam = cam.id;\n\n  const head = document.createElement('div');\n  head.className = 'cam-head';\n  const nameSpan = document.createElement('span');\n  nameSpan.className = 'cam-name';\n  nameSpan.textContent = cam.name || ('Camera ' + cam.id);\n  const timeSpan = document.createElement('span');\n  timeSpan.className = 'cam-time';\n  head.appendChild(nameSpan);\n  head.appendChild(timeSpan);\n\n  const contentHost = document.createElement('div');\n  contentHost.className = 'cam-content';\n  const empty = document.createElement('div');\n  empty.className = 'cam-empty';\n  empty.textContent = 'Loading…';\n  contentHost.appendChild(empty);\n\n  const actions = document.createElement('div');\n  actions.className = 'cam-actions';\n  const liveBtn = document.createElement('button');\n  liveBtn.className = 'live';\n  liveBtn.textContent = cam.liveCapable ? '📡 Live' : '🚫 No LiveView';\n  liveBtn.disabled = !cam.liveCapable;\n  liveBtn.title = cam.liveCapable ? 'Start LiveView' : ('LiveView not available: ' + (cam.liveMissing || []).join(', '));\n  const stopBtn = document.createElement('button');\n  stopBtn.className = 'stop';\n  stopBtn.textContent = '▪ Stop';\n  stopBtn.disabled = true;\n  const liveLabel = document.createElement('span');\n  liveLabel.className = 'live-label';\n  actions.appendChild(liveBtn);\n  actions.appendChild(stopBtn);\n  actions.appendChild(liveLabel);\n\n  const nav = document.createElement('div');\n  nav.className = 'cam-nav';\n  const olderBtn = document.createElement('button');\n  olderBtn.textContent = '◀';\n  olderBtn.title = 'Older clip';\n  const label = document.createElement('div');\n  label.className = 'label';\n  label.textContent = 'Current';\n  const currentBtn = document.createElement('button');\n  currentBtn.textContent = 'Current';\n  currentBtn.title = 'Show current clip';\n  const newerBtn = document.createElement('button');\n  newerBtn.textContent = '▶';\n  newerBtn.title = 'Newer clip';\n  nav.appendChild(olderBtn);\n  nav.appendChild(label);\n  nav.appendChild(currentBtn);\n  nav.appendChild(newerBtn);\n\n  card.appendChild(head);\n  card.appendChild(contentHost);\n  card.appendChild(actions);\n  card.appendChild(nav);\n  $grid.appendChild(card);\n\n  cards[cam.id] = {\n    value: null, ts: null, ready: null, error: null,\n    hist: new Array(HISTORY_SIZE).fill(null).map(function(){ return {}; }),\n    pickIdx: null,\n    root: card, contentHost: contentHost, timeEl: timeSpan,\n    olderBtn: olderBtn, newerBtn: newerBtn, currentBtn: currentBtn, label: label,\n    liveBtn: liveBtn, stopBtn: stopBtn, liveLabel: liveLabel,\n    datapoint: cam.datapoint,\n    ts_datapoint: cam.ts_datapoint,\n    ready_datapoint: cam.ready_datapoint,\n    error_datapoint: cam.error_datapoint,\n    history: cam.history,\n    name: cam.name || ('Camera ' + cam.id),\n    liveCapable: !!cam.liveCapable,\n    liveMissing: cam.liveMissing || [],\n    currentMode: null,\n    liveUrl: null\n  };\n\n  olderBtn.addEventListener('click', function(){ navigate(cam.id, +1); });\n  newerBtn.addEventListener('click', function(){ navigate(cam.id, -1); });\n  currentBtn.addEventListener('click', function(){ cards[cam.id].pickIdx = null; renderCard(cam.id); });\n  liveBtn.addEventListener('click', function(){ startLive(cam.id); });\n  stopBtn.addEventListener('click', function(){ stopLive(); });\n  updateLiveButtons();\n}\n\nfunction updateLiveButtons() {\n  Object.keys(cards).forEach(function(id) {\n    const c = cards[id];\n    const isStarting = window.__blinkLiveStarting && String(window.__blinkLiveStarting) === String(id);\n    const isActive = window.__blinkLiveState && window.__blinkLiveState.running &&\n      String(window.__blinkLiveState.camera_id) === String(id);\n\n    if (c.liveBtn) {\n      c.liveBtn.disabled = !c.liveCapable || isStarting || isActive;\n      if (!c.liveCapable) {\n        c.liveBtn.textContent = '🚫 No LiveView';\n      } else {\n        c.liveBtn.textContent = isStarting ? '⏳ Starting…' : (isActive ? '📡 Live running' : '📡 Live');\n      }\n      c.liveBtn.title = !c.liveCapable\n        ? ('LiveView not available: ' + (c.liveMissing || []).join(', '))\n        : (isActive ? 'LiveView is already running' : 'Start LiveView');\n    }\n\n    if (c.stopBtn) {\n      c.stopBtn.disabled = !(isStarting || isActive);\n      c.stopBtn.textContent = isStarting ? '✕ Cancel' : '▪ Stop';\n      c.stopBtn.title = isStarting ? 'Cancel start/stop LiveView' : (isActive ? 'Stop LiveView' : 'No LiveView active');\n    }\n\n    if (c.liveLabel && isStarting) {\n      c.liveLabel.textContent = 'Starting LiveView…';\n    }\n  });\n}\n\nfunction navigate(camId, delta) {\n  const c = cards[camId];\n  if (!c) return;\n  let next = c.pickIdx == null ? 0 : c.pickIdx + delta;\n  if (next < 0) { c.pickIdx = null; renderCard(camId); return; }\n  if (next >= HISTORY_SIZE) next = HISTORY_SIZE - 1;\n  while (next >= 0 && next < HISTORY_SIZE && !c.hist[next].file) next += delta;\n  if (next >= 0 && next < HISTORY_SIZE) c.pickIdx = next;\n  else c.pickIdx = null;\n  renderCard(camId);\n}\n\nfunction setEmpty(c, text, errMsg) {\n  clearContent(c);\n  const empty = document.createElement('div');\n  empty.className = 'cam-empty';\n  const main = document.createElement('div');\n  main.textContent = text;\n  empty.appendChild(main);\n  if (errMsg) {\n    const sub = document.createElement('div');\n    sub.className = 'err-msg';\n    sub.textContent = '⚠ ' + errMsg;\n    empty.appendChild(sub);\n  }\n  c.contentHost.appendChild(empty);\n}\n\nfunction renderLiveCard(camId, hlsUrl) {\n  const c = cards[camId];\n  if (!c) return;\n\n  c.liveLabel.textContent = 'LIVE: ' + c.name + ' (' + camId + ')';\n  if (c.timeEl) c.timeEl.textContent = 'LIVE';\n  c.label.textContent = 'LIVE · ' + c.name;\n\n  const iframeSrc = '/live-player?camera=' + encodeURIComponent(camId) + '&t=' + Date.now();\n\n  if (c.currentMode === 'live' && c.liveUrl === hlsUrl && c.contentHost.querySelector('iframe')) {\n    return;\n  }\n\n  clearContent(c);\n  c.currentMode = 'live';\n  c.liveUrl = hlsUrl;\n\n  const wrap = document.createElement('div');\n  wrap.className = 'cam-video-wrap playing live-playing';\n\n  const iframe = document.createElement('iframe');\n  iframe.src = iframeSrc;\n  iframe.style.width = '100%';\n  iframe.style.height = '100%';\n  iframe.style.border = '0';\n  iframe.style.display = 'block';\n  iframe.allow = 'autoplay; fullscreen';\n  iframe.allowFullscreen = true;\n  iframe.title = 'Blink LiveView ' + c.name;\n\n  wrap.appendChild(iframe);\n  c.contentHost.appendChild(wrap);\n  updateLiveButtons();\n}\n\nfunction renderCard(camId) {\n  const c = cards[camId];\n  if (!c) return;\n\n  if (window.__blinkLiveState && window.__blinkLiveState.running &&\n      String(window.__blinkLiveState.camera_id) === String(camId) &&\n      window.__blinkLiveState.hls_url) {\n    renderLiveCard(camId, window.__blinkLiveState.hls_url);\n    updateNav(c);\n    return;\n  }\n\n  if (window.__blinkLiveStarting && String(window.__blinkLiveStarting) === String(camId)) {\n    c.liveLabel.textContent = 'Starting LiveView…';\n  } else {\n    c.liveLabel.textContent = '';\n  }\n  if (c.timeEl) c.timeEl.textContent = c.ts ? relativeTime(c.ts) : '';\n\n  let showFile, showTs, showSource, slotLabel;\n  if (c.pickIdx !== null && c.hist[c.pickIdx] && c.hist[c.pickIdx].file) {\n    const h = c.hist[c.pickIdx];\n    showFile = h.file;\n    showTs = h.timestamp;\n    showSource = h.source;\n    slotLabel = '#' + c.pickIdx;\n  } else if (isVideoValid(c.value, c.ready, c.error)) {\n    showFile = c.value;\n    showTs = c.ts;\n    showSource = null;\n    slotLabel = null;\n    c.pickIdx = null;\n  } else {\n    const errText = c.error && String(c.error).trim() && String(c.error).toLowerCase() !== 'null' ? String(c.error) : null;\n    setEmpty(c, 'No current video', errText);\n    updateNav(c);\n    return;\n  }\n\n  const url = buildUrl(showFile, showTs);\n  if (!url) { setEmpty(c, 'No video'); updateNav(c); return; }\n\n  if (c.currentMode === 'clip' && c.clipUrl === url) {\n    updateNav(c);\n    return;\n  }\n\n  clearContent(c);\n  c.currentMode = 'clip';\n  c.clipUrl = url;\n\n  const wrap = document.createElement('div');\n  wrap.className = 'cam-video-wrap';\n  wrap.dataset.cam = camId;\n\n  const video = document.createElement('video');\n  video.preload = 'metadata';\n  video.muted = true;\n  video.playsInline = true;\n  video.dataset.cam = camId;\n\n  const source = document.createElement('source');\n  source.setAttribute('src', url);\n  source.setAttribute('type', 'video/mp4');\n  video.appendChild(source);\n\n  const overlay = document.createElement('div');\n  overlay.className = 'overlay';\n  const playBtn = document.createElement('div');\n  playBtn.className = 'play-btn';\n  overlay.appendChild(playBtn);\n\n  wrap.appendChild(video);\n  wrap.appendChild(overlay);\n\n  if (slotLabel) {\n    const badge = document.createElement('div');\n    badge.className = 'slot-badge';\n    badge.textContent = slotLabel;\n    wrap.appendChild(badge);\n  }\n\n  wrap.addEventListener('click', function() {\n    if (video.paused) {\n      video.controls = true;\n      wrap.classList.add('playing');\n      video.muted = false;\n      video.play().catch(function(){ video.muted = true; video.play().catch(function(){}); });\n    }\n  });\n  video.addEventListener('ended', function(){ wrap.classList.remove('playing'); video.controls = false; });\n  video.addEventListener('pause', function(){ if (video.ended) wrap.classList.remove('playing'); });\n\n  c.contentHost.appendChild(wrap);\n  video.load();\n\n  let lbl = (slotLabel ? (slotLabel + ' · ') : 'Current · ') + (formatTs(showTs).replace(/, \\\\d{4}/, '') || '—');\n  if (showSource) lbl += '<span class=\"source ' + showSource + '\">' + showSource + '</span>';\n  c.label.innerHTML = lbl;\n\n  updateNav(c);\n}\n\nfunction updateNav(c) {\n  const nextIdx = c.pickIdx == null ? 0 : c.pickIdx + 1;\n  c.olderBtn.disabled = !c.hist.slice(nextIdx).some(function(h){ return h && h.file; });\n  c.newerBtn.disabled = c.pickIdx === null;\n  c.currentBtn.disabled = c.pickIdx === null;\n}\n\nfunction renderAllCards() {\n  Object.keys(cards).forEach(function(id){ renderCard(id); });\n}\n\nfunction updateAllTimes() {\n  Object.keys(cards).forEach(function(id) {\n    const c = cards[id];\n    if (window.__blinkLiveState.running && String(window.__blinkLiveState.camera_id) === String(id)) {\n      if (c.timeEl) c.timeEl.textContent = 'LIVE';\n    } else if (c.timeEl) {\n      c.timeEl.textContent = c.ts ? relativeTime(c.ts) : '';\n    }\n  });\n}\nsetInterval(updateAllTimes, 30000);\n\nasync function refreshLiveState() {\n  try {\n    const r = await fetch('/live/last-session?t=' + Date.now(), { cache: 'no-store' });\n    const j = await r.json();\n    const st = j.status || {};\n    const oldCam = window.__blinkLiveState.camera_id;\n    window.__blinkLiveState = {\n      // Only show LIVE when the HLS playlist really exists.\n      // Otherwise the grid renders the player during startup and the browser\n      // shows \"Playlist not available: HTTP 404\".\n      running: !!(st.running && st.hls_url && st.playlist),\n      camera_id: st.camera_id || null,\n      hls_url: st.hls_url || null\n    };\n    if (oldCam && String(oldCam) !== String(window.__blinkLiveState.camera_id || '')) renderCard(oldCam);\n    if (window.__blinkLiveState.camera_id) renderCard(window.__blinkLiveState.camera_id);\n    updateLiveButtons();\n  } catch (e) {}\n}\nsetInterval(refreshLiveState, 5000);\n\nasync function startLive(camId) {\n  const c = cards[camId];\n  if (!c || !c.liveCapable) return;\n\n  // Only one start operation at a time. Double-clicks or multiple fast clicks\n  // previously triggered multiple /live/start requests and caused the button state\n  // to become inconsistent afterwards.\n  if (window.__blinkLiveStarting) return;\n\n  const seq = ++window.__blinkLiveStartSeq;\n  window.__blinkLiveStarting = String(camId);\n  c.liveLabel.textContent = 'Starting LiveView…';\n  updateLiveButtons();\n\n  try {\n    const r = await fetch('/live/start?camera=' + encodeURIComponent(camId) + '&t=' + Date.now(), { cache: 'no-store' });\n    const text = await r.text();\n\n    // If Stop was clicked during startup, ignore this stale response.\n    if (seq !== window.__blinkLiveStartSeq) return;\n\n    let data;\n    try { data = JSON.parse(text); } catch (e) { throw new Error(text); }\n    if (!data.ok) throw new Error(data.error || 'Start failed');\n\n    const previous = window.__blinkLiveState.camera_id;\n    window.__blinkLiveState = { running:true, camera_id:String(data.camera_id), hls_url:data.hls_url };\n    if (previous && String(previous) !== String(data.camera_id)) renderCard(previous);\n    renderCard(data.camera_id);\n  } catch (e) {\n    if (seq === window.__blinkLiveStartSeq) {\n      c.liveLabel.textContent = '';\n      setEmpty(c, 'LiveView start error', e.message || String(e));\n    }\n  } finally {\n    if (seq === window.__blinkLiveStartSeq) {\n      window.__blinkLiveStarting = null;\n      updateLiveButtons();\n    }\n  }\n}\n\nasync function stopLive() {\n  const old = window.__blinkLiveState.camera_id;\n  const starting = window.__blinkLiveStarting;\n\n  // Invalidate the running frontend start operation so a delayed\n  // /live/start response does not overwrite the stop action again.\n  window.__blinkLiveStartSeq++;\n  window.__blinkLiveStarting = null;\n  updateLiveButtons();\n\n  try { await fetch('/live/stop?t=' + Date.now(), { cache: 'no-store' }); } catch (e) {}\n  window.__blinkLiveState = { running:false, camera_id:null, hls_url:null };\n\n  if (old) renderCard(old);\n  if (starting && String(starting) !== String(old || '')) renderCard(starting);\n  renderAllCards();\n  updateLiveButtons();\n}\n\nfetch('/cameras?t=' + Date.now(), { cache: 'no-store' }).then(function(r){ return r.json(); }).then(function(list) {\n  cameras = list;\n  if (!list.length) {\n    setStatus('No cameras', 'err');\n    $grid.innerHTML = '<div style=\"padding:40px;text-align:center;color:#777\">No cameras found</div>';\n    return;\n  }\n  list.forEach(buildCard);\n  updateLiveButtons();\n\n  if (typeof io === 'undefined') { setStatus('Socket.IO library is missing', 'err'); return; }\n  socket = io(IOBROKER_URL, { transports: ['websocket', 'polling'] });\n  socket.on('connect', function() {\n    setStatus('Connected', 'ok');\n    list.forEach(function(cam) {\n      let pending = 4 + 4 * HISTORY_SIZE;\n      const done = function(){ if (--pending === 0) renderCard(cam.id); };\n\n      socket.emit('getState', cam.ts_datapoint, function(e, st){ if (st && st.val) cards[cam.id].ts = st.val; done(); });\n      socket.emit('getState', cam.ready_datapoint, function(e, st){ if (st) cards[cam.id].ready = st.val; done(); });\n      socket.emit('getState', cam.error_datapoint, function(e, st){ if (st) cards[cam.id].error = st.val; done(); });\n      socket.emit('getState', cam.datapoint, function(e, st){ if (st) cards[cam.id].value = st.val; done(); });\n\n      socket.emit('subscribe', cam.datapoint);\n      socket.emit('subscribe', cam.ts_datapoint);\n      socket.emit('subscribe', cam.ready_datapoint);\n      socket.emit('subscribe', cam.error_datapoint);\n      if (cam.unsupported_datapoint) {\n        socket.emit('subscribe', cam.unsupported_datapoint);\n      }\n\n      cam.history.forEach(function(h, idx) {\n        socket.emit('getState', h.file_datapoint, function(e, st){ cards[cam.id].hist[idx].file = st ? st.val : null; done(); });\n        socket.emit('getState', h.timestamp_datapoint, function(e, st){ cards[cam.id].hist[idx].timestamp = st ? st.val : null; done(); });\n        socket.emit('getState', h.id_datapoint, function(e, st){ cards[cam.id].hist[idx].id = st ? st.val : null; done(); });\n        socket.emit('getState', h.source_datapoint, function(e, st){ cards[cam.id].hist[idx].source = st ? st.val : null; done(); });\n        socket.emit('subscribe', h.file_datapoint);\n        socket.emit('subscribe', h.timestamp_datapoint);\n        socket.emit('subscribe', h.id_datapoint);\n        socket.emit('subscribe', h.source_datapoint);\n      });\n    });\n    refreshLiveState();\n  });\n  socket.on('disconnect', function(){ setStatus('Disconnected', 'err'); });\n  socket.on('connect_error', function(e){ setStatus('Connection error', 'err'); console.error(e); });\n  socket.on('stateChange', function(id, state) {\n    if (!state) return;\n    for (const cam of list) {\n      if (id === cam.datapoint)        { cards[cam.id].value = state.val; cards[cam.id].ts = state.ts || cards[cam.id].ts; renderCard(cam.id); return; }\n      if (id === cam.ts_datapoint)     { if (state.val) cards[cam.id].ts = state.val; renderCard(cam.id); return; }\n      if (id === cam.ready_datapoint)  { cards[cam.id].ready = state.val; renderCard(cam.id); return; }\n      if (id === cam.error_datapoint)  { cards[cam.id].error = state.val; renderCard(cam.id); return; }\n      if (id === cam.unsupported_datapoint) {\n        const UNSUPPORTED_MSG = 'Camera model does not support LiveView (detected by adapter)';\n        const isUnsupported = state.val === true;\n        // Basis-liveMissing aus dem Discovery beibehalten, nur unseren Marker togglen.\n        const baseMissing = (cam.liveMissing || []).filter(function(m){ return m !== UNSUPPORTED_MSG; });\n        if (isUnsupported) {\n          cards[cam.id].liveCapable = false;\n          cards[cam.id].liveMissing = baseMissing.concat([UNSUPPORTED_MSG]);\n        } else {\n          // The adapter cleared the block; return to the original discovery state.\n          cards[cam.id].liveCapable = !!cam.liveCapable;\n          cards[cam.id].liveMissing = baseMissing;\n        }\n        updateLiveButtons();\n        return;\n      }\n      for (let idx = 0; idx < HISTORY_SIZE; idx++) {\n        const h = cam.history[idx];\n        if (id === h.file_datapoint)      { cards[cam.id].hist[idx].file = state.val; renderCard(cam.id); return; }\n        if (id === h.timestamp_datapoint) { cards[cam.id].hist[idx].timestamp = state.val; renderCard(cam.id); return; }\n        if (id === h.id_datapoint)        { cards[cam.id].hist[idx].id = state.val; renderCard(cam.id); return; }\n        if (id === h.source_datapoint)    { cards[cam.id].hist[idx].source = state.val; renderCard(cam.id); return; }\n      }\n    }\n  });\n}).catch(function(e){ setStatus('Server error', 'err'); console.error(e); });\n</script>\n</body>\n</html>`;\n\nfunction buildHTML(template) {\n    return template\n        .replace('__COMMON_JS__',     COMMON_JS)\n        .replace(/__VIDEO_BASE__/g,    VIDEO_BASE)\n        .replace(/__VIDEO_PORT__/g,    PORT)\n        .replace(/__IOBROKER_PORT__/g, IOBROKER_PORT)\n        .replace(/__HISTORY_SIZE__/g,  HISTORY_SIZE);\n}\nconst GRID_PAGE = buildHTML(GRID_HTML);\n\nconst MIME = {\n    '.mp4':'video/mp4','.webm':'video/webm','.mov':'video/quicktime',\n    '.jpg':'image/jpeg','.jpeg':'image/jpeg','.png':'image/png',\n    '.html':'text/html; charset=utf-8','.json':'application/json'\n};\n\nconst server = http.createServer(async (req, res) => {\n    try {\n        res.setHeader('Access-Control-Allow-Origin', '*');\n        res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');\n        if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }\n\n        const parsed = new URL(req.url, 'http://localhost');\n        const urlPath = parsed.pathname;\n\n        if (urlPath === '/cameras') {\n            const cams = await discoverCameras();\n            res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-cache, no-store, must-revalidate' });\n            res.end(JSON.stringify(cams));\n            return;\n        }\n\n        if (urlPath === '/live/debug-cameras' || urlPath === '/debug-cameras') {\n            const cams = await discoverCameras();\n            const out = cams.map(c => ({\n                id: c.id,\n                name: c.name,\n                liveview: c.liveview ? {\n                    id: c.liveview.id,\n                    name: c.liveview.name,\n                    accountId: c.liveview.accountId,\n                    accountSource: c.liveview.accountSource,\n                    accountCandidates: c.liveview.accountCandidates || c.accountCandidates || [],\n                    networkId: c.liveview.networkId,\n                    networkSource: c.liveview.networkSource,\n                    type: c.liveview.type,\n                    hasSerial: !!c.liveview.serial\n                } : null,\n                missing: c.liveMissing || [],\n                warnings: c.liveWarnings || [],\n                accountCandidates: c.accountCandidates || [],\n                syncNetworks: c.syncNetworks || []\n            }));\n            res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-cache, no-store, must-revalidate' });\n            res.end(JSON.stringify(out, null, 2));\n            return;\n        }\n\n        if (urlPath === '/live/start') {\n            const cameraId = parsed.searchParams.get('camera');\n            if (!cameraId) { res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ ok:false, error:'camera is missing' })); return; }\n\n            // Schutz: vom Adapter als \"kein LiveView möglich\" markierte Kameras gleich abweisen.\n            const unsupportedStateId = CAMERA_PREFIX + cameraId + UNSUPPORTED_STATE;\n            if (objectExists(unsupportedStateId)) {\n                try {\n                    const st = await getStateAsync(unsupportedStateId);\n                    if (st && st.val === true) {\n                        log('LiveView for camera ' + cameraId + ' blocked: marked as unsupported by the adapter.', 'warn');\n                        res.writeHead(409, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-cache, no-store, must-revalidate' });\n                        res.end(JSON.stringify({ ok:false, error:'Camera model does not support LiveView (detected by adapter)', unsupported:true }));\n                        return;\n                    }\n                } catch (e) {\n                    // State nicht lesbar – wir fahren normal fort.\n                }\n            }\n\n            try {\n                const out = await startLiveForCamera(cameraId, req);\n                res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-cache, no-store, must-revalidate' });\n                res.end(JSON.stringify(out));\n            } catch (e) {\n                log('LiveView start error for camera ' + cameraId + ': ' + (e.message || e), 'warn');\n                res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-cache, no-store, must-revalidate' });\n                res.end(JSON.stringify({ ok:false, error:e.message || String(e), status:liveStatus }));\n            }\n            return;\n        }\n\n        if (urlPath === '/live/stop') {\n            await stopLiveViewProcess();\n            liveStatus.running = false;\n            liveStatus.pid = null;\n            liveStatus.playlist = fs.existsSync(path.join(HLS_DIR, 'live.m3u8'));\n            res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-cache, no-store, must-revalidate' });\n            res.end(JSON.stringify({ ok:true, status: liveStatus }));\n            return;\n        }\n\n        if (urlPath === '/live/last-session') {\n            liveStatus.playlist = fs.existsSync(path.join(HLS_DIR, 'live.m3u8'));\n            if (!liveStatus.playlist) {\n                // During startup, the status must not yet appear as a running LiveView\n                // in the frontend. Only an existing playlist means the stream is really running.\n                liveStatus.running = false;\n            }\n            let session = null;\n            if (liveStatus.session_file) {\n                const raw = safeJson(liveStatus.session_file);\n                if (raw) {\n                    session = {\n                        device_id: raw.device_id,\n                        device_type: raw.device_type,\n                        command_id: raw.command_id,\n                        state_condition: raw.state_condition,\n                        status_msg: raw.status_msg,\n                        server_present: !!raw.server,\n                        token_present: !!raw.liveview_token\n                    };\n                }\n            }\n            res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-cache, no-store, must-revalidate' });\n            res.end(JSON.stringify({ status: liveStatus, session: session }, null, 2));\n            return;\n        }\n\n        if (urlPath === '/live-player') {\n            const html = String.raw`<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>Blink LiveView</title>\n<script src=\"https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.20/hls.min.js\"></script>\n<style>\nhtml, body { margin:0; padding:0; width:100%; height:100%; background:#000; overflow:hidden; }\nbody { display:flex; align-items:center; justify-content:center; }\nvideo { width:100%; height:100%; background:#000; object-fit:contain; }\n.status { position:absolute; left:8px; bottom:8px; color:#fff; background:rgba(0,0,0,.68); padding:4px 8px; border-radius:6px; font:12px system-ui,-apple-system,sans-serif; max-width:calc(100% - 16px); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; z-index:5; }\n</style>\n</head>\n<body>\n<video id=\"v\" controls autoplay muted playsinline></video>\n<div class=\"status\" id=\"s\">Waiting for LiveView playlist…</div>\n<script>\n(function(){\n  const video = document.getElementById('v');\n  const statusEl = document.getElementById('s');\n  const manifestUrl = '/live-hls/live.m3u8';\n  let hls = null;\n  let manifestAttempts = 0;\n  let started = false;\n\n  function setStatus(t) {\n    statusEl.style.display = '';\n    statusEl.textContent = t;\n  }\n\n  function hideStatusSoon() {\n    setTimeout(function(){ statusEl.style.display = 'none'; }, 2500);\n  }\n\n  async function waitForManifest() {\n    manifestAttempts++;\n    setStatus('Waiting for playlist… ' + manifestAttempts);\n\n    try {\n      const r = await fetch(manifestUrl + '?t=' + Date.now(), { cache: 'no-store' });\n      if (!r.ok) throw new Error('HTTP ' + r.status);\n      const txt = await r.text();\n      if (txt.indexOf('#EXTM3U') < 0) throw new Error('no M3U8');\n      if (txt.indexOf('#EXTINF') < 0 || txt.indexOf('.ts') < 0) throw new Error('no segments yet');\n      startPlayer();\n      return;\n    } catch (e) {\n      if (manifestAttempts < 80) {\n        setTimeout(waitForManifest, 500);\n      } else {\n        setStatus('Playlist not available: ' + (e && e.message ? e.message : e));\n      }\n    }\n  }\n\n  function playVideo() {\n    video.muted = true;\n    video.controls = true;\n    video.play().then(function(){\n      setStatus('LiveView running');\n      hideStatusSoon();\n    }).catch(function(){\n      setStatus('Autoplay blockiert – bitte ins Video klicken');\n    });\n  }\n\n  function destroyHls() {\n    if (hls) {\n      try { hls.destroy(); } catch(e) {}\n      hls = null;\n    }\n  }\n\n  function restartSoon(reason) {\n    setStatus('HLS Neustart: ' + reason);\n    destroyHls();\n    started = false;\n    manifestAttempts = 0;\n    setTimeout(waitForManifest, 1000);\n  }\n\n  function startPlayer() {\n    if (started) return;\n    started = true;\n    const url = manifestUrl + '?t=' + Date.now();\n\n    if (window.Hls && Hls.isSupported()) {\n      hls = new Hls({\n        lowLatencyMode: false,\n        liveSyncDurationCount: 4,\n        liveMaxLatencyDurationCount: 8,\n        maxLiveSyncPlaybackRate: 1.0,\n        backBufferLength: 20,\n        maxBufferLength: 20,\n        maxMaxBufferLength: 30,\n        enableWorker: true,\n        startFragPrefetch: true,\n        manifestLoadingTimeOut: 10000,\n        manifestLoadingMaxRetry: 20,\n        manifestLoadingRetryDelay: 500,\n        manifestLoadingMaxRetryTimeout: 5000,\n        levelLoadingMaxRetry: 20,\n        levelLoadingRetryDelay: 500,\n        fragLoadingMaxRetry: 12,\n        fragLoadingRetryDelay: 500\n      });\n\n      hls.on(Hls.Events.MEDIA_ATTACHED, function () {\n        setStatus('Player connected, loading manifest…');\n        hls.loadSource(url);\n      });\n\n      hls.on(Hls.Events.MANIFEST_PARSED, function () {\n        setStatus('Manifest loaded');\n        playVideo();\n      });\n\n      hls.on(Hls.Events.ERROR, function (event, data) {\n        console.warn('HLS error:', data);\n        const details = data && data.details ? data.details : 'Error';\n        setStatus('HLS: ' + details);\n\n        if (details === 'bufferStalledError') {\n          try { hls.startLoad(-1); } catch(e) {}\n          try { video.play().catch(function(){}); } catch(e) {}\n          return;\n        }\n\n        if (details === 'manifestLoadError' || details === 'manifestLoadTimeOut') {\n          if (data && data.fatal) restartSoon(details);\n          return;\n        }\n\n        if (data && data.fatal) {\n          if (data.type === Hls.ErrorTypes.NETWORK_ERROR) {\n            try { hls.startLoad(); } catch(e) { restartSoon(details); }\n          } else if (data.type === Hls.ErrorTypes.MEDIA_ERROR) {\n            try { hls.recoverMediaError(); } catch(e) { restartSoon(details); }\n          } else {\n            restartSoon(details);\n          }\n        }\n      });\n\n      hls.attachMedia(video);\n    } else if (video.canPlayType('application/vnd.apple.mpegurl')) {\n      video.src = url;\n      playVideo();\n    } else {\n      setStatus('HLS is not supported');\n    }\n  }\n\n  video.addEventListener('click', function(){ video.play().catch(function(){}); });\n  video.addEventListener('playing', function(){ setStatus('LiveView running'); hideStatusSoon(); });\n  video.addEventListener('waiting', function(){ setStatus('Buffering…'); });\n  video.addEventListener('error', function(){ setStatus('Video-Error'); });\n\n  waitForManifest();\n})();\n</script>\n</body>\n</html>`;\n            res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache, no-store, must-revalidate' });\n            res.end(html);\n            return;\n        }\n\n        if (urlPath === '/grid' || urlPath === '/grid.html' || urlPath === '/' || urlPath === '/index.html') {\n            res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache, no-store, must-revalidate' });\n            res.end(GRID_PAGE);\n            return;\n        }\n\n        if (urlPath.startsWith('/live-hls/')) {\n            const filename = decodeURIComponent(urlPath.slice('/live-hls/'.length));\n            if (filename.includes('..') || filename.includes('/') || filename.includes('\\\\')) { res.writeHead(403); res.end('Forbidden'); return; }\n            const ext = path.extname(filename).toLowerCase();\n            const type = ext === '.m3u8' ? 'application/vnd.apple.mpegurl' : (ext === '.ts' ? 'video/mp2t' : 'application/octet-stream');\n            serveFile(req, res, path.join(HLS_DIR, filename), type, true);\n            return;\n        }\n\n        if (urlPath.startsWith(VIDEO_BASE)) {\n            const filename = decodeURIComponent(urlPath.slice(VIDEO_BASE.length));\n            if (filename.includes('..') || filename.includes('/') || filename.includes('\\\\')) { res.writeHead(403); res.end('Forbidden'); return; }\n            const fullPath = path.join(ROOT_DIR, filename);\n            if (!fullPath.startsWith(ROOT_DIR)) { res.writeHead(403); res.end('Forbidden'); return; }\n            const ext = path.extname(filename).toLowerCase();\n            serveFile(req, res, fullPath, MIME[ext] || 'application/octet-stream', ext === '.mp4');\n            return;\n        }\n\n        res.writeHead(404); res.end('Not Found');\n    } catch (e) {\n        log('Server-Request Error: ' + (e.stack || e.message || e), 'error');\n        try { res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Server error: ' + (e.message || e)); } catch (ignore) {}\n    }\n});\n\nserver.listen(PORT, () => {\n    log(`Blink server running: http://<host>:${PORT}/grid`);\n});\nserver.on('error', (err) => log(`Blink-Server Error: ${err.message}`, 'error'));\n\nglobalThis.__blinkServer = server;\n\nonStop(() => {\n    if (server) { server.close(); log('Blink server stopped'); }\n    try { stopLiveViewProcess(); } catch (e) {}\n}, 2000);\n",
    "debug": false,
    "verbose": false
  },
  "native": {
    "scriptVersion": "0.0.19-review-fixes-1"
  }
}
