|
| 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 | +} |
0 commit comments