Files
jellybloom/src/api/jellyfin.ts
T

313 lines
9.6 KiB
TypeScript

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()
},
}