Release v2.2.0
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
// Entry point for the web auth helper modules.
|
||||
|
||||
module.exports = {
|
||||
createSessionService: require('./session').createSessionService,
|
||||
rbacData: require('./rbac-data')
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
const { PERMISSIONS, normalizePermissionKeys } = require('../../rbac');
|
||||
const { fetchPagedRows } = require('../../data/utils');
|
||||
// RBAC data access helpers for roles, permissions, and user-role links.
|
||||
|
||||
const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac');
|
||||
const { fetchPagedRows } = require('#src/data/utils');
|
||||
|
||||
function parseCsvIds(value) {
|
||||
return String(value || '')
|
||||
@@ -1,10 +1,13 @@
|
||||
const { normalizePermissionKeys } = require('../../rbac');
|
||||
// Session cookie and current-user helpers for the web auth flow.
|
||||
|
||||
const { normalizePermissionKeys } = require('#src/rbac');
|
||||
|
||||
function createSessionService(options) {
|
||||
const sessionCookieName = String(options && options.sessionCookieName || '').trim();
|
||||
const sessionMaxAgeMs = Number(options && options.sessionMaxAgeMs);
|
||||
const hashSessionToken = options && options.hashSessionToken;
|
||||
const createSessionToken = options && options.createSessionToken;
|
||||
const authMessageCookieName = 'pulse_auth_message';
|
||||
|
||||
if (!sessionCookieName || !Number.isFinite(sessionMaxAgeMs) || typeof hashSessionToken !== 'function' || typeof createSessionToken !== 'function') {
|
||||
throw new Error('createSessionService requires the session dependencies.');
|
||||
@@ -39,12 +42,37 @@ function createSessionService(options) {
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
||||
function appendCookieHeader(res, cookieValue) {
|
||||
const existingCookieHeader = res.getHeader('Set-Cookie');
|
||||
if (!existingCookieHeader) {
|
||||
res.setHeader('Set-Cookie', cookieValue);
|
||||
return;
|
||||
}
|
||||
|
||||
const cookies = Array.isArray(existingCookieHeader) ? existingCookieHeader.slice() : [existingCookieHeader];
|
||||
cookies.push(cookieValue);
|
||||
res.setHeader('Set-Cookie', cookies);
|
||||
}
|
||||
|
||||
function clearSessionCookie(res) {
|
||||
res.setHeader('Set-Cookie', serializeCookie(sessionCookieName, '', { maxAge: 0 }));
|
||||
appendCookieHeader(res, serializeCookie(sessionCookieName, '', { maxAge: 0 }));
|
||||
}
|
||||
|
||||
function setSessionCookie(res, token) {
|
||||
res.setHeader('Set-Cookie', serializeCookie(sessionCookieName, token, { maxAge: sessionMaxAgeMs }));
|
||||
appendCookieHeader(res, serializeCookie(sessionCookieName, token, { maxAge: sessionMaxAgeMs }));
|
||||
}
|
||||
|
||||
function setAuthMessageCookie(res, message) {
|
||||
appendCookieHeader(res, serializeCookie(authMessageCookieName, message, { maxAge: 60 * 1000 }));
|
||||
}
|
||||
|
||||
function consumeAuthMessageCookie(req, res) {
|
||||
const cookies = parseCookies(req.headers.cookie || '');
|
||||
const message = String(cookies[authMessageCookieName] || '').trim();
|
||||
if (message) {
|
||||
appendCookieHeader(res, serializeCookie(authMessageCookieName, '', { maxAge: 0 }));
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
async function loadCurrentUser(pool, req) {
|
||||
@@ -113,7 +141,8 @@ function createSessionService(options) {
|
||||
if (req.currentUser) {
|
||||
return next();
|
||||
}
|
||||
res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||
setAuthMessageCookie(res, 'Please sign in to continue.');
|
||||
res.redirect('/login');
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -121,6 +150,8 @@ function createSessionService(options) {
|
||||
serializeCookie: serializeCookie,
|
||||
clearSessionCookie: clearSessionCookie,
|
||||
setSessionCookie: setSessionCookie,
|
||||
setAuthMessageCookie: setAuthMessageCookie,
|
||||
consumeAuthMessageCookie: consumeAuthMessageCookie,
|
||||
loadCurrentUser: loadCurrentUser,
|
||||
createUserSession: createUserSession,
|
||||
requireAuth: requireAuth
|
||||
@@ -1,93 +0,0 @@
|
||||
function registerBackgroundTaskHandlers(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const captureSlideThumbnail = options && options.captureSlideThumbnail;
|
||||
const refreshApiSource = options && options.refreshApiSource;
|
||||
const refreshRssFeed = options && options.refreshRssFeed;
|
||||
const runMediaSyncTask = options && options.runMediaSyncTask;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').trim();
|
||||
|
||||
if (!pool || !common || !backgroundTaskQueue || !captureSlideThumbnail || !refreshApiSource || !refreshRssFeed || !runMediaSyncTask || !mediaDir || !playerInternalBaseUrl) {
|
||||
throw new Error('registerBackgroundTaskHandlers requires the background task dependencies.');
|
||||
}
|
||||
|
||||
backgroundTaskQueue.setTaskHandler('media-sync', function (task) {
|
||||
return runMediaSyncTask(task && task.payload ? task.payload : task);
|
||||
});
|
||||
|
||||
backgroundTaskQueue.setTaskHandler('data-source-refresh', async function (task) {
|
||||
const payload = task && task.payload ? task.payload : {};
|
||||
const sourceType = String(payload.sourceType || '').trim();
|
||||
const sourceId = Number(payload.sourceId || 0);
|
||||
|
||||
if (sourceType === 'api-source') {
|
||||
const apiSource = await common.fetchApiSourceById(pool, sourceId);
|
||||
if (!apiSource) {
|
||||
throw new Error('API source not found.');
|
||||
}
|
||||
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, Number(payload.actorId) || null);
|
||||
}
|
||||
|
||||
if (sourceType === 'rss-feed') {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, sourceId);
|
||||
if (!rssFeed) {
|
||||
throw new Error('RSS feed not found.');
|
||||
}
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null);
|
||||
}
|
||||
|
||||
throw new Error('Unsupported data source refresh task.');
|
||||
});
|
||||
|
||||
backgroundTaskQueue.setTaskHandler('slide-thumbnail-refresh', async function (task) {
|
||||
const payload = task && task.payload ? task.payload : {};
|
||||
const slideId = Number(payload.slideId || 0);
|
||||
if (!Number.isFinite(slideId) || slideId <= 0) {
|
||||
throw new Error('Slide id is required.');
|
||||
}
|
||||
|
||||
return captureSlideThumbnail({
|
||||
pool: pool,
|
||||
common: common,
|
||||
mediaDir: mediaDir,
|
||||
baseUrl: playerInternalBaseUrl,
|
||||
slideId: slideId,
|
||||
previousThumbnailPath: payload.previousThumbnailPath || null
|
||||
});
|
||||
});
|
||||
|
||||
backgroundTaskQueue.setTaskHandler('template-slide-thumbnail-refresh', async function (task) {
|
||||
const payload = task && task.payload ? task.payload : {};
|
||||
const templateId = Number(payload.templateId || 0);
|
||||
if (!Number.isFinite(templateId) || templateId <= 0) {
|
||||
throw new Error('Template id is required.');
|
||||
}
|
||||
|
||||
const [slides] = await pool.query(
|
||||
'SELECT id, thumbnail_path FROM c_slides WHERE template_id = ? ORDER BY id ASC',
|
||||
[templateId]
|
||||
);
|
||||
|
||||
for (const slide of slides || []) {
|
||||
const slideId = Number(slide && slide.id || 0);
|
||||
if (!Number.isFinite(slideId) || slideId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await captureSlideThumbnail({
|
||||
pool: pool,
|
||||
common: common,
|
||||
mediaDir: mediaDir,
|
||||
baseUrl: playerInternalBaseUrl,
|
||||
slideId: slideId,
|
||||
previousThumbnailPath: slide && slide.thumbnail_path ? slide.thumbnail_path : null
|
||||
});
|
||||
}
|
||||
|
||||
return { templateId: templateId, slideCount: Array.isArray(slides) ? slides.length : 0 };
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerBackgroundTaskHandlers };
|
||||
@@ -1,774 +0,0 @@
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function toIsoDate(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
|
||||
}
|
||||
|
||||
function normalizeIntervalMs(value, unit) {
|
||||
const numericValue = Math.max(1, Number(value) || 0);
|
||||
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase();
|
||||
if (normalizedUnit === 'seconds') {
|
||||
return numericValue * 1000;
|
||||
}
|
||||
return numericValue * 60 * 1000;
|
||||
}
|
||||
|
||||
function createBackgroundTaskQueue(options) {
|
||||
const pool = options && options.pool;
|
||||
const maxConcurrent = Math.max(1, Number(options && options.maxConcurrent) || 1);
|
||||
const taskHandlers = new Map();
|
||||
const tasksById = new Map();
|
||||
const recurringJobsByKey = new Map();
|
||||
const taskIdToRecurringKey = new Map();
|
||||
const pendingIds = [];
|
||||
let nextTaskId = 1;
|
||||
let activeCount = 0;
|
||||
let drainScheduled = false;
|
||||
let initializationPromise = null;
|
||||
|
||||
function createTaskCompletionController() {
|
||||
let resolveCompletion = null;
|
||||
let rejectCompletion = null;
|
||||
const completionPromise = new Promise(function (resolve, reject) {
|
||||
resolveCompletion = resolve;
|
||||
rejectCompletion = reject;
|
||||
});
|
||||
|
||||
return {
|
||||
promise: completionPromise,
|
||||
resolve: resolveCompletion,
|
||||
reject: rejectCompletion
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonValue(value, fallback) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(String(value));
|
||||
} catch (_error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyJsonValue(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function buildSnapshot(task) {
|
||||
return {
|
||||
id: task.id,
|
||||
key: task.key,
|
||||
taskType: task.taskType || '',
|
||||
title: task.title,
|
||||
category: task.category,
|
||||
status: task.status,
|
||||
createdAt: task.createdAt,
|
||||
startedAt: task.startedAt,
|
||||
finishedAt: task.finishedAt,
|
||||
errorMessage: task.errorMessage,
|
||||
attempts: task.attempts || 0,
|
||||
metadata: task.metadata,
|
||||
payload: task.payload || null
|
||||
};
|
||||
}
|
||||
|
||||
function getTaskById(taskId) {
|
||||
const numericTaskId = Number(taskId);
|
||||
if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return tasksById.get(numericTaskId) || null;
|
||||
}
|
||||
|
||||
function buildTaskFromRow(row) {
|
||||
const task = {
|
||||
id: Number(row.id),
|
||||
key: String(row.task_key || '').trim(),
|
||||
taskType: String(row.task_type || '').trim(),
|
||||
title: String(row.title || 'Background task').trim() || 'Background task',
|
||||
category: String(row.category || 'general').trim() || 'general',
|
||||
status: String(row.status || 'queued').trim() || 'queued',
|
||||
createdAt: row.created_at ? new Date(row.created_at).toISOString() : '',
|
||||
startedAt: row.started_at ? new Date(row.started_at).toISOString() : '',
|
||||
finishedAt: row.finished_at ? new Date(row.finished_at).toISOString() : '',
|
||||
errorMessage: String(row.error_message || ''),
|
||||
attempts: Math.max(0, Number(row.attempts) || 0),
|
||||
metadata: parseJsonValue(row.metadata_json, {}),
|
||||
payload: parseJsonValue(row.payload_json, null),
|
||||
completionPromise: null,
|
||||
resolveCompletion: null,
|
||||
rejectCompletion: null,
|
||||
persisted: true,
|
||||
run: typeof row.run === 'function' ? row.run : function () {
|
||||
return Promise.resolve();
|
||||
}
|
||||
};
|
||||
|
||||
const completionController = createTaskCompletionController();
|
||||
task.completionPromise = completionController.promise;
|
||||
task.resolveCompletion = completionController.resolve;
|
||||
task.rejectCompletion = completionController.reject;
|
||||
return task;
|
||||
}
|
||||
|
||||
function buildTaskRecord(task) {
|
||||
return {
|
||||
task_key: task.key || null,
|
||||
task_type: task.taskType || 'general',
|
||||
title: task.title,
|
||||
category: task.category || 'general',
|
||||
status: task.status,
|
||||
payload_json: stringifyJsonValue(task.payload),
|
||||
metadata_json: stringifyJsonValue(task.metadata),
|
||||
attempts: Math.max(0, Number(task.attempts) || 0),
|
||||
created_at: task.createdAt ? new Date(task.createdAt) : new Date(),
|
||||
started_at: task.startedAt ? new Date(task.startedAt) : null,
|
||||
finished_at: task.finishedAt ? new Date(task.finishedAt) : null,
|
||||
error_message: task.errorMessage || null
|
||||
};
|
||||
}
|
||||
|
||||
async function persistTaskInsert(task) {
|
||||
if (!pool || !task.taskType) {
|
||||
return task;
|
||||
}
|
||||
|
||||
const record = buildTaskRecord(task);
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO o_background_tasks (
|
||||
task_key,
|
||||
task_type,
|
||||
title,
|
||||
category,
|
||||
status,
|
||||
payload_json,
|
||||
metadata_json,
|
||||
attempts,
|
||||
created_at,
|
||||
started_at,
|
||||
finished_at,
|
||||
error_message
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
record.task_key,
|
||||
record.task_type,
|
||||
record.title,
|
||||
record.category,
|
||||
record.status,
|
||||
record.payload_json,
|
||||
record.metadata_json,
|
||||
record.attempts,
|
||||
record.created_at,
|
||||
record.started_at,
|
||||
record.finished_at,
|
||||
record.error_message
|
||||
]
|
||||
);
|
||||
|
||||
task.id = Number(result.insertId);
|
||||
task.persisted = true;
|
||||
return task;
|
||||
}
|
||||
|
||||
async function persistTaskUpdate(task) {
|
||||
if (!pool || !task.persisted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = buildTaskRecord(task);
|
||||
await pool.query(
|
||||
`UPDATE o_background_tasks
|
||||
SET task_key = ?, task_type = ?, title = ?, category = ?, status = ?, payload_json = ?, metadata_json = ?, attempts = ?, started_at = ?, finished_at = ?, error_message = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
record.task_key,
|
||||
record.task_type,
|
||||
record.title,
|
||||
record.category,
|
||||
record.status,
|
||||
record.payload_json,
|
||||
record.metadata_json,
|
||||
record.attempts,
|
||||
record.started_at,
|
||||
record.finished_at,
|
||||
record.error_message,
|
||||
task.id
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async function persistTaskDelete(taskId) {
|
||||
if (!pool) {
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM o_background_tasks WHERE id = ?', [taskId]);
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
if (initializationPromise) {
|
||||
return initializationPromise;
|
||||
}
|
||||
|
||||
initializationPromise = (async function () {
|
||||
if (!pool) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, task_key, task_type, title, category, status, payload_json, metadata_json, attempts, created_at, started_at, finished_at, error_message FROM o_background_tasks ORDER BY id ASC'
|
||||
);
|
||||
|
||||
let highestTaskId = 0;
|
||||
for (const row of rows || []) {
|
||||
const task = buildTaskFromRow(row);
|
||||
if (!Number.isInteger(task.id) || task.id <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
highestTaskId = Math.max(highestTaskId, task.id);
|
||||
tasksById.set(task.id, task);
|
||||
|
||||
if (task.status === 'running') {
|
||||
task.status = 'queued';
|
||||
task.startedAt = '';
|
||||
task.finishedAt = '';
|
||||
task.errorMessage = '';
|
||||
await pool.query(
|
||||
'UPDATE o_background_tasks SET status = ?, started_at = NULL, finished_at = NULL, error_message = NULL WHERE id = ?',
|
||||
['queued', task.id]
|
||||
);
|
||||
}
|
||||
|
||||
if (task.status === 'queued' || task.status === 'running') {
|
||||
pendingIds.push(task.id);
|
||||
}
|
||||
}
|
||||
|
||||
nextTaskId = Math.max(nextTaskId, highestTaskId + 1);
|
||||
if (pendingIds.length) {
|
||||
scheduleDrain();
|
||||
}
|
||||
})();
|
||||
|
||||
return initializationPromise;
|
||||
}
|
||||
|
||||
function scheduleDrain() {
|
||||
if (drainScheduled) {
|
||||
return;
|
||||
}
|
||||
|
||||
drainScheduled = true;
|
||||
setTimeout(function () {
|
||||
drainScheduled = false;
|
||||
processQueue();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function clearRecurringTimer(job) {
|
||||
if (job && job.timerId) {
|
||||
clearTimeout(job.timerId);
|
||||
job.timerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function setTaskHandler(taskType, handler) {
|
||||
const normalizedTaskType = normalizeText(taskType);
|
||||
if (!normalizedTaskType || typeof handler !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
taskHandlers.set(normalizedTaskType, handler);
|
||||
return true;
|
||||
}
|
||||
|
||||
function scheduleRecurringRun(job, delayMs) {
|
||||
if (!job || job.enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearRecurringTimer(job);
|
||||
const safeDelay = Math.max(1, Number(delayMs) || job.intervalMs || 0);
|
||||
job.nextRunAt = toIsoDate(new Date(Date.now() + safeDelay));
|
||||
job.timerId = setTimeout(function () {
|
||||
job.timerId = null;
|
||||
triggerRecurringJob(job.key);
|
||||
}, safeDelay);
|
||||
}
|
||||
|
||||
function triggerRecurringJob(recurringKey) {
|
||||
const job = recurringJobsByKey.get(recurringKey);
|
||||
if (!job || job.enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (job.activeTaskId && tasksById.has(job.activeTaskId)) {
|
||||
scheduleRecurringRun(job, job.intervalMs);
|
||||
return;
|
||||
}
|
||||
|
||||
job.activeTaskId = -1;
|
||||
enqueueTask({
|
||||
key: `${job.key}:${Date.now()}`,
|
||||
title: job.title,
|
||||
category: job.category,
|
||||
taskType: job.taskType || '',
|
||||
metadata: Object.assign({}, job.metadata || {}, {
|
||||
recurringKey: job.key,
|
||||
recurringTitle: job.title
|
||||
}),
|
||||
payload: Object.assign({}, job.payload || {}, {
|
||||
recurringKey: job.key
|
||||
}),
|
||||
run: job.run,
|
||||
persist: Boolean(job.taskType)
|
||||
}).then(function (task) {
|
||||
if (task && Number.isInteger(task.id)) {
|
||||
job.activeTaskId = task.id;
|
||||
taskIdToRecurringKey.set(task.id, job.key);
|
||||
} else {
|
||||
job.activeTaskId = null;
|
||||
}
|
||||
}).catch(function (error) {
|
||||
job.activeTaskId = null;
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
scheduleRecurringRun(job, job.intervalMs);
|
||||
}
|
||||
|
||||
function runRecurringTask(recurringKey) {
|
||||
const normalizedKey = normalizeText(recurringKey);
|
||||
if (!normalizedKey || !recurringJobsByKey.has(normalizedKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
triggerRecurringJob(normalizedKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
function syncRecurringTaskState(task, status, errorMessage) {
|
||||
const recurringKey = taskIdToRecurringKey.get(task.id) || (task && task.metadata && task.metadata.recurringKey);
|
||||
if (!recurringKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const job = recurringJobsByKey.get(recurringKey);
|
||||
if (!job) {
|
||||
taskIdToRecurringKey.delete(task.id);
|
||||
return;
|
||||
}
|
||||
|
||||
job.activeTaskId = null;
|
||||
job.lastRunAt = toIsoDate(new Date());
|
||||
job.lastStatus = status;
|
||||
job.lastError = errorMessage ? String(errorMessage) : '';
|
||||
taskIdToRecurringKey.delete(task.id);
|
||||
}
|
||||
|
||||
async function processQueue() {
|
||||
while (activeCount < maxConcurrent) {
|
||||
const nextTaskId = pendingIds.shift();
|
||||
if (!nextTaskId) {
|
||||
break;
|
||||
}
|
||||
|
||||
const task = tasksById.get(nextTaskId);
|
||||
if (!task || task.status !== 'queued') {
|
||||
continue;
|
||||
}
|
||||
|
||||
activeCount += 1;
|
||||
task.status = 'running';
|
||||
task.startedAt = toIsoDate(new Date());
|
||||
task.errorMessage = '';
|
||||
task.attempts = Math.max(0, Number(task.attempts) || 0) + 1;
|
||||
|
||||
try {
|
||||
await persistTaskUpdate(task);
|
||||
} catch (error) {
|
||||
task.status = 'failed';
|
||||
task.errorMessage = String(error && error.message ? error.message : 'Unable to update task state.');
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
if (typeof task.rejectCompletion === 'function') {
|
||||
const completionError = new Error(task.errorMessage);
|
||||
completionError.task = buildSnapshot(task);
|
||||
task.rejectCompletion(completionError);
|
||||
}
|
||||
activeCount = Math.max(0, activeCount - 1);
|
||||
scheduleDrain();
|
||||
continue;
|
||||
}
|
||||
|
||||
Promise.resolve()
|
||||
.then(function () {
|
||||
if (task.taskType) {
|
||||
const handler = taskHandlers.get(task.taskType);
|
||||
if (!handler) {
|
||||
throw new Error('No handler registered for task type ' + task.taskType + '.');
|
||||
}
|
||||
|
||||
return handler({
|
||||
id: task.id,
|
||||
key: task.key,
|
||||
title: task.title,
|
||||
category: task.category,
|
||||
taskType: task.taskType,
|
||||
payload: task.payload,
|
||||
metadata: task.metadata,
|
||||
attempts: task.attempts
|
||||
});
|
||||
}
|
||||
|
||||
return task.run({
|
||||
id: task.id,
|
||||
key: task.key,
|
||||
title: task.title,
|
||||
category: task.category,
|
||||
metadata: task.metadata
|
||||
});
|
||||
})
|
||||
.then(function () {
|
||||
task.status = 'completed';
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
if (typeof task.resolveCompletion === 'function') {
|
||||
task.resolveCompletion(buildSnapshot(task));
|
||||
}
|
||||
syncRecurringTaskState(task, task.status, '');
|
||||
return persistTaskUpdate(task);
|
||||
})
|
||||
.catch(function (error) {
|
||||
task.status = 'failed';
|
||||
task.errorMessage = String(error && error.message ? error.message : 'Background task failed.');
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
if (typeof task.rejectCompletion === 'function') {
|
||||
const completionError = new Error(task.errorMessage);
|
||||
completionError.task = buildSnapshot(task);
|
||||
task.rejectCompletion(completionError);
|
||||
}
|
||||
syncRecurringTaskState(task, task.status, task.errorMessage);
|
||||
return persistTaskUpdate(task);
|
||||
})
|
||||
.finally(function () {
|
||||
activeCount = Math.max(0, activeCount - 1);
|
||||
scheduleDrain();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function enqueueTask(definition) {
|
||||
const normalizedKey = normalizeText(definition && definition.key);
|
||||
const normalizedTitle = normalizeText(definition && definition.title) || 'Background task';
|
||||
const normalizedTaskType = normalizeText(definition && definition.taskType);
|
||||
const shouldPersist = Boolean((definition && definition.persist) || (pool && normalizedTaskType));
|
||||
const existingTask = normalizedKey
|
||||
? Array.from(tasksById.values()).find(function (task) {
|
||||
return task.key === normalizedKey && task.status === 'queued' && (!normalizedTaskType || task.taskType === normalizedTaskType);
|
||||
})
|
||||
: null;
|
||||
|
||||
if (existingTask) {
|
||||
existingTask.title = normalizedTitle;
|
||||
existingTask.category = normalizeText(definition && definition.category) || existingTask.category || 'general';
|
||||
existingTask.metadata = definition && definition.metadata ? definition.metadata : {};
|
||||
existingTask.taskType = normalizedTaskType || existingTask.taskType || '';
|
||||
existingTask.payload = definition && definition.payload !== undefined ? definition.payload : existingTask.payload;
|
||||
existingTask.run = typeof definition.run === 'function' ? definition.run : existingTask.run;
|
||||
existingTask.createdAt = toIsoDate(new Date());
|
||||
existingTask.errorMessage = '';
|
||||
existingTask.persisted = existingTask.persisted || shouldPersist;
|
||||
await persistTaskUpdate(existingTask);
|
||||
return buildSnapshot(existingTask);
|
||||
}
|
||||
|
||||
const task = {
|
||||
id: shouldPersist ? 0 : nextTaskId,
|
||||
key: normalizedKey,
|
||||
taskType: normalizedTaskType,
|
||||
title: normalizedTitle,
|
||||
category: normalizeText(definition && definition.category) || 'general',
|
||||
status: 'queued',
|
||||
createdAt: toIsoDate(new Date()),
|
||||
startedAt: '',
|
||||
finishedAt: '',
|
||||
errorMessage: '',
|
||||
attempts: 0,
|
||||
metadata: definition && definition.metadata ? definition.metadata : {},
|
||||
payload: definition && definition.payload !== undefined ? definition.payload : null,
|
||||
persisted: shouldPersist,
|
||||
run: typeof definition.run === 'function' ? definition.run : function () {
|
||||
return Promise.resolve();
|
||||
},
|
||||
completionPromise: null,
|
||||
resolveCompletion: null,
|
||||
rejectCompletion: null
|
||||
};
|
||||
|
||||
const completionController = createTaskCompletionController();
|
||||
task.completionPromise = completionController.promise;
|
||||
task.resolveCompletion = completionController.resolve;
|
||||
task.rejectCompletion = completionController.reject;
|
||||
|
||||
if (shouldPersist) {
|
||||
await persistTaskInsert(task);
|
||||
} else {
|
||||
nextTaskId += 1;
|
||||
}
|
||||
|
||||
tasksById.set(task.id, task);
|
||||
pendingIds.push(task.id);
|
||||
scheduleDrain();
|
||||
return buildSnapshot(task);
|
||||
}
|
||||
|
||||
function registerRecurringTask(definition) {
|
||||
const normalizedKey = normalizeText(definition && definition.key);
|
||||
if (!normalizedKey) {
|
||||
throw new Error('Recurring tasks require a key.');
|
||||
}
|
||||
|
||||
const intervalMs = Math.max(1000, Number(definition && definition.intervalMs) || 0);
|
||||
if (!Number.isFinite(intervalMs) || intervalMs < 1000) {
|
||||
throw new Error('Recurring tasks require a valid interval.');
|
||||
}
|
||||
|
||||
const job = recurringJobsByKey.get(normalizedKey) || {
|
||||
key: normalizedKey,
|
||||
activeTaskId: null,
|
||||
lastRunAt: '',
|
||||
lastStatus: '',
|
||||
lastError: '',
|
||||
nextRunAt: '',
|
||||
timerId: null,
|
||||
enabled: true
|
||||
};
|
||||
|
||||
clearRecurringTimer(job);
|
||||
job.title = normalizeText(definition && definition.title) || 'Background task';
|
||||
job.category = normalizeText(definition && definition.category) || 'general';
|
||||
job.intervalMs = intervalMs;
|
||||
job.metadata = definition && definition.metadata ? definition.metadata : {};
|
||||
job.run = typeof definition.run === 'function' ? definition.run : function () {
|
||||
return Promise.resolve();
|
||||
};
|
||||
job.enabled = definition && definition.enabled === false ? false : true;
|
||||
recurringJobsByKey.set(normalizedKey, job);
|
||||
|
||||
if (job.enabled) {
|
||||
scheduleRecurringRun(job, intervalMs);
|
||||
}
|
||||
|
||||
return buildRecurringSnapshot(job);
|
||||
}
|
||||
|
||||
function removeRecurringTask(recurringKey) {
|
||||
const normalizedKey = normalizeText(recurringKey);
|
||||
const job = recurringJobsByKey.get(normalizedKey);
|
||||
if (!job) {
|
||||
return false;
|
||||
}
|
||||
|
||||
clearRecurringTimer(job);
|
||||
recurringJobsByKey.delete(normalizedKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildRecurringSnapshot(job) {
|
||||
return {
|
||||
key: job.key,
|
||||
title: job.title,
|
||||
category: job.category,
|
||||
intervalMs: job.intervalMs,
|
||||
enabled: job.enabled !== false,
|
||||
activeTaskId: job.activeTaskId || null,
|
||||
createdAt: job.createdAt || '',
|
||||
nextRunAt: job.nextRunAt || '',
|
||||
lastRunAt: job.lastRunAt || '',
|
||||
lastStatus: job.lastStatus || '',
|
||||
lastError: job.lastError || '',
|
||||
metadata: job.metadata || {}
|
||||
};
|
||||
}
|
||||
|
||||
function getTaskSortTime(task) {
|
||||
const finishedTime = task && task.finishedAt ? Date.parse(task.finishedAt) : NaN;
|
||||
if (Number.isFinite(finishedTime)) {
|
||||
return finishedTime;
|
||||
}
|
||||
|
||||
const createdTime = task && task.createdAt ? Date.parse(task.createdAt) : NaN;
|
||||
if (Number.isFinite(createdTime)) {
|
||||
return createdTime;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function listTasks() {
|
||||
return Array.from(tasksById.values())
|
||||
.slice()
|
||||
.sort(function (left, right) {
|
||||
const leftStartedTime = left && left.startedAt ? Date.parse(left.startedAt) : NaN;
|
||||
const rightStartedTime = right && right.startedAt ? Date.parse(right.startedAt) : NaN;
|
||||
if (Number.isFinite(leftStartedTime) && Number.isFinite(rightStartedTime) && leftStartedTime !== rightStartedTime) {
|
||||
return rightStartedTime - leftStartedTime;
|
||||
}
|
||||
|
||||
if (Number.isFinite(leftStartedTime) !== Number.isFinite(rightStartedTime)) {
|
||||
return Number.isFinite(leftStartedTime) ? -1 : 1;
|
||||
}
|
||||
|
||||
const leftCreatedTime = left && left.createdAt ? Date.parse(left.createdAt) : NaN;
|
||||
const rightCreatedTime = right && right.createdAt ? Date.parse(right.createdAt) : NaN;
|
||||
if (Number.isFinite(leftCreatedTime) && Number.isFinite(rightCreatedTime) && leftCreatedTime !== rightCreatedTime) {
|
||||
return rightCreatedTime - leftCreatedTime;
|
||||
}
|
||||
|
||||
if (Number.isFinite(leftCreatedTime) !== Number.isFinite(rightCreatedTime)) {
|
||||
return Number.isFinite(leftCreatedTime) ? -1 : 1;
|
||||
}
|
||||
|
||||
return right.id - left.id;
|
||||
})
|
||||
.map(buildSnapshot);
|
||||
}
|
||||
|
||||
function listRecurringTasks() {
|
||||
return Array.from(recurringJobsByKey.values())
|
||||
.slice()
|
||||
.sort(function (left, right) {
|
||||
return left.key.localeCompare(right.key);
|
||||
})
|
||||
.map(buildRecurringSnapshot);
|
||||
}
|
||||
|
||||
function clearFinishedTasks() {
|
||||
let removedCount = 0;
|
||||
Array.from(tasksById.values()).forEach(function (task) {
|
||||
if (task.status === 'running' || task.status === 'queued') {
|
||||
return;
|
||||
}
|
||||
tasksById.delete(task.id);
|
||||
removedCount += 1;
|
||||
persistTaskDelete(task.id).catch(function (error) {
|
||||
console.warn('Unable to delete finished task from persistence:', error);
|
||||
});
|
||||
});
|
||||
return removedCount;
|
||||
}
|
||||
|
||||
function cancelTask(taskId) {
|
||||
const task = getTaskById(taskId);
|
||||
if (!task || task.status !== 'queued') {
|
||||
return false;
|
||||
}
|
||||
|
||||
task.status = 'canceled';
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
const pendingIndex = pendingIds.indexOf(task.id);
|
||||
if (pendingIndex >= 0) {
|
||||
pendingIds.splice(pendingIndex, 1);
|
||||
}
|
||||
|
||||
if (typeof task.rejectCompletion === 'function') {
|
||||
const cancellationError = new Error('Task canceled.');
|
||||
cancellationError.task = buildSnapshot(task);
|
||||
task.rejectCompletion(cancellationError);
|
||||
}
|
||||
|
||||
persistTaskUpdate(task).catch(function (error) {
|
||||
console.warn('Unable to persist canceled task:', error);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function retryTask(taskId) {
|
||||
const task = getTaskById(taskId);
|
||||
if (!task || task.status !== 'failed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return enqueueTask({
|
||||
key: task.key,
|
||||
title: task.title,
|
||||
category: task.category,
|
||||
metadata: task.metadata,
|
||||
run: task.run
|
||||
});
|
||||
}
|
||||
|
||||
function getSummary() {
|
||||
const counts = {
|
||||
queued: 0,
|
||||
running: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
canceled: 0
|
||||
};
|
||||
|
||||
listTasks().forEach(function (task) {
|
||||
if (Object.prototype.hasOwnProperty.call(counts, task.status)) {
|
||||
counts[task.status] += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
activeCount: activeCount,
|
||||
counts: counts,
|
||||
scheduledCount: recurringJobsByKey.size,
|
||||
total: listTasks().length
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enqueueTask: enqueueTask,
|
||||
enqueueTaskAndWait: function (definition) {
|
||||
return enqueueTask(definition).then(function (snapshot) {
|
||||
const task = snapshot && snapshot.id ? getTaskById(snapshot.id) : null;
|
||||
if (!task || !task.completionPromise) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
return task.completionPromise;
|
||||
});
|
||||
},
|
||||
initialize: initialize,
|
||||
setTaskHandler: setTaskHandler,
|
||||
registerRecurringTask: registerRecurringTask,
|
||||
removeRecurringTask: removeRecurringTask,
|
||||
runRecurringTask: runRecurringTask,
|
||||
listTasks: listTasks,
|
||||
listRecurringTasks: listRecurringTasks,
|
||||
getTaskById: getTaskById,
|
||||
getSummary: getSummary,
|
||||
cancelTask: cancelTask,
|
||||
retryTask: retryTask,
|
||||
clearFinishedTasks: clearFinishedTasks
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBackgroundTaskQueue: createBackgroundTaskQueue,
|
||||
normalizeIntervalMs: normalizeIntervalMs
|
||||
};
|
||||
@@ -1,146 +0,0 @@
|
||||
const { normalizeIntervalMs } = require('./background-task-queue');
|
||||
|
||||
function registerBackgroundTaskScheduling(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const collectUploadPathsFromDirectory = options && options.collectUploadPathsFromDirectory;
|
||||
const removeUnusedUploadFiles = options && options.removeUnusedUploadFiles;
|
||||
const refreshApiSource = options && options.refreshApiSource;
|
||||
const refreshRssFeed = options && options.refreshRssFeed;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(options && options.dataSourceStartupRefreshStaggerMs || 250));
|
||||
|
||||
if (!pool || !common || !backgroundTaskQueue || !collectUploadPathsFromDirectory || !removeUnusedUploadFiles || !refreshApiSource || !refreshRssFeed || !mediaDir) {
|
||||
throw new Error('registerBackgroundTaskScheduling requires the background task dependencies.');
|
||||
}
|
||||
|
||||
async function syncRecurringRefreshes() {
|
||||
const apiSourcesData = await common.fetchApiSourcesData(pool);
|
||||
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: 'api-source-refresh:' + Number(apiSource.id),
|
||||
title: 'API source refresh',
|
||||
category: 'data-source',
|
||||
intervalMs: normalizeIntervalMs(apiSource.update_interval_value, apiSource.update_interval_unit),
|
||||
metadata: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: Number(apiSource.id),
|
||||
sourceName: apiSource.name
|
||||
},
|
||||
run: function () {
|
||||
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const rssFeedsData = await common.fetchRssFeedsData(pool);
|
||||
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: 'rss-feed-refresh:' + Number(rssFeed.id),
|
||||
title: 'RSS feed refresh',
|
||||
category: 'data-source',
|
||||
intervalMs: normalizeIntervalMs(rssFeed.update_interval_value, rssFeed.update_interval_unit),
|
||||
metadata: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: Number(rssFeed.id),
|
||||
sourceName: rssFeed.name
|
||||
},
|
||||
run: function () {
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function registerRecurringMaintenanceTasks() {
|
||||
async function runUnusedUploadSweep() {
|
||||
const uploadPaths = await collectUploadPathsFromDirectory(mediaDir);
|
||||
if (!uploadPaths.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await removeUnusedUploadFiles(pool, mediaDir, uploadPaths);
|
||||
}
|
||||
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: 'unused-upload-sweep',
|
||||
title: 'Unused upload sweep',
|
||||
category: 'media-sync',
|
||||
intervalMs: 24 * 60 * 60 * 1000,
|
||||
metadata: {
|
||||
mediaDir: mediaDir
|
||||
},
|
||||
run: runUnusedUploadSweep
|
||||
});
|
||||
}
|
||||
|
||||
async function scheduleInitialDataSourceRefreshes() {
|
||||
const apiSourcesData = await common.fetchApiSourcesData(pool);
|
||||
const rssFeedsData = await common.fetchRssFeedsData(pool);
|
||||
const startupSources = [];
|
||||
|
||||
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
||||
startupSources.push({
|
||||
type: 'api-source',
|
||||
id: Number(apiSource.id),
|
||||
name: apiSource.name,
|
||||
run: function () {
|
||||
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
|
||||
startupSources.push({
|
||||
type: 'rss-feed',
|
||||
id: Number(rssFeed.id),
|
||||
name: rssFeed.name,
|
||||
run: function () {
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
startupSources.forEach(function (source, index) {
|
||||
const startupDelayMs = index * dataSourceStartupRefreshStaggerMs;
|
||||
|
||||
setTimeout(function () {
|
||||
backgroundTaskQueue.enqueueTask({
|
||||
key: 'startup-data-source-refresh:' + source.type + ':' + source.id + ':' + Date.now(),
|
||||
title: source.type === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
metadata: {
|
||||
sourceType: source.type,
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
startupRefresh: true
|
||||
},
|
||||
payload: {
|
||||
sourceType: source.type,
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
startupRefresh: true
|
||||
}
|
||||
}).catch(function (error) {
|
||||
console.warn('Unable to queue startup refresh for ' + source.type + ' ' + source.id + ':', error);
|
||||
});
|
||||
}, startupDelayMs);
|
||||
});
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
await syncRecurringRefreshes();
|
||||
await registerRecurringMaintenanceTasks();
|
||||
scheduleInitialDataSourceRefreshes().catch(function (error) {
|
||||
console.warn('Unable to schedule startup data source refreshes:', error);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
initialize: initialize
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { registerBackgroundTaskScheduling };
|
||||
@@ -1,32 +0,0 @@
|
||||
const { registerBackgroundTaskHandlers } = require('./background-task-handlers');
|
||||
const { registerBackgroundTaskScheduling } = require('./background-task-scheduling');
|
||||
|
||||
function createBackgroundTaskSetup(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const collectUploadPathsFromDirectory = options && options.collectUploadPathsFromDirectory;
|
||||
const removeUnusedUploadFiles = options && options.removeUnusedUploadFiles;
|
||||
const refreshApiSource = options && options.refreshApiSource;
|
||||
const refreshRssFeed = options && options.refreshRssFeed;
|
||||
const uploadsDir = String(options && options.uploadsDir || '').trim();
|
||||
const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(options && options.dataSourceStartupRefreshStaggerMs || 250));
|
||||
|
||||
if (!pool || !common || !backgroundTaskQueue || !collectUploadPathsFromDirectory || !removeUnusedUploadFiles || !refreshApiSource || !refreshRssFeed || !uploadsDir) {
|
||||
throw new Error('createBackgroundTaskSetup requires the background task dependencies.');
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
registerBackgroundTaskHandlers(options);
|
||||
const backgroundTaskScheduling = registerBackgroundTaskScheduling(options);
|
||||
if (backgroundTaskScheduling && typeof backgroundTaskScheduling.initialize === 'function') {
|
||||
await backgroundTaskScheduling.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
initialize: initialize
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createBackgroundTaskSetup };
|
||||
@@ -0,0 +1,99 @@
|
||||
// Shared loader for task folders under background-tasks.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function getTaskExport(taskModule) {
|
||||
return typeof taskModule === 'function'
|
||||
? taskModule
|
||||
: taskModule && typeof taskModule === 'object'
|
||||
? Object.values(taskModule).find(function (value) {
|
||||
return typeof value === 'function';
|
||||
})
|
||||
: null;
|
||||
}
|
||||
|
||||
function loadTaskModules(directory, options) {
|
||||
const taskFiles = fs.readdirSync(directory)
|
||||
.filter(function (fileName) {
|
||||
return fileName.toLowerCase().endsWith('.js') && fileName !== 'index.js';
|
||||
})
|
||||
.sort(function (left, right) {
|
||||
return left.localeCompare(right);
|
||||
});
|
||||
|
||||
return Promise.all(taskFiles.map(function (fileName) {
|
||||
const taskModule = require(path.join(directory, fileName));
|
||||
const exported = getTaskExport(taskModule);
|
||||
|
||||
if (typeof exported === 'function') {
|
||||
return exported(options);
|
||||
}
|
||||
return null;
|
||||
}));
|
||||
}
|
||||
|
||||
// Register ad hoc task handlers that execute queued background work.
|
||||
function registerAdhocTasks(options) {
|
||||
const backgroundTaskDirectory = path.join(__dirname, 'tasks-adhoc');
|
||||
if (!options || !options.pool || !options.common || !options.backgroundTaskQueue || !options.captureSlideThumbnail || !options.uploadSyncService || !String(options.mediaDir || '').trim()) {
|
||||
throw new Error('registerAdhocTasks requires the background task dependencies.');
|
||||
}
|
||||
|
||||
loadTaskModules(backgroundTaskDirectory, options);
|
||||
}
|
||||
|
||||
// Register one-time startup work that should run during boot only.
|
||||
function registerStartupTasks(options) {
|
||||
const backgroundTaskDirectory = path.join(__dirname, 'tasks-startup');
|
||||
|
||||
if (!options || !options.pool || !options.common || !options.backgroundTaskQueue || !String(options.mediaDir || '').trim()) {
|
||||
throw new Error('registerStartupTasks requires the startup task dependencies.');
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
await loadTaskModules(backgroundTaskDirectory, options);
|
||||
}
|
||||
|
||||
return {
|
||||
initialize: initialize
|
||||
};
|
||||
}
|
||||
|
||||
// Register recurring maintenance and data-source refresh tasks.
|
||||
function registerScheduledTasks(options) {
|
||||
const backgroundTaskDirectory = path.join(__dirname, 'tasks-scheduled');
|
||||
|
||||
if (!options || !options.pool || !options.common || !options.backgroundTaskQueue || !options.uploadSyncService || !String(options.mediaDir || '').trim()) {
|
||||
throw new Error('registerScheduledTasks requires the background task dependencies.');
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
await loadTaskModules(backgroundTaskDirectory, options);
|
||||
}
|
||||
|
||||
return {
|
||||
initialize: initialize
|
||||
};
|
||||
}
|
||||
|
||||
async function initializeBackgroundTasks(options) {
|
||||
registerAdhocTasks(options);
|
||||
|
||||
const backgroundTaskStartup = registerStartupTasks(options);
|
||||
if (backgroundTaskStartup && typeof backgroundTaskStartup.initialize === 'function') {
|
||||
await backgroundTaskStartup.initialize();
|
||||
}
|
||||
|
||||
const backgroundTaskScheduling = registerScheduledTasks(options);
|
||||
if (backgroundTaskScheduling && typeof backgroundTaskScheduling.initialize === 'function') {
|
||||
await backgroundTaskScheduling.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerAdhocTasks: registerAdhocTasks,
|
||||
registerStartupTasks: registerStartupTasks,
|
||||
registerScheduledTasks: registerScheduledTasks,
|
||||
initializeBackgroundTasks: initializeBackgroundTasks
|
||||
};
|
||||
@@ -0,0 +1,719 @@
|
||||
// Database-backed background task queue.
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function toIsoDate(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
|
||||
}
|
||||
|
||||
function normalizeIntervalMs(value, unit) {
|
||||
const numericValue = Math.max(1, Number(value) || 0);
|
||||
const normalizedUnit = String(unit || 'minutes').trim().toLowerCase();
|
||||
if (normalizedUnit === 'seconds') {
|
||||
return numericValue * 1000;
|
||||
}
|
||||
return numericValue * 60 * 1000;
|
||||
}
|
||||
|
||||
function createBackgroundTaskQueue(options) {
|
||||
const pool = options && options.pool;
|
||||
const maxConcurrent = Math.max(1, Number(options && options.maxConcurrent) || 1);
|
||||
const taskHandlers = new Map();
|
||||
const recurringJobsByKey = new Map();
|
||||
let activeCount = 0;
|
||||
let drainScheduled = false;
|
||||
let initializationPromise = null;
|
||||
|
||||
if (!pool) {
|
||||
throw new Error('createBackgroundTaskQueue requires a database pool.');
|
||||
}
|
||||
|
||||
function buildSnapshot(task) {
|
||||
return {
|
||||
id: Number(task && task.id) || 0,
|
||||
key: String(task && task.key || '').trim(),
|
||||
taskType: String(task && task.taskType || '').trim(),
|
||||
title: String(task && task.title || 'Background task').trim() || 'Background task',
|
||||
category: String(task && task.category || 'general').trim() || 'general',
|
||||
status: String(task && task.status || 'queued').trim() || 'queued',
|
||||
createdAt: String(task && task.createdAt || ''),
|
||||
startedAt: String(task && task.startedAt || ''),
|
||||
finishedAt: String(task && task.finishedAt || ''),
|
||||
errorMessage: String(task && task.errorMessage || ''),
|
||||
attempts: Math.max(0, Number(task && task.attempts) || 0),
|
||||
metadata: task && task.metadata ? task.metadata : {},
|
||||
payload: task && task.payload !== undefined ? task.payload : null
|
||||
};
|
||||
}
|
||||
|
||||
function parseJsonValue(value, fallback) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(String(value));
|
||||
} catch (_error) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyJsonValue(value) {
|
||||
if (value === undefined || value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function hydrateTaskRow(row) {
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: Number(row.id),
|
||||
key: String(row.task_key || '').trim(),
|
||||
taskType: String(row.task_type || '').trim(),
|
||||
title: String(row.title || 'Background task').trim() || 'Background task',
|
||||
category: String(row.category || 'general').trim() || 'general',
|
||||
status: String(row.status || 'queued').trim() || 'queued',
|
||||
createdAt: row.created_at ? new Date(row.created_at).toISOString() : '',
|
||||
startedAt: row.started_at ? new Date(row.started_at).toISOString() : '',
|
||||
finishedAt: row.finished_at ? new Date(row.finished_at).toISOString() : '',
|
||||
errorMessage: String(row.error_message || ''),
|
||||
attempts: Math.max(0, Number(row.attempts) || 0),
|
||||
metadata: parseJsonValue(row.metadata_json, {}),
|
||||
payload: parseJsonValue(row.payload_json, null)
|
||||
};
|
||||
}
|
||||
|
||||
function buildTaskRecord(task) {
|
||||
return {
|
||||
task_key: task.key || null,
|
||||
task_type: task.taskType || null,
|
||||
title: task.title,
|
||||
category: task.category || 'general',
|
||||
status: task.status,
|
||||
payload_json: stringifyJsonValue(task.payload),
|
||||
metadata_json: stringifyJsonValue(task.metadata),
|
||||
attempts: Math.max(0, Number(task.attempts) || 0),
|
||||
created_at: task.createdAt ? new Date(task.createdAt) : new Date(),
|
||||
started_at: task.startedAt ? new Date(task.startedAt) : null,
|
||||
finished_at: task.finishedAt ? new Date(task.finishedAt) : null,
|
||||
error_message: task.errorMessage || null
|
||||
};
|
||||
}
|
||||
|
||||
async function persistTaskInsert(task) {
|
||||
const normalizedTaskType = normalizeText(task && task.taskType);
|
||||
if (!normalizedTaskType) {
|
||||
throw new Error('Background tasks require a task type.');
|
||||
}
|
||||
|
||||
const record = buildTaskRecord(Object.assign({}, task, {
|
||||
taskType: normalizedTaskType
|
||||
}));
|
||||
const [result] = await pool.query(
|
||||
`INSERT INTO o_background_tasks (
|
||||
task_key,
|
||||
task_type,
|
||||
title,
|
||||
category,
|
||||
status,
|
||||
payload_json,
|
||||
metadata_json,
|
||||
attempts,
|
||||
created_at,
|
||||
started_at,
|
||||
finished_at,
|
||||
error_message
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
record.task_key,
|
||||
record.task_type,
|
||||
record.title,
|
||||
record.category,
|
||||
record.status,
|
||||
record.payload_json,
|
||||
record.metadata_json,
|
||||
record.attempts,
|
||||
record.created_at,
|
||||
record.started_at,
|
||||
record.finished_at,
|
||||
record.error_message
|
||||
]
|
||||
);
|
||||
|
||||
task.id = Number(result.insertId);
|
||||
return task;
|
||||
}
|
||||
|
||||
async function persistTaskUpdate(task) {
|
||||
if (!task || !Number.isInteger(Number(task.id)) || Number(task.id) <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = buildTaskRecord(task);
|
||||
await pool.query(
|
||||
`UPDATE o_background_tasks
|
||||
SET task_key = ?, task_type = ?, title = ?, category = ?, status = ?, payload_json = ?, metadata_json = ?, attempts = ?, started_at = ?, finished_at = ?, error_message = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
record.task_key,
|
||||
record.task_type,
|
||||
record.title,
|
||||
record.category,
|
||||
record.status,
|
||||
record.payload_json,
|
||||
record.metadata_json,
|
||||
record.attempts,
|
||||
record.started_at,
|
||||
record.finished_at,
|
||||
record.error_message,
|
||||
Number(task.id)
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async function persistTaskDelete(taskId) {
|
||||
const numericTaskId = Number(taskId);
|
||||
if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM o_background_tasks WHERE id = ?', [numericTaskId]);
|
||||
}
|
||||
|
||||
async function fetchTaskById(taskId) {
|
||||
const numericTaskId = Number(taskId);
|
||||
if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, task_key, task_type, title, category, status, payload_json, metadata_json, attempts, created_at, started_at, finished_at, error_message
|
||||
FROM o_background_tasks
|
||||
WHERE id = ?
|
||||
LIMIT 1`,
|
||||
[numericTaskId]
|
||||
);
|
||||
|
||||
return hydrateTaskRow(rows && rows[0]) || null;
|
||||
}
|
||||
|
||||
async function fetchQueuedTaskCount() {
|
||||
const [rows] = await pool.query(`SELECT COUNT(*) AS count FROM o_background_tasks WHERE status = 'queued'`);
|
||||
return Number(rows && rows[0] && rows[0].count) || 0;
|
||||
}
|
||||
|
||||
async function claimNextTask() {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const [rows] = await connection.query(
|
||||
`SELECT id, task_key, task_type, title, category, status, payload_json, metadata_json, attempts, created_at, started_at, finished_at, error_message
|
||||
FROM o_background_tasks
|
||||
WHERE status = 'queued'
|
||||
ORDER BY created_at ASC, id ASC
|
||||
LIMIT 1
|
||||
FOR UPDATE SKIP LOCKED`
|
||||
);
|
||||
|
||||
const row = rows && rows[0] ? rows[0] : null;
|
||||
if (!row) {
|
||||
await connection.commit();
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextAttempts = Math.max(0, Number(row.attempts) || 0) + 1;
|
||||
await connection.query(
|
||||
`UPDATE o_background_tasks
|
||||
SET status = 'running', started_at = NOW(), attempts = ?, error_message = NULL
|
||||
WHERE id = ?`,
|
||||
[nextAttempts, row.id]
|
||||
);
|
||||
await connection.commit();
|
||||
|
||||
return hydrateTaskRow(Object.assign({}, row, {
|
||||
status: 'running',
|
||||
started_at: new Date(),
|
||||
attempts: nextAttempts,
|
||||
error_message: null
|
||||
}));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function syncRecurringTaskState(task, status, errorMessage) {
|
||||
const recurringKey = String(task && task.metadata && task.metadata.recurringKey || '').trim();
|
||||
if (!recurringKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const job = recurringJobsByKey.get(recurringKey);
|
||||
if (!job) {
|
||||
return;
|
||||
}
|
||||
|
||||
job.lastRunAt = toIsoDate(new Date());
|
||||
job.lastStatus = String(status || '');
|
||||
job.lastError = errorMessage ? String(errorMessage) : '';
|
||||
}
|
||||
|
||||
function setTaskHandler(taskType, handler) {
|
||||
const normalizedTaskType = normalizeText(taskType);
|
||||
if (!normalizedTaskType || typeof handler !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
taskHandlers.set(normalizedTaskType, handler);
|
||||
return true;
|
||||
}
|
||||
|
||||
setTaskHandler('recurring-run', async function (task) {
|
||||
const recurringKey = String(task && task.metadata && task.metadata.recurringKey || '').trim();
|
||||
if (!recurringKey) {
|
||||
throw new Error('Recurring task key is required.');
|
||||
}
|
||||
|
||||
const job = recurringJobsByKey.get(recurringKey);
|
||||
if (!job || typeof job.run !== 'function') {
|
||||
throw new Error('Recurring task not found.');
|
||||
}
|
||||
|
||||
return job.run(task);
|
||||
});
|
||||
|
||||
function scheduleDrain() {
|
||||
if (drainScheduled) {
|
||||
return;
|
||||
}
|
||||
|
||||
drainScheduled = true;
|
||||
setTimeout(function () {
|
||||
drainScheduled = false;
|
||||
processQueue().catch(function (error) {
|
||||
console.error(error);
|
||||
});
|
||||
}, 0);
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
if (initializationPromise) {
|
||||
return initializationPromise;
|
||||
}
|
||||
|
||||
initializationPromise = (async function () {
|
||||
await pool.query(
|
||||
`UPDATE o_background_tasks
|
||||
SET status = 'queued', started_at = NULL, finished_at = NULL, error_message = NULL
|
||||
WHERE status = 'running'`
|
||||
);
|
||||
|
||||
if (await fetchQueuedTaskCount() > 0) {
|
||||
scheduleDrain();
|
||||
}
|
||||
})();
|
||||
|
||||
return initializationPromise;
|
||||
}
|
||||
|
||||
async function processQueue() {
|
||||
while (activeCount < maxConcurrent) {
|
||||
const task = await claimNextTask();
|
||||
if (!task) {
|
||||
break;
|
||||
}
|
||||
|
||||
activeCount += 1;
|
||||
|
||||
(async function () {
|
||||
try {
|
||||
const handler = taskHandlers.get(task.taskType);
|
||||
if (!handler) {
|
||||
throw new Error('No handler registered for task type ' + task.taskType + '.');
|
||||
}
|
||||
|
||||
await handler(task);
|
||||
task.status = 'completed';
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
task.errorMessage = '';
|
||||
await persistTaskUpdate(task);
|
||||
await syncRecurringTaskState(task, task.status, '');
|
||||
} catch (error) {
|
||||
task.status = 'failed';
|
||||
task.errorMessage = String(error && error.message ? error.message : 'Background task failed.');
|
||||
task.finishedAt = toIsoDate(new Date());
|
||||
try {
|
||||
await persistTaskUpdate(task);
|
||||
} catch (persistError) {
|
||||
console.error(persistError);
|
||||
}
|
||||
await syncRecurringTaskState(task, task.status, task.errorMessage);
|
||||
} finally {
|
||||
activeCount = Math.max(0, activeCount - 1);
|
||||
scheduleDrain();
|
||||
}
|
||||
})().catch(function (error) {
|
||||
console.error(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function enqueueTask(definition) {
|
||||
const normalizedKey = normalizeText(definition && definition.key);
|
||||
const normalizedTitle = normalizeText(definition && definition.title) || 'Background task';
|
||||
const normalizedTaskType = normalizeText(definition && definition.taskType);
|
||||
if (!normalizedTaskType) {
|
||||
throw new Error('Background tasks require a task type.');
|
||||
}
|
||||
|
||||
const task = {
|
||||
id: 0,
|
||||
key: normalizedKey,
|
||||
taskType: normalizedTaskType,
|
||||
title: normalizedTitle,
|
||||
category: normalizeText(definition && definition.category) || 'general',
|
||||
status: 'queued',
|
||||
createdAt: toIsoDate(new Date()),
|
||||
startedAt: '',
|
||||
finishedAt: '',
|
||||
errorMessage: '',
|
||||
attempts: 0,
|
||||
metadata: definition && definition.metadata ? definition.metadata : {},
|
||||
payload: definition && definition.payload !== undefined ? definition.payload : null
|
||||
};
|
||||
|
||||
await persistTaskInsert(task);
|
||||
scheduleDrain();
|
||||
return buildSnapshot(task);
|
||||
}
|
||||
|
||||
async function enqueueTaskAndWait(definition) {
|
||||
const snapshot = await enqueueTask(definition);
|
||||
const taskId = snapshot && snapshot.id ? snapshot.id : null;
|
||||
if (!taskId) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const currentTask = await fetchTaskById(taskId);
|
||||
if (!currentTask) {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
if (currentTask.status !== 'queued' && currentTask.status !== 'running') {
|
||||
return buildSnapshot(currentTask);
|
||||
}
|
||||
|
||||
await new Promise(function (resolve) {
|
||||
setTimeout(resolve, 250);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function clearRecurringTimer(job) {
|
||||
if (job && job.timerId) {
|
||||
clearTimeout(job.timerId);
|
||||
job.timerId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleRecurringRun(job, delayMs) {
|
||||
if (!job || job.enabled === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearRecurringTimer(job);
|
||||
const safeDelay = Math.max(1, Number(delayMs) || job.intervalMs || 0);
|
||||
job.nextRunAt = toIsoDate(new Date(Date.now() + safeDelay));
|
||||
job.timerId = setTimeout(function () {
|
||||
job.timerId = null;
|
||||
triggerRecurringJob(job.key).catch(function (error) {
|
||||
console.error(error);
|
||||
});
|
||||
}, safeDelay);
|
||||
}
|
||||
|
||||
async function triggerRecurringJob(recurringKey) {
|
||||
const job = recurringJobsByKey.get(recurringKey);
|
||||
if (!job || job.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await enqueueTask({
|
||||
key: `${job.key}:${Date.now()}`,
|
||||
title: job.title,
|
||||
category: job.category,
|
||||
taskType: 'recurring-run',
|
||||
metadata: Object.assign({}, job.metadata || {}, {
|
||||
recurringKey: job.key,
|
||||
recurringTitle: job.title
|
||||
}),
|
||||
payload: Object.assign({}, job.payload || {}, {
|
||||
recurringKey: job.key
|
||||
})
|
||||
});
|
||||
|
||||
scheduleRecurringRun(job, job.intervalMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function runRecurringTask(recurringKey) {
|
||||
const normalizedKey = normalizeText(recurringKey);
|
||||
if (!normalizedKey || !recurringJobsByKey.has(normalizedKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return triggerRecurringJob(normalizedKey);
|
||||
}
|
||||
|
||||
function registerRecurringTask(definition) {
|
||||
const normalizedKey = normalizeText(definition && definition.key);
|
||||
if (!normalizedKey) {
|
||||
throw new Error('Recurring tasks require a key.');
|
||||
}
|
||||
|
||||
const intervalMs = Math.max(1000, Number(definition && definition.intervalMs) || 0);
|
||||
if (!Number.isFinite(intervalMs) || intervalMs < 1000) {
|
||||
throw new Error('Recurring tasks require a valid interval.');
|
||||
}
|
||||
|
||||
const job = recurringJobsByKey.get(normalizedKey) || {
|
||||
key: normalizedKey,
|
||||
lastRunAt: '',
|
||||
lastStatus: '',
|
||||
lastError: '',
|
||||
nextRunAt: '',
|
||||
timerId: null,
|
||||
enabled: true
|
||||
};
|
||||
|
||||
clearRecurringTimer(job);
|
||||
job.title = normalizeText(definition && definition.title) || 'Background task';
|
||||
job.category = normalizeText(definition && definition.category) || 'general';
|
||||
job.intervalMs = intervalMs;
|
||||
job.metadata = definition && definition.metadata ? definition.metadata : {};
|
||||
job.payload = definition && definition.payload ? definition.payload : null;
|
||||
job.run = typeof definition.run === 'function' ? definition.run : function () {
|
||||
return Promise.resolve();
|
||||
};
|
||||
job.enabled = definition && definition.enabled === false ? false : true;
|
||||
recurringJobsByKey.set(normalizedKey, job);
|
||||
|
||||
if (job.enabled) {
|
||||
scheduleRecurringRun(job, intervalMs);
|
||||
}
|
||||
|
||||
return buildRecurringSnapshot(job);
|
||||
}
|
||||
|
||||
function removeRecurringTask(recurringKey) {
|
||||
const normalizedKey = normalizeText(recurringKey);
|
||||
const job = recurringJobsByKey.get(normalizedKey);
|
||||
if (!job) {
|
||||
return false;
|
||||
}
|
||||
|
||||
clearRecurringTimer(job);
|
||||
recurringJobsByKey.delete(normalizedKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildRecurringSnapshot(job, state) {
|
||||
const latestState = state || null;
|
||||
const activeTaskId = latestState && (latestState.status === 'queued' || latestState.status === 'running')
|
||||
? latestState.id
|
||||
: null;
|
||||
const lastRunAt = latestState
|
||||
? latestState.finishedAt || latestState.startedAt || latestState.createdAt || ''
|
||||
: job.lastRunAt || '';
|
||||
|
||||
return {
|
||||
key: job.key,
|
||||
title: job.title,
|
||||
category: job.category,
|
||||
intervalMs: job.intervalMs,
|
||||
enabled: job.enabled !== false,
|
||||
activeTaskId: activeTaskId,
|
||||
createdAt: latestState ? latestState.createdAt || '' : job.createdAt || '',
|
||||
nextRunAt: job.nextRunAt || '',
|
||||
lastRunAt: lastRunAt || '',
|
||||
lastStatus: latestState ? latestState.status || '' : job.lastStatus || '',
|
||||
lastError: latestState ? latestState.errorMessage || '' : job.lastError || '',
|
||||
metadata: job.metadata || {}
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchRecurringTaskStates() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, status, created_at, started_at, finished_at, error_message, metadata_json
|
||||
FROM o_background_tasks
|
||||
WHERE JSON_UNQUOTE(JSON_EXTRACT(metadata_json, '$.recurringKey')) IS NOT NULL
|
||||
ORDER BY created_at DESC, id DESC`
|
||||
);
|
||||
|
||||
const statesByKey = new Map();
|
||||
for (const row of rows || []) {
|
||||
const metadata = parseJsonValue(row.metadata_json, {});
|
||||
const recurringKey = normalizeText(metadata && metadata.recurringKey);
|
||||
if (!recurringKey || statesByKey.has(recurringKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
statesByKey.set(recurringKey, {
|
||||
id: Number(row.id),
|
||||
status: String(row.status || ''),
|
||||
createdAt: row.created_at ? new Date(row.created_at).toISOString() : '',
|
||||
startedAt: row.started_at ? new Date(row.started_at).toISOString() : '',
|
||||
finishedAt: row.finished_at ? new Date(row.finished_at).toISOString() : '',
|
||||
errorMessage: String(row.error_message || '')
|
||||
});
|
||||
}
|
||||
|
||||
return statesByKey;
|
||||
}
|
||||
|
||||
async function listTasks() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, task_key, task_type, title, category, status, payload_json, metadata_json, attempts, created_at, started_at, finished_at, error_message
|
||||
FROM o_background_tasks
|
||||
ORDER BY
|
||||
CASE status
|
||||
WHEN 'running' THEN 0
|
||||
WHEN 'queued' THEN 1
|
||||
WHEN 'failed' THEN 2
|
||||
WHEN 'completed' THEN 3
|
||||
WHEN 'canceled' THEN 4
|
||||
ELSE 5
|
||||
END,
|
||||
COALESCE(started_at, created_at) DESC,
|
||||
id DESC`
|
||||
);
|
||||
|
||||
return (rows || []).map(hydrateTaskRow);
|
||||
}
|
||||
|
||||
async function listRecurringTasks() {
|
||||
const statesByKey = await fetchRecurringTaskStates();
|
||||
return Array.from(recurringJobsByKey.values())
|
||||
.slice()
|
||||
.sort(function (left, right) {
|
||||
return left.key.localeCompare(right.key);
|
||||
})
|
||||
.map(function (job) {
|
||||
return buildRecurringSnapshot(job, statesByKey.get(job.key) || null);
|
||||
});
|
||||
}
|
||||
|
||||
async function clearFinishedTasks() {
|
||||
const [result] = await pool.query(
|
||||
`DELETE FROM o_background_tasks
|
||||
WHERE status IN ('completed', 'failed', 'canceled')`
|
||||
);
|
||||
return Number(result && result.affectedRows) || 0;
|
||||
}
|
||||
|
||||
async function cancelTask(taskId) {
|
||||
const numericTaskId = Number(taskId);
|
||||
if (!Number.isFinite(numericTaskId) || numericTaskId <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [result] = await pool.query(
|
||||
`UPDATE o_background_tasks
|
||||
SET status = 'canceled',
|
||||
finished_at = NOW(),
|
||||
error_message = 'Task canceled.'
|
||||
WHERE id = ?
|
||||
AND status = 'queued'`,
|
||||
[numericTaskId]
|
||||
);
|
||||
return Number(result && result.affectedRows) > 0;
|
||||
}
|
||||
|
||||
async function retryTask(taskId) {
|
||||
const task = await fetchTaskById(taskId);
|
||||
if (!task || task.status !== 'failed') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return enqueueTask({
|
||||
key: task.key,
|
||||
title: task.title,
|
||||
category: task.category,
|
||||
taskType: task.taskType,
|
||||
metadata: task.metadata,
|
||||
payload: task.payload
|
||||
});
|
||||
}
|
||||
|
||||
async function getSummary() {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT
|
||||
SUM(status = 'queued') AS queued,
|
||||
SUM(status = 'running') AS running,
|
||||
SUM(status = 'completed') AS completed,
|
||||
SUM(status = 'failed') AS failed,
|
||||
SUM(status = 'canceled') AS canceled,
|
||||
COUNT(*) AS total
|
||||
FROM o_background_tasks`
|
||||
);
|
||||
const row = rows && rows[0] ? rows[0] : {};
|
||||
|
||||
return {
|
||||
activeCount: activeCount,
|
||||
counts: {
|
||||
queued: Number(row.queued) || 0,
|
||||
running: Number(row.running) || 0,
|
||||
completed: Number(row.completed) || 0,
|
||||
failed: Number(row.failed) || 0,
|
||||
canceled: Number(row.canceled) || 0
|
||||
},
|
||||
scheduledCount: recurringJobsByKey.size,
|
||||
total: Number(row.total) || 0
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
enqueueTask: enqueueTask,
|
||||
enqueueTaskAndWait: enqueueTaskAndWait,
|
||||
initialize: initialize,
|
||||
setTaskHandler: setTaskHandler,
|
||||
registerRecurringTask: registerRecurringTask,
|
||||
removeRecurringTask: removeRecurringTask,
|
||||
runRecurringTask: runRecurringTask,
|
||||
listTasks: listTasks,
|
||||
listRecurringTasks: listRecurringTasks,
|
||||
getTaskById: fetchTaskById,
|
||||
getSummary: getSummary,
|
||||
cancelTask: cancelTask,
|
||||
retryTask: retryTask,
|
||||
clearFinishedTasks: clearFinishedTasks
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBackgroundTaskQueue: createBackgroundTaskQueue,
|
||||
normalizeIntervalMs: normalizeIntervalMs
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
const { refreshApiSource, refreshRssFeed } = require('../../data-source-refresh');
|
||||
|
||||
const TASK = {
|
||||
taskType: 'data-source-refresh'
|
||||
};
|
||||
|
||||
function registerDataSourceRefreshTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
|
||||
if (!backgroundTaskQueue || !pool || !common) {
|
||||
throw new Error('registerDataSourceRefreshTask requires the data source refresh dependencies.');
|
||||
}
|
||||
|
||||
backgroundTaskQueue.setTaskHandler(TASK.taskType, async function (task) {
|
||||
const payload = task && task.payload ? task.payload : {};
|
||||
const sourceType = String(payload.sourceType || '').trim();
|
||||
const sourceId = Number(payload.sourceId || 0);
|
||||
|
||||
// Resolve the source record at execution time so stale queued tasks fail cleanly.
|
||||
if (sourceType === 'api-source') {
|
||||
const apiSource = await common.fetchApiSourceById(pool, sourceId);
|
||||
if (!apiSource) {
|
||||
throw new Error('API source not found.');
|
||||
}
|
||||
return refreshApiSource(pool, common, apiSource, Number(payload.actorId) || null);
|
||||
}
|
||||
|
||||
if (sourceType === 'rss-feed') {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, sourceId);
|
||||
if (!rssFeed) {
|
||||
throw new Error('RSS feed not found.');
|
||||
}
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null);
|
||||
}
|
||||
|
||||
throw new Error('Unsupported data source refresh task.');
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerDataSourceRefreshTask };
|
||||
@@ -0,0 +1,39 @@
|
||||
function registerFontSyncTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const uploadSyncService = options && options.uploadSyncService;
|
||||
|
||||
if (!backgroundTaskQueue || !uploadSyncService) {
|
||||
throw new Error('registerFontSyncTask requires the font sync dependencies.');
|
||||
}
|
||||
|
||||
backgroundTaskQueue.setTaskHandler('font-sync', async function (task) {
|
||||
const payload = task && task.payload ? task.payload : task || {};
|
||||
const uploadDir = String(payload.uploadDir || '').trim();
|
||||
const operations = Array.isArray(payload.operations)
|
||||
? payload.operations
|
||||
: Array.isArray(payload.uploadPaths)
|
||||
? payload.uploadPaths.map(function (uploadPath) {
|
||||
return { type: String(payload.action || 'put').trim().toLowerCase(), uploadPath: uploadPath };
|
||||
})
|
||||
: [];
|
||||
|
||||
if (!uploadDir || !operations.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < operations.length; i += 1) {
|
||||
const operation = operations[i] || {};
|
||||
const uploadPath = String(operation.uploadPath || '').trim();
|
||||
if (!uploadPath) {
|
||||
continue;
|
||||
}
|
||||
if (String(operation.type || '').trim().toLowerCase() === 'delete') {
|
||||
await uploadSyncService.removeUploadFileFromPlayer(uploadPath, uploadDir);
|
||||
} else {
|
||||
await uploadSyncService.pushUploadFileToPlayer(uploadPath, uploadDir);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerFontSyncTask };
|
||||
@@ -0,0 +1,20 @@
|
||||
const TASK = {
|
||||
taskType: 'media-sync'
|
||||
};
|
||||
|
||||
function registerMediaSyncTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const uploadSyncService = options && options.uploadSyncService;
|
||||
const runMediaSyncTask = uploadSyncService && uploadSyncService.runMediaSyncTask;
|
||||
|
||||
if (!backgroundTaskQueue || typeof runMediaSyncTask !== 'function') {
|
||||
throw new Error('registerMediaSyncTask requires the media sync dependencies.');
|
||||
}
|
||||
|
||||
// Media sync is just a pass-through to the shared media sync runner.
|
||||
backgroundTaskQueue.setTaskHandler(TASK.taskType, function (task) {
|
||||
return runMediaSyncTask(task && task.payload ? task.payload : task);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerMediaSyncTask };
|
||||
@@ -0,0 +1,51 @@
|
||||
const TASK = {
|
||||
taskType: 'slide-thumbnail-refresh'
|
||||
};
|
||||
|
||||
async function fetchPlayerInternalBaseUrl(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT internal_base_url
|
||||
FROM d_players
|
||||
WHERE device_id = '1'
|
||||
LIMIT 1`
|
||||
);
|
||||
|
||||
return String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '') || null;
|
||||
}
|
||||
|
||||
function registerSlideThumbnailRefreshTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const captureSlideThumbnail = options && options.captureSlideThumbnail;
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
|
||||
if (!backgroundTaskQueue || typeof captureSlideThumbnail !== 'function' || !pool || !common || !mediaDir) {
|
||||
throw new Error('registerSlideThumbnailRefreshTask requires the slide thumbnail dependencies.');
|
||||
}
|
||||
|
||||
// Refresh the slide thumbnail for one slide id.
|
||||
backgroundTaskQueue.setTaskHandler(TASK.taskType, async function (task) {
|
||||
const payload = task && task.payload ? task.payload : {};
|
||||
const slideId = Number(payload.slideId || 0);
|
||||
if (!Number.isFinite(slideId) || slideId <= 0) {
|
||||
throw new Error('Slide id is required.');
|
||||
}
|
||||
|
||||
const playerInternalBaseUrl = await fetchPlayerInternalBaseUrl(pool);
|
||||
if (!playerInternalBaseUrl) {
|
||||
throw new Error('Player internal base URL is required.');
|
||||
}
|
||||
|
||||
return captureSlideThumbnail({
|
||||
pool: pool,
|
||||
common: common,
|
||||
mediaDir: mediaDir,
|
||||
baseUrl: playerInternalBaseUrl,
|
||||
slideId: slideId,
|
||||
previousThumbnailPath: payload.previousThumbnailPath || null
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerSlideThumbnailRefreshTask };
|
||||
@@ -0,0 +1,65 @@
|
||||
const TASK = {
|
||||
taskType: 'template-slide-thumbnail-refresh'
|
||||
};
|
||||
|
||||
async function fetchPlayerInternalBaseUrl(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT internal_base_url
|
||||
FROM d_players
|
||||
WHERE device_id = '1'
|
||||
LIMIT 1`
|
||||
);
|
||||
|
||||
return String(rows && rows[0] && rows[0].internal_base_url || '').trim().replace(/\/$/, '') || null;
|
||||
}
|
||||
|
||||
function registerTemplateSlideThumbnailRefreshTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const captureSlideThumbnail = options && options.captureSlideThumbnail;
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
|
||||
if (!backgroundTaskQueue || typeof captureSlideThumbnail !== 'function' || !pool || !common || !mediaDir) {
|
||||
throw new Error('registerTemplateSlideThumbnailRefreshTask requires the template thumbnail dependencies.');
|
||||
}
|
||||
|
||||
// Walk every slide in the template and regenerate its thumbnail.
|
||||
backgroundTaskQueue.setTaskHandler(TASK.taskType, async function (task) {
|
||||
const payload = task && task.payload ? task.payload : {};
|
||||
const templateId = Number(payload.templateId || 0);
|
||||
if (!Number.isFinite(templateId) || templateId <= 0) {
|
||||
throw new Error('Template id is required.');
|
||||
}
|
||||
|
||||
const playerInternalBaseUrl = await fetchPlayerInternalBaseUrl(pool);
|
||||
if (!playerInternalBaseUrl) {
|
||||
throw new Error('Player internal base URL is required.');
|
||||
}
|
||||
|
||||
const [slides] = await pool.query(
|
||||
'SELECT id, thumbnail_path FROM c_slides WHERE template_id = ? ORDER BY id ASC',
|
||||
[templateId]
|
||||
);
|
||||
|
||||
for (const slide of slides || []) {
|
||||
const slideId = Number(slide && slide.id || 0);
|
||||
if (!Number.isFinite(slideId) || slideId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await captureSlideThumbnail({
|
||||
pool: pool,
|
||||
common: common,
|
||||
mediaDir: mediaDir,
|
||||
baseUrl: playerInternalBaseUrl,
|
||||
slideId: slideId,
|
||||
previousThumbnailPath: slide && slide.thumbnail_path ? slide.thumbnail_path : null
|
||||
});
|
||||
}
|
||||
|
||||
return { templateId: templateId, slideCount: Array.isArray(slides) ? slides.length : 0 };
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerTemplateSlideThumbnailRefreshTask };
|
||||
@@ -0,0 +1,139 @@
|
||||
const TASK = {
|
||||
key: 'recurring-data-source-refreshes',
|
||||
title: 'Recurring data source refreshes',
|
||||
category: 'data-source',
|
||||
trigger: 'scheduled recurring task definitions from the database',
|
||||
purpose: 'keep API sources and RSS feeds refreshed on their configured intervals.',
|
||||
taskType: 'data-source-refresh',
|
||||
intervalMs: null
|
||||
};
|
||||
|
||||
const { normalizeIntervalMs } = require('../queue');
|
||||
const { refreshApiSource, refreshRssFeed } = require('../../data-source-refresh');
|
||||
|
||||
function registerRecurringDataSourceRefreshes(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
|
||||
if (!pool || !common || !backgroundTaskQueue) {
|
||||
throw new Error('registerRecurringDataSourceRefreshes requires the recurring refresh dependencies.');
|
||||
}
|
||||
|
||||
return (async function () {
|
||||
const apiSourcesData = await common.fetchApiSourcesData(pool);
|
||||
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: 'api-source-refresh:' + Number(apiSource.id),
|
||||
title: 'API source refresh',
|
||||
category: TASK.category,
|
||||
intervalMs: normalizeIntervalMs(apiSource.update_interval_value, apiSource.update_interval_unit),
|
||||
metadata: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: Number(apiSource.id),
|
||||
sourceName: apiSource.name
|
||||
},
|
||||
run: function () {
|
||||
return refreshApiSource(pool, common, apiSource, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const rssFeedsData = await common.fetchRssFeedsData(pool);
|
||||
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: 'rss-feed-refresh:' + Number(rssFeed.id),
|
||||
title: 'RSS feed refresh',
|
||||
category: TASK.category,
|
||||
intervalMs: normalizeIntervalMs(rssFeed.update_interval_value, rssFeed.update_interval_unit),
|
||||
metadata: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: Number(rssFeed.id),
|
||||
sourceName: rssFeed.name
|
||||
},
|
||||
run: function () {
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
function createDataSourceTaskService(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
|
||||
if (!pool || !common || !backgroundTaskQueue) {
|
||||
throw new Error('createDataSourceTaskService requires the data source task dependencies.');
|
||||
}
|
||||
|
||||
function formatRecurringKey(sourceType, id) {
|
||||
return sourceType + '-refresh:' + Number(id);
|
||||
}
|
||||
|
||||
function buildRecurringTitle(sourceType) {
|
||||
return sourceType === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh';
|
||||
}
|
||||
|
||||
function registerRecurringRefresh(sourceType, id, name, intervalValue, intervalUnit, run) {
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: formatRecurringKey(sourceType, id),
|
||||
title: buildRecurringTitle(sourceType),
|
||||
category: 'data-source',
|
||||
intervalMs: normalizeIntervalMs(intervalValue, intervalUnit),
|
||||
metadata: {
|
||||
sourceType: sourceType,
|
||||
sourceId: Number(id),
|
||||
sourceName: name
|
||||
},
|
||||
run: run
|
||||
});
|
||||
}
|
||||
|
||||
function removeRecurringRefresh(sourceType, id) {
|
||||
backgroundTaskQueue.removeRecurringTask(formatRecurringKey(sourceType, id));
|
||||
}
|
||||
|
||||
async function getTaskStatusById(taskId) {
|
||||
if (!backgroundTaskQueue || typeof backgroundTaskQueue.getTaskById !== 'function') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const task = await backgroundTaskQueue.getTaskById(taskId);
|
||||
if (!task) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
key: task.key,
|
||||
status: task.status,
|
||||
finishedAt: task.finishedAt || '',
|
||||
errorMessage: task.errorMessage || ''
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshApiSourceInBackground(apiSourceId, actorId) {
|
||||
return refreshApiSource(pool, common, apiSourceId, actorId);
|
||||
}
|
||||
|
||||
async function refreshRssFeedInBackground(rssFeedId, feedUrl, itemLimit, actorId) {
|
||||
return refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId);
|
||||
}
|
||||
|
||||
return {
|
||||
formatRecurringKey: formatRecurringKey,
|
||||
buildRecurringTitle: buildRecurringTitle,
|
||||
registerRecurringRefresh: registerRecurringRefresh,
|
||||
removeRecurringRefresh: removeRecurringRefresh,
|
||||
getTaskStatusById: getTaskStatusById,
|
||||
refreshApiSourceInBackground: refreshApiSourceInBackground,
|
||||
refreshRssFeedInBackground: refreshRssFeedInBackground
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerRecurringDataSourceRefreshes: registerRecurringDataSourceRefreshes,
|
||||
createDataSourceTaskService: createDataSourceTaskService
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
const TASK = {
|
||||
key: 'font-sweep',
|
||||
title: 'Font sweep',
|
||||
category: 'cleanup',
|
||||
trigger: 'recurring scheduled task, daily',
|
||||
purpose: 'reconcile managed fonts on the player and remove stale font files.',
|
||||
taskType: 'recurring-run',
|
||||
intervalMs: 24 * 60 * 60 * 1000
|
||||
};
|
||||
|
||||
const {
|
||||
collectFontLibraryDirectoryUploadPaths,
|
||||
collectFontLibrarySyncOperations
|
||||
} = require('../../media/font-library');
|
||||
|
||||
function registerFontSweepTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const uploadSyncService = options && options.uploadSyncService;
|
||||
const pushUploadFileToPlayer = uploadSyncService && uploadSyncService.pushUploadFileToPlayer;
|
||||
const removeUploadFileFromPlayer = uploadSyncService && uploadSyncService.removeUploadFileFromPlayer;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
|
||||
if (!backgroundTaskQueue || typeof pushUploadFileToPlayer !== 'function' || typeof removeUploadFileFromPlayer !== 'function' || !mediaDir) {
|
||||
throw new Error('registerFontSweepTask requires the font sweep dependencies.');
|
||||
}
|
||||
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: TASK.key,
|
||||
title: TASK.title,
|
||||
category: TASK.category,
|
||||
intervalMs: TASK.intervalMs,
|
||||
metadata: {
|
||||
mediaDir: mediaDir
|
||||
},
|
||||
run: async function () {
|
||||
const desiredOperations = collectFontLibrarySyncOperations(mediaDir);
|
||||
const desiredUploadPaths = new Set(desiredOperations.map(function (operation) {
|
||||
return operation && operation.uploadPath ? operation.uploadPath : '';
|
||||
}).filter(Boolean));
|
||||
const currentUploadPaths = await collectFontLibraryDirectoryUploadPaths(mediaDir);
|
||||
|
||||
for (let i = 0; i < desiredOperations.length; i += 1) {
|
||||
const operation = desiredOperations[i] || {};
|
||||
const uploadPath = String(operation.uploadPath || '').trim();
|
||||
if (!uploadPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (String(operation.type || '').trim().toLowerCase() === 'delete') {
|
||||
await removeUploadFileFromPlayer(uploadPath, mediaDir);
|
||||
} else {
|
||||
await pushUploadFileToPlayer(uploadPath, mediaDir);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < currentUploadPaths.length; i += 1) {
|
||||
const uploadPath = String(currentUploadPaths[i] || '').trim();
|
||||
if (!uploadPath || desiredUploadPaths.has(uploadPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await removeUploadFileFromPlayer(uploadPath, mediaDir);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerFontSweepTask };
|
||||
@@ -0,0 +1,42 @@
|
||||
const TASK = {
|
||||
key: 'unused-upload-sweep',
|
||||
title: 'Unused upload sweep',
|
||||
category: 'cleanup',
|
||||
trigger: 'recurring scheduled task, daily',
|
||||
purpose: 'remove uploaded media files that are no longer referenced.',
|
||||
taskType: 'recurring-run',
|
||||
intervalMs: 24 * 60 * 60 * 1000
|
||||
};
|
||||
|
||||
function registerUnusedUploadSweepTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const uploadSyncService = options && options.uploadSyncService;
|
||||
const collectUploadPathsFromDirectory = uploadSyncService && uploadSyncService.collectUploadPathsFromDirectory;
|
||||
const removeUnusedUploadFiles = uploadSyncService && uploadSyncService.removeUnusedUploadFiles;
|
||||
const pool = options && options.pool;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
|
||||
if (!backgroundTaskQueue || typeof collectUploadPathsFromDirectory !== 'function' || typeof removeUnusedUploadFiles !== 'function' || !pool || !mediaDir) {
|
||||
throw new Error('registerUnusedUploadSweepTask requires the unused upload sweep dependencies.');
|
||||
}
|
||||
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: TASK.key,
|
||||
title: TASK.title,
|
||||
category: TASK.category,
|
||||
intervalMs: TASK.intervalMs,
|
||||
metadata: {
|
||||
mediaDir: mediaDir
|
||||
},
|
||||
run: async function () {
|
||||
const uploadPaths = await collectUploadPathsFromDirectory(mediaDir);
|
||||
if (!uploadPaths.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await removeUnusedUploadFiles(pool, mediaDir, uploadPaths);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerUnusedUploadSweepTask };
|
||||
@@ -0,0 +1,76 @@
|
||||
const { refreshApiSource, refreshRssFeed } = require('../../data-source-refresh');
|
||||
|
||||
const TASK = {
|
||||
key: 'startup-data-source-refresh',
|
||||
category: 'data-source',
|
||||
};
|
||||
|
||||
function scheduleStartupDataSourceRefreshes(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(options && options.dataSourceStartupRefreshStaggerMs || 250));
|
||||
|
||||
if (!backgroundTaskQueue || !pool || !common) {
|
||||
throw new Error('scheduleStartupDataSourceRefreshes requires the startup refresh dependencies.');
|
||||
}
|
||||
|
||||
const staggerMs = Math.max(100, Number(dataSourceStartupRefreshStaggerMs || 250));
|
||||
|
||||
function buildStartupSource(type, id, name, run) {
|
||||
return {
|
||||
type: type,
|
||||
id: Number(id),
|
||||
name: name,
|
||||
run: run
|
||||
};
|
||||
}
|
||||
|
||||
return (async function () {
|
||||
const apiSourcesData = await common.fetchApiSourcesData(pool);
|
||||
const rssFeedsData = await common.fetchRssFeedsData(pool);
|
||||
const startupSources = [];
|
||||
|
||||
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
||||
startupSources.push(buildStartupSource('api-source', apiSource.id, apiSource.name, function () {
|
||||
return refreshApiSource(pool, common, apiSource, null);
|
||||
}));
|
||||
});
|
||||
|
||||
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
|
||||
startupSources.push(buildStartupSource('rss-feed', rssFeed.id, rssFeed.name, function () {
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
|
||||
}));
|
||||
});
|
||||
|
||||
// Stagger startup refreshes to avoid a burst against the DB/player.
|
||||
startupSources.forEach(function (source, index) {
|
||||
const startupDelayMs = index * staggerMs;
|
||||
|
||||
setTimeout(function () {
|
||||
backgroundTaskQueue.enqueueTask({
|
||||
key: TASK.key + ':' + source.type + ':' + source.id + ':' + Date.now(),
|
||||
title: source.type === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh',
|
||||
category: TASK.category,
|
||||
taskType: 'data-source-refresh',
|
||||
metadata: {
|
||||
sourceType: source.type,
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
startupRefresh: true
|
||||
},
|
||||
payload: {
|
||||
sourceType: source.type,
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
startupRefresh: true
|
||||
}
|
||||
}).catch(function (error) {
|
||||
console.warn('Unable to queue startup refresh for ' + source.type + ' ' + source.id + ':', error);
|
||||
});
|
||||
}, startupDelayMs);
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
module.exports = { scheduleStartupDataSourceRefreshes };
|
||||
@@ -0,0 +1,32 @@
|
||||
const { collectFontLibrarySyncOperations } = require('../../media/font-library');
|
||||
|
||||
const TASK = {
|
||||
key: 'initial-font-sync',
|
||||
category: 'fonts'
|
||||
};
|
||||
|
||||
function registerInitialFontSyncTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
|
||||
if (!backgroundTaskQueue || !mediaDir) {
|
||||
throw new Error('registerInitialFontSyncTask requires the initial font sync dependencies.');
|
||||
}
|
||||
|
||||
return backgroundTaskQueue.enqueueTask({
|
||||
key: TASK.key,
|
||||
title: 'Initial font sync',
|
||||
category: TASK.category,
|
||||
taskType: 'font-sync',
|
||||
payload: {
|
||||
mode: 'initial',
|
||||
uploadDir: mediaDir,
|
||||
operations: collectFontLibrarySyncOperations(mediaDir)
|
||||
},
|
||||
persist: true
|
||||
}).catch(function (error) {
|
||||
console.warn('Unable to queue initial font sync:', error);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerInitialFontSyncTask };
|
||||
@@ -0,0 +1,29 @@
|
||||
const TASK = {
|
||||
key: 'initial-media-sync',
|
||||
category: 'media-sync',
|
||||
};
|
||||
|
||||
function registerInitialMediaSyncTask(options) {
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
|
||||
if (!backgroundTaskQueue || !mediaDir) {
|
||||
throw new Error('registerInitialMediaSyncTask requires the initial media sync dependencies.');
|
||||
}
|
||||
|
||||
return backgroundTaskQueue.enqueueTask({
|
||||
key: TASK.key,
|
||||
title: 'Initial media sync',
|
||||
category: TASK.category,
|
||||
taskType: 'media-sync',
|
||||
payload: {
|
||||
mode: 'initial',
|
||||
uploadDir: mediaDir
|
||||
},
|
||||
persist: true
|
||||
}).catch(function (error) {
|
||||
console.warn('Unable to queue initial media sync:', error);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerInitialMediaSyncTask };
|
||||
@@ -0,0 +1,30 @@
|
||||
const path = require('path');
|
||||
|
||||
function createWebConfig() {
|
||||
const mediaDir = path.join(__dirname, '..', '..', '..', 'media');
|
||||
const uploadsDir = path.join(mediaDir, 'uploads');
|
||||
const thumbnailsDir = path.join(mediaDir, 'thumbnails');
|
||||
const assetDir = path.join(__dirname, '..', 'public');
|
||||
const playerInternalBaseUrl = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:8081').replace(/\/$/, '');
|
||||
const playerPublicBaseUrl = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:8081').replace(/\/$/, '');
|
||||
const sessionCookieName = 'digital_signage_session';
|
||||
const sessionMaxAgeDays = Number(process.env.SESSION_MAX_AGE_DAYS || 14);
|
||||
const sessionMaxAgeMs = (Number.isFinite(sessionMaxAgeDays) && sessionMaxAgeDays > 0 ? sessionMaxAgeDays : 14) * 24 * 60 * 60 * 1000;
|
||||
const port = Number(process.env.WEB_PORT || 8080);
|
||||
const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(process.env.DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS || 250));
|
||||
|
||||
return {
|
||||
port: port,
|
||||
mediaDir: mediaDir,
|
||||
uploadsDir: uploadsDir,
|
||||
thumbnailsDir: thumbnailsDir,
|
||||
assetDir: assetDir,
|
||||
playerInternalBaseUrl: playerInternalBaseUrl,
|
||||
playerPublicBaseUrl: playerPublicBaseUrl,
|
||||
sessionCookieName: sessionCookieName,
|
||||
sessionMaxAgeMs: sessionMaxAgeMs,
|
||||
dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createWebConfig };
|
||||
@@ -1,21 +1,24 @@
|
||||
// Helpers for normalizing dashboard state and date values.
|
||||
|
||||
const { WebSocket } = require('ws');
|
||||
|
||||
function normalizeClientName(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function enrichScreensWithConnections(screens, connectionsBySlug, onboardingNameBySlug) {
|
||||
function enrichScreensWithConnections(screens, connectionsBySlug, onboardingNameBySlug, playerUrlsBySlug) {
|
||||
return (screens || []).map(function (screen) {
|
||||
const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] };
|
||||
return Object.assign({}, screen, {
|
||||
client_name: onboardingNameBySlug[screen.slug] || null,
|
||||
player_connection_count: connectionState.count || 0,
|
||||
player_connections: Array.isArray(connectionState.connections) ? connectionState.connections : []
|
||||
player_connections: Array.isArray(connectionState.connections) ? connectionState.connections : [],
|
||||
player_url: String(playerUrlsBySlug && playerUrlsBySlug[screen.slug] || '').trim() || null
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate) {
|
||||
function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, formatDashboardDate) {
|
||||
return (screens || []).flatMap(function (screen) {
|
||||
const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] };
|
||||
return (connectionState.connections || []).map(function (connection) {
|
||||
@@ -27,7 +30,7 @@ function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboa
|
||||
playlist_name: screen.playlist_name || null,
|
||||
connectedAtLabel: formatDashboardDate(connection.connectedAt),
|
||||
lastSeenAtLabel: formatDashboardDate(connection.lastSeenAt),
|
||||
player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}`
|
||||
player_url: screen.player_url || String(connection.page || '').trim() || null
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,7 +54,6 @@ function createDashboardStateService(options) {
|
||||
const playerSnapshotCache = options && options.playerSnapshotCache;
|
||||
const playerSnapshotSockets = options && options.playerSnapshotSockets;
|
||||
const ensurePlayerSnapshotSubscription = options && options.ensurePlayerSnapshotSubscription;
|
||||
const playerPublicBaseUrl = String(options && options.playerPublicBaseUrl || '').replace(/\/$/, '');
|
||||
const formatDashboardDate = options && options.formatDashboardDate;
|
||||
|
||||
if (!pool || !common || !playerSnapshotCache || !playerSnapshotSockets || typeof ensurePlayerSnapshotSubscription !== 'function' || typeof formatDashboardDate !== 'function') {
|
||||
@@ -61,6 +63,9 @@ function createDashboardStateService(options) {
|
||||
async function buildDashboardState() {
|
||||
const data = await common.fetchAdminData(pool);
|
||||
const screensData = data.screens || [];
|
||||
const playerUrlsBySlug = typeof common.fetchScreenPlayerUrls === 'function'
|
||||
? await common.fetchScreenPlayerUrls(pool)
|
||||
: {};
|
||||
screensData.forEach(function (screen) {
|
||||
ensurePlayerSnapshotSubscription(screen.slug);
|
||||
});
|
||||
@@ -94,14 +99,14 @@ function createDashboardStateService(options) {
|
||||
}
|
||||
});
|
||||
|
||||
const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug)
|
||||
const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug, playerUrlsBySlug)
|
||||
.map(function (screen) {
|
||||
return Object.assign({}, screen, {
|
||||
player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}`
|
||||
player_url: screen.player_url || null
|
||||
});
|
||||
})
|
||||
.sort(compareScreenNames);
|
||||
const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate);
|
||||
const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, formatDashboardDate);
|
||||
const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) {
|
||||
return socket && socket.readyState === WebSocket.OPEN;
|
||||
});
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
async function refreshApiSource(pool, common, apiSourceId, apiUrl, actorId) {
|
||||
async function refreshApiSource(pool, common, apiSourceOrId, actorId) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const apiSource = apiSourceOrId && typeof apiSourceOrId === 'object'
|
||||
? apiSourceOrId
|
||||
: typeof common.fetchApiSourceById === 'function'
|
||||
? await common.fetchApiSourceById(pool, Number(apiSourceOrId))
|
||||
: null;
|
||||
if (!apiSource) {
|
||||
throw new Error('API source not found.');
|
||||
}
|
||||
|
||||
let responseDetails = null;
|
||||
let pullError = '';
|
||||
|
||||
try {
|
||||
responseDetails = await common.fetchApiSourceResponse(apiUrl);
|
||||
responseDetails = await common.fetchApiSourceResponse(apiSource);
|
||||
} catch (error) {
|
||||
pullError = String(error && error.message ? error.message : 'Unable to load API response.');
|
||||
}
|
||||
@@ -13,7 +22,7 @@ async function refreshApiSource(pool, common, apiSourceId, apiUrl, actorId) {
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE i_api_sources SET last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
|
||||
[new Date(), pullError || null, responseDetails ? responseDetails.responseStatus : null, responseDetails ? responseDetails.responseContentType : null, responseDetails ? responseDetails.responseJson : null, actorId, apiSourceId]
|
||||
[new Date(), pullError || null, responseDetails ? responseDetails.responseStatus : null, responseDetails ? responseDetails.responseContentType : null, responseDetails ? responseDetails.responseJson : null, actorId, apiSource.id]
|
||||
);
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Shared helpers for dashboard date formatting, uploads, and query parsing.
|
||||
|
||||
const dashboardDateFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
// Shared helpers for parsing and sorting web list query state.
|
||||
|
||||
function parsePageNumber(value) {
|
||||
const pageNumber = Math.floor(Number(value) || 1);
|
||||
return Math.max(1, pageNumber);
|
||||
}
|
||||
|
||||
function getSearchQuery(req) {
|
||||
return String(req && req.query && req.query.search || '').trim();
|
||||
}
|
||||
|
||||
function getSortQuery(req) {
|
||||
return String(req && req.query && req.query.sort || '').trim();
|
||||
}
|
||||
|
||||
function getSortDirectionQuery(req) {
|
||||
return String(req && req.query && req.query.direction || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
|
||||
}
|
||||
|
||||
function normalizeSortDirection(value) {
|
||||
return String(value || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
|
||||
}
|
||||
@@ -81,7 +95,10 @@ function createSearchMatcher(searchTerm, fields) {
|
||||
|
||||
module.exports = {
|
||||
parsePageNumber: parsePageNumber,
|
||||
getSearchQuery: getSearchQuery,
|
||||
getSortQuery: getSortQuery,
|
||||
normalizeSortDirection: normalizeSortDirection,
|
||||
getSortDirectionQuery: getSortDirectionQuery,
|
||||
getComparableSortValue: getComparableSortValue,
|
||||
compareSortValues: compareSortValues,
|
||||
sortRows: sortRows,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
function createNotifyPlayerScreens(forwardPlayerCommand) {
|
||||
if (typeof forwardPlayerCommand !== 'function') {
|
||||
throw new Error('createNotifyPlayerScreens requires a player command sender.');
|
||||
}
|
||||
|
||||
return function notifyPlayerScreens(slugs, commandOrPayload) {
|
||||
const uniqueSlugs = Array.from(new Set((slugs || []).map(function (slug) {
|
||||
return String(slug || '').trim();
|
||||
}).filter(Boolean)));
|
||||
|
||||
if (!uniqueSlugs.length) {
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
|
||||
return Promise.allSettled(uniqueSlugs.map(function (slug) {
|
||||
return forwardPlayerCommand(slug, commandOrPayload || 'refresh');
|
||||
})).then(function (results) {
|
||||
return results.filter(function (result) {
|
||||
return result.status === 'fulfilled';
|
||||
}).length;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createNotifyPlayerScreens };
|
||||
@@ -1,3 +1,5 @@
|
||||
// Shared helpers for building paginated list response state.
|
||||
|
||||
function normalizePageNumber(value) {
|
||||
const pageNumber = Math.floor(Number(value) || 1);
|
||||
return Math.max(1, pageNumber);
|
||||
@@ -89,6 +91,15 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz
|
||||
const firstQuery = Object.assign({}, queryState, { [normalizedPageParam]: 1 });
|
||||
const lastQuery = Object.assign({}, queryState, { [normalizedPageParam]: totalPages });
|
||||
|
||||
let normalizedItemLabel = String(itemLabel || 'items');
|
||||
const total = Number(totalItems) || 0;
|
||||
|
||||
if (total === 1 && normalizedItemLabel.endsWith('s')) {
|
||||
normalizedItemLabel = normalizedItemLabel.slice(0, -1);
|
||||
} else if (total > 1 && !normalizedItemLabel.endsWith('s')) {
|
||||
normalizedItemLabel += 's';
|
||||
}
|
||||
|
||||
return {
|
||||
currentPage: safeCurrentPage,
|
||||
totalPages: totalPages,
|
||||
@@ -107,7 +118,7 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz
|
||||
pages: pages,
|
||||
pageSize: normalizedPageSize,
|
||||
pageParam: normalizedPageParam,
|
||||
itemLabel: String(itemLabel || 'items'),
|
||||
itemLabel: String(normalizedItemLabel || 'item'),
|
||||
ariaLabel: String(ariaLabel || 'Pagination')
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,13 +1,55 @@
|
||||
const { createRequestAuthHeaders } = require('../../request-auth');
|
||||
const { createRequestAuthHeaders } = require('#src/request-auth');
|
||||
|
||||
function createPlayerActionService(options) {
|
||||
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||
const pool = options && options.pool;
|
||||
const configuredPlayerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||
const common = options && options.common;
|
||||
|
||||
if (!playerInternalBaseUrl || !common) {
|
||||
if (!common) {
|
||||
throw new Error('createPlayerActionService requires the player action dependencies.');
|
||||
}
|
||||
|
||||
let playerInternalBaseUrl = null;
|
||||
let playerInternalBaseUrlPromise = null;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function forwardPlayerCommand(slug, commandOrPayload, connectionId) {
|
||||
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
|
||||
? Object.assign({}, commandOrPayload)
|
||||
@@ -20,7 +62,12 @@ function createPlayerActionService(options) {
|
||||
pathname: `/api/screens/${encodeURIComponent(slug)}/commands`,
|
||||
body: payload
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/commands`, {
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
if (!resolvedPlayerInternalBaseUrl) {
|
||||
throw new Error('Unable to resolve the player internal base URL.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/commands`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -42,12 +89,50 @@ function createPlayerActionService(options) {
|
||||
});
|
||||
}
|
||||
|
||||
async function forwardAnnouncementRefresh(slug) {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'POST',
|
||||
pathname: `/api/screens/${encodeURIComponent(slug)}/announcements/refresh`,
|
||||
body: { command: 'announcement-refresh' }
|
||||
});
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
if (!resolvedPlayerInternalBaseUrl) {
|
||||
throw new Error('Unable to resolve the player internal base URL.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/announcements/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...authHeaders
|
||||
},
|
||||
body: JSON.stringify({ command: 'announcement-refresh' })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(function () { return ''; });
|
||||
const error = new Error(errorText || `Unable to refresh announcements for player ${slug}.`);
|
||||
error.statusCode = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json().catch(function () {
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
async function getScreenConnections(slug) {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: `/api/screens/${encodeURIComponent(slug)}/connections`
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, {
|
||||
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
|
||||
if (!resolvedPlayerInternalBaseUrl) {
|
||||
throw new Error('Unable to resolve the player internal base URL.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/connections`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
@@ -110,6 +195,7 @@ function createPlayerActionService(options) {
|
||||
|
||||
return {
|
||||
forwardPlayerCommand: forwardPlayerCommand,
|
||||
forwardAnnouncementRefresh: forwardAnnouncementRefresh,
|
||||
getScreenConnections: getScreenConnections,
|
||||
getScreenDeleteBlockMessage: getScreenDeleteBlockMessage,
|
||||
getSlideDeleteBlockMessage: getSlideDeleteBlockMessage,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PUBLIC_JS_ROOT = path.join(__dirname, '..', 'public', 'js');
|
||||
const REGION_ROOT_DIR = path.join(PUBLIC_JS_ROOT, 'regions');
|
||||
const REGION_TYPE_DIR = path.join(REGION_ROOT_DIR, 'type');
|
||||
const REGION_CORE_SCRIPTS = ['js/shared/placeholder-utils.js', 'js/shared/placeholder-chips.js', 'js/shared/time-date-placeholders.js', 'js/regions/region-utils.js', 'js/regions/region-types.js'];
|
||||
|
||||
function withAssetVersion(scriptPath, assetVersion) {
|
||||
if (!assetVersion) {
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
return scriptPath + (String(scriptPath).indexOf('?') === -1 ? '?v=' : '&v=') + encodeURIComponent(String(assetVersion));
|
||||
}
|
||||
|
||||
function getRegionModuleScripts(assetVersion) {
|
||||
if (!fs.existsSync(REGION_TYPE_DIR)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(REGION_TYPE_DIR, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith('.js'))
|
||||
.map((entry) => withAssetVersion(`js/regions/type/${entry.name}`, assetVersion))
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function getRegionEditorScripts(assetVersion) {
|
||||
return REGION_CORE_SCRIPTS.map((script) => withAssetVersion(script, assetVersion)).concat(getRegionModuleScripts(assetVersion));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getRegionModuleScripts,
|
||||
getRegionEditorScripts,
|
||||
withAssetVersion
|
||||
};
|
||||
@@ -1,291 +0,0 @@
|
||||
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('../../player/render-helpers');
|
||||
const { createRequestAuthHeaders } = require('../../request-auth');
|
||||
|
||||
const SYSTEM_CHROMIUM_PATHS = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
process.env.CHROMIUM_PATH,
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/usr/local/bin/chromium',
|
||||
'/snap/bin/chromium'
|
||||
].filter(Boolean);
|
||||
const PLAYER_VIEWPORT = {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
deviceScaleFactor: 1
|
||||
};
|
||||
const THUMBNAIL_MAX_SIZE = {
|
||||
width: 480,
|
||||
height: 270
|
||||
};
|
||||
|
||||
function normalizeBaseUrl(baseUrl) {
|
||||
return String(baseUrl || '').trim().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function resolveAssetUrl(baseUrl, value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
if (/^(?:https?:)?\/\//i.test(raw) || raw.startsWith('data:')) {
|
||||
return raw;
|
||||
}
|
||||
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
||||
if (!normalizedBaseUrl) {
|
||||
return raw;
|
||||
}
|
||||
if (raw.startsWith('/')) {
|
||||
return normalizedBaseUrl + raw;
|
||||
}
|
||||
return normalizedBaseUrl + '/' + raw.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
function getCanvasSize(slide) {
|
||||
const template = slide && slide.template ? slide.template : null;
|
||||
return {
|
||||
width: Math.max(1, Number(template && template.canvas_size_width ? template.canvas_size_width : 1920)),
|
||||
height: Math.max(1, Number(template && template.canvas_size_height ? template.canvas_size_height : 1080))
|
||||
};
|
||||
}
|
||||
|
||||
function getRegionContent(slide, region) {
|
||||
const content = slide && slide.content && slide.content[region.region_key] ? slide.content[region.region_key] : {};
|
||||
return content && typeof content === 'object' ? content : { value: content };
|
||||
}
|
||||
|
||||
function hasVisibleContent(html) {
|
||||
return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim());
|
||||
}
|
||||
|
||||
function buildTextRegionMarkup(region, regionContent) {
|
||||
const fontFamily = sanitizeFontFamily(regionContent.font_family || region.font_family);
|
||||
const fontSize = sanitizeFontSize(regionContent.font_size || region.font_size);
|
||||
const fontColor = sanitizeTextColor(regionContent.font_color || region.font_color);
|
||||
const renderedBody = renderEditorJsContent(regionContent.value || '');
|
||||
if (!hasVisibleContent(renderedBody)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '<div class="template-region text" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor || '#000000') + ';">' + renderedBody + '</div></div>';
|
||||
}
|
||||
|
||||
function buildRegionInnerHtml(region, regionContent, baseUrl) {
|
||||
const regionType = String(regionContent.type || region.region_type || 'text').trim().toLowerCase();
|
||||
const rawValue = regionContent && regionContent.value !== undefined ? regionContent.value : '';
|
||||
|
||||
if (regionType === 'image') {
|
||||
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||
return src
|
||||
? '<img src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'image') + '" />'
|
||||
: '';
|
||||
}
|
||||
|
||||
if (regionType === 'video') {
|
||||
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||
return src
|
||||
? '<video src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'video') + '" muted playsinline preload="metadata"></video>'
|
||||
: '';
|
||||
}
|
||||
|
||||
if (regionType === 'webpage') {
|
||||
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||
return src
|
||||
? '<iframe src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'webpage') + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>'
|
||||
: '';
|
||||
}
|
||||
|
||||
if (regionType === 'html') {
|
||||
const html = String(rawValue || '').trim();
|
||||
return html
|
||||
? '<iframe sandbox="" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no"></iframe>'
|
||||
: '';
|
||||
}
|
||||
|
||||
if (regionType === 'rtmp') {
|
||||
const label = String(rawValue || '').trim() || 'RTMP source';
|
||||
return '<div class="template-region-rtmp-placeholder">' + escapeHtml(label) + '</div>';
|
||||
}
|
||||
|
||||
return buildTextRegionMarkup(region, regionContent);
|
||||
}
|
||||
|
||||
async function launchBrowser() {
|
||||
let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) {
|
||||
return fs.existsSync(candidate);
|
||||
}) || '';
|
||||
const usingSystemChromium = Boolean(executablePath);
|
||||
|
||||
if (!executablePath && chromium && typeof chromium.executablePath === 'function') {
|
||||
executablePath = await chromium.executablePath();
|
||||
}
|
||||
|
||||
if (!executablePath || !fs.existsSync(executablePath)) {
|
||||
throw new Error('Chromium executable was not found.');
|
||||
}
|
||||
|
||||
if (usingSystemChromium) {
|
||||
return puppeteer.launch({
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu'
|
||||
],
|
||||
defaultViewport: { width: 1920, height: 1080, deviceScaleFactor: 1 },
|
||||
executablePath: executablePath,
|
||||
headless: true
|
||||
});
|
||||
}
|
||||
|
||||
return puppeteer.launch({
|
||||
args: puppeteer.defaultArgs({
|
||||
args: chromium && chromium.args ? chromium.args : [],
|
||||
headless: 'shell'
|
||||
}),
|
||||
defaultViewport: chromium && chromium.defaultViewport ? chromium.defaultViewport : null,
|
||||
executablePath: executablePath,
|
||||
headless: 'shell'
|
||||
});
|
||||
}
|
||||
|
||||
async function captureSlideThumbnail(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
const baseUrl = normalizeBaseUrl(options && options.baseUrl);
|
||||
const slideId = Number(options && options.slideId || 0);
|
||||
const previousThumbnailPath = String(options && options.previousThumbnailPath || '').trim();
|
||||
|
||||
if (!pool || !common || !mediaDir || !Number.isFinite(slideId) || slideId <= 0) {
|
||||
throw new Error('captureSlideThumbnail requires pool, common, mediaDir, and slideId.');
|
||||
}
|
||||
|
||||
const slide = await common.fetchSlideById(pool, slideId);
|
||||
if (!slide) {
|
||||
throw new Error('Slide not found.');
|
||||
}
|
||||
|
||||
const canvasSize = getCanvasSize(slide);
|
||||
const thumbnailDir = path.join(mediaDir, 'thumbnails');
|
||||
const thumbnailRelativePath = String(slide.thumbnail_path || '').trim() || '/media/thumbnails/slides/slide-' + slide.id + '.png';
|
||||
const filePath = path.join(mediaDir, thumbnailRelativePath.replace(/^\/+media\//, ''));
|
||||
const fullSizePath = filePath.replace(/\.png$/i, '.full.png');
|
||||
const thumbnailTempPath = filePath.replace(/\.png$/i, '.tmp.png');
|
||||
const thumbnailPath = thumbnailRelativePath;
|
||||
|
||||
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
||||
|
||||
async function waitForThumbnailRender(page) {
|
||||
await page.waitForFunction(function () {
|
||||
return document.readyState === 'complete' && Boolean(document.querySelector('.slide-canvas'));
|
||||
}, { timeout: 30000 });
|
||||
|
||||
await page.waitForFunction(function () {
|
||||
var canvas = document.querySelector('.slide-canvas');
|
||||
if (!canvas) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var images = Array.prototype.slice.call(canvas.querySelectorAll('img'));
|
||||
return images.every(function (image) {
|
||||
return image.complete && typeof image.naturalWidth === 'number';
|
||||
});
|
||||
}, { timeout: 30000 });
|
||||
|
||||
await page.evaluate(async function () {
|
||||
if (document.fonts && document.fonts.ready) {
|
||||
try {
|
||||
await document.fonts.ready;
|
||||
} catch (_error) {
|
||||
// Ignore font readiness failures and fall back to the rendered frame.
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await page.evaluate(function () {
|
||||
return new Promise(function (resolve) {
|
||||
window.requestAnimationFrame(function () {
|
||||
window.requestAnimationFrame(resolve);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const browser = await launchBrowser();
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
try {
|
||||
const previewPath = '/api/internal/slide-thumbnails/' + slide.id + '/preview';
|
||||
const previewUrl = baseUrl + previewPath;
|
||||
await page.setExtraHTTPHeaders(createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: previewPath
|
||||
}));
|
||||
await page.setViewport(PLAYER_VIEWPORT);
|
||||
await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await waitForThumbnailRender(page);
|
||||
const canvas = await page.$('.slide-canvas');
|
||||
if (!canvas) {
|
||||
throw new Error('Player render did not produce a slide canvas.');
|
||||
}
|
||||
await canvas.screenshot({ path: fullSizePath });
|
||||
} finally {
|
||||
await page.close().catch(function () {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
await browser.close().catch(function () {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
await sharp(fullSizePath)
|
||||
.resize({
|
||||
width: THUMBNAIL_MAX_SIZE.width,
|
||||
height: THUMBNAIL_MAX_SIZE.height,
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true
|
||||
})
|
||||
.png()
|
||||
.toFile(thumbnailTempPath);
|
||||
|
||||
await fs.promises.rm(filePath, { force: true });
|
||||
await fs.promises.rename(thumbnailTempPath, filePath);
|
||||
await fs.promises.unlink(fullSizePath).catch(function (error) {
|
||||
if (!error || error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
await pool.query('UPDATE c_slides SET thumbnail_path = ? WHERE id = ?', [thumbnailPath, slide.id]);
|
||||
return {
|
||||
slideId: slide.id,
|
||||
thumbnailPath: thumbnailPath,
|
||||
filePath: filePath,
|
||||
fullSizePath: fullSizePath,
|
||||
mediaKind: mediaKind('')
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
captureSlideThumbnail: captureSlideThumbnail
|
||||
};
|
||||
@@ -1,721 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const multer = require('multer');
|
||||
const { createRequestAuthHeaders } = require('../../request-auth');
|
||||
|
||||
function normalizeUploadRoot(uploadDir) {
|
||||
return path.resolve(String(uploadDir || '').trim());
|
||||
}
|
||||
|
||||
function createUploadSyncService(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const playerInternalBaseUrl = 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;
|
||||
|
||||
let playerUploadSyncMode = null;
|
||||
let playerUploadSyncModePromise = null;
|
||||
const pendingPlayerUploadSyncs = new Map();
|
||||
let pendingPlayerUploadSyncFlushTimer = null;
|
||||
let pendingPlayerUploadSyncFlushInFlight = null;
|
||||
const pendingPlaylistUploadSyncs = new Map();
|
||||
let pendingPlaylistUploadSyncFlushTimer = null;
|
||||
let pendingPlaylistUploadSyncFlushInFlight = null;
|
||||
|
||||
if (!common || !playerSnapshotCache || typeof notifyPlayerScreens !== 'function') {
|
||||
throw new Error('createUploadSyncService requires the upload dependencies.');
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function getPlayerUploadSyncMode(localUploadDir) {
|
||||
if (playerUploadSyncMode) {
|
||||
return playerUploadSyncMode;
|
||||
}
|
||||
if (playerUploadSyncModePromise) {
|
||||
return playerUploadSyncModePromise;
|
||||
}
|
||||
|
||||
playerUploadSyncModePromise = (async function () {
|
||||
try {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: '/api/media/config'
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/config`, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...authHeaders
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
const data = await response.json();
|
||||
const playerUploadDir = data && (data.uploadDir || data.mediaDir) ? normalizeUploadRoot(data.uploadDir || data.mediaDir) : null;
|
||||
if (!playerUploadDir) {
|
||||
return null;
|
||||
}
|
||||
return playerUploadDir === normalizeUploadRoot(localUploadDir) ? 'shared' : 'different';
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
})().then(function (mode) {
|
||||
if (mode) {
|
||||
playerUploadSyncMode = mode;
|
||||
}
|
||||
playerUploadSyncModePromise = null;
|
||||
return mode;
|
||||
}, function () {
|
||||
playerUploadSyncModePromise = null;
|
||||
return null;
|
||||
});
|
||||
|
||||
return playerUploadSyncModePromise;
|
||||
}
|
||||
|
||||
async 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 || !(await shouldMirrorUploads(localUploadDir))) {
|
||||
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(`${playerInternalBaseUrl}/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 || !(await shouldMirrorUploads(localUploadDir))) {
|
||||
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(`${playerInternalBaseUrl}/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 (!(await 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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function syncExistingUploadsToPlayer(pool, localUploadDir) {
|
||||
return queueMediaSyncTask('media-sync:initial', 'Initial media sync', {
|
||||
mode: 'initial',
|
||||
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);
|
||||
});
|
||||
});
|
||||
Array.from(uploadRefs).forEach(function (uploadPath) {
|
||||
queuePlayerUploadSync({
|
||||
type: 'put',
|
||||
uploadPath: 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,
|
||||
getPlayerUploadSyncMode: getPlayerUploadSyncMode,
|
||||
shouldMirrorUploads: shouldMirrorUploads,
|
||||
queuePlayerUploadSync: queuePlayerUploadSync,
|
||||
schedulePendingPlayerUploadSyncFlush: schedulePendingPlayerUploadSyncFlush,
|
||||
pushUploadFileToPlayer: pushUploadFileToPlayer,
|
||||
removeUploadFileFromPlayer: removeUploadFileFromPlayer,
|
||||
syncUploadRefsToPlayer: syncUploadRefsToPlayer,
|
||||
syncExistingUploadsToPlayer: syncExistingUploadsToPlayer,
|
||||
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 };
|
||||
@@ -0,0 +1,34 @@
|
||||
async function initializeWebServer(options) {
|
||||
const common = options && options.common;
|
||||
const pool = options && options.pool;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const webBootstrap = options && options.webBootstrap;
|
||||
const loadCurrentUser = options && options.loadCurrentUser;
|
||||
const initializeBackgroundTasks = options && options.initializeBackgroundTasks;
|
||||
const captureSlideThumbnail = options && options.captureSlideThumbnail;
|
||||
const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(options && options.dataSourceStartupRefreshStaggerMs || 250));
|
||||
const server = options && options.server;
|
||||
|
||||
if (!common || !pool || !mediaDir || !backgroundTaskQueue || !webBootstrap || typeof loadCurrentUser !== 'function' || typeof initializeBackgroundTasks !== 'function' || typeof captureSlideThumbnail !== 'function' || !server) {
|
||||
throw new Error('initializeWebServer requires the web startup dependencies.');
|
||||
}
|
||||
|
||||
await common.ensureSchema(pool, { mediaDir: mediaDir });
|
||||
await common.bootstrapDatabase(pool);
|
||||
await backgroundTaskQueue.initialize();
|
||||
|
||||
await initializeBackgroundTasks({
|
||||
pool: pool,
|
||||
common: common,
|
||||
backgroundTaskQueue: backgroundTaskQueue,
|
||||
uploadSyncService: webBootstrap.uploadSyncService,
|
||||
captureSlideThumbnail: captureSlideThumbnail,
|
||||
mediaDir: mediaDir,
|
||||
dataSourceStartupRefreshStaggerMs: dataSourceStartupRefreshStaggerMs
|
||||
});
|
||||
|
||||
webBootstrap.installDashboardWebsocket(server, loadCurrentUser);
|
||||
}
|
||||
|
||||
module.exports = { initializeWebServer };
|
||||
Reference in New Issue
Block a user