nuxt-media-library 0.0.57 → 0.0.58

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/README.md CHANGED
@@ -237,9 +237,11 @@ merges the layers, so every widget on the site can use any backend by name.
237
237
  export default defineNuxtConfig({
238
238
  modules: ['nuxt-media-library'],
239
239
  mediaLibrary: {
240
- serviceUploads: {
241
- backends: { videos: { start: '/api/videos/uploads' } },
242
- // refresh defaults to `${start}/:sessionId/grant`
240
+ backends: {
241
+ videos: {
242
+ upload: { start: '/api/videos/uploads' }, // refresh defaults to `${start}/:sessionId/grant`
243
+ claim: '/api/videos/uploads/:sessionId/attach', // py_client on_attach
244
+ },
243
245
  },
244
246
  },
245
247
  })
@@ -257,17 +259,26 @@ export default defineNuxtConfig({
257
259
  ```vue
258
260
  <MLMediaGallery
259
261
  v-model="lesson.mediaIds"
260
- service="videos"
262
+ backend="videos"
261
263
  profile="lesson-video"
262
264
  :service-body="{ context: { lessonId } }"
263
265
  @uploaded="(assetId, role, sessionId) => attach(assetId, sessionId)"
264
266
  />
265
267
  ```
266
268
 
267
- - `service` on `MLMediaGallery`, `MLMediaField` and the file picker (and
268
- `useMLMediaUpload`): a backend name, or `{ start }` for a one-off. Without
269
- it, widgets upload through Nuxt's own route as before; an unknown name
270
- fails that upload with a message naming the missing backend.
269
+ - `backend` on `MLMediaGallery`, `MLMediaField` and the file picker (and
270
+ `useMLMediaUpload`): a name in `mediaLibrary.backends`. `service` is its
271
+ older name and still takes `{ start }` for a one-off; `backend` wins.
272
+ Without either, widgets upload through Nuxt's own route as before; an
273
+ unknown name fails that upload with a message naming the missing backend.
274
+ - Each backend has a `kind`: `media-library` (the default) or `media-kit`.
275
+ This version uploads only to `media-library` backends; a `media-kit` one
276
+ fails the upload with a clear message.
277
+ - With `claim`, the upload queue attaches files whose page was left before
278
+ the widget attached them (`POST { assetId, profile }`; 409 counts as
279
+ attached). The tray shows a refusal with "Add again".
280
+ - `mediaLibrary.serviceUploads.backends` still works: each entry becomes a
281
+ `media-library` backend with that `upload` and no `claim`.
271
282
  - The start request carries the file (`mimeType`, `sizeBytes`, `filename`),
272
283
  `profile` (each role's, in a gallery with roles) and `service-body`. The
273
284
  service's profile decides kind, visibility and processing, so
package/dist/module.d.mts CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as _nuxt_schema from '@nuxt/schema';
2
2
  import { ServiceUploadBackend } from '../dist/runtime/utils/uploader/serviceBackends.js';
3
+ import { MediaBackends } from '../dist/runtime/utils/backends.js';
3
4
  export * from '../dist/runtime/types/resourcePolicy.js';
4
5
  export * from '../dist/runtime/types/uploader.js';
5
6
  export * from '../dist/runtime/types/preview.js';
@@ -43,9 +44,8 @@ interface ModuleOptions {
43
44
  * public API root with `/v1`, or a proxy route); with `proxy` it defaults
44
45
  * to the pass-through route.
45
46
  *
46
- * `backends` names the services that start uploads, so widgets can use
47
- * `service="videos"`. Each service's Nuxt layer registers its own; Nuxt
48
- * merges them across layers. Paths only: each layer serves its routes.
47
+ * `backends`: older form of `mediaLibrary.backends` (start/refresh only).
48
+ * Each entry becomes a `media-library` backend with that `upload`.
49
49
  */
50
50
  serviceUploads?: {
51
51
  proxy?: boolean;
@@ -62,6 +62,16 @@ interface ModuleOptions {
62
62
  * pass-through are still registered.
63
63
  */
64
64
  manageProxy?: boolean;
65
+ /**
66
+ * The backend registry: named places widgets upload to and read from
67
+ * (`<MLMediaGallery backend="videos" />`). Each entry has a `kind`
68
+ * (`media-library`, the default, or `media-kit`) and its routes: `upload`
69
+ * (`{ start, refresh? }`, py_client `create_upload_router`), `claim`
70
+ * (attach a finished upload, `/…/:sessionId/attach`). Each service's Nuxt
71
+ * layer registers its own; Nuxt merges them. Paths only: each layer serves
72
+ * its routes.
73
+ */
74
+ backends?: MediaBackends;
65
75
  }
66
76
  declare const SERVICE_UPLOADS_ROUTE = "/api/media-library/service-uploads";
67
77
  declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
package/dist/module.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as _nuxt_schema from '@nuxt/schema';
2
2
  import { ServiceUploadBackend } from '../dist/runtime/utils/uploader/serviceBackends.js';
3
+ import { MediaBackends } from '../dist/runtime/utils/backends.js';
3
4
  export * from '../dist/runtime/types/resourcePolicy.js';
4
5
  export * from '../dist/runtime/types/uploader.js';
5
6
  export * from '../dist/runtime/types/preview.js';
@@ -43,9 +44,8 @@ interface ModuleOptions {
43
44
  * public API root with `/v1`, or a proxy route); with `proxy` it defaults
44
45
  * to the pass-through route.
45
46
  *
46
- * `backends` names the services that start uploads, so widgets can use
47
- * `service="videos"`. Each service's Nuxt layer registers its own; Nuxt
48
- * merges them across layers. Paths only: each layer serves its routes.
47
+ * `backends`: older form of `mediaLibrary.backends` (start/refresh only).
48
+ * Each entry becomes a `media-library` backend with that `upload`.
49
49
  */
50
50
  serviceUploads?: {
51
51
  proxy?: boolean;
@@ -62,6 +62,16 @@ interface ModuleOptions {
62
62
  * pass-through are still registered.
63
63
  */
64
64
  manageProxy?: boolean;
65
+ /**
66
+ * The backend registry: named places widgets upload to and read from
67
+ * (`<MLMediaGallery backend="videos" />`). Each entry has a `kind`
68
+ * (`media-library`, the default, or `media-kit`) and its routes: `upload`
69
+ * (`{ start, refresh? }`, py_client `create_upload_router`), `claim`
70
+ * (attach a finished upload, `/…/:sessionId/attach`). Each service's Nuxt
71
+ * layer registers its own; Nuxt merges them. Paths only: each layer serves
72
+ * its routes.
73
+ */
74
+ backends?: MediaBackends;
65
75
  }
66
76
  declare const SERVICE_UPLOADS_ROUTE = "/api/media-library/service-uploads";
67
77
  declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "media-library",
3
3
  "configKey": "mediaLibrary",
4
- "version": "0.0.57",
4
+ "version": "0.0.58",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readdirSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { defineNuxtModule, createResolver, addImportsDir, addComponentsDir, addServerHandler, useLogger, addPlugin } from '@nuxt/kit';
4
+ import { normalizeBackends } from '../dist/runtime/utils/backends.js';
4
5
  export * from '../dist/runtime/types/resourcePolicy.js';
5
6
  export * from '../dist/runtime/types/uploader.js';
6
7
  export * from '../dist/runtime/types/preview.js';
@@ -22,23 +23,6 @@ function browserBaseURL(existingPublic, publicBaseURL) {
22
23
  }
23
24
 
24
25
  const SERVICE_UPLOADS_ROUTE = "/api/media-library/service-uploads";
25
- function serviceBackends(backends, logger) {
26
- const out = {};
27
- for (const [name, backend] of Object.entries(backends ?? {})) {
28
- if (!backend || typeof backend.start !== "string" || !backend.start) {
29
- logger.warn(`mediaLibrary.serviceUploads.backends.${name} has no start path; ignored.`);
30
- continue;
31
- }
32
- if (backend.refresh && !backend.refresh.includes(":sessionId")) {
33
- logger.warn(`mediaLibrary.serviceUploads.backends.${name}.refresh needs a :sessionId placeholder; using the default.`);
34
- }
35
- out[name] = {
36
- start: backend.start,
37
- ...backend.refresh?.includes(":sessionId") ? { refresh: backend.refresh } : {}
38
- };
39
- }
40
- return out;
41
- }
42
26
  function appCatchAlls(layers) {
43
27
  const found = [];
44
28
  for (const { config } of layers) {
@@ -171,6 +155,10 @@ const module$1 = defineNuxtModule({
171
155
  if (existingPublic.proxyUploads === false && !existingPublic.baseURL && !options.publicBaseURL) {
172
156
  logger.warn("proxyUploads is false but no public media library address is set: set mediaLibrary.publicBaseURL. baseURL is server-side only.");
173
157
  }
158
+ const backends = {
159
+ ...normalizeBackends(options.backends, options.serviceUploads?.backends, (m) => logger.warn(m)),
160
+ ...normalizeBackends(existingPublic.backends, existingPublic.serviceUploadBackends, (m) => logger.warn(m))
161
+ };
174
162
  nuxt.options.runtimeConfig.public.mediaLibrary = {
175
163
  ...existingPublic,
176
164
  baseURL: browserBaseURL(existingPublic.baseURL, options.publicBaseURL),
@@ -186,8 +174,12 @@ const module$1 = defineNuxtModule({
186
174
  headers: publicHeaders.headers,
187
175
  // Where useMLServiceUpload sends parts (NUXT_PUBLIC_MEDIA_LIBRARY_SERVICE_UPLOAD_BASE_URL).
188
176
  serviceUploadBaseURL: existingPublic.serviceUploadBaseURL || options.serviceUploads?.baseURL || (options.serviceUploads?.proxy ? SERVICE_UPLOADS_ROUTE : ""),
189
- // Names widgets can pass as `service` (NUXT_PUBLIC_MEDIA_LIBRARY_SERVICE_UPLOAD_BACKENDS).
190
- serviceUploadBackends: { ...serviceBackends(options.serviceUploads?.backends, logger), ...existingPublic.serviceUploadBackends }
177
+ // The backend registry (NUXT_PUBLIC_MEDIA_LIBRARY_BACKENDS): names widgets pass as `backend`.
178
+ backends,
179
+ // Older form, kept for code that reads it: each backend's upload routes.
180
+ serviceUploadBackends: Object.fromEntries(
181
+ Object.entries(backends).flatMap(([name, b]) => b.upload ? [[name, b.upload]] : [])
182
+ )
191
183
  };
192
184
  addPlugin({
193
185
  src: resolver.resolve("./runtime/plugins/config")
@@ -18,10 +18,12 @@ type __VLS_Props = {
18
18
  /** Upload profile name for new files. */
19
19
  profile?: string | null;
20
20
  /**
21
- * Start uploads at a backend service instead of Nuxt's own route: a name
22
- * registered under `mediaLibrary.serviceUploads.backends` (e.g. "videos"),
23
- * or `{ start }`. See useMLMediaUpload.
21
+ * The backend registered under `mediaLibrary.backends` that uploads go to
22
+ * (e.g. "videos"). Its `claim` route attaches files whose page was left
23
+ * before they finished. Wins over `service`.
24
24
  */
25
+ backend?: string | null;
26
+ /** Older name for `backend`; also takes an inline `{ start }`. See useMLMediaUpload. */
25
27
  service?: MediaUploadOptions['service'];
26
28
  /** Extra fields for the service's start request, e.g. `{ context: { lessonId } }`. */
27
29
  serviceBody?: Record<string, unknown> | null;
@@ -65,6 +67,7 @@ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {},
65
67
  };
66
68
  collectionName: string | null;
67
69
  profile: string | null;
70
+ backend: string | null;
68
71
  service: string | import("../../index.js").MediaServiceUpload | null;
69
72
  serviceBody: Record<string, unknown> | null;
70
73
  hint: string;
@@ -16,6 +16,7 @@ const props = defineProps({
16
16
  processingConfig: { type: Object, required: false, default: void 0 },
17
17
  collectionName: { type: [String, null], required: false, default: null },
18
18
  profile: { type: [String, null], required: false, default: null },
19
+ backend: { type: [String, null], required: false, default: null },
19
20
  service: { type: null, required: false, default: void 0 },
20
21
  serviceBody: { type: [Object, null], required: false, default: null },
21
22
  picker: { type: Boolean, required: false, default: true },
@@ -34,6 +35,7 @@ const field = useMLMediaField(model, {
34
35
  processingConfig: props.processingConfig,
35
36
  collectionName: props.collectionName,
36
37
  profile: props.profile,
38
+ backend: props.backend,
37
39
  service: props.service,
38
40
  serviceBody: props.serviceBody,
39
41
  label: props.label || void 0,
@@ -77,6 +79,7 @@ const uploadOptions = computed(() => ({
77
79
  processingConfig: props.processingConfig,
78
80
  collectionName: props.collectionName,
79
81
  profile: props.profile,
82
+ backend: props.backend,
80
83
  service: props.service,
81
84
  serviceBody: props.serviceBody
82
85
  }));
@@ -18,10 +18,12 @@ type __VLS_Props = {
18
18
  /** Upload profile name for new files. */
19
19
  profile?: string | null;
20
20
  /**
21
- * Start uploads at a backend service instead of Nuxt's own route: a name
22
- * registered under `mediaLibrary.serviceUploads.backends` (e.g. "videos"),
23
- * or `{ start }`. See useMLMediaUpload.
21
+ * The backend registered under `mediaLibrary.backends` that uploads go to
22
+ * (e.g. "videos"). Its `claim` route attaches files whose page was left
23
+ * before they finished. Wins over `service`.
24
24
  */
25
+ backend?: string | null;
26
+ /** Older name for `backend`; also takes an inline `{ start }`. See useMLMediaUpload. */
25
27
  service?: MediaUploadOptions['service'];
26
28
  /** Extra fields for the service's start request, e.g. `{ context: { lessonId } }`. */
27
29
  serviceBody?: Record<string, unknown> | null;
@@ -65,6 +67,7 @@ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {},
65
67
  };
66
68
  collectionName: string | null;
67
69
  profile: string | null;
70
+ backend: string | null;
68
71
  service: string | import("../../index.js").MediaServiceUpload | null;
69
72
  serviceBody: Record<string, unknown> | null;
70
73
  hint: string;
@@ -35,10 +35,12 @@ type __VLS_Props = {
35
35
  /** Upload profile for the single list (with `profiles`, each role has its own). */
36
36
  profile?: string | null;
37
37
  /**
38
- * Start uploads at a backend service instead of Nuxt's own route: a name
39
- * registered under `mediaLibrary.serviceUploads.backends` (e.g. "videos"),
40
- * or `{ start }`. Each role's profile is sent with the start request.
38
+ * The backend registered under `mediaLibrary.backends` that uploads go to
39
+ * (e.g. "videos"). Its `claim` route attaches files whose page was left
40
+ * before they finished. Each role's profile is sent with the start request. Wins over `service`.
41
41
  */
42
+ backend?: string | null;
43
+ /** Older name for `backend`; also takes an inline `{ start }`. */
42
44
  service?: MediaUploadOptions['service'];
43
45
  /** Extra fields for the service's start request, e.g. `{ context: { lessonId } }`. */
44
46
  serviceBody?: Record<string, unknown> | null;
@@ -76,6 +78,7 @@ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {},
76
78
  };
77
79
  collectionName: string | null;
78
80
  profile: string | null;
81
+ backend: string | null;
79
82
  service: string | import("../../index.js").MediaServiceUpload | null;
80
83
  serviceBody: Record<string, unknown> | null;
81
84
  hint: string;
@@ -19,6 +19,7 @@ const props = defineProps({
19
19
  processingConfig: { type: Object, required: false, default: void 0 },
20
20
  collectionName: { type: [String, null], required: false, default: null },
21
21
  profile: { type: [String, null], required: false, default: null },
22
+ backend: { type: [String, null], required: false, default: null },
22
23
  service: { type: null, required: false, default: void 0 },
23
24
  serviceBody: { type: [Object, null], required: false, default: null },
24
25
  picker: { type: Boolean, required: false, default: true },
@@ -275,6 +276,7 @@ function chipCount(key, max) {
275
276
  :profile="defaultRole?.profile ?? null"
276
277
  :profile-for="profileFor"
277
278
  :on-route-files="routeFiles"
279
+ :backend="backend"
278
280
  :service="service"
279
281
  :service-body="serviceBody"
280
282
  :disabled="disabled"
@@ -353,6 +355,7 @@ function chipCount(key, max) {
353
355
  :processing-config="processingConfig"
354
356
  :collection-name="collectionName"
355
357
  :profile="profile"
358
+ :backend="backend"
356
359
  :service="service"
357
360
  :service-body="serviceBody"
358
361
  :picker="picker"
@@ -35,10 +35,12 @@ type __VLS_Props = {
35
35
  /** Upload profile for the single list (with `profiles`, each role has its own). */
36
36
  profile?: string | null;
37
37
  /**
38
- * Start uploads at a backend service instead of Nuxt's own route: a name
39
- * registered under `mediaLibrary.serviceUploads.backends` (e.g. "videos"),
40
- * or `{ start }`. Each role's profile is sent with the start request.
38
+ * The backend registered under `mediaLibrary.backends` that uploads go to
39
+ * (e.g. "videos"). Its `claim` route attaches files whose page was left
40
+ * before they finished. Each role's profile is sent with the start request. Wins over `service`.
41
41
  */
42
+ backend?: string | null;
43
+ /** Older name for `backend`; also takes an inline `{ start }`. */
42
44
  service?: MediaUploadOptions['service'];
43
45
  /** Extra fields for the service's start request, e.g. `{ context: { lessonId } }`. */
44
46
  serviceBody?: Record<string, unknown> | null;
@@ -76,6 +78,7 @@ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {},
76
78
  };
77
79
  collectionName: string | null;
78
80
  profile: string | null;
81
+ backend: string | null;
79
82
  service: string | import("../../index.js").MediaServiceUpload | null;
80
83
  serviceBody: Record<string, unknown> | null;
81
84
  hint: string;
@@ -26,7 +26,9 @@ type __VLS_Props = {
26
26
  onOpen?: (assetId: string) => void;
27
27
  /** Upload profile name for new files in this list. */
28
28
  profile?: string | null;
29
- /** Start uploads at a backend service (name or `{ start }`). See useMLMediaUpload. */
29
+ /** Registered backend uploads go to (`mediaLibrary.backends`). Wins over `service`. */
30
+ backend?: string | null;
31
+ /** Older name for `backend`; also takes an inline `{ start }`. See useMLMediaUpload. */
30
32
  service?: MediaUploadOptions['service'];
31
33
  /** Extra fields for the service's start request, e.g. `{ context: { lessonId } }`. */
32
34
  serviceBody?: Record<string, unknown> | null;
@@ -89,6 +91,7 @@ declare const __VLS_base: import("vue").DefineComponent<__VLS_PublicProps, {
89
91
  };
90
92
  collectionName: string | null;
91
93
  profile: string | null;
94
+ backend: string | null;
92
95
  service: string | import("../../index.js").MediaServiceUpload | null;
93
96
  serviceBody: Record<string, unknown> | null;
94
97
  profileFor: (file: File) => string | null | undefined;
@@ -24,6 +24,7 @@ const props = defineProps({
24
24
  editor: { type: Boolean, required: false, default: true },
25
25
  onOpen: { type: Function, required: false, default: void 0 },
26
26
  profile: { type: [String, null], required: false, default: null },
27
+ backend: { type: [String, null], required: false, default: null },
27
28
  service: { type: null, required: false, default: void 0 },
28
29
  serviceBody: { type: [Object, null], required: false, default: null },
29
30
  removeLabel: { type: String, required: false, default: void 0 },
@@ -45,6 +46,7 @@ const gallery = useMLMediaGallery(model, {
45
46
  processingConfig: props.processingConfig,
46
47
  collectionName: props.collectionName,
47
48
  profile: props.profile,
49
+ backend: props.backend,
48
50
  service: props.service,
49
51
  serviceBody: props.serviceBody,
50
52
  label: props.recordName ? `${props.title} of ${props.recordName}` : props.title,
@@ -130,6 +132,7 @@ async function pickExisting() {
130
132
  collectionName: props.collectionName,
131
133
  profile: props.profile,
132
134
  profileFor: props.profileFor,
135
+ backend: props.backend,
133
136
  service: props.service,
134
137
  serviceBody: props.serviceBody
135
138
  },
@@ -26,7 +26,9 @@ type __VLS_Props = {
26
26
  onOpen?: (assetId: string) => void;
27
27
  /** Upload profile name for new files in this list. */
28
28
  profile?: string | null;
29
- /** Start uploads at a backend service (name or `{ start }`). See useMLMediaUpload. */
29
+ /** Registered backend uploads go to (`mediaLibrary.backends`). Wins over `service`. */
30
+ backend?: string | null;
31
+ /** Older name for `backend`; also takes an inline `{ start }`. See useMLMediaUpload. */
30
32
  service?: MediaUploadOptions['service'];
31
33
  /** Extra fields for the service's start request, e.g. `{ context: { lessonId } }`. */
32
34
  serviceBody?: Record<string, unknown> | null;
@@ -89,6 +91,7 @@ declare const __VLS_base: import("vue").DefineComponent<__VLS_PublicProps, {
89
91
  };
90
92
  collectionName: string | null;
91
93
  profile: string | null;
94
+ backend: string | null;
92
95
  service: string | import("../../index.js").MediaServiceUpload | null;
93
96
  serviceBody: Record<string, unknown> | null;
94
97
  profileFor: (file: File) => string | null | undefined;
@@ -42,12 +42,12 @@ declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {},
42
42
  progress: number | null;
43
43
  name: string;
44
44
  kind: string | null;
45
+ duration: number | null;
45
46
  disabled: boolean;
46
47
  selected: boolean;
47
48
  phase: ProgressVeilPhase | null;
48
49
  missingAlt: boolean;
49
50
  onOpen: () => void;
50
- duration: number | null;
51
51
  selectable: boolean;
52
52
  selecting: boolean;
53
53
  phaseDetail: string;
@@ -42,12 +42,12 @@ declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {},
42
42
  progress: number | null;
43
43
  name: string;
44
44
  kind: string | null;
45
+ duration: number | null;
45
46
  disabled: boolean;
46
47
  selected: boolean;
47
48
  phase: ProgressVeilPhase | null;
48
49
  missingAlt: boolean;
49
50
  onOpen: () => void;
50
- duration: number | null;
51
51
  selectable: boolean;
52
52
  selecting: boolean;
53
53
  phaseDetail: string;
@@ -56,6 +56,7 @@ const subtitle = computed(() => {
56
56
  return ["Processing", at, x.step && stepLabel(x.step), elapsed.value].filter(Boolean).join(" \xB7 ");
57
57
  }
58
58
  case "ready":
59
+ if (x.orphaned && x.claimError) return `Not added${where.value ? ` to ${where.value}` : ""}: ${x.claimError}`;
59
60
  if (x.orphaned) return `Uploaded, not added${where.value ? ` to ${where.value}` : ""}`;
60
61
  return ["Ready to use", where.value].filter(Boolean).join(" \xB7 ");
61
62
  case "failed":
@@ -191,6 +192,15 @@ async function retryStep() {
191
192
  :loading="retrying"
192
193
  @click="retryStep"
193
194
  />
195
+ <UButton
196
+ v-else-if="u.orphaned && u.claimError && u.assetId"
197
+ icon="i-lucide-rotate-ccw"
198
+ color="neutral"
199
+ variant="outline"
200
+ size="xs"
201
+ label="Add again"
202
+ @click="queue.claim(u)"
203
+ />
194
204
  <UButton
195
205
  v-if="canShow"
196
206
  :to="u.context.to"
@@ -1,6 +1,7 @@
1
1
  import type { ChunkUploaderConfig } from '../types/uploader.js';
2
2
  import type { ServiceUploadChoice } from '../utils/uploader/serviceBackends.js';
3
3
  export type { MediaServiceUpload, ServiceUploadBackend, ServiceUploadChoice } from '../utils/uploader/serviceBackends.js';
4
+ export type { MediaBackend, MediaBackendKind, MediaBackends } from '../utils/backends.js';
4
5
  export type MediaUploadPhase = 'uploading' | 'processing' | 'ready' | 'failed' | 'canceled';
5
6
  /** What a policy-less upload session takes. See docs/resource-policy/retirement.md. */
6
7
  export interface MediaUploadOptions {
@@ -23,10 +24,18 @@ export interface MediaUploadOptions {
23
24
  /** Overrides the uploader config the module provides. */
24
25
  chunkUploader?: ChunkUploaderConfig;
25
26
  /**
27
+ * The backend registered under `mediaLibrary.backends` that uploads go to
28
+ * (e.g. `'videos'`). Its `upload.start` starts them (py_client
29
+ * `create_upload_router`); without one they go through Nuxt's own route.
30
+ * Its `claim` route attaches files whose widget went away first.
31
+ * Wins over `service`.
32
+ */
33
+ backend?: string | null;
34
+ /**
35
+ * Older name for `backend`, which also takes an inline `{ start, … }`.
26
36
  * Start uploads at a backend service (py_client `create_upload_router`)
27
- * instead of Nuxt's own upload route: the name of a backend registered
28
- * under `mediaLibrary.serviceUploads.backends` (e.g. `'videos'`), or
29
- * `{ start, … }`. The service creates the session and its profile decides
37
+ * instead of Nuxt's own upload route: the name of a registered backend
38
+ * (e.g. `'videos'`), or `{ start, … }`. The service creates the session and its profile decides
30
39
  * kind, visibility and processing, so those options are not used here;
31
40
  * `profile` is sent as the start request's `profile`. The file's parts go
32
41
  * to `mediaLibrary.serviceUploads` (pass-through route or public URL).
@@ -69,11 +78,12 @@ export interface MediaUpload {
69
78
  readonly created: Promise<string | null>;
70
79
  /** Bytes. Kept for uploads restored after a reload, whose file is gone. */
71
80
  size: number;
81
+ /** The registered backend it went to (`mediaLibrary.backends`), or null. */
82
+ backend: string | null;
72
83
  /**
73
- * Where the upload started: the backend name under
74
- * `mediaLibrary.serviceUploads.backends` (e.g. `'videos'`), the start URL
75
- * for an inline `{ start }`, or null for Nuxt's own route. With `sessionId`
76
- * and `assetId`, enough to attach the file at that service.
84
+ * Where the upload started: the backend name (e.g. `'videos'`), the start
85
+ * URL for an inline `{ start }`, or null for Nuxt's own route. With
86
+ * `sessionId` and `assetId`, enough to attach the file at that service.
77
87
  */
78
88
  service: string | null;
79
89
  /** Upload profile sent with the session (after `profileFor`), or null. */
@@ -81,8 +91,14 @@ export interface MediaUpload {
81
91
  context: MediaUploadContext | null;
82
92
  /** The widget added the asset to its record. */
83
93
  attached: boolean;
84
- /** The widget went away (page change) before it could add the asset. */
94
+ /**
95
+ * The widget went away (page change) before it could add the asset. With
96
+ * a backend `claim` route the queue attaches it there; otherwise the app
97
+ * does (onCreated).
98
+ */
85
99
  orphaned: boolean;
100
+ /** Why attaching an orphaned upload at its backend failed, or null. */
101
+ claimError: string | null;
86
102
  /** Current processing step (server name), and its place in the pipeline. */
87
103
  step: string | null;
88
104
  stepIndex: number | null;
@@ -150,6 +166,7 @@ export declare function useMLMediaUpload(defaults?: MediaUploadOptions): {
150
166
  readonly [Symbol.toStringTag]: string;
151
167
  };
152
168
  size: number;
169
+ backend: string | null;
153
170
  service: string | null;
154
171
  profile: string | null;
155
172
  context: {
@@ -163,6 +180,7 @@ export declare function useMLMediaUpload(defaults?: MediaUploadOptions): {
163
180
  } | null;
164
181
  attached: boolean;
165
182
  orphaned: boolean;
183
+ claimError: string | null;
166
184
  step: string | null;
167
185
  stepIndex: number | null;
168
186
  stepTotal: number | null;
@@ -217,6 +235,7 @@ export declare function useMLMediaUpload(defaults?: MediaUploadOptions): {
217
235
  readonly [Symbol.toStringTag]: string;
218
236
  };
219
237
  size: number;
238
+ backend: string | null;
220
239
  service: string | null;
221
240
  profile: string | null;
222
241
  context: {
@@ -230,6 +249,7 @@ export declare function useMLMediaUpload(defaults?: MediaUploadOptions): {
230
249
  } | null;
231
250
  attached: boolean;
232
251
  orphaned: boolean;
252
+ claimError: string | null;
233
253
  step: string | null;
234
254
  stepIndex: number | null;
235
255
  stepTotal: number | null;
@@ -1,5 +1,6 @@
1
1
  import { getCurrentScope, onScopeDispose, ref } from "vue";
2
2
  import { useRoute, useRuntimeConfig } from "#imports";
3
+ import { legacyBackends } from "../utils/backends.js";
3
4
  import { useMLChunkUploader } from "./useMLChunkUploader.js";
4
5
  import { useMLUploadQueue } from "./useMLUploadQueue.js";
5
6
  let owners = 0;
@@ -8,7 +9,7 @@ export function useMLMediaUpload(defaults = {}) {
8
9
  const publicConfig = useRuntimeConfig().public?.mediaLibrary;
9
10
  const service = {
10
11
  baseURL: publicConfig?.serviceUploadBaseURL,
11
- backends: publicConfig?.serviceUploadBackends
12
+ backends: publicConfig?.backends ?? legacyBackends(publicConfig?.serviceUploadBackends)
12
13
  };
13
14
  const queue = useMLUploadQueue();
14
15
  const route = useRoute();
@@ -1,14 +1,14 @@
1
1
  import { type Ref } from 'vue';
2
2
  import type { ChunkUploaderConfig } from '../types/uploader.js';
3
- import { type ServiceUploadBackend } from '../utils/uploader/serviceBackends.js';
3
+ import { type MediaBackends } from '../utils/backends.js';
4
4
  import type { MediaUpload, MediaUploadContext, MediaUploadOptions } from './useMLMediaUpload.js';
5
5
  interface StartMeta {
6
6
  owner: string;
7
7
  config: ChunkUploaderConfig;
8
- /** Public service-upload config (mediaLibrary.serviceUploads), read in setup by the caller. */
8
+ /** Public upload config, read in setup by the caller: where service-upload parts go, and the backend registry. */
9
9
  service?: {
10
10
  baseURL?: string;
11
- backends?: Record<string, ServiceUploadBackend>;
11
+ backends?: MediaBackends;
12
12
  };
13
13
  context?: MediaUploadContext | null;
14
14
  }
@@ -25,6 +25,8 @@ export declare function useMLUploadQueue(): {
25
25
  cancelUploads: () => void;
26
26
  retryStep: (upload: MediaUpload) => Promise<void>;
27
27
  onCreated: (fn: (upload: MediaUpload) => void) => () => boolean;
28
+ /** Try attaching an orphaned upload at its backend's claim route again. */
29
+ claim: (upload: MediaUpload) => Promise<void>;
28
30
  /** Let the next unload through (the user confirmed it). */
29
31
  allowUnload: () => void;
30
32
  };
@@ -2,7 +2,7 @@ import { computed, reactive, ref, watch } from "vue";
2
2
  import { $fetch } from "ofetch";
3
3
  import { ChunkUploader } from "../utils/uploader/uploader.js";
4
4
  import { createServiceUpload } from "../utils/uploader/serviceUpload.js";
5
- import { resolveServiceUpload } from "../utils/uploader/serviceBackends.js";
5
+ import { resolveUploadTarget } from "../utils/backends.js";
6
6
  import { kindForFile, mimeForFile } from "../utils/fileAccept.js";
7
7
  import { stepLabel, usableAfter } from "../utils/processingSteps.js";
8
8
  import { useMLProcessingStatus } from "./useMLProcessingStatus.js";
@@ -14,6 +14,8 @@ function createQueue() {
14
14
  const owners = /* @__PURE__ */ new Map();
15
15
  const cleanups = /* @__PURE__ */ new Map();
16
16
  const createdHandlers = /* @__PURE__ */ new Set();
17
+ const claimUrls = /* @__PURE__ */ new Map();
18
+ const claiming = /* @__PURE__ */ new Set();
17
19
  const trays = ref(0);
18
20
  let allowUnload = false;
19
21
  const processing = import.meta.client ? useMLProcessingStatus() : null;
@@ -26,6 +28,7 @@ function createQueue() {
26
28
  file,
27
29
  kind,
28
30
  size: file.size,
31
+ backend: null,
29
32
  service: null,
30
33
  profile: null,
31
34
  localPreview,
@@ -37,6 +40,7 @@ function createQueue() {
37
40
  context,
38
41
  attached: false,
39
42
  orphaned: false,
43
+ claimError: null,
40
44
  step: null,
41
45
  stepIndex: null,
42
46
  stepTotal: null,
@@ -170,10 +174,18 @@ function createQueue() {
170
174
  const localPreview = isVisual && typeof URL !== "undefined" ? URL.createObjectURL(file) : null;
171
175
  const { upload, resolveCreated } = create(file, kind, meta.context ?? null, localPreview);
172
176
  upload.profile = opts.profile ?? null;
173
- upload.service = typeof opts.service === "string" ? opts.service : opts.service?.start ?? null;
177
+ upload.service = opts.backend || (typeof opts.service === "string" ? opts.service : opts.service?.start ?? null);
174
178
  const cfg = meta.config.proxyUploads ? { ...meta.config, baseURL: "" } : meta.config;
175
179
  let uploader = null;
176
180
  let serviceRun = null;
181
+ let target = null;
182
+ let targetError = null;
183
+ try {
184
+ target = resolveUploadTarget(opts.backend, opts.service, meta.service?.backends, opts.serviceBody);
185
+ } catch (err) {
186
+ targetError = err;
187
+ }
188
+ upload.backend = target?.backend ?? null;
177
189
  const onProgress = (p) => {
178
190
  upload.progress = p.percent;
179
191
  };
@@ -210,9 +222,11 @@ function createQueue() {
210
222
  upload.error = null;
211
223
  upload.sessionId = null;
212
224
  try {
213
- const service = resolveServiceUpload(opts.service, meta.service?.backends, opts.serviceBody);
225
+ if (targetError) throw targetError;
226
+ const service = target?.service ?? null;
214
227
  const { assetId, sessionId } = service ? await uploadViaService(service) : await uploadViaNuxt();
215
228
  upload.sessionId = sessionId;
229
+ claimUrls.set(upload.key, sessionId && target?.claimUrl ? target.claimUrl(sessionId) : null);
216
230
  if (!assetId) {
217
231
  upload.phase = "failed";
218
232
  upload.error = "The upload finished but no file was created";
@@ -222,6 +236,7 @@ function createQueue() {
222
236
  upload.assetId = assetId;
223
237
  resolveCreated(assetId);
224
238
  for (const fn of createdHandlers) fn(upload);
239
+ if (upload.orphaned) void claim(upload);
225
240
  followProcessing(upload, assetId);
226
241
  } catch (err) {
227
242
  if (upload.phase === "canceled") return;
@@ -255,9 +270,33 @@ function createQueue() {
255
270
  owners.get(upload.key)?.delete(owner);
256
271
  if (unmounted && !upload.attached && (upload.phase === "uploading" || upload.phase === "processing")) {
257
272
  upload.orphaned = true;
273
+ if (upload.assetId) void claim(upload);
274
+ persist();
258
275
  }
259
276
  pruneOrphan(upload);
260
277
  }
278
+ async function claim(upload) {
279
+ const url = claimUrls.get(upload.key);
280
+ if (!url || upload.attached || claiming.has(upload.key) || !upload.assetId) return;
281
+ claiming.add(upload.key);
282
+ try {
283
+ await $fetch(url, { method: "POST", body: { assetId: upload.assetId, profile: upload.profile } });
284
+ upload.attached = true;
285
+ upload.orphaned = false;
286
+ upload.claimError = null;
287
+ } catch (err) {
288
+ if (err?.statusCode === 409 || err?.status === 409) {
289
+ upload.attached = true;
290
+ upload.orphaned = false;
291
+ upload.claimError = null;
292
+ } else {
293
+ upload.claimError = err?.data?.detail ?? err?.data?.message ?? err?.statusMessage ?? err?.message ?? "Could not add the file";
294
+ }
295
+ } finally {
296
+ claiming.delete(upload.key);
297
+ persist();
298
+ }
299
+ }
261
300
  function pruneOrphan(upload) {
262
301
  const held = owners.get(upload.key)?.size ?? 0;
263
302
  const finished = upload.phase === "ready" || upload.phase === "canceled";
@@ -268,6 +307,7 @@ function createQueue() {
268
307
  cleanups.get(upload.key)?.();
269
308
  cleanups.delete(upload.key);
270
309
  owners.delete(upload.key);
310
+ claimUrls.delete(upload.key);
271
311
  items.value = items.value.filter((u) => u.key !== upload.key);
272
312
  persist();
273
313
  }
@@ -290,7 +330,7 @@ function createQueue() {
290
330
  }
291
331
  function persist() {
292
332
  if (!import.meta.client) return;
293
- const stored = items.value.filter((u) => u.phase === "processing" && u.assetId).map((u) => ({ assetId: u.assetId, sessionId: u.sessionId, service: u.service, profile: u.profile, name: u.file.name, kind: u.kind, size: u.size, context: u.context }));
333
+ const stored = items.value.filter((u) => u.assetId && (u.phase === "processing" || u.orphaned && !u.attached && claimUrls.get(u.key))).map((u) => ({ assetId: u.assetId, sessionId: u.sessionId, backend: u.backend, claimUrl: claimUrls.get(u.key) ?? null, service: u.service, profile: u.profile, name: u.file.name, kind: u.kind, size: u.size, context: u.context }));
294
334
  try {
295
335
  if (stored.length) sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
296
336
  else sessionStorage.removeItem(STORAGE_KEY);
@@ -312,13 +352,20 @@ function createQueue() {
312
352
  upload.size = s.size;
313
353
  upload.assetId = s.assetId;
314
354
  upload.sessionId = s.sessionId ?? null;
355
+ upload.backend = s.backend ?? null;
315
356
  upload.service = s.service ?? null;
316
357
  upload.profile = s.profile ?? null;
317
- upload.attached = true;
318
358
  resolveCreated(s.assetId);
319
359
  owners.set(upload.key, /* @__PURE__ */ new Set());
360
+ claimUrls.set(upload.key, s.claimUrl ?? null);
320
361
  items.value = [...items.value, upload];
321
362
  followProcessing(upload, s.assetId);
363
+ if (s.claimUrl) {
364
+ upload.orphaned = true;
365
+ void claim(upload);
366
+ } else {
367
+ upload.attached = true;
368
+ }
322
369
  }
323
370
  }
324
371
  if (import.meta.client) {
@@ -341,6 +388,8 @@ function createQueue() {
341
388
  cancelUploads,
342
389
  retryStep,
343
390
  onCreated,
391
+ /** Try attaching an orphaned upload at its backend's claim route again. */
392
+ claim,
344
393
  /** Let the next unload through (the user confirmed it). */
345
394
  allowUnload: () => {
346
395
  allowUnload = true;
@@ -0,0 +1,63 @@
1
+ import { type MediaServiceUpload, type ServiceUploadBackend, type ServiceUploadChoice } from './uploader/serviceBackends.js';
2
+ /**
3
+ * Which system a backend is. `media-library`: the current media library,
4
+ * reached through the module's /api/media-library proxies and media grants.
5
+ * `media-kit`: an app's own media-kit routes (not supported by this version's
6
+ * widgets yet; registering one is allowed, using it fails clearly).
7
+ */
8
+ export type MediaBackendKind = 'media-library' | 'media-kit';
9
+ /**
10
+ * One entry of `mediaLibrary.backends`: a named place widgets read from and
11
+ * upload to (`<MLMediaGallery backend="videos" />`). Paths only: each layer
12
+ * serves its own routes. Paths with `:sessionId` / `:id` get them filled in.
13
+ */
14
+ export interface MediaBackend {
15
+ /** Default `media-library`. */
16
+ kind?: MediaBackendKind;
17
+ /**
18
+ * Where uploads start. Without it, a `media-library` backend uploads
19
+ * through Nuxt's own route (`/api/media-library/manage/uploads`).
20
+ * `start`: py_client `create_upload_router`'s prefix. `refresh`: its grant
21
+ * refresh, default `<start>/:sessionId/grant`.
22
+ */
23
+ upload?: ServiceUploadBackend;
24
+ /**
25
+ * Attach a finished upload: `POST` with `{ assetId, profile }`, e.g.
26
+ * `/api/videos/uploads/:sessionId/attach` (py_client
27
+ * `create_upload_router(on_attach=…)`). When set, the upload queue attaches
28
+ * files whose widget went away before it could (page change), so the app
29
+ * needs no plugin for them.
30
+ */
31
+ claim?: string;
32
+ /** Planned (media-kit): list, resolve and asset routes. Not used yet; `media-library` backends use the module's proxies. */
33
+ list?: string;
34
+ resolve?: string;
35
+ asset?: string;
36
+ }
37
+ export type MediaBackends = Record<string, MediaBackend>;
38
+ /**
39
+ * The registry as public config: `backends`, plus the older
40
+ * `serviceUploads.backends` as `media-library` backends with only `upload`
41
+ * (an entry in `backends` wins over one of the same name). Invalid entries
42
+ * are dropped with a warning.
43
+ */
44
+ export declare function normalizeBackends(backends: MediaBackends | undefined, legacy: Record<string, ServiceUploadBackend> | undefined, warn: (message: string) => void): MediaBackends;
45
+ /** The registry from only the older `serviceUploadBackends` public config (runtime overrides). */
46
+ export declare function legacyBackends(legacy: Record<string, ServiceUploadBackend> | null | undefined): MediaBackends | undefined;
47
+ /** Where an upload goes, resolved from a widget's `backend` (or `service`). */
48
+ export interface ResolvedUploadTarget {
49
+ /** The registered name, or null (inline `{ start }`, or no backend). */
50
+ backend: string | null;
51
+ /** The service that starts the upload, or null for Nuxt's own route. */
52
+ service: MediaServiceUpload | null;
53
+ /** The claim URL for this session, when the backend has one. */
54
+ claimUrl: ((sessionId: string) => string) | null;
55
+ }
56
+ /**
57
+ * Resolve a widget's `backend` / `service` against the registry. `backend`
58
+ * wins; `service` is its older name and still takes an inline `{ start }`.
59
+ * `body` (e.g. `{ context }`) is merged into the start request.
60
+ *
61
+ * @throws Error for an unregistered name, or a `media-kit` backend.
62
+ */
63
+ export declare function resolveUploadTarget(backend: string | null | undefined, service: ServiceUploadChoice, registry: MediaBackends | null | undefined, body?: Record<string, unknown> | null): ResolvedUploadTarget;
@@ -0,0 +1,72 @@
1
+ import { resolveServiceUpload } from "./uploader/serviceBackends.js";
2
+ const KINDS = ["media-library", "media-kit"];
3
+ export function normalizeBackends(backends, legacy, warn) {
4
+ const out = {};
5
+ const upload = (where, u) => {
6
+ if (u === void 0) return void 0;
7
+ if (!u || typeof u.start !== "string" || !u.start) {
8
+ warn(`${where} has no start path; ignored.`);
9
+ return null;
10
+ }
11
+ if (u.refresh && !u.refresh.includes(":sessionId")) {
12
+ warn(`${where}.refresh needs a :sessionId placeholder; using the default.`);
13
+ }
14
+ return { start: u.start, ...u.refresh?.includes(":sessionId") ? { refresh: u.refresh } : {} };
15
+ };
16
+ for (const [name, b] of Object.entries(legacy ?? {})) {
17
+ const u = upload(`mediaLibrary.serviceUploads.backends.${name}`, b);
18
+ if (u) out[name] = { kind: "media-library", upload: u };
19
+ }
20
+ for (const [name, b] of Object.entries(backends ?? {})) {
21
+ const where = `mediaLibrary.backends.${name}`;
22
+ if (!b || typeof b !== "object") {
23
+ warn(`${where} is not an object; ignored.`);
24
+ continue;
25
+ }
26
+ const kind = b.kind ?? "media-library";
27
+ if (!KINDS.includes(kind)) {
28
+ warn(`${where}.kind must be one of ${KINDS.join(", ")}; ignored.`);
29
+ continue;
30
+ }
31
+ const entry = { kind };
32
+ const u = upload(`${where}.upload`, b.upload);
33
+ if (u === null) continue;
34
+ if (u) entry.upload = u;
35
+ if (b.claim) {
36
+ if (b.claim.includes(":sessionId")) entry.claim = b.claim;
37
+ else warn(`${where}.claim needs a :sessionId placeholder; ignored.`);
38
+ }
39
+ for (const key of ["list", "resolve", "asset"]) {
40
+ if (typeof b[key] === "string" && b[key]) entry[key] = b[key];
41
+ }
42
+ out[name] = entry;
43
+ }
44
+ return out;
45
+ }
46
+ export function legacyBackends(legacy) {
47
+ return legacy ? normalizeBackends(void 0, legacy, () => {
48
+ }) : void 0;
49
+ }
50
+ function fill(path, sessionId) {
51
+ return path.replace(/:sessionId\b|\{sessionId\}/g, encodeURIComponent(sessionId));
52
+ }
53
+ export function resolveUploadTarget(backend, service, registry, body) {
54
+ const choice = backend || service;
55
+ if (!choice) return { backend: null, service: null, claimUrl: null };
56
+ if (typeof choice !== "string") {
57
+ return { backend: null, service: resolveServiceUpload(choice, null, body), claimUrl: null };
58
+ }
59
+ const entry = registry?.[choice];
60
+ if (!entry) {
61
+ throw new Error(`No backend '${choice}': add it under mediaLibrary.backends in nuxt.config.`);
62
+ }
63
+ if (entry.kind === "media-kit") {
64
+ throw new Error(`Backend '${choice}' is a media-kit backend; this version of nuxt-media-library cannot upload to media-kit yet.`);
65
+ }
66
+ const claim = entry.claim;
67
+ return {
68
+ backend: choice,
69
+ service: entry.upload ? resolveServiceUpload(choice, { [choice]: entry.upload }, body) : null,
70
+ claimUrl: claim ? (sessionId) => fill(claim, sessionId) : null
71
+ };
72
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuxt-media-library",
3
- "version": "0.0.57",
3
+ "version": "0.0.58",
4
4
  "description": "Nuxt module for media library management — chunked uploads, asset previews, and resource policies",
5
5
  "repository": {
6
6
  "type": "git",
@@ -38,6 +38,8 @@
38
38
  "dev": "yarn dev:prepare && nuxi dev playground",
39
39
  "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground",
40
40
  "build": "nuxt build playground",
41
+ "docs:dev": "nuxt-module-build prepare && nuxi dev docs",
42
+ "docs:generate": "nuxt-module-build prepare && nuxi generate docs",
41
43
  "admin:dev": "yarn dev:prepare && yarn --cwd admin-playground dev",
42
44
  "prepack": "nuxt-module-build build",
43
45
  "lint": "eslint .",
@@ -74,6 +76,7 @@
74
76
  "dashjs": "^5.1.1",
75
77
  "eslint": "^9.39.2",
76
78
  "hls.js": "^1.6.16",
79
+ "msw": "2.15.0",
77
80
  "nuxt": "^4.2.2",
78
81
  "sirv": "^3.0.1",
79
82
  "typescript": "~5.9.3",