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