@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/CHANGELOG.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Changelog — @zerotal/media
|
|
2
|
+
|
|
3
|
+
All notable changes to this package are documented here. The format is
|
|
4
|
+
based on [Keep a Changelog](https://keepachangelog.com/); this package
|
|
5
|
+
follows the Zerotal monorepo's unified versioning.
|
|
6
|
+
|
|
7
|
+
**Maturity: `experimental`**
|
|
8
|
+
|
|
9
|
+
## [Unreleased]
|
|
10
|
+
|
|
11
|
+
## [1.3.0] — 2026-08-09
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Initial release. Attach files to models with the `Media` mixin —
|
|
16
|
+
`class Product extends Model.using(Media)` — declaring collections
|
|
17
|
+
declaratively on the model, and store originals on any configured disk. One
|
|
18
|
+
stored file is a `MediaItem`.
|
|
19
|
+
- Media collections with `accepts` (checked against sniffed bytes), `maxSize`,
|
|
20
|
+
`single`, `onlyKeepLatest`, `fallbackUrl` / `fallbackPath`, and per-collection
|
|
21
|
+
disk overrides.
|
|
22
|
+
- Image conversions on `Bun.Image` — no native module required. `fit: "cover"`
|
|
23
|
+
is unavailable on the default driver and raises `UnsupportedManipulationError`
|
|
24
|
+
naming the fix; install `sharp` and set `driver: "sharp"` for cropping.
|
|
25
|
+
- Responsive image ladders with `srcset()` and a ThumbHash inline placeholder.
|
|
26
|
+
- Arbitrary per-item metadata via `withCustomProperties()` on the adder and
|
|
27
|
+
`getCustomProperty()` / `setCustomProperty()` / `forgetCustomProperty()` on the
|
|
28
|
+
item, round-tripped through the row's JSON column. Reading without a fallback
|
|
29
|
+
yields `unknown` (narrow it yourself); reading with one yields the fallback's
|
|
30
|
+
type and never `undefined`.
|
|
31
|
+
- Queued conversions through `@zerotal/queue` when it is registered, falling
|
|
32
|
+
back to inline generation when it is not.
|
|
33
|
+
- `media` table provisioned at boot by `mediaSchemaConcern`, so apps write no
|
|
34
|
+
migration.
|
|
35
|
+
- `MediaFake` assertions, `media:clean` and `media:regenerate` commands.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zerotal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# @zerotal/media
|
|
2
|
+
|
|
3
|
+
> Attach files to models — collections, image conversions, responsive images, and ordering.
|
|
4
|
+
|
|
5
|
+
Associate uploads with any model, store them on any disk, and generate derived
|
|
6
|
+
images without installing a native module. Zerotal's answer to
|
|
7
|
+
`spatie/laravel-medialibrary`.
|
|
8
|
+
|
|
9
|
+
Part of the [Zerotal](../../README.md) framework. Requires **Bun ≥ 1.3.14**.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bun add @zerotal/media
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Setup
|
|
18
|
+
|
|
19
|
+
Register the provider in `bootstrap/providers.ts` — alongside `StorageProvider`,
|
|
20
|
+
which media writes through:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { StorageProvider } from "@zerotal/core/storage";
|
|
24
|
+
import { MediaProvider } from "@zerotal/media";
|
|
25
|
+
|
|
26
|
+
export default [DatabaseProvider, StorageProvider, MediaProvider];
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
No migration is needed: the `media` table is provisioned at boot, once, only
|
|
30
|
+
when missing.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
Compose `Media` and declare the collections a model owns:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { Model, column } from "@zerotal/orm";
|
|
38
|
+
import { Media, type MediaCollections } from "@zerotal/media";
|
|
39
|
+
|
|
40
|
+
export class Product extends Model.using(Media) {
|
|
41
|
+
@column() name!: string;
|
|
42
|
+
|
|
43
|
+
static override mediaCollections: MediaCollections = {
|
|
44
|
+
images: {
|
|
45
|
+
accepts: ["image/jpeg", "image/png"],
|
|
46
|
+
conversions: { thumb: { width: 200, height: 200, format: "webp" } },
|
|
47
|
+
responsive: true,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Then add and read files:
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
await product.addMedia(await ctx.file("photo")).toCollection("images");
|
|
57
|
+
|
|
58
|
+
await product.getFirstMediaUrl("images", "thumb");
|
|
59
|
+
await product.getMedia("images");
|
|
60
|
+
await product.clearMediaCollection("images");
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Notable behaviour
|
|
64
|
+
|
|
65
|
+
- **Types come from bytes.** `accepts` is checked against the type sniffed from
|
|
66
|
+
the file's own contents, never the filename or the upload's `Content-Type`.
|
|
67
|
+
- **No native dependency.** Conversions run on `Bun.Image`, built into the
|
|
68
|
+
runtime. JPEG, PNG and WebP work on every host.
|
|
69
|
+
- **`Bun.Image` cannot crop.** `fit: "cover"` throws rather than silently
|
|
70
|
+
returning a stretched image. Install `sharp` and set `driver: "sharp"` if you
|
|
71
|
+
need centre-cropped thumbnails.
|
|
72
|
+
- **Deleting a model deletes its files** — unless it soft-deletes, in which case
|
|
73
|
+
they wait for `forceDelete()`.
|
|
74
|
+
- **Paths are keyed on uuid,** not the numeric id, so a public URL discloses
|
|
75
|
+
nothing about row counts.
|
|
76
|
+
|
|
77
|
+
## Commands
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
bun zt media:clean [--force]
|
|
81
|
+
bun zt media:regenerate [--model=Product] [--only=thumb,hero]
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Documentation
|
|
85
|
+
|
|
86
|
+
Full guide: [Media Library](https://zerotal.dev/docs/media).
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zerotal/media",
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"maturity": "experimental",
|
|
6
|
+
"private": false,
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./src/index.ts",
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.ts",
|
|
12
|
+
"./commands": "./src/commands/index.ts"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"CHANGELOG.md",
|
|
16
|
+
"src",
|
|
17
|
+
"!src/**/*.test.ts",
|
|
18
|
+
"!src/**/*.test.tsx",
|
|
19
|
+
"!src/**/*.spec.ts",
|
|
20
|
+
"!src/**/__fixtures__/**"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"bun": ">=1.3.14"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"test": "bun test",
|
|
30
|
+
"typecheck": "tsc --noEmit"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@zerotal/core": "1.3.0",
|
|
34
|
+
"@zerotal/orm": "1.3.0"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"@zerotal/queue": "^1.0.0",
|
|
38
|
+
"sharp": "^0.33 || ^0.34"
|
|
39
|
+
},
|
|
40
|
+
"peerDependenciesMeta": {
|
|
41
|
+
"@zerotal/queue": {
|
|
42
|
+
"optional": true
|
|
43
|
+
},
|
|
44
|
+
"sharp": {
|
|
45
|
+
"optional": true
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@zerotal/queue": "1.3.0",
|
|
50
|
+
"typescript": "^5.8.0"
|
|
51
|
+
},
|
|
52
|
+
"description": "Attach files to models: media collections, image conversions, responsive images, and ordering — on any Zerotal storage disk.",
|
|
53
|
+
"keywords": [
|
|
54
|
+
"zerotal",
|
|
55
|
+
"bun",
|
|
56
|
+
"typescript",
|
|
57
|
+
"framework",
|
|
58
|
+
"media",
|
|
59
|
+
"uploads",
|
|
60
|
+
"images"
|
|
61
|
+
],
|
|
62
|
+
"repository": {
|
|
63
|
+
"type": "git",
|
|
64
|
+
"url": "git+https://github.com/zerotaldev/zerotal.git",
|
|
65
|
+
"directory": "packages/media"
|
|
66
|
+
},
|
|
67
|
+
"homepage": "https://github.com/zerotaldev/zerotal/tree/main/packages/media#readme",
|
|
68
|
+
"bugs": "https://github.com/zerotaldev/zerotal/issues"
|
|
69
|
+
}
|
package/src/Media.ts
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import type { Constructor, ModelQueryBuilder } from "@zerotal/orm";
|
|
2
|
+
import { MediaItem } from "./MediaItem.ts";
|
|
3
|
+
import { MediaAdder } from "./MediaAdder.ts";
|
|
4
|
+
import { resolveCollection, type CollectionHost } from "./collections/resolve.ts";
|
|
5
|
+
import { mediaState } from "./state.ts";
|
|
6
|
+
import { fromDisk, fromPath, fromUrl, fromValue, type MediaSource } from "./sources.ts";
|
|
7
|
+
import type { MediaCollections, MediaOwner } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* What the mixin needs from whatever it is composed onto.
|
|
11
|
+
*
|
|
12
|
+
* Declaring the shape here — rather than casting `this` at each use — is what
|
|
13
|
+
* keeps this file free of escape-hatch casts. `constructor` is typed as the
|
|
14
|
+
* concrete class so `mediaCollections` and `name` are reachable directly;
|
|
15
|
+
* `softDeletes` is whatever the `SoftDeletes` mixin set, if it is in the chain.
|
|
16
|
+
*/
|
|
17
|
+
interface MediaHost extends MediaOwner {
|
|
18
|
+
id: number | string;
|
|
19
|
+
readonly constructor: CollectionHost & { softDeletes?: boolean };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Helpers live at module scope rather than as private methods: a mixin returns an
|
|
23
|
+
// anonymous class type, and TypeScript refuses to emit declarations for one that
|
|
24
|
+
// carries private members (TS4094). Every ORM mixin has the same shape.
|
|
25
|
+
|
|
26
|
+
/** Every media row this model owns, before any collection filter. */
|
|
27
|
+
function ownedQuery(self: MediaHost): ModelQueryBuilder<MediaItem> {
|
|
28
|
+
return MediaItem.query()
|
|
29
|
+
.where("model_type", self.constructor.name)
|
|
30
|
+
.where("model_id", String(self.id));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Adds media handling to a model.
|
|
35
|
+
*
|
|
36
|
+
* Compose it with `Model.using(...)` and declare the collections the model
|
|
37
|
+
* owns in a static `mediaCollections` field. Zerotal's equivalent of Laravel's
|
|
38
|
+
* `HasMedia` interface plus `InteractsWithMedia` trait, in one piece — named for
|
|
39
|
+
* how it reads at the call site: `Model.using(Media)`.
|
|
40
|
+
*
|
|
41
|
+
* ## Deleting
|
|
42
|
+
*
|
|
43
|
+
* Hard-deleting a model deletes every file attached to it — without that, an
|
|
44
|
+
* upload outlives the only row that knew where it was, and you keep paying for
|
|
45
|
+
* storage nothing can reach. **Soft deletes are left alone**: restoring a model
|
|
46
|
+
* whose images had already been destroyed would be worse than an orphaned file,
|
|
47
|
+
* so a `SoftDeletes` model keeps its media until `forceDelete()`.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* ```ts
|
|
51
|
+
* export class Product extends Model.using(Media) {
|
|
52
|
+
* \@column() name!: string;
|
|
53
|
+
*
|
|
54
|
+
* static override mediaCollections: MediaCollections = {
|
|
55
|
+
* images: {
|
|
56
|
+
* accepts: ["image/jpeg", "image/png"],
|
|
57
|
+
* conversions: { thumb: { width: 200, height: 200 } },
|
|
58
|
+
* },
|
|
59
|
+
* };
|
|
60
|
+
* }
|
|
61
|
+
*
|
|
62
|
+
* const product = await Product.create({ name: "Kettle" });
|
|
63
|
+
* await product.addMedia(await ctx.file("photo")).toCollection("images");
|
|
64
|
+
* await product.getFirstMediaUrl("images", "thumb");
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
export function Media<TBase extends Constructor>(Base: TBase) {
|
|
68
|
+
class MediaModel extends Base {
|
|
69
|
+
/**
|
|
70
|
+
* The collections this model owns. Override in the subclass.
|
|
71
|
+
*
|
|
72
|
+
* A collection must be declared before anything can be added to it: an
|
|
73
|
+
* undeclared name is nearly always a typo, and silently accepting one would
|
|
74
|
+
* hide the mistake until someone noticed an empty gallery.
|
|
75
|
+
*/
|
|
76
|
+
static mediaCollections: MediaCollections = {};
|
|
77
|
+
|
|
78
|
+
/** Primary key, supplied by the model this is composed onto. */
|
|
79
|
+
declare id: number | string;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The concrete subclass, typed so its statics are reachable.
|
|
83
|
+
*
|
|
84
|
+
* `Object.prototype.constructor` is `Function`, which knows nothing of
|
|
85
|
+
* `mediaCollections`. Re-declaring it here is what lets every method below
|
|
86
|
+
* read the model's collections without a cast at each use.
|
|
87
|
+
*/
|
|
88
|
+
declare readonly ["constructor"]: CollectionHost & { softDeletes?: boolean };
|
|
89
|
+
|
|
90
|
+
// ── Adding ───────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Attach a file. Nothing is read or written until `.toCollection(name)` is
|
|
94
|
+
* awaited.
|
|
95
|
+
*
|
|
96
|
+
* @param source - An `UploadedFile`, `File`, `Blob`, `Uint8Array` or `ArrayBuffer`.
|
|
97
|
+
* @param fileName - Overrides the name the source reports.
|
|
98
|
+
*/
|
|
99
|
+
addMedia(source: MediaSource, fileName?: string): MediaAdder {
|
|
100
|
+
return new MediaAdder(this, this.constructor, fromValue(source, fileName));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Attach a file fetched over http(s). */
|
|
104
|
+
addMediaFromUrl(url: string): MediaAdder {
|
|
105
|
+
return new MediaAdder(
|
|
106
|
+
this,
|
|
107
|
+
this.constructor,
|
|
108
|
+
fromUrl(url, mediaState().config.maxConversionInputSize),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Attach a file already stored on one of the app's disks. */
|
|
113
|
+
addMediaFromDisk(path: string, disk?: string): MediaAdder {
|
|
114
|
+
return new MediaAdder(this, this.constructor, fromDisk(path, disk));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Attach a file from the local filesystem. */
|
|
118
|
+
addMediaFromPath(path: string): MediaAdder {
|
|
119
|
+
return new MediaAdder(this, this.constructor, fromPath(path));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Copy an existing media item onto this model.
|
|
124
|
+
*
|
|
125
|
+
* The bytes are re-read and re-stored under a fresh uuid, so the two items
|
|
126
|
+
* are fully independent — deleting either leaves the other's file intact.
|
|
127
|
+
* Name and custom properties carry over; chain the builder's methods to
|
|
128
|
+
* change them.
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* await draft.copyMedia(original).toCollection("images");
|
|
132
|
+
*/
|
|
133
|
+
copyMedia(media: MediaItem): MediaAdder {
|
|
134
|
+
return new MediaAdder(this, this.constructor, async () => ({
|
|
135
|
+
bytes: await media.bytes(),
|
|
136
|
+
originalName: media.fileName,
|
|
137
|
+
}))
|
|
138
|
+
.usingName(media.name)
|
|
139
|
+
.withCustomProperties(media.customProperties ?? {});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Reading ──────────────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
/** Every item in a collection, in order. */
|
|
145
|
+
async getMedia(collection = "default"): Promise<MediaItem[]> {
|
|
146
|
+
return ownedQuery(this)
|
|
147
|
+
.where("collection_name", collection)
|
|
148
|
+
.orderBy("order_column", "asc")
|
|
149
|
+
.orderBy("id", "asc")
|
|
150
|
+
.get();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The first item in a collection, or `null`. */
|
|
154
|
+
async getFirstMedia(collection = "default"): Promise<MediaItem | null> {
|
|
155
|
+
const all = await this.getMedia(collection);
|
|
156
|
+
return all[0] ?? null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* URL of the first item, or the collection's `fallbackUrl` when it is empty.
|
|
161
|
+
*
|
|
162
|
+
* Returns `""` when there is neither, so it can go straight into `src`
|
|
163
|
+
* without a null check.
|
|
164
|
+
*
|
|
165
|
+
* A conversion that has not been generated yet — a queued one, most likely —
|
|
166
|
+
* falls back to the original rather than to nothing, so a gallery is never
|
|
167
|
+
* blank while the queue catches up.
|
|
168
|
+
*/
|
|
169
|
+
async getFirstMediaUrl(collection = "default", conversion?: string): Promise<string> {
|
|
170
|
+
const media = await this.getFirstMedia(collection);
|
|
171
|
+
if (media === null) return resolveCollection(this.constructor, collection).fallbackUrl ?? "";
|
|
172
|
+
|
|
173
|
+
const url = media.getUrl(conversion);
|
|
174
|
+
if (url === "" && conversion !== undefined) return media.getUrl();
|
|
175
|
+
return url;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Path of the first item, or the collection's `fallbackPath`. */
|
|
179
|
+
async getFirstMediaPath(collection = "default", conversion?: string): Promise<string> {
|
|
180
|
+
const media = await this.getFirstMedia(collection);
|
|
181
|
+
if (media === null) return resolveCollection(this.constructor, collection).fallbackPath ?? "";
|
|
182
|
+
return media.getPath(conversion);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Whether a collection holds anything. */
|
|
186
|
+
async hasMedia(collection = "default"): Promise<boolean> {
|
|
187
|
+
const rows = await ownedQuery(this).where("collection_name", collection).limit(1).get();
|
|
188
|
+
return rows.length > 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** How many items a collection holds. */
|
|
192
|
+
async mediaCount(collection = "default"): Promise<number> {
|
|
193
|
+
const rows = await ownedQuery(this).where("collection_name", collection).get();
|
|
194
|
+
return rows.length;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Removing ─────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Delete every item in a collection, files included.
|
|
201
|
+
*
|
|
202
|
+
* @returns How many items were removed.
|
|
203
|
+
*/
|
|
204
|
+
async clearMediaCollection(collection = "default"): Promise<number> {
|
|
205
|
+
const all = await this.getMedia(collection);
|
|
206
|
+
for (const media of all) await media.delete();
|
|
207
|
+
return all.length;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Delete every item this model owns, across every collection. */
|
|
211
|
+
async clearAllMedia(): Promise<number> {
|
|
212
|
+
const all = await ownedQuery(this).get();
|
|
213
|
+
for (const media of all) await media.delete();
|
|
214
|
+
return all.length;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── Ordering ─────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Reorder a collection to match the given media ids.
|
|
221
|
+
*
|
|
222
|
+
* Ids missing from the list keep their relative order *after* the ones
|
|
223
|
+
* present, so handing in only the three items a drag-and-drop UI moved does
|
|
224
|
+
* what it looks like rather than silently discarding the rest.
|
|
225
|
+
*/
|
|
226
|
+
async setMediaOrder(ids: Array<number | string>, collection = "default"): Promise<void> {
|
|
227
|
+
const all = await this.getMedia(collection);
|
|
228
|
+
const wanted = ids.map(String);
|
|
229
|
+
|
|
230
|
+
const ranked = [...all].sort((a, b) => {
|
|
231
|
+
const ai = wanted.indexOf(String(a.id));
|
|
232
|
+
const bi = wanted.indexOf(String(b.id));
|
|
233
|
+
if (ai !== -1 && bi !== -1) return ai - bi;
|
|
234
|
+
if (ai !== -1) return -1;
|
|
235
|
+
if (bi !== -1) return 1;
|
|
236
|
+
return 0;
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
for (const [index, media] of ranked.entries()) {
|
|
240
|
+
if (media.orderColumn === index) continue;
|
|
241
|
+
media.orderColumn = index;
|
|
242
|
+
await media.save();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Lifecycle ────────────────────────────────────────────────────────────
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Delete the model, cascading to its files on a hard delete.
|
|
250
|
+
*
|
|
251
|
+
* A soft-deleting model keeps its media: `restore()` is supposed to give
|
|
252
|
+
* back the model you had, and it cannot do that if the images were destroyed
|
|
253
|
+
* on the way out. Those files go on `forceDelete()` instead.
|
|
254
|
+
*/
|
|
255
|
+
async delete(): Promise<void> {
|
|
256
|
+
if (this.constructor.softDeletes !== true) await this.clearAllMedia();
|
|
257
|
+
|
|
258
|
+
// Reached through the base prototype rather than `super` because the mixin
|
|
259
|
+
// is generic over a bare constructor — the same reason every ORM mixin is.
|
|
260
|
+
const base = Base.prototype as { delete?: () => Promise<void> };
|
|
261
|
+
await base.delete?.call(this);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Permanently delete a soft-deleting model, taking its files with it.
|
|
266
|
+
*
|
|
267
|
+
* A no-op passthrough when `SoftDeletes` is not composed in — there is no
|
|
268
|
+
* `forceDelete` to extend, and `delete()` has already done the cascade.
|
|
269
|
+
*/
|
|
270
|
+
async forceDelete(): Promise<void> {
|
|
271
|
+
await this.clearAllMedia();
|
|
272
|
+
const base = Base.prototype as { forceDelete?: () => Promise<void> };
|
|
273
|
+
await base.forceDelete?.call(this);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return MediaModel;
|
|
278
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { sniffContentType } from "@zerotal/core/http";
|
|
2
|
+
import { MediaItem, pathGenerator } from "./MediaItem.ts";
|
|
3
|
+
import { resolveCollection, type CollectionHost } from "./collections/resolve.ts";
|
|
4
|
+
import { applyRetentionRules } from "./collections/retention.ts";
|
|
5
|
+
import { ConversionRunner, partitionConversions } from "./conversions/ConversionRunner.ts";
|
|
6
|
+
import { dispatchConversions, isQueueAvailable } from "./conversions/dispatch.ts";
|
|
7
|
+
import { DisallowedMimeTypeError, FileTooLargeError, UnsavedOwnerError } from "./errors.ts";
|
|
8
|
+
import { mediaState } from "./state.ts";
|
|
9
|
+
import { defaultDiskName, diskFor, diskNameFor } from "./support/disks.ts";
|
|
10
|
+
import type { SourceResolver } from "./sources.ts";
|
|
11
|
+
import type { CollectionDefinition, MediaOwner, PendingMediaMeta } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The pending file returned by `model.addMedia(...)`, awaiting a collection.
|
|
15
|
+
*
|
|
16
|
+
* Nothing is read, validated or written until {@link toCollection} runs — the
|
|
17
|
+
* builder just records intent, so the collection's own rules decide what is
|
|
18
|
+
* allowed before any bytes are buffered.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* await product
|
|
22
|
+
* .addMedia(await ctx.file("photo"))
|
|
23
|
+
* .usingName("Front view")
|
|
24
|
+
* .withCustomProperties({ alt: "Front view of the kettle" })
|
|
25
|
+
* .toCollection("images");
|
|
26
|
+
*/
|
|
27
|
+
export class MediaAdder {
|
|
28
|
+
#meta: PendingMediaMeta = {};
|
|
29
|
+
#fileName: string | undefined;
|
|
30
|
+
|
|
31
|
+
constructor(
|
|
32
|
+
private readonly owner: MediaOwner,
|
|
33
|
+
private readonly ownerClass: CollectionHost,
|
|
34
|
+
private readonly resolve: SourceResolver,
|
|
35
|
+
) {}
|
|
36
|
+
|
|
37
|
+
/** Set the human-facing label. Defaults to the filename without its extension. */
|
|
38
|
+
usingName(name: string): this {
|
|
39
|
+
this.#meta.name = name;
|
|
40
|
+
return this;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Override the name written to disk.
|
|
45
|
+
*
|
|
46
|
+
* The extension is still taken from the file's own bytes: a name you chose is
|
|
47
|
+
* yours to get right, but the stored `Content-Type` is a security boundary and
|
|
48
|
+
* is never client-derived.
|
|
49
|
+
*/
|
|
50
|
+
usingFileName(fileName: string): this {
|
|
51
|
+
this.#fileName = fileName;
|
|
52
|
+
return this;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Attach arbitrary JSON to the media row. */
|
|
56
|
+
withCustomProperties(properties: Record<string, unknown>): this {
|
|
57
|
+
this.#meta.customProperties = { ...(this.#meta.customProperties ?? {}), ...properties };
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Set an explicit sort position. Defaults to the end of the collection. */
|
|
62
|
+
withOrder(order: number): this {
|
|
63
|
+
this.#meta.order = order;
|
|
64
|
+
return this;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Override the collection's disk for this one file. */
|
|
68
|
+
toDisk(disk: string): this {
|
|
69
|
+
this.#meta.disk = disk;
|
|
70
|
+
return this;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Store the file, create its row, and generate whatever the collection asks for.
|
|
75
|
+
*
|
|
76
|
+
* @param collection - Name of a collection the model declares.
|
|
77
|
+
* @returns The saved {@link MediaItem}, with inline conversions already generated.
|
|
78
|
+
* @throws {UnknownCollectionError} when the model declares no such collection.
|
|
79
|
+
* @throws {DisallowedMimeTypeError} when the sniffed type is not accepted.
|
|
80
|
+
* @throws {FileTooLargeError} when the file exceeds the collection's `maxSize`.
|
|
81
|
+
*/
|
|
82
|
+
async toCollection(collection = "default"): Promise<MediaItem> {
|
|
83
|
+
if (this.owner.id === undefined || this.owner.id === null || this.owner.id === "") {
|
|
84
|
+
throw new UnsavedOwnerError(this.ownerClass.name);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const definition = resolveCollection(this.ownerClass, collection);
|
|
88
|
+
const { bytes, originalName } = await this.resolve();
|
|
89
|
+
|
|
90
|
+
const sniffed = sniffContentType(bytes);
|
|
91
|
+
this.guard(definition, collection, sniffed.contentType, bytes.byteLength);
|
|
92
|
+
|
|
93
|
+
const config = mediaState().config;
|
|
94
|
+
const diskName = diskNameFor(
|
|
95
|
+
this.#meta.disk ?? definition.disk,
|
|
96
|
+
diskNameFor(config.disk, defaultDiskName()),
|
|
97
|
+
);
|
|
98
|
+
const conversionsDiskName = definition.conversionsDisk ?? config.conversionsDisk;
|
|
99
|
+
|
|
100
|
+
const media = new MediaItem();
|
|
101
|
+
media.modelType = this.ownerClass.name;
|
|
102
|
+
media.modelId = String(this.owner.id);
|
|
103
|
+
media.uuid = crypto.randomUUID();
|
|
104
|
+
media.collectionName = collection;
|
|
105
|
+
media.name = this.#meta.name ?? _stripExtension(originalName);
|
|
106
|
+
media.fileName = this.#fileName ?? `original.${sniffed.extension}`;
|
|
107
|
+
media.mimeType = sniffed.contentType;
|
|
108
|
+
media.disk = diskName;
|
|
109
|
+
media.conversionsDisk = conversionsDiskName === "" ? null : conversionsDiskName;
|
|
110
|
+
media.size = bytes.byteLength;
|
|
111
|
+
media.manipulations = {};
|
|
112
|
+
media.customProperties = this.#meta.customProperties ?? {};
|
|
113
|
+
media.generatedConversions = {};
|
|
114
|
+
media.responsiveImages = {};
|
|
115
|
+
media.orderColumn = this.#meta.order ?? (await _nextOrder(this.ownerClass.name, this.owner.id));
|
|
116
|
+
|
|
117
|
+
// The row is saved before the bytes land so the uuid — which the path is
|
|
118
|
+
// built from — is fixed and unique before anything is written under it.
|
|
119
|
+
await media.save();
|
|
120
|
+
|
|
121
|
+
const path = `${pathGenerator().forOriginal(media)}/${media.fileName}`;
|
|
122
|
+
await diskFor(diskName).put(path, bytes, { contentType: sniffed.contentType });
|
|
123
|
+
|
|
124
|
+
await this.generate(media, definition);
|
|
125
|
+
await applyRetentionRules(this.ownerClass.name, this.owner.id, collection, definition, media);
|
|
126
|
+
|
|
127
|
+
return media;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Enforce the collection's admission rules against the sniffed type. */
|
|
131
|
+
private guard(
|
|
132
|
+
definition: CollectionDefinition,
|
|
133
|
+
collection: string,
|
|
134
|
+
contentType: string,
|
|
135
|
+
size: number,
|
|
136
|
+
): void {
|
|
137
|
+
if (definition.accepts !== undefined && !definition.accepts.includes(contentType)) {
|
|
138
|
+
throw new DisallowedMimeTypeError(collection, contentType, definition.accepts);
|
|
139
|
+
}
|
|
140
|
+
if (definition.maxSize !== undefined && size > definition.maxSize) {
|
|
141
|
+
throw new FileTooLargeError(collection, size, definition.maxSize);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Run inline conversions now and hand the rest to the queue. */
|
|
146
|
+
private async generate(media: MediaItem, definition: CollectionDefinition): Promise<void> {
|
|
147
|
+
const state = mediaState();
|
|
148
|
+
const { inline, queued } = partitionConversions(
|
|
149
|
+
definition.conversions,
|
|
150
|
+
isQueueAvailable() && state.config.queueConversions,
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const runner = new ConversionRunner(state.driver, state.config);
|
|
154
|
+
await runner.run(media, inline);
|
|
155
|
+
|
|
156
|
+
if (definition.responsive !== undefined && definition.responsive !== false) {
|
|
157
|
+
const widths = Array.isArray(definition.responsive)
|
|
158
|
+
? definition.responsive
|
|
159
|
+
: state.config.responsiveWidths;
|
|
160
|
+
await runner.runResponsive(media, widths);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (Object.keys(queued).length > 0) {
|
|
164
|
+
await dispatchConversions(media.id as number, Object.keys(queued));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Position one past the current end of the collection. */
|
|
170
|
+
async function _nextOrder(modelType: string, modelId: number | string): Promise<number> {
|
|
171
|
+
const rows = await MediaItem.query()
|
|
172
|
+
.where("model_type", modelType)
|
|
173
|
+
.where("model_id", String(modelId))
|
|
174
|
+
.orderBy("order_column", "desc")
|
|
175
|
+
.limit(1)
|
|
176
|
+
.get();
|
|
177
|
+
|
|
178
|
+
const highest = rows[0]?.orderColumn;
|
|
179
|
+
return typeof highest === "number" ? highest + 1 : 0;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function _stripExtension(fileName: string): string {
|
|
183
|
+
const index = fileName.lastIndexOf(".");
|
|
184
|
+
return index > 0 ? fileName.slice(0, index) : fileName;
|
|
185
|
+
}
|