Updated Jellyfin media item parsers, added startIndex && limit params to YTMusic getPlaylistItems() method

This commit is contained in:
Eclypsed
2024-05-29 01:25:17 -04:00
parent 11497f8b91
commit a98b258a03
10 changed files with 303 additions and 230 deletions

13
src/app.d.ts vendored
View File

@@ -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'). * @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 * @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<Response> getAudioStream: (id: string, headers: Headers) => Promise<Response>
@@ -83,9 +83,11 @@ declare global {
/** /**
* @param id The id of a playlist * @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 * @returns A promise of the songs in the playlist as and array of Song objects
*/ */
getPlaylistItems: (id: string) => Promise<Song[]> getPlaylistItems: (id: string, startIndex?: number, limit?: number) => Promise<Song[]>
} }
// These Schemas should only contain general info data that is necessary for data fetching purposes. // These Schemas should only contain general info data that is necessary for data fetching purposes.
@@ -105,21 +107,18 @@ declare global {
type: 'song' type: 'song'
duration: number // Seconds duration: number // Seconds
thumbnailUrl: string // Base/maxres url of song, any scaling for performance purposes will be handled by remoteImage endpoint thumbnailUrl: string // Base/maxres url of song, any scaling for performance purposes will be handled by remoteImage endpoint
releaseDate: string // ISOString releaseDate?: string // ISOString
artists?: { // Should try to order artists?: { // Should try to order
id: string id: string
name: string name: string
profilePicture?: string
}[] }[]
album?: { album?: {
id: string id: string
name: string name: string
thumbnailUrl?: string
} }
uploader?: { uploader?: {
id: string id: string
name: string name: string
profilePicture?: string
} }
isVideo: boolean isVideo: boolean
} }
@@ -137,7 +136,6 @@ declare global {
artists: { // Should try to order artists: { // Should try to order
id: string id: string
name: string name: string
profilePicture?: string
}[] | 'Various Artists' }[] | 'Various Artists'
releaseYear?: string // #### releaseYear?: string // ####
} }
@@ -166,7 +164,6 @@ declare global {
createdBy?: { createdBy?: {
id: string id: string
name: string name: string
profilePicture?: string
} }
} }

View File

@@ -1,5 +1,7 @@
import { PUBLIC_VERSION } from '$env/static/public' 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 { export class Jellyfin implements Connection {
public readonly id: string public readonly id: string
private readonly userId: string private readonly userId: string
@@ -19,22 +21,21 @@ export class Jellyfin implements Connection {
this.authHeader = new Headers({ Authorization: `MediaBrowser Token="${this.accessToken}"` }) this.authHeader = new Headers({ Authorization: `MediaBrowser Token="${this.accessToken}"` })
} }
public getConnectionInfo = async (): Promise<Extract<ConnectionInfo, { type: 'jellyfin' }>> => { public async getConnectionInfo() {
const userUrl = new URL(`Users/${this.jfUserId}`, this.serverUrl) const userUrl = new URL(`Users/${this.jfUserId}`, this.serverUrl)
const systemUrl = new URL('System/Info', this.serverUrl) const systemUrl = new URL('System/Info', this.serverUrl)
const userData: JellyfinAPI.User | undefined = await fetch(userUrl, { headers: this.authHeader }) const userData = await fetch(userUrl, { headers: this.authHeader })
.then((response) => response.json()) .then((response) => response.json() as Promise<JellyfinAPI.User>)
.catch(() => { .catch(() => null)
console.error(`Fetch to ${userUrl.toString()} failed`)
return undefined if (!userData) console.error(`Fetch to ${userUrl.toString()} failed`)
})
const systemData: JellyfinAPI.System | undefined = await fetch(systemUrl, { headers: this.authHeader }) const systemData = await fetch(systemUrl, { headers: this.authHeader })
.then((response) => response.json()) .then((response) => response.json() as Promise<JellyfinAPI.System>)
.catch(() => { .catch(() => null)
console.error(`Fetch to ${systemUrl.toString()} failed`)
return undefined if (!systemData) console.error(`Fetch to ${systemUrl.toString()} failed`)
})
return { return {
id: this.id, id: this.id,
@@ -44,10 +45,10 @@ export class Jellyfin implements Connection {
serverName: systemData?.ServerName, serverName: systemData?.ServerName,
jellyfinUserId: this.jfUserId, jellyfinUserId: this.jfUserId,
username: userData?.Name, username: userData?.Name,
} } satisfies ConnectionInfo
} }
public getRecommendations = async (): Promise<(Song | Album | Playlist)[]> => { public async getRecommendations(): Promise<(Song | Album | Artist | Playlist)[]> {
const searchParams = new URLSearchParams({ const searchParams = new URLSearchParams({
SortBy: 'PlayCount', SortBy: 'PlayCount',
SortOrder: 'Descending', 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()) 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<Song[]>
public async search(searchTerm: string, filter: 'album'): Promise<Album[]>
public async search(searchTerm: string, filter: 'artist'): Promise<Artist[]>
public async search(searchTerm: string, filter: 'playlist'): Promise<Playlist[]>
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({ const searchParams = new URLSearchParams({
searchTerm, searchTerm,
includeItemTypes: 'Audio,MusicAlbum,Playlist', // Potentially add MusicArtist includeItemTypes: filter ? filterMap[filter] : Object.values(filterMap).join(','),
recursive: 'true', recursive: 'true',
}) })
const searchURL = new URL(`Users/${this.jfUserId}/Items?${searchParams.toString()}`, this.serverUrl) const searchURL = new URL(`Users/${this.jfUserId}/Items?${searchParams.toString()}`, this.serverUrl)
const searchResponse = await fetch(searchURL, { headers: this.authHeader }) const searchResponse = await fetch(searchURL, { headers: this.authHeader })
if (!searchResponse.ok) throw new JellyfinFetchError('Failed to search Jellyfin', searchResponse.status, searchURL.toString()) 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) { switch (result.Type) {
case 'Audio': case 'Audio':
return this.parseSong(result) return this.parseSong(result)
case 'MusicAlbum': case 'MusicAlbum':
return this.parseAlbum(result) return this.parseAlbum(result)
case 'MusicArtist':
return this.parseArtist(result)
case 'Playlist': case 'Playlist':
return this.parsePlaylist(result) return this.parsePlaylist(result)
} }
}) })
return parsedResults
} }
public getAudioStream = async (id: string, range: string | null): Promise<Response> => { public getAudioStream = async (id: string, headers: Headers): Promise<Response> => {
const audoSearchParams = new URLSearchParams({ const audoSearchParams = new URLSearchParams({
MaxStreamingBitrate: '2000000', MaxStreamingBitrate: '2000000',
Container: 'opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg', 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 audioUrl = new URL(`Audio/${id}/universal?${audoSearchParams.toString()}`, this.serverUrl)
const headers = new Headers(this.authHeader) return fetch(audioUrl, { headers })
headers.set('range', range || '0-')
return await fetch(audioUrl, { headers })
} }
private parseSong = (song: JellyfinAPI.Song): Song => { 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() ? new URL(`Items/${song.Id}/Images/Primary`, this.serverUrl).toString()
: song.AlbumPrimaryImageTag : song.AlbumPrimaryImageTag
? new URL(`Items/${song.AlbumId}/Images/Primary`, this.serverUrl).toString() ? new URL(`Items/${song.AlbumId}/Images/Primary`, this.serverUrl).toString()
: undefined : jellyfinLogo
const artists: Song['artists'] = song.ArtistItems const artists: Song['artists'] = song.ArtistItems?.map((artist) => ({ id: artist.Id, name: artist.Name }))
? Array.from(song.ArtistItems, (artist) => {
return { id: artist.Id, name: artist.Name }
})
: undefined
const album: Song['album'] = song.AlbumId && song.Album ? { id: song.AlbumId, name: song.Album } : undefined const album: Song['album'] = song.AlbumId && song.Album ? { id: song.AlbumId, name: song.Album } : undefined
return { return {
connection: this.id, connection: { id: this.id, type: 'jellyfin' },
type: 'song',
id: song.Id, id: song.Id,
name: song.Name, name: song.Name,
type: 'song',
duration: ticksToSeconds(song.RunTimeTicks), duration: ticksToSeconds(song.RunTimeTicks),
thumbnail, thumbnailUrl,
releaseDate: song.ProductionYear ? new Date(song.ProductionYear.toString()).toISOString() : undefined,
artists, artists,
album, album,
releaseDate: song.ProductionYear?.toString(), isVideo: false,
} }
} }
private parseAlbum = (album: JellyfinAPI.Album): Album => { 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 const artists: Album['artists'] = album.AlbumArtists?.map((artist) => ({ id: artist.Id, name: artist.Name })) ?? 'Various Artists'
? Array.from(album.AlbumArtists, (artist) => {
return { id: artist.Id, name: artist.Name }
})
: undefined
return { return {
connection: this.id, connection: { id: this.id, type: 'jellyfin' },
type: 'album',
id: album.Id, id: album.Id,
name: album.Name, name: album.Name,
duration: ticksToSeconds(album.RunTimeTicks), type: 'album',
thumbnail, thumbnailUrl,
artists, 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 => { 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 { return {
connection: this.id, connection: { id: this.id, type: 'jellyfin' },
id: playlist.Id, id: playlist.Id,
name: playlist.Name, name: playlist.Name,
type: 'playlist', type: 'playlist',
thumbnail, thumbnailUrl,
} }
} }

View File

@@ -100,6 +100,14 @@ export namespace InnerTube {
} }
} }
interface PlaylistErrorResponse {
error: {
code: number
message: string
status: string
}
}
interface ContinuationResponse { interface ContinuationResponse {
continuationContents: { continuationContents: {
musicPlaylistShelfContinuation: ContentShelf musicPlaylistShelfContinuation: ContentShelf
@@ -339,10 +347,22 @@ export namespace InnerTube {
} }
namespace Player { namespace Player {
interface PlayerResponse { type PlayerResponse = {
playabilityStatus: {
status: 'OK'
}
streamingData: { streamingData: {
formats: Format[] formats?: Format[]
adaptiveFormats: Format[] adaptiveFormats?: Format[]
dashManifestUrl?: string
hlsManifestUrl?: string
}
}
interface PlayerErrorResponse {
playabilityStatus: {
status: 'ERROR'
reason: string
} }
} }

View File

@@ -55,42 +55,50 @@ export class YouTubeMusic implements Connection {
this.expiry = expiry 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<string> | null = null
private get accessToken() { private get accessToken() {
return (async () => { const refreshAccessToken = async () => {
const refreshTokens = async (): Promise<{ accessToken: string; expiry: number }> => { const MAX_TRIES = 3
const MAX_TRIES = 3 let tries = 0
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 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) { while (tries < MAX_TRIES) {
++tries ++tries
const response = await fetch('https://oauth2.googleapis.com/token', { const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST', method: 'POST',
body: JSON.stringify(refreshDetails), body: JSON.stringify(refreshDetails),
}).catch((reason) => { }).catch((reason) => {
console.error(`Fetch to refresh endpoint failed: ${reason}`) console.error(`Fetch to refresh endpoint failed: ${reason}`)
return null return null
}) })
if (!response || !response.ok) continue if (!response || !response.ok) continue
const { access_token, expires_in } = await response.json() const { access_token, expires_in } = await response.json()
const expiry = Date.now() + expires_in * 1000 const expiry = Date.now() + expires_in * 1000
return { accessToken: access_token, expiry } return { accessToken: access_token as string, expiry }
}
throw new Error(`Failed to refresh access tokens for YouTube Music connection: ${this.id}`)
} }
if (this.expiry < Date.now()) { throw Error(`Failed to refresh access tokens for YouTube Music connection: ${this.id}`)
const { accessToken, expiry } = await refreshTokens() }
if (this.expiry > Date.now()) return new Promise<string>((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 }) DB.updateTokens(this.id, { accessToken, refreshToken: this.refreshToken, expiry })
this.currentAccessToken = accessToken this.currentAccessToken = accessToken
this.expiry = expiry 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() { public async getConnectionInfo() {
@@ -110,11 +118,6 @@ export class YouTubeMusic implements Connection {
private async ytMusicv1ApiRequest(requestDetails: ytMusicv1ApiRequestParams) { private async ytMusicv1ApiRequest(requestDetails: ytMusicv1ApiRequestParams) {
const headers = new Headers({ const headers = new Headers({
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0', '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}`, authorization: `Bearer ${await this.accessToken}`,
}) })
@@ -127,40 +130,35 @@ export class YouTubeMusic implements Connection {
client: { client: {
clientName: 'WEB_REMIX', clientName: 'WEB_REMIX',
clientVersion: `1.${year + month + day}.01.00`, clientVersion: `1.${year + month + day}.01.00`,
hl: 'en',
}, },
} }
const fetchData = (): [URL, Object] => { let url: string
switch (requestDetails.type) { let body: Record<string, any>
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,
},
]
}
}
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) }) 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<Response> { public async getAudioStream(id: string, headers: Headers): Promise<Response> {
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 // ? 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 requiring the user's cookies if the video is premium exclusive. // ? 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: // ? Ideally, I want to avoid having to mess with a user's cookies at all costs because:
// ? a) It's another security risk // ? 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 // ? 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 // ? 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 // ? 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) // * 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', { const playerResponse = await fetch('https://www.youtube.com/youtubei/v1/player', {
headers: { headers: {
'user-agent': 'com.google.android.youtube/17.36.4 (Linux; U; Android 12; GB) gzip', // '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?
accept: '*/*', authorization: `Bearer ${await this.accessToken}`, // * Including the access token is what enables access to premium content
'accept-encoding': 'gzip, deflate',
'content-type': 'application/json',
'content-encoding': 'gzip',
origin: 'https://music.youtube.com',
authorization: `Bearer ${await this.accessToken}`,
}, },
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
@@ -269,33 +261,39 @@ export class YouTubeMusic implements Connection {
client: { client: {
clientName: 'ANDROID_TESTSUITE', clientName: 'ANDROID_TESTSUITE',
clientVersion: '1.9', clientVersion: '1.9',
androidSdkVersion: 30, // androidSdkVersion: 30, <-- I thought this was necessary but it appears it might not be?
hl: 'en',
gl: 'US',
utcOffsetMinutes: 0,
}, },
}, },
}), }),
}) })
.then((response) => response.json() as Promise<InnerTube.Player.PlayerResponse>) .then((response) => response.json() as Promise<InnerTube.Player.PlayerResponse | InnerTube.Player.PlayerErrorResponse>)
.catch(() => null) .catch(() => null)
if (!playerResponse) throw Error(`Failed to fetch player for song ${id} of connection ${this.id}`) 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<InnerTube.Player.Format, 'url' | 'audioQuality'> => (format): format is HasDefinedProperty<InnerTube.Player.Format, 'url' | 'audioQuality'> =>
format.qualityLabel === undefined && format.qualityLabel === undefined &&
format.audioQuality !== undefined && format.audioQuality !== undefined &&
format.url !== undefined && format.url !== undefined &&
!/\bsource[/=]yt_live_broadcast\b/.test(format.url) && // Filters out live broadcasts !/\bsource[/=]yt_live_broadcast\b/.test(format.url) && // Filters out live broadcasts
!/\/manifest\/hls_(variant|playlist)\//.test(format.url) && // Filters out HLS 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 !/\/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. // ? 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. // ? 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)) 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!). * @param id The id of the playlist (not the browseId!).
*/ */
public async getPlaylist(id: string): Promise<Playlist> { public async getPlaylist(id: string): Promise<Playlist> {
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<InnerTube.Playlist.PlaylistResponse | InnerTube.Playlist.PlaylistErrorResponse>)
.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 = const header =
'musicEditablePlaylistDetailHeaderRenderer' in playlistResponse.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 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, startIndex?: number, limit?: number): Promise<Song[]> {
public async getPlaylistItems(id: string): Promise<Song[]> { const playlistResponse = await this.ytMusicv1ApiRequest({ type: 'browse', browseId: 'VL'.concat(id) })
const playlistResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: 'VL'.concat(id) }).then((response) => response.json())) as InnerTube.Playlist.PlaylistResponse .then((response) => response.json() as Promise<InnerTube.Playlist.PlaylistResponse | InnerTube.Playlist.PlaylistErrorResponse>)
.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 = let continuation =
playlistResponse.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicPlaylistShelfRenderer.continuations?.[0].nextContinuationData.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 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 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) const scrapedItems = playableContents.slice(startIndex ?? 0, limit ? (startIndex ?? 0) + limit : undefined).map((item) => {
// 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 [col0, col1, col2] = item.musicResponsiveListItemRenderer.flexColumns const [col0, col1, col2] = item.musicResponsiveListItemRenderer.flexColumns
const id = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint!.watchEndpoint.videoId const id = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint!.watchEndpoint.videoId
@@ -503,22 +528,16 @@ export class YouTubeMusic implements Connection {
private async scrapedToMediaItems<T extends (InnerTube.ScrapedSong | InnerTube.ScrapedAlbum | InnerTube.ScrapedArtist | InnerTube.ScrapedPlaylist)[]>(scrapedItems: T): Promise<ScrapedMediaItemMap<T[number]>[]> { private async scrapedToMediaItems<T extends (InnerTube.ScrapedSong | InnerTube.ScrapedAlbum | InnerTube.ScrapedArtist | InnerTube.ScrapedPlaylist)[]>(scrapedItems: T): Promise<ScrapedMediaItemMap<T[number]>[]> {
const songIds = new Set<string>(), const songIds = new Set<string>(),
albumIds = new Set<string>(), albumIds = new Set<string>(),
artistIds = new Set<string>(),
playlistIds = new Set<string>() playlistIds = new Set<string>()
scrapedItems.forEach((item) => { scrapedItems.forEach((item) => {
switch (item.type) { switch (item.type) {
case 'song': case 'song':
songIds.add(item.id) songIds.add(item.id)
if (item.album?.id) albumIds.add(item.album.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
item.artists?.forEach((artist) => artistIds.add(artist.id))
break break
case 'album': case 'album':
albumIds.add(item.id) 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 break
case 'playlist': case 'playlist':
playlistIds.add(item.id) playlistIds.add(item.id)
@@ -554,7 +573,7 @@ export class YouTubeMusic implements Connection {
return scrapedItems.map((item) => { return scrapedItems.map((item) => {
switch (item.type) { switch (item.type) {
case 'song': case 'song':
const { id, name, artists, album, isVideo, uploader } = item const { id, name, artists, isVideo, uploader } = item
const songDetails = songDetailsMap.get(id)! const songDetails = songDetailsMap.get(id)!
const duration = secondsFromISO8601(songDetails.contentDetails?.duration!) 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 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 let album: Song['album']
const fullAlbum = (albumDetails ? { id: albumDetails.id, name: albumDetails.name, thumbnailUrl: albumDetails.thumbnailUrl } : undefined) satisfies 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': case 'album':
return albumDetailsMap.get(item.id)! satisfies Album return albumDetailsMap.get(item.id)! satisfies Album
case 'artist': case 'artist':
@@ -843,3 +865,12 @@ function parseAndSetCookies(response: Response) {
return result 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)

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import Loader from '$lib/components/util/loader.svelte'
import type { PageData } from './$types'
export let data: PageData
</script>
<main>
{#await data.playlistDetails}
<Loader />
{:then [playlist, items]}
<section class="flex gap-8">
<img class="h-60" src="/api/remoteImage?url={playlist.thumbnailUrl}" alt="{playlist.name} cover art" />
<div>
<div class="text-4xl">{playlist.name}</div>
</div>
</section>
{#each items as item}
<div>{item.name}</div>
{/each}
{:catch}
<div>Failed to fetch playlist</div>
{/await}
</main>

View File

@@ -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()]) }
}

View File

@@ -1,66 +1,16 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'
import { queue } from '$lib/stores'
import type { PageServerData } from './$types' import type { PageServerData } from './$types'
import MediaCard from '$lib/components/media/mediaCard.svelte'
let queueRef = $queue // This nonsense is to prevent an bug that causes svelte to throw an error when setting a property of the queue directly
export let data: PageServerData export let data: PageServerData
const formatTime = (seconds: number): string => {
seconds = Math.floor(seconds)
const hours = Math.floor(seconds / 3600)
seconds = seconds - hours * 3600
const minutes = Math.floor(seconds / 60)
seconds = seconds - minutes * 60
return hours > 0 ? `${hours}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}` : `${minutes}:${seconds.toString().padStart(2, '0')}`
}
</script> </script>
{#if data.searchResults} {#if data.searchResults}
{#await data.searchResults then searchResults} {#await data.searchResults then searchResults}
<section class="flex w-full flex-col items-center gap-2"> <section class="flex w-full flex-wrap gap-6">
{#each searchResults as searchResult} {#each searchResults as searchResult}
<div class="flex h-20 w-full max-w-screen-md gap-4 bg-black p-2"> <MediaCard mediaItem={searchResult} />
<button
id="searchResult"
on:click={() => {
if (searchResult.type === 'song') {
queueRef.current = searchResult
} else {
goto(`/details/${searchResult.type}?id=${searchResult.id}&connection=${searchResult.connection.id}`)
}
}}
class="grid aspect-square h-full place-items-center bg-cover bg-center bg-no-repeat"
style="--thumbnail: url('/api/remoteImage?url={'thumbnailUrl' in searchResult ? searchResult.thumbnailUrl : searchResult.profilePicture}')"
>
<i class="fa-solid fa-play opacity-0" />
</button>
<div>
<div>{searchResult.name}{searchResult.type === 'song' && searchResult.album?.name ? ` - ${searchResult.album.name}` : ''}</div>
{#if 'artists' in searchResult && searchResult.artists}
<div>{searchResult.artists === 'Various Artists' ? searchResult.artists : searchResult.artists.map((artist) => artist.name).join(', ')}</div>
{:else if 'createdBy' in searchResult && searchResult.createdBy}
<div>{searchResult.createdBy?.name}</div>
{/if}
</div>
{#if 'duration' in searchResult && searchResult.duration}
<span class="justify-self-end">{formatTime(searchResult.duration)}</span>
{/if}
</div>
{/each} {/each}
</section> </section>
{/await} {/await}
{/if} {/if}
<style>
#searchResult {
background-image: var(--thumbnail);
}
#searchResult:hover {
background-image: linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5)), var(--thumbnail);
}
#searchResult:hover > i {
opacity: 100%;
}
</style>

View File

@@ -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) // * 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 // * 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) => { .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 }) return new Response('Failed to fetch valid audio stream', { status: 502 })
}) })

View File

@@ -9,8 +9,13 @@ export const GET: RequestHandler = async ({ params, url }) => {
const playlistId = url.searchParams.get('id') const playlistId = url.searchParams.get('id')
if (!playlistId) return new Response(`Missing id search parameter`, { status: 400 }) if (!playlistId) return new Response(`Missing id search parameter`, { status: 400 })
const playlist = await connection.getPlaylist(playlistId).catch(() => undefined) const response = await connection
if (!playlist) return new Response(`Failed to fetch playlist with id: ${playlistId}`, { status: 400 }) .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
} }

View File

@@ -1,13 +1,27 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections' import { Connections } from '$lib/server/connections'
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params, url }) => {
const { connectionId, playlistId } = params const { connectionId, playlistId } = params
const connection = Connections.getConnection(connectionId!) const connection = Connections.getConnection(connectionId!)
if (!connection) return new Response('Invalid connection id', { status: 400 }) if (!connection) return new Response('Invalid connection id', { status: 400 })
const items = await connection.getPlaylistItems(playlistId!).catch((reason) => console.error(reason)) const startIndexString = url.searchParams.get('startIndex')
if (!items) return new Response(`Failed to fetch playlist with id: ${playlistId!}`, { status: 400 }) 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
} }