[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
0

Configure Feed

Select the types of activity you want to include in your feed.

feat: Add additional metadata from spotify and for listenbrainz submissions

* Parse irsc and track number from spotify data
* Add isrc and track number to listenbrainz submission
* Correct spotify ids for listenbrainz submission

#418

FoxxMD (Dec 16, 2025, 6:18 PM UTC) c1318f7e 615947b4

+89 -9
+15
src/backend/utils.ts
··· 668 668 export const getFirstNonEmptyString = (values: unknown[]) => getFirstNonEmptyVal<string>(values, nonEmptyStringOpts); 669 669 export const getNonEmptyString = (value: unknown) => getNonEmptyVal<string>(value, nonEmptyStringOpts); 670 670 671 + const nonEmptyValueOpts: NonEmptyOptions<any> = { test: (v) => v !== undefined && v !== null && typeof v !== 'string' || v.trim() !== '' }; 672 + export const isEmptyArrayOrUndefined = <T = unknown>(arr: unknown[] | undefined, options: NonEmptyOptions<T> = {}): boolean => { 673 + if(arr === undefined || arr === null || arr.length === 0) { 674 + return true; 675 + } 676 + const opts = {...nonEmptyValueOpts, ...options}; 677 + for(const v of arr) { 678 + const nonEmptyVal = getNonEmptyVal(v, opts); 679 + if(nonEmptyVal !== undefined) { 680 + return false; 681 + } 682 + } 683 + return true; 684 + } 685 + 671 686 /** 672 687 * Runs the function `fn` 673 688 * and retries automatically if it fails.
+8
src/core/Atomic.ts
··· 129 129 * The "consolidated" album like --> "The Wall" by "Pink Floyd" */ 130 130 releaseGroup?: string 131 131 additionalInfo?: AdditionalTrackInfoResponse 132 + /** International Standard Recording Code (ISRC) for this track 133 + * 134 + * https://beta.musicbrainz.org/doc/ISRC 135 + */ 136 + isrc?: string[] 137 + 138 + /** Position of track within Release */ 139 + trackNumber?: number 132 140 } 133 141 134 142 export interface SpotifyMeta {
+30 -3
src/backend/sources/SpotifySource.ts
··· 2 2 import EventEmitter from "events"; 3 3 import SpotifyWebApi from "spotify-web-api-node"; 4 4 import request from 'superagent'; 5 - import { PlayObject, SCROBBLE_TS_SOC_END, SCROBBLE_TS_SOC_START, ScrobbleTsSOC, SpotifyMeta } from "../../core/Atomic.js"; 5 + import { BrainzMeta, PlayObject, SCROBBLE_TS_SOC_END, SCROBBLE_TS_SOC_START, ScrobbleTsSOC, SpotifyMeta } from "../../core/Atomic.js"; 6 6 import { combinePartsToString, truncateStringToLength } from "../../core/StringUtils.js"; 7 7 import { isNodeNetworkException } from "../common/errors/NodeErrors.js"; 8 8 import { hasUpstreamError, UpstreamError } from "../common/errors/UpstreamError.js"; ··· 88 88 let url: string; 89 89 let playbackPosition: number | undefined; 90 90 let deviceId: string | undefined; 91 + let isrcString: string | undefined; 92 + let trackNumber: number | undefined; 91 93 let scrobbleTsSOC: ScrobbleTsSOC; 92 94 93 95 ··· 104 106 album: a, 105 107 external_urls: { 106 108 spotify, 107 - } = {} 109 + } = {}, 110 + external_ids: { 111 + isrc 112 + }, 113 + track_number 108 114 } = track; 109 115 110 116 scrobbleTsSOC = SCROBBLE_TS_SOC_END; ··· 116 122 duration_ms = dm; 117 123 album = a; 118 124 url = spotify; 125 + isrcString = isrc; 126 + trackNumber = track_number; 119 127 120 128 } else if (asCurrentlyPlayingObject(obj)) { 121 129 const { ··· 136 144 album: a, 137 145 external_urls: { 138 146 spotify, 139 - } = {} 147 + } = {}, 148 + external_ids: { 149 + isrc 150 + }, 151 + track_number 140 152 } = item as TrackObjectFull; 141 153 142 154 scrobbleTsSOC = SCROBBLE_TS_SOC_START; ··· 149 161 url = spotify; 150 162 playbackPosition = progress_ms / 1000; 151 163 deviceId = combinePartsToString([shortDeviceId(deviceIdentifier), deviceName]); 164 + isrcString = isrc; 165 + trackNumber = track_number 152 166 153 167 } else { 154 168 throw new Error('Could not determine format of spotify response data'); ··· 207 221 } 208 222 } 209 223 }; 224 + 225 + const brainz: BrainzMeta = {}; 226 + 227 + 228 + if(isrcString !== undefined) { 229 + brainz.isrc = [isrcString]; 230 + } 231 + if(trackNumber !== undefined) { 232 + brainz.trackNumber = trackNumber; 233 + } 234 + if(Object.keys(brainz).length > 0) { 235 + play.data.meta.brainz = brainz; 236 + } 210 237 211 238 if(imageData !== undefined) { 212 239 play.meta.art = {album: imageData.url};
+33 -5
src/backend/common/vendor/ListenbrainzApiClient.ts
··· 18 18 import { ListenBrainzClientData } from "../infrastructure/config/client/listenbrainz.js"; 19 19 import AbstractApiClient from "./AbstractApiClient.js"; 20 20 import { getBaseFromUrl, isPortReachableConnect, joinedUrl, normalizeWebAddress } from '../../utils/NetworkUtils.js'; 21 - import { removeUndefinedKeys, unique } from '../../utils.js'; 21 + import { isEmptyArrayOrUndefined, removeUndefinedKeys, unique } from '../../utils.js'; 22 22 import {ListensResponse as KoitoListensResponse} from '../infrastructure/config/client/koito.js' 23 23 import { listenObjectResponseToPlay } from './koito/KoitoApiClient.js'; 24 24 import { version } from '../../ioc.js'; ··· 584 584 submission_client, 585 585 submission_client_version, 586 586 artist_names = [], 587 + isrc, 588 + tracknumber 587 589 } = {}, 588 590 mbid_mapping: { 589 591 recording_mbid: mRecordingMbid ··· 659 661 const brainzMeta: BrainzMeta = removeUndefinedKeys({ 660 662 album: release_mbid, 661 663 releaseGroup: release_group_mbid, 662 - track: trackId 664 + track: trackId, 665 + isrc: isrc !== undefined ? [isrc] : undefined, 666 + trackNumber: tracknumber 663 667 }) ?? {}; 664 668 665 669 if(Object.keys(additional_info).length > 0) { ··· 721 725 media_player_version: mediaPlayerVersion ?? msAdditionalInfo.media_player_version, 722 726 music_service: musicService !== undefined ? musicServiceToCononical(musicService) : msAdditionalInfo.music_service, 723 727 music_service_name: source, 724 - spotify_id: spotify.track ?? msAdditionalInfo.spotify_id, 725 - spotify_album_id: spotify.album ?? msAdditionalInfo.spotify_album_id, 726 - spotify_artist_ids: spotify.artist ?? msAdditionalInfo.spotify_artist_ids 728 + spotify_id: msAdditionalInfo.spotify_id, 729 + spotify_album_id: msAdditionalInfo.spotify_album_id, 730 + spotify_artist_ids: msAdditionalInfo.spotify_artist_ids, 731 + origin_url: msAdditionalInfo.origin_url, 732 + isrc: brainz.isrc !== undefined ? brainz.isrc[0] : msAdditionalInfo.isrc, 733 + tracknumber: brainz.trackNumber ?? msAdditionalInfo.tracknumber 727 734 }; 735 + 736 + if(Object.keys(spotify).length > 0) { 737 + if(spotify.track !== undefined) { 738 + const trackUrl = `https://open.spotify.com/track/${spotify.track}`; 739 + if(addInfo.origin_url === undefined) { 740 + addInfo.origin_url = trackUrl; 741 + } 742 + if(addInfo.spotify_id === undefined) { 743 + addInfo.spotify_id = trackUrl; 744 + } 745 + } 746 + if(isEmptyArrayOrUndefined(addInfo.spotify_artist_ids) && !isEmptyArrayOrUndefined(spotify.artist)) { 747 + addInfo.spotify_artist_ids = spotify.artist.map(x => `https://open.spotify.com/artist/${x}`) 748 + } 749 + if(isEmptyArrayOrUndefined(addInfo.spotify_album_artist_ids) && !isEmptyArrayOrUndefined(spotify.albumArtist)) { 750 + addInfo.spotify_album_artist_ids = spotify.albumArtist.map(x => `https://open.spotify.com/artist/${x}`) 751 + } 752 + if(addInfo.spotify_album_id === undefined && spotify.album !== undefined) { 753 + addInfo.spotify_album_id = `https://open.spotify.com/album/${spotify.album}` 754 + } 755 + } 728 756 729 757 addInfo = removeUndefinedKeys(addInfo) 730 758
+1
src/backend/common/vendor/listenbrainz/interfaces.ts
··· 77 77 submission_client?: string; 78 78 submission_client_version?: string; 79 79 spotify_id?: string; 80 + isrc?: string 80 81 media_player?: string; 81 82 media_player_version?: string; 82 83
+2 -1
src/backend/common/vendor/musicbrainz/MusicbrainzApiClient.ts
··· 332 332 artist: data["artist-credit"] !== undefined ? data["artist-credit"].map(x => x.artist.id) : undefined, 333 333 albumArtist: albumArtistIds, 334 334 album: album !== undefined ? album.id : undefined, 335 - releaseGroup: album !== undefined ? album["release-group"]?.id : undefined 335 + releaseGroup: album !== undefined ? album["release-group"]?.id : undefined, 336 + isrc: data.isrcs !== undefined && data.isrcs.length > 0 ? data.isrcs : undefined 336 337 } 337 338 } 338 339 },