@gigamusic/checkout 4.7.0 → 4.8.1
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 +52 -0
- package/dist/index.d.ts +96 -8
- package/dist/index.js +131 -38
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/src/checkout.ts +17 -0
- package/src/download.ts +72 -25
- package/src/filenames.ts +23 -0
- package/src/index.ts +1 -0
- package/src/types.ts +41 -0
- package/src/zip-stream.ts +44 -2
- package/src/zip.ts +132 -30
package/README.md
CHANGED
|
@@ -14,6 +14,58 @@ export const POST = createCheckoutHandler({
|
|
|
14
14
|
});
|
|
15
15
|
```
|
|
16
16
|
|
|
17
|
+
## Bulk downloads and URL expiry
|
|
18
|
+
|
|
19
|
+
`createDownloadZipHandler` returns a manifest of `{ fileName, url }` for the
|
|
20
|
+
service worker to stream through `client-zip`. Those URLs point back at
|
|
21
|
+
**`createDownloadHandler` on the same origin**, not directly at presigned
|
|
22
|
+
storage URLs — the 302 mints the signature at the moment the SW reaches that
|
|
23
|
+
entry.
|
|
24
|
+
|
|
25
|
+
That matters because the SW fetches entries strictly sequentially (a zip is a
|
|
26
|
+
sequential format; `client-zip` drains one body before opening the next), so a
|
|
27
|
+
multi-gigabyte bundle is still working through the list long after the click.
|
|
28
|
+
Signing every URL up front gave each one the storage default of 5 minutes;
|
|
29
|
+
everything past minute five 403'd, and because R2's signature failures carry no
|
|
30
|
+
CORS headers the SW saw an opaque `NetworkError` rather than a readable status
|
|
31
|
+
and wrote a `_FAILED_*.txt` placeholder — a broken download that looks
|
|
32
|
+
successful.
|
|
33
|
+
|
|
34
|
+
Two things follow for consumers:
|
|
35
|
+
|
|
36
|
+
- **`/download/[token]` must be mounted** for bulk downloads to work. It always
|
|
37
|
+
was, for the per-track buttons; it's now load-bearing for zips too. If the
|
|
38
|
+
manifest route isn't a direct child of it, set `downloadPath` (e.g.
|
|
39
|
+
`"/files/{token}/get"`).
|
|
40
|
+
- Cover art is addressed as `?asset=cover&releaseId=…`, tracks as
|
|
41
|
+
`?trackId=…&format=…`. Both check the token grant.
|
|
42
|
+
|
|
43
|
+
The manifest shape is unchanged, so an already-deployed service worker needs no
|
|
44
|
+
edits.
|
|
45
|
+
|
|
46
|
+
`createDownloadZipStreamHandler` (the server-side fallback) always signed
|
|
47
|
+
per-file inside its loop, so it never had the expiry bug. Its ceiling is the
|
|
48
|
+
platform function timeout instead — audio proxies through the function, so
|
|
49
|
+
large bundles need `export const maxDuration` raised in the route.
|
|
50
|
+
|
|
51
|
+
### Knowing when a bulk download broke
|
|
52
|
+
|
|
53
|
+
The per-file `_FAILED_<name>.txt` policy keeps one bad object from corrupting
|
|
54
|
+
the whole archive, at the cost of making a broken purchase look like a
|
|
55
|
+
successful download. Pass `onZipEntryFailure` to hear about them:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
export const GET = createDownloadZipStreamHandler({
|
|
59
|
+
queries,
|
|
60
|
+
storage,
|
|
61
|
+
onZipEntryFailure: ({ token, fileName, reason }) =>
|
|
62
|
+
alerting.warn(`zip entry failed: ${fileName} (${token}): ${reason}`),
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Failures are also logged individually plus an `n/total entries failed` summary
|
|
67
|
+
line per request, so they're greppable without wiring anything up.
|
|
68
|
+
|
|
17
69
|
## Bundles — discounted subsets of the catalog
|
|
18
70
|
|
|
19
71
|
`catalogPurchase` is all-or-nothing. For a curated pack ("the remix EPs", "2024
|
package/dist/index.d.ts
CHANGED
|
@@ -158,9 +158,49 @@ interface WebhookDeps extends FulfillSessionDeps, PurchaseConfirmationDeps {
|
|
|
158
158
|
stripeSecret?: string;
|
|
159
159
|
webhookSecret: string;
|
|
160
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* One entry `createDownloadZipStreamHandler` could not stream into the
|
|
163
|
+
* archive. The customer still gets a zip — the entry is replaced by a
|
|
164
|
+
* `_FAILED_<name>.txt` placeholder so one bad object doesn't corrupt the rest
|
|
165
|
+
* — which is exactly why the failure needs surfacing somewhere the operator
|
|
166
|
+
* will see it: from the outside, a half-empty archive looks like a successful
|
|
167
|
+
* download.
|
|
168
|
+
*/
|
|
169
|
+
interface ZipEntryFailure {
|
|
170
|
+
/** Download token whose bundle was being streamed — identifies the order. */
|
|
171
|
+
token: string;
|
|
172
|
+
/** Path of the entry inside the archive, e.g. `"Album/track.wav"`. */
|
|
173
|
+
fileName: string;
|
|
174
|
+
storageKey: string;
|
|
175
|
+
/** Message from the failed storage fetch or mid-stream error. */
|
|
176
|
+
reason: string;
|
|
177
|
+
/** Bytes received before the failure; 0 when the fetch never got going. */
|
|
178
|
+
bytesReceived: number;
|
|
179
|
+
}
|
|
161
180
|
interface DownloadDeps {
|
|
162
181
|
queries: Queries;
|
|
163
182
|
storage: StorageProvider;
|
|
183
|
+
/**
|
|
184
|
+
* Path of the single-file download route (`createDownloadHandler`) that
|
|
185
|
+
* `createDownloadZipHandler`'s manifest addresses its entries through, with
|
|
186
|
+
* `{token}` as the placeholder — e.g. `"/downloads/{token}/file"`.
|
|
187
|
+
*
|
|
188
|
+
* Defaults to the manifest route's own path minus its last segment, which
|
|
189
|
+
* resolves to `/download/[token]` under the layout `docs/setup.md`
|
|
190
|
+
* prescribes. Only set this if you mount the two routes such that the
|
|
191
|
+
* relationship doesn't hold.
|
|
192
|
+
*/
|
|
193
|
+
downloadPath?: string;
|
|
194
|
+
/**
|
|
195
|
+
* Called by `createDownloadZipStreamHandler` once per entry it fails to
|
|
196
|
+
* stream, so a broken bulk download can raise an alert instead of silently
|
|
197
|
+
* shipping a zip full of `_FAILED_*.txt` placeholders. Failures are also
|
|
198
|
+
* logged, with a per-request summary line carrying the failed/total count.
|
|
199
|
+
*
|
|
200
|
+
* Errors thrown here are swallowed — reporting must never take down a
|
|
201
|
+
* download that is otherwise still producing bytes.
|
|
202
|
+
*/
|
|
203
|
+
onZipEntryFailure?: (failure: ZipEntryFailure) => void;
|
|
164
204
|
/**
|
|
165
205
|
* Optional artist-name prefix prepended to zip filenames — both the SW
|
|
166
206
|
* manifest and the server-side stream output use it. Example: passing
|
|
@@ -284,9 +324,34 @@ interface RouteContext$2 {
|
|
|
284
324
|
* URL — the storage provider has `Content-Disposition: attachment` baked in,
|
|
285
325
|
* so the browser triggers a same-tab download.
|
|
286
326
|
*
|
|
287
|
-
* Order of checks: locate the file
|
|
288
|
-
*
|
|
289
|
-
*
|
|
327
|
+
* Order of checks: locate the file first (so a missing-file is 404), *then*
|
|
328
|
+
* enforce the token-track grant (a wrong trackId for the given token is 403,
|
|
329
|
+
* not 404).
|
|
330
|
+
*
|
|
331
|
+
* One extra job beyond serving the order page's per-track buttons, in service
|
|
332
|
+
* of `createDownloadZipHandler`, whose manifest points every zip entry back
|
|
333
|
+
* here so the R2 signature is minted when the service worker actually reaches
|
|
334
|
+
* that file (see `zip.ts`): `?asset=cover&releaseId=…` serves a release's
|
|
335
|
+
* cover art, which isn't track-keyed and so has no `trackId` to address it by.
|
|
336
|
+
* That branch checks `queries.tokenGrantsRelease` instead.
|
|
337
|
+
*
|
|
338
|
+
* ### Lookups are keyed, not scanned
|
|
339
|
+
*
|
|
340
|
+
* Both branches resolve their target with a single keyed query
|
|
341
|
+
* (`getTrackFile` / `getReleaseById`). They must not go back to
|
|
342
|
+
* `listPublishedReleases()`: that pulls every published release with its
|
|
343
|
+
* tracks and files, and because zip manifests fan every entry through this
|
|
344
|
+
* route, one bulk download would issue that read once per file — dozens of
|
|
345
|
+
* full-catalog scans to serve a single archive, against the most expensive
|
|
346
|
+
* resource these sites have.
|
|
347
|
+
*
|
|
348
|
+
* Neither keyed query filters on `isPublished`, which is deliberate and is
|
|
349
|
+
* also what makes them a complete replacement for the catalog walk rather
|
|
350
|
+
* than a narrowing of it. Ownership is decided by the grant check below, not
|
|
351
|
+
* by catalog membership, so a release unpublished after purchase and an
|
|
352
|
+
* à-la-carte track belonging to no release both stay reachable to the customer
|
|
353
|
+
* who bought them — exactly the cases the zip resolver lists, since it reads
|
|
354
|
+
* the order rather than the catalog.
|
|
290
355
|
*/
|
|
291
356
|
declare function createDownloadHandler(deps: DownloadDeps): (req: NextRequest, ctx: RouteContext$2) => Promise<Response>;
|
|
292
357
|
|
|
@@ -298,9 +363,21 @@ interface RouteContext$1 {
|
|
|
298
363
|
/**
|
|
299
364
|
* Build the GET handler for the zip-manifest endpoint. Returns the JSON
|
|
300
365
|
* manifest the consumer-shipped service worker (`public/sw-zip.js`)
|
|
301
|
-
* consumes — the SW pipes
|
|
302
|
-
*
|
|
303
|
-
*
|
|
366
|
+
* consumes — the SW pipes each URL through `client-zip` and streams the
|
|
367
|
+
* archive straight from storage to the browser, with the serverless function
|
|
368
|
+
* never touching audio bytes.
|
|
369
|
+
*
|
|
370
|
+
* Manifest URLs point back at `createDownloadHandler` on this same origin
|
|
371
|
+
* rather than directly at presigned R2 URLs. That route 302s to a *freshly*
|
|
372
|
+
* signed URL, so the signature is minted at the moment the SW reaches that
|
|
373
|
+
* entry — however many hours into the archive that is. Signing everything up
|
|
374
|
+
* front meant every URL in a multi-gigabyte bundle died 5 minutes after the
|
|
375
|
+
* click; see `ZIP_MANIFEST_EXPIRES_IN_SECONDS`. The redirect adds one cheap
|
|
376
|
+
* function invocation per file and gives failures a server-side trace they
|
|
377
|
+
* never had while the SW talked straight to R2.
|
|
378
|
+
*
|
|
379
|
+
* The manifest shape (`{ zipName, files: [{ fileName, url }] }`) is unchanged,
|
|
380
|
+
* so an already-deployed service worker keeps working without modification.
|
|
304
381
|
*
|
|
305
382
|
* Query-param branches:
|
|
306
383
|
* - `trackIds` set → curated track list, flat layout
|
|
@@ -332,7 +409,18 @@ interface RouteContext {
|
|
|
332
409
|
*
|
|
333
410
|
* Per-file failure policy matches the SW: any failed storage fetch becomes
|
|
334
411
|
* a `_FAILED_<name>.txt` placeholder so one bad object doesn't taint the
|
|
335
|
-
* whole archive.
|
|
412
|
+
* whole archive. Because that turns a broken purchase into an
|
|
413
|
+
* apparently-successful download, every failure is reported through
|
|
414
|
+
* `deps.onZipEntryFailure` and summarised in a single log line at the end of
|
|
415
|
+
* the request.
|
|
416
|
+
*
|
|
417
|
+
* Unlike the manifest path, this handler presigns each file *inside* the loop,
|
|
418
|
+
* immediately before fetching it, so signature expiry can't bite. Its own
|
|
419
|
+
* ceiling is the platform's function timeout: audio bytes proxy through the
|
|
420
|
+
* function, so the whole archive has to be produced within `maxDuration`
|
|
421
|
+
* (Vercel: 300s by default, up to 800s on Fluid compute). Multi-gigabyte
|
|
422
|
+
* bundles need that raised in the consuming route — `export const maxDuration`
|
|
423
|
+
* — or they'll be cut off mid-stream.
|
|
336
424
|
*/
|
|
337
425
|
declare function createDownloadZipStreamHandler(deps: DownloadDeps): (req: NextRequest, ctx: RouteContext) => Promise<Response>;
|
|
338
426
|
|
|
@@ -353,4 +441,4 @@ declare function createDownloadZipStreamHandler(deps: DownloadDeps): (req: NextR
|
|
|
353
441
|
*/
|
|
354
442
|
declare function createSwZipFallbackHandler(): () => Response;
|
|
355
443
|
|
|
356
|
-
export { type CheckoutCartItem, type CheckoutDeps, type DownloadDeps, type FulfillSessionDeps, type FulfillSessionResult, type PurchaseConfirmationDeps, type ResolvedBundle, type WebhookDeps, createCheckoutHandler, createDownloadHandler, createDownloadZipHandler, createDownloadZipStreamHandler, createStripeWebhookHandler, createSwZipFallbackHandler, fulfillCheckoutSession, sendPurchaseConfirmation };
|
|
444
|
+
export { type CheckoutCartItem, type CheckoutDeps, type DownloadDeps, type FulfillSessionDeps, type FulfillSessionResult, type PurchaseConfirmationDeps, type ResolvedBundle, type WebhookDeps, type ZipEntryFailure, createCheckoutHandler, createDownloadHandler, createDownloadZipHandler, createDownloadZipStreamHandler, createStripeWebhookHandler, createSwZipFallbackHandler, fulfillCheckoutSession, sendPurchaseConfirmation };
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import archiver from 'archiver';
|
|
|
9
9
|
|
|
10
10
|
// src/checkout.ts
|
|
11
11
|
var STRIPE_MIN_CHARGE_CENTS = 50;
|
|
12
|
+
var MANAGED_PAYMENTS_DISABLED = { enabled: false };
|
|
12
13
|
function resolveUnitAmount(overrideCents, catalogCents) {
|
|
13
14
|
if (typeof overrideCents === "number" && Number.isFinite(overrideCents) && overrideCents > 0) {
|
|
14
15
|
return Math.max(STRIPE_MIN_CHARGE_CENTS, Math.round(overrideCents));
|
|
@@ -93,6 +94,7 @@ function createCheckoutHandler(deps) {
|
|
|
93
94
|
release_ids: JSON.stringify(releases.map((r) => r.id)),
|
|
94
95
|
track_ids: "[]"
|
|
95
96
|
},
|
|
97
|
+
managed_payments: MANAGED_PAYMENTS_DISABLED,
|
|
96
98
|
success_url: successUrl,
|
|
97
99
|
cancel_url: cancelUrl
|
|
98
100
|
});
|
|
@@ -233,6 +235,7 @@ function createCheckoutHandler(deps) {
|
|
|
233
235
|
// items — but invaluable when supporting "what did I actually buy?".
|
|
234
236
|
...bundleIds.length > 0 ? { bundle_ids: JSON.stringify(bundleIds) } : {}
|
|
235
237
|
},
|
|
238
|
+
managed_payments: MANAGED_PAYMENTS_DISABLED,
|
|
236
239
|
success_url: successUrl,
|
|
237
240
|
cancel_url: cancelUrl
|
|
238
241
|
});
|
|
@@ -433,14 +436,30 @@ function createStripeWebhookHandler(deps) {
|
|
|
433
436
|
};
|
|
434
437
|
}
|
|
435
438
|
|
|
439
|
+
// src/filenames.ts
|
|
440
|
+
function cleanDownloadFilename(name) {
|
|
441
|
+
const dot = name.lastIndexOf(".");
|
|
442
|
+
if (dot <= 0) return name.trim();
|
|
443
|
+
return `${name.slice(0, dot).trim()}${name.slice(dot).trim()}`;
|
|
444
|
+
}
|
|
445
|
+
function sanitizeSegment(name) {
|
|
446
|
+
return name.replace(/[/\\]+/g, "-").trim();
|
|
447
|
+
}
|
|
448
|
+
function coverArtFilename(releaseName) {
|
|
449
|
+
return `${sanitizeSegment(releaseName)} - COVER ART.jpg`;
|
|
450
|
+
}
|
|
451
|
+
|
|
436
452
|
// src/download.ts
|
|
437
453
|
function createDownloadHandler(deps) {
|
|
438
454
|
const { queries, storage } = deps;
|
|
439
455
|
return async (req, ctx) => {
|
|
440
456
|
const { token } = await ctx.params;
|
|
441
457
|
const requestUrl = new URL(req.url);
|
|
442
|
-
const trackIdRaw = requestUrl.searchParams.get("trackId");
|
|
443
458
|
const format = requestUrl.searchParams.get("format") ?? "mp3";
|
|
459
|
+
if (requestUrl.searchParams.get("asset") === "cover") {
|
|
460
|
+
return handleCoverArt(deps, token, requestUrl.searchParams.get("releaseId"));
|
|
461
|
+
}
|
|
462
|
+
const trackIdRaw = requestUrl.searchParams.get("trackId");
|
|
444
463
|
if (!trackIdRaw) {
|
|
445
464
|
return Response.json({ error: "Missing trackId" }, { status: 400 });
|
|
446
465
|
}
|
|
@@ -452,7 +471,7 @@ function createDownloadHandler(deps) {
|
|
|
452
471
|
if (!downloadToken) {
|
|
453
472
|
return Response.json({ error: "Invalid download link" }, { status: 404 });
|
|
454
473
|
}
|
|
455
|
-
const file = await
|
|
474
|
+
const file = await queries.getTrackFile(trackId, format);
|
|
456
475
|
if (!file) {
|
|
457
476
|
return Response.json({ error: "File not found" }, { status: 404 });
|
|
458
477
|
}
|
|
@@ -467,23 +486,36 @@ function createDownloadHandler(deps) {
|
|
|
467
486
|
return Response.redirect(url, 302);
|
|
468
487
|
};
|
|
469
488
|
}
|
|
470
|
-
async function
|
|
471
|
-
const
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
if (!track) continue;
|
|
475
|
-
const file = track.files.find((f) => f.format === format);
|
|
476
|
-
return file ?? null;
|
|
489
|
+
async function handleCoverArt(deps, token, releaseIdRaw) {
|
|
490
|
+
const { queries, storage } = deps;
|
|
491
|
+
if (!releaseIdRaw) {
|
|
492
|
+
return Response.json({ error: "Missing releaseId" }, { status: 400 });
|
|
477
493
|
}
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
494
|
+
const releaseId = Number(releaseIdRaw);
|
|
495
|
+
if (!Number.isFinite(releaseId)) {
|
|
496
|
+
return Response.json({ error: "Invalid releaseId" }, { status: 400 });
|
|
497
|
+
}
|
|
498
|
+
const downloadToken = await queries.getDownloadToken(token);
|
|
499
|
+
if (!downloadToken) {
|
|
500
|
+
return Response.json({ error: "Invalid download link" }, { status: 404 });
|
|
501
|
+
}
|
|
502
|
+
const release = await queries.getReleaseById(releaseId);
|
|
503
|
+
if (!release?.coverImageUrl) {
|
|
504
|
+
return Response.json({ error: "File not found" }, { status: 404 });
|
|
505
|
+
}
|
|
506
|
+
const ok = await queries.tokenGrantsRelease(token, releaseId);
|
|
507
|
+
if (!ok) {
|
|
508
|
+
return Response.json({ error: "Release not in order" }, { status: 403 });
|
|
509
|
+
}
|
|
510
|
+
const url = await storage.getPresignedDownloadUrl(release.coverImageUrl, {
|
|
511
|
+
filename: coverArtFilename(release.name),
|
|
512
|
+
contentType: "image/jpeg"
|
|
513
|
+
});
|
|
514
|
+
return Response.redirect(url, 302);
|
|
484
515
|
}
|
|
485
516
|
|
|
486
517
|
// src/zip.ts
|
|
518
|
+
var ZIP_MANIFEST_EXPIRES_IN_SECONDS = 12 * 60 * 60;
|
|
487
519
|
function applyZipNamePrefix(prefix, base) {
|
|
488
520
|
const trimmed = prefix?.trim();
|
|
489
521
|
return trimmed ? `${trimmed} - ${base}` : base;
|
|
@@ -529,7 +561,11 @@ function createDownloadZipHandler(deps) {
|
|
|
529
561
|
}
|
|
530
562
|
const manifest = {
|
|
531
563
|
zipName: resolution.zipName,
|
|
532
|
-
files: await
|
|
564
|
+
files: await buildManifestFiles(
|
|
565
|
+
storage,
|
|
566
|
+
resolution.files,
|
|
567
|
+
downloadRouteUrl(url, token, deps.downloadPath)
|
|
568
|
+
)
|
|
533
569
|
};
|
|
534
570
|
return Response.json(manifest);
|
|
535
571
|
};
|
|
@@ -565,7 +601,8 @@ function resolveTrackList(order, trackIdsParam, format, audioContentType, fmt, z
|
|
|
565
601
|
files.push({
|
|
566
602
|
fileName: zipEntryPath(ctx.release?.name ?? null, file.fileName),
|
|
567
603
|
storageKey: file.storageKey,
|
|
568
|
-
contentType: audioContentType
|
|
604
|
+
contentType: audioContentType,
|
|
605
|
+
source: { kind: "track", trackId: id, format }
|
|
569
606
|
});
|
|
570
607
|
}
|
|
571
608
|
if (files.length === 0) {
|
|
@@ -596,11 +633,12 @@ function resolveSingleRelease(order, releaseId, format, audioContentType, fmt, z
|
|
|
596
633
|
return {
|
|
597
634
|
fileName: zipEntryPath(null, file.fileName),
|
|
598
635
|
storageKey: file.storageKey,
|
|
599
|
-
contentType: audioContentType
|
|
636
|
+
contentType: audioContentType,
|
|
637
|
+
source: { kind: "track", trackId: track.id, format }
|
|
600
638
|
};
|
|
601
639
|
}).filter((f) => f !== null);
|
|
602
640
|
if (files.length > 0 && release.coverImageUrl) {
|
|
603
|
-
files.push(...coverArtEntries(release
|
|
641
|
+
files.push(...coverArtEntries(release, release.coverImageUrl, "", files));
|
|
604
642
|
}
|
|
605
643
|
if (files.length === 0) {
|
|
606
644
|
return {
|
|
@@ -628,13 +666,14 @@ function resolveWholeOrder(order, format, audioContentType, fmt, zipNamePrefix)
|
|
|
628
666
|
entries.push({
|
|
629
667
|
fileName: zipEntryPath(release.name, file.fileName),
|
|
630
668
|
storageKey: file.storageKey,
|
|
631
|
-
contentType: audioContentType
|
|
669
|
+
contentType: audioContentType,
|
|
670
|
+
source: { kind: "track", trackId: track.id, format }
|
|
632
671
|
});
|
|
633
672
|
}
|
|
634
673
|
if (entries.length > 0 && release.coverImageUrl) {
|
|
635
674
|
entries.push(
|
|
636
675
|
...coverArtEntries(
|
|
637
|
-
release
|
|
676
|
+
release,
|
|
638
677
|
release.coverImageUrl,
|
|
639
678
|
`${sanitizeSegment(release.name)}/`,
|
|
640
679
|
entries
|
|
@@ -650,7 +689,8 @@ function resolveWholeOrder(order, format, audioContentType, fmt, zipNamePrefix)
|
|
|
650
689
|
aLaCarteFiles.push({
|
|
651
690
|
fileName: zipEntryPath(release?.name ?? null, file.fileName),
|
|
652
691
|
storageKey: file.storageKey,
|
|
653
|
-
contentType: audioContentType
|
|
692
|
+
contentType: audioContentType,
|
|
693
|
+
source: { kind: "track", trackId: track.id, format }
|
|
654
694
|
});
|
|
655
695
|
}
|
|
656
696
|
}
|
|
@@ -688,9 +728,6 @@ function trackOwnership(order) {
|
|
|
688
728
|
locate: (id) => byId.get(id) ?? null
|
|
689
729
|
};
|
|
690
730
|
}
|
|
691
|
-
function sanitizeSegment(name) {
|
|
692
|
-
return name.replace(/[/\\]+/g, "-").trim();
|
|
693
|
-
}
|
|
694
731
|
function isExtendedMix(fileName) {
|
|
695
732
|
return /\bextended\b/i.test(fileName);
|
|
696
733
|
}
|
|
@@ -702,34 +739,62 @@ function zipEntryPath(releaseName, fileName) {
|
|
|
702
739
|
segments.push(clean);
|
|
703
740
|
return segments.join("/");
|
|
704
741
|
}
|
|
705
|
-
function
|
|
706
|
-
|
|
707
|
-
}
|
|
708
|
-
function coverArtEntries(releaseName, coverImageUrl, folder, trackEntries) {
|
|
709
|
-
const file = coverArtFilename(releaseName);
|
|
742
|
+
function coverArtEntries(release, coverImageUrl, folder, trackEntries) {
|
|
743
|
+
const file = coverArtFilename(release.name);
|
|
744
|
+
const source = { kind: "cover", releaseId: release.id };
|
|
710
745
|
const entries = [
|
|
711
|
-
{
|
|
746
|
+
{
|
|
747
|
+
fileName: `${folder}${file}`,
|
|
748
|
+
storageKey: coverImageUrl,
|
|
749
|
+
contentType: "image/jpeg",
|
|
750
|
+
source
|
|
751
|
+
}
|
|
712
752
|
];
|
|
713
753
|
if (trackEntries.some((e) => e.fileName.split("/").includes("Extended"))) {
|
|
714
754
|
entries.push({
|
|
715
755
|
fileName: `${folder}Extended/${file}`,
|
|
716
756
|
storageKey: coverImageUrl,
|
|
717
|
-
contentType: "image/jpeg"
|
|
757
|
+
contentType: "image/jpeg",
|
|
758
|
+
source
|
|
718
759
|
});
|
|
719
760
|
}
|
|
720
761
|
return entries;
|
|
721
762
|
}
|
|
722
|
-
|
|
763
|
+
function downloadRouteUrl(manifestUrl, token, downloadPath) {
|
|
764
|
+
if (downloadPath) {
|
|
765
|
+
return new URL(downloadPath.replace("{token}", encodeURIComponent(token)), manifestUrl);
|
|
766
|
+
}
|
|
767
|
+
const segments = manifestUrl.pathname.replace(/\/+$/, "").split("/");
|
|
768
|
+
segments.pop();
|
|
769
|
+
const url = new URL(manifestUrl.toString());
|
|
770
|
+
url.search = "";
|
|
771
|
+
url.hash = "";
|
|
772
|
+
url.pathname = segments.join("/") || "/";
|
|
773
|
+
return url;
|
|
774
|
+
}
|
|
775
|
+
async function buildManifestFiles(storage, files, downloadUrl) {
|
|
723
776
|
return Promise.all(
|
|
724
|
-
files.map(async (
|
|
725
|
-
fileName:
|
|
726
|
-
url: await storage.getPresignedDownloadUrl(
|
|
727
|
-
filename:
|
|
728
|
-
contentType:
|
|
777
|
+
files.map(async (file) => ({
|
|
778
|
+
fileName: file.fileName,
|
|
779
|
+
url: file.source ? sourceUrl(downloadUrl, file.source) : await storage.getPresignedDownloadUrl(file.storageKey, {
|
|
780
|
+
filename: file.fileName.split("/").pop() ?? file.fileName,
|
|
781
|
+
contentType: file.contentType,
|
|
782
|
+
expiresInSeconds: ZIP_MANIFEST_EXPIRES_IN_SECONDS
|
|
729
783
|
})
|
|
730
784
|
}))
|
|
731
785
|
);
|
|
732
786
|
}
|
|
787
|
+
function sourceUrl(downloadUrl, source) {
|
|
788
|
+
const url = new URL(downloadUrl.toString());
|
|
789
|
+
if (source.kind === "track") {
|
|
790
|
+
url.searchParams.set("trackId", String(source.trackId));
|
|
791
|
+
url.searchParams.set("format", source.format);
|
|
792
|
+
} else {
|
|
793
|
+
url.searchParams.set("asset", "cover");
|
|
794
|
+
url.searchParams.set("releaseId", String(source.releaseId));
|
|
795
|
+
}
|
|
796
|
+
return url.toString();
|
|
797
|
+
}
|
|
733
798
|
function createDownloadZipStreamHandler(deps) {
|
|
734
799
|
const { queries, storage } = deps;
|
|
735
800
|
return async (req, ctx) => {
|
|
@@ -751,6 +816,15 @@ function createDownloadZipStreamHandler(deps) {
|
|
|
751
816
|
console.error("[zip-stream] archiver error:", err);
|
|
752
817
|
});
|
|
753
818
|
req.signal.addEventListener("abort", () => archive.abort());
|
|
819
|
+
const failures = [];
|
|
820
|
+
const reportFailure = (failure) => {
|
|
821
|
+
failures.push(failure);
|
|
822
|
+
try {
|
|
823
|
+
deps.onZipEntryFailure?.(failure);
|
|
824
|
+
} catch (err) {
|
|
825
|
+
console.warn("[zip-stream] onZipEntryFailure threw:", err);
|
|
826
|
+
}
|
|
827
|
+
};
|
|
754
828
|
(async () => {
|
|
755
829
|
for (const file of resolution.files) {
|
|
756
830
|
if (req.signal.aborted) return;
|
|
@@ -783,6 +857,13 @@ function createDownloadZipStreamHandler(deps) {
|
|
|
783
857
|
console.error(
|
|
784
858
|
`[zip-stream] body stream error for ${file.fileName} (storageKey=${file.storageKey}, bytesReceived=${bytesReceived}): ${detail}`
|
|
785
859
|
);
|
|
860
|
+
reportFailure({
|
|
861
|
+
token,
|
|
862
|
+
fileName: file.fileName,
|
|
863
|
+
storageKey: file.storageKey,
|
|
864
|
+
reason: detail,
|
|
865
|
+
bytesReceived
|
|
866
|
+
});
|
|
786
867
|
try {
|
|
787
868
|
archive.append(
|
|
788
869
|
`Streaming "${file.fileName}" from storage failed after ${bytesReceived} bytes: ${detail}.
|
|
@@ -801,6 +882,13 @@ Try downloading the track individually from your order page.
|
|
|
801
882
|
if (req.signal.aborted) return;
|
|
802
883
|
const detail = err instanceof Error ? err.message : String(err);
|
|
803
884
|
console.warn(`[zip-stream] fetch failed for ${file.fileName}:`, detail);
|
|
885
|
+
reportFailure({
|
|
886
|
+
token,
|
|
887
|
+
fileName: file.fileName,
|
|
888
|
+
storageKey: file.storageKey,
|
|
889
|
+
reason: detail,
|
|
890
|
+
bytesReceived: 0
|
|
891
|
+
});
|
|
804
892
|
archive.append(
|
|
805
893
|
`Failed to download "${file.fileName}" from storage: ${detail}.
|
|
806
894
|
Try downloading the track individually from your order page.
|
|
@@ -809,6 +897,11 @@ Try downloading the track individually from your order page.
|
|
|
809
897
|
);
|
|
810
898
|
}
|
|
811
899
|
}
|
|
900
|
+
if (failures.length > 0) {
|
|
901
|
+
console.error(
|
|
902
|
+
`[zip-stream] ${failures.length}/${resolution.files.length} entries failed for token ${token}: ${failures.map((f) => f.fileName).join(", ")}`
|
|
903
|
+
);
|
|
904
|
+
}
|
|
812
905
|
await archive.finalize();
|
|
813
906
|
})().catch((err) => {
|
|
814
907
|
console.error("[zip-stream] pipeline error:", err);
|