From 7289e99599c8e86594285c0477ac44dec8a9f1e6 Mon Sep 17 00:00:00 2001 From: Gustav Utterheim Date: Thu, 2 Oct 2025 20:51:28 +0200 Subject: [PATCH] feat: Detect DJ-mix based on the release title or track titles --- harmonizer/release_types.test.ts | 102 ++++++++++++++++++++++++++++++- harmonizer/release_types.ts | 27 +++++++- 2 files changed, 127 insertions(+), 2 deletions(-) diff --git a/harmonizer/release_types.test.ts b/harmonizer/release_types.test.ts index 93eb9c3..2795a2f 100644 --- a/harmonizer/release_types.test.ts +++ b/harmonizer/release_types.test.ts @@ -1,5 +1,6 @@ import { capitalizeReleaseType, + guessDjMixRelease, guessLiveRelease, guessTypesForRelease, guessTypesFromTitle, @@ -15,7 +16,7 @@ import type { FunctionSpec } from '../utils/test_spec.ts'; describe('release types', () => { describe('guess types for release', () => { - const passingCases: Array<[string, HarmonyRelease, string[]]> = [ + const passingCases: Array<[string, HarmonyRelease, ReleaseGroupType[]]> = [ ['should detect EP type from title', makeRelease('Wake of a Nation (EP)'), ['EP']], ['should keep existing types', makeRelease('Wake of a Nation (EP)', ['Interview']), ['EP', 'Interview']], ['should detect live type from title', makeRelease('One Second (Live)'), ['Live']], @@ -24,6 +25,15 @@ describe('release types', () => { makeRelease('One Second', undefined, [{ title: 'One Second - Live' }, { title: 'Darker Thoughts - Live' }]), ['Live'], ], + ['should detect DJ-mix type from title', makeRelease('DJ-Kicks (Forest Swords) [DJ Mix]'), ['DJ-mix']], + [ + 'should detect DJ-mix type from tracks', + makeRelease('DJ-Kicks: Modeselektor', undefined, [ + { title: 'PREY - Mixed' }, + { title: 'Permit Riddim - Mixed' }, + ]), + ['DJ-mix'], + ], ]; passingCases.forEach(([description, release, expected]) => { @@ -123,6 +133,21 @@ describe('release types', () => { new Set(['Remix']), ])), ['should not treat a premix as remix', 'Wild (premix version)', new Set()], + // DJ Mix releases + ...([ + 'Kitsuné Musique Mixed by YOU LOVE HER (DJ Mix)', + 'Club Life - Volume One Las Vegas (Continuous DJ Mix)', + 'DJ-Kicks (Forest Swords) [DJ Mix]', + 'Paragon Continuous DJ Mix', + 'Babylicious (Continuous DJ Mix by Baby Anne)', + ].map(( + title, + ): FunctionSpec[number] => [ + `should detect DJ-mix type (${title})`, + title, + new Set(['DJ-mix']), + ])), + ['should not treat just DJ mix as DJ-mix', 'DJ mix', new Set()], // Multiple types [ 'should detect both remix and soundtrack type', @@ -194,6 +219,81 @@ describe('release types', () => { }); }); + describe('guess DJ-mix release', () => { + const passingCases: FunctionSpec = [ + ['should be true if all tracks have mixed type', [ + { + tracklist: [ + { title: 'Heavenly Hell (feat. Ne-Yo) (Mixed)' }, + { title: 'Clap Back (feat. Raphaella) (Mixed)' }, + { title: '2x2 (Mixed)' }, + ], + }, + ], true], + ['should be true if all tracks on one medium have mixed type', [ + { + tracklist: [ + { title: 'PREY - Mixed' }, + { title: 'Permit Riddim - Mixed' }, + { title: 'MEGA MEGA MEGA (DJ-Kicks) - Mixed' }, + ], + }, + { + tracklist: [ + { title: 'PREY' }, + { title: 'Permit Riddim' }, + { title: 'MEGA MEGA MEGA (DJ-Kicks)' }, + ], + }, + ], true], + ['should support " - Mixed" style', [ + { + tracklist: [ + { title: 'Salute - Mixed' }, + { title: 'Friday - Mixed' }, + ], + }, + ], true], + ['should support case insensitive of mixed', [ + { + tracklist: [ + { title: 'Heavenly Hell (feat. Ne-Yo) (mixed)' }, + { title: 'Clap Back (feat. Raphaella) (mixed)' }, + { title: '2x2 (mixed)' }, + ], + }, + ], true], + ['should support mixed usage of formats', [ + { + tracklist: [ + { title: 'Heavenly Hell (feat. Ne-Yo) [Mixed]' }, + { title: 'Clap Back (feat. Raphaella) (Mixed)' }, + { title: '2x2 - Mixed' }, + ], + }, + ], true], + ['should be false if not all tracks are mixed', [ + { + tracklist: [ + { title: 'Heavenly Hell (feat. Ne-Yo) (Mixed)' }, + { title: 'Clap Back (feat. Raphaella)' }, + { title: '2x2 (Mixed)' }, + ], + }, + ], false], + ['should be false for empty tracklist', [{ + tracklist: [], + }], false], + ['should be false for no medium', [], false], + ]; + + passingCases.forEach(([description, input, expected]) => { + it(description, () => { + assertEquals(guessDjMixRelease(input), expected); + }); + }); + }); + describe('capitalize release type', () => { const passingCases: FunctionSpec = [ ['should uppercase first letter', 'single', 'Single'], diff --git a/harmonizer/release_types.ts b/harmonizer/release_types.ts index 0ec1d05..a0cdcb7 100644 --- a/harmonizer/release_types.ts +++ b/harmonizer/release_types.ts @@ -1,4 +1,4 @@ -import { HarmonyRelease, HarmonyTrack, ReleaseGroupType } from './types.ts'; +import { HarmonyMedium, HarmonyRelease, HarmonyTrack, ReleaseGroupType } from './types.ts'; import { primaryTypeIds } from '@kellnerd/musicbrainz/data/release-group'; /** Guess the types for a release from release and track titles. */ @@ -8,6 +8,9 @@ export function guessTypesForRelease(release: HarmonyRelease): Iterable media.tracklist))) { types.add('Live'); } + if (!types.has('DJ-mix') && guessDjMixRelease(release.media)) { + types.add('DJ-mix'); + } return types; } @@ -20,6 +23,8 @@ const releaseGroupTypeMatchers: Array<{ type?: ReleaseGroupType; pattern: RegExp { pattern: /\s(EP)(?:\s\(.*?\))?$/i }, // Common remix title: "Remixed", "The Remixes", or " ( remix)". { pattern: /\b(Remix)(?:e[sd])?\b/i }, + // Common DJ-mix titles + { type: 'DJ-mix', pattern: /\bContinuous DJ[\s-]Mix\b|[\(\[]DJ[\s-]mix[\)\]]/i }, // Common soundtrack title: "Official/Original Soundtrack" and "Original Score" { type: 'Soundtrack', @@ -87,6 +92,26 @@ export function guessLiveRelease(tracks: HarmonyTrack[]): boolean { }); } +/** + * Expression matching common DJ-mix track name patterns. + * Support `Track name - Mixed`, `Track name (Mixed)`, and `Track name [Mixed]`. + */ +const djMixTrackPattern = /\s(?:- Mixed|\(Mixed\)|\[Mixed\])(?:\s\(.*?\))?$/i; + +/** + * Returns true if all track titles on at least one medium indicate a DJ-mix release. + * + * Some DJ-mix releases have both a medium with the mixed tracks and another with the unmixed tracks. + */ +export function guessDjMixRelease(media: HarmonyMedium[]): boolean { + return media?.length > 0 && media.some((medium) => { + const tracks = medium.tracklist; + return tracks?.length > 0 && tracks.every((track) => { + return djMixTrackPattern.test(track.title); + }); + }); +} + /** Takes a release type as a string and turns it into a [ReleaseGroupType]. */ export function capitalizeReleaseType(sourceType: string): ReleaseGroupType { const type = sourceType.toLowerCase();