338 lines
15 KiB
JavaScript
338 lines
15 KiB
JavaScript
// 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;
|
|
|
|
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);
|
|
}
|
|
|
|
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 : [];
|
|
}
|
|
|
|
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
|
|
};
|