Putting a pause on MusicBrainz, working on yt playlist fetcher

This commit is contained in:
Eclypsed
2024-05-09 15:25:25 -04:00
parent b443382f1a
commit 05f4b61ec7
6 changed files with 602 additions and 209 deletions

View File

@@ -12,13 +12,13 @@
<div id="card-wrapper" class="flex-shrink-0">
<button id="thumbnail" class="relative h-52 transition-all duration-200 ease-out" on:click={() => goto(`/details/${mediaItem.type}?id=${mediaItem.id}`)}>
{#if mediaItem.thumbnail}
{#if 'thumbnailUrl' in mediaItem || 'profilePicture' in mediaItem}
<img
bind:this={image}
id="card-image"
on:load={() => (captionText.style.width = `${image.width}px`)}
class="h-full rounded transition-all"
src="/api/remoteImage?url={mediaItem.thumbnail}"
src="/api/remoteImage?url={'thumbnailUrl' in mediaItem ? mediaItem.thumbnailUrl : mediaItem.profilePicture}"
alt="{mediaItem.name} thumbnail"
/>
{:else}
@@ -39,17 +39,23 @@
</button>
<div bind:this={captionText} class="w-56 p-1">
<div class="mb-0.5 line-clamp-2 text-wrap text-sm" title={mediaItem.name}>{mediaItem.name}</div>
<div class="leading-2 line-clamp-2 text-neutral-400" style="font-size: 0;">
{#if 'artists' in mediaItem && mediaItem.artists}
{#each mediaItem.artists as artist}
{@const listIndex = mediaItem.artists.indexOf(artist)}
<a class="text-sm hover:underline focus:underline" href="/details/artist?id={artist.id}&connection={mediaItem.connection}">{artist.name}</a>
{#if listIndex < mediaItem.artists.length - 1}
<span class="mr-0.5 text-sm">,</span>
{/if}
{/each}
<div class="leading-2 line-clamp-2 flex flex-wrap text-neutral-400" style="font-size: 0;">
{#if 'artists' in mediaItem && mediaItem.artists && mediaItem.artists.length > 0}
{#if mediaItem.artists === 'Various Artists'}
<span class="text-sm">Various Artists</span>
{:else}
{#each mediaItem.artists as artist}
{@const listIndex = mediaItem.artists.indexOf(artist)}
<a class="text-sm hover:underline focus:underline" href="/details/artist?id={artist.id}&connection={mediaItem.connection.id}">{artist.name}</a>
{#if listIndex < mediaItem.artists.length - 1}
<span class="mr-0.5 text-sm">,</span>
{/if}
{/each}
{/if}
{:else if 'uploader' in mediaItem && mediaItem.uploader}
<a class="text-sm hover:underline focus:underline" href="/details/user?id={mediaItem.uploader.id}&connection={mediaItem.connection.id}">{mediaItem.uploader.name}</a>
{:else if 'createdBy' in mediaItem && mediaItem.createdBy}
<span class="text-sm">{mediaItem.createdBy.name}</span>
<a class="text-sm hover:underline focus:underline" href="/details/user?id={mediaItem.createdBy.id}&connection={mediaItem.connection.id}">{mediaItem.createdBy.name}</a>
{/if}
</div>
</div>

View File

@@ -33,9 +33,9 @@
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: media.name,
artist: media.artists?.map((artist) => artist.name).join(', ') || media.createdBy?.name,
artist: media.artists?.map((artist) => artist.name).join(', ') || media.uploader?.name,
album: media.album?.name,
artwork: [{ src: `/api/remoteImage?url=${media.thumbnail}`, sizes: '256x256', type: 'image/png' }],
artwork: [{ src: `/api/remoteImage?url=${media.thumbnailUrl}`, sizes: '256x256', type: 'image/png' }],
})
}
}
@@ -85,12 +85,12 @@
<section class="flex gap-3">
<div class="relative h-full w-20 min-w-20">
{#key currentlyPlaying}
<div transition:fade={{ duration: 500 }} class="absolute h-full w-full bg-cover bg-center bg-no-repeat" style="background-image: url(/api/remoteImage?url={currentlyPlaying.thumbnail});" />
<div transition:fade={{ duration: 500 }} class="absolute h-full w-full bg-cover bg-center bg-no-repeat" style="background-image: url(/api/remoteImage?url={currentlyPlaying.thumbnailUrl});" />
{/key}
</div>
<section class="flex flex-col justify-center gap-1">
<div class="line-clamp-2 text-sm">{currentlyPlaying.name}</div>
<div class="text-xs">{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') || currentlyPlaying.createdBy?.name}</div>
<div class="text-xs">{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') || currentlyPlaying.uploader?.name}</div>
</section>
</section>
<section class="flex min-w-max flex-col items-center justify-center gap-1">
@@ -148,11 +148,11 @@
</section>
</main>
{:else}
<main in:fade={{ delay: 500 }} out:fade={{ duration: 75 }} class="expanded-player h-full" style="--currentlyPlayingImage: url(/api/remoteImage?url={currentlyPlaying.thumbnail});">
<main in:fade={{ delay: 500 }} out:fade={{ duration: 75 }} class="expanded-player h-full" style="--currentlyPlayingImage: url(/api/remoteImage?url={currentlyPlaying.thumbnailUrl});">
<section class="song-queue-wrapper h-full px-24 py-20">
<section class="relative">
{#key currentlyPlaying}
<img transition:fade={{ duration: 300 }} class="absolute h-full max-w-full object-contain py-8" src="/api/remoteImage?url={currentlyPlaying.thumbnail}" alt="" />
<img transition:fade={{ duration: 300 }} class="absolute h-full max-w-full object-contain py-8" src="/api/remoteImage?url={currentlyPlaying.thumbnailUrl}" alt="" />
{/key}
</section>
<section class="flex flex-col gap-2">
@@ -166,12 +166,12 @@
class="queue-item w-full items-center gap-3 rounded-xl p-3 {isCurrent ? 'bg-[rgba(64,_64,_64,_0.5)]' : 'bg-[rgba(10,_10,_10,_0.5)]'}"
>
<div class="justify-self-center">{index + 1}</div>
<img class="justify-self-center" src="/api/remoteImage?url={item.thumbnail}" alt="" draggable="false" />
<img class="justify-self-center" src="/api/remoteImage?url={item.thumbnailUrl}" alt="" draggable="false" />
<div class="justify-items-left text-left">
<div class="line-clamp-2">{item.name}</div>
<div class="mt-[.15rem] text-neutral-500">{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') || currentlyPlaying.createdBy?.name}</div>
<div class="mt-[.15rem] text-neutral-500">{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') || currentlyPlaying.uploader?.name}</div>
</div>
<span class="text-right">{item.duration ?? ''}</span>
<span class="text-right">{formatTime(item.duration)}</span>
</button>
{/each}
</section>
@@ -197,7 +197,7 @@
<div>
<div class="mb-2 line-clamp-2 text-3xl">{currentlyPlaying.name}</div>
<div class="line-clamp-1 text-lg">
{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') || currentlyPlaying.createdBy?.name}{currentlyPlaying.album ? ` - ${currentlyPlaying.album.name}` : ''}
{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') || currentlyPlaying.uploader?.name}{currentlyPlaying.album ? ` - ${currentlyPlaying.album.name}` : ''}
</div>
</div>
<div class="flex w-full items-center justify-center gap-2 text-2xl">
@@ -237,7 +237,7 @@
</section>
</main>
{/if}
<audio autoplay bind:paused bind:volume bind:currentTime bind:duration on:ended={() => $queue.next()} src="/api/audio?connection={currentlyPlaying.connection}&id={currentlyPlaying.id}" />
<audio autoplay bind:paused bind:volume bind:currentTime bind:duration on:ended={() => $queue.next()} src="/api/audio?connection={currentlyPlaying.connection.id}&id={currentlyPlaying.id}" />
</div>
{/if}

View File

@@ -7,7 +7,38 @@ const mbApi = new MusicBrainzApi({
appContactInfo: 'Ec1ypsed@proton.me',
})
async function potentialAliasesFromNames(artistNames: string[]) {
const luceneQuery = artistNames.join(' OR ')
const artistsResponse = await mbApi.search('artist', { query: luceneQuery })
const SCORE_THRESHOLD = 90
const possibleArtists = artistsResponse.artists.filter((artist) => artist.score >= SCORE_THRESHOLD)
const aliases = possibleArtists.flatMap((artist) => [artist.name].concat(artist.aliases?.filter((alias) => alias.primary !== null).map((alias) => alias.name) ?? []))
return [...new Set(aliases)] // Removes any duplicates
}
export class MusicBrainz {
static async searchRecording(songName: string, artistNames?: string[]) {
const standardSearchResults = await mbApi.search('recording', { query: songName, limit: 5 })
const SCORE_THRESHOLD = 90
const bestResults = standardSearchResults.recordings.filter((recording) => recording.score >= SCORE_THRESHOLD)
const artistAliases = artistNames ? await potentialAliasesFromNames(artistNames) : null
const luceneQuery = artistAliases ? `"${songName}"`.concat(` AND (${artistAliases.map((alias) => `artist:"${alias}"`).join(' OR ')})`) : `"${songName}"`
console.log(luceneQuery)
const searchResults = await mbApi.search('recording', { query: luceneQuery, limit: 1 })
if (searchResults.recordings.length === 0) {
console.log('Nothing returned for ' + songName)
return null
}
const topResult = searchResults.recordings[0]
// const bestMatch = searchResults.recordings.reduce((prev, current) => (prev.score > current.score ? prev : current))
console.log(JSON.stringify(topResult))
}
static async searchRelease(albumName: string, artistNames?: string[]): Promise<MusicBrainz.ReleaseSearchResult | null> {
const searchResulst = await mbApi.search('release', { query: albumName, limit: 10 })
if (searchResulst.releases.length === 0) {
@@ -16,9 +47,7 @@ export class MusicBrainz {
}
const bestMatch = searchResulst.releases.reduce((prev, current) => {
if (prev.score === current.score) {
return new Date(prev.date).getTime() > new Date(current.date).getTime() ? prev : current
}
if (prev.score === current.score) return new Date(prev.date).getTime() > new Date(current.date).getTime() ? prev : current
return prev.score > current.score ? prev : current
})
@@ -28,6 +57,10 @@ export class MusicBrainz {
return { id, name: title, releaseDate: date, artists, trackCount } satisfies MusicBrainz.ReleaseSearchResult
}
static async searchArtist(artistName: string) {
const searchResults = await mbApi.search('artist', { query: artistName })
}
}
declare namespace MusicBrainz {

View File

@@ -1,9 +1,8 @@
import { google, run_v1, type youtube_v3 } from 'googleapis'
import { google, type youtube_v3 } from 'googleapis'
import ytdl from 'ytdl-core'
import { DB } from './db'
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private'
import { MusicBrainz } from './musicBrainz'
export class YouTubeMusic implements Connection {
public readonly id: string
@@ -121,14 +120,14 @@ export class YouTubeMusic implements Connection {
const contents = searchResulsts.contents.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
const parsedSearchResults: (Song | Album | Artist | Playlist)[] = []
const parsedSearchResults: (InnerTube.Home.ScrapedSong | InnerTube.Home.ScrapedAlbum | InnerTube.Home.ScrapedArtist | InnerTube.Home.ScrapedPlaylist)[] = []
const goodSections = ['Songs', 'Videos', 'Albums', 'Artists', 'Community playlists']
for (const section of contents) {
if ('musicCardShelfRenderer' in section) {
parsedSearchResults.push(parseMusicCardShelfRenderer(this.id, section.musicCardShelfRenderer))
parsedSearchResults.push(parseMusicCardShelfRenderer(section.musicCardShelfRenderer))
section.musicCardShelfRenderer.contents?.forEach((item) => {
if ('musicResponsiveListItemRenderer' in item) {
parsedSearchResults.push(parseResponsiveListItemRenderer(this.id, item.musicResponsiveListItemRenderer))
parsedSearchResults.push(parseResponsiveListItemRenderer(item.musicResponsiveListItemRenderer))
}
})
continue
@@ -137,11 +136,11 @@ export class YouTubeMusic implements Connection {
const sectionType = section.musicShelfRenderer.title.runs[0].text
if (!goodSections.includes(sectionType)) continue
const parsedSectionContents = section.musicShelfRenderer.contents.map((item) => parseResponsiveListItemRenderer(this.id, item.musicResponsiveListItemRenderer))
const parsedSectionContents = section.musicShelfRenderer.contents.map((item) => parseResponsiveListItemRenderer(item.musicResponsiveListItemRenderer))
parsedSearchResults.push(...parsedSectionContents)
}
return parsedSearchResults
return await this.buildFullProfiles(parsedSearchResults)
}
public async getRecommendations(): Promise<(Song | Album | Artist | Playlist)[]> {
@@ -162,22 +161,6 @@ export class YouTubeMusic implements Connection {
const contents = homeResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents
const albums = [
'AD:Trance 10',
'Hardcore Syndrome 3',
'Nanosecond Eternity',
'Social Outcast',
'Kathastrophe',
'Reverse Clock',
'SPEED BALL GT',
'HYPER FULL THROTTLE',
'IRREPARABLE HARDCORE IS BACK 2',
'Cruel Wounds',
]
albums.forEach((album) => MusicBrainz.searchAlbum(album))
return []
const recommendations: (InnerTube.Home.ScrapedSong | InnerTube.Home.ScrapedAlbum | InnerTube.Home.ScrapedArtist | InnerTube.Home.ScrapedPlaylist)[] = []
const goodSections = ['Listen again', 'Forgotten favorites', 'Quick picks', 'From your library']
for (const section of contents) {
@@ -190,14 +173,14 @@ export class YouTubeMusic implements Connection {
recommendations.push(...parsedContent)
}
const songs = recommendations.filter((recommendation) => recommendation.type === 'song') as Song[]
const scrapedSong = songs.map((song) => {
return { id: song.id, name: song.name, type: 'song', isVideo: false } satisfies ScrapedSong
})
console.log(JSON.stringify(await google.youtube('v3').playlistItems.list({ part: ['snippet', 'contentDetails'], playlistId: 'PLzs_8-KtyJFiM2zwEFqWqSX_WfzBenR9D', access_token: await this.accessToken })))
this.buildFullSongProfiles(scrapedSong)
return []
return recommendations
const fullProfiles = await this.buildFullProfiles(recommendations)
console.log(JSON.stringify(fullProfiles))
return fullProfiles
}
public async getAudioStream(id: string, range: string | null): Promise<Response> {
@@ -209,8 +192,8 @@ export class YouTubeMusic implements Connection {
return await fetch(format.url, { headers })
}
public async getAlbum(id: string): Promise<ScrapedAlbum> {
const albumResponse: InnerTube.AlbumResponse = await fetch(`https://music.youtube.com/youtubei/v1/browse`, {
public async getAlbum(id: string): Promise<Album> {
const albumResponse: InnerTube.AlbumResponse = await fetch('https://music.youtube.com/youtubei/v1/browse', {
headers: await this.innertubeRequestHeaders,
method: 'POST',
body: JSON.stringify({
@@ -227,95 +210,266 @@ export class YouTubeMusic implements Connection {
const header = albumResponse.header.musicDetailHeaderRenderer
const contents = albumResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer.contents
const connection = { id: this.id, type: 'youtube-music' } satisfies Album['connection']
const name = header.title.runs[0].text,
thumbnailUrl = cleanThumbnailUrl(header.thumbnail.croppedSquareThumbnailRenderer.thumbnail.thumbnails[0].url)
let artists: Album['artists'] = []
for (const run of header.subtitle.runs) {
if (run.text === 'Various Artists') {
artists = 'Various Artists'
break
}
if (run.navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ARTIST') {
artists.push({ id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text })
}
}
const releaseDate = header.subtitle.runs.at(-1)?.text!,
length = contents.length
const duration = contents.reduce(
(accumulator, current) => (accumulator += timestampToSeconds(current.musicResponsiveListItemRenderer.fixedColumns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text)),
0,
)
return { connection, id, name, type: 'album', duration, thumbnailUrl, artists, releaseDate, length } satisfies Album
}
public async getArtist(id: string): Promise<ScrapedArtist> {}
public async getPlaylist(id: string): Promise<Playlist> {
const playlistResponse: InnerTube.Playlist.PlaylistResponse = await fetch('https://music.youtube.com/youtubei/v1/browse', {
headers: await this.innertubeRequestHeaders,
method: 'POST',
body: JSON.stringify({
browseId: id,
context: {
client: {
clientName: 'WEB_REMIX',
clientVersion: `1.${formatDate()}.01.00`,
hl: 'en',
},
},
}),
}).then((response) => response.json())
public async getUser(id: string): Promise<ScrapedUser> {}
const header =
'musicEditablePlaylistDetailHeaderRenderer' in playlistResponse.header
? playlistResponse.header.musicEditablePlaylistDetailHeaderRenderer.header.musicDetailHeaderRenderer
: playlistResponse.header.musicDetailHeaderRenderer
private async buildFullSongProfiles(scrapedSongs: InnerTube.Home.ScrapedSong[]): Promise<Song[]> {
const playlistItems = await this.scrapePlaylistItems(playlistResponse)
const connection = { id: this.id, type: 'youtube-music' } satisfies Playlist['connection']
const name = header.title.runs[0].text
const thumbnailUrl = cleanThumbnailUrl(
header.thumbnail.croppedSquareThumbnailRenderer.thumbnail.thumbnails.reduce((prev, current) => (prev.width * prev.height > current.width * current.height ? prev : current)).url,
)
let createdBy: Playlist['createdBy']
header.subtitle.runs.forEach((run) => {
if (run.navigationEndpoint?.browseEndpoint.browseId) createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
})
const length = playlistItems.length
const duration = playlistItems.reduce((accumulator, current) => (accumulator += current.duration), 0)
return { connection, id, name, type: 'playlist', duration, thumbnailUrl, createdBy, length } satisfies Playlist
}
// public async getPlaylistItems(playlistId: string, startIndex: number, limit: number): Promise<Song[]> {
// const playlistResponse: InnerTube.Playlist.PlaylistResponse = await fetch('https://music.youtube.com/youtubei/v1/browse', {
// headers: await this.innertubeRequestHeaders,
// method: 'POST',
// body: JSON.stringify({
// browseId: playlistId,
// context: {
// client: {
// clientName: 'WEB_REMIX',
// clientVersion: `1.${formatDate()}.01.00`,
// hl: 'en',
// },
// },
// }),
// }).then((response) => response.json())
// const playlistItems = await this.scrapePlaylistItems(playlistResponse)
// const fullProfile = await this.buildFullProfiles(playlistItems)
// }
private async scrapePlaylistItems(playlistResponse: InnerTube.Playlist.PlaylistResponse): Promise<InnerTube.Playlist.ScrapedPlaylistItem[]> {
const contents = playlistResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicPlaylistShelfRenderer.contents
let continuation =
playlistResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicPlaylistShelfRenderer.continuations?.[0].nextContinuationData.continuation
while (continuation) {
const continuationResponse: InnerTube.Playlist.ContinuationResponse = await fetch(`https://music.youtube.com/youtubei/v1/browse?ctoken=${continuation}&continuation=${continuation}`, {
headers: await this.innertubeRequestHeaders,
method: 'POST',
body: JSON.stringify({
context: {
client: {
clientName: 'WEB_REMIX',
clientVersion: `1.${formatDate()}.01.00`,
hl: 'en',
},
},
}),
}).then((response) => response.json())
contents.push(...continuationResponse.continuationContents.musicPlaylistShelfContinuation.contents)
continuation = continuationResponse.continuationContents.musicPlaylistShelfContinuation.continuations?.[0].nextContinuationData.continuation
}
const playlistItems: InnerTube.Playlist.ScrapedPlaylistItem[] = []
contents.forEach((item) => {
const [col0, col1, col2] = item.musicResponsiveListItemRenderer.flexColumns
// This is simply to handle completely fucked playlists where the playlist items might be missing navigation endpoints (e.g. Deleted Videos)
// or in some really bad cases, have a navigationEndpoint, but not a watchEndpoint somehow (Possibly for unlisted/private content?)
if (!col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint?.watchEndpoint?.videoId) return
const id = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId
const name = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
const duration = timestampToSeconds(item.musicResponsiveListItemRenderer.fixedColumns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text)
const videoType = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType
const isVideo = videoType !== 'MUSIC_VIDEO_TYPE_ATV'
const thumbnailUrl = isVideo ? undefined : cleanThumbnailUrl(item.musicResponsiveListItemRenderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
const col2run = col2.musicResponsiveListItemFlexColumnRenderer.text.runs?.[0]
const album: Song['album'] = col2run ? { id: col2run.navigationEndpoint.browseEndpoint.browseId, name: col2run.text } : undefined
let artists: Song['artists'] = [],
uploader: Song['uploader']
for (const run of col1.musicResponsiveListItemFlexColumnRenderer.text.runs) {
if (!run.navigationEndpoint) continue
const pageType = run.navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
const runData = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
pageType === 'MUSIC_PAGE_TYPE_ARTIST' ? artists.push(runData) : (uploader = runData)
}
playlistItems.push({ id, name, type: 'song', duration, thumbnailUrl, artists, album, uploader, isVideo })
})
return playlistItems
}
private async buildFullProfiles(
scrapedItems: (InnerTube.Home.ScrapedSong | InnerTube.Home.ScrapedAlbum | InnerTube.Home.ScrapedArtist | InnerTube.Home.ScrapedPlaylist)[],
): Promise<(Song | Album | Artist | Playlist)[]> {
const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection']
const songIds = new Set<string>(),
albumIds = new Set<string>(),
artistIds = new Set<string>(),
userIds = new Set<string>()
playlistIds = new Set<string>()
scrapedSongs.forEach((song) => {
songIds.add(song.id)
if (song.album?.id) {
albumIds.add(song.album.id)
}
song.artists.forEach((artist) => artistIds.add(artist.id))
if (song.uploader) {
userIds.add(song.uploader.id)
scrapedItems.forEach((item) => {
switch (item.type) {
case 'song':
songIds.add(item.id)
if (item.album?.id) albumIds.add(item.album.id)
item.artists?.forEach((artist) => artistIds.add(artist.id))
break
case 'album':
albumIds.add(item.id)
if (item.artists instanceof Array) item.artists.forEach((artist) => artistIds.add(artist.id))
break
case 'artist':
artistIds.add(item.id)
break
case 'playlist':
playlistIds.add(item.id)
break
}
})
const getSongDetails = async () => google.youtube('v3').videos.list({ part: ['snippet', 'contentDetails'], id: Array.from(songIds), access_token: await this.accessToken })
const getAlbumDetails = () => Promise.all(Array.from(albumIds).map((id) => this.getAlbum(id)))
const getArtistDetails = () => Promise.all(Array.from(artistIds).map((id) => this.getArtist(id)))
const getUserDetails = () => Promise.all(Array.from(userIds).map((id) => this.getUser(id)))
const yt = google.youtube('v3')
const access_token = await this.accessToken
const [songDetails, albumDetails, artistDetails, userDetails] = await Promise.all([getSongDetails(), getAlbumDetails(), getArtistDetails(), getUserDetails()])
const getSongDetails = () => yt.videos.list({ part: ['snippet', 'contentDetails'], id: Array.from(songIds), access_token })
const getAlbumDetails = () => Promise.all(Array.from(albumIds).map((id) => this.getAlbum(id)))
const getPlaylistDetails = () => Promise.all(Array.from(playlistIds).map((id) => this.getPlaylist(id)))
const [songDetails, albumDetails, playlistDetails] = await Promise.all([getSongDetails(), getAlbumDetails(), getPlaylistDetails()])
const songDetailsMap = new Map<string, youtube_v3.Schema$Video>(),
albumDetailsMap = new Map<string, ScrapedAlbum>(),
artistDetailsMap = new Map<string, ScrapedArtist>(),
userDetailsMap = new Map<string, ScrapedUser>()
albumDetailsMap = new Map<string, Album>(),
playlistDetailsMap = new Map<string, Playlist>()
songDetails.data.items!.forEach((item) => songDetailsMap.set(item.id!, item))
albumDetails.forEach((album) => albumDetailsMap.set(album.id, album))
artistDetails.forEach((artist) => artistDetailsMap.set(artist.id, artist))
userDetails.forEach((user) => userDetailsMap.set(user.id, user))
playlistDetails.forEach((playlist) => playlistDetailsMap.set(playlist.id, playlist))
return scrapedSongs.map((song) => {
const songDetails = songDetailsMap.get(song.id)!
const duration = secondsFromISO8601(songDetails.contentDetails?.duration!)
return scrapedItems.map((item) => {
switch (item.type) {
case 'song':
const { id, name, artists, album, isVideo, uploader } = item
const songDetails = songDetailsMap.get(id)!
const duration = secondsFromISO8601(songDetails.contentDetails?.duration!)
const thumbnails = songDetails.snippet?.thumbnails!
const thumbnailUrl = song.thumbnailUrl ?? thumbnails.maxres?.url ?? thumbnails.standard?.url ?? thumbnails.high?.url ?? thumbnails.medium?.url ?? thumbnails.default?.url!
const thumbnails = songDetails.snippet?.thumbnails!
const thumbnailUrl = item.thumbnailUrl ?? thumbnails.maxres?.url ?? thumbnails.standard?.url ?? thumbnails.high?.url ?? thumbnails.medium?.url ?? thumbnails.default?.url!
let album: Song['album'],
uploader: Song['uploader'],
releaseDate = new Date(songDetails.snippet?.publishedAt!).toLocaleDateString()
let songReleaseDate = new Date(songDetails.snippet?.publishedAt!)
const artists: Song['artists'] = song.artists.map((artist) => {
const { id, name, profilePicture } = artistDetailsMap.get(artist.id)!
return { id, name, profilePicture }
})
if (song.album) {
const { id, name, thumbnailUrl, releaseYear } = albumDetailsMap.get(song.album.id)!
album = { id, name, thumbnailUrl }
releaseDate = releaseYear
const albumDetails = album ? albumDetailsMap.get(album.id)! : undefined
const fullAlbum = albumDetails ? { id: albumDetails.id, name: albumDetails.name, thumbnailUrl: albumDetails.thumbnailUrl } : (undefined satisfies Song['album'])
if (albumDetails?.releaseDate) {
const albumReleaseDate = new Date(albumDetails.releaseDate)
if (albumReleaseDate.getFullYear() < songReleaseDate.getFullYear()) songReleaseDate = albumReleaseDate
}
const releaseDate = songReleaseDate.toISOString()
return { connection, id, name, type: 'song', duration, thumbnailUrl, releaseDate, artists, album: fullAlbum, isVideo, uploader } satisfies Song
case 'album':
return albumDetailsMap.get(item.id)! satisfies Album
case 'artist':
return { connection, id: item.id, name: item.name, type: 'artist', profilePicture: item.profilePicture } satisfies Artist
case 'playlist':
return playlistDetailsMap.get(item.id)!
}
if (song.uploader) {
const { id, name, profilePicture } = userDetailsMap.get(song.uploader.id)!
uploader = { id, name, profilePicture }
}
return { connection, id: song.id, name: song.name, type: 'song', duration, thumbnailUrl, artists, album, isVideo: song.isVideo, uploader, releaseDate }
})
}
private async scrapedToFull(scrapedItems: (InnerTube.Home.ScrapedSong | InnerTube.Home.ScrapedAlbum | InnerTube.Home.ScrapedArtist | InnerTube.Home.ScrapedPlaylist)[]): Promise<(Song | Album | Artist | Playlist)[]> {
const connection = { id: this.id, type: 'youtube-music' } as const satisfies Album['connection']
const musicBrainzAlbumData = await Promise.all(scrapedAlbums.map((album) => ({ scrapedAlbum: album, musicBrainzData: MusicBrainz.searchAlbum(album.name) })))
musicBrainzAlbumData.forEach((album) => {
const ids: Album['ids'] = { connection: album.scrapedAlbum.id, musicBrainz: album.musicBrainzData}
const album = { connection, ids: {}} satisfies Album
})
}
}
function parseTwoRowItemRenderer(rowContent: InnerTube.musicTwoRowItemRenderer): InnerTube.Home.ScrapedSong | InnerTube.Home.ScrapedAlbum | InnerTube.Home.ScrapedArtist | InnerTube.Home.ScrapedPlaylist {
const name = rowContent.title.runs[0].text
let artists: InnerTube.Home.ScrapedSong['artists'] | InnerTube.Home.ScrapedAlbum['artists'] = [],
creator: InnerTube.Home.ScrapedSong['uploader'] | InnerTube.Home.ScrapedPlaylist['createdBy']
rowContent.subtitle.runs.forEach((run) => {
if (run.text === 'Various Artists') return (artists = 'Various Artists')
if (!run.navigationEndpoint) return
const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType,
id = run.navigationEndpoint.browseEndpoint.browseId,
name = run.text
switch (pageType) {
case 'MUSIC_PAGE_TYPE_ARTIST':
if (artists instanceof Array) artists.push({ id, name })
break
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
creator = { id, name }
break
}
})
if ('watchEndpoint' in rowContent.navigationEndpoint) {
const id = rowContent.navigationEndpoint.watchEndpoint.videoId
const isVideo = rowContent.navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType === 'MUSIC_VIDEO_TYPE_UGC'
const thumbnailUrl: InnerTube.Home.ScrapedSong['thumbnailUrl'] = isVideo ? undefined : refineThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
const musicVideoType = rowContent.navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType
const isVideo = musicVideoType === 'MUSIC_VIDEO_TYPE_UGC' || musicVideoType === 'MUSIC_VIDEO_TYPE_OMV'
const thumbnailUrl: InnerTube.Home.ScrapedSong['thumbnailUrl'] = isVideo ? undefined : cleanThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
let albumId: string | undefined
rowContent.menu?.menuRenderer.items.forEach((menuOption) => {
@@ -323,44 +477,28 @@ function parseTwoRowItemRenderer(rowContent: InnerTube.musicTwoRowItemRenderer):
'menuNavigationItemRenderer' in menuOption &&
'browseEndpoint' in menuOption.menuNavigationItemRenderer.navigationEndpoint &&
menuOption.menuNavigationItemRenderer.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ALBUM'
) albumId = menuOption.menuNavigationItemRenderer.navigationEndpoint.browseEndpoint.browseId
)
albumId = menuOption.menuNavigationItemRenderer.navigationEndpoint.browseEndpoint.browseId
})
const album: InnerTube.Home.ScrapedSong['album'] = albumId ? { id: albumId } : undefined
const artists = rowContent.subtitle.runs.map((run) => {
if (run.navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ARTIST') {
return { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
}
}).filter((value): value is { id: string, name: string } => value !== undefined)
const uploaderRun = rowContent.subtitle.runs.find((run) => run.navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_USER_CHANNEL')
const uploader = uploaderRun?.navigationEndpoint?.browseEndpoint.browseId ? { id: uploaderRun.navigationEndpoint.browseEndpoint.browseId, name: uploaderRun.text } : undefined
return { id, name, type: 'song', thumbnailUrl, artists, album, uploader, isVideo } satisfies InnerTube.Home.ScrapedSong
return { id, name, type: 'song', thumbnailUrl, artists, album, uploader: creator, isVideo } satisfies InnerTube.Home.ScrapedSong
}
const pageType = rowContent.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
const id = rowContent.navigationEndpoint.browseEndpoint.browseId
const thumbnailUrl = refineThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
const artists = rowContent.subtitle.runs.map((run) => {
if (run.navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ARTIST') {
return { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
}
}).filter((value): value is { id: string, name: string } => value !== undefined)
const creatorRun = rowContent.subtitle.runs.find((run) => run.navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_USER_CHANNEL')!
const createdBy = { id: creatorRun.navigationEndpoint?.browseEndpoint.browseId!, name: creatorRun.text }
switch (pageType) {
case 'MUSIC_PAGE_TYPE_ALBUM':
return { id, name, type: 'album', artists: artists.length > 0 ? artists : undefined, thumbnailUrl } satisfies InnerTube.Home.ScrapedAlbum
const thumbnailUrl = cleanThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
return { id, name, type: 'album', artists, thumbnailUrl } satisfies InnerTube.Home.ScrapedAlbum
case 'MUSIC_PAGE_TYPE_ARTIST':
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
return { id, name, type: 'artist', profilePicture: thumbnailUrl } satisfies InnerTube.Home.ScrapedArtist
const profilePicture = cleanThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
return { id, name, type: 'artist', profilePicture } satisfies InnerTube.Home.ScrapedArtist
case 'MUSIC_PAGE_TYPE_PLAYLIST':
return { id, name, type: 'playlist', createdBy, thumbnailUrl } satisfies InnerTube.Home.ScrapedPlaylist
return { id, name, type: 'playlist', createdBy: creator! } satisfies InnerTube.Home.ScrapedPlaylist
}
}
@@ -368,83 +506,105 @@ function parseResponsiveListItemRenderer(listContent: InnerTube.musicResponsiveL
const name = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
const column1Runs = listContent.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs
const artists = column1Runs.map((run) => {
if (run.navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ARTIST') {
return { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text}
let artists: InnerTube.Home.ScrapedSong['artists'] | InnerTube.Home.ScrapedAlbum['artists'] = [],
creator: InnerTube.Home.ScrapedSong['uploader'] | InnerTube.Home.ScrapedPlaylist['createdBy']
column1Runs.forEach((run) => {
if (run.text === 'Various Artists') return (artists = 'Various Artists')
if (!run.navigationEndpoint) return
const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType,
id = run.navigationEndpoint.browseEndpoint.browseId,
name = run.text
switch (pageType) {
case 'MUSIC_PAGE_TYPE_ARTIST':
if (artists instanceof Array) artists.push({ id, name })
break
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
creator = { id, name }
break
}
}).filter((artist): artist is { id: string, name: string } => artist !== undefined)
})
if (!('navigationEndpoint' in listContent)) {
const id = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId
const isVideo = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType === 'MUSIC_VIDEO_TYPE_UGC'
const thumbnailUrl = isVideo ? undefined : refineThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
const musicVideoType = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType
const isVideo = musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'
const thumbnailUrl = isVideo ? undefined : cleanThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
const column2run = listContent.flexColumns[2]?.musicResponsiveListItemFlexColumnRenderer.text.runs?.[0]
const album = (() => {
if (column2run?.navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ALBUM') {
return { id: column2run.navigationEndpoint.browseEndpoint.browseId, name: column2run.text }
}
})()
const album =
column2run?.navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ALBUM'
? { id: column2run.navigationEndpoint.browseEndpoint.browseId, name: column2run.text }
: undefined
const uploaderRun = column1Runs.find((run) => run.navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_USER_CHANNEL')
const uploader = uploaderRun?.navigationEndpoint?.browseEndpoint.browseId ? { id: uploaderRun.navigationEndpoint.browseEndpoint.browseId, name: uploaderRun.text } : undefined
return { id, name, type: 'song', thumbnailUrl, artists, album, uploader, isVideo } satisfies InnerTube.Home.ScrapedSong
return { id, name, type: 'song', thumbnailUrl, artists, album, uploader: creator, isVideo } satisfies InnerTube.Home.ScrapedSong
}
const id = listContent.navigationEndpoint.browseEndpoint.browseId
const pageType = listContent.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
const thumbnailUrl = refineThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
const creatorRun = column1Runs.find((run) => run.navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_USER_CHANNEL')!
const createdBy = { id: creatorRun.navigationEndpoint?.browseEndpoint.browseId!, name: creatorRun.text }
switch (pageType) {
case 'MUSIC_PAGE_TYPE_ALBUM':
return { id, name, type: 'album', thumbnailUrl, artists: artists.length > 0 ? artists : undefined } satisfies InnerTube.Home.ScrapedAlbum
const thumbnailUrl = cleanThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
return { id, name, type: 'album', thumbnailUrl, artists } satisfies InnerTube.Home.ScrapedAlbum
case 'MUSIC_PAGE_TYPE_ARTIST':
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
return { id, name, type: 'artist', profilePicture: thumbnailUrl } satisfies InnerTube.Home.ScrapedArtist
const profilePicture = cleanThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
return { id, name, type: 'artist', profilePicture } satisfies InnerTube.Home.ScrapedArtist
case 'MUSIC_PAGE_TYPE_PLAYLIST':
return { id, name, type: 'playlist', thumbnailUrl, createdBy } satisfies InnerTube.Home.ScrapedPlaylist
return { id, name, type: 'playlist', createdBy: creator! } satisfies InnerTube.Home.ScrapedPlaylist
}
}
function parseMusicCardShelfRenderer(connection: string, cardContent: InnerTube.musicCardShelfRenderer): Song | Album | Artist | Playlist {
function parseMusicCardShelfRenderer(cardContent: InnerTube.musicCardShelfRenderer): InnerTube.Home.ScrapedSong | InnerTube.Home.ScrapedAlbum | InnerTube.Home.ScrapedArtist | InnerTube.Home.ScrapedPlaylist {
const name = cardContent.title.runs[0].text
const thumbnail = refineThumbnailUrl(cardContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
let album: Song['album'], artists: (Song | Album)['artists'], createdBy: (Song | Playlist)['createdBy']
let album: Song['album'],
artists: InnerTube.Home.ScrapedSong['artists'] | InnerTube.Home.ScrapedAlbum['artists'] = [],
creator: Song['uploader'] | Playlist['createdBy']
for (const run of cardContent.subtitle.runs) {
if (!run.navigationEndpoint) continue
const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
if (pageType === 'MUSIC_PAGE_TYPE_ALBUM') {
album = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
} else if (pageType === 'MUSIC_PAGE_TYPE_ARTIST') {
const artist = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
artists ? artists.push(artist) : (artists = [artist])
} else if (pageType === 'MUSIC_PAGE_TYPE_USER_CHANNEL') {
createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
const runData = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
switch (pageType) {
case 'MUSIC_PAGE_TYPE_ALBUM':
album = runData
break
case 'MUSIC_PAGE_TYPE_ARTIST':
artists.push(runData)
break
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
creator = runData
break
}
}
const navigationEndpoint = cardContent.title.runs[0].navigationEndpoint
if ('watchEndpoint' in navigationEndpoint) {
const id = navigationEndpoint.watchEndpoint.videoId
return { connection, id, name, type: 'song', artists, album, createdBy, thumbnail } satisfies Song
const musicVideoType = navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType
const isVideo = musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'
const thumbnailUrl = isVideo ? undefined : cleanThumbnailUrl(cardContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
return { id, name, type: 'song', thumbnailUrl, artists, album, uploader: creator, isVideo } satisfies InnerTube.Home.ScrapedSong
}
const pageType = navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
const id = navigationEndpoint.browseEndpoint.browseId
switch (pageType) {
case 'MUSIC_PAGE_TYPE_ALBUM':
return { connection, id, name, type: 'album', artists, thumbnail } satisfies Album
const thumbnailUrl = cleanThumbnailUrl(cardContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
return { id, name, type: 'album', thumbnailUrl, artists } satisfies InnerTube.Home.ScrapedAlbum
case 'MUSIC_PAGE_TYPE_ARTIST':
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
return { connection, id, name, type: 'artist', thumbnail } satisfies Artist
const profilePicture = cleanThumbnailUrl(cardContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url)
return { id, name, type: 'artist', profilePicture } satisfies InnerTube.Home.ScrapedArtist
case 'MUSIC_PAGE_TYPE_PLAYLIST':
return { connection, id, name, type: 'playlist', createdBy, thumbnail } satisfies Playlist
return { id, name, type: 'playlist', createdBy: creator! } satisfies InnerTube.Home.ScrapedPlaylist
}
}
@@ -459,14 +619,20 @@ function secondsFromISO8601(duration: string): number {
return Number(seconds) + Number(minutes) * 60 + Number(hours) * 3600 + Number(days) * 86400
}
/** Remove YouTubes fake query parameters from their thumbnail urls returning the base url for as needed modification.
/** Remove YouTube's fake query parameters from their thumbnail urls returning the base url for as needed modification.
* Valid URL origins:
* - https://lh3.googleusercontent.com
* - https://yt3.googleusercontent.com
* - https://yt3.ggpht.com
* - https://music.youtube.com
* - https://i.ytimg.com
*
* NOTE:
* https://i.ytimg.com corresponds to videos, which follow the mqdefault...maxres resolutions scale. It is generally bad practice to use these as there is no way to scale them with query params, and there is no way to tell if a maxres.jpg exists or not.
* It is generally best practice to not directly scrape these video thumbnails directly from youtube and insted get the highest res from the v3 api.
* However there a few instances in which we want to scrape a thumbail directly from the webapp (e.g. Playlist thumbanils) so it still remains valid.
*/
function refineThumbnailUrl(urlString: string): string {
function cleanThumbnailUrl(urlString: string): string {
if (!URL.canParse(urlString)) throw new Error('Invalid thumbnail url')
switch (new URL(urlString).origin) {
@@ -476,8 +642,10 @@ function refineThumbnailUrl(urlString: string): string {
return urlString.slice(0, urlString.indexOf('='))
case 'https://music.youtube.com':
return urlString
default: // 'https://i.ytimg.com' cannot be manimulated with query params and as such is invalid
console.error(urlString)
case 'https://i.ytimg.com':
return urlString.slice(0, urlString.indexOf('?'))
default:
console.error('Tried to clean invalid url: ' + urlString)
throw new Error('Invalid thumbnail url origin')
}
}
@@ -491,11 +659,21 @@ function formatDate(): string {
return year + month + day
}
/**
* @param timestamp A string in the format Hours:Minutes:Seconds (Standard Timestamp format on YouTube)
* @returns The total duration of that timestamp in seconds
*/
const timestampToSeconds = (timestamp: string) =>
timestamp
.split(':')
.reverse()
.reduce((accumulator, current, index) => (accumulator += Number(current) * 60 ** index), 0)
// NOTE 1: Thumbnails
// When scraping thumbnails from the YTMusic browse pages, there are two different types of images that can be returned,
// standard video thumbnais and auto-generated square thumbnails for propper releases. The auto-generated thumbanils we want to
// keep from the scrape because:
// a) They can be easily scaled with ytmusic's weird fake query parameters (Ex: https://baseUrl=w1000&h1000)
// a) They can be easily scaled with ytmusic's weird fake query parameters (Ex: https://baseUrl=h1000)
// b) When fetched from the youtube data api it returns the 16:9 filled thumbnails like you would see in the standard yt player, we want the squares
//
// However when the thumbnail is for a video, we want to ignore it because the highest quality thumbnail will rarely be used in the ytmusic webapp
@@ -519,8 +697,8 @@ declare namespace InnerTube {
name: string
type: 'song'
thumbnailUrl?: string
artists: {
id?: string
artists?: {
id: string
name: string
}[]
album?: {
@@ -539,10 +717,12 @@ declare namespace InnerTube {
name: string
type: 'album'
thumbnailUrl: string
artists?: {
id: string
name: string
}[]
artists:
| {
id: string
name: string
}[]
| 'Various Artists'
}
type ScrapedArtist = {
@@ -556,13 +736,169 @@ declare namespace InnerTube {
id: string
name: string
type: 'playlist'
thumbnailUrl: string
// thumbnailUrl: string Need to figure out how I want to do playlists
createdBy: {
id: string
name: string
}
}
}
namespace Playlist {
interface PlaylistResponse {
contents: {
singleColumnBrowseResultsRenderer: {
tabs: [
{
tabRenderer: {
content: {
sectionListRenderer: {
contents: [
{
musicPlaylistShelfRenderer: ContentShelf
},
]
}
}
}
},
]
}
}
header:
| Header
| {
musicEditablePlaylistDetailHeaderRenderer: {
header: Header
}
}
}
interface ContinuationResponse {
continuationContents: {
musicPlaylistShelfContinuation: ContentShelf
}
}
type ContentShelf = {
contents: Array<PlaylistItem>
continuations?: [
{
nextContinuationData: {
continuation: string
}
},
]
}
type PlaylistItem = {
musicResponsiveListItemRenderer: {
thumbnail: {
musicThumbnailRenderer: musicThumbnailRenderer
}
flexColumns: [
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs: [
{
text: string
navigationEndpoint?: {
watchEndpoint: watchEndpoint
}
},
]
}
}
},
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs: {
text: string
navigationEndpoint?: {
browseEndpoint: browseEndpoint
}
}[]
}
}
},
{
musicResponsiveListItemFlexColumnRenderer: {
text: {
runs?: [
{
text: string
navigationEndpoint: {
browseEndpoint: browseEndpoint
}
},
]
}
}
},
]
fixedColumns: [
{
musicResponsiveListItemFixedColumnRenderer: {
text: {
runs: [
{
text: string
},
]
}
}
},
]
}
}
type Header = {
musicDetailHeaderRenderer: {
title: {
runs: [
{
text: string
},
]
}
subtitle: {
runs: {
text: string
navigationEndpoint?: {
browseEndpoint: browseEndpoint
}
}[]
}
thumbnail: {
croppedSquareThumbnailRenderer: musicThumbnailRenderer
}
}
}
type ScrapedPlaylistItem = {
id: string
name: string
type: 'song'
duration: number
thumbnailUrl?: string
artists?: {
id: string
name: string
}[]
album?: {
id: string
name: string
}
uploader?: {
id: string
name: string
}
isVideo: boolean
}
}
interface AlbumResponse {
contents: {
singleColumnBrowseResultsRenderer: {
@@ -590,6 +926,19 @@ declare namespace InnerTube {
}
}
}>
fixedColumns: [
{
musicResponsiveListItemFixedColumnRenderer: {
text: {
runs: [
{
text: string
},
]
}
}
},
]
}
}>
}
@@ -612,6 +961,9 @@ declare namespace InnerTube {
]
}
subtitle: {
// Alright let's break down this dumbass pattern. First run will always have the text 'Album', last will always be the release year. Interspersed throughout the middle will be the artist runs
// which, if they have a dedicated channel, will have a navigation endpoint. Every other run is some kind of delimiter (• , &). Because y'know, it's perfectly sensible to include your decorative
// elements in your api responses /s
runs: Array<{
text: string
navigationEndpoint?: {
@@ -619,6 +971,13 @@ declare namespace InnerTube {
}
}>
}
secondSubtitle: {
// Slightly less dumbass. Three runs, first is the number of songs in the format: "# songs". Second is another bullshit delimiter. Last is the album's duration, spelled out rather than as a timestamp
// for god knows what reason. Duration follows the following format: "# hours, # minutes" or just "# minutes".
runs: {
text: string
}[]
}
thumbnail: {
croppedSquareThumbnailRenderer: musicThumbnailRenderer
}
@@ -776,7 +1135,7 @@ declare namespace InnerTube {
text: {
runs: [
{
text: 'Go to album' | 'Go to artist' |
text: 'Go to album' | 'Go to artist'
},
]
}
@@ -914,7 +1273,8 @@ declare namespace InnerTube {
playlistId: string
watchEndpointMusicSupportedConfigs: {
watchEndpointMusicConfig: {
musicVideoType: 'MUSIC_VIDEO_TYPE_UGC' | 'MUSIC_VIDEO_TYPE_ATV' // UGC Means it is a user-uploaded video, ATV means it is auto-generated
musicVideoType: 'MUSIC_VIDEO_TYPE_UGC' | 'MUSIC_VIDEO_TYPE_OMV' | 'MUSIC_VIDEO_TYPE_ATV' | 'MUSIC_VIDEO_TYPE_OFFICIAL_SOURCE_MUSIC'
// UGC and OMV Means it is a user-uploaded video, ATV means it is auto-generated, I don't have a fucking clue what OFFICIAL_SOURCE_MUSIC means but so far it seems like videos too?
}
}
}