screentinker/server/routes/status.js
ScreenTinker 59489b3b20 #240: stop the morning wave buying itself a blocking checkpoint
Bold reported loop lag that grew with uptime and reset on restart. The
signature they saw — mean = p50 = p99 = max, identical to two decimals —
is not a fixed cost paid on every cycle. It is what an IntervalHistogram
window reports when it recorded exactly ONE delay: the mean is the raw
value, and every percentile returns the bucket ceiling above it.
Reproduced against their exact numbers (1329.07 / 1329.59). So the loop
took one long turn that swallowed the sampling second, episodically —
which is what they later confirmed independently.

The turn is ours, and it is now measured rather than theorised. Probing
the real worker against a real WAL with one reader mid-transaction: a
single main-thread write blocked for 4,936ms behind the worker's
wal_checkpoint(TRUNCATE), which then reported WAL 8.8MB -> 8.8MB. TRUNCATE
is the blocking form and its locks are held ACROSS connections, so moving
it to a worker kept the fsync off the loop but not the lock; and it does
not throw when it cannot get those locks, it returns busy=1 having sat on
SQLite's 5s busy timeout and reclaimed nothing. Five seconds of stalled
loop for zero benefit, and silent.

It was reached far too easily. The rule was "escalate if the WAL grew
across three consecutive 15s runs" — which any sustained 45-second write
burst satisfies. A customer's fleet powering on in the morning does it
daily.

Two gates, because either alone leaves the hole open. A size FLOOR, so a
WAL in the lower half of its budget can't buy a blocking checkpoint it has
nothing to reclaim from. And a COOLDOWN, because the floor alone fixes
nothing for Bold — their WAL already sits at 6.2MB against a 16MB
high-water, above any sane floor, so every burst would still escalate.
However long the pressure lasts, our own maintenance may now stall the
loop at most once per window. The high-water rule bypasses both and is
untouched: a runaway WAL is the one case worth blocking for, so the
"WAL cannot grow forever" invariant is exactly as strong as before.

A busy TRUNCATE now says so in the log instead of reading like a success.

Also softened the adjacent path: when the worker is declared
unrecoverable, engageFallback() re-arms inline autocheckpoint on the main
connection — a state that is STICKY for the life of the process, i.e.
exactly the shape of "degrades with uptime, a restart fixes it". It used
to also run an unconditional main-thread TRUNCATE on the way in; that now
happens only when the WAL is genuinely over high-water, and the fallback
state is served on /api/status rather than being inferable only from a log
line that may have rolled.

Telemetry, so the next report is self-explanatory: loop_lag carries
`samples` (~50 when healthy, 1 when a single turn swallowed the second),
`tick_gap_ms` measured on the WALL CLOCK independently of the histogram,
and `worst_tick_gap_ms`/`worst_tick_at` — monotone, so five-minute polling
can no longer miss an episode.

Band semantics are deliberately unchanged. A one-sample window during a
real stall is the correct trigger for the shed valve; suppressing it would
blind the protection at exactly the moment it is needed.

Separately, device_telemetry gets the age sweep it never had. The
per-heartbeat row cap only ever trims the device whose heartbeat is being
handled, so a device that STOPS reporting leaves its rows behind forever.
The new sweep is per-device (rides idx_telemetry_device rather than
scanning), chunked and yielding like the device_status_log one, and
defaults to 30 days to match the uptime report's own default window — so
it cannot remove rows that report would have shown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014skWYXJUWhF73EvNPgB2AS
2026-08-06 20:22:21 -05:00

593 lines
31 KiB
JavaScript

const express = require('express');
const router = express.Router();
const { db } = require('../db/database');
const os = require('os');
const path = require('path');
const fs = require('fs');
const config = require('../config');
const { sixDigitCode } = require('../lib/numeric-code');
const VERSION = require('../version');
const { PLATFORM_ROLES, resolveSessionUser } = require('../middleware/auth');
const { INLINE_SAFE_EXTS } = require('../lib/upload-sniff');
const loopLag = require('../services/loop-lag');
// #146 P3.8: soak observability — internal limiter/maintenance states.
const flapLimiter = require('../lib/flap-limiter');
const otaBreaker = require('../lib/ota-breaker');
const otaDownloadGuard = require('../lib/ota-download-guard');
const logCoalescer = require('../lib/log-coalescer');
const { getMaintenanceStats } = require('../db/database');
const { getCheckpointerState } = require('../db/wal-checkpointer'); // #240
const heartbeat = require('../services/heartbeat');
const appSettings = require('../lib/app-settings');
// Public status page
router.get('/', (req, res) => {
const uptime = process.uptime();
const version = VERSION;
const body = {
status: 'ok',
version,
uptime_human: formatUptime(uptime),
timestamp: new Date().toISOString(),
// #142: current event-loop lag snapshot, so site lag is diagnosable from the
// health endpoint independent of any throttling. Cheap (in-memory read).
loop_lag: loopLag.getLag(),
// #146: ALWAYS-ON live-fleet gauge — devices with a live WS socket THIS INSTANT
// (from the heartbeat connection map), NOT devices.status='online' (which lags by
// the offline-timeout). The single most-glanced operational number; never gated.
devices_connected: heartbeat.getConnectedCount(),
};
// #146: the debug block is admin-toggleable (app_settings.status_debug_enabled),
// defaulting to the STATUS_DEBUG_ENABLED env behavior. Cheap cached boolean. When off,
// the `debug` key is omitted entirely. Aggregate counts only (no ids/secrets).
if (appSettings.getBool('status_debug_enabled', config.statusDebugEnabled)) {
body.debug = {
flap: flapLimiter.stats(), // buckets, quarantined, refused{Total,LastWindow}, quarantineStarts{Total,LastWindow}
ota_breaker: otaBreaker.stats(), // rateBackoff{Total,LastWindow}
ota_download: otaDownloadGuard.stats(), // inFlight, served/shed ThisWindow + Total
maintenance: getMaintenanceStats(), // deleted, ms, at, running, sweepsTotal
wal_checkpoint: getCheckpointerState(), // #240 worker alive?, sticky fallback?, respawns, WAL bytes
log_coalescer_buffer: logCoalescer._size(),
};
}
res.json(body);
});
function formatUptime(seconds) {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
// These three routes take the session token from the query string / Authorization header
// and resolve it themselves rather than sitting behind requireAuth. resolveSessionUser is
// the SAME resolver requireAuth uses, so they inherit every check it makes (pre-TOTP
// refusal, live user row, forced password change). Each keeps the exact status/body it
// returned before for the invalid-token case.
function denySession(res, err) {
if (err && err.code === 'mfa_required') return res.status(401).json({ error: 'mfa_required' });
if (err && err.code === 'password_change_required') return res.status(403).json({ error: 'password_change_required' });
return res.status(401).json({ error: 'Invalid token' });
}
// Full database backup (superadmin only)
router.get('/backup', (req, res) => {
const token = req.query.token;
if (!token) return res.status(401).json({ error: 'Token required' });
let session;
try {
session = resolveSessionUser(token);
} catch (err) {
// An unknown user id stays indistinguishable from "not a platform admin" (as before),
// so this endpoint never confirms whether a given id exists.
if (err.code === 'user_not_found') return res.status(403).json({ error: 'Platform admin only' });
return denySession(res, err);
}
// A break-glass identity has no users row, so it could never pass the role check here
// before; keep it that way rather than letting the synthetic role claim decide.
if (session.viaRecovery || !PLATFORM_ROLES.includes(session.user.role)) {
return res.status(403).json({ error: 'Platform admin only' });
}
const dbPath = require('../config').dbPath;
res.download(dbPath, `remotedisplay-backup-${new Date().toISOString().split('T')[0]}.db`);
});
// User data export (own data only)
router.get('/export', (req, res) => {
const token = req.query.token;
if (!token) return res.status(401).json({ error: 'Token required' });
let userId;
let workspaceId;
try {
const session = resolveSessionUser(token);
// For a break-glass identity this is the synthetic recovery id, which has no users
// row - the lookup below then 404s exactly as the inline verify did before.
userId = session.user.id;
workspaceId = session.decoded.current_workspace_id || null;
if (!userId) return res.status(401).json({ error: 'Invalid token' });
} catch (err) {
if (err.code === 'user_not_found') return res.status(404).json({ error: 'User not found' });
return denySession(res, err);
}
// Re-read with the export's own column list (it needs created_at, which the session
// resolver doesn't select).
const user = db.prepare('SELECT id, email, name, role, auth_provider, plan_id, created_at FROM users WHERE id = ?').get(userId);
if (!user) return res.status(404).json({ error: 'User not found' });
// Phase 2.2f: export workspace-scoped branding. Fall back to first-accessible
// workspace if the JWT didn't carry one.
if (!workspaceId) {
const w = db.prepare(`
SELECT w.id FROM workspaces w
JOIN workspace_members wm ON wm.workspace_id = w.id
WHERE wm.user_id = ? ORDER BY wm.joined_at ASC LIMIT 1
`).get(userId);
workspaceId = w?.id || null;
}
const devices = db.prepare('SELECT id, name, status, ip_address, android_version, app_version, screen_width, screen_height, created_at FROM devices WHERE user_id = ?').all(userId);
const deviceIds = devices.map(d => d.id);
const devicePlaceholders = deviceIds.map(() => '?').join(',') || "'__none__'";
const content = db.prepare('SELECT id, filename, mime_type, file_size, duration_sec, remote_url, width, height, created_at FROM content WHERE user_id = ?').all(userId);
const widgets = db.prepare('SELECT id, widget_type, name, config, created_at FROM widgets WHERE user_id = ?').all(userId);
const layouts = db.prepare('SELECT id, name, width, height, is_template, template_category, created_at FROM layouts WHERE user_id = ? AND is_template = 0').all(userId);
const layoutIds = layouts.map(l => l.id);
const layoutPlaceholders = layoutIds.map(() => '?').join(',') || "'__none__'";
const layoutZones = layoutIds.length ? db.prepare(`SELECT * FROM layout_zones WHERE layout_id IN (${layoutPlaceholders})`).all(...layoutIds) : [];
const playlists = db.prepare('SELECT id, name, description, is_auto_generated, created_at, updated_at FROM playlists WHERE user_id = ?').all(userId);
const playlistIds = playlists.map(p => p.id);
const playlistPlaceholders = playlistIds.map(() => '?').join(',') || "'__none__'";
const playlistItems = playlistIds.length ? db.prepare(`SELECT id, playlist_id, content_id, widget_id, sort_order, duration_sec FROM playlist_items WHERE playlist_id IN (${playlistPlaceholders})`).all(...playlistIds) : [];
const schedules = db.prepare('SELECT id, device_id, group_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time, timezone, recurrence, recurrence_end, priority, enabled, color, created_at FROM schedules WHERE user_id = ?').all(userId);
const videoWalls = db.prepare('SELECT * FROM video_walls WHERE user_id = ?').all(userId);
const wallIds = videoWalls.map(w => w.id);
const wallPlaceholders = wallIds.map(() => '?').join(',') || "'__none__'";
const wallDevices = wallIds.length ? db.prepare(`SELECT * FROM video_wall_devices WHERE wall_id IN (${wallPlaceholders})`).all(...wallIds) : [];
const kioskPages = db.prepare('SELECT id, name, config, created_at FROM kiosk_pages WHERE user_id = ?').all(userId);
const deviceGroups = db.prepare('SELECT id, name, color, created_at FROM device_groups WHERE user_id = ?').all(userId);
const groupIds = deviceGroups.map(g => g.id);
const groupPlaceholders = groupIds.map(() => '?').join(',') || "'__none__'";
const groupMembers = groupIds.length ? db.prepare(`SELECT * FROM device_group_members WHERE group_id IN (${groupPlaceholders})`).all(...groupIds) : [];
const alertConfigs = db.prepare('SELECT id, alert_type, enabled, config, created_at FROM alert_configs WHERE user_id = ?').all(userId);
const whiteLabel = workspaceId ? db.prepare('SELECT * FROM white_labels WHERE workspace_id = ?').get(workspaceId) : null;
const exportData = {
format: 'screentinker-export-v2',
exported_at: new Date().toISOString(),
user,
devices: devices.map(d => {
const dev = db.prepare('SELECT playlist_id FROM devices WHERE id = ?').get(d.id);
return { ...d, playlist_id: dev?.playlist_id || null };
}),
content,
widgets: widgets.map(w => ({ ...w, config: JSON.parse(w.config || '{}') })),
layouts,
layout_zones: layoutZones,
playlists,
playlist_items: playlistItems,
schedules,
video_walls: videoWalls,
video_wall_devices: wallDevices,
kiosk_pages: kioskPages.map(k => ({ ...k, config: JSON.parse(k.config || '{}') })),
device_groups: deviceGroups,
device_group_members: groupMembers,
alert_configs: alertConfigs.map(a => ({ ...a, config: JSON.parse(a.config || '{}') })),
white_label: whiteLabel || null,
};
// If include_files requested, bundle as ZIP with content files
if (req.query.include_files === 'true') {
const archiver = require('archiver');
const dateStr = new Date().toISOString().split('T')[0];
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename=screentinker-export-${dateStr}.zip`);
const archive = archiver('zip', { zlib: { level: 5 } });
archive.pipe(res);
// Collect file info and add files to archive
const filesToInclude = [];
for (const c of exportData.content) {
if (c.remote_url || !c.filename) continue;
const row = db.prepare('SELECT filepath, thumbnail_path FROM content WHERE id = ?').get(c.id);
if (row?.filepath) {
const filePath = path.join(config.contentDir, path.basename(row.filepath));
if (fs.existsSync(filePath)) {
c.original_filepath = path.basename(row.filepath);
archive.file(filePath, { name: `files/${c.id}/${c.original_filepath}` });
}
}
if (row?.thumbnail_path) {
const thumbPath = path.join(config.contentDir, path.basename(row.thumbnail_path));
if (fs.existsSync(thumbPath)) {
c.original_thumbnail = path.basename(row.thumbnail_path);
archive.file(thumbPath, { name: `files/${c.id}/${c.original_thumbnail}` });
}
}
}
// Add JSON manifest (after filepath fields are populated)
archive.append(JSON.stringify(exportData, null, 2), { name: 'export.json' });
archive.finalize();
return;
}
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename=screentinker-export-${new Date().toISOString().split('T')[0]}.json`);
res.json(exportData);
});
// User data import (JSON or ZIP with files)
const multer = require('multer');
const importUpload = multer({ dest: path.join(os.tmpdir(), 'screentinker-import'), limits: { fileSize: 2 * 1024 * 1024 * 1024 } }); // 2GB max
router.post('/import', importUpload.single('file'), async (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) return res.status(401).json({ error: 'Token required' });
let userId;
let workspaceId;
try {
const session = resolveSessionUser(authHeader.split(' ')[1]);
// A break-glass identity has no users row: the lookup this replaced returned nothing
// for it, so the route 404'd. Preserve that.
if (session.viaRecovery) return res.status(404).json({ error: 'User not found' });
userId = session.user.id;
workspaceId = session.decoded.current_workspace_id || null;
if (!userId) return res.status(401).json({ error: 'Invalid token' });
} catch (err) {
if (err.code === 'user_not_found') return res.status(404).json({ error: 'User not found' });
return denySession(res, err);
}
// Phase 2.2b: imports stamp workspace_id on devices and content so the
// rows are visible to the workspace-filtered list endpoints. Fall back to
// the importer's first accessible workspace if the JWT didn't carry one.
if (!workspaceId) {
const w = db.prepare(`
SELECT w.id FROM workspaces w
JOIN workspace_members wm ON wm.workspace_id = w.id
WHERE wm.user_id = ? ORDER BY wm.joined_at ASC LIMIT 1
`).get(userId);
workspaceId = w?.id || null;
}
if (!workspaceId) return res.status(403).json({ error: 'No workspace context for import. Switch to a workspace first.' });
let data;
let extractedFiles = {}; // Map of old content ID -> { filepath, thumbnail }
if (req.file) {
// ZIP upload — extract export.json and files/
try {
const unzipper = require('unzipper');
const extractDir = path.join(os.tmpdir(), `screentinker-import-${Date.now()}`);
fs.mkdirSync(extractDir, { recursive: true });
await new Promise((resolve, reject) => {
fs.createReadStream(req.file.path)
.pipe(unzipper.Extract({ path: extractDir }))
.on('close', resolve)
.on('error', reject);
});
// Read the JSON manifest
const jsonPath = path.join(extractDir, 'export.json');
if (!fs.existsSync(jsonPath)) {
fs.unlinkSync(req.file.path);
return res.status(400).json({ error: 'ZIP does not contain export.json' });
}
data = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
// Map extracted files by content ID, with path traversal validation
const filesDir = path.join(extractDir, 'files');
const resolvedExtractDir = path.resolve(extractDir);
if (fs.existsSync(filesDir)) {
for (const contentDir of fs.readdirSync(filesDir)) {
const contentPath = path.resolve(filesDir, contentDir);
// Validate path is within extractDir to prevent directory traversal
if (!contentPath.startsWith(resolvedExtractDir)) continue;
if (!fs.statSync(contentPath).isDirectory()) continue;
const files = fs.readdirSync(contentPath);
extractedFiles[contentDir] = files.map(f => {
const filePath = path.resolve(contentPath, f);
// Validate each file path is within extractDir
if (!filePath.startsWith(resolvedExtractDir)) return null;
return { name: f, path: filePath };
}).filter(Boolean);
}
}
// Cleanup uploaded zip
fs.unlinkSync(req.file.path);
} catch (err) {
if (req.file?.path) try { fs.unlinkSync(req.file.path); } catch {}
return res.status(400).json({ error: 'Failed to extract ZIP: ' + err.message });
}
} else {
data = req.body;
}
if (!data || !data.format || !data.format.startsWith('screentinker-export')) {
return res.status(400).json({ error: 'Invalid export file. Must be a ScreenTinker export JSON.' });
}
const isV2 = data.format === 'screentinker-export-v2';
const uuid = require('uuid');
const stats = { devices: 0, content: 0, widgets: 0, layouts: 0, playlists: 0, schedules: 0, video_walls: 0, kiosk_pages: 0, device_groups: 0 };
// Map old IDs to new IDs
const idMap = { devices: {}, content: {}, widgets: {}, layouts: {}, zones: {}, playlists: {}, groups: {}, walls: {}, kiosk: {} };
const importDb = db.transaction(() => {
// Import devices (as offline, unlinked - they'll need re-pairing)
for (const d of (data.devices || [])) {
const newId = uuid.v4();
idMap.devices[d.id] = newId;
const pairingCode = sixDigitCode(); // CSPRNG (lib/numeric-code): this code claims a device
db.prepare(`INSERT INTO devices (id, user_id, workspace_id, name, pairing_code, status, screen_width, screen_height, created_at) VALUES (?, ?, ?, ?, ?, 'provisioning', ?, ?, ?)`).run(newId, userId, workspaceId, d.name, pairingCode, d.screen_width || null, d.screen_height || null, d.created_at || Math.floor(Date.now() / 1000));
stats.devices++;
}
// Import content metadata + files from ZIP if available
for (const c of (data.content || [])) {
const newId = uuid.v4();
idMap.content[c.id] = newId;
let newFilepath = '';
let newThumbnail = null;
// Copy files from ZIP extract if available
const files = extractedFiles[c.id];
if (files && files.length > 0) {
for (const f of files) {
// The archive chooses this name, so the extension is caller-controlled — the
// same defect the upload path fixes. Constrain it to the media allowlist; an
// entry with any other extension is skipped rather than written to the content
// dir under a name the browser would treat as an active document.
const ext = path.extname(f.name).toLowerCase();
if (!INLINE_SAFE_EXTS.has(ext)) continue;
const destName = `${newId}${ext}`;
const destPath = path.join(config.contentDir, destName);
try {
fs.copyFileSync(f.path, destPath);
// Match original filepath vs thumbnail
if (c.original_filepath && f.name === c.original_filepath) {
newFilepath = destName;
} else if (c.original_thumbnail && f.name === c.original_thumbnail) {
newThumbnail = destName;
} else if (!newFilepath) {
// Fallback: first non-thumbnail file is the content
newFilepath = destName;
}
stats.files_restored = (stats.files_restored || 0) + 1;
} catch (err) {
// File copy failed, content will need re-upload
}
}
}
db.prepare(`INSERT INTO content (id, user_id, workspace_id, filename, filepath, mime_type, file_size, duration_sec, remote_url, thumbnail_path, width, height, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newId, userId, workspaceId, c.filename, newFilepath, c.mime_type, c.file_size || 0, c.duration_sec || null, c.remote_url || null, newThumbnail, c.width || null, c.height || null, c.created_at || Math.floor(Date.now() / 1000));
stats.content++;
}
// Import widgets
for (const w of (data.widgets || [])) {
const newId = uuid.v4();
idMap.widgets[w.id] = newId;
const config = typeof w.config === 'string' ? w.config : JSON.stringify(w.config || {});
db.prepare(`INSERT INTO widgets (id, user_id, workspace_id, widget_type, name, config, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(newId, userId, workspaceId, w.widget_type, w.name, config, w.created_at || Math.floor(Date.now() / 1000));
stats.widgets++;
}
// Import layouts and zones
for (const l of (data.layouts || [])) {
const newId = uuid.v4();
idMap.layouts[l.id] = newId;
db.prepare(`INSERT INTO layouts (id, user_id, workspace_id, name, width, height, is_template, created_at) VALUES (?, ?, ?, ?, ?, ?, 0, ?)`).run(newId, userId, workspaceId, l.name, l.width || 1920, l.height || 1080, l.created_at || Math.floor(Date.now() / 1000));
stats.layouts++;
}
for (const z of (data.layout_zones || [])) {
const newLayoutId = idMap.layouts[z.layout_id];
if (!newLayoutId) continue;
const newId = uuid.v4();
idMap.zones[z.id] = newId;
db.prepare(`INSERT INTO layout_zones (id, layout_id, name, x_percent, y_percent, width_percent, height_percent, z_index, zone_type, fit_mode, background_color, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newId, newLayoutId, z.name, z.x_percent, z.y_percent, z.width_percent, z.height_percent, z.z_index || 0, z.zone_type || 'content', z.fit_mode || 'cover', z.background_color || '#000000', z.sort_order || 0);
}
// Import playlists (v2) or convert assignments to playlists (v1)
if (isV2) {
for (const p of (data.playlists || [])) {
const newId = uuid.v4();
idMap.playlists[p.id] = newId;
db.prepare('INSERT INTO playlists (id, user_id, workspace_id, name, description, is_auto_generated, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)').run(newId, userId, workspaceId, p.name, p.description || '', p.is_auto_generated || 0, p.created_at || Math.floor(Date.now() / 1000), p.updated_at || Math.floor(Date.now() / 1000));
stats.playlists++;
}
for (const pi of (data.playlist_items || [])) {
const playlistId = idMap.playlists[pi.playlist_id];
if (!playlistId) continue;
const contentId = pi.content_id ? idMap.content[pi.content_id] : null;
const widgetId = pi.widget_id ? idMap.widgets[pi.widget_id] : null;
if (!contentId && !widgetId) continue;
db.prepare('INSERT INTO playlist_items (playlist_id, content_id, widget_id, sort_order, duration_sec) VALUES (?, ?, ?, ?, ?)').run(playlistId, contentId, widgetId, pi.sort_order || 0, pi.duration_sec || 10);
}
// Set device playlist_id references
for (const d of (data.devices || [])) {
if (d.playlist_id && idMap.playlists[d.playlist_id]) {
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(idMap.playlists[d.playlist_id], idMap.devices[d.id]);
}
}
} else {
// v1: defer playlist creation to after the transaction so we can async-probe videos
// Just stash the mapping for now; actual insertion happens below after importDb()
}
// Import schedules
for (const s of (data.schedules || [])) {
const devId = s.device_id ? (idMap.devices[s.device_id] || null) : null;
const grpId = s.group_id ? (idMap.groups[s.group_id] || null) : null;
// Must have either a mapped device or group target
if (!devId && !grpId) continue;
const newId = uuid.v4();
const playlistId = s.playlist_id ? (idMap.playlists[s.playlist_id] || null) : null;
db.prepare(`INSERT INTO schedules (id, user_id, device_id, group_id, zone_id, content_id, widget_id, layout_id, playlist_id, title, start_time, end_time, timezone, recurrence, recurrence_end, priority, enabled, color, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newId, userId, devId, grpId, s.zone_id ? (idMap.zones[s.zone_id] || null) : null, s.content_id ? (idMap.content[s.content_id] || null) : null, s.widget_id ? (idMap.widgets[s.widget_id] || null) : null, s.layout_id ? (idMap.layouts[s.layout_id] || null) : null, playlistId, s.title || '', s.start_time, s.end_time, s.timezone || 'UTC', s.recurrence || null, s.recurrence_end || null, s.priority || 0, s.enabled !== undefined ? s.enabled : 1, s.color || '#3B82F6', s.created_at || Math.floor(Date.now() / 1000));
stats.schedules++;
}
// Import video walls
for (const w of (data.video_walls || [])) {
const newId = uuid.v4();
idMap.walls[w.id] = newId;
db.prepare(`INSERT INTO video_walls (id, user_id, name, grid_cols, grid_rows, bezel_h_mm, bezel_v_mm, screen_w_mm, screen_h_mm, sync_mode, content_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(newId, userId, w.name, w.grid_cols, w.grid_rows, w.bezel_h_mm || 0, w.bezel_v_mm || 0, w.screen_w_mm || 400, w.screen_h_mm || 225, w.sync_mode || 'leader', w.content_id ? (idMap.content[w.content_id] || null) : null, w.created_at || Math.floor(Date.now() / 1000));
stats.video_walls++;
}
for (const wd of (data.video_wall_devices || [])) {
const wallId = idMap.walls[wd.wall_id];
const devId = idMap.devices[wd.device_id];
if (!wallId || !devId) continue;
db.prepare(`INSERT INTO video_wall_devices (wall_id, device_id, grid_col, grid_row, rotation) VALUES (?, ?, ?, ?, ?)`).run(wallId, devId, wd.grid_col, wd.grid_row, wd.rotation || 0);
}
// Import kiosk pages
for (const k of (data.kiosk_pages || [])) {
const newId = uuid.v4();
idMap.kiosk[k.id] = newId;
const config = typeof k.config === 'string' ? k.config : JSON.stringify(k.config || {});
db.prepare(`INSERT INTO kiosk_pages (id, user_id, workspace_id, name, config, created_at) VALUES (?, ?, ?, ?, ?, ?)`).run(newId, userId, workspaceId, k.name, config, k.created_at || Math.floor(Date.now() / 1000));
stats.kiosk_pages++;
}
// Import device groups
for (const g of (data.device_groups || [])) {
const newId = uuid.v4();
idMap.groups[g.id] = newId;
db.prepare(`INSERT INTO device_groups (id, user_id, workspace_id, name, color, created_at) VALUES (?, ?, ?, ?, ?, ?)`).run(newId, userId, workspaceId, g.name, g.color || '#3B82F6', g.created_at || Math.floor(Date.now() / 1000));
stats.device_groups++;
}
for (const gm of (data.device_group_members || [])) {
const groupId = idMap.groups[gm.group_id];
const devId = idMap.devices[gm.device_id];
if (!groupId || !devId) continue;
db.prepare(`INSERT OR IGNORE INTO device_group_members (group_id, device_id) VALUES (?, ?)`).run(groupId, devId);
}
// Import alert configs
for (const a of (data.alert_configs || [])) {
const newId = uuid.v4();
const config = typeof a.config === 'string' ? a.config : JSON.stringify(a.config || {});
db.prepare(`INSERT INTO alert_configs (id, user_id, alert_type, enabled, config, created_at) VALUES (?, ?, ?, ?, ?, ?)`).run(newId, userId, a.alert_type, a.enabled !== undefined ? a.enabled : 1, config, a.created_at || Math.floor(Date.now() / 1000));
}
// Import white label - UPSERT into the importer's current workspace.
if (data.white_label && workspaceId) {
const wl = data.white_label;
const existing = db.prepare('SELECT id FROM white_labels WHERE workspace_id = ?').get(workspaceId);
if (existing) {
db.prepare(`UPDATE white_labels SET brand_name=?, logo_url=?, favicon_url=?, primary_color=?, bg_color=?, custom_domain=?, custom_css=?, hide_branding=?, updated_at=strftime('%s','now') WHERE workspace_id=?`).run(wl.brand_name || 'ScreenTinker', wl.logo_url || null, wl.favicon_url || null, wl.primary_color || '#3B82F6', wl.bg_color || '#111827', wl.custom_domain || null, wl.custom_css || null, wl.hide_branding || 0, workspaceId);
} else {
db.prepare(`INSERT INTO white_labels (id, user_id, workspace_id, brand_name, logo_url, favicon_url, primary_color, bg_color, custom_domain, custom_css, hide_branding) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(uuid.v4(), userId, workspaceId, wl.brand_name || 'ScreenTinker', wl.logo_url || null, wl.favicon_url || null, wl.primary_color || '#3B82F6', wl.bg_color || '#111827', wl.custom_domain || null, wl.custom_css || null, wl.hide_branding || 0);
}
}
});
try {
importDb();
// v1: convert assignments to per-device playlists AFTER transaction (content files now on disk)
if (!isV2 && data.assignments?.length) {
const { execFile } = require('child_process');
async function probeImportedContent(newContentId) {
const c = db.prepare('SELECT id, mime_type, filepath, duration_sec FROM content WHERE id = ?').get(newContentId);
if (!c || !c.mime_type?.startsWith('video/') || !c.filepath) return c?.duration_sec ? Math.ceil(c.duration_sec) : null;
if (c.duration_sec) return Math.ceil(c.duration_sec);
try {
const fullPath = path.join(config.contentDir, c.filepath);
const stdout = await new Promise((resolve, reject) => {
execFile('ffprobe', ['-v', 'quiet', '-print_format', 'json', '-show_format', fullPath],
{ timeout: 15000 }, (err, out) => err ? reject(err) : resolve(out));
});
const info = JSON.parse(stdout);
if (info.format?.duration) {
const dur = parseFloat(info.format.duration);
db.prepare('UPDATE content SET duration_sec = ? WHERE id = ?').run(dur, c.id);
return Math.ceil(dur);
}
} catch (e) { /* probe failed, fall back to default */ }
return null;
}
const assignmentsByDevice = {};
for (const a of (data.assignments || [])) {
if (!assignmentsByDevice[a.device_id]) assignmentsByDevice[a.device_id] = [];
assignmentsByDevice[a.device_id].push(a);
}
for (const [oldDevId, assignments] of Object.entries(assignmentsByDevice)) {
const devId = idMap.devices[oldDevId];
if (!devId) continue;
const devName = (data.devices || []).find(d => d.id === oldDevId)?.name || 'Display';
const playlistId = uuid.v4();
const items = [];
for (const a of assignments) {
const contentId = a.content_id ? idMap.content[a.content_id] : null;
const widgetId = a.widget_id ? idMap.widgets[a.widget_id] : null;
if (!contentId && !widgetId) continue;
let duration = a.duration_sec || 10;
if (contentId) {
const probed = await probeImportedContent(contentId);
if (probed) duration = probed;
}
items.push({ contentId, widgetId, sort_order: a.sort_order || 0, duration });
}
db.prepare('INSERT INTO playlists (id, user_id, workspace_id, name, description, is_auto_generated) VALUES (?, ?, ?, ?, ?, 1)')
.run(playlistId, userId, workspaceId, `${devName} (imported)`, 'Converted from v1 assignments');
for (const item of items) {
db.prepare('INSERT INTO playlist_items (playlist_id, content_id, widget_id, sort_order, duration_sec) VALUES (?, ?, ?, ?, ?)')
.run(playlistId, item.contentId, item.widgetId, item.sort_order, item.duration);
}
db.prepare('UPDATE devices SET playlist_id = ? WHERE id = ?').run(playlistId, devId);
stats.playlists++;
}
}
// Collect pairing codes for imported devices
const devicePairings = (data.devices || []).map(d => {
const newId = idMap.devices[d.id];
const dev = db.prepare('SELECT name, pairing_code FROM devices WHERE id = ?').get(newId);
return dev ? { name: dev.name, pairing_code: dev.pairing_code } : null;
}).filter(Boolean);
res.json({
success: true,
message: 'Import complete',
stats,
device_pairings: devicePairings,
notes: [
'Devices need to be re-paired. Use the pairing codes below or re-pair from the Displays page.',
stats.files_restored ? `${stats.files_restored} content files restored from export.` : 'File-based content needs to be re-uploaded. Remote URL content works immediately.',
'All IDs have been regenerated to avoid conflicts.',
]
});
} catch (err) {
console.error('Import error:', err);
res.status(500).json({ error: 'Import failed: ' + err.message });
}
});
module.exports = router;