Added album and playlist requests to Jellyfin Connection

This commit is contained in:
Eclypsed
2024-05-29 16:54:41 -04:00
parent a98b258a03
commit 292dc1425e
3 changed files with 164 additions and 64 deletions

2
src/app.d.ts vendored
View File

@@ -161,7 +161,7 @@ declare global {
name: string name: string
type: 'playlist' type: 'playlist'
thumbnailUrl: string 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 id: string
name: string name: string
} }

View File

@@ -5,7 +5,7 @@ const jellyfinLogo = 'https://raw.githubusercontent.com/jellyfin/jellyfin-ux/556
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
private readonly jfUserId: string private readonly jellyfinUserId: string
private readonly serverUrl: string private readonly serverUrl: string
private readonly accessToken: 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) { constructor(id: string, userId: string, jellyfinUserId: string, serverUrl: string, accessToken: string) {
this.id = id this.id = id
this.userId = userId this.userId = userId
this.jfUserId = jellyfinUserId this.jellyfinUserId = jellyfinUserId
this.serverUrl = serverUrl this.serverUrl = serverUrl
this.accessToken = accessToken this.accessToken = accessToken
@@ -22,19 +22,22 @@ export class Jellyfin implements Connection {
} }
public async getConnectionInfo() { 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 systemUrl = new URL('System/Info', this.serverUrl)
const userData = await fetch(userUrl, { headers: this.authHeader }) const getUserData = () =>
.then((response) => response.json() as Promise<JellyfinAPI.User>) fetch(userUrl, { headers: this.authHeader })
.then((response) => response.json() as Promise<JellyfinAPI.UserResponse>)
.catch(() => null) .catch(() => null)
const getSystemData = () =>
fetch(systemUrl, { headers: this.authHeader })
.then((response) => response.json() as Promise<JellyfinAPI.SystemResponse>)
.catch(() => null)
const [userData, systemData] = await Promise.all([getUserData(), getSystemData()])
if (!userData) console.error(`Fetch to ${userUrl.toString()} failed`) if (!userData) console.error(`Fetch to ${userUrl.toString()} failed`)
const systemData = await fetch(systemUrl, { headers: this.authHeader })
.then((response) => response.json() as Promise<JellyfinAPI.System>)
.catch(() => null)
if (!systemData) console.error(`Fetch to ${systemUrl.toString()} failed`) if (!systemData) console.error(`Fetch to ${systemUrl.toString()} failed`)
return { return {
@@ -43,27 +46,11 @@ export class Jellyfin implements Connection {
type: 'jellyfin', type: 'jellyfin',
serverUrl: this.serverUrl, serverUrl: this.serverUrl,
serverName: systemData?.ServerName, serverName: systemData?.ServerName,
jellyfinUserId: this.jfUserId, jellyfinUserId: this.jellyfinUserId,
username: userData?.Name, username: userData?.Name,
} satisfies ConnectionInfo } 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<Song[]> public async search(searchTerm: string, filter: 'song'): Promise<Song[]>
public async search(searchTerm: string, filter: 'album'): Promise<Album[]> public async search(searchTerm: string, filter: 'album'): Promise<Album[]>
public async search(searchTerm: string, filter: 'artist'): Promise<Artist[]> public async search(searchTerm: string, filter: 'artist'): Promise<Artist[]>
@@ -78,7 +65,7 @@ export class Jellyfin implements Connection {
recursive: 'true', 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 }) 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.Artist | JellyfinAPI.Playlist)[] 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<Response> => { 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({ 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',
TranscodingContainer: 'ts', TranscodingContainer: 'ts',
TranscodingProtocol: 'hls', TranscodingProtocol: 'hls',
AudioCodec: 'aac', AudioCodec: 'aac',
userId: this.jfUserId, userId: this.jellyfinUserId,
}) })
const audioUrl = new URL(`Audio/${id}/universal?${audoSearchParams.toString()}`, this.serverUrl) 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<JellyfinAPI.Album>
})
.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<JellyfinAPI.Playlist>
})
.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 => { private parseSong = (song: JellyfinAPI.Song): Song => {
@@ -128,7 +217,7 @@ export class Jellyfin implements Connection {
id: song.Id, id: song.Id,
name: song.Name, name: song.Name,
type: 'song', type: 'song',
duration: ticksToSeconds(song.RunTimeTicks), duration: Math.floor(song.RunTimeTicks / 10000000),
thumbnailUrl, thumbnailUrl,
releaseDate: song.ProductionYear ? new Date(song.ProductionYear.toString()).toISOString() : undefined, releaseDate: song.ProductionYear ? new Date(song.ProductionYear.toString()).toISOString() : undefined,
artists, artists,
@@ -177,7 +266,7 @@ export class Jellyfin implements Connection {
} }
} }
public static authenticateByName = async (username: string, password: string, serverUrl: URL, deviceId: string): Promise<JellyfinAPI.AuthData> => { public static authenticateByName = async (username: string, password: string, serverUrl: URL, deviceId: string): Promise<JellyfinAPI.AuthenticationResponse> => {
const authUrl = new URL('/Users/AuthenticateByName', serverUrl.origin).toString() const authUrl = new URL('/Users/AuthenticateByName', serverUrl.origin).toString()
return fetch(authUrl, { return fetch(authUrl, {
method: 'POST', method: 'POST',
@@ -195,13 +284,11 @@ export class Jellyfin implements Connection {
}) })
.then((response) => { .then((response) => {
if (!response.ok) throw new JellyfinFetchError('Failed to Authenticate', 401, authUrl) if (!response.ok) throw new JellyfinFetchError('Failed to Authenticate', 401, authUrl)
return response.json() as Promise<JellyfinAPI.AuthData> return response.json() as Promise<JellyfinAPI.AuthenticationResponse>
}) })
} }
} }
const ticksToSeconds = (ticks: number): number => Math.floor(ticks / 10000)
export class JellyfinFetchError extends Error { export class JellyfinFetchError extends Error {
public httpCode: number public httpCode: number
public url: string public url: string
@@ -214,20 +301,6 @@ export class JellyfinFetchError extends Error {
} }
declare namespace JellyfinAPI { declare namespace JellyfinAPI {
interface User {
Name: string
Id: string
}
interface AuthData {
User: JellyfinAPI.User
AccessToken: string
}
interface System {
ServerName: string
}
type Song = { type Song = {
Name: string Name: string
Id: string Id: string
@@ -269,6 +342,15 @@ declare namespace JellyfinAPI {
} }
} }
type Artist = {
Name: string
Id: string
Type: 'MusicArtist'
ImageTags?: {
Primary?: string
}
}
type Playlist = { type Playlist = {
Name: string Name: string
Id: string Id: string
@@ -280,12 +362,17 @@ declare namespace JellyfinAPI {
} }
} }
type Artist = { interface UserResponse {
Name: string Name: string
Id: string Id: string
Type: 'MusicArtist' }
ImageTags?: {
Primary?: string interface AuthenticationResponse {
} User: JellyfinAPI.UserResponse
AccessToken: string
}
interface SystemResponse {
ServerName: string
} }
} }

View File

@@ -163,7 +163,7 @@ export class YouTubeMusic implements Connection {
return fetch(url, { headers, method: 'POST', body: JSON.stringify(body) }) 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<Song[]> public async search(searchTerm: string, filter: 'song'): Promise<Song[]>
public async search(searchTerm: string, filter: 'album'): Promise<Album[]> public async search(searchTerm: string, filter: 'album'): Promise<Album[]>
public async search(searchTerm: string, filter: 'artist'): Promise<Artist[]> public async search(searchTerm: string, filter: 'artist'): Promise<Artist[]>
@@ -187,7 +187,12 @@ export class YouTubeMusic implements Connection {
parsedSearchResults.push(parseMusicCardShelfRenderer(section.musicCardShelfRenderer)) parsedSearchResults.push(parseMusicCardShelfRenderer(section.musicCardShelfRenderer))
section.musicCardShelfRenderer.contents?.forEach((item) => { section.musicCardShelfRenderer.contents?.forEach((item) => {
if ('musicResponsiveListItemRenderer' in item) { if ('musicResponsiveListItemRenderer' in item) {
try {
// ! TEMPORARY I need to rework all my parsers to be able to handle edge cases
parsedSearchResults.push(parseResponsiveListItemRenderer(item.musicResponsiveListItemRenderer)) parsedSearchResults.push(parseResponsiveListItemRenderer(item.musicResponsiveListItemRenderer))
} catch {
return
}
} }
}) })
continue continue
@@ -196,8 +201,14 @@ export class YouTubeMusic implements Connection {
const sectionType = section.musicShelfRenderer.title.runs[0].text const sectionType = section.musicShelfRenderer.title.runs[0].text
if (!goodSections.includes(sectionType)) continue if (!goodSections.includes(sectionType)) continue
const parsedSectionContents = section.musicShelfRenderer.contents.map((item) => parseResponsiveListItemRenderer(item.musicResponsiveListItemRenderer)) section.musicShelfRenderer.contents.forEach((item) => {
parsedSearchResults.push(...parsedSectionContents) 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) 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() { public async getRecommendations() {
const homeResponse = (await this.ytMusicv1ApiRequest({ type: 'browse', browseId: 'FEmusic_home' }).then((response) => response.json())) as InnerTube.HomeResponse 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<Response> { public async getAudioStream(id: string, headers: Headers) {
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 TVHTML5_SIMPLY_EMBEDDED_PLAYER 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
@@ -688,7 +699,9 @@ function parseResponsiveListItemRenderer(listContent: InnerTube.musicResponsiveL
}) })
if (!('navigationEndpoint' in listContent)) { 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 = const isVideo =
listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !== listContent.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !==
'MUSIC_VIDEO_TYPE_ATV' 'MUSIC_VIDEO_TYPE_ATV'