1067 lines
40 KiB
JavaScript
1067 lines
40 KiB
JavaScript
// 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 appendPlayerDeviceIdToUrl(baseUrl, playerIdentifier) {
|
|
const targetBaseUrl = normalizeBaseUrl(baseUrl);
|
|
const deviceId = String(playerIdentifier || '').trim();
|
|
if (!targetBaseUrl || !deviceId) {
|
|
return targetBaseUrl;
|
|
}
|
|
|
|
try {
|
|
const url = new URL(targetBaseUrl);
|
|
url.searchParams.set('deviceId', deviceId);
|
|
return url.toString().replace(/\/$/, '');
|
|
} catch (_error) {
|
|
return targetBaseUrl;
|
|
}
|
|
}
|
|
|
|
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 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 || !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 (!relativeDir && entryName === 'player-cache') {
|
|
continue;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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 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 = appendPlayerDeviceIdToUrl(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, playerMetadata && playerMetadata.playerIdentifier);
|
|
const response = await fetch(mediaUploadUrl, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/octet-stream',
|
|
...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 relativePath = getUploadRelativePath(uploadPath);
|
|
if (!relativePath) {
|
|
return false;
|
|
}
|
|
try {
|
|
const authHeaders = createRequestAuthHeaders({
|
|
method: 'DELETE',
|
|
pathname: `/api/media/${encodeURIComponent(relativePath)}`
|
|
});
|
|
const mediaDeleteUrl = appendPlayerDeviceIdToUrl(`${targetBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, playerMetadata && playerMetadata.playerIdentifier);
|
|
const response = await fetch(mediaDeleteUrl, {
|
|
method: 'DELETE',
|
|
headers: {
|
|
Accept: 'application/json',
|
|
...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);
|
|
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);
|
|
|
|
if (operation.nextUploadRefs.length) {
|
|
await syncUploadRefsToPlayer(operation.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;
|
|
}
|
|
|
|
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 }; |