// 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'); const { fetchPlayerRegistrations, getConfiguredPlayerIdentifier } = require('#src/data/player-registry'); function normalizeUploadRoot(uploadDir) { return path.resolve(String(uploadDir || '').trim()); } function isLocalLikeBaseUrl(value) { let host = ''; try { host = new URL(String(value || '').trim().replace(/\/$/, '')).hostname.toLowerCase(); } catch (_error) { return false; } return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === 'host.docker.internal' || host === 'player' || host === 'web' || host === 'player-bridge' || host.endsWith('.local') || host.endsWith('.internal') || host.endsWith('.docker.internal'); } function normalizeBaseUrl(value) { return String(value || '').trim().replace(/\/$/, ''); } function normalizePlayerRowBaseUrl(player) { return normalizeBaseUrl(player && player.internal_base_url); } function isRecentPlayerRegistration(player, staleSeconds) { const lastSeenAt = player && player.last_seen_at; const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime(); const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000; return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime; } function createUploadSyncService(options) { const pool = options && options.pool; const common = options && options.common; const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, ''); const configuredBridgeInternalBaseUrl = String(options && options.bridgeInternalBaseUrl || '').trim().replace(/\/$/, ''); const playerSnapshotCache = options && options.playerSnapshotCache; const notifyPlayerScreens = options && options.notifyPlayerScreens; const backgroundTaskQueue = options && options.backgroundTaskQueue; const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024; const MAX_FIELD_BYTES = 10 * 1024 * 1024; const PLAYER_UPLOAD_SYNC_RETRY_LOG_INTERVAL_MS = 60000; const pendingPlayerUploadSyncs = new Map(); let pendingPlayerUploadSyncFlushTimer = null; let pendingPlayerUploadSyncFlushInFlight = null; let pendingPlayerUploadSyncRetryLogAt = 0; const pendingPlaylistUploadSyncs = new Map(); let pendingPlaylistUploadSyncFlushTimer = null; let pendingPlaylistUploadSyncFlushInFlight = null; let playerInternalBaseUrl = null; let playerInternalBaseUrlPromise = null; let playerTaskMetadata = null; let playerTaskMetadataPromise = null; if (!common || !playerSnapshotCache || typeof notifyPlayerScreens !== 'function') { throw new Error('createUploadSyncService requires the upload dependencies.'); } async function getPlayerInternalBaseUrl(preferredPlayerIdentifier, preferredPlayerInternalBaseUrl) { const metadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, preferredPlayerInternalBaseUrl); if (!metadata || metadata.playerActive === false) { return null; } return metadata.playerInternalBaseUrl ? metadata.playerInternalBaseUrl : null; } async function getPlayerTaskMetadata(preferredPlayerIdentifier, preferredPlayerInternalBaseUrl) { const normalizedPreferredPlayerIdentifier = String(preferredPlayerIdentifier || '').trim(); const normalizedPreferredPlayerInternalBaseUrl = String(preferredPlayerInternalBaseUrl || '').trim().replace(/\/$/, ''); if (normalizedPreferredPlayerIdentifier && pool && typeof fetchPlayerRegistrations === 'function') { try { const players = await fetchPlayerRegistrations(pool); const registeredPlayers = Array.isArray(players) ? players : []; const exactPlayer = registeredPlayers.find(function (player) { return String(player && player.identifier || '').trim() === normalizedPreferredPlayerIdentifier; }) || null; if (exactPlayer) { const resolvedInternalBaseUrl = normalizePlayerRowBaseUrl(exactPlayer) || normalizedPreferredPlayerInternalBaseUrl || null; const resolvedPublicBaseUrl = normalizeBaseUrl(exactPlayer && exactPlayer.public_base_url); const resolvedIdentifier = String(exactPlayer && exactPlayer.identifier || '').trim(); playerInternalBaseUrl = resolvedInternalBaseUrl || null; playerTaskMetadata = { playerIdentifier: resolvedIdentifier || normalizedPreferredPlayerIdentifier || null, playerPublicBaseUrl: resolvedPublicBaseUrl || null, playerInternalBaseUrl: resolvedInternalBaseUrl || null, playerLabel: resolvedIdentifier || resolvedPublicBaseUrl || resolvedInternalBaseUrl || null, playerActive: true }; return playerTaskMetadata; } } catch (_error) { } } if (playerTaskMetadata && playerTaskMetadata.playerActive !== false) { return playerTaskMetadata; } if (playerTaskMetadata && playerTaskMetadata.playerActive === false) { playerTaskMetadata = null; playerInternalBaseUrl = null; } if (playerTaskMetadataPromise) { return playerTaskMetadataPromise; } playerTaskMetadataPromise = (async function () { try { if (pool && typeof fetchPlayerRegistrations === 'function') { const configuredPlayerIdentifier = getConfiguredPlayerIdentifier(); const players = await fetchPlayerRegistrations(pool); const registeredPlayers = Array.isArray(players) ? players : []; const recentPlayers = registeredPlayers.filter(function (player) { return isRecentPlayerRegistration(player, 60); }); const preferredPlayer = recentPlayers.find(function (player) { const internalBaseUrl = normalizePlayerRowBaseUrl(player); return internalBaseUrl && !isLocalLikeBaseUrl(internalBaseUrl); }) || recentPlayers.find(function (player) { return String(player && player.identifier || '').trim() === configuredPlayerIdentifier; }) || recentPlayers[0] || null; if (preferredPlayer) { const resolvedInternalBaseUrl = normalizePlayerRowBaseUrl(preferredPlayer); const resolvedPublicBaseUrl = normalizeBaseUrl(preferredPlayer && preferredPlayer.public_base_url); const resolvedIdentifier = String(preferredPlayer && preferredPlayer.identifier || '').trim(); playerInternalBaseUrl = resolvedInternalBaseUrl || null; playerTaskMetadata = { playerIdentifier: resolvedIdentifier || null, playerPublicBaseUrl: resolvedPublicBaseUrl || null, playerInternalBaseUrl: resolvedInternalBaseUrl || null, playerLabel: resolvedIdentifier || resolvedPublicBaseUrl || resolvedInternalBaseUrl || null, playerActive: true }; return playerTaskMetadata; } if (registeredPlayers.length) { const stalePlayer = registeredPlayers.find(function (player) { return String(player && player.identifier || '').trim() === configuredPlayerIdentifier; }) || registeredPlayers[0] || null; const staleInternalBaseUrl = normalizePlayerRowBaseUrl(stalePlayer); const stalePublicBaseUrl = normalizeBaseUrl(stalePlayer && stalePlayer.public_base_url); const staleIdentifier = String(stalePlayer && stalePlayer.identifier || '').trim(); playerInternalBaseUrl = staleInternalBaseUrl || null; playerTaskMetadata = { playerIdentifier: staleIdentifier || null, playerPublicBaseUrl: stalePublicBaseUrl || null, playerInternalBaseUrl: staleInternalBaseUrl || null, playerLabel: staleIdentifier || stalePublicBaseUrl || staleInternalBaseUrl || null, playerActive: false }; return playerTaskMetadata; } } } catch (_error) { } playerInternalBaseUrl = configuredPlayerInternalBaseUrl || null; playerTaskMetadata = { playerIdentifier: getConfiguredPlayerIdentifier() || null, playerPublicBaseUrl: null, playerInternalBaseUrl: playerInternalBaseUrl, playerLabel: getConfiguredPlayerIdentifier() || playerInternalBaseUrl || null, playerActive: true }; return playerTaskMetadata; })().then(function (metadata) { playerTaskMetadataPromise = null; return metadata || null; }, function () { playerTaskMetadataPromise = null; return { playerIdentifier: getConfiguredPlayerIdentifier() || null, playerPublicBaseUrl: null, playerInternalBaseUrl: configuredPlayerInternalBaseUrl || null, playerLabel: getConfiguredPlayerIdentifier() || configuredPlayerInternalBaseUrl || null }; }); return playerTaskMetadataPromise; } function formatPlayerTaskLabel(metadata) { const playerLabel = String(metadata && metadata.playerLabel || '').trim(); if (playerLabel) { return playerLabel; } const playerIdentifier = String(metadata && metadata.playerIdentifier || '').trim(); if (playerIdentifier) { return playerIdentifier; } const playerPublicBaseUrl = String(metadata && metadata.playerPublicBaseUrl || '').trim(); if (playerPublicBaseUrl) { return playerPublicBaseUrl; } return ''; } function logMediaSyncSummary(level, message, metadata, details) { const suffix = formatPlayerTaskLabel(metadata); const logger = level === 'warn' ? console.warn : console.info; if (details !== undefined) { logger(`[media-sync] ${message}${suffix ? ` for ${suffix}` : ''}`, details); return; } logger(`[media-sync] ${message}${suffix ? ` for ${suffix}` : ''}`); } 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, fieldSize: MAX_FIELD_BYTES } }); } function normalizeUploadReference(uploadPath) { const value = String(uploadPath || '').trim(); if (!value) { return null; } if (value.startsWith('/media/')) { return value; } try { const parsed = new URL(value); return parsed.pathname.startsWith('/media/') ? parsed.pathname : null; } catch (_error) { return null; } } 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); } const embeddedReferences = current.match(/\/media\/[^"'\s<>)]+/g) || []; embeddedReferences.forEach(function (embeddedReference) { const normalizedReference = normalizeUploadReference(embeddedReference); if (normalizedReference) { refs.add(normalizedReference); } }); } } 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 uploadFileExists(uploadDir, uploadPath) { const filePath = resolveUploadFilePath(uploadDir, uploadPath); if (!filePath) { return false; } try { await fs.promises.access(filePath, fs.constants.F_OK); return true; } catch (_error) { return false; } } async function removeUnusedUploadFiles(pool, uploadDir, uploadPaths) { return; } 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; } const normalizedRelativePath = nextRelativePath.replace(/\\/g, '/'); if (normalizedRelativePath.startsWith('player-cache/') && !normalizedRelativePath.startsWith('player-cache/remote-images/')) { continue; } uploadPaths.push((normalizedRelativePath.startsWith('player-cache/') ? '/media/' : '/media/uploads/') + normalizedRelativePath); } } await walkDirectory(normalizedUploadDir, ''); return uploadPaths; } function shouldMirrorUploads(localUploadDir) { return Boolean(localUploadDir); } async function fetchLivePlayerRegistrations() { if (!pool || typeof fetchPlayerRegistrations !== 'function') { return []; } try { const players = await fetchPlayerRegistrations(pool); return Array.isArray(players) ? players.filter(function (player) { return isRecentPlayerRegistration(player, 60); }) : []; } catch (_error) { return []; } } function isPlayerUnavailableError(error) { const code = String(error && error.cause && error.cause.code || error && error.code || '').trim().toUpperCase(); return code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'EAI_AGAIN' || code === 'ETIMEDOUT'; } function isPlayerUnavailableResponse(response) { return Boolean(response) && Number(response.status) === 503; } function buildPendingPlayerUploadSyncKey(operation) { const uploadPath = normalizeUploadReference(operation && operation.uploadPath); const metadata = operation && operation.metadata && typeof operation.metadata === 'object' ? operation.metadata : null; const playerIdentifier = String((operation && operation.playerIdentifier) || (metadata && metadata.playerIdentifier) || '').trim(); const playerInternalBaseUrl = normalizeBaseUrl((operation && operation.playerInternalBaseUrl) || (metadata && metadata.playerInternalBaseUrl) || ''); return [uploadPath, playerIdentifier, playerInternalBaseUrl].filter(Boolean).join('|'); } function queuePlayerUploadSync(operation) { if (!operation || !operation.uploadPath) { return; } const metadata = operation.metadata && typeof operation.metadata === 'object' ? operation.metadata : null; const playerIdentifier = String((operation && operation.playerIdentifier) || (metadata && metadata.playerIdentifier) || '').trim(); const playerInternalBaseUrl = normalizeBaseUrl((operation && operation.playerInternalBaseUrl) || (metadata && metadata.playerInternalBaseUrl) || ''); pendingPlayerUploadSyncs.set(buildPendingPlayerUploadSyncKey(operation), { type: operation.type === 'delete' ? 'delete' : 'put', uploadPath: normalizeUploadReference(operation.uploadPath), uploadDir: operation.uploadDir || null, metadata: metadata, playerIdentifier: playerIdentifier || null, playerInternalBaseUrl: playerInternalBaseUrl || 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); if (pendingPlayerUploadSyncFlushTimer && typeof pendingPlayerUploadSyncFlushTimer.unref === 'function') { pendingPlayerUploadSyncFlushTimer.unref(); } } async function pushUploadFileToPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier) { if (!uploadPath || !shouldMirrorUploads(localUploadDir)) { return false; } const targetBaseUrl = String(resolvedPlayerInternalBaseUrl || '').trim() || await getPlayerInternalBaseUrl(); if (!targetBaseUrl) { return false; } const playerMetadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, targetBaseUrl); const mediaBaseUrl = playerMetadata && playerMetadata.playerIdentifier && !isLocalLikeBaseUrl(targetBaseUrl) && configuredBridgeInternalBaseUrl ? configuredBridgeInternalBaseUrl : targetBaseUrl; 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 mediaUploadUrl = `${mediaBaseUrl}/api/media/${encodeURIComponent(relativePath)}`; const playerDeviceId = String(playerMetadata && playerMetadata.playerIdentifier || '').trim(); const response = await fetch(mediaUploadUrl, { method: 'PUT', headers: { 'Content-Type': 'application/octet-stream', ...(playerDeviceId ? { 'x-pulse-player-device-id': playerDeviceId } : {}), ...authHeaders }, body: fileBuffer }); if (!response.ok) { if (!isPlayerUnavailableResponse(response)) { console.warn('Unable to sync upload to player:', relativePath, response.status, response.statusText); } return false; } return true; } catch (error) { if (!isPlayerUnavailableError(error)) { console.warn('Unable to sync upload to player:', relativePath, error); } return false; } } async function removeUploadFileFromPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier) { if (!uploadPath || !shouldMirrorUploads(localUploadDir)) { return false; } const targetBaseUrl = String(resolvedPlayerInternalBaseUrl || '').trim() || await getPlayerInternalBaseUrl(); if (!targetBaseUrl) { return false; } const playerMetadata = await getPlayerTaskMetadata(preferredPlayerIdentifier, targetBaseUrl); const mediaBaseUrl = playerMetadata && playerMetadata.playerIdentifier && !isLocalLikeBaseUrl(targetBaseUrl) && configuredBridgeInternalBaseUrl ? configuredBridgeInternalBaseUrl : targetBaseUrl; const relativePath = getUploadRelativePath(uploadPath); if (!relativePath) { return false; } try { const authHeaders = createRequestAuthHeaders({ method: 'DELETE', pathname: `/api/media/${encodeURIComponent(relativePath)}` }); const mediaDeleteUrl = `${mediaBaseUrl}/api/media/${encodeURIComponent(relativePath)}`; const playerDeviceId = String(playerMetadata && playerMetadata.playerIdentifier || '').trim(); const response = await fetch(mediaDeleteUrl, { method: 'DELETE', headers: { Accept: 'application/json', ...(playerDeviceId ? { 'x-pulse-player-device-id': playerDeviceId } : {}), ...authHeaders } }); if (!response.ok && response.status !== 404) { if (!isPlayerUnavailableResponse(response)) { console.warn('Unable to remove upload from player:', relativePath, response.status, response.statusText); } return false; } return true; } catch (error) { if (!isPlayerUnavailableError(error)) { console.warn('Unable to remove upload from player:', relativePath, error); } return false; } } async function syncUploadRefsToPlayer(uploadRefs, localUploadDir, preferredPlayerIdentifier, preferredPlayerInternalBaseUrl) { if (!shouldMirrorUploads(localUploadDir)) { return; } const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(preferredPlayerIdentifier, preferredPlayerInternalBaseUrl); 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, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier); if (!success) { queuePlayerUploadSync({ type: 'put', uploadPath: uniqueRefs[i], uploadDir: localUploadDir, playerIdentifier: preferredPlayerIdentifier, playerInternalBaseUrl: preferredPlayerInternalBaseUrl, metadata: preferredPlayerIdentifier || preferredPlayerInternalBaseUrl ? { playerIdentifier: preferredPlayerIdentifier || null, playerInternalBaseUrl: preferredPlayerInternalBaseUrl || null } : null }); } } } 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; } const players = await fetchLivePlayerRegistrations(); if (!players.length) { return queueMediaSyncTask('media-sync:' + operation.key, 'Media sync', { mode: 'playlist', operation: operation }); } return Promise.all(players.map(function (player) { const playerIdentifier = String(player && player.identifier || '').trim(); const playerInternalBaseUrl = String(player && player.internal_base_url || '').trim().replace(/\/$/, ''); const playerPublicBaseUrl = String(player && player.public_base_url || '').trim().replace(/\/$/, ''); return queueMediaSyncTask('media-sync:' + operation.key + (playerIdentifier ? ':' + playerIdentifier : ''), 'Media sync', { mode: 'playlist', operation: operation, playerIdentifier: playerIdentifier || null, playerInternalBaseUrl: playerInternalBaseUrl || null, playerPublicBaseUrl: playerPublicBaseUrl || null }); })); } async function flushPendingPlaylistUploadSyncs() { if (pendingPlaylistUploadSyncFlushInFlight) { return pendingPlaylistUploadSyncFlushInFlight; } if (pendingPlaylistUploadSyncFlushTimer) { clearTimeout(pendingPlaylistUploadSyncFlushTimer); pendingPlaylistUploadSyncFlushTimer = null; } 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); } if (pendingEntries.length) { logMediaSyncSummary('info', `Playlist sync flushed ${pendingEntries.length} task${pendingEntries.length === 1 ? '' : 's'}`, pendingEntries[0] && pendingEntries[0].metadata); } })().finally(function () { pendingPlaylistUploadSyncFlushInFlight = null; if (pendingPlaylistUploadSyncs.size) { schedulePendingPlaylistUploadSyncFlush(); } }); return pendingPlaylistUploadSyncFlushInFlight; } async function flushPendingPlayerUploadSyncs() { if (pendingPlayerUploadSyncFlushInFlight) { return pendingPlayerUploadSyncFlushInFlight; } if (pendingPlayerUploadSyncFlushTimer) { clearTimeout(pendingPlayerUploadSyncFlushTimer); pendingPlayerUploadSyncFlushTimer = null; } if (!pendingPlayerUploadSyncs.size) { return null; } pendingPlayerUploadSyncFlushInFlight = (async function () { const pendingEntries = Array.from(pendingPlayerUploadSyncs.entries()); const firstOperation = pendingEntries.length && pendingEntries[0] ? pendingEntries[0][1] : null; const summaryPlayerMetadata = firstOperation && firstOperation.metadata ? firstOperation.metadata : await getPlayerTaskMetadata(firstOperation && firstOperation.playerIdentifier, firstOperation && firstOperation.playerInternalBaseUrl); let successCount = 0; let failureCount = 0; for (let i = 0; i < pendingEntries.length; i += 1) { const entry = pendingEntries[i]; const pendingKey = entry[0]; const operation = entry[1]; const playerMetadata = operation && operation.metadata ? operation.metadata : await getPlayerTaskMetadata(operation && operation.playerIdentifier, operation && operation.playerInternalBaseUrl); if (playerMetadata && playerMetadata.playerActive === false) { pendingPlayerUploadSyncs.delete(pendingKey); continue; } const resolvedPlayerInternalBaseUrl = playerMetadata && playerMetadata.playerInternalBaseUrl ? playerMetadata.playerInternalBaseUrl : await getPlayerInternalBaseUrl(playerMetadata && playerMetadata.playerIdentifier, playerMetadata && playerMetadata.playerInternalBaseUrl); let success = false; if (operation.type === 'delete') { success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl, playerMetadata && playerMetadata.playerIdentifier); } else { success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir, resolvedPlayerInternalBaseUrl, playerMetadata && playerMetadata.playerIdentifier); } if (success) { successCount += 1; pendingPlayerUploadSyncs.delete(pendingKey); } else { failureCount += 1; } } if (successCount) { logMediaSyncSummary('info', `Media sync completed ${successCount} upload${successCount === 1 ? '' : 's'}`, summaryPlayerMetadata); } if (failureCount) { const now = Date.now(); if (!pendingPlayerUploadSyncRetryLogAt || now - pendingPlayerUploadSyncRetryLogAt >= PLAYER_UPLOAD_SYNC_RETRY_LOG_INTERVAL_MS) { pendingPlayerUploadSyncRetryLogAt = now; logMediaSyncSummary('warn', `Player unavailable, retry queued for ${failureCount} upload${failureCount === 1 ? '' : 's'}`, summaryPlayerMetadata); } } else if (!pendingPlayerUploadSyncs.size) { pendingPlayerUploadSyncRetryLogAt = 0; } })().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 playerMetadata = await getPlayerTaskMetadata(taskPayload.playerIdentifier, taskPayload.playerInternalBaseUrl); 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); const generatedCachePaths = await collectUploadPathsFromDirectory(uploadDir); generatedCachePaths.filter(function (uploadPath) { return String(uploadPath || '').startsWith('/media/player-cache/remote-images/'); }).forEach(function (uploadPath) { uploadRefs.add(uploadPath); }); Array.from(uploadRefs).forEach(function (uploadPath) { queuePlayerUploadSync({ type: 'put', uploadPath: uploadPath, uploadDir: uploadDir, metadata: playerMetadata }); }); 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, metadata: playerMetadata }); }); await flushPendingPlayerUploadSyncs(); return; } if (mode === 'playlist') { const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload); const generatedCachePaths = await collectUploadPathsFromDirectory(operation.localUploadDir); const nextUploadRefs = operation.nextUploadRefs.concat(generatedCachePaths.filter(function (uploadPath) { return String(uploadPath || '').startsWith('/media/player-cache/remote-images/'); })); if (nextUploadRefs.length) { await syncUploadRefsToPlayer(nextUploadRefs, operation.localUploadDir, taskPayload.playerIdentifier, taskPayload.playerInternalBaseUrl); } if (operation.previousUploadRefs.length) { const nextUploadRefSet = new Set(operation.nextUploadRefs); const removedUploadRefs = operation.previousUploadRefs.filter(function (reference) { return !nextUploadRefSet.has(reference); }); for (let i = 0; i < removedUploadRefs.length; i += 1) { const removedUploadRef = removedUploadRefs[i]; const referenceCount = await countUploadReferences(pool, removedUploadRef); if (referenceCount > 0) { continue; } if (isLocalLikeBaseUrl(taskPayload.playerInternalBaseUrl) && await uploadFileExists(operation.localUploadDir, removedUploadRef)) { continue; } const deleted = await removeUploadFileFromPlayer(removedUploadRef, operation.localUploadDir, taskPayload.playerInternalBaseUrl, taskPayload.playerIdentifier); if (!deleted) { queuePlayerUploadSync({ type: 'delete', uploadPath: removedUploadRef, uploadDir: operation.localUploadDir, playerIdentifier: taskPayload.playerIdentifier, playerInternalBaseUrl: taskPayload.playerInternalBaseUrl, metadata: playerMetadata }); } } await removeUnusedUploadFiles(pool, operation.localUploadDir, removedUploadRefs); } 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 playerMetadata = await getPlayerTaskMetadata(safePayload.playerIdentifier, safePayload.playerInternalBaseUrl); const definition = { key: taskKey, title: title, category: 'media-sync', taskType: 'media-sync', metadata: Object.assign({}, playerMetadata || {}), 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, getPlayerTaskMetadata: getPlayerTaskMetadata }; } module.exports = { createUploadSyncService };