A decentralized music tracking and discovery platform built on AT Protocol 🎵

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

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