Added album and playlist requests to Jellyfin Connection
This commit is contained in:
2
src/app.d.ts
vendored
2
src/app.d.ts
vendored
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<JellyfinAPI.User>)
|
||||
const getUserData = () =>
|
||||
fetch(userUrl, { headers: this.authHeader })
|
||||
.then((response) => response.json() as Promise<JellyfinAPI.UserResponse>)
|
||||
.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`)
|
||||
|
||||
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`)
|
||||
|
||||
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<Song[]>
|
||||
public async search(searchTerm: string, filter: 'album'): Promise<Album[]>
|
||||
public async search(searchTerm: string, filter: 'artist'): Promise<Artist[]>
|
||||
@@ -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<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({
|
||||
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<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 => {
|
||||
@@ -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<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()
|
||||
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<JellyfinAPI.AuthData>
|
||||
return response.json() as Promise<JellyfinAPI.AuthenticationResponse>
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Song[]>
|
||||
public async search(searchTerm: string, filter: 'album'): Promise<Album[]>
|
||||
public async search(searchTerm: string, filter: 'artist'): Promise<Artist[]>
|
||||
@@ -187,7 +187,12 @@ export class YouTubeMusic implements Connection {
|
||||
parsedSearchResults.push(parseMusicCardShelfRenderer(section.musicCardShelfRenderer))
|
||||
section.musicCardShelfRenderer.contents?.forEach((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))
|
||||
} 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<Response> {
|
||||
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'
|
||||
|
||||
Reference in New Issue
Block a user