api clients for jellyfin, tmdb, fanart, etc.
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildAddRadarrMovieBody } from './radarr'
|
||||
import { buildAddSonarrSeriesBody } from './sonarr'
|
||||
|
||||
describe('request payload builders', () => {
|
||||
it('builds Radarr movie add payload defaults', () => {
|
||||
expect(buildAddRadarrMovieBody({
|
||||
tmdbId: 550,
|
||||
qualityProfileId: 3,
|
||||
rootFolderPath: 'D:/Movies',
|
||||
title: 'Fight Club',
|
||||
titleSlug: 'fight-club-1999',
|
||||
year: 1999,
|
||||
searchOnAdd: false,
|
||||
})).toMatchObject({
|
||||
tmdbId: 550,
|
||||
monitored: true,
|
||||
minimumAvailability: 'released',
|
||||
addOptions: { searchForMovie: false, monitor: 'movieOnly' },
|
||||
images: [],
|
||||
tags: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('builds Sonarr series add payload with optional language profile', () => {
|
||||
expect(buildAddSonarrSeriesBody({
|
||||
tvdbId: 121361,
|
||||
qualityProfileId: 7,
|
||||
rootFolderPath: 'D:/Shows',
|
||||
languageProfileId: 2,
|
||||
monitored: false,
|
||||
seasonFolder: false,
|
||||
title: 'Game of Thrones',
|
||||
titleSlug: 'game-of-thrones',
|
||||
year: 2011,
|
||||
seasons: [],
|
||||
searchOnAdd: false,
|
||||
})).toMatchObject({
|
||||
tvdbId: 121361,
|
||||
monitored: false,
|
||||
seasonFolder: false,
|
||||
languageProfileId: 2,
|
||||
addOptions: {
|
||||
searchForMissingEpisodes: false,
|
||||
searchForCutoffUnmetEpisodes: false,
|
||||
monitor: 'all',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Cinemeta is the public addon that powers Stremio's catalog. The HTTP
|
||||
* surface is unauthenticated and returns IMDB-style metadata keyed by
|
||||
* IMDB id. We use it as a quiet fallback for the IMDB rating chip and
|
||||
* the IMDB-style plot when TMDB doesn't have one.
|
||||
*
|
||||
* Endpoint shape:
|
||||
* GET https://v3-cinemeta.strem.io/meta/{type}/{imdb_id}.json
|
||||
* where type = 'movie' | 'series'
|
||||
*
|
||||
* Response (truncated):
|
||||
* { meta: { id, name, year, imdbRating, description, poster, cast,
|
||||
* director, runtime, genres, country, awards, ... } }
|
||||
*
|
||||
* No SLA. Treat it as best-effort.
|
||||
*/
|
||||
|
||||
const BASE = 'https://v3-cinemeta.strem.io'
|
||||
|
||||
export interface CinemetaEpisode {
|
||||
/** Synthetic id of the form `<imdb>:<season>:<episode>`. */
|
||||
id?: string
|
||||
imdb_id?: string
|
||||
season: number
|
||||
episode: number
|
||||
title?: string
|
||||
thumbnail?: string
|
||||
released?: string
|
||||
/** Episode-level IMDB rating as a string ("8.4"). May be missing. */
|
||||
imdbRating?: string
|
||||
description?: string
|
||||
/** Some payloads include episode-level director / writer. */
|
||||
director?: string[]
|
||||
writer?: string[]
|
||||
}
|
||||
|
||||
export interface CinemetaMeta {
|
||||
id: string
|
||||
type: 'movie' | 'series'
|
||||
name: string
|
||||
year?: string
|
||||
imdbRating?: string
|
||||
description?: string
|
||||
poster?: string
|
||||
background?: string
|
||||
logo?: string
|
||||
cast?: string[]
|
||||
director?: string[]
|
||||
writer?: string[]
|
||||
runtime?: string
|
||||
genres?: string[]
|
||||
country?: string
|
||||
awards?: string
|
||||
released?: string
|
||||
trailers?: { source: string; type: string }[]
|
||||
/** Per-episode entries on series payloads. */
|
||||
videos?: CinemetaEpisode[]
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T | null> {
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) return null
|
||||
return (await res.json()) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function cinemetaMovie(imdbId: string): Promise<CinemetaMeta | null> {
|
||||
if (!imdbId) return null
|
||||
const data = await fetchJson<{ meta?: CinemetaMeta }>(`${BASE}/meta/movie/${encodeURIComponent(imdbId)}.json`)
|
||||
return data?.meta || null
|
||||
}
|
||||
|
||||
export async function cinemetaSeries(imdbId: string): Promise<CinemetaMeta | null> {
|
||||
if (!imdbId) return null
|
||||
const data = await fetchJson<{ meta?: CinemetaMeta }>(`${BASE}/meta/series/${encodeURIComponent(imdbId)}.json`)
|
||||
return data?.meta || null
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────── */
|
||||
/* Per-episode helpers */
|
||||
/* ──────────────────────────────────────────────────────────── */
|
||||
|
||||
function epKey(season: number, episode: number): string {
|
||||
return `${season}:${episode}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Map keyed by `${season}:${episode}` so callers can do O(1)
|
||||
* lookups while rendering a long episode list. Returns an empty map if
|
||||
* the meta has no `videos` array.
|
||||
*/
|
||||
export function buildCinemetaEpisodeMap(
|
||||
meta: CinemetaMeta | null | undefined,
|
||||
): Map<string, CinemetaEpisode> {
|
||||
const out = new Map<string, CinemetaEpisode>()
|
||||
for (const v of meta?.videos || []) {
|
||||
if (Number.isFinite(v.season) && Number.isFinite(v.episode)) {
|
||||
out.set(epKey(v.season, v.episode), v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Look up a single episode in the meta payload. */
|
||||
export function findCinemetaEpisode(
|
||||
meta: CinemetaMeta | null | undefined,
|
||||
season: number,
|
||||
episode: number,
|
||||
): CinemetaEpisode | null {
|
||||
for (const v of meta?.videos || []) {
|
||||
if (v.season === season && v.episode === episode) return v
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Fanart.tv client. Requires a personal API key (free, no email required
|
||||
* but you do need to register at https://fanart.tv/get-an-api-key/ and
|
||||
* obtain a "personal" key). The key is stored in user preferences and
|
||||
* supplied to every request.
|
||||
*
|
||||
* Most-useful endpoints for our chrome:
|
||||
* - /v3/movies/{tmdb_or_imdb_id} movie artwork (logos, clearart, banners)
|
||||
* - /v3/tv/{tvdb_id} TV artwork (logos, clearart, banners)
|
||||
*
|
||||
* Each artwork item carries `lang` so we can pick English versions first
|
||||
* when present, falling back to whatever the highest-voted entry is.
|
||||
*/
|
||||
|
||||
const BASE = 'https://webservice.fanart.tv/v3'
|
||||
|
||||
export interface FanartImage {
|
||||
id: string
|
||||
url: string
|
||||
lang?: string
|
||||
likes?: string
|
||||
/* Some artwork types include season number (TV) */
|
||||
season?: string
|
||||
}
|
||||
|
||||
export interface FanartMovieResponse {
|
||||
name?: string
|
||||
tmdb_id?: string
|
||||
imdb_id?: string
|
||||
hdmovielogo?: FanartImage[]
|
||||
hdmovieclearart?: FanartImage[]
|
||||
movielogo?: FanartImage[]
|
||||
movieart?: FanartImage[]
|
||||
movieposter?: FanartImage[]
|
||||
moviebackground?: FanartImage[]
|
||||
moviedisc?: FanartImage[]
|
||||
moviebanner?: FanartImage[]
|
||||
moviethumb?: FanartImage[]
|
||||
}
|
||||
|
||||
export interface FanartTvResponse {
|
||||
name?: string
|
||||
thetvdb_id?: string
|
||||
hdtvlogo?: FanartImage[]
|
||||
clearlogo?: FanartImage[]
|
||||
hdclearart?: FanartImage[]
|
||||
clearart?: FanartImage[]
|
||||
showbackground?: FanartImage[]
|
||||
tvthumb?: FanartImage[]
|
||||
tvbanner?: FanartImage[]
|
||||
characterart?: FanartImage[]
|
||||
seasonposter?: FanartImage[]
|
||||
seasonbanner?: FanartImage[]
|
||||
seasonthumb?: FanartImage[]
|
||||
tvposter?: FanartImage[]
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string, apiKey: string): Promise<T | null> {
|
||||
if (!apiKey) return null
|
||||
try {
|
||||
const res = await fetch(url, { headers: { 'api-key': apiKey } })
|
||||
if (!res.ok) return null
|
||||
return (await res.json()) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function fanartMovie(idTmdbOrImdb: string, apiKey: string): Promise<FanartMovieResponse | null> {
|
||||
if (!idTmdbOrImdb) return Promise.resolve(null)
|
||||
return fetchJson<FanartMovieResponse>(
|
||||
`${BASE}/movies/${encodeURIComponent(idTmdbOrImdb)}`,
|
||||
apiKey,
|
||||
)
|
||||
}
|
||||
|
||||
export function fanartTv(tvdbId: string, apiKey: string): Promise<FanartTvResponse | null> {
|
||||
if (!tvdbId) return Promise.resolve(null)
|
||||
return fetchJson<FanartTvResponse>(`${BASE}/tv/${encodeURIComponent(tvdbId)}`, apiKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the best image from a list: prefer English entries, then
|
||||
* highest `likes` count, falling back to first in list.
|
||||
*/
|
||||
export function pickBestFanartImage(images?: FanartImage[]): FanartImage | null {
|
||||
if (!images || images.length === 0) return null
|
||||
const eng = images.filter(i => (i.lang || '').toLowerCase() === 'en')
|
||||
const pool = eng.length > 0 ? eng : images
|
||||
return [...pool].sort((a, b) => Number(b.likes ?? 0) - Number(a.likes ?? 0))[0] || null
|
||||
}
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Letterboxd RSS reader. Letterboxd publishes an RSS endpoint for any
|
||||
* public list at `/{user}/list/{slug}/rss/` containing per-film entries
|
||||
* with embedded TMDB ids. We can't reach it directly because Letterboxd
|
||||
* doesn't set CORS headers, so requests route through one of several
|
||||
* open proxies that add Access-Control-Allow-Origin headers.
|
||||
*
|
||||
* Multiple proxies are tried in sequence so one provider going down
|
||||
* doesn't break the feature outright.
|
||||
*/
|
||||
|
||||
const PROXIES: Array<(target: string) => string> = [
|
||||
target => `https://api.allorigins.win/raw?url=${encodeURIComponent(target)}`,
|
||||
target => `https://corsproxy.io/?${encodeURIComponent(target)}`,
|
||||
target => `https://api.codetabs.com/v1/proxy?quest=${encodeURIComponent(target)}`,
|
||||
]
|
||||
|
||||
export interface LetterboxdListItem {
|
||||
/** TMDB id parsed from the entry, when available. Letterboxd publishes
|
||||
* this in a `<tmdb:movieId>` element on most film entries. */
|
||||
tmdbId: string | null
|
||||
/** Letterboxd film url, used as a fallback link. */
|
||||
link: string
|
||||
title: string
|
||||
year: number | null
|
||||
/** Order within the user's list - 1-based. */
|
||||
position: number
|
||||
}
|
||||
|
||||
export interface LetterboxdList {
|
||||
title: string
|
||||
description: string
|
||||
link: string
|
||||
items: LetterboxdListItem[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate + normalise a user-pasted Letterboxd list URL into the
|
||||
* canonical `/{user}/list/{slug}/` form. Returns null when the URL
|
||||
* doesn't look like a list.
|
||||
*/
|
||||
export function normaliseLetterboxdListUrl(raw: string): string | null {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) return null
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(trimmed)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!parsed.hostname.endsWith('letterboxd.com')) return null
|
||||
// Path looks like /{user}/list/{slug}/[detail/[page/N/]]?
|
||||
const m = parsed.pathname.match(/^\/([^/]+)\/list\/([^/]+)/)
|
||||
if (!m) return null
|
||||
return `https://letterboxd.com/${m[1]}/list/${m[2]}/`
|
||||
}
|
||||
|
||||
function listRssUrl(canonicalUrl: string): string {
|
||||
return canonicalUrl.replace(/\/?$/, '/rss/')
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch + parse a Letterboxd list's RSS through the allorigins proxy.
|
||||
* Robust against missing TMDB ids - entries without one carry a null
|
||||
* `tmdbId` and the renderer can fall back to title-only display.
|
||||
*/
|
||||
export async function fetchLetterboxdList(canonicalUrl: string): Promise<LetterboxdList | null> {
|
||||
const url = listRssUrl(canonicalUrl)
|
||||
let xml: string | null = null
|
||||
for (const buildUrl of PROXIES) {
|
||||
try {
|
||||
const res = await fetch(buildUrl(url))
|
||||
if (!res.ok) continue
|
||||
const body = await res.text()
|
||||
if (body && body.trim().startsWith('<')) {
|
||||
xml = body
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// Try the next proxy.
|
||||
}
|
||||
}
|
||||
if (!xml) return null
|
||||
|
||||
let doc: Document
|
||||
try {
|
||||
doc = new DOMParser().parseFromString(xml, 'application/xml')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
// DOMParser returns an error doc when the XML is malformed; check for
|
||||
// the parsererror node before trusting the result.
|
||||
if (doc.querySelector('parsererror')) return null
|
||||
|
||||
const channelTitle = doc.querySelector('channel > title')?.textContent || 'Letterboxd list'
|
||||
const channelDesc = doc.querySelector('channel > description')?.textContent || ''
|
||||
const channelLink = doc.querySelector('channel > link')?.textContent || canonicalUrl
|
||||
|
||||
const items: LetterboxdListItem[] = []
|
||||
let pos = 0
|
||||
for (const entry of Array.from(doc.querySelectorAll('channel > item'))) {
|
||||
pos++
|
||||
const titleRaw = entry.querySelector('title')?.textContent || ''
|
||||
const link = entry.querySelector('link')?.textContent || ''
|
||||
// Various Letterboxd RSS variants. tmdb:movieId is the most common.
|
||||
const tmdbEl =
|
||||
entry.getElementsByTagName('tmdb:movieId')[0] ||
|
||||
entry.getElementsByTagName('letterboxd:tmdbId')[0] ||
|
||||
null
|
||||
const yearEl =
|
||||
entry.getElementsByTagName('letterboxd:filmYear')[0] ||
|
||||
null
|
||||
const titleElLb = entry.getElementsByTagName('letterboxd:filmTitle')[0]
|
||||
const cleanTitle = (titleElLb?.textContent || titleRaw)
|
||||
// Letterboxd titles often look like "Title, 2007 - ★★★★½" - strip rating + year suffix.
|
||||
.replace(/\s*-\s*★+½?\s*$/u, '')
|
||||
.replace(/,\s*\d{4}\s*$/, '')
|
||||
.trim()
|
||||
const yearFromTitle = (titleRaw.match(/,\s*(\d{4})\s*-/) || [])[1]
|
||||
items.push({
|
||||
tmdbId: tmdbEl?.textContent?.trim() || null,
|
||||
link,
|
||||
title: cleanTitle || titleRaw,
|
||||
year: yearEl?.textContent ? Number(yearEl.textContent) : yearFromTitle ? Number(yearFromTitle) : null,
|
||||
position: pos,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
title: channelTitle.replace(/^Letterboxd \| /, ''),
|
||||
description: channelDesc,
|
||||
link: channelLink,
|
||||
items,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Thin Radarr v3 API client. Used to add new movies, list quality
|
||||
* profiles and root folders, and reconcile our TMDB-keyed view of the
|
||||
* library against what Radarr already has or is downloading.
|
||||
*
|
||||
* All endpoints expect an `X-Api-Key` header. Calls return null on
|
||||
* network or HTTP failure so callers can fall through gracefully.
|
||||
*/
|
||||
|
||||
import type { ArrInstance } from '../stores/arr-instances-store'
|
||||
|
||||
export interface RadarrRootFolder {
|
||||
id: number
|
||||
path: string
|
||||
freeSpace?: number
|
||||
unmappedFolders?: { name: string; path: string }[]
|
||||
}
|
||||
|
||||
export interface RadarrQualityProfile {
|
||||
id: number
|
||||
name: string
|
||||
cutoff: number
|
||||
}
|
||||
|
||||
export interface RadarrTag {
|
||||
id: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface RadarrMovie {
|
||||
id?: number
|
||||
title: string
|
||||
originalTitle?: string
|
||||
year?: number
|
||||
tmdbId: number
|
||||
imdbId?: string | null
|
||||
hasFile?: boolean
|
||||
monitored?: boolean
|
||||
status?: 'announced' | 'inCinemas' | 'released' | 'deleted'
|
||||
qualityProfileId?: number
|
||||
rootFolderPath?: string
|
||||
tags?: number[]
|
||||
added?: string
|
||||
sizeOnDisk?: number
|
||||
isAvailable?: boolean
|
||||
/** Posters / fanart attached. */
|
||||
images?: { coverType: string; remoteUrl?: string; url?: string }[]
|
||||
}
|
||||
|
||||
export interface RadarrLookupResult extends RadarrMovie {
|
||||
/** Returned by /lookup with no `id` until added. */
|
||||
folder?: string
|
||||
}
|
||||
|
||||
export interface RadarrQueueItem {
|
||||
id: number
|
||||
movieId?: number
|
||||
title: string
|
||||
status: string
|
||||
trackedDownloadStatus?: string
|
||||
size?: number
|
||||
sizeleft?: number
|
||||
timeleft?: string
|
||||
estimatedCompletionTime?: string
|
||||
}
|
||||
|
||||
export interface RadarrSystemStatus {
|
||||
version: string
|
||||
startTime?: string
|
||||
branch?: string
|
||||
appData?: string
|
||||
isProduction?: boolean
|
||||
}
|
||||
|
||||
export interface AddRadarrMoviePayload {
|
||||
tmdbId: number
|
||||
qualityProfileId: number
|
||||
rootFolderPath: string
|
||||
monitored?: boolean
|
||||
searchOnAdd?: boolean
|
||||
tags?: number[]
|
||||
/** Required by Radarr but the lookup payload supplies it; we pass it back unchanged. */
|
||||
title: string
|
||||
titleSlug: string
|
||||
year: number
|
||||
images?: { coverType: string; remoteUrl?: string; url?: string }[]
|
||||
minimumAvailability?: 'announced' | 'inCinemas' | 'released' | 'preDB'
|
||||
}
|
||||
|
||||
export function buildAddRadarrMovieBody(payload: AddRadarrMoviePayload) {
|
||||
return {
|
||||
tmdbId: payload.tmdbId,
|
||||
qualityProfileId: payload.qualityProfileId,
|
||||
rootFolderPath: payload.rootFolderPath,
|
||||
monitored: payload.monitored ?? true,
|
||||
title: payload.title,
|
||||
titleSlug: payload.titleSlug,
|
||||
year: payload.year,
|
||||
images: payload.images || [],
|
||||
tags: payload.tags || [],
|
||||
minimumAvailability: payload.minimumAvailability || 'released',
|
||||
addOptions: {
|
||||
searchForMovie: payload.searchOnAdd ?? true,
|
||||
monitor: 'movieOnly',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
class RadarrClient {
|
||||
instance: ArrInstance
|
||||
constructor(instance: ArrInstance) {
|
||||
this.instance = instance
|
||||
}
|
||||
|
||||
private url(path: string): string {
|
||||
const base = this.instance.baseUrl.replace(/\/+$/, '')
|
||||
return `${base}/api/v3${path}`
|
||||
}
|
||||
|
||||
private async req<T>(path: string, init: RequestInit = {}): Promise<T | null> {
|
||||
try {
|
||||
const res = await fetch(this.url(path), {
|
||||
...init,
|
||||
headers: {
|
||||
'X-Api-Key': this.instance.apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
...(init.headers || {}),
|
||||
},
|
||||
})
|
||||
if (!res.ok) return null
|
||||
const text = await res.text()
|
||||
return text ? (JSON.parse(text) as T) : (null as unknown as T)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
systemStatus() { return this.req<RadarrSystemStatus>('/system/status') }
|
||||
qualityProfiles() { return this.req<RadarrQualityProfile[]>('/qualityprofile') }
|
||||
rootFolders() { return this.req<RadarrRootFolder[]>('/rootfolder') }
|
||||
tags() { return this.req<RadarrTag[]>('/tag') }
|
||||
movies() { return this.req<RadarrMovie[]>('/movie') }
|
||||
queue() { return this.req<{ records: RadarrQueueItem[] }>('/queue') }
|
||||
|
||||
/**
|
||||
* Look up a movie by TMDB id. Returns the metadata Radarr needs to add
|
||||
* it; `id` is undefined until the movie has been added.
|
||||
*/
|
||||
lookupByTmdbId(tmdbId: number) {
|
||||
return this.req<RadarrLookupResult[]>(
|
||||
`/movie/lookup/tmdb?tmdbId=${encodeURIComponent(tmdbId)}`,
|
||||
)
|
||||
}
|
||||
|
||||
async addMovie(payload: AddRadarrMoviePayload): Promise<RadarrMovie | null> {
|
||||
const body = buildAddRadarrMovieBody(payload)
|
||||
return this.req<RadarrMovie>('/movie', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
removeMovie(id: number, deleteFiles = false) {
|
||||
return this.req<void>(
|
||||
`/movie/${id}?deleteFiles=${deleteFiles ? 'true' : 'false'}&addImportExclusion=false`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
}
|
||||
|
||||
searchMovie(id: number) {
|
||||
return this.req<void>('/command', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: 'MoviesSearch', movieIds: [id] }),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function radarrClient(instance: ArrInstance) {
|
||||
return new RadarrClient(instance)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Rotten Tomatoes ratings via the same private Algolia search RT uses on
|
||||
* their own site. No official API; the agent / api-key here are public
|
||||
* values pulled from the RT web client. Best-effort - results matched
|
||||
* fuzzily by title + year.
|
||||
*
|
||||
* Same approach Jellyseerr uses (server/api/rating/rottentomatoes.ts).
|
||||
*/
|
||||
|
||||
const ALGOLIA_BASE = 'https://79frdp12pn-dsn.algolia.net/1/indexes/*/queries'
|
||||
const ALGOLIA_HEADERS: Record<string, string> = {
|
||||
'x-algolia-agent': 'Algolia%20for%20JavaScript%20(4.14.3)%3B%20Browser%20(lite)',
|
||||
'x-algolia-api-key': '175588f6e5f8319b27702e4cc4013561',
|
||||
'x-algolia-application-id': '79FRDP12PN',
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
}
|
||||
|
||||
interface RTAlgoliaHit {
|
||||
emsId: string
|
||||
type: string
|
||||
title: string
|
||||
titles?: string[]
|
||||
releaseYear: number
|
||||
rating: string
|
||||
vanity: string
|
||||
aka?: string[]
|
||||
rottenTomatoes?: {
|
||||
audienceScore: number
|
||||
criticsScore: number
|
||||
certifiedFresh: boolean
|
||||
scoreSentiment: string
|
||||
}
|
||||
}
|
||||
|
||||
interface RTAlgoliaResponse {
|
||||
results: Array<{ hits: RTAlgoliaHit[]; index: 'content_rt' | 'people_rt' }>
|
||||
}
|
||||
|
||||
export interface RTRating {
|
||||
title: string
|
||||
url: string
|
||||
year: number
|
||||
criticsRating: 'Certified Fresh' | 'Fresh' | 'Rotten'
|
||||
criticsScore: number
|
||||
audienceRating?: 'Upright' | 'Spilled'
|
||||
audienceScore?: number
|
||||
}
|
||||
|
||||
const norm = (s: string): string =>
|
||||
s.toLowerCase().replace(/[^\p{L}\p{N} ]/gu, '')
|
||||
|
||||
// Cheap, dependency-free Jaro similarity. Good enough for the fuzzy match.
|
||||
function jaro(a: string, b: string): number {
|
||||
if (a === b) return 1
|
||||
if (!a.length || !b.length) return 0
|
||||
const match = Math.max(0, Math.floor(Math.max(a.length, b.length) / 2) - 1)
|
||||
const am = new Array(a.length).fill(false)
|
||||
const bm = new Array(b.length).fill(false)
|
||||
let m = 0
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const lo = Math.max(0, i - match)
|
||||
const hi = Math.min(b.length, i + match + 1)
|
||||
for (let j = lo; j < hi; j++) {
|
||||
if (bm[j] || a[i] !== b[j]) continue
|
||||
am[i] = true
|
||||
bm[j] = true
|
||||
m++
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!m) return 0
|
||||
let t = 0
|
||||
let k = 0
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (!am[i]) continue
|
||||
while (!bm[k]) k++
|
||||
if (a[i] !== b[k]) t++
|
||||
k++
|
||||
}
|
||||
t = t / 2
|
||||
return (m / a.length + m / b.length + (m - t) / m) / 3
|
||||
}
|
||||
|
||||
const INEXACT = 0.25
|
||||
const ALT = 0.8
|
||||
const PER_YEAR = 0.4
|
||||
const MIN = 0.175
|
||||
|
||||
const tScore = (h: RTAlgoliaHit, name: string): number => {
|
||||
const target = norm(name)
|
||||
const f = (t: string, idx: number) => {
|
||||
const n = norm(t)
|
||||
const sim = n === target ? 1 : jaro(n, target) * INEXACT
|
||||
return sim * (idx ? ALT : 1)
|
||||
}
|
||||
return Math.max(...[h.title, ...(h.aka || []), ...(h.titles || [])].map(f))
|
||||
}
|
||||
|
||||
const yScore = (h: RTAlgoliaHit, year?: number): number =>
|
||||
year ? Math.max(0, 1 - Math.abs(h.releaseYear - year) * PER_YEAR) : 1
|
||||
|
||||
const extra = (h: RTAlgoliaHit): number => (h.rottenTomatoes ? 1 : 0.5)
|
||||
|
||||
const score = (h: RTAlgoliaHit, name: string, year?: number): number =>
|
||||
tScore(h, name) * yScore(h, year) * extra(h)
|
||||
|
||||
const best = (hits: RTAlgoliaHit[], name: string, year?: number): RTAlgoliaHit | undefined =>
|
||||
hits
|
||||
.map(h => ({ s: score(h, name, year), h }))
|
||||
.filter(({ s }) => s > MIN)
|
||||
.sort((a, b) => b.s - a.s)[0]?.h
|
||||
|
||||
async function searchRT(name: string, kind: 'movie' | 'tv'): Promise<RTAlgoliaHit[]> {
|
||||
const query = kind === 'movie' ? name.replace(/\bthe\b ?/gi, '') : name
|
||||
const filters = encodeURIComponent(`isEmsSearchable=1 AND type:"${kind}"`)
|
||||
try {
|
||||
const res = await fetch(ALGOLIA_BASE, {
|
||||
method: 'POST',
|
||||
headers: ALGOLIA_HEADERS,
|
||||
body: JSON.stringify({
|
||||
requests: [
|
||||
{
|
||||
indexName: 'content_rt',
|
||||
query,
|
||||
params: `filters=${filters}&hitsPerPage=20`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
if (!res.ok) return []
|
||||
const data = (await res.json()) as RTAlgoliaResponse
|
||||
return data.results.find(r => r.index === 'content_rt')?.hits || []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function buildRating(
|
||||
hit: RTAlgoliaHit,
|
||||
kind: 'movie' | 'tv',
|
||||
): RTRating | null {
|
||||
if (!hit?.rottenTomatoes) return null
|
||||
const rt = hit.rottenTomatoes
|
||||
return {
|
||||
title: hit.title,
|
||||
year: Number(hit.releaseYear),
|
||||
url: `https://www.rottentomatoes.com/${kind === 'movie' ? 'm' : 'tv'}/${hit.vanity}`,
|
||||
criticsRating: rt.certifiedFresh
|
||||
? 'Certified Fresh'
|
||||
: rt.criticsScore >= 60
|
||||
? 'Fresh'
|
||||
: 'Rotten',
|
||||
criticsScore: rt.criticsScore,
|
||||
audienceRating: rt.audienceScore >= 60 ? 'Upright' : 'Spilled',
|
||||
audienceScore: rt.audienceScore,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getMovieRating(name: string, year?: number): Promise<RTRating | null> {
|
||||
if (!name) return null
|
||||
const hits = await searchRT(name, 'movie')
|
||||
const hit = best(hits, name, year)
|
||||
return hit ? buildRating(hit, 'movie') : null
|
||||
}
|
||||
|
||||
export async function getTvRating(name: string, year?: number): Promise<RTRating | null> {
|
||||
if (!name) return null
|
||||
const hits = await searchRT(name, 'tv')
|
||||
const hit = best(hits, name, year)
|
||||
return hit ? buildRating(hit, 'tv') : null
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
/**
|
||||
* Thin Sonarr v3 API client. Same shape as the Radarr client - same
|
||||
* auth, same null-on-failure convention - but the data model is
|
||||
* series + seasons + episodes rather than movies, and Sonarr keys off
|
||||
* TVDB ids natively (TMDB ids are accepted via `lookup?term=tmdb:N`).
|
||||
*/
|
||||
|
||||
import type { ArrInstance } from '../stores/arr-instances-store'
|
||||
|
||||
export interface SonarrRootFolder {
|
||||
id: number
|
||||
path: string
|
||||
freeSpace?: number
|
||||
}
|
||||
|
||||
export interface SonarrQualityProfile {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface SonarrLanguageProfile {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface SonarrTag {
|
||||
id: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface SonarrSeason {
|
||||
seasonNumber: number
|
||||
monitored: boolean
|
||||
statistics?: {
|
||||
episodeCount: number
|
||||
episodeFileCount: number
|
||||
totalEpisodeCount: number
|
||||
sizeOnDisk: number
|
||||
percentOfEpisodes: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface SonarrSeries {
|
||||
id?: number
|
||||
title: string
|
||||
titleSlug?: string
|
||||
year?: number
|
||||
tvdbId?: number
|
||||
tmdbId?: number
|
||||
imdbId?: string | null
|
||||
status?: 'continuing' | 'ended' | 'upcoming' | 'deleted'
|
||||
monitored?: boolean
|
||||
seasons?: SonarrSeason[]
|
||||
qualityProfileId?: number
|
||||
languageProfileId?: number
|
||||
rootFolderPath?: string
|
||||
tags?: number[]
|
||||
added?: string
|
||||
images?: { coverType: string; remoteUrl?: string; url?: string }[]
|
||||
}
|
||||
|
||||
export interface SonarrLookupResult extends SonarrSeries {
|
||||
folder?: string
|
||||
}
|
||||
|
||||
export interface SonarrQueueItem {
|
||||
id: number
|
||||
seriesId?: number
|
||||
episodeId?: number
|
||||
seasonNumber?: number
|
||||
title: string
|
||||
status: string
|
||||
trackedDownloadStatus?: string
|
||||
size?: number
|
||||
sizeleft?: number
|
||||
timeleft?: string
|
||||
}
|
||||
|
||||
export interface SonarrSystemStatus {
|
||||
version: string
|
||||
startTime?: string
|
||||
branch?: string
|
||||
isProduction?: boolean
|
||||
}
|
||||
|
||||
export interface AddSonarrSeriesPayload {
|
||||
tvdbId: number
|
||||
qualityProfileId: number
|
||||
languageProfileId?: number
|
||||
rootFolderPath: string
|
||||
monitored?: boolean
|
||||
seasonFolder?: boolean
|
||||
searchOnAdd?: boolean
|
||||
tags?: number[]
|
||||
title: string
|
||||
titleSlug: string
|
||||
year: number
|
||||
images?: { coverType: string; remoteUrl?: string; url?: string }[]
|
||||
/** Per-season monitor flags so the user can pick which seasons to fetch. */
|
||||
seasons: { seasonNumber: number; monitored: boolean }[]
|
||||
}
|
||||
|
||||
export function buildAddSonarrSeriesBody(payload: AddSonarrSeriesPayload) {
|
||||
const body: {
|
||||
tvdbId: number
|
||||
qualityProfileId: number
|
||||
rootFolderPath: string
|
||||
monitored: boolean
|
||||
seasonFolder: boolean
|
||||
title: string
|
||||
titleSlug: string
|
||||
year: number
|
||||
images: { coverType: string; remoteUrl?: string; url?: string }[]
|
||||
tags: number[]
|
||||
seasons?: SonarrSeason[]
|
||||
languageProfileId?: number
|
||||
addOptions: {
|
||||
searchForMissingEpisodes: boolean
|
||||
searchForCutoffUnmetEpisodes: boolean
|
||||
monitor: 'all'
|
||||
}
|
||||
} = {
|
||||
tvdbId: payload.tvdbId,
|
||||
qualityProfileId: payload.qualityProfileId,
|
||||
rootFolderPath: payload.rootFolderPath,
|
||||
monitored: payload.monitored ?? true,
|
||||
seasonFolder: payload.seasonFolder ?? true,
|
||||
title: payload.title,
|
||||
titleSlug: payload.titleSlug,
|
||||
year: payload.year,
|
||||
images: payload.images || [],
|
||||
tags: payload.tags || [],
|
||||
seasons: payload.seasons,
|
||||
addOptions: {
|
||||
searchForMissingEpisodes: payload.searchOnAdd ?? true,
|
||||
searchForCutoffUnmetEpisodes: false,
|
||||
monitor: 'all',
|
||||
},
|
||||
}
|
||||
if (payload.languageProfileId != null) body.languageProfileId = payload.languageProfileId
|
||||
return body
|
||||
}
|
||||
|
||||
class SonarrClient {
|
||||
instance: ArrInstance
|
||||
constructor(instance: ArrInstance) {
|
||||
this.instance = instance
|
||||
}
|
||||
|
||||
private url(path: string): string {
|
||||
const base = this.instance.baseUrl.replace(/\/+$/, '')
|
||||
return `${base}/api/v3${path}`
|
||||
}
|
||||
|
||||
private async req<T>(path: string, init: RequestInit = {}): Promise<T | null> {
|
||||
try {
|
||||
const res = await fetch(this.url(path), {
|
||||
...init,
|
||||
headers: {
|
||||
'X-Api-Key': this.instance.apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
...(init.headers || {}),
|
||||
},
|
||||
})
|
||||
if (!res.ok) return null
|
||||
const text = await res.text()
|
||||
return text ? (JSON.parse(text) as T) : (null as unknown as T)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
systemStatus() { return this.req<SonarrSystemStatus>('/system/status') }
|
||||
qualityProfiles() { return this.req<SonarrQualityProfile[]>('/qualityprofile') }
|
||||
languageProfiles() { return this.req<SonarrLanguageProfile[]>('/languageprofile') }
|
||||
rootFolders() { return this.req<SonarrRootFolder[]>('/rootfolder') }
|
||||
tags() { return this.req<SonarrTag[]>('/tag') }
|
||||
series() { return this.req<SonarrSeries[]>('/series') }
|
||||
queue() { return this.req<{ records: SonarrQueueItem[] }>('/queue') }
|
||||
|
||||
/**
|
||||
* Series lookup by TVDB id (preferred), IMDB id (`imdb:tt...`), or
|
||||
* TMDB id (`tmdb:N`). Sonarr accepts the term-prefix syntax for the
|
||||
* latter two.
|
||||
*/
|
||||
lookup(term: string) {
|
||||
return this.req<SonarrLookupResult[]>(
|
||||
`/series/lookup?term=${encodeURIComponent(term)}`,
|
||||
)
|
||||
}
|
||||
lookupByTvdbId(tvdbId: number) { return this.lookup(`tvdb:${tvdbId}`) }
|
||||
lookupByImdbId(imdbId: string) { return this.lookup(`imdb:${imdbId}`) }
|
||||
lookupByTmdbId(tmdbId: number) { return this.lookup(`tmdb:${tmdbId}`) }
|
||||
|
||||
async addSeries(payload: AddSonarrSeriesPayload): Promise<SonarrSeries | null> {
|
||||
const body = buildAddSonarrSeriesBody(payload)
|
||||
return this.req<SonarrSeries>('/series', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing series - used when the user changes which seasons
|
||||
* to monitor or swaps quality profile.
|
||||
*/
|
||||
updateSeries(series: SonarrSeries) {
|
||||
if (!series.id) return Promise.resolve(null)
|
||||
return this.req<SonarrSeries>(`/series/${series.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(series),
|
||||
})
|
||||
}
|
||||
|
||||
removeSeries(id: number, deleteFiles = false) {
|
||||
return this.req<void>(
|
||||
`/series/${id}?deleteFiles=${deleteFiles ? 'true' : 'false'}&addImportListExclusion=false`,
|
||||
{ method: 'DELETE' },
|
||||
)
|
||||
}
|
||||
|
||||
searchSeries(id: number) {
|
||||
return this.req<void>('/command', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: 'SeriesSearch', seriesId: id }),
|
||||
})
|
||||
}
|
||||
|
||||
searchSeason(seriesId: number, seasonNumber: number) {
|
||||
return this.req<void>('/command', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name: 'SeasonSearch', seriesId, seasonNumber }),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function sonarrClient(instance: ArrInstance) {
|
||||
return new SonarrClient(instance)
|
||||
}
|
||||
+712
@@ -0,0 +1,712 @@
|
||||
import { readSecret } from '../lib/sensitive-storage'
|
||||
|
||||
const BASE_URL = 'https://api.themoviedb.org/3'
|
||||
const IMG_BASE = 'https://image.tmdb.org/t/p'
|
||||
|
||||
/* ────────────────────────────────────────────────────────────── */
|
||||
/* Types */
|
||||
/* ────────────────────────────────────────────────────────────── */
|
||||
|
||||
export interface TmdbProductionCompany {
|
||||
id: number
|
||||
name: string
|
||||
logo_path: string | null
|
||||
origin_country: string
|
||||
}
|
||||
|
||||
export interface TmdbNetwork {
|
||||
id: number
|
||||
name: string
|
||||
logo_path: string | null
|
||||
origin_country: string
|
||||
}
|
||||
|
||||
export interface TmdbCreator {
|
||||
id: number
|
||||
credit_id: string
|
||||
name: string
|
||||
gender: number
|
||||
profile_path: string | null
|
||||
}
|
||||
|
||||
export interface TmdbSpokenLanguage {
|
||||
iso_639_1: string
|
||||
english_name: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface TmdbCountry {
|
||||
iso_3166_1: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface TmdbGenre {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface TmdbCollectionRef {
|
||||
id: number
|
||||
name: string
|
||||
poster_path: string | null
|
||||
backdrop_path: string | null
|
||||
}
|
||||
|
||||
export interface TmdbCollection {
|
||||
id: number
|
||||
name: string
|
||||
overview: string
|
||||
poster_path: string | null
|
||||
backdrop_path: string | null
|
||||
parts: TmdbMovie[]
|
||||
}
|
||||
|
||||
export interface TmdbVideo {
|
||||
id: string
|
||||
iso_639_1: string
|
||||
iso_3166_1: string
|
||||
key: string
|
||||
name: string
|
||||
site: string
|
||||
size: number
|
||||
type: string
|
||||
official: boolean
|
||||
published_at: string
|
||||
}
|
||||
|
||||
export interface TmdbCastMember {
|
||||
id: number
|
||||
name: string
|
||||
character: string
|
||||
profile_path: string | null
|
||||
order: number
|
||||
cast_id?: number
|
||||
credit_id?: string
|
||||
gender?: number
|
||||
known_for_department?: string
|
||||
popularity?: number
|
||||
department?: string
|
||||
job?: string
|
||||
}
|
||||
|
||||
export interface TmdbAggregateRole {
|
||||
credit_id: string
|
||||
character: string
|
||||
episode_count: number
|
||||
}
|
||||
|
||||
export interface TmdbAggregateCastMember {
|
||||
id: number
|
||||
name: string
|
||||
profile_path: string | null
|
||||
roles: TmdbAggregateRole[]
|
||||
total_episode_count: number
|
||||
order: number
|
||||
gender?: number
|
||||
}
|
||||
|
||||
export interface TmdbAggregateJob {
|
||||
credit_id: string
|
||||
job: string
|
||||
episode_count: number
|
||||
}
|
||||
|
||||
export interface TmdbAggregateCrewMember {
|
||||
id: number
|
||||
name: string
|
||||
profile_path: string | null
|
||||
jobs: TmdbAggregateJob[]
|
||||
total_episode_count: number
|
||||
department: string
|
||||
gender?: number
|
||||
}
|
||||
|
||||
export interface TmdbCredits {
|
||||
cast: TmdbCastMember[]
|
||||
crew: TmdbCastMember[]
|
||||
}
|
||||
|
||||
export interface TmdbAggregateCredits {
|
||||
cast: TmdbAggregateCastMember[]
|
||||
crew: TmdbAggregateCrewMember[]
|
||||
}
|
||||
|
||||
export interface TmdbKeyword {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface TmdbExternalIds {
|
||||
imdb_id?: string | null
|
||||
tvdb_id?: number | null
|
||||
tvrage_id?: number | null
|
||||
facebook_id?: string | null
|
||||
instagram_id?: string | null
|
||||
twitter_id?: string | null
|
||||
wikidata_id?: string | null
|
||||
}
|
||||
|
||||
export interface TmdbReleaseDate {
|
||||
certification: string
|
||||
iso_639_1: string
|
||||
note: string
|
||||
release_date: string
|
||||
type: number
|
||||
}
|
||||
|
||||
export interface TmdbReleaseDatesByCountry {
|
||||
iso_3166_1: string
|
||||
release_dates: TmdbReleaseDate[]
|
||||
}
|
||||
|
||||
export interface TmdbContentRating {
|
||||
iso_3166_1: string
|
||||
rating: string
|
||||
descriptors?: string[]
|
||||
}
|
||||
|
||||
export interface TmdbReviewAuthor {
|
||||
name: string
|
||||
username: string
|
||||
avatar_path: string | null
|
||||
rating: number | null
|
||||
}
|
||||
|
||||
export interface TmdbReview {
|
||||
id: string
|
||||
author: string
|
||||
author_details: TmdbReviewAuthor
|
||||
content: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface TmdbImage {
|
||||
aspect_ratio: number
|
||||
file_path: string
|
||||
height: number
|
||||
width: number
|
||||
iso_639_1: string | null
|
||||
vote_average: number
|
||||
vote_count: number
|
||||
}
|
||||
|
||||
export interface TmdbImages {
|
||||
backdrops: TmdbImage[]
|
||||
posters: TmdbImage[]
|
||||
logos: TmdbImage[]
|
||||
stills?: TmdbImage[]
|
||||
profiles?: TmdbImage[]
|
||||
}
|
||||
|
||||
export interface TmdbAlternativeTitle {
|
||||
iso_3166_1: string
|
||||
title: string
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface TmdbProvider {
|
||||
display_priority: number
|
||||
logo_path: string | null
|
||||
provider_id: number
|
||||
provider_name: string
|
||||
}
|
||||
|
||||
export interface TmdbProviderRegion {
|
||||
link: string
|
||||
flatrate?: TmdbProvider[]
|
||||
rent?: TmdbProvider[]
|
||||
buy?: TmdbProvider[]
|
||||
ads?: TmdbProvider[]
|
||||
free?: TmdbProvider[]
|
||||
}
|
||||
|
||||
export interface TmdbWatchProviders {
|
||||
results: Record<string, TmdbProviderRegion>
|
||||
}
|
||||
|
||||
export interface TmdbEpisodeGroup {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
episode_count: number
|
||||
group_count: number
|
||||
network: TmdbNetwork | null
|
||||
/** 1 Original air, 2 Absolute, 3 DVD, 4 Digital, 5 Story arc, 6 Production, 7 TV */
|
||||
type: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Full payload for a single episode group, returned by GET /tv/episode_group/{id}.
|
||||
* Each `groups[]` entry is a "season" within the group, containing an
|
||||
* ordered list of episodes referencing original (season, episode) pairs.
|
||||
*/
|
||||
export interface TmdbEpisodeGroupEntry {
|
||||
id: string
|
||||
name: string
|
||||
order: number
|
||||
episodes: Array<{
|
||||
id: number
|
||||
name: string
|
||||
overview: string
|
||||
air_date: string | null
|
||||
season_number: number
|
||||
episode_number: number
|
||||
/** Override numbers within this group, sometimes resequenced. */
|
||||
order: number
|
||||
runtime?: number | null
|
||||
still_path?: string | null
|
||||
}>
|
||||
locked: boolean
|
||||
}
|
||||
|
||||
export interface TmdbEpisodeGroupFull extends TmdbEpisodeGroup {
|
||||
groups: TmdbEpisodeGroupEntry[]
|
||||
}
|
||||
|
||||
export interface TmdbMovie {
|
||||
id: number
|
||||
title: string
|
||||
original_title: string
|
||||
overview: string
|
||||
poster_path: string | null
|
||||
backdrop_path: string | null
|
||||
release_date: string
|
||||
runtime: number
|
||||
vote_average: number
|
||||
vote_count: number
|
||||
popularity?: number
|
||||
genres: TmdbGenre[]
|
||||
status?: string
|
||||
tagline?: string
|
||||
budget?: number
|
||||
revenue?: number
|
||||
homepage?: string
|
||||
imdb_id?: string
|
||||
original_language?: string
|
||||
spoken_languages?: TmdbSpokenLanguage[]
|
||||
production_companies?: TmdbProductionCompany[]
|
||||
production_countries?: TmdbCountry[]
|
||||
belongs_to_collection?: TmdbCollectionRef | null
|
||||
adult?: boolean
|
||||
|
||||
// append_to_response fields
|
||||
videos?: { results: TmdbVideo[] }
|
||||
credits?: TmdbCredits
|
||||
keywords?: { keywords: TmdbKeyword[] }
|
||||
recommendations?: TmdbPagedResults<TmdbMovie>
|
||||
similar?: TmdbPagedResults<TmdbMovie>
|
||||
release_dates?: { results: TmdbReleaseDatesByCountry[] }
|
||||
external_ids?: TmdbExternalIds
|
||||
images?: TmdbImages
|
||||
alternative_titles?: { titles: TmdbAlternativeTitle[] }
|
||||
reviews?: TmdbPagedResults<TmdbReview>
|
||||
'watch/providers'?: TmdbWatchProviders
|
||||
}
|
||||
|
||||
export interface TmdbTvShow {
|
||||
id: number
|
||||
name: string
|
||||
original_name: string
|
||||
overview: string
|
||||
poster_path: string | null
|
||||
backdrop_path: string | null
|
||||
first_air_date: string
|
||||
last_air_date?: string | null
|
||||
vote_average: number
|
||||
vote_count: number
|
||||
popularity?: number
|
||||
number_of_seasons: number
|
||||
number_of_episodes?: number
|
||||
episode_run_time?: number[]
|
||||
genres: TmdbGenre[]
|
||||
status?: string
|
||||
tagline?: string
|
||||
type?: string
|
||||
in_production?: boolean
|
||||
homepage?: string
|
||||
original_language?: string
|
||||
origin_country?: string[]
|
||||
languages?: string[]
|
||||
spoken_languages?: TmdbSpokenLanguage[]
|
||||
production_companies?: TmdbProductionCompany[]
|
||||
production_countries?: TmdbCountry[]
|
||||
networks?: TmdbNetwork[]
|
||||
created_by?: TmdbCreator[]
|
||||
seasons?: TmdbSeasonSummary[]
|
||||
last_episode_to_air?: TmdbEpisode | null
|
||||
next_episode_to_air?: TmdbEpisode | null
|
||||
adult?: boolean
|
||||
|
||||
// append_to_response fields
|
||||
videos?: { results: TmdbVideo[] }
|
||||
credits?: TmdbCredits
|
||||
aggregate_credits?: TmdbAggregateCredits
|
||||
keywords?: { results: TmdbKeyword[] }
|
||||
recommendations?: TmdbPagedResults<TmdbTvShow>
|
||||
similar?: TmdbPagedResults<TmdbTvShow>
|
||||
content_ratings?: { results: TmdbContentRating[] }
|
||||
external_ids?: TmdbExternalIds
|
||||
images?: TmdbImages
|
||||
alternative_titles?: { results: TmdbAlternativeTitle[] }
|
||||
reviews?: TmdbPagedResults<TmdbReview>
|
||||
'watch/providers'?: TmdbWatchProviders
|
||||
episode_groups?: { results: TmdbEpisodeGroup[] }
|
||||
}
|
||||
|
||||
export interface TmdbSeasonSummary {
|
||||
id: number
|
||||
name: string
|
||||
overview: string
|
||||
poster_path: string | null
|
||||
season_number: number
|
||||
episode_count: number
|
||||
air_date: string | null
|
||||
vote_average?: number
|
||||
}
|
||||
|
||||
export interface TmdbSeason extends TmdbSeasonSummary {
|
||||
episodes: TmdbEpisode[]
|
||||
// append_to_response
|
||||
credits?: TmdbCredits
|
||||
aggregate_credits?: TmdbAggregateCredits
|
||||
external_ids?: TmdbExternalIds
|
||||
images?: TmdbImages
|
||||
videos?: { results: TmdbVideo[] }
|
||||
}
|
||||
|
||||
export interface TmdbEpisode {
|
||||
id: number
|
||||
name: string
|
||||
overview: string
|
||||
still_path: string | null
|
||||
episode_number: number
|
||||
season_number: number
|
||||
air_date: string | null
|
||||
vote_average: number
|
||||
vote_count?: number
|
||||
runtime?: number | null
|
||||
production_code?: string
|
||||
show_id?: number
|
||||
guest_stars?: TmdbCastMember[]
|
||||
crew?: TmdbCastMember[]
|
||||
// append_to_response
|
||||
credits?: TmdbCredits
|
||||
external_ids?: TmdbExternalIds
|
||||
images?: TmdbImages
|
||||
videos?: { results: TmdbVideo[] }
|
||||
}
|
||||
|
||||
export interface TmdbPerson {
|
||||
id: number
|
||||
name: string
|
||||
also_known_as: string[]
|
||||
biography: string
|
||||
birthday: string | null
|
||||
deathday: string | null
|
||||
gender: number
|
||||
homepage: string | null
|
||||
imdb_id: string | null
|
||||
known_for_department: string
|
||||
place_of_birth: string | null
|
||||
popularity: number
|
||||
profile_path: string | null
|
||||
adult?: boolean
|
||||
|
||||
// append_to_response
|
||||
combined_credits?: TmdbCombinedCredits
|
||||
external_ids?: TmdbExternalIds
|
||||
images?: TmdbImages
|
||||
}
|
||||
|
||||
export interface TmdbCombinedCreditCast {
|
||||
id: number
|
||||
credit_id: string
|
||||
character: string
|
||||
media_type: 'movie' | 'tv'
|
||||
title?: string
|
||||
name?: string
|
||||
poster_path: string | null
|
||||
backdrop_path: string | null
|
||||
release_date?: string
|
||||
first_air_date?: string
|
||||
vote_average: number
|
||||
popularity?: number
|
||||
episode_count?: number
|
||||
}
|
||||
|
||||
export interface TmdbCombinedCreditCrew extends TmdbCombinedCreditCast {
|
||||
job: string
|
||||
department: string
|
||||
}
|
||||
|
||||
export interface TmdbCombinedCredits {
|
||||
cast: TmdbCombinedCreditCast[]
|
||||
crew: TmdbCombinedCreditCrew[]
|
||||
}
|
||||
|
||||
export interface TmdbPagedResults<T> {
|
||||
page: number
|
||||
results: T[]
|
||||
total_pages: number
|
||||
total_results: number
|
||||
}
|
||||
|
||||
export interface TmdbSearchMultiResult extends TmdbMovie {
|
||||
media_type: 'movie' | 'tv' | 'person'
|
||||
name?: string
|
||||
first_air_date?: string
|
||||
profile_path?: string | null
|
||||
known_for?: any[]
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────── */
|
||||
/* Fetcher */
|
||||
/* ────────────────────────────────────────────────────────────── */
|
||||
|
||||
// Build-time default, read from VITE_TMDB_API_KEY in .env. Lets the
|
||||
// app ship working out of the box; users can still override per-install
|
||||
// via Settings.
|
||||
const BUILT_IN_TMDB_KEY = (import.meta.env.VITE_TMDB_API_KEY || '').trim()
|
||||
|
||||
const apiKey = () => {
|
||||
try {
|
||||
const direct = readSecret('jf_tmdb_key')
|
||||
if (direct) return direct
|
||||
const persisted = readSecret('jf_prefs')
|
||||
if (persisted) {
|
||||
const parsed = JSON.parse(persisted)
|
||||
const key = parsed?.state?.tmdbApiKey
|
||||
if (typeof key === 'string' && key) return key
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return BUILT_IN_TMDB_KEY
|
||||
}
|
||||
|
||||
export function hasTmdbKey(): boolean {
|
||||
return !!apiKey()
|
||||
}
|
||||
|
||||
export function isUsingBuiltInTmdbKey(): boolean {
|
||||
try {
|
||||
const direct = readSecret('jf_tmdb_key')
|
||||
if (direct) return false
|
||||
const persisted = readSecret('jf_prefs')
|
||||
if (persisted) {
|
||||
const parsed = JSON.parse(persisted)
|
||||
const key = parsed?.state?.tmdbApiKey
|
||||
if (typeof key === 'string' && key) return false
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return !!BUILT_IN_TMDB_KEY
|
||||
}
|
||||
|
||||
async function tmdbFetch<T>(path: string, params: Record<string, string> = {}): Promise<T | null> {
|
||||
const key = apiKey()
|
||||
if (!key) return null
|
||||
|
||||
const url = new URL(`${BASE_URL}${path}`)
|
||||
url.searchParams.set('api_key', key)
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
url.searchParams.set(k, v)
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(url.toString())
|
||||
if (!res.ok) return null
|
||||
return res.json() as Promise<T>
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getTmdbImageUrl(path: string | null | undefined, size: string = 'w500'): string {
|
||||
if (!path) return ''
|
||||
return `${IMG_BASE}/${size}${path}`
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────── */
|
||||
/* Append-to-response combos (one consolidated request each) */
|
||||
/* ────────────────────────────────────────────────────────────── */
|
||||
|
||||
const MOVIE_APPENDS = [
|
||||
'videos',
|
||||
'credits',
|
||||
'similar',
|
||||
'recommendations',
|
||||
'keywords',
|
||||
'release_dates',
|
||||
'external_ids',
|
||||
'images',
|
||||
'alternative_titles',
|
||||
'reviews',
|
||||
'watch/providers',
|
||||
].join(',')
|
||||
|
||||
const TV_APPENDS = [
|
||||
'videos',
|
||||
'credits',
|
||||
'aggregate_credits',
|
||||
'similar',
|
||||
'recommendations',
|
||||
'keywords',
|
||||
'content_ratings',
|
||||
'external_ids',
|
||||
'images',
|
||||
'alternative_titles',
|
||||
'reviews',
|
||||
'watch/providers',
|
||||
'episode_groups',
|
||||
].join(',')
|
||||
|
||||
const SEASON_APPENDS = ['credits', 'aggregate_credits', 'external_ids', 'images', 'videos'].join(',')
|
||||
const EPISODE_APPENDS = ['credits', 'external_ids', 'images', 'videos'].join(',')
|
||||
const PERSON_APPENDS = ['combined_credits', 'external_ids', 'images'].join(',')
|
||||
|
||||
export async function getMovieFull(id: number) {
|
||||
return tmdbFetch<TmdbMovie>(`/movie/${id}`, {
|
||||
append_to_response: MOVIE_APPENDS,
|
||||
include_image_language: 'en,null',
|
||||
})
|
||||
}
|
||||
|
||||
export async function getTvShowFull(id: number) {
|
||||
return tmdbFetch<TmdbTvShow>(`/tv/${id}`, {
|
||||
append_to_response: TV_APPENDS,
|
||||
include_image_language: 'en,null',
|
||||
})
|
||||
}
|
||||
|
||||
export async function getSeasonFull(tvId: number, seasonNum: number) {
|
||||
return tmdbFetch<TmdbSeason>(`/tv/${tvId}/season/${seasonNum}`, {
|
||||
append_to_response: SEASON_APPENDS,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getEpisodeFull(tvId: number, seasonNum: number, episodeNum: number) {
|
||||
return tmdbFetch<TmdbEpisode>(`/tv/${tvId}/season/${seasonNum}/episode/${episodeNum}`, {
|
||||
append_to_response: EPISODE_APPENDS,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getPersonFull(id: number) {
|
||||
return tmdbFetch<TmdbPerson>(`/person/${id}`, {
|
||||
append_to_response: PERSON_APPENDS,
|
||||
})
|
||||
}
|
||||
|
||||
export async function getCollection(id: number) {
|
||||
return tmdbFetch<TmdbCollection>(`/collection/${id}`)
|
||||
}
|
||||
|
||||
export async function getEpisodeGroup(id: string) {
|
||||
return tmdbFetch<TmdbEpisodeGroupFull>(`/tv/episode_group/${id}`)
|
||||
}
|
||||
|
||||
export async function getTrending(
|
||||
mediaType: 'all' | 'movie' | 'tv' | 'person' = 'all',
|
||||
window: 'day' | 'week' = 'week',
|
||||
) {
|
||||
return tmdbFetch<TmdbPagedResults<TmdbSearchMultiResult>>(`/trending/${mediaType}/${window}`)
|
||||
}
|
||||
|
||||
export async function searchMulti(query: string) {
|
||||
return tmdbFetch<TmdbPagedResults<TmdbSearchMultiResult>>('/search/multi', { query })
|
||||
}
|
||||
|
||||
export async function discoverMovies(params: Record<string, string> = {}) {
|
||||
return tmdbFetch<TmdbPagedResults<TmdbMovie>>('/discover/movie', params)
|
||||
}
|
||||
|
||||
export async function discoverTv(params: Record<string, string> = {}) {
|
||||
return tmdbFetch<TmdbPagedResults<TmdbTvShow>>('/discover/tv', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* Items hitting theatres / streaming releases in the near future. TMDB
|
||||
* `/movie/upcoming` is region-aware via the `region` param.
|
||||
*/
|
||||
export async function getUpcomingMovies(region?: string, page = 1) {
|
||||
return tmdbFetch<TmdbPagedResults<TmdbMovie>>('/movie/upcoming', {
|
||||
page: String(page),
|
||||
...(region ? { region } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* TMDB top-rated lists. The community-vote-driven canon, useful as a
|
||||
* "have I missed any of these?" prompt against the user's library.
|
||||
*/
|
||||
export async function getTopRatedMovies(page = 1) {
|
||||
return tmdbFetch<TmdbPagedResults<TmdbMovie>>('/movie/top_rated', {
|
||||
page: String(page),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getTopRatedTv(page = 1) {
|
||||
return tmdbFetch<TmdbPagedResults<TmdbTvShow>>('/tv/top_rated', {
|
||||
page: String(page),
|
||||
})
|
||||
}
|
||||
|
||||
/* ────────────────────────────────────────────────────────────── */
|
||||
/* Backwards-compatible thin wrappers */
|
||||
/* Existing call sites still work but everything routes through */
|
||||
/* the consolidated full-payload requests above. */
|
||||
/* ────────────────────────────────────────────────────────────── */
|
||||
|
||||
export async function getMovieDetails(id: number) {
|
||||
return getMovieFull(id)
|
||||
}
|
||||
|
||||
export async function getMovieCredits(id: number) {
|
||||
const m = await getMovieFull(id)
|
||||
return m?.credits ?? null
|
||||
}
|
||||
|
||||
export async function getMovieVideos(id: number) {
|
||||
const m = await getMovieFull(id)
|
||||
return m?.videos ?? null
|
||||
}
|
||||
|
||||
export async function getSimilarMovies(id: number) {
|
||||
const m = await getMovieFull(id)
|
||||
return m?.similar ?? null
|
||||
}
|
||||
|
||||
export async function getTvShowDetails(id: number) {
|
||||
return getTvShowFull(id)
|
||||
}
|
||||
|
||||
export async function getTvShowCredits(id: number) {
|
||||
const t = await getTvShowFull(id)
|
||||
return t?.credits ?? null
|
||||
}
|
||||
|
||||
export async function getSeasonDetails(tvId: number, seasonNum: number) {
|
||||
return getSeasonFull(tvId, seasonNum)
|
||||
}
|
||||
|
||||
/* Search result alias for legacy callers */
|
||||
export type TmdbSearchResult = TmdbPagedResults<TmdbSearchMultiResult>
|
||||
|
||||
/**
|
||||
* Pick the best logo from a TMDB images.logos array. We already request
|
||||
* `include_image_language=en,null` so the input is pre-filtered to
|
||||
* English + no-language variants. Within that set, we prefer English
|
||||
* logos (they include the wordmark) then fall back to language-less
|
||||
* variants (typically symbol-only marks). Ties broken by vote_average.
|
||||
*/
|
||||
export function pickTmdbLogo(logos?: TmdbImage[] | null): TmdbImage | null {
|
||||
if (!logos || logos.length === 0) return null
|
||||
const sorted = [...logos].sort((a, b) => (b.vote_average || 0) - (a.vote_average || 0))
|
||||
return (
|
||||
sorted.find(l => l.iso_639_1 === 'en') ||
|
||||
sorted.find(l => l.iso_639_1 === null) ||
|
||||
sorted[0] ||
|
||||
null
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* TVmaze REST client. No API key required and no rate limit advertised.
|
||||
* Source: https://www.tvmaze.com/api
|
||||
*
|
||||
* Useful endpoints:
|
||||
* - /lookup/shows?imdb=ttXXXXX lookup by IMDB id (best match for our flow)
|
||||
* - /lookup/shows?thetvdb=NNNNN lookup by TVDB id
|
||||
* - /shows/{id}?embed[]=... detail with cast/episodes/seasons/network
|
||||
* - /shows/{id}/episodes ordered episode list
|
||||
* - /shows/{id}/episodebynumber?... single episode by S/E
|
||||
* - /schedule/web?country=US&date=... streaming schedule
|
||||
*
|
||||
* Fields we actually surface: network name + logo + country, schedule
|
||||
* (`days` + `time`), official site, runtime, status, summary (HTML), and
|
||||
* `previousepisode` / `nextepisode` air dates.
|
||||
*/
|
||||
|
||||
const BASE_URL = 'https://api.tvmaze.com'
|
||||
|
||||
export interface TvmazeNetwork {
|
||||
id: number
|
||||
name: string
|
||||
country?: { name: string; code: string; timezone: string }
|
||||
officialSite?: string | null
|
||||
}
|
||||
|
||||
export interface TvmazeSchedule {
|
||||
time?: string
|
||||
days?: string[]
|
||||
}
|
||||
|
||||
export interface TvmazeImage {
|
||||
medium?: string
|
||||
original?: string
|
||||
}
|
||||
|
||||
export interface TvmazeShow {
|
||||
id: number
|
||||
url: string
|
||||
name: string
|
||||
type?: string
|
||||
language?: string
|
||||
genres?: string[]
|
||||
status?: string
|
||||
runtime?: number | null
|
||||
averageRuntime?: number | null
|
||||
premiered?: string | null
|
||||
ended?: string | null
|
||||
officialSite?: string | null
|
||||
schedule?: TvmazeSchedule
|
||||
rating?: { average: number | null }
|
||||
network?: TvmazeNetwork | null
|
||||
webChannel?: TvmazeNetwork | null
|
||||
image?: TvmazeImage | null
|
||||
summary?: string | null
|
||||
externals?: { tvrage?: number | null; thetvdb?: number | null; imdb?: string | null }
|
||||
_embedded?: {
|
||||
cast?: TvmazeCastMember[]
|
||||
episodes?: TvmazeEpisode[]
|
||||
nextepisode?: TvmazeEpisode | null
|
||||
previousepisode?: TvmazeEpisode | null
|
||||
}
|
||||
}
|
||||
|
||||
export interface TvmazeCastMember {
|
||||
person: { id: number; name: string; image?: TvmazeImage }
|
||||
character: { id: number; name: string; image?: TvmazeImage }
|
||||
}
|
||||
|
||||
export interface TvmazeEpisode {
|
||||
id: number
|
||||
name: string
|
||||
season: number
|
||||
number: number | null
|
||||
type?: string
|
||||
airdate?: string | null
|
||||
airtime?: string | null
|
||||
runtime?: number | null
|
||||
image?: TvmazeImage | null
|
||||
summary?: string | null
|
||||
}
|
||||
|
||||
async function fetchJson<T>(path: string): Promise<T | null> {
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}${path}`)
|
||||
if (!res.ok) return null
|
||||
return (await res.json()) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function tvmazeLookupByImdbId(imdbId: string): Promise<TvmazeShow | null> {
|
||||
if (!imdbId) return Promise.resolve(null)
|
||||
return fetchJson<TvmazeShow>(`/lookup/shows?imdb=${encodeURIComponent(imdbId)}`)
|
||||
}
|
||||
|
||||
export function tvmazeLookupByTvdbId(tvdbId: string | number): Promise<TvmazeShow | null> {
|
||||
if (!tvdbId) return Promise.resolve(null)
|
||||
return fetchJson<TvmazeShow>(`/lookup/shows?thetvdb=${tvdbId}`)
|
||||
}
|
||||
|
||||
export function tvmazeShow(id: number, embed: string[] = ['cast', 'nextepisode', 'previousepisode']): Promise<TvmazeShow | null> {
|
||||
if (!id) return Promise.resolve(null)
|
||||
const embeds = embed.map(e => `embed[]=${e}`).join('&')
|
||||
return fetchJson<TvmazeShow>(`/shows/${id}?${embeds}`)
|
||||
}
|
||||
|
||||
export function tvmazeStripHtml(s: string | null | undefined): string {
|
||||
if (!s) return ''
|
||||
return s.replace(/<[^>]+>/g, '').trim()
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
import type {
|
||||
BaseItemDto,
|
||||
BaseItemDtoQueryResult,
|
||||
PlaybackInfoResponse,
|
||||
UserDto,
|
||||
} from '@jellyfin/sdk/lib/generated-client/models'
|
||||
|
||||
export type { BaseItemDto, BaseItemDtoQueryResult, PlaybackInfoResponse, UserDto }
|
||||
|
||||
export interface AuthState {
|
||||
serverUrl: string
|
||||
token: string
|
||||
userId: string
|
||||
userName?: string
|
||||
}
|
||||
|
||||
export interface HomeShowSettings {
|
||||
continueWatching: boolean
|
||||
nextUp: boolean
|
||||
recentlyAdded: boolean
|
||||
trendingWeek: boolean
|
||||
topRated: boolean
|
||||
watchedRecently: boolean
|
||||
surpriseMe: boolean
|
||||
watchlist: boolean
|
||||
becauseYouWatched: boolean
|
||||
personSpotlights: boolean
|
||||
trendingToday: boolean
|
||||
criticallyAcclaimed: boolean
|
||||
genreDeepDive: boolean
|
||||
cultClassics: boolean
|
||||
yearEndBestOf: boolean
|
||||
foreignCinema: boolean
|
||||
documentaryPicks: boolean
|
||||
awardWinnersMissing: boolean
|
||||
discoverCanon: boolean
|
||||
letterboxdLists: boolean
|
||||
comingSoon: boolean
|
||||
smartShelves: boolean
|
||||
moodPicker: boolean
|
||||
timeOfDay: boolean
|
||||
hiddenGems: boolean
|
||||
untouched: boolean
|
||||
decadeRows: boolean
|
||||
featuredGenres: boolean
|
||||
studios: boolean
|
||||
networks: boolean
|
||||
}
|
||||
|
||||
export interface DetailShowSettings {
|
||||
awards: boolean
|
||||
filmingLocations: boolean
|
||||
trivia: boolean
|
||||
videos: boolean
|
||||
personal: boolean
|
||||
diary: boolean
|
||||
episodeExtras: boolean
|
||||
collectionMeter: boolean
|
||||
}
|
||||
|
||||
export interface EpisodeShowSettings {
|
||||
fillerChips: boolean
|
||||
ratingChips: boolean
|
||||
sparklines: boolean
|
||||
spoilerBlur: boolean
|
||||
}
|
||||
|
||||
export interface EpisodeBehaviorSettings {
|
||||
swipeActions: boolean
|
||||
sortMode: 'order' | 'rating-desc' | 'rating-asc'
|
||||
orderPreference: 'aired' | 'production' | 'dvd' | 'story' | 'absolute' | 'tv'
|
||||
}
|
||||
|
||||
export interface EpisodeRecapSettings {
|
||||
card: boolean
|
||||
gapDays: number
|
||||
}
|
||||
|
||||
export interface EpisodeSettings {
|
||||
show: EpisodeShowSettings
|
||||
behavior: EpisodeBehaviorSettings
|
||||
recap: EpisodeRecapSettings
|
||||
}
|
||||
|
||||
export interface EndVideoShowSettings {
|
||||
moreLikeThis: boolean
|
||||
antiRec: boolean
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
// Playback
|
||||
autoplayNext: boolean
|
||||
skipIntros: boolean
|
||||
skipCredits: boolean
|
||||
resumeThresholdSec: number
|
||||
sleepTimerMinutes: number
|
||||
areYouStillWatching: boolean
|
||||
// 1.0 default. Vidstack's playbackRate setter applies this; persisted so
|
||||
// a user who likes 1.5x doesn't have to set it on every episode.
|
||||
defaultPlaybackRate: number
|
||||
preserveAudioPitch: boolean
|
||||
// Pause when window loses focus, resume when it regains. Off by default.
|
||||
pauseOnBlur: boolean
|
||||
// Show "Resume from M:SS / Start over" prompt instead of silently
|
||||
// resuming when there's a saved position.
|
||||
showResumePrompt: boolean
|
||||
// Show end-of-video card (Replay / Episodes / Back) when the video ends
|
||||
// and there's no auto-advance target.
|
||||
endOfVideoCard: boolean
|
||||
// Play a trailer before starting a movie, when one is available.
|
||||
preRollTrailers: boolean
|
||||
// Per-shortcut-id override map. Empty by default; defaults come from the
|
||||
// shortcut registry.
|
||||
keyboardShortcuts: Record<string, string[]>
|
||||
|
||||
// Audio + subtitles
|
||||
subtitleLanguage: string
|
||||
audioLanguage: string
|
||||
subtitleMode: 'default' | 'always' | 'none'
|
||||
// Persistent player volume (0-1). Restored across episodes / sessions so
|
||||
// users don't have to re-set it every time they open the player.
|
||||
playerVolume: number
|
||||
// Volume boost beyond 100%, via Web Audio gain. 1.0..3.0.
|
||||
volumeBoost: number
|
||||
// Dynamic range compression for "night mode" - lifts quiet dialogue,
|
||||
// tames loud action.
|
||||
nightMode: boolean
|
||||
// Request server-side audio passthrough for codecs like TrueHD/DTS-HD.
|
||||
// Browser players rarely support this, but the flag tells the server
|
||||
// to prefer direct-stream over transcode for these tracks.
|
||||
audioPassthrough: boolean
|
||||
// Persistent default offset (ms). Per-session offsets live in the
|
||||
// player runtime store.
|
||||
audioDelayMs: number
|
||||
subtitleDelayMs: number
|
||||
subtitleFontSize: number
|
||||
subtitleFontFamily: 'sans' | 'serif' | 'mono'
|
||||
subtitleBackground: 'none' | 'subtle' | 'solid'
|
||||
subtitleEdge: 'none' | 'shadow' | 'outline'
|
||||
subtitlePosition: 'bottom' | 'top'
|
||||
subtitleColor: 'white' | 'yellow' | 'cyan'
|
||||
|
||||
// Picture filters (applied via CSS `filter` on the <video> element)
|
||||
videoBrightness: number
|
||||
videoContrast: number
|
||||
videoSaturation: number
|
||||
|
||||
// Display
|
||||
theme: 'dark'
|
||||
density: 'comfortable' | 'compact'
|
||||
// Interface zoom factor. Multiplied via CSS `zoom` on <html> so every
|
||||
// pixel-defined size scales together. 1.0 = native pixels.
|
||||
uiZoom: number
|
||||
reduceMotion: boolean
|
||||
accentColor: string
|
||||
showTechBadges: boolean
|
||||
showTmdbRatings: boolean
|
||||
heroAutoAdvance: boolean
|
||||
heroAutoAdvanceMs: number
|
||||
// Auto-play TMDB trailer in the poster card after a short hover.
|
||||
hoverTrailers: boolean
|
||||
// Pre-fire Jellyfin PlaybackInfo + first stream bytes on poster hover
|
||||
// so the click → playing transition feels instant.
|
||||
prebufferOnHover: boolean
|
||||
|
||||
// Discovery
|
||||
region: string
|
||||
tmdbApiKey: string
|
||||
// Optional personal key from https://fanart.tv/get-an-api-key/.
|
||||
fanartApiKey: string
|
||||
hideAdult: boolean
|
||||
defaultLanding: 'home' | 'movies' | 'shows'
|
||||
|
||||
// Section toggles grouped by surface
|
||||
home: { show: HomeShowSettings }
|
||||
detail: { show: DetailShowSettings }
|
||||
episode: EpisodeSettings
|
||||
endVideo: { show: EndVideoShowSettings }
|
||||
|
||||
// Quick-look right-click / long-press peek
|
||||
quickLookEnabled: boolean
|
||||
|
||||
// Privacy
|
||||
pushNotifications: boolean
|
||||
diagnosticLogging: boolean
|
||||
}
|
||||
|
||||
export const defaultHomeShow: HomeShowSettings = {
|
||||
continueWatching: true,
|
||||
nextUp: true,
|
||||
recentlyAdded: true,
|
||||
trendingWeek: true,
|
||||
topRated: true,
|
||||
watchedRecently: true,
|
||||
surpriseMe: true,
|
||||
watchlist: true,
|
||||
becauseYouWatched: true,
|
||||
personSpotlights: true,
|
||||
trendingToday: true,
|
||||
criticallyAcclaimed: true,
|
||||
genreDeepDive: true,
|
||||
cultClassics: true,
|
||||
yearEndBestOf: true,
|
||||
foreignCinema: true,
|
||||
documentaryPicks: true,
|
||||
awardWinnersMissing: true,
|
||||
discoverCanon: true,
|
||||
letterboxdLists: true,
|
||||
comingSoon: true,
|
||||
smartShelves: true,
|
||||
moodPicker: true,
|
||||
timeOfDay: true,
|
||||
hiddenGems: true,
|
||||
untouched: true,
|
||||
decadeRows: true,
|
||||
featuredGenres: true,
|
||||
studios: true,
|
||||
networks: true,
|
||||
}
|
||||
|
||||
export const defaultDetailShow: DetailShowSettings = {
|
||||
awards: true,
|
||||
filmingLocations: true,
|
||||
trivia: true,
|
||||
videos: true,
|
||||
personal: true,
|
||||
diary: true,
|
||||
episodeExtras: true,
|
||||
collectionMeter: true,
|
||||
}
|
||||
|
||||
export const defaultEpisode: EpisodeSettings = {
|
||||
show: {
|
||||
fillerChips: true,
|
||||
ratingChips: true,
|
||||
sparklines: true,
|
||||
spoilerBlur: true,
|
||||
},
|
||||
behavior: {
|
||||
swipeActions: true,
|
||||
sortMode: 'order',
|
||||
orderPreference: 'aired',
|
||||
},
|
||||
recap: {
|
||||
card: true,
|
||||
gapDays: 14,
|
||||
},
|
||||
}
|
||||
|
||||
export const defaultEndVideoShow: EndVideoShowSettings = {
|
||||
moreLikeThis: true,
|
||||
antiRec: true,
|
||||
}
|
||||
|
||||
export const defaultSettings: AppSettings = {
|
||||
autoplayNext: true,
|
||||
skipIntros: false,
|
||||
skipCredits: false,
|
||||
resumeThresholdSec: 5,
|
||||
sleepTimerMinutes: 0,
|
||||
areYouStillWatching: false,
|
||||
defaultPlaybackRate: 1,
|
||||
preserveAudioPitch: true,
|
||||
pauseOnBlur: false,
|
||||
showResumePrompt: true,
|
||||
endOfVideoCard: true,
|
||||
preRollTrailers: false,
|
||||
keyboardShortcuts: {},
|
||||
|
||||
subtitleLanguage: 'eng',
|
||||
audioLanguage: 'eng',
|
||||
subtitleMode: 'default',
|
||||
playerVolume: 1,
|
||||
volumeBoost: 1,
|
||||
nightMode: false,
|
||||
audioPassthrough: false,
|
||||
audioDelayMs: 0,
|
||||
subtitleDelayMs: 0,
|
||||
subtitleFontSize: 22,
|
||||
subtitleFontFamily: 'sans',
|
||||
subtitleBackground: 'subtle',
|
||||
subtitleEdge: 'shadow',
|
||||
subtitlePosition: 'bottom',
|
||||
subtitleColor: 'white',
|
||||
|
||||
videoBrightness: 1,
|
||||
videoContrast: 1,
|
||||
videoSaturation: 1,
|
||||
|
||||
theme: 'dark',
|
||||
density: 'comfortable',
|
||||
uiZoom: 1,
|
||||
reduceMotion: false,
|
||||
accentColor: '#F5B642',
|
||||
showTechBadges: true,
|
||||
showTmdbRatings: true,
|
||||
heroAutoAdvance: true,
|
||||
heroAutoAdvanceMs: 9000,
|
||||
hoverTrailers: true,
|
||||
prebufferOnHover: true,
|
||||
|
||||
region: '',
|
||||
tmdbApiKey: '',
|
||||
fanartApiKey: '',
|
||||
hideAdult: true,
|
||||
defaultLanding: 'home',
|
||||
|
||||
home: { show: defaultHomeShow },
|
||||
detail: { show: defaultDetailShow },
|
||||
episode: defaultEpisode,
|
||||
endVideo: { show: defaultEndVideoShow },
|
||||
|
||||
quickLookEnabled: true,
|
||||
|
||||
pushNotifications: false,
|
||||
diagnosticLogging: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a v1 flat-keyed preferences blob into the v2 nested shape.
|
||||
* Anything missing or malformed in the persisted state falls back to the
|
||||
* matching default, so partial / corrupt user data still loads.
|
||||
*/
|
||||
export function migrateV1Preferences(raw: Record<string, unknown>): AppSettings {
|
||||
const pick = <T,>(key: string, fallback: T): T =>
|
||||
(raw[key] as T | undefined) ?? fallback
|
||||
|
||||
const boolPair = <K extends string>(prefix: string, key: K, def: boolean): boolean =>
|
||||
pick(prefix + capitalize(key), def)
|
||||
|
||||
return {
|
||||
...defaultSettings,
|
||||
...raw,
|
||||
|
||||
home: {
|
||||
show: {
|
||||
...defaultHomeShow,
|
||||
...Object.fromEntries(
|
||||
(Object.keys(defaultHomeShow) as (keyof HomeShowSettings)[]).map(k => [
|
||||
k,
|
||||
boolPair('homeShow', k, defaultHomeShow[k]),
|
||||
]),
|
||||
),
|
||||
} as HomeShowSettings,
|
||||
},
|
||||
|
||||
detail: {
|
||||
show: {
|
||||
...defaultDetailShow,
|
||||
...Object.fromEntries(
|
||||
(Object.keys(defaultDetailShow) as (keyof DetailShowSettings)[]).map(k => [
|
||||
k,
|
||||
boolPair('detailShow', k, defaultDetailShow[k]),
|
||||
]),
|
||||
),
|
||||
} as DetailShowSettings,
|
||||
},
|
||||
|
||||
episode: {
|
||||
show: {
|
||||
fillerChips: pick('episodeShowFillerChips', defaultEpisode.show.fillerChips),
|
||||
ratingChips: pick('episodeShowRatingChips', defaultEpisode.show.ratingChips),
|
||||
sparklines: pick('episodeShowSparklines', defaultEpisode.show.sparklines),
|
||||
spoilerBlur: pick('spoilerBlur', defaultEpisode.show.spoilerBlur),
|
||||
},
|
||||
behavior: {
|
||||
swipeActions: pick('episodeSwipeActions', defaultEpisode.behavior.swipeActions),
|
||||
sortMode: pick('episodeSortMode', defaultEpisode.behavior.sortMode),
|
||||
orderPreference: pick('episodeOrderPreference', defaultEpisode.behavior.orderPreference),
|
||||
},
|
||||
recap: {
|
||||
card: pick('showRecapCard', defaultEpisode.recap.card),
|
||||
gapDays: pick('recapGapDays', defaultEpisode.recap.gapDays),
|
||||
},
|
||||
},
|
||||
|
||||
endVideo: {
|
||||
show: {
|
||||
moreLikeThis: pick('endVideoShowMoreLikeThis', defaultEndVideoShow.moreLikeThis),
|
||||
antiRec: pick('endVideoShowAntiRec', defaultEndVideoShow.antiRec),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function capitalize<S extends string>(s: S): Capitalize<S> {
|
||||
return (s.charAt(0).toUpperCase() + s.slice(1)) as Capitalize<S>
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a v2 preferences blob (nested but with the old flat episode
|
||||
* fields) into the v3 shape (episode regrouped into show/behavior/recap).
|
||||
* Anything missing or malformed falls back to defaults.
|
||||
*/
|
||||
export function migrateV2Preferences(raw: Record<string, unknown>): AppSettings {
|
||||
const episode = (raw.episode as Record<string, unknown> | undefined) ?? {}
|
||||
const episodeShow = (episode.show as Record<string, unknown> | undefined) ?? {}
|
||||
|
||||
return {
|
||||
...defaultSettings,
|
||||
...raw,
|
||||
episode: {
|
||||
show: {
|
||||
fillerChips: (episodeShow.fillerChips as boolean | undefined) ?? defaultEpisode.show.fillerChips,
|
||||
ratingChips: (episodeShow.ratingChips as boolean | undefined) ?? defaultEpisode.show.ratingChips,
|
||||
sparklines: (episodeShow.sparklines as boolean | undefined) ?? defaultEpisode.show.sparklines,
|
||||
spoilerBlur: (episode.spoilerBlur as boolean | undefined) ?? defaultEpisode.show.spoilerBlur,
|
||||
},
|
||||
behavior: {
|
||||
swipeActions: (episode.swipeActions as boolean | undefined) ?? defaultEpisode.behavior.swipeActions,
|
||||
sortMode: (episode.sortMode as EpisodeBehaviorSettings['sortMode'] | undefined) ?? defaultEpisode.behavior.sortMode,
|
||||
orderPreference: (episode.orderPreference as EpisodeBehaviorSettings['orderPreference'] | undefined) ?? defaultEpisode.behavior.orderPreference,
|
||||
},
|
||||
recap: {
|
||||
card: (episode.recapCard as boolean | undefined) ?? defaultEpisode.recap.card,
|
||||
gapDays: (episode.recapGapDays as number | undefined) ?? defaultEpisode.recap.gapDays,
|
||||
},
|
||||
},
|
||||
} as AppSettings
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Tiny Wikidata Query Service client. SPARQL endpoint, no key required.
|
||||
* Used to surface awards (P166) and filming locations (P915) keyed by an
|
||||
* item's Wikidata id (TMDB exposes this via external_ids.wikidata_id).
|
||||
*/
|
||||
|
||||
const SPARQL_ENDPOINT = 'https://query.wikidata.org/sparql'
|
||||
|
||||
async function sparql<T = unknown>(query: string): Promise<T | null> {
|
||||
try {
|
||||
const url = `${SPARQL_ENDPOINT}?query=${encodeURIComponent(query)}&format=json`
|
||||
const res = await fetch(url, { headers: { Accept: 'application/sparql-results+json' } })
|
||||
if (!res.ok) return null
|
||||
return (await res.json()) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export interface WikidataAward {
|
||||
id: string
|
||||
label: string
|
||||
ceremony?: string
|
||||
point_in_time?: string
|
||||
for_work?: string
|
||||
}
|
||||
|
||||
export interface WikidataLocation {
|
||||
id: string
|
||||
label: string
|
||||
/** "Point(longitude latitude)" - Wikidata returns coords this way */
|
||||
coords?: string
|
||||
countryLabel?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Awards won/nominated for the given Wikidata id. Returns label-decorated
|
||||
* results, ranked roughly by ceremony recency.
|
||||
*/
|
||||
export async function getAwards(qid: string): Promise<WikidataAward[]> {
|
||||
if (!qid) return []
|
||||
const query = `
|
||||
SELECT DISTINCT ?award ?awardLabel ?ceremony ?ceremonyLabel ?pit ?for ?forLabel WHERE {
|
||||
wd:${qid} p:P166 ?statement.
|
||||
?statement ps:P166 ?award.
|
||||
OPTIONAL { ?statement pq:P585 ?pit. }
|
||||
OPTIONAL { ?statement pq:P805 ?ceremony. }
|
||||
OPTIONAL { ?statement pq:P1686 ?for. }
|
||||
SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
|
||||
}
|
||||
LIMIT 60
|
||||
`
|
||||
type Row = {
|
||||
award: { value: string }
|
||||
awardLabel?: { value: string }
|
||||
ceremony?: { value: string }
|
||||
ceremonyLabel?: { value: string }
|
||||
pit?: { value: string }
|
||||
for?: { value: string }
|
||||
forLabel?: { value: string }
|
||||
}
|
||||
const data = await sparql<{ results: { bindings: Row[] } }>(query)
|
||||
if (!data?.results?.bindings) return []
|
||||
return data.results.bindings.map(r => ({
|
||||
id: r.award.value.split('/').pop() || '',
|
||||
label: r.awardLabel?.value || 'Award',
|
||||
ceremony: r.ceremonyLabel?.value,
|
||||
point_in_time: r.pit?.value,
|
||||
for_work: r.forLabel?.value,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Filming locations for the given Wikidata id. Each location optionally
|
||||
* carries coordinates so a downstream map can plot it.
|
||||
*/
|
||||
export async function getFilmingLocations(qid: string): Promise<WikidataLocation[]> {
|
||||
if (!qid) return []
|
||||
const query = `
|
||||
SELECT DISTINCT ?loc ?locLabel ?coords ?countryLabel WHERE {
|
||||
wd:${qid} wdt:P915 ?loc.
|
||||
OPTIONAL { ?loc wdt:P625 ?coords. }
|
||||
OPTIONAL { ?loc wdt:P17 ?country. }
|
||||
SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
|
||||
}
|
||||
LIMIT 50
|
||||
`
|
||||
type Row = {
|
||||
loc: { value: string }
|
||||
locLabel?: { value: string }
|
||||
coords?: { value: string }
|
||||
countryLabel?: { value: string }
|
||||
}
|
||||
const data = await sparql<{ results: { bindings: Row[] } }>(query)
|
||||
if (!data?.results?.bindings) return []
|
||||
return data.results.bindings.map(r => ({
|
||||
id: r.loc.value.split('/').pop() || '',
|
||||
label: r.locLabel?.value || 'Location',
|
||||
coords: r.coords?.value,
|
||||
countryLabel: r.countryLabel?.value,
|
||||
}))
|
||||
}
|
||||
|
||||
export interface WikidataAwardWinner {
|
||||
/** Wikidata item id of the work (e.g. Q24862 for "The Godfather"). */
|
||||
qid: string
|
||||
label: string
|
||||
/** TMDB id from P4947 (movie) or P4983 (tv). May be null. */
|
||||
tmdbId: string | null
|
||||
/** "movie" / "tv" hint based on which TMDB id was found. */
|
||||
type: 'movie' | 'tv' | null
|
||||
year: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all winners of a given award (P166) where the award itself is
|
||||
* a single Wikidata entity (e.g. wd:Q102427 for Academy Award for Best
|
||||
* Picture). Returns labelled rows with TMDB ids when available so the
|
||||
* caller can cross-reference with the local library.
|
||||
*
|
||||
* Capped at 200 rows; awards with bigger histories should be queried
|
||||
* with a year filter on the caller side.
|
||||
*/
|
||||
export async function getAwardWinners(awardQid: string): Promise<WikidataAwardWinner[]> {
|
||||
if (!awardQid) return []
|
||||
const query = `
|
||||
SELECT DISTINCT ?work ?workLabel ?tmdb ?tmdbTv ?year WHERE {
|
||||
?work wdt:P166 wd:${awardQid}.
|
||||
OPTIONAL { ?work wdt:P4947 ?tmdb. }
|
||||
OPTIONAL { ?work wdt:P4983 ?tmdbTv. }
|
||||
OPTIONAL { ?work wdt:P577 ?date. BIND(YEAR(?date) AS ?year) }
|
||||
SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
|
||||
}
|
||||
ORDER BY DESC(?year)
|
||||
LIMIT 200
|
||||
`
|
||||
type Row = {
|
||||
work: { value: string }
|
||||
workLabel?: { value: string }
|
||||
tmdb?: { value: string }
|
||||
tmdbTv?: { value: string }
|
||||
year?: { value: string }
|
||||
}
|
||||
const data = await sparql<{ results: { bindings: Row[] } }>(query)
|
||||
if (!data?.results?.bindings) return []
|
||||
const seen = new Set<string>()
|
||||
const out: WikidataAwardWinner[] = []
|
||||
for (const r of data.results.bindings) {
|
||||
const qid = r.work.value.split('/').pop() || ''
|
||||
if (!qid || seen.has(qid)) continue
|
||||
seen.add(qid)
|
||||
const tmdb = r.tmdb?.value || null
|
||||
const tmdbTv = r.tmdbTv?.value || null
|
||||
out.push({
|
||||
qid,
|
||||
label: r.workLabel?.value || qid,
|
||||
tmdbId: tmdb || tmdbTv || null,
|
||||
type: tmdb ? 'movie' : tmdbTv ? 'tv' : null,
|
||||
year: r.year?.value ? Number(r.year.value) : null,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Wikidata "Point(lon lat)" coordinate string into [lat, lon].
|
||||
* Returns null on malformed input.
|
||||
*/
|
||||
export function parseCoords(coords: string | null | undefined): [number, number] | null {
|
||||
if (!coords) return null
|
||||
const m = /^Point\(([-\d.]+)\s+([-\d.]+)\)$/.exec(coords.trim())
|
||||
if (!m) return null
|
||||
const lon = Number(m[1])
|
||||
const lat = Number(m[2])
|
||||
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return null
|
||||
return [lat, lon]
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Wikipedia REST API client. No key, no auth. Used for two things:
|
||||
* - Long-form plot summaries when the TMDB overview is thin
|
||||
* - Person biographies + portraits on the People page
|
||||
*
|
||||
* Docs: https://en.wikipedia.org/api/rest_v1/
|
||||
*
|
||||
* The summary endpoint takes a URL-encoded title and returns a clean
|
||||
* extract plus the leading thumbnail. We resolve titles via a search-first
|
||||
* strategy so we don't have to maintain title disambiguation by hand.
|
||||
*/
|
||||
|
||||
const BASE = 'https://en.wikipedia.org'
|
||||
const REST = `${BASE}/api/rest_v1`
|
||||
const ACTION = `${BASE}/w/api.php`
|
||||
|
||||
export interface WikiSummary {
|
||||
title: string
|
||||
description?: string
|
||||
extract: string
|
||||
extract_html?: string
|
||||
thumbnail?: { source: string; width: number; height: number }
|
||||
originalimage?: { source: string; width: number; height: number }
|
||||
content_urls?: { desktop: { page: string }; mobile: { page: string } }
|
||||
}
|
||||
|
||||
export interface WikiSearchResult {
|
||||
title: string
|
||||
pageid: number
|
||||
snippet?: string
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string): Promise<T | null> {
|
||||
try {
|
||||
const res = await fetch(url, { headers: { Accept: 'application/json' } })
|
||||
if (!res.ok) return null
|
||||
return (await res.json()) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a free-text query (movie title, person name) to the best-matching
|
||||
* Wikipedia article title. Used when we don't have a Wikidata id.
|
||||
*/
|
||||
export async function wikipediaSearch(query: string): Promise<WikiSearchResult[]> {
|
||||
if (!query) return []
|
||||
const url = `${ACTION}?action=query&list=search&format=json&origin=*&srsearch=${encodeURIComponent(query)}&srlimit=3`
|
||||
const data = await fetchJson<{ query?: { search?: WikiSearchResult[] } }>(url)
|
||||
return data?.query?.search || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Pulls the lead summary + thumbnail for an article title. Title can include
|
||||
* spaces; we URL-encode it.
|
||||
*/
|
||||
export async function wikipediaSummary(title: string): Promise<WikiSummary | null> {
|
||||
if (!title) return null
|
||||
return fetchJson<WikiSummary>(`${REST}/page/summary/${encodeURIComponent(title)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: search by query, take the first match, fetch its summary.
|
||||
* Used for movies/shows/people without Wikidata ids.
|
||||
*/
|
||||
export async function wikipediaResolve(query: string): Promise<WikiSummary | null> {
|
||||
const hits = await wikipediaSearch(query)
|
||||
if (hits.length === 0) return null
|
||||
return wikipediaSummary(hits[0].title)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull a specific section's plain-text from a Wikipedia article. Used to
|
||||
* surface "Production" / "Themes" content as a trivia block on detail
|
||||
* pages. Returns null if the article doesn't have that section.
|
||||
*
|
||||
* Strategy: use the parse API with `prop=sections|text` to find the
|
||||
* section index, then fetch that section as wikitext stripped to plain
|
||||
* paragraphs.
|
||||
*/
|
||||
export async function wikipediaSection(
|
||||
title: string,
|
||||
sectionName: string,
|
||||
): Promise<{ title: string; section: string; extract: string } | null> {
|
||||
if (!title || !sectionName) return null
|
||||
const sectionsUrl = `${ACTION}?action=parse&page=${encodeURIComponent(title)}&format=json&origin=*&prop=sections`
|
||||
const sections = await fetchJson<{
|
||||
parse?: {
|
||||
sections?: Array<{ index: string; line: string; level: string }>
|
||||
}
|
||||
}>(sectionsUrl)
|
||||
const list = sections?.parse?.sections || []
|
||||
// Top-level only (level "2") and case-insensitive match. Take the first.
|
||||
const target = list.find(
|
||||
s => s.level === '2' && s.line.toLowerCase() === sectionName.toLowerCase(),
|
||||
)
|
||||
if (!target) return null
|
||||
const sectionUrl = `${ACTION}?action=parse&page=${encodeURIComponent(title)}&format=json&origin=*&prop=text§ion=${target.index}&disabletoc=1`
|
||||
const html = await fetchJson<{ parse?: { text?: { '*': string } } }>(sectionUrl)
|
||||
const raw = html?.parse?.text?.['*'] || ''
|
||||
if (!raw) return null
|
||||
return { title, section: target.line, extract: stripWikiHtml(raw) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wikipedia's parse API returns the section's raw HTML. We want plain
|
||||
* paragraphs - no headings, no infoboxes, no [edit] markers, no
|
||||
* citation superscripts. The pipeline below applies the strips in
|
||||
* order so each later step gets a smaller, cleaner input.
|
||||
*
|
||||
* Order matters: remove structured non-prose (style, references, edit
|
||||
* links, headings, tables, navboxes) BEFORE the generic tag-stripping
|
||||
* pass, otherwise their text content leaks into the output and merges
|
||||
* with the surrounding prose.
|
||||
*/
|
||||
function stripWikiHtml(raw: string): string {
|
||||
if (!raw) return ''
|
||||
return raw
|
||||
// 1. Drop entirely-non-prose blocks. Each gets matched as a unit
|
||||
// so their text content (heading words, edit-link text, etc.)
|
||||
// disappears with the wrapping tags.
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, '')
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||
.replace(/<sup[^>]*class="[^"]*reference[^"]*"[^>]*>[\s\S]*?<\/sup>/gi, '')
|
||||
.replace(/<span[^>]*class="[^"]*mw-editsection[^"]*"[^>]*>[\s\S]*?<\/span>/gi, '')
|
||||
.replace(/<h[1-6][^>]*>[\s\S]*?<\/h[1-6]>/gi, '\n\n')
|
||||
.replace(/<table[\s\S]*?<\/table>/gi, '')
|
||||
.replace(/<figure[\s\S]*?<\/figure>/gi, '')
|
||||
.replace(/<div[^>]*class="[^"]*(?:thumb|infobox|navbox|hatnote|reflist|mw-references|gallery)[^"]*"[\s\S]*?<\/div>/gi, '')
|
||||
// 2. Preserve paragraph + line breaks before flattening tags.
|
||||
.replace(/<\/p>/gi, '\n\n')
|
||||
.replace(/<br\s*\/?>/gi, '\n')
|
||||
.replace(/<li[^>]*>/gi, '\n- ')
|
||||
.replace(/<\/li>/gi, '')
|
||||
// 3. Strip remaining tags.
|
||||
.replace(/<[^>]+>/g, '')
|
||||
// 4. Citation noise that didn't come wrapped in tags.
|
||||
.replace(/\[\s*\d+\s*\]/g, '')
|
||||
.replace(/\[\s*[a-z]\s*\]/g, '')
|
||||
.replace(/\[\s*(citation needed|clarification needed|when\?|who\?|why\?)\s*\]/gi, '')
|
||||
// 5. HTML entities.
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
// 6. Whitespace cleanup. Collapse runs of spaces, trim stray
|
||||
// indents around newlines, normalise blank-line gaps to one.
|
||||
.replace(/[ \t]+/g, ' ')
|
||||
.replace(/[ \t]*\n[ \t]*/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
Vendored
+31
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Minimal ambient declaration for libass-wasm (SubtitlesOctopus). The
|
||||
* package ships JS only, no .d.ts, so we declare the surface we actually
|
||||
* use.
|
||||
*/
|
||||
declare module 'libass-wasm' {
|
||||
export interface SubtitlesOctopusOptions {
|
||||
video: HTMLVideoElement
|
||||
canvas: HTMLCanvasElement
|
||||
subUrl?: string
|
||||
subContent?: string
|
||||
workerUrl: string
|
||||
legacyWorkerUrl?: string
|
||||
fonts?: string[]
|
||||
availableFonts?: Record<string, string>
|
||||
fallbackFont?: string
|
||||
targetFps?: number
|
||||
timeOffset?: number
|
||||
onReady?: () => void
|
||||
onError?: (err: unknown) => void
|
||||
}
|
||||
export default class SubtitlesOctopus {
|
||||
constructor(opts: SubtitlesOctopusOptions)
|
||||
setTrackByUrl(url: string): void
|
||||
freeTrack(): void
|
||||
setCurrentTime(time: number): void
|
||||
resize(width: number, height: number): void
|
||||
dispose(): void
|
||||
}
|
||||
export { SubtitlesOctopus }
|
||||
}
|
||||
Reference in New Issue
Block a user