@adonis-agora/media 0.12.1 → 0.12.2

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,253 @@
1
+ ---
2
+ name: media-setup
3
+ description: >-
4
+ Install and configure @adonis-agora/media in an AdonisJS app. Covers node ace
5
+ configure (media_provider + dashboard_provider registration), config/media.ts via
6
+ defineConfig with stores/processors/disks/uploadSessions factory maps, the
7
+ media singleton from @adonis-agora/media/services/main, disk resolution precedence
8
+ (per-call > collection > top-level disk > Drive default), lazy optional peers
9
+ (@adonisjs/lucid, sharp, @aws-sdk/client-s3, mediabunny), StoreNotConfiguredError,
10
+ DriveNotReadyError, and the embedded console provider. Use when setting up media,
11
+ choosing a MediaStore, wiring disks, or fixing boot errors.
12
+ license: MIT
13
+ metadata:
14
+ type: core
15
+ library: "@adonis-agora/media"
16
+ library_version: "0.12.1"
17
+ framework: adonisjs
18
+ sources:
19
+ - "DavideCarvalho/adonis-media:README.md"
20
+ - "DavideCarvalho/adonis-media:docs/getting-started.mdx"
21
+ - "DavideCarvalho/adonis-media:docs/configuration.mdx"
22
+ - "DavideCarvalho/adonis-media:packages/adonis/src/define_config.ts"
23
+ - "DavideCarvalho/adonis-media:packages/adonis/src/media_manager.ts"
24
+ ---
25
+
26
+ # Setting up @adonis-agora/media
27
+
28
+ `@adonis-agora/media` is a media library for AdonisJS on top of `@adonisjs/drive`: owner
29
+ collections with policy, column attachments, image conversions, content transformers,
30
+ large-file uploads, configurable delivery, and an embedded management console. Storage is
31
+ delegated entirely to Drive — this package never reimplements disk drivers.
32
+
33
+ ## Setup
34
+
35
+ Install Drive (the storage backend) plus the package, then run the configure codemod:
36
+
37
+ ```bash
38
+ node ace add @adonisjs/drive # required — media owns no disk drivers
39
+ npm i @adonis-agora/media
40
+ node ace configure @adonis-agora/media
41
+ ```
42
+
43
+ `configure` registers **both** providers in `adonisrc.ts`, publishes `config/media.ts`
44
+ and `config/media_dashboard.ts`, and publishes two Lucid migrations (one for the optional
45
+ `lucid` media store, one for the upload-session store). Delete the migrations you don't
46
+ need; the in-memory stores need none.
47
+
48
+ ```ts title="config/media.ts"
49
+ import { defineConfig, stores, processors } from '@adonis-agora/media'
50
+
51
+ export default defineConfig({
52
+ // disk: 's3', // omit to use Drive's default disk
53
+ store: 'memory',
54
+ stores: {
55
+ memory: stores.memory(),
56
+ lucid: stores.lucid({ connection: 'pg' }),
57
+ },
58
+ imageProcessor: processors.sharp(),
59
+ collections: [
60
+ { name: 'avatar', single: true, acceptsMimeTypes: ['image/png', 'image/jpeg'] },
61
+ {
62
+ name: 'gallery',
63
+ conversions: [
64
+ { name: 'thumb', width: 200, height: 200 },
65
+ { name: 'og', width: 1200, eager: true },
66
+ ],
67
+ },
68
+ ],
69
+ })
70
+ ```
71
+
72
+ Reach the singleton manager anywhere:
73
+
74
+ ```ts
75
+ import media from '@adonis-agora/media/services/main'
76
+
77
+ const url = await media.library.url(recordId)
78
+ ```
79
+
80
+ Source: `docs/getting-started.mdx`, `docs/configuration.mdx`.
81
+
82
+ ## Core patterns
83
+
84
+ ### Pattern 1 — select drivers by name from lazy factory maps
85
+
86
+ The Agora idiom: drivers live in the core package (`stores`, `processors`, `disks`,
87
+ `uploadSessions`, `transformers`), you select them **by name**, and each heavy optional peer
88
+ (`@adonisjs/lucid`, `sharp`, `@aws-sdk/client-s3`, `mediabunny`) is imported only when its
89
+ driver is selected at boot.
90
+
91
+ ```ts
92
+ import { defineConfig, stores, processors, disks } from '@adonis-agora/media'
93
+
94
+ export default defineConfig({
95
+ store: 'lucid',
96
+ stores: { lucid: stores.lucid({ connection: 'pg' }) },
97
+ imageProcessor: processors.sharp(),
98
+ disks: { s3: disks.s3({ bucket: 'my-bucket', region: 'us-east-1' }) },
99
+ disk: 's3',
100
+ })
101
+ ```
102
+
103
+ Listing a driver you never select costs nothing — the thunk imports nothing until built.
104
+ An empty `defineConfig({})` gives the in-memory store on Drive's default disk, no
105
+ conversions, no upload routes, `auto` delivery, diagnostics on.
106
+
107
+ Source: `docs/configuration.mdx`.
108
+
109
+ ### Pattern 2 — disk precedence
110
+
111
+ Which disk a write targets resolves per-call first, then collection, then top-level, then
112
+ Drive's default:
113
+
114
+ ```ts
115
+ defineConfig({
116
+ disk: 'public-s3', // 3rd choice
117
+ collections: [
118
+ { name: 'invoices', disk: 'private-s3' }, // 2nd choice
119
+ ],
120
+ })
121
+
122
+ // per-call wins over everything:
123
+ await media.library.attach({ ..., disk: 'temp-fs' }) // 1st choice
124
+ ```
125
+
126
+ Where the NAME resolves from has separate precedence: the config's own `disks` map first,
127
+ then `config/drive.ts`. A record stores which disk it lives on, so moving a collection to a
128
+ new disk never strands existing files.
129
+
130
+ Source: `docs/configuration.mdx` (`disk` — where files live).
131
+
132
+ ### Pattern 3 — DI instead of the service import
133
+
134
+ The provider binds one singleton `MediaManager`. Prefer it through the container when you
135
+ want testability:
136
+
137
+ ```ts
138
+ import { MediaManager } from '@adonis-agora/media'
139
+
140
+ export default class PostImagesController {
141
+ constructor(protected media: MediaManager) {}
142
+ }
143
+ // or: const media = await app.container.make(MediaManager)
144
+ ```
145
+
146
+ Accessors: `media.library` (collections), `media.attachments` (column attachments),
147
+ `media.disk(name?)` (raw Drive disk escape hatch), `media.store` (cross-owner reads),
148
+ and the opt-in coordinators `media.uploads` / `media.resumable` / `media.direct`.
149
+
150
+ Source: `docs/getting-started.mdx` (Step 2), `packages/adonis/src/media_manager.ts`.
151
+
152
+ ## Common mistakes
153
+
154
+ ### [CRITICAL] Calling the media singleton at module scope before Drive boots
155
+
156
+ Wrong:
157
+
158
+ ```ts
159
+ // module scope — runs before the app (and Drive) has booted
160
+ import media from '@adonis-agora/media/services/main'
161
+ export const DEFAULT_URL = await media.library.url('seed-image')
162
+ ```
163
+
164
+ Correct:
165
+
166
+ ```ts
167
+ import media from '@adonis-agora/media/services/main'
168
+
169
+ export default class LogoController {
170
+ async show() {
171
+ const url = await media.library.url('seed-image') // inside request lifecycle
172
+ }
173
+ }
174
+ ```
175
+
176
+ Mechanism: a disk resolution reaching `@adonisjs/drive` before its manager has booted throws
177
+ `DriveNotReadyError` (`E_MEDIA_DRIVE_NOT_READY`) — almost always a media call at module scope
178
+ instead of inside a request or `app.booted`.
179
+ Source: `docs/errors.mdx` (Storage and configuration).
180
+
181
+ ### [HIGH] Naming a `store` that has no matching factory entry
182
+
183
+ Wrong:
184
+
185
+ ```ts
186
+ defineConfig({
187
+ store: 'luicd', // typo
188
+ stores: { lucid: stores.lucid() },
189
+ })
190
+ ```
191
+
192
+ Correct:
193
+
194
+ ```ts
195
+ defineConfig({
196
+ store: 'lucid',
197
+ stores: { lucid: stores.lucid() },
198
+ })
199
+ ```
200
+
201
+ Mechanism: the provider throws `StoreNotConfiguredError` at boot — deliberately NOT a silent
202
+ fallback to in-memory, because a typo quietly swapping durable persistence for a process-local
203
+ Map is discovered only after data loss. Only the zero-config path (no `store` named at all)
204
+ resolves to in-memory. Same rule for upload-session stores.
205
+ Source: `docs/configuration.mdx` (How the provider builds it), `docs/errors.mdx`.
206
+
207
+ ### [HIGH] Selecting lucid/sharp without installing the peer or running the migration
208
+
209
+ Wrong:
210
+
211
+ ```sh
212
+ npm i @adonis-agora/media # no @adonisjs/lucid, no sharp, no migration:run
213
+ # config selects store: 'lucid' + conversions → fails at boot / first attach
214
+ ```
215
+
216
+ Correct:
217
+
218
+ ```sh
219
+ node ace add @adonisjs/lucid # or ensure it exists
220
+ npm i sharp
221
+ node ace migration:run # published by configure for the lucid stores
222
+ ```
223
+
224
+ Mechanism: the factory thunks import their peers lazily at boot-time selection; selecting
225
+ `lucid` without the published `media` table fails on the first store write, not at
226
+ `configure` time.
227
+ Source: `docs/getting-started.mdx` (Prerequisites, Callout on migrations).
228
+
229
+ ### [MEDIUM] Importing the dashboard's defineConfig into config/media.ts
230
+
231
+ Wrong:
232
+
233
+ ```ts
234
+ // config/media.ts
235
+ import { defineConfig } from '@adonis-agora/media/dashboard' // wrong file's helper
236
+ export default defineConfig({ collections: [{ name: 'gallery' }] })
237
+ ```
238
+
239
+ Correct:
240
+
241
+ ```ts
242
+ // config/media.ts
243
+ import { defineConfig } from '@adonis-agora/media'
244
+ // config/media_dashboard.ts uses:
245
+ // import { defineConfig } from '@adonis-agora/media/dashboard'
246
+ ```
247
+
248
+ Mechanism: both packages export a `defineConfig`; the wrong one types the wrong file and
249
+ silently drops unknown keys instead of failing.
250
+ Source: README.md (subpath exports table), `docs/dashboard/index.mdx`.
251
+
252
+ See also: `media-library-collections/SKILL.md` — attaching to collections after setup;
253
+ `media-stores-delivery/SKILL.md` — writing custom stores/processors/disks.
@@ -0,0 +1,302 @@
1
+ ---
2
+ name: media-stores-delivery
3
+ description: >-
4
+ Pluggable storage seams and delivery in @adonis-agora/media — the MediaStore SPI
5
+ (stores.memory / stores.lucid with the published migration, custom stores via
6
+ StoreFactory, cross-owner keyset list() with encodeMediaCursor/clampMediaListLimit),
7
+ the ImageProcessor SPI (processors.sharp, custom processors), the structural Disk
8
+ contract (putStream, getVisibility), createDriveBackedResolver (module namespace,
9
+ not default export), the bundled disks.s3() with extended operations
10
+ (copy/move/deleteMany/list/size/stat) and declarative visibility, presignS3Url,
11
+ delivery.mode auto|public|signed|proxy, MediaDeliveryHandler and HlsDeliveryHandler
12
+ route mounting, and the @adonis-agora/media/testing doubles. Use when choosing or
13
+ writing a store, wiring S3, serving media, signing URLs, or testing without disks.
14
+ license: MIT
15
+ metadata:
16
+ type: core
17
+ library: "@adonis-agora/media"
18
+ library_version: "0.12.1"
19
+ framework: adonisjs
20
+ sources:
21
+ - "DavideCarvalho/adonis-media:docs/stores-and-processors.mdx"
22
+ - "DavideCarvalho/adonis-media:docs/storage/s3-disk.mdx"
23
+ - "DavideCarvalho/adonis-media:docs/delivery.mdx"
24
+ - "DavideCarvalho/adonis-media:docs/testing.mdx"
25
+ - "DavideCarvalho/adonis-media:packages/adonis/src/disks/drive.ts"
26
+ ---
27
+
28
+ # Storage seams & delivery
29
+
30
+ The library delegates everything heavy to three pluggable seams — the `MediaStore`
31
+ (metadata persistence), the `ImageProcessor` (conversions), and a structural `Disk`
32
+ contract that real `@adonisjs/drive` disks satisfy directly. `delivery` is the mirror-image
33
+ strategy for **reads**.
34
+
35
+ ## Setup
36
+
37
+ ```ts title="config/media.ts"
38
+ import { defineConfig, stores, processors, disks } from '@adonis-agora/media'
39
+
40
+ export default defineConfig({
41
+ disk: 's3',
42
+ disks: { s3: disks.s3({ bucket: 'my-bucket', region: 'us-east-1' }) },
43
+ store: 'lucid',
44
+ stores: { lucid: stores.lucid({ connection: 'pg', table: 'media' }) },
45
+ imageProcessor: processors.sharp(),
46
+ delivery: { mode: 'auto', signedTtlSeconds: 300 },
47
+ })
48
+ ```
49
+
50
+ ```sh
51
+ node ace migration:run # publishes the media table for stores.lucid()
52
+ npm i @aws-sdk/client-s3 # optional peer, imported only because s3 is selected
53
+ ```
54
+
55
+ Source: `docs/configuration.mdx`, `docs/stores-and-processors.mdx`.
56
+
57
+ ## Core patterns
58
+
59
+ ### Pattern 1 — the cross-owner `MediaStore.list` read
60
+
61
+ `listByOwner` answers "what has this entity got"; `list` answers "everything, paginated,
62
+ newest first" — the read the dashboard's Collections view is built on:
63
+
64
+ ```ts
65
+ import media from '@adonis-agora/media/services/main'
66
+
67
+ const page = await media.store.list({
68
+ collection: 'gallery',
69
+ ownerType: 'Post',
70
+ prefix: 'Post/42/',
71
+ limit: 50, // default 50, clamped to 200
72
+ })
73
+
74
+ page.items // ordered createdAt desc, then id desc (stable keyset under writes)
75
+ page.nextCursor // opaque string, or null on the last page
76
+ ```
77
+
78
+ Ordering is a stable keyset so paging stays consistent while rows are written underneath.
79
+ `encodeMediaCursor` / `decodeMediaCursor` / `clampMediaListLimit` and the
80
+ `DEFAULT_MEDIA_LIST_LIMIT` / `MAX_MEDIA_LIST_LIMIT` constants are exported for implementers.
81
+
82
+ Source: `docs/dashboard/collections.mdx`, `docs/stores-and-processors.mdx`.
83
+
84
+ ### Pattern 2 — wire a custom store or processor
85
+
86
+ Both seams are tiny interfaces; wire them through a factory thunk so any peer loads lazily:
87
+
88
+ ```ts
89
+ import { defineConfig } from '@adonis-agora/media'
90
+ import type { ImageProcessor, MediaStore, StoreFactory } from '@adonis-agora/media'
91
+
92
+ class RedisMediaStore implements MediaStore { /* save/find/listByOwner/list/delete/nextOrder */ }
93
+ class PassthroughProcessor implements ImageProcessor {
94
+ async convert(input: Buffer, preset) {
95
+ return { data: input, format: preset.format ?? 'webp', contentType: 'image/webp' }
96
+ }
97
+ }
98
+
99
+ defineConfig({
100
+ store: 'redis',
101
+ stores: {
102
+ redis: (() => async () => new RedisMediaStore()) as unknown as StoreFactory,
103
+ },
104
+ imageProcessor: new PassthroughProcessor(), // or a factory thunk
105
+ })
106
+ ```
107
+
108
+ A `StoreFactory` receives the booted app as `StoreContext`, so a driver can resolve a peer's
109
+ service when it builds.
110
+
111
+ Source: `docs/stores-and-processors.mdx` (Writing your own store / processor).
112
+
113
+ ### Pattern 3 — the bundled S3 disk and its extended operations
114
+
115
+ `disks.s3()` implements the base `Disk` contract plus `ExtendedDisk` (detected structurally
116
+ with `isExtendedDisk`) and `MultipartUploadDisk` (`isMultipartCapable`):
117
+
118
+ ```ts
119
+ const s3 = media.disk('s3')
120
+
121
+ if (isExtendedDisk(s3)) {
122
+ await s3.copy('a/photo.jpg', 'b/photo.jpg', { toBucket: 'archive' })
123
+ await s3.deleteMany(['a/1.jpg', 'a/2.jpg'])
124
+ await s3.stat('a/photo.jpg') // { size, contentType?, lastModified? }
125
+ const page = await s3.list('uploads/', { delimiter: '/', limit: 100 })
126
+ page.folders // sub-prefixes from CommonPrefixes
127
+ }
128
+ ```
129
+
130
+ Key options: `endpoint` + `forcePathStyle` for MinIO/R2/Spaces, `publicEndpoint` for the
131
+ host presigned URLs are signed against (the browser's endpoint), `publicBaseUrl` for stable
132
+ `getUrl()` output, and `visibility: 'public' | 'private'` (default `private`) — declarative,
133
+ read by `delivery.mode: 'auto'`, never applied as an ACL and never probed.
134
+
135
+ Source: `docs/storage/s3-disk.mdx`.
136
+
137
+ ### Pattern 4 — serve media through a delivery route you own
138
+
139
+ `delivery.mode` (`auto` default) decides what `deliver()` returns: `public`/`signed` yield
140
+ `{ kind: 'redirect', url }`; `proxy` yields `{ kind: 'stream', stream, mimeType, size,
141
+ fileName }`. `auto` asks the disk's `getVisibility` — public ⇒ public, private ⇒ signed,
142
+ unknown ⇒ signed. `MediaDeliveryHandler` is framework-agnostic: YOU mount the route behind
143
+ YOUR auth:
144
+
145
+ ```ts title="start/routes.ts"
146
+ import { MediaDeliveryHandler, MediaManager } from '@adonis-agora/media'
147
+
148
+ const media = await app.container.make(MediaManager)
149
+ const delivery = new MediaDeliveryHandler({ library: media })
150
+
151
+ router.get('/media/:id', async ({ params, request, response, auth }) => {
152
+ await authorizeMediaAccess(auth.user, params.id) // ← your rule, before handle()
153
+
154
+ const result = await delivery.handle({ mediaId: params.id, conversion: request.input('conversion') })
155
+ if (result.kind === 'redirect') return response.redirect(result.url)
156
+
157
+ response.header('content-type', result.mimeType)
158
+ if (result.size !== undefined) response.header('content-length', String(result.size))
159
+ response.header('content-disposition', `inline; filename="${result.fileName}"`)
160
+ return response.stream(result.stream)
161
+ }).use(middleware.auth())
162
+ ```
163
+
164
+ The handler's own `mode` / `signedTtlSeconds` override the configured ones per call. For
165
+ transformer-generated HLS packages use `HlsDeliveryHandler` — playlists are rewritten per
166
+ request, segment files are validated against the persisted artifact list (no caller input
167
+ ever reaches the disk as a path).
168
+
169
+ Source: `docs/delivery.mdx`, `docs/transformers.mdx` (Serving HLS).
170
+
171
+ ## Common mistakes
172
+
173
+ ### [CRITICAL] Passing the drive service default export instead of the module namespace
174
+
175
+ Wrong:
176
+
177
+ ```ts
178
+ import driveService from '@adonisjs/drive/services/main' // captured default
179
+ const resolve = createDriveBackedResolver({ driveService, configuredDisks: {}, defaultDisk: 's3' })
180
+ ```
181
+
182
+ Correct:
183
+
184
+ ```ts
185
+ import * as driveService from '@adonisjs/drive/services/main' // the NAMESPACE
186
+ const resolve = createDriveBackedResolver({ driveService, configuredDisks: {}, defaultDisk: 's3' })
187
+ ```
188
+
189
+ Mechanism: Drive's service module assigns its manager inside an `app.booted()` callback, so
190
+ at import time the default export is still `undefined` — a captured value stays `undefined`
191
+ forever (writes fail with `DriveNotReadyError`), while the namespace's ESM live binding
192
+ reflects the later assignment.
193
+ Source: `docs/stores-and-processors.mdx` (createDriveBackedResolver).
194
+
195
+ ### [CRITICAL] Exposing a delivery route without authorization
196
+
197
+ Wrong:
198
+
199
+ ```ts
200
+ router.get('/media/:id', async ({ params, response }) => {
201
+ const result = await delivery.handle({ mediaId: params.id })
202
+ if (result.kind === 'redirect') return response.redirect(result.url)
203
+ return response.stream(result.stream)
204
+ }) // no middleware — every record is public to anyone who can guess an id
205
+ ```
206
+
207
+ Correct:
208
+
209
+ ```ts
210
+ router.get('/media/:id', async ({ params, request, response, auth }) => {
211
+ await authorizeMediaAccess(auth.user, params.id)
212
+ const result = await delivery.handle({ mediaId: params.id })
213
+ /* ... */
214
+ }).use(middleware.auth())
215
+ ```
216
+
217
+ Mechanism: `MediaDeliveryHandler` resolves a media id to bytes or a URL and performs NO
218
+ authorization — the same split as `TusUploadHandler`. This is also why the provider mounts
219
+ no delivery route of its own: there is no safe default.
220
+ Source: `docs/delivery.mdx` (error callout: The handler performs NO authorization).
221
+
222
+ ### [HIGH] Relying on auto delivery for storage the browser cannot reach
223
+
224
+ Wrong:
225
+
226
+ ```ts
227
+ disks.s3({ bucket: 'media', endpoint: 'http://minio.internal:9000' }) // internal host
228
+ // delivery auto signs URLs against the INTERNAL host → unresolvable in the browser
229
+ ```
230
+
231
+ Correct:
232
+
233
+ ```ts
234
+ disks.s3({
235
+ bucket: 'media',
236
+ endpoint: 'http://minio.internal:9000', // server-side operations
237
+ publicEndpoint: 'https://files.example.com', // what presigned URLs are signed for
238
+ forcePathStyle: true,
239
+ })
240
+ ```
241
+
242
+ Mechanism: `auto` resolves to `signed` (never `proxy`) when visibility is unknown, and
243
+ SigV4 bakes the host into the signature — presigned URLs must be signed for the endpoint
244
+ the BROWSER reaches. Proxying is a deployment decision and must be opted into explicitly.
245
+ Source: `docs/delivery.mdx` (auto never picks proxy), `docs/storage/s3-disk.mdx` (publicEndpoint).
246
+
247
+ ### [MEDIUM] Implementing a custom MediaStore without a working list()
248
+
249
+ Wrong:
250
+
251
+ ```ts
252
+ class MyStore implements MediaStore {
253
+ async list() { return { items: [], nextCursor: null } } // stubbed "for now"
254
+ // save/find/listByOwner/delete/nextOrder implemented
255
+ }
256
+ ```
257
+
258
+ Correct:
259
+
260
+ ```ts
261
+ class MyStore implements MediaStore {
262
+ async list(options?: MediaListOptions): Promise<MediaListPage> {
263
+ // keyset page over { createdAt desc, id desc } with cursor + clamped limit
264
+ }
265
+ }
266
+ ```
267
+
268
+ Mechanism: nothing inside `attach`/`url`/`delete` calls `list()`, so a stubbed
269
+ implementation surfaces only later — the dashboard's Collections view renders empty and
270
+ admin tooling sees nothing. `list` is part of the `MediaStore` interface, not optional.
271
+ Source: `docs/stores-and-processors.mdx` (The MediaStore), `docs/dashboard/collections.mdx`.
272
+
273
+ ### [MEDIUM] Setting s3 visibility public and expecting an ACL to be applied
274
+
275
+ Wrong:
276
+
277
+ ```ts
278
+ disks.s3({ bucket: 'media', visibility: 'public' }) // expecting objects to become readable
279
+ // → 403s from the bucket: no bucket policy grants anonymous reads
280
+ ```
281
+
282
+ Correct:
283
+
284
+ ```ts
285
+ // make the bucket readable via its policy/CDN, THEN declare it:
286
+ disks.s3({ bucket: 'media', visibility: 'public', publicBaseUrl: 'https://cdn.example.com' })
287
+ ```
288
+
289
+ Mechanism: `visibility` is declarative metadata read by `delivery.mode: 'auto'` — the disk
290
+ never applies an ACL and never probes the bucket, so the declaration must match reality the
291
+ infrastructure already enforces.
292
+ Source: `docs/storage/s3-disk.mdx` (visibility option, getVisibility).
293
+
294
+ ## References
295
+
296
+ - Testing doubles for all three seams: use `@adonis-agora/media/testing` —
297
+ `InMemoryMediaStore`, `inMemoryDiskResolver` (with a `DiskVisibility` argument to test
298
+ `auto` delivery's unknown-visibility branch), `FakeImageProcessor`, `InMemoryUploadSessionStore`,
299
+ `FakeTransformer`. See `docs/testing.mdx`.
300
+
301
+ See also: `media-dashboard-console/SKILL.md` — the console browses through these same
302
+ ExtendedDisk operations and `MediaStore.list`.