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

fix(tealfm): Don't define brainz artist array if empty

FoxxMD (Jan 5, 2026, 4:21 PM UTC) 8bf1bffd df163a26

+124 -13
+57
src/backend/tests/tealfm/tealfm.test.ts
··· 1 + import chai, { expect } from 'chai'; 2 + import asPromised from 'chai-as-promised'; 3 + import { after, before, describe, it } from 'mocha'; 4 + import { generateLastfmTrackObject, generateMbid, generatePlay, generateTealPlayRecord } from "../utils/PlayTestUtils.js"; 5 + import { AbstractBlueSkyApiClient, listRecordToPlay } from '../../common/vendor/bluesky/AbstractBlueSkyApiClient.js'; 6 + import dayjs from 'dayjs'; 7 + 8 + chai.use(asPromised); 9 + 10 + 11 + describe('#tealfm Record to Play', function() { 12 + 13 + it('Parses basic record data', function() { 14 + 15 + const [rec, {tid, did}] = generateTealPlayRecord(); 16 + const play = listRecordToPlay(rec); 17 + 18 + expect(play.data.track).eq(rec.value.trackName); 19 + expect(play.data.album).eq(rec.value.releaseName); 20 + expect(play.data.playDate.unix()).eq(dayjs(rec.value.playedTime).unix()); 21 + expect(play.data.duration).eq(rec.value.duration); 22 + expect(play.data.artists).eql(rec.value.artists.map(x => x.artistName)); 23 + expect(play.meta.user).eq(`did:plc:${did}`); 24 + expect(play.meta.playId).eq(tid); 25 + }); 26 + 27 + it('Parses mbids and isrc', function() { 28 + 29 + const [rec, {tid, did}] = generateTealPlayRecord(); 30 + const play = listRecordToPlay(rec); 31 + 32 + expect(play.data.meta.brainz).to.not.be.undefined; 33 + expect(play.data.meta.brainz.album).eq(rec.value.releaseMbId); 34 + expect(play.data.meta.brainz.track).eq(rec.value.recordingMbId); 35 + expect(play.data.meta.brainz.artist).eql(rec.value.artists.map(x => x.artistMbId)); 36 + expect(play.data.isrc).eq(rec.value.isrc); 37 + }); 38 + 39 + it('Removes brainz if no mbids', function() { 40 + 41 + const [rec, {tid, did}] = generateTealPlayRecord({ withMbids : false}); 42 + const play = listRecordToPlay(rec); 43 + 44 + expect(play.data.meta?.brainz).to.be.undefined; 45 + }); 46 + 47 + it('Leaves brainz artists undefined if no artist mbids', function() { 48 + 49 + const [rec, {tid, did}] = generateTealPlayRecord(); 50 + rec.value.artists = rec.value.artists.map(x => ({artistName: x.artistName})); 51 + const play = listRecordToPlay(rec); 52 + 53 + expect(play.data.meta?.brainz).to.not.be.undefined; 54 + expect(play.data.meta?.brainz.artist).to.be.undefined; 55 + }); 56 + 57 + });
+44 -1
src/backend/tests/utils/PlayTestUtils.ts
··· 11 11 import { arrayListAnd } from '../../../core/StringUtils.js'; 12 12 import { findDelimiters } from '../../utils/StringUtils.js'; 13 13 import { TrackObject } from 'lastfm-node-client'; 14 + import { ListRecord, ScrobbleRecord } from '../../common/infrastructure/config/client/tealfm.js'; 15 + import { nanoid } from 'nanoid'; 14 16 15 17 dayjs.extend(utc) 16 18 dayjs.extend(isBetween); ··· 332 334 faker.string.alphanumeric({length: 4}), 333 335 faker.string.alphanumeric({length: 12}) 334 336 ].join('-') 335 - } 337 + } 338 + 339 + export const generateTealPlayRecord = (opts: { 340 + withMbids?: boolean, 341 + withIsrc?: boolean 342 + } = {}): [ListRecord<ScrobbleRecord>, { did: string, tid: string }] => { 343 + const { 344 + withMbids = true, 345 + withIsrc = true, 346 + } = opts; 347 + 348 + const now = dayjs(); 349 + const artists = generateArtists(2); 350 + 351 + const did = nanoid(12); 352 + const tid = nanoid(10); 353 + 354 + const rec: ListRecord<ScrobbleRecord> = { 355 + uri: `at://did:plc:${did}/fm.teal.alpha.feed.play/${tid}`, 356 + cid: nanoid(12), 357 + value: { 358 + '$type': 'fm.teal.alpha.feed.play', 359 + artists: artists.map(x => withMbids ? { artistName: x, artistMbId: generateMbid() } : { artistName: x }), 360 + releaseName: faker.music.album(), 361 + trackName: faker.music.songName(), 362 + playedTime: now.toISOString(), 363 + submissionClientAgent: 'test', 364 + duration: faker.number.int({ min: 1, max: 300 }) 365 + } 366 + } 367 + 368 + if (withMbids) { 369 + rec.value.releaseMbId = generateMbid(); 370 + rec.value.recordingMbId = generateMbid(); 371 + } 372 + 373 + if (withIsrc) { 374 + rec.value.isrc = nanoid(12); 375 + } 376 + 377 + return [rec, { did, tid }]; 378 + }
+18 -11
src/backend/common/vendor/bluesky/AbstractBlueSkyApiClient.ts
··· 4 4 import AbstractApiClient from "../AbstractApiClient.js"; 5 5 import { Agent } from "@atproto/api"; 6 6 import { MSCache } from "../../Cache.js"; 7 - import { PlayObject } from "../../../../core/Atomic.js"; 7 + import { BrainzMeta, PlayObject } from "../../../../core/Atomic.js"; 8 8 import { musicServiceToCononical } from "../ListenbrainzApiClient.js"; 9 9 import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; 10 10 import { RecordOptions } from "../../infrastructure/config/client/tealfm.js"; 11 11 import dayjs from "dayjs"; 12 12 import { getScrobbleTsSOCDateWithContext } from "../../../utils/TimeUtils.js"; 13 + import { removeUndefinedKeys } from "../../../utils.js"; 13 14 14 15 15 16 export abstract class AbstractBlueSkyApiClient extends AbstractApiClient { ··· 73 74 }; 74 75 75 76 return record; 76 - };export const listRecordToPlay = (listRecord: ListRecord<ScrobbleRecord>): PlayObject => { 77 + } 78 + 79 + export const listRecordToPlay = (listRecord: ListRecord<ScrobbleRecord>): PlayObject => { 77 80 const opts: RecordOptions = {}; 78 81 const uriRes = parseRegexSingle(ATPROTO_URI_REGEX, listRecord.uri); 79 82 if (uriRes !== undefined) { ··· 82 85 opts.user = uriRes.named.did; 83 86 } 84 87 return recordToPlay(listRecord.value, opts); 85 - }; 88 + } 89 + 86 90 export const recordToPlay = (record: ScrobbleRecord, options: RecordOptions = {}): PlayObject => { 87 91 88 92 const play: PlayObject = { ··· 92 96 duration: record.duration, 93 97 playDate: dayjs(record.playedTime), 94 98 album: record.releaseName, 95 - isrc: record.isrc, 96 - meta: { 97 - brainz: { 98 - track: record.recordingMbId, 99 - album: record.releaseMbId, 100 - artist: record.artists.filter(x => x.artistMbId !== undefined).map(x => x.artistMbId) 101 - } 102 - } 99 + isrc: record.isrc 103 100 }, 104 101 meta: { 105 102 source: 'tealfm', ··· 112 109 user: options.user 113 110 } 114 111 }; 112 + 113 + const brainz: BrainzMeta | undefined = removeUndefinedKeys({ 114 + track: record.recordingMbId, 115 + album: record.releaseMbId, 116 + artist: record.artists.filter(x => x.artistMbId !== undefined).length > 0 ? record.artists.filter(x => x.artistMbId !== undefined).map(x => x.artistMbId) : undefined 117 + }); 118 + 119 + if(brainz !== undefined) { 120 + play.data.meta = {brainz}; 121 + } 115 122 116 123 return play; 117 124 };
+5 -1
src/backend/common/infrastructure/config/client/tealfm.ts
··· 51 51 export interface TealClientAIOConfig extends TealClientConfig { 52 52 type: 'tealfm' 53 53 } 54 + export interface TealArtistCredit { 55 + artistName?: string, 56 + artistMbId?: string 57 + } 54 58 /** 55 59 * https://github.com/teal-fm/teal/blob/main/lexicons/fm.teal.alpha/feed/play.json 56 60 * https://github.com/teal-fm/teal/blob/main/lexicons/fm.teal.alpha/feed/defs.json ··· 60 64 trackName: string, 61 65 playedTime: string, 62 66 duration?: number 63 - artists?: {artistName?: string, artistMbId?: string}[] 67 + artists?: TealArtistCredit[] 64 68 /** Album name */ 65 69 releaseName?: string 66 70 /** A metadata string specifying the user agent where the format is `<app-identifier>/<version> (<kernel/OS-base>; <platform/OS-version>; <device-model>)` */