[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.

Merge pull request #397 from FoxxMD/lzParsingCleanup

refactor: Cleanup and consolidate listenbrainz listen parsing

authored by

Matt Foxx and committed by
GitHub
(Nov 13, 2025, 11:46 AM EST) 1701a5e5 406c39b6

+194 -148
+101 -102
src/backend/common/vendor/ListenbrainzApiClient.ts
··· 206 206 207 207 try { 208 208 const resp = await this.getUserListens(maxTracks, user); 209 - return resp.listens.map(x => ListenbrainzApiClient.listenResponseToPlay(x)); 209 + return resp.listens.map(x => listenResponseToPlay(x)); 210 210 } catch (e) { 211 211 this.logger.error(`Error encountered while getting User listens | Error => ${e.message}`); 212 212 return []; ··· 248 248 } 249 249 } 250 250 251 - static listenPayloadToPlay(payload: ListenPayload, nowPlaying: boolean = false): PlayObject { 251 + static submitToPlayObj(submitObj: SubmitPayload, playObj: PlayObject): PlayObject { 252 + if (submitObj.payload.length > 0) { 253 + const respPlay = { 254 + ...playObj, 255 + }; 256 + respPlay.data = { 257 + ...playObj.data, 258 + album: submitObj.payload[0].track_metadata?.release_name ?? playObj.data.album, 259 + track: submitObj.payload[0].track_metadata?.track_name ?? playObj.data.album, 260 + }; 261 + return respPlay; 262 + } 263 + return playObj; 264 + } 265 + 266 + static formatPlayObj(obj: any, options: FormatPlayObjectOptions): PlayObject { 267 + return listenResponseToPlay(obj); 268 + } 269 + } 270 + 271 + export const listenPayloadToPlay = (payload: ListenPayload, nowPlaying: boolean = false): PlayObject => { 272 + 273 + const listened = payload.listened_at ?? dayjs().unix(); 274 + const listenedAt = typeof listened === 'number' ? dayjs.unix(listened) : dayjs(listened); 275 + 252 276 const { 253 - listened_at = dayjs().unix(), 254 277 track_metadata: { 255 - artist_name, 256 - track_name, 257 - release_name, 258 - additional_info: { 259 - duration, 260 - duration_ms, 261 - track_mbid, 262 - artist_mbids, 263 - artist_names = [], 264 - release_mbid, 265 - release_group_mbid, 266 - release_artist_name, 267 - release_artist_names = [] 268 - } = {} 278 + additional_info = {} 269 279 } = {}, 270 280 } = payload; 271 281 272 - let albumArtists: string[]; 273 - if(release_artist_name !== undefined) { 274 - albumArtists = [release_artist_name]; 275 - } 276 - if(release_artist_names.length > 0) { 277 - albumArtists = unique([...(albumArtists ?? []), ...release_artist_names]) 278 - } 282 + const play = listenResponseToPlay({ 283 + ...payload, 284 + track_metadata: { 285 + ...payload.track_metadata, 286 + additional_info, 287 + }, 288 + listened_at: listenedAt.unix() 289 + }); 279 290 280 - let dur: number = duration; 281 - if(dur === undefined && duration_ms !== undefined) { 282 - dur = duration_ms/1000; 283 - } 291 + play.meta.nowPlaying = nowPlaying; 284 292 285 - return { 286 - data: { 287 - playDate: typeof listened_at === 'number' ? dayjs.unix(listened_at) : dayjs(listened_at), 288 - track: track_name, 289 - artists: unique([artist_name, ...artist_names]), 290 - albumArtists, 291 - album: release_name, 292 - duration: dur, 293 - meta: { 294 - brainz: { 295 - artist: artist_mbids !== undefined ? artist_mbids : undefined, 296 - album: release_mbid, 297 - albumArtist: release_group_mbid, 298 - track: track_mbid 299 - } 300 - } 301 - }, 302 - meta: { 303 - nowPlaying, 304 - } 305 - } 293 + return play; 306 294 } 307 295 308 - static listenResponseToPlay(listen: ListenResponse): PlayObject { 296 + export const listenResponseToPlay = (listen: ListenResponse): PlayObject => { 309 297 const { 310 298 listened_at, 311 299 track_metadata: { ··· 318 306 release_mbid 319 307 } = {}, 320 308 additional_info: { 321 - release_artists_names = [], 309 + release_artist_names = [], 322 310 release_group_mbid 323 311 } = {} 324 312 } = {} 325 313 } = listen; 326 314 327 - const naivePlay = ListenbrainzApiClient.listenResponseToNaivePlay(listen); 315 + const naivePlay = listenToNaivePlay(listen); 328 316 329 317 if(artistMappings.length === 0) { 330 318 // if there are no artist mappings its likely MB doesn't have info on this track so just use our internally derived attempt ··· 545 533 const primaryArtistMBMapping = artistMappings.find(x => x.artist_credit_name === primaryArtist); 546 534 if(primaryArtistMBMapping !== undefined) { 547 535 // only include as primary if musicbrainz does not disagree with us 548 - if(release_artists_names.length === 0 || (release_artists_names.length > 0 && release_artists_names.includes(primaryArtist))) { 536 + if(release_artist_names.length === 0 || (release_artist_names.length > 0 && release_artist_names.includes(primaryArtist))) { 549 537 brainzMetaRaw.albumArtist = primaryArtistMBMapping.artist_mbid; 550 538 } 551 539 } ··· 570 558 return play; 571 559 } 572 560 573 - /** 574 - * Try to parse true artists and track name without using MB information 575 - * */ 576 - static listenResponseToNaivePlay(listen: ListenResponse): PlayObject { 561 + /** 562 + * Try to parse true artists and track name without using MB information 563 + * */ 564 + export const listenToNaivePlay = (listen: ListenResponse): PlayObject => { 577 565 const { 578 566 listened_at, 579 567 recording_msid, ··· 581 569 track_name, 582 570 artist_name, 583 571 release_name, 584 - duration, 585 572 additional_info: { 586 573 recording_msid: aRecordingMsid, 587 574 recording_mbid: aRecordingMbid, 575 + release_artist_name, 576 + release_artist_names = [], 577 + release_group_mbid, 578 + release_mbid, 579 + artist_mbids = [], 588 580 duration: aDuration, 589 581 duration_ms: aDurationMs, 590 582 music_service_name, 591 583 music_service, 592 584 submission_client, 593 - submission_client_version 585 + submission_client_version, 586 + artist_names = [], 594 587 } = {}, 595 588 mbid_mapping: { 596 589 recording_mbid: mRecordingMbid ··· 601 594 602 595 const playId = recording_msid ?? aRecordingMsid; 603 596 const trackId = aRecordingMbid ?? mRecordingMbid; 604 - let dur = duration ?? aDuration; 597 + let dur = aDuration; 605 598 if (dur === undefined && aDurationMs !== undefined) { 606 599 dur = Math.round(aDurationMs / 1000); 607 600 } 608 601 609 602 let normalTrackName = track_name; 610 - let artists: string[] = [artist_name]; 603 + let artists: string[] = []; 611 604 612 - // since we aren't using MB mappings we should be conservative and assume artist string with & are proper names (not joiner) 613 - const parsedArtists = parseCredits(artist_name, [',', '/', '\\']); 614 - if (parsedArtists !== undefined) { 615 - if (parsedArtists.primary !== undefined) { 616 - artists.push(parsedArtists.primary); 605 + if(artist_names.length > 0) { 606 + artists = artist_names; 607 + } else { 608 + artists = [artist_name]; 609 + 610 + // since we aren't using MB mappings we should be conservative and assume artist string with & are proper names (not joiner) 611 + const parsedArtists = parseCredits(artist_name, [',', '/', '\\']); 612 + if (parsedArtists !== undefined) { 613 + if (parsedArtists.primary !== undefined) { 614 + artists.push(parsedArtists.primary); 615 + } 616 + artists = artists.concat(parsedArtists.secondary); 617 + } 618 + // use all delimiters when trying to find artists in track name 619 + const parsedTrackArtists = parseCredits(track_name); 620 + if (parsedTrackArtists !== undefined) { 621 + // if we found "ft. something" in track string then we now have a "real" track name and more artists 622 + normalTrackName = parsedTrackArtists.primary; 623 + artists = artists.concat(parsedTrackArtists.secondary) 617 624 } 618 - artists = artists.concat(parsedArtists.secondary); 625 + artists = uniqueNormalizedStrArr(artists); 626 + } 627 + 628 + let albumArtists: string[]; 629 + if(release_artist_name !== undefined) { 630 + albumArtists = [release_artist_name]; 619 631 } 620 - // use all delimiters when trying to find artists in track name 621 - const parsedTrackArtists = parseCredits(track_name); 622 - if (parsedTrackArtists !== undefined) { 623 - // if we found "ft. something" in track string then we now have a "real" track name and more artists 624 - normalTrackName = parsedTrackArtists.primary; 625 - artists = artists.concat(parsedTrackArtists.secondary) 632 + if(release_artist_names.length > 0) { 633 + albumArtists = unique([...(albumArtists ?? []), ...release_artist_names]) 626 634 } 627 - artists = uniqueNormalizedStrArr(artists); 635 + 628 636 629 637 const play: PlayObject = { 630 638 data: { ··· 632 640 track: normalTrackName, 633 641 artists: artists, 634 642 album: release_name, 635 - duration: dur 643 + albumArtists, 644 + duration: dur, 645 + meta: { 646 + } 636 647 }, 637 648 meta: { 638 - source: 'listenbrainz', 649 + source: submission_client ?? 'listenbrainz', 639 650 playId, 640 651 deviceId: combinePartsToString([music_service_name ?? music_service, submission_client, submission_client_version]) 641 652 } 642 653 } 643 654 644 - const brainzMeta: BrainzMeta = {}; 655 + if(trackId !== undefined) { 656 + play.meta.trackid = trackId; 657 + } 658 + 659 + const brainzMeta: BrainzMeta = removeUndefinedKeys({ 660 + album: release_mbid, 661 + releaseGroup: release_group_mbid, 662 + track: trackId 663 + }) ?? {}; 664 + 645 665 if(Object.keys(additional_info).length > 0) { 646 666 brainzMeta.additionalInfo = additional_info; 667 + 647 668 } 648 - 649 - // we shouldn't include more metdata here because we don't know if the MB mapped data is actually correct 650 - if(trackId !== undefined) { 651 - brainzMeta.track = trackId; 652 - play.meta.trackid = trackId; 669 + if(artist_mbids.filter(x => x.trim() !== "").length > 0) { 670 + brainzMeta.artist = artist_mbids.filter(x => x.trim() !== ""); 671 + brainzMeta.additionalInfo.artist_mbids = brainzMeta.artist; 653 672 } 654 673 655 674 if(Object.keys(brainzMeta).length > 0) { ··· 660 679 661 680 return play; 662 681 } 663 - 664 - static submitToPlayObj(submitObj: SubmitPayload, playObj: PlayObject): PlayObject { 665 - if (submitObj.payload.length > 0) { 666 - const respPlay = { 667 - ...playObj, 668 - }; 669 - respPlay.data = { 670 - ...playObj.data, 671 - album: submitObj.payload[0].track_metadata?.release_name ?? playObj.data.album, 672 - track: submitObj.payload[0].track_metadata?.track_name ?? playObj.data.album, 673 - }; 674 - return respPlay; 675 - } 676 - return playObj; 677 - } 678 - 679 - static formatPlayObj(obj: any, options: FormatPlayObjectOptions): PlayObject { 680 - return ListenbrainzApiClient.listenResponseToPlay(obj); 681 - } 682 - } 683 682 684 683 685 684 export const playToListenPayload = (play: PlayObject): ListenPayload => {
+43 -30
src/backend/common/vendor/listenbrainz/interfaces.ts
··· 89 89 duration_ms?: number; 90 90 track_mbid?: string; 91 91 work_mbids?: WorkMbid[]; 92 + 93 + release_artist_name?: string; 94 + release_artist_names?: string[]; 95 + spotify_album_id?: string; 96 + spotify_album_artist_ids?: string[]; 97 + spotify_artist_ids?: string[]; 98 + artist_names?: string[]; 99 + albumartist?: string; 100 + 101 + tracknumber?: number 92 102 } 93 103 export interface Track { 94 104 artist_name: string; ··· 103 113 104 114 duration?: number; 105 115 } 106 - export interface AdditionalTrackInfoResponse extends AdditionalTrackInfo { 107 - recording_msid?: RecordingMsid; 108 - release_artist_name?: string; 109 - release_artists_names?: string; 110 - spotify_album_id?: string; 111 - spotify_album_artist_ids?: string[]; 112 - spotify_artist_ids?: string[]; 116 + 117 + export type ListenType = 'single' | 'playing_now'; 118 + export interface MbidMapping { 119 + recording_name?: string; 120 + artist_mbids?: ArtistMbid[]; 121 + artists?: ArtistMBIDMapping[]; 122 + caa_id?: number; 123 + /** cover album archive mbid, not related to anything else I think */ 124 + caa_release_mbid?: string; 125 + recording_mbid?: RecordingMbid; 126 + release_mbid?: ReleaseMbid; 113 127 } 128 + 114 129 // using submit-listens example from openapi https://rain0r.github.io/listenbrainz-openapi/index.html#/lbCore/submitListens 115 130 // which is documented in official docs https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#openapi-specification 116 131 // and based on this LZ developer comment https://github.com/lyarenei/jellyfin-plugin-listenbrainz/issues/10#issuecomment-1253867941 117 132 133 + // 134 + // data structures for submitting a listen 135 + // 118 136 export interface SubmitListenAdditionalTrackInfo extends AdditionalTrackInfo { 119 - artist_names?: string[]; 120 - release_artist_name?: string; 121 - release_artist_names?: string[]; 122 - spotify_album_id?: string; 123 - spotify_album_artist_ids?: string[]; 124 - spotify_artist_ids?: string[]; 125 - albumartist?: string; 137 + 126 138 } 127 139 export interface TrackPayload extends MinimumTrack { 128 140 additional_info?: SubmitListenAdditionalTrackInfo; 141 + mbid_mapping?: MbidMapping 129 142 } 130 143 export interface ListenPayload { 131 - listened_at: Date | number; 132 - recording_msid?: RecordingMsid; 144 + listened_at?: Date | number; 133 145 track_metadata: TrackPayload; 134 146 } 135 - export type ListenType = 'single' | 'playing_now'; 147 + 148 + // this is what is sent to submit-listens 136 149 export interface SubmitPayload { 137 150 listen_type: ListenType; 138 151 payload: [ListenPayload]; 139 152 } 140 - export interface TrackResponse extends MinimumTrack { 141 - duration: number; 153 + 154 + // 155 + // data structures returned from listens 156 + // 157 + 158 + export interface AdditionalTrackInfoResponse extends SubmitListenAdditionalTrackInfo { 159 + recording_msid?: RecordingMsid; 160 + } 161 + 162 + export interface TrackResponse extends TrackPayload { 142 163 additional_info: AdditionalTrackInfoResponse; 143 - mbid_mapping: { 144 - recording_name?: string; 145 - artist_mbids?: ArtistMbid[]; 146 - artists?: ArtistMBIDMapping[]; 147 - caa_id?: number; 148 - /** cover album archive mbid, not related to anything else I think */ 149 - caa_release_mbid?: string; 150 - recording_mbid?: RecordingMbid; 151 - release_mbid?: ReleaseMbid; 152 - }; 153 164 } 165 + 166 + // this is what is received from listens endpoint 154 167 export interface ListenResponse { 155 168 156 - inserted_at: number; 169 + inserted_at?: number; 157 170 listened_at: number; 158 171 recording_msid?: RecordingMsid; 159 172 track_metadata: TrackResponse;
+3 -3
src/backend/sources/EndpointListenbrainzSource.ts
··· 12 12 ReportedPlayerStatus 13 13 } from "../common/infrastructure/Atomic.js"; 14 14 import { ListenbrainzEndpointSourceConfig } from "../common/infrastructure/config/source/endpointlz.js"; 15 - import { ListenbrainzApiClient } from "../common/vendor/ListenbrainzApiClient.js"; 15 + import { ListenbrainzApiClient, listenPayloadToPlay } from "../common/vendor/ListenbrainzApiClient.js"; 16 16 import { SubmitPayload } from '../common/vendor/listenbrainz/interfaces.js'; 17 17 import { ListenPayload } from '../common/vendor/listenbrainz/interfaces.js'; 18 18 import { parseRegexSingleOrFail } from "../utils.js"; ··· 76 76 static formatPlayObj(obj: ListenPayload, options: FormatPlayObjectOptions & { 77 77 nowPlaying?: boolean 78 78 } = {}): PlayObject { 79 - return ListenbrainzApiClient.listenPayloadToPlay(obj, options.nowPlaying); 79 + return listenPayloadToPlay(obj, options.nowPlaying); 80 80 } 81 81 82 82 getRecentlyPlayed = async (options = {}) => { ··· 108 108 payload, 109 109 } = obj; 110 110 111 - const play = ListenbrainzApiClient.listenPayloadToPlay(payload[0], listen_type === 'playing_now'); 111 + const play = listenPayloadToPlay(payload[0], listen_type === 'playing_now'); 112 112 return { 113 113 platformId: [play.meta.deviceId, NO_USER], 114 114 play,
+47 -13
src/backend/tests/listenbrainz/listenbrainz.test.ts
··· 6 6 import { PlayObject } from "../../../core/Atomic.js"; 7 7 import { UpstreamError } from "../../common/errors/UpstreamError.js"; 8 8 9 - import { ListenbrainzApiClient, playToListenPayload } from "../../common/vendor/ListenbrainzApiClient.js"; 10 - import { ListenResponse } from '../../common/vendor/listenbrainz/interfaces.js'; 9 + import { ListenbrainzApiClient, playToListenPayload, listenResponseToPlay, listenPayloadToPlay } from "../../common/vendor/ListenbrainzApiClient.js"; 10 + import { ListenPayload, ListenResponse, SubmitPayload } from '../../common/vendor/listenbrainz/interfaces.js'; 11 11 import { ExpectedResults } from "../utils/interfaces.js"; 12 12 import { withRequestInterception } from "../utils/networking.js"; 13 13 import artistWithProperJoiner from './correctlyMapped/artistProperHasJoinerInName.json' with { type: "json" }; ··· 33 33 describe('When user-submitted artist/track do NOT match MB mappings', function() { 34 34 it('Uses user submitted values when no artist mappings', async function () { 35 35 for(const test of noArtistMapping as unknown as LZTestFixture[]) { 36 - const play = ListenbrainzApiClient.listenResponseToPlay(test.data); 36 + const play = listenResponseToPlay(test.data); 37 37 assert.equal(play.data.track, test.expected.track); 38 38 assert.sameDeepMembers(play.data.artists, test.expected.artists); 39 39 } ··· 41 41 42 42 it('Uses user-submitted values when when either mapped track/artist do not match', async function () { 43 43 for(const test of veryWrong as unknown as LZTestFixture[]) { 44 - const play = ListenbrainzApiClient.listenResponseToPlay(test.data); 44 + const play = listenResponseToPlay(test.data); 45 45 assert.equal(play.data.track, test.expected.track); 46 46 assert.sameDeepMembers( play.data.artists, test.expected.artists); 47 47 } ··· 49 49 50 50 it('Should extract additional artists from track name', async function () { 51 51 for(const test of incorrectMultiArtistsTrackName as unknown as LZTestFixture[]) { 52 - const play = ListenbrainzApiClient.listenResponseToPlay(test.data); 52 + const play = listenResponseToPlay(test.data); 53 53 assert.equal(play.data.track, test.expected.track); 54 54 assert.sameDeepMembers(play.data.artists, test.expected.artists); 55 55 } ··· 61 61 62 62 it('Detects slightly different track names as equal', async function () { 63 63 for(const test of slightlyDifferentNames as unknown as LZTestFixture[]) { 64 - const play = ListenbrainzApiClient.listenResponseToPlay(test.data); 64 + const play = listenResponseToPlay(test.data); 65 65 assert.equal(play.data.track, test.expected.track); 66 66 assert.sameDeepMembers(play.data.artists, test.expected.artists); 67 67 } ··· 69 69 70 70 it('Uses all mapped artists', async function () { 71 71 for(const test of multiMappedArtistsWithSingleUserArtist as unknown as LZTestFixture[]) { 72 - const play = ListenbrainzApiClient.listenResponseToPlay(test.data); 72 + const play = listenResponseToPlay(test.data); 73 73 assert.equal(play.data.track, test.expected.track); 74 74 assert.sameDeepMembers(play.data.artists, test.expected.artists); 75 75 } ··· 77 77 78 78 it('Respects artists with joiner symbols in proper names', async function () { 79 79 for(const test of artistWithProperJoiner as unknown as LZTestFixture[]) { 80 - const play = ListenbrainzApiClient.listenResponseToPlay(test.data); 80 + const play = listenResponseToPlay(test.data); 81 81 assert.equal(play.data.track, test.expected.track); 82 82 assert.sameDeepMembers( play.data.artists, test.expected.artists); 83 83 } ··· 85 85 86 86 it('Detects user-submitted artists have joiners', async function () { 87 87 for(const test of multiArtistInArtistName as unknown as LZTestFixture[]) { 88 - const play = ListenbrainzApiClient.listenResponseToPlay(test.data); 88 + const play = listenResponseToPlay(test.data); 89 89 assert.equal(play.data.track, test.expected.track); 90 90 assert.sameDeepMembers(play.data.artists, test.expected.artists); 91 91 } ··· 93 93 94 94 it('Detects artists in user-submitted track', async function () { 95 95 for(const test of multiArtistsInTrackName as unknown as LZTestFixture[]) { 96 - const play = ListenbrainzApiClient.listenResponseToPlay(test.data); 96 + const play = listenResponseToPlay(test.data); 97 97 assert.equal(play.data.track, test.expected.track); 98 98 assert.sameDeepMembers( play.data.artists, test.expected.artists); 99 99 } ··· 101 101 102 102 it('Detects and uses normalized artist/track names', async function () { 103 103 for(const test of normalizedValues as unknown as LZTestFixture[]) { 104 - const play = ListenbrainzApiClient.listenResponseToPlay(test.data); 104 + const play = listenResponseToPlay(test.data); 105 105 assert.equal(play.data.track, test.expected.track); 106 106 assert.sameDeepMembers( play.data.artists, test.expected.artists); 107 107 } ··· 164 164 165 165 submitPayload.track_metadata.additional_info.artist_names = additionalArtists; 166 166 167 - const playFromPayload = ListenbrainzApiClient.listenPayloadToPlay(submitPayload); 167 + const playFromPayload = listenPayloadToPlay(submitPayload); 168 168 169 169 expect(playFromPayload.data.artists).to.be.eql(additionalArtists) 170 170 ··· 179 179 180 180 submitPayload.track_metadata.additional_info.artist_names = additionalArtists; 181 181 182 - const playFromPayload = ListenbrainzApiClient.listenPayloadToPlay(submitPayload); 182 + const playFromPayload = listenPayloadToPlay(submitPayload); 183 183 184 184 expect(playFromPayload.data.artists).to.be.eql(['Artist A', 'Artist B']) 185 185 ··· 194 194 195 195 }); 196 196 197 + it('Should use artist_names if provided, rather than parse artist from string', function () { 198 + 199 + const playFromPayload = listenPayloadToPlay(submit); 200 + 201 + expect(playFromPayload.data.artists).to.be.eql(submit.track_metadata.additional_info.artist_names); 202 + 203 + }); 204 + 197 205 }); 206 + 207 + 208 + const submit: ListenPayload = { 209 + track_metadata: { 210 + artist_name: "Télépopmusik feat. Mau", 211 + track_name: "15 Minutes", 212 + release_name: "Angel Milk", 213 + additional_info: { 214 + submission_client: "navidrome", 215 + submission_client_version: "0.58.5 (131c0c56)", 216 + tracknumber: 15, 217 + artist_names: [ 218 + "Télépopmusik", 219 + "Mau", 220 + ], 221 + artist_mbids: [ 222 + "265f242e-cf4e-4fbe-a3fe-43112387172f", 223 + "", 224 + ], 225 + recording_mbid: "69864bde-4958-484e-bbeb-f9d8f06eb932", 226 + release_mbid: "90e011e2-1a3b-483c-9684-355601689c0f", 227 + release_group_mbid: "d1456679-3901-30a6-929c-39d6d84f49a0", 228 + duration_ms: 939020, 229 + }, 230 + }, 231 + };