includio-cms 0.13.4 → 0.14.0

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.
Files changed (58) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/ROADMAP.md +12 -2
  3. package/dist/admin/api/handler.js +4 -0
  4. package/dist/admin/api/media-gc.js +14 -3
  5. package/dist/admin/api/system-info.d.ts +2 -0
  6. package/dist/admin/api/system-info.js +9 -0
  7. package/dist/admin/api/transcode-videos.d.ts +2 -0
  8. package/dist/admin/api/transcode-videos.js +32 -0
  9. package/dist/admin/client/maintenance/maintenance-page.svelte +401 -3
  10. package/dist/core/fields/structuredToHtml.js +20 -1
  11. package/dist/core/server/fields/resolveImageFields.js +37 -2
  12. package/dist/core/server/media/operations/batchTranscodeVideos.d.ts +23 -0
  13. package/dist/core/server/media/operations/batchTranscodeVideos.js +104 -0
  14. package/dist/core/server/media/operations/deleteMediaFile.js +16 -1
  15. package/dist/core/server/media/operations/getDiskUsage.d.ts +24 -0
  16. package/dist/core/server/media/operations/getDiskUsage.js +103 -0
  17. package/dist/core/server/media/operations/getSystemInfo.d.ts +27 -0
  18. package/dist/core/server/media/operations/getSystemInfo.js +90 -0
  19. package/dist/core/server/media/operations/replaceFile.js +10 -0
  20. package/dist/core/server/media/operations/uploadFile.js +2 -0
  21. package/dist/core/server/media/styles/ffmpeg/generateVideoStyle.d.ts +7 -0
  22. package/dist/core/server/media/styles/ffmpeg/generateVideoStyle.js +59 -0
  23. package/dist/core/server/media/styles/operations/generateDefaultVideoStyles.d.ts +9 -0
  24. package/dist/core/server/media/styles/operations/generateDefaultVideoStyles.js +88 -0
  25. package/dist/db-postgres/index.js +113 -0
  26. package/dist/db-postgres/schema/index.d.ts +1 -0
  27. package/dist/db-postgres/schema/index.js +1 -0
  28. package/dist/db-postgres/schema/videoStyle.d.ts +228 -0
  29. package/dist/db-postgres/schema/videoStyle.js +18 -0
  30. package/dist/files-local/index.d.ts +5 -1
  31. package/dist/files-local/index.js +5 -2
  32. package/dist/files-local/transcode.d.ts +27 -0
  33. package/dist/files-local/transcode.js +130 -0
  34. package/dist/files-local/video.d.ts +1 -0
  35. package/dist/files-local/video.js +27 -5
  36. package/dist/paraglide/messages/_index.d.ts +36 -3
  37. package/dist/paraglide/messages/_index.js +71 -3
  38. package/dist/paraglide/messages/en.d.ts +5 -0
  39. package/dist/paraglide/messages/en.js +14 -0
  40. package/dist/paraglide/messages/pl.d.ts +5 -0
  41. package/dist/paraglide/messages/pl.js +14 -0
  42. package/dist/sveltekit/components/video.svelte +15 -1
  43. package/dist/sveltekit/utils/media.js +2 -2
  44. package/dist/types/adapters/db.d.ts +37 -1
  45. package/dist/types/cms.d.ts +16 -0
  46. package/dist/types/fields.d.ts +12 -1
  47. package/dist/types/index.d.ts +1 -1
  48. package/dist/types/media.d.ts +15 -0
  49. package/dist/updates/0.14.0/index.d.ts +2 -0
  50. package/dist/updates/0.14.0/index.js +36 -0
  51. package/dist/updates/index.js +2 -1
  52. package/package.json +1 -1
  53. package/dist/paraglide/messages/hello_world.d.ts +0 -5
  54. package/dist/paraglide/messages/hello_world.js +0 -33
  55. package/dist/paraglide/messages/login_hello.d.ts +0 -16
  56. package/dist/paraglide/messages/login_hello.js +0 -34
  57. package/dist/paraglide/messages/login_please_login.d.ts +0 -16
  58. package/dist/paraglide/messages/login_please_login.js +0 -34
package/CHANGELOG.md CHANGED
@@ -3,6 +3,47 @@
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.14.0 — 2026-03-24
7
+
8
+ Video transcoding & optimization
9
+
10
+ ### Added
11
+ - Auto-transcode uploaded videos to mp4 (h264) and webm (vp9) in background
12
+ - Video styles system with status tracking (pending/processing/done/failed)
13
+ - Admin maintenance: video transcoding card with batch transcode, purge & retranscode, SSE progress
14
+ - Configurable video transcoding: formats, max resolution, CRF quality, concurrency
15
+ - Skip logic: skip already-optimized mp4 h264 at 1080p or below, skip files over 500MB
16
+ - Frontend video serves multiple source elements (webm, mp4, original fallback)
17
+ - System info endpoint: CMS version, Node, PostgreSQL, ffmpeg (codec checks), sharp, OS diagnostics
18
+ - Disk usage endpoint: breakdown per category (originals by type, image styles, video styles, posters)
19
+
20
+ ### Migration
21
+
22
+ ```sql
23
+ CREATE TABLE IF NOT EXISTS video_styles (
24
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
25
+ media_file_id UUID NOT NULL REFERENCES media_file(id) ON DELETE CASCADE,
26
+ name TEXT NOT NULL,
27
+ url TEXT NOT NULL,
28
+ width INTEGER,
29
+ height INTEGER,
30
+ format TEXT NOT NULL,
31
+ codec TEXT,
32
+ file_size INTEGER,
33
+ mime_type TEXT NOT NULL,
34
+ status TEXT NOT NULL DEFAULT 'pending',
35
+ error TEXT,
36
+ created_at TIMESTAMP DEFAULT NOW()
37
+ );
38
+
39
+ CREATE UNIQUE INDEX IF NOT EXISTS video_styles_unique_key
40
+ ON video_styles (media_file_id, name);
41
+ ```
42
+
43
+ ### Notes
44
+
45
+ Video transcoding requires ffmpeg with libx264 and libvpx-vp9 codecs. Graceful degradation if ffmpeg is unavailable.
46
+
6
47
  ## 0.13.4 — 2026-03-24
7
48
 
8
49
  Video poster regeneration, filename sanitization
package/ROADMAP.md CHANGED
@@ -246,14 +246,24 @@
246
246
  - [x] `[chore]` `[P1]` Extracted video processing to reusable module <!-- files: src/lib/files-local/video.ts, src/lib/files-local/index.ts -->
247
247
  - [x] `[fix]` `[P1]` Filename sanitization normalizes Unicode to NFC <!-- files: src/lib/files-local/sanitizeFilename.ts -->
248
248
 
249
- ## 0.14.0 — SEO module
249
+ ## 0.14.0 — Video transcoding
250
+
251
+ - [x] `[feature]` `[P1]` Video styles system — auto-transcode to mp4 h264 + webm vp9 <!-- files: src/lib/db-postgres/schema/videoStyle.ts, src/lib/files-local/transcode.ts -->
252
+ - [x] `[feature]` `[P1]` Background transcoding pipeline on upload with skip logic <!-- files: src/lib/core/server/media/styles/operations/generateDefaultVideoStyles.ts -->
253
+ - [x] `[feature]` `[P1]` Admin maintenance: video transcoding card (batch, purge, SSE progress) <!-- files: src/lib/admin/client/maintenance/maintenance-page.svelte -->
254
+ - [x] `[feature]` `[P2]` Frontend multi-source video delivery (webm, mp4, original) <!-- files: src/lib/sveltekit/components/video.svelte -->
255
+ - [x] `[feature]` `[P2]` Configurable video transcoding in MediaConfig <!-- files: src/lib/types/cms.ts -->
256
+ - [x] `[feature]` `[P2]` System info endpoint — CMS version, Node, PostgreSQL, ffmpeg, sharp, OS <!-- files: src/lib/admin/api/system-info.ts, src/lib/core/server/media/operations/getSystemInfo.ts -->
257
+ - [x] `[feature]` `[P2]` Disk usage endpoint — breakdown per category (originals, image styles, video styles, posters) <!-- files: src/lib/admin/api/system-info.ts, src/lib/core/server/media/operations/getDiskUsage.ts -->
258
+
259
+ ## 0.15.0 — SEO module
250
260
 
251
261
  - [ ] `[feature]` `[P1]` SERP preview + character limits for title/description <!-- files: src/lib/admin/components/fields/seo-field.svelte -->
252
262
  - [ ] `[feature]` `[P1]` Global SEO settings
253
263
  - [ ] `[feature]` `[P1]` Dedicated frontend SEO components <!-- files: src/lib/sveltekit/components/seo.svelte -->
254
264
  - [ ] `[feature]` `[P2]` Sitemap generation
255
265
 
256
- ## 0.15.0 — WCAG/ATAG compliance
266
+ ## 0.16.0 — WCAG/ATAG compliance
257
267
 
258
268
  - [ ] `[chore]` `[P0]` Full WCAG/ATAG audit
259
269
  - [ ] `[feature]` `[P0]` Accessibility rework based on audit findings
@@ -7,7 +7,9 @@ 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
9
  import * as regeneratePostersHandlers from './regenerate-posters.js';
10
+ import * as transcodeVideosHandlers from './transcode-videos.js';
10
11
  import * as uploadLimitHandlers from './upload-limit.js';
12
+ import * as systemInfoHandlers from './system-info.js';
11
13
  import { requireAuth } from '../remote/middleware/auth.js';
12
14
  import { getCMS } from '../../core/cms.js';
13
15
  import { lookup } from 'mrmime';
@@ -21,7 +23,9 @@ export function createAdminApiHandler(options) {
21
23
  'media-gc': mediaGcHandlers,
22
24
  'generate-styles': generateStylesHandlers,
23
25
  'regenerate-posters': regeneratePostersHandlers,
26
+ 'transcode-videos': transcodeVideosHandlers,
24
27
  'upload-limit': uploadLimitHandlers,
28
+ 'system-info': systemInfoHandlers,
25
29
  ...options?.extraRoutes
26
30
  };
27
31
  const privateMediaGet = async (event) => {
@@ -3,13 +3,15 @@ import { purgeAllImageStyles } from '../../core/server/media/operations/purgeIma
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
5
  import { getVideoPosterStatus } from '../../core/server/media/operations/batchRegenerateVideoPosters.js';
6
+ import { getVideoTranscodeStatus, purgeVideoStyles } from '../../core/server/media/operations/batchTranscodeVideos.js';
6
7
  import { json } from '@sveltejs/kit';
7
8
  export const GET = async ({ url }) => {
8
9
  requireRole('admin');
9
- const [stylesStatus, report, videoPosterStatus] = await Promise.all([
10
+ const [stylesStatus, report, videoPosterStatus, videoTranscodeStatus] = await Promise.all([
10
11
  getStylesStatus(),
11
12
  getReconciliationReport(),
12
- getVideoPosterStatus()
13
+ getVideoPosterStatus(),
14
+ getVideoTranscodeStatus()
13
15
  ]);
14
16
  return json({
15
17
  imageStylesCount: stylesStatus.existingStyles,
@@ -20,7 +22,12 @@ export const GET = async ({ url }) => {
20
22
  missingDiskRecords: report.missingDisk,
21
23
  videosCount: videoPosterStatus.videosCount,
22
24
  videosWithPosters: videoPosterStatus.videosWithPosters,
23
- videosMissingPosters: videoPosterStatus.videosMissingPosters
25
+ videosMissingPosters: videoPosterStatus.videosMissingPosters,
26
+ videoStylesCount: videoTranscodeStatus.videoStylesCount,
27
+ videoStylesExpected: videoTranscodeStatus.videoStylesExpected,
28
+ videoStylesDone: videoTranscodeStatus.videoStylesDone,
29
+ videoStylesPending: videoTranscodeStatus.videoStylesPending,
30
+ videoStylesFailed: videoTranscodeStatus.videoStylesFailed
24
31
  });
25
32
  };
26
33
  export const DELETE = async ({ url }) => {
@@ -34,5 +41,9 @@ export const DELETE = async ({ url }) => {
34
41
  const result = await deleteOrphanedDiskFiles();
35
42
  return json(result);
36
43
  }
44
+ if (action === 'purge-video-styles') {
45
+ const result = await purgeVideoStyles();
46
+ return json(result);
47
+ }
37
48
  return json({ error: 'Unknown action' }, { status: 400 });
38
49
  };
@@ -0,0 +1,2 @@
1
+ import { type RequestHandler } from '@sveltejs/kit';
2
+ export declare const GET: RequestHandler;
@@ -0,0 +1,9 @@
1
+ import { requireRole } from '../remote/middleware/auth.js';
2
+ import { getDiskUsage } from '../../core/server/media/operations/getDiskUsage.js';
3
+ import { getSystemInfo } from '../../core/server/media/operations/getSystemInfo.js';
4
+ import { json } from '@sveltejs/kit';
5
+ export const GET = async () => {
6
+ requireRole('admin');
7
+ const [diskUsage, systemInfo] = await Promise.all([getDiskUsage(), getSystemInfo()]);
8
+ return json({ diskUsage, systemInfo });
9
+ };
@@ -0,0 +1,2 @@
1
+ import type { RequestHandler } from '@sveltejs/kit';
2
+ export declare const POST: RequestHandler;
@@ -0,0 +1,32 @@
1
+ import { requireRole } from '../remote/middleware/auth.js';
2
+ import { batchTranscodeVideos } from '../../core/server/media/operations/batchTranscodeVideos.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 batchTranscodeVideos(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
+ };
@@ -9,6 +9,9 @@
9
9
  import PlayerStop from '@tabler/icons-svelte/icons/player-stop';
10
10
  import CircleCheck from '@tabler/icons-svelte/icons/circle-check';
11
11
  import Video from '@tabler/icons-svelte/icons/video';
12
+ import Transform from '@tabler/icons-svelte/icons/transform';
13
+ import Database from '@tabler/icons-svelte/icons/database';
14
+ import Server from '@tabler/icons-svelte/icons/server';
12
15
  import Button from '../../../components/ui/button/button.svelte';
13
16
  import * as Card from '../../../components/ui/card/index.js';
14
17
  import { toast } from 'svelte-sonner';
@@ -23,9 +26,45 @@
23
26
  videosCount: number;
24
27
  videosWithPosters: number;
25
28
  videosMissingPosters: number;
29
+ videoStylesCount: number;
30
+ videoStylesExpected: number;
31
+ videoStylesDone: number;
32
+ videoStylesPending: number;
33
+ videoStylesFailed: number;
34
+ }
35
+
36
+ interface DiskUsage {
37
+ originals: { images: number; videos: number; audio: number; pdf: number; other: number; total: number };
38
+ imageStyles: { count: number; bytes: number };
39
+ videoStyles: { count: number; bytes: number };
40
+ posters: { count: number; bytes: number };
41
+ total: number;
42
+ }
43
+
44
+ interface SystemInfo {
45
+ cmsVersion: string;
46
+ nodeVersion: string;
47
+ postgresVersion: string | null;
48
+ ffmpeg: { available: boolean; version: string | null; codecs: { h264: boolean; vp9: boolean } };
49
+ sharp: { available: boolean; version: string | null };
50
+ os: { platform: string; release: string; cpus: number; totalMemory: number };
51
+ uploads: { path: string };
52
+ }
53
+
54
+ interface SystemInfoResponse {
55
+ diskUsage: DiskUsage;
56
+ systemInfo: SystemInfo;
57
+ }
58
+
59
+ function formatBytes(bytes: number): string {
60
+ if (bytes === 0) return '0 B';
61
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
62
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
63
+ return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`;
26
64
  }
27
65
 
28
66
  let report = $state<GcReport | null>(null);
67
+ let sysInfo = $state<SystemInfoResponse | null>(null);
29
68
  let loading = $state(true);
30
69
  let purging = $state(false);
31
70
  let cleaningOrphans = $state(false);
@@ -54,12 +93,35 @@
54
93
 
55
94
  let posterPercent = $derived(posterTotal > 0 ? Math.round((posterProcessed / posterTotal) * 100) : 0);
56
95
 
96
+ // Video transcode state
97
+ let transcoding = $state(false);
98
+ let transTotal = $state(0);
99
+ let transProcessed = $state(0);
100
+ let transCreated = $state(0);
101
+ let transSkipped = $state(0);
102
+ let transCurrentFile = $state('');
103
+ let transErrors = $state(0);
104
+ let transAbort: AbortController | null = null;
105
+ let purgingVideoStyles = $state(false);
106
+
107
+ let transPercent = $derived(transTotal > 0 ? Math.round((transProcessed / transTotal) * 100) : 0);
108
+
109
+ let videoStylesMissing = $derived(
110
+ report ? report.videoStylesExpected - report.videoStylesDone : 0
111
+ );
112
+
57
113
  async function loadReport() {
58
114
  loading = true;
59
115
  try {
60
- const res = await fetch('/admin/api/media-gc');
61
- if (!res.ok) throw new Error('Failed to load');
62
- report = await res.json();
116
+ const [gcRes, sysRes] = await Promise.all([
117
+ fetch('/admin/api/media-gc'),
118
+ fetch('/admin/api/system-info')
119
+ ]);
120
+ if (!gcRes.ok) throw new Error('Failed to load GC report');
121
+ report = await gcRes.json();
122
+ if (sysRes.ok) {
123
+ sysInfo = await sysRes.json();
124
+ }
63
125
  } catch {
64
126
  toast.error('Nie udało się załadować raportu');
65
127
  } finally {
@@ -239,6 +301,92 @@
239
301
  posterAbort?.abort();
240
302
  }
241
303
 
304
+ async function startTranscode() {
305
+ transcoding = true;
306
+ transTotal = 0;
307
+ transProcessed = 0;
308
+ transCreated = 0;
309
+ transSkipped = 0;
310
+ transCurrentFile = '';
311
+ transErrors = 0;
312
+ transAbort = new AbortController();
313
+
314
+ try {
315
+ const res = await fetch('/admin/api/transcode-videos', {
316
+ method: 'POST',
317
+ signal: transAbort.signal
318
+ });
319
+
320
+ if (!res.ok) throw new Error('Failed to start');
321
+ if (!res.body) throw new Error('No response body');
322
+
323
+ const reader = res.body.getReader();
324
+ const decoder = new TextDecoder();
325
+ let buffer = '';
326
+
327
+ while (true) {
328
+ const { done, value } = await reader.read();
329
+ if (done) break;
330
+
331
+ buffer += decoder.decode(value, { stream: true });
332
+ const chunks = buffer.split('\n\n');
333
+ buffer = chunks.pop() || '';
334
+
335
+ for (const chunk of chunks) {
336
+ if (!chunk.startsWith('data: ')) continue;
337
+ const event = JSON.parse(chunk.slice(6));
338
+
339
+ transTotal = event.total ?? transTotal;
340
+ transProcessed = event.processed ?? transProcessed;
341
+ transCreated = event.created ?? transCreated;
342
+ transSkipped = event.skipped ?? transSkipped;
343
+ transCurrentFile = event.currentFile ?? transCurrentFile;
344
+
345
+ if (event.type === 'error') {
346
+ transErrors++;
347
+ }
348
+
349
+ if (event.type === 'done') {
350
+ const parts = [`Przetworzono ${transTotal} wariantów`];
351
+ if (transCreated > 0) parts.push(`utworzono ${transCreated}`);
352
+ if (transSkipped > 0) parts.push(`pominięto ${transSkipped}`);
353
+ if (transErrors > 0) parts.push(`${transErrors} błędów`);
354
+ toast.success(parts.join(', '));
355
+ }
356
+ }
357
+ }
358
+ } catch (e) {
359
+ if (e instanceof DOMException && e.name === 'AbortError') {
360
+ toast.info(`Przerwano po ${transProcessed}/${transTotal} wariantów`);
361
+ } else {
362
+ toast.error('Błąd podczas transkodowania');
363
+ }
364
+ } finally {
365
+ transcoding = false;
366
+ transAbort = null;
367
+ await loadReport();
368
+ }
369
+ }
370
+
371
+ function cancelTranscode() {
372
+ transAbort?.abort();
373
+ }
374
+
375
+ async function purgeVideoStylesAction() {
376
+ purgingVideoStyles = true;
377
+ try {
378
+ const res = await fetch('/admin/api/media-gc?action=purge-video-styles', { method: 'DELETE' });
379
+ if (!res.ok) throw new Error('Failed');
380
+ const data = await res.json();
381
+ toast.success(`Usunięto ${data.deletedCount} styli wideo (${data.filesDeleted} plików z dysku)`);
382
+ await loadReport();
383
+ } catch {
384
+ toast.error('Nie udało się usunąć styli wideo');
385
+ } finally {
386
+ purgingVideoStyles = false;
387
+ }
388
+ }
389
+
242
390
  $effect(() => {
243
391
  loadReport();
244
392
  });
@@ -413,6 +561,108 @@
413
561
  </Card.Content>
414
562
  </Card.Root>
415
563
 
564
+ <!-- Video transcoding -->
565
+ <Card.Root>
566
+ <Card.Header>
567
+ <div class="flex items-center gap-2">
568
+ <Transform class="size-5" style="color: var(--primary);" />
569
+ <Card.Title>Transkodowanie wideo</Card.Title>
570
+ </div>
571
+ <Card.Description>
572
+ Warianty wideo (mp4 h264, webm vp9) zoptymalizowane do wyświetlania
573
+ </Card.Description>
574
+ </Card.Header>
575
+ <Card.Content>
576
+ <p class="mb-1 text-3xl font-bold" style="color: var(--primary);">
577
+ {report.videoStylesDone}
578
+ <span class="text-base font-normal" style="color: var(--muted-foreground);">/ {report.videoStylesExpected}</span>
579
+ </p>
580
+ <p class="mb-4 text-xs" style="color: var(--muted-foreground);">
581
+ {report.videosCount} filmów{report.videoStylesPending > 0 ? `, ${report.videoStylesPending} w kolejce` : ''}{report.videoStylesFailed > 0 ? `, ${report.videoStylesFailed} błędów` : ''}
582
+ </p>
583
+
584
+ {#if transcoding}
585
+ <div class="mb-4">
586
+ <div class="mb-1 flex items-center justify-between text-xs" style="color: var(--muted-foreground);">
587
+ <span>{transProcessed}/{transTotal} wariantów</span>
588
+ <span>{transPercent}%</span>
589
+ </div>
590
+ <div class="h-2 w-full overflow-hidden rounded-full" style="background: var(--muted, #e5e7eb);">
591
+ <div
592
+ class="h-full rounded-full transition-all duration-300"
593
+ style="width: {transPercent}%; background: var(--primary);"
594
+ ></div>
595
+ </div>
596
+ <p class="mt-1 text-xs" style="color: var(--muted-foreground);">
597
+ Utworzono: {transCreated}, pominięto: {transSkipped}{transErrors > 0 ? `, błędów: ${transErrors}` : ''}
598
+ </p>
599
+ <p class="mt-0.5 truncate text-xs" style="color: var(--muted-foreground);">
600
+ {transCurrentFile}
601
+ </p>
602
+ <Button
603
+ variant="outline"
604
+ size="sm"
605
+ onclick={cancelTranscode}
606
+ class="mt-2"
607
+ >
608
+ <PlayerStop class="size-4" />
609
+ Anuluj
610
+ </Button>
611
+ </div>
612
+ {:else if videoStylesMissing > 0 || report.videoStylesFailed > 0}
613
+ <div class="mb-3">
614
+ {#if videoStylesMissing > 0}
615
+ <p class="mb-2 text-sm" style="color: var(--warning, #C4893A);">
616
+ Brakuje {videoStylesMissing} wariantów wideo.
617
+ </p>
618
+ {/if}
619
+ {#if report.videoStylesFailed > 0}
620
+ <p class="mb-2 text-sm" style="color: var(--error, #C44B4B);">
621
+ {report.videoStylesFailed} wariantów nie udało się wygenerować.
622
+ </p>
623
+ {/if}
624
+ <Button
625
+ variant="default"
626
+ size="sm"
627
+ onclick={startTranscode}
628
+ >
629
+ <PlayerPlay class="size-4" />
630
+ Transkoduj brakujące
631
+ </Button>
632
+ </div>
633
+ {:else if report.videosCount > 0}
634
+ <div class="mb-3 flex items-center gap-1.5 text-sm" style="color: var(--success, #3A8A5C);">
635
+ <CircleCheck class="size-4" />
636
+ Wszystkie warianty wygenerowane
637
+ </div>
638
+ {:else}
639
+ <div class="mb-3 flex items-center gap-1.5 text-sm" style="color: var(--success, #3A8A5C);">
640
+ <CircleCheck class="size-4" />
641
+ Brak plików wideo
642
+ </div>
643
+ {/if}
644
+
645
+ {#if report.videoStylesCount > 0}
646
+ <Button
647
+ variant="destructive"
648
+ size="sm"
649
+ onclick={purgeVideoStylesAction}
650
+ disabled={purgingVideoStyles || transcoding}
651
+ >
652
+ {#if purgingVideoStyles}
653
+ <Loader2 class="size-4 animate-spin" />
654
+ {:else}
655
+ <Trash class="size-4" />
656
+ {/if}
657
+ Usuń wszystkie i transkoduj ponownie
658
+ </Button>
659
+ <p class="mt-2 text-xs" style="color: var(--text-light);">
660
+ Warianty zostaną odtworzone przy następnym transkodowaniu
661
+ </p>
662
+ {/if}
663
+ </Card.Content>
664
+ </Card.Root>
665
+
416
666
  <!-- Orphaned disk files -->
417
667
  <Card.Root>
418
668
  <Card.Header>
@@ -489,6 +739,154 @@
489
739
  {/if}
490
740
  </Card.Content>
491
741
  </Card.Root>
742
+
743
+ <!-- Disk usage -->
744
+ {#if sysInfo}
745
+ {@const du = sysInfo.diskUsage}
746
+ <Card.Root>
747
+ <Card.Header>
748
+ <div class="flex items-center gap-2">
749
+ <Database class="size-5" style="color: var(--primary);" />
750
+ <Card.Title>Zużycie dysku</Card.Title>
751
+ </div>
752
+ <Card.Description>
753
+ Rozmiar plików mediów i wygenerowanych wariantów
754
+ </Card.Description>
755
+ </Card.Header>
756
+ <Card.Content>
757
+ <p class="mb-4 text-3xl font-bold" style="color: var(--primary);">
758
+ {formatBytes(du.total)}
759
+ </p>
760
+ <dl class="space-y-1.5 text-sm">
761
+ {#if du.originals.images > 0}
762
+ <div class="flex justify-between">
763
+ <dt style="color: var(--muted-foreground);">Obrazy</dt>
764
+ <dd class="font-medium">{formatBytes(du.originals.images)}</dd>
765
+ </div>
766
+ {/if}
767
+ {#if du.originals.videos > 0}
768
+ <div class="flex justify-between">
769
+ <dt style="color: var(--muted-foreground);">Wideo</dt>
770
+ <dd class="font-medium">{formatBytes(du.originals.videos)}</dd>
771
+ </div>
772
+ {/if}
773
+ {#if du.originals.audio > 0}
774
+ <div class="flex justify-between">
775
+ <dt style="color: var(--muted-foreground);">Audio</dt>
776
+ <dd class="font-medium">{formatBytes(du.originals.audio)}</dd>
777
+ </div>
778
+ {/if}
779
+ {#if du.originals.pdf > 0}
780
+ <div class="flex justify-between">
781
+ <dt style="color: var(--muted-foreground);">PDF</dt>
782
+ <dd class="font-medium">{formatBytes(du.originals.pdf)}</dd>
783
+ </div>
784
+ {/if}
785
+ {#if du.originals.other > 0}
786
+ <div class="flex justify-between">
787
+ <dt style="color: var(--muted-foreground);">Inne</dt>
788
+ <dd class="font-medium">{formatBytes(du.originals.other)}</dd>
789
+ </div>
790
+ {/if}
791
+ {#if du.imageStyles.count > 0}
792
+ <div class="flex justify-between">
793
+ <dt style="color: var(--muted-foreground);">Style obrazów ({du.imageStyles.count})</dt>
794
+ <dd class="font-medium">{formatBytes(du.imageStyles.bytes)}</dd>
795
+ </div>
796
+ {/if}
797
+ {#if du.videoStyles.count > 0}
798
+ <div class="flex justify-between">
799
+ <dt style="color: var(--muted-foreground);">Style wideo ({du.videoStyles.count})</dt>
800
+ <dd class="font-medium">{formatBytes(du.videoStyles.bytes)}</dd>
801
+ </div>
802
+ {/if}
803
+ {#if du.posters.count > 0}
804
+ <div class="flex justify-between">
805
+ <dt style="color: var(--muted-foreground);">Postery/miniaturki ({du.posters.count})</dt>
806
+ <dd class="font-medium">{formatBytes(du.posters.bytes)}</dd>
807
+ </div>
808
+ {/if}
809
+ </dl>
810
+ </Card.Content>
811
+ </Card.Root>
812
+
813
+ <!-- System info -->
814
+ {@const si = sysInfo.systemInfo}
815
+ <Card.Root>
816
+ <Card.Header>
817
+ <div class="flex items-center gap-2">
818
+ <Server class="size-5" style="color: var(--primary);" />
819
+ <Card.Title>System</Card.Title>
820
+ </div>
821
+ <Card.Description>
822
+ Informacje o środowisku i zależnościach
823
+ </Card.Description>
824
+ </Card.Header>
825
+ <Card.Content>
826
+ <dl class="space-y-1.5 text-sm">
827
+ <div class="flex justify-between">
828
+ <dt style="color: var(--muted-foreground);">includio-cms</dt>
829
+ <dd class="font-medium">{si.cmsVersion}</dd>
830
+ </div>
831
+ <div class="flex justify-between">
832
+ <dt style="color: var(--muted-foreground);">Node.js</dt>
833
+ <dd class="font-medium">{si.nodeVersion}</dd>
834
+ </div>
835
+ <div class="flex justify-between">
836
+ <dt style="color: var(--muted-foreground);">PostgreSQL</dt>
837
+ <dd class="font-medium">{si.postgresVersion ?? 'niedostępny'}</dd>
838
+ </div>
839
+ <div class="flex justify-between">
840
+ <dt style="color: var(--muted-foreground);">ffmpeg</dt>
841
+ <dd class="font-medium">
842
+ {#if si.ffmpeg.available}
843
+ {si.ffmpeg.version}
844
+ {:else}
845
+ <span style="color: var(--error, #C44B4B);">niedostępny</span>
846
+ {/if}
847
+ </dd>
848
+ </div>
849
+ {#if si.ffmpeg.available}
850
+ <div class="flex justify-between">
851
+ <dt style="color: var(--muted-foreground);">Kodeki</dt>
852
+ <dd class="flex gap-1.5 font-medium">
853
+ <span
854
+ class="rounded px-1.5 py-0.5 text-xs"
855
+ style="background: {si.ffmpeg.codecs.h264 ? 'var(--success, #3A8A5C)' : 'var(--error, #C44B4B)'}; color: white;"
856
+ >h264</span>
857
+ <span
858
+ class="rounded px-1.5 py-0.5 text-xs"
859
+ style="background: {si.ffmpeg.codecs.vp9 ? 'var(--success, #3A8A5C)' : 'var(--error, #C44B4B)'}; color: white;"
860
+ >vp9</span>
861
+ </dd>
862
+ </div>
863
+ {/if}
864
+ <div class="flex justify-between">
865
+ <dt style="color: var(--muted-foreground);">sharp</dt>
866
+ <dd class="font-medium">
867
+ {#if si.sharp.available}
868
+ {si.sharp.version}
869
+ {:else}
870
+ <span style="color: var(--error, #C44B4B);">niedostępny</span>
871
+ {/if}
872
+ </dd>
873
+ </div>
874
+ <div class="flex justify-between">
875
+ <dt style="color: var(--muted-foreground);">OS</dt>
876
+ <dd class="font-medium">{si.os.platform} {si.os.release}</dd>
877
+ </div>
878
+ <div class="flex justify-between">
879
+ <dt style="color: var(--muted-foreground);">CPU / RAM</dt>
880
+ <dd class="font-medium">{si.os.cpus} rdzeni / {formatBytes(si.os.totalMemory)}</dd>
881
+ </div>
882
+ <div class="flex justify-between">
883
+ <dt style="color: var(--muted-foreground);">Katalog uploads</dt>
884
+ <dd class="max-w-[200px] truncate font-medium" title={si.uploads.path}>{si.uploads.path}</dd>
885
+ </div>
886
+ </dl>
887
+ </Card.Content>
888
+ </Card.Root>
889
+ {/if}
492
890
  </div>
493
891
 
494
892
  <!-- Refresh button -->
@@ -135,7 +135,26 @@ function renderNode(node, options) {
135
135
  videoAttrs += ` width="${width}"`;
136
136
  if (height)
137
137
  videoAttrs += ` height="${height}"`;
138
- return `<video${videoAttrs}><source src="${escapeHtml(src)}"></video>`;
138
+ // Build source elements: transcoded styles first, then original
139
+ let sources = '';
140
+ const mediaData = attrs._media;
141
+ if (mediaData?.videoStyles) {
142
+ const styles = Object.values(mediaData.videoStyles)
143
+ .filter((s) => s.status === 'done' && s.url);
144
+ // webm first, then mp4
145
+ styles.sort((a, b) => {
146
+ if (a.mimeType === 'video/webm' && b.mimeType !== 'video/webm')
147
+ return -1;
148
+ if (a.mimeType !== 'video/webm' && b.mimeType === 'video/webm')
149
+ return 1;
150
+ return 0;
151
+ });
152
+ for (const style of styles) {
153
+ sources += `<source src="${escapeHtml(resolveUrl(style.url, options.baseUrl))}" type="${escapeHtml(style.mimeType)}">`;
154
+ }
155
+ }
156
+ sources += `<source src="${escapeHtml(src)}">`;
157
+ return `<video${videoAttrs}>${sources}</video>`;
139
158
  }
140
159
  case 'table':
141
160
  return `<table>${children}</table>`;