@zerotal/media 1.3.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.
@@ -0,0 +1,99 @@
1
+ import { MediaItem } from "./MediaItem.ts";
2
+
3
+ /**
4
+ * Assertions over what a test attached to its models.
5
+ *
6
+ * Pairs with `Storage.fake()`, which handles the bytes: that one asserts a file
7
+ * landed on a disk, this one asserts a row points at it from the right
8
+ * collection. Both matter — a media row with no file and a file with no row are
9
+ * different bugs.
10
+ *
11
+ * @example
12
+ * const disk = Storage.fake();
13
+ * await product.addMedia(file).toCollection("images");
14
+ *
15
+ * await MediaFake.assertHas(product, "images");
16
+ * await MediaFake.assertCount(product, "images", 1);
17
+ * disk.assertExistsMatching(/^media\/[0-9a-f-]+\/original\.png$/);
18
+ */
19
+ export const MediaFake = {
20
+ /** Every media row attached to a model. */
21
+ async all(owner: { id: number | string; constructor: { name: string } }): Promise<MediaItem[]> {
22
+ return MediaItem.query()
23
+ .where("model_type", owner.constructor.name)
24
+ .where("model_id", String(owner.id))
25
+ .orderBy("id", "asc")
26
+ .get();
27
+ },
28
+
29
+ /** Media rows in one of a model's collections. */
30
+ async inCollection(
31
+ owner: { id: number | string; constructor: { name: string } },
32
+ collection: string,
33
+ ): Promise<MediaItem[]> {
34
+ const all = await MediaFake.all(owner);
35
+ return all.filter((media) => media.collectionName === collection);
36
+ },
37
+
38
+ /** Fail unless the collection holds at least one item. */
39
+ async assertHas(
40
+ owner: { id: number | string; constructor: { name: string } },
41
+ collection: string,
42
+ ): Promise<void> {
43
+ const found = await MediaFake.inCollection(owner, collection);
44
+ if (found.length > 0) return;
45
+
46
+ const all = await MediaFake.all(owner);
47
+ const others = [...new Set(all.map((m) => m.collectionName))];
48
+ throw new Error(
49
+ `Expected ${owner.constructor.name}#${owner.id} to have media in "${collection}", ` +
50
+ `but it has none. Collections with media: ${others.length > 0 ? others.join(", ") : "none"}.`,
51
+ );
52
+ },
53
+
54
+ /** Fail unless the collection is empty. */
55
+ async assertMissing(
56
+ owner: { id: number | string; constructor: { name: string } },
57
+ collection: string,
58
+ ): Promise<void> {
59
+ const found = await MediaFake.inCollection(owner, collection);
60
+ if (found.length === 0) return;
61
+ throw new Error(
62
+ `Expected "${collection}" to be empty, but it holds ${found.length}: ` +
63
+ found.map((m) => m.fileName).join(", "),
64
+ );
65
+ },
66
+
67
+ /** Fail unless the collection holds exactly `count` items. */
68
+ async assertCount(
69
+ owner: { id: number | string; constructor: { name: string } },
70
+ collection: string,
71
+ count: number,
72
+ ): Promise<void> {
73
+ const found = await MediaFake.inCollection(owner, collection);
74
+ if (found.length === count) return;
75
+ throw new Error(
76
+ `Expected "${collection}" to hold ${count} item(s), found ${found.length}: ` +
77
+ (found.map((m) => m.fileName).join(", ") || "none"),
78
+ );
79
+ },
80
+
81
+ /** Fail unless a named conversion was generated for the collection's first item. */
82
+ async assertConversion(
83
+ owner: { id: number | string; constructor: { name: string } },
84
+ collection: string,
85
+ conversion: string,
86
+ ): Promise<void> {
87
+ const [first] = await MediaFake.inCollection(owner, collection);
88
+ if (first === undefined) {
89
+ throw new Error(`Expected a conversion "${conversion}", but "${collection}" is empty.`);
90
+ }
91
+ if (first.hasConversion(conversion)) return;
92
+
93
+ const generated = first.conversionNames();
94
+ throw new Error(
95
+ `Expected conversion "${conversion}" on ${first.fileName}, but it has ` +
96
+ `${generated.length > 0 ? generated.join(", ") : "none"}.`,
97
+ );
98
+ },
99
+ };
@@ -0,0 +1,282 @@
1
+ import { BaseModel, column, table } from "@zerotal/orm";
2
+ import type { StorageDriver } from "@zerotal/core/storage";
3
+ import { diskFor } from "./support/disks.ts";
4
+ import { DefaultPathGenerator, type PathGenerator } from "./paths/PathGenerator.ts";
5
+ import { MediaFileMissingError } from "./errors.ts";
6
+ import type { GeneratedConversion, ResponsiveImageSet } from "./types.ts";
7
+
8
+ /** Swappable at the container level by `MediaProvider`. */
9
+ let _pathGenerator: PathGenerator = new DefaultPathGenerator();
10
+
11
+ /** Replace the global path generator. Called by `MediaProvider` from config. */
12
+ export function setPathGenerator(generator: PathGenerator): void {
13
+ _pathGenerator = generator;
14
+ }
15
+
16
+ /** The path generator currently in force. */
17
+ export function pathGenerator(): PathGenerator {
18
+ return _pathGenerator;
19
+ }
20
+
21
+ /**
22
+ * One stored file attached to a model.
23
+ *
24
+ * Rows are created by `model.addMedia(...)` rather than directly — the adder is
25
+ * what sniffs the file's type, enforces the collection's rules, writes the bytes
26
+ * and generates conversions. Constructing a `MediaItem` by hand gets you a row with
27
+ * no file behind it.
28
+ *
29
+ * @example
30
+ * const media = await product.addMedia(file).toCollection("images");
31
+ * media.getUrl(); // the original
32
+ * media.getUrl("thumb"); // a conversion, or "" when it has not been generated
33
+ * await media.delete(); // removes the row, the original, and every derivative
34
+ */
35
+ @(table("media").withTimestamps())
36
+ export class MediaItem extends BaseModel {
37
+ /** Owner discriminator — the owning model's class name. */
38
+ @column() modelType!: string;
39
+ /** Owner primary key. Stored as text so UUID keys work as well as integers. */
40
+ @column() modelId!: string;
41
+ /** Public identifier. Used in paths, so it never exposes the row count. */
42
+ @column() uuid!: string;
43
+ @column() collectionName!: string;
44
+ /** Human-facing label; defaults to the original filename without its extension. */
45
+ @column() name!: string;
46
+ /** Name on disk, e.g. `original.jpg`. */
47
+ @column() fileName!: string;
48
+ /** Sniffed from the file's own bytes — never the client-supplied header. */
49
+ @column() mimeType!: string | null;
50
+ @column() disk!: string;
51
+ @column() conversionsDisk!: string | null;
52
+ @column("number") size!: number;
53
+ @column("json") manipulations!: Record<string, unknown>;
54
+ @column("json") customProperties!: Record<string, unknown>;
55
+ @column("json") generatedConversions!: Record<string, GeneratedConversion>;
56
+ @column("json") responsiveImages!: ResponsiveImageSet | Record<string, never>;
57
+ @column("number") orderColumn!: number | null;
58
+
59
+ // ── Scopes ─────────────────────────────────────────────────────────────────
60
+
61
+ static forOwner = this.scope((q, type: string, id: string | number) =>
62
+ q.where("model_type", type).where("model_id", String(id)),
63
+ );
64
+
65
+ static inCollection = this.scope((q, collection: string) =>
66
+ q.where("collection_name", collection),
67
+ );
68
+
69
+ static ordered = this.scope((q) => q.orderBy("order_column", "asc").orderBy("id", "asc"));
70
+
71
+ // ── Disks ──────────────────────────────────────────────────────────────────
72
+
73
+ /** The disk holding the original. */
74
+ originalDisk(): StorageDriver {
75
+ return diskFor(this.disk);
76
+ }
77
+
78
+ /** The disk holding conversions — falls back to the original's disk. */
79
+ derivedDisk(): StorageDriver {
80
+ return diskFor(this.conversionsDisk ?? this.disk);
81
+ }
82
+
83
+ // ── Paths ──────────────────────────────────────────────────────────────────
84
+
85
+ /**
86
+ * Path to the original, or to a named conversion.
87
+ *
88
+ * Returns `""` for a conversion that has not been generated — matching
89
+ * `getUrl()`, so a template can use either without a null check.
90
+ */
91
+ getPath(conversion?: string): string {
92
+ if (conversion === undefined) {
93
+ return `${pathGenerator().forOriginal(this)}/${this.fileName}`;
94
+ }
95
+ const generated = this.generatedConversions?.[conversion];
96
+ if (!generated) return "";
97
+ return `${pathGenerator().forConversions(this)}/${generated.fileName}`;
98
+ }
99
+
100
+ /** Path to one responsive variant by width, or `""` when absent. */
101
+ getResponsivePath(width: number): string {
102
+ const entry = this.responsiveSet().images.find((i) => i.width === width);
103
+ if (!entry) return "";
104
+ return `${pathGenerator().forResponsiveImages(this)}/${entry.fileName}`;
105
+ }
106
+
107
+ // ── URLs ───────────────────────────────────────────────────────────────────
108
+
109
+ /**
110
+ * Public URL for the original, or for a named conversion.
111
+ *
112
+ * Returns `""` when the conversion does not exist, so `<img src="">` renders
113
+ * nothing rather than a broken path — check `hasConversion()` when you need to
114
+ * branch.
115
+ */
116
+ getUrl(conversion?: string): string {
117
+ const path = this.getPath(conversion);
118
+ if (path === "") return "";
119
+ const disk = conversion === undefined ? this.originalDisk() : this.derivedDisk();
120
+ return disk.url(path);
121
+ }
122
+
123
+ /**
124
+ * A signed, expiring URL — the way to expose a file on a private disk without
125
+ * making the disk public.
126
+ *
127
+ * @param expiresInSeconds - Lifetime of the link. Default 900 (15 minutes).
128
+ */
129
+ async getTemporaryUrl(expiresInSeconds = 900, conversion?: string): Promise<string> {
130
+ const path = this.getPath(conversion);
131
+ if (path === "") return "";
132
+ const disk = conversion === undefined ? this.originalDisk() : this.derivedDisk();
133
+ return disk.temporaryUrl(path, expiresInSeconds);
134
+ }
135
+
136
+ // ── Conversions ────────────────────────────────────────────────────────────
137
+
138
+ /** Whether a named conversion has been generated. */
139
+ hasConversion(name: string): boolean {
140
+ return Boolean(this.generatedConversions?.[name]);
141
+ }
142
+
143
+ /** Metadata for a generated conversion, or `null`. */
144
+ conversion(name: string): GeneratedConversion | null {
145
+ return this.generatedConversions?.[name] ?? null;
146
+ }
147
+
148
+ /** Names of every generated conversion. */
149
+ conversionNames(): string[] {
150
+ return Object.keys(this.generatedConversions ?? {});
151
+ }
152
+
153
+ // ── Responsive images ──────────────────────────────────────────────────────
154
+
155
+ /** The responsive set, normalised so callers never see `undefined`. */
156
+ responsiveSet(): ResponsiveImageSet {
157
+ const raw = this.responsiveImages as ResponsiveImageSet | undefined;
158
+ if (!raw || !Array.isArray(raw.images)) return { images: [] };
159
+ return raw;
160
+ }
161
+
162
+ /**
163
+ * A `srcset` attribute value for the responsive ladder, or `""` when none was
164
+ * generated.
165
+ *
166
+ * @example
167
+ * <img src={media.getUrl()} srcset={media.srcset()} sizes="(max-width: 768px) 100vw, 50vw" />
168
+ */
169
+ srcset(): string {
170
+ const set = this.responsiveSet();
171
+ if (set.images.length === 0) return "";
172
+ const disk = this.derivedDisk();
173
+ const dir = pathGenerator().forResponsiveImages(this);
174
+ return set.images
175
+ .map((image) => `${disk.url(`${dir}/${image.fileName}`)} ${image.width}w`)
176
+ .join(", ");
177
+ }
178
+
179
+ /**
180
+ * An inline `data:` URI of a tiny blurred version, for rendering while the
181
+ * real image loads. `null` when none was generated.
182
+ */
183
+ get placeholder(): string | null {
184
+ return this.responsiveSet().placeholder ?? null;
185
+ }
186
+
187
+ // ── Custom properties ──────────────────────────────────────────────────────
188
+
189
+ /**
190
+ * Read a custom property, with an optional fallback.
191
+ *
192
+ * Custom properties are an untyped JSON bag, so without a fallback the result is `unknown` —
193
+ * narrow it yourself (`as string`, or a type guard). With a fallback the result is the
194
+ * fallback's type and can never be `undefined`.
195
+ *
196
+ * The no-fallback overload is deliberately **non-generic**. A `<T = unknown>` parameter that
197
+ * appears only in the return type is inferred from the CALL SITE's context rather than falling
198
+ * back to its default, so `expect(item.getCustomProperty("alt"))` collapsed `T` to `undefined`
199
+ * and rejected every assertion against it. A concrete `unknown` cannot be hijacked that way.
200
+ */
201
+ getCustomProperty(key: string): unknown;
202
+ getCustomProperty<T>(key: string, fallback: T): T;
203
+ getCustomProperty<T>(key: string, fallback?: T): T | undefined {
204
+ const value = this.customProperties?.[key];
205
+ return (value as T | undefined) ?? fallback;
206
+ }
207
+
208
+ /** Set a custom property in memory. Call `save()` to persist. */
209
+ setCustomProperty(key: string, value: unknown): this {
210
+ this.customProperties = { ...(this.customProperties ?? {}), [key]: value };
211
+ return this;
212
+ }
213
+
214
+ /** Remove a custom property in memory. Call `save()` to persist. */
215
+ forgetCustomProperty(key: string): this {
216
+ const next = { ...(this.customProperties ?? {}) };
217
+ delete next[key];
218
+ this.customProperties = next;
219
+ return this;
220
+ }
221
+
222
+ // ── Contents ───────────────────────────────────────────────────────────────
223
+
224
+ /** Read the original's bytes. */
225
+ async bytes(): Promise<Uint8Array> {
226
+ const path = this.getPath();
227
+ const buffer = await this.originalDisk().getBuffer(path);
228
+ if (buffer === null) throw new MediaFileMissingError(path, this.disk);
229
+ return buffer;
230
+ }
231
+
232
+ /** Whether the original is actually present on its disk. */
233
+ async fileExists(): Promise<boolean> {
234
+ return this.originalDisk().exists(this.getPath());
235
+ }
236
+
237
+ // ── Lifecycle ──────────────────────────────────────────────────────────────
238
+
239
+ /**
240
+ * Delete the row and every file behind it — original, conversions, responsive
241
+ * variants.
242
+ *
243
+ * Files go first. A failure part-way then leaves a row pointing at a missing
244
+ * file, which `media:clean` can find and fix; the reverse order would leave an
245
+ * orphaned file that nothing references and nothing can find.
246
+ */
247
+ override async delete(): Promise<void> {
248
+ await this.deleteFiles();
249
+ await super.delete();
250
+ }
251
+
252
+ /** Remove every file for this item, leaving the row. */
253
+ async deleteFiles(): Promise<void> {
254
+ const original = this.originalDisk();
255
+ const derived = this.derivedDisk();
256
+
257
+ await _ignoreMissing(original.delete(this.getPath()));
258
+
259
+ const conversionDir = pathGenerator().forConversions(this);
260
+ for (const generated of Object.values(this.generatedConversions ?? {})) {
261
+ await _ignoreMissing(derived.delete(`${conversionDir}/${generated.fileName}`));
262
+ }
263
+
264
+ const responsiveDir = pathGenerator().forResponsiveImages(this);
265
+ for (const image of this.responsiveSet().images) {
266
+ await _ignoreMissing(derived.delete(`${responsiveDir}/${image.fileName}`));
267
+ }
268
+ }
269
+ }
270
+
271
+ /**
272
+ * Deleting a file that is already gone is the outcome we wanted. Anything else
273
+ * propagates.
274
+ */
275
+ async function _ignoreMissing(operation: Promise<void>): Promise<void> {
276
+ try {
277
+ await operation;
278
+ } catch (error) {
279
+ const message = error instanceof Error ? error.message : String(error);
280
+ if (!/not found|no such file|ENOENT|does not exist/i.test(message)) throw error;
281
+ }
282
+ }
@@ -0,0 +1,111 @@
1
+ import { MediaItem } from "./MediaItem.ts";
2
+ import { ConversionRunner } from "./conversions/ConversionRunner.ts";
3
+ import { resolveCollection, type CollectionHost } from "./collections/resolve.ts";
4
+ import { diskFor } from "./support/disks.ts";
5
+ import type { MediaConfigShape } from "./config.ts";
6
+ import type { ImageDriver } from "./conversions/ImageDriver.ts";
7
+ import type { ConversionMap } from "./types.ts";
8
+
9
+ /** Outcome of a {@link MediaManager.clean} pass. */
10
+ export interface CleanReport {
11
+ /** Rows whose file is gone from its disk. */
12
+ orphanedRows: number[];
13
+ /** Rows deleted (empty when `dryRun`). */
14
+ deletedRows: number[];
15
+ /** Conversions recorded on a row whose file is missing. */
16
+ danglingConversions: Array<{ mediaId: number; conversion: string }>;
17
+ }
18
+
19
+ /**
20
+ * Application-level media operations — the thing behind the `MediaLibrary` facade.
21
+ *
22
+ * Per-model work lives on the {@link Media} mixin; this is for the jobs that
23
+ * cut across models: regenerating conversions after changing a definition, and
24
+ * reconciling rows against what is actually on disk.
25
+ */
26
+ export class MediaManager {
27
+ constructor(
28
+ readonly config: MediaConfigShape,
29
+ readonly driver: ImageDriver,
30
+ ) {}
31
+
32
+ /** A runner bound to the configured driver. */
33
+ runner(): ConversionRunner {
34
+ return new ConversionRunner(this.driver, this.config);
35
+ }
36
+
37
+ /**
38
+ * Regenerate conversions for one media item.
39
+ *
40
+ * Reads the collection definition off the owning model class, so changing a
41
+ * conversion's width and re-running this is all it takes to reprocess.
42
+ *
43
+ * @param media - The item to reprocess.
44
+ * @param ownerClass - The owning model class, for its collection definitions.
45
+ * @param only - Limit to these conversion names; omit for all of them.
46
+ */
47
+ async regenerate(
48
+ media: MediaItem,
49
+ ownerClass: CollectionHost,
50
+ only?: string[],
51
+ ): Promise<string[]> {
52
+ const definition = resolveCollection(ownerClass, media.collectionName);
53
+ const all = definition.conversions ?? {};
54
+
55
+ const wanted: ConversionMap = {};
56
+ for (const [name, conversion] of Object.entries(all)) {
57
+ if (only === undefined || only.includes(name)) wanted[name] = conversion;
58
+ }
59
+
60
+ const { generated } = await this.runner().run(media, wanted);
61
+
62
+ if (definition.responsive !== undefined && definition.responsive !== false) {
63
+ const widths = Array.isArray(definition.responsive)
64
+ ? definition.responsive
65
+ : this.config.responsiveWidths;
66
+ await this.runner().runResponsive(media, widths);
67
+ }
68
+
69
+ return generated;
70
+ }
71
+
72
+ /**
73
+ * Find media rows whose files have gone missing, and optionally remove them.
74
+ *
75
+ * Rows are checked one at a time against their own disk rather than by listing
76
+ * the disk, because the two live on different sides of a network for S3 and a
77
+ * full listing of a large bucket is not something to do casually.
78
+ *
79
+ * @param options.dryRun - Report without deleting. Default `true`, because the
80
+ * destructive reading of "clean" should never be the one you get by accident.
81
+ */
82
+ async clean(options: { dryRun?: boolean } = {}): Promise<CleanReport> {
83
+ const dryRun = options.dryRun ?? true;
84
+ const report: CleanReport = { orphanedRows: [], deletedRows: [], danglingConversions: [] };
85
+
86
+ const all = await MediaItem.query().get();
87
+
88
+ for (const media of all) {
89
+ const id = Number(media.id);
90
+
91
+ if (!(await media.fileExists())) {
92
+ report.orphanedRows.push(id);
93
+ if (!dryRun) {
94
+ await media.delete();
95
+ report.deletedRows.push(id);
96
+ }
97
+ continue;
98
+ }
99
+
100
+ const derived = diskFor(media.conversionsDisk ?? media.disk);
101
+ for (const name of media.conversionNames()) {
102
+ const path = media.getPath(name);
103
+ if (path !== "" && !(await derived.exists(path))) {
104
+ report.danglingConversions.push({ mediaId: id, conversion: name });
105
+ }
106
+ }
107
+ }
108
+
109
+ return report;
110
+ }
111
+ }
@@ -0,0 +1,39 @@
1
+ import { UnknownCollectionError } from "../errors.ts";
2
+ import type { CollectionDefinition, MediaCollections } from "../types.ts";
3
+
4
+ /** A model class that may declare media collections. */
5
+ export interface CollectionHost {
6
+ name: string;
7
+ mediaCollections?: MediaCollections;
8
+ }
9
+
10
+ /**
11
+ * The collection definition a model declares under `name`.
12
+ *
13
+ * Values may be a plain object or a thunk; the thunk is called on every lookup
14
+ * rather than cached, because the reason to write one is a value that changes
15
+ * between calls (the current tenant's disk, say).
16
+ *
17
+ * @throws {UnknownCollectionError} when the model declares no such collection —
18
+ * listing the ones it does declare, because the mistake is nearly always a typo.
19
+ */
20
+ export function resolveCollection(host: CollectionHost, name: string): CollectionDefinition {
21
+ const declared = host.mediaCollections ?? {};
22
+ const entry = declared[name];
23
+
24
+ if (entry === undefined) {
25
+ throw new UnknownCollectionError(host.name, name, Object.keys(declared));
26
+ }
27
+
28
+ return typeof entry === "function" ? entry() : entry;
29
+ }
30
+
31
+ /** Whether a model declares a collection under `name`. */
32
+ export function hasCollection(host: CollectionHost, name: string): boolean {
33
+ return (host.mediaCollections ?? {})[name] !== undefined;
34
+ }
35
+
36
+ /** Every collection name the model declares. */
37
+ export function collectionNames(host: CollectionHost): string[] {
38
+ return Object.keys(host.mediaCollections ?? {});
39
+ }
@@ -0,0 +1,47 @@
1
+ import { MediaItem } from "../MediaItem.ts";
2
+ import type { CollectionDefinition } from "../types.ts";
3
+
4
+ /**
5
+ * Trim a collection back to what its rules allow, after something was added.
6
+ *
7
+ * `single` is `onlyKeepLatest(1)` under a friendlier name — an avatar or a hero
8
+ * image, where a second upload replaces the first rather than joining it.
9
+ *
10
+ * Deletion goes through `MediaItem.delete()` on each row rather than a bulk query,
11
+ * so the files behind the rows go too. A bulk delete would leave every
12
+ * superseded avatar on disk forever, which is the bug this exists to avoid.
13
+ *
14
+ * @param justAdded - The item that triggered the trim; never removed, even if
15
+ * its sort position would otherwise put it outside the window.
16
+ */
17
+ export async function applyRetentionRules(
18
+ modelType: string,
19
+ modelId: number | string,
20
+ collection: string,
21
+ definition: CollectionDefinition,
22
+ justAdded: MediaItem,
23
+ ): Promise<void> {
24
+ const keep = definition.single === true ? 1 : definition.onlyKeepLatest;
25
+ if (keep === undefined || keep < 1) return;
26
+
27
+ // Newest first: highest order, then highest id as the tiebreak for items added
28
+ // within the same order slot.
29
+ const rows = await MediaItem.query()
30
+ .where("model_type", modelType)
31
+ .where("model_id", String(modelId))
32
+ .where("collection_name", collection)
33
+ .orderBy("order_column", "desc")
34
+ .orderBy("id", "desc")
35
+ .get();
36
+
37
+ const survivors = new Set<unknown>([justAdded.id]);
38
+ for (const row of rows) {
39
+ if (survivors.size >= keep) break;
40
+ survivors.add(row.id);
41
+ }
42
+
43
+ for (const row of rows) {
44
+ if (survivors.has(row.id)) continue;
45
+ await row.delete();
46
+ }
47
+ }
@@ -0,0 +1,62 @@
1
+ import { Command } from "@zerotal/core";
2
+ import type { FlagDef } from "@zerotal/core";
3
+ import { MediaLibrary } from "../facades/MediaLibrary.ts";
4
+
5
+ /**
6
+ * Reconcile media rows against what is actually on disk.
7
+ *
8
+ * Reports by default; `--force` is what deletes. "Clean" reads as harmless, and
9
+ * a command that silently removes rows the first time someone runs it to see
10
+ * what it does is a bad trade.
11
+ *
12
+ * @example
13
+ * ```bash
14
+ * bun zt media:clean # report only
15
+ * bun zt media:clean --force # delete rows whose files are gone
16
+ * ```
17
+ */
18
+ export class MediaCleanCommand extends Command {
19
+ static override commandName = "media:clean";
20
+ static override description =
21
+ "Find media rows whose files are missing, and optionally remove them";
22
+ static override needsApp = true;
23
+ static override flags: FlagDef[] = [
24
+ {
25
+ name: "force",
26
+ short: "f",
27
+ type: "boolean",
28
+ description: "Delete the orphaned rows instead of just listing them",
29
+ default: false,
30
+ },
31
+ ];
32
+
33
+ async run(): Promise<void> {
34
+ const force = this.flags["force"] === true;
35
+ const report = await MediaLibrary.clean({ dryRun: !force });
36
+
37
+ if (report.orphanedRows.length === 0 && report.danglingConversions.length === 0) {
38
+ this.info("Every media row has its file. Nothing to do.");
39
+ return;
40
+ }
41
+
42
+ if (report.orphanedRows.length > 0) {
43
+ this.warn(
44
+ `${report.orphanedRows.length} media row(s) point at a file that is gone: ` +
45
+ report.orphanedRows.join(", "),
46
+ );
47
+ }
48
+
49
+ if (report.danglingConversions.length > 0) {
50
+ this.warn(
51
+ `${report.danglingConversions.length} recorded conversion(s) are missing their file. ` +
52
+ "Run `bun zt media:regenerate` to rebuild them.",
53
+ );
54
+ }
55
+
56
+ if (force) {
57
+ this.info(`Deleted ${report.deletedRows.length} row(s).`);
58
+ } else {
59
+ this.info("Nothing was deleted. Re-run with --force to remove the orphaned rows.");
60
+ }
61
+ }
62
+ }