From a98b258a03f62f802bfbf5747db3ac9fc05b0d7d Mon Sep 17 00:00:00 2001 From: Eclypsed Date: Wed, 29 May 2024 01:25:17 -0400 Subject: [PATCH] Updated Jellyfin media item parsers, added startIndex && limit params to YTMusic getPlaylistItems() method --- src/app.d.ts | 13 +- src/lib/server/jellyfin.ts | 112 ++++---- src/lib/server/youtube-music-types.d.ts | 26 +- src/lib/server/youtube-music.ts | 245 ++++++++++-------- .../(app)/details/playlist/+page.svelte | 24 ++ src/routes/(app)/details/playlist/+page.ts | 22 ++ src/routes/(app)/search/+page.svelte | 56 +--- src/routes/api/audio/+server.ts | 2 +- .../[connectionId]/playlist/+server.ts | 11 +- .../playlist/[playlistId]/items/+server.ts | 22 +- 10 files changed, 303 insertions(+), 230 deletions(-) create mode 100644 src/routes/(app)/details/playlist/+page.svelte create mode 100644 src/routes/(app)/details/playlist/+page.ts diff --git a/src/app.d.ts b/src/app.d.ts index 4ced474..d433ea6 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -59,7 +59,7 @@ declare global { * @param headers The request headers sent by the Lazuli client that need to be relayed to the connection's request to the server (e.g. 'range'). * @returns A promise of response object containing the audio stream for the specified byte range * - * Fetches the audio stream for a song. + * Fetches the audio stream for a song. Will return an response containing the audio stream if the fetch was successfull, otherwise throw an error. */ getAudioStream: (id: string, headers: Headers) => Promise @@ -83,9 +83,11 @@ declare global { /** * @param id The id of a playlist + * @param startIndex The index to start at (0 based). All playlist items with a lower index will be dropped from the results + * @param limit The maximum number of playlist items to return * @returns A promise of the songs in the playlist as and array of Song objects */ - getPlaylistItems: (id: string) => Promise + getPlaylistItems: (id: string, startIndex?: number, limit?: number) => Promise } // These Schemas should only contain general info data that is necessary for data fetching purposes. @@ -105,21 +107,18 @@ declare global { 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 // ISOString + releaseDate?: string // ISOString artists?: { // Should try to order id: string name: string - profilePicture?: string }[] album?: { id: string name: string - thumbnailUrl?: string } uploader?: { id: string name: string - profilePicture?: string } isVideo: boolean } @@ -137,7 +136,6 @@ declare global { artists: { // Should try to order id: string name: string - profilePicture?: string }[] | 'Various Artists' releaseYear?: string // #### } @@ -166,7 +164,6 @@ declare global { createdBy?: { id: string name: string - profilePicture?: string } } diff --git a/src/lib/server/jellyfin.ts b/src/lib/server/jellyfin.ts index a76e6d4..f54257d 100644 --- a/src/lib/server/jellyfin.ts +++ b/src/lib/server/jellyfin.ts @@ -1,5 +1,7 @@ import { PUBLIC_VERSION } from '$env/static/public' +const jellyfinLogo = 'https://raw.githubusercontent.com/jellyfin/jellyfin-ux/55616553b692b1a6c7d8e786eeb7d8216e9b50df/branding/SVG/icon-transparent.svg' + export class Jellyfin implements Connection { public readonly id: string private readonly userId: string @@ -19,22 +21,21 @@ export class Jellyfin implements Connection { this.authHeader = new Headers({ Authorization: `MediaBrowser Token="${this.accessToken}"` }) } - public getConnectionInfo = async (): Promise> => { + public async getConnectionInfo() { const userUrl = new URL(`Users/${this.jfUserId}`, this.serverUrl) const systemUrl = new URL('System/Info', this.serverUrl) - const userData: JellyfinAPI.User | undefined = await fetch(userUrl, { headers: this.authHeader }) - .then((response) => response.json()) - .catch(() => { - console.error(`Fetch to ${userUrl.toString()} failed`) - return undefined - }) - const systemData: JellyfinAPI.System | undefined = await fetch(systemUrl, { headers: this.authHeader }) - .then((response) => response.json()) - .catch(() => { - console.error(`Fetch to ${systemUrl.toString()} failed`) - return undefined - }) + const userData = await fetch(userUrl, { headers: this.authHeader }) + .then((response) => response.json() as Promise) + .catch(() => null) + + if (!userData) console.error(`Fetch to ${userUrl.toString()} failed`) + + const systemData = await fetch(systemUrl, { headers: this.authHeader }) + .then((response) => response.json() as Promise) + .catch(() => null) + + if (!systemData) console.error(`Fetch to ${systemUrl.toString()} failed`) return { id: this.id, @@ -44,10 +45,10 @@ export class Jellyfin implements Connection { serverName: systemData?.ServerName, jellyfinUserId: this.jfUserId, username: userData?.Name, - } + } satisfies ConnectionInfo } - public getRecommendations = async (): Promise<(Song | Album | Playlist)[]> => { + public async getRecommendations(): Promise<(Song | Album | Artist | Playlist)[]> { const searchParams = new URLSearchParams({ SortBy: 'PlayCount', SortOrder: 'Descending', @@ -60,35 +61,43 @@ export class Jellyfin implements Connection { const mostPlayed: { Items: JellyfinAPI.Song[] } = await fetch(mostPlayedSongsURL, { headers: this.authHeader }).then((response) => response.json()) - return Array.from(mostPlayed.Items, (song) => this.parseSong(song)) + return mostPlayed.Items.map((song) => this.parseSong(song)) } - public search = async (searchTerm: string, filter?: 'song' | 'album' | 'artist' | 'playlist'): Promise<(Song | Album | Playlist)[]> => { + public async search(searchTerm: string, filter: 'song'): Promise + public async search(searchTerm: string, filter: 'album'): Promise + public async search(searchTerm: string, filter: 'artist'): Promise + public async search(searchTerm: string, filter: 'playlist'): Promise + public async search(searchTerm: string, filter?: undefined): Promise<(Song | Album | Artist | Playlist)[]> + public async search(searchTerm: string, filter?: 'song' | 'album' | 'artist' | 'playlist'): Promise<(Song | Album | Artist | Playlist)[]> { + const filterMap = { song: 'Audio', album: 'MusicAlbum', artist: 'MusicArtist', playlist: 'Playlist' } as const + const searchParams = new URLSearchParams({ searchTerm, - includeItemTypes: 'Audio,MusicAlbum,Playlist', // Potentially add MusicArtist + includeItemTypes: filter ? filterMap[filter] : Object.values(filterMap).join(','), recursive: 'true', }) const searchURL = new URL(`Users/${this.jfUserId}/Items?${searchParams.toString()}`, this.serverUrl) const searchResponse = await fetch(searchURL, { headers: this.authHeader }) if (!searchResponse.ok) throw new JellyfinFetchError('Failed to search Jellyfin', searchResponse.status, searchURL.toString()) - const searchResults = (await searchResponse.json()).Items as (JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Playlist)[] // JellyfinAPI.Artist + const searchResults = (await searchResponse.json()).Items as (JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist)[] - const parsedResults: (Song | Album | Playlist)[] = Array.from(searchResults, (result) => { + return searchResults.map((result) => { switch (result.Type) { case 'Audio': return this.parseSong(result) case 'MusicAlbum': return this.parseAlbum(result) + case 'MusicArtist': + return this.parseArtist(result) case 'Playlist': return this.parsePlaylist(result) } }) - return parsedResults } - public getAudioStream = async (id: string, range: string | null): Promise => { + public getAudioStream = async (id: string, headers: Headers): Promise => { const audoSearchParams = new URLSearchParams({ MaxStreamingBitrate: '2000000', Container: 'opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg', @@ -100,70 +109,71 @@ export class Jellyfin implements Connection { const audioUrl = new URL(`Audio/${id}/universal?${audoSearchParams.toString()}`, this.serverUrl) - const headers = new Headers(this.authHeader) - headers.set('range', range || '0-') - - return await fetch(audioUrl, { headers }) + return fetch(audioUrl, { headers }) } private parseSong = (song: JellyfinAPI.Song): Song => { - const thumbnail = song.ImageTags?.Primary + const thumbnailUrl = song.ImageTags?.Primary ? new URL(`Items/${song.Id}/Images/Primary`, this.serverUrl).toString() : song.AlbumPrimaryImageTag ? new URL(`Items/${song.AlbumId}/Images/Primary`, this.serverUrl).toString() - : undefined + : jellyfinLogo - const artists: Song['artists'] = song.ArtistItems - ? Array.from(song.ArtistItems, (artist) => { - return { id: artist.Id, name: artist.Name } - }) - : undefined + const artists: Song['artists'] = song.ArtistItems?.map((artist) => ({ id: artist.Id, name: artist.Name })) const album: Song['album'] = song.AlbumId && song.Album ? { id: song.AlbumId, name: song.Album } : undefined return { - connection: this.id, - type: 'song', + connection: { id: this.id, type: 'jellyfin' }, id: song.Id, name: song.Name, + type: 'song', duration: ticksToSeconds(song.RunTimeTicks), - thumbnail, + thumbnailUrl, + releaseDate: song.ProductionYear ? new Date(song.ProductionYear.toString()).toISOString() : undefined, artists, album, - releaseDate: song.ProductionYear?.toString(), + isVideo: false, } } private parseAlbum = (album: JellyfinAPI.Album): Album => { - const thumbnail = album.ImageTags?.Primary ? new URL(`Items/${album.Id}/Images/Primary`, this.serverUrl).toString() : undefined + const thumbnailUrl = album.ImageTags?.Primary ? new URL(`Items/${album.Id}/Images/Primary`, this.serverUrl).toString() : jellyfinLogo - const artists: Album['artists'] = album.AlbumArtists - ? Array.from(album.AlbumArtists, (artist) => { - return { id: artist.Id, name: artist.Name } - }) - : undefined + const artists: Album['artists'] = album.AlbumArtists?.map((artist) => ({ id: artist.Id, name: artist.Name })) ?? 'Various Artists' return { - connection: this.id, - type: 'album', + connection: { id: this.id, type: 'jellyfin' }, id: album.Id, name: album.Name, - duration: ticksToSeconds(album.RunTimeTicks), - thumbnail, + type: 'album', + thumbnailUrl, artists, - releaseDate: album.ProductionYear?.toString(), + releaseYear: album.ProductionYear?.toString(), + } + } + + private parseArtist(artist: JellyfinAPI.Artist): Artist { + const profilePicture = artist.ImageTags?.Primary ? new URL(`Items/${artist.Id}/Images/Primary`, this.serverUrl).toString() : jellyfinLogo + + return { + connection: { id: this.id, type: 'jellyfin' }, + id: artist.Id, + name: artist.Name, + type: 'artist', + profilePicture, } } private parsePlaylist = (playlist: JellyfinAPI.Playlist): Playlist => { - const thumbnail = playlist.ImageTags?.Primary ? new URL(`Items/${playlist.Id}/Images/Primary`, this.serverUrl).toString() : undefined + const thumbnailUrl = playlist.ImageTags?.Primary ? new URL(`Items/${playlist.Id}/Images/Primary`, this.serverUrl).toString() : jellyfinLogo return { - connection: this.id, + connection: { id: this.id, type: 'jellyfin' }, id: playlist.Id, name: playlist.Name, type: 'playlist', - thumbnail, + thumbnailUrl, } } diff --git a/src/lib/server/youtube-music-types.d.ts b/src/lib/server/youtube-music-types.d.ts index d3d3eb2..f35c11e 100644 --- a/src/lib/server/youtube-music-types.d.ts +++ b/src/lib/server/youtube-music-types.d.ts @@ -100,6 +100,14 @@ export namespace InnerTube { } } + interface PlaylistErrorResponse { + error: { + code: number + message: string + status: string + } + } + interface ContinuationResponse { continuationContents: { musicPlaylistShelfContinuation: ContentShelf @@ -339,10 +347,22 @@ export namespace InnerTube { } namespace Player { - interface PlayerResponse { + type PlayerResponse = { + playabilityStatus: { + status: 'OK' + } streamingData: { - formats: Format[] - adaptiveFormats: Format[] + formats?: Format[] + adaptiveFormats?: Format[] + dashManifestUrl?: string + hlsManifestUrl?: string + } + } + + interface PlayerErrorResponse { + playabilityStatus: { + status: 'ERROR' + reason: string } } diff --git a/src/lib/server/youtube-music.ts b/src/lib/server/youtube-music.ts index 6e9f868..20dac58 100644 --- a/src/lib/server/youtube-music.ts +++ b/src/lib/server/youtube-music.ts @@ -55,42 +55,50 @@ export class YouTubeMusic implements Connection { this.expiry = expiry } - // TODO: Need to figure out a way to prevent this from this refresh the access token twice in the event that it is requested again while awaiting the first refreshed token + private accessTokenRefreshRequest: Promise | null = null private get accessToken() { - return (async () => { - const refreshTokens = async (): Promise<{ accessToken: string; expiry: number }> => { - const MAX_TRIES = 3 - let tries = 0 - const refreshDetails = { client_id: PUBLIC_YOUTUBE_API_CLIENT_ID, client_secret: YOUTUBE_API_CLIENT_SECRET, refresh_token: this.refreshToken, grant_type: 'refresh_token' } + const refreshAccessToken = async () => { + const MAX_TRIES = 3 + let tries = 0 + const refreshDetails = { client_id: PUBLIC_YOUTUBE_API_CLIENT_ID, client_secret: YOUTUBE_API_CLIENT_SECRET, refresh_token: this.refreshToken, grant_type: 'refresh_token' } - while (tries < MAX_TRIES) { - ++tries - const response = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - body: JSON.stringify(refreshDetails), - }).catch((reason) => { - console.error(`Fetch to refresh endpoint failed: ${reason}`) - return null - }) - if (!response || !response.ok) continue + while (tries < MAX_TRIES) { + ++tries + const response = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + body: JSON.stringify(refreshDetails), + }).catch((reason) => { + console.error(`Fetch to refresh endpoint failed: ${reason}`) + return null + }) + if (!response || !response.ok) continue - const { access_token, expires_in } = await response.json() - const expiry = Date.now() + expires_in * 1000 - return { accessToken: access_token, expiry } - } - - throw new Error(`Failed to refresh access tokens for YouTube Music connection: ${this.id}`) + const { access_token, expires_in } = await response.json() + const expiry = Date.now() + expires_in * 1000 + return { accessToken: access_token as string, expiry } } - if (this.expiry < Date.now()) { - const { accessToken, expiry } = await refreshTokens() + throw Error(`Failed to refresh access tokens for YouTube Music connection: ${this.id}`) + } + + if (this.expiry > Date.now()) return new Promise((resolve) => resolve(this.currentAccessToken)) + + if (this.accessTokenRefreshRequest) return this.accessTokenRefreshRequest + + this.accessTokenRefreshRequest = refreshAccessToken() + .then(({ accessToken, expiry }) => { DB.updateTokens(this.id, { accessToken, refreshToken: this.refreshToken, expiry }) this.currentAccessToken = accessToken this.expiry = expiry - } + this.accessTokenRefreshRequest = null + return accessToken + }) + .catch((error: Error) => { + this.accessTokenRefreshRequest = null + throw error + }) - return this.currentAccessToken - })() + return this.accessTokenRefreshRequest } public async getConnectionInfo() { @@ -110,11 +118,6 @@ export class YouTubeMusic implements Connection { private async ytMusicv1ApiRequest(requestDetails: ytMusicv1ApiRequestParams) { const headers = new Headers({ 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0', - accept: '*/*', - 'accept-encoding': 'gzip, deflate', - 'content-type': 'application/json', - 'content-encoding': 'gzip', - origin: 'https://music.youtube.com', authorization: `Bearer ${await this.accessToken}`, }) @@ -127,40 +130,35 @@ export class YouTubeMusic implements Connection { client: { clientName: 'WEB_REMIX', clientVersion: `1.${year + month + day}.01.00`, - hl: 'en', }, } - const fetchData = (): [URL, Object] => { - switch (requestDetails.type) { - case 'browse': - return [ - new URL('https://music.youtube.com/youtubei/v1/browse'), - { - browseId: requestDetails.browseId, - context, - }, - ] - case 'search': - return [ - new URL('https://music.youtube.com/youtubei/v1/search'), - { - query: requestDetails.searchTerm, - filter: requestDetails.filter ? searchFilterParams[requestDetails.filter] : undefined, - context, - }, - ] - case 'continuation': - return [ - new URL(`https://music.youtube.com/youtubei/v1/browse?ctoken=${requestDetails.ctoken}&continuation=${requestDetails.ctoken}`), - { - context, - }, - ] - } - } + let url: string + let body: Record - const [url, body] = fetchData() + switch (requestDetails.type) { + case 'browse': + url = 'https://music.youtube.com/youtubei/v1/browse' + body = { + browseId: requestDetails.browseId, + context, + } + break + case 'search': + url = 'https://music.youtube.com/youtubei/v1/search' + body = { + query: requestDetails.searchTerm, + filter: requestDetails.filter ? searchFilterParams[requestDetails.filter] : undefined, + context, + } + break + case 'continuation': + url = `https://music.youtube.com/youtubei/v1/browse?ctoken=${requestDetails.ctoken}&continuation=${requestDetails.ctoken}` + body = { + context, + } + break + } return fetch(url, { headers, method: 'POST', body: JSON.stringify(body) }) } @@ -238,29 +236,23 @@ export class YouTubeMusic implements Connection { } public async getAudioStream(id: string, headers: Headers): Promise { - if (!/^[a-zA-Z0-9-_]{11}$/.test(id)) throw TypeError('Invalid youtube video ID') + if (!/^[a-zA-Z0-9-_]{11}$/.test(id)) throw TypeError('Invalid youtube video Id') - // ? In the future, may want to implement the original web client method both in order to bypass age-restrictions and just to serve as a fallback - // ? However this has the downsides of being slower and requiring the user's cookies if the video is premium exclusive. + // ? In the future, may want to implement the TVHTML5_SIMPLY_EMBEDDED_PLAYER client method both in order to bypass age-restrictions and just to serve as a fallback + // ? However this has the downsides of being slower and (I think) requiring the user's cookies if the video is premium exclusive. // ? Ideally, I want to avoid having to mess with a user's cookies at all costs because: // ? a) It's another security risk // ? b) A user would have to manually copy them over, which is about as user friendly as a kick to the face // ? c) Cookies get updated with every request, meaning the db would get hit more frequently, and it's just another thing to maintain // ? Ulimately though, I may have to implment cookie support anyway dependeding on how youtube tracks a user's watch history and prefrences - // * MASSIVE props and credit to Oleksii Holub (https://github.com/Tyrrrz) for documenting the android client method of player fetching: https://tyrrrz.me/blog/reverse-engineering-youtube-revisited. + // * MASSIVE props and credit to Oleksii Holub for documenting the android client method of player fetching (See refrences at bottom). // * Go support him and go support Ukraine (he's Ukrainian) - // TODO: Differentiate errors thrown by the player fetch and handle them respectively, rather than just a global catch. (Throw TypeError if the request contained an invalid videoId) const playerResponse = await fetch('https://www.youtube.com/youtubei/v1/player', { headers: { - 'user-agent': 'com.google.android.youtube/17.36.4 (Linux; U; Android 12; GB) gzip', - accept: '*/*', - 'accept-encoding': 'gzip, deflate', - 'content-type': 'application/json', - 'content-encoding': 'gzip', - origin: 'https://music.youtube.com', - authorization: `Bearer ${await this.accessToken}`, + // 'user-agent': 'com.google.android.youtube/17.36.4 (Linux; U; Android 12; GB) gzip', <-- I thought this was necessary but it appears it might not be? + authorization: `Bearer ${await this.accessToken}`, // * Including the access token is what enables access to premium content }, method: 'POST', body: JSON.stringify({ @@ -269,33 +261,39 @@ export class YouTubeMusic implements Connection { client: { clientName: 'ANDROID_TESTSUITE', clientVersion: '1.9', - androidSdkVersion: 30, - hl: 'en', - gl: 'US', - utcOffsetMinutes: 0, + // androidSdkVersion: 30, <-- I thought this was necessary but it appears it might not be? }, }, }), }) - .then((response) => response.json() as Promise) + .then((response) => response.json() as Promise) .catch(() => null) if (!playerResponse) throw Error(`Failed to fetch player for song ${id} of connection ${this.id}`) - const audioOnlyFormats = playerResponse.streamingData.formats.concat(playerResponse.streamingData.adaptiveFormats).filter( + if (!('streamingData' in playerResponse)) { + if (playerResponse.playabilityStatus.reason === 'This video is unavailable') throw TypeError('Invalid youtube video Id') + + const errorMessage = `Unknown player response error: ${playerResponse.playabilityStatus.reason}` + console.error(errorMessage) + throw Error(errorMessage) + } + + const formats = playerResponse.streamingData.formats?.concat(playerResponse.streamingData.adaptiveFormats ?? []) + const audioOnlyFormats = formats?.filter( (format): format is HasDefinedProperty => format.qualityLabel === undefined && format.audioQuality !== undefined && format.url !== undefined && !/\bsource[/=]yt_live_broadcast\b/.test(format.url) && // Filters out live broadcasts - !/\/manifest\/hls_(variant|playlist)\//.test(format.url) && // Filters out HLS streams - !/\/manifest\/dash\//.test(format.url), // Filters out DashMPD streams + !/\/manifest\/hls_(variant|playlist)\//.test(format.url) && // Filters out HLS streams (Might not be applicable to the ANDROID_TESTSUITE client) + !/\/manifest\/dash\//.test(format.url), // Filters out DashMPD streams (Might not be applicable to the ANDROID_TESTSUITE client) // ? For each of the three above filters, I may want to look into how to support them. // ? Especially live streams, being able to support those live music stream channels seems like a necessary feature. - // ? HLS and DashMPD I *think* are more efficient so it would be nice to support those too. + // ? HLS and DashMPD I *think* are more efficient so it would be nice to support those too, if applicable. ) - if (audioOnlyFormats.length === 0) throw Error(`No valid audio formats returned for song ${id} of connection ${this.id}`) + if (!audioOnlyFormats || audioOnlyFormats.length === 0) throw Error(`No valid audio formats returned for song ${id} of connection ${this.id}`) const hqAudioFormat = audioOnlyFormats.reduce((previous, current) => (previous.bitrate > current.bitrate ? previous : current)) @@ -401,7 +399,19 @@ export class YouTubeMusic implements Connection { * @param id The id of the playlist (not the browseId!). */ public async getPlaylist(id: string): Promise { - const playlistResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: 'VL'.concat(id) }).then((response) => response.json())) as InnerTube.Playlist.PlaylistResponse + const playlistResponse = await this.ytMusicv1ApiRequest({ type: 'browse', browseId: 'VL'.concat(id) }) + .then((response) => response.json() as Promise) + .catch(() => null) + + if (!playlistResponse) throw Error(`Failed to fetch playlist ${id} of connection ${this.id}`) + + if ('error' in playlistResponse) { + if (playlistResponse.error.status === 'NOT_FOUND' || playlistResponse.error.status === 'INVALID_ARGUMENT') throw TypeError('Invalid youtube playlist id') + + const errorMessage = `Unknown playlist response error: ${playlistResponse.error.message}` + console.error(errorMessage) + throw Error(errorMessage) + } const header = 'musicEditablePlaylistDetailHeaderRenderer' in playlistResponse.header @@ -425,27 +435,42 @@ export class YouTubeMusic implements Connection { /** * @param id The id of the playlist (not the browseId!). + * @param startIndex The index to start at (0 based). All playlist items with a lower index will be dropped from the results + * @param limit The maximum number of playlist items to return */ - // TODO: Add startIndex and length parameters - public async getPlaylistItems(id: string): Promise { - const playlistResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: 'VL'.concat(id) }).then((response) => response.json())) as InnerTube.Playlist.PlaylistResponse + public async getPlaylistItems(id: string, startIndex?: number, limit?: number): Promise { + const playlistResponse = await this.ytMusicv1ApiRequest({ type: 'browse', browseId: 'VL'.concat(id) }) + .then((response) => response.json() as Promise) + .catch(() => null) + + if (!playlistResponse) throw Error(`Failed to fetch playlist ${id} of connection ${this.id}`) + + if ('error' in playlistResponse) { + if (playlistResponse.error.status === 'NOT_FOUND' || playlistResponse.error.status === 'INVALID_ARGUMENT') throw TypeError('Invalid youtube playlist id') + + const errorMessage = `Unknown playlist items response error: ${playlistResponse.error.message}` + console.error(errorMessage) + throw Error(errorMessage) + } + + const playableContents = playlistResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicPlaylistShelfRenderer.contents.filter( + (item) => item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint?.watchEndpoint?.videoId !== undefined, + ) - 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) { + while (continuation && (!limit || playableContents.length < (startIndex ?? 0) + limit)) { const continuationResponse = (await this.ytMusicv1ApiRequest({ type: 'continuation', ctoken: continuation }).then((response) => response.json())) as InnerTube.Playlist.ContinuationResponse + const playableContinuationContents = continuationResponse.continuationContents.musicPlaylistShelfContinuation.contents.filter( + (item) => item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint?.watchEndpoint?.videoId !== undefined, + ) - contents.push(...continuationResponse.continuationContents.musicPlaylistShelfContinuation.contents) + playableContents.push(...playableContinuationContents) continuation = continuationResponse.continuationContents.musicPlaylistShelfContinuation.continuations?.[0].nextContinuationData.continuation } - // 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?) - const playableItems = contents.filter((item) => item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint?.watchEndpoint?.videoId !== undefined) - - const scrapedItems = playableItems.map((item) => { + const scrapedItems = playableContents.slice(startIndex ?? 0, limit ? (startIndex ?? 0) + limit : undefined).map((item) => { const [col0, col1, col2] = item.musicResponsiveListItemRenderer.flexColumns const id = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint!.watchEndpoint.videoId @@ -503,22 +528,16 @@ export class YouTubeMusic implements Connection { private async scrapedToMediaItems(scrapedItems: T): Promise[]> { const songIds = new Set(), albumIds = new Set(), - artistIds = new Set(), playlistIds = new Set() 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)) + if (item.album?.id && !item.album.name) albumIds.add(item.album.id) // This is here because sometimes it is not possible to get the album name directly from a page, only the 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) @@ -554,7 +573,7 @@ export class YouTubeMusic implements Connection { return scrapedItems.map((item) => { switch (item.type) { case 'song': - const { id, name, artists, album, isVideo, uploader } = item + const { id, name, artists, isVideo, uploader } = item const songDetails = songDetailsMap.get(id)! const duration = secondsFromISO8601(songDetails.contentDetails?.duration!) @@ -563,10 +582,13 @@ export class YouTubeMusic implements Connection { const releaseDate = new Date(songDetails.snippet?.description?.match(/Released on: \d{4}-\d{2}-\d{2}/)?.[0] ?? songDetails.snippet?.publishedAt!).toISOString() - const albumDetails = album ? albumDetailsMap.get(album.id)! : undefined - const fullAlbum = (albumDetails ? { id: albumDetails.id, name: albumDetails.name, thumbnailUrl: albumDetails.thumbnailUrl } : undefined) satisfies Song['album'] + let album: Song['album'] + if (item.album?.id) { + const albumName = item.album.name ? item.album.name : albumDetailsMap.get(item.album.id)!.name + album = { id: item.album.id, name: albumName } + } - return { connection, id, name, type: 'song', duration, thumbnailUrl, releaseDate, artists, album: fullAlbum, isVideo, uploader } satisfies Song + return { connection, id, name, type: 'song', duration, thumbnailUrl, releaseDate, artists, album, isVideo, uploader } satisfies Song case 'album': return albumDetailsMap.get(item.id)! satisfies Album case 'artist': @@ -843,3 +865,12 @@ function parseAndSetCookies(response: Response) { return result }) } + +// ? Helpfull Docummentation: +// ? - Making requests to the youtube player: https://tyrrrz.me/blog/reverse-engineering-youtube-revisited (Oleksii Holub, https://github.com/Tyrrrz) +// ? - YouTube API Clients: https://github.com/zerodytrash/YouTube-Internal-Clients (https://github.com/zerodytrash) + +// ? Video Test ids: +// ? - DJ Sharpnel Blue Army full ver: iyL0zueK4CY (Standard video; 144p, 240p) +// ? - HELLOHELL: p0qace56glE (Music video type ATV; Premium Exclusive) +// ? - The Stampy Channel - Endless Episodes - 🔴 Rebroadcast: S8s3eRBPCX0 (Live stream; 144p, 240p, 360p, 480p, 720p, 1080p) diff --git a/src/routes/(app)/details/playlist/+page.svelte b/src/routes/(app)/details/playlist/+page.svelte new file mode 100644 index 0000000..7e088af --- /dev/null +++ b/src/routes/(app)/details/playlist/+page.svelte @@ -0,0 +1,24 @@ + + +
+ {#await data.playlistDetails} + + {:then [playlist, items]} +
+ {playlist.name} cover art +
+
{playlist.name}
+
+
+ {#each items as item} +
{item.name}
+ {/each} + {:catch} +
Failed to fetch playlist
+ {/await} +
diff --git a/src/routes/(app)/details/playlist/+page.ts b/src/routes/(app)/details/playlist/+page.ts new file mode 100644 index 0000000..1773765 --- /dev/null +++ b/src/routes/(app)/details/playlist/+page.ts @@ -0,0 +1,22 @@ +import type { PageLoad } from './$types' + +export const load: PageLoad = async ({ fetch, url }) => { + const connectionId = url.searchParams.get('connection') + const id = url.searchParams.get('id') + + async function getPlaylist() { + const playlistResponse = (await fetch(`/api/connections/${connectionId}/playlist?id=${id}`, { + credentials: 'include', + }).then((response) => response.json())) as { playlist: Playlist } + return playlistResponse.playlist + } + + async function getPlaylistItems() { + const itemsResponse = (await fetch(`/api/connections/${connectionId}/playlist/${id}/items`, { + credentials: 'include', + }).then((response) => response.json())) as { items: Song[] } + return itemsResponse.items + } + + return { playlistDetails: Promise.all([getPlaylist(), getPlaylistItems()]) } +} diff --git a/src/routes/(app)/search/+page.svelte b/src/routes/(app)/search/+page.svelte index 0fc804d..c76eb6c 100644 --- a/src/routes/(app)/search/+page.svelte +++ b/src/routes/(app)/search/+page.svelte @@ -1,66 +1,16 @@ {#if data.searchResults} {#await data.searchResults then searchResults} -
+
{#each searchResults as searchResult} -
- -
-
{searchResult.name}{searchResult.type === 'song' && searchResult.album?.name ? ` - ${searchResult.album.name}` : ''}
- {#if 'artists' in searchResult && searchResult.artists} -
{searchResult.artists === 'Various Artists' ? searchResult.artists : searchResult.artists.map((artist) => artist.name).join(', ')}
- {:else if 'createdBy' in searchResult && searchResult.createdBy} -
{searchResult.createdBy?.name}
- {/if} -
- {#if 'duration' in searchResult && searchResult.duration} - {formatTime(searchResult.duration)} - {/if} -
+ {/each}
{/await} {/if} - - diff --git a/src/routes/api/audio/+server.ts b/src/routes/api/audio/+server.ts index 9f23ac6..5c039b9 100644 --- a/src/routes/api/audio/+server.ts +++ b/src/routes/api/audio/+server.ts @@ -16,7 +16,7 @@ export const GET: RequestHandler = async ({ url, request }) => { // * Withing the .getAudioStream() method of connections, a TypeError should be thrown if the request was invalid (e.g. non-existent id) // * A standard Error should be thrown if the fetch to the service's server failed or the request returned invalid data .catch((error: TypeError | Error) => { - if (error instanceof TypeError) return new Response('Malformed Request', { status: 400 }) + if (error instanceof TypeError) return new Response('Bad Request', { status: 400 }) return new Response('Failed to fetch valid audio stream', { status: 502 }) }) diff --git a/src/routes/api/connections/[connectionId]/playlist/+server.ts b/src/routes/api/connections/[connectionId]/playlist/+server.ts index 4bb77ac..248d546 100644 --- a/src/routes/api/connections/[connectionId]/playlist/+server.ts +++ b/src/routes/api/connections/[connectionId]/playlist/+server.ts @@ -9,8 +9,13 @@ export const GET: RequestHandler = async ({ params, url }) => { const playlistId = url.searchParams.get('id') if (!playlistId) return new Response(`Missing id search parameter`, { status: 400 }) - const playlist = await connection.getPlaylist(playlistId).catch(() => undefined) - if (!playlist) return new Response(`Failed to fetch playlist with id: ${playlistId}`, { status: 400 }) + const response = await connection + .getPlaylistItems(playlistId) + .then((playlist) => Response.json({ playlist })) + .catch((error: TypeError | Error) => { + if (error instanceof TypeError) return new Response('Bad Request', { status: 400 }) + return new Response('Failed to fetch playlist items', { status: 502 }) + }) - return Response.json({ playlist }) + return response } diff --git a/src/routes/api/connections/[connectionId]/playlist/[playlistId]/items/+server.ts b/src/routes/api/connections/[connectionId]/playlist/[playlistId]/items/+server.ts index e870f31..20aaa22 100644 --- a/src/routes/api/connections/[connectionId]/playlist/[playlistId]/items/+server.ts +++ b/src/routes/api/connections/[connectionId]/playlist/[playlistId]/items/+server.ts @@ -1,13 +1,27 @@ import type { RequestHandler } from '@sveltejs/kit' import { Connections } from '$lib/server/connections' -export const GET: RequestHandler = async ({ params }) => { +export const GET: RequestHandler = async ({ params, url }) => { const { connectionId, playlistId } = params const connection = Connections.getConnection(connectionId!) if (!connection) return new Response('Invalid connection id', { status: 400 }) - const items = await connection.getPlaylistItems(playlistId!).catch((reason) => console.error(reason)) - if (!items) return new Response(`Failed to fetch playlist with id: ${playlistId!}`, { status: 400 }) + const startIndexString = url.searchParams.get('startIndex') + const limitString = url.searchParams.get('limit') - return Response.json({ items }) + const numberStartIndex = Number(startIndexString) + const numberLimit = Number(limitString) + + const startIndex = Number.isInteger(numberStartIndex) && numberStartIndex > 0 ? numberStartIndex : undefined + const limit = Number.isInteger(numberLimit) && numberLimit > 0 ? numberLimit : undefined + + const response = await connection + .getPlaylistItems(playlistId!, startIndex, limit) + .then((items) => Response.json({ items })) + .catch((error: TypeError | Error) => { + if (error instanceof TypeError) return new Response('Bad Request', { status: 400 }) + return new Response('Failed to fetch playlist items', { status: 502 }) + }) + + return response }