api clients for jellyfin, tmdb, fanart, etc.

This commit is contained in:
2026-03-23 13:32:13 +02:00
parent d65da148d4
commit 292b3f42cf
14 changed files with 2903 additions and 0 deletions
+50
View File
@@ -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',
},
})
})
})
+117
View File
@@ -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
}
+91
View File
@@ -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
}
+312
View File
@@ -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()
},
}
+135
View File
@@ -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,
}
}
+180
View File
@@ -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)
}
+172
View File
@@ -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
}
+239
View File
@@ -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
View File
@@ -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
)
}
+112
View File
@@ -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()
}
+420
View File
@@ -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
}
+177
View File
@@ -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]
}
+155
View File
@@ -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&section=${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(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/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()
}