@gigamusic/admin 4.3.1 → 4.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gigamusic/admin",
3
- "version": "4.3.1",
3
+ "version": "4.4.0",
4
4
  "description": "Server-only admin API handler factories for the gigamusic platform (releases CRUD, tracks, uploads, settings, links, orders).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,10 +24,10 @@
24
24
  },
25
25
  "dependencies": {
26
26
  "zod": "^3.24.1",
27
- "@gigamusic/core": "4.2.0",
28
27
  "@gigamusic/audio": "4.3.0",
29
- "@gigamusic/db": "4.3.1",
30
- "@gigamusic/storage": "3.0.0"
28
+ "@gigamusic/core": "4.2.0",
29
+ "@gigamusic/storage": "3.0.0",
30
+ "@gigamusic/db": "4.4.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^22.10.5",
@@ -0,0 +1,187 @@
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 type { DefaultTables, Queries, QueryTables } from "@gigamusic/db";
6
+ import type { AudioTags } from "@gigamusic/audio";
7
+ import type { AdminDeps, RouteHandler } from "../lib/types";
8
+ import { badRequest, json, serverError } from "../lib/responses";
9
+
10
+ type RetagDeps<T extends QueryTables = DefaultTables> = Pick<
11
+ AdminDeps<T>,
12
+ "storage" | "audio" | "logger"
13
+ > & { queries: Queries<T> };
14
+
15
+ /** One already-uploaded track file to rewrite tags onto, in place. */
16
+ export interface RetagFile {
17
+ /** Track row to refresh `fileSize` on; omit for not-yet-persisted tracks. */
18
+ trackId?: number;
19
+ format: "wav" | "mp3";
20
+ /** Public URL the file already lives at (as stored in `track_files.storageKey`). */
21
+ storageKey: string;
22
+ fileName: string;
23
+ tags: AudioTags;
24
+ }
25
+
26
+ export interface RetagTrackFilesInput {
27
+ files: RetagFile[];
28
+ /** Release cover; fetched once and embedded as front-cover art on every file. */
29
+ coverImageUrl?: string;
30
+ /** Pre-resolved art bytes; takes precedence over `coverImageUrl` when set. */
31
+ coverArt?: Buffer;
32
+ }
33
+
34
+ export interface RetagFileResult {
35
+ trackId?: number;
36
+ format: "wav" | "mp3";
37
+ ok: boolean;
38
+ fileSize?: number;
39
+ error?: string;
40
+ }
41
+
42
+ const CONTENT_TYPE: Record<RetagFile["format"], string> = {
43
+ wav: "audio/wav",
44
+ mp3: "audio/mpeg",
45
+ };
46
+
47
+ /**
48
+ * Rewrite ID3/RIFF tags (and front-cover art) onto already-uploaded track
49
+ * files, in place, **without re-transcoding**. `tagWav` copies the PCM
50
+ * bit-for-bit (ffmpeg `-c:a copy`) and `tagMp3` is a `node-id3` frame write, so
51
+ * the only cost is the storage round-trip of each file's bytes.
52
+ *
53
+ * Consumers call this AFTER their own track sync has persisted the
54
+ * authoritative release/track metadata — it's the save-time counterpart to the
55
+ * provisional tagging done in `createAdminUploadProcessHandler` at upload time.
56
+ *
57
+ * Best-effort per file: a single file's failure is logged and recorded in the
58
+ * returned results, never thrown — stale metadata is non-fatal and must not
59
+ * roll back a save. Each successful file's `track_files.fileSize` is refreshed
60
+ * via `queries.upsertTrackFile`.
61
+ */
62
+ export async function retagTrackFiles<T extends QueryTables = DefaultTables>(
63
+ deps: RetagDeps<T>,
64
+ input: RetagTrackFilesInput,
65
+ ): Promise<RetagFileResult[]> {
66
+ const coverArt = input.coverArt ?? (await resolveCover(deps, input.coverImageUrl));
67
+
68
+ const results: RetagFileResult[] = [];
69
+ for (const file of input.files) {
70
+ results.push(await retagOne(deps, file, coverArt));
71
+ }
72
+ return results;
73
+ }
74
+
75
+ async function resolveCover<T extends QueryTables>(
76
+ deps: RetagDeps<T>,
77
+ coverImageUrl: string | undefined,
78
+ ): Promise<Buffer | undefined> {
79
+ if (!coverImageUrl) return undefined;
80
+ try {
81
+ return await deps.storage.getFileBuffer(
82
+ deps.storage.keyFromPublicUrl(coverImageUrl),
83
+ );
84
+ } catch (err) {
85
+ deps.logger?.warn("retag: cover-image fetch failed; proceeding without art", {
86
+ error: String(err),
87
+ });
88
+ return undefined;
89
+ }
90
+ }
91
+
92
+ async function retagOne<T extends QueryTables>(
93
+ deps: RetagDeps<T>,
94
+ file: RetagFile,
95
+ coverArt: Buffer | undefined,
96
+ ): Promise<RetagFileResult> {
97
+ const key = deps.storage.keyFromPublicUrl(file.storageKey);
98
+ const tmpId = randomUUID();
99
+ const inputPath = join(tmpdir(), `${tmpId}.in.${file.format}`);
100
+ const outputPath = join(tmpdir(), `${tmpId}.out.${file.format}`);
101
+
102
+ try {
103
+ const buf = await deps.storage.getFileBuffer(key);
104
+ await writeFile(inputPath, buf);
105
+
106
+ if (file.format === "wav") {
107
+ await deps.audio.tagWav({ inputPath, outputPath, tags: file.tags, coverArt });
108
+ } else {
109
+ await deps.audio.tagMp3({ inputPath, outputPath, tags: file.tags, coverArt });
110
+ }
111
+
112
+ const tagged = await readFile(outputPath);
113
+ await deps.storage.uploadBuffer(tagged, key, CONTENT_TYPE[file.format]);
114
+
115
+ let fileSize = tagged.length;
116
+ try {
117
+ fileSize = (await stat(outputPath)).size;
118
+ } catch {
119
+ // Best-effort; fall back to the in-memory buffer length.
120
+ }
121
+
122
+ if (file.trackId != null) {
123
+ await deps.queries.upsertTrackFile({
124
+ trackId: file.trackId,
125
+ format: file.format,
126
+ fileName: file.fileName,
127
+ storageKey: deps.storage.publicUrlFromKey(key),
128
+ fileSize,
129
+ });
130
+ }
131
+
132
+ return { trackId: file.trackId, format: file.format, ok: true, fileSize };
133
+ } catch (err) {
134
+ const error = err instanceof Error ? err.message : String(err);
135
+ deps.logger?.error("retag: file failed", {
136
+ trackId: file.trackId,
137
+ format: file.format,
138
+ error,
139
+ });
140
+ return { trackId: file.trackId, format: file.format, ok: false, error };
141
+ } finally {
142
+ await Promise.allSettled([unlink(inputPath), unlink(outputPath)]);
143
+ }
144
+ }
145
+
146
+ interface RetagBody {
147
+ files?: unknown;
148
+ coverImageUrl?: unknown;
149
+ }
150
+
151
+ /**
152
+ * Build POST /api/admin/retag — a thin HTTP wrapper over `retagTrackFiles` for
153
+ * consumers that prefer a route over an in-process call. Apps that re-tag from
154
+ * inside their release-save handler should call `retagTrackFiles` directly.
155
+ */
156
+ export function createAdminRetagTracksHandler<T extends QueryTables = DefaultTables>(
157
+ deps: RetagDeps<T>,
158
+ ): RouteHandler {
159
+ return async (req) => {
160
+ const body = (await safeJson(req)) as RetagBody;
161
+ if (!Array.isArray(body.files)) {
162
+ return badRequest("files[] is required");
163
+ }
164
+ const coverImageUrl =
165
+ typeof body.coverImageUrl === "string" ? body.coverImageUrl : undefined;
166
+ try {
167
+ const results = await retagTrackFiles(deps, {
168
+ files: body.files as RetagFile[],
169
+ coverImageUrl,
170
+ });
171
+ return json({ results });
172
+ } catch (err) {
173
+ deps.logger?.error("retag failed", { error: String(err) });
174
+ return serverError("Retag failed", {
175
+ detail: err instanceof Error ? err.message : String(err),
176
+ });
177
+ }
178
+ };
179
+ }
180
+
181
+ async function safeJson(req: Request): Promise<unknown> {
182
+ try {
183
+ return await req.json();
184
+ } catch {
185
+ return {};
186
+ }
187
+ }
@@ -72,7 +72,8 @@ export function createAdminUploadPresignHandler(deps: PresignDeps): RouteHandler
72
72
  };
73
73
  }
74
74
 
75
- interface AudioMetadata {
75
+ /** Client-supplied audio tag fields accepted by the upload/process endpoint. */
76
+ export interface UploadAudioMetadata {
76
77
  title?: string;
77
78
  artist?: string;
78
79
  album?: string;
@@ -87,10 +88,10 @@ interface AudioMetadata {
87
88
  * are trimmed: stray whitespace embeds into the file and trips up players
88
89
  * like Apple Music that treat otherwise-identical tracks as separate albums.
89
90
  */
90
- function parseMetadata(raw: unknown): AudioMetadata | undefined {
91
+ function parseMetadata(raw: unknown): UploadAudioMetadata | undefined {
91
92
  if (!raw || typeof raw !== "object") return undefined;
92
93
  const r = raw as Record<string, unknown>;
93
- const out: AudioMetadata = {};
94
+ const out: UploadAudioMetadata = {};
94
95
  if (typeof r.title === "string" && r.title.trim()) out.title = r.title.trim();
95
96
  if (typeof r.artist === "string" && r.artist.trim()) out.artist = r.artist.trim();
96
97
  if (typeof r.album === "string" && r.album.trim()) out.album = r.album.trim();
@@ -174,11 +175,13 @@ export function createAdminUploadProcessHandler<T extends QueryTables = DefaultT
174
175
 
175
176
  const tags = metadataToTags(metadata);
176
177
 
177
- // 2. Tag the WAV.
178
+ // 2. Tag the WAV (cover art included so the lossless master carries the
179
+ // same front-cover the MP3 gets).
178
180
  await deps.audio.tagWav({
179
181
  inputPath: wavPath,
180
182
  outputPath: taggedWavPath,
181
183
  tags,
184
+ coverArt: artBuffer ?? undefined,
182
185
  });
183
186
  await deps.storage.uploadBuffer(
184
187
  await readFile(taggedWavPath),
@@ -270,7 +273,7 @@ function basename(key: string): string {
270
273
  return slash < 0 ? key : key.slice(slash + 1);
271
274
  }
272
275
 
273
- function metadataToTags(meta: AudioMetadata | undefined) {
276
+ function metadataToTags(meta: UploadAudioMetadata | undefined) {
274
277
  return {
275
278
  title: meta?.title ?? "",
276
279
  artist: meta?.artist ?? "",
package/src/server.ts CHANGED
@@ -21,6 +21,16 @@ export {
21
21
  createAdminUploadPresignHandler,
22
22
  createAdminUploadProcessHandler,
23
23
  } from "./handlers/upload";
24
+ export type { UploadAudioMetadata } from "./handlers/upload";
25
+ export {
26
+ retagTrackFiles,
27
+ createAdminRetagTracksHandler,
28
+ } from "./handlers/retag";
29
+ export type {
30
+ RetagFile,
31
+ RetagTrackFilesInput,
32
+ RetagFileResult,
33
+ } from "./handlers/retag";
24
34
  export { createAdminSettingsHandlers } from "./handlers/settings";
25
35
  export { createAdminLinksHandlers } from "./handlers/links";
26
36
  export { createAdminOrdersHandler } from "./handlers/orders";