| 1 | import * as mediaFiles from "#src/file-viewer/models/media-files.ts"; |
| 2 | import * as mime from "@clo/lib/mime"; |
| 3 | import * as progress from "@clo/lib/progress"; |
| 4 | import { sql } from "drizzle-orm"; |
| 5 | import * as registry from "./registry.ts"; |
| 6 | import type { Service } from "./service.ts"; |
| 7 | |
| 8 | type 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 | }; |
| 17 | type StateRow = { file: number; name: string; version: number; status: number; updated: number; error: string | null }; |
| 18 | |
| 19 | const seconds = (value: number | string) => (typeof value === "string" ? Date.parse(value) : value) / 1000; |
| 20 | const 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 | |
| 26 | export 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 | |
| 124 | const firstSeen = new WeakMap<progress.ReadOnlyNode, number>(); |
| 125 | |
| 126 | export 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 | } |