@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 +4 -4
- package/src/handlers/retag.ts +187 -0
- package/src/handlers/upload.ts +8 -5
- package/src/server.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gigamusic/admin",
|
|
3
|
-
"version": "4.
|
|
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/
|
|
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
|
+
}
|
package/src/handlers/upload.ts
CHANGED
|
@@ -72,7 +72,8 @@ export function createAdminUploadPresignHandler(deps: PresignDeps): RouteHandler
|
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
|
|
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):
|
|
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:
|
|
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:
|
|
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";
|