Save worktree changes
This commit is contained in:
@@ -0,0 +1,743 @@
|
||||
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 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 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 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 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 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 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 listTasks() {
|
||||
return Array.from(tasksById.values())
|
||||
.slice()
|
||||
.sort(function (left, right) {
|
||||
const statusRank = {
|
||||
running: 0,
|
||||
queued: 1,
|
||||
failed: 2,
|
||||
completed: 3,
|
||||
canceled: 4
|
||||
};
|
||||
|
||||
const leftRank = Object.prototype.hasOwnProperty.call(statusRank, left.status) ? statusRank[left.status] : 9;
|
||||
const rightRank = Object.prototype.hasOwnProperty.call(statusRank, right.status) ? statusRank[right.status] : 9;
|
||||
if (leftRank !== rightRank) {
|
||||
return leftRank - rightRank;
|
||||
}
|
||||
|
||||
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,
|
||||
listTasks: listTasks,
|
||||
listRecurringTasks: listRecurringTasks,
|
||||
getTaskById: getTaskById,
|
||||
getSummary: getSummary,
|
||||
cancelTask: cancelTask,
|
||||
retryTask: retryTask,
|
||||
clearFinishedTasks: clearFinishedTasks
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBackgroundTaskQueue: createBackgroundTaskQueue,
|
||||
normalizeIntervalMs: normalizeIntervalMs
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
const { WebSocket } = require('ws');
|
||||
|
||||
function normalizeClientName(value) {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function enrichScreensWithConnections(screens, connectionsBySlug, onboardingNameBySlug) {
|
||||
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 : []
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate) {
|
||||
return (screens || []).flatMap(function (screen) {
|
||||
const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] };
|
||||
return (connectionState.connections || []).map(function (connection) {
|
||||
const deviceId = String(connection.deviceId || '').trim();
|
||||
return Object.assign({}, connection, {
|
||||
screen_slug: screen.slug,
|
||||
screen_name: screen.name,
|
||||
client_name: (deviceId && onboardingNameByDeviceId && onboardingNameByDeviceId[deviceId]) || connection.clientName || onboardingNameBySlug[screen.slug] || String(connection.clientId || '').trim() || null,
|
||||
playlist_name: screen.playlist_name || null,
|
||||
connectedAtLabel: formatDashboardDate(connection.connectedAt),
|
||||
lastSeenAtLabel: formatDashboardDate(connection.lastSeenAt),
|
||||
player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}`
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createDashboardStateService(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
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') {
|
||||
throw new Error('createDashboardStateService requires the dashboard dependencies.');
|
||||
}
|
||||
|
||||
async function buildDashboardState() {
|
||||
const data = await common.fetchAdminData(pool);
|
||||
const screensData = data.screens || [];
|
||||
screensData.forEach(function (screen) {
|
||||
ensurePlayerSnapshotSubscription(screen.slug);
|
||||
});
|
||||
|
||||
const [onboardingRows] = await pool.query(
|
||||
`SELECT s.slug, pod.device_id, pod.client_name
|
||||
FROM player_onboarding_devices pod
|
||||
JOIN screens s ON s.id = pod.screen_id
|
||||
WHERE pod.client_name IS NOT NULL
|
||||
AND TRIM(pod.client_name) <> ''`
|
||||
);
|
||||
const onboardingNameBySlug = {};
|
||||
const onboardingNameByDeviceId = {};
|
||||
onboardingRows.forEach(function (row) {
|
||||
const clientName = normalizeClientName(row.client_name);
|
||||
const slug = normalizeClientName(row.slug);
|
||||
const deviceId = normalizeClientName(row.device_id);
|
||||
if (slug) {
|
||||
onboardingNameBySlug[slug] = clientName;
|
||||
}
|
||||
if (deviceId) {
|
||||
onboardingNameByDeviceId[deviceId] = clientName;
|
||||
}
|
||||
});
|
||||
|
||||
const connectionsBySlug = {};
|
||||
screensData.forEach(function (screen) {
|
||||
const cached = playerSnapshotCache.get(String(screen.slug || '').trim());
|
||||
if (cached && Array.isArray(cached.connections)) {
|
||||
connectionsBySlug[screen.slug] = cached;
|
||||
}
|
||||
});
|
||||
|
||||
const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug).map(function (screen) {
|
||||
return Object.assign({}, screen, {
|
||||
player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}`
|
||||
});
|
||||
});
|
||||
const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate);
|
||||
const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) {
|
||||
return socket && socket.readyState === WebSocket.OPEN;
|
||||
});
|
||||
|
||||
return {
|
||||
playlists: data.playlists || [],
|
||||
screens: screens,
|
||||
clients: clients,
|
||||
slides: data.slides || [],
|
||||
playerServiceConnected: playerServiceConnected,
|
||||
connectedClientsCount: screens.reduce(function (total, screen) {
|
||||
return total + Number(screen.player_connection_count || 0);
|
||||
}, 0)
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
buildDashboardState: buildDashboardState
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createDashboardStateService };
|
||||
@@ -0,0 +1,67 @@
|
||||
async function refreshApiSource(pool, common, apiSourceId, apiUrl, actorId) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
let responseDetails = null;
|
||||
let pullError = '';
|
||||
|
||||
try {
|
||||
responseDetails = await common.fetchApiSourceResponse(apiUrl);
|
||||
} catch (error) {
|
||||
pullError = String(error && error.message ? error.message : 'Unable to load API response.');
|
||||
}
|
||||
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE 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]
|
||||
);
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRssFeed(pool, common, rssFeedId, feedUrl, itemLimit, actorId) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
let updatedItems = [];
|
||||
let pullError = '';
|
||||
|
||||
try {
|
||||
updatedItems = await common.fetchRssFeedItems(feedUrl, itemLimit);
|
||||
} catch (error) {
|
||||
pullError = String(error && error.message ? error.message : 'Unable to load feed items.');
|
||||
}
|
||||
|
||||
await connection.beginTransaction();
|
||||
if (typeof common.replaceRssFeedItems === 'function') {
|
||||
await common.replaceRssFeedItems(connection, rssFeedId, updatedItems);
|
||||
}
|
||||
await connection.commit();
|
||||
|
||||
if (pullError) {
|
||||
console.error('[data-source-refresh] RSS feed refresh completed with an error for feed ' + rssFeedId + ': ' + pullError);
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
refreshApiSource: refreshApiSource,
|
||||
refreshRssFeed: refreshRssFeed
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
const dashboardDateFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
|
||||
function normalizeUploadRoot(uploadDir) {
|
||||
return require('path').resolve(String(uploadDir || '').trim());
|
||||
}
|
||||
|
||||
function formatDashboardDate(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
return dashboardDateFormatter.format(date);
|
||||
}
|
||||
|
||||
function buildDashboardPayload(state) {
|
||||
return JSON.stringify({
|
||||
type: 'dashboard-state',
|
||||
state: state
|
||||
});
|
||||
}
|
||||
|
||||
function readArrayField(body, keys) {
|
||||
const searchKeys = Array.isArray(keys) ? keys : [keys];
|
||||
for (let i = 0; i < searchKeys.length; i += 1) {
|
||||
const key = searchKeys[i];
|
||||
const value = body && Object.prototype.hasOwnProperty.call(body, key) ? body[key] : undefined;
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter(function (item) {
|
||||
return item !== undefined && item !== null && String(item).trim() !== '';
|
||||
}).map(function (item) {
|
||||
return String(item);
|
||||
});
|
||||
}
|
||||
if (value !== undefined && value !== null && String(value).trim() !== '') {
|
||||
return [String(value)];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function parseDateTimeLocal(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(String(value));
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function parseTimeLocal(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
if (!/^\d{2}:\d{2}(:\d{2})?$/.test(raw)) {
|
||||
return null;
|
||||
}
|
||||
return raw.length === 5 ? raw + ':00' : raw;
|
||||
}
|
||||
|
||||
function normalizeScheduleMode(value) {
|
||||
const mode = String(value || 'always');
|
||||
if (mode === 'dates' || mode === 'times') {
|
||||
return mode;
|
||||
}
|
||||
return 'always';
|
||||
}
|
||||
|
||||
function getAuditUserId(req) {
|
||||
return req && req.currentUser ? Number(req.currentUser.id) : null;
|
||||
}
|
||||
|
||||
function getCanvasSignature(width, height) {
|
||||
const normalizedWidth = Number(width);
|
||||
const normalizedHeight = Number(height);
|
||||
if (!Number.isFinite(normalizedWidth) || !Number.isFinite(normalizedHeight)) {
|
||||
return null;
|
||||
}
|
||||
return normalizedWidth + 'x' + normalizedHeight;
|
||||
}
|
||||
|
||||
async function fetchPlaylistCanvasSignature(pool, playlistId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT DISTINCT cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM playlist_slides ps
|
||||
JOIN slides sl ON sl.id = ps.slide_id
|
||||
LEFT JOIN slide_templates st ON st.id = sl.template_id
|
||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
WHERE ps.playlist_id = ?
|
||||
AND cs.width IS NOT NULL
|
||||
AND cs.height IS NOT NULL`,
|
||||
[playlistId]
|
||||
);
|
||||
const signatures = Array.from(new Set(rows.map(function (row) {
|
||||
return getCanvasSignature(row.canvas_width, row.canvas_height);
|
||||
}).filter(Boolean)));
|
||||
if (!signatures.length) {
|
||||
return null;
|
||||
}
|
||||
return signatures.length === 1 ? signatures[0] : 'mismatch';
|
||||
}
|
||||
|
||||
async function fetchScreensByPlaylistId(connection, playlistId) {
|
||||
const [rows] = await connection.query(
|
||||
'SELECT slug FROM screens WHERE playlist_id = ? AND slug IS NOT NULL',
|
||||
[playlistId]
|
||||
);
|
||||
return rows.map(function (row) {
|
||||
return row.slug;
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchScreensBySlideId(connection, slideId) {
|
||||
const [rows] = await connection.query(
|
||||
`SELECT DISTINCT s.slug
|
||||
FROM screens s
|
||||
JOIN playlist_slides ps ON ps.playlist_id = s.playlist_id
|
||||
WHERE ps.slide_id = ?
|
||||
AND s.slug IS NOT NULL`,
|
||||
[slideId]
|
||||
);
|
||||
return rows.map(function (row) {
|
||||
return row.slug;
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchScreensByTemplateId(connection, templateId) {
|
||||
const [rows] = await connection.query(
|
||||
`SELECT DISTINCT s.slug
|
||||
FROM screens s
|
||||
JOIN playlist_slides ps ON ps.playlist_id = s.playlist_id
|
||||
JOIN slides sl ON sl.id = ps.slide_id
|
||||
WHERE sl.template_id = ?
|
||||
AND s.slug IS NOT NULL`,
|
||||
[templateId]
|
||||
);
|
||||
return rows.map(function (row) {
|
||||
return row.slug;
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchOrderedPlaylistSlides(connection, playlistId) {
|
||||
const [rows] = await connection.query(
|
||||
'SELECT id, position FROM playlist_slides WHERE playlist_id = ? ORDER BY position ASC, id ASC',
|
||||
[playlistId]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
function redirectAfterSave(req, res, defaultUrl, options) {
|
||||
const safeOptions = options || {};
|
||||
const action = String((req && req.body && req.body.action) || req.query.action || '').toLowerCase();
|
||||
if (action === 'close') {
|
||||
return res.redirect(safeOptions.closeUrl || defaultUrl);
|
||||
}
|
||||
if (action === 'new') {
|
||||
return res.redirect(safeOptions.newUrl || defaultUrl);
|
||||
}
|
||||
const message = safeOptions.message || '';
|
||||
if (message) {
|
||||
const joiner = defaultUrl.indexOf('?') === -1 ? '?' : '&';
|
||||
return res.redirect(defaultUrl + joiner + 'message=' + encodeURIComponent(message));
|
||||
}
|
||||
return res.redirect(defaultUrl);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeUploadRoot: normalizeUploadRoot,
|
||||
formatDashboardDate: formatDashboardDate,
|
||||
buildDashboardPayload: buildDashboardPayload,
|
||||
readArrayField: readArrayField,
|
||||
parseDateTimeLocal: parseDateTimeLocal,
|
||||
parseTimeLocal: parseTimeLocal,
|
||||
normalizeScheduleMode: normalizeScheduleMode,
|
||||
getAuditUserId: getAuditUserId,
|
||||
getCanvasSignature: getCanvasSignature,
|
||||
fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature,
|
||||
fetchScreensByPlaylistId: fetchScreensByPlaylistId,
|
||||
fetchScreensBySlideId: fetchScreensBySlideId,
|
||||
fetchScreensByTemplateId: fetchScreensByTemplateId,
|
||||
fetchOrderedPlaylistSlides: fetchOrderedPlaylistSlides,
|
||||
redirectAfterSave: redirectAfterSave
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
const { createRequestAuthHeaders } = require('../../request-auth');
|
||||
|
||||
function createPlayerActionService(options) {
|
||||
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').replace(/\/$/, '');
|
||||
const common = options && options.common;
|
||||
|
||||
if (!playerInternalBaseUrl || !common) {
|
||||
throw new Error('createPlayerActionService requires the player action dependencies.');
|
||||
}
|
||||
|
||||
async function forwardPlayerCommand(slug, commandOrPayload, connectionId) {
|
||||
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
|
||||
? Object.assign({}, commandOrPayload)
|
||||
: { command: commandOrPayload };
|
||||
if (connectionId) {
|
||||
payload.connectionId = connectionId;
|
||||
}
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'POST',
|
||||
pathname: `/api/screens/${encodeURIComponent(slug)}/commands`,
|
||||
body: payload
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/commands`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...authHeaders
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(function () { return ''; });
|
||||
const error = new Error(errorText || `Unable to send command to 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`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...authHeaders
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(function () { return ''; });
|
||||
const error = new Error(errorText || `Unable to load screen connections for ${slug}.`);
|
||||
error.statusCode = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response.json().catch(function () {
|
||||
return { connections: [] };
|
||||
});
|
||||
}
|
||||
|
||||
async function getScreenDeleteBlockMessage(pool, screen, getScreenConnections) {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM player_onboarding_devices WHERE screen_id = ?', [screen.id]);
|
||||
if (Number(rows[0] && rows[0].ref_count) > 0) {
|
||||
return 'This screen is still linked to onboarding devices.';
|
||||
}
|
||||
|
||||
if (typeof getScreenConnections === 'function' && String(screen && screen.slug ? screen.slug : '').trim()) {
|
||||
try {
|
||||
const response = await getScreenConnections(screen.slug);
|
||||
const liveConnections = Array.isArray(response && response.connections) ? response.connections : [];
|
||||
if (liveConnections.length > 0) {
|
||||
return 'This screen is still in use by connected players.';
|
||||
}
|
||||
} catch (_error) {
|
||||
// Keep the delete guard based on onboarding references if live connection lookup fails.
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function getSlideDeleteBlockMessage(pool, slide) {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM playlist_slides WHERE slide_id = ?', [slide.id]);
|
||||
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This slide is still used by one or more playlists.' : '';
|
||||
}
|
||||
|
||||
async function getTemplateDeleteBlockMessage(pool, template) {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM slides WHERE template_id = ?', [template.id]);
|
||||
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This template is still used by one or more slides.' : '';
|
||||
}
|
||||
|
||||
async function getCanvasSizeDeleteBlockMessage(pool, canvasSize) {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM slide_templates WHERE canvas_size_id = ?', [canvasSize.id]);
|
||||
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This canvas size is still used by one or more templates.' : '';
|
||||
}
|
||||
|
||||
async function getPlaylistDeleteBlockMessage(pool, playlist) {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM screens WHERE playlist_id = ?', [playlist.id]);
|
||||
return Number(rows[0] && rows[0].ref_count) > 0 ? 'This playlist is still assigned to one or more screens.' : '';
|
||||
}
|
||||
|
||||
return {
|
||||
forwardPlayerCommand: forwardPlayerCommand,
|
||||
getScreenConnections: getScreenConnections,
|
||||
getScreenDeleteBlockMessage: getScreenDeleteBlockMessage,
|
||||
getSlideDeleteBlockMessage: getSlideDeleteBlockMessage,
|
||||
getTemplateDeleteBlockMessage: getTemplateDeleteBlockMessage,
|
||||
getCanvasSizeDeleteBlockMessage: getCanvasSizeDeleteBlockMessage,
|
||||
getPlaylistDeleteBlockMessage: getPlaylistDeleteBlockMessage
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createPlayerActionService };
|
||||
@@ -0,0 +1,196 @@
|
||||
const { PERMISSIONS, normalizePermissionKeys } = require('../../rbac');
|
||||
|
||||
function parseCsvIds(value) {
|
||||
return String(value || '')
|
||||
.split(',')
|
||||
.map(function (item) {
|
||||
return Number(item);
|
||||
})
|
||||
.filter(function (item) {
|
||||
return Number.isInteger(item) && item > 0;
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchPermissions(pool) {
|
||||
const [rows] = await pool.query('SELECT id, permission_key, name, section_name, description FROM permissions ORDER BY section_name ASC, name ASC');
|
||||
return rows || [];
|
||||
}
|
||||
|
||||
async function fetchRoles(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
|
||||
(SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count,
|
||||
(SELECT COUNT(*) FROM role_permissions rp WHERE rp.role_id = r.id) AS permission_count
|
||||
FROM roles r
|
||||
ORDER BY r.name ASC`
|
||||
);
|
||||
return rows || [];
|
||||
}
|
||||
|
||||
async function fetchRoleById(pool, roleId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
|
||||
(SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count,
|
||||
(SELECT COUNT(*) FROM role_permissions rp WHERE rp.role_id = r.id) AS permission_count
|
||||
FROM roles r
|
||||
WHERE r.id = ?
|
||||
LIMIT 1`,
|
||||
[roleId]
|
||||
);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function fetchRolePermissionKeys(pool, roleId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT p.permission_key
|
||||
FROM role_permissions rp
|
||||
JOIN permissions p ON p.id = rp.permission_id
|
||||
WHERE rp.role_id = ?
|
||||
ORDER BY p.section_name ASC, p.name ASC`,
|
||||
[roleId]
|
||||
);
|
||||
return (rows || []).map(function (row) {
|
||||
return String(row.permission_key || '').trim();
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
async function fetchRoleUserIds(pool, roleId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT ur.user_id
|
||||
FROM user_roles ur
|
||||
WHERE ur.role_id = ?
|
||||
ORDER BY ur.user_id ASC`,
|
||||
[roleId]
|
||||
);
|
||||
return (rows || []).map(function (row) {
|
||||
return Number(row.user_id);
|
||||
}).filter(function (userId) {
|
||||
return Number.isInteger(userId) && userId > 0;
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchRolesForUser(pool, userId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT r.id, r.role_key, r.name, r.description
|
||||
FROM user_roles ur
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
WHERE ur.user_id = ?
|
||||
ORDER BY r.name ASC`,
|
||||
[userId]
|
||||
);
|
||||
return rows || [];
|
||||
}
|
||||
|
||||
async function fetchUsersWithRoles(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
|
||||
COALESCE(role_data.role_names, '') AS role_names,
|
||||
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
|
||||
FROM users u
|
||||
LEFT JOIN (
|
||||
SELECT ur.user_id,
|
||||
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
|
||||
GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv
|
||||
FROM user_roles ur
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
GROUP BY ur.user_id
|
||||
) role_data ON role_data.user_id = u.id
|
||||
ORDER BY u.id ASC`
|
||||
);
|
||||
|
||||
return (rows || []).map(function (row) {
|
||||
return Object.assign({}, row, {
|
||||
roleIds: parseCsvIds(row.role_ids_csv),
|
||||
roleNames: String(row.role_names || '').trim()
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchUserWithRoles(pool, userId) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
|
||||
COALESCE(role_data.role_names, '') AS role_names,
|
||||
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
|
||||
FROM users u
|
||||
LEFT JOIN (
|
||||
SELECT ur.user_id,
|
||||
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
|
||||
GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv
|
||||
FROM user_roles ur
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
GROUP BY ur.user_id
|
||||
) role_data ON role_data.user_id = u.id
|
||||
WHERE u.id = ?
|
||||
LIMIT 1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (!rows.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign({}, rows[0], {
|
||||
roleIds: parseCsvIds(rows[0].role_ids_csv),
|
||||
roleNames: String(rows[0].role_names || '').trim()
|
||||
});
|
||||
}
|
||||
|
||||
async function syncUserRoles(pool, userId, roleIds) {
|
||||
const uniqueRoleIds = Array.from(new Set((Array.isArray(roleIds) ? roleIds : []).map(function (roleId) {
|
||||
return Number(roleId);
|
||||
}).filter(function (roleId) {
|
||||
return Number.isInteger(roleId) && roleId > 0;
|
||||
})));
|
||||
|
||||
await pool.query('DELETE FROM user_roles WHERE user_id = ?', [userId]);
|
||||
for (const roleId of uniqueRoleIds) {
|
||||
await pool.query('INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncRoleUsers(pool, roleId, userIds) {
|
||||
const uniqueUserIds = Array.from(new Set((Array.isArray(userIds) ? userIds : []).map(function (userId) {
|
||||
return Number(userId);
|
||||
}).filter(function (userId) {
|
||||
return Number.isInteger(userId) && userId > 0;
|
||||
})));
|
||||
|
||||
await pool.query('DELETE FROM user_roles WHERE role_id = ?', [roleId]);
|
||||
for (const userId of uniqueUserIds) {
|
||||
await pool.query('INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncRolePermissions(pool, roleId, permissionKeys) {
|
||||
const uniquePermissionKeys = normalizePermissionKeys(permissionKeys);
|
||||
|
||||
if (!uniquePermissionKeys.length) {
|
||||
await pool.query('DELETE FROM role_permissions WHERE role_id = ?', [roleId]);
|
||||
return;
|
||||
}
|
||||
|
||||
const [permissionRows] = await pool.query('SELECT id, permission_key FROM permissions WHERE permission_key IN (?)', [uniquePermissionKeys]);
|
||||
if (permissionRows.length !== uniquePermissionKeys.length) {
|
||||
throw new Error('One or more selected permissions are invalid.');
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM role_permissions WHERE role_id = ?', [roleId]);
|
||||
for (const permissionRow of permissionRows) {
|
||||
await pool.query('INSERT IGNORE INTO role_permissions (role_id, permission_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [roleId, Number(permissionRow.id), null, null]);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PERMISSIONS,
|
||||
fetchPermissions,
|
||||
fetchRoles,
|
||||
fetchRoleById,
|
||||
fetchRolePermissionKeys,
|
||||
fetchRoleUserIds,
|
||||
fetchRolesForUser,
|
||||
fetchUsersWithRoles,
|
||||
fetchUserWithRoles,
|
||||
syncUserRoles,
|
||||
syncRoleUsers,
|
||||
syncRolePermissions
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
const { normalizePermissionKeys } = require('../../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;
|
||||
|
||||
if (!sessionCookieName || !Number.isFinite(sessionMaxAgeMs) || typeof hashSessionToken !== 'function' || typeof createSessionToken !== 'function') {
|
||||
throw new Error('createSessionService requires the session dependencies.');
|
||||
}
|
||||
|
||||
function parseCookies(cookieHeader) {
|
||||
return String(cookieHeader || '').split(/;\s*/).reduce(function (cookies, pair) {
|
||||
if (!pair) {
|
||||
return cookies;
|
||||
}
|
||||
const separatorIndex = pair.indexOf('=');
|
||||
if (separatorIndex === -1) {
|
||||
return cookies;
|
||||
}
|
||||
const name = decodeURIComponent(pair.slice(0, separatorIndex).trim());
|
||||
const value = decodeURIComponent(pair.slice(separatorIndex + 1).trim());
|
||||
if (name) {
|
||||
cookies[name] = value;
|
||||
}
|
||||
return cookies;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function serializeCookie(name, value, options) {
|
||||
const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`];
|
||||
if (options && options.maxAge !== undefined) {
|
||||
parts.push(`Max-Age=${Math.max(0, Math.trunc(Number(options.maxAge) / 1000))}`);
|
||||
}
|
||||
parts.push('Path=/');
|
||||
parts.push('HttpOnly');
|
||||
parts.push('SameSite=Lax');
|
||||
return parts.join('; ');
|
||||
}
|
||||
|
||||
function clearSessionCookie(res) {
|
||||
res.setHeader('Set-Cookie', serializeCookie(sessionCookieName, '', { maxAge: 0 }));
|
||||
}
|
||||
|
||||
function setSessionCookie(res, token) {
|
||||
res.setHeader('Set-Cookie', serializeCookie(sessionCookieName, token, { maxAge: sessionMaxAgeMs }));
|
||||
}
|
||||
|
||||
async function loadCurrentUser(pool, req) {
|
||||
const cookies = parseCookies(req.headers.cookie || '');
|
||||
const token = cookies[sessionCookieName];
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tokenHash = hashSessionToken(token);
|
||||
const [rows] = await pool.query(
|
||||
`SELECT s.user_id, u.id, u.name, u.username
|
||||
FROM auth_sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.session_hash = ?
|
||||
AND s.expires_at > NOW()
|
||||
LIMIT 1`,
|
||||
[tokenHash]
|
||||
);
|
||||
if (!rows.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const userId = Number(rows[0].id);
|
||||
const [roleRows] = await pool.query(
|
||||
`SELECT r.role_key
|
||||
FROM user_roles ur
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
WHERE ur.user_id = ?
|
||||
ORDER BY r.name ASC`,
|
||||
[userId]
|
||||
);
|
||||
const [permissionRows] = await pool.query(
|
||||
`SELECT p.permission_key
|
||||
FROM user_roles ur
|
||||
JOIN role_permissions rp ON rp.role_id = ur.role_id
|
||||
JOIN permissions p ON p.id = rp.permission_id
|
||||
WHERE ur.user_id = ?
|
||||
ORDER BY p.section_name ASC, p.name ASC`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
await pool.query('UPDATE auth_sessions SET last_used_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, tokenHash]);
|
||||
return Object.assign({}, rows[0], {
|
||||
roleKeys: roleRows.map(function (row) {
|
||||
return String(row.role_key || '').trim();
|
||||
}).filter(Boolean),
|
||||
permissionKeys: normalizePermissionKeys(permissionRows.map(function (row) {
|
||||
return String(row.permission_key || '').trim();
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
async function createUserSession(pool, userId) {
|
||||
const token = createSessionToken();
|
||||
const tokenHash = hashSessionToken(token);
|
||||
const expiresAt = new Date(Date.now() + sessionMaxAgeMs);
|
||||
await pool.query(
|
||||
'INSERT INTO auth_sessions (session_hash, user_id, expires_at, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
|
||||
[tokenHash, userId, expiresAt, userId, userId]
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
if (req.currentUser) {
|
||||
return next();
|
||||
}
|
||||
res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||
}
|
||||
|
||||
return {
|
||||
parseCookies: parseCookies,
|
||||
serializeCookie: serializeCookie,
|
||||
clearSessionCookie: clearSessionCookie,
|
||||
setSessionCookie: setSessionCookie,
|
||||
loadCurrentUser: loadCurrentUser,
|
||||
createUserSession: createUserSession,
|
||||
requireAuth: requireAuth
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createSessionService };
|
||||
@@ -0,0 +1,632 @@
|
||||
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;
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
function normalizeUploadReference(uploadPath) {
|
||||
const value = String(uploadPath || '').trim();
|
||||
if (!value || !value.startsWith('/media/')) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
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(slide.media_path, 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(payload.mediaPath, 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 slides
|
||||
WHERE media_path = ?
|
||||
OR JSON_SEARCH(COALESCE(content_json, JSON_OBJECT()), 'one', ?) IS NOT NULL`,
|
||||
[uploadPath, uploadPath]
|
||||
);
|
||||
const [templateRows] = await pool.query(
|
||||
'SELECT COUNT(*) AS ref_count FROM slide_templates WHERE background_image_path = ?',
|
||||
[uploadPath]
|
||||
);
|
||||
return Number(slideRows[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 = path.join(uploadDir, path.basename(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 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.mediaDir ? normalizeUploadRoot(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 filename = path.basename(uploadPath);
|
||||
const sourcePath = path.join(localUploadDir, filename);
|
||||
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(filename)}`,
|
||||
body: fileBuffer
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(filename)}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
...authHeaders
|
||||
},
|
||||
body: fileBuffer
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.warn('Unable to sync upload to player:', filename, response.status, response.statusText);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Unable to sync upload to player:', filename, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeUploadFileFromPlayer(uploadPath, localUploadDir) {
|
||||
if (!uploadPath || !(await shouldMirrorUploads(localUploadDir))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const filename = path.basename(uploadPath);
|
||||
try {
|
||||
const authHeaders = createRequestAuthHeaders({
|
||||
method: 'DELETE',
|
||||
pathname: `/api/media/${encodeURIComponent(filename)}`
|
||||
});
|
||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(filename)}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...authHeaders
|
||||
}
|
||||
});
|
||||
if (!response.ok && response.status !== 404) {
|
||||
console.warn('Unable to remove upload from player:', filename, response.status, response.statusText);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Unable to remove upload from player:', filename, 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.previousUploadRefs.length) {
|
||||
const nextUploadRefSet = new Set(operation.nextUploadRefs);
|
||||
await removeUnusedUploadFiles(pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) {
|
||||
return !nextUploadRefSet.has(reference);
|
||||
}));
|
||||
}
|
||||
|
||||
if (operation.nextUploadRefs.length) {
|
||||
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
|
||||
}
|
||||
|
||||
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,
|
||||
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 };
|
||||
Reference in New Issue
Block a user