@gigamusic/checkout 0.1.0 → 0.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gigamusic/checkout",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Next.js route-handler factories for Stripe Checkout, Stripe webhooks, and presigned-URL downloads. Stripe is hard-wired; all secrets enter as factory args.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -21,6 +21,7 @@
21
21
  },
22
22
  "files": [
23
23
  "dist",
24
+ "src",
24
25
  "README.md"
25
26
  ],
26
27
  "publishConfig": {
@@ -29,10 +30,10 @@
29
30
  "dependencies": {
30
31
  "archiver": "^7.0.1",
31
32
  "stripe": "^22.0.0",
32
- "@gigamusic/core": "0.1.0",
33
- "@gigamusic/db": "0.1.0",
34
- "@gigamusic/email": "0.1.0",
35
- "@gigamusic/storage": "0.1.0"
33
+ "@gigamusic/core": "0.1.1",
34
+ "@gigamusic/storage": "0.1.1",
35
+ "@gigamusic/db": "0.1.1",
36
+ "@gigamusic/email": "0.1.1"
36
37
  },
37
38
  "peerDependencies": {
38
39
  "next": ">=15"
@@ -0,0 +1,204 @@
1
+ import type { NextRequest } from "next/server";
2
+ import Stripe from "stripe";
3
+ import { applyCatalogDiscount, sumLineItems } from "@gigamusic/core";
4
+ import type { ReleaseWithTracks, TrackWithFiles } from "@gigamusic/db";
5
+ import type { CheckoutCartItem, CheckoutDeps } from "./types.js";
6
+
7
+ /**
8
+ * Build a POST handler that translates `{ items }` from the cart into a Stripe
9
+ * Checkout Session and replies with `{ url }`. Release/track metadata is
10
+ * stashed on the session so the webhook handler can later record the order
11
+ * without re-deriving cart contents.
12
+ *
13
+ * Stripe is hard-wired (per project policy). The Stripe SDK instance is
14
+ * created once per factory call and captured in the closure — no module
15
+ * globals, no `process.env`.
16
+ */
17
+ export function createCheckoutHandler(
18
+ deps: CheckoutDeps,
19
+ ): (req: NextRequest) => Promise<Response> {
20
+ const {
21
+ stripeSecret,
22
+ queries,
23
+ baseUrl,
24
+ successPath = "/checkout/success",
25
+ cancelPath = "/cart",
26
+ currency = "usd",
27
+ catalogDiscount,
28
+ } = deps;
29
+
30
+ const stripe = new Stripe(stripeSecret, { typescript: true });
31
+
32
+ return async (req: NextRequest): Promise<Response> => {
33
+ let payload: { items?: CheckoutCartItem[] };
34
+ try {
35
+ payload = (await req.json()) as { items?: CheckoutCartItem[] };
36
+ } catch {
37
+ return jsonError("Invalid JSON body", 400);
38
+ }
39
+
40
+ const items = payload.items;
41
+ if (!items || items.length === 0) {
42
+ return jsonError("No items provided", 400);
43
+ }
44
+
45
+ const successUrl = `${baseUrl}${successPath}?session_id={CHECKOUT_SESSION_ID}`;
46
+ const cancelUrl = `${baseUrl}${cancelPath}`;
47
+
48
+ const isCatalogPurchase = items.some((i) => i.kind === "catalog");
49
+
50
+ try {
51
+ if (isCatalogPurchase) {
52
+ const resolvedDiscount =
53
+ typeof catalogDiscount === "function"
54
+ ? await catalogDiscount()
55
+ : catalogDiscount;
56
+ if (!resolvedDiscount) {
57
+ return jsonError("Catalog purchase not configured", 400);
58
+ }
59
+ const releases = await queries.listPublishedReleases();
60
+ const subtotal = sumLineItems(
61
+ releases.map((r) => ({ id: r.id, priceCents: r.price })),
62
+ );
63
+ const { totalCents } = applyCatalogDiscount(subtotal, resolvedDiscount.percent);
64
+
65
+ const session = await stripe.checkout.sessions.create({
66
+ mode: "payment",
67
+ line_items: [
68
+ {
69
+ price_data: {
70
+ currency,
71
+ product_data: { name: resolvedDiscount.productName },
72
+ unit_amount: totalCents,
73
+ },
74
+ quantity: 1,
75
+ },
76
+ ],
77
+ metadata: {
78
+ catalog_purchase: "true",
79
+ release_ids: JSON.stringify(releases.map((r) => r.id)),
80
+ track_ids: "[]",
81
+ },
82
+ success_url: successUrl,
83
+ cancel_url: cancelUrl,
84
+ });
85
+
86
+ return Response.json({ url: session.url });
87
+ }
88
+
89
+ const releaseIds = items
90
+ .filter((i) => i.kind === "release" && i.id != null)
91
+ .map((i) => i.id as number);
92
+ const trackIds = items
93
+ .filter((i) => i.kind === "track" && i.id != null)
94
+ .map((i) => i.id as number);
95
+
96
+ if (releaseIds.length === 0 && trackIds.length === 0) {
97
+ return jsonError("No valid items found", 400);
98
+ }
99
+
100
+ // Single `listPublishedReleases` call sources both release and track
101
+ // line items. Tracks live inside their release, so one fetch keeps the
102
+ // ID/price/cover lookups consistent without a second round-trip.
103
+ const allReleases = await queries.listPublishedReleases();
104
+ const releaseById = new Map(allReleases.map((r) => [r.id, r]));
105
+ const trackContext = new Map<
106
+ number,
107
+ { track: TrackWithFiles; release: ReleaseWithTracks }
108
+ >();
109
+ for (const release of allReleases) {
110
+ for (const track of release.tracks) {
111
+ trackContext.set(track.id, { track, release });
112
+ }
113
+ }
114
+
115
+ const releaseLineItems = releaseIds
116
+ .map((id) => releaseById.get(id))
117
+ .filter((r): r is ReleaseWithTracks => r !== undefined)
118
+ .map((r) => ({
119
+ price_data: {
120
+ currency,
121
+ product_data: {
122
+ name: r.name,
123
+ ...(r.coverImageUrl
124
+ ? { images: [toAbsoluteUrl(r.coverImageUrl, baseUrl)] }
125
+ : {}),
126
+ },
127
+ unit_amount: r.price,
128
+ },
129
+ quantity: 1 as const,
130
+ }));
131
+
132
+ const trackLineItems = trackIds
133
+ .map((id) => trackContext.get(id))
134
+ .filter(
135
+ (c): c is { track: TrackWithFiles; release: ReleaseWithTracks } =>
136
+ c !== undefined,
137
+ )
138
+ .map(({ track, release }) => ({
139
+ price_data: {
140
+ currency,
141
+ product_data: {
142
+ name: `${release.name} — ${track.name}`,
143
+ ...(release.coverImageUrl
144
+ ? { images: [toAbsoluteUrl(release.coverImageUrl, baseUrl)] }
145
+ : {}),
146
+ },
147
+ unit_amount: track.price,
148
+ },
149
+ quantity: 1 as const,
150
+ }));
151
+
152
+ const lineItems = [...releaseLineItems, ...trackLineItems];
153
+ if (lineItems.length === 0) {
154
+ return jsonError("No valid items found", 400);
155
+ }
156
+
157
+ const resolvedReleaseIds = releaseLineItems.length
158
+ ? releaseIds.filter((id) => releaseById.has(id))
159
+ : [];
160
+ const resolvedTrackIds = trackLineItems.length
161
+ ? trackIds.filter((id) => trackContext.has(id))
162
+ : [];
163
+
164
+ const session = await stripe.checkout.sessions.create({
165
+ mode: "payment",
166
+ line_items: lineItems,
167
+ metadata: {
168
+ release_ids: JSON.stringify(resolvedReleaseIds),
169
+ track_ids: JSON.stringify(resolvedTrackIds),
170
+ },
171
+ success_url: successUrl,
172
+ cancel_url: cancelUrl,
173
+ });
174
+
175
+ return Response.json({ url: session.url });
176
+ } catch (err) {
177
+ return jsonError("Failed to create checkout session", 500, err);
178
+ }
179
+ };
180
+ }
181
+
182
+ /** Resolve a stored relative URL against `baseUrl` and percent-encode the path. Bare http(s) URLs pass through. */
183
+ function toAbsoluteUrl(url: string, baseUrl: string): string {
184
+ const raw = url.startsWith("http")
185
+ ? url
186
+ : `${baseUrl}${url.startsWith("/") ? "" : "/"}${url}`;
187
+ try {
188
+ const parsed = new URL(raw);
189
+ parsed.pathname = encodeURI(decodeURI(parsed.pathname));
190
+ return parsed.toString();
191
+ } catch {
192
+ return raw;
193
+ }
194
+ }
195
+
196
+ function jsonError(message: string, status: number, cause?: unknown): Response {
197
+ if (cause) {
198
+ // Logged via console.error. Consumers can swap this for a structured
199
+ // logger in a future iteration; the package contract doesn't expose a
200
+ // logger slot yet.
201
+ console.error(`[checkout] ${message}:`, cause);
202
+ }
203
+ return Response.json({ error: message }, { status });
204
+ }
@@ -0,0 +1,85 @@
1
+ import type { NextRequest } from "next/server";
2
+ import type { Queries, TrackFile } from "@gigamusic/db";
3
+ import type { DownloadDeps } from "./types.js";
4
+
5
+ interface RouteContext {
6
+ params: Promise<{ token: string }>;
7
+ }
8
+
9
+ /**
10
+ * Build the GET handler for `/download/[token]?trackId=…&format=…`. The token
11
+ * is looked up via `queries.getDownloadToken`; the track grant is checked via
12
+ * `queries.tokenGrantsTrack`. On success the handler 302s to a presigned R2
13
+ * URL — the storage provider has `Content-Disposition: attachment` baked in,
14
+ * so the browser triggers a same-tab download.
15
+ *
16
+ * Order of checks: locate the file in the catalog first (so a missing-file
17
+ * is 404), *then* enforce the token-track grant (a wrong trackId for the
18
+ * given token is 403, not 404).
19
+ */
20
+ export function createDownloadHandler(
21
+ deps: DownloadDeps,
22
+ ): (req: NextRequest, ctx: RouteContext) => Promise<Response> {
23
+ const { queries, storage } = deps;
24
+
25
+ return async (req: NextRequest, ctx: RouteContext): Promise<Response> => {
26
+ const { token } = await ctx.params;
27
+ const requestUrl = new URL(req.url);
28
+ const trackIdRaw = requestUrl.searchParams.get("trackId");
29
+ const format = requestUrl.searchParams.get("format") ?? "mp3";
30
+
31
+ if (!trackIdRaw) {
32
+ return Response.json({ error: "Missing trackId" }, { status: 400 });
33
+ }
34
+ const trackId = Number(trackIdRaw);
35
+ if (!Number.isFinite(trackId)) {
36
+ return Response.json({ error: "Invalid trackId" }, { status: 400 });
37
+ }
38
+
39
+ const downloadToken = await queries.getDownloadToken(token);
40
+ if (!downloadToken) {
41
+ return Response.json({ error: "Invalid download link" }, { status: 404 });
42
+ }
43
+
44
+ const file = await findTrackFileInCatalog(queries, trackId, format);
45
+ if (!file) {
46
+ return Response.json({ error: "File not found" }, { status: 404 });
47
+ }
48
+
49
+ const ok = await queries.tokenGrantsTrack(token, trackId);
50
+ if (!ok) {
51
+ return Response.json({ error: "Track not in order" }, { status: 403 });
52
+ }
53
+
54
+ const url = await storage.getPresignedDownloadUrl(file.storageKey, {
55
+ filename: cleanDownloadFilename(file.fileName),
56
+ contentType: format === "wav" ? "audio/wav" : "audio/mpeg",
57
+ });
58
+ return Response.redirect(url, 302);
59
+ };
60
+ }
61
+
62
+ /** Walk the published catalog (released to all customers) to find the file matching `trackId` + `format`. */
63
+ async function findTrackFileInCatalog(
64
+ queries: Queries,
65
+ trackId: number,
66
+ format: string,
67
+ ): Promise<TrackFile | null> {
68
+ const releases = await queries.listPublishedReleases();
69
+ for (const release of releases) {
70
+ const track = release.tracks.find((t) => t.id === trackId);
71
+ if (!track) continue;
72
+ const file = track.files.find((f) => f.format === format);
73
+ return file ?? null;
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /** Trim stray leading/trailing whitespace from a download filename while keeping its extension intact. */
79
+ export function cleanDownloadFilename(name: string): string {
80
+ const dot = name.lastIndexOf(".");
81
+ if (dot <= 0) return name.trim();
82
+ return `${name.slice(0, dot).trim()}${name.slice(dot).trim()}`;
83
+ }
84
+
85
+ export type { Queries };
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ export { createCheckoutHandler } from "./checkout.js";
2
+ export { createStripeWebhookHandler } from "./webhook.js";
3
+ export { createDownloadHandler } from "./download.js";
4
+ export { createDownloadZipHandler } from "./zip.js";
5
+ export { createDownloadZipStreamHandler } from "./zip-stream.js";
6
+ export { createSwZipFallbackHandler } from "./sw-zip-fallback.js";
7
+
8
+ export type {
9
+ CheckoutCartItem,
10
+ CheckoutDeps,
11
+ DownloadDeps,
12
+ WebhookDeps,
13
+ } from "./types.js";
@@ -0,0 +1,56 @@
1
+ const ERROR_HTML = `<!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="color-scheme" content="light dark">
7
+ <title>Bulk download couldn't start</title>
8
+ <style>
9
+ body { font-family: system-ui, -apple-system, sans-serif; max-width: 560px; margin: 3rem auto; padding: 0 1.25rem; line-height: 1.55; }
10
+ h1 { font-size: 1.35rem; margin-bottom: 0.75rem; }
11
+ ul { padding-left: 1.25rem; }
12
+ li { margin: 0.35rem 0; }
13
+ a { color: #6366f1; }
14
+ </style>
15
+ </head>
16
+ <body>
17
+ <h1>Bulk download couldn't start</h1>
18
+ <p>Your browser couldn't generate the zip in the background. This usually happens in private/incognito mode, or after the order page has been open for a long time.</p>
19
+ <p>To get your music:</p>
20
+ <ul>
21
+ <li>Go back to your order page and use the individual track download buttons.</li>
22
+ <li>Or reopen the order link in a fresh tab and try the bulk download again.</li>
23
+ </ul>
24
+ <p><a href="javascript:history.back()">&larr; Back to your order</a></p>
25
+ </body>
26
+ </html>`;
27
+
28
+ /**
29
+ * Fallback for `/sw-zip/[...path]` reached only when the service worker
30
+ * shipped at `@gigamusic/ui`'s `public/sw-zip.js` fails to intercept the
31
+ * navigation. Without this route the browser would save Next.js's 404
32
+ * HTML under the URL's `.zip` filename — and macOS Archive Utility
33
+ * surfaces that as the confusing "Error 79 - Inappropriate file type or
34
+ * format." Returning `text/html` (and no `Content-Disposition`) makes
35
+ * the browser render the message instead of saving it.
36
+ *
37
+ * Wire up at `app/sw-zip/[...path]/route.ts`:
38
+ *
39
+ * ```ts
40
+ * export const GET = createSwZipFallbackHandler();
41
+ * ```
42
+ */
43
+ export function createSwZipFallbackHandler(): () => Response {
44
+ return () => {
45
+ // Hitting this route is always a failure mode — the SW should have
46
+ // intercepted. Log so the frequency shows up in serverless logs.
47
+ console.warn("[sw-zip] fallback route hit — service worker did not intercept");
48
+ return new Response(ERROR_HTML, {
49
+ status: 503,
50
+ headers: {
51
+ "Content-Type": "text/html; charset=utf-8",
52
+ "Cache-Control": "no-store",
53
+ },
54
+ });
55
+ };
56
+ }
package/src/types.ts ADDED
@@ -0,0 +1,67 @@
1
+ import type Stripe from "stripe";
2
+ import type { Queries } from "@gigamusic/db";
3
+ import type { StorageProvider } from "@gigamusic/storage";
4
+ import type { EmailProvider, EmailBranding } from "@gigamusic/email";
5
+
6
+ /** Cart item shape accepted by `createCheckoutHandler`. `id` is required for "release" / "track"; ignored for "catalog". */
7
+ export interface CheckoutCartItem {
8
+ kind: "release" | "track" | "catalog";
9
+ id?: number;
10
+ }
11
+
12
+ export interface CheckoutDeps {
13
+ stripeSecret: string;
14
+ queries: Queries;
15
+ /** e.g. https://artist.com — used for success/cancel redirects. */
16
+ baseUrl: string;
17
+ /** Defaults to "/checkout/success". `?session_id={CHECKOUT_SESSION_ID}` is appended automatically. */
18
+ successPath?: string;
19
+ /** Defaults to "/cart". */
20
+ cancelPath?: string;
21
+ /** Defaults to "usd". */
22
+ currency?: string;
23
+ /**
24
+ * Catalog discount applied to "buy the whole catalog" line items. Can be a
25
+ * literal value or an async callback resolved at request time — use the
26
+ * callback when the discount lives in a settings table that admins edit.
27
+ * Resolving to `null`/`undefined` disables the discount for that request.
28
+ */
29
+ catalogDiscount?:
30
+ | { percent: number; productName: string }
31
+ | (() => Promise<{ percent: number; productName: string } | null | undefined>);
32
+ }
33
+
34
+ export interface WebhookDeps {
35
+ /**
36
+ * Either a pre-built Stripe client (handy in tests, where the SDK is mocked
37
+ * at the consumer-app boundary) or a `stripeSecret` string the handler uses
38
+ * to construct one. Pass exactly one — `stripe` wins if both are provided.
39
+ */
40
+ stripe?: Stripe;
41
+ stripeSecret?: string;
42
+ webhookSecret: string;
43
+ queries: Queries;
44
+ email: EmailProvider;
45
+ branding: EmailBranding;
46
+ emailFrom: string;
47
+ baseUrl: string;
48
+ /** JWT secret for the magic-link order-verification token mailed to the customer. */
49
+ orderTokenSecret: string;
50
+ /** Defaults to "/orders/verify". */
51
+ verifyPath?: string;
52
+ }
53
+
54
+ export interface DownloadDeps {
55
+ queries: Queries;
56
+ storage: StorageProvider;
57
+ /** Optional second factor; the DB-stored token UUID remains the primary check. */
58
+ downloadTokenSecret?: string;
59
+ /**
60
+ * Optional artist-name prefix prepended to zip filenames — both the SW
61
+ * manifest and the server-side stream output use it. Example: passing
62
+ * `"My Artist"` yields `"My Artist - Order 12 (MP3).zip"`. Whitespace is
63
+ * trimmed and the joiner (`" - "`) is added automatically; empty / undefined
64
+ * leaves the brand-agnostic default (`"Order 12 (MP3).zip"`).
65
+ */
66
+ zipNamePrefix?: string;
67
+ }
package/src/webhook.ts ADDED
@@ -0,0 +1,212 @@
1
+ import { after, type NextRequest } from "next/server";
2
+ import Stripe from "stripe";
3
+ import { signOrderToken } from "@gigamusic/core";
4
+ import type { ReleaseWithTracks, TrackWithFiles } from "@gigamusic/db";
5
+ import { renderPurchaseConfirmation } from "@gigamusic/email";
6
+ import type { WebhookDeps } from "./types.js";
7
+
8
+ function isUniqueConstraintError(err: unknown): boolean {
9
+ return (
10
+ typeof err === "object" &&
11
+ err !== null &&
12
+ (err as { code?: string }).code === "P2002"
13
+ );
14
+ }
15
+
16
+ /**
17
+ * Build the POST handler for Stripe's webhook endpoint. Verifies the
18
+ * signature, records the order on `checkout.session.completed`, then defers
19
+ * the magic-link email via `after()` so Stripe sees a fast 200.
20
+ *
21
+ * Idempotency is enforced two ways: a pre-flight `getOrderByStripeSessionId`
22
+ * lookup, plus a P2002-unique-constraint catch around `recordCompletedOrder`
23
+ * for the case where two webhook deliveries race past the lookup. Both paths
24
+ * short-circuit with `{ received: true }` so Stripe doesn't retry.
25
+ *
26
+ * ### Published-only resolution (gotcha)
27
+ *
28
+ * The handler resolves the release/track IDs from `session.metadata` against
29
+ * `queries.listPublishedReleases()`. Anything not in the published catalog at
30
+ * the moment the webhook fires is silently dropped from the persisted order's
31
+ * line items — the customer is still charged, but the order row doesn't
32
+ * include the unpublished item.
33
+ *
34
+ * In the canonical flow this can't happen: `createCheckoutHandler` filters on
35
+ * the same `listPublishedReleases()` before it ever creates the Stripe
36
+ * Session. The only ways an unpublished ID can reach this handler are:
37
+ * - an admin unpublishes between session-create and webhook-delivery
38
+ * (a several-second race window),
39
+ * - a consumer bypasses `createCheckoutHandler` and creates Stripe sessions
40
+ * directly with their own metadata.
41
+ *
42
+ * If either of those is a realistic concern, swap the resolution call here
43
+ * to `listAllReleases()` (or wrap with a "warn + fallback" branch) — but be
44
+ * aware that doing so will surface unpublished items in admin order views.
45
+ */
46
+ export function createStripeWebhookHandler(
47
+ deps: WebhookDeps,
48
+ ): (req: NextRequest) => Promise<Response> {
49
+ const {
50
+ stripe: stripeOverride,
51
+ stripeSecret,
52
+ webhookSecret,
53
+ queries,
54
+ email,
55
+ branding,
56
+ emailFrom,
57
+ baseUrl,
58
+ orderTokenSecret,
59
+ verifyPath = "/orders/verify",
60
+ } = deps;
61
+
62
+ if (!stripeOverride && !stripeSecret) {
63
+ throw new Error(
64
+ "createStripeWebhookHandler: must supply either `stripe` or `stripeSecret`",
65
+ );
66
+ }
67
+ const stripe =
68
+ stripeOverride ?? new Stripe(stripeSecret as string, { typescript: true });
69
+
70
+ return async (req: NextRequest): Promise<Response> => {
71
+ const body = await req.text();
72
+ const sig = req.headers.get("stripe-signature");
73
+
74
+ if (!sig) {
75
+ return Response.json({ error: "Missing signature" }, { status: 400 });
76
+ }
77
+
78
+ let event: Stripe.Event;
79
+ try {
80
+ event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
81
+ } catch {
82
+ return Response.json({ error: "Invalid signature" }, { status: 400 });
83
+ }
84
+
85
+ if (event.type !== "checkout.session.completed") {
86
+ return Response.json({ received: true });
87
+ }
88
+
89
+ const session = event.data.object as Stripe.Checkout.Session;
90
+
91
+ const existing = await queries.getOrderByStripeSessionId(session.id);
92
+ if (existing) {
93
+ return Response.json({ received: true });
94
+ }
95
+
96
+ const releaseIds = safeJsonIdList(session.metadata?.release_ids);
97
+ const trackIds = safeJsonIdList(session.metadata?.track_ids);
98
+
99
+ if (releaseIds.length === 0 && trackIds.length === 0) {
100
+ return Response.json({ received: true });
101
+ }
102
+
103
+ // Snapshot the published catalog once so we can resolve both release and
104
+ // track prices + names off a single in-memory lookup.
105
+ const allReleases = await queries.listPublishedReleases();
106
+ const releaseById = new Map(allReleases.map((r) => [r.id, r]));
107
+ const trackContext = new Map<
108
+ number,
109
+ { track: TrackWithFiles; release: ReleaseWithTracks }
110
+ >();
111
+ for (const release of allReleases) {
112
+ for (const track of release.tracks) {
113
+ trackContext.set(track.id, { track, release });
114
+ }
115
+ }
116
+
117
+ const matchedReleases = releaseIds
118
+ .map((id) => releaseById.get(id))
119
+ .filter((r): r is ReleaseWithTracks => r !== undefined);
120
+ const matchedTracks = trackIds
121
+ .map((id) => trackContext.get(id))
122
+ .filter(
123
+ (c): c is { track: TrackWithFiles; release: ReleaseWithTracks } =>
124
+ c !== undefined,
125
+ );
126
+
127
+ const orderItems = [
128
+ ...matchedReleases.map((r) => ({ releaseId: r.id, price: r.price })),
129
+ ...matchedTracks.map(({ track }) => ({ trackId: track.id, price: track.price })),
130
+ ];
131
+
132
+ const customerEmail = session.customer_details?.email ?? "";
133
+
134
+ let recordedOrder;
135
+ try {
136
+ recordedOrder = await queries.recordCompletedOrder({
137
+ stripeSessionId: session.id,
138
+ stripePaymentId:
139
+ typeof session.payment_intent === "string" ? session.payment_intent : null,
140
+ email: customerEmail,
141
+ amountTotal: session.amount_total ?? 0,
142
+ items: orderItems,
143
+ });
144
+ } catch (err) {
145
+ // Two deliveries can both pass the pre-flight lookup and race here. The
146
+ // unique constraint on `stripeSessionId` guarantees only one row lands;
147
+ // the loser surfaces as P2002. Treat that as the same idempotent
148
+ // short-circuit — no email, no 500, no Stripe retry.
149
+ if (isUniqueConstraintError(err)) {
150
+ return Response.json({ received: true });
151
+ }
152
+ throw err;
153
+ }
154
+
155
+ if (!customerEmail) {
156
+ // Stripe almost always supplies an email but doesn't guarantee it. The
157
+ // order is safely recorded; manual intervention required to deliver the
158
+ // magic link.
159
+ console.warn(
160
+ `[stripe-webhook] checkout.session.completed (session=${session.id}) has no customer_details.email — order recorded but customer cannot retrieve downloads.`,
161
+ );
162
+ return Response.json({ received: true });
163
+ }
164
+
165
+ const itemNames = [
166
+ ...matchedReleases.map((r) => r.name),
167
+ ...matchedTracks.map(({ track, release }) => `${release.name} — ${track.name}`),
168
+ ];
169
+
170
+ after(async () => {
171
+ try {
172
+ const verifyToken = await signOrderToken({
173
+ orderId: String(recordedOrder.id),
174
+ email: customerEmail,
175
+ secret: orderTokenSecret,
176
+ });
177
+ const verifyUrl = `${baseUrl}${verifyPath}?token=${verifyToken}`;
178
+ const { subject, html } = renderPurchaseConfirmation({
179
+ branding,
180
+ verifyUrl,
181
+ itemNames,
182
+ totalCents: session.amount_total ?? 0,
183
+ });
184
+ await email.send({
185
+ from: emailFrom,
186
+ to: customerEmail,
187
+ subject,
188
+ html,
189
+ });
190
+ } catch (err) {
191
+ console.error(
192
+ `[stripe-webhook] failed to send purchase confirmation to ${customerEmail} for session ${session.id}:`,
193
+ err,
194
+ );
195
+ }
196
+ });
197
+
198
+ return Response.json({ received: true });
199
+ };
200
+ }
201
+
202
+ /** Parse a JSON-stringified id array from Stripe metadata, returning `[]` on any failure. NaN entries are dropped. */
203
+ function safeJsonIdList(value: string | undefined | null): number[] {
204
+ if (!value) return [];
205
+ try {
206
+ const parsed: unknown = JSON.parse(value);
207
+ if (!Array.isArray(parsed)) return [];
208
+ return parsed.map((v) => Number(v)).filter((n) => Number.isFinite(n));
209
+ } catch {
210
+ return [];
211
+ }
212
+ }
@@ -0,0 +1,156 @@
1
+ import type { NextRequest } from "next/server";
2
+ import { Readable, Transform } from "node:stream";
3
+ import { finished } from "node:stream/promises";
4
+ import type { ReadableStream as NodeReadableStream } from "node:stream/web";
5
+ import archiver from "archiver";
6
+ import type { DownloadDeps } from "./types.js";
7
+ import { resolveZipBundle } from "./zip.js";
8
+
9
+ interface RouteContext {
10
+ params: Promise<{ token: string }>;
11
+ }
12
+
13
+ /**
14
+ * Server-side bulk-download fallback. Mirrors `createDownloadZipHandler`'s
15
+ * auth + file-list logic via the shared `resolveZipBundle()`, but streams
16
+ * a real zip back to the browser through `archiver` instead of returning
17
+ * a manifest for a service worker to assemble.
18
+ *
19
+ * When to wire this up: in addition to `createDownloadZipHandler`. Browsers
20
+ * that can't run the service worker reliably (WebKit — Safari desktop and
21
+ * every iOS browser, plus private/incognito on most engines) get truncated
22
+ * archives from the SW path. Detect those browsers at click time on the
23
+ * client and hard-navigate here instead.
24
+ *
25
+ * Cost trade-off: audio bytes proxy through the serverless function and
26
+ * cost egress, which is why the SW path stays the default for browsers
27
+ * that handle it correctly.
28
+ *
29
+ * Per-file failure policy matches the SW: any failed storage fetch becomes
30
+ * a `_FAILED_<name>.txt` placeholder so one bad object doesn't taint the
31
+ * whole archive.
32
+ */
33
+ export function createDownloadZipStreamHandler(
34
+ deps: DownloadDeps,
35
+ ): (req: NextRequest, ctx: RouteContext) => Promise<Response> {
36
+ const { queries, storage } = deps;
37
+
38
+ return async (req: NextRequest, ctx: RouteContext): Promise<Response> => {
39
+ const { token } = await ctx.params;
40
+ const url = new URL(req.url);
41
+
42
+ const resolution = await resolveZipBundle({
43
+ queries,
44
+ token,
45
+ releaseId: url.searchParams.get("releaseId"),
46
+ trackIdsParam: url.searchParams.get("trackIds"),
47
+ format: url.searchParams.get("format") ?? "mp3",
48
+ zipNamePrefix: deps.zipNamePrefix,
49
+ });
50
+ if (!resolution.ok) {
51
+ return Response.json({ error: resolution.error }, { status: resolution.status });
52
+ }
53
+
54
+ // `store: true` = no DEFLATE compression. MP3/WAV barely compress, and
55
+ // the SW path serves uncompressed entries — staying consistent means
56
+ // identical byte counts across the two routes.
57
+ const archive = archiver("zip", { store: true });
58
+
59
+ archive.on("error", (err) => {
60
+ console.error("[zip-stream] archiver error:", err);
61
+ });
62
+
63
+ // Tear the pipeline down if the client disconnects mid-download.
64
+ req.signal.addEventListener("abort", () => archive.abort());
65
+
66
+ // Serialise storage fetches: only open file N's socket after archiver
67
+ // has fully consumed file N-1. The naive parallel approach (open every
68
+ // socket up front, let archiver drain them in order) leaves the later
69
+ // sockets idle behind backpressure long enough for upstream R2/S3 to
70
+ // time them out, surfacing as `TypeError: terminated` mid-stream. A
71
+ // serial pipeline costs one TTFB per file transition — small compared
72
+ // to the per-file streaming time — and archiver is the serial
73
+ // bottleneck anyway, so end-to-end throughput is unchanged.
74
+ (async () => {
75
+ for (const file of resolution.files) {
76
+ if (req.signal.aborted) return;
77
+ const baseName = file.fileName.split("/").pop() || file.fileName;
78
+ try {
79
+ const url = await storage.getPresignedDownloadUrl(file.storageKey, {
80
+ filename: baseName,
81
+ contentType: file.contentType,
82
+ });
83
+ const res = await fetch(url, { signal: req.signal });
84
+ if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
85
+ // DOM `ReadableStream` and Node's web `ReadableStream` are
86
+ // runtime-compatible but typed separately — cast at the boundary.
87
+ const body = Readable.fromWeb(
88
+ res.body as unknown as NodeReadableStream<Uint8Array>,
89
+ );
90
+ // Pass-through counter for two reasons: we get a meaningful
91
+ // "bytesReceived" in failure logs, and `archiver.append(counter)`
92
+ // gives us a handle the `finished()` await below can resolve on
93
+ // (archiver's internal entry isn't exposed).
94
+ let bytesReceived = 0;
95
+ const counter = new Transform({
96
+ transform(chunk: Buffer, _enc, cb) {
97
+ bytesReceived += chunk.length;
98
+ cb(null, chunk);
99
+ },
100
+ });
101
+ // Forward upstream errors onto the counter so the `finished`
102
+ // await rejects and the loop can append a _FAILED_ placeholder.
103
+ body.on("error", (err) => counter.destroy(err));
104
+ body.pipe(counter);
105
+ archive.append(counter, { name: file.fileName });
106
+ try {
107
+ // Resolves once both sides of `counter` are done: body has
108
+ // ended (upstream finished) AND archiver has drained the
109
+ // readable side (entry fully written to the zip queue).
110
+ await finished(counter);
111
+ } catch (err) {
112
+ if (req.signal.aborted) return;
113
+ const detail = err instanceof Error ? err.message : String(err);
114
+ console.error(
115
+ `[zip-stream] body stream error for ${file.fileName} ` +
116
+ `(storageKey=${file.storageKey}, bytesReceived=${bytesReceived}): ${detail}`,
117
+ );
118
+ try {
119
+ archive.append(
120
+ `Streaming "${file.fileName}" from storage failed after ${bytesReceived} bytes: ${detail}.\n` +
121
+ `Try downloading the track individually from your order page.\n`,
122
+ { name: `_FAILED_${baseName}.txt` },
123
+ );
124
+ } catch (appendErr) {
125
+ console.warn(
126
+ `[zip-stream] could not append failure placeholder for ${file.fileName}:`,
127
+ appendErr,
128
+ );
129
+ }
130
+ }
131
+ } catch (err) {
132
+ if (req.signal.aborted) return;
133
+ const detail = err instanceof Error ? err.message : String(err);
134
+ console.warn(`[zip-stream] fetch failed for ${file.fileName}:`, detail);
135
+ archive.append(
136
+ `Failed to download "${file.fileName}" from storage: ${detail}.\n` +
137
+ `Try downloading the track individually from your order page.\n`,
138
+ { name: `_FAILED_${baseName}.txt` },
139
+ );
140
+ }
141
+ }
142
+ await archive.finalize();
143
+ })().catch((err) => {
144
+ console.error("[zip-stream] pipeline error:", err);
145
+ archive.abort();
146
+ });
147
+
148
+ return new Response(Readable.toWeb(archive) as ReadableStream<Uint8Array>, {
149
+ headers: {
150
+ "Content-Type": "application/zip",
151
+ "Content-Disposition": `attachment; filename="${resolution.zipName.replace(/"/g, "")}"`,
152
+ "Cache-Control": "no-store",
153
+ },
154
+ });
155
+ };
156
+ }
package/src/zip.ts ADDED
@@ -0,0 +1,418 @@
1
+ import type { NextRequest } from "next/server";
2
+ import type {
3
+ OrderWithItems,
4
+ Queries,
5
+ ReleaseWithTracks,
6
+ TrackWithFiles,
7
+ } from "@gigamusic/db";
8
+ import type { StorageProvider } from "@gigamusic/storage";
9
+ import type { DownloadDeps } from "./types.js";
10
+ import { cleanDownloadFilename } from "./download.js";
11
+
12
+ interface RouteContext {
13
+ params: Promise<{ token: string }>;
14
+ }
15
+
16
+ /** A file destined for the zip, paired with the storage object it streams from. */
17
+ export interface ZipFile {
18
+ fileName: string;
19
+ storageKey: string;
20
+ contentType: string;
21
+ }
22
+
23
+ interface ZipManifest {
24
+ zipName: string;
25
+ files: { fileName: string; url: string }[];
26
+ }
27
+
28
+ /**
29
+ * Discriminated result of `resolveZipBundle` — the SW-manifest handler and
30
+ * the server-side streaming handler both call this so they produce
31
+ * byte-identical archives. `ok: false` carries the HTTP status the caller
32
+ * should return.
33
+ */
34
+ export type ResolveZipResult =
35
+ | { ok: true; zipName: string; files: ZipFile[] }
36
+ | { ok: false; status: number; error: string };
37
+
38
+ export interface ResolveZipArgs {
39
+ queries: Queries;
40
+ token: string;
41
+ /** Single-release download; falsy means "whole order or selected tracks". */
42
+ releaseId?: number | string | null;
43
+ /** Comma-separated track IDs for partial-order downloads. */
44
+ trackIdsParam?: string | null;
45
+ /** "mp3" or "wav". */
46
+ format?: string;
47
+ /** Optional artist-name prefix; see `DownloadDeps.zipNamePrefix`. */
48
+ zipNamePrefix?: string;
49
+ }
50
+
51
+ /** Prepend the artist prefix when configured; otherwise return `base` untouched. */
52
+ function applyZipNamePrefix(prefix: string | undefined, base: string): string {
53
+ const trimmed = prefix?.trim();
54
+ return trimmed ? `${trimmed} - ${base}` : base;
55
+ }
56
+
57
+ /**
58
+ * Auth + file-list resolution for a customer's bulk download. The
59
+ * `createDownloadZipHandler` (SW manifest path) and
60
+ * `createDownloadZipStreamHandler` (server-side streaming path) both call
61
+ * this so the two routes stay in lockstep on auth and on what ends up
62
+ * in the archive.
63
+ */
64
+ export async function resolveZipBundle(args: ResolveZipArgs): Promise<ResolveZipResult> {
65
+ const { queries, token } = args;
66
+ const format = args.format ?? "mp3";
67
+ const audioContentType = format === "wav" ? "audio/wav" : "audio/mpeg";
68
+ const releaseIdRaw = args.releaseId == null ? null : String(args.releaseId);
69
+ const trackIdsParam = args.trackIdsParam ?? null;
70
+
71
+ const downloadToken = await queries.getDownloadToken(token);
72
+ if (!downloadToken) {
73
+ return { ok: false, status: 404, error: "Invalid download link" };
74
+ }
75
+
76
+ const order = await queries.getOrderById(downloadToken.orderId);
77
+ if (!order) {
78
+ return { ok: false, status: 404, error: "Invalid download link" };
79
+ }
80
+
81
+ return resolveBundle({
82
+ order,
83
+ releaseId: releaseIdRaw,
84
+ trackIdsParam,
85
+ format,
86
+ audioContentType,
87
+ zipNamePrefix: args.zipNamePrefix,
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Build the GET handler for the zip-manifest endpoint. Returns the JSON
93
+ * manifest the service worker (shipped from `@gigamusic/ui`'s
94
+ * `public/sw-zip.js`) consumes — the SW pipes presigned R2 URLs through
95
+ * `client-zip` and streams the archive straight from R2 to the browser, with
96
+ * the Vercel function never touching audio bytes.
97
+ *
98
+ * Query-param branches:
99
+ * - `trackIds` set → curated track list, flat layout
100
+ * - `releaseId` set → single-release bundle with cover art
101
+ * - neither set → whole order (releases + à-la-carte tracks)
102
+ */
103
+ export function createDownloadZipHandler(
104
+ deps: DownloadDeps,
105
+ ): (req: NextRequest, ctx: RouteContext) => Promise<Response> {
106
+ const { queries, storage } = deps;
107
+
108
+ return async (req: NextRequest, ctx: RouteContext): Promise<Response> => {
109
+ const { token } = await ctx.params;
110
+ const url = new URL(req.url);
111
+ const resolution = await resolveZipBundle({
112
+ queries,
113
+ token,
114
+ releaseId: url.searchParams.get("releaseId"),
115
+ trackIdsParam: url.searchParams.get("trackIds"),
116
+ format: url.searchParams.get("format") ?? "mp3",
117
+ zipNamePrefix: deps.zipNamePrefix,
118
+ });
119
+ if (!resolution.ok) {
120
+ return Response.json({ error: resolution.error }, { status: resolution.status });
121
+ }
122
+
123
+ const manifest: ZipManifest = {
124
+ zipName: resolution.zipName,
125
+ files: await presignZipFiles(storage, resolution.files),
126
+ };
127
+ return Response.json(manifest);
128
+ };
129
+ }
130
+
131
+ type Resolution =
132
+ | { ok: true; zipName: string; files: ZipFile[] }
133
+ | { ok: false; status: number; error: string };
134
+
135
+ interface ResolveArgs {
136
+ order: OrderWithItems;
137
+ releaseId: string | null;
138
+ trackIdsParam: string | null;
139
+ format: string;
140
+ audioContentType: string;
141
+ zipNamePrefix?: string;
142
+ }
143
+
144
+ function resolveBundle(args: ResolveArgs): Resolution {
145
+ const { order, releaseId, trackIdsParam, format, audioContentType, zipNamePrefix } = args;
146
+ const fmt = format.toUpperCase();
147
+
148
+ if (trackIdsParam) {
149
+ return resolveTrackList(order, trackIdsParam, format, audioContentType, fmt, zipNamePrefix);
150
+ }
151
+ if (releaseId) {
152
+ const id = Number(releaseId);
153
+ if (!Number.isFinite(id)) {
154
+ return { ok: false, status: 404, error: "Release not found" };
155
+ }
156
+ return resolveSingleRelease(order, id, format, audioContentType, fmt, zipNamePrefix);
157
+ }
158
+ return resolveWholeOrder(order, format, audioContentType, fmt, zipNamePrefix);
159
+ }
160
+
161
+ function resolveTrackList(
162
+ order: OrderWithItems,
163
+ trackIdsParam: string,
164
+ format: string,
165
+ audioContentType: string,
166
+ fmt: string,
167
+ zipNamePrefix: string | undefined,
168
+ ): Resolution {
169
+ const requestedTrackIds = trackIdsParam
170
+ .split(",")
171
+ .map((s) => Number(s.trim()))
172
+ .filter((n) => Number.isFinite(n));
173
+ const ownership = trackOwnership(order);
174
+ const allOwned = requestedTrackIds.every((id) => ownership.owns(id));
175
+ if (!allOwned) {
176
+ return { ok: false, status: 403, error: "Track not in order" };
177
+ }
178
+
179
+ const files: ZipFile[] = [];
180
+ for (const id of requestedTrackIds) {
181
+ const ctx = ownership.locate(id);
182
+ if (!ctx) continue;
183
+ const file = ctx.track.files.find((f) => f.format === format);
184
+ if (!file) continue;
185
+ files.push({
186
+ fileName: zipEntryPath(ctx.release?.name ?? null, file.fileName),
187
+ storageKey: file.storageKey,
188
+ contentType: audioContentType,
189
+ });
190
+ }
191
+ if (files.length === 0) {
192
+ return {
193
+ ok: false,
194
+ status: 404,
195
+ error: "No audio files have been uploaded for these tracks yet.",
196
+ };
197
+ }
198
+ return {
199
+ ok: true,
200
+ zipName: applyZipNamePrefix(zipNamePrefix, `Tracks (${fmt}).zip`),
201
+ files,
202
+ };
203
+ }
204
+
205
+ function resolveSingleRelease(
206
+ order: OrderWithItems,
207
+ releaseId: number,
208
+ format: string,
209
+ audioContentType: string,
210
+ fmt: string,
211
+ zipNamePrefix: string | undefined,
212
+ ): Resolution {
213
+ const orderHasRelease = order.items.some((item) => item.releaseId === releaseId);
214
+ if (!orderHasRelease) {
215
+ return { ok: false, status: 403, error: "Release not in order" };
216
+ }
217
+
218
+ const release = order.items.find((item) => item.release?.id === releaseId)?.release;
219
+ if (!release) {
220
+ return { ok: false, status: 404, error: "Release not found" };
221
+ }
222
+
223
+ const files: ZipFile[] = release.tracks
224
+ .map((track) => {
225
+ const file = track.files.find((f) => f.format === format);
226
+ if (!file) return null;
227
+ return {
228
+ fileName: zipEntryPath(null, file.fileName),
229
+ storageKey: file.storageKey,
230
+ contentType: audioContentType,
231
+ } satisfies ZipFile;
232
+ })
233
+ .filter((f): f is ZipFile => f !== null);
234
+
235
+ if (files.length > 0 && release.coverImageUrl) {
236
+ files.push(...coverArtEntries(release.name, release.coverImageUrl, "", files));
237
+ }
238
+
239
+ if (files.length === 0) {
240
+ return {
241
+ ok: false,
242
+ status: 404,
243
+ error: "No audio files have been uploaded for these tracks yet.",
244
+ };
245
+ }
246
+ return {
247
+ ok: true,
248
+ zipName: applyZipNamePrefix(zipNamePrefix, `${release.name} (${fmt}).zip`),
249
+ files,
250
+ };
251
+ }
252
+
253
+ function resolveWholeOrder(
254
+ order: OrderWithItems,
255
+ format: string,
256
+ audioContentType: string,
257
+ fmt: string,
258
+ zipNamePrefix: string | undefined,
259
+ ): Resolution {
260
+ const releaseFiles: ZipFile[] = [];
261
+ const aLaCarteFiles: ZipFile[] = [];
262
+
263
+ for (const item of order.items) {
264
+ if (item.release) {
265
+ const release = item.release;
266
+ const entries: ZipFile[] = [];
267
+ for (const track of release.tracks) {
268
+ const file = track.files.find((f) => f.format === format);
269
+ if (!file) continue;
270
+ entries.push({
271
+ fileName: zipEntryPath(release.name, file.fileName),
272
+ storageKey: file.storageKey,
273
+ contentType: audioContentType,
274
+ });
275
+ }
276
+ if (entries.length > 0 && release.coverImageUrl) {
277
+ entries.push(
278
+ ...coverArtEntries(
279
+ release.name,
280
+ release.coverImageUrl,
281
+ `${sanitizeSegment(release.name)}/`,
282
+ entries,
283
+ ),
284
+ );
285
+ }
286
+ releaseFiles.push(...entries);
287
+ } else if (item.track) {
288
+ const track = item.track;
289
+ const release = track.release ?? null;
290
+ const file = track.files.find((f) => f.format === format);
291
+ if (!file) continue;
292
+ aLaCarteFiles.push({
293
+ fileName: zipEntryPath(release?.name ?? null, file.fileName),
294
+ storageKey: file.storageKey,
295
+ contentType: audioContentType,
296
+ });
297
+ }
298
+ }
299
+
300
+ const files = [...releaseFiles, ...aLaCarteFiles];
301
+ if (files.length === 0) {
302
+ return {
303
+ ok: false,
304
+ status: 404,
305
+ error: "No audio files have been uploaded for these tracks yet.",
306
+ };
307
+ }
308
+ return {
309
+ ok: true,
310
+ zipName: applyZipNamePrefix(zipNamePrefix, `Order ${order.id} (${fmt}).zip`),
311
+ files,
312
+ };
313
+ }
314
+
315
+ interface TrackContext {
316
+ track: TrackWithFiles;
317
+ release?: ReleaseWithTracks | null;
318
+ }
319
+
320
+ /** Index every track an order grants access to so the manifest resolver can both check ownership and locate file rows. */
321
+ function trackOwnership(order: OrderWithItems) {
322
+ const byId = new Map<number, TrackContext>();
323
+ for (const item of order.items) {
324
+ if (item.release) {
325
+ for (const t of item.release.tracks) {
326
+ byId.set(t.id, { track: t, release: item.release });
327
+ }
328
+ }
329
+ if (item.track) {
330
+ byId.set(item.track.id, {
331
+ track: item.track,
332
+ release: (item.track.release as ReleaseWithTracks | null | undefined) ?? null,
333
+ });
334
+ }
335
+ }
336
+ return {
337
+ owns: (id: number) => byId.has(id),
338
+ locate: (id: number) => byId.get(id) ?? null,
339
+ };
340
+ }
341
+
342
+ /** Strip path separators so a release/track name can't spawn unintended zip subfolders. */
343
+ export function sanitizeSegment(name: string): string {
344
+ return name.replace(/[/\\]+/g, "-").trim();
345
+ }
346
+
347
+ /**
348
+ * Detects extended-mix tracks from their filename. The catalog marks them
349
+ * inconsistently ("Extended", "(Extended)", "[EXTENDED MIX]"), so a loose
350
+ * whole-word match on "extended" catches every variant.
351
+ */
352
+ export function isExtendedMix(fileName: string): boolean {
353
+ return /\bextended\b/i.test(fileName);
354
+ }
355
+
356
+ /**
357
+ * Build a zip entry path: `[Release Name/][Extended/]filename`. Extended mixes
358
+ * are nested in their own `Extended/` subfolder so they don't clutter the main
359
+ * release listing. `releaseName` is null for single-release zips (which stay
360
+ * flat apart from the `Extended/` split).
361
+ */
362
+ export function zipEntryPath(releaseName: string | null, fileName: string): string {
363
+ const clean = cleanDownloadFilename(fileName);
364
+ const segments: string[] = [];
365
+ if (releaseName) segments.push(sanitizeSegment(releaseName));
366
+ if (isExtendedMix(clean)) segments.push("Extended");
367
+ segments.push(clean);
368
+ return segments.join("/");
369
+ }
370
+
371
+ /** Zip filename for a release's cover art. */
372
+ export function coverArtFilename(releaseName: string): string {
373
+ return `${sanitizeSegment(releaseName)} - COVER ART.jpg`;
374
+ }
375
+
376
+ /**
377
+ * Cover-art zip entries for a release: the artwork alongside the tracks, plus
378
+ * a duplicate inside the `Extended/` subfolder when any track entry uses one.
379
+ * `folder` is the release's `Name/` prefix for multi-release zips, or "" for
380
+ * flat single-release zips.
381
+ */
382
+ export function coverArtEntries(
383
+ releaseName: string,
384
+ coverImageUrl: string,
385
+ folder: string,
386
+ trackEntries: ZipFile[],
387
+ ): ZipFile[] {
388
+ const file = coverArtFilename(releaseName);
389
+ const entries: ZipFile[] = [
390
+ { fileName: `${folder}${file}`, storageKey: coverImageUrl, contentType: "image/jpeg" },
391
+ ];
392
+ if (trackEntries.some((e) => e.fileName.split("/").includes("Extended"))) {
393
+ entries.push({
394
+ fileName: `${folder}Extended/${file}`,
395
+ storageKey: coverImageUrl,
396
+ contentType: "image/jpeg",
397
+ });
398
+ }
399
+ return entries;
400
+ }
401
+
402
+ /** Presign every zip entry into the `{ fileName, url }` manifest shape the service worker consumes. */
403
+ async function presignZipFiles(
404
+ storage: StorageProvider,
405
+ files: ZipFile[],
406
+ ): Promise<ZipManifest["files"]> {
407
+ return Promise.all(
408
+ files.map(async (t) => ({
409
+ fileName: t.fileName,
410
+ url: await storage.getPresignedDownloadUrl(t.storageKey, {
411
+ filename: t.fileName.split("/").pop() ?? t.fileName,
412
+ contentType: t.contentType,
413
+ }),
414
+ })),
415
+ );
416
+ }
417
+
418
+ export type { Queries };