diff --git a/apps/api/lexicons/artist/defs.json b/apps/api/lexicons/artist/defs.json index ab76a1b8..9dac32ef 100644 --- a/apps/api/lexicons/artist/defs.json +++ b/apps/api/lexicons/artist/defs.json @@ -132,6 +132,21 @@ "minimum": 1 } } + }, + "artists": { + "type": "object", + "properties": { + "mbid": { + "type": "string", + "description": "The MusicBrainz Identifier (MBID) of the artist." + }, + "name": { + "type": "string", + "description": "The name of the artist.", + "minLength": 1, + "maxLength": 256 + } + } } } } diff --git a/apps/api/lexicons/scrobble/scrobble.json b/apps/api/lexicons/scrobble/scrobble.json index 69fcd1b7..9f528bba 100644 --- a/apps/api/lexicons/scrobble/scrobble.json +++ b/apps/api/lexicons/scrobble/scrobble.json @@ -29,6 +29,14 @@ "minLength": 1, "maxLength": 256 }, + "artists": { + "type": "array", + "description": "The artists of the song with MusicBrainz IDs.", + "items": { + "type": "ref", + "ref": "app.rocksky.artist.defs#artistMbid" + } + }, "albumArtist": { "type": "string", "description": "The album artist of the song.", diff --git a/apps/api/lexicons/song/song.json b/apps/api/lexicons/song/song.json index 1ee5f195..896efd2b 100644 --- a/apps/api/lexicons/song/song.json +++ b/apps/api/lexicons/song/song.json @@ -29,6 +29,14 @@ "minLength": 1, "maxLength": 256 }, + "artists": { + "type": "array", + "description": "The artists of the song with MusicBrainz IDs.", + "items": { + "type": "ref", + "ref": "app.rocksky.artist.defs#artistMbid" + } + }, "albumArtist": { "type": "string", "description": "The album artist of the song.", diff --git a/apps/api/pkl/defs/artist/defs.pkl b/apps/api/pkl/defs/artist/defs.pkl index 10a4d395..9c5fb4dc 100644 --- a/apps/api/pkl/defs/artist/defs.pkl +++ b/apps/api/pkl/defs/artist/defs.pkl @@ -161,4 +161,20 @@ defs = new Mapping { } } + + ["artists"] { + type = "object" + properties { + ["mbid"] = new StringType { + type = "string" + description = "The MusicBrainz Identifier (MBID) of the artist." + } + ["name"] = new StringType { + type = "string" + description = "The name of the artist." + minLength = 1 + maxLength = 256 + } + } + } } diff --git a/apps/api/pkl/defs/scrobble/scrobble.pkl b/apps/api/pkl/defs/scrobble/scrobble.pkl index d43e138c..3ebbcf31 100644 --- a/apps/api/pkl/defs/scrobble/scrobble.pkl +++ b/apps/api/pkl/defs/scrobble/scrobble.pkl @@ -25,6 +25,16 @@ defs = new Mapping { maxLength = 256 } + + + ["artists"] = new Array { + type = "array" + description = "The artists of the song with MusicBrainz IDs." + items = new Ref { + ref = "app.rocksky.artist.defs#artistMbid" + } + } + ["albumArtist"] = new StringType { type = "string" description = "The album artist of the song." diff --git a/apps/api/pkl/defs/song/song.pkl b/apps/api/pkl/defs/song/song.pkl index 1f274b9e..6a1faec5 100644 --- a/apps/api/pkl/defs/song/song.pkl +++ b/apps/api/pkl/defs/song/song.pkl @@ -25,6 +25,14 @@ defs = new Mapping { maxLength = 256 } + ["artists"] = new Array { + type = "array" + description = "The artists of the song with MusicBrainz IDs." + items = new Ref { + ref = "app.rocksky.artist.defs#artistMbid" + } + } + ["albumArtist"] = new StringType { type = "string" description = "The album artist of the song." diff --git a/apps/api/src/lexicon/lexicons.ts b/apps/api/src/lexicon/lexicons.ts index 222c3b51..f46cbec3 100644 --- a/apps/api/src/lexicon/lexicons.ts +++ b/apps/api/src/lexicon/lexicons.ts @@ -1720,6 +1720,21 @@ export const schemaDict = { }, }, }, + artists: { + type: "object", + properties: { + mbid: { + type: "string", + description: "The MusicBrainz Identifier (MBID) of the artist.", + }, + name: { + type: "string", + description: "The name of the artist.", + minLength: 1, + maxLength: 256, + }, + }, + }, }, }, AppRockskyArtistGetArtistAlbums: { @@ -3769,6 +3784,14 @@ export const schemaDict = { minLength: 1, maxLength: 256, }, + artists: { + type: "array", + description: "The artists of the song with MusicBrainz IDs.", + items: { + type: "ref", + ref: "lex:app.rocksky.artist.defs#artistMbid", + }, + }, albumArtist: { type: "string", description: "The album artist of the song.", @@ -4676,6 +4699,14 @@ export const schemaDict = { minLength: 1, maxLength: 256, }, + artists: { + type: "array", + description: "The artists of the song with MusicBrainz IDs.", + items: { + type: "ref", + ref: "lex:app.rocksky.artist.defs#artistMbid", + }, + }, albumArtist: { type: "string", description: "The album artist of the song.", diff --git a/apps/api/src/lexicon/types/app/rocksky/artist/defs.ts b/apps/api/src/lexicon/types/app/rocksky/artist/defs.ts index ce5684e6..57f96ca5 100644 --- a/apps/api/src/lexicon/types/app/rocksky/artist/defs.ts +++ b/apps/api/src/lexicon/types/app/rocksky/artist/defs.ts @@ -118,3 +118,23 @@ export function isListenerViewBasic(v: unknown): v is ListenerViewBasic { export function validateListenerViewBasic(v: unknown): ValidationResult { return lexicons.validate("app.rocksky.artist.defs#listenerViewBasic", v); } + +export interface Artists { + /** The MusicBrainz Identifier (MBID) of the artist. */ + mbid?: string; + /** The name of the artist. */ + name?: string; + [k: string]: unknown; +} + +export function isArtists(v: unknown): v is Artists { + return ( + isObj(v) && + hasProp(v, "$type") && + v.$type === "app.rocksky.artist.defs#artists" + ); +} + +export function validateArtists(v: unknown): ValidationResult { + return lexicons.validate("app.rocksky.artist.defs#artists", v); +} diff --git a/apps/api/src/lexicon/types/app/rocksky/scrobble.ts b/apps/api/src/lexicon/types/app/rocksky/scrobble.ts index d84bfacf..58d637b9 100644 --- a/apps/api/src/lexicon/types/app/rocksky/scrobble.ts +++ b/apps/api/src/lexicon/types/app/rocksky/scrobble.ts @@ -5,12 +5,15 @@ import type { ValidationResult, BlobRef } from "@atproto/lexicon"; import { lexicons } from "../../../lexicons"; import { isObj, hasProp } from "../../../util"; import { CID } from "multiformats/cid"; +import type * as AppRockskyArtistDefs from "./artist/defs"; export interface Record { /** The title of the song. */ title: string; /** The artist of the song. */ artist: string; + /** The artists of the song with MusicBrainz IDs. */ + artists?: AppRockskyArtistDefs.ArtistMbid[]; /** The album artist of the song. */ albumArtist: string; /** The album of the song. */ diff --git a/apps/api/src/lexicon/types/app/rocksky/song.ts b/apps/api/src/lexicon/types/app/rocksky/song.ts index 1d35a761..c7c6d271 100644 --- a/apps/api/src/lexicon/types/app/rocksky/song.ts +++ b/apps/api/src/lexicon/types/app/rocksky/song.ts @@ -5,12 +5,15 @@ import type { ValidationResult, BlobRef } from "@atproto/lexicon"; import { lexicons } from "../../../lexicons"; import { isObj, hasProp } from "../../../util"; import { CID } from "multiformats/cid"; +import type * as AppRockskyArtistDefs from "./artist/defs"; export interface Record { /** The title of the song. */ title: string; /** The artist of the song. */ artist: string; + /** The artists of the song with MusicBrainz IDs. */ + artists?: AppRockskyArtistDefs.ArtistMbid[]; /** The album artist of the song. */ albumArtist: string; /** The album of the song. */ diff --git a/apps/api/src/nowplaying/nowplaying.service.ts b/apps/api/src/nowplaying/nowplaying.service.ts index 56bccbfb..1d891969 100644 --- a/apps/api/src/nowplaying/nowplaying.service.ts +++ b/apps/api/src/nowplaying/nowplaying.service.ts @@ -26,7 +26,7 @@ import tealfm from "../tealfm"; export async function putArtistRecord( track: Track, - agent: Agent + agent: Agent, ): Promise { const rkey = TID.nextStr(); const record: Artist.Record = { @@ -62,7 +62,7 @@ export async function putArtistRecord( export async function putAlbumRecord( track: Track, - agent: Agent + agent: Agent, ): Promise { const rkey = TID.nextStr(); @@ -103,7 +103,7 @@ export async function putAlbumRecord( export async function putSongRecord( track: Track, - agent: Agent + agent: Agent, ): Promise { const rkey = TID.nextStr(); @@ -111,6 +111,7 @@ export async function putSongRecord( $type: "app.rocksky.song", title: track.title, artist: track.artist, + artists: track.artists === null ? undefined : track.artists, album: track.album, albumArtist: track.albumArtist, duration: track.duration, @@ -157,7 +158,7 @@ export async function putSongRecord( async function putScrobbleRecord( track: Track, - agent: Agent + agent: Agent, ): Promise { const rkey = TID.nextStr(); @@ -167,6 +168,7 @@ async function putScrobbleRecord( albumArtist: track.albumArtist, albumArtUrl: track.albumArt, artist: track.artist, + artists: track.artists === null ? undefined : track.artists, album: track.album, duration: track.duration, trackNumber: track.trackNumber, @@ -274,8 +276,8 @@ export async function publishScrobble(ctx: Context, id: string) { .where( and( eq(artistAlbums.albumId, scrobble.album.id), - eq(artistAlbums.artistId, scrobble.artist.id) - ) + eq(artistAlbums.artistId, scrobble.artist.id), + ), ) .limit(1) .then((rows) => rows[0]), @@ -438,12 +440,12 @@ export async function publishScrobble(ctx: Context, id: string) { }, }), null, - 2 + 2, ); ctx.nc.publish( "rocksky.scrobble", - Buffer.from(message.replaceAll("sha_256", "sha256")) + Buffer.from(message.replaceAll("sha_256", "sha256")), ); const trackMessage = JSON.stringify( @@ -490,12 +492,12 @@ export async function publishScrobble(ctx: Context, id: string) { xata_createdat: artist_album.createdAt.toISOString(), xata_updatedat: artist_album.updatedAt.toISOString(), }, - }) + }), ); ctx.nc.publish( "rocksky.track", - Buffer.from(trackMessage.replaceAll("sha_256", "sha256")) + Buffer.from(trackMessage.replaceAll("sha_256", "sha256")), ); } @@ -503,7 +505,7 @@ export async function scrobbleTrack( ctx: Context, track: Track, agent: Agent, - userDid: string + userDid: string, ): Promise { // check if scrobble already exists (user did + timestamp) const scrobbleTime = dayjs.unix(track.timestamp || dayjs().unix()); @@ -522,8 +524,8 @@ export async function scrobbleTrack( eq(tracks.title, track.title), eq(tracks.artist, track.artist), gte(scrobbles.timestamp, scrobbleTime.subtract(5, "seconds").toDate()), - lte(scrobbles.timestamp, scrobbleTime.add(5, "seconds").toDate()) - ) + lte(scrobbles.timestamp, scrobbleTime.add(5, "seconds").toDate()), + ), ) .limit(1) .then((rows) => rows[0]); @@ -531,8 +533,8 @@ export async function scrobbleTrack( if (existingScrobble) { console.log( `Scrobble already exists for ${chalk.cyan(track.title)} at ${chalk.cyan( - scrobbleTime.format("YYYY-MM-DD HH:mm:ss") - )}` + scrobbleTime.format("YYYY-MM-DD HH:mm:ss"), + )}`, ); return; } @@ -545,10 +547,10 @@ export async function scrobbleTrack( tracks.sha256, createHash("sha256") .update( - `${track.title} - ${track.artist} - ${track.album}`.toLowerCase() + `${track.title} - ${track.artist} - ${track.album}`.toLowerCase(), ) - .digest("hex") - ) + .digest("hex"), + ), ) .limit(1) .then((rows) => rows[0]); @@ -562,8 +564,8 @@ export async function scrobbleTrack( albums.sha256, createHash("sha256") .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) - .digest("hex") - ) + .digest("hex"), + ), ) .limit(1) .then((rows) => rows[0]); @@ -584,8 +586,8 @@ export async function scrobbleTrack( artists.sha256, createHash("sha256") .update(track.albumArtist.toLowerCase()) - .digest("hex") - ) + .digest("hex"), + ), ) .limit(1) .then((rows) => rows[0]); @@ -616,7 +618,7 @@ export async function scrobbleTrack( artist: track.artist.split(",").map((a) => ({ name: a.trim() })), name: track.title, album: track.album, - } + }, ); if (!mbTrack?.trackMBID) { @@ -628,6 +630,10 @@ export async function scrobbleTrack( } track.mbId = mbTrack?.trackMBID; + track.artists = mbTrack?.artist?.map((artist) => ({ + mbid: artist.mbid, + name: artist.name, + })); if (!existingTrack?.uri || !userTrack?.userTrack.uri?.includes(userDid)) { await putSongRecord(track, agent); @@ -641,8 +647,8 @@ export async function scrobbleTrack( albums.sha256, createHash("sha256") .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) - .digest("hex") - ) + .digest("hex"), + ), ) .limit(1) .then((rows) => rows[0]); @@ -658,10 +664,10 @@ export async function scrobbleTrack( tracks.sha256, createHash("sha256") .update( - `${track.title} - ${track.artist} - ${track.album}`.toLowerCase() + `${track.title} - ${track.artist} - ${track.album}`.toLowerCase(), ) - .digest("hex") - ) + .digest("hex"), + ), ) .limit(1) .then((rows) => rows[0]); @@ -675,7 +681,7 @@ export async function scrobbleTrack( if (existingTrack) { console.log( - `Song found: ${chalk.cyan(existingTrack.id)} - ${track.title}, after ${chalk.magenta(tries)} tries` + `Song found: ${chalk.cyan(existingTrack.id)} - ${track.title}, after ${chalk.magenta(tries)} tries`, ); } @@ -688,13 +694,13 @@ export async function scrobbleTrack( artists.sha256, createHash("sha256") .update(track.albumArtist.toLowerCase()) - .digest("hex") + .digest("hex"), ), eq( artists.sha256, - createHash("sha256").update(track.artist.toLowerCase()).digest("hex") - ) - ) + createHash("sha256").update(track.artist.toLowerCase()).digest("hex"), + ), + ), ) .limit(1) .then((rows) => rows[0]); @@ -709,7 +715,7 @@ export async function scrobbleTrack( .innerJoin(artists, eq(userArtists.artistId, artists.id)) .innerJoin(users, eq(userArtists.userId, users.id)) .where( - and(eq(artists.id, existingArtist?.id || ""), eq(users.did, userDid)) + and(eq(artists.id, existingArtist?.id || ""), eq(users.did, userDid)), ) .limit(1) .then((rows) => rows[0]); @@ -744,17 +750,17 @@ export async function scrobbleTrack( tracks.sha256, createHash("sha256") .update( - `${track.title} - ${track.artist} - ${track.album}`.toLowerCase() + `${track.title} - ${track.artist} - ${track.album}`.toLowerCase(), ) - .digest("hex") - ) + .digest("hex"), + ), ) .limit(1) .then((rows) => rows[0]); while (!existingTrack?.artistUri && !existingTrack?.albumUri && tries < 30) { console.log( - `Artist uri not ready, trying again: ${chalk.magenta(tries + 1)}` + `Artist uri not ready, trying again: ${chalk.magenta(tries + 1)}`, ); existingTrack = await ctx.db .select() @@ -764,10 +770,10 @@ export async function scrobbleTrack( tracks.sha256, createHash("sha256") .update( - `${track.title} - ${track.artist} - ${track.album}`.toLowerCase() + `${track.title} - ${track.artist} - ${track.album}`.toLowerCase(), ) - .digest("hex") - ) + .digest("hex"), + ), ) .limit(1) .then((rows) => rows[0]); @@ -782,8 +788,8 @@ export async function scrobbleTrack( artists.sha256, createHash("sha256") .update(track.albumArtist.toLowerCase()) - .digest("hex") - ) + .digest("hex"), + ), ) .limit(1) .then((rows) => rows[0]); @@ -806,8 +812,8 @@ export async function scrobbleTrack( albums.sha256, createHash("sha256") .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) - .digest("hex") - ) + .digest("hex"), + ), ) .limit(1) .then((rows) => rows[0]); @@ -837,7 +843,7 @@ export async function scrobbleTrack( if (existingTrack?.artistUri) { console.log( - `Artist uri ready: ${chalk.cyan(existingTrack.id)} - ${track.title}, after ${chalk.magenta(tries)} tries` + `Artist uri ready: ${chalk.cyan(existingTrack.id)} - ${track.title}, after ${chalk.magenta(tries)} tries`, ); } @@ -848,7 +854,7 @@ export async function scrobbleTrack( await tealfm.publishPlayingNow( agent, mbTrack, - Math.floor(track.duration / 1000) + Math.floor(track.duration / 1000), ); } diff --git a/apps/api/src/tealfm/index.ts b/apps/api/src/tealfm/index.ts index 781eaf23..c0770648 100644 --- a/apps/api/src/tealfm/index.ts +++ b/apps/api/src/tealfm/index.ts @@ -9,7 +9,7 @@ const SUBMISSION_CLIENT_AGENT = "rocksky/v0.0.1"; async function publishPlayingNow( agent: Agent, track: MusicbrainzTrack, - duration: number + duration: number, ) { try { const rkey = TID.nextStr(); diff --git a/apps/api/src/types/track.ts b/apps/api/src/types/track.ts index d3a5b01d..8c8b2ef0 100644 --- a/apps/api/src/types/track.ts +++ b/apps/api/src/types/track.ts @@ -3,6 +3,15 @@ import z from "zod"; export const trackSchema = z.object({ title: z.string().nonempty().trim(), artist: z.string().nonempty().trim(), + artists: z + .array( + z.object({ + mbid: z.string().optional().nullable(), + name: z.string().nonempty().trim(), + }), + ) + .optional() + .nullable(), album: z.string().nonempty().trim(), albumArtist: z.string().nonempty().trim(), duration: z.number(),