includio-cms 0.13.3 → 0.13.4
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 +12 -0
- package/ROADMAP.md +7 -0
- package/dist/admin/api/handler.js +2 -0
- package/dist/admin/api/media-gc.js +8 -3
- package/dist/admin/api/regenerate-posters.d.ts +2 -0
- package/dist/admin/api/regenerate-posters.js +32 -0
- package/dist/admin/client/maintenance/maintenance-page.svelte +153 -0
- package/dist/core/server/media/operations/batchRegenerateVideoPosters.d.ts +15 -0
- package/dist/core/server/media/operations/batchRegenerateVideoPosters.js +112 -0
- package/dist/files-local/index.d.ts +1 -0
- package/dist/files-local/index.js +3 -140
- package/dist/files-local/sanitizeFilename.js +2 -1
- package/dist/files-local/video.d.ts +9 -0
- package/dist/files-local/video.js +145 -0
- package/dist/updates/0.13.4/index.d.ts +2 -0
- package/dist/updates/0.13.4/index.js +14 -0
- package/dist/updates/index.js +2 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,18 @@
|
|
|
3
3
|
All notable changes to includio-cms are documented here.
|
|
4
4
|
Generated from `src/lib/updates/` — do not edit manually.
|
|
5
5
|
|
|
6
|
+
## 0.13.4 — 2026-03-24
|
|
7
|
+
|
|
8
|
+
Video poster regeneration, filename sanitization
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Batch regenerate video posters & thumbnails from admin maintenance page with SSE progress
|
|
12
|
+
- Video poster status reporting in media GC endpoint
|
|
13
|
+
- Extracted video processing to reusable module
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- Filename sanitization normalizes Unicode to NFC (prevents NFD/NFC mismatch)
|
|
17
|
+
|
|
6
18
|
## 0.13.3 — 2026-03-23
|
|
7
19
|
|
|
8
20
|
Security hardening, per-language content fixes
|
package/ROADMAP.md
CHANGED
|
@@ -239,6 +239,13 @@
|
|
|
239
239
|
- [x] `[fix]` `[P1]` CMS constructor validates non-empty languages array <!-- files: src/lib/core/cms.ts -->
|
|
240
240
|
- [x] `[fix]` `[P2]` Unhandled promise rejection handlers in admin components
|
|
241
241
|
|
|
242
|
+
## 0.13.4 — Video poster regeneration
|
|
243
|
+
|
|
244
|
+
- [x] `[feature]` `[P1]` Batch video poster regeneration — admin maintenance page with SSE progress <!-- files: src/lib/admin/api/regenerate-posters.ts, src/lib/core/server/media/operations/batchRegenerateVideoPosters.ts, src/lib/admin/client/maintenance/maintenance-page.svelte -->
|
|
245
|
+
- [x] `[feature]` `[P1]` Video poster status reporting in media GC endpoint <!-- files: src/lib/admin/api/media-gc.ts -->
|
|
246
|
+
- [x] `[chore]` `[P1]` Extracted video processing to reusable module <!-- files: src/lib/files-local/video.ts, src/lib/files-local/index.ts -->
|
|
247
|
+
- [x] `[fix]` `[P1]` Filename sanitization normalizes Unicode to NFC <!-- files: src/lib/files-local/sanitizeFilename.ts -->
|
|
248
|
+
|
|
242
249
|
## 0.14.0 — SEO module
|
|
243
250
|
|
|
244
251
|
- [ ] `[feature]` `[P1]` SERP preview + character limits for title/description <!-- files: src/lib/admin/components/fields/seo-field.svelte -->
|
|
@@ -6,6 +6,7 @@ import * as inviteHandlers from './invite.js';
|
|
|
6
6
|
import * as acceptInviteHandlers from './accept-invite.js';
|
|
7
7
|
import * as mediaGcHandlers from './media-gc.js';
|
|
8
8
|
import * as generateStylesHandlers from './generate-styles.js';
|
|
9
|
+
import * as regeneratePostersHandlers from './regenerate-posters.js';
|
|
9
10
|
import * as uploadLimitHandlers from './upload-limit.js';
|
|
10
11
|
import { requireAuth } from '../remote/middleware/auth.js';
|
|
11
12
|
import { getCMS } from '../../core/cms.js';
|
|
@@ -19,6 +20,7 @@ export function createAdminApiHandler(options) {
|
|
|
19
20
|
'accept-invite': acceptInviteHandlers,
|
|
20
21
|
'media-gc': mediaGcHandlers,
|
|
21
22
|
'generate-styles': generateStylesHandlers,
|
|
23
|
+
'regenerate-posters': regeneratePostersHandlers,
|
|
22
24
|
'upload-limit': uploadLimitHandlers,
|
|
23
25
|
...options?.extraRoutes
|
|
24
26
|
};
|
|
@@ -2,12 +2,14 @@ import { requireRole } from '../remote/middleware/auth.js';
|
|
|
2
2
|
import { purgeAllImageStyles } from '../../core/server/media/operations/purgeImageStyles.js';
|
|
3
3
|
import { getReconciliationReport, deleteOrphanedDiskFiles } from '../../core/server/media/operations/reconcileMedia.js';
|
|
4
4
|
import { getStylesStatus } from '../../core/server/media/styles/operations/batchGenerateStyles.js';
|
|
5
|
+
import { getVideoPosterStatus } from '../../core/server/media/operations/batchRegenerateVideoPosters.js';
|
|
5
6
|
import { json } from '@sveltejs/kit';
|
|
6
7
|
export const GET = async ({ url }) => {
|
|
7
8
|
requireRole('admin');
|
|
8
|
-
const [stylesStatus, report] = await Promise.all([
|
|
9
|
+
const [stylesStatus, report, videoPosterStatus] = await Promise.all([
|
|
9
10
|
getStylesStatus(),
|
|
10
|
-
getReconciliationReport()
|
|
11
|
+
getReconciliationReport(),
|
|
12
|
+
getVideoPosterStatus()
|
|
11
13
|
]);
|
|
12
14
|
return json({
|
|
13
15
|
imageStylesCount: stylesStatus.existingStyles,
|
|
@@ -15,7 +17,10 @@ export const GET = async ({ url }) => {
|
|
|
15
17
|
expectedStylesCount: stylesStatus.expectedStyles,
|
|
16
18
|
missingStylesCount: stylesStatus.missingStyles,
|
|
17
19
|
orphanedDiskFiles: report.orphanedDisk,
|
|
18
|
-
missingDiskRecords: report.missingDisk
|
|
20
|
+
missingDiskRecords: report.missingDisk,
|
|
21
|
+
videosCount: videoPosterStatus.videosCount,
|
|
22
|
+
videosWithPosters: videoPosterStatus.videosWithPosters,
|
|
23
|
+
videosMissingPosters: videoPosterStatus.videosMissingPosters
|
|
19
24
|
});
|
|
20
25
|
};
|
|
21
26
|
export const DELETE = async ({ url }) => {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { requireRole } from '../remote/middleware/auth.js';
|
|
2
|
+
import { batchRegenerateVideoPosters } from '../../core/server/media/operations/batchRegenerateVideoPosters.js';
|
|
3
|
+
export const POST = async () => {
|
|
4
|
+
requireRole('admin');
|
|
5
|
+
const abort = new AbortController();
|
|
6
|
+
const encoder = new TextEncoder();
|
|
7
|
+
const stream = new ReadableStream({
|
|
8
|
+
async start(controller) {
|
|
9
|
+
try {
|
|
10
|
+
for await (const event of batchRegenerateVideoPosters(abort.signal)) {
|
|
11
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
catch (e) {
|
|
15
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'error', error: e instanceof Error ? e.message : String(e) })}\n\n`));
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
controller.close();
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
cancel() {
|
|
22
|
+
abort.abort();
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
return new Response(stream, {
|
|
26
|
+
headers: {
|
|
27
|
+
'Content-Type': 'text/event-stream',
|
|
28
|
+
'Cache-Control': 'no-cache',
|
|
29
|
+
Connection: 'keep-alive'
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
};
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import PlayerPlay from '@tabler/icons-svelte/icons/player-play';
|
|
9
9
|
import PlayerStop from '@tabler/icons-svelte/icons/player-stop';
|
|
10
10
|
import CircleCheck from '@tabler/icons-svelte/icons/circle-check';
|
|
11
|
+
import Video from '@tabler/icons-svelte/icons/video';
|
|
11
12
|
import Button from '../../../components/ui/button/button.svelte';
|
|
12
13
|
import * as Card from '../../../components/ui/card/index.js';
|
|
13
14
|
import { toast } from 'svelte-sonner';
|
|
@@ -19,6 +20,9 @@
|
|
|
19
20
|
missingStylesCount: number;
|
|
20
21
|
orphanedDiskFiles: string[];
|
|
21
22
|
missingDiskRecords: { table: string; id: string; url: string }[];
|
|
23
|
+
videosCount: number;
|
|
24
|
+
videosWithPosters: number;
|
|
25
|
+
videosMissingPosters: number;
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
let report = $state<GcReport | null>(null);
|
|
@@ -38,6 +42,18 @@
|
|
|
38
42
|
|
|
39
43
|
let genPercent = $derived(genTotal > 0 ? Math.round((genProcessed / genTotal) * 100) : 0);
|
|
40
44
|
|
|
45
|
+
// Video poster generation state
|
|
46
|
+
let posterGenerating = $state(false);
|
|
47
|
+
let posterTotal = $state(0);
|
|
48
|
+
let posterProcessed = $state(0);
|
|
49
|
+
let posterCreated = $state(0);
|
|
50
|
+
let posterSkipped = $state(0);
|
|
51
|
+
let posterCurrentFile = $state('');
|
|
52
|
+
let posterErrors = $state(0);
|
|
53
|
+
let posterAbort: AbortController | null = null;
|
|
54
|
+
|
|
55
|
+
let posterPercent = $derived(posterTotal > 0 ? Math.round((posterProcessed / posterTotal) * 100) : 0);
|
|
56
|
+
|
|
41
57
|
async function loadReport() {
|
|
42
58
|
loading = true;
|
|
43
59
|
try {
|
|
@@ -152,6 +168,77 @@
|
|
|
152
168
|
genAbort?.abort();
|
|
153
169
|
}
|
|
154
170
|
|
|
171
|
+
async function startPosterGenerate() {
|
|
172
|
+
posterGenerating = true;
|
|
173
|
+
posterTotal = 0;
|
|
174
|
+
posterProcessed = 0;
|
|
175
|
+
posterCreated = 0;
|
|
176
|
+
posterSkipped = 0;
|
|
177
|
+
posterCurrentFile = '';
|
|
178
|
+
posterErrors = 0;
|
|
179
|
+
posterAbort = new AbortController();
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
const res = await fetch('/admin/api/regenerate-posters', {
|
|
183
|
+
method: 'POST',
|
|
184
|
+
signal: posterAbort.signal
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
if (!res.ok) throw new Error('Failed to start');
|
|
188
|
+
if (!res.body) throw new Error('No response body');
|
|
189
|
+
|
|
190
|
+
const reader = res.body.getReader();
|
|
191
|
+
const decoder = new TextDecoder();
|
|
192
|
+
let buffer = '';
|
|
193
|
+
|
|
194
|
+
while (true) {
|
|
195
|
+
const { done, value } = await reader.read();
|
|
196
|
+
if (done) break;
|
|
197
|
+
|
|
198
|
+
buffer += decoder.decode(value, { stream: true });
|
|
199
|
+
const chunks = buffer.split('\n\n');
|
|
200
|
+
buffer = chunks.pop() || '';
|
|
201
|
+
|
|
202
|
+
for (const chunk of chunks) {
|
|
203
|
+
if (!chunk.startsWith('data: ')) continue;
|
|
204
|
+
const event = JSON.parse(chunk.slice(6));
|
|
205
|
+
|
|
206
|
+
posterTotal = event.total ?? posterTotal;
|
|
207
|
+
posterProcessed = event.processed ?? posterProcessed;
|
|
208
|
+
posterCreated = event.created ?? posterCreated;
|
|
209
|
+
posterSkipped = event.skipped ?? posterSkipped;
|
|
210
|
+
posterCurrentFile = event.currentFile ?? posterCurrentFile;
|
|
211
|
+
|
|
212
|
+
if (event.type === 'error') {
|
|
213
|
+
posterErrors++;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (event.type === 'done') {
|
|
217
|
+
const parts = [`Przetworzono ${posterTotal} filmów`];
|
|
218
|
+
if (posterCreated > 0) parts.push(`utworzono ${posterCreated} posterów`);
|
|
219
|
+
if (posterSkipped > 0) parts.push(`pominięto ${posterSkipped} (już istnieją)`);
|
|
220
|
+
if (posterErrors > 0) parts.push(`${posterErrors} błędów`);
|
|
221
|
+
toast.success(parts.join(', '));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
} catch (e) {
|
|
226
|
+
if (e instanceof DOMException && e.name === 'AbortError') {
|
|
227
|
+
toast.info(`Przerwano po ${posterProcessed}/${posterTotal} filmów`);
|
|
228
|
+
} else {
|
|
229
|
+
toast.error('Błąd podczas generowania posterów');
|
|
230
|
+
}
|
|
231
|
+
} finally {
|
|
232
|
+
posterGenerating = false;
|
|
233
|
+
posterAbort = null;
|
|
234
|
+
await loadReport();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function cancelPosterGenerate() {
|
|
239
|
+
posterAbort?.abort();
|
|
240
|
+
}
|
|
241
|
+
|
|
155
242
|
$effect(() => {
|
|
156
243
|
loadReport();
|
|
157
244
|
});
|
|
@@ -260,6 +347,72 @@
|
|
|
260
347
|
</Card.Content>
|
|
261
348
|
</Card.Root>
|
|
262
349
|
|
|
350
|
+
<!-- Video posters -->
|
|
351
|
+
<Card.Root>
|
|
352
|
+
<Card.Header>
|
|
353
|
+
<div class="flex items-center gap-2">
|
|
354
|
+
<Video class="size-5" style="color: var(--primary);" />
|
|
355
|
+
<Card.Title>Postery video</Card.Title>
|
|
356
|
+
</div>
|
|
357
|
+
<Card.Description>
|
|
358
|
+
Miniaturki i postery generowane z plików wideo (ffmpeg)
|
|
359
|
+
</Card.Description>
|
|
360
|
+
</Card.Header>
|
|
361
|
+
<Card.Content>
|
|
362
|
+
<p class="mb-1 text-3xl font-bold" style="color: var(--primary);">
|
|
363
|
+
{report.videosWithPosters}
|
|
364
|
+
<span class="text-base font-normal" style="color: var(--muted-foreground);">/ {report.videosCount}</span>
|
|
365
|
+
</p>
|
|
366
|
+
<p class="mb-4 text-xs" style="color: var(--muted-foreground);">
|
|
367
|
+
{report.videosCount} filmów, {report.videosMissingPosters} bez posterów
|
|
368
|
+
</p>
|
|
369
|
+
|
|
370
|
+
{#if posterGenerating}
|
|
371
|
+
<div class="mb-4">
|
|
372
|
+
<div class="mb-1 flex items-center justify-between text-xs" style="color: var(--muted-foreground);">
|
|
373
|
+
<span>{posterProcessed}/{posterTotal} filmów</span>
|
|
374
|
+
<span>{posterPercent}%</span>
|
|
375
|
+
</div>
|
|
376
|
+
<div class="h-2 w-full overflow-hidden rounded-full" style="background: var(--muted, #e5e7eb);">
|
|
377
|
+
<div
|
|
378
|
+
class="h-full rounded-full transition-all duration-300"
|
|
379
|
+
style="width: {posterPercent}%; background: var(--primary);"
|
|
380
|
+
></div>
|
|
381
|
+
</div>
|
|
382
|
+
<p class="mt-1 text-xs" style="color: var(--muted-foreground);">
|
|
383
|
+
Utworzono: {posterCreated}, pominięto: {posterSkipped}{posterErrors > 0 ? `, błędów: ${posterErrors}` : ''}
|
|
384
|
+
</p>
|
|
385
|
+
<p class="mt-0.5 truncate text-xs" style="color: var(--muted-foreground);">
|
|
386
|
+
{posterCurrentFile}
|
|
387
|
+
</p>
|
|
388
|
+
<Button
|
|
389
|
+
variant="outline"
|
|
390
|
+
size="sm"
|
|
391
|
+
onclick={cancelPosterGenerate}
|
|
392
|
+
class="mt-2"
|
|
393
|
+
>
|
|
394
|
+
<PlayerStop class="size-4" />
|
|
395
|
+
Anuluj
|
|
396
|
+
</Button>
|
|
397
|
+
</div>
|
|
398
|
+
{:else if report.videosCount > 0}
|
|
399
|
+
<Button
|
|
400
|
+
variant="default"
|
|
401
|
+
size="sm"
|
|
402
|
+
onclick={startPosterGenerate}
|
|
403
|
+
>
|
|
404
|
+
<PlayerPlay class="size-4" />
|
|
405
|
+
Generuj brakujące postery
|
|
406
|
+
</Button>
|
|
407
|
+
{:else}
|
|
408
|
+
<div class="mb-3 flex items-center gap-1.5 text-sm" style="color: var(--success, #3A8A5C);">
|
|
409
|
+
<CircleCheck class="size-4" />
|
|
410
|
+
Brak plików wideo
|
|
411
|
+
</div>
|
|
412
|
+
{/if}
|
|
413
|
+
</Card.Content>
|
|
414
|
+
</Card.Root>
|
|
415
|
+
|
|
263
416
|
<!-- Orphaned disk files -->
|
|
264
417
|
<Card.Root>
|
|
265
418
|
<Card.Header>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type PosterBatchProgress = {
|
|
2
|
+
type: 'progress' | 'error' | 'done';
|
|
3
|
+
total: number;
|
|
4
|
+
processed: number;
|
|
5
|
+
created: number;
|
|
6
|
+
skipped: number;
|
|
7
|
+
currentFile?: string;
|
|
8
|
+
error?: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function batchRegenerateVideoPosters(signal?: AbortSignal): AsyncGenerator<PosterBatchProgress>;
|
|
11
|
+
export declare function getVideoPosterStatus(): Promise<{
|
|
12
|
+
videosCount: number;
|
|
13
|
+
videosWithPosters: number;
|
|
14
|
+
videosMissingPosters: number;
|
|
15
|
+
}>;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { getCMS } from '../../../cms.js';
|
|
2
|
+
import { processVideo } from '../../../../files-local/video.js';
|
|
3
|
+
import { fullDir } from '../../../../files-local/index.js';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { unlink } from 'node:fs/promises';
|
|
6
|
+
const VIDEO_MIME_TYPES = [
|
|
7
|
+
'video/mp4',
|
|
8
|
+
'video/quicktime',
|
|
9
|
+
'video/webm',
|
|
10
|
+
'video/x-msvideo',
|
|
11
|
+
'video/x-matroska'
|
|
12
|
+
];
|
|
13
|
+
export async function* batchRegenerateVideoPosters(signal) {
|
|
14
|
+
const cms = getCMS();
|
|
15
|
+
const allFiles = await cms.databaseAdapter.getMediaFiles({
|
|
16
|
+
data: { mimeTypes: VIDEO_MIME_TYPES }
|
|
17
|
+
});
|
|
18
|
+
const total = allFiles.length;
|
|
19
|
+
let totalCreated = 0;
|
|
20
|
+
let totalSkipped = 0;
|
|
21
|
+
for (let i = 0; i < allFiles.length; i++) {
|
|
22
|
+
if (signal?.aborted)
|
|
23
|
+
return;
|
|
24
|
+
const file = allFiles[i];
|
|
25
|
+
yield {
|
|
26
|
+
type: 'progress',
|
|
27
|
+
total,
|
|
28
|
+
processed: i,
|
|
29
|
+
created: totalCreated,
|
|
30
|
+
skipped: totalSkipped,
|
|
31
|
+
currentFile: file.name
|
|
32
|
+
};
|
|
33
|
+
// Check if poster and thumbnail already exist on disk
|
|
34
|
+
const posterFilename = file.posterUrl?.split('/').pop();
|
|
35
|
+
const thumbFilename = file.thumbnailUrl?.split('/').pop();
|
|
36
|
+
const hasPoster = posterFilename ? await fileExists(resolve(fullDir, posterFilename)) : false;
|
|
37
|
+
const hasThumb = thumbFilename ? await fileExists(resolve(fullDir, thumbFilename)) : false;
|
|
38
|
+
if (hasPoster && hasThumb) {
|
|
39
|
+
totalSkipped++;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
// Get video filepath
|
|
44
|
+
const videoFilename = file.url.split('/').pop();
|
|
45
|
+
if (!videoFilename) {
|
|
46
|
+
throw new Error('No video filename');
|
|
47
|
+
}
|
|
48
|
+
const videoPath = resolve(fullDir, videoFilename);
|
|
49
|
+
// Delete old poster/thumb if they exist
|
|
50
|
+
if (posterFilename) {
|
|
51
|
+
try {
|
|
52
|
+
await unlink(resolve(fullDir, posterFilename));
|
|
53
|
+
}
|
|
54
|
+
catch { /* ok */ }
|
|
55
|
+
}
|
|
56
|
+
if (thumbFilename) {
|
|
57
|
+
try {
|
|
58
|
+
await unlink(resolve(fullDir, thumbFilename));
|
|
59
|
+
}
|
|
60
|
+
catch { /* ok */ }
|
|
61
|
+
}
|
|
62
|
+
// Regenerate
|
|
63
|
+
const meta = await processVideo(videoPath, file.name, fullDir);
|
|
64
|
+
// Update DB
|
|
65
|
+
await cms.databaseAdapter.updateMediaFile({
|
|
66
|
+
id: file.id,
|
|
67
|
+
data: {
|
|
68
|
+
posterUrl: meta.posterUrl,
|
|
69
|
+
thumbnailUrl: meta.thumbnailUrl,
|
|
70
|
+
...(meta.width != null && { width: meta.width }),
|
|
71
|
+
...(meta.height != null && { height: meta.height }),
|
|
72
|
+
...(meta.duration != null && { duration: meta.duration })
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
totalCreated++;
|
|
76
|
+
}
|
|
77
|
+
catch (e) {
|
|
78
|
+
yield {
|
|
79
|
+
type: 'error',
|
|
80
|
+
total,
|
|
81
|
+
processed: i + 1,
|
|
82
|
+
created: totalCreated,
|
|
83
|
+
skipped: totalSkipped,
|
|
84
|
+
currentFile: file.name,
|
|
85
|
+
error: e instanceof Error ? e.message : String(e)
|
|
86
|
+
};
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
yield { type: 'done', total, processed: total, created: totalCreated, skipped: totalSkipped };
|
|
91
|
+
}
|
|
92
|
+
async function fileExists(path) {
|
|
93
|
+
try {
|
|
94
|
+
const { stat } = await import('node:fs/promises');
|
|
95
|
+
await stat(path);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
export async function getVideoPosterStatus() {
|
|
103
|
+
const allFiles = await getCMS().databaseAdapter.getMediaFiles({
|
|
104
|
+
data: { mimeTypes: VIDEO_MIME_TYPES }
|
|
105
|
+
});
|
|
106
|
+
const videosWithPosters = allFiles.filter((f) => f.posterUrl != null).length;
|
|
107
|
+
return {
|
|
108
|
+
videosCount: allFiles.length,
|
|
109
|
+
videosWithPosters,
|
|
110
|
+
videosMissingPosters: allFiles.length - videosWithPosters
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -2,152 +2,15 @@ import { basename, extname, resolve } from 'node:path';
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { readFile, writeFile, rename, unlink, mkdir, readdir } from 'node:fs/promises';
|
|
4
4
|
import sharp from 'sharp';
|
|
5
|
-
import ffmpeg from 'fluent-ffmpeg';
|
|
6
5
|
import { fileURLToPath } from 'url';
|
|
7
6
|
import { dirname } from 'path';
|
|
8
7
|
import { sanitizeFilename } from './sanitizeFilename.js';
|
|
8
|
+
import { processVideo } from './video.js';
|
|
9
9
|
const __filename = fileURLToPath(import.meta.url);
|
|
10
10
|
const __dirname = dirname(__filename);
|
|
11
11
|
global.__dirname = __dirname ?? '';
|
|
12
|
-
const fullDir = process.env.NODE_ENV === 'production' ? `/data/uploads` : `./static/uploads`;
|
|
12
|
+
export const fullDir = process.env.NODE_ENV === 'production' ? `/data/uploads` : `./static/uploads`;
|
|
13
13
|
const privateDir = process.env.NODE_ENV === 'production' ? `/data/private-uploads` : `./data/private-uploads`;
|
|
14
|
-
// Ustawienie ścieżek do ffmpeg
|
|
15
|
-
const ffmpegPath = process.env.FFMPEG_PATH || '/usr/bin/ffmpeg';
|
|
16
|
-
const ffprobePath = process.env.FFPROBE_PATH || '/usr/bin/ffprobe';
|
|
17
|
-
ffmpeg.setFfmpegPath(ffmpegPath);
|
|
18
|
-
ffmpeg.setFfprobePath(ffprobePath);
|
|
19
|
-
function withTimeout(promise, ms, label) {
|
|
20
|
-
return new Promise((resolve, reject) => {
|
|
21
|
-
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
22
|
-
promise.then((val) => {
|
|
23
|
-
clearTimeout(timer);
|
|
24
|
-
resolve(val);
|
|
25
|
-
}, (err) => {
|
|
26
|
-
clearTimeout(timer);
|
|
27
|
-
reject(err);
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
}
|
|
31
|
-
function probeVideo(filepath) {
|
|
32
|
-
return new Promise((resolve, reject) => {
|
|
33
|
-
ffmpeg.ffprobe(filepath, (err, metadata) => {
|
|
34
|
-
if (err) {
|
|
35
|
-
reject(new Error(`ffprobe failed: ${err.message || err}`));
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
const videoStream = metadata.streams.find((s) => s.codec_type === 'video');
|
|
39
|
-
const duration = metadata.format.duration ?? null;
|
|
40
|
-
resolve({
|
|
41
|
-
width: videoStream?.width ?? null,
|
|
42
|
-
height: videoStream?.height ?? null,
|
|
43
|
-
duration: duration ? Math.round(duration * 100) / 100 : null
|
|
44
|
-
});
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
}
|
|
48
|
-
function takeScreenshot(filepath, config) {
|
|
49
|
-
return new Promise((resolve, reject) => {
|
|
50
|
-
ffmpeg(filepath)
|
|
51
|
-
.on('error', (err) => reject(new Error(`Screenshot failed: ${err.message || err}`)))
|
|
52
|
-
.on('end', () => resolve())
|
|
53
|
-
.screenshots({
|
|
54
|
-
timestamps: [config.timestamp],
|
|
55
|
-
filename: config.filename,
|
|
56
|
-
folder: config.folder,
|
|
57
|
-
size: config.size
|
|
58
|
-
});
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
function getSafeTimestamp(duration) {
|
|
62
|
-
if (duration === null || duration <= 0)
|
|
63
|
-
return '00:00:00.000';
|
|
64
|
-
if (duration < 1)
|
|
65
|
-
return '00:00:00.000';
|
|
66
|
-
if (duration < 3) {
|
|
67
|
-
const t = duration * 0.1;
|
|
68
|
-
return formatTimestamp(t);
|
|
69
|
-
}
|
|
70
|
-
return formatTimestamp(Math.min(1, duration * 0.1));
|
|
71
|
-
}
|
|
72
|
-
function formatTimestamp(seconds) {
|
|
73
|
-
const h = Math.floor(seconds / 3600);
|
|
74
|
-
const m = Math.floor((seconds % 3600) / 60);
|
|
75
|
-
const s = seconds % 60;
|
|
76
|
-
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${s.toFixed(3).padStart(6, '0')}`;
|
|
77
|
-
}
|
|
78
|
-
let ffmpegAvailable = null;
|
|
79
|
-
async function checkFfmpegAvailable() {
|
|
80
|
-
if (ffmpegAvailable !== null)
|
|
81
|
-
return ffmpegAvailable;
|
|
82
|
-
return new Promise((resolve) => {
|
|
83
|
-
ffmpeg.ffprobe('/dev/null', (err) => {
|
|
84
|
-
if (err && (String(err).includes('ENOENT') || String(err).includes('spawn'))) {
|
|
85
|
-
console.warn('[video] ffmpeg/ffprobe not found — video thumbnails disabled');
|
|
86
|
-
ffmpegAvailable = false;
|
|
87
|
-
}
|
|
88
|
-
else {
|
|
89
|
-
ffmpegAvailable = true;
|
|
90
|
-
}
|
|
91
|
-
resolve(ffmpegAvailable);
|
|
92
|
-
});
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
async function processVideo(filepath, filename) {
|
|
96
|
-
if (!(await checkFfmpegAvailable())) {
|
|
97
|
-
console.warn(`[video] Skipping processing for ${filename}: ffmpeg not available`);
|
|
98
|
-
return { width: null, height: null, duration: null, thumbnailUrl: null, posterUrl: null };
|
|
99
|
-
}
|
|
100
|
-
const thumbnailFilename = `${filename}_thumb.jpg`;
|
|
101
|
-
const thumbnailUrl = `/uploads/${thumbnailFilename}`;
|
|
102
|
-
const posterFilename = `${filename}_poster.jpg`;
|
|
103
|
-
const posterUrl = `/uploads/${posterFilename}`;
|
|
104
|
-
// 1. Probe — get metadata first
|
|
105
|
-
let probe;
|
|
106
|
-
try {
|
|
107
|
-
probe = await withTimeout(probeVideo(filepath), 10_000, 'ffprobe');
|
|
108
|
-
}
|
|
109
|
-
catch (err) {
|
|
110
|
-
console.warn(`[video] ffprobe failed for ${filename}:`, err);
|
|
111
|
-
return { width: null, height: null, duration: null, thumbnailUrl: null, posterUrl: null };
|
|
112
|
-
}
|
|
113
|
-
const timestamp = getSafeTimestamp(probe.duration);
|
|
114
|
-
// 2. Thumbnail (320px wide, maintain aspect ratio)
|
|
115
|
-
let finalThumbnailUrl = null;
|
|
116
|
-
try {
|
|
117
|
-
await withTimeout(takeScreenshot(filepath, {
|
|
118
|
-
timestamp,
|
|
119
|
-
filename: thumbnailFilename,
|
|
120
|
-
folder: fullDir,
|
|
121
|
-
size: '320x?'
|
|
122
|
-
}), 15_000, 'thumbnail');
|
|
123
|
-
finalThumbnailUrl = thumbnailUrl;
|
|
124
|
-
}
|
|
125
|
-
catch (err) {
|
|
126
|
-
console.warn(`[video] Thumbnail failed for ${filename}:`, err);
|
|
127
|
-
}
|
|
128
|
-
// 3. Poster (up to 1280px wide, no upscale)
|
|
129
|
-
let finalPosterUrl = null;
|
|
130
|
-
const posterWidth = Math.min(1280, probe.width ?? 1280);
|
|
131
|
-
try {
|
|
132
|
-
await withTimeout(takeScreenshot(filepath, {
|
|
133
|
-
timestamp,
|
|
134
|
-
filename: posterFilename,
|
|
135
|
-
folder: fullDir,
|
|
136
|
-
size: `${posterWidth}x?`
|
|
137
|
-
}), 15_000, 'poster');
|
|
138
|
-
finalPosterUrl = posterUrl;
|
|
139
|
-
}
|
|
140
|
-
catch (err) {
|
|
141
|
-
console.warn(`[video] Poster failed for ${filename}:`, err);
|
|
142
|
-
}
|
|
143
|
-
return {
|
|
144
|
-
width: probe.width,
|
|
145
|
-
height: probe.height,
|
|
146
|
-
duration: probe.duration ? Math.round(probe.duration) : null,
|
|
147
|
-
thumbnailUrl: finalThumbnailUrl,
|
|
148
|
-
posterUrl: finalPosterUrl
|
|
149
|
-
};
|
|
150
|
-
}
|
|
151
14
|
async function ensureDir(dir) {
|
|
152
15
|
try {
|
|
153
16
|
await mkdir(dir, { recursive: true });
|
|
@@ -228,7 +91,7 @@ export function local() {
|
|
|
228
91
|
else if (file.type.startsWith('video/')) {
|
|
229
92
|
type = 'video';
|
|
230
93
|
try {
|
|
231
|
-
const videoMeta = await processVideo(filepath, filename);
|
|
94
|
+
const videoMeta = await processVideo(filepath, filename, fullDir);
|
|
232
95
|
width = videoMeta.width;
|
|
233
96
|
height = videoMeta.height;
|
|
234
97
|
duration = videoMeta.duration;
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* Prevents path traversal, null byte injection, and other attacks.
|
|
4
4
|
*/
|
|
5
5
|
export function sanitizeFilename(name) {
|
|
6
|
-
|
|
6
|
+
// Normalize Unicode to NFC (composed form) to prevent NFD/NFC mismatch
|
|
7
|
+
let sanitized = name.normalize('NFC');
|
|
7
8
|
// Remove null bytes
|
|
8
9
|
sanitized = sanitized.replace(/\0/g, '');
|
|
9
10
|
// Remove path separators and traversal patterns
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface VideoMetadata {
|
|
2
|
+
width: number | null;
|
|
3
|
+
height: number | null;
|
|
4
|
+
duration: number | null;
|
|
5
|
+
thumbnailUrl: string | null;
|
|
6
|
+
posterUrl: string | null;
|
|
7
|
+
}
|
|
8
|
+
export declare function checkFfmpegAvailable(): Promise<boolean>;
|
|
9
|
+
export declare function processVideo(filepath: string, filename: string, outputDir: string): Promise<VideoMetadata>;
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import ffmpeg from 'fluent-ffmpeg';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import { dirname } from 'path';
|
|
4
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
5
|
+
const __dirname = dirname(__filename);
|
|
6
|
+
if (typeof globalThis.__dirname === 'undefined') {
|
|
7
|
+
globalThis.__dirname = __dirname ?? '';
|
|
8
|
+
}
|
|
9
|
+
// Ustawienie ścieżek do ffmpeg
|
|
10
|
+
const ffmpegPath = process.env.FFMPEG_PATH || '/usr/bin/ffmpeg';
|
|
11
|
+
const ffprobePath = process.env.FFPROBE_PATH || '/usr/bin/ffprobe';
|
|
12
|
+
ffmpeg.setFfmpegPath(ffmpegPath);
|
|
13
|
+
ffmpeg.setFfprobePath(ffprobePath);
|
|
14
|
+
function withTimeout(promise, ms, label) {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
17
|
+
promise.then((val) => {
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
resolve(val);
|
|
20
|
+
}, (err) => {
|
|
21
|
+
clearTimeout(timer);
|
|
22
|
+
reject(err);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
function probeVideo(filepath) {
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
ffmpeg.ffprobe(filepath, (err, metadata) => {
|
|
29
|
+
if (err) {
|
|
30
|
+
reject(new Error(`ffprobe failed: ${err.message || err}`));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const videoStream = metadata.streams.find((s) => s.codec_type === 'video');
|
|
34
|
+
const duration = metadata.format.duration ?? null;
|
|
35
|
+
resolve({
|
|
36
|
+
width: videoStream?.width ?? null,
|
|
37
|
+
height: videoStream?.height ?? null,
|
|
38
|
+
duration: duration ? Math.round(duration * 100) / 100 : null
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
function takeScreenshot(filepath, config) {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
ffmpeg(filepath)
|
|
46
|
+
.on('error', (err) => reject(new Error(`Screenshot failed: ${err.message || err}`)))
|
|
47
|
+
.on('end', () => resolve())
|
|
48
|
+
.screenshots({
|
|
49
|
+
timestamps: [config.timestamp],
|
|
50
|
+
filename: config.filename,
|
|
51
|
+
folder: config.folder,
|
|
52
|
+
size: config.size
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
function getSafeTimestamp(duration) {
|
|
57
|
+
if (duration === null || duration <= 0)
|
|
58
|
+
return '00:00:00.000';
|
|
59
|
+
if (duration < 1)
|
|
60
|
+
return '00:00:00.000';
|
|
61
|
+
if (duration < 3) {
|
|
62
|
+
const t = duration * 0.1;
|
|
63
|
+
return formatTimestamp(t);
|
|
64
|
+
}
|
|
65
|
+
return formatTimestamp(Math.min(1, duration * 0.1));
|
|
66
|
+
}
|
|
67
|
+
function formatTimestamp(seconds) {
|
|
68
|
+
const h = Math.floor(seconds / 3600);
|
|
69
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
70
|
+
const s = seconds % 60;
|
|
71
|
+
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${s.toFixed(3).padStart(6, '0')}`;
|
|
72
|
+
}
|
|
73
|
+
let ffmpegAvailable = null;
|
|
74
|
+
export async function checkFfmpegAvailable() {
|
|
75
|
+
if (ffmpegAvailable !== null)
|
|
76
|
+
return ffmpegAvailable;
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
ffmpeg.ffprobe('/dev/null', (err) => {
|
|
79
|
+
if (err && (String(err).includes('ENOENT') || String(err).includes('spawn'))) {
|
|
80
|
+
console.warn('[video] ffmpeg/ffprobe not found — video thumbnails disabled');
|
|
81
|
+
ffmpegAvailable = false;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
ffmpegAvailable = true;
|
|
85
|
+
}
|
|
86
|
+
resolve(ffmpegAvailable);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
export async function processVideo(filepath, filename, outputDir) {
|
|
91
|
+
if (!(await checkFfmpegAvailable())) {
|
|
92
|
+
console.warn(`[video] Skipping processing for ${filename}: ffmpeg not available`);
|
|
93
|
+
return { width: null, height: null, duration: null, thumbnailUrl: null, posterUrl: null };
|
|
94
|
+
}
|
|
95
|
+
const thumbnailFilename = `${filename}_thumb.jpg`;
|
|
96
|
+
const thumbnailUrl = `/uploads/${thumbnailFilename}`;
|
|
97
|
+
const posterFilename = `${filename}_poster.jpg`;
|
|
98
|
+
const posterUrl = `/uploads/${posterFilename}`;
|
|
99
|
+
// 1. Probe — get metadata first
|
|
100
|
+
let probe;
|
|
101
|
+
try {
|
|
102
|
+
probe = await withTimeout(probeVideo(filepath), 10_000, 'ffprobe');
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
console.warn(`[video] ffprobe failed for ${filename}:`, err);
|
|
106
|
+
return { width: null, height: null, duration: null, thumbnailUrl: null, posterUrl: null };
|
|
107
|
+
}
|
|
108
|
+
const timestamp = getSafeTimestamp(probe.duration);
|
|
109
|
+
// 2. Thumbnail (320px wide, maintain aspect ratio)
|
|
110
|
+
let finalThumbnailUrl = null;
|
|
111
|
+
try {
|
|
112
|
+
await withTimeout(takeScreenshot(filepath, {
|
|
113
|
+
timestamp,
|
|
114
|
+
filename: thumbnailFilename,
|
|
115
|
+
folder: outputDir,
|
|
116
|
+
size: '320x?'
|
|
117
|
+
}), 15_000, 'thumbnail');
|
|
118
|
+
finalThumbnailUrl = thumbnailUrl;
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
console.warn(`[video] Thumbnail failed for ${filename}:`, err);
|
|
122
|
+
}
|
|
123
|
+
// 3. Poster (up to 1280px wide, no upscale)
|
|
124
|
+
let finalPosterUrl = null;
|
|
125
|
+
const posterWidth = Math.min(1280, probe.width ?? 1280);
|
|
126
|
+
try {
|
|
127
|
+
await withTimeout(takeScreenshot(filepath, {
|
|
128
|
+
timestamp,
|
|
129
|
+
filename: posterFilename,
|
|
130
|
+
folder: outputDir,
|
|
131
|
+
size: `${posterWidth}x?`
|
|
132
|
+
}), 15_000, 'poster');
|
|
133
|
+
finalPosterUrl = posterUrl;
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
console.warn(`[video] Poster failed for ${filename}:`, err);
|
|
137
|
+
}
|
|
138
|
+
return {
|
|
139
|
+
width: probe.width,
|
|
140
|
+
height: probe.height,
|
|
141
|
+
duration: probe.duration ? Math.round(probe.duration) : null,
|
|
142
|
+
thumbnailUrl: finalThumbnailUrl,
|
|
143
|
+
posterUrl: finalPosterUrl
|
|
144
|
+
};
|
|
145
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const update = {
|
|
2
|
+
version: '0.13.4',
|
|
3
|
+
date: '2026-03-24',
|
|
4
|
+
description: 'Video poster regeneration, filename sanitization',
|
|
5
|
+
features: [
|
|
6
|
+
'Batch regenerate video posters & thumbnails from admin maintenance page with SSE progress',
|
|
7
|
+
'Video poster status reporting in media GC endpoint',
|
|
8
|
+
'Extracted video processing to reusable module'
|
|
9
|
+
],
|
|
10
|
+
fixes: [
|
|
11
|
+
'Filename sanitization normalizes Unicode to NFC (prevents NFD/NFC mismatch)'
|
|
12
|
+
],
|
|
13
|
+
breakingChanges: []
|
|
14
|
+
};
|
package/dist/updates/index.js
CHANGED
|
@@ -36,7 +36,8 @@ import { update as update0130 } from './0.13.0/index.js';
|
|
|
36
36
|
import { update as update0131 } from './0.13.1/index.js';
|
|
37
37
|
import { update as update0132 } from './0.13.2/index.js';
|
|
38
38
|
import { update as update0133 } from './0.13.3/index.js';
|
|
39
|
-
|
|
39
|
+
import { update as update0134 } from './0.13.4/index.js';
|
|
40
|
+
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];
|
|
40
41
|
export const getUpdatesFrom = (fromVersion) => {
|
|
41
42
|
const fromParts = fromVersion.split('.').map(Number);
|
|
42
43
|
return updates.filter((update) => {
|