Skip to content

Commit 92c296a

Browse files
committed
feat(mora): add provider for mora.jp
1 parent 6a6c9f4 commit 92c296a

File tree

15 files changed

+1047
-0
lines changed

15 files changed

+1047
-0
lines changed

providers/Mora/__snapshots__/mod.test.ts.snap

Lines changed: 649 additions & 0 deletions
Large diffs are not rendered by default.

providers/Mora/json_types.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
export interface ApiArgs {
2+
mountPoint: string;
3+
labelId: string;
4+
materialNo: string;
5+
}
6+
7+
export interface WithApiUrl<Data> {
8+
apiUrl: URL;
9+
data: Data;
10+
}
11+
12+
export interface PackageMeta {
13+
artistName: string;
14+
cdPartNo: string | null;
15+
fullsizeimage: string;
16+
title: string;
17+
labelCode: string;
18+
labelcompanyname: string;
19+
master: string;
20+
distPartNo: string;
21+
startDate: string;
22+
mediaFormatNo: MediaFormat;
23+
mediaType: MediaType;
24+
trackList: Record<number, Track>;
25+
}
26+
27+
export interface Track {
28+
arranger?: string;
29+
composer?: string;
30+
/** Lyricist name */
31+
lyrics?: string;
32+
33+
artistName: string;
34+
/** Track duration in seconds */
35+
duration: number;
36+
mediaFormatNo: MediaFormat;
37+
mediaType: MediaType;
38+
title: string;
39+
}
40+
41+
export enum MediaFormat {
42+
Music = 10,
43+
Video = 11,
44+
HiRes = 12,
45+
Lossless = 15,
46+
}
47+
48+
export enum MediaType {
49+
AAC = 6,
50+
AVC_H264 = 7,
51+
FLAC = 8,
52+
DSD_DSF = 9,
53+
DSD_DFF = 10,
54+
}

providers/Mora/mod.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describeProvider, makeProviderOptions } from '@/providers/test_spec.ts';
2+
import { stubProviderLookups } from '@/providers/test_stubs.ts';
3+
import { afterAll, describe } from '@std/testing/bdd';
4+
import { assertSnapshot } from '@std/testing/snapshot';
5+
6+
import MoraProvider from './mod.ts';
7+
import { assertEquals } from 'std/assert/assert_equals.ts';
8+
9+
describe('Mora provider', () => {
10+
const bc = new MoraProvider(makeProviderOptions());
11+
const lookupStub = stubProviderLookups(bc);
12+
13+
describeProvider(bc, {
14+
urls: [{
15+
description: 'album page',
16+
url: new URL('https://mora.jp/package/43000006/00602488058599/'),
17+
id: { type: 'album', id: '43000006/00602488058599' },
18+
isCanonical: true,
19+
}, {
20+
description: 'album page with tracking parameter',
21+
url: new URL('https://mora.jp/package/43000087/SEXX03051B00Z/?fmid=TOPRNKS'),
22+
id: { type: 'album', id: '43000087/SEXX03051B00Z' },
23+
}, {
24+
description: 'artist page',
25+
url: new URL('https://mora.jp/artist/1739884/'),
26+
id: { type: 'artist', id: '1739884' },
27+
isCanonical: true,
28+
}],
29+
releaseLookup: [{
30+
description: 'release with GTIN in distPartNo',
31+
release: '43000006/00602488058599',
32+
assert: async (release, ctx) => {
33+
await assertSnapshot(ctx, release);
34+
assertEquals(release.gtin, '00602488058599');
35+
},
36+
}, {
37+
description: 'release with GTIN in cdPartNo',
38+
release: '43000035/198704758065_F',
39+
assert: async (release, ctx) => {
40+
await assertSnapshot(ctx, release);
41+
assertEquals(release.gtin, '198704758065');
42+
},
43+
}, {
44+
description: 'video release',
45+
release: '43000006/00199957093194',
46+
assert: async (release, ctx) => {
47+
await assertSnapshot(ctx, release);
48+
49+
const trackCount = release.media.flatMap((medium) => medium.tracklist).length;
50+
assertEquals(trackCount, 1, 'Release should have 1 track');
51+
assertEquals(release.media[0].tracklist[0].type, 'video');
52+
},
53+
}],
54+
});
55+
56+
afterAll(() => {
57+
lookupStub.restore();
58+
});
59+
});

providers/Mora/mod.ts

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { ApiArgs, MediaFormat, PackageMeta, Track, WithApiUrl } from './json_types.ts';
2+
import type {
3+
ArtistCreditName,
4+
Artwork,
5+
EntityId,
6+
HarmonyEntityType,
7+
HarmonyRelease,
8+
HarmonyTrack,
9+
LinkType,
10+
ReleaseGroupType,
11+
} from '@/harmonizer/types.ts';
12+
import { type CacheEntry, MetadataProvider, ReleaseLookup } from '@/providers/base.ts';
13+
import { DurationPrecision, FeatureQuality, FeatureQualityMap } from '@/providers/features.ts';
14+
import { parseISODateTime, PartialDate } from '@/utils/date.ts';
15+
import { ProviderError, ResponseError } from '@/utils/errors.ts';
16+
import { extractMetadataTag } from '@/utils/html.ts';
17+
import { isValidGTIN } from '../../utils/gtin.ts';
18+
19+
export default class MoraProvider extends MetadataProvider {
20+
readonly name = 'Mora';
21+
22+
readonly supportedUrls = new URLPattern({
23+
hostname: 'mora.jp',
24+
pathname: '/package/:labelCode([0-9]+)/:materialNo{/}?',
25+
});
26+
27+
readonly artistUrlPattern = new URLPattern({
28+
hostname: this.supportedUrls.hostname,
29+
pathname: '/artist/:id{/}?',
30+
});
31+
32+
override readonly features: FeatureQualityMap = {
33+
// The API returns a "full size" image of at most 200x200
34+
'cover size': 200,
35+
'duration precision': DurationPrecision.SECONDS,
36+
'GTIN lookup': FeatureQuality.MISSING,
37+
'MBID resolving': FeatureQuality.PRESENT,
38+
'release label': FeatureQuality.GOOD,
39+
};
40+
41+
readonly entityTypeMap = {
42+
artist: 'artist',
43+
release: 'album',
44+
};
45+
46+
override readonly launchDate: PartialDate = {
47+
year: 2004,
48+
month: 4,
49+
};
50+
51+
readonly releaseLookup = MoraReleaseLookup;
52+
53+
constructUrl(entity: EntityId): URL {
54+
if (entity.type === 'artist') {
55+
return new URL(`https://mora.jp/artist/${entity.id}/`);
56+
} else if (entity.type === 'album') {
57+
return new URL(`https://mora.jp/package/${entity.id}/`);
58+
}
59+
60+
throw new ProviderError(this.name, `Incomplete release ID '${entity.id}' does not match format \`band/title\``);
61+
}
62+
63+
override extractEntityFromUrl(url: URL): EntityId | undefined {
64+
const releaseResult = this.supportedUrls.exec(url);
65+
if (releaseResult) {
66+
const { labelCode, materialNo } = releaseResult.pathname.groups;
67+
if (!labelCode || !materialNo) {
68+
return undefined;
69+
}
70+
71+
return {
72+
type: 'album',
73+
id: [labelCode, materialNo].join('/'),
74+
};
75+
}
76+
77+
const artistResult = this.artistUrlPattern.exec(url);
78+
if (artistResult) {
79+
return {
80+
type: 'artist',
81+
id: artistResult.pathname.groups.id!,
82+
};
83+
}
84+
85+
return undefined;
86+
}
87+
88+
override parseProviderId(id: string, entityType: HarmonyEntityType): EntityId {
89+
return { id, type: this.entityTypeMap[entityType] };
90+
}
91+
92+
override getLinkTypesForEntity(_entity: EntityId): LinkType[] {
93+
return ['paid download'];
94+
}
95+
96+
extractEmbeddedJson<Data>(webUrl: URL, maxTimestamp?: number): Promise<CacheEntry<WithApiUrl<Data>>> {
97+
return this.fetchJSON<ApiArgs>(webUrl, {
98+
policy: { maxTimestamp },
99+
responseMutator: async (response) => {
100+
const html = await response.text();
101+
const metaTag = extractMetadataTag(html, 'msApplication-Arguments');
102+
if (!metaTag) {
103+
throw new ResponseError(this.name, 'Response is missing the expected <meta> tag', webUrl);
104+
}
105+
106+
const apiArgsRaw = metaTag.replace(/&quot;/g, '"');
107+
try {
108+
const apiArgs: ApiArgs = JSON.parse(apiArgsRaw);
109+
110+
if (apiArgs) {
111+
return new Response(JSON.stringify(apiArgs), response);
112+
}
113+
} catch (_error) {
114+
throw new ResponseError(this.name, 'Failed to extract API arguments', webUrl);
115+
}
116+
117+
throw new ResponseError(this.name, 'Failed to extract API arguments', webUrl);
118+
},
119+
}).then(({ content }) => {
120+
const apiBase = apiUrl(content.mountPoint, content.labelId, content.materialNo);
121+
122+
const packageMetaUrl = new URL(apiBase);
123+
packageMetaUrl.pathname += 'packageMeta.json';
124+
125+
return this.fetchJSON<WithApiUrl<Data>>(packageMetaUrl, {
126+
policy: { maxTimestamp },
127+
responseMutator: async (response) => {
128+
const data = await response.json();
129+
return new Response(
130+
JSON.stringify({
131+
apiUrl: apiBase,
132+
data,
133+
}),
134+
response,
135+
);
136+
},
137+
});
138+
});
139+
}
140+
}
141+
142+
function apiUrl(mountPoint: string, labelId: string, materialNo: string) {
143+
const paddedMaterialNo = materialNo.padStart(10, '0');
144+
const slicedMaterialNo = `${paddedMaterialNo.slice(0, 4)}/${paddedMaterialNo.slice(4, 7)}/${
145+
paddedMaterialNo.slice(7)
146+
}`;
147+
148+
return new URL(`https://cf.mora.jp/contents/package/${mountPoint}/${labelId}/${slicedMaterialNo}/`);
149+
}
150+
151+
export class MoraReleaseLookup extends ReleaseLookup<MoraProvider, PackageMeta> {
152+
rawReleaseUrl: URL | undefined;
153+
apiUrl: URL | undefined;
154+
155+
constructReleaseApiUrl(): URL | undefined {
156+
return undefined;
157+
}
158+
159+
async getRawRelease(): Promise<PackageMeta> {
160+
if (this.lookup.method === 'gtin') {
161+
throw new ProviderError(this.provider.name, 'GTIN lookups are not supported');
162+
}
163+
164+
// Entity is already defined for ID/URL lookups.
165+
const webUrl = this.provider.constructUrl(this.entity!);
166+
this.rawReleaseUrl = webUrl;
167+
const { content: release, timestamp } = await this.provider.extractEmbeddedJson<PackageMeta>(
168+
webUrl,
169+
this.options.snapshotMaxTimestamp,
170+
);
171+
this.apiUrl = release.apiUrl;
172+
this.updateCacheTime(timestamp);
173+
174+
return release.data;
175+
}
176+
177+
convertRawRelease(albumPage: PackageMeta): HarmonyRelease {
178+
const label = { name: albumPage.labelcompanyname };
179+
const tracklist = Object.entries(albumPage.trackList).map(([index, track]) =>
180+
this.convertRawTrack(Number(index), track)
181+
);
182+
const types: ReleaseGroupType[] = [Object.keys(albumPage.trackList).length > 1 ? 'Album' : 'Single'];
183+
184+
// `distPartNo` *might* contain the GTIN, but will oftentimes contain either label-specific catalog numbers, or
185+
// some package-specific code for mora (e.g. <package number>_F for FLAC releases)
186+
let gtin = isValidGTIN(albumPage.distPartNo) ? albumPage.distPartNo : undefined;
187+
if (!gtin) {
188+
// If we're lucky, the GTIN might be in `cdPartNo`. In testing, the field seems to be `null` most of the time.
189+
if (albumPage.cdPartNo && isValidGTIN(albumPage.cdPartNo)) {
190+
gtin = albumPage.cdPartNo;
191+
} else {
192+
this.addMessage('Failed to determine GTIN', 'warning');
193+
}
194+
}
195+
196+
const release: HarmonyRelease = {
197+
title: albumPage.title,
198+
artists: [this.makeArtistCreditName(albumPage.artistName)],
199+
labels: [label],
200+
gtin,
201+
releaseDate: parseISODateTime(albumPage.startDate),
202+
availableIn: ['JP'],
203+
media: [{
204+
format: 'Digital Media',
205+
tracklist,
206+
}],
207+
status: 'Official',
208+
packaging: 'None',
209+
types,
210+
externalLinks: [{
211+
url: this.rawReleaseUrl!.href,
212+
types: ['paid download'],
213+
}],
214+
images: [this.getArtwork(albumPage)],
215+
copyright: albumPage.master,
216+
info: this.generateReleaseInfo(),
217+
};
218+
219+
return release;
220+
}
221+
222+
convertRawTrack(index: number, rawTrack: Track): HarmonyTrack {
223+
return {
224+
number: index,
225+
title: rawTrack.title,
226+
type: rawTrack.mediaFormatNo == MediaFormat.Video ? 'video' : 'audio',
227+
artists: [this.makeArtistCreditName(rawTrack.artistName)],
228+
length: rawTrack.duration * 1000,
229+
recording: {
230+
externalIds: [],
231+
},
232+
};
233+
}
234+
235+
makeArtistCreditName(artist: string): ArtistCreditName {
236+
return {
237+
name: artist,
238+
creditedName: artist,
239+
};
240+
}
241+
242+
getArtwork(albumPage: PackageMeta): Artwork {
243+
const imageUrl = new URL(this.apiUrl!);
244+
imageUrl.pathname += albumPage.fullsizeimage;
245+
246+
return {
247+
url: imageUrl.href,
248+
types: ['front'],
249+
};
250+
}
251+
}

providers/mod.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import iTunesProvider from './iTunes/mod.ts';
1010
import MusicBrainzProvider from './MusicBrainz/mod.ts';
1111
import SpotifyProvider from './Spotify/mod.ts';
1212
import TidalProvider from './Tidal/mod.ts';
13+
import MoraProvider from './Mora/mod.ts';
1314

1415
/** Registry with all supported providers. */
1516
export const providers = new ProviderRegistry({
@@ -26,6 +27,7 @@ providers.addMultiple(
2627
TidalProvider,
2728
BandcampProvider,
2829
BeatportProvider,
30+
MoraProvider,
2931
);
3032

3133
/** Internal names of providers which are enabled by default (for GTIN lookups). */

server/components/ProviderIcon.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const providerIconMap: Record<string, string> = {
88
deezer: 'brand-deezer',
99
itunes: 'brand-apple',
1010
musicbrainz: 'brand-metabrainz',
11+
mora: 'brand-mora',
1112
spotify: 'brand-spotify',
1213
tidal: 'brand-tidal',
1314
};

0 commit comments

Comments
 (0)