const { version: appVersion } = require('#root/package.json'); const VERSIONED_MIGRATIONS = [ { version: '2.1.0', label: 'v2.1.0 player registry schema', run: async function (pool) { // Keep the player registry as the source of truth for device metadata. if (!(await columnExists(pool, 'd_players', 'public_base_url'))) { await pool.query(` CREATE TABLE IF NOT EXISTS d_players ( device_id VARCHAR(128) PRIMARY KEY, public_base_url VARCHAR(512) NULL, internal_base_url VARCHAR(512) NULL, last_seen_at TIMESTAMP NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); } // Seed the singleton player row used by the current one-player model. // For multi-player support, this seed and the hardcoded screen_id/player_id mapping will need to be replaced. await pool.query(`INSERT IGNORE INTO d_players (device_id) VALUES ('1')`); // Store the player pointer on screens so we can resolve the player without needing a player-side screen_id. // This is the singleton-player shortcut; a multi-player model should make this relational instead of hardcoded to '1'. if (!(await columnExists(pool, 'd_screens', 'player_id'))) { await pool.query("ALTER TABLE d_screens ADD COLUMN player_id VARCHAR(128) NOT NULL DEFAULT '1' AFTER playlist_id"); } else { await pool.query("UPDATE d_screens SET player_id = '1' WHERE player_id IS NULL OR player_id <> '1'"); const [playerColumnNullableRows] = await pool.query( `SELECT COUNT(*) AS nullable_count FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'd_screens' AND COLUMN_NAME = 'player_id' AND IS_NULLABLE = 'YES'` ); if (Number(playerColumnNullableRows && playerColumnNullableRows[0] && playerColumnNullableRows[0].nullable_count) > 0) { await pool.query("ALTER TABLE d_screens MODIFY COLUMN player_id VARCHAR(128) NOT NULL DEFAULT '1' AFTER playlist_id"); } } const [screenPlayerUniqueRows] = await pool.query( `SELECT COUNT(*) AS index_count FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'd_screens' AND INDEX_NAME = 'uq_screens_player_id'` ); if (Number(screenPlayerUniqueRows && screenPlayerUniqueRows[0] && screenPlayerUniqueRows[0].index_count) > 0) { await pool.query('ALTER TABLE d_screens DROP INDEX uq_screens_player_id'); } // Recreate the screen-to-player foreign key after the column exists and legacy data is copied over. const [screenPlayerFkRows] = await pool.query( `SELECT COUNT(*) AS fk_count FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'd_screens' AND CONSTRAINT_NAME = 'fk_screens_player'` ); if (Number(screenPlayerFkRows && screenPlayerFkRows[0] && screenPlayerFkRows[0].fk_count) === 0) { await pool.query('ALTER TABLE d_screens ADD CONSTRAINT fk_screens_player FOREIGN KEY (player_id) REFERENCES d_players(device_id) ON DELETE RESTRICT'); } if (await columnExists(pool, 'c_template_regions', 'font_family')) { await pool.query('ALTER TABLE c_template_regions DROP COLUMN font_family'); } } }, { version: '2.1.1', label: 'v2.1.1 API source schema', run: async function (pool) { await ensureColumn(pool, 'i_api_sources', 'auth_method', "VARCHAR(32) NOT NULL DEFAULT 'none'", 'api_url'); await ensureColumn(pool, 'i_api_sources', 'auth_username', 'VARCHAR(255) NULL', 'auth_method'); await ensureColumn(pool, 'i_api_sources', 'auth_password', 'MEDIUMTEXT NULL', 'auth_username'); await ensureColumn(pool, 'i_api_sources', 'auth_bearer_token', 'MEDIUMTEXT NULL', 'auth_password'); await ensureColumn(pool, 'i_api_sources', 'auth_header_name', 'VARCHAR(255) NULL', 'auth_bearer_token'); await ensureColumn(pool, 'i_api_sources', 'auth_header_value', 'MEDIUMTEXT NULL', 'auth_header_name'); await ensureColumn(pool, 'i_api_sources', 'items_path', 'VARCHAR(255) NULL', 'auth_header_value'); } }, { version: '2.2.0', label: 'v2.2.0 announcement schema', run: async function (pool) { await pool.query(` CREATE TABLE IF NOT EXISTS d_announcements ( id INT AUTO_INCREMENT PRIMARY KEY, message TEXT NOT NULL, short_label VARCHAR(255) NOT NULL DEFAULT '', announcement_type VARCHAR(32) NOT NULL DEFAULT 'lower-third', color_key VARCHAR(32) NOT NULL DEFAULT 'primary', icon_key VARCHAR(64) NOT NULL DEFAULT 'megaphone-fill', duration_seconds INT NULL, expires_at TIMESTAMP NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by INT NULL, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, modified_by INT NULL, INDEX idx_announcements_expires_at (expires_at), INDEX idx_announcements_modified_at (modified_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); } }, { version: '2.4.0', label: 'v2.4.0 playlist schedule rules schema', run: async function (pool) { await pool.query(` CREATE TABLE IF NOT EXISTS c_playlist_slide_schedule_rules ( id INT AUTO_INCREMENT PRIMARY KEY, playlist_slide_id INT NOT NULL, position INT NOT NULL DEFAULT 0, start_datetime DATETIME NULL, end_datetime DATETIME NULL, start_time TIME NULL, end_time TIME NULL, schedule_days_json JSON NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by INT NULL, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, modified_by INT NULL, CONSTRAINT fk_playlist_slide_schedule_rules_slide FOREIGN KEY (playlist_slide_id) REFERENCES c_playlist_slides(id) ON DELETE CASCADE, INDEX idx_playlist_slide_schedule_rules_slide_position (playlist_slide_id, position) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); const legacyColumnNames = [ 'schedule_mode', 'schedule_start_datetime', 'schedule_end_datetime', 'schedule_start_time', 'schedule_end_time', 'schedule_days_json', 'schedule_rules_json' ]; const existingLegacyColumns = []; for (const columnName of legacyColumnNames) { if (await columnExists(pool, 'c_playlist_slides', columnName)) { existingLegacyColumns.push(columnName); } } const selectColumns = ['id'].concat(existingLegacyColumns).join(', '); const [legacyRows] = await pool.query('SELECT ' + selectColumns + ' FROM c_playlist_slides'); for (const row of legacyRows) { const rules = normalizeLegacyScheduleRules(row); if (rules.length) { for (let index = 0; index < rules.length; index += 1) { const rule = rules[index]; await pool.query( 'INSERT INTO c_playlist_slide_schedule_rules (playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL)', [row.id, index, rule.start_datetime || null, rule.end_datetime || null, rule.start_time || null, rule.end_time || null, rule.schedule_days_json ? JSON.stringify(rule.schedule_days_json) : null] ); } } } await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_rules_json'); await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_mode'); await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_start_datetime'); await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_end_datetime'); await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_start_time'); await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_end_time'); await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_days_json'); await pool.query(` CREATE TABLE IF NOT EXISTS d_announcement_screens ( announcement_id INT NOT NULL, screen_id INT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, created_by INT NULL, modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, modified_by INT NULL, PRIMARY KEY (announcement_id, screen_id), INDEX idx_announcement_screens_screen_id (screen_id), CONSTRAINT fk_announcement_screens_announcement FOREIGN KEY (announcement_id) REFERENCES d_announcements(id) ON DELETE CASCADE, CONSTRAINT fk_announcement_screens_screen FOREIGN KEY (screen_id) REFERENCES d_screens(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci `); } }, { version: '2.4.2', label: 'v2.4.2 playlist canvas id schema', run: async function (pool) { await ensureColumn(pool, 'c_playlists', 'canvas_id', 'INT NULL', 'skip_unavailable_rtmp'); await ensureForeignKey(pool, 'c_playlists', 'fk_playlists_canvas', 'canvas_id', 'c_canvas_sizes', 'id', 'SET NULL'); await ensureColumn(pool, 'c_playlist_slides', 'disable_audio', 'TINYINT(1) NOT NULL DEFAULT 1', 'use_video_duration'); await pool.query(` UPDATE c_playlists p JOIN ( SELECT ps.playlist_id, COUNT(DISTINCT CONCAT(cs.width, 'x', cs.height)) AS signature_count, MIN(CONCAT(cs.width, 'x', cs.height)) AS canvas_signature FROM c_playlist_slides ps JOIN c_slides sl ON sl.id = ps.slide_id LEFT JOIN c_templates st ON st.id = sl.template_id LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id WHERE cs.width IS NOT NULL AND cs.height IS NOT NULL GROUP BY ps.playlist_id ) x ON x.playlist_id = p.id JOIN c_canvas_sizes cs ON CONCAT(cs.width, 'x', cs.height) = x.canvas_signature SET p.canvas_id = cs.id WHERE (p.canvas_id IS NULL) AND x.signature_count = 1 `); } }, { version: '2.5.1', label: 'v2.5.1 template animation json schema', run: async function (pool) { await ensureColumn(pool, 'c_template_regions', 'animation_json', 'JSON NULL', 'lock_ratio'); } } ]; async function columnExists(pool, tableName, columnName) { const [rows] = await pool.query( `SELECT COUNT(*) AS column_count FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`, [tableName, columnName] ); return Number(rows && rows[0] && rows[0].column_count) > 0; } async function ensureColumn(pool, tableName, columnName, columnDefinition, afterColumn) { if (await columnExists(pool, tableName, columnName)) { return; } const afterClause = afterColumn ? ' AFTER ' + afterColumn : ''; await pool.query('ALTER TABLE ' + tableName + ' ADD COLUMN ' + columnName + ' ' + columnDefinition + afterClause); } async function ensureForeignKey(pool, tableName, constraintName, columnName, referencedTable, referencedColumn, onDeleteAction) { const [rows] = await pool.query( `SELECT COUNT(*) AS fk_count FROM information_schema.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND CONSTRAINT_NAME = ?`, [tableName, constraintName] ); if (Number(rows && rows[0] && rows[0].fk_count) > 0) { return; } await pool.query('ALTER TABLE ' + tableName + ' ADD CONSTRAINT ' + constraintName + ' FOREIGN KEY (' + columnName + ') REFERENCES ' + referencedTable + '(' + referencedColumn + ') ON DELETE ' + onDeleteAction); } async function dropColumnIfExists(pool, tableName, columnName) { if (await columnExists(pool, tableName, columnName)) { try { await pool.query('ALTER TABLE ' + tableName + ' DROP COLUMN ' + columnName); } catch (error) { if (!error || (error.code !== 'ER_CANT_DROP_FIELD_OR_KEY' && error.errno !== 1091)) { throw error; } } } } function normalizeLegacyScheduleRules(row) { const rules = []; if (!row) { return rules; } if (row.schedule_rules_json) { try { const parsed = JSON.parse(row.schedule_rules_json); if (Array.isArray(parsed)) { parsed.forEach(function (rule) { const normalized = normalizeScheduleRule(rule); if (normalized) { rules.push(normalized); } }); return rules; } } catch (_error) { // fall through to legacy columns } } const mode = String(row.schedule_mode || 'always').trim().toLowerCase(); if (mode === 'dates' && row.schedule_start_datetime && row.schedule_end_datetime) { rules.push({ start_datetime: formatMigrationDateTime(row.schedule_start_datetime), end_datetime: formatMigrationDateTime(row.schedule_end_datetime) }); return rules; } if (mode === 'times' && row.schedule_start_time && row.schedule_end_time) { const days = normalizeMigrationDays(row.schedule_days_json); const rule = { start_time: formatMigrationTime(row.schedule_start_time), end_time: formatMigrationTime(row.schedule_end_time) }; if (days.length) { rule.schedule_days_json = days; } rules.push(rule); } return rules; } function normalizeScheduleRule(rule) { if (!rule || typeof rule !== 'object') { return null; } const normalized = {}; if (rule.start_datetime) { normalized.start_datetime = String(rule.start_datetime); } if (rule.end_datetime) { normalized.end_datetime = String(rule.end_datetime); } if (rule.start_time) { normalized.start_time = String(rule.start_time).slice(0, 5); } if (rule.end_time) { normalized.end_time = String(rule.end_time).slice(0, 5); } if (Array.isArray(rule.schedule_days_json)) { normalized.schedule_days_json = rule.schedule_days_json; } else if (Array.isArray(rule.days)) { normalized.schedule_days_json = rule.days; } return Object.keys(normalized).length ? normalized : null; } function formatMigrationDateTime(value) { if (!value) { return null; } const date = value instanceof Date ? value : new Date(value); if (Number.isNaN(date.getTime())) { return null; } const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, '0'); const day = String(date.getDate()).padStart(2, '0'); const hours = String(date.getHours()).padStart(2, '0'); const minutes = String(date.getMinutes()).padStart(2, '0'); return year + '-' + month + '-' + day + 'T' + hours + ':' + minutes; } function formatMigrationTime(value) { if (!value) { return null; } return String(value).slice(0, 5); } function normalizeMigrationDays(value) { let parsed = []; if (Array.isArray(value)) { parsed = value; } else if (value) { try { const raw = typeof value === 'string' ? JSON.parse(value) : value; parsed = Array.isArray(raw) ? raw : []; } catch (_error) { parsed = []; } } return Array.from(new Set(parsed.map(function (day) { return Number(day); }).filter(function (day) { return Number.isInteger(day) && day >= 0 && day <= 6; }))).sort(function (left, right) { return left - right; }); } function compareVersions(leftVersion, rightVersion) { const leftParts = String(leftVersion || '0.0.0').split('.').map(function (value) { return Number(value) || 0; }); const rightParts = String(rightVersion || '0.0.0').split('.').map(function (value) { return Number(value) || 0; }); const length = Math.max(leftParts.length, rightParts.length); for (let index = 0; index < length; index += 1) { const leftPart = leftParts[index] || 0; const rightPart = rightParts[index] || 0; if (leftPart > rightPart) { return 1; } if (leftPart < rightPart) { return -1; } } return 0; } async function runMigrations(pool, options) { // Only run migrations that are newer than the installed schema version and not beyond the app version. const targetVersion = String(appVersion || '0.0.0').trim(); const currentVersion = String(options && options.currentVersion || '0.0.0').trim(); for (const migration of VERSIONED_MIGRATIONS) { if (compareVersions(migration.version, currentVersion) > 0 && compareVersions(migration.version, targetVersion) <= 0) { await migration.run(pool); } } } module.exports = { appVersion: appVersion, runMigrations: runMigrations };