@gigamusic/admin 0.1.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.
@@ -0,0 +1,117 @@
1
+ import type { AdminDeps, RouteHandler } from "../lib/types";
2
+ import type { LinkInput } from "@gigamusic/db";
3
+ import { badRequest, json, notFound, unauthorized } from "../lib/responses";
4
+ import { isAdminAuthenticated } from "../lib/session";
5
+
6
+ interface LinkBody {
7
+ id?: unknown;
8
+ title?: unknown;
9
+ url?: unknown;
10
+ position?: unknown;
11
+ isVisible?: unknown;
12
+ showOnHero?: unknown;
13
+ }
14
+
15
+ interface ReorderBody {
16
+ orderedIds?: unknown;
17
+ }
18
+
19
+ function toLinkInput(body: LinkBody): LinkInput | null {
20
+ const title = typeof body.title === "string" ? body.title : null;
21
+ const url = typeof body.url === "string" ? body.url : null;
22
+ if (!title || !url) return null;
23
+ return {
24
+ id: typeof body.id === "number" && Number.isFinite(body.id) ? body.id : undefined,
25
+ title,
26
+ url,
27
+ position: typeof body.position === "number" ? body.position : undefined,
28
+ isVisible: typeof body.isVisible === "boolean" ? body.isVisible : undefined,
29
+ showOnHero: typeof body.showOnHero === "boolean" ? body.showOnHero : undefined,
30
+ };
31
+ }
32
+
33
+ /**
34
+ * GET/POST/PUT/DELETE /api/admin/links — manages the global `/links` page
35
+ * link list. (Per-release link pages live in `@gigamusic/links`.)
36
+ *
37
+ * PUT accepts either a partial single-link update or `{ orderedIds: [...] }`
38
+ * to reorder the whole list in one round-trip.
39
+ *
40
+ * DELETE expects `?id=...`.
41
+ */
42
+ export function createAdminLinksHandlers(deps: AdminDeps): {
43
+ GET: RouteHandler;
44
+ POST: RouteHandler;
45
+ PUT: RouteHandler;
46
+ DELETE: RouteHandler;
47
+ } {
48
+ return {
49
+ GET: async () => {
50
+ if (!(await isAdminAuthenticated(deps.adminSessionSecret))) {
51
+ return unauthorized();
52
+ }
53
+ return json(await deps.queries.listAllLinks());
54
+ },
55
+ POST: async (req) => {
56
+ if (!(await isAdminAuthenticated(deps.adminSessionSecret))) {
57
+ return unauthorized();
58
+ }
59
+ const body = (await safeJson(req)) as LinkBody;
60
+ const input = toLinkInput(body);
61
+ if (!input) return badRequest("title and url are required");
62
+ const link = await deps.queries.upsertLink(input);
63
+ return json(link, { status: 201 });
64
+ },
65
+ PUT: async (req) => {
66
+ if (!(await isAdminAuthenticated(deps.adminSessionSecret))) {
67
+ return unauthorized();
68
+ }
69
+ const body = (await safeJson(req)) as LinkBody & ReorderBody;
70
+ if (Array.isArray(body.orderedIds)) {
71
+ const ids = body.orderedIds
72
+ .map((x) => (typeof x === "number" ? x : Number(x)))
73
+ .filter((n) => Number.isFinite(n));
74
+ await deps.queries.reorderLinks(ids);
75
+ return json({ ok: true });
76
+ }
77
+ const id = typeof body.id === "number" && Number.isFinite(body.id) ? body.id : null;
78
+ if (id == null) return badRequest("id is required");
79
+ // upsertLink handles partial updates when given an id; pass through
80
+ // every supplied field.
81
+ const link = await deps.queries.upsertLink({
82
+ id,
83
+ title: typeof body.title === "string" ? body.title : "",
84
+ url: typeof body.url === "string" ? body.url : "",
85
+ position: typeof body.position === "number" ? body.position : undefined,
86
+ isVisible: typeof body.isVisible === "boolean" ? body.isVisible : undefined,
87
+ showOnHero: typeof body.showOnHero === "boolean" ? body.showOnHero : undefined,
88
+ });
89
+ return json(link);
90
+ },
91
+ DELETE: async (req) => {
92
+ if (!(await isAdminAuthenticated(deps.adminSessionSecret))) {
93
+ return unauthorized();
94
+ }
95
+ const url = new URL(req.url);
96
+ const idRaw = url.searchParams.get("id");
97
+ if (!idRaw) return badRequest("id is required");
98
+ const id = Number(idRaw);
99
+ if (!Number.isFinite(id)) return badRequest("id is required");
100
+ try {
101
+ await deps.queries.deleteLink(id);
102
+ } catch (err) {
103
+ deps.logger?.warn("deleteLink failed", { error: String(err) });
104
+ return notFound("Link not found");
105
+ }
106
+ return json({ ok: true });
107
+ },
108
+ };
109
+ }
110
+
111
+ async function safeJson(req: Request): Promise<unknown> {
112
+ try {
113
+ return await req.json();
114
+ } catch {
115
+ return {};
116
+ }
117
+ }
@@ -0,0 +1,21 @@
1
+ import type { AdminDeps, RouteHandler } from "../lib/types";
2
+ import { badRequest, json, unauthorized } from "../lib/responses";
3
+ import { isAdminAuthenticated } from "../lib/session";
4
+
5
+ /**
6
+ * GET /api/admin/orders?email=… — returns the order history for a given email
7
+ * address. The plan only requires an admin "view" surface; this is the small
8
+ * read endpoint that backs the OrdersPage filter.
9
+ */
10
+ export function createAdminOrdersHandler(deps: AdminDeps): RouteHandler {
11
+ return async (req) => {
12
+ if (!(await isAdminAuthenticated(deps.adminSessionSecret))) {
13
+ return unauthorized();
14
+ }
15
+ const url = new URL(req.url);
16
+ const email = url.searchParams.get("email");
17
+ if (!email) return badRequest("email query param is required");
18
+ const orders = await deps.queries.listOrdersByEmail(email);
19
+ return json(orders);
20
+ };
21
+ }
@@ -0,0 +1,209 @@
1
+ import { z } from "zod";
2
+ import {
3
+ ReleaseInputSchema,
4
+ TrackInputSchema,
5
+ type ReleaseInput,
6
+ } from "@gigamusic/core";
7
+ import type { AdminDeps, RouteHandler } from "../lib/types";
8
+ import { badRequest, json, notFound, unauthorized } from "../lib/responses";
9
+ import { isAdminAuthenticated } from "../lib/session";
10
+
11
+ /**
12
+ * Draft releases skip the strict published-validation rules so an admin can
13
+ * save in-progress work. We coerce missing numerics to 0 and missing track
14
+ * arrays to `[]` so the persistence call sees a complete shape.
15
+ */
16
+ const DraftReleaseSchema = ReleaseInputSchema.extend({
17
+ isPublished: z.literal(false),
18
+ price: z.number().int().nonnegative().optional(),
19
+ slug: z.string().optional(),
20
+ type: z.enum(["album", "single"]).optional(),
21
+ tracks: z.array(TrackInputSchema.partial({ files: true, price: true })).optional(),
22
+ });
23
+
24
+ const ReleasePayloadSchema = z.union([ReleaseInputSchema, DraftReleaseSchema]);
25
+
26
+ /**
27
+ * Flatten zod issues into `{ "tracks[0].artist": ["msg", …] }` shape — matches
28
+ * the field-error format the admin form expects.
29
+ */
30
+ function flattenIssues(error: z.ZodError): Record<string, string[]> {
31
+ const out: Record<string, string[]> = {};
32
+ for (const issue of error.issues) {
33
+ const key = issue.path
34
+ .map((seg: PropertyKey) => (typeof seg === "number" ? `[${seg}]` : `.${String(seg)}`))
35
+ .join("")
36
+ .replace(/^\./, "");
37
+ if (!key) continue;
38
+ (out[key] ??= []).push(issue.message);
39
+ }
40
+ return out;
41
+ }
42
+
43
+ function requireAuth(deps: Pick<AdminDeps, "adminSessionSecret">) {
44
+ return isAdminAuthenticated(deps.adminSessionSecret);
45
+ }
46
+
47
+ /** GET /api/admin/releases and POST /api/admin/releases. */
48
+ export function createAdminReleasesHandlers(
49
+ deps: AdminDeps,
50
+ ): { GET: RouteHandler; POST: RouteHandler } {
51
+ return {
52
+ GET: async () => {
53
+ if (!(await requireAuth(deps))) return unauthorized();
54
+ const releases = await deps.queries.listAllReleases();
55
+ return json(releases);
56
+ },
57
+ POST: async (req) => {
58
+ if (!(await requireAuth(deps))) return unauthorized();
59
+ const body = await readJson(req);
60
+ if (!body.ok) return badRequest("Invalid JSON");
61
+
62
+ const parsed = ReleasePayloadSchema.safeParse(body.value);
63
+ if (!parsed.success) {
64
+ return badRequest("Validation failed", {
65
+ fieldErrors: flattenIssues(parsed.error),
66
+ });
67
+ }
68
+ try {
69
+ const release = await deps.queries.createRelease(parsed.data as ReleaseInput);
70
+ return json(release, { status: 201 });
71
+ } catch (err) {
72
+ if (isUniqueConstraintError(err)) {
73
+ return badRequest("Slug already exists", {
74
+ fieldErrors: { slug: ["Slug already exists"] },
75
+ });
76
+ }
77
+ deps.logger?.error("createRelease failed", { error: String(err) });
78
+ throw err;
79
+ }
80
+ },
81
+ };
82
+ }
83
+
84
+ /** GET/PUT/DELETE /api/admin/releases/[id]. */
85
+ export function createAdminReleaseByIdHandlers(
86
+ deps: AdminDeps,
87
+ ): { GET: RouteHandler; PUT: RouteHandler; DELETE: RouteHandler } {
88
+ return {
89
+ GET: async (_req, ctx) => {
90
+ if (!(await requireAuth(deps))) return unauthorized();
91
+ const id = await resolveIdParam(ctx);
92
+ if (id == null) return badRequest("Missing id");
93
+ const all = await deps.queries.listAllReleases();
94
+ const found = all.find((r) => r.id === id);
95
+ return found ? json(found) : notFound("Release not found");
96
+ },
97
+ PUT: async (req, ctx) => {
98
+ if (!(await requireAuth(deps))) return unauthorized();
99
+ const id = await resolveIdParam(ctx);
100
+ if (id == null) return badRequest("Missing id");
101
+
102
+ const body = await readJson(req);
103
+ if (!body.ok) return badRequest("Invalid JSON");
104
+ const parsed = ReleasePayloadSchema.safeParse(body.value);
105
+ if (!parsed.success) {
106
+ return badRequest("Validation failed", {
107
+ fieldErrors: flattenIssues(parsed.error),
108
+ });
109
+ }
110
+
111
+ try {
112
+ const updated = await deps.queries.updateRelease(
113
+ id,
114
+ parsed.data as Partial<ReleaseInput>,
115
+ );
116
+ return json(updated);
117
+ } catch (err) {
118
+ if (isUniqueConstraintError(err)) {
119
+ return badRequest("Slug already exists", {
120
+ fieldErrors: { slug: ["Slug already exists"] },
121
+ });
122
+ }
123
+ deps.logger?.error("updateRelease failed", { error: String(err) });
124
+ throw err;
125
+ }
126
+ },
127
+ DELETE: async (_req, ctx) => {
128
+ if (!(await requireAuth(deps))) return unauthorized();
129
+ const id = await resolveIdParam(ctx);
130
+ if (id == null) return badRequest("Missing id");
131
+
132
+ // Snapshot storage keys before the cascade nukes the rows, so we can
133
+ // clean up the bucket without an orphaned-file scan.
134
+ const all = await deps.queries.listAllReleases();
135
+ const release = all.find((r) => r.id === id);
136
+ if (!release) return notFound("Release not found");
137
+
138
+ const keys = collectStorageKeys(release, deps.storage);
139
+ await deps.queries.deleteRelease(id);
140
+ await Promise.all(
141
+ keys.map((key) =>
142
+ deps.storage
143
+ .deleteFile(key)
144
+ .catch((err) =>
145
+ deps.logger?.warn("storage.deleteFile failed", {
146
+ key,
147
+ error: String(err),
148
+ }),
149
+ ),
150
+ ),
151
+ );
152
+ return json({ ok: true });
153
+ },
154
+ };
155
+ }
156
+
157
+ interface ReleaseWithFiles {
158
+ coverImageUrl: string | null;
159
+ tracks: Array<{ files: Array<{ storageKey: string }> }>;
160
+ }
161
+
162
+ function collectStorageKeys(
163
+ release: ReleaseWithFiles,
164
+ storage: { keyFromPublicUrl: (u: string) => string },
165
+ ): string[] {
166
+ const keys: string[] = [];
167
+ if (release.coverImageUrl) {
168
+ try {
169
+ // Cover image is stored as a public URL — round-trip to the key.
170
+ keys.push(storage.keyFromPublicUrl(release.coverImageUrl));
171
+ } catch {
172
+ // Non-public URL (e.g. external cover) — skip cleanup.
173
+ }
174
+ }
175
+ for (const track of release.tracks) {
176
+ for (const file of track.files) {
177
+ keys.push(file.storageKey);
178
+ }
179
+ }
180
+ return keys;
181
+ }
182
+
183
+ function isUniqueConstraintError(err: unknown): boolean {
184
+ if (typeof err !== "object" || err === null) return false;
185
+ const code = (err as { code?: unknown }).code;
186
+ if (code === "P2002") return true;
187
+ // Some Queries implementations may surface plain Errors with this prefix.
188
+ const message = (err as { message?: unknown }).message;
189
+ return typeof message === "string" && /slug.*(already|exists|unique)/i.test(message);
190
+ }
191
+
192
+ async function resolveIdParam(
193
+ ctx?: { params: Promise<Record<string, string>> } | undefined,
194
+ ): Promise<number | null> {
195
+ if (!ctx) return null;
196
+ const params = await ctx.params;
197
+ if (params.id == null) return null;
198
+ const id = Number(params.id);
199
+ return Number.isFinite(id) ? id : null;
200
+ }
201
+
202
+ async function readJson(req: Request): Promise<{ ok: true; value: unknown } | { ok: false }> {
203
+ try {
204
+ return { ok: true, value: await req.json() };
205
+ } catch {
206
+ return { ok: false };
207
+ }
208
+ }
209
+
@@ -0,0 +1,54 @@
1
+ import type { AdminDeps, RouteHandler } from "../lib/types";
2
+ import { badRequest, json, unauthorized } from "../lib/responses";
3
+ import { isAdminAuthenticated } from "../lib/session";
4
+
5
+ interface SettingPayload {
6
+ key?: unknown;
7
+ value?: unknown;
8
+ }
9
+
10
+ /**
11
+ * GET/PUT /api/admin/settings.
12
+ *
13
+ * GET returns all known settings as a `Record<string, unknown>`. PUT upserts a
14
+ * single key/value pair; the consumer's `queries.setSetting` is the source of
15
+ * truth for serialization (JSON stringification happens inside the db layer).
16
+ *
17
+ * Per-key validation is intentionally not done here — settings are
18
+ * heterogeneous and the admin form for each setting validates before
19
+ * submitting.
20
+ */
21
+ export function createAdminSettingsHandlers(
22
+ deps: AdminDeps,
23
+ ): { GET: RouteHandler; PUT: RouteHandler } {
24
+ return {
25
+ GET: async (req) => {
26
+ if (!(await isAdminAuthenticated(deps.adminSessionSecret))) {
27
+ return unauthorized();
28
+ }
29
+ const url = new URL(req.url);
30
+ const key = url.searchParams.get("key");
31
+ if (key) {
32
+ const value = await deps.queries.getSetting(key);
33
+ return json({ key, value });
34
+ }
35
+ return json({});
36
+ },
37
+ PUT: async (req) => {
38
+ if (!(await isAdminAuthenticated(deps.adminSessionSecret))) {
39
+ return unauthorized();
40
+ }
41
+ let body: SettingPayload;
42
+ try {
43
+ body = (await req.json()) as SettingPayload;
44
+ } catch {
45
+ return badRequest("Invalid JSON");
46
+ }
47
+ if (typeof body.key !== "string" || body.key.length === 0) {
48
+ return badRequest("key is required");
49
+ }
50
+ await deps.queries.setSetting(body.key, body.value);
51
+ return json({ ok: true });
52
+ },
53
+ };
54
+ }
@@ -0,0 +1,286 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { writeFile, readFile, unlink, stat } from "node:fs/promises";
5
+ import { createReadStream } from "node:fs";
6
+ import type { AdminDeps, RouteHandler } from "../lib/types";
7
+ import { badRequest, json, serverError, unauthorized } from "../lib/responses";
8
+ import { isAdminAuthenticated } from "../lib/session";
9
+
10
+ // Path segments allow the punctuation common in real-world track filenames
11
+ // alongside alphanumerics, dashes, dots, underscores, spaces, parens, and
12
+ // slashes. Must start with a known top-level prefix and end with an allowed
13
+ // extension (case-insensitive). Path traversal is blocked separately.
14
+ const KEY_RE =
15
+ /^(audio|images|uploads)\/[A-Za-z0-9_.\-() /'&,!+[\]=]+\.(wav|mp3|jpg|jpeg|png|webp)$/i;
16
+
17
+ const ALLOWED_CONTENT_TYPES = new Set([
18
+ "audio/wav",
19
+ "audio/x-wav",
20
+ "audio/mpeg",
21
+ "image/jpeg",
22
+ "image/png",
23
+ "image/webp",
24
+ // Some browsers serve .wav as octet-stream when the OS has no handler.
25
+ "application/octet-stream",
26
+ ]);
27
+
28
+ interface ProcessBody {
29
+ key?: unknown;
30
+ trackId?: unknown;
31
+ metadata?: unknown;
32
+ artDataUrl?: unknown;
33
+ coverImageUrl?: unknown;
34
+ }
35
+
36
+ /** Build POST /api/admin/upload/presign. */
37
+ export function createAdminUploadPresignHandler(deps: AdminDeps): RouteHandler {
38
+ return async (req) => {
39
+ if (!(await isAdminAuthenticated(deps.adminSessionSecret))) {
40
+ return unauthorized();
41
+ }
42
+
43
+ const body = (await safeJson(req)) as {
44
+ key?: unknown;
45
+ contentType?: unknown;
46
+ };
47
+ const key = typeof body.key === "string" ? body.key : "";
48
+ const contentType =
49
+ typeof body.contentType === "string" ? body.contentType : "";
50
+
51
+ if (!key || !contentType) {
52
+ return badRequest("key and contentType are required");
53
+ }
54
+ if (key.includes("..") || !KEY_RE.test(key)) {
55
+ return badRequest(
56
+ "Invalid key — must be under audio/, images/, or uploads/ with an allowed extension",
57
+ );
58
+ }
59
+ if (!ALLOWED_CONTENT_TYPES.has(contentType)) {
60
+ return badRequest(`Unsupported contentType: ${contentType}`);
61
+ }
62
+
63
+ const result = await deps.storage.getPresignedUploadUrl(key, { contentType });
64
+ return json(result);
65
+ };
66
+ }
67
+
68
+ interface AudioMetadata {
69
+ title?: string;
70
+ artist?: string;
71
+ album?: string;
72
+ genre?: string;
73
+ trackNumber?: number;
74
+ trackTotal?: number;
75
+ year?: number;
76
+ }
77
+
78
+ /**
79
+ * Coerce arbitrary client input into a clean metadata object. String fields
80
+ * are trimmed: stray whitespace embeds into the file and trips up players
81
+ * like Apple Music that treat otherwise-identical tracks as separate albums.
82
+ */
83
+ function parseMetadata(raw: unknown): AudioMetadata | undefined {
84
+ if (!raw || typeof raw !== "object") return undefined;
85
+ const r = raw as Record<string, unknown>;
86
+ const out: AudioMetadata = {};
87
+ if (typeof r.title === "string" && r.title.trim()) out.title = r.title.trim();
88
+ if (typeof r.artist === "string" && r.artist.trim()) out.artist = r.artist.trim();
89
+ if (typeof r.album === "string" && r.album.trim()) out.album = r.album.trim();
90
+ if (typeof r.genre === "string" && r.genre.trim()) out.genre = r.genre.trim();
91
+ if (typeof r.trackNumber === "number") out.trackNumber = r.trackNumber;
92
+ if (typeof r.trackTotal === "number") out.trackTotal = r.trackTotal;
93
+ if (typeof r.year === "number") out.year = r.year;
94
+ return Object.keys(out).length ? out : undefined;
95
+ }
96
+
97
+ /** Decode a `data:<mime>;base64,<data>` URL into a Buffer, or null. */
98
+ function bufferFromDataUrl(value: unknown): Buffer | null {
99
+ if (typeof value !== "string") return null;
100
+ const match = /^data:[^;,]*;base64,([A-Za-z0-9+/=]+)$/.exec(value);
101
+ if (!match) return null;
102
+ try {
103
+ return Buffer.from(match[1] ?? "", "base64");
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Build POST /api/admin/upload/process. After the client direct-uploads a
111
+ * WAV to R2 via the presigned URL, this handler:
112
+ * 1. fetches the WAV stream out of storage
113
+ * 2. transcodes to 320kbps MP3 and embeds ID3 tags (via `@gigamusic/audio`)
114
+ * 3. uploads the MP3 back to storage at the sibling `.mp3` key
115
+ * 4. tags the original WAV in place
116
+ * 5. records both files against `trackId` via `queries.upsertTrackFile`
117
+ * (if `trackId` is supplied; new releases call this without an id
118
+ * because the track row isn't created yet — the release POST handler
119
+ * persists the files alongside the row).
120
+ */
121
+ export function createAdminUploadProcessHandler(deps: AdminDeps): RouteHandler {
122
+ return async (req) => {
123
+ if (!(await isAdminAuthenticated(deps.adminSessionSecret))) {
124
+ return unauthorized();
125
+ }
126
+
127
+ const body = (await safeJson(req)) as ProcessBody;
128
+ const key = typeof body.key === "string" ? body.key : "";
129
+ if (!key.endsWith(".wav")) {
130
+ return badRequest("A .wav storage key is required");
131
+ }
132
+
133
+ const metadata = parseMetadata(body.metadata);
134
+ const mp3Key = key.replace(/\.wav$/i, ".mp3");
135
+ const trackId =
136
+ typeof body.trackId === "number" && Number.isFinite(body.trackId)
137
+ ? body.trackId
138
+ : null;
139
+
140
+ let artBuffer = bufferFromDataUrl(body.artDataUrl);
141
+ if (
142
+ !artBuffer &&
143
+ typeof body.coverImageUrl === "string" &&
144
+ body.coverImageUrl
145
+ ) {
146
+ try {
147
+ artBuffer = await deps.storage.getFileBuffer(
148
+ deps.storage.keyFromPublicUrl(body.coverImageUrl),
149
+ );
150
+ } catch (err) {
151
+ deps.logger?.warn("cover-image fetch failed; proceeding without art", {
152
+ error: String(err),
153
+ });
154
+ }
155
+ }
156
+
157
+ const tmpId = randomUUID();
158
+ const wavPath = join(tmpdir(), `${tmpId}.wav`);
159
+ const taggedWavPath = join(tmpdir(), `${tmpId}.tagged.wav`);
160
+ const mp3Path = join(tmpdir(), `${tmpId}.mp3`);
161
+ const coverPath = artBuffer ? join(tmpdir(), `${tmpId}.img`) : undefined;
162
+
163
+ try {
164
+ if (artBuffer && coverPath) await writeFile(coverPath, artBuffer);
165
+
166
+ // 1. Pull the WAV down so the tag step can rewrite it in place.
167
+ const wavBuf = await deps.storage.getFileBuffer(key);
168
+ await writeFile(wavPath, wavBuf);
169
+
170
+ const tags = metadataToTags(metadata);
171
+
172
+ // 2. Tag the WAV.
173
+ await deps.audio.tagWav({
174
+ inputPath: wavPath,
175
+ outputPath: taggedWavPath,
176
+ tags,
177
+ });
178
+ await deps.storage.uploadBuffer(
179
+ await readFile(taggedWavPath),
180
+ key,
181
+ "audio/wav",
182
+ );
183
+
184
+ // 3. Generate 320kbps MP3 from the tagged WAV.
185
+ // We piggy-back on `generatePreview` for the ffmpeg call but request a
186
+ // ~24h duration so the full track is encoded (ffmpeg stops at EOF). The
187
+ // post-tag step then attaches ID3 frames + artwork — `generatePreview`
188
+ // intentionally strips metadata for the public 30s preview case.
189
+ await deps.audio.generatePreview({
190
+ inputPath: taggedWavPath,
191
+ outputPath: mp3Path,
192
+ startSeconds: 0,
193
+ durationSeconds: 24 * 60 * 60,
194
+ bitrateKbps: 320,
195
+ });
196
+
197
+ await deps.audio.tagMp3({
198
+ inputPath: mp3Path,
199
+ outputPath: mp3Path,
200
+ tags,
201
+ coverArt: artBuffer ?? undefined,
202
+ });
203
+
204
+ const mp3Stream = createReadStream(mp3Path);
205
+ const mp3Upload = await deps.storage.uploadStream(
206
+ mp3Stream,
207
+ mp3Key,
208
+ "audio/mpeg",
209
+ );
210
+
211
+ let wavFileSize = wavBuf.length;
212
+ try {
213
+ wavFileSize = (await stat(taggedWavPath)).size;
214
+ } catch {
215
+ // Best-effort; fall back to the pre-tag buffer length.
216
+ }
217
+ let mp3FileSize = 0;
218
+ try {
219
+ mp3FileSize = (await stat(mp3Path)).size;
220
+ } catch {
221
+ // Best-effort.
222
+ }
223
+
224
+ // 4. Persist file rows when the consumer supplied a trackId.
225
+ if (trackId != null) {
226
+ await deps.queries.upsertTrackFile({
227
+ trackId,
228
+ format: "wav",
229
+ fileName: basename(key),
230
+ storageKey: deps.storage.publicUrlFromKey(key),
231
+ fileSize: wavFileSize,
232
+ });
233
+ await deps.queries.upsertTrackFile({
234
+ trackId,
235
+ format: "mp3",
236
+ fileName: basename(mp3Key),
237
+ storageKey: mp3Upload.url,
238
+ fileSize: mp3FileSize,
239
+ });
240
+ }
241
+
242
+ return json({
243
+ wavUrl: deps.storage.publicUrlFromKey(key),
244
+ mp3Url: mp3Upload.url,
245
+ wavFileSize,
246
+ mp3FileSize,
247
+ });
248
+ } catch (err) {
249
+ deps.logger?.error("upload/process failed", { error: String(err) });
250
+ const message = err instanceof Error ? err.message : String(err);
251
+ return serverError("Transcoding failed", { mp3Error: message });
252
+ } finally {
253
+ await Promise.allSettled([
254
+ unlink(wavPath),
255
+ unlink(taggedWavPath),
256
+ unlink(mp3Path),
257
+ coverPath ? unlink(coverPath) : Promise.resolve(),
258
+ ]);
259
+ }
260
+ };
261
+ }
262
+
263
+ function basename(key: string): string {
264
+ const slash = key.lastIndexOf("/");
265
+ return slash < 0 ? key : key.slice(slash + 1);
266
+ }
267
+
268
+ function metadataToTags(meta: AudioMetadata | undefined) {
269
+ return {
270
+ title: meta?.title ?? "",
271
+ artist: meta?.artist ?? "",
272
+ album: meta?.album,
273
+ genre: meta?.genre,
274
+ trackNumber: meta?.trackNumber,
275
+ trackTotal: meta?.trackTotal,
276
+ date: meta?.year != null ? String(meta.year) : undefined,
277
+ };
278
+ }
279
+
280
+ async function safeJson(req: Request): Promise<unknown> {
281
+ try {
282
+ return await req.json();
283
+ } catch {
284
+ return {};
285
+ }
286
+ }