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 { PLATFORM_ROLES } = require('../middleware/auth'); // Public status page router.get('/', (req, res) => { const totalDevices = db.prepare('SELECT COUNT(*) as count FROM devices').get().count; const onlineDevices = db.prepare("SELECT COUNT(*) as count FROM devices WHERE status = 'online'").get().count; const totalContent = db.prepare('SELECT COUNT(*) as count FROM content').get().count; const totalUsers = db.prepare('SELECT COUNT(*) as count FROM users').get().count; const uptime = process.uptime(); // Public status - minimal info only (no user counts, no server internals) let version = '1.5.1'; try { version = require('fs').readFileSync(require('path').join(__dirname, '..', '..', 'VERSION'), 'utf8').trim(); } catch {} res.json({ status: 'ok', version, uptime_human: formatUptime(uptime), timestamp: new Date().toISOString(), }); }); 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`; } // 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' }); try { const jwt = require('jsonwebtoken'); const config = require('../config'); const decoded = jwt.verify(token, config.jwtSecret); const user = db.prepare('SELECT role FROM users WHERE id = ?').get(decoded.id); if (!user || !PLATFORM_ROLES.includes(user.role)) return res.status(403).json({ error: 'Platform admin only' }); } catch { return res.status(401).json({ error: 'Invalid token' }); } 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 jwt = require('jsonwebtoken'); const config = require('../config'); const decoded = jwt.verify(token, config.jwtSecret); userId = decoded.id; workspaceId = decoded.current_workspace_id || null; if (!userId) return res.status(401).json({ error: 'Invalid token' }); } catch { return res.status(401).json({ error: 'Invalid token' }); } 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 jwt = require('jsonwebtoken'); const jwtConfig = require('../config'); const decoded = jwt.verify(authHeader.split(' ')[1], jwtConfig.jwtSecret); userId = decoded.id; workspaceId = decoded.current_workspace_id || null; if (!userId) return res.status(401).json({ error: 'Invalid token' }); } catch { return res.status(401).json({ error: 'Invalid token' }); } const user = db.prepare('SELECT id, role FROM users WHERE id = ?').get(userId); if (!user) return res.status(404).json({ error: 'User not found' }); // 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 = String(Math.floor(100000 + Math.random() * 900000)); 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) { const ext = path.extname(f.name); 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, name, description, is_auto_generated, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run(newId, userId, 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, name, color, created_at) VALUES (?, ?, ?, ?, ?)`).run(newId, userId, 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, name, description, is_auto_generated) VALUES (?, ?, ?, ?, 1)') .run(playlistId, userId, `${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;