@gigamusic/checkout 4.6.0 → 4.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -8
- package/dist/index.d.ts +117 -15
- package/dist/index.js +152 -52
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/checkout.ts +50 -26
- package/src/download.ts +72 -25
- package/src/filenames.ts +23 -0
- package/src/index.ts +1 -0
- package/src/types.ts +62 -7
- 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
|
|
@@ -38,28 +90,45 @@ export const POST = createCheckoutHandler({
|
|
|
38
90
|
});
|
|
39
91
|
```
|
|
40
92
|
|
|
93
|
+
A bundle's members can also be individual tracks — a "bundle of singles" —
|
|
94
|
+
via `trackIds`. Both member lists are optional; supply at least one, and mix
|
|
95
|
+
them freely if that's the product you're selling:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
return {
|
|
99
|
+
totalCents: bundle.discountedPrice,
|
|
100
|
+
productName: `${bundle.name} (${bundle.trackIds.length} songs)`,
|
|
101
|
+
trackIds: bundle.trackIds,
|
|
102
|
+
};
|
|
103
|
+
```
|
|
104
|
+
|
|
41
105
|
As with the catalog, the consumer owns pricing math. The handler:
|
|
42
106
|
|
|
43
107
|
- clamps `totalCents` up to the Stripe card minimum;
|
|
44
|
-
- resolves `releaseIds` against `listPublishedReleases()
|
|
45
|
-
aren't published
|
|
108
|
+
- resolves `releaseIds` against `listPublishedReleases()` and `trackIds`
|
|
109
|
+
against those releases' tracks, dropping any that aren't published — a track
|
|
110
|
+
whose release is unpublished goes the same way as an unpublished release;
|
|
46
111
|
- **apportions** the charged total across the surviving members, proportional
|
|
47
112
|
to list price with largest-remainder rounding, so the `amounts` metadata sums
|
|
48
113
|
to exactly what Stripe charged — and therefore so do the `order_items` rows
|
|
49
|
-
`fulfillCheckoutSession` writes
|
|
114
|
+
`fulfillCheckoutSession` writes. Releases and tracks apportion as one set, so
|
|
115
|
+
a mixed bundle is split once rather than rounded twice;
|
|
50
116
|
- stamps `bundle_ids` onto the session for support. Fulfillment ignores it:
|
|
51
|
-
bundles decompose into per-release order items, so downloads and
|
|
52
|
-
history need no special handling.
|
|
117
|
+
bundles decompose into per-release / per-track order items, so downloads and
|
|
118
|
+
order history need no special handling.
|
|
53
119
|
|
|
54
120
|
Bundles compose with loose `release` / `track` items in a single session. A
|
|
55
|
-
release charged by both a bundle and a loose line yields one deduped
|
|
56
|
-
whose price is the sum.
|
|
121
|
+
release or track charged by both a bundle and a loose line yields one deduped
|
|
122
|
+
order item whose price is the sum.
|
|
123
|
+
|
|
124
|
+
A track has no cover of its own, so a track-only bundle's line item shows the
|
|
125
|
+
cover of the release its first member belongs to.
|
|
57
126
|
|
|
58
127
|
Two rejections worth handling in the cart UI:
|
|
59
128
|
|
|
60
129
|
| Case | Response |
|
|
61
130
|
| --- | --- |
|
|
62
|
-
| Resolver returns `null`, or every member is unpublished | `409 { error: "bundle-unavailable", bundleIds }` |
|
|
131
|
+
| Resolver returns `null`, or every member (of either kind) is unpublished | `409 { error: "bundle-unavailable", bundleIds }` |
|
|
63
132
|
| Cart mixes `kind: "catalog"` with `kind: "bundle"` | `400` |
|
|
64
133
|
|
|
65
134
|
The 409 is the expected path when an admin deletes or unpublishes a bundle still
|
package/dist/index.d.ts
CHANGED
|
@@ -73,9 +73,10 @@ interface CheckoutDeps {
|
|
|
73
73
|
*
|
|
74
74
|
* Called once per distinct `bundleId` in the cart. As with the catalog, the
|
|
75
75
|
* consumer owns pricing math: hand back the final `totalCents` and the
|
|
76
|
-
*
|
|
77
|
-
* members (proportional to list price,
|
|
78
|
-
* order items sum to exactly what Stripe
|
|
76
|
+
* members (`releaseIds`, `trackIds`, or both), and the handler apportions
|
|
77
|
+
* the total across those members (proportional to list price,
|
|
78
|
+
* largest-remainder) so the recorded order items sum to exactly what Stripe
|
|
79
|
+
* charged.
|
|
79
80
|
*
|
|
80
81
|
* Resolving to `null`/`undefined` means "no such bundle" — the handler
|
|
81
82
|
* replies 409 `{ error: "bundle-unavailable", bundleIds }` so the cart can
|
|
@@ -84,7 +85,14 @@ interface CheckoutDeps {
|
|
|
84
85
|
*/
|
|
85
86
|
bundlePurchase?: (bundleId: string) => Promise<ResolvedBundle | null | undefined> | ResolvedBundle | null | undefined;
|
|
86
87
|
}
|
|
87
|
-
/**
|
|
88
|
+
/**
|
|
89
|
+
* What `bundlePurchase` returns for a bundle the consumer recognises.
|
|
90
|
+
*
|
|
91
|
+
* A bundle's members are `releaseIds`, `trackIds`, or both — the package
|
|
92
|
+
* imposes no rule about mixing them, so a consumer is free to model bundles as
|
|
93
|
+
* releases-only, singles-only, or a combination. Supply at least one member
|
|
94
|
+
* list; a bundle that resolves to no surviving member is reported unavailable.
|
|
95
|
+
*/
|
|
88
96
|
interface ResolvedBundle {
|
|
89
97
|
/** Final charged price for the whole bundle, in cents. Clamped up to the Stripe card minimum. */
|
|
90
98
|
totalCents: number;
|
|
@@ -92,10 +100,16 @@ interface ResolvedBundle {
|
|
|
92
100
|
productName: string;
|
|
93
101
|
/**
|
|
94
102
|
* Member release ids. Resolved against `listPublishedReleases()` — ids that
|
|
95
|
-
* aren't published are dropped, and a bundle left with
|
|
96
|
-
* unavailable rather than charging for an order with no items.
|
|
103
|
+
* aren't published are dropped, and a bundle left with no members at all is
|
|
104
|
+
* treated as unavailable rather than charging for an order with no items.
|
|
97
105
|
*/
|
|
98
|
-
releaseIds
|
|
106
|
+
releaseIds?: number[];
|
|
107
|
+
/**
|
|
108
|
+
* Member track ids — individual songs, for a "bundle of singles". Resolved
|
|
109
|
+
* against the tracks of `listPublishedReleases()`, so a track whose release
|
|
110
|
+
* is unpublished is dropped on the same terms as an unpublished release.
|
|
111
|
+
*/
|
|
112
|
+
trackIds?: number[];
|
|
99
113
|
}
|
|
100
114
|
/** What `fulfillCheckoutSession` needs to turn a paid Stripe session into an order row. */
|
|
101
115
|
interface FulfillSessionDeps {
|
|
@@ -144,9 +158,49 @@ interface WebhookDeps extends FulfillSessionDeps, PurchaseConfirmationDeps {
|
|
|
144
158
|
stripeSecret?: string;
|
|
145
159
|
webhookSecret: string;
|
|
146
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
|
+
}
|
|
147
180
|
interface DownloadDeps {
|
|
148
181
|
queries: Queries;
|
|
149
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;
|
|
150
204
|
/**
|
|
151
205
|
* Optional artist-name prefix prepended to zip filenames — both the SW
|
|
152
206
|
* manifest and the server-side stream output use it. Example: passing
|
|
@@ -270,9 +324,34 @@ interface RouteContext$2 {
|
|
|
270
324
|
* URL — the storage provider has `Content-Disposition: attachment` baked in,
|
|
271
325
|
* so the browser triggers a same-tab download.
|
|
272
326
|
*
|
|
273
|
-
* Order of checks: locate the file
|
|
274
|
-
*
|
|
275
|
-
*
|
|
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.
|
|
276
355
|
*/
|
|
277
356
|
declare function createDownloadHandler(deps: DownloadDeps): (req: NextRequest, ctx: RouteContext$2) => Promise<Response>;
|
|
278
357
|
|
|
@@ -284,9 +363,21 @@ interface RouteContext$1 {
|
|
|
284
363
|
/**
|
|
285
364
|
* Build the GET handler for the zip-manifest endpoint. Returns the JSON
|
|
286
365
|
* manifest the consumer-shipped service worker (`public/sw-zip.js`)
|
|
287
|
-
* consumes — the SW pipes
|
|
288
|
-
*
|
|
289
|
-
*
|
|
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.
|
|
290
381
|
*
|
|
291
382
|
* Query-param branches:
|
|
292
383
|
* - `trackIds` set → curated track list, flat layout
|
|
@@ -318,7 +409,18 @@ interface RouteContext {
|
|
|
318
409
|
*
|
|
319
410
|
* Per-file failure policy matches the SW: any failed storage fetch becomes
|
|
320
411
|
* a `_FAILED_<name>.txt` placeholder so one bad object doesn't taint the
|
|
321
|
-
* 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.
|
|
322
424
|
*/
|
|
323
425
|
declare function createDownloadZipStreamHandler(deps: DownloadDeps): (req: NextRequest, ctx: RouteContext) => Promise<Response>;
|
|
324
426
|
|
|
@@ -339,4 +441,4 @@ declare function createDownloadZipStreamHandler(deps: DownloadDeps): (req: NextR
|
|
|
339
441
|
*/
|
|
340
442
|
declare function createSwZipFallbackHandler(): () => Response;
|
|
341
443
|
|
|
342
|
-
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
|
@@ -24,13 +24,13 @@ function apportion(totalCents, members) {
|
|
|
24
24
|
const exact = weights.map((w) => total * w);
|
|
25
25
|
const base = exact.map((v) => Math.floor(v));
|
|
26
26
|
let remainder = total - base.reduce((s, v) => s + v, 0);
|
|
27
|
-
const order = members.map((m, i) => ({ i, frac: exact[i] - base[i], price: m.price,
|
|
27
|
+
const order = members.map((m, i) => ({ i, frac: exact[i] - base[i], price: m.price, key: m.key })).sort((a, b) => b.frac - a.frac || b.price - a.price || a.key.localeCompare(b.key));
|
|
28
28
|
for (const { i } of order) {
|
|
29
29
|
if (remainder <= 0) break;
|
|
30
30
|
base[i] += 1;
|
|
31
31
|
remainder -= 1;
|
|
32
32
|
}
|
|
33
|
-
members.forEach((m, i) => result.set(m.
|
|
33
|
+
members.forEach((m, i) => result.set(m.key, base[i]));
|
|
34
34
|
return result;
|
|
35
35
|
}
|
|
36
36
|
function createCheckoutHandler(deps) {
|
|
@@ -161,12 +161,16 @@ function createCheckoutHandler(deps) {
|
|
|
161
161
|
};
|
|
162
162
|
});
|
|
163
163
|
const bundleLineItems = [];
|
|
164
|
-
const
|
|
164
|
+
const bundleReleaseMemberIds = [];
|
|
165
|
+
const bundleTrackMemberIds = [];
|
|
165
166
|
const unavailableBundleIds = [];
|
|
166
167
|
for (const bundleId of bundleIds) {
|
|
167
168
|
const resolved = await bundlePurchase(bundleId);
|
|
168
|
-
const
|
|
169
|
-
|
|
169
|
+
const releaseMembers = (resolved?.releaseIds ?? []).map((id) => releaseById.get(id)).filter((r) => r !== void 0);
|
|
170
|
+
const trackMembers = (resolved?.trackIds ?? []).map((id) => trackContext.get(id)).filter(
|
|
171
|
+
(c) => c !== void 0
|
|
172
|
+
);
|
|
173
|
+
if (!resolved || releaseMembers.length + trackMembers.length === 0) {
|
|
170
174
|
unavailableBundleIds.push(bundleId);
|
|
171
175
|
continue;
|
|
172
176
|
}
|
|
@@ -174,7 +178,7 @@ function createCheckoutHandler(deps) {
|
|
|
174
178
|
STRIPE_MIN_CHARGE_CENTS,
|
|
175
179
|
Math.round(resolved.totalCents)
|
|
176
180
|
);
|
|
177
|
-
const cover =
|
|
181
|
+
const cover = releaseMembers.find((m) => m.coverImageUrl)?.coverImageUrl ?? trackMembers.find((c) => c.release.coverImageUrl)?.release.coverImageUrl;
|
|
178
182
|
bundleLineItems.push({
|
|
179
183
|
price_data: {
|
|
180
184
|
currency,
|
|
@@ -186,13 +190,14 @@ function createCheckoutHandler(deps) {
|
|
|
186
190
|
},
|
|
187
191
|
quantity: 1
|
|
188
192
|
});
|
|
189
|
-
for (const [
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
)) {
|
|
193
|
-
chargedAmounts[
|
|
194
|
-
bundleMemberIds.push(id);
|
|
193
|
+
for (const [key, cents] of apportion(total, [
|
|
194
|
+
...releaseMembers.map((m) => ({ key: `r${m.id}`, price: m.price })),
|
|
195
|
+
...trackMembers.map(({ track }) => ({ key: `t${track.id}`, price: track.price }))
|
|
196
|
+
])) {
|
|
197
|
+
chargedAmounts[key] = (chargedAmounts[key] ?? 0) + cents;
|
|
195
198
|
}
|
|
199
|
+
bundleReleaseMemberIds.push(...releaseMembers.map((m) => m.id));
|
|
200
|
+
bundleTrackMemberIds.push(...trackMembers.map(({ track }) => track.id));
|
|
196
201
|
}
|
|
197
202
|
if (unavailableBundleIds.length > 0) {
|
|
198
203
|
return Response.json(
|
|
@@ -207,10 +212,15 @@ function createCheckoutHandler(deps) {
|
|
|
207
212
|
const resolvedReleaseIds = [
|
|
208
213
|
.../* @__PURE__ */ new Set([
|
|
209
214
|
...releaseLineItems.length ? releaseIds.filter((id) => releaseById.has(id)) : [],
|
|
210
|
-
...
|
|
215
|
+
...bundleReleaseMemberIds
|
|
216
|
+
])
|
|
217
|
+
];
|
|
218
|
+
const resolvedTrackIds = [
|
|
219
|
+
.../* @__PURE__ */ new Set([
|
|
220
|
+
...trackLineItems.length ? trackIds.filter((id) => trackContext.has(id)) : [],
|
|
221
|
+
...bundleTrackMemberIds
|
|
211
222
|
])
|
|
212
223
|
];
|
|
213
|
-
const resolvedTrackIds = trackLineItems.length ? [...new Set(trackIds.filter((id) => trackContext.has(id)))] : [];
|
|
214
224
|
const session = await stripe.checkout.sessions.create({
|
|
215
225
|
mode: "payment",
|
|
216
226
|
line_items: lineItems,
|
|
@@ -423,14 +433,30 @@ function createStripeWebhookHandler(deps) {
|
|
|
423
433
|
};
|
|
424
434
|
}
|
|
425
435
|
|
|
436
|
+
// src/filenames.ts
|
|
437
|
+
function cleanDownloadFilename(name) {
|
|
438
|
+
const dot = name.lastIndexOf(".");
|
|
439
|
+
if (dot <= 0) return name.trim();
|
|
440
|
+
return `${name.slice(0, dot).trim()}${name.slice(dot).trim()}`;
|
|
441
|
+
}
|
|
442
|
+
function sanitizeSegment(name) {
|
|
443
|
+
return name.replace(/[/\\]+/g, "-").trim();
|
|
444
|
+
}
|
|
445
|
+
function coverArtFilename(releaseName) {
|
|
446
|
+
return `${sanitizeSegment(releaseName)} - COVER ART.jpg`;
|
|
447
|
+
}
|
|
448
|
+
|
|
426
449
|
// src/download.ts
|
|
427
450
|
function createDownloadHandler(deps) {
|
|
428
451
|
const { queries, storage } = deps;
|
|
429
452
|
return async (req, ctx) => {
|
|
430
453
|
const { token } = await ctx.params;
|
|
431
454
|
const requestUrl = new URL(req.url);
|
|
432
|
-
const trackIdRaw = requestUrl.searchParams.get("trackId");
|
|
433
455
|
const format = requestUrl.searchParams.get("format") ?? "mp3";
|
|
456
|
+
if (requestUrl.searchParams.get("asset") === "cover") {
|
|
457
|
+
return handleCoverArt(deps, token, requestUrl.searchParams.get("releaseId"));
|
|
458
|
+
}
|
|
459
|
+
const trackIdRaw = requestUrl.searchParams.get("trackId");
|
|
434
460
|
if (!trackIdRaw) {
|
|
435
461
|
return Response.json({ error: "Missing trackId" }, { status: 400 });
|
|
436
462
|
}
|
|
@@ -442,7 +468,7 @@ function createDownloadHandler(deps) {
|
|
|
442
468
|
if (!downloadToken) {
|
|
443
469
|
return Response.json({ error: "Invalid download link" }, { status: 404 });
|
|
444
470
|
}
|
|
445
|
-
const file = await
|
|
471
|
+
const file = await queries.getTrackFile(trackId, format);
|
|
446
472
|
if (!file) {
|
|
447
473
|
return Response.json({ error: "File not found" }, { status: 404 });
|
|
448
474
|
}
|
|
@@ -457,23 +483,36 @@ function createDownloadHandler(deps) {
|
|
|
457
483
|
return Response.redirect(url, 302);
|
|
458
484
|
};
|
|
459
485
|
}
|
|
460
|
-
async function
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
if (!track) continue;
|
|
465
|
-
const file = track.files.find((f) => f.format === format);
|
|
466
|
-
return file ?? null;
|
|
486
|
+
async function handleCoverArt(deps, token, releaseIdRaw) {
|
|
487
|
+
const { queries, storage } = deps;
|
|
488
|
+
if (!releaseIdRaw) {
|
|
489
|
+
return Response.json({ error: "Missing releaseId" }, { status: 400 });
|
|
467
490
|
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
491
|
+
const releaseId = Number(releaseIdRaw);
|
|
492
|
+
if (!Number.isFinite(releaseId)) {
|
|
493
|
+
return Response.json({ error: "Invalid releaseId" }, { status: 400 });
|
|
494
|
+
}
|
|
495
|
+
const downloadToken = await queries.getDownloadToken(token);
|
|
496
|
+
if (!downloadToken) {
|
|
497
|
+
return Response.json({ error: "Invalid download link" }, { status: 404 });
|
|
498
|
+
}
|
|
499
|
+
const release = await queries.getReleaseById(releaseId);
|
|
500
|
+
if (!release?.coverImageUrl) {
|
|
501
|
+
return Response.json({ error: "File not found" }, { status: 404 });
|
|
502
|
+
}
|
|
503
|
+
const ok = await queries.tokenGrantsRelease(token, releaseId);
|
|
504
|
+
if (!ok) {
|
|
505
|
+
return Response.json({ error: "Release not in order" }, { status: 403 });
|
|
506
|
+
}
|
|
507
|
+
const url = await storage.getPresignedDownloadUrl(release.coverImageUrl, {
|
|
508
|
+
filename: coverArtFilename(release.name),
|
|
509
|
+
contentType: "image/jpeg"
|
|
510
|
+
});
|
|
511
|
+
return Response.redirect(url, 302);
|
|
474
512
|
}
|
|
475
513
|
|
|
476
514
|
// src/zip.ts
|
|
515
|
+
var ZIP_MANIFEST_EXPIRES_IN_SECONDS = 12 * 60 * 60;
|
|
477
516
|
function applyZipNamePrefix(prefix, base) {
|
|
478
517
|
const trimmed = prefix?.trim();
|
|
479
518
|
return trimmed ? `${trimmed} - ${base}` : base;
|
|
@@ -519,7 +558,11 @@ function createDownloadZipHandler(deps) {
|
|
|
519
558
|
}
|
|
520
559
|
const manifest = {
|
|
521
560
|
zipName: resolution.zipName,
|
|
522
|
-
files: await
|
|
561
|
+
files: await buildManifestFiles(
|
|
562
|
+
storage,
|
|
563
|
+
resolution.files,
|
|
564
|
+
downloadRouteUrl(url, token, deps.downloadPath)
|
|
565
|
+
)
|
|
523
566
|
};
|
|
524
567
|
return Response.json(manifest);
|
|
525
568
|
};
|
|
@@ -555,7 +598,8 @@ function resolveTrackList(order, trackIdsParam, format, audioContentType, fmt, z
|
|
|
555
598
|
files.push({
|
|
556
599
|
fileName: zipEntryPath(ctx.release?.name ?? null, file.fileName),
|
|
557
600
|
storageKey: file.storageKey,
|
|
558
|
-
contentType: audioContentType
|
|
601
|
+
contentType: audioContentType,
|
|
602
|
+
source: { kind: "track", trackId: id, format }
|
|
559
603
|
});
|
|
560
604
|
}
|
|
561
605
|
if (files.length === 0) {
|
|
@@ -586,11 +630,12 @@ function resolveSingleRelease(order, releaseId, format, audioContentType, fmt, z
|
|
|
586
630
|
return {
|
|
587
631
|
fileName: zipEntryPath(null, file.fileName),
|
|
588
632
|
storageKey: file.storageKey,
|
|
589
|
-
contentType: audioContentType
|
|
633
|
+
contentType: audioContentType,
|
|
634
|
+
source: { kind: "track", trackId: track.id, format }
|
|
590
635
|
};
|
|
591
636
|
}).filter((f) => f !== null);
|
|
592
637
|
if (files.length > 0 && release.coverImageUrl) {
|
|
593
|
-
files.push(...coverArtEntries(release
|
|
638
|
+
files.push(...coverArtEntries(release, release.coverImageUrl, "", files));
|
|
594
639
|
}
|
|
595
640
|
if (files.length === 0) {
|
|
596
641
|
return {
|
|
@@ -618,13 +663,14 @@ function resolveWholeOrder(order, format, audioContentType, fmt, zipNamePrefix)
|
|
|
618
663
|
entries.push({
|
|
619
664
|
fileName: zipEntryPath(release.name, file.fileName),
|
|
620
665
|
storageKey: file.storageKey,
|
|
621
|
-
contentType: audioContentType
|
|
666
|
+
contentType: audioContentType,
|
|
667
|
+
source: { kind: "track", trackId: track.id, format }
|
|
622
668
|
});
|
|
623
669
|
}
|
|
624
670
|
if (entries.length > 0 && release.coverImageUrl) {
|
|
625
671
|
entries.push(
|
|
626
672
|
...coverArtEntries(
|
|
627
|
-
release
|
|
673
|
+
release,
|
|
628
674
|
release.coverImageUrl,
|
|
629
675
|
`${sanitizeSegment(release.name)}/`,
|
|
630
676
|
entries
|
|
@@ -640,7 +686,8 @@ function resolveWholeOrder(order, format, audioContentType, fmt, zipNamePrefix)
|
|
|
640
686
|
aLaCarteFiles.push({
|
|
641
687
|
fileName: zipEntryPath(release?.name ?? null, file.fileName),
|
|
642
688
|
storageKey: file.storageKey,
|
|
643
|
-
contentType: audioContentType
|
|
689
|
+
contentType: audioContentType,
|
|
690
|
+
source: { kind: "track", trackId: track.id, format }
|
|
644
691
|
});
|
|
645
692
|
}
|
|
646
693
|
}
|
|
@@ -678,9 +725,6 @@ function trackOwnership(order) {
|
|
|
678
725
|
locate: (id) => byId.get(id) ?? null
|
|
679
726
|
};
|
|
680
727
|
}
|
|
681
|
-
function sanitizeSegment(name) {
|
|
682
|
-
return name.replace(/[/\\]+/g, "-").trim();
|
|
683
|
-
}
|
|
684
728
|
function isExtendedMix(fileName) {
|
|
685
729
|
return /\bextended\b/i.test(fileName);
|
|
686
730
|
}
|
|
@@ -692,34 +736,62 @@ function zipEntryPath(releaseName, fileName) {
|
|
|
692
736
|
segments.push(clean);
|
|
693
737
|
return segments.join("/");
|
|
694
738
|
}
|
|
695
|
-
function
|
|
696
|
-
|
|
697
|
-
}
|
|
698
|
-
function coverArtEntries(releaseName, coverImageUrl, folder, trackEntries) {
|
|
699
|
-
const file = coverArtFilename(releaseName);
|
|
739
|
+
function coverArtEntries(release, coverImageUrl, folder, trackEntries) {
|
|
740
|
+
const file = coverArtFilename(release.name);
|
|
741
|
+
const source = { kind: "cover", releaseId: release.id };
|
|
700
742
|
const entries = [
|
|
701
|
-
{
|
|
743
|
+
{
|
|
744
|
+
fileName: `${folder}${file}`,
|
|
745
|
+
storageKey: coverImageUrl,
|
|
746
|
+
contentType: "image/jpeg",
|
|
747
|
+
source
|
|
748
|
+
}
|
|
702
749
|
];
|
|
703
750
|
if (trackEntries.some((e) => e.fileName.split("/").includes("Extended"))) {
|
|
704
751
|
entries.push({
|
|
705
752
|
fileName: `${folder}Extended/${file}`,
|
|
706
753
|
storageKey: coverImageUrl,
|
|
707
|
-
contentType: "image/jpeg"
|
|
754
|
+
contentType: "image/jpeg",
|
|
755
|
+
source
|
|
708
756
|
});
|
|
709
757
|
}
|
|
710
758
|
return entries;
|
|
711
759
|
}
|
|
712
|
-
|
|
760
|
+
function downloadRouteUrl(manifestUrl, token, downloadPath) {
|
|
761
|
+
if (downloadPath) {
|
|
762
|
+
return new URL(downloadPath.replace("{token}", encodeURIComponent(token)), manifestUrl);
|
|
763
|
+
}
|
|
764
|
+
const segments = manifestUrl.pathname.replace(/\/+$/, "").split("/");
|
|
765
|
+
segments.pop();
|
|
766
|
+
const url = new URL(manifestUrl.toString());
|
|
767
|
+
url.search = "";
|
|
768
|
+
url.hash = "";
|
|
769
|
+
url.pathname = segments.join("/") || "/";
|
|
770
|
+
return url;
|
|
771
|
+
}
|
|
772
|
+
async function buildManifestFiles(storage, files, downloadUrl) {
|
|
713
773
|
return Promise.all(
|
|
714
|
-
files.map(async (
|
|
715
|
-
fileName:
|
|
716
|
-
url: await storage.getPresignedDownloadUrl(
|
|
717
|
-
filename:
|
|
718
|
-
contentType:
|
|
774
|
+
files.map(async (file) => ({
|
|
775
|
+
fileName: file.fileName,
|
|
776
|
+
url: file.source ? sourceUrl(downloadUrl, file.source) : await storage.getPresignedDownloadUrl(file.storageKey, {
|
|
777
|
+
filename: file.fileName.split("/").pop() ?? file.fileName,
|
|
778
|
+
contentType: file.contentType,
|
|
779
|
+
expiresInSeconds: ZIP_MANIFEST_EXPIRES_IN_SECONDS
|
|
719
780
|
})
|
|
720
781
|
}))
|
|
721
782
|
);
|
|
722
783
|
}
|
|
784
|
+
function sourceUrl(downloadUrl, source) {
|
|
785
|
+
const url = new URL(downloadUrl.toString());
|
|
786
|
+
if (source.kind === "track") {
|
|
787
|
+
url.searchParams.set("trackId", String(source.trackId));
|
|
788
|
+
url.searchParams.set("format", source.format);
|
|
789
|
+
} else {
|
|
790
|
+
url.searchParams.set("asset", "cover");
|
|
791
|
+
url.searchParams.set("releaseId", String(source.releaseId));
|
|
792
|
+
}
|
|
793
|
+
return url.toString();
|
|
794
|
+
}
|
|
723
795
|
function createDownloadZipStreamHandler(deps) {
|
|
724
796
|
const { queries, storage } = deps;
|
|
725
797
|
return async (req, ctx) => {
|
|
@@ -741,6 +813,15 @@ function createDownloadZipStreamHandler(deps) {
|
|
|
741
813
|
console.error("[zip-stream] archiver error:", err);
|
|
742
814
|
});
|
|
743
815
|
req.signal.addEventListener("abort", () => archive.abort());
|
|
816
|
+
const failures = [];
|
|
817
|
+
const reportFailure = (failure) => {
|
|
818
|
+
failures.push(failure);
|
|
819
|
+
try {
|
|
820
|
+
deps.onZipEntryFailure?.(failure);
|
|
821
|
+
} catch (err) {
|
|
822
|
+
console.warn("[zip-stream] onZipEntryFailure threw:", err);
|
|
823
|
+
}
|
|
824
|
+
};
|
|
744
825
|
(async () => {
|
|
745
826
|
for (const file of resolution.files) {
|
|
746
827
|
if (req.signal.aborted) return;
|
|
@@ -773,6 +854,13 @@ function createDownloadZipStreamHandler(deps) {
|
|
|
773
854
|
console.error(
|
|
774
855
|
`[zip-stream] body stream error for ${file.fileName} (storageKey=${file.storageKey}, bytesReceived=${bytesReceived}): ${detail}`
|
|
775
856
|
);
|
|
857
|
+
reportFailure({
|
|
858
|
+
token,
|
|
859
|
+
fileName: file.fileName,
|
|
860
|
+
storageKey: file.storageKey,
|
|
861
|
+
reason: detail,
|
|
862
|
+
bytesReceived
|
|
863
|
+
});
|
|
776
864
|
try {
|
|
777
865
|
archive.append(
|
|
778
866
|
`Streaming "${file.fileName}" from storage failed after ${bytesReceived} bytes: ${detail}.
|
|
@@ -791,6 +879,13 @@ Try downloading the track individually from your order page.
|
|
|
791
879
|
if (req.signal.aborted) return;
|
|
792
880
|
const detail = err instanceof Error ? err.message : String(err);
|
|
793
881
|
console.warn(`[zip-stream] fetch failed for ${file.fileName}:`, detail);
|
|
882
|
+
reportFailure({
|
|
883
|
+
token,
|
|
884
|
+
fileName: file.fileName,
|
|
885
|
+
storageKey: file.storageKey,
|
|
886
|
+
reason: detail,
|
|
887
|
+
bytesReceived: 0
|
|
888
|
+
});
|
|
794
889
|
archive.append(
|
|
795
890
|
`Failed to download "${file.fileName}" from storage: ${detail}.
|
|
796
891
|
Try downloading the track individually from your order page.
|
|
@@ -799,6 +894,11 @@ Try downloading the track individually from your order page.
|
|
|
799
894
|
);
|
|
800
895
|
}
|
|
801
896
|
}
|
|
897
|
+
if (failures.length > 0) {
|
|
898
|
+
console.error(
|
|
899
|
+
`[zip-stream] ${failures.length}/${resolution.files.length} entries failed for token ${token}: ${failures.map((f) => f.fileName).join(", ")}`
|
|
900
|
+
);
|
|
901
|
+
}
|
|
802
902
|
await archive.finalize();
|
|
803
903
|
})().catch((err) => {
|
|
804
904
|
console.error("[zip-stream] pipeline error:", err);
|