Initial commit
This commit is contained in:
235
apps/server/src/services/trackIngest.ts
Normal file
235
apps/server/src/services/trackIngest.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { db } from "../db/index.js";
|
||||
import { albums, artists, trackArtists, tracks } from "../db/schema.js";
|
||||
import { env } from "../env.js";
|
||||
import { enrichTrack } from "../lib/metadata/index.js";
|
||||
import { putBuffer } from "../lib/storage.js";
|
||||
import { logger } from "../lib/logger.js";
|
||||
|
||||
export function mediaUrl(key: string): string {
|
||||
return `/api/media/${key}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads an external image (Cover Art Archive / Wikipedia) into object storage
|
||||
* and returns a local /api/media URL, so we stop depending on third-party services.
|
||||
*/
|
||||
export async function localizeExternalImage(
|
||||
url: string | undefined,
|
||||
folder: string,
|
||||
): Promise<string | undefined> {
|
||||
if (!url) return undefined;
|
||||
if (url.startsWith("/api/media/")) return url;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
headers: { "User-Agent": env.MB_USER_AGENT, Accept: "image/*" },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
if (!res.ok) return url;
|
||||
const contentType = (res.headers.get("content-type") ?? "image/jpeg").split(";")[0];
|
||||
if (!contentType.startsWith("image/")) return url;
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
if (buffer.length === 0 || buffer.length > 12 * 1024 * 1024) return url;
|
||||
const ext = (contentType.split("/")[1] ?? "jpg").replace("jpeg", "jpg").replace("svg+xml", "svg");
|
||||
const key = `${folder}/${randomUUID()}.${ext}`;
|
||||
await putBuffer(key, buffer, contentType);
|
||||
return mediaUrl(key);
|
||||
} catch (err) {
|
||||
logger.debug({ err, url }, "failed to localize image, keeping remote url");
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
export async function findOrCreateArtist(
|
||||
name: string,
|
||||
opts: { mbArtistId?: string; description?: string; imageUrl?: string },
|
||||
): Promise<string> {
|
||||
let existing;
|
||||
if (opts.mbArtistId) {
|
||||
[existing] = await db.select().from(artists).where(eq(artists.mbArtistId, opts.mbArtistId)).limit(1);
|
||||
}
|
||||
if (!existing) {
|
||||
[existing] = await db
|
||||
.select()
|
||||
.from(artists)
|
||||
.where(sql`lower(${artists.name}) = ${name.toLowerCase()}`)
|
||||
.limit(1);
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
const patch: Partial<typeof artists.$inferInsert> = { updatedAt: new Date() };
|
||||
if (!existing.mbArtistId && opts.mbArtistId) patch.mbArtistId = opts.mbArtistId;
|
||||
if (!existing.description && opts.description) patch.description = opts.description;
|
||||
if (!existing.imageUrl && opts.imageUrl) patch.imageUrl = opts.imageUrl;
|
||||
if (Object.keys(patch).length > 1) {
|
||||
await db.update(artists).set(patch).where(eq(artists.id, existing.id));
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(artists)
|
||||
.values({
|
||||
name,
|
||||
mbArtistId: opts.mbArtistId,
|
||||
description: opts.description,
|
||||
imageUrl: opts.imageUrl,
|
||||
metadata: { provenance: "auto" },
|
||||
})
|
||||
.returning();
|
||||
return created.id;
|
||||
}
|
||||
|
||||
async function findOrCreateAlbum(
|
||||
title: string,
|
||||
artistId: string | null,
|
||||
opts: { mbReleaseGroupId?: string; mbReleaseId?: string; releaseDate?: string; coverUrl?: string },
|
||||
): Promise<string> {
|
||||
let existing;
|
||||
if (opts.mbReleaseGroupId) {
|
||||
[existing] = await db
|
||||
.select()
|
||||
.from(albums)
|
||||
.where(eq(albums.mbReleaseGroupId, opts.mbReleaseGroupId))
|
||||
.limit(1);
|
||||
}
|
||||
if (!existing) {
|
||||
[existing] = await db
|
||||
.select()
|
||||
.from(albums)
|
||||
.where(
|
||||
and(sql`lower(${albums.title}) = ${title.toLowerCase()}`, artistId ? eq(albums.artistId, artistId) : sql`true`),
|
||||
)
|
||||
.limit(1);
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
const patch: Partial<typeof albums.$inferInsert> = { updatedAt: new Date() };
|
||||
if (!existing.coverUrl && opts.coverUrl) patch.coverUrl = opts.coverUrl;
|
||||
if (!existing.releaseDate && opts.releaseDate) patch.releaseDate = opts.releaseDate;
|
||||
if (!existing.mbReleaseGroupId && opts.mbReleaseGroupId) patch.mbReleaseGroupId = opts.mbReleaseGroupId;
|
||||
if (!existing.mbReleaseId && opts.mbReleaseId) patch.mbReleaseId = opts.mbReleaseId;
|
||||
if (Object.keys(patch).length > 1) {
|
||||
await db.update(albums).set(patch).where(eq(albums.id, existing.id));
|
||||
}
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const [created] = await db
|
||||
.insert(albums)
|
||||
.values({
|
||||
title,
|
||||
artistId,
|
||||
mbReleaseGroupId: opts.mbReleaseGroupId,
|
||||
mbReleaseId: opts.mbReleaseId,
|
||||
releaseDate: opts.releaseDate,
|
||||
coverUrl: opts.coverUrl,
|
||||
metadata: { provenance: "auto" },
|
||||
})
|
||||
.returning();
|
||||
return created.id;
|
||||
}
|
||||
|
||||
export async function ingestTrack(input: {
|
||||
trackId: string;
|
||||
filePath: string;
|
||||
originalName?: string;
|
||||
mimeType?: string;
|
||||
}): Promise<void> {
|
||||
const [track] = await db.select().from(tracks).where(eq(tracks.id, input.trackId)).limit(1);
|
||||
if (!track) {
|
||||
logger.warn({ trackId: input.trackId }, "ingest: track not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const { tags, data } = await enrichTrack({
|
||||
filePath: input.filePath,
|
||||
originalName: input.originalName,
|
||||
});
|
||||
|
||||
// Localize external cover art so we don't keep hitting third-party services.
|
||||
let coverUrl = await localizeExternalImage(data.coverUrl, "covers");
|
||||
if (!coverUrl && tags.pictureBuffer) {
|
||||
const ext = (tags.pictureMime?.split("/")[1] ?? "jpg").replace("jpeg", "jpg");
|
||||
const key = `covers/${randomUUID()}.${ext}`;
|
||||
await putBuffer(key, tags.pictureBuffer, tags.pictureMime ?? "image/jpeg");
|
||||
coverUrl = mediaUrl(key);
|
||||
}
|
||||
|
||||
const credits =
|
||||
data.artists?.length
|
||||
? data.artists
|
||||
: [{ name: data.artistName ?? "Неизвестный исполнитель", role: "main" as const }];
|
||||
const artistImageUrl = await localizeExternalImage(data.artistImageUrl, "artists");
|
||||
|
||||
const resolvedArtists: { artistId: string; position: number; role: string }[] = [];
|
||||
for (let i = 0; i < credits.length; i++) {
|
||||
const credit = credits[i];
|
||||
const artistId = await findOrCreateArtist(credit.name, {
|
||||
mbArtistId: credit.mbArtistId,
|
||||
description: i === 0 ? data.artistDescription : undefined,
|
||||
imageUrl: i === 0 ? artistImageUrl : undefined,
|
||||
});
|
||||
resolvedArtists.push({ artistId, position: i, role: credit.role ?? "main" });
|
||||
}
|
||||
const artistId = resolvedArtists[0].artistId;
|
||||
|
||||
let albumId: string | null = null;
|
||||
if (data.albumTitle) {
|
||||
albumId = await findOrCreateAlbum(data.albumTitle, artistId, {
|
||||
mbReleaseGroupId: data.mbReleaseGroupId,
|
||||
mbReleaseId: data.mbReleaseId,
|
||||
releaseDate: data.releaseDate,
|
||||
coverUrl,
|
||||
});
|
||||
}
|
||||
|
||||
await db
|
||||
.update(tracks)
|
||||
.set({
|
||||
title: data.title ?? track.title,
|
||||
artistId,
|
||||
albumId,
|
||||
durationMs: data.durationMs ?? track.durationMs,
|
||||
trackNumber: data.trackNumber ?? track.trackNumber,
|
||||
discNumber: data.discNumber ?? track.discNumber,
|
||||
releaseDate: data.releaseDate ?? track.releaseDate,
|
||||
lyricsPlain: data.lyricsPlain ?? track.lyricsPlain,
|
||||
lyricsSynced: data.lyricsSynced ?? track.lyricsSynced,
|
||||
lyricsSource: data.lyricsSource ?? track.lyricsSource,
|
||||
fingerprint: data.fingerprint ?? track.fingerprint,
|
||||
mbRecordingId: data.mbRecordingId ?? track.mbRecordingId,
|
||||
coverUrl: coverUrl ?? track.coverUrl,
|
||||
mimeType: input.mimeType ?? track.mimeType,
|
||||
metadata: {
|
||||
...(track.metadata ?? {}),
|
||||
processing: false,
|
||||
enrichment: data,
|
||||
sources: data.sources,
|
||||
},
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(tracks.id, track.id));
|
||||
|
||||
// Replace the artist credits for this track (idempotent on re-ingest)
|
||||
await db.delete(trackArtists).where(eq(trackArtists.trackId, track.id));
|
||||
await db.insert(trackArtists).values(
|
||||
resolvedArtists.map((a) => ({
|
||||
trackId: track.id,
|
||||
artistId: a.artistId,
|
||||
position: a.position,
|
||||
role: a.role,
|
||||
})),
|
||||
);
|
||||
|
||||
logger.info(
|
||||
{
|
||||
trackId: track.id,
|
||||
title: data.title,
|
||||
artists: credits.map((c) => c.name),
|
||||
sources: data.sources,
|
||||
},
|
||||
"track ingested",
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user