()
+ 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)