Release v2.13.1
This commit is contained in:
1 parent
0a03cdd0b7
commit
394d23bb4d
47 files changed
+2408
-559
No files matched your search
@@ -1,42 +0,0 @@
|
||||
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,178 @@
|
||||
// Data access helpers for the shared media library.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const MEDIA_MIME_TYPES = {
|
||||
'.gif': 'image/gif',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.webp': 'image/webp',
|
||||
'.avi': 'video/x-msvideo',
|
||||
'.mov': 'video/quicktime',
|
||||
'.mp4': 'video/mp4',
|
||||
'.mpeg': 'video/mpeg',
|
||||
'.webm': 'video/webm'
|
||||
};
|
||||
|
||||
function normalizeMediaPath(value) {
|
||||
const mediaPath = String(value || '').trim();
|
||||
return mediaPath.startsWith('/media/') ? mediaPath : null;
|
||||
}
|
||||
|
||||
async function registerMediaAsset(pool, file, mediaPath, userId, options) {
|
||||
const normalizedPath = normalizeMediaPath(mediaPath);
|
||||
if (!pool || !file || !normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO c_media_assets
|
||||
(media_path, original_name, media_type, mime_type, file_size, is_published, created_by, modified_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
${options && options.preserveOriginalName ? 'original_name = original_name,' : 'original_name = VALUES(original_name),' }
|
||||
media_type = VALUES(media_type),
|
||||
mime_type = VALUES(mime_type),
|
||||
file_size = VALUES(file_size),
|
||||
${options && options.preservePublication ? 'is_published = is_published,' : 'is_published = VALUES(is_published),' }
|
||||
modified_by = VALUES(modified_by)`,
|
||||
[
|
||||
normalizedPath,
|
||||
String(file.originalname || file.filename || 'media').slice(0, 255),
|
||||
String(file.mediaType || '').trim() || 'unknown',
|
||||
String(file.mimetype || '').trim() || 'application/octet-stream',
|
||||
Number(file.size) || 0,
|
||||
options && options.published === false ? 0 : 1,
|
||||
userId || null,
|
||||
userId || null
|
||||
]
|
||||
);
|
||||
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
async function registerMediaAssets(pool, files, getMediaPath, getMediaType, userId, options) {
|
||||
const registered = [];
|
||||
const list = Array.isArray(files) ? files : [];
|
||||
for (let index = 0; index < list.length; index += 1) {
|
||||
const file = list[index];
|
||||
const mediaPath = typeof getMediaPath === 'function' ? getMediaPath(file) : null;
|
||||
if (!mediaPath) {
|
||||
continue;
|
||||
}
|
||||
const enrichedFile = Object.assign({}, file, {
|
||||
mediaType: typeof getMediaType === 'function' ? getMediaType(file) : 'unknown'
|
||||
});
|
||||
registered.push(await registerMediaAsset(pool, enrichedFile, mediaPath, userId, options));
|
||||
}
|
||||
return registered.filter(Boolean);
|
||||
}
|
||||
|
||||
async function fetchMediaAssets(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT id, media_path, original_name, media_type, mime_type, file_size, created_at
|
||||
FROM c_media_assets
|
||||
WHERE is_published = 1
|
||||
ORDER BY created_at DESC, id DESC`
|
||||
);
|
||||
return rows || [];
|
||||
}
|
||||
|
||||
async function syncMediaAssetsFromDirectory(pool, uploadDir, userId) {
|
||||
const directory = String(uploadDir || '').trim();
|
||||
if (!pool || !directory) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.promises.readdir(directory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') {
|
||||
return 0;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let registeredCount = 0;
|
||||
for (const entry of entries) {
|
||||
if (!entry || !entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
const originalName = String(entry.name || '').trim();
|
||||
const mimeType = MEDIA_MIME_TYPES[path.extname(originalName).toLowerCase()];
|
||||
if (!mimeType) {
|
||||
continue;
|
||||
}
|
||||
const filePath = path.join(directory, originalName);
|
||||
const stats = await fs.promises.stat(filePath);
|
||||
const result = await registerMediaAsset(pool, {
|
||||
originalname: originalName,
|
||||
mimetype: mimeType,
|
||||
size: stats.size,
|
||||
mediaType: mimeType.startsWith('video/') ? 'video' : 'image'
|
||||
}, '/media/uploads/' + originalName, userId, { preserveOriginalName: true, preservePublication: true });
|
||||
if (result) {
|
||||
registeredCount += 1;
|
||||
}
|
||||
}
|
||||
return registeredCount;
|
||||
}
|
||||
|
||||
async function countMediaAssetReferences(pool, mediaPath) {
|
||||
const normalizedPath = normalizeMediaPath(mediaPath);
|
||||
if (!normalizedPath) {
|
||||
return 0;
|
||||
}
|
||||
const [slideRows] = await pool.query(
|
||||
`SELECT COUNT(*) AS ref_count
|
||||
FROM c_slides
|
||||
WHERE LOCATE(?, COALESCE(content_json, '')) > 0`,
|
||||
[normalizedPath]
|
||||
);
|
||||
const [templateRows] = await pool.query(
|
||||
'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?',
|
||||
[normalizedPath]
|
||||
);
|
||||
return Number(slideRows[0] && slideRows[0].ref_count || 0) + Number(templateRows[0] && templateRows[0].ref_count || 0);
|
||||
}
|
||||
|
||||
async function publishMediaAssets(pool, mediaPaths) {
|
||||
const paths = Array.from(new Set((Array.isArray(mediaPaths) ? mediaPaths : []).map(normalizeMediaPath).filter(Boolean)));
|
||||
for (let index = 0; index < paths.length; index += 1) {
|
||||
await pool.query('UPDATE c_media_assets SET is_published = 1 WHERE media_path = ?', [paths[index]]);
|
||||
}
|
||||
}
|
||||
|
||||
async function removePendingMediaAssets(pool, uploadDir, mediaPaths) {
|
||||
const paths = Array.from(new Set((Array.isArray(mediaPaths) ? mediaPaths : []).map(normalizeMediaPath).filter(Boolean)));
|
||||
for (let index = 0; index < paths.length; index += 1) {
|
||||
const mediaPath = paths[index];
|
||||
const [rows] = await pool.query('SELECT id FROM c_media_assets WHERE media_path = ? AND is_published = 0 LIMIT 1', [mediaPath]);
|
||||
const asset = rows && rows[0];
|
||||
if (!asset || await countMediaAssetReferences(pool, mediaPath) > 0) {
|
||||
continue;
|
||||
}
|
||||
await pool.query('DELETE FROM c_media_assets WHERE id = ?', [asset.id]);
|
||||
const filePath = path.join(path.dirname(uploadDir), mediaPath.replace(/^\/media\//, ''));
|
||||
await fs.promises.unlink(filePath).catch(function (error) {
|
||||
if (error && error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeMediaPath,
|
||||
registerMediaAsset,
|
||||
registerMediaAssets,
|
||||
fetchMediaAssets,
|
||||
syncMediaAssetsFromDirectory,
|
||||
countMediaAssetReferences,
|
||||
publishMediaAssets,
|
||||
removePendingMediaAssets
|
||||
};
|
||||
@@ -253,10 +253,18 @@ function createUploadSyncService(options) {
|
||||
|
||||
function normalizeUploadReference(uploadPath) {
|
||||
const value = String(uploadPath || '').trim();
|
||||
if (!value || !value.startsWith('/media/')) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (value.startsWith('/media/')) {
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.pathname.startsWith('/media/') ? parsed.pathname : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getUploadRelativePath(uploadPath) {
|
||||
@@ -313,6 +321,13 @@ function createUploadSyncService(options) {
|
||||
if (reference) {
|
||||
refs.add(reference);
|
||||
}
|
||||
const embeddedReferences = current.match(/\/media\/[^"'\s<>)]+/g) || [];
|
||||
embeddedReferences.forEach(function (embeddedReference) {
|
||||
const normalizedReference = normalizeUploadReference(embeddedReference);
|
||||
if (normalizedReference) {
|
||||
refs.add(normalizedReference);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
@@ -364,32 +379,21 @@ function createUploadSyncService(options) {
|
||||
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);
|
||||
if (String(uploadPath || '').startsWith('/media/player-cache/')) {
|
||||
continue;
|
||||
}
|
||||
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 uploadFileExists(uploadDir, uploadPath) {
|
||||
const filePath = resolveUploadFilePath(uploadDir, uploadPath);
|
||||
if (!filePath) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await fs.promises.access(filePath, fs.constants.F_OK);
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeUnusedUploadFiles(pool, uploadDir, uploadPaths) {
|
||||
return;
|
||||
}
|
||||
|
||||
async function collectUploadPathsFromDirectory(uploadDir) {
|
||||
@@ -981,6 +985,10 @@ function createUploadSyncService(options) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isLocalLikeBaseUrl(taskPayload.playerInternalBaseUrl) && await uploadFileExists(operation.localUploadDir, removedUploadRef)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const deleted = await removeUploadFileFromPlayer(removedUploadRef, operation.localUploadDir, taskPayload.playerInternalBaseUrl, taskPayload.playerIdentifier);
|
||||
if (!deleted) {
|
||||
queuePlayerUploadSync({
|
||||
|
||||
Reference in new issue
Block a user