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 { 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 { 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 = { 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 { 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 = { 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 { 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", ); }