A decentralized music tracking and discovery platform built on AT Protocol 🎵 rocksky.app
spotify atproto lastfm musicbrainz scrobbling listenbrainz

feat: add artists property to song and scrobble records for MusicBrainz IDs

+185 -48
+15
apps/api/lexicons/artist/defs.json
··· 132 132 "minimum": 1 133 133 } 134 134 } 135 + }, 136 + "artists": { 137 + "type": "object", 138 + "properties": { 139 + "mbid": { 140 + "type": "string", 141 + "description": "The MusicBrainz Identifier (MBID) of the artist." 142 + }, 143 + "name": { 144 + "type": "string", 145 + "description": "The name of the artist.", 146 + "minLength": 1, 147 + "maxLength": 256 148 + } 149 + } 135 150 } 136 151 } 137 152 }
+8
apps/api/lexicons/scrobble/scrobble.json
··· 29 29 "minLength": 1, 30 30 "maxLength": 256 31 31 }, 32 + "artists": { 33 + "type": "array", 34 + "description": "The artists of the song with MusicBrainz IDs.", 35 + "items": { 36 + "type": "ref", 37 + "ref": "app.rocksky.artist.defs#artistMbid" 38 + } 39 + }, 32 40 "albumArtist": { 33 41 "type": "string", 34 42 "description": "The album artist of the song.",
+8
apps/api/lexicons/song/song.json
··· 29 29 "minLength": 1, 30 30 "maxLength": 256 31 31 }, 32 + "artists": { 33 + "type": "array", 34 + "description": "The artists of the song with MusicBrainz IDs.", 35 + "items": { 36 + "type": "ref", 37 + "ref": "app.rocksky.artist.defs#artistMbid" 38 + } 39 + }, 32 40 "albumArtist": { 33 41 "type": "string", 34 42 "description": "The album artist of the song.",
+16
apps/api/pkl/defs/artist/defs.pkl
··· 161 161 162 162 } 163 163 } 164 + 165 + ["artists"] { 166 + type = "object" 167 + properties { 168 + ["mbid"] = new StringType { 169 + type = "string" 170 + description = "The MusicBrainz Identifier (MBID) of the artist." 171 + } 172 + ["name"] = new StringType { 173 + type = "string" 174 + description = "The name of the artist." 175 + minLength = 1 176 + maxLength = 256 177 + } 178 + } 179 + } 164 180 }
+10
apps/api/pkl/defs/scrobble/scrobble.pkl
··· 25 25 maxLength = 256 26 26 } 27 27 28 + 29 + 30 + ["artists"] = new Array { 31 + type = "array" 32 + description = "The artists of the song with MusicBrainz IDs." 33 + items = new Ref { 34 + ref = "app.rocksky.artist.defs#artistMbid" 35 + } 36 + } 37 + 28 38 ["albumArtist"] = new StringType { 29 39 type = "string" 30 40 description = "The album artist of the song."
+8
apps/api/pkl/defs/song/song.pkl
··· 25 25 maxLength = 256 26 26 } 27 27 28 + ["artists"] = new Array { 29 + type = "array" 30 + description = "The artists of the song with MusicBrainz IDs." 31 + items = new Ref { 32 + ref = "app.rocksky.artist.defs#artistMbid" 33 + } 34 + } 35 + 28 36 ["albumArtist"] = new StringType { 29 37 type = "string" 30 38 description = "The album artist of the song."
+31
apps/api/src/lexicon/lexicons.ts
··· 1720 1720 }, 1721 1721 }, 1722 1722 }, 1723 + artists: { 1724 + type: "object", 1725 + properties: { 1726 + mbid: { 1727 + type: "string", 1728 + description: "The MusicBrainz Identifier (MBID) of the artist.", 1729 + }, 1730 + name: { 1731 + type: "string", 1732 + description: "The name of the artist.", 1733 + minLength: 1, 1734 + maxLength: 256, 1735 + }, 1736 + }, 1737 + }, 1723 1738 }, 1724 1739 }, 1725 1740 AppRockskyArtistGetArtistAlbums: { ··· 3769 3784 minLength: 1, 3770 3785 maxLength: 256, 3771 3786 }, 3787 + artists: { 3788 + type: "array", 3789 + description: "The artists of the song with MusicBrainz IDs.", 3790 + items: { 3791 + type: "ref", 3792 + ref: "lex:app.rocksky.artist.defs#artistMbid", 3793 + }, 3794 + }, 3772 3795 albumArtist: { 3773 3796 type: "string", 3774 3797 description: "The album artist of the song.", ··· 4675 4698 description: "The artist of the song.", 4676 4699 minLength: 1, 4677 4700 maxLength: 256, 4701 + }, 4702 + artists: { 4703 + type: "array", 4704 + description: "The artists of the song with MusicBrainz IDs.", 4705 + items: { 4706 + type: "ref", 4707 + ref: "lex:app.rocksky.artist.defs#artistMbid", 4708 + }, 4678 4709 }, 4679 4710 albumArtist: { 4680 4711 type: "string",
+20
apps/api/src/lexicon/types/app/rocksky/artist/defs.ts
··· 118 118 export function validateListenerViewBasic(v: unknown): ValidationResult { 119 119 return lexicons.validate("app.rocksky.artist.defs#listenerViewBasic", v); 120 120 } 121 + 122 + export interface Artists { 123 + /** The MusicBrainz Identifier (MBID) of the artist. */ 124 + mbid?: string; 125 + /** The name of the artist. */ 126 + name?: string; 127 + [k: string]: unknown; 128 + } 129 + 130 + export function isArtists(v: unknown): v is Artists { 131 + return ( 132 + isObj(v) && 133 + hasProp(v, "$type") && 134 + v.$type === "app.rocksky.artist.defs#artists" 135 + ); 136 + } 137 + 138 + export function validateArtists(v: unknown): ValidationResult { 139 + return lexicons.validate("app.rocksky.artist.defs#artists", v); 140 + }
+3
apps/api/src/lexicon/types/app/rocksky/scrobble.ts
··· 5 5 import { lexicons } from "../../../lexicons"; 6 6 import { isObj, hasProp } from "../../../util"; 7 7 import { CID } from "multiformats/cid"; 8 + import type * as AppRockskyArtistDefs from "./artist/defs"; 8 9 9 10 export interface Record { 10 11 /** The title of the song. */ 11 12 title: string; 12 13 /** The artist of the song. */ 13 14 artist: string; 15 + /** The artists of the song with MusicBrainz IDs. */ 16 + artists?: AppRockskyArtistDefs.ArtistMbid[]; 14 17 /** The album artist of the song. */ 15 18 albumArtist: string; 16 19 /** The album of the song. */
+3
apps/api/src/lexicon/types/app/rocksky/song.ts
··· 5 5 import { lexicons } from "../../../lexicons"; 6 6 import { isObj, hasProp } from "../../../util"; 7 7 import { CID } from "multiformats/cid"; 8 + import type * as AppRockskyArtistDefs from "./artist/defs"; 8 9 9 10 export interface Record { 10 11 /** The title of the song. */ 11 12 title: string; 12 13 /** The artist of the song. */ 13 14 artist: string; 15 + /** The artists of the song with MusicBrainz IDs. */ 16 + artists?: AppRockskyArtistDefs.ArtistMbid[]; 14 17 /** The album artist of the song. */ 15 18 albumArtist: string; 16 19 /** The album of the song. */
+53 -47
apps/api/src/nowplaying/nowplaying.service.ts
··· 26 26 27 27 export async function putArtistRecord( 28 28 track: Track, 29 - agent: Agent 29 + agent: Agent, 30 30 ): Promise<string | null> { 31 31 const rkey = TID.nextStr(); 32 32 const record: Artist.Record = { ··· 62 62 63 63 export async function putAlbumRecord( 64 64 track: Track, 65 - agent: Agent 65 + agent: Agent, 66 66 ): Promise<string | null> { 67 67 const rkey = TID.nextStr(); 68 68 ··· 103 103 104 104 export async function putSongRecord( 105 105 track: Track, 106 - agent: Agent 106 + agent: Agent, 107 107 ): Promise<string | null> { 108 108 const rkey = TID.nextStr(); 109 109 ··· 111 111 $type: "app.rocksky.song", 112 112 title: track.title, 113 113 artist: track.artist, 114 + artists: track.artists === null ? undefined : track.artists, 114 115 album: track.album, 115 116 albumArtist: track.albumArtist, 116 117 duration: track.duration, ··· 157 158 158 159 async function putScrobbleRecord( 159 160 track: Track, 160 - agent: Agent 161 + agent: Agent, 161 162 ): Promise<string | null> { 162 163 const rkey = TID.nextStr(); 163 164 ··· 167 168 albumArtist: track.albumArtist, 168 169 albumArtUrl: track.albumArt, 169 170 artist: track.artist, 171 + artists: track.artists === null ? undefined : track.artists, 170 172 album: track.album, 171 173 duration: track.duration, 172 174 trackNumber: track.trackNumber, ··· 274 276 .where( 275 277 and( 276 278 eq(artistAlbums.albumId, scrobble.album.id), 277 - eq(artistAlbums.artistId, scrobble.artist.id) 278 - ) 279 + eq(artistAlbums.artistId, scrobble.artist.id), 280 + ), 279 281 ) 280 282 .limit(1) 281 283 .then((rows) => rows[0]), ··· 438 440 }, 439 441 }), 440 442 null, 441 - 2 443 + 2, 442 444 ); 443 445 444 446 ctx.nc.publish( 445 447 "rocksky.scrobble", 446 - Buffer.from(message.replaceAll("sha_256", "sha256")) 448 + Buffer.from(message.replaceAll("sha_256", "sha256")), 447 449 ); 448 450 449 451 const trackMessage = JSON.stringify( ··· 490 492 xata_createdat: artist_album.createdAt.toISOString(), 491 493 xata_updatedat: artist_album.updatedAt.toISOString(), 492 494 }, 493 - }) 495 + }), 494 496 ); 495 497 496 498 ctx.nc.publish( 497 499 "rocksky.track", 498 - Buffer.from(trackMessage.replaceAll("sha_256", "sha256")) 500 + Buffer.from(trackMessage.replaceAll("sha_256", "sha256")), 499 501 ); 500 502 } 501 503 ··· 503 505 ctx: Context, 504 506 track: Track, 505 507 agent: Agent, 506 - userDid: string 508 + userDid: string, 507 509 ): Promise<void> { 508 510 // check if scrobble already exists (user did + timestamp) 509 511 const scrobbleTime = dayjs.unix(track.timestamp || dayjs().unix()); ··· 522 524 eq(tracks.title, track.title), 523 525 eq(tracks.artist, track.artist), 524 526 gte(scrobbles.timestamp, scrobbleTime.subtract(5, "seconds").toDate()), 525 - lte(scrobbles.timestamp, scrobbleTime.add(5, "seconds").toDate()) 526 - ) 527 + lte(scrobbles.timestamp, scrobbleTime.add(5, "seconds").toDate()), 528 + ), 527 529 ) 528 530 .limit(1) 529 531 .then((rows) => rows[0]); ··· 531 533 if (existingScrobble) { 532 534 console.log( 533 535 `Scrobble already exists for ${chalk.cyan(track.title)} at ${chalk.cyan( 534 - scrobbleTime.format("YYYY-MM-DD HH:mm:ss") 535 - )}` 536 + scrobbleTime.format("YYYY-MM-DD HH:mm:ss"), 537 + )}`, 536 538 ); 537 539 return; 538 540 } ··· 545 547 tracks.sha256, 546 548 createHash("sha256") 547 549 .update( 548 - `${track.title} - ${track.artist} - ${track.album}`.toLowerCase() 550 + `${track.title} - ${track.artist} - ${track.album}`.toLowerCase(), 549 551 ) 550 - .digest("hex") 551 - ) 552 + .digest("hex"), 553 + ), 552 554 ) 553 555 .limit(1) 554 556 .then((rows) => rows[0]); ··· 562 564 albums.sha256, 563 565 createHash("sha256") 564 566 .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 565 - .digest("hex") 566 - ) 567 + .digest("hex"), 568 + ), 567 569 ) 568 570 .limit(1) 569 571 .then((rows) => rows[0]); ··· 584 586 artists.sha256, 585 587 createHash("sha256") 586 588 .update(track.albumArtist.toLowerCase()) 587 - .digest("hex") 588 - ) 589 + .digest("hex"), 590 + ), 589 591 ) 590 592 .limit(1) 591 593 .then((rows) => rows[0]); ··· 616 618 artist: track.artist.split(",").map((a) => ({ name: a.trim() })), 617 619 name: track.title, 618 620 album: track.album, 619 - } 621 + }, 620 622 ); 621 623 622 624 if (!mbTrack?.trackMBID) { ··· 628 630 } 629 631 630 632 track.mbId = mbTrack?.trackMBID; 633 + track.artists = mbTrack?.artist?.map((artist) => ({ 634 + mbid: artist.mbid, 635 + name: artist.name, 636 + })); 631 637 632 638 if (!existingTrack?.uri || !userTrack?.userTrack.uri?.includes(userDid)) { 633 639 await putSongRecord(track, agent); ··· 641 647 albums.sha256, 642 648 createHash("sha256") 643 649 .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 644 - .digest("hex") 645 - ) 650 + .digest("hex"), 651 + ), 646 652 ) 647 653 .limit(1) 648 654 .then((rows) => rows[0]); ··· 658 664 tracks.sha256, 659 665 createHash("sha256") 660 666 .update( 661 - `${track.title} - ${track.artist} - ${track.album}`.toLowerCase() 667 + `${track.title} - ${track.artist} - ${track.album}`.toLowerCase(), 662 668 ) 663 - .digest("hex") 664 - ) 669 + .digest("hex"), 670 + ), 665 671 ) 666 672 .limit(1) 667 673 .then((rows) => rows[0]); ··· 675 681 676 682 if (existingTrack) { 677 683 console.log( 678 - `Song found: ${chalk.cyan(existingTrack.id)} - ${track.title}, after ${chalk.magenta(tries)} tries` 684 + `Song found: ${chalk.cyan(existingTrack.id)} - ${track.title}, after ${chalk.magenta(tries)} tries`, 679 685 ); 680 686 } 681 687 ··· 688 694 artists.sha256, 689 695 createHash("sha256") 690 696 .update(track.albumArtist.toLowerCase()) 691 - .digest("hex") 697 + .digest("hex"), 692 698 ), 693 699 eq( 694 700 artists.sha256, 695 - createHash("sha256").update(track.artist.toLowerCase()).digest("hex") 696 - ) 697 - ) 701 + createHash("sha256").update(track.artist.toLowerCase()).digest("hex"), 702 + ), 703 + ), 698 704 ) 699 705 .limit(1) 700 706 .then((rows) => rows[0]); ··· 709 715 .innerJoin(artists, eq(userArtists.artistId, artists.id)) 710 716 .innerJoin(users, eq(userArtists.userId, users.id)) 711 717 .where( 712 - and(eq(artists.id, existingArtist?.id || ""), eq(users.did, userDid)) 718 + and(eq(artists.id, existingArtist?.id || ""), eq(users.did, userDid)), 713 719 ) 714 720 .limit(1) 715 721 .then((rows) => rows[0]); ··· 744 750 tracks.sha256, 745 751 createHash("sha256") 746 752 .update( 747 - `${track.title} - ${track.artist} - ${track.album}`.toLowerCase() 753 + `${track.title} - ${track.artist} - ${track.album}`.toLowerCase(), 748 754 ) 749 - .digest("hex") 750 - ) 755 + .digest("hex"), 756 + ), 751 757 ) 752 758 .limit(1) 753 759 .then((rows) => rows[0]); 754 760 755 761 while (!existingTrack?.artistUri && !existingTrack?.albumUri && tries < 30) { 756 762 console.log( 757 - `Artist uri not ready, trying again: ${chalk.magenta(tries + 1)}` 763 + `Artist uri not ready, trying again: ${chalk.magenta(tries + 1)}`, 758 764 ); 759 765 existingTrack = await ctx.db 760 766 .select() ··· 764 770 tracks.sha256, 765 771 createHash("sha256") 766 772 .update( 767 - `${track.title} - ${track.artist} - ${track.album}`.toLowerCase() 773 + `${track.title} - ${track.artist} - ${track.album}`.toLowerCase(), 768 774 ) 769 - .digest("hex") 770 - ) 775 + .digest("hex"), 776 + ), 771 777 ) 772 778 .limit(1) 773 779 .then((rows) => rows[0]); ··· 782 788 artists.sha256, 783 789 createHash("sha256") 784 790 .update(track.albumArtist.toLowerCase()) 785 - .digest("hex") 786 - ) 791 + .digest("hex"), 792 + ), 787 793 ) 788 794 .limit(1) 789 795 .then((rows) => rows[0]); ··· 806 812 albums.sha256, 807 813 createHash("sha256") 808 814 .update(`${track.album} - ${track.albumArtist}`.toLowerCase()) 809 - .digest("hex") 810 - ) 815 + .digest("hex"), 816 + ), 811 817 ) 812 818 .limit(1) 813 819 .then((rows) => rows[0]); ··· 837 843 838 844 if (existingTrack?.artistUri) { 839 845 console.log( 840 - `Artist uri ready: ${chalk.cyan(existingTrack.id)} - ${track.title}, after ${chalk.magenta(tries)} tries` 846 + `Artist uri ready: ${chalk.cyan(existingTrack.id)} - ${track.title}, after ${chalk.magenta(tries)} tries`, 841 847 ); 842 848 } 843 849 ··· 848 854 await tealfm.publishPlayingNow( 849 855 agent, 850 856 mbTrack, 851 - Math.floor(track.duration / 1000) 857 + Math.floor(track.duration / 1000), 852 858 ); 853 859 } 854 860
+1 -1
apps/api/src/tealfm/index.ts
··· 9 9 async function publishPlayingNow( 10 10 agent: Agent, 11 11 track: MusicbrainzTrack, 12 - duration: number 12 + duration: number, 13 13 ) { 14 14 try { 15 15 const rkey = TID.nextStr();
+9
apps/api/src/types/track.ts
··· 3 3 export const trackSchema = z.object({ 4 4 title: z.string().nonempty().trim(), 5 5 artist: z.string().nonempty().trim(), 6 + artists: z 7 + .array( 8 + z.object({ 9 + mbid: z.string().optional().nullable(), 10 + name: z.string().nonempty().trim(), 11 + }), 12 + ) 13 + .optional() 14 + .nullable(), 6 15 album: z.string().nonempty().trim(), 7 16 albumArtist: z.string().nonempty().trim(), 8 17 duration: z.number(),