[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: Normalize ISRC values #643

Spotify does not gaurantee non-hyphenated ISRC and musicbrainz won't search for hyphenated, so normalize before ISRC values downstream

FoxxMD (Jul 22, 2026, 5:19 PM UTC) fc85790d a30d1ae6

+143 -4
+2 -1
src/backend/common/vendor/ListenbrainzApiClient.ts
··· 28 28 import { findCauseByFunc } from '../../utils/ErrorUtils.ts'; 29 29 import { isSuperAgentResponseError } from '../errors/ErrorUtils.ts'; 30 30 import { playToSubmitPayload } from './listenbrainz/lzUtils.ts'; 31 + import { isrcNoHyphens } from '../../../core/PlayUtils.ts'; 31 32 32 33 33 34 export interface SubmitOptions { ··· 694 695 album: release_name, 695 696 albumArtists: artistNamesToCredits(albumArtists), 696 697 duration: dur, 697 - isrc: isrc !== undefined ? isrc : undefined, 698 + isrc: isrc !== undefined ? isrcNoHyphens(isrc) : undefined, 698 699 meta: { 699 700 } 700 701 },
+2 -1
src/backend/common/vendor/RockSkyApiClient.ts
··· 22 22 import type {HandleData} from "../infrastructure/config/client/atproto.ts"; 23 23 import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; 24 24 import { removeUndefinedKeys } from "../../../core/DataUtils.ts"; 25 + import { isrcNoHyphens } from '../../../core/PlayUtils.ts'; 25 26 26 27 interface SubmitOptions { 27 28 log?: boolean ··· 309 310 artist: artistCreditsToNames(play.data.artists).join(', '), 310 311 album: play.data.album, 311 312 mbId: play.data.meta?.brainz?.track, 312 - isrc: play.data.isrc, 313 + isrc: play.data.isrc !== undefined ? isrcNoHyphens(play.data.isrc) : undefined, 313 314 duration: play.data.duration !== undefined ? play.data.duration * 1000 : undefined, 314 315 spotifyLink: play.meta.source === 'spotify' && play.meta.url?.web !== undefined ? play.meta.url?.web : undefined, 315 316 timestamp: play.data.playDate.unix()
+2 -1
src/backend/common/vendor/musicbrainz/MusicbrainzApiClient.ts
··· 22 22 import type {IRecordingMSList} from '../../transforms/MusicbrainzTransformer.ts'; 23 23 import dayjs, { type Dayjs } from 'dayjs'; 24 24 import { artistCreditsToNames } from '../../../../core/StringUtils.ts'; 25 + import { isrcNoHyphens } from '../../../../core/PlayUtils.ts'; 25 26 26 27 export interface SubmitResponse { 27 28 payload?: { ··· 259 260 query.artist_mbids = play.data.meta.brainz.artist 260 261 } 261 262 if(play.data.isrc !== undefined && using.includes('isrc')) { 262 - query.isrc = play.data.isrc; 263 + query.isrc = isrcNoHyphens(play.data.isrc); 263 264 } 264 265 if(using.includes('title')) { 265 266 query.recording = play.data.track;
+2 -1
src/backend/common/vendor/teal/TealApiClient.ts
··· 18 18 import { UpstreamError } from "../../errors/UpstreamError.ts"; 19 19 import type { ComAtprotoRepoCreateRecord, ComAtprotoRepoPutRecord } from '@atcute/atproto'; 20 20 import { nowPlayingExpirationDuration } from "../../../scrobblers/AbstractScrobbleClient.ts"; 21 + import { isrcNoHyphens } from "../../../../core/PlayUtils.ts"; 21 22 22 23 export class TealApiClient extends AbstractApiClient implements PagelessTimeRangeListens { 23 24 ··· 196 197 releaseName: play.data.album, 197 198 submissionClientAgent: `multi-scrobbler/${getRoot().items.version}`, 198 199 musicServiceBaseDomain: musicServiceToCononical(play.meta.musicService) ?? play.meta.musicService, 199 - isrc: play.data.isrc, 200 + isrc: play.data.isrc !== undefined ? isrcNoHyphens(play.data.isrc) : undefined, 200 201 trackMbId: mbidUriOrUndefined(play.data.meta?.brainz?.track as MBID), 201 202 recordingMbId: mbidUriOrUndefined(play.data.meta?.brainz?.recording as MBID), 202 203 releaseMbId: mbidUriOrUndefined(play.data.meta?.brainz?.album as MBID)
+85
src/backend/tests/utils/strings.test.ts
··· 7 7 import { replaceInterpolatedValues } from "../../utils/DataUtils.ts"; 8 8 import { splitByFirstFound } from '../../../core/StringUtils.ts'; 9 9 import { noCasePropObj } from '../../utils/DataUtils.ts'; 10 + import { isrcNoHyphens, isrcWithHyphens, REGEX_ISRC_HYPHENS, REGEX_ISRC_NO_HYPHENS } from '../../../core/PlayUtils.ts'; 10 11 11 12 describe('String Comparisons', function () { 12 13 ··· 172 173 }); 173 174 }); 174 175 176 + describe('ISRC Parsing', function() { 177 + 178 + describe('Regex tests', function() { 179 + 180 + it('isrc no-hypen regex matches isrc with no hyphens', function() { 181 + const isrcs = ['USRC17607839','GBWUL1805639','USUG11902962','QT65X2609829']; 182 + for(const i of isrcs) { 183 + expect(REGEX_ISRC_NO_HYPHENS.test(i)).is.true; 184 + } 185 + }); 186 + 187 + it('isrc no-hypen regex does not match isrc with hyphens or invalid', function() { 188 + const isrcs = ['US-RC1-76-07839','GB-WUL1-80-5639','US-UG1-19-02962','QTLASDSDASD','']; 189 + for(const i of isrcs) { 190 + expect(REGEX_ISRC_NO_HYPHENS.test(i)).is.false; 191 + } 192 + }); 193 + 194 + it('isrc hypen regex matches isrc with hyphens', function() { 195 + const isrcs = ['US-RC1-76-07839','GB-WUL-18-05639','US-UG1-19-02962']; 196 + for(const i of isrcs) { 197 + expect(REGEX_ISRC_HYPHENS.test(i), `${i} did not match`).is.true; 198 + } 199 + }); 200 + 201 + it('isrc hypen regex does not match isrc with no hyphens or invalid', function() { 202 + const isrcs = ['USRC17607839','GBWUL1805639','USUG11902962','QT65X2609829','QTLASDSDASD','']; 203 + for(const i of isrcs) { 204 + expect(REGEX_ISRC_HYPHENS.test(i)).is.false; 205 + } 206 + }); 207 + 208 + }); 209 + 210 + describe('ISRC Transform', function() { 211 + 212 + describe('To non-hyphenated', function() { 213 + it('Transforms from hyphenated', function() { 214 + const isrcs = ['US-RC1-76-07839','GB-WUL-18-05639','US-UG1-19-02962']; 215 + for(const i of isrcs) { 216 + const res = isrcNoHyphens(i); 217 + expect(res).eq(i.replaceAll(/-/g, '')); 218 + } 219 + }); 220 + 221 + it('Returns already non-hyphenated', function() { 222 + const isrcs = ['USRC17607839','GBWUL1805639','USUG11902962','QT65X2609829']; 223 + for(const i of isrcs) { 224 + const res = isrcNoHyphens(i); 225 + expect(res).eq(i); 226 + } 227 + }); 228 + 229 + it('Fails on invalid', function() { 230 + const isrcs = ['GB-WUL1-80-5639','QTLASDSDASD','']; 231 + for(const i of isrcs) { 232 + expect(() => isrcNoHyphens(i)).to.throw(); 233 + } 234 + }); 235 + }); 236 + 237 + describe('To hyphenated', function() { 238 + it('Transforms from non-hyphenated', function() { 239 + const res = isrcWithHyphens('USRC17607839'); 240 + expect(res).eq('US-RC1-76-07839'); 241 + }); 242 + 243 + it('Returns already non-hyphenated', function() { 244 + const isrcs = ['US-RC1-76-07839','GB-WUL-18-05639','US-UG1-19-02962']; 245 + for(const i of isrcs) { 246 + const res = isrcWithHyphens(i); 247 + expect(res).eq(i); 248 + } 249 + }); 250 + 251 + it('Fails on invalid', function() { 252 + const isrcs = ['GB-WUL1-80-5639','QTLASDSDASD','']; 253 + for(const i of isrcs) { 254 + expect(() => isrcNoHyphens(i)).to.throw(); 255 + } 256 + }); 257 + }); 258 + }); 259 + });
+50
src/core/PlayUtils.ts
··· 1 + import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; 1 2 import type {AmbPlayObject, DateLike, PlayObject, PlayObjectMinimal, PlayPlatformId} from "./Atomic.ts"; 2 3 import dayjs from "dayjs"; 3 4 ··· 56 57 }; 57 58 }; 58 59 60 + export const REGEX_ISRC_NO_HYPHENS = new RegExp(/^(?<cc>[\d\w]{2})(?<registrant>[\d\w]{3})(?<year>\d{2})(?<designation>\d{5})$/); 61 + export const REGEX_ISRC_HYPHENS = new RegExp(/^(?<cc>[\d\w]{2})-(?<registrant>[\d\w]{3})-(?<year>\d{2})-(?<designation>\d{5})$/); 62 + 63 + /** 64 + * Removes hyphens from ISRC identifiers 65 + * 66 + * ISRC identifiers officially use hyphens but it is valid to use them without 67 + * and some systems expect no hyphens (musicbrainz) 68 + * 69 + * @see https://en.wikipedia.org/wiki/International_Standard_Recording_Code 70 + * @see https://isrctools.com/format/ 71 + */ 72 + export const isrcNoHyphens = (isrc: string): string => { 73 + const parsed = parseRegexSingle(REGEX_ISRC_HYPHENS, isrc); 74 + if(parsed === undefined) { 75 + // check if its already parsed 76 + const alreadyOk = REGEX_ISRC_NO_HYPHENS.test(isrc); 77 + if(alreadyOk) { 78 + return isrc; 79 + } 80 + throw new Error(`Value ${isrc} is not a valid ISRC`); 81 + } 82 + return `${parsed.named.cc}${parsed.named.registrant}${parsed.named.year}${parsed.named.designation}`; 83 + } 84 + 85 + /** 86 + * Add hyphens to ISRC identifiers 87 + * 88 + * ISRC identifiers officially use hyphens but it is valid to use them without 89 + * and some systems expect no hyphens (musicbrainz). 90 + * 91 + * This function re-adds hyphens to an isrc value if they are not present 92 + * 93 + * @see https://en.wikipedia.org/wiki/International_Standard_Recording_Code 94 + * @see https://isrctools.com/format/ 95 + * 96 + */ 97 + export const isrcWithHyphens = (isrc: string): string => { 98 + const parsed = parseRegexSingle(REGEX_ISRC_NO_HYPHENS, isrc); 99 + if(parsed === undefined) { 100 + // check if its already parsed 101 + const alreadyOk = REGEX_ISRC_HYPHENS.test(isrc); 102 + if(alreadyOk) { 103 + return isrc; 104 + } 105 + throw new Error(`Value ${isrc} is not a valid ISRC`); 106 + } 107 + return `${parsed.named.cc}-${parsed.named.registrant}-${parsed.named.year}-${parsed.named.designation}`; 108 + }