diff --git a/.gitignore b/.gitignore
index b4df7a2..b18d49c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,7 @@
node_modules/
media/
+!src/web/lib/media/
+!src/web/lib/media/**
docker-compose.dev.yml
.vscode/
.env
diff --git a/src/web/lib/media/font-library.js b/src/web/lib/media/font-library.js
new file mode 100644
index 0000000..37c94b8
--- /dev/null
+++ b/src/web/lib/media/font-library.js
@@ -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
+};
\ No newline at end of file
diff --git a/src/web/lib/media/index.js b/src/web/lib/media/index.js
new file mode 100644
index 0000000..a5e522f
--- /dev/null
+++ b/src/web/lib/media/index.js
@@ -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')
+};
\ No newline at end of file
diff --git a/src/web/lib/media/slide-thumbnails.js b/src/web/lib/media/slide-thumbnails.js
new file mode 100644
index 0000000..8cee565
--- /dev/null
+++ b/src/web/lib/media/slide-thumbnails.js
@@ -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 '
';
+}
+
+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
+ ? '
'
+ : '';
+ }
+
+ if (regionType === 'video') {
+ const src = resolveAssetUrl(baseUrl, rawValue);
+ return src
+ ? ''
+ : '';
+ }
+
+ if (regionType === 'webpage') {
+ const src = resolveAssetUrl(baseUrl, rawValue);
+ return src
+ ? ''
+ : '';
+ }
+
+ if (regionType === 'html') {
+ const html = String(rawValue || '').trim();
+ return html
+ ? ''
+ : '';
+ }
+
+ if (regionType === 'rtmp') {
+ const label = String(rawValue || '').trim() || 'RTMP source';
+ return '' + escapeHtml(label) + '
';
+ }
+
+ 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
+};
\ No newline at end of file
diff --git a/src/web/lib/media/upload-sync.js b/src/web/lib/media/upload-sync.js
new file mode 100644
index 0000000..2ba27fc
--- /dev/null
+++ b/src/web/lib/media/upload-sync.js
@@ -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 };
\ No newline at end of file