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.
@@ -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 (awaited so client gets fresh URLs)
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 { generateDefaultStyles } = await import('../../core/server/media/styles/operations/generateDefaultStyles.js');
123
- await generateDefaultStyles(file);
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 expectedPerVideo = defaultVideoStyles.length;
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: allFiles.length * expectedPerVideo,
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 cms.databaseAdapter.getAllImageStyles();
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 per-file and compare with existing count per-file
120
+ // Count expected and existing per-file
121
121
  let expectedStyles = 0;
122
+ let existingStyles = 0;
122
123
  let missingStyles = 0;
123
- // Count existing per mediaFileId
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
- existingCountByFile.set(fileId, (existingCountByFile.get(fileId) ?? 0) + 1);
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: allStyles.length,
146
+ existingStyles,
142
147
  missingStyles
143
148
  };
144
149
  }
@@ -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,2 @@
1
+ import type { CmsUpdate } from '../index.js';
2
+ export declare const update: CmsUpdate;
@@ -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
+ };
@@ -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
- 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];
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) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "includio-cms",
3
- "version": "0.14.2",
3
+ "version": "0.14.3",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "vite build && npm run prepack",