diff --git a/src/api/arr-payloads.test.ts b/src/api/arr-payloads.test.ts new file mode 100644 index 0000000..26a04ed --- /dev/null +++ b/src/api/arr-payloads.test.ts @@ -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', + }, + }) + }) +}) diff --git a/src/api/cinemeta.ts b/src/api/cinemeta.ts new file mode 100644 index 0000000..e1c2e4f --- /dev/null +++ b/src/api/cinemeta.ts @@ -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 `::`. */ + 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(url: string): Promise { + 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 { + 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 { + 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 { + const out = new Map() + 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 +} diff --git a/src/api/fanart.ts b/src/api/fanart.ts new file mode 100644 index 0000000..3fc4b1b --- /dev/null +++ b/src/api/fanart.ts @@ -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(url: string, apiKey: string): Promise { + 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 { + if (!idTmdbOrImdb) return Promise.resolve(null) + return fetchJson( + `${BASE}/movies/${encodeURIComponent(idTmdbOrImdb)}`, + apiKey, + ) +} + +export function fanartTv(tvdbId: string, apiKey: string): Promise { + if (!tvdbId) return Promise.resolve(null) + return fetchJson(`${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 +} diff --git a/src/api/jellyfin.ts b/src/api/jellyfin.ts new file mode 100644 index 0000000..9d43fe3 --- /dev/null +++ b/src/api/jellyfin.ts @@ -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 | null + BackdropImageTags?: string[] | null + ParentBackdropImageTags?: string[] | null + ParentBackdropItemId?: string | null + ParentThumbImageTag?: string | null + ParentThumbItemId?: string | null + SeriesId?: string | null + SeriesPrimaryImageTag?: string | null + }, + preference: 'backdrop' | 'primary' | 'thumb' = 'primary', + maxWidth?: number, +): string { + if (!serverUrl || !item.Id) return '' + const tags = item.ImageTags || {} + + if (preference === 'backdrop') { + const backdrop = item.BackdropImageTags?.[0] + if (backdrop) return getImageUrl(serverUrl, item.Id, 'Backdrop', maxWidth, backdrop) + if (tags.Thumb) return getImageUrl(serverUrl, item.Id, 'Thumb', maxWidth, tags.Thumb) + const parentBackdrop = item.ParentBackdropImageTags?.[0] + if (parentBackdrop && item.ParentBackdropItemId) { + return getImageUrl(serverUrl, item.ParentBackdropItemId, 'Backdrop', maxWidth, parentBackdrop) + } + if (tags.Primary) return getImageUrl(serverUrl, item.Id, 'Primary', maxWidth, tags.Primary) + return '' + } + + if (preference === 'thumb') { + if (tags.Thumb) return getImageUrl(serverUrl, item.Id, 'Thumb', maxWidth, tags.Thumb) + if (tags.Primary) return getImageUrl(serverUrl, item.Id, 'Primary', maxWidth, tags.Primary) + const parentBackdrop = item.ParentBackdropImageTags?.[0] + if (parentBackdrop && item.ParentBackdropItemId) { + return getImageUrl(serverUrl, item.ParentBackdropItemId, 'Backdrop', maxWidth, parentBackdrop) + } + if (item.ParentThumbImageTag && item.ParentThumbItemId) { + return getImageUrl(serverUrl, item.ParentThumbItemId, 'Thumb', maxWidth, item.ParentThumbImageTag) + } + if (item.SeriesPrimaryImageTag && item.SeriesId) { + return getImageUrl(serverUrl, item.SeriesId, 'Primary', maxWidth, item.SeriesPrimaryImageTag) + } + return '' + } + + // primary preference + if (tags.Primary) return getImageUrl(serverUrl, item.Id, 'Primary', maxWidth, tags.Primary) + if (item.SeriesPrimaryImageTag && item.SeriesId) { + return getImageUrl(serverUrl, item.SeriesId, 'Primary', maxWidth, item.SeriesPrimaryImageTag) + } + if (tags.Thumb) return getImageUrl(serverUrl, item.Id, 'Thumb', maxWidth, tags.Thumb) + return '' +} + +/** + * Build a Jellyfin subtitle stream URL for a given track. Uses VTT format + * which vidstack consumes natively. Returns empty when any param is missing. + */ +export function getSubtitleUrl( + serverUrl: string, + itemId: string, + mediaSourceId: string | null | undefined, + subtitleStreamIndex: number, + token: string, + format: 'vtt' | 'ass' | 'ssa' | 'srt' = 'vtt', +): string { + if (!serverUrl || !itemId || !token) return '' + const ms = mediaSourceId || itemId + return `${serverUrl}/Videos/${itemId}/${ms}/Subtitles/${subtitleStreamIndex}/0/Stream.${format}?api_key=${token}` +} + +export function getAudioStreamUrl( + serverUrl: string, + itemId: string, + token: string, + mediaSourceId?: string, +): string { + let url = `${serverUrl}/Audio/${itemId}/stream?static=true&api_key=${token}` + if (mediaSourceId) url += `&MediaSourceId=${mediaSourceId}` + return url +} + +export const jellyfinClient = { + connect(serverUrl: string): Jellyfin { + const normalized = serverUrl.replace(/\/+$/, '') + jellyfin = new Jellyfin({ + clientInfo: { name: 'Jellyfin Client', version: '0.1.0' }, + deviceInfo: { name: 'Desktop', id: 'jf-desktop-' + Date.now() }, + }) + api = jellyfin.createApi(normalized) + return jellyfin + }, + + async login(username: string, password: string): Promise { + if (!api) throw new Error('Not connected to a server') + + const userApi = getUserApi(api) + const auth = await userApi.authenticateUserByName({ + authenticateUserByName: { + Username: username.trim(), + Pw: password, + }, + }) + + const state: AuthState = { + serverUrl: api.basePath, + token: auth.data.AccessToken || '', + userId: auth.data.User?.Id || '', + userName: auth.data.User?.Name ?? undefined, + } + + storeAuth(state) + addKnownServer(state) + try { + localStorage.setItem(LAST_SERVER_KEY, state.serverUrl) + } catch { /* noop */ } + return state + }, + + /** Set a previously-known server as the active one. */ + activateServer(target: AuthState) { + storeAuth(target) + api = jellyfin?.createApi(target.serverUrl, target.token) ?? null + if (!api) { + jellyfin = new Jellyfin({ + clientInfo: { name: 'Jellyfin Client', version: '0.1.0' }, + deviceInfo: { name: 'Desktop', id: 'jf-desktop-' + Date.now() }, + }) + api = jellyfin.createApi(target.serverUrl, target.token) + } + try { + localStorage.setItem(LAST_SERVER_KEY, target.serverUrl) + } catch { /* noop */ } + }, + + async tryAutoLogin(): Promise { + const stored = getStoredAuth() + if (!stored) return null + + jellyfin = new Jellyfin({ + clientInfo: { name: 'Jellyfin Client', version: '0.1.0' }, + deviceInfo: { name: 'Desktop', id: 'jf-desktop-' + Date.now() }, + }) + api = jellyfin.createApi(stored.serverUrl, stored.token) + + try { + await getSystemApi(api).getSystemInfo() + return stored + } catch { + storeAuth(null) + api = null + return null + } + }, + + logout() { + storeAuth(null) + api = null + jellyfin = null + }, + + isAuthenticated(): boolean { + return api !== null && !!getStoredAuth() + }, + + getApi(): Api | null { + return api + }, + + getAuthState(): AuthState | null { + return getStoredAuth() + }, +} diff --git a/src/api/letterboxd.ts b/src/api/letterboxd.ts new file mode 100644 index 0000000..197eed3 --- /dev/null +++ b/src/api/letterboxd.ts @@ -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 `` 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 { + 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, + } +} diff --git a/src/api/radarr.ts b/src/api/radarr.ts new file mode 100644 index 0000000..94260ce --- /dev/null +++ b/src/api/radarr.ts @@ -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(path: string, init: RequestInit = {}): Promise { + 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('/system/status') } + qualityProfiles() { return this.req('/qualityprofile') } + rootFolders() { return this.req('/rootfolder') } + tags() { return this.req('/tag') } + movies() { return this.req('/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( + `/movie/lookup/tmdb?tmdbId=${encodeURIComponent(tmdbId)}`, + ) + } + + async addMovie(payload: AddRadarrMoviePayload): Promise { + const body = buildAddRadarrMovieBody(payload) + return this.req('/movie', { + method: 'POST', + body: JSON.stringify(body), + }) + } + + removeMovie(id: number, deleteFiles = false) { + return this.req( + `/movie/${id}?deleteFiles=${deleteFiles ? 'true' : 'false'}&addImportExclusion=false`, + { method: 'DELETE' }, + ) + } + + searchMovie(id: number) { + return this.req('/command', { + method: 'POST', + body: JSON.stringify({ name: 'MoviesSearch', movieIds: [id] }), + }) + } +} + +export function radarrClient(instance: ArrInstance) { + return new RadarrClient(instance) +} diff --git a/src/api/rotten-tomatoes.ts b/src/api/rotten-tomatoes.ts new file mode 100644 index 0000000..61aa20a --- /dev/null +++ b/src/api/rotten-tomatoes.ts @@ -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 = { + '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 { + 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 { + 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 { + if (!name) return null + const hits = await searchRT(name, 'tv') + const hit = best(hits, name, year) + return hit ? buildRating(hit, 'tv') : null +} diff --git a/src/api/sonarr.ts b/src/api/sonarr.ts new file mode 100644 index 0000000..3dffc36 --- /dev/null +++ b/src/api/sonarr.ts @@ -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(path: string, init: RequestInit = {}): Promise { + 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('/system/status') } + qualityProfiles() { return this.req('/qualityprofile') } + languageProfiles() { return this.req('/languageprofile') } + rootFolders() { return this.req('/rootfolder') } + tags() { return this.req('/tag') } + series() { return this.req('/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( + `/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 { + const body = buildAddSonarrSeriesBody(payload) + return this.req('/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(`/series/${series.id}`, { + method: 'PUT', + body: JSON.stringify(series), + }) + } + + removeSeries(id: number, deleteFiles = false) { + return this.req( + `/series/${id}?deleteFiles=${deleteFiles ? 'true' : 'false'}&addImportListExclusion=false`, + { method: 'DELETE' }, + ) + } + + searchSeries(id: number) { + return this.req('/command', { + method: 'POST', + body: JSON.stringify({ name: 'SeriesSearch', seriesId: id }), + }) + } + + searchSeason(seriesId: number, seasonNumber: number) { + return this.req('/command', { + method: 'POST', + body: JSON.stringify({ name: 'SeasonSearch', seriesId, seasonNumber }), + }) + } +} + +export function sonarrClient(instance: ArrInstance) { + return new SonarrClient(instance) +} diff --git a/src/api/tmdb.ts b/src/api/tmdb.ts new file mode 100644 index 0000000..d43a778 --- /dev/null +++ b/src/api/tmdb.ts @@ -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 +} + +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 + similar?: TmdbPagedResults + release_dates?: { results: TmdbReleaseDatesByCountry[] } + external_ids?: TmdbExternalIds + images?: TmdbImages + alternative_titles?: { titles: TmdbAlternativeTitle[] } + reviews?: TmdbPagedResults + '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 + similar?: TmdbPagedResults + content_ratings?: { results: TmdbContentRating[] } + external_ids?: TmdbExternalIds + images?: TmdbImages + alternative_titles?: { results: TmdbAlternativeTitle[] } + reviews?: TmdbPagedResults + '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 { + 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(path: string, params: Record = {}): Promise { + 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 + } 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(`/movie/${id}`, { + append_to_response: MOVIE_APPENDS, + include_image_language: 'en,null', + }) +} + +export async function getTvShowFull(id: number) { + return tmdbFetch(`/tv/${id}`, { + append_to_response: TV_APPENDS, + include_image_language: 'en,null', + }) +} + +export async function getSeasonFull(tvId: number, seasonNum: number) { + return tmdbFetch(`/tv/${tvId}/season/${seasonNum}`, { + append_to_response: SEASON_APPENDS, + }) +} + +export async function getEpisodeFull(tvId: number, seasonNum: number, episodeNum: number) { + return tmdbFetch(`/tv/${tvId}/season/${seasonNum}/episode/${episodeNum}`, { + append_to_response: EPISODE_APPENDS, + }) +} + +export async function getPersonFull(id: number) { + return tmdbFetch(`/person/${id}`, { + append_to_response: PERSON_APPENDS, + }) +} + +export async function getCollection(id: number) { + return tmdbFetch(`/collection/${id}`) +} + +export async function getEpisodeGroup(id: string) { + return tmdbFetch(`/tv/episode_group/${id}`) +} + +export async function getTrending( + mediaType: 'all' | 'movie' | 'tv' | 'person' = 'all', + window: 'day' | 'week' = 'week', +) { + return tmdbFetch>(`/trending/${mediaType}/${window}`) +} + +export async function searchMulti(query: string) { + return tmdbFetch>('/search/multi', { query }) +} + +export async function discoverMovies(params: Record = {}) { + return tmdbFetch>('/discover/movie', params) +} + +export async function discoverTv(params: Record = {}) { + return tmdbFetch>('/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>('/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>('/movie/top_rated', { + page: String(page), + }) +} + +export async function getTopRatedTv(page = 1) { + return tmdbFetch>('/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 + +/** + * 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 + ) +} diff --git a/src/api/tvmaze.ts b/src/api/tvmaze.ts new file mode 100644 index 0000000..4e43a9d --- /dev/null +++ b/src/api/tvmaze.ts @@ -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(path: string): Promise { + 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 { + if (!imdbId) return Promise.resolve(null) + return fetchJson(`/lookup/shows?imdb=${encodeURIComponent(imdbId)}`) +} + +export function tvmazeLookupByTvdbId(tvdbId: string | number): Promise { + if (!tvdbId) return Promise.resolve(null) + return fetchJson(`/lookup/shows?thetvdb=${tvdbId}`) +} + +export function tvmazeShow(id: number, embed: string[] = ['cast', 'nextepisode', 'previousepisode']): Promise { + if (!id) return Promise.resolve(null) + const embeds = embed.map(e => `embed[]=${e}`).join('&') + return fetchJson(`/shows/${id}?${embeds}`) +} + +export function tvmazeStripHtml(s: string | null | undefined): string { + if (!s) return '' + return s.replace(/<[^>]+>/g, '').trim() +} diff --git a/src/api/types.ts b/src/api/types.ts new file mode 100644 index 0000000..e1354be --- /dev/null +++ b/src/api/types.ts @@ -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 + + // 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