Something went wrong. Try again.
A decentralized music tracking and discovery platform built on AT Protocol 🎵 rocksky.app
spotify atproto lastfm musicbrainz scrobbling listenbrainz
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381import chalk from "chalk";import { consola } from "consola";import type { Context } from "context";import { eq } from "drizzle-orm";import { createHash } from "node:crypto";import tables from "schema";import type { InsertDiscogsCredit } from "schema/discogs-credits";import type { InsertDiscogsRelease, SelectDiscogsRelease,} from "schema/discogs-releases";import type { SelectDiscogsSearch } from "schema/discogs-searches";import { type DiscogsCredit, type DiscogsEnrichResponse, type DiscogsEnrichedTrack, type DiscogsRelease, enrichWithDiscogs, getDiscogsMaster, getDiscogsRelease,} from "./discogs";import { toIdentifierRows, toMasterRow, toReleaseArtistRows, toReleaseLabelRows, toTrackRows,} from "./discogsRelation";
// Discogs keeps being added to, so a miss is retried eventually rather than// treated as settled; a hit is never re-asked.export const MISS_RETRY_MS = 30 * 24 * 60 * 60 * 1000;
// "missed" is Discogs answering that it has nothing, and is recorded.// "unavailable" is the service not answering at all, which records nothing so// the next pass asks again — the backfill needs to tell those apart.export type DiscogsLookup = | { status: "matched"; release: SelectDiscogsRelease } | { status: "missed" } | { status: "unavailable" } | { status: "skipped" };
export const discogsSearchKey = (artist: string, album: string) => createHash("sha256") .update(`${album.trim()} - ${artist.trim()}`.toLowerCase()) .digest("hex");
export const isStaleSearch = ( search: Pick<SelectDiscogsSearch, "releaseId" | "searchedAt">, now = Date.now(),) => !search.releaseId && now - new Date(search.searchedAt).getTime() > MISS_RETRY_MS;
// The album rows a scrobble writes are keyed this way, so the lookup can find// the album to link without carrying its id around.const albumKey = (album: string, albumArtist: string) => createHash("sha256") .update(`${album} - ${albumArtist}`.toLowerCase()) .digest("hex");
export const toDiscogsCreditRows = ( releaseId: string, credits: DiscogsCredit[] = [],): InsertDiscogsCredit[] => credits .filter((credit) => credit.name?.trim()) .map((credit, position) => ({ releaseId, artistId: credit.artistId || null, name: credit.name.trim(), role: credit.role?.trim() || null, tracks: credit.tracks?.trim() || null, position, }));
export const toDiscogsReleaseRow = ( track: DiscogsEnrichedTrack, score?: number,): InsertDiscogsRelease | undefined => { if (!track.discogsReleaseId || !track.album || !track.artist) { return undefined; } return { discogsId: track.discogsReleaseId, masterId: track.discogsMasterId ?? null, title: track.album, artist: track.albumArtist || track.artist, albumArt: track.albumArt ?? null, year: track.year ?? null, originalYear: track.originalYear ?? null, releaseDate: track.releaseDate ?? null, country: track.country ?? null, label: track.label ?? null, catalogNumber: track.catalogNumber ?? null, barcode: track.barcode ?? null, formats: track.formats ?? null, genres: track.genres ?? null, styles: track.styles ?? null, discogsUrl: track.discogsUrl ?? null, score: score ?? null, };};
// The response carries no score of its own; it belongs to the candidate that// was deep-fetched.export const matchScore = (response: DiscogsEnrichResponse) => response.matches.find((m) => m.id === response.track?.discogsReleaseId) ?.score ?? response.matches[0]?.score;
/** * Look an artist+album up on Discogs and store the match, answering from our * own tables whenever they already know. Never throws and never blocks * anything a scrobble depends on: every failure is logged and dropped. */export const enrichAlbumWithDiscogs = async ( ctx: Context, artist: string, album: string, albumId?: string,): Promise<DiscogsLookup> => { if (!artist?.trim() || !album?.trim()) { return { status: "skipped" }; }
const sha256 = discogsSearchKey(artist, album); const existing = await ctx.db .select() .from(tables.discogsSearches) .where(eq(tables.discogsSearches.sha256, sha256)) .limit(1) .then((rows) => rows[0]);
if (existing && !isStaleSearch(existing)) { if (!existing.releaseId) { return { status: "missed" }; } const release = await ctx.db .select() .from(tables.discogsReleases) .where(eq(tables.discogsReleases.id, existing.releaseId)) .limit(1) .then((rows) => rows[0]); // A release stored before credits were collected is asked again, once. if (release?.creditsFetchedAt) { await linkAlbum(ctx, { album, artist, albumId }, release.id); return { status: "matched", release }; } }
const response = await enrichWithDiscogs(ctx, { artist, album }); if (!response) { // The service could not answer, so the question stays open rather than // being recorded as a miss for the next month. return { status: "unavailable" }; }
const row = response.track ? toDiscogsReleaseRow(response.track, matchScore(response)) : undefined; if (!row) { await recordSearch(ctx, { sha256, artist, album, releaseId: null }); consola.info(`No Discogs match for ${chalk.cyan(`${artist} - ${album}`)}`); return { status: "missed" }; }
const release = await ctx.db .insert(tables.discogsReleases) .values({ ...row, creditsFetchedAt: null }) .onConflictDoUpdate({ target: tables.discogsReleases.discogsId, set: { ...row, creditsFetchedAt: null, updatedAt: new Date() }, }) .returning() .then((rows) => rows[0]);
await storeCredits(ctx, release.id, response.track?.credits); await storeRelations(ctx, release.id, release.discogsId, release.masterId);
// Mark completion only after child records are persisted. A dropped // connection must not make a retry accept a partially stored release. const completedAt = new Date(); await ctx.db .update(tables.discogsReleases) .set({ creditsFetchedAt: completedAt, updatedAt: completedAt }) .where(eq(tables.discogsReleases.id, release.id)); release.creditsFetchedAt = completedAt;
await recordSearch(ctx, { sha256, artist, album, releaseId: release.id, score: row.score ?? null, }); await linkAlbum(ctx, { album, artist, albumId }, release.id);
consola.info( `Discogs matched ${chalk.cyan(`${artist} - ${album}`)} to release ${chalk.green(release.discogsId)}`, ); return { status: "matched", release };};
// Credits are replaced rather than merged: Discogs is the only source, and a// re-fetch can drop or reorder them.const storeCredits = async ( ctx: Context, releaseId: string, credits?: DiscogsCredit[],) => { const rows = toDiscogsCreditRows(releaseId, credits); await ctx.db.transaction(async (tx) => { await tx .delete(tables.discogsCredits) .where(eq(tables.discogsCredits.releaseId, releaseId)); if (rows.length > 0) { await tx.insert(tables.discogsCredits).values(rows); } });};
// The full release comes back from the service's own cache (the /enrich// deep-fetch already paid for it), so the relational half costs no quota. The// master can cost a request, so it is only asked for when it is not stored.const storeRelations = async ( ctx: Context, releaseRowId: string, discogsId: number, masterId: number | null,) => { const release = await getDiscogsRelease(ctx, discogsId); if (release) { await writeReleaseChildren(ctx, releaseRowId, release); } if (masterId) { await storeMaster(ctx, masterId); }};
const writeReleaseChildren = async ( ctx: Context, releaseId: string, release: DiscogsRelease,) => { const artists = toReleaseArtistRows(releaseId, release.artists); const labels = toReleaseLabelRows(releaseId, release); const identifiers = toIdentifierRows(releaseId, release.identifiers); const tracks = toTrackRows(releaseId, release.tracklist);
await ctx.db.transaction(async (tx) => { await tx .delete(tables.discogsReleaseArtists) .where(eq(tables.discogsReleaseArtists.releaseId, releaseId)); await tx .delete(tables.discogsReleaseLabels) .where(eq(tables.discogsReleaseLabels.releaseId, releaseId)); await tx .delete(tables.discogsIdentifiers) .where(eq(tables.discogsIdentifiers.releaseId, releaseId)); await tx .delete(tables.discogsTracks) .where(eq(tables.discogsTracks.releaseId, releaseId));
if (artists.length > 0) { await tx.insert(tables.discogsReleaseArtists).values(artists); } if (labels.length > 0) { await tx.insert(tables.discogsReleaseLabels).values(labels); } if (identifiers.length > 0) { await tx.insert(tables.discogsIdentifiers).values(identifiers); } if (tracks.length > 0) { await tx.insert(tables.discogsTracks).values(tracks); } });};
const storeMaster = async (ctx: Context, masterId: number) => { const stored = await ctx.db .select({ id: tables.discogsMasters.id }) .from(tables.discogsMasters) .where(eq(tables.discogsMasters.discogsId, masterId)) .limit(1) .then((rows) => rows[0]); if (stored) { return; }
const master = await getDiscogsMaster(ctx, masterId); const row = master ? toMasterRow(master) : undefined; if (!row) { return; } await ctx.db .insert(tables.discogsMasters) .values(row) .onConflictDoUpdate({ target: tables.discogsMasters.discogsId, set: { ...row, updatedAt: new Date() }, });};
const recordSearch = async ( ctx: Context, values: { sha256: string; artist: string; album: string; releaseId: string | null; score?: number | null; },) => { const searchedAt = new Date(); await ctx.db .insert(tables.discogsSearches) .values({ ...values, searchedAt }) .onConflictDoUpdate({ target: tables.discogsSearches.sha256, set: { releaseId: values.releaseId, score: values.score ?? null, searchedAt, updatedAt: searchedAt, }, });};
// The link is only ever filled in, never overwritten. A caller that already// has the album row says so; the scrobble path only knows the track, so it// finds the album under the hash a scrobble writes it with.const linkAlbum = async ( ctx: Context, target: { album: string; artist: string; albumId?: string }, releaseId: string,) => { const existing = await ctx.db .select() .from(tables.albums) .where( target.albumId ? eq(tables.albums.id, target.albumId) : eq(tables.albums.sha256, albumKey(target.album, target.artist)), ) .limit(1) .then((rows) => rows[0]);
if (!existing || existing.discogsReleaseId) { return; } await ctx.db .update(tables.albums) .set({ discogsReleaseId: releaseId }) .where(eq(tables.albums.id, existing.id));};
/** * Fire-and-forget Discogs enrichment for a scrobbled track. Called from the * new-scrobble subscriber, which has already answered the scrobbler. */export const enrichScrobbleWithDiscogs = async ( ctx: Context, trackId: string,) => { const track = await ctx.db .select() .from(tables.tracks) .where(eq(tables.tracks.id, trackId)) .limit(1) .then((rows) => rows[0]);
if (!track) { return; } await enrichAlbumWithDiscogs( ctx, track.albumArtist || track.artist, track.album, );};