diff --git a/src/app.d.ts b/src/app.d.ts index 609bb72..69977b7 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -52,61 +52,79 @@ declare global { // Big data should be fetched as needed in the app, these exist to ensure that the info necessary to fetch that data is there. type Song = { - connection: string + connection: { + id: string + type: 'jellyfin' | 'youtube-music' + } id: string name: string type: 'song' - duration?: number - thumbnail?: string - artists?: { + duration: number // Seconds + thumbnailUrl: string // Base/maxres url of song, any scaling for performance purposes will be handled by remoteImage endpoint + artists: { // Should try to order id: string name: string + profilePicture?: string }[] album?: { id: string name: string + thumbnailUrl: string } - createdBy?: { + isVideo: boolean + uploader?: { id: string name: string + profilePicture?: string } - releaseDate?: string + releaseDate: string // YYYY-MM-DD || YYYY-MM || YYYY } type Album = { - connection: string + connection: { + id: string + type: 'jellyfin' | 'youtube-music' + } id: string name: string - type: 'album' - duration?: number - thumbnail?: string - artists?: { - // Album Artists + type: 'song' + duration: number // Seconds + thumbnailUrl: string + artists: { // Should try to order id: string name: string - }[] - releaseDate?: string + profilePicture?: string + }[] | 'Various Artists' + releaseDate: string // YYYY-MM-DD || YYYY-MM || YYYY + length: number } // Need to figure out how to do Artists, maybe just query MusicBrainz? type Artist = { - connection: string + connection: { + id: string + type: 'jellyfin' | 'youtube-music' + } id: string name: string type: 'artist' - thumbnail?: string + profilePicture?: string } type Playlist = { - connection: string + connection: { + id: string + type: 'jellyfin' | 'youtube-music' + } id: string name: string type: 'playlist' + thumbnailUrl: string createdBy?: { id: string name: string + profilePicture?: string } - thumbnail?: string } } diff --git a/src/lib/components/util/loader.svelte b/src/lib/components/util/loader.svelte new file mode 100644 index 0000000..804d335 --- /dev/null +++ b/src/lib/components/util/loader.svelte @@ -0,0 +1,44 @@ + + + + + diff --git a/src/lib/server/youtube-music.ts b/src/lib/server/youtube-music.ts index d0c2f18..e9312e0 100644 --- a/src/lib/server/youtube-music.ts +++ b/src/lib/server/youtube-music.ts @@ -1,4 +1,4 @@ -import { google } 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' @@ -144,7 +144,7 @@ export class YouTubeMusic implements Connection { } public async getRecommendations(): Promise<(Song | Album | Artist | Playlist)[]> { - const browseResponse: InnerTube.BrowseResponse = await fetch(`https://music.youtube.com/youtubei/v1/browse`, { + const homeResponse: InnerTube.HomeResponse = await fetch(`https://music.youtube.com/youtubei/v1/browse`, { headers: await this.innertubeRequestHeaders, method: 'POST', body: JSON.stringify({ @@ -159,8 +159,12 @@ export class YouTubeMusic implements Connection { }), }).then((response) => response.json()) - const contents = browseResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents - console.log(JSON.stringify(contents)) + const contents = homeResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents + + const playlist = await google.youtube('v3').playlistItems.list({ playlistId: 'OLAK5uy_luC2CX2NPU_qVCVvCh4r4M2igAltIJ0Bc', part: ['snippet'], maxResults: 50, access_token: await this.accessToken }) + console.log(JSON.stringify(playlist)) + + return [] const recommendations: (Song | Album | Artist | Playlist)[] = [] const goodSections = ['Listen again', 'Forgotten favorites', 'Quick picks', 'From your library'] @@ -174,6 +178,13 @@ 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 + }) + + this.buildFullSongProfiles(scrapedSong) + return recommendations } @@ -185,51 +196,153 @@ export class YouTubeMusic implements Connection { return await fetch(format.url, { headers }) } -} -function parseTwoRowItemRenderer(connection: string, rowContent: InnerTube.musicTwoRowItemRenderer): Song | Album | Artist | Playlist { - const name = rowContent.title.runs[0].text - const thumbnail = refineThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url) + 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({ + browseId: id, + context: { + client: { + clientName: 'WEB_REMIX', + clientVersion: `1.${formatDate()}.01.00`, + hl: 'en', + }, + }, + }), + }).then((response) => response.json()) - let artists: (Song | Album)['artists'], createdBy: (Song | Playlist)['createdBy'] - for (const run of rowContent.subtitle.runs) { - if (!run.navigationEndpoint) continue - - const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType - 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 header = albumResponse.header.musicDetailHeaderRenderer + const contents = albumResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer.contents } - let album: Song['album'] - rowContent.menu.menuRenderer.items.forEach((menuOption) => { - if ( - 'menuNavigationItemRenderer' in menuOption && - 'browseEndpoint' in menuOption.menuNavigationItemRenderer.navigationEndpoint && - menuOption.menuNavigationItemRenderer.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ALBUM' - ) { - album = { id: menuOption.menuNavigationItemRenderer.navigationEndpoint.browseEndpoint.browseId, name: 'NEED TO FIND A WAY TO GET ALBUM NAME FROM ID' } - } - }) + public async getArtist(id: string): Promise {} + + public async getUser(id: string): Promise {} + + private async buildFullSongProfiles(scrapedSongs: ScrapedSong[]): Promise { + const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection'] + + const songIds = new Set(), + albumIds = new Set(), + artistIds = new Set(), + userIds = 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) + } + }) + + 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 [songDetails, albumDetails, artistDetails, userDetails] = await Promise.all([getSongDetails(), getAlbumDetails(), getArtistDetails(), getUserDetails()]) + const songDetailsMap = new Map(), + albumDetailsMap = new Map(), + artistDetailsMap = new Map(), + userDetailsMap = 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)) + + return scrapedSongs.map((song) => { + const songDetails = songDetailsMap.get(song.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! + + let album: Song['album'], + uploader: Song['uploader'], + releaseDate = new Date(songDetails.snippet?.publishedAt!).toLocaleDateString() + + 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 + } + 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 } + }) + } +} + +function parseTwoRowItemRenderer(rowContent: InnerTube.musicTwoRowItemRenderer): ScrapedSong | ScrapedAlbum | ScrapedArtist | ScrapedPlaylist { + const name = rowContent.title.runs[0].text if ('watchEndpoint' in rowContent.navigationEndpoint) { + let album: ScrapedSong['album'], + artists: ScrapedSong['artists'] = [], + uploader: ScrapedSong['uploader'] + rowContent.menu.menuRenderer.items.forEach((menuOption) => { + if ( + 'menuNavigationItemRenderer' in menuOption && + 'browseEndpoint' in menuOption.menuNavigationItemRenderer.navigationEndpoint && + menuOption.menuNavigationItemRenderer.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType === 'MUSIC_PAGE_TYPE_ALBUM' + ) { + album = { id: menuOption.menuNavigationItemRenderer.navigationEndpoint.browseEndpoint.browseId } + } + }) + rowContent.subtitle.runs.forEach((run) => { + if (!run.navigationEndpoint) return + + const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType + const runData = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text } + if (pageType === 'MUSIC_PAGE_TYPE_ARTIST') { + artists.push(runData) + } else if (pageType === 'MUSIC_PAGE_TYPE_USER_CHANNEL') { + uploader = runData + } + }) + + const isUserUploaded = rowContent.navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType === 'MUSIC_VIDEO_TYPE_UGC' + const thumbnailUrl: ScrapedSong['thumbnailUrl'] = isUserUploaded ? undefined : refineThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url) + const id = rowContent.navigationEndpoint.watchEndpoint.videoId - return { connection, id, name, type: 'song', artists, createdBy, thumbnail } satisfies Song + return { id, name, type: 'song', thumbnailUrl, album, artists, uploader, isVideo: isUserUploaded } satisfies ScrapedSong } const pageType = rowContent.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType + rowContent.menu.menuRenderer.items.forEach((menuItem) => { + if ('menuServiceItemRenderer' in menuItem) { + const queueTarget = menuItem.menuServiceItemRenderer.serviceEndpoint.queueAddEndpoint.queueTarget + if ('playlistId' in queueTarget) { + const thumbnailUrl = refineThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url) + if (pageType === 'MUSIC_PAGE_TYPE_ALBUM') { + const album = { id: queueTarget.playlistId, name, type: 'album', thumbnailUrl } satisfies ScrapedAlbum + } + } + } + }) + const id = rowContent.navigationEndpoint.browseEndpoint.browseId + const thumbnailUrl = refineThumbnailUrl(rowContent.thumbnailRenderer.musicThumbnailRenderer.thumbnail.thumbnails[0].url) switch (pageType) { case 'MUSIC_PAGE_TYPE_ALBUM': - return { connection, id, name, type: 'album', artists, thumbnail } satisfies Album + return { id, name, type: 'album', artists, thumbnailUrl } satisfies ScrapedAlbum case 'MUSIC_PAGE_TYPE_ARTIST': case 'MUSIC_PAGE_TYPE_USER_CHANNEL': - return { connection, id, name, type: 'artist', thumbnail } satisfies Artist + return { id, name, type: 'artist', profilePicture: thumbnailUrl } satisfies ScrapedArtist case 'MUSIC_PAGE_TYPE_PLAYLIST': - return { connection, id, name, type: 'playlist', createdBy, thumbnail } satisfies Playlist + return { id, name, type: 'playlist', createdBy, thumbnailUrl } satisfies ScrapedPlaylist } } @@ -312,20 +425,35 @@ function parseMusicCardShelfRenderer(connection: string, cardContent: InnerTube. } } +/** YouTube should (I haven't confirmed) cap duration scaling at days, so any duration will be in the following ISO8601 Format: PnDTnHnMnS */ +function secondsFromISO8601(duration: string): number { + const iso8601DurationRegex = /P(?:([.,\d]+)D)?T(?:([.,\d]+)H)?(?:([.,\d]+)M)?(?:([.,\d]+)S)?/ // Credit: https://stackoverflow.com/users/1195273/crush + const result = iso8601DurationRegex.exec(duration) + const days = result?.[1] ?? 0, + hours = result?.[2] ?? 0, + minutes = result?.[3] ?? 0, + seconds = result?.[4] ?? 0 + 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. + * Valid URL origins: + * - https://lh3.googleusercontent.com + * - https://yt3.googleusercontent.com + * - https://yt3.ggpht.com + * - https://music.youtube.com + */ function refineThumbnailUrl(urlString: string): string { if (!URL.canParse(urlString)) throw new Error('Invalid thumbnail url') - const url = new URL(urlString) - switch (url.origin) { - case 'https://i.ytimg.com': - return urlString.slice(0, urlString.indexOf('?')).replace('sddefault', 'mqdefault') + switch (new URL(urlString).origin) { case 'https://lh3.googleusercontent.com': case 'https://yt3.googleusercontent.com': case 'https://yt3.ggpht.com': return urlString.slice(0, urlString.indexOf('=')) case 'https://music.youtube.com': return urlString - default: + default: // 'https://i.ytimg.com' cannot be manimulated with query params and as such is invalid console.error(urlString) throw new Error('Invalid thumbnail url origin') } @@ -340,7 +468,141 @@ function formatDate(): string { return year + month + day } +// NOTE 1: +// 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) +// 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 player +// and there is no easy way scale them due to the fixed sizes (default, medium, high, standard, maxres) without any way to determine if a higher quality exists. +// Therefor, these thumbanils should be fetched from the youtube data api and the highest res should be chosen. In the remoteImage endpoint this his res can +// be scaled to the desired resolution with image processing. +type ScrapedSong = { + id: string + name: string + type: 'song' + thumbnailUrl: string + artists: { + id: string + name?: string + }[] + album?: { + id: string + name?: string + } + uploader?: { + id: string + name?: string + } + isVideo: boolean +} + +type ScrapedAlbum = { + id: string + name: string + type: 'album' + thumbnailUrl: string + artists: + | { + id: string + name?: string + }[] + | 'Various Artists' + releaseYear?: string + length?: number +} + +type ScrapedArtist = { + id: string + name: string + type: 'artist' + profilePicture?: string +} + +type ScrapedPlaylist = { + id: string + name: string + type: 'playlist' + thumbnailUrl: string + createdBy?: { + id: string + name?: string + } +} + +type ScrapedUser = { + id: string + name: string + type: 'user' + profilePicture?: string +} + declare namespace InnerTube { + interface AlbumResponse { + contents: { + singleColumnBrowseResultsRenderer: { + tabs: [ + { + tabRenderer: { + content: { + sectionListRenderer: { + contents: [ + { + musicShelfRenderer: { + contents: Array<{ + musicResponsiveListItemRenderer: { + flexColumns: Array<{ + musicResponsiveListItemFlexColumnRenderer: { + text: { + runs?: [ + { + text: string + navigationEndpoint?: { + watchEndpoint: watchEndpoint + } + }, + ] + } + } + }> + } + }> + } + }, + ] + } + } + } + }, + ] + } + } + header: { + musicDetailHeaderRenderer: { + title: { + runs: [ + { + text: string + }, + ] + } + subtitle: { + runs: Array<{ + text: string + navigationEndpoint?: { + browseEndpoint: browseEndpoint + } + }> + } + thumbnail: { + croppedSquareThumbnailRenderer: musicThumbnailRenderer + } + } + } + } + interface SearchResponse { contents: { tabbedSearchResultsRenderer: { @@ -416,7 +678,7 @@ declare namespace InnerTube { }> } - interface BrowseResponse { + interface HomeResponse { contents: { singleColumnBrowseResultsRenderer: { tabs: [ @@ -483,7 +745,7 @@ declare namespace InnerTube { | { browseEndpoint: browseEndpoint } - menu: { + menu?: { menuRenderer: { items: Array< | { @@ -511,7 +773,26 @@ declare namespace InnerTube { } } | { - menuServiceItemRenderer: unknown + menuServiceItemRenderer: { + text: { + runs: [ + { + text: 'Play next' | 'Add to queue' + }, + ] + } + serviceEndpoint: { + queueAddEndpoint: { + queueTarget: + | { + playlistId: string + } + | { + videoId: string + } + } + } + } } | { toggleMenuServiceItemRenderer: unknown @@ -626,7 +907,7 @@ declare namespace InnerTube { playlistId: string watchEndpointMusicSupportedConfigs: { watchEndpointMusicConfig: { - musicVideoType: 'MUSIC_VIDEO_TYPE_UGC' | 'MUSIC_VIDEO_TYPE_ATV' + musicVideoType: 'MUSIC_VIDEO_TYPE_UGC' | 'MUSIC_VIDEO_TYPE_ATV' // UGC Means it is a user-uploaded video, ATV means it is auto-generated } } } diff --git a/src/routes/(app)/+page.svelte b/src/routes/(app)/+page.svelte index e80a7d2..9205dda 100644 --- a/src/routes/(app)/+page.svelte +++ b/src/routes/(app)/+page.svelte @@ -1,18 +1,15 @@
- {#await data.recommendations then recommendations} + {#await data.recommendations} + + {:then recommendations} {/await} -
diff --git a/src/routes/(app)/user/+page.svelte b/src/routes/(app)/user/+page.svelte index 481e77c..58df39f 100644 --- a/src/routes/(app)/user/+page.svelte +++ b/src/routes/(app)/user/+page.svelte @@ -29,13 +29,14 @@ $newestAlert = ['caution', 'All fields must be filled out'] return cancel() } - try { - formData.set('serverUrl', new URL(serverUrl.toString()).origin) - } catch { + + if (!URL.canParse(serverUrl.toString())) { $newestAlert = ['caution', 'Server URL is invalid'] return cancel() } + formData.set('serverUrl', new URL(serverUrl.toString()).origin) + const deviceId = getDeviceUUID() formData.append('deviceId', deviceId)