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

refactor(listenbrainz): Consolidate lz endpoint submit parsing into lz response parsing

Consolidtes logic for parsing lz listen data structure

FoxxMD (Nov 13, 2025, 4:25 PM UTC) ce7ad36d fa4bf5ab

+109 -98
+71 -96
src/backend/common/vendor/ListenbrainzApiClient.ts
··· 270 270 271 271 export const listenPayloadToPlay = (payload: ListenPayload, nowPlaying: boolean = false): PlayObject => { 272 272 273 - // const listened = payload.listened_at ?? dayjs().unix(); 274 - // const listenedAt = typeof listened === 'number' ? dayjs.unix(listened) : dayjs(listened); 275 - 276 - // const { 277 - // track_metadata: { 278 - // additional_info = {} 279 - // } = {}, 280 - // } = payload; 281 - 282 - // const play = ListenbrainzApiClient.listenResponseToPlay({ 283 - // ...payload, 284 - // track_metadata: { 285 - // ...payload.track_metadata, 286 - // additional_info 287 - // }, 288 - // listened_at: listenedAt.unix() 289 - // }); 290 - 291 - // play.meta.nowPlaying = nowPlaying; 273 + const listened = payload.listened_at ?? dayjs().unix(); 274 + const listenedAt = typeof listened === 'number' ? dayjs.unix(listened) : dayjs(listened); 292 275 293 276 const { 294 - listened_at = dayjs().unix(), 295 277 track_metadata: { 296 - artist_name, 297 - track_name, 298 - release_name, 299 - additional_info: { 300 - duration, 301 - duration_ms, 302 - track_mbid, 303 - artist_mbids, 304 - artist_names = [], 305 - release_mbid, 306 - release_group_mbid, 307 - release_artist_name, 308 - release_artist_names = [] 309 - } = {} 278 + additional_info = {} 310 279 } = {}, 311 280 } = payload; 312 281 313 - let albumArtists: string[]; 314 - if(release_artist_name !== undefined) { 315 - albumArtists = [release_artist_name]; 316 - } 317 - if(release_artist_names.length > 0) { 318 - albumArtists = unique([...(albumArtists ?? []), ...release_artist_names]) 319 - } 282 + const play = listenResponseToPlay({ 283 + ...payload, 284 + track_metadata: { 285 + ...payload.track_metadata, 286 + additional_info, 287 + }, 288 + listened_at: listenedAt.unix() 289 + }); 320 290 321 - let dur: number = duration; 322 - if(dur === undefined && duration_ms !== undefined) { 323 - dur = duration_ms/1000; 324 - } 291 + play.meta.nowPlaying = nowPlaying; 325 292 326 - const oldPlay = { 327 - data: { 328 - playDate: typeof listened_at === 'number' ? dayjs.unix(listened_at) : dayjs(listened_at), 329 - track: track_name, 330 - artists: unique([artist_name, ...artist_names]), 331 - albumArtists, 332 - album: release_name, 333 - duration: dur, 334 - meta: { 335 - brainz: { 336 - artist: artist_mbids !== undefined ? artist_mbids : undefined, 337 - album: release_mbid, 338 - albumArtist: release_group_mbid, 339 - track: track_mbid 340 - } 341 - } 342 - }, 343 - meta: { 344 - nowPlaying, 345 - } 346 - } 347 - 348 - return oldPlay; 293 + return play; 349 294 } 350 295 351 296 export const listenResponseToPlay = (listen: ListenResponse): PlayObject => { ··· 367 312 } = {} 368 313 } = listen; 369 314 370 - const naivePlay = listenResponseToNaivePlay(listen); 315 + const naivePlay = listenToNaivePlay(listen); 371 316 372 317 if(artistMappings.length === 0) { 373 318 // if there are no artist mappings its likely MB doesn't have info on this track so just use our internally derived attempt ··· 616 561 /** 617 562 * Try to parse true artists and track name without using MB information 618 563 * */ 619 - export const listenResponseToNaivePlay = (listen: ListenResponse): PlayObject => { 564 + export const listenToNaivePlay = (listen: ListenResponse): PlayObject => { 620 565 const { 621 566 listened_at, 622 567 recording_msid, ··· 627 572 additional_info: { 628 573 recording_msid: aRecordingMsid, 629 574 recording_mbid: aRecordingMbid, 575 + release_artist_name, 576 + release_artist_names = [], 577 + release_group_mbid, 578 + release_mbid, 579 + artist_mbids = [], 630 580 duration: aDuration, 631 581 duration_ms: aDurationMs, 632 582 music_service_name, 633 583 music_service, 634 584 submission_client, 635 - submission_client_version 585 + submission_client_version, 586 + artist_names = [], 636 587 } = {}, 637 588 mbid_mapping: { 638 589 recording_mbid: mRecordingMbid ··· 649 600 } 650 601 651 602 let normalTrackName = track_name; 652 - let artists: string[] = [artist_name]; 603 + let artists: string[] = []; 653 604 654 - // since we aren't using MB mappings we should be conservative and assume artist string with & are proper names (not joiner) 655 - const parsedArtists = parseCredits(artist_name, [',', '/', '\\']); 656 - if (parsedArtists !== undefined) { 657 - if (parsedArtists.primary !== undefined) { 658 - 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); 659 617 } 660 - artists = artists.concat(parsedArtists.secondary); 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) 624 + } 625 + artists = uniqueNormalizedStrArr(artists); 626 + } 627 + 628 + let albumArtists: string[]; 629 + if(release_artist_name !== undefined) { 630 + albumArtists = [release_artist_name]; 661 631 } 662 - // use all delimiters when trying to find artists in track name 663 - const parsedTrackArtists = parseCredits(track_name); 664 - if (parsedTrackArtists !== undefined) { 665 - // if we found "ft. something" in track string then we now have a "real" track name and more artists 666 - normalTrackName = parsedTrackArtists.primary; 667 - artists = artists.concat(parsedTrackArtists.secondary) 632 + if(release_artist_names.length > 0) { 633 + albumArtists = unique([...(albumArtists ?? []), ...release_artist_names]) 668 634 } 669 - artists = uniqueNormalizedStrArr(artists); 635 + 670 636 671 637 const play: PlayObject = { 672 638 data: { ··· 674 640 track: normalTrackName, 675 641 artists: artists, 676 642 album: release_name, 677 - duration: dur 643 + albumArtists, 644 + duration: dur, 645 + meta: { 646 + } 678 647 }, 679 648 meta: { 680 - source: 'listenbrainz', 649 + source: submission_client ?? 'listenbrainz', 681 650 playId, 682 651 deviceId: combinePartsToString([music_service_name ?? music_service, submission_client, submission_client_version]) 683 652 } 684 653 } 685 654 686 - 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 + 687 665 if(Object.keys(additional_info).length > 0) { 688 666 brainzMeta.additionalInfo = additional_info; 667 + 689 668 } 690 - 691 - // we shouldn't include more metdata here because we don't know if the MB mapped data is actually correct 692 - if(trackId !== undefined) { 693 - brainzMeta.track = trackId; 694 - 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; 695 672 } 696 673 697 674 if(Object.keys(brainzMeta).length > 0) { ··· 809 786 } 810 787 } 811 788 return undefined; 812 - } 813 - 814 - const FEAT_REGEX = new RegExp(/(.+) feat\. (.+)/); 789 + }
+3 -1
src/backend/common/vendor/listenbrainz/interfaces.ts
··· 97 97 spotify_artist_ids?: string[]; 98 98 artist_names?: string[]; 99 99 albumartist?: string; 100 + 101 + tracknumber?: number 100 102 } 101 103 export interface Track { 102 104 artist_name: string; ··· 139 141 mbid_mapping?: MbidMapping 140 142 } 141 143 export interface ListenPayload { 142 - listened_at: Date | number; 144 + listened_at?: Date | number; 143 145 track_metadata: TrackPayload; 144 146 } 145 147
+35 -1
src/backend/tests/listenbrainz/listenbrainz.test.ts
··· 7 7 import { UpstreamError } from "../../common/errors/UpstreamError.js"; 8 8 9 9 import { ListenbrainzApiClient, playToListenPayload, listenResponseToPlay, listenPayloadToPlay } from "../../common/vendor/ListenbrainzApiClient.js"; 10 - import { ListenResponse } from '../../common/vendor/listenbrainz/interfaces.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" }; ··· 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 + };