Skip to content

Commit 176cb69

Browse files
committed
Youtube Music provider
1 parent 4aebe60 commit 176cb69

File tree

3 files changed

+267
-1
lines changed

3 files changed

+267
-1
lines changed

deno.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
"std/": "https://deno.land/[email protected]/",
3030
"tabler-icons/": "https://deno.land/x/[email protected]/tsx/",
3131
"ts-custom-error": "https://esm.sh/[email protected]",
32-
"utils/": "https://deno.land/x/[email protected]/"
32+
"utils/": "https://deno.land/x/[email protected]/",
33+
"youtubei.js": "https://deno.land/x/[email protected]/deno.ts"
3334
},
3435
"lock": false,
3536
"tasks": {

providers/YouTubeMusic/mod.ts

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,263 @@
1+
import { MetadataProvider, ReleaseLookup } from '@/providers/base.ts';
2+
import { DurationPrecision, FeatureQuality, FeatureQualityMap } from '@/providers/features.ts';
3+
import { ProviderError } from '@/utils/errors.ts';
4+
import type {
5+
ArtistCreditName,
6+
EntityId,
7+
HarmonyEntityType,
8+
HarmonyRelease,
9+
HarmonyTrack,
10+
LinkType,
11+
} from '@/harmonizer/types.ts';
12+
13+
import { Innertube, YTMusic, YTNodes } from 'youtubei.js';
14+
15+
// See https://ytjs.dev/guide/ and https://ytjs.dev/api/
16+
17+
// Avoid typos when repeating entity types
18+
const CHANNEL = 'channel';
19+
const PLAYLIST = 'playlist';
20+
const ALBUM = 'album';
21+
const TRACK = 'track';
22+
23+
export default class YoutubeMusicProvider extends MetadataProvider {
24+
override entityTypeMap: Record<HarmonyEntityType, string | string[]> = {
25+
artist: CHANNEL,
26+
release: [PLAYLIST, ALBUM],
27+
};
28+
29+
override readonly features: FeatureQualityMap = {
30+
'MBID resolving': FeatureQuality.GOOD,
31+
'GTIN lookup': FeatureQuality.GOOD,
32+
'duration precision': DurationPrecision.SECONDS,
33+
};
34+
35+
protected override releaseLookup = YoutubeMusicReleaseLookup;
36+
37+
override readonly name = 'Youtube Music';
38+
39+
/**
40+
* Accepts:
41+
* - https://music.youtube.com/channel/:channel_id
42+
* - https://music.youtube.com/browse/:album_id
43+
* - https://music.youtube.com/playlist?list=:playlist_id
44+
*/
45+
override readonly supportedUrls = new URLPattern({
46+
hostname: 'music.youtube.com',
47+
pathname: '/:type(playlist|channel|browse)/:id?',
48+
search: '{list=:id}?',
49+
});
50+
51+
override getLinkTypesForEntity(): LinkType[] {
52+
return ['free streaming'];
53+
}
54+
55+
// Override entity extraction since we also need to also extract groups from search
56+
override extractEntityFromUrl(url: URL) {
57+
const matched = this.supportedUrls.exec(url);
58+
if (matched) {
59+
const { type } = matched.pathname.groups;
60+
const id = matched.pathname.groups['id'] ?? matched.search.groups['id'];
61+
if (type && id) {
62+
return {
63+
type: (type === 'browse' ? ALBUM : type),
64+
id,
65+
};
66+
}
67+
}
68+
}
69+
70+
override constructUrl(entity: EntityId): URL {
71+
return new URL(
72+
(entity.type === PLAYLIST)
73+
? `playlist?list=${entity.id}`
74+
: (entity.type === TRACK)
75+
? `watch?v=${entity.id}`
76+
: `${entity.type}/${entity.id}`,
77+
'https://music.youtube.com',
78+
);
79+
}
80+
81+
/** Both playlist and album are a release, distinguish between them */
82+
override serializeProviderId(entity: EntityId): string {
83+
return (entity.type === PLAYLIST || entity.type === ALBUM) ? `${entity.type}:${entity.id}` : entity.id;
84+
}
85+
86+
override parseProviderId(id: string, entityType: HarmonyEntityType): EntityId {
87+
if (entityType === 'release') {
88+
// Split at first ':', collect rest of items into array to join later in case id contained additional ':'
89+
const [type, ...splitId] = id.split(':');
90+
return {
91+
type,
92+
id: splitId.join(':'),
93+
};
94+
} else {
95+
return {
96+
type: 'artist',
97+
id,
98+
};
99+
}
100+
}
101+
}
102+
103+
export class YoutubeMusicReleaseLookup extends ReleaseLookup<YoutubeMusicProvider, YTMusic.Album> {
104+
// Needs asynchronous creation, so is created in first getRawRelease call
105+
innertube: Innertube | undefined;
106+
107+
override constructReleaseApiUrl(): URL | undefined {
108+
return undefined;
109+
}
110+
111+
protected override async getRawRelease(): Promise<YTMusic.Album> {
112+
// Innertube needs an async context to be created, so create it in the first async context available
113+
if (!this.innertube) {
114+
this.innertube = await Innertube.create();
115+
}
116+
117+
const { type, id } = (this.lookup.method === 'gtin')
118+
? await this.lookupGTIN()
119+
: this.provider.parseProviderId(this.lookup.value, 'release');
120+
121+
let albumId = id;
122+
123+
// Try to convert playlist to album by getting the album of the first track
124+
// Reasoning:
125+
// If the playlist is an album, the first track of the playlist is the first track of the corresponding album.
126+
// If the first track does indeed have an album, we can check that album's playlist url.
127+
// If that playlist url is the same as the current playlist url, we are indeed in the album we found.
128+
if (type === PLAYLIST) {
129+
const playlist = await this.innertube.music.getPlaylist(id).catch((reason) => {
130+
throw new ProviderError(this.provider.name, `Failed to fetch playlist '${albumId}': ${reason}`);
131+
});
132+
const trackAlbum = playlist.contents?.as(YTNodes.MusicResponsiveListItem).at(0)?.album;
133+
if (!trackAlbum?.id) {
134+
throw new ProviderError(this.provider.name, `Failed to convert playlist '${id}' to album`);
135+
}
136+
albumId = trackAlbum.id;
137+
}
138+
139+
// Convert album id to album
140+
const album = await this.innertube.music.getAlbum(albumId).catch((reason) => {
141+
throw new ProviderError(this.provider.name, `Failed to fetch album '${albumId}': ${reason}`);
142+
});
143+
144+
// If type was playlist, assert that the playlist url of the converted album is indeed the original playlist
145+
if (
146+
type === PLAYLIST &&
147+
this.provider.extractEntityFromUrl(new URL(album.url!))?.id !== id
148+
) {
149+
throw new ProviderError(this.provider.name, `Failed to convert playlist '${id}' to album`);
150+
}
151+
152+
return album;
153+
}
154+
155+
private async lookupGTIN(): Promise<EntityId> {
156+
// When searching YouTube Music for a GTIN in quotes, the first (and only) search result always seems to be the album with that GTIN
157+
// If there is no album with that GTIN on YouTube, this should just return undefined
158+
const id = (await this.innertube!.music.search(`"${this.lookup.value}"`, { type: 'album' }).catch((reason) => {
159+
throw new ProviderError(this.provider.name, `Failed to lookup GTIN '${this.lookup.value}': ${reason}`);
160+
})).albums?.contents.at(0)?.id;
161+
162+
if (!id) {
163+
throw new ProviderError(this.provider.name, `Failed to lookup GTIN '${this.lookup.value}'`);
164+
}
165+
166+
return {
167+
type: ALBUM,
168+
id,
169+
};
170+
}
171+
172+
protected override async convertRawRelease(rawRelease: YTMusic.Album) {
173+
if (!this.entity) {
174+
this.entity = this.provider.extractEntityFromUrl(new URL(rawRelease.url!));
175+
}
176+
177+
// Youtube always seems to return a MusicResponsiveHeader.
178+
// Throw if this isn't the case, as all other header types don't contain helpful data anyways
179+
if (!(rawRelease.header instanceof YTNodes.MusicResponsiveHeader)) {
180+
throw new ProviderError(this.provider.name, 'Got bad header type from API');
181+
}
182+
183+
const title = rawRelease.header.title.text;
184+
if (!title) {
185+
throw new ProviderError(this.provider.name, 'Release has no title');
186+
}
187+
188+
const artists = rawRelease.header.strapline_text_one.runs?.reduce((artists: ArtistCreditName[], run) => {
189+
// Text is divided into "runs" of links and normal text.
190+
// Usually, links point to artists and normal text acts as join phrases
191+
192+
if ('endpoint' in run && run.endpoint) {
193+
// Current run is artist credit, so append info to list of existing artist credits
194+
return [...artists, {
195+
name: run.text,
196+
externalIds: [{
197+
provider: this.provider.internalName,
198+
type: CHANNEL,
199+
id: run.endpoint.payload.browseId,
200+
}],
201+
}];
202+
} else {
203+
// Current run is join phrase, so set text as join phrase of previous artist
204+
const lastArtistCredit = artists.at(-1);
205+
if (lastArtistCredit) {
206+
lastArtistCredit.joinPhrase = (lastArtistCredit.joinPhrase ?? '') + run.text;
207+
}
208+
return artists;
209+
}
210+
}, []);
211+
212+
const tracklist = rawRelease.contents.map((item) => {
213+
const videoId = item.overlay?.content?.endpoint.payload.videoId;
214+
215+
let length;
216+
if (item.duration) {
217+
length = item.duration.seconds * 1000;
218+
}
219+
220+
let number;
221+
if (item.index?.text) {
222+
try {
223+
number = parseInt(item.index.text);
224+
} catch (_e) {
225+
// Leave number undefined if parsing failed
226+
}
227+
}
228+
229+
return {
230+
title: item.title!,
231+
tracktype: 'audio',
232+
recording: {
233+
title: item.title!,
234+
externalIds: [{
235+
type: TRACK,
236+
id: videoId,
237+
provider: this.provider.internalName,
238+
}],
239+
},
240+
length,
241+
number,
242+
} as HarmonyTrack;
243+
});
244+
245+
const release: HarmonyRelease = {
246+
title,
247+
artists: artists ?? [],
248+
externalLinks: [{
249+
// rawRelease.url is always of type https://music.youtube.com/playlist?list=:playlist_id
250+
url: rawRelease.url!,
251+
types: this.provider.getLinkTypesForEntity(),
252+
}],
253+
media: [{
254+
format: 'Digital Media',
255+
tracklist,
256+
}],
257+
packaging: 'None',
258+
info: this.generateReleaseInfo(),
259+
};
260+
261+
return release;
262+
}
263+
}

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 YouTubeMusicProvider from './YouTubeMusic/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+
YouTubeMusicProvider,
2931
);
3032

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

0 commit comments

Comments
 (0)