Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,318 changes: 1,318 additions & 0 deletions providers/Melon/__snapshots__/mod.test.ts.snap

Large diffs are not rendered by default.

55 changes: 55 additions & 0 deletions providers/Melon/api_types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
export interface MelonAlbumInfoResponse {
response: {
ALBUMINFO: MelonAlbumInfo;
/** The type of the album, e.g. "정규", "EP", "싱글" */
ALBUMTYPE?: string;
/** The agency/label name (기획사) */
PLANCNPY?: string;
/** The distributor/publisher (발매사) */
SELLCNPY?: string;
};
}

export interface MelonAlbumInfo {
ALBUMID: string;
ALBUMNAME: string;
/** The album's release date, formatted as YYYY.MM.DD */
ISSUEDATE: string;
/** 500px thumbnail CDN URL with query string */
ALBUMIMG: string;
/** 1000px CDN URL with query string */
ALBUMIMGLARGE: string;
ISSERVICE: boolean;
ARTISTLIST: MelonArtist[];
}

export interface MelonArtist {
ARTISTID: string;
ARTISTNAME: string;
}

export interface MelonSongListResponse {
response: {
CDLIST: MelonDisc[];
};
}

export interface MelonDisc {
/** Disc number as a string, e.g. "1" */
CDNO: string;
SONGLIST: MelonSong[];
}

export interface MelonSong {
SONGID: string;
SONGNAME: string;
ARTISTLIST: MelonArtist[];
/** Duration in seconds as a string */
PLAYTIME: string;
/** Track number as a string */
TRACKNO: string;
ISSERVICE: boolean;
/** Whether the song is marked as a title song, which is usually actively promoted, has an MV, and/or was previously released as a single. */
ISTITLESONG: boolean;
ISHOLDBACK: boolean;
}
94 changes: 94 additions & 0 deletions providers/Melon/mod.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type { ReleaseOptions } from '@/harmonizer/types.ts';
import { describeProvider, makeProviderOptions } from '@/providers/test_spec.ts';
import { stubProviderLookups } from '@/providers/test_stubs.ts';
import { afterAll, describe } from '@std/testing/bdd';
import { assertSnapshot } from '@std/testing/snapshot';
import { assert, assertEquals } from 'std/assert/mod.ts';

import MelonProvider from './mod.ts';

describe('Melon provider', () => {
const melon = new MelonProvider(makeProviderOptions());
const stubs = [stubProviderLookups(melon)];

const releaseOptions: ReleaseOptions = {
withISRC: false,
withAllTrackArtists: true,
};

describeProvider(melon, {
urls: [{
description: 'album page',
url: new URL('https://www.melon.com/album/detail.htm?albumId=13693526'),
id: { type: 'album', id: '13693526' },
isCanonical: true,
}, {
description: 'artist page',
url: new URL('https://www.melon.com/artist/detail.htm?artistId=1273010'),
id: { type: 'artist', id: '1273010' },
isCanonical: true,
}, {
description: 'song page',
url: new URL('https://www.melon.com/song/detail.htm?songId=602111505'),
id: { type: 'song', id: '602111505' },
isCanonical: true,
}, {
description: 'search page (unsupported)',
url: new URL('https://www.melon.com/search/total/index.htm?q=test'),
id: undefined,
}],
invalidIds: ['abc', 'not-a-number', '123abc'],
releaseLookup: [{
description: 'Regular (정규) album with unavailable CD-only tracks',
release: new URL('https://www.melon.com/album/detail.htm?albumId=13691104'),
options: releaseOptions,
assert: async (release, ctx) => {
await assertSnapshot(ctx, release);
assertEquals(release.media.length, 1, 'Should have one disc');
assertEquals(release.media[0].tracklist.length, 11, 'Should have 11 tracks (without the two CD-only tracks)');
assert(release.types?.includes('Album'), 'Should be classified as Album');
},
}, {
description: 'Regular (정규) album with multiple discs and multiple artists',
release: new URL('https://www.melon.com/album/detail.htm?albumId=10000727'),
options: releaseOptions,
assert: async (release, ctx) => {
await assertSnapshot(ctx, release);
assertEquals(release.media.length, 2, 'Should have two discs');
assertEquals(release.media[0].tracklist.length, 10, 'Disc 1 should have 10 tracks');
assertEquals(release.media[1].tracklist.length, 7, 'Disc 2 should have 7 tracks');
assert(release.types?.includes('Album'), 'Should be classified as Album');
const lastTrack = release.media[1].tracklist.at(-1)!;
assertEquals(
lastTrack.artists?.map((a) => a.name),
['웨이 (크레용팝)', '초아 (크레용팝)'],
'Track with multiple artists should have correct artists',
);
},
}, {
description: 'EP (미니) album',
release: new URL('https://www.melon.com/album/detail.htm?albumId=13693526'),
options: releaseOptions,
assert: async (release, ctx) => {
await assertSnapshot(ctx, release);
assertEquals(release.media.length, 1, 'Should have one disc');
assertEquals(release.media[0].tracklist.length, 7, 'Should have 7 tracks');
assert(release.types?.includes('EP'), 'Should be classified as EP');
},
}, {
description: 'Single (싱글) album',
release: new URL('https://www.melon.com/album/detail.htm?albumId=13732020'),
options: releaseOptions,
assert: async (release, ctx) => {
await assertSnapshot(ctx, release);
assertEquals(release.media.length, 1, 'Should have one disc');
assertEquals(release.media[0].tracklist.length, 1, 'Should have 1 track');
assert(release.types?.includes('Single'), 'Should be classified as Single');
},
}],
});

afterAll(() => {
stubs.forEach((s) => s.restore());
});
});
231 changes: 231 additions & 0 deletions providers/Melon/mod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import { type ApiQueryOptions, type CacheEntry, MetadataApiProvider, ReleaseApiLookup } from '@/providers/base.ts';
import { DurationPrecision, FeatureQuality, type FeatureQualityMap } from '@/providers/features.ts';
import type { ProviderCategory } from '@/providers/categories.ts';
import type { PartialDate } from '@/utils/date.ts';
import { ProviderError } from '@/utils/errors.ts';
import type {
ArtistCreditName,
Artwork,
EntityId,
HarmonyMedium,
HarmonyRelease,
HarmonyTrack,
Label,
LinkType,
ReleaseGroupType,
} from '@/harmonizer/types.ts';
import type {
MelonAlbumInfo,
MelonAlbumInfoResponse,
MelonArtist,
MelonDisc,
MelonSong,
MelonSongListResponse,
} from './api_types.ts';

const API_BASE = 'https://m2.melon.com/m6';

export default class MelonProvider extends MetadataApiProvider {
readonly name = 'Melon';

readonly supportedUrls = new URLPattern({
hostname: 'www.melon.com',
pathname: String.raw`/:type(album|song|artist)/detail.htm\?\1Id=:id(\d+)`,
});

override readonly categories = new Set<ProviderCategory>(['digital']);

override readonly features: FeatureQualityMap = {
'cover size': 2000,
'duration precision': DurationPrecision.SECONDS,
'GTIN lookup': FeatureQuality.MISSING,
'MBID resolving': FeatureQuality.PRESENT,
'release label': FeatureQuality.PRESENT,
};

readonly entityTypeMap = {
artist: 'artist',
release: 'album',
recording: 'song',
};

override readonly availableRegions = new Set(['KR']);

readonly releaseLookup = MelonReleaseLookup;

override readonly launchDate: PartialDate = {
year: 2004,
month: 11,
};

override extractEntityFromUrl(url: URL): EntityId | undefined {
if (!this.supportsDomain(url)) return undefined;

const albumId = url.searchParams.get('albumId');
if (albumId && url.pathname.startsWith('/album/')) {
return { type: 'album', id: albumId };
}

const artistId = url.searchParams.get('artistId');
if (artistId && url.pathname.startsWith('/artist/')) {
return { type: 'artist', id: artistId };
}

const songId = url.searchParams.get('songId');
if (songId && url.pathname.startsWith('/song/')) {
return { type: 'song', id: songId };
}
}

constructUrl(entity: EntityId): URL {
return new URL(`https://www.melon.com/${entity.type}/detail.htm?${entity.type}Id=${entity.id}`);
}

override getLinkTypesForEntity(): LinkType[] {
return ['paid streaming', 'paid download'];
}

query<Data>(apiUrl: URL, options?: ApiQueryOptions): Promise<CacheEntry<Data>> {
return this.fetchJSON<Data>(apiUrl, {
policy: { maxTimestamp: options?.snapshotMaxTimestamp },
});
}
}

export class MelonReleaseLookup extends ReleaseApiLookup<MelonProvider, MelonRawRelease> {
constructReleaseApiUrl(): URL {
const url = new URL(`${API_BASE}/v3/album/info.json`);
url.searchParams.set('albumId', this.lookup.value);
return url;
}

protected async getRawRelease(): Promise<MelonRawRelease> {
if (this.lookup.method === 'gtin') {
throw new ProviderError(this.provider.name, 'GTIN lookups are not supported');
}

const albumId = this.lookup.value;

const infoUrl = this.constructReleaseApiUrl();
const songListUrl = new URL(`${API_BASE}/v2/album/song/list.json`);
songListUrl.searchParams.set('albumId', albumId);

const snapshotMaxTimestamp = this.options.snapshotMaxTimestamp;
const [infoEntry, songListEntry] = await Promise.all([
this.provider.query<MelonAlbumInfoResponse>(infoUrl, { snapshotMaxTimestamp }),
this.provider.query<MelonSongListResponse>(songListUrl, { snapshotMaxTimestamp }),
]);

this.updateCacheTime(infoEntry.timestamp);
this.updateCacheTime(songListEntry.timestamp);

const albumInfo = infoEntry.content.response.ALBUMINFO;
const albumType = infoEntry.content.response.ALBUMTYPE;
const planCnpy = infoEntry.content.response.PLANCNPY;
const cdList = songListEntry.content.response.CDLIST;

return {
albumInfo,
albumType,
planCnpy,
cdList,
};
}

protected convertRawRelease(raw: MelonRawRelease): HarmonyRelease {
this.entity = { type: 'album', id: raw.albumInfo.ALBUMID };

return {
title: raw.albumInfo.ALBUMNAME,
artists: raw.albumInfo.ARTISTLIST.map((a) => this.convertRawArtist(a)),
releaseDate: this.convertReleaseDate(parseISSUEDATE(raw.albumInfo.ISSUEDATE)),
labels: this.extractLabels(raw.planCnpy),
images: [this.coverArtwork(raw.albumInfo.ALBUMIMGLARGE)],
types: mapReleaseType(raw.albumType),
availableIn: ['KR'],
status: 'Official',
packaging: 'None',
media: this.buildMedia(raw.cdList),
// TODO: check if we can check for download availability
externalLinks: raw.albumInfo.ISSERVICE
? [{ url: this.provider.constructUrl(this.entity).toString(), types: ['paid streaming'] }]
: [],
info: this.generateReleaseInfo(),
};
}

private convertRawArtist(artist: MelonArtist): ArtistCreditName {
return {
name: artist.ARTISTNAME,
creditedName: artist.ARTISTNAME,
externalIds: this.provider.makeExternalIds({ type: 'artist', id: artist.ARTISTID }),
};
}

private extractLabels(planCnpy?: string): Label[] {
if (!planCnpy) return [];
return planCnpy.split(', ').map((name) => ({ name: name.trim() }));
}

private coverArtwork(largeUrl: string): Artwork {
const originalUrl = largeUrl.replace(
/(images\/.*\/[^/_]*)((_[^/.]*)_)?(_?[^/._]*)?(\.[^/.?]*)(?:[?/].*)?$/,
'$1$3_org$5',
);
return {
url: originalUrl,
thumbUrl: largeUrl,
types: ['front'],
};
}

private buildMedia(cdList: MelonDisc[]): HarmonyMedium[] {
return cdList.map((disc) => ({
number: parseInt(disc.CDNO),
format: 'Digital Media',
tracklist: disc.SONGLIST
.filter((song) => song.ISSERVICE)
.map((song) => this.convertRawTrack(song)),
}));
}

private convertRawTrack(song: MelonSong): HarmonyTrack {
return {
title: song.SONGNAME,
artists: song.ARTISTLIST.map((a) => this.convertRawArtist(a)),
number: parseInt(song.TRACKNO),
length: parseInt(song.PLAYTIME) * 1000,
recording: {
externalIds: this.provider.makeExternalIds({ type: 'song', id: song.SONGID }),
},
};
}
}

interface MelonRawRelease {
albumInfo: MelonAlbumInfo;
albumType?: string;
planCnpy?: string;
cdList: MelonDisc[];
}

function parseISSUEDATE(date: string): PartialDate {
const match = date.match(/^(\d{4})\.(\d{2})\.(\d{2})$/);
if (!match) return {};
return {
year: parseInt(match[1]),
month: parseInt(match[2]),
day: parseInt(match[3]),
};
}

function mapReleaseType(albumType?: string): ReleaseGroupType[] | undefined {
if (!albumType) return undefined;
if (albumType === '싱글') return ['Single'];
if (albumType === '정규') return ['Album'];
if (albumType === 'EP') return ['EP'];
if (albumType === '옴니버스') return ['Compilation'];
if (albumType === 'OST') return ['Soundtrack'];
if (albumType === '리믹스') return ['Single', 'Remix'];
return undefined;
}
2 changes: 2 additions & 0 deletions providers/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import TidalProvider from './Tidal/mod.ts';
import MoraProvider from './Mora/mod.ts';
import QobuzProvider from './Qobuz/mod.ts';
import BugsProvider from './Bugs/mod.ts';
import MelonProvider from './Melon/mod.ts';

/** Registry with all supported providers. */
export const providers = new ProviderRegistry({
Expand All @@ -36,6 +37,7 @@ providers.addMultiple(
MoraProvider,
OtotoyProvider,
BugsProvider,
MelonProvider,
);

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