From 90db0f933d4b30d77cfa30781a1d0a3fef7d7d60 Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Sat, 1 Aug 2026 21:52:50 +0100 Subject: [PATCH] Fix ignored media source module --- .gitignore | 2 + src/web/lib/media/font-library.js | 371 +++++++++++++ src/web/lib/media/index.js | 7 + src/web/lib/media/slide-thumbnails.js | 293 +++++++++++ src/web/lib/media/upload-sync.js | 728 ++++++++++++++++++++++++++ 5 files changed, 1401 insertions(+) create mode 100644 src/web/lib/media/font-library.js create mode 100644 src/web/lib/media/index.js create mode 100644 src/web/lib/media/slide-thumbnails.js create mode 100644 src/web/lib/media/upload-sync.js 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 '
' + renderedBody + '
'; +} + +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 + ? '' + escapeHtml(region.label || region.region_key || 'image') + '' + : ''; + } + + 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