diff --git a/src/app.d.ts b/src/app.d.ts index b714c36..db47483 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -56,15 +56,12 @@ declare global { id: string type: 'jellyfin' | 'youtube-music' } - ids: { - connection: string - musicBrainz?: string - } + id: string name: string type: 'song' duration: number // Seconds 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 id: string name: string @@ -73,7 +70,7 @@ declare global { album?: { id: string name: string - thumbnailUrl: string + thumbnailUrl?: string } uploader?: { id: string @@ -88,20 +85,18 @@ declare global { id: string type: 'jellyfin' | 'youtube-music' } - ids: { - connection: string - musicBrainz?: string - } + id: string name: string type: 'album' + duration?: number // Seconds thumbnailUrl: string artists: { // Should try to order id: string name: string profilePicture?: string }[] | 'Various Artists' - releaseDate: string // YYYY-MM-DD || YYYY-MM || YYYY - length: number + releaseDate?: string // ISOString + length?: number } // Need to figure out how to do Artists, maybe just query MusicBrainz? @@ -110,16 +105,13 @@ declare global { id: string type: 'jellyfin' | 'youtube-music' } - ids: { - connection: string - musicBrainz?: string - } + id: string name: string type: 'artist' 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: { id: string type: 'jellyfin' | 'youtube-music' @@ -127,12 +119,14 @@ declare global { id: string name: string type: 'playlist' + duration: number thumbnailUrl: string createdBy?: { id: string name: string profilePicture?: string } + length: number } } diff --git a/src/lib/components/media/mediaCard.svelte b/src/lib/components/media/mediaCard.svelte index 9cf70a8..14e2ec1 100644 --- a/src/lib/components/media/mediaCard.svelte +++ b/src/lib/components/media/mediaCard.svelte @@ -12,13 +12,13 @@
{mediaItem.name}
-
- {#if 'artists' in mediaItem && mediaItem.artists} - {#each mediaItem.artists as artist} - {@const listIndex = mediaItem.artists.indexOf(artist)} - {artist.name} - {#if listIndex < mediaItem.artists.length - 1} - , - {/if} - {/each} +
+ {#if 'artists' in mediaItem && mediaItem.artists && mediaItem.artists.length > 0} + {#if mediaItem.artists === 'Various Artists'} + Various Artists + {:else} + {#each mediaItem.artists as artist} + {@const listIndex = mediaItem.artists.indexOf(artist)} + {artist.name} + {#if listIndex < mediaItem.artists.length - 1} + , + {/if} + {/each} + {/if} + {:else if 'uploader' in mediaItem && mediaItem.uploader} + {mediaItem.uploader.name} {:else if 'createdBy' in mediaItem && mediaItem.createdBy} - {mediaItem.createdBy.name} + {mediaItem.createdBy.name} {/if}
diff --git a/src/lib/components/media/mediaPlayer.svelte b/src/lib/components/media/mediaPlayer.svelte index 79344d9..15dd738 100644 --- a/src/lib/components/media/mediaPlayer.svelte +++ b/src/lib/components/media/mediaPlayer.svelte @@ -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 @@
{#key currentlyPlaying} -
+
{/key}
{currentlyPlaying.name}
-
{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') || currentlyPlaying.createdBy?.name}
+
{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') || currentlyPlaying.uploader?.name}
@@ -148,11 +148,11 @@
{:else} -
+
{#key currentlyPlaying} - + {/key}
@@ -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)]'}" >
{index + 1}
- +
{item.name}
-
{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') || currentlyPlaying.createdBy?.name}
+
{currentlyPlaying.artists?.map((artist) => artist.name).join(', ') || currentlyPlaying.uploader?.name}
- {item.duration ?? ''} + {formatTime(item.duration)} {/each}
@@ -197,7 +197,7 @@
{currentlyPlaying.name}
- {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}` : ''}
@@ -237,7 +237,7 @@
{/if} -
{/if} diff --git a/src/lib/server/musicBrainz.ts b/src/lib/server/musicBrainz.ts index 84e4b9e..5a8a643 100644 --- a/src/lib/server/musicBrainz.ts +++ b/src/lib/server/musicBrainz.ts @@ -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 { 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 { diff --git a/src/lib/server/youtube-music.ts b/src/lib/server/youtube-music.ts index 60677d1..7fbf508 100644 --- a/src/lib/server/youtube-music.ts +++ b/src/lib/server/youtube-music.ts @@ -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 { @@ -209,8 +192,8 @@ export class YouTubeMusic implements Connection { return await fetch(format.url, { headers }) } - public async getAlbum(id: string): Promise { - const albumResponse: InnerTube.AlbumResponse = await fetch(`https://music.youtube.com/youtubei/v1/browse`, { + public async getAlbum(id: string): Promise { + 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 {} + public async getPlaylist(id: string): Promise { + 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 {} + const header = + 'musicEditablePlaylistDetailHeaderRenderer' in playlistResponse.header + ? playlistResponse.header.musicEditablePlaylistDetailHeaderRenderer.header.musicDetailHeaderRenderer + : playlistResponse.header.musicDetailHeaderRenderer - private async buildFullSongProfiles(scrapedSongs: InnerTube.Home.ScrapedSong[]): Promise { + 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 { + // 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 { + 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(), albumIds = new Set(), artistIds = new Set(), - userIds = new Set() + playlistIds = new Set() - 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(), - albumDetailsMap = new Map(), - artistDetailsMap = new Map(), - userDetailsMap = new Map() + albumDetailsMap = new Map(), + playlistDetailsMap = new Map() + 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 + 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? } } } diff --git a/src/routes/(app)/search/+page.svelte b/src/routes/(app)/search/+page.svelte index 1b2a5f0..1af1e4d 100644 --- a/src/routes/(app)/search/+page.svelte +++ b/src/routes/(app)/search/+page.svelte @@ -27,14 +27,14 @@ if (searchResult.type === 'song') queueRef.current = searchResult }} 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}')" >
{searchResult.name}{searchResult.type === 'song' && searchResult.album?.name ? ` - ${searchResult.album.name}` : ''}
{#if 'artists' in searchResult && searchResult.artists} -
{searchResult.artists.map((artist) => artist.name).join(', ')}
+
{searchResult.artists === 'Various Artists' ? searchResult.artists : searchResult.artists.map((artist) => artist.name).join(', ')}
{:else if 'createdBy' in searchResult && searchResult.createdBy}
{searchResult.createdBy?.name}
{/if}