// Player playlist assembly, snapshot persistence, and playlist revision helpers. const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const { createStyledQrCodeDataUrl } = require('../data/qr-code'); function createPlayerPlaylistService(options) { const pool = options && options.pool ? options.pool : null; const common = options && options.common ? options.common : null; const snapshotDir = options && options.snapshotDir ? options.snapshotDir : null; const mediaDir = options && options.mediaDir ? path.resolve(String(options.mediaDir)) : null; const remoteImageCacheDir = mediaDir ? path.join(mediaDir, 'player-cache', 'remote-images') : null; if (!pool) { throw new Error('pool is required'); } if (!common) { throw new Error('common is required'); } function getSnapshotFilePath(slug) { if (!snapshotDir) { return null; } const normalizedSlug = String(slug || '').trim(); if (!normalizedSlug) { return null; } return path.join(snapshotDir, `${normalizedSlug}.json`); } async function readSnapshot(slug) { const filePath = getSnapshotFilePath(slug); if (!filePath) { return null; } try { const raw = await fs.promises.readFile(filePath, 'utf8'); const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== 'object') { return null; } return parsed; } catch (error) { if (error && error.code === 'ENOENT') { return null; } return null; } } async function writeSnapshot(slug, payload) { const filePath = getSnapshotFilePath(slug); if (!filePath) { return; } await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); await fs.promises.writeFile(filePath, JSON.stringify(payload, null, 2), 'utf8'); } async function createQrPreviewDataUrl(value) { return createStyledQrCodeDataUrl(value); } function getPlaceholderImageExpressions(value, output) { const expressions = output || []; const source = String(value || ''); const pattern = /\{\{\s*([^{}]*?\.image\s*\([^{}]*\)[^{}]*?)\s*\}\}/gi; let match = null; while ((match = pattern.exec(source))) { expressions.push(String(match[1] || '').trim()); } return expressions; } function resolvePathValue(value, expression) { const parsed = String(expression || '').replace(/\.image\s*\([^)]*\)\s*$/i, '').trim(); return parsed.split('.').reduce(function (current, segment) { return current === undefined || current === null ? '' : current[segment]; }, value); } function getApiItems(responseJson, itemsPath) { let current = responseJson; const pathValue = String(itemsPath || '').trim(); if (pathValue) { pathValue.split('.').forEach(function (segment) { current = current === undefined || current === null ? '' : current[segment]; }); return Array.isArray(current) ? current : []; } if (Array.isArray(responseJson)) return responseJson; if (responseJson && Array.isArray(responseJson.items)) return responseJson.items; if (responseJson && Array.isArray(responseJson.results)) return responseJson.results; if (responseJson && Array.isArray(responseJson.data)) return responseJson.data; return responseJson ? [responseJson] : []; } async function cacheRemoteImage(url) { const remoteUrl = String(url || '').trim(); if (!remoteImageCacheDir || !/^https?:\/\//i.test(remoteUrl)) return ''; const hash = crypto.createHash('sha256').update(remoteUrl).digest('hex'); await fs.promises.mkdir(remoteImageCacheDir, { recursive: true }); const existing = (await fs.promises.readdir(remoteImageCacheDir)).find(function (name) { return name.startsWith(hash + '.'); }); if (existing) return '/media/player-cache/remote-images/' + existing; try { const response = await fetch(remoteUrl, { signal: AbortSignal.timeout(15000) }); if (!response.ok || !String(response.headers.get('content-type') || '').toLowerCase().startsWith('image/')) return ''; const bytes = Buffer.from(await response.arrayBuffer()); if (bytes.length > 10 * 1024 * 1024) return ''; const contentType = String(response.headers.get('content-type') || '').toLowerCase(); const extension = contentType.includes('svg') ? '.svg' : contentType.includes('png') ? '.png' : contentType.includes('webp') ? '.webp' : contentType.includes('gif') ? '.gif' : '.jpg'; const fileName = hash + extension; const filePath = path.join(remoteImageCacheDir, fileName); await fs.promises.writeFile(filePath, bytes); return '/media/player-cache/remote-images/' + fileName; } catch (_error) { return ''; } } async function cachePlaceholderImages(slides, rssFeeds, apiSources) { if (!remoteImageCacheDir) return; const usedPaths = new Set(); const allSlideRows = await pool.query('SELECT content_json FROM c_slides').then(function (result) { return result[0] || []; }); const allContents = allSlideRows.map(function (row) { return common.parseJsonSafe(row.content_json) || {}; }); const sourceContent = allContents.concat((slides || []).map(function (slide) { return slide.content || {}; })); const cacheSource = async function (source, item, expressions) { if (!source || !item) return; for (const expression of expressions) { const remoteUrl = String(resolvePathValue(item, expression) || '').trim(); const localPath = await cacheRemoteImage(remoteUrl); if (localPath) { source.imageCache = source.imageCache || {}; source.imageCache[remoteUrl] = localPath; usedPaths.add(localPath); } } }; for (const content of sourceContent) { for (const key of Object.keys(content || {})) { const region = content[key]; if (!region || typeof region !== 'object') continue; const expressions = getPlaceholderImageExpressions(region.value, []); if (!expressions.length) continue; const itemNumber = Math.max(1, Number(region.item_number || 1)) - 1; if (String(region.type || '').toLowerCase() === 'api') { const source = (apiSources || []).find(function (entry) { return Number(entry.id) === Number(region.source_id); }); const items = source ? getApiItems(source.responseJson, region.items_path) : []; await cacheSource(source, items[itemNumber], expressions); } if (String(region.type || '').toLowerCase() === 'rss') { const feed = (rssFeeds || []).find(function (entry) { return Number(entry.id) === Number(region.feed_id); }); await cacheSource(feed, feed && feed.items && feed.items[itemNumber], expressions); } } } const files = await fs.promises.readdir(remoteImageCacheDir).catch(function () { return []; }); await Promise.all(files.filter(function (file) { return !usedPaths.has('/media/player-cache/remote-images/' + file); }).map(function (file) { return fs.promises.unlink(path.join(remoteImageCacheDir, file)).catch(function () {}); })); } async function buildScreenPlaylist(slug) { try { const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM d_screens WHERE slug = ?', [slug]); if (!screenRows.length) { return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [], timetableGroups: [] }; } const screen = screenRows[0]; if (!screen.playlist_id) { const payloadWithoutPlaylist = { screen: screen, playlist: null, slides: [], rssFeeds: [], apiSources: [], timetableGroups: [], revision: getPlaylistRevision(screen, null, [], [], [], [], [], []) }; await writeSnapshot(slug, payloadWithoutPlaylist); return payloadWithoutPlaylist; } const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists WHERE id = ?', [screen.playlist_id]); const playlist = playlistRows[0] || null; const [slideRows] = await pool.query(` SELECT sl.id, sl.title, sl.template_id, sl.content_json, sl.created_at, sl.modified_at, ps.position, ps.duration_seconds AS duration_seconds, ps.use_video_duration, ps.disable_audio, st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height 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 ps.playlist_id = ? ORDER BY ps.position ASC, ps.id ASC `, [screen.playlist_id]); const [scheduleRuleRows] = await pool.query(` SELECT r.id, r.playlist_slide_id, r.position, r.start_datetime, r.end_datetime, r.start_time, r.end_time, r.schedule_days_json, r.created_at, r.modified_at, r.created_by, r.modified_by FROM c_playlist_slide_schedule_rules r JOIN c_playlist_slides ps ON ps.id = r.playlist_slide_id WHERE ps.playlist_id = ? ORDER BY r.playlist_slide_id ASC, r.position ASC, r.id ASC `, [screen.playlist_id]); const rulesBySlideId = {}; scheduleRuleRows.forEach(function (rule) { const slideId = Number(rule.playlist_slide_id); if (!rulesBySlideId[slideId]) { rulesBySlideId[slideId] = []; } rulesBySlideId[slideId].push(rule); }); const templateIds = slideRows .filter(function (slide) { return slide.template_id; }) .map(function (slide) { return slide.template_id; }); const templatesById = {}; let templateRows = []; let regionRows = []; if (templateIds.length) { [templateRows] = await pool.query(` SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height FROM c_templates st LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id WHERE st.id IN (?) `, [templateIds]); [regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]); templateRows.forEach(function (template) { template.regions = regionRows.filter(function (region) { return region.template_id === template.id; }); templatesById[template.id] = template; }); } function getVideoRegionDurationSeconds(contentJson) { if (!contentJson) { return null; } try { const parsed = common.parseJsonSafe(contentJson) || {}; if (!parsed || typeof parsed !== 'object') { return null; } const videoRegions = Object.keys(parsed).map(function (key) { return parsed[key]; }).filter(function (region) { return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0; }); const duration = videoRegions.reduce(function (longest, region) { const regionDuration = Math.round(Number(region.duration_seconds || 0) * 1000) / 1000; return regionDuration > longest ? regionDuration : longest; }, 0); return Number.isFinite(duration) && duration > 0 ? duration : null; } catch (_error) { return null; } } const slides = await Promise.all(slideRows.map(async function (slide) { const storedDuration = Number(slide.duration_seconds || 0); const videoDuration = slide.use_video_duration ? getVideoRegionDurationSeconds(slide.content_json) : null; const disableAudio = slide.disable_audio === undefined || slide.disable_audio === null ? true : Boolean(slide.disable_audio); const videoCacheBust = String(slide.modified_at || slide.content_json || slide.id || ''); const content = common.parseJsonSafe(slide.content_json) || {}; const qrBackfillTasks = []; Object.keys(content).forEach(function (key) { const region = content[key]; if (region && typeof region === 'object') { const regionType = String(region.type || '').trim().toLowerCase(); if (regionType === 'video' || regionType === 'rtmp') { region.disable_audio = disableAudio; } } if (region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video') { region.cache_bust = videoCacheBust; } }); Object.keys(content).forEach(function (key) { const region = content[key]; if (!region || typeof region !== 'object' || String(region.type || '').trim().toLowerCase() !== 'qr-code') { return; } if (region.qr_preview && /^data:image\//i.test(String(region.qr_preview || '').trim())) { return; } qrBackfillTasks.push(createQrPreviewDataUrl(region.value).then(function (preview) { if (preview) { region.qr_preview = preview; } return null; }).catch(function () { return null; })); }); await Promise.all(qrBackfillTasks); return { id: slide.id, title: slide.title, modified_at: slide.modified_at, duration_seconds: videoDuration || storedDuration, use_video_duration: Boolean(slide.use_video_duration), disable_audio: disableAudio, scheduleRules: rulesBySlideId[Number(slide.id)] || [], template_id: slide.template_id, template: slide.template_id ? templatesById[slide.template_id] || null : null, content: content }; })); let rssFeeds = []; if (typeof common.fetchRssFeedsData === 'function' && typeof common.fetchRssFeedItemsByFeedId === 'function') { const rssData = await common.fetchRssFeedsData(pool); const feedRows = Array.isArray(rssData && rssData.rssFeeds) ? rssData.rssFeeds : []; rssFeeds = await Promise.all(feedRows.map(async function (feed) { const items = await common.fetchRssFeedItemsByFeedId(pool, feed.id); return Object.assign({}, feed, { items: items.map(function (item) { return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item; }) }); })); } let apiSources = []; if (typeof common.fetchApiSourcesData === 'function') { const apiData = await common.fetchApiSourcesData(pool); const sourceRows = Array.isArray(apiData && apiData.apiSources) ? apiData.apiSources : []; apiSources = sourceRows.map(function (source) { return Object.assign({}, source, { responseJson: common.parseJsonSafe ? common.parseJsonSafe(source.last_response_json) : null }); }); } let timetableGroups = []; if (typeof common.fetchTimetablesData === 'function') { const timetableData = await common.fetchTimetablesData(pool); timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : []; } await cachePlaceholderImages(slides, rssFeeds, apiSources); const revision = getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups); const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, timetableGroups: timetableGroups, revision: revision }; await writeSnapshot(slug, payload); return payload; } catch (error) { const snapshot = await readSnapshot(slug); if (snapshot) { return snapshot; } throw error; } } function updatePlaylistRevisionHash(hash, value) { hash.update(String(value === null || value === undefined ? '' : value)); hash.update('\0'); } function getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups) { const hash = crypto.createHash('sha1'); updatePlaylistRevisionHash(hash, screen && screen.id); updatePlaylistRevisionHash(hash, screen && screen.playlist_id); updatePlaylistRevisionHash(hash, screen && screen.modified_at); updatePlaylistRevisionHash(hash, playlist && playlist.id); updatePlaylistRevisionHash(hash, playlist && playlist.modified_at); updatePlaylistRevisionHash(hash, playlist && playlist.fade_between_slides); updatePlaylistRevisionHash(hash, playlist && playlist.skip_unavailable_rtmp); (Array.isArray(slideRows) ? slideRows : []).forEach(function (slide) { updatePlaylistRevisionHash(hash, slide.id); updatePlaylistRevisionHash(hash, slide.title); updatePlaylistRevisionHash(hash, slide.template_id); updatePlaylistRevisionHash(hash, slide.content_json); updatePlaylistRevisionHash(hash, slide.modified_at); updatePlaylistRevisionHash(hash, slide.position); updatePlaylistRevisionHash(hash, slide.duration_seconds); updatePlaylistRevisionHash(hash, slide.use_video_duration); updatePlaylistRevisionHash(hash, slide.disable_audio); }); (Array.isArray(scheduleRuleRows) ? scheduleRuleRows : []).forEach(function (rule) { updatePlaylistRevisionHash(hash, rule.id); updatePlaylistRevisionHash(hash, rule.playlist_slide_id); updatePlaylistRevisionHash(hash, rule.position); updatePlaylistRevisionHash(hash, rule.start_datetime); updatePlaylistRevisionHash(hash, rule.end_datetime); updatePlaylistRevisionHash(hash, rule.start_time); updatePlaylistRevisionHash(hash, rule.end_time); updatePlaylistRevisionHash(hash, rule.schedule_days_json); }); (Array.isArray(templateRows) ? templateRows : []).forEach(function (template) { updatePlaylistRevisionHash(hash, template.id); updatePlaylistRevisionHash(hash, template.name); updatePlaylistRevisionHash(hash, template.canvas_size_id); updatePlaylistRevisionHash(hash, template.canvas_size_width); updatePlaylistRevisionHash(hash, template.canvas_size_height); updatePlaylistRevisionHash(hash, template.background_image_path); updatePlaylistRevisionHash(hash, template.background_color); updatePlaylistRevisionHash(hash, template.modified_at); }); (Array.isArray(regionRows) ? regionRows : []).forEach(function (region) { updatePlaylistRevisionHash(hash, region.id); updatePlaylistRevisionHash(hash, region.template_id); updatePlaylistRevisionHash(hash, region.region_key); updatePlaylistRevisionHash(hash, region.region_type); updatePlaylistRevisionHash(hash, region.label); updatePlaylistRevisionHash(hash, region.font_family); updatePlaylistRevisionHash(hash, region.x); updatePlaylistRevisionHash(hash, region.y); updatePlaylistRevisionHash(hash, region.width); updatePlaylistRevisionHash(hash, region.height); updatePlaylistRevisionHash(hash, region.z_index); updatePlaylistRevisionHash(hash, region.modified_at); }); updatePlaylistRevisionHash(hash, JSON.stringify(rssFeeds || [])); updatePlaylistRevisionHash(hash, JSON.stringify(apiSources || [])); updatePlaylistRevisionHash(hash, JSON.stringify(timetableGroups || [])); return hash.digest('hex'); } return { buildScreenPlaylist: buildScreenPlaylist }; } module.exports = { createPlayerPlaylistService: createPlayerPlaylistService };