includio-cms 0.14.2 → 0.14.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/DOCS.md +34 -1
- package/ROADMAP.md +7 -0
- package/dist/admin/api/handler.js +2 -0
- package/dist/admin/api/maintenance-status.d.ts +2 -0
- package/dist/admin/api/maintenance-status.js +7 -0
- package/dist/admin/client/maintenance/maintenance-page.svelte +518 -612
- package/dist/admin/remote/media.remote.js +3 -3
- package/dist/core/cms.js +4 -0
- package/dist/core/server/media/operations/backgroundMaintenance.d.ts +15 -0
- package/dist/core/server/media/operations/backgroundMaintenance.js +105 -0
- package/dist/core/server/media/operations/batchTranscodeVideos.js +17 -2
- package/dist/core/server/media/operations/reconcileMedia.js +19 -1
- package/dist/core/server/media/styles/operations/batchGenerateStyles.js +9 -4
- package/dist/types/cms.d.ts +7 -0
- package/dist/updates/0.14.3/index.d.ts +2 -0
- package/dist/updates/0.14.3/index.js +15 -0
- package/dist/updates/index.js +2 -1
- package/package.json +1 -1
|
@@ -116,11 +116,11 @@ export const setFocalPoint = command(z.object({
|
|
|
116
116
|
await cms.filesAdapter.deleteFile(fileName).catch((e) => console.warn('Style file cleanup failed:', e));
|
|
117
117
|
}
|
|
118
118
|
}
|
|
119
|
-
// Re-generate styles with new focal point
|
|
119
|
+
// Re-generate styles with new focal point in background (lazy fallback via getImageStyle)
|
|
120
120
|
const file = await cms.databaseAdapter.getMediaFile({ data: { id: fileId } });
|
|
121
121
|
if (file) {
|
|
122
|
-
const {
|
|
123
|
-
|
|
122
|
+
const { generateDefaultStylesInBackground } = await import('../../core/server/media/styles/operations/generateDefaultStyles.js');
|
|
123
|
+
generateDefaultStylesInBackground(file);
|
|
124
124
|
}
|
|
125
125
|
});
|
|
126
126
|
export const renameMediaFile = command(z.object({
|
package/dist/core/cms.js
CHANGED
|
@@ -147,6 +147,10 @@ export class CMS {
|
|
|
147
147
|
let cms;
|
|
148
148
|
export function initCMS(config) {
|
|
149
149
|
cms = new CMS(config);
|
|
150
|
+
// Start background maintenance (delayed, non-blocking)
|
|
151
|
+
import('./server/media/operations/backgroundMaintenance.js')
|
|
152
|
+
.then((m) => m.startBackgroundMaintenance())
|
|
153
|
+
.catch((e) => console.warn('[cms] Failed to start background maintenance:', e));
|
|
150
154
|
return cms;
|
|
151
155
|
}
|
|
152
156
|
export function getCMS() {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface MaintenanceResult {
|
|
2
|
+
stylesCreated: number;
|
|
3
|
+
postersCreated: number;
|
|
4
|
+
transcodesCreated: number;
|
|
5
|
+
orphansDeleted: number;
|
|
6
|
+
ranAt: Date;
|
|
7
|
+
}
|
|
8
|
+
export declare function getMaintenanceStatus(): {
|
|
9
|
+
running: boolean;
|
|
10
|
+
lastRun: Date | null;
|
|
11
|
+
nextRun: Date | null;
|
|
12
|
+
lastResult: MaintenanceResult | null;
|
|
13
|
+
};
|
|
14
|
+
export declare function startBackgroundMaintenance(): void;
|
|
15
|
+
export declare function stopBackgroundMaintenance(): void;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { getCMS } from '../../../cms.js';
|
|
2
|
+
let running = false;
|
|
3
|
+
let timer = null;
|
|
4
|
+
let lastResult = null;
|
|
5
|
+
let nextRunAt = null;
|
|
6
|
+
export function getMaintenanceStatus() {
|
|
7
|
+
return {
|
|
8
|
+
running,
|
|
9
|
+
lastRun: lastResult?.ranAt ?? null,
|
|
10
|
+
nextRun: nextRunAt,
|
|
11
|
+
lastResult
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
async function runMaintenance() {
|
|
15
|
+
if (running) {
|
|
16
|
+
console.info('[maintenance] Skipping — previous run still active');
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
running = true;
|
|
20
|
+
console.info('[maintenance] Starting background maintenance...');
|
|
21
|
+
const result = {
|
|
22
|
+
stylesCreated: 0,
|
|
23
|
+
postersCreated: 0,
|
|
24
|
+
transcodesCreated: 0,
|
|
25
|
+
orphansDeleted: 0,
|
|
26
|
+
ranAt: new Date()
|
|
27
|
+
};
|
|
28
|
+
try {
|
|
29
|
+
// 1. Generate missing image styles
|
|
30
|
+
const { batchGenerateAllStyles } = await import('../styles/operations/batchGenerateStyles.js');
|
|
31
|
+
for await (const event of batchGenerateAllStyles()) {
|
|
32
|
+
if (event.type === 'done') {
|
|
33
|
+
result.stylesCreated = event.created;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// 2. Generate missing video posters
|
|
37
|
+
const { batchRegenerateVideoPosters } = await import('./batchRegenerateVideoPosters.js');
|
|
38
|
+
for await (const event of batchRegenerateVideoPosters()) {
|
|
39
|
+
if (event.type === 'done') {
|
|
40
|
+
result.postersCreated = event.created;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// 3. Transcode missing videos
|
|
44
|
+
const { checkFfmpegAvailable } = await import('../../../../files-local/video.js');
|
|
45
|
+
if (await checkFfmpegAvailable()) {
|
|
46
|
+
const { batchTranscodeVideos } = await import('./batchTranscodeVideos.js');
|
|
47
|
+
for await (const event of batchTranscodeVideos()) {
|
|
48
|
+
if (event.type === 'done') {
|
|
49
|
+
result.transcodesCreated = event.created;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// 4. Clean up orphaned files
|
|
54
|
+
const { deleteOrphanedDiskFiles } = await import('./reconcileMedia.js');
|
|
55
|
+
const orphanResult = await deleteOrphanedDiskFiles();
|
|
56
|
+
result.orphansDeleted = orphanResult.deletedCount;
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
console.warn('[maintenance] Error during background maintenance:', err);
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
running = false;
|
|
63
|
+
lastResult = result;
|
|
64
|
+
const parts = [];
|
|
65
|
+
if (result.stylesCreated > 0)
|
|
66
|
+
parts.push(`${result.stylesCreated} styles`);
|
|
67
|
+
if (result.postersCreated > 0)
|
|
68
|
+
parts.push(`${result.postersCreated} posters`);
|
|
69
|
+
if (result.transcodesCreated > 0)
|
|
70
|
+
parts.push(`${result.transcodesCreated} transcodes`);
|
|
71
|
+
if (result.orphansDeleted > 0)
|
|
72
|
+
parts.push(`${result.orphansDeleted} orphans cleaned`);
|
|
73
|
+
if (parts.length > 0) {
|
|
74
|
+
console.info(`[maintenance] Done: ${parts.join(', ')}`);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
console.info('[maintenance] Done — nothing to process');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
export function startBackgroundMaintenance() {
|
|
82
|
+
const config = getCMS().mediaConfig?.maintenance;
|
|
83
|
+
if (config?.autoRun === false)
|
|
84
|
+
return;
|
|
85
|
+
const intervalHours = config?.intervalHours ?? 6;
|
|
86
|
+
const intervalMs = intervalHours * 60 * 60 * 1000;
|
|
87
|
+
// First run after 30s delay
|
|
88
|
+
setTimeout(() => {
|
|
89
|
+
runMaintenance();
|
|
90
|
+
// Then repeat on interval
|
|
91
|
+
nextRunAt = new Date(Date.now() + intervalMs);
|
|
92
|
+
timer = setInterval(() => {
|
|
93
|
+
nextRunAt = new Date(Date.now() + intervalMs);
|
|
94
|
+
runMaintenance();
|
|
95
|
+
}, intervalMs);
|
|
96
|
+
}, 30_000);
|
|
97
|
+
console.info(`[maintenance] Scheduled: first run in 30s, then every ${intervalHours}h`);
|
|
98
|
+
}
|
|
99
|
+
export function stopBackgroundMaintenance() {
|
|
100
|
+
if (timer) {
|
|
101
|
+
clearInterval(timer);
|
|
102
|
+
timer = null;
|
|
103
|
+
nextRunAt = null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -2,6 +2,7 @@ import { getCMS } from '../../../cms.js';
|
|
|
2
2
|
import { generateVideoStyle } from '../styles/ffmpeg/generateVideoStyle.js';
|
|
3
3
|
import { defaultVideoStyles } from '../styles/operations/generateDefaultVideoStyles.js';
|
|
4
4
|
import { checkFfmpegAvailable } from '../../../../files-local/video.js';
|
|
5
|
+
import { shouldTranscode, MAX_AUTO_TRANSCODE_SIZE } from '../../../../files-local/transcode.js';
|
|
5
6
|
const VIDEO_MIME_TYPES = [
|
|
6
7
|
'video/mp4',
|
|
7
8
|
'video/quicktime',
|
|
@@ -70,11 +71,25 @@ export async function getVideoTranscodeStatus() {
|
|
|
70
71
|
data: { mimeTypes: VIDEO_MIME_TYPES }
|
|
71
72
|
});
|
|
72
73
|
const allStyles = await cms.databaseAdapter.getAllVideoStyles();
|
|
73
|
-
const
|
|
74
|
+
const config = getCMS().mediaConfig?.video;
|
|
75
|
+
const formats = config?.formats ?? ['mp4', 'webm'];
|
|
76
|
+
// Count expected styles per-file respecting filters
|
|
77
|
+
let videoStylesExpected = 0;
|
|
78
|
+
for (const file of allFiles) {
|
|
79
|
+
if (file.size > MAX_AUTO_TRANSCODE_SIZE)
|
|
80
|
+
continue;
|
|
81
|
+
for (const style of defaultVideoStyles) {
|
|
82
|
+
if (!formats.includes(style.format))
|
|
83
|
+
continue;
|
|
84
|
+
if (!shouldTranscode(file.mimeType, file.width, file.height, file.size, file.duration, style.format))
|
|
85
|
+
continue;
|
|
86
|
+
videoStylesExpected++;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
74
89
|
return {
|
|
75
90
|
videosCount: allFiles.length,
|
|
76
91
|
videoStylesCount: allStyles.length,
|
|
77
|
-
videoStylesExpected
|
|
92
|
+
videoStylesExpected,
|
|
78
93
|
videoStylesDone: allStyles.filter((s) => s.status === 'done').length,
|
|
79
94
|
videoStylesPending: allStyles.filter((s) => s.status === 'pending' || s.status === 'processing').length,
|
|
80
95
|
videoStylesFailed: allStyles.filter((s) => s.status === 'failed').length
|
|
@@ -4,7 +4,10 @@ export async function getReconciliationReport() {
|
|
|
4
4
|
const diskFiles = await cms.filesAdapter.listFiles();
|
|
5
5
|
// Collect all known URLs from DB
|
|
6
6
|
const mediaFiles = await cms.databaseAdapter.getMediaFiles({ data: {} });
|
|
7
|
-
const imageStyles = await
|
|
7
|
+
const [imageStyles, videoStyles] = await Promise.all([
|
|
8
|
+
cms.databaseAdapter.getAllImageStyles(),
|
|
9
|
+
cms.databaseAdapter.getAllVideoStyles()
|
|
10
|
+
]);
|
|
8
11
|
const dbFilenames = new Set();
|
|
9
12
|
for (const mf of mediaFiles) {
|
|
10
13
|
const fn = mf.url.split('/').pop();
|
|
@@ -26,6 +29,13 @@ export async function getReconciliationReport() {
|
|
|
26
29
|
if (fn)
|
|
27
30
|
dbFilenames.add(fn);
|
|
28
31
|
}
|
|
32
|
+
for (const vs of videoStyles) {
|
|
33
|
+
if (vs.url) {
|
|
34
|
+
const fn = vs.url.split('/').pop();
|
|
35
|
+
if (fn)
|
|
36
|
+
dbFilenames.add(fn);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
29
39
|
// Orphaned: on disk but not in DB
|
|
30
40
|
const orphanedDisk = diskFiles.filter((f) => !dbFilenames.has(f));
|
|
31
41
|
// Missing: in DB but not on disk
|
|
@@ -43,6 +53,14 @@ export async function getReconciliationReport() {
|
|
|
43
53
|
missingDisk.push({ table: 'image_styles', id: is.id, url: is.url });
|
|
44
54
|
}
|
|
45
55
|
}
|
|
56
|
+
for (const vs of videoStyles) {
|
|
57
|
+
if (vs.url) {
|
|
58
|
+
const fn = vs.url.split('/').pop();
|
|
59
|
+
if (fn && !diskSet.has(fn)) {
|
|
60
|
+
missingDisk.push({ table: 'video_styles', id: vs.id, url: vs.url });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
46
64
|
return { orphanedDisk, missingDisk };
|
|
47
65
|
}
|
|
48
66
|
export async function deleteOrphanedDiskFiles() {
|
|
@@ -117,20 +117,25 @@ export async function getStylesStatus() {
|
|
|
117
117
|
// We only have id/url/mediaFileId from getAllImageStyles, not name.
|
|
118
118
|
// So we count per-file total instead
|
|
119
119
|
}
|
|
120
|
-
// Count expected
|
|
120
|
+
// Count expected and existing per-file
|
|
121
121
|
let expectedStyles = 0;
|
|
122
|
+
let existingStyles = 0;
|
|
122
123
|
let missingStyles = 0;
|
|
123
|
-
|
|
124
|
+
const processableFileIds = new Set(files.map((f) => f.id));
|
|
125
|
+
// Count existing per mediaFileId (only for current processable files)
|
|
124
126
|
const existingCountByFile = new Map();
|
|
125
127
|
for (const s of allStyles) {
|
|
126
128
|
const fileId = s.mediaFileId;
|
|
127
|
-
|
|
129
|
+
if (processableFileIds.has(fileId)) {
|
|
130
|
+
existingCountByFile.set(fileId, (existingCountByFile.get(fileId) ?? 0) + 1);
|
|
131
|
+
}
|
|
128
132
|
}
|
|
129
133
|
for (const file of files) {
|
|
130
134
|
const expectedNames = getExpectedStyleNames(file);
|
|
131
135
|
const expected = expectedNames.length;
|
|
132
136
|
const existing = existingCountByFile.get(file.id) ?? 0;
|
|
133
137
|
expectedStyles += expected;
|
|
138
|
+
existingStyles += existing;
|
|
134
139
|
if (existing < expected) {
|
|
135
140
|
missingStyles += expected - existing;
|
|
136
141
|
}
|
|
@@ -138,7 +143,7 @@ export async function getStylesStatus() {
|
|
|
138
143
|
return {
|
|
139
144
|
processableImages: files.length,
|
|
140
145
|
expectedStyles,
|
|
141
|
-
existingStyles
|
|
146
|
+
existingStyles,
|
|
142
147
|
missingStyles
|
|
143
148
|
};
|
|
144
149
|
}
|
package/dist/types/cms.d.ts
CHANGED
|
@@ -22,10 +22,17 @@ export interface VideoTranscodeConfig {
|
|
|
22
22
|
/** Concurrent transcode jobs (default: 1) */
|
|
23
23
|
concurrency?: number;
|
|
24
24
|
}
|
|
25
|
+
export interface MaintenanceConfig {
|
|
26
|
+
/** Enable automatic background maintenance (default: true) */
|
|
27
|
+
autoRun?: boolean;
|
|
28
|
+
/** Interval in hours between maintenance runs (default: 6) */
|
|
29
|
+
intervalHours?: number;
|
|
30
|
+
}
|
|
25
31
|
export interface MediaConfig {
|
|
26
32
|
maxOriginalWidth?: number;
|
|
27
33
|
maxOriginalHeight?: number;
|
|
28
34
|
video?: VideoTranscodeConfig;
|
|
35
|
+
maintenance?: MaintenanceConfig;
|
|
29
36
|
}
|
|
30
37
|
export interface AuthAdapter {
|
|
31
38
|
api: {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const update = {
|
|
2
|
+
version: '0.14.3',
|
|
3
|
+
date: '2026-03-27',
|
|
4
|
+
description: 'Background maintenance system — automatic style generation, poster regeneration, video transcoding, orphan cleanup on configurable schedule',
|
|
5
|
+
features: [
|
|
6
|
+
'Background maintenance scheduler — runs style generation, poster regeneration, video transcoding, and orphan cleanup automatically',
|
|
7
|
+
'Maintenance status API endpoint (`/admin/api/maintenance-status`) — real-time status of background tasks',
|
|
8
|
+
'Configurable maintenance interval via `media.maintenance` config (`autoRun`, `intervalHours`)',
|
|
9
|
+
'Maintenance page UI redesign — auto-status banner, organized sections, compact progress indicators'
|
|
10
|
+
],
|
|
11
|
+
fixes: [
|
|
12
|
+
'Maintenance page SSE handler deduplication — single reusable `handleSSE()` replaces 3 duplicated implementations'
|
|
13
|
+
],
|
|
14
|
+
breakingChanges: []
|
|
15
|
+
};
|
package/dist/updates/index.js
CHANGED
|
@@ -40,7 +40,8 @@ import { update as update0134 } from './0.13.4/index.js';
|
|
|
40
40
|
import { update as update0140 } from './0.14.0/index.js';
|
|
41
41
|
import { update as update0141 } from './0.14.1/index.js';
|
|
42
42
|
import { update as update0142 } from './0.14.2/index.js';
|
|
43
|
-
|
|
43
|
+
import { update as update0143 } from './0.14.3/index.js';
|
|
44
|
+
export const updates = [update0065, update0066, update0067, update0068, update0069, update010, update011, update012, update013, update014, update015, update020, update022, update050, update051, update052, update053, update054, update055, update056, update057, update058, update060, update061, update062, update070, update071, update072, update073, update080, update090, update0100, update0110, update0120, update0130, update0131, update0132, update0133, update0134, update0140, update0141, update0142, update0143];
|
|
44
45
|
export const getUpdatesFrom = (fromVersion) => {
|
|
45
46
|
const fromParts = fromVersion.split('.').map(Number);
|
|
46
47
|
return updates.filter((update) => {
|