1import * as mediaFiles from "#src/file-viewer/models/media-files.ts";
2import * as mime from "@clo/lib/mime";
3import * as progress from "@clo/lib/progress";
4import { sql } from "drizzle-orm";
5import * as registry from "./registry.ts";
6import type { Service } from "./service.ts";
7
8type FileRow = {
9 id: number;
10 path: string;
11 kind: number;
12 size: number;
13 timestamp_updated: number | string;
14 duration: number;
15 dimensions: string;
16};
17type StateRow = { file: number; name: string; version: number; status: number; updated: number; error: string | null };
18
19const seconds = (value: number | string) => (typeof value === "string" ? Date.parse(value) : value) / 1000;
20const extension = (path: string) => {
21 const name = path.split("/").at(-1) ?? "";
22 const dot = name.lastIndexOf(".");
23 return (dot > 0 ? name.slice(dot) : name).toLowerCase();
24};
25
26export function stats(service: Service | null) {
27 const { db } = mediaFiles;
28 const all = db.all<FileRow>(sql`select id,path,kind,size,timestamp_updated,duration,dimensions from media_files`);
29 const files = all.filter((file) => file.kind === 1);
30 const extensions = new Map(files.map((file) => [file.id, extension(file.path)]));
31 const recorded = db.all<StateRow>(sql`
32 select fp.file,p.name,fp.version,fp.status,fp.updated,fp.error
33 from file_processors fp join processors p on p.id=fp.processor
34 `);
35 const byFile = new Map<number, Map<string, StateRow>>();
36 for (const row of recorded) {
37 const states = byFile.get(row.file) ?? new Map<string, StateRow>();
38 states.set(row.name, row);
39 byFile.set(row.file, states);
40 }
41 const folders = new Map<string, { name: string; files: number; bytes: number }>();
42 const types = new Map<string, { ext: string; files: number; bytes: number }>();
43 for (const file of files) {
44 const top = file.path.split("/")[1] ?? "";
45 const folder = folders.get(top) ?? { name: top, files: 0, bytes: 0 };
46 folder.files++;
47 folder.bytes += file.size;
48 folders.set(top, folder);
49 const ext = extensions.get(file.id) ?? "";
50 const type = types.get(ext) ?? { ext, files: 0, bytes: 0 };
51 type.files++;
52 type.bytes += file.size;
53 types.set(ext, type);
54 }
55 const processors = registry.processors.map((p) => {
56 let applicable = 0;
57 let done = 0;
58 let failed = 0;
59 let stale = 0;
60 for (const file of files) {
61 const ext = extensions.get(file.id) ?? "";
62 if (p.include ? !p.include.has(ext) : p.exclude?.has(ext)) continue;
63 applicable++;
64 const state = byFile.get(file.id)?.get(p.name);
65 if (state?.version !== p.version) {
66 if (state) stale++;
67 } else if (state.status === 1) done++;
68 else if (state.status === 2) failed++;
69 }
70 return {
71 name: p.name,
72 title: p.title,
73 version: p.version,
74 runnable: registry.canExecute(p),
75 applicable,
76 done,
77 failed,
78 stale,
79 outputs: null,
80 bytes: null,
81 seconds: null,
82 };
83 });
84 const failures = files.flatMap((file) => {
85 const states = [...(byFile.get(file.id)?.values() ?? [])];
86 const errors = states.flatMap((state) =>
87 state.status === 2 && state.error !== null
88 ? [{ processor: state.name, error: state.error, at: state.updated / 1000 }]
89 : []
90 );
91 return errors.length
92 ? [{
93 path: file.path,
94 size: file.size,
95 mime: mime.for(file.path),
96 made: states.filter((state) => state.status === 1).map((state) => state.name),
97 errors,
98 }]
99 : [];
100 });
101 const recent = [...files].sort((a, b) => seconds(b.timestamp_updated) - seconds(a.timestamp_updated))
102 .slice(0, 12).map((file) => ({ path: file.path, size: file.size, at: seconds(file.timestamp_updated) }));
103 const largest = [...files].sort((a, b) => b.size - a.size).slice(0, 12)
104 .map((file) => ({ path: file.path, size: file.size, duration: file.duration, dimensions: file.dimensions }));
105 const derived = db.get<{ files: number; bytes: number }>(
106 sql`select count(*) as files,coalesce(sum(size),0) as bytes from derived_files`,
107 );
108 return {
109 indexer: { enabled: service !== null, watching: Boolean(service?.watcher), nextSweepAt: null },
110 lastSweep: service?.lastSweep ?? null,
111 files: files.length,
112 directories: all.length - files.length,
113 bytes: files.reduce((sum, file) => sum + file.size, 0),
114 folders: [...folders.values()].sort((a, b) => b.bytes - a.bytes),
115 types: [...types.values()].sort((a, b) => b.bytes - a.bytes),
116 processors,
117 derived,
118 failures,
119 recent,
120 largest,
121 };
122}
123
124const firstSeen = new WeakMap<progress.ReadOnlyNode, number>();
125
126export function activity(root: progress.Root) {
127 const convert = (node: progress.ReadOnlyNode): object => {
128 if (!firstSeen.has(node)) firstSeen.set(node, Date.now() / 1000);
129 const match = /^(\/.*?) - (.*)$/.exec(node.text);
130 const children = (node.sortChildren ? [...node.children].sort(node.sortChildren) : node.children)
131 .filter((child) => !child.hidden && (!child.passive || child.children.length)).map(convert);
132 return {
133 path: match?.[1] ?? null,
134 text: match?.[2] ?? node.text,
135 count: node.valueFormatter(node.value, node.total),
136 progress: node.showTotal && node.total && node.total > 0 ? node.value / node.total : null,
137 eta: node.estimatedTime ? node.estimatedTime / 1000 : null,
138 since: firstSeen.get(node),
139 logs: node.logs.slice(-3).map((entry) => ({
140 level: entry.level ?? "info",
141 text: entry.text.replace(/\x1b\[[\d;]*m/g, ""),
142 })),
143 children,
144 };
145 };
146 return root.active.filter((node) => !node.hidden && (!node.passive || node.children.length)).map(convert);
147}