api clients for jellyfin, tmdb, fanart, etc.
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
import { Jellyfin } from '@jellyfin/sdk'
|
||||
import { getUserApi } from '@jellyfin/sdk/lib/utils/api/user-api'
|
||||
import { getSystemApi } from '@jellyfin/sdk/lib/utils/api/system-api'
|
||||
import { getLibraryApi } from '@jellyfin/sdk/lib/utils/api/library-api'
|
||||
import { getItemsApi } from '@jellyfin/sdk/lib/utils/api/items-api'
|
||||
import { getSessionApi } from '@jellyfin/sdk/lib/utils/api/session-api'
|
||||
import { getActivityLogApi } from '@jellyfin/sdk/lib/utils/api/activity-log-api'
|
||||
import { getTvShowsApi } from '@jellyfin/sdk/lib/utils/api/tv-shows-api'
|
||||
import { getUserViewsApi } from '@jellyfin/sdk/lib/utils/api/user-views-api'
|
||||
import { getPlaystateApi } from '@jellyfin/sdk/lib/utils/api/playstate-api'
|
||||
import { getUserLibraryApi } from '@jellyfin/sdk/lib/utils/api/user-library-api'
|
||||
import { getVideosApi } from '@jellyfin/sdk/lib/utils/api/videos-api'
|
||||
import { getAudioApi } from '@jellyfin/sdk/lib/utils/api/audio-api'
|
||||
import { getSearchApi } from '@jellyfin/sdk/lib/utils/api/search-api'
|
||||
import { getMediaInfoApi } from '@jellyfin/sdk/lib/utils/api/media-info-api'
|
||||
import { getMediaSegmentsApi } from '@jellyfin/sdk/lib/utils/api/media-segments-api'
|
||||
import { getPlaylistsApi } from '@jellyfin/sdk/lib/utils/api/playlists-api'
|
||||
import { getItemRefreshApi } from '@jellyfin/sdk/lib/utils/api/item-refresh-api'
|
||||
import type { Api } from '@jellyfin/sdk/lib/api'
|
||||
import { readSecret, removeSecret, writeSecret } from '../lib/sensitive-storage'
|
||||
import type { AuthState } from './types'
|
||||
|
||||
export {
|
||||
getUserApi,
|
||||
getSystemApi,
|
||||
getLibraryApi,
|
||||
getItemsApi,
|
||||
getSessionApi,
|
||||
getActivityLogApi,
|
||||
getTvShowsApi,
|
||||
getUserViewsApi,
|
||||
getPlaystateApi,
|
||||
getUserLibraryApi,
|
||||
getVideosApi,
|
||||
getAudioApi,
|
||||
getSearchApi,
|
||||
getMediaInfoApi,
|
||||
getMediaSegmentsApi,
|
||||
getPlaylistsApi,
|
||||
getItemRefreshApi,
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'jf_auth'
|
||||
const LAST_SERVER_KEY = 'jf_last_server'
|
||||
const SERVERS_KEY = 'jf_servers'
|
||||
|
||||
export function getLastServerUrl(): string {
|
||||
try {
|
||||
return localStorage.getItem(LAST_SERVER_KEY) || ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredAuth(): AuthState | null {
|
||||
try {
|
||||
const raw = readSecret(STORAGE_KEY)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getStoredServerUrl(): string {
|
||||
return getStoredAuth()?.serverUrl || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Known-servers list, kept alongside the active `jf_auth` blob. Each
|
||||
* entry is a full AuthState (server URL + token + user) so switching
|
||||
* between them is one assignment, no re-login required.
|
||||
*
|
||||
* `getStoredAuth()` is the source of truth for "currently active"; the
|
||||
* list mirrors it plus any other servers the user has signed into.
|
||||
*/
|
||||
export function getKnownServers(): AuthState[] {
|
||||
try {
|
||||
const raw = readSecret(SERVERS_KEY)
|
||||
if (!raw) {
|
||||
const current = getStoredAuth()
|
||||
return current ? [current] : []
|
||||
}
|
||||
const parsed = JSON.parse(raw)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeKnownServers(list: AuthState[]) {
|
||||
if (list.length === 0) {
|
||||
removeSecret(SERVERS_KEY)
|
||||
} else {
|
||||
writeSecret(SERVERS_KEY, JSON.stringify(list))
|
||||
}
|
||||
}
|
||||
|
||||
function serverKey(s: { serverUrl: string; userId: string }): string {
|
||||
return `${s.serverUrl}|${s.userId}`
|
||||
}
|
||||
|
||||
export function addKnownServer(state: AuthState) {
|
||||
const list = getKnownServers()
|
||||
const key = serverKey(state)
|
||||
const without = list.filter(s => serverKey(s) !== key)
|
||||
writeKnownServers([state, ...without])
|
||||
}
|
||||
|
||||
export function removeKnownServer(state: { serverUrl: string; userId: string }) {
|
||||
const list = getKnownServers().filter(s => serverKey(s) !== serverKey(state))
|
||||
writeKnownServers(list)
|
||||
}
|
||||
|
||||
function storeAuth(state: AuthState | null) {
|
||||
if (state) {
|
||||
writeSecret(STORAGE_KEY, JSON.stringify(state))
|
||||
} else {
|
||||
removeSecret(STORAGE_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
let jellyfin: Jellyfin | null = null
|
||||
let api: Api | null = null
|
||||
|
||||
export function getImageUrl(
|
||||
serverUrl: string,
|
||||
itemId: string,
|
||||
imageType: string = 'Primary',
|
||||
maxWidth?: number,
|
||||
tag?: string | null,
|
||||
): string {
|
||||
if (!serverUrl || !itemId || !tag) return ''
|
||||
let url = `${serverUrl}/Items/${itemId}/Images/${imageType}?tag=${tag}`
|
||||
if (maxWidth) url += `&maxWidth=${maxWidth}`
|
||||
return url
|
||||
}
|
||||
|
||||
export function getBestImage(
|
||||
serverUrl: string,
|
||||
item: {
|
||||
Id?: string | null
|
||||
ImageTags?: Record<string, string> | null
|
||||
BackdropImageTags?: string[] | null
|
||||
ParentBackdropImageTags?: string[] | null
|
||||
ParentBackdropItemId?: string | null
|
||||
ParentThumbImageTag?: string | null
|
||||
ParentThumbItemId?: string | null
|
||||
SeriesId?: string | null
|
||||
SeriesPrimaryImageTag?: string | null
|
||||
},
|
||||
preference: 'backdrop' | 'primary' | 'thumb' = 'primary',
|
||||
maxWidth?: number,
|
||||
): string {
|
||||
if (!serverUrl || !item.Id) return ''
|
||||
const tags = item.ImageTags || {}
|
||||
|
||||
if (preference === 'backdrop') {
|
||||
const backdrop = item.BackdropImageTags?.[0]
|
||||
if (backdrop) return getImageUrl(serverUrl, item.Id, 'Backdrop', maxWidth, backdrop)
|
||||
if (tags.Thumb) return getImageUrl(serverUrl, item.Id, 'Thumb', maxWidth, tags.Thumb)
|
||||
const parentBackdrop = item.ParentBackdropImageTags?.[0]
|
||||
if (parentBackdrop && item.ParentBackdropItemId) {
|
||||
return getImageUrl(serverUrl, item.ParentBackdropItemId, 'Backdrop', maxWidth, parentBackdrop)
|
||||
}
|
||||
if (tags.Primary) return getImageUrl(serverUrl, item.Id, 'Primary', maxWidth, tags.Primary)
|
||||
return ''
|
||||
}
|
||||
|
||||
if (preference === 'thumb') {
|
||||
if (tags.Thumb) return getImageUrl(serverUrl, item.Id, 'Thumb', maxWidth, tags.Thumb)
|
||||
if (tags.Primary) return getImageUrl(serverUrl, item.Id, 'Primary', maxWidth, tags.Primary)
|
||||
const parentBackdrop = item.ParentBackdropImageTags?.[0]
|
||||
if (parentBackdrop && item.ParentBackdropItemId) {
|
||||
return getImageUrl(serverUrl, item.ParentBackdropItemId, 'Backdrop', maxWidth, parentBackdrop)
|
||||
}
|
||||
if (item.ParentThumbImageTag && item.ParentThumbItemId) {
|
||||
return getImageUrl(serverUrl, item.ParentThumbItemId, 'Thumb', maxWidth, item.ParentThumbImageTag)
|
||||
}
|
||||
if (item.SeriesPrimaryImageTag && item.SeriesId) {
|
||||
return getImageUrl(serverUrl, item.SeriesId, 'Primary', maxWidth, item.SeriesPrimaryImageTag)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// primary preference
|
||||
if (tags.Primary) return getImageUrl(serverUrl, item.Id, 'Primary', maxWidth, tags.Primary)
|
||||
if (item.SeriesPrimaryImageTag && item.SeriesId) {
|
||||
return getImageUrl(serverUrl, item.SeriesId, 'Primary', maxWidth, item.SeriesPrimaryImageTag)
|
||||
}
|
||||
if (tags.Thumb) return getImageUrl(serverUrl, item.Id, 'Thumb', maxWidth, tags.Thumb)
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Jellyfin subtitle stream URL for a given track. Uses VTT format
|
||||
* which vidstack consumes natively. Returns empty when any param is missing.
|
||||
*/
|
||||
export function getSubtitleUrl(
|
||||
serverUrl: string,
|
||||
itemId: string,
|
||||
mediaSourceId: string | null | undefined,
|
||||
subtitleStreamIndex: number,
|
||||
token: string,
|
||||
format: 'vtt' | 'ass' | 'ssa' | 'srt' = 'vtt',
|
||||
): string {
|
||||
if (!serverUrl || !itemId || !token) return ''
|
||||
const ms = mediaSourceId || itemId
|
||||
return `${serverUrl}/Videos/${itemId}/${ms}/Subtitles/${subtitleStreamIndex}/0/Stream.${format}?api_key=${token}`
|
||||
}
|
||||
|
||||
export function getAudioStreamUrl(
|
||||
serverUrl: string,
|
||||
itemId: string,
|
||||
token: string,
|
||||
mediaSourceId?: string,
|
||||
): string {
|
||||
let url = `${serverUrl}/Audio/${itemId}/stream?static=true&api_key=${token}`
|
||||
if (mediaSourceId) url += `&MediaSourceId=${mediaSourceId}`
|
||||
return url
|
||||
}
|
||||
|
||||
export const jellyfinClient = {
|
||||
connect(serverUrl: string): Jellyfin {
|
||||
const normalized = serverUrl.replace(/\/+$/, '')
|
||||
jellyfin = new Jellyfin({
|
||||
clientInfo: { name: 'Jellyfin Client', version: '0.1.0' },
|
||||
deviceInfo: { name: 'Desktop', id: 'jf-desktop-' + Date.now() },
|
||||
})
|
||||
api = jellyfin.createApi(normalized)
|
||||
return jellyfin
|
||||
},
|
||||
|
||||
async login(username: string, password: string): Promise<AuthState> {
|
||||
if (!api) throw new Error('Not connected to a server')
|
||||
|
||||
const userApi = getUserApi(api)
|
||||
const auth = await userApi.authenticateUserByName({
|
||||
authenticateUserByName: {
|
||||
Username: username.trim(),
|
||||
Pw: password,
|
||||
},
|
||||
})
|
||||
|
||||
const state: AuthState = {
|
||||
serverUrl: api.basePath,
|
||||
token: auth.data.AccessToken || '',
|
||||
userId: auth.data.User?.Id || '',
|
||||
userName: auth.data.User?.Name ?? undefined,
|
||||
}
|
||||
|
||||
storeAuth(state)
|
||||
addKnownServer(state)
|
||||
try {
|
||||
localStorage.setItem(LAST_SERVER_KEY, state.serverUrl)
|
||||
} catch { /* noop */ }
|
||||
return state
|
||||
},
|
||||
|
||||
/** Set a previously-known server as the active one. */
|
||||
activateServer(target: AuthState) {
|
||||
storeAuth(target)
|
||||
api = jellyfin?.createApi(target.serverUrl, target.token) ?? null
|
||||
if (!api) {
|
||||
jellyfin = new Jellyfin({
|
||||
clientInfo: { name: 'Jellyfin Client', version: '0.1.0' },
|
||||
deviceInfo: { name: 'Desktop', id: 'jf-desktop-' + Date.now() },
|
||||
})
|
||||
api = jellyfin.createApi(target.serverUrl, target.token)
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(LAST_SERVER_KEY, target.serverUrl)
|
||||
} catch { /* noop */ }
|
||||
},
|
||||
|
||||
async tryAutoLogin(): Promise<AuthState | null> {
|
||||
const stored = getStoredAuth()
|
||||
if (!stored) return null
|
||||
|
||||
jellyfin = new Jellyfin({
|
||||
clientInfo: { name: 'Jellyfin Client', version: '0.1.0' },
|
||||
deviceInfo: { name: 'Desktop', id: 'jf-desktop-' + Date.now() },
|
||||
})
|
||||
api = jellyfin.createApi(stored.serverUrl, stored.token)
|
||||
|
||||
try {
|
||||
await getSystemApi(api).getSystemInfo()
|
||||
return stored
|
||||
} catch {
|
||||
storeAuth(null)
|
||||
api = null
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
logout() {
|
||||
storeAuth(null)
|
||||
api = null
|
||||
jellyfin = null
|
||||
},
|
||||
|
||||
isAuthenticated(): boolean {
|
||||
return api !== null && !!getStoredAuth()
|
||||
},
|
||||
|
||||
getApi(): Api | null {
|
||||
return api
|
||||
},
|
||||
|
||||
getAuthState(): AuthState | null {
|
||||
return getStoredAuth()
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user