diff --git a/src/backend/common/vendor/ListenbrainzApiClient.ts b/src/backend/common/vendor/ListenbrainzApiClient.ts index fe908f9a..1e6cc77d 100644 --- a/src/backend/common/vendor/ListenbrainzApiClient.ts +++ b/src/backend/common/vendor/ListenbrainzApiClient.ts @@ -28,6 +28,7 @@ import pRetry from 'p-retry'; import { findCauseByFunc } from '../../utils/ErrorUtils.ts'; import { isSuperAgentResponseError } from '../errors/ErrorUtils.ts'; import { playToSubmitPayload } from './listenbrainz/lzUtils.ts'; +import { isrcNoHyphens } from '../../../core/PlayUtils.ts'; export interface SubmitOptions { @@ -694,7 +695,7 @@ export const listenToNaivePlay = (listen: ListenResponse): PlayObject => { album: release_name, albumArtists: artistNamesToCredits(albumArtists), duration: dur, - isrc: isrc !== undefined ? isrc : undefined, + isrc: isrc !== undefined ? isrcNoHyphens(isrc) : undefined, meta: { } }, diff --git a/src/backend/common/vendor/RockSkyApiClient.ts b/src/backend/common/vendor/RockSkyApiClient.ts index 7f256aa0..4c715efb 100644 --- a/src/backend/common/vendor/RockSkyApiClient.ts +++ b/src/backend/common/vendor/RockSkyApiClient.ts @@ -22,6 +22,7 @@ import type { MSCache } from "../Cache.ts"; import type {HandleData} from "../infrastructure/config/client/atproto.ts"; import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; import { removeUndefinedKeys } from "../../../core/DataUtils.ts"; +import { isrcNoHyphens } from '../../../core/PlayUtils.ts'; interface SubmitOptions { log?: boolean @@ -309,7 +310,7 @@ export const playToRockskyRecord = (play: PlayObject): CreateScrobbleInput => { artist: artistCreditsToNames(play.data.artists).join(', '), album: play.data.album, mbId: play.data.meta?.brainz?.track, - isrc: play.data.isrc, + isrc: play.data.isrc !== undefined ? isrcNoHyphens(play.data.isrc) : undefined, duration: play.data.duration !== undefined ? play.data.duration * 1000 : undefined, spotifyLink: play.meta.source === 'spotify' && play.meta.url?.web !== undefined ? play.meta.url?.web : undefined, timestamp: play.data.playDate.unix() diff --git a/src/backend/common/vendor/musicbrainz/MusicbrainzApiClient.ts b/src/backend/common/vendor/musicbrainz/MusicbrainzApiClient.ts index b04c7145..30ba4e0f 100644 --- a/src/backend/common/vendor/musicbrainz/MusicbrainzApiClient.ts +++ b/src/backend/common/vendor/musicbrainz/MusicbrainzApiClient.ts @@ -22,6 +22,7 @@ import { baseFormatPlayObj } from '../../../utils/PlayTransformUtils.ts'; import type {IRecordingMSList} from '../../transforms/MusicbrainzTransformer.ts'; import dayjs, { type Dayjs } from 'dayjs'; import { artistCreditsToNames } from '../../../../core/StringUtils.ts'; +import { isrcNoHyphens } from '../../../../core/PlayUtils.ts'; export interface SubmitResponse { payload?: { @@ -259,7 +260,7 @@ export class MusicbrainzApiClient extends AbstractApiClient { query.artist_mbids = play.data.meta.brainz.artist } if(play.data.isrc !== undefined && using.includes('isrc')) { - query.isrc = play.data.isrc; + query.isrc = isrcNoHyphens(play.data.isrc); } if(using.includes('title')) { query.recording = play.data.track; diff --git a/src/backend/common/vendor/teal/TealApiClient.ts b/src/backend/common/vendor/teal/TealApiClient.ts index ea538e73..b7152bd3 100644 --- a/src/backend/common/vendor/teal/TealApiClient.ts +++ b/src/backend/common/vendor/teal/TealApiClient.ts @@ -18,6 +18,7 @@ import type { ATProtoAuthenticatedApiClient } from "../atproto/ATProtoAuthentica import { UpstreamError } from "../../errors/UpstreamError.ts"; import type { ComAtprotoRepoCreateRecord, ComAtprotoRepoPutRecord } from '@atcute/atproto'; import { nowPlayingExpirationDuration } from "../../../scrobblers/AbstractScrobbleClient.ts"; +import { isrcNoHyphens } from "../../../../core/PlayUtils.ts"; export class TealApiClient extends AbstractApiClient implements PagelessTimeRangeListens { @@ -196,7 +197,7 @@ export const playToRecord = (play: PlayObject): FmTealAlphaFeedPlay.Main => { releaseName: play.data.album, submissionClientAgent: `multi-scrobbler/${getRoot().items.version}`, musicServiceBaseDomain: musicServiceToCononical(play.meta.musicService) ?? play.meta.musicService, - isrc: play.data.isrc, + isrc: play.data.isrc !== undefined ? isrcNoHyphens(play.data.isrc) : undefined, trackMbId: mbidUriOrUndefined(play.data.meta?.brainz?.track as MBID), recordingMbId: mbidUriOrUndefined(play.data.meta?.brainz?.recording as MBID), releaseMbId: mbidUriOrUndefined(play.data.meta?.brainz?.album as MBID) diff --git a/src/backend/tests/utils/strings.test.ts b/src/backend/tests/utils/strings.test.ts index bb339921..3ba831d4 100644 --- a/src/backend/tests/utils/strings.test.ts +++ b/src/backend/tests/utils/strings.test.ts @@ -7,6 +7,7 @@ import { import { replaceInterpolatedValues } from "../../utils/DataUtils.ts"; import { splitByFirstFound } from '../../../core/StringUtils.ts'; import { noCasePropObj } from '../../utils/DataUtils.ts'; +import { isrcNoHyphens, isrcWithHyphens, REGEX_ISRC_HYPHENS, REGEX_ISRC_NO_HYPHENS } from '../../../core/PlayUtils.ts'; describe('String Comparisons', function () { @@ -172,3 +173,87 @@ describe('Interpolation', function() { }); }); +describe('ISRC Parsing', function() { + + describe('Regex tests', function() { + + it('isrc no-hypen regex matches isrc with no hyphens', function() { + const isrcs = ['USRC17607839','GBWUL1805639','USUG11902962','QT65X2609829']; + for(const i of isrcs) { + expect(REGEX_ISRC_NO_HYPHENS.test(i)).is.true; + } + }); + + it('isrc no-hypen regex does not match isrc with hyphens or invalid', function() { + const isrcs = ['US-RC1-76-07839','GB-WUL1-80-5639','US-UG1-19-02962','QTLASDSDASD','']; + for(const i of isrcs) { + expect(REGEX_ISRC_NO_HYPHENS.test(i)).is.false; + } + }); + + it('isrc hypen regex matches isrc with hyphens', function() { + const isrcs = ['US-RC1-76-07839','GB-WUL-18-05639','US-UG1-19-02962']; + for(const i of isrcs) { + expect(REGEX_ISRC_HYPHENS.test(i), `${i} did not match`).is.true; + } + }); + + it('isrc hypen regex does not match isrc with no hyphens or invalid', function() { + const isrcs = ['USRC17607839','GBWUL1805639','USUG11902962','QT65X2609829','QTLASDSDASD','']; + for(const i of isrcs) { + expect(REGEX_ISRC_HYPHENS.test(i)).is.false; + } + }); + + }); + + describe('ISRC Transform', function() { + + describe('To non-hyphenated', function() { + it('Transforms from hyphenated', function() { + const isrcs = ['US-RC1-76-07839','GB-WUL-18-05639','US-UG1-19-02962']; + for(const i of isrcs) { + const res = isrcNoHyphens(i); + expect(res).eq(i.replaceAll(/-/g, '')); + } + }); + + it('Returns already non-hyphenated', function() { + const isrcs = ['USRC17607839','GBWUL1805639','USUG11902962','QT65X2609829']; + for(const i of isrcs) { + const res = isrcNoHyphens(i); + expect(res).eq(i); + } + }); + + it('Fails on invalid', function() { + const isrcs = ['GB-WUL1-80-5639','QTLASDSDASD','']; + for(const i of isrcs) { + expect(() => isrcNoHyphens(i)).to.throw(); + } + }); + }); + + describe('To hyphenated', function() { + it('Transforms from non-hyphenated', function() { + const res = isrcWithHyphens('USRC17607839'); + expect(res).eq('US-RC1-76-07839'); + }); + + it('Returns already non-hyphenated', function() { + const isrcs = ['US-RC1-76-07839','GB-WUL-18-05639','US-UG1-19-02962']; + for(const i of isrcs) { + const res = isrcWithHyphens(i); + expect(res).eq(i); + } + }); + + it('Fails on invalid', function() { + const isrcs = ['GB-WUL1-80-5639','QTLASDSDASD','']; + for(const i of isrcs) { + expect(() => isrcNoHyphens(i)).to.throw(); + } + }); + }); + }); +}); \ No newline at end of file diff --git a/src/core/PlayUtils.ts b/src/core/PlayUtils.ts index 4804c858..18d36f62 100644 --- a/src/core/PlayUtils.ts +++ b/src/core/PlayUtils.ts @@ -1,3 +1,4 @@ +import { parseRegexSingle } from "@foxxmd/regex-buddy-core"; import type {AmbPlayObject, DateLike, PlayObject, PlayObjectMinimal, PlayPlatformId} from "./Atomic.ts"; import dayjs from "dayjs"; @@ -56,3 +57,52 @@ export const statefulInvariantTransform = (play: PlayObject, withIds?: boolean): }; }; +export const REGEX_ISRC_NO_HYPHENS = new RegExp(/^(?[\d\w]{2})(?[\d\w]{3})(?\d{2})(?\d{5})$/); +export const REGEX_ISRC_HYPHENS = new RegExp(/^(?[\d\w]{2})-(?[\d\w]{3})-(?\d{2})-(?\d{5})$/); + +/** + * Removes hyphens from ISRC identifiers + * + * ISRC identifiers officially use hyphens but it is valid to use them without + * and some systems expect no hyphens (musicbrainz) + * + * @see https://en.wikipedia.org/wiki/International_Standard_Recording_Code + * @see https://isrctools.com/format/ + */ +export const isrcNoHyphens = (isrc: string): string => { + const parsed = parseRegexSingle(REGEX_ISRC_HYPHENS, isrc); + if(parsed === undefined) { + // check if its already parsed + const alreadyOk = REGEX_ISRC_NO_HYPHENS.test(isrc); + if(alreadyOk) { + return isrc; + } + throw new Error(`Value ${isrc} is not a valid ISRC`); + } + return `${parsed.named.cc}${parsed.named.registrant}${parsed.named.year}${parsed.named.designation}`; +} + +/** + * Add hyphens to ISRC identifiers + * + * ISRC identifiers officially use hyphens but it is valid to use them without + * and some systems expect no hyphens (musicbrainz). + * + * This function re-adds hyphens to an isrc value if they are not present + * + * @see https://en.wikipedia.org/wiki/International_Standard_Recording_Code + * @see https://isrctools.com/format/ + * + */ +export const isrcWithHyphens = (isrc: string): string => { + const parsed = parseRegexSingle(REGEX_ISRC_NO_HYPHENS, isrc); + if(parsed === undefined) { + // check if its already parsed + const alreadyOk = REGEX_ISRC_HYPHENS.test(isrc); + if(alreadyOk) { + return isrc; + } + throw new Error(`Value ${isrc} is not a valid ISRC`); + } + return `${parsed.named.cc}-${parsed.named.registrant}-${parsed.named.year}-${parsed.named.designation}`; +} \ No newline at end of file