diff --git a/src/app.d.ts b/src/app.d.ts index d433ea6..0927825 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -161,7 +161,7 @@ declare global { name: string type: 'playlist' thumbnailUrl: string - createdBy?: { + createdBy?: { // Optional, in the case that a playlist is auto-generated or it's the user's playlist in which case this is unnecessary id: string name: string } diff --git a/src/lib/server/jellyfin.ts b/src/lib/server/jellyfin.ts index f54257d..003b9ca 100644 --- a/src/lib/server/jellyfin.ts +++ b/src/lib/server/jellyfin.ts @@ -5,7 +5,7 @@ const jellyfinLogo = 'https://raw.githubusercontent.com/jellyfin/jellyfin-ux/556 export class Jellyfin implements Connection { public readonly id: string private readonly userId: string - private readonly jfUserId: string + private readonly jellyfinUserId: string private readonly serverUrl: string private readonly accessToken: string @@ -14,7 +14,7 @@ export class Jellyfin implements Connection { constructor(id: string, userId: string, jellyfinUserId: string, serverUrl: string, accessToken: string) { this.id = id this.userId = userId - this.jfUserId = jellyfinUserId + this.jellyfinUserId = jellyfinUserId this.serverUrl = serverUrl this.accessToken = accessToken @@ -22,19 +22,22 @@ export class Jellyfin implements Connection { } public async getConnectionInfo() { - const userUrl = new URL(`Users/${this.jfUserId}`, this.serverUrl) + const userUrl = new URL(`Users/${this.jellyfinUserId}`, this.serverUrl) const systemUrl = new URL('System/Info', this.serverUrl) - const userData = await fetch(userUrl, { headers: this.authHeader }) - .then((response) => response.json() as Promise) - .catch(() => null) + const getUserData = () => + fetch(userUrl, { headers: this.authHeader }) + .then((response) => response.json() as Promise) + .catch(() => null) + + const getSystemData = () => + fetch(systemUrl, { headers: this.authHeader }) + .then((response) => response.json() as Promise) + .catch(() => null) + + const [userData, systemData] = await Promise.all([getUserData(), getSystemData()]) 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 { @@ -43,27 +46,11 @@ export class Jellyfin implements Connection { type: 'jellyfin', serverUrl: this.serverUrl, serverName: systemData?.ServerName, - jellyfinUserId: this.jfUserId, + jellyfinUserId: this.jellyfinUserId, username: userData?.Name, } satisfies ConnectionInfo } - public async getRecommendations(): Promise<(Song | Album | Artist | Playlist)[]> { - const searchParams = new URLSearchParams({ - SortBy: 'PlayCount', - SortOrder: 'Descending', - IncludeItemTypes: 'Audio', - Recursive: 'true', - limit: '10', - }) - - const mostPlayedSongsURL = new URL(`/Users/${this.jfUserId}/Items?${searchParams.toString()}`, this.serverUrl) - - const mostPlayed: { Items: JellyfinAPI.Song[] } = await fetch(mostPlayedSongsURL, { headers: this.authHeader }).then((response) => response.json()) - - return mostPlayed.Items.map((song) => this.parseSong(song)) - } - public async search(searchTerm: string, filter: 'song'): Promise public async search(searchTerm: string, filter: 'album'): Promise public async search(searchTerm: string, filter: 'artist'): Promise @@ -78,7 +65,7 @@ export class Jellyfin implements Connection { recursive: 'true', }) - const searchURL = new URL(`Users/${this.jfUserId}/Items?${searchParams.toString()}`, this.serverUrl) + const searchURL = new URL(`Users/${this.jellyfinUserId}/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.Artist | JellyfinAPI.Playlist)[] @@ -97,19 +84,121 @@ export class Jellyfin implements Connection { }) } - public getAudioStream = async (id: string, headers: Headers): Promise => { + public async getRecommendations(): Promise<(Song | Album | Artist | Playlist)[]> { + const searchParams = new URLSearchParams({ + SortBy: 'PlayCount', + SortOrder: 'Descending', + IncludeItemTypes: 'Audio', + Recursive: 'true', + limit: '10', + }) + + const mostPlayedSongsURL = new URL(`/Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`, this.serverUrl) + + const mostPlayed: { Items: JellyfinAPI.Song[] } = await fetch(mostPlayedSongsURL, { headers: this.authHeader }).then((response) => response.json()) + + return mostPlayed.Items.map((song) => this.parseSong(song)) + } + + // TODO: Figure out why seeking a jellyfin song takes so much longer than ytmusic (hls?) + public async getAudioStream(id: string, headers: Headers) { const audoSearchParams = new URLSearchParams({ MaxStreamingBitrate: '2000000', Container: 'opus,webm|opus,mp3,aac,m4a|aac,m4b|aac,flac,webma,webm|webma,wav,ogg', TranscodingContainer: 'ts', TranscodingProtocol: 'hls', AudioCodec: 'aac', - userId: this.jfUserId, + userId: this.jellyfinUserId, }) const audioUrl = new URL(`Audio/${id}/universal?${audoSearchParams.toString()}`, this.serverUrl) - return fetch(audioUrl, { headers }) + return fetch(audioUrl, { headers: Object.assign(headers, this.authHeader) }) + } + + public async getAlbum(id: string) { + const albumUrl = new URL(`/Users/${this.jellyfinUserId}/Items/${id}`, this.serverUrl) + + const album = await fetch(albumUrl, { headers: this.authHeader }) + .then((response) => { + if (!response.ok) { + if (response.status >= 500) throw Error(`Jellyfin Server of connection ${this.id} experienced and internal server error`) + throw TypeError(`Invalid album ${id} of jellyfin connection ${this.id}`) + } + return response.json() as Promise + }) + .catch(() => null) + + if (!album) throw Error(`Failed to fetch album ${id} of jellyfin connection ${this.id}`) + + return this.parseAlbum(album) + } + + public async getAlbumItems(id: string) { + const searchParams = new URLSearchParams({ + parentId: id, + sortBy: 'ParentIndexNumber,IndexNumber,SortName', + }) + + const albumItemsUrl = new URL(`/Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`, this.serverUrl) + + const albumItems = await fetch(albumItemsUrl, { headers: this.authHeader }) + .then((response) => { + if (!response.ok) { + if (response.status >= 500) throw Error(`Jellyfin Server of connection ${this.id} experienced and internal server error`) + throw TypeError(`Invalid album ${id} of jellyfin connection ${this.id}`) + } + return response.json() as Promise<{ Items: JellyfinAPI.Song[] }> + }) + .catch(() => null) + + if (!albumItems) throw Error(`Failed to fetch album ${id} items of jellyfin connection ${this.id}`) + + return albumItems.Items.map((item) => this.parseSong(item)) + } + + public async getPlaylist(id: string) { + const playlistUrl = new URL(`/Users/${this.jellyfinUserId}/Items/${id}`, this.serverUrl) + + const playlist = await fetch(playlistUrl, { headers: this.authHeader }) + .then((response) => { + if (!response.ok) { + if (response.status >= 500) throw Error(`Jellyfin Server of connection ${this.id} experienced and internal server error`) + throw TypeError(`Invalid playlist ${id} of jellyfin connection ${this.id}`) + } + return response.json() as Promise + }) + .catch(() => null) + + if (!playlist) throw Error(`Failed to fetch playlist ${id} of jellyfin connection ${this.id}`) + + return this.parsePlaylist(playlist) + } + + public async getPlaylistItems(id: string, startIndex?: number, limit?: number) { + const searchParams = new URLSearchParams({ + parentId: id, + includeItemTypes: 'Audio', + }) + + if (startIndex) searchParams.append('startIndex', startIndex.toString()) + if (limit) searchParams.append('limit', limit.toString()) + + const playlistItemsUrl = new URL(`/Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`, this.serverUrl) + + const playlistItems = await fetch(playlistItemsUrl, { headers: this.authHeader }) + .then((response) => { + if (!response.ok) { + if (response.status >= 500) throw Error(`Jellyfin Server of connection ${this.id} experienced and internal server error`) + throw TypeError(`Invalid playlist ${id} of jellyfin connection ${this.id}`) + } + return response.json() as Promise<{ Items: JellyfinAPI.Song[] }> + }) + .catch(() => null) + + if (!playlistItems) throw Error(`Failed to fetch playlist ${id} items of jellyfin connection ${this.id}`) + + return playlistItems.Items.map((item) => this.parseSong(item)) } private parseSong = (song: JellyfinAPI.Song): Song => { @@ -128,7 +217,7 @@ export class Jellyfin implements Connection { id: song.Id, name: song.Name, type: 'song', - duration: ticksToSeconds(song.RunTimeTicks), + duration: Math.floor(song.RunTimeTicks / 10000000), thumbnailUrl, releaseDate: song.ProductionYear ? new Date(song.ProductionYear.toString()).toISOString() : undefined, artists, @@ -177,7 +266,7 @@ export class Jellyfin implements Connection { } } - public static authenticateByName = async (username: string, password: string, serverUrl: URL, deviceId: string): Promise => { + public static authenticateByName = async (username: string, password: string, serverUrl: URL, deviceId: string): Promise => { const authUrl = new URL('/Users/AuthenticateByName', serverUrl.origin).toString() return fetch(authUrl, { method: 'POST', @@ -195,13 +284,11 @@ export class Jellyfin implements Connection { }) .then((response) => { if (!response.ok) throw new JellyfinFetchError('Failed to Authenticate', 401, authUrl) - return response.json() as Promise + return response.json() as Promise }) } } -const ticksToSeconds = (ticks: number): number => Math.floor(ticks / 10000) - export class JellyfinFetchError extends Error { public httpCode: number public url: string @@ -214,20 +301,6 @@ export class JellyfinFetchError extends Error { } declare namespace JellyfinAPI { - interface User { - Name: string - Id: string - } - - interface AuthData { - User: JellyfinAPI.User - AccessToken: string - } - - interface System { - ServerName: string - } - type Song = { Name: string Id: string @@ -269,6 +342,15 @@ declare namespace JellyfinAPI { } } + type Artist = { + Name: string + Id: string + Type: 'MusicArtist' + ImageTags?: { + Primary?: string + } + } + type Playlist = { Name: string Id: string @@ -280,12 +362,17 @@ declare namespace JellyfinAPI { } } - type Artist = { + interface UserResponse { Name: string Id: string - Type: 'MusicArtist' - ImageTags?: { - Primary?: string - } + } + + interface AuthenticationResponse { + User: JellyfinAPI.UserResponse + AccessToken: string + } + + interface SystemResponse { + ServerName: string } } diff --git a/src/lib/server/youtube-music.ts b/src/lib/server/youtube-music.ts index 20dac58..b769de4 100644 --- a/src/lib/server/youtube-music.ts +++ b/src/lib/server/youtube-music.ts @@ -163,7 +163,7 @@ export class YouTubeMusic implements Connection { return fetch(url, { headers, method: 'POST', body: JSON.stringify(body) }) } - // TODO: Figure out why this still breaks sometimes + // TODO: Figure out why this still breaks sometimes (Figured out one cause: "Episodes" can appear as videos) public async search(searchTerm: string, filter: 'song'): Promise public async search(searchTerm: string, filter: 'album'): Promise public async search(searchTerm: string, filter: 'artist'): Promise @@ -187,7 +187,12 @@ export class YouTubeMusic implements Connection { parsedSearchResults.push(parseMusicCardShelfRenderer(section.musicCardShelfRenderer)) section.musicCardShelfRenderer.contents?.forEach((item) => { if ('musicResponsiveListItemRenderer' in item) { - parsedSearchResults.push(parseResponsiveListItemRenderer(item.musicResponsiveListItemRenderer)) + try { + // ! TEMPORARY I need to rework all my parsers to be able to handle edge cases + parsedSearchResults.push(parseResponsiveListItemRenderer(item.musicResponsiveListItemRenderer)) + } catch { + return + } } }) continue @@ -196,8 +201,14 @@ export class YouTubeMusic implements Connection { const sectionType = section.musicShelfRenderer.title.runs[0].text if (!goodSections.includes(sectionType)) continue - const parsedSectionContents = section.musicShelfRenderer.contents.map((item) => parseResponsiveListItemRenderer(item.musicResponsiveListItemRenderer)) - parsedSearchResults.push(...parsedSectionContents) + section.musicShelfRenderer.contents.forEach((item) => { + try { + // ! TEMPORARY I need to rework all my parsers to be able to handle edge cases + parsedSearchResults.push(parseResponsiveListItemRenderer(item.musicResponsiveListItemRenderer)) + } catch { + return + } + }) } return this.scrapedToMediaItems(parsedSearchResults) @@ -208,7 +219,7 @@ export class YouTubeMusic implements Connection { } } - // TODO: Figure out why this still breaks sometimes + // TODO: Figure out why this still breaks sometimes (Figured out one cause: "Episodes" can appear as videos) public async getRecommendations() { const homeResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: 'FEmusic_home' }).then((response) => response.json())) as InnerTube.HomeResponse @@ -235,7 +246,7 @@ export class YouTubeMusic implements Connection { } } - public async getAudioStream(id: string, headers: Headers): Promise { + public async getAudioStream(id: string, headers: Headers) { if (!/^[a-zA-Z0-9-_]{11}$/.test(id)) throw TypeError('Invalid youtube video Id') // ? 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 @@ -688,7 +699,9 @@ function parseResponsiveListItemRenderer(listContent: InnerTube.musicResponsiveL }) if (!('navigationEndpoint' in listContent)) { - const id = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId + const id = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint?.videoId + if (!id) throw TypeError('Encountered a bad responsiveListItemRenderer, potentially and "Episode or something like that"') // ! I need to rework all my parsers to be able to handle these kinds of edge cases + const isVideo = listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV'