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

28
src/app.d.ts vendored
View File

@@ -56,15 +56,12 @@ declare global {
id: string id: string
type: 'jellyfin' | 'youtube-music' type: 'jellyfin' | 'youtube-music'
} }
ids: { id: string
connection: string
musicBrainz?: string
}
name: string name: string
type: 'song' type: 'song'
duration: number // Seconds duration: number // Seconds
thumbnailUrl: string // Base/maxres url of song, any scaling for performance purposes will be handled by remoteImage endpoint thumbnailUrl: string // Base/maxres url of song, any scaling for performance purposes will be handled by remoteImage endpoint
releaseDate: string // YYYY-MM-DD || YYYY-MM || YYYY releaseDate: string // ISOString
artists?: { // Should try to order artists?: { // Should try to order
id: string id: string
name: string name: string
@@ -73,7 +70,7 @@ declare global {
album?: { album?: {
id: string id: string
name: string name: string
thumbnailUrl: string thumbnailUrl?: string
} }
uploader?: { uploader?: {
id: string id: string
@@ -88,20 +85,18 @@ declare global {
id: string id: string
type: 'jellyfin' | 'youtube-music' type: 'jellyfin' | 'youtube-music'
} }
ids: { id: string
connection: string
musicBrainz?: string
}
name: string name: string
type: 'album' type: 'album'
duration?: number // Seconds
thumbnailUrl: string thumbnailUrl: string
artists: { // Should try to order artists: { // Should try to order
id: string id: string
name: string name: string
profilePicture?: string profilePicture?: string
}[] | 'Various Artists' }[] | 'Various Artists'
releaseDate: string // YYYY-MM-DD || YYYY-MM || YYYY releaseDate?: string // ISOString
length: number length?: number
} }
// Need to figure out how to do Artists, maybe just query MusicBrainz? // Need to figure out how to do Artists, maybe just query MusicBrainz?
@@ -110,16 +105,13 @@ declare global {
id: string id: string
type: 'jellyfin' | 'youtube-music' type: 'jellyfin' | 'youtube-music'
} }
ids: { id: string
connection: string
musicBrainz?: string
}
name: string name: string
type: 'artist' type: 'artist'
profilePicture?: string profilePicture?: string
} }
type Playlist = { type Playlist = { // Keep Playlist items seperate from the playlist itself. What's really nice is playlist items can just be an ordered array of Songs
connection: { connection: {
id: string id: string
type: 'jellyfin' | 'youtube-music' type: 'jellyfin' | 'youtube-music'
@@ -127,12 +119,14 @@ declare global {
id: string id: string
name: string name: string
type: 'playlist' type: 'playlist'
duration: number
thumbnailUrl: string thumbnailUrl: string
createdBy?: { createdBy?: {
id: string id: string
name: string name: string
profilePicture?: string profilePicture?: string
} }
length: number
} }
} }

View File

@@ -12,13 +12,13 @@
<div id="card-wrapper" class="flex-shrink-0"> <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}`)}> <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 <img
bind:this={image} bind:this={image}
id="card-image" id="card-image"
on:load={() => (captionText.style.width = `${image.width}px`)} on:load={() => (captionText.style.width = `${image.width}px`)}
class="h-full rounded transition-all" 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" alt="{mediaItem.name} thumbnail"
/> />
{:else} {:else}
@@ -39,17 +39,23 @@
</button> </button>
<div bind:this={captionText} class="w-56 p-1"> <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="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;"> <div class="leading-2 line-clamp-2 flex flex-wrap text-neutral-400" style="font-size: 0;">
{#if 'artists' in mediaItem && mediaItem.artists} {#if 'artists' in mediaItem && mediaItem.artists && mediaItem.artists.length > 0}
{#each mediaItem.artists as artist} {#if mediaItem.artists === 'Various Artists'}
{@const listIndex = mediaItem.artists.indexOf(artist)} <span class="text-sm">Various Artists</span>
<a class="text-sm hover:underline focus:underline" href="/details/artist?id={artist.id}&connection={mediaItem.connection}">{artist.name}</a> {:else}
{#if listIndex < mediaItem.artists.length - 1} {#each mediaItem.artists as artist}
<span class="mr-0.5 text-sm">,</span> {@const listIndex = mediaItem.artists.indexOf(artist)}
{/if} <a class="text-sm hover:underline focus:underline" href="/details/artist?id={artist.id}&connection={mediaItem.connection.id}">{artist.name}</a>
{/each} {#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} {: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} {/if}
</div> </div>
</div> </div>

View File

@@ -33,9 +33,9 @@
if ('mediaSession' in navigator) { if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({ navigator.mediaSession.metadata = new MediaMetadata({
title: media.name, 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, 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"> <section class="flex gap-3">
<div class="relative h-full w-20 min-w-20"> <div class="relative h-full w-20 min-w-20">
{#key currentlyPlaying} {#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} {/key}
</div> </div>
<section class="flex flex-col justify-center gap-1"> <section class="flex flex-col justify-center gap-1">
<div class="line-clamp-2 text-sm">{currentlyPlaying.name}</div> <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> </section>
<section class="flex min-w-max flex-col items-center justify-center gap-1"> <section class="flex min-w-max flex-col items-center justify-center gap-1">
@@ -148,11 +148,11 @@
</section> </section>
</main> </main>
{:else} {: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="song-queue-wrapper h-full px-24 py-20">
<section class="relative"> <section class="relative">
{#key currentlyPlaying} {#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} {/key}
</section> </section>
<section class="flex flex-col gap-2"> <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)]'}" 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> <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="justify-items-left text-left">
<div class="line-clamp-2">{item.name}</div> <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> </div>
<span class="text-right">{item.duration ?? ''}</span> <span class="text-right">{formatTime(item.duration)}</span>
</button> </button>
{/each} {/each}
</section> </section>
@@ -197,7 +197,7 @@
<div> <div>
<div class="mb-2 line-clamp-2 text-3xl">{currentlyPlaying.name}</div> <div class="mb-2 line-clamp-2 text-3xl">{currentlyPlaying.name}</div>
<div class="line-clamp-1 text-lg"> <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> </div>
<div class="flex w-full items-center justify-center gap-2 text-2xl"> <div class="flex w-full items-center justify-center gap-2 text-2xl">
@@ -237,7 +237,7 @@
</section> </section>
</main> </main>
{/if} {/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> </div>
{/if} {/if}

View File

@@ -7,7 +7,38 @@ const mbApi = new MusicBrainzApi({
appContactInfo: 'Ec1ypsed@proton.me', 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 { 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> { static async searchRelease(albumName: string, artistNames?: string[]): Promise<MusicBrainz.ReleaseSearchResult | null> {
const searchResulst = await mbApi.search('release', { query: albumName, limit: 10 }) const searchResulst = await mbApi.search('release', { query: albumName, limit: 10 })
if (searchResulst.releases.length === 0) { if (searchResulst.releases.length === 0) {
@@ -16,9 +47,7 @@ export class MusicBrainz {
} }
const bestMatch = searchResulst.releases.reduce((prev, current) => { const bestMatch = searchResulst.releases.reduce((prev, current) => {
if (prev.score === current.score) { if (prev.score === current.score) return new Date(prev.date).getTime() > new Date(current.date).getTime() ? prev : current
return new Date(prev.date).getTime() > new Date(current.date).getTime() ? prev : current
}
return prev.score > current.score ? 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 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 { 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 ytdl from 'ytdl-core'
import { DB } from './db' import { DB } from './db'
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public' import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private' import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private'
import { MusicBrainz } from './musicBrainz'
export class YouTubeMusic implements Connection { export class YouTubeMusic implements Connection {
public readonly id: string 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 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'] const goodSections = ['Songs', 'Videos', 'Albums', 'Artists', 'Community playlists']
for (const section of contents) { for (const section of contents) {
if ('musicCardShelfRenderer' in section) { if ('musicCardShelfRenderer' in section) {
parsedSearchResults.push(parseMusicCardShelfRenderer(this.id, section.musicCardShelfRenderer)) parsedSearchResults.push(parseMusicCardShelfRenderer(section.musicCardShelfRenderer))
section.musicCardShelfRenderer.contents?.forEach((item) => { section.musicCardShelfRenderer.contents?.forEach((item) => {
if ('musicResponsiveListItemRenderer' in item) { if ('musicResponsiveListItemRenderer' in item) {
parsedSearchResults.push(parseResponsiveListItemRenderer(this.id, item.musicResponsiveListItemRenderer)) parsedSearchResults.push(parseResponsiveListItemRenderer(item.musicResponsiveListItemRenderer))
} }
}) })
continue continue
@@ -137,11 +136,11 @@ export class YouTubeMusic implements Connection {
const sectionType = section.musicShelfRenderer.title.runs[0].text const sectionType = section.musicShelfRenderer.title.runs[0].text
if (!goodSections.includes(sectionType)) continue 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) parsedSearchResults.push(...parsedSectionContents)
} }
return parsedSearchResults return await this.buildFullProfiles(parsedSearchResults)
} }
public async getRecommendations(): Promise<(Song | Album | Artist | Playlist)[]> { 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 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 recommendations: (InnerTube.Home.ScrapedSong | InnerTube.Home.ScrapedAlbum | InnerTube.Home.ScrapedArtist | InnerTube.Home.ScrapedPlaylist)[] = []
const goodSections = ['Listen again', 'Forgotten favorites', 'Quick picks', 'From your library'] const goodSections = ['Listen again', 'Forgotten favorites', 'Quick picks', 'From your library']
for (const section of contents) { for (const section of contents) {
@@ -190,14 +173,14 @@ export class YouTubeMusic implements Connection {
recommendations.push(...parsedContent) recommendations.push(...parsedContent)
} }
const songs = recommendations.filter((recommendation) => recommendation.type === 'song') as Song[] console.log(JSON.stringify(await google.youtube('v3').playlistItems.list({ part: ['snippet', 'contentDetails'], playlistId: 'PLzs_8-KtyJFiM2zwEFqWqSX_WfzBenR9D', access_token: await this.accessToken })))
const scrapedSong = songs.map((song) => {
return { id: song.id, name: song.name, type: 'song', isVideo: false } satisfies ScrapedSong
})
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> { 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 }) return await fetch(format.url, { headers })
} }
public async getAlbum(id: string): Promise<ScrapedAlbum> { public async getAlbum(id: string): Promise<Album> {
const albumResponse: InnerTube.AlbumResponse = await fetch(`https://music.youtube.com/youtubei/v1/browse`, { const albumResponse: InnerTube.AlbumResponse = await fetch('https://music.youtube.com/youtubei/v1/browse', {
headers: await this.innertubeRequestHeaders, headers: await this.innertubeRequestHeaders,
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
@@ -227,95 +210,266 @@ export class YouTubeMusic implements Connection {
const header = albumResponse.header.musicDetailHeaderRenderer const header = albumResponse.header.musicDetailHeaderRenderer
const contents = albumResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer.contents 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 connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection']
const songIds = new Set<string>(), const songIds = new Set<string>(),
albumIds = new Set<string>(), albumIds = new Set<string>(),
artistIds = new Set<string>(), artistIds = new Set<string>(),
userIds = new Set<string>() playlistIds = new Set<string>()
scrapedSongs.forEach((song) => { scrapedItems.forEach((item) => {
songIds.add(song.id) switch (item.type) {
if (song.album?.id) { case 'song':
albumIds.add(song.album.id) songIds.add(item.id)
} if (item.album?.id) albumIds.add(item.album.id)
song.artists.forEach((artist) => artistIds.add(artist.id)) item.artists?.forEach((artist) => artistIds.add(artist.id))
if (song.uploader) { break
userIds.add(song.uploader.id) 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 yt = google.youtube('v3')
const getAlbumDetails = () => Promise.all(Array.from(albumIds).map((id) => this.getAlbum(id))) const access_token = await this.accessToken
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 [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>(), const songDetailsMap = new Map<string, youtube_v3.Schema$Video>(),
albumDetailsMap = new Map<string, ScrapedAlbum>(), albumDetailsMap = new Map<string, Album>(),
artistDetailsMap = new Map<string, ScrapedArtist>(), playlistDetailsMap = new Map<string, Playlist>()
userDetailsMap = new Map<string, ScrapedUser>()
songDetails.data.items!.forEach((item) => songDetailsMap.set(item.id!, item)) songDetails.data.items!.forEach((item) => songDetailsMap.set(item.id!, item))
albumDetails.forEach((album) => albumDetailsMap.set(album.id, album)) albumDetails.forEach((album) => albumDetailsMap.set(album.id, album))
artistDetails.forEach((artist) => artistDetailsMap.set(artist.id, artist)) playlistDetails.forEach((playlist) => playlistDetailsMap.set(playlist.id, playlist))
userDetails.forEach((user) => userDetailsMap.set(user.id, user))
return scrapedSongs.map((song) => { return scrapedItems.map((item) => {
const songDetails = songDetailsMap.get(song.id)! switch (item.type) {
const duration = secondsFromISO8601(songDetails.contentDetails?.duration!) 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 thumbnails = songDetails.snippet?.thumbnails!
const thumbnailUrl = song.thumbnailUrl ?? thumbnails.maxres?.url ?? thumbnails.standard?.url ?? thumbnails.high?.url ?? thumbnails.medium?.url ?? thumbnails.default?.url! const thumbnailUrl = item.thumbnailUrl ?? thumbnails.maxres?.url ?? thumbnails.standard?.url ?? thumbnails.high?.url ?? thumbnails.medium?.url ?? thumbnails.default?.url!
let album: Song['album'], let songReleaseDate = new Date(songDetails.snippet?.publishedAt!)
uploader: Song['uploader'],
releaseDate = new Date(songDetails.snippet?.publishedAt!).toLocaleDateString()
const artists: Song['artists'] = song.artists.map((artist) => { const albumDetails = album ? albumDetailsMap.get(album.id)! : undefined
const { id, name, profilePicture } = artistDetailsMap.get(artist.id)! const fullAlbum = albumDetails ? { id: albumDetails.id, name: albumDetails.name, thumbnailUrl: albumDetails.thumbnailUrl } : (undefined satisfies Song['album'])
return { id, name, profilePicture }
}) if (albumDetails?.releaseDate) {
if (song.album) { const albumReleaseDate = new Date(albumDetails.releaseDate)
const { id, name, thumbnailUrl, releaseYear } = albumDetailsMap.get(song.album.id)! if (albumReleaseDate.getFullYear() < songReleaseDate.getFullYear()) songReleaseDate = albumReleaseDate
album = { id, name, thumbnailUrl } }
releaseDate = releaseYear
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 { function parseTwoRowItemRenderer(rowContent: InnerTube.musicTwoRowItemRenderer): InnerTube.Home.ScrapedSong | InnerTube.Home.ScrapedAlbum | InnerTube.Home.ScrapedArtist | InnerTube.Home.ScrapedPlaylist {
const name = rowContent.title.runs[0].text 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) { if ('watchEndpoint' in rowContent.navigationEndpoint) {
const id = rowContent.navigationEndpoint.watchEndpoint.videoId const id = rowContent.navigationEndpoint.watchEndpoint.videoId
const isVideo = rowContent.navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType === 'MUSIC_VIDEO_TYPE_UGC' const musicVideoType = rowContent.navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType
const thumbnailUrl: InnerTube.Home.ScrapedSong['thumbnailUrl'] = isVideo ? undefined : refineThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url) 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 let albumId: string | undefined
rowContent.menu?.menuRenderer.items.forEach((menuOption) => { rowContent.menu?.menuRenderer.items.forEach((menuOption) => {
@@ -323,44 +477,28 @@ function parseTwoRowItemRenderer(rowContent: InnerTube.musicTwoRowItemRenderer):
'menuNavigationItemRenderer' in menuOption && 'menuNavigationItemRenderer' in menuOption &&
'browseEndpoint' in menuOption.menuNavigationItemRenderer.navigationEndpoint && 'browseEndpoint' in menuOption.menuNavigationItemRenderer.navigationEndpoint &&
menuOption.menuNavigationItemRenderer.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ALBUM' 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 album: InnerTube.Home.ScrapedSong['album'] = albumId ? { id: albumId } : undefined
const artists = rowContent.subtitle.runs.map((run) => { return { id, name, type: 'song', thumbnailUrl, artists, album, uploader: creator, isVideo } satisfies InnerTube.Home.ScrapedSong
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
} }
const pageType = rowContent.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType const pageType = rowContent.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
const id = rowContent.navigationEndpoint.browseEndpoint.browseId 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) { switch (pageType) {
case 'MUSIC_PAGE_TYPE_ALBUM': 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_ARTIST':
case 'MUSIC_PAGE_TYPE_USER_CHANNEL': 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': 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 name = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
const column1Runs = listContent.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs const column1Runs = listContent.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs
const artists = column1Runs.map((run) => { let artists: InnerTube.Home.ScrapedSong['artists'] | InnerTube.Home.ScrapedAlbum['artists'] = [],
if (run.navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ARTIST') { creator: InnerTube.Home.ScrapedSong['uploader'] | InnerTube.Home.ScrapedPlaylist['createdBy']
return { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text}
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)) { if (!('navigationEndpoint' in listContent)) {
const id = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId 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 musicVideoType = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType
const thumbnailUrl = isVideo ? undefined : refineThumbnailUrl(listContent.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails[0].url) 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 column2run = listContent.flexColumns[2]?.musicResponsiveListItemFlexColumnRenderer.text.runs?.[0]
const album = (() => { const album =
if (column2run?.navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ALBUM') { column2run?.navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ALBUM'
return { id: column2run.navigationEndpoint.browseEndpoint.browseId, name: column2run.text } ? { 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') return { id, name, type: 'song', thumbnailUrl, artists, album, uploader: creator, isVideo } satisfies InnerTube.Home.ScrapedSong
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
} }
const id = listContent.navigationEndpoint.browseEndpoint.browseId const id = listContent.navigationEndpoint.browseEndpoint.browseId
const pageType = listContent.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType 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) { switch (pageType) {
case 'MUSIC_PAGE_TYPE_ALBUM': 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_ARTIST':
case 'MUSIC_PAGE_TYPE_USER_CHANNEL': 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': 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 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) { for (const run of cardContent.subtitle.runs) {
if (!run.navigationEndpoint) continue if (!run.navigationEndpoint) continue
const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
if (pageType === 'MUSIC_PAGE_TYPE_ALBUM') { const runData = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text }
album = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text } switch (pageType) {
} else if (pageType === 'MUSIC_PAGE_TYPE_ARTIST') { case 'MUSIC_PAGE_TYPE_ALBUM':
const artist = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text } album = runData
artists ? artists.push(artist) : (artists = [artist]) break
} else if (pageType === 'MUSIC_PAGE_TYPE_USER_CHANNEL') { case 'MUSIC_PAGE_TYPE_ARTIST':
createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text } artists.push(runData)
break
case 'MUSIC_PAGE_TYPE_USER_CHANNEL':
creator = runData
break
} }
} }
const navigationEndpoint = cardContent.title.runs[0].navigationEndpoint const navigationEndpoint = cardContent.title.runs[0].navigationEndpoint
if ('watchEndpoint' in navigationEndpoint) { if ('watchEndpoint' in navigationEndpoint) {
const id = navigationEndpoint.watchEndpoint.videoId 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 pageType = navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType
const id = navigationEndpoint.browseEndpoint.browseId const id = navigationEndpoint.browseEndpoint.browseId
switch (pageType) { switch (pageType) {
case 'MUSIC_PAGE_TYPE_ALBUM': 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_ARTIST':
case 'MUSIC_PAGE_TYPE_USER_CHANNEL': 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': 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 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: * Valid URL origins:
* - https://lh3.googleusercontent.com * - https://lh3.googleusercontent.com
* - https://yt3.googleusercontent.com * - https://yt3.googleusercontent.com
* - https://yt3.ggpht.com * - https://yt3.ggpht.com
* - https://music.youtube.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') if (!URL.canParse(urlString)) throw new Error('Invalid thumbnail url')
switch (new URL(urlString).origin) { switch (new URL(urlString).origin) {
@@ -476,8 +642,10 @@ function refineThumbnailUrl(urlString: string): string {
return urlString.slice(0, urlString.indexOf('=')) return urlString.slice(0, urlString.indexOf('='))
case 'https://music.youtube.com': case 'https://music.youtube.com':
return urlString return urlString
default: // 'https://i.ytimg.com' cannot be manimulated with query params and as such is invalid case 'https://i.ytimg.com':
console.error(urlString) return urlString.slice(0, urlString.indexOf('?'))
default:
console.error('Tried to clean invalid url: ' + urlString)
throw new Error('Invalid thumbnail url origin') throw new Error('Invalid thumbnail url origin')
} }
} }
@@ -491,11 +659,21 @@ function formatDate(): string {
return year + month + day 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 // NOTE 1: Thumbnails
// When scraping thumbnails from the YTMusic browse pages, there are two different types of images that can be returned, // 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 // standard video thumbnais and auto-generated square thumbnails for propper releases. The auto-generated thumbanils we want to
// keep from the scrape because: // 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 // 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 // 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 name: string
type: 'song' type: 'song'
thumbnailUrl?: string thumbnailUrl?: string
artists: { artists?: {
id?: string id: string
name: string name: string
}[] }[]
album?: { album?: {
@@ -539,10 +717,12 @@ declare namespace InnerTube {
name: string name: string
type: 'album' type: 'album'
thumbnailUrl: string thumbnailUrl: string
artists?: { artists:
id: string | {
name: string id: string
}[] name: string
}[]
| 'Various Artists'
} }
type ScrapedArtist = { type ScrapedArtist = {
@@ -556,13 +736,169 @@ declare namespace InnerTube {
id: string id: string
name: string name: string
type: 'playlist' type: 'playlist'
thumbnailUrl: string // thumbnailUrl: string Need to figure out how I want to do playlists
createdBy: { createdBy: {
id: string id: string
name: 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 { interface AlbumResponse {
contents: { contents: {
singleColumnBrowseResultsRenderer: { singleColumnBrowseResultsRenderer: {
@@ -590,6 +926,19 @@ declare namespace InnerTube {
} }
} }
}> }>
fixedColumns: [
{
musicResponsiveListItemFixedColumnRenderer: {
text: {
runs: [
{
text: string
},
]
}
}
},
]
} }
}> }>
} }
@@ -612,6 +961,9 @@ declare namespace InnerTube {
] ]
} }
subtitle: { 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<{ runs: Array<{
text: string text: string
navigationEndpoint?: { 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: { thumbnail: {
croppedSquareThumbnailRenderer: musicThumbnailRenderer croppedSquareThumbnailRenderer: musicThumbnailRenderer
} }
@@ -776,7 +1135,7 @@ declare namespace InnerTube {
text: { text: {
runs: [ runs: [
{ {
text: 'Go to album' | 'Go to artist' | text: 'Go to album' | 'Go to artist'
}, },
] ]
} }
@@ -914,7 +1273,8 @@ declare namespace InnerTube {
playlistId: string playlistId: string
watchEndpointMusicSupportedConfigs: { watchEndpointMusicSupportedConfigs: {
watchEndpointMusicConfig: { 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?
} }
} }
} }

View File

@@ -27,14 +27,14 @@
if (searchResult.type === 'song') queueRef.current = searchResult if (searchResult.type === 'song') queueRef.current = searchResult
}} }}
class="grid aspect-square h-full place-items-center bg-cover bg-center bg-no-repeat" class="grid aspect-square h-full place-items-center bg-cover bg-center bg-no-repeat"
style="--thumbnail: url('/api/remoteImage?url={searchResult.thumbnail}')" style="--thumbnail: url('/api/remoteImage?url={'thumbnailUrl' in searchResult ? searchResult.thumbnailUrl : searchResult.profilePicture}')"
> >
<i class="fa-solid fa-play opacity-0" /> <i class="fa-solid fa-play opacity-0" />
</button> </button>
<div> <div>
<div>{searchResult.name}{searchResult.type === 'song' && searchResult.album?.name ? ` - ${searchResult.album.name}` : ''}</div> <div>{searchResult.name}{searchResult.type === 'song' && searchResult.album?.name ? ` - ${searchResult.album.name}` : ''}</div>
{#if 'artists' in searchResult && searchResult.artists} {#if 'artists' in searchResult && searchResult.artists}
<div>{searchResult.artists.map((artist) => artist.name).join(', ')}</div> <div>{searchResult.artists === 'Various Artists' ? searchResult.artists : searchResult.artists.map((artist) => artist.name).join(', ')}</div>
{:else if 'createdBy' in searchResult && searchResult.createdBy} {:else if 'createdBy' in searchResult && searchResult.createdBy}
<div>{searchResult.createdBy?.name}</div> <div>{searchResult.createdBy?.name}</div>
{/if} {/if}