Skip to content

Commit 6b0127b

Browse files
authored
Merge branch 'main' into main
2 parents 5d2413f + 98547d4 commit 6b0127b

File tree

76 files changed

+7174
-153
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

76 files changed

+7174
-153
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,6 @@ snaps/
1010

1111
# Temporary files
1212
local/
13+
14+
# Coverage directory
15+
coverage/

deno.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@
44
"jsxImportSource": "preact"
55
},
66
"fmt": {
7-
"exclude": ["**/*.svg"],
7+
"exclude": ["**/*.svg", "./testdata/**"],
88
"lineWidth": 120,
99
"proseWrap": "preserve",
1010
"singleQuote": true,
1111
"useTabs": true
1212
},
1313
"imports": {
1414
"@/": "./",
15+
"@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@^0.1.56",
1516
"@deno/gfm": "jsr:@deno/gfm@^0.8.0",
1617
"@kellnerd/musicbrainz": "jsr:@kellnerd/musicbrainz@^0.4.1",
1718
"@std/collections": "jsr:@std/collections@^1.0.10",

harmonizer/isrc.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { assertEquals } from 'std/assert/assert_equals.ts';
2+
import { assertThrows } from 'std/assert/assert_throws.ts';
3+
import { describe, it } from 'std/testing/bdd.ts';
4+
import type { FunctionSpec } from '@/utils/test_spec.ts';
5+
import { ISRC, normalizeISRC } from './isrc.ts';
6+
7+
describe('ISRC', () => {
8+
it('parses a hyphenated ISRC', () => {
9+
const isrc = new ISRC('GB-AYE-69-00531');
10+
assertEquals(isrc.country, 'GB');
11+
assertEquals(isrc.registrant, 'AYE');
12+
assertEquals(isrc.year, '69');
13+
assertEquals(isrc.designation, '00531');
14+
assertEquals(isrc.format(), 'GB-AYE-69-00531');
15+
assertEquals(isrc.format(' '), 'GB AYE 69 00531');
16+
assertEquals(isrc.toString(), 'GBAYE6900531');
17+
});
18+
it('normalizes a lowercased ISRC', () => {
19+
const isrc = new ISRC(' ushm81871741 ');
20+
assertEquals(isrc.country, 'US');
21+
assertEquals(isrc.registrant, 'HM8');
22+
assertEquals(isrc.year, '18');
23+
assertEquals(isrc.designation, '71741');
24+
assertEquals(isrc.raw, 'ushm81871741');
25+
assertEquals(isrc.format(), 'US-HM8-18-71741');
26+
assertEquals(isrc.toString(), 'USHM81871741');
27+
});
28+
it('throws for unrecognized input', () => {
29+
assertThrows(() => new ISRC(''));
30+
});
31+
});
32+
33+
describe('normalizeISRC', () => {
34+
const testCases: FunctionSpec<typeof normalizeISRC> = [
35+
['preserves normalized ISRC', 'GBAYE6900531', 'GBAYE6900531'],
36+
['supports hyphenated ISRC', 'GB-AYE-69-00531', 'GBAYE6900531'],
37+
['allows ISRC with alphanumeric owner code', 'US-AT2-23-06147', 'USAT22306147'],
38+
['supports lowercased ISRC', 'ushm81871741', 'USHM81871741'],
39+
['ignores unrecognized input', 'invalid text', undefined],
40+
];
41+
testCases.forEach(([description, isrc, expected]) => {
42+
it(description, () => {
43+
assertEquals(normalizeISRC(isrc), expected);
44+
});
45+
});
46+
});

harmonizer/isrc.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { assert } from 'std/assert/assert.ts';
2+
import type { HarmonyRelease } from './types.ts';
3+
4+
const isrcPattern = /^([A-Z]{2})-?([A-Z0-9]{3})-?(\d{2})-?(\d{5})$/;
5+
6+
/** Normalized ISRC (International Standard Recording Code). */
7+
export class ISRC {
8+
readonly country: string;
9+
readonly registrant: string;
10+
readonly year: string;
11+
readonly designation: string;
12+
readonly raw: string;
13+
14+
constructor(code: string) {
15+
this.raw = code.trim();
16+
const isrcMatch = this.raw.toUpperCase().match(isrcPattern);
17+
assert(isrcMatch, 'Invalid ISRC code or unrecognized format');
18+
[this.country, this.registrant, this.year, this.designation] = isrcMatch.slice(1);
19+
}
20+
21+
/** Formats the ISRC using the given separator, which defaults to a hyphen. */
22+
format(separator = '-'): string {
23+
return [this.country, this.registrant, this.year, this.designation].join(separator);
24+
}
25+
26+
/** Returns the normalized string representation of the ISRC without separators. */
27+
toString(): string {
28+
return this.format('');
29+
}
30+
}
31+
32+
/**
33+
* Normalizes the given ISRC code.
34+
*
35+
* Valid codes are converted to uppercase and stripped of optional hyphens and whitespace.
36+
* Returns `undefined` for invalid or unrecognized codes.
37+
*/
38+
export function normalizeISRC(code: string): string | undefined {
39+
try {
40+
const isrc = new ISRC(code);
41+
return isrc.toString();
42+
} catch {
43+
return;
44+
}
45+
}
46+
47+
/**
48+
* Normalizes the ISRCs for all tracks of the given release.
49+
* Preserves invalid or unrecognized ISRCs as is.
50+
*/
51+
export function normalizeReleaseISRCs(release: HarmonyRelease) {
52+
release.media.flatMap((medium) => medium.tracklist).forEach((track) => {
53+
const { isrc } = track;
54+
if (isrc) {
55+
track.isrc = normalizeISRC(isrc) ?? isrc;
56+
}
57+
});
58+
}

harmonizer/release_types.test.ts

Lines changed: 209 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
capitalizeReleaseType,
3+
guessDjMixRelease,
34
guessLiveRelease,
45
guessTypesForRelease,
56
guessTypesFromTitle,
@@ -15,7 +16,7 @@ import type { FunctionSpec } from '../utils/test_spec.ts';
1516

1617
describe('release types', () => {
1718
describe('guess types for release', () => {
18-
const passingCases: Array<[string, HarmonyRelease, string[]]> = [
19+
const passingCases: Array<[string, HarmonyRelease, ReleaseGroupType[]]> = [
1920
['should detect EP type from title', makeRelease('Wake of a Nation (EP)'), ['EP']],
2021
['should keep existing types', makeRelease('Wake of a Nation (EP)', ['Interview']), ['EP', 'Interview']],
2122
['should detect live type from title', makeRelease('One Second (Live)'), ['Live']],
@@ -24,6 +25,15 @@ describe('release types', () => {
2425
makeRelease('One Second', undefined, [{ title: 'One Second - Live' }, { title: 'Darker Thoughts - Live' }]),
2526
['Live'],
2627
],
28+
['should detect DJ-mix type from title', makeRelease('DJ-Kicks (Forest Swords) [DJ Mix]'), ['DJ-mix']],
29+
[
30+
'should detect DJ-mix type from tracks',
31+
makeRelease('DJ-Kicks: Modeselektor', undefined, [
32+
{ title: 'PREY - Mixed' },
33+
{ title: 'Permit Riddim - Mixed' },
34+
]),
35+
['DJ-mix'],
36+
],
2737
];
2838

2939
passingCases.forEach(([description, release, expected]) => {
@@ -42,10 +52,132 @@ describe('release types', () => {
4252
['should detect type before comment', 'Enter Suicidal Angels - EP (Remastered 2021)', new Set(['EP'])],
4353
['should detect EP suffix', 'Zero Distance EP', new Set(['EP'])],
4454
['should detect demo type', 'Parasite Inc. (Demo)', new Set(['Demo'])],
55+
// Soundtrack releases
56+
...([
57+
// Titles with original/official <medium> soundtrack
58+
'The Lord of the Rings: The Return of the King (Original Motion Picture Soundtrack)',
59+
'The Bodyguard - Original Soundtrack Album',
60+
'Plants Vs. Zombies (Original Video Game Soundtrack)',
61+
'Stardew Valley (Original Game Soundtrack)',
62+
'L.A. Noire Official Soundtrack',
63+
'Tarzan (Deutscher Original Film-Soundtrack)',
64+
'Die Eiskönigin Völlig Unverfroren (Deutscher Original Film Soundtrack)',
65+
// Soundtrack from the ... <medium>
66+
'KPop Demon Hunters (Soundtrack from the Netflix Film)',
67+
'The Witcher: Season 2 (Soundtrack from the Netflix Original Series)',
68+
'The White Lotus (Soundtrack from the HBO® Original Limited Series)',
69+
'Inception (Music from the Motion Picture)',
70+
// Releases referring to score instead of soundtrack
71+
'Fantastic Mr. Fox - Additional Music From The Original Score By Alexandre Desplat - The Abbey Road Mixes',
72+
'Scott Pilgrim Vs. The World (Original Score Composed by Nigel Godrich)',
73+
'F1® The Movie (Original Score By Hans Zimmer)',
74+
'EUPHORIA SEASON 2 OFFICIAL SCORE (FROM THE HBO ORIGINAL SERIES)',
75+
'The Bible (Official Score Soundtrack)',
76+
'The Good Wife (The Official TV Score)',
77+
// German release titles
78+
'Get Up (Der Original Soundtrack zum Kinofilm)',
79+
'Ein Mädchen namens Willow - Soundtrack zum Film',
80+
'Das Boot (Soundtrack zur TV Serie, zweite Staffel)',
81+
// Swedish release titles
82+
'Fucking Åmål - Musiken från filmen',
83+
'Fejkpatient (Musik från TV-serien)',
84+
'Kärlek Fårever (Soundtrack till Netflix-filmen)',
85+
// Norwegian release titles
86+
'Kvitebjørn (Musikken fra filmen)',
87+
'Døden på Oslo S (Musikken fra teaterforestillingen)',
88+
// Musical releases
89+
'The Lion King: Original Broadway Cast Recording',
90+
].map((
91+
title,
92+
): FunctionSpec<typeof guessTypesFromTitle>[number] => [
93+
`should detect soundtrack type (${title})`,
94+
title,
95+
new Set(['Soundtrack']),
96+
])),
97+
// Remix releases
98+
...([
99+
'Human (Paul Woolford Remix)',
100+
'Paper Romance (Purple Disco Machine Remix - Edit)',
101+
'Paper Romance (Purple Disco Machine Remix) [Edit]',
102+
'Paper Romance (Purple Disco Machine Remix) (Edit)',
103+
'Paper Romance (Purple Disco Machine Remix; Edit)',
104+
"Stay (Don't Go Away) [feat. Raye] [Nicky Romero Remix]",
105+
'Anti‐Hero (Kungs remix extended version)',
106+
'Remix',
107+
'Anti‐Hero (Remixes)',
108+
'The One (feat. Daddy Yankee) [The Remixes]',
109+
'The Remixes',
110+
'The Remixes - Vol.1',
111+
'The Remixes, Pt. 1',
112+
'Remixes',
113+
'Remixes 81>04',
114+
'Never Say Never - The Remixes',
115+
'Skin: The Remixes',
116+
'The Hills Remixes',
117+
'MIDI Kittyy - The Remixes Vol 1',
118+
'The Slow Rush B-Sides & Remixes',
119+
'Remixed',
120+
'Remixed (2003 Remaster)',
121+
'Remixed Sides',
122+
'Remixed: The Definitive Collection',
123+
'The Hits: Remixed',
124+
'Remixed & Revisited',
125+
'Revived Remixed Revisited',
126+
'Welcome To My World (Remixed)',
127+
'Mörkrets Narr Remixed',
128+
].map((
129+
title,
130+
): FunctionSpec<typeof guessTypesFromTitle>[number] => [
131+
`should detect remix type (${title})`,
132+
title,
133+
new Set(['Remix']),
134+
])),
135+
['should not treat a premix as remix', 'Wild (premix version)', new Set()],
136+
// DJ Mix releases
137+
...([
138+
'Kitsuné Musique Mixed by YOU LOVE HER (DJ Mix)',
139+
'Club Life - Volume One Las Vegas (Continuous DJ Mix)',
140+
'DJ-Kicks (Forest Swords) [DJ Mix]',
141+
'Paragon Continuous DJ Mix',
142+
'Babylicious (Continuous DJ Mix by Baby Anne)',
143+
].map((
144+
title,
145+
): FunctionSpec<typeof guessTypesFromTitle>[number] => [
146+
`should detect DJ-mix type (${title})`,
147+
title,
148+
new Set(['DJ-mix']),
149+
])),
150+
['should not treat just DJ mix as DJ-mix', 'DJ mix', new Set()],
151+
// Multiple types
152+
[
153+
'should detect both remix and soundtrack type',
154+
'The Sims 2: Nightlife (Remixes) (Original Soundtrack)',
155+
new Set(['Remix', 'Soundtrack']),
156+
],
157+
[
158+
'should detect both remix and soundtrack type',
159+
'Remixes - EP',
160+
new Set(['EP', 'Remix']),
161+
],
162+
];
163+
164+
const passingCaseSensitiveCases: FunctionSpec<typeof guessTypesFromTitle> = [
165+
// Soundtrack releases
166+
...([
167+
// Releases with OST abbreviation
168+
'O.S.T. Das Boot',
169+
'Alvin & The Chipmunks / OST',
170+
].map((
171+
title,
172+
): FunctionSpec<typeof guessTypesFromTitle>[number] => [
173+
`should detect soundtrack type (${title})`,
174+
title,
175+
new Set(['Soundtrack']),
176+
])),
45177
];
46178

47179
describe('exact case match', () => {
48-
passingCases.forEach(([description, input, expected]) => {
180+
[...passingCases, ...passingCaseSensitiveCases].forEach(([description, input, expected]) => {
49181
it(description, () => {
50182
assertEquals(guessTypesFromTitle(input), expected);
51183
});
@@ -87,6 +219,81 @@ describe('release types', () => {
87219
});
88220
});
89221

222+
describe('guess DJ-mix release', () => {
223+
const passingCases: FunctionSpec<typeof guessDjMixRelease> = [
224+
['should be true if all tracks have mixed type', [
225+
{
226+
tracklist: [
227+
{ title: 'Heavenly Hell (feat. Ne-Yo) (Mixed)' },
228+
{ title: 'Clap Back (feat. Raphaella) (Mixed)' },
229+
{ title: '2x2 (Mixed)' },
230+
],
231+
},
232+
], true],
233+
['should be true if all tracks on one medium have mixed type', [
234+
{
235+
tracklist: [
236+
{ title: 'PREY - Mixed' },
237+
{ title: 'Permit Riddim - Mixed' },
238+
{ title: 'MEGA MEGA MEGA (DJ-Kicks) - Mixed' },
239+
],
240+
},
241+
{
242+
tracklist: [
243+
{ title: 'PREY' },
244+
{ title: 'Permit Riddim' },
245+
{ title: 'MEGA MEGA MEGA (DJ-Kicks)' },
246+
],
247+
},
248+
], true],
249+
['should support " - Mixed" style', [
250+
{
251+
tracklist: [
252+
{ title: 'Salute - Mixed' },
253+
{ title: 'Friday - Mixed' },
254+
],
255+
},
256+
], true],
257+
['should support case insensitive of mixed', [
258+
{
259+
tracklist: [
260+
{ title: 'Heavenly Hell (feat. Ne-Yo) (mixed)' },
261+
{ title: 'Clap Back (feat. Raphaella) (mixed)' },
262+
{ title: '2x2 (mixed)' },
263+
],
264+
},
265+
], true],
266+
['should support mixed usage of formats', [
267+
{
268+
tracklist: [
269+
{ title: 'Heavenly Hell (feat. Ne-Yo) [Mixed]' },
270+
{ title: 'Clap Back (feat. Raphaella) (Mixed)' },
271+
{ title: '2x2 - Mixed' },
272+
],
273+
},
274+
], true],
275+
['should be false if not all tracks are mixed', [
276+
{
277+
tracklist: [
278+
{ title: 'Heavenly Hell (feat. Ne-Yo) (Mixed)' },
279+
{ title: 'Clap Back (feat. Raphaella)' },
280+
{ title: '2x2 (Mixed)' },
281+
],
282+
},
283+
], false],
284+
['should be false for empty tracklist', [{
285+
tracklist: [],
286+
}], false],
287+
['should be false for no medium', [], false],
288+
];
289+
290+
passingCases.forEach(([description, input, expected]) => {
291+
it(description, () => {
292+
assertEquals(guessDjMixRelease(input), expected);
293+
});
294+
});
295+
});
296+
90297
describe('capitalize release type', () => {
91298
const passingCases: FunctionSpec<typeof capitalizeReleaseType> = [
92299
['should uppercase first letter', 'single', 'Single'],

0 commit comments

Comments
 (0)