313 lines
9.6 KiB
TypeScript
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()
|
|
},
|
|
}
|