@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.
- package/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.md +90 -0
- package/package.json +69 -0
- package/src/Media.ts +278 -0
- package/src/MediaAdder.ts +185 -0
- package/src/MediaFake.ts +99 -0
- package/src/MediaItem.ts +282 -0
- package/src/MediaManager.ts +111 -0
- package/src/collections/resolve.ts +39 -0
- package/src/collections/retention.ts +47 -0
- package/src/commands/MediaCleanCommand.ts +62 -0
- package/src/commands/MediaRegenerateCommand.ts +89 -0
- package/src/commands/index.ts +2 -0
- package/src/config.ts +89 -0
- package/src/conversions/BunImageDriver.ts +183 -0
- package/src/conversions/ConversionRunner.ts +219 -0
- package/src/conversions/ImageDriver.ts +100 -0
- package/src/conversions/PerformConversionsJob.ts +45 -0
- package/src/conversions/SharpImageDriver.ts +174 -0
- package/src/conversions/dispatch.ts +38 -0
- package/src/conversions/queueBridge.ts +82 -0
- package/src/errors.ts +114 -0
- package/src/facades/MediaLibrary.ts +16 -0
- package/src/index.ts +94 -0
- package/src/mediaSchemaConcern.ts +65 -0
- package/src/paths/PathGenerator.ts +47 -0
- package/src/provider/MediaProvider.ts +143 -0
- package/src/sources.ts +137 -0
- package/src/state.ts +36 -0
- package/src/support/disks.ts +78 -0
- package/src/types.ts +139 -0
package/src/MediaFake.ts
ADDED
|
@@ -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
|
+
};
|
package/src/MediaItem.ts
ADDED
|
@@ -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
|
+
}
|