Fix ignored media source module

This commit is contained in:
2026-08-01 21:52:50 +01:00
parent d6417b667c
commit 90db0f933d
5 changed files with 1401 additions and 0 deletions
+2
View File
@@ -1,5 +1,7 @@
node_modules/
media/
!src/web/lib/media/
!src/web/lib/media/**
docker-compose.dev.yml
.vscode/
.env
+371
View File
@@ -0,0 +1,371 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const FONT_LIBRARY_DIR_NAME = 'fonts';
const FONT_MANIFEST_FILE = 'fonts.json';
const FONT_STYLESHEET_FILE = 'fonts.css';
const DEFAULT_FONT_FAMILY_FORMATS = [
'Arial=Arial,Helvetica,sans-serif',
'Comic Sans MS=Comic Sans MS,cursive,sans-serif',
'Courier New=Courier New,Courier,monospace',
'Georgia=Georgia,serif',
'Helvetica=Helvetica,Arial,sans-serif',
'Impact=Impact,Charcoal,sans-serif',
'Lucida Sans Unicode=Lucida Sans Unicode,Lucida Grande,sans-serif',
'Palatino Linotype=Palatino Linotype,Book Antiqua,Palatino,serif',
'Tahoma=Tahoma,Geneva,sans-serif',
'Times New Roman=Times New Roman,Times,serif',
'Trebuchet MS=Trebuchet MS,Helvetica,sans-serif',
'Verdana=Verdana,Geneva,sans-serif'
];
const FONT_FILE_EXTENSIONS = new Map([
['.woff2', 'woff2'],
['.woff', 'woff'],
['.ttf', 'truetype'],
['.otf', 'opentype']
]);
function normalizeText(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
}
function resolveMediaRoot(mediaRootOrUploadDir) {
const resolved = path.resolve(String(mediaRootOrUploadDir || '').trim());
return path.basename(resolved) === 'uploads' ? path.dirname(resolved) : resolved;
}
function getFontLibraryDir(mediaRootOrUploadDir) {
return path.join(resolveMediaRoot(mediaRootOrUploadDir), FONT_LIBRARY_DIR_NAME);
}
function getFontManifestPath(mediaRootOrUploadDir) {
return path.join(getFontLibraryDir(mediaRootOrUploadDir), FONT_MANIFEST_FILE);
}
function getFontStylesheetPath(mediaRootOrUploadDir) {
return path.join(getFontLibraryDir(mediaRootOrUploadDir), FONT_STYLESHEET_FILE);
}
function getFontStylesheetHref(mediaRootOrUploadDir) {
const stylesheetPath = getFontStylesheetPath(mediaRootOrUploadDir);
if (!fs.existsSync(stylesheetPath)) {
return '';
}
const stat = fs.statSync(stylesheetPath);
return `/media/${FONT_LIBRARY_DIR_NAME}/${FONT_STYLESHEET_FILE}?v=${Number(stat.mtimeMs) || Date.now()}`;
}
function normalizeFontEntry(entry) {
const fileName = normalizeText(entry && entry.fileName);
const family = normalizeText(entry && (entry.family || entry.name || entry.displayName));
const extension = path.extname(fileName).toLowerCase();
return {
id: normalizeText(entry && entry.id) || fileName,
name: family || fileName.replace(/\.[^.]+$/, ''),
family: family || fileName.replace(/\.[^.]+$/, ''),
fileName: fileName,
format: normalizeText(entry && entry.format) || FONT_FILE_EXTENSIONS.get(extension) || '',
enabled: entry && entry.enabled === false ? false : true,
createdAt: normalizeText(entry && entry.createdAt),
modifiedAt: normalizeText(entry && entry.modifiedAt)
};
}
function sortFonts(left, right) {
return String(left && left.name || '').localeCompare(String(right && right.name || ''), undefined, { sensitivity: 'base' })
|| String(left && left.fileName || '').localeCompare(String(right && right.fileName || ''), undefined, { sensitivity: 'base' });
}
function readManifest(mediaRootOrUploadDir) {
const manifestPath = getFontManifestPath(mediaRootOrUploadDir);
if (!fs.existsSync(manifestPath)) {
return [];
}
try {
const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8') || '[]');
const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed && parsed.fonts) ? parsed.fonts : [];
return list.map(normalizeFontEntry).filter(function (entry) {
return Boolean(entry.fileName) && Boolean(normalizeText(entry.family || entry.name));
}).sort(sortFonts);
} catch (_error) {
return [];
}
}
function buildFontFaceRule(entry) {
const family = normalizeText(entry.family || entry.name);
const fileName = normalizeText(entry.fileName);
if (!family || !fileName) {
return '';
}
const extension = path.extname(fileName).toLowerCase();
const format = FONT_FILE_EXTENSIONS.get(extension) || normalizeText(entry.format) || 'woff2';
const escapedFamily = family.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
return [
'@font-face {',
` font-family: '${escapedFamily}';`,
` src: url('/media/${FONT_LIBRARY_DIR_NAME}/${encodeURIComponent(fileName)}') format('${format}');`,
' font-style: normal;',
' font-weight: 400;',
' font-display: swap;',
'}'
].join('\n');
}
function buildFontStylesheet(fonts) {
const rules = (Array.isArray(fonts) ? fonts : [])
.filter(function (font) {
return font && font.enabled !== false;
})
.map(buildFontFaceRule)
.filter(Boolean);
return rules.length ? `/* Managed fonts */\n\n${rules.join('\n\n')}\n` : '/* Managed fonts */\n';
}
function buildFontFamilyFormats(fonts) {
const formatEntries = Array.from(new Set(DEFAULT_FONT_FAMILY_FORMATS.concat((Array.isArray(fonts) ? fonts : [])
.filter(function (font) {
return font && font.enabled !== false && normalizeText(font.family || font.name);
})
.map(function (font) {
const family = normalizeText(font.family || font.name);
return `${family}=${family}`;
}))))
.sort(function (left, right) {
const leftLabel = String(left || '').split('=')[0];
const rightLabel = String(right || '').split('=')[0];
return leftLabel.localeCompare(rightLabel, undefined, { sensitivity: 'base' });
});
return formatEntries.join(';');
}
async function ensureFontLibrary(mediaRootOrUploadDir) {
const fontDir = getFontLibraryDir(mediaRootOrUploadDir);
const manifestPath = getFontManifestPath(mediaRootOrUploadDir);
const stylesheetPath = getFontStylesheetPath(mediaRootOrUploadDir);
await fs.promises.mkdir(fontDir, { recursive: true });
if (!fs.existsSync(manifestPath)) {
await fs.promises.writeFile(manifestPath, '[]\n', 'utf8');
}
if (!fs.existsSync(stylesheetPath)) {
await fs.promises.writeFile(stylesheetPath, buildFontStylesheet([]), 'utf8');
}
return {
fonts: [],
fontDir: fontDir,
manifestPath: manifestPath,
stylesheetPath: stylesheetPath,
stylesheetHref: getFontStylesheetHref(mediaRootOrUploadDir),
fontFamilyFormats: buildFontFamilyFormats([])
};
}
function loadFontLibrary(mediaRootOrUploadDir) {
const fonts = readManifest(mediaRootOrUploadDir);
return {
fonts: fonts,
fontDir: getFontLibraryDir(mediaRootOrUploadDir),
manifestPath: getFontManifestPath(mediaRootOrUploadDir),
stylesheetPath: getFontStylesheetPath(mediaRootOrUploadDir),
stylesheetHref: getFontStylesheetHref(mediaRootOrUploadDir),
fontFamilyFormats: buildFontFamilyFormats(fonts)
};
}
async function saveFontLibrary(mediaRootOrUploadDir, fonts) {
const fontDir = getFontLibraryDir(mediaRootOrUploadDir);
const manifestPath = getFontManifestPath(mediaRootOrUploadDir);
const stylesheetPath = getFontStylesheetPath(mediaRootOrUploadDir);
const normalizedFonts = (Array.isArray(fonts) ? fonts : []).map(normalizeFontEntry).filter(function (entry) {
return Boolean(entry.fileName) && Boolean(normalizeText(entry.family || entry.name));
}).sort(sortFonts);
await fs.promises.mkdir(fontDir, { recursive: true });
await fs.promises.writeFile(manifestPath, JSON.stringify(normalizedFonts, null, 2) + '\n', 'utf8');
await fs.promises.writeFile(stylesheetPath, buildFontStylesheet(normalizedFonts), 'utf8');
return normalizedFonts;
}
function isSupportedFontUpload(file) {
const extension = path.extname(String(file && file.originalname || file && file.filename || '')).toLowerCase();
return FONT_FILE_EXTENSIONS.has(extension);
}
async function addFontFile(mediaRootOrUploadDir, uploadedFile, requestedFamily) {
if (!uploadedFile || !uploadedFile.path || !isSupportedFontUpload(uploadedFile)) {
throw new Error('Unsupported font upload.');
}
const fontDir = getFontLibraryDir(mediaRootOrUploadDir);
const fonts = readManifest(mediaRootOrUploadDir);
const family = normalizeText(requestedFamily) || normalizeText(path.basename(uploadedFile.originalname || uploadedFile.filename || '', path.extname(uploadedFile.originalname || uploadedFile.filename || '')));
if (!family) {
throw new Error('Font family is required.');
}
if (fonts.some(function (font) {
return String(font.family || font.name || '').trim().toLowerCase() === family.toLowerCase();
})) {
throw new Error('A font with that family name already exists.');
}
const extension = path.extname(uploadedFile.originalname || uploadedFile.filename || '').toLowerCase();
const slug = family.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'font';
const fileName = `${slug}-${Date.now()}-${crypto.randomUUID().slice(0, 8)}${extension}`;
const destinationPath = path.join(fontDir, fileName);
await fs.promises.mkdir(fontDir, { recursive: true });
await fs.promises.rename(uploadedFile.path, destinationPath);
const now = new Date().toISOString();
const nextFonts = fonts.concat({
id: fileName,
name: family,
family: family,
fileName: fileName,
format: FONT_FILE_EXTENSIONS.get(extension) || 'woff2',
enabled: true,
createdAt: now,
modifiedAt: now
});
await saveFontLibrary(mediaRootOrUploadDir, nextFonts);
return {
font: normalizeFontEntry(nextFonts[nextFonts.length - 1]),
fonts: loadFontLibrary(mediaRootOrUploadDir).fonts
};
}
async function deleteFontFile(mediaRootOrUploadDir, fontId) {
const fontDir = getFontLibraryDir(mediaRootOrUploadDir);
const fonts = readManifest(mediaRootOrUploadDir);
const normalizedId = normalizeText(fontId);
const index = fonts.findIndex(function (font) {
return String(font.id || font.fileName || '').trim() === normalizedId || String(font.fileName || '').trim() === normalizedId;
});
if (index === -1) {
return null;
}
const [removed] = fonts.splice(index, 1);
if (removed && removed.fileName) {
try {
await fs.promises.unlink(path.join(fontDir, removed.fileName));
} catch (_error) {
// ignore missing file cleanup issues
}
}
await saveFontLibrary(mediaRootOrUploadDir, fonts);
return normalizeFontEntry(removed);
}
async function setFontEnabled(mediaRootOrUploadDir, fontId, enabled) {
const fonts = readManifest(mediaRootOrUploadDir);
const normalizedId = normalizeText(fontId);
const index = fonts.findIndex(function (font) {
return String(font.id || font.fileName || '').trim() === normalizedId || String(font.fileName || '').trim() === normalizedId;
});
if (index === -1) {
return null;
}
const nextFonts = fonts.slice();
nextFonts[index] = Object.assign({}, nextFonts[index], {
enabled: Boolean(enabled),
modifiedAt: new Date().toISOString()
});
await saveFontLibrary(mediaRootOrUploadDir, nextFonts);
return normalizeFontEntry(nextFonts[index]);
}
function collectFontLibraryUploadPaths(mediaRootOrUploadDir) {
const fonts = readManifest(mediaRootOrUploadDir);
const uploadPaths = new Set();
const manifestPath = getFontManifestPath(mediaRootOrUploadDir);
const stylesheetPath = getFontStylesheetPath(mediaRootOrUploadDir);
const mediaRoot = resolveMediaRoot(mediaRootOrUploadDir);
if (manifestPath) {
uploadPaths.add('/media/' + path.relative(mediaRoot, manifestPath).replace(/\\/g, '/'));
}
if (stylesheetPath) {
uploadPaths.add('/media/' + path.relative(mediaRoot, stylesheetPath).replace(/\\/g, '/'));
}
fonts.forEach(function (font) {
if (font && font.fileName) {
uploadPaths.add(`/media/${FONT_LIBRARY_DIR_NAME}/${font.fileName}`);
}
});
return Array.from(uploadPaths);
}
async function collectFontLibraryDirectoryUploadPaths(mediaRootOrUploadDir) {
const fontDir = getFontLibraryDir(mediaRootOrUploadDir);
try {
const entries = await fs.promises.readdir(fontDir, { withFileTypes: true });
return entries
.filter(function (entry) {
return entry && entry.isFile();
})
.map(function (entry) {
return `/media/${FONT_LIBRARY_DIR_NAME}/${entry.name}`;
});
} catch (_error) {
return [];
}
}
function collectFontLibrarySyncOperations(mediaRootOrUploadDir) {
const fonts = readManifest(mediaRootOrUploadDir);
const operations = [
{ type: 'put', uploadPath: '/media/' + FONT_LIBRARY_DIR_NAME + '/' + FONT_MANIFEST_FILE },
{ type: 'put', uploadPath: '/media/' + FONT_LIBRARY_DIR_NAME + '/' + FONT_STYLESHEET_FILE }
];
fonts.forEach(function (font) {
if (!font || !font.fileName) {
return;
}
operations.push({
type: font.enabled === false ? 'delete' : 'put',
uploadPath: `/media/${FONT_LIBRARY_DIR_NAME}/${font.fileName}`
});
});
return operations;
}
module.exports = {
ensureFontLibrary: ensureFontLibrary,
loadFontLibrary: loadFontLibrary,
saveFontLibrary: saveFontLibrary,
addFontFile: addFontFile,
deleteFontFile: deleteFontFile,
setFontEnabled: setFontEnabled,
collectFontLibraryUploadPaths: collectFontLibraryUploadPaths,
collectFontLibraryDirectoryUploadPaths: collectFontLibraryDirectoryUploadPaths,
collectFontLibrarySyncOperations: collectFontLibrarySyncOperations,
getFontStylesheetHref: getFontStylesheetHref,
buildFontFamilyFormats: buildFontFamilyFormats,
isSupportedFontUpload: isSupportedFontUpload,
getFontLibraryDir: getFontLibraryDir,
getFontManifestPath: getFontManifestPath,
getFontStylesheetPath: getFontStylesheetPath
};
+7
View File
@@ -0,0 +1,7 @@
// Media helper entry point for upload sync and thumbnail capture.
module.exports = {
createUploadSyncService: require('./upload-sync').createUploadSyncService,
captureSlideThumbnail: require('./slide-thumbnails').captureSlideThumbnail,
fontLibrary: require('./font-library')
};
+293
View File
@@ -0,0 +1,293 @@
// Slide thumbnail capture helpers for player-sourced screenshots.
const fs = require('fs');
const path = require('path');
const puppeteer = require('puppeteer-core');
const chromiumModule = require('@sparticuz/chromium');
const sharp = require('sharp');
const chromium = chromiumModule && typeof chromiumModule.executablePath === 'function'
? chromiumModule
: chromiumModule && chromiumModule.default && typeof chromiumModule.default.executablePath === 'function'
? chromiumModule.default
: chromiumModule;
const {
escapeHtml,
mediaKind,
renderEditorJsContent,
sanitizeFontFamily,
sanitizeFontSize,
sanitizeTextColor
} = require('#src/player/render-helpers');
const { createRequestAuthHeaders } = require('#src/request-auth');
const SYSTEM_CHROMIUM_PATHS = [
process.env.PUPPETEER_EXECUTABLE_PATH,
process.env.CHROMIUM_PATH,
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/usr/local/bin/chromium',
'/snap/bin/chromium'
].filter(Boolean);
const PLAYER_VIEWPORT = {
width: 1920,
height: 1080,
deviceScaleFactor: 1
};
const THUMBNAIL_MAX_SIZE = {
width: 480,
height: 270
};
function normalizeBaseUrl(baseUrl) {
return String(baseUrl || '').trim().replace(/\/$/, '');
}
function resolveAssetUrl(baseUrl, value) {
const raw = String(value || '').trim();
if (!raw) {
return '';
}
if (/^(?:https?:)?\/\//i.test(raw) || raw.startsWith('data:')) {
return raw;
}
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
if (!normalizedBaseUrl) {
return raw;
}
if (raw.startsWith('/')) {
return normalizedBaseUrl + raw;
}
return normalizedBaseUrl + '/' + raw.replace(/^\/+/, '');
}
function getCanvasSize(slide) {
const template = slide && slide.template ? slide.template : null;
return {
width: Math.max(1, Number(template && template.canvas_size_width ? template.canvas_size_width : 1920)),
height: Math.max(1, Number(template && template.canvas_size_height ? template.canvas_size_height : 1080))
};
}
function getRegionContent(slide, region) {
const content = slide && slide.content && slide.content[region.region_key] ? slide.content[region.region_key] : {};
return content && typeof content === 'object' ? content : { value: content };
}
function hasVisibleContent(html) {
return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim());
}
function buildTextRegionMarkup(region, regionContent) {
const fontFamily = sanitizeFontFamily(regionContent.font_family || region.font_family);
const fontSize = sanitizeFontSize(regionContent.font_size || region.font_size);
const fontColor = sanitizeTextColor(regionContent.font_color || region.font_color);
const renderedBody = renderEditorJsContent(regionContent.value || '');
if (!hasVisibleContent(renderedBody)) {
return '';
}
return '<div class="template-region text" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor || '#000000') + ';">' + renderedBody + '</div></div>';
}
function buildRegionInnerHtml(region, regionContent, baseUrl) {
const regionType = String(regionContent.type || region.region_type || 'text').trim().toLowerCase();
const rawValue = regionContent && regionContent.value !== undefined ? regionContent.value : '';
if (regionType === 'image') {
const src = resolveAssetUrl(baseUrl, rawValue);
return src
? '<img src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'image') + '" />'
: '';
}
if (regionType === 'video') {
const src = resolveAssetUrl(baseUrl, rawValue);
return src
? '<video src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'video') + '" muted playsinline preload="metadata"></video>'
: '';
}
if (regionType === 'webpage') {
const src = resolveAssetUrl(baseUrl, rawValue);
return src
? '<iframe src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'webpage') + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>'
: '';
}
if (regionType === 'html') {
const html = String(rawValue || '').trim();
return html
? '<iframe sandbox="" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no"></iframe>'
: '';
}
if (regionType === 'rtmp') {
const label = String(rawValue || '').trim() || 'RTMP source';
return '<div class="template-region-rtmp-placeholder">' + escapeHtml(label) + '</div>';
}
return buildTextRegionMarkup(region, regionContent);
}
async function launchBrowser() {
let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) {
return fs.existsSync(candidate);
}) || '';
const usingSystemChromium = Boolean(executablePath);
if (!executablePath && chromium && typeof chromium.executablePath === 'function') {
executablePath = await chromium.executablePath();
}
if (!executablePath || !fs.existsSync(executablePath)) {
throw new Error('Chromium executable was not found.');
}
if (usingSystemChromium) {
return puppeteer.launch({
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu'
],
defaultViewport: { width: 1920, height: 1080, deviceScaleFactor: 1 },
executablePath: executablePath,
headless: true
});
}
return puppeteer.launch({
args: puppeteer.defaultArgs({
args: chromium && chromium.args ? chromium.args : [],
headless: 'shell'
}),
defaultViewport: chromium && chromium.defaultViewport ? chromium.defaultViewport : null,
executablePath: executablePath,
headless: 'shell'
});
}
async function captureSlideThumbnail(options) {
const pool = options && options.pool;
const common = options && options.common;
const mediaDir = String(options && options.mediaDir || '').trim();
const baseUrl = normalizeBaseUrl(options && options.baseUrl);
const slideId = Number(options && options.slideId || 0);
const previousThumbnailPath = String(options && options.previousThumbnailPath || '').trim();
if (!pool || !common || !mediaDir || !Number.isFinite(slideId) || slideId <= 0) {
throw new Error('captureSlideThumbnail requires pool, common, mediaDir, and slideId.');
}
const slide = await common.fetchSlideById(pool, slideId);
if (!slide) {
throw new Error('Slide not found.');
}
const canvasSize = getCanvasSize(slide);
const thumbnailDir = path.join(mediaDir, 'thumbnails');
const thumbnailRelativePath = String(slide.thumbnail_path || '').trim() || '/media/thumbnails/slides/slide-' + slide.id + '.png';
const filePath = path.join(mediaDir, thumbnailRelativePath.replace(/^\/+media\//, ''));
const fullSizePath = filePath.replace(/\.png$/i, '.full.png');
const thumbnailTempPath = filePath.replace(/\.png$/i, '.tmp.png');
const thumbnailPath = thumbnailRelativePath;
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
async function waitForThumbnailRender(page) {
await page.waitForFunction(function () {
return document.readyState === 'complete' && Boolean(document.querySelector('.slide-canvas'));
}, { timeout: 30000 });
await page.waitForFunction(function () {
var canvas = document.querySelector('.slide-canvas');
if (!canvas) {
return false;
}
var images = Array.prototype.slice.call(canvas.querySelectorAll('img'));
return images.every(function (image) {
return image.complete && typeof image.naturalWidth === 'number';
});
}, { timeout: 30000 });
await page.evaluate(async function () {
if (document.fonts && document.fonts.ready) {
try {
await document.fonts.ready;
} catch (_error) {
// Ignore font readiness failures and fall back to the rendered frame.
}
}
});
await page.evaluate(function () {
return new Promise(function (resolve) {
window.requestAnimationFrame(function () {
window.requestAnimationFrame(resolve);
});
});
});
}
const browser = await launchBrowser();
try {
const page = await browser.newPage();
try {
const previewPath = '/api/internal/slide-thumbnails/' + slide.id + '/preview';
const previewUrl = baseUrl + previewPath;
await page.setExtraHTTPHeaders(createRequestAuthHeaders({
method: 'GET',
pathname: previewPath
}));
await page.setViewport(PLAYER_VIEWPORT);
await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
await waitForThumbnailRender(page);
const canvas = await page.$('.slide-canvas');
if (!canvas) {
throw new Error('Player render did not produce a slide canvas.');
}
await canvas.screenshot({ path: fullSizePath });
} finally {
await page.close().catch(function () {
return null;
});
}
} finally {
await browser.close().catch(function () {
return null;
});
}
await sharp(fullSizePath)
.resize({
width: THUMBNAIL_MAX_SIZE.width,
height: THUMBNAIL_MAX_SIZE.height,
fit: 'inside',
withoutEnlargement: true
})
.png()
.toFile(thumbnailTempPath);
await fs.promises.rm(filePath, { force: true });
await fs.promises.rename(thumbnailTempPath, filePath);
await fs.promises.unlink(fullSizePath).catch(function (error) {
if (!error || error.code !== 'ENOENT') {
throw error;
}
});
await pool.query('UPDATE c_slides SET thumbnail_path = ? WHERE id = ?', [thumbnailPath, slide.id]);
return {
slideId: slide.id,
thumbnailPath: thumbnailPath,
filePath: filePath,
fullSizePath: fullSizePath,
mediaKind: mediaKind('')
};
}
module.exports = {
captureSlideThumbnail: captureSlideThumbnail
};
+728
View File
@@ -0,0 +1,728 @@
// Upload sync helpers for mirroring web media to player storage.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const multer = require('multer');
const { createRequestAuthHeaders } = require('#src/request-auth');
const { collectFontLibrarySyncOperations } = require('./font-library');
function normalizeUploadRoot(uploadDir) {
return path.resolve(String(uploadDir || '').trim());
}
function createUploadSyncService(options) {
const pool = options && options.pool;
const common = options && options.common;
const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
const playerSnapshotCache = options && options.playerSnapshotCache;
const notifyPlayerScreens = options && options.notifyPlayerScreens;
const backgroundTaskQueue = options && options.backgroundTaskQueue;
const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024;
const pendingPlayerUploadSyncs = new Map();
let pendingPlayerUploadSyncFlushTimer = null;
let pendingPlayerUploadSyncFlushInFlight = null;
const pendingPlaylistUploadSyncs = new Map();
let pendingPlaylistUploadSyncFlushTimer = null;
let pendingPlaylistUploadSyncFlushInFlight = null;
let playerInternalBaseUrl = null;
let playerInternalBaseUrlPromise = null;
if (!common || !playerSnapshotCache || typeof notifyPlayerScreens !== 'function') {
throw new Error('createUploadSyncService requires the upload dependencies.');
}
async function getPlayerInternalBaseUrl() {
if (playerInternalBaseUrl) {
return playerInternalBaseUrl;
}
if (playerInternalBaseUrlPromise) {
return playerInternalBaseUrlPromise;
}
playerInternalBaseUrlPromise = (async function () {
if (!pool) {
return configuredPlayerInternalBaseUrl || null;
}
try {
const [rows] = await pool.query(
`SELECT internal_base_url
FROM d_players
WHERE device_id = '1'
LIMIT 1`
);
const resolvedBaseUrl = String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '');
return resolvedBaseUrl || configuredPlayerInternalBaseUrl || null;
} catch (_error) {
return configuredPlayerInternalBaseUrl || null;
}
})().then(function (baseUrl) {
playerInternalBaseUrl = baseUrl || null;
playerInternalBaseUrlPromise = null;
return playerInternalBaseUrl;
}, function () {
playerInternalBaseUrlPromise = null;
return configuredPlayerInternalBaseUrl || null;
});
return playerInternalBaseUrlPromise;
}
function createUploadMiddleware(uploadDir) {
const storage = multer.diskStorage({
destination: function (_req, _file, cb) {
cb(null, uploadDir);
},
filename: function (_req, file, cb) {
const safeExt = path.extname(file.originalname || '').toLowerCase();
const stamp = `${Date.now()}-${crypto.randomUUID()}`;
cb(null, `${stamp}${safeExt}`);
}
});
return multer({
storage: storage,
limits: {
fileSize: MAX_UPLOAD_BYTES
}
});
}
function normalizeUploadReference(uploadPath) {
const value = String(uploadPath || '').trim();
if (!value || !value.startsWith('/media/')) {
return null;
}
return value;
}
function getUploadRelativePath(uploadPath) {
const value = normalizeUploadReference(uploadPath);
if (!value) {
return null;
}
return value.replace(/^\/media\//, '');
}
function resolveUploadFilePath(uploadDir, uploadPath) {
const relativePath = getUploadRelativePath(uploadPath);
if (!relativePath) {
return null;
}
const normalizedUploadDir = normalizeUploadRoot(uploadDir);
if (!normalizedUploadDir) {
return null;
}
const mediaRoot = path.basename(normalizedUploadDir) === 'uploads'
? path.dirname(normalizedUploadDir)
: normalizedUploadDir;
if (relativePath.startsWith('uploads/')) {
return path.join(mediaRoot, relativePath);
}
return path.join(mediaRoot, relativePath);
}
function collectUploadReferencesFromValue(value, refs) {
if (!value) {
return refs;
}
const stack = [value];
while (stack.length) {
const current = stack.pop();
if (Array.isArray(current)) {
current.forEach(function (item) {
stack.push(item);
});
continue;
}
if (current && typeof current === 'object') {
Object.keys(current).forEach(function (key) {
stack.push(current[key]);
});
continue;
}
if (typeof current === 'string') {
const reference = normalizeUploadReference(current);
if (reference) {
refs.add(reference);
}
}
}
return refs;
}
function collectUploadReferencesFromSlide(slide) {
const refs = new Set();
if (!slide) {
return refs;
}
collectUploadReferencesFromValue(common.parseJsonSafe(slide.content_json), refs);
return refs;
}
function collectUploadReferencesFromTemplate(template) {
const refs = new Set();
if (!template) {
return refs;
}
collectUploadReferencesFromValue(template.background_image_path, refs);
return refs;
}
function collectUploadReferencesFromPayload(payload) {
const refs = new Set();
if (!payload) {
return refs;
}
collectUploadReferencesFromValue(common.parseJsonSafe(payload.contentJson), refs);
collectUploadReferencesFromValue(payload.backgroundImagePath, refs);
return refs;
}
async function countUploadReferences(pool, uploadPath) {
const [slideRows] = await pool.query(
`SELECT COUNT(*) AS ref_count
FROM c_slides
WHERE JSON_SEARCH(COALESCE(content_json, JSON_OBJECT()), 'one', ?) IS NOT NULL`,
[uploadPath]
);
const [thumbnailRows] = await pool.query(
'SELECT COUNT(*) AS ref_count FROM c_slides WHERE thumbnail_path = ?',
[uploadPath]
);
const [templateRows] = await pool.query(
'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?',
[uploadPath]
);
return Number(slideRows[0].ref_count || 0) + Number(thumbnailRows[0].ref_count || 0) + Number(templateRows[0].ref_count || 0);
}
async function removeUnusedUploadFiles(pool, uploadDir, uploadPaths) {
const uniquePaths = Array.from(new Set((uploadPaths || []).map(normalizeUploadReference).filter(Boolean)));
for (let i = 0; i < uniquePaths.length; i += 1) {
const uploadPath = uniquePaths[i];
const referenceCount = await countUploadReferences(pool, uploadPath);
if (referenceCount > 0) {
continue;
}
const filePath = resolveUploadFilePath(uploadDir, uploadPath);
try {
await fs.promises.unlink(filePath);
} catch (error) {
if (error && error.code !== 'ENOENT') {
console.warn('Unable to remove unused upload file:', filePath, error);
}
}
queuePlayerUploadSync({
type: 'delete',
uploadPath: uploadPath,
uploadDir: uploadDir
});
}
}
async function collectUploadPathsFromDirectory(uploadDir) {
const normalizedUploadDir = normalizeUploadRoot(uploadDir);
if (!normalizedUploadDir) {
return [];
}
const uploadPaths = [];
async function walkDirectory(currentDir, relativeDir) {
let entries = [];
try {
entries = await fs.promises.readdir(currentDir, { withFileTypes: true });
} catch (error) {
if (error && error.code !== 'ENOENT') {
console.warn('Unable to read upload directory:', currentDir, error);
}
return;
}
for (const entry of entries) {
const entryName = String(entry && entry.name || '').trim();
if (!entryName || entryName === '.' || entryName === '..') {
continue;
}
const nextRelativePath = relativeDir ? path.posix.join(relativeDir, entryName) : entryName;
const nextAbsolutePath = path.join(currentDir, entryName);
if (entry.isDirectory && entry.isDirectory()) {
await walkDirectory(nextAbsolutePath, nextRelativePath);
continue;
}
if (entry.isFile && !entry.isFile()) {
continue;
}
uploadPaths.push('/media/uploads/' + nextRelativePath.replace(/\\/g, '/'));
}
}
await walkDirectory(normalizedUploadDir, '');
return uploadPaths;
}
function shouldMirrorUploads(localUploadDir) {
return Boolean(localUploadDir);
}
function queuePlayerUploadSync(operation) {
if (!operation || !operation.uploadPath) {
return;
}
pendingPlayerUploadSyncs.set(normalizeUploadReference(operation.uploadPath), {
type: operation.type === 'delete' ? 'delete' : 'put',
uploadPath: normalizeUploadReference(operation.uploadPath),
uploadDir: operation.uploadDir || null
});
schedulePendingPlayerUploadSyncFlush();
}
function schedulePendingPlayerUploadSyncFlush() {
if (pendingPlayerUploadSyncFlushTimer) {
return;
}
pendingPlayerUploadSyncFlushTimer = setTimeout(function () {
pendingPlayerUploadSyncFlushTimer = null;
flushPendingPlayerUploadSyncs().catch(function (error) {
console.warn('Unable to flush pending upload syncs:', error);
});
}, 5000);
}
async function pushUploadFileToPlayer(uploadPath, localUploadDir) {
if (!uploadPath || !shouldMirrorUploads(localUploadDir)) {
return false;
}
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
if (!resolvedPlayerInternalBaseUrl) {
return false;
}
const relativePath = getUploadRelativePath(uploadPath);
const sourcePath = resolveUploadFilePath(localUploadDir, uploadPath);
if (!relativePath || !sourcePath) {
return false;
}
let fileBuffer = null;
try {
fileBuffer = await fs.promises.readFile(sourcePath);
} catch (error) {
if (!error || error.code !== 'ENOENT') {
console.warn('Unable to read upload for player sync:', sourcePath, error);
}
return;
}
try {
const authHeaders = createRequestAuthHeaders({
method: 'PUT',
pathname: `/api/media/${encodeURIComponent(relativePath)}`,
body: fileBuffer
});
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
...authHeaders
},
body: fileBuffer
});
if (!response.ok) {
console.warn('Unable to sync upload to player:', relativePath, response.status, response.statusText);
return false;
}
return true;
} catch (error) {
console.warn('Unable to sync upload to player:', relativePath, error);
return false;
}
}
async function removeUploadFileFromPlayer(uploadPath, localUploadDir) {
if (!uploadPath || !shouldMirrorUploads(localUploadDir)) {
return false;
}
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
if (!resolvedPlayerInternalBaseUrl) {
return false;
}
const relativePath = getUploadRelativePath(uploadPath);
if (!relativePath) {
return false;
}
try {
const authHeaders = createRequestAuthHeaders({
method: 'DELETE',
pathname: `/api/media/${encodeURIComponent(relativePath)}`
});
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
method: 'DELETE',
headers: {
Accept: 'application/json',
...authHeaders
}
});
if (!response.ok && response.status !== 404) {
console.warn('Unable to remove upload from player:', relativePath, response.status, response.statusText);
return false;
}
return true;
} catch (error) {
console.warn('Unable to remove upload from player:', relativePath, error);
return false;
}
}
async function syncUploadRefsToPlayer(uploadRefs, localUploadDir) {
if (!shouldMirrorUploads(localUploadDir)) {
return;
}
const uniqueRefs = Array.from(new Set((uploadRefs || []).map(normalizeUploadReference).filter(Boolean)));
for (let i = 0; i < uniqueRefs.length; i += 1) {
const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir);
if (!success) {
queuePlayerUploadSync({
type: 'put',
uploadPath: uniqueRefs[i],
uploadDir: localUploadDir
});
}
}
}
function getVisibleCurrentSlideIds() {
const visibleSlideIds = new Set();
playerSnapshotCache.forEach(function (snapshot) {
const connections = snapshot && Array.isArray(snapshot.connections) ? snapshot.connections : [];
connections.forEach(function (connection) {
const currentSlide = connection && connection.currentSlide && typeof connection.currentSlide === 'object'
? connection.currentSlide
: null;
const slideId = currentSlide && currentSlide.id !== undefined && currentSlide.id !== null
? String(currentSlide.id).trim()
: '';
if (slideId) {
visibleSlideIds.add(slideId);
}
});
});
return visibleSlideIds;
}
function isScreenRefreshBlocked(screenSlug, blockedSlideIds, screenSlideCounts) {
const slideIds = Array.isArray(blockedSlideIds)
? blockedSlideIds.map(function (value) {
return String(value || '').trim();
}).filter(Boolean)
: [];
if (!slideIds.length) {
return false;
}
const normalizedScreenSlug = String(screenSlug || '').trim();
const slideCount = screenSlideCounts && Object.prototype.hasOwnProperty.call(screenSlideCounts, normalizedScreenSlug)
? Number(screenSlideCounts[normalizedScreenSlug])
: null;
if (Number.isFinite(slideCount) && slideCount <= 1) {
return false;
}
const snapshot = playerSnapshotCache.get(String(screenSlug || '').trim());
const connections = snapshot && Array.isArray(snapshot.connections) ? snapshot.connections : [];
return connections.some(function (connection) {
const currentSlide = connection && connection.currentSlide && typeof connection.currentSlide === 'object'
? connection.currentSlide
: null;
const currentSlideId = currentSlide && currentSlide.id !== undefined && currentSlide.id !== null
? String(currentSlide.id).trim()
: '';
return currentSlideId && slideIds.includes(currentSlideId);
});
}
function splitRefreshScreenSlugsByVisibility(screenSlugs, blockedSlideIds, screenSlideCounts) {
const ready = [];
const blocked = [];
Array.from(new Set(Array.isArray(screenSlugs) ? screenSlugs : [])).forEach(function (screenSlug) {
const normalizedScreenSlug = String(screenSlug || '').trim();
if (!normalizedScreenSlug) {
return;
}
if (isScreenRefreshBlocked(normalizedScreenSlug, blockedSlideIds, screenSlideCounts)) {
blocked.push(normalizedScreenSlug);
} else {
ready.push(normalizedScreenSlug);
}
});
return { ready: ready, blocked: blocked };
}
function normalizePlaylistUploadSyncOperation(options) {
return {
key: String(options && options.key ? options.key : '').trim(),
pool: options && options.pool ? options.pool : null,
localUploadDir: options && options.localUploadDir ? options.localUploadDir : null,
previousUploadRefs: Array.from(new Set(options && options.previousUploadRefs ? options.previousUploadRefs : [])),
nextUploadRefs: Array.from(new Set(options && options.nextUploadRefs ? options.nextUploadRefs : [])),
blockedSlideIds: Array.from(new Set(options && options.blockedSlideIds ? options.blockedSlideIds : [])).map(function (value) {
return String(value || '').trim();
}).filter(Boolean),
refreshScreenSlugs: Array.from(new Set(options && options.refreshScreenSlugs ? options.refreshScreenSlugs : [])).map(function (value) {
return String(value || '').trim();
}).filter(Boolean)
,
screenSlideCounts: options && options.screenSlideCounts && typeof options.screenSlideCounts === 'object'
? options.screenSlideCounts
: {}
};
}
function queuePlaylistUploadSync(operation) {
if (!operation || !operation.key) {
return;
}
pendingPlaylistUploadSyncs.set(operation.key, normalizePlaylistUploadSyncOperation(operation));
schedulePendingPlaylistUploadSyncFlush();
}
function schedulePendingPlaylistUploadSyncFlush() {
if (pendingPlaylistUploadSyncFlushTimer) {
return;
}
pendingPlaylistUploadSyncFlushTimer = setTimeout(function () {
pendingPlaylistUploadSyncFlushTimer = null;
flushPendingPlaylistUploadSyncs().catch(function (error) {
console.warn('Unable to flush pending playlist upload syncs:', error);
});
}, 5000);
}
async function syncPlaylistUploadsOnChange(options) {
const operation = normalizePlaylistUploadSyncOperation(options);
if (!operation.key) {
return;
}
return queueMediaSyncTask('media-sync:' + operation.key, 'Media sync', {
mode: 'playlist',
operation: operation
});
}
async function flushPendingPlaylistUploadSyncs() {
if (pendingPlaylistUploadSyncFlushInFlight) {
return pendingPlaylistUploadSyncFlushInFlight;
}
if (!pendingPlaylistUploadSyncs.size) {
return null;
}
pendingPlaylistUploadSyncFlushInFlight = (async function () {
const pendingEntries = Array.from(pendingPlaylistUploadSyncs.values());
for (let i = 0; i < pendingEntries.length; i += 1) {
const operation = pendingEntries[i];
const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts);
if (!refreshTargets.ready.length) {
continue;
}
if (refreshTargets.ready.length) {
await notifyPlayerScreens(refreshTargets.ready, 'refresh');
}
pendingPlaylistUploadSyncs.delete(operation.key);
}
})().finally(function () {
pendingPlaylistUploadSyncFlushInFlight = null;
if (pendingPlaylistUploadSyncs.size) {
schedulePendingPlaylistUploadSyncFlush();
}
});
return pendingPlaylistUploadSyncFlushInFlight;
}
async function flushPendingPlayerUploadSyncs() {
if (pendingPlayerUploadSyncFlushInFlight) {
return pendingPlayerUploadSyncFlushInFlight;
}
if (!pendingPlayerUploadSyncs.size) {
return null;
}
pendingPlayerUploadSyncFlushInFlight = (async function () {
const pendingEntries = Array.from(pendingPlayerUploadSyncs.values());
for (let i = 0; i < pendingEntries.length; i += 1) {
const operation = pendingEntries[i];
let success = false;
if (operation.type === 'delete') {
success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir);
} else {
success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir);
}
if (success) {
pendingPlayerUploadSyncs.delete(operation.uploadPath);
}
}
})().finally(function () {
pendingPlayerUploadSyncFlushInFlight = null;
if (pendingPlayerUploadSyncs.size) {
schedulePendingPlayerUploadSyncFlush();
}
});
return pendingPlayerUploadSyncFlushInFlight;
}
async function runMediaSyncTask(payload) {
const taskPayload = payload || {};
const mode = String(taskPayload.mode || '').trim();
if (mode === 'initial') {
const uploadDir = String(taskPayload.uploadDir || '').trim();
if (!(await shouldMirrorUploads(uploadDir))) {
return;
}
const data = await common.fetchAdminData(pool);
const uploadRefs = new Set();
(data.slides || []).forEach(function (slide) {
collectUploadReferencesFromSlide(slide).forEach(function (reference) {
uploadRefs.add(reference);
});
});
(data.templates || []).forEach(function (template) {
collectUploadReferencesFromTemplate(template).forEach(function (reference) {
uploadRefs.add(reference);
});
});
const fontLibraryOperations = collectFontLibrarySyncOperations(uploadDir);
Array.from(uploadRefs).forEach(function (uploadPath) {
queuePlayerUploadSync({
type: 'put',
uploadPath: uploadPath,
uploadDir: uploadDir
});
});
fontLibraryOperations.forEach(function (operation) {
if (!operation || !operation.uploadPath) {
return;
}
queuePlayerUploadSync({
type: String(operation.type || 'put').trim().toLowerCase() === 'delete' ? 'delete' : 'put',
uploadPath: operation.uploadPath,
uploadDir: uploadDir
});
});
await flushPendingPlayerUploadSyncs();
return;
}
if (mode === 'playlist') {
const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload);
if (operation.nextUploadRefs.length) {
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
}
if (operation.previousUploadRefs.length) {
const nextUploadRefSet = new Set(operation.nextUploadRefs);
await removeUnusedUploadFiles(pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) {
return !nextUploadRefSet.has(reference);
}));
}
if (operation.refreshScreenSlugs.length) {
const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts);
if (refreshTargets.ready.length) {
await notifyPlayerScreens(refreshTargets.ready, 'refresh');
}
refreshTargets.blocked.forEach(function (screenSlug) {
queuePlaylistUploadSync({
key: operation.key + ':refresh:' + screenSlug,
blockedSlideIds: operation.blockedSlideIds,
refreshScreenSlugs: [screenSlug]
});
});
}
return;
}
throw new Error('Unknown media sync task mode.');
}
async function queueMediaSyncTask(taskKey, title, payload) {
const safePayload = Object.assign({}, payload || {});
delete safePayload.pool;
if (safePayload.operation && typeof safePayload.operation === 'object') {
safePayload.operation = Object.assign({}, safePayload.operation);
delete safePayload.operation.pool;
}
const definition = {
key: taskKey,
title: title,
category: 'media-sync',
taskType: 'media-sync',
payload: safePayload,
persist: true
};
if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTaskAndWait === 'function') {
return backgroundTaskQueue.enqueueTaskAndWait(definition);
}
return runMediaSyncTask(safePayload);
}
return {
createUploadMiddleware: createUploadMiddleware,
normalizeUploadReference: normalizeUploadReference,
collectUploadReferencesFromValue: collectUploadReferencesFromValue,
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
countUploadReferences: countUploadReferences,
removeUnusedUploadFiles: removeUnusedUploadFiles,
collectUploadPathsFromDirectory: collectUploadPathsFromDirectory,
shouldMirrorUploads: shouldMirrorUploads,
queuePlayerUploadSync: queuePlayerUploadSync,
schedulePendingPlayerUploadSyncFlush: schedulePendingPlayerUploadSyncFlush,
pushUploadFileToPlayer: pushUploadFileToPlayer,
removeUploadFileFromPlayer: removeUploadFileFromPlayer,
syncUploadRefsToPlayer: syncUploadRefsToPlayer,
getVisibleCurrentSlideIds: getVisibleCurrentSlideIds,
isScreenRefreshBlocked: isScreenRefreshBlocked,
splitRefreshScreenSlugsByVisibility: splitRefreshScreenSlugsByVisibility,
normalizePlaylistUploadSyncOperation: normalizePlaylistUploadSyncOperation,
queuePlaylistUploadSync: queuePlaylistUploadSync,
schedulePendingPlaylistUploadSyncFlush: schedulePendingPlaylistUploadSyncFlush,
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
flushPendingPlaylistUploadSyncs: flushPendingPlaylistUploadSyncs,
flushPendingPlayerUploadSyncs: flushPendingPlayerUploadSyncs,
runMediaSyncTask: runMediaSyncTask,
queueMediaSyncTask: queueMediaSyncTask
};
}
module.exports = { createUploadSyncService };