Files
OmniArr/server/lib/services.ts
T

4595 lines
150 KiB
TypeScript

import type {
AppConfig,
CalendarEntry,
CalendarResponse,
CompletedDownloadItem,
ConnectionStatus,
DashboardResponse,
DiskSpaceStatus,
DownloadItem,
DownloadsResponse,
EpisodeRequestPayload,
EpisodeWatchStateRequest,
HubPageResponse,
HubSection,
MediaCard,
MovieDeletePayload,
ProcessingItem,
SeasonDeletePayload,
SeasonRequestPayload,
SelectOption,
ServiceOptionsResponse,
TitleDetailsEpisode,
TitleDetailsRequest,
TitleDetailsResponse,
TitleDetailsSeason,
TitleDetailsCastMember
} from '../../shared/contracts.js';
const TMDB_IMAGE = 'https://image.tmdb.org/t/p';
const TMDB_ARTWORK_TTL_MS = 6 * 60 * 60 * 1000;
const TMDB_ARTWORK_FAILURE_TTL_MS = 60 * 1000;
const TMDB_ARTWORK_CONCURRENCY = 8;
const TMDB_ARTWORK_SECTION_CONCURRENCY = 2;
const TVDB_TRANSLATION_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
const PROCESSING_TTL_MS = 60 * 60 * 1000; // 1 hour max
const processingQueue: ProcessingItem[] = [];
const completedDownloads: CompletedDownloadItem[] = [];
let previousDownloadIds = new Set<string>();
const cancelledDownloadIds = new Set<string>();
export function addProcessingItem(item: Omit<ProcessingItem, 'id' | 'createdAt'>): void {
const id = `proc-${item.source}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
processingQueue.push({
...item,
id,
user: item.user || 'Unknown',
createdAt: new Date().toISOString(),
});
}
export function getProcessingItems(): ProcessingItem[] {
const now = Date.now();
// Remove expired items (older than 1 hour)
for (let i = processingQueue.length - 1; i >= 0; i--) {
if (now - new Date(processingQueue[i].createdAt).getTime() > PROCESSING_TTL_MS) {
processingQueue.splice(i, 1);
}
}
return [...processingQueue];
}
function reconcileProcessingWithDownloads(downloads: DownloadItem[]): void {
const downloadTitles = new Set(downloads.map((d) => d.title.toLowerCase()));
for (let i = processingQueue.length - 1; i >= 0; i--) {
if (downloadTitles.has(processingQueue[i].title.toLowerCase())) {
processingQueue.splice(i, 1);
}
}
}
function detectCompletedDownloads(currentDownloads: DownloadItem[]): void {
const currentIds = new Set(currentDownloads.map((d) => d.id));
// Items that were downloading before but are no longer in the queue = completed
for (const prevId of previousDownloadIds) {
if (!currentIds.has(prevId)) {
const wasCancelled = cancelledDownloadIds.delete(prevId);
// Find the item info from the last known state — we stored it below
const cached = lastKnownDownloads.get(prevId);
if (cached) {
completedDownloads.push({
id: `done-${prevId}-${Date.now()}`,
source: cached.source,
title: cached.title,
subtitle: cached.subtitle,
posterUrl: cached.posterUrl,
backdropUrl: cached.backdropUrl,
tmdbId: cached.tmdbId,
mediaType: cached.mediaType,
status: wasCancelled ? 'cancelled' : 'completed',
completedAt: new Date().toISOString(),
});
}
}
}
// Update tracking state
previousDownloadIds = currentIds;
lastKnownDownloads.clear();
for (const d of currentDownloads) {
lastKnownDownloads.set(d.id, d);
}
}
const lastKnownDownloads = new Map<string, DownloadItem>();
export function getCompletedDownloads(): CompletedDownloadItem[] {
return [...completedDownloads];
}
export function dismissCompletedDownload(id: string): void {
const idx = completedDownloads.findIndex((item) => item.id === id);
if (idx !== -1) {
completedDownloads.splice(idx, 1);
}
}
export function dismissAllCompletedDownloads(): void {
completedDownloads.length = 0;
}
interface JellyfinItem {
Id?: string;
Name?: string;
Type?: string;
Overview?: string;
ImageTags?: Record<string, string>;
BackdropImageTags?: string[];
ProviderIds?: Record<string, string>;
RunTimeTicks?: number;
ProductionYear?: number;
UserData?: {
PlayedPercentage?: number;
PlaybackPositionTicks?: number;
Played?: boolean;
UnplayedItemCount?: number;
};
SeriesId?: string;
SeriesName?: string;
SeriesPrimaryImageTag?: string;
ParentIndexNumber?: number;
IndexNumber?: number;
ParentBackdropItemId?: string;
ParentBackdropImageTags?: string[];
DateCreated?: string;
DateLastMediaAdded?: string;
ChildCount?: number;
IndexNumberEnd?: number;
PremiereDate?: string;
}
interface SonarrQueueRecord {
id?: number;
seriesId?: number;
title?: string;
downloadId?: string;
series?: { title?: string; tmdbId?: number; images?: Array<{ coverType?: string; remoteUrl?: string; url?: string }> };
episode?: { seasonNumber?: number; episodeNumber?: number; title?: string };
trackedDownloadState?: string;
sizeleft?: number;
size?: number;
status?: string;
timeleft?: string;
}
interface SonarrCommandRecord {
id?: number;
name?: string;
commandName?: string;
status?: string;
state?: string;
body?: {
seriesId?: number;
seasonNumber?: number;
episodeIds?: number[];
};
}
interface SonarrCalendarRecord {
id?: number;
title?: string;
airDate?: string;
airDateUtc?: string;
overview?: string;
seasonNumber?: number;
episodeNumber?: number;
hasFile?: boolean;
monitored?: boolean;
series?: {
title?: string;
tmdbId?: number;
images?: Array<{ coverType?: string; remoteUrl?: string; url?: string }>;
year?: number;
status?: string;
};
}
interface RadarrQueueRecord {
id?: number;
title?: string;
movie?: { id?: number; title?: string; tmdbId?: number; year?: number; images?: Array<{ coverType?: string; remoteUrl?: string; url?: string }> };
trackedDownloadState?: string;
sizeleft?: number;
size?: number;
status?: string;
timeleft?: string;
}
interface RadarrCommandRecord {
id?: number;
name?: string;
commandName?: string;
status?: string;
state?: string;
body?: {
movieIds?: number[];
};
}
interface RadarrMovieRecord {
id?: number;
tmdbId?: number;
title?: string;
year?: number;
hasFile?: boolean;
monitored?: boolean;
images?: Array<{ coverType?: string; remoteUrl?: string; url?: string }>;
}
interface RadarrCalendarRecord {
id?: number;
tmdbId?: number;
title?: string;
year?: number;
overview?: string;
status?: string;
inCinemas?: string;
physicalRelease?: string;
digitalRelease?: string;
monitored?: boolean;
hasFile?: boolean;
images?: Array<{ coverType?: string; remoteUrl?: string; url?: string }>;
}
interface DownloadMetadataHint {
query: string;
mediaType: 'movie' | 'tv';
subtitle?: string;
}
interface TmdbBackdropChoice {
url: string;
backdropLanguageFallback: boolean;
}
interface TmdbArtwork {
available: boolean;
logos: Array<{ file_path: string; iso_639_1: string | null }>;
backdrops: Record<string, unknown>[];
}
interface TvdbSeriesRecord {
id?: number;
name?: string;
overview?: string;
image?: string;
artworks?: Array<{ image?: string; type?: number }>;
seasons?: TvdbSeasonRecord[];
}
interface TvdbSeasonRecord {
id?: number;
number?: number;
image?: string;
name?: string;
type?: {
name?: string;
type?: string;
};
}
interface TvdbEpisodeRecord {
id?: number;
name?: string;
overview?: string;
image?: string;
aired?: string;
seasonNumber?: number;
number?: number;
runtime?: number;
}
interface TvdbTranslationRecord {
name?: string;
overview?: string;
}
interface SonarrSeriesRecord {
id?: number;
title?: string;
tmdbId?: number;
tvdbId?: number;
images?: Array<{ coverType?: string; remoteUrl?: string; url?: string }>;
seasons?: Array<{
seasonNumber?: number;
monitored?: boolean;
}>;
qualityProfileId?: number;
languageProfileId?: number;
rootFolderPath?: string;
seasonFolder?: boolean;
monitored?: boolean;
seriesType?: string;
titleSlug?: string;
path?: string;
addOptions?: Record<string, unknown>;
}
interface SonarrEpisodeRecord {
id?: number;
seriesId?: number;
seasonNumber?: number;
episodeNumber?: number;
title?: string;
overview?: string;
airDateUtc?: string;
runtime?: number;
monitored?: boolean;
hasFile?: boolean;
episodeFileId?: number;
}
interface SonarrEpisodeFileRecord {
id?: number;
seriesId?: number;
seasonNumber?: number;
relativePath?: string;
path?: string;
size?: number;
}
interface TmdbSeasonSummary {
seasonNumber: number;
name: string;
episodeCount?: number;
}
interface TmdbEpisodeRecord {
episode_number?: number;
name?: string;
overview?: string;
still_path?: string | null;
air_date?: string;
runtime?: number;
}
interface SeriesSeasonDescriptor {
id: string;
title: string;
seasonNumber?: number;
episodeCount?: number;
hasTmdbSeason?: boolean;
jellyfinSeasonId?: string;
imageUrl?: string;
}
const ANIME_PROVIDERS = '337|8|9|430|283';
const DORAMA_PROVIDERS = '344|337|8|9|464';
let tvdbTokenCache: { token: string; expiresAt: number } | null = null;
const tmdbArtworkCache = new Map<string, { expiresAt: number; value: Promise<TmdbArtwork> }>();
const tvdbTranslationCache = new Map<string, { expiresAt: number; value: Promise<TvdbTranslationRecord | null> }>();
export function sanitizeConfig(input: AppConfig): AppConfig {
return {
preferences: {
language: input.preferences.language.trim() || 'en-US',
region: input.preferences.region.trim().toUpperCase(),
excludeLibraryItems: Boolean(input.preferences.excludeLibraryItems),
animationLevel: input.preferences.animationLevel === 'reduced' ? 'reduced' : 'smooth',
sonarrDownloadQualityProfileIds: Array.isArray(input.preferences.sonarrDownloadQualityProfileIds)
? input.preferences.sonarrDownloadQualityProfileIds.map(String)
: [],
radarrDownloadQualityProfileIds: Array.isArray(input.preferences.radarrDownloadQualityProfileIds)
? input.preferences.radarrDownloadQualityProfileIds.map(String)
: []
},
jellyfin: {
baseUrl: normalizeBaseUrl(input.jellyfin.baseUrl),
apiKey: input.jellyfin.apiKey.trim(),
userId: input.jellyfin.userId.trim()
},
sonarr: {
baseUrl: normalizeBaseUrl(input.sonarr.baseUrl),
apiKey: input.sonarr.apiKey.trim(),
rootFolderPath: input.sonarr.rootFolderPath.trim(),
qualityProfileId: Number(input.sonarr.qualityProfileId) || 0,
languageProfileId: Number(input.sonarr.languageProfileId) || 0,
monitor: input.sonarr.monitor.trim() || 'all',
startSearch: Boolean(input.sonarr.startSearch)
},
radarr: {
baseUrl: normalizeBaseUrl(input.radarr.baseUrl),
apiKey: input.radarr.apiKey.trim(),
rootFolderPath: input.radarr.rootFolderPath.trim(),
qualityProfileId: Number(input.radarr.qualityProfileId) || 0,
monitor: input.radarr.monitor.trim() || 'movieOnly',
startSearch: Boolean(input.radarr.startSearch)
},
tmdb: {
bearerToken: input.tmdb.bearerToken.trim()
},
tvdb: {
apiKey: input.tvdb.apiKey.trim()
}
};
}
export async function getConnectionStatuses(config: AppConfig): Promise<ConnectionStatus[]> {
const [jellyfin, sonarr, radarr, tmdb, tvdb] = await Promise.all([
testJellyfin(config),
testSonarr(config),
testRadarr(config),
testTmdb(config),
testTvdb(config)
]);
return [jellyfin, sonarr, radarr, tmdb, tvdb];
}
export async function getServiceOptions(config: AppConfig): Promise<ServiceOptionsResponse> {
const [
jellyfinUsers,
sonarrRootFolders,
sonarrQualityProfiles,
sonarrLanguageProfiles,
radarrRootFolders,
radarrQualityProfiles
] = await Promise.all([
getJellyfinUsers(config),
getSonarrRootFolders(config),
getSonarrQualityProfiles(config),
getSonarrLanguageProfiles(config),
getRadarrRootFolders(config),
getRadarrQualityProfiles(config)
]);
return {
jellyfinUsers,
sonarrRootFolders,
sonarrQualityProfiles,
sonarrLanguageProfiles,
radarrRootFolders,
radarrQualityProfiles
};
}
export async function getMediaDiskSpace(config: AppConfig): Promise<DiskSpaceStatus | null> {
const services = [
{ source: 'radarr' as const, baseUrl: config.radarr.baseUrl, apiKey: config.radarr.apiKey, rootFolderPath: config.radarr.rootFolderPath },
{ source: 'sonarr' as const, baseUrl: config.sonarr.baseUrl, apiKey: config.sonarr.apiKey, rootFolderPath: config.sonarr.rootFolderPath }
];
for (const service of services) {
if (!service.baseUrl || !service.apiKey) {
continue;
}
try {
const disks = await fetchJson<Record<string, unknown>[]>(`${service.baseUrl}/api/v3/diskspace`, {
headers: { 'X-Api-Key': service.apiKey }
});
const disk = selectDiskSpaceForRootFolder(disks, service.rootFolderPath);
if (!disk) {
console.warn(
`[Storage] ${service.source} did not report disk space for ${service.rootFolderPath}. Reported paths: ${disks.map((entry) => String(entry.path || '')).join(', ') || 'none'}`
);
continue;
}
const totalBytes = Number(disk?.totalSpace);
const availableBytes = Number(disk?.freeSpace);
if (Number.isFinite(totalBytes) && totalBytes > 0 && Number.isFinite(availableBytes) && availableBytes >= 0) {
return {
totalBytes,
availableBytes,
path: String(disk?.path || service.rootFolderPath),
source: service.source
};
}
} catch {
// The local data volume remains a useful fallback when an *arr service is unavailable.
}
}
return null;
}
function selectDiskSpaceForRootFolder(
disks: Record<string, unknown>[],
rootFolderPath: string
): Record<string, unknown> | undefined {
const normalizedRoot = rootFolderPath.replace(/\/+$/, '');
const matchingDisks = disks
.filter((disk) => {
const diskPath = String(disk.path || '').replace(/\/+$/, '');
return diskPath && (normalizedRoot === diskPath || normalizedRoot.startsWith(`${diskPath}/`));
})
.sort((left, right) => String(right.path || '').length - String(left.path || '').length);
if (matchingDisks[0]) {
return matchingDisks[0];
}
if (!normalizedRoot) {
return disks.find((disk) => {
const path = String(disk.path || '').replace(/\/+$/, '');
return path && path !== '/config';
});
}
return undefined;
}
export async function loadJellyfinUsers(baseUrl: string, apiKey: string): Promise<SelectOption[]> {
if (!baseUrl.trim() || !apiKey.trim()) {
return [];
}
return getJellyfinUsersFromConfig(normalizeBaseUrl(baseUrl), apiKey.trim());
}
export async function authenticateJellyfinUser(config: AppConfig, userId: string, password: string): Promise<boolean> {
if (!config.jellyfin.baseUrl || !userId || !password) {
return false;
}
const authorization = 'MediaBrowser Client="OmniArr", Device="OmniArr", DeviceId="omniarr-web", Version="1.0.0"';
const response = await fetch(`${config.jellyfin.baseUrl}/Users/AuthenticateByName`, {
method: 'POST',
headers: {
Authorization: authorization,
'X-Emby-Authorization': authorization,
'Content-Type': 'application/json'
},
body: JSON.stringify({
Username: (await getJellyfinUsers(config)).find((user) => user.id === userId)?.label || '',
Pw: password
})
});
if (!response.ok) {
return false;
}
const payload = await response.json() as { User?: { Id?: string } };
return payload.User?.Id === userId;
}
export async function getDashboard(config: AppConfig): Promise<DashboardResponse> {
const [connections, library, downloads, tmdb] = await Promise.all([
getConnectionStatuses(config),
getLibrarySnapshot(config),
getDownloads(config),
getTmdbSnapshot(config)
]);
const libraryTmdbIds = new Set<number>();
for (const item of library.allItems) {
const value = Number(item.ProviderIds?.Tmdb);
if (value) {
libraryTmdbIds.add(value);
}
}
const filteredRecommendations = config.preferences.excludeLibraryItems
? tmdb.recommendations.filter((item) => !item.tmdbId || !libraryTmdbIds.has(item.tmdbId))
: tmdb.recommendations;
const filteredTrendingSeries = config.preferences.excludeLibraryItems
? tmdb.trendingSeries.filter((item) => !item.tmdbId || !libraryTmdbIds.has(item.tmdbId))
: tmdb.trendingSeries;
return {
connections,
hero: [...library.continueWatching, ...tmdb.hero].slice(0, 3),
continueWatching: library.continueWatching,
recentlyAdded: library.recentlyAdded,
recommendations: filteredRecommendations.slice(0, 12),
trendingSeries: filteredTrendingSeries.slice(0, 12),
downloads,
processing: getProcessingItems(),
completedDownloads: getCompletedDownloads()
};
}
export async function getHubPage(
config: AppConfig,
page: HubPageResponse['page']
): Promise<HubPageResponse> {
console.log(`[getHubPage] Loading page: ${page}`);
let library: { continueWatching: MediaCard[]; recentlyAdded: MediaCard[]; allItems: JellyfinItem[] };
try {
library = await getLibrarySnapshot(config);
} catch (err) {
console.error(`[getHubPage] Error fetching library for ${page}:`, err);
library = { continueWatching: [], recentlyAdded: [], allItems: [] };
}
const allSections = await buildHubSections(config, library, page).catch((err) => {
console.error(`[getHubPage] Error building hub sections for ${page}:`, err);
return { hero: [], sections: [] };
}) || { hero: [], sections: [] };
console.log(`[getHubPage] Sections built for ${page}. Hero count: ${allSections.hero?.length}, Section count: ${allSections.sections?.length}`);
const isNetworkPage = ['netflix', 'primevideo', 'hbo', 'viki', 'disney'].includes(page);
if (page !== 'home' && !isNetworkPage && allSections.hero?.length === 0 && allSections.sections?.every(s => s.items.length === 0)) {
console.log(`[getHubPage] Hub for ${page} is completely empty, using Home as fallback`);
return getHubPage(config, 'home');
}
const backdropCache = new Map<string, Promise<TmdbBackdropChoice>>();
const networkScopedContinueWatching = isNetworkPage
? (() => {
const sectionTmdbIds = new Set(
uniqueCards([...allSections.hero, ...allSections.sections.flatMap((section) => section.items)])
.map((item) => item.tmdbId)
.filter((value): value is number => Boolean(value))
);
const filtered = library.continueWatching.filter((card) => card.tmdbId && sectionTmdbIds.has(card.tmdbId));
const minimumScopedItems = Math.min(3, library.continueWatching.length);
return filtered.length >= minimumScopedItems ? filtered : library.continueWatching;
})()
: library.continueWatching;
const [continueWatchingWithLogos, heroWithHighResBackdrops, sectionsWithLogos] = await Promise.all([
applyTitleLogosToCards(config, networkScopedContinueWatching),
applyHighResBackdropsToCards(
config,
(allSections && allSections.hero) || [],
shouldUseHighResBackdropForCard,
backdropCache,
null,
'original'
),
applyTitleLogosToSections(config, (allSections && allSections.sections) || [])
]);
const [continueWatching, heroWithLogos, sectionsWithHighResBackdrops] = await Promise.all([
applyHighResBackdropsToCards(
config,
continueWatchingWithLogos || [],
shouldUseHighResBackdropForCard,
backdropCache,
'en'
),
applyTitleLogosToCards(config, heroWithHighResBackdrops || [], 'original'),
applyHighResBackdropsToSections(config, sectionsWithLogos || [], backdropCache, 'en')
]);
return {
page,
hero: heroWithLogos,
continueWatching,
sections: sectionsWithHighResBackdrops
.filter((section) => section.items.some((item) => item.backdropUrl || item.posterUrl))
};
}
export async function getDownloads(config: AppConfig): Promise<DownloadItem[]> {
const [sonarrResult, radarrResult] = await Promise.allSettled([
getSonarrQueue(config),
getRadarrQueue(config)
]);
const sonarr = sonarrResult.status === 'fulfilled' ? sonarrResult.value : [];
const radarr = radarrResult.status === 'fulfilled' ? radarrResult.value : [];
const metadataCache = new Map<string, Promise<Partial<DownloadItem>>>();
const baseDownloads: DownloadItem[] = [
...sonarr.map((item): DownloadItem => ({
id: `sonarr-${item.id ?? Math.random()}`,
source: 'sonarr',
title: item.series?.title || item.title || 'Series',
detail: item.title || 'Downloading episode',
subtitle: formatEpisodeSubtitle(item.episode, item.title),
progress: getQueueProgress(item.size, item.sizeleft),
state: item.status || item.trackedDownloadState || 'queue',
eta: item.timeleft || '',
size: item.size ? formatBytes(item.size) : '',
tmdbId: item.series?.tmdbId,
posterUrl: extractRemoteImage(item.series?.images),
backdropUrl: undefined,
mediaType: 'tv',
speed: undefined
})),
...radarr.map((item): DownloadItem => ({
id: `radarr-${item.id ?? Math.random()}`,
source: 'radarr',
title: item.movie?.title || item.title || 'Movie',
detail: item.title || 'Downloading movie',
subtitle: item.movie?.year ? String(item.movie.year) : 'Movie',
progress: getQueueProgress(item.size, item.sizeleft),
state: item.status || item.trackedDownloadState || 'queue',
eta: item.timeleft || '',
size: item.size ? formatBytes(item.size) : '',
tmdbId: item.movie?.tmdbId,
posterUrl: extractRemoteImage(item.movie?.images),
backdropUrl: undefined,
mediaType: 'movie',
speed: undefined
}))
];
const downloads = await Promise.all(
baseDownloads.map((item) => enrichDownloadItem(config, item, metadataCache))
);
const sorted = downloads.sort((a, b) => b.progress - a.progress);
// Reconcile processing queue: remove items that now appear as downloads
reconcileProcessingWithDownloads(sorted);
// Detect completed downloads: items that disappeared from the queue
detectCompletedDownloads(sorted);
return sorted;
}
export async function getDownloadsResponse(config: AppConfig): Promise<DownloadsResponse> {
const downloading = await getDownloads(config);
return {
processing: getProcessingItems(),
downloading,
completed: getCompletedDownloads(),
};
}
export async function getCalendar(config: AppConfig, start: Date, end: Date): Promise<CalendarResponse> {
const [sonarrResult, radarrResult] = await Promise.allSettled([
getSonarrCalendar(config, start, end),
getRadarrCalendar(config, start, end)
])
const sonarrItems = sonarrResult.status === 'fulfilled'
? sonarrResult.value.map((item) => mapSonarrCalendarEntry(item))
: []
const radarrItems = radarrResult.status === 'fulfilled'
? radarrResult.value.map((item) => mapRadarrCalendarEntry(item))
: []
const allItems = [...sonarrItems, ...radarrItems]
.filter((item): item is CalendarEntry => Boolean(item));
const backdropCache = new Map<string, Promise<TmdbBackdropChoice>>();
const cards = await applyHighResBackdropsToCards(config, allItems.map(i => i.card), shouldUseHighResBackdropForCard, backdropCache, 'en');
const itemsWithBackdrops = allItems.map(item => {
const correspondingCard = cards.find(c => c.id === item.card.id);
if (correspondingCard && correspondingCard.backdropUrl) {
return {
...item,
imageUrl: correspondingCard.backdropUrl,
card: correspondingCard
}
}
return item;
});
return {
items: itemsWithBackdrops
.sort((left, right) => new Date(left.startsAt).getTime() - new Date(right.startsAt).getTime())
}
}
export async function searchTitles(
config: AppConfig,
rawQuery: string
): Promise<{ query: string; items: MediaCard[] }> {
const query = rawQuery.trim();
if (!query) {
return { query: '', items: [] };
}
const library = await getLibrarySnapshot(config);
const libraryCards = library.allItems
.map((item) => jellyfinToCard(item, item.Name || 'Library'))
.filter(Boolean) as MediaCard[];
const normalizedQuery = normalizeSearchValue(query);
const libraryMatches = libraryCards.filter((card) => {
const haystack = normalizeSearchValue([card.title, card.subtitle].filter(Boolean).join(' '));
return haystack.includes(normalizedQuery);
});
const libraryCardByTmdbId = new Map(
libraryCards
.filter((card) => card.tmdbId)
.map((card) => [card.tmdbId!, card] as const)
);
const remoteResults = await tmdbFetch(
config,
`/search/multi?query=${encodeURIComponent(query)}&language=${encodeURIComponent(config.preferences.language)}&include_adult=false`
);
const remoteCards = asArray(remoteResults?.results)
.map((entry) => asRecord(entry))
.filter((entry) => entry.media_type === 'movie' || entry.media_type === 'tv')
.map((entry) => {
const mediaType = entry.media_type === 'movie' ? 'movie' : 'tv';
const card = tmdbToCard(entry, mediaType);
const libraryMatch = card.tmdbId ? libraryCardByTmdbId.get(card.tmdbId) : undefined;
return libraryMatch
? {
...card,
providerId: libraryMatch.providerId,
badge: libraryMatch.badge,
progress: libraryMatch.progress,
secondaryValue: libraryMatch.secondaryValue,
hoverLabel: libraryMatch.hoverLabel
}
: card;
});
const searchBackdropCache = new Map<string, Promise<TmdbBackdropChoice>>();
const cardsWithLogos = await applyTitleLogosToCards(
config,
uniqueSearchCards([...libraryMatches, ...remoteCards]).slice(0, 36)
);
const enrichedCards = await applyHighResBackdropsToCards(
config,
cardsWithLogos,
shouldUseHighResBackdropForCard,
searchBackdropCache,
'en'
);
return {
query,
items: enrichedCards.filter((card) => Boolean(card.backdropUrl))
};
}
export async function removeDownload(config: AppConfig, id: string): Promise<void> {
const [source, rawId] = id.split('-', 2);
if (!source || !rawId) {
throw new Error('Invalid download id.');
}
if (source !== 'sonarr' && source !== 'radarr') {
throw new Error('Unsupported download source.');
}
const baseUrl = source === 'sonarr' ? config.sonarr.baseUrl : config.radarr.baseUrl;
const apiKey = source === 'sonarr' ? config.sonarr.apiKey : config.radarr.apiKey;
if (!baseUrl || !apiKey) {
throw new Error(`${source === 'sonarr' ? 'Sonarr' : 'Radarr'} is not configured.`);
}
const queue = source === 'sonarr' ? await getSonarrQueue(config) : await getRadarrQueue(config);
const queueItem = queue.find((item) => String(item.id) === rawId);
const response = await fetch(
`${baseUrl}/api/v3/queue/${encodeURIComponent(rawId)}?removeFromClient=true&blocklist=false&skipRedownload=false`,
{
method: 'DELETE',
headers: {
'X-Api-Key': apiKey
}
}
);
if (!response.ok) {
throw new Error(`${source === 'sonarr' ? 'Sonarr' : 'Radarr'} ${response.status} ${response.statusText}`);
}
cancelledDownloadIds.add(id);
if (source === 'radarr' && queueItem) {
const movieId = (queueItem as RadarrQueueRecord).movie?.id;
const title = (queueItem as RadarrQueueRecord).movie?.title || queueItem.title;
if (title) {
removeProcessingItem('radarr', title);
}
if (movieId) {
await removeRadarrMovieIfEmpty(config, movieId, queue as RadarrQueueRecord[], rawId);
}
}
if (source === 'sonarr' && queueItem) {
const seriesId = (queueItem as SonarrQueueRecord).seriesId;
const title = (queueItem as SonarrQueueRecord).series?.title || queueItem.title;
if (title) {
removeProcessingItem('sonarr', title);
}
if (seriesId) {
await removeSonarrSeriesIfEmpty(config, seriesId, queue as SonarrQueueRecord[], rawId);
}
}
}
function removeProcessingItem(source: ProcessingItem['source'], title: string): void {
for (let index = processingQueue.length - 1; index >= 0; index--) {
if (processingQueue[index].source === source && processingQueue[index].title.toLowerCase() === title.toLowerCase()) {
processingQueue.splice(index, 1);
}
}
}
async function removeRadarrMovieIfEmpty(
config: AppConfig,
movieId: number,
queue: RadarrQueueRecord[],
cancelledQueueId: string
): Promise<void> {
if (queue.some((item) => String(item.id) !== cancelledQueueId && item.movie?.id === movieId)) {
return;
}
const movie = await getRadarrMovieById(config, movieId);
if (!movie || movie.hasFile) {
return;
}
const response = await fetch(
`${config.radarr.baseUrl}/api/v3/movie/${encodeURIComponent(String(movieId))}?deleteFiles=false&addImportExclusion=false`,
{ method: 'DELETE', headers: { 'X-Api-Key': config.radarr.apiKey } }
);
if (!response.ok) {
throw new Error(`Radarr ${response.status} ${response.statusText}`);
}
}
async function removeSonarrSeriesIfEmpty(
config: AppConfig,
seriesId: number,
queue: SonarrQueueRecord[],
cancelledQueueId: string
): Promise<void> {
if (queue.some((item) => String(item.id) !== cancelledQueueId && item.seriesId === seriesId)) {
return;
}
const files = await getSonarrEpisodeFilesBySeriesId(config, seriesId);
if (files.length) {
return;
}
const response = await fetch(
`${config.sonarr.baseUrl}/api/v3/series/${encodeURIComponent(String(seriesId))}?deleteFiles=false`,
{ method: 'DELETE', headers: { 'X-Api-Key': config.sonarr.apiKey } }
);
if (!response.ok) {
throw new Error(`Sonarr ${response.status} ${response.statusText}`);
}
}
export async function getTitleDetails(
config: AppConfig,
request: TitleDetailsRequest
): Promise<TitleDetailsResponse> {
const { card, seasonId } = request;
const item = card.providerId ? await getJellyfinItemDetails(config, card.providerId) : null;
const seriesItem = await resolveSeriesItem(config, card, item);
const primaryItem = seriesItem || item;
const isSeries = card.mediaType === 'series' || card.mediaType === 'tv' || seriesItem?.Type === 'Series' || item?.Type === 'Episode';
const detailMediaType: 'movie' | 'tv' = primaryItem?.Type === 'Movie' || card.mediaType === 'movie' ? 'movie' : 'tv';
const tvdbId = Number(primaryItem?.ProviderIds?.Tvdb) || undefined;
const tmdbId = Number(primaryItem?.ProviderIds?.Tmdb) || card.tmdbId || await resolveTmdbSeriesIdByTvdbId(config, tvdbId);
const originalLanguage = card.originalLanguage;
const [backdropUrl, logoUrl] = await Promise.all([
tmdbId ? getTmdbHighResBackdrop(config, tmdbId, detailMediaType, null, originalLanguage, 'original') : Promise.resolve(''),
tmdbId ? getPreferredTitleLogo(config, tmdbId, detailMediaType, 'original') : Promise.resolve('')
]);
const fallbackTitle = seriesItem?.Name || primaryItem?.Name || card.title;
const fallbackOverview = seriesItem?.Overview || primaryItem?.Overview || card.overview;
const subtitle = buildTitleSubtitle(seriesItem || item, card);
const posterUrl = resolvePrimaryPoster(seriesItem || item, card);
const effectiveBackdropUrl = backdropUrl || resolveBackdrop(seriesItem || item, card);
if (!isSeries) {
const [radarrMovie, radarrQueue, radarrCommands] = tmdbId
? await Promise.all([
getRadarrMovieRecord(config, tmdbId),
getRadarrQueue(config),
getRadarrCommands(config)
])
: [null, [], []];
const radarrQueueEntry = radarrMovie?.id
? radarrQueue.find((entry) => entry.movie?.id === radarrMovie.id || entry.movie?.tmdbId === tmdbId)
: radarrQueue.find((entry) => entry.movie?.tmdbId === tmdbId);
const isDownloadingInRadarr = Boolean(radarrQueueEntry);
const radarrMovieId = radarrMovie?.id;
const isProcessingInRadarr = Boolean(
!isDownloadingInRadarr
&& radarrMovieId
&& radarrCommands.some((command) => isActiveMovieSearchCommand(command, radarrMovieId))
);
const movieDetails = tmdbId ? await getTmdbMovieSummary(config, tmdbId) : null;
return {
id: card.id,
tmdbId,
providerId: item?.Id || card.providerId,
radarrMovieId,
playTargetId: item?.Id || card.providerId,
availableInJellyfin: Boolean(item?.Id || card.providerId),
title: fallbackTitle,
overview: fallbackOverview,
subtitle,
mediaType: 'movie',
posterUrl,
backdropUrl: effectiveBackdropUrl,
logoUrl: logoUrl || card.logoUrl,
requestStatus: isDownloadingInRadarr ? 'downloading' : isProcessingInRadarr ? 'processing' : 'idle',
requestProgress: isDownloadingInRadarr ? getMovieQueueProgress(radarrQueueEntry) : undefined,
seasons: [],
episodes: [],
cast: buildTitleCast(movieDetails, 'movie')
};
}
const [tmdbSeries, jellyfinSeasons, nextEpisode] = await Promise.all([
tmdbId ? getTmdbSeriesSummary(config, tmdbId) : Promise.resolve(null),
seriesItem?.Id ? getJellyfinSeriesSeasons(config, seriesItem.Id) : Promise.resolve([]),
seriesItem?.Id ? getNextEpisodeToPlay(config, seriesItem.Id) : Promise.resolve(null)
]);
const resolvedTvdbId = Number(asRecord(tmdbSeries?.external_ids).tvdb_id || tvdbId || 0) || undefined;
const sonarrSeries = await getSonarrSeriesRecord(
config,
Number(resolvedTvdbId || 0),
tmdbId
);
const sonarrContext = sonarrSeries?.id
? await getSonarrSeriesContext(config, sonarrSeries.id)
: { episodes: [], queue: [], commands: [] };
const [tvdbSeries, tvdbEpisodes] = resolvedTvdbId
? await Promise.all([
getTvdbSeriesSummary(config, resolvedTvdbId),
getTvdbSeriesEpisodes(config, resolvedTvdbId)
])
: [null, []];
const hasUsableTvdbEpisodes = tvdbEpisodes.some((episode) => typeof episode.seasonNumber === 'number' && episode.seasonNumber > 0);
const title = seriesItem?.Name || String(tmdbSeries?.name || '') || tvdbSeries?.name || primaryItem?.Name || card.title;
const overview = seriesItem?.Overview || String(tmdbSeries?.overview || '') || tvdbSeries?.overview || primaryItem?.Overview || card.overview;
const seasonDescriptors = buildSeriesSeasonDescriptors(
tmdbSeries,
jellyfinSeasons,
sonarrContext.episodes,
hasUsableTvdbEpisodes ? tvdbSeries : null,
hasUsableTvdbEpisodes ? tvdbEpisodes : []
);
const selectedSeason = chooseSelectedSeason(seasonDescriptors, seasonId, item, nextEpisode);
const episodeGroups = await Promise.all(
seasonDescriptors.map((season) => buildSeasonEpisodes(
config,
tmdbId,
season,
seriesItem?.Id,
Boolean(sonarrSeries?.id),
sonarrContext.episodes,
sonarrContext.queue,
sonarrContext.commands,
hasUsableTvdbEpisodes ? tvdbEpisodes : []
))
);
const episodes = episodeGroups.flat();
const seasons = buildTitleSeasons(seasonDescriptors, episodes, sonarrContext.episodes, sonarrContext.queue, sonarrContext.commands);
const resolvedNextEpisode = nextEpisode && episodes.some((episode) => episode.id === nextEpisode.id)
? episodes.find((episode) => episode.id === nextEpisode.id) || nextEpisode
: nextEpisode;
return {
id: card.id,
tmdbId,
providerId: seriesItem?.Id || card.providerId,
sonarrSeriesId: sonarrSeries?.id,
playTargetId: resolvedNextEpisode?.id || seriesItem?.Id,
availableInJellyfin: Boolean(seriesItem?.Id),
title,
overview,
subtitle,
mediaType: 'series',
posterUrl,
backdropUrl: effectiveBackdropUrl,
logoUrl: logoUrl || card.logoUrl,
selectedSeasonId: selectedSeason?.id,
seasons,
episodes,
cast: buildTitleCast(tmdbSeries, 'series')
};
}
function buildTitleCast(
tmdbPayload: Record<string, unknown> | null,
mediaType: 'movie' | 'series'
): TitleDetailsCastMember[] {
const creditsKey = mediaType === 'series' ? 'aggregate_credits' : 'credits';
const castEntries = asArray(asRecord(tmdbPayload?.[creditsKey])?.cast)
.map((entry) => asRecord(entry))
.slice(0, 20);
return castEntries.map((entry) => ({
id: String(entry.id || ''),
name: String(entry.name || ''),
role: String(
entry.character
|| asArray(entry.roles).map((role) => String(asRecord(role).character || '')).find(Boolean)
|| entry.known_for_department
|| ''
),
imageUrl: typeof entry.profile_path === 'string' && entry.profile_path
? `${TMDB_IMAGE}/w342${entry.profile_path}`
: ''
})).filter((member) => Boolean(member.id && member.name));
}
async function getTmdbMovieSummary(config: AppConfig, tmdbId: number): Promise<Record<string, unknown> | null> {
return tmdbFetch(
config,
`/movie/${tmdbId}?append_to_response=credits,images&language=${encodeURIComponent(config.preferences.language)}`
);
}
async function enrichDownloadItem(
config: AppConfig,
item: DownloadItem,
metadataCache: Map<string, Promise<Partial<DownloadItem>>>
): Promise<DownloadItem> {
if (item.tmdbId && item.backdropUrl) {
return item;
}
if (item.tmdbId) {
const cacheKey = `${item.mediaType === 'movie' ? 'movie' : 'tv'}:tmdb:${item.tmdbId}`;
let metadataPromise = metadataCache.get(cacheKey);
if (!metadataPromise) {
metadataPromise = findDownloadMetadataByTmdbId(config, item.tmdbId, item.mediaType === 'movie' ? 'movie' : 'tv');
metadataCache.set(cacheKey, metadataPromise);
}
const metadata = await metadataPromise;
return {
...item,
...metadata,
posterUrl: item.posterUrl || metadata.posterUrl,
backdropUrl: item.backdropUrl || metadata.backdropUrl,
subtitle: item.subtitle || metadata.subtitle
};
}
const hint = inferDownloadHint(item.detail || item.title);
if (!hint.query) {
return item;
}
const cacheKey = `${hint.mediaType}:${hint.query}`;
let metadataPromise = metadataCache.get(cacheKey);
if (!metadataPromise) {
metadataPromise = findDownloadMetadata(config, hint);
metadataCache.set(cacheKey, metadataPromise);
}
return {
...item,
...(await metadataPromise)
};
}
async function findDownloadMetadataByTmdbId(
config: AppConfig,
tmdbId: number,
mediaType: 'movie' | 'tv'
): Promise<Partial<DownloadItem>> {
try {
const response = await tmdbFetch(
config,
`/${mediaType}/${tmdbId}?language=${encodeURIComponent(config.preferences.language)}`
);
if (!response) {
return {};
}
const card = tmdbToCard(response, mediaType);
const englishBackdropUrl = await getTmdbHighResBackdrop(config, tmdbId, mediaType, 'en', card.originalLanguage);
return {
title: card.title,
posterUrl: card.posterUrl,
backdropUrl: englishBackdropUrl || card.backdropUrl,
tmdbId: card.tmdbId,
mediaType: mediaType === 'tv' ? 'series' : 'movie'
};
} catch {
return {};
}
}
async function findDownloadMetadata(
config: AppConfig,
hint: DownloadMetadataHint
): Promise<Partial<DownloadItem>> {
try {
const response = await tmdbFetch(
config,
`/search/${hint.mediaType}?query=${encodeURIComponent(hint.query)}&language=${encodeURIComponent(config.preferences.language)}`
);
const match = asArray(response?.results)
.map((entry) => asRecord(entry))
.find((entry) => entry.id);
if (!match) {
return {};
}
const card = tmdbToCard(match, hint.mediaType);
const englishBackdropUrl = card.tmdbId
? await getTmdbHighResBackdrop(config, card.tmdbId, hint.mediaType, 'en', card.originalLanguage)
: '';
return {
title: card.title,
posterUrl: card.posterUrl,
backdropUrl: englishBackdropUrl || card.backdropUrl,
tmdbId: card.tmdbId,
mediaType: hint.mediaType === 'tv' ? 'series' : 'movie',
subtitle: hint.subtitle
};
} catch {
return {};
}
}
function inferDownloadHint(rawTitle: string): DownloadMetadataHint {
const scope = inferDownloadScope(rawTitle);
const cleaned = rawTitle
.replace(/\[[^\]]*]/g, ' ')
.replace(/\([^)]*]/g, ' ')
.replace(/[._]/g, ' ')
.replace(/\b(2160p|1080p|720p|480p|x264|x265|h\.?264|h\.?265|hevc|hdr10\+?|aac2?\.?0|ddp\d?\.?\d?|atmos|web[- ]?dl|webrip|bluray|bdremux|remux|multi[- ]subs?|dual[- ]audio|repack|proper|complete|extras)\b/gi, ' ')
.replace(/\b(S\d{1,2}E\d{1,3}|E\d{1,3}|EP\d{1,3}|Season\s+\d+|Part\s+\d+)\b.*$/i, ' ')
.replace(/\s+/g, ' ')
.trim();
return {
query: cleaned || rawTitle,
mediaType: scope.mediaType,
subtitle: scope.scopeLabel
};
}
function formatEpisodeSubtitle(
episode: { seasonNumber?: number; episodeNumber?: number; title?: string } | undefined,
rawTitle: string | undefined
): string {
const seasonNumber = episode?.seasonNumber;
const episodeNumber = episode?.episodeNumber;
const seasonEpisodeLabel = seasonNumber && episodeNumber
? `Season ${seasonNumber}, Episode ${episodeNumber}`
: extractSeasonEpisodeLabel(rawTitle || '');
return [seasonEpisodeLabel, episode?.title].filter(Boolean).join(' · ') || 'Episode';
}
function extractSeasonEpisodeLabel(rawTitle: string): string {
const seasonEpisodeMatch = rawTitle.match(/\bS(\d{1,2})E(\d{1,3})\b/i);
if (seasonEpisodeMatch) {
return `Season ${Number(seasonEpisodeMatch[1])}, Episode ${Number(seasonEpisodeMatch[2])}`;
}
const episodeMatch = rawTitle.match(/\bEP?(\d{1,3})\b/i);
if (episodeMatch) {
return `Episode ${Number(episodeMatch[1])}`;
}
return '';
}
function inferDownloadScope(rawTitle: string): { scopeLabel: string; mediaType: 'movie' | 'tv' } {
if (/\bS\d{1,2}E\d{1,3}\b/i.test(rawTitle) || /\bEP?\d{1,3}\b/i.test(rawTitle)) {
return { scopeLabel: 'Episode', mediaType: 'tv' };
}
if (/\bcomplete\b|\bcollection\b|\bseasons?\s*\d+\s*-\s*\d+\b/i.test(rawTitle)) {
return { scopeLabel: 'Series', mediaType: 'tv' };
}
if (/\bSeason\s+\d+\b/i.test(rawTitle) || /\bS\d{1,2}\b/i.test(rawTitle)) {
return { scopeLabel: 'Season', mediaType: 'tv' };
}
return { scopeLabel: 'Movie', mediaType: 'movie' };
}
function extractRemoteImage(
images?: Array<{ coverType?: string; remoteUrl?: string; url?: string }>
): string | undefined {
const poster = images?.find((image) => image.coverType === 'poster');
return poster?.remoteUrl || poster?.url || undefined;
}
function mapSonarrCalendarEntry(item: SonarrCalendarRecord): CalendarEntry | null {
const startsAt = item.airDateUtc || item.airDate
const seriesTitle = item.series?.title || ''
const tmdbId = Number(item.series?.tmdbId) || undefined
if (!startsAt || !seriesTitle) {
return null
}
const seasonNumber = typeof item.seasonNumber === 'number' ? item.seasonNumber : undefined
const episodeNumber = typeof item.episodeNumber === 'number' ? item.episodeNumber : undefined
const detail = item.title || ''
const subtitle = [
typeof seasonNumber === 'number' ? `Season ${seasonNumber}` : '',
typeof episodeNumber === 'number' ? `Episode ${episodeNumber}` : '',
].filter(Boolean).join(' · ')
return {
id: `sonarr-calendar-${item.id || `${seriesTitle}-${startsAt}`}`,
source: 'sonarr',
kind: 'episode',
startsAt,
title: seriesTitle,
subtitle: subtitle || 'Upcoming episode',
detail: detail || 'Upcoming episode',
imageUrl: extractRemoteImage(item.series?.images),
stateLabel: item.hasFile ? 'Available' : item.monitored ? 'Upcoming' : 'Unmonitored',
card: {
id: `calendar-series-${tmdbId || item.id || seriesTitle}`,
tmdbId,
title: seriesTitle,
subtitle: subtitle || detail || 'Series',
overview: item.overview || '',
mediaType: 'series',
posterUrl: extractRemoteImage(item.series?.images) || '',
backdropUrl: extractRemoteImage(item.series?.images) || '',
badge: 'Sonarr',
actionLabel: 'Series details',
releaseDate: item.airDate,
firstAirDate: item.airDate,
mediaStatus: item.series?.status,
}
}
}
function mapRadarrCalendarEntry(item: RadarrCalendarRecord): CalendarEntry | null {
const startsAt = item.physicalRelease || item.digitalRelease || item.inCinemas
if (!startsAt || !item.title) {
return null
}
const posterUrl = extractRemoteImage(item.images) || ''
const releaseLabel = item.physicalRelease
? 'Digital release'
: item.inCinemas
? 'In cinemas'
: 'Release'
return {
id: `radarr-calendar-${item.id || `${item.title}-${startsAt}`}`,
source: 'radarr',
kind: 'movie',
startsAt,
title: item.title,
subtitle: item.year ? String(item.year) : 'Movie',
detail: releaseLabel,
imageUrl: posterUrl,
stateLabel: item.hasFile ? 'Available' : item.monitored ? 'Upcoming' : 'Unmonitored',
card: {
id: `calendar-movie-${item.tmdbId || item.id || item.title}`,
tmdbId: Number(item.tmdbId) || undefined,
title: item.title,
subtitle: item.year ? String(item.year) : 'Movie',
overview: item.overview || '',
mediaType: 'movie',
posterUrl,
backdropUrl: posterUrl,
badge: 'Radarr',
actionLabel: 'Movie details',
releaseDate: startsAt,
mediaStatus: item.status,
}
}
}
async function getJellyfinItemDetails(config: AppConfig, itemId: string): Promise<JellyfinItem | null> {
if (!config.jellyfin.baseUrl || !config.jellyfin.apiKey || !config.jellyfin.userId) {
return null;
}
try {
return await jellyfinFetch<JellyfinItem>(
config,
`/Users/${config.jellyfin.userId}/Items/${encodeURIComponent(itemId)}?fields=ProviderIds,Overview,ImageTags,BackdropImageTags,ParentBackdropItemId,ParentBackdropImageTags,RunTimeTicks,ParentIndexNumber,IndexNumber,ProductionYear,SeriesId,SeriesName,PremiereDate,ChildCount`
);
} catch {
return null;
}
}
async function resolveSeriesItem(
config: AppConfig,
card: MediaCard,
item: JellyfinItem | null
): Promise<JellyfinItem | null> {
if (item?.Type === 'Series') {
return item;
}
const seriesId = item?.SeriesId || (card.mediaType === 'series' || card.mediaType === 'tv' ? card.providerId : '');
if (!seriesId) {
return null;
}
return getJellyfinItemDetails(config, seriesId);
}
function buildJellyfinAssetUrl(itemId: string, kind: string, maxWidth: number, tag: string): string {
return `/api/assets/jellyfin/${itemId}?kind=${encodeURIComponent(kind)}&maxWidth=${maxWidth}&tag=${encodeURIComponent(tag)}`;
}
function resolvePrimaryPoster(item: JellyfinItem | null, card: MediaCard): string {
if (item?.Id) {
const posterTag = item.ImageTags?.Primary || '';
return buildJellyfinAssetUrl(item.Id, 'Primary', 640, posterTag);
}
return card.posterUrl;
}
function resolveBackdrop(item: JellyfinItem | null, card: MediaCard): string {
if (item?.Id) {
const backdropItemId = item.ParentBackdropItemId || item.Id;
const backdropTag = item.ParentBackdropImageTags?.[0] || item.BackdropImageTags?.[0] || item.ImageTags?.Primary || '';
return buildJellyfinAssetUrl(backdropItemId, 'Backdrop', 1920, backdropTag);
}
return card.backdropUrl || card.posterUrl;
}
function buildTitleSubtitle(item: JellyfinItem | null, card: MediaCard): string {
if (!item) {
return card.subtitle;
}
if (item.Type === 'Movie') {
return item.ProductionYear ? String(item.ProductionYear) : card.subtitle;
}
if (item.Type === 'Series') {
return item.ProductionYear ? String(item.ProductionYear) : card.subtitle;
}
if (item.Type === 'Episode') {
return [item.SeriesName, formatEpisodeCode(item.ParentIndexNumber, item.IndexNumber)].filter(Boolean).join(' · ') || card.subtitle;
}
return card.subtitle;
}
async function getPreferredTitleLogo(
config: AppConfig,
tmdbId: number,
mediaType: 'movie' | 'tv',
imageSize = 'w500'
): Promise<string> {
const logos = await getTmdbLogos(config, tmdbId, mediaType);
const englishLogo = logos.find((logo) => logo.iso_639_1 === 'en' || logo.iso_639_1 === 'en-US');
const noLanguageLogo = logos.find((logo) => logo.iso_639_1 == null);
const selectedLogo = englishLogo || noLanguageLogo || logos[0];
return selectedLogo?.file_path ? `${TMDB_IMAGE}/${imageSize}${selectedLogo.file_path}` : '';
}
async function getTmdbSeriesSummary(config: AppConfig, tmdbId: number): Promise<Record<string, unknown> | null> {
return tmdbFetch(
config,
`/tv/${tmdbId}?append_to_response=external_ids,aggregate_credits&language=${encodeURIComponent(config.preferences.language)}`
);
}
async function resolveTmdbSeriesIdByTvdbId(config: AppConfig, tvdbId?: number): Promise<number | undefined> {
if (!tvdbId) {
return undefined;
}
try {
const response = await tmdbFetch(
config,
`/find/${encodeURIComponent(String(tvdbId))}?external_source=tvdb_id`
);
const firstMatch = asArray(response?.tv_results)[0];
const id = Number(asRecord(firstMatch).id || 0);
return id || undefined;
} catch {
return undefined;
}
}
function resolveTvdbImageUrl(image: string | undefined): string {
if (!image) {
return '';
}
if (image.startsWith('http://') || image.startsWith('https://')) {
return image;
}
return `https://artworks.thetvdb.com${image.startsWith('/') ? image : `/${image}`}`;
}
function getTvdbTranslationCandidates(config: AppConfig): string[] {
const preferred = String(config.preferences.language || '').trim().toLowerCase();
if (preferred.startsWith('en')) {
return ['eng'];
}
return [];
}
function hasLikelyJapaneseText(value: string | undefined): boolean {
return /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff]/.test(String(value || ''));
}
async function mapWithConcurrency<T, R>(
items: T[],
limit: number,
mapper: (item: T) => Promise<R>
): Promise<R[]> {
const results = new Array<R>(items.length);
let nextIndex = 0;
async function worker(): Promise<void> {
while (nextIndex < items.length) {
const index = nextIndex++;
results[index] = await mapper(items[index]);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
return results;
}
async function getTvdbTranslation(
config: AppConfig,
entity: 'episodes' | 'seasons' | 'series',
id: number
): Promise<TvdbTranslationRecord | null> {
const cacheKey = `${entity}:${id}:${getTvdbTranslationCandidates(config).join(',')}`;
const cached = tvdbTranslationCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
const value = getTvdbTranslationUncached(config, entity, id);
tvdbTranslationCache.set(cacheKey, {
expiresAt: Date.now() + TVDB_TRANSLATION_CACHE_TTL_MS,
value
});
return value;
}
async function getTvdbTranslationUncached(
config: AppConfig,
entity: 'episodes' | 'seasons' | 'series',
id: number
): Promise<TvdbTranslationRecord | null> {
const candidates = getTvdbTranslationCandidates(config);
for (const language of candidates) {
const response = await tvdbFetch(
config,
`/${entity}/${encodeURIComponent(String(id))}/translations/${encodeURIComponent(language)}`,
{ quiet: true }
);
const data = asRecord(response?.data);
const translation = asRecord(data.translation || data);
const name = typeof translation.name === 'string'
? translation.name
: typeof data.name === 'string'
? data.name
: undefined;
const overview = typeof translation.overview === 'string'
? translation.overview
: typeof data.overview === 'string'
? data.overview
: undefined;
if (name || overview) {
return { name, overview };
}
}
return null;
}
async function applyTvdbEpisodeTranslations(
config: AppConfig,
episodes: TvdbEpisodeRecord[]
): Promise<TvdbEpisodeRecord[]> {
const candidates = getTvdbTranslationCandidates(config);
if (!candidates.length) {
return episodes;
}
return mapWithConcurrency(
episodes,
4,
async (episode) => {
if (!episode.id || (!hasLikelyJapaneseText(episode.name) && !hasLikelyJapaneseText(episode.overview))) {
return episode;
}
const translation = await getTvdbTranslation(config, 'episodes', episode.id);
return translation
? {
...episode,
name: translation.name || episode.name,
overview: translation.overview || episode.overview
}
: episode;
}
);
}
async function applyTvdbSeasonTranslations(
config: AppConfig,
seasons: TvdbSeasonRecord[]
): Promise<TvdbSeasonRecord[]> {
if (!getTvdbTranslationCandidates(config).length) {
return seasons;
}
return mapWithConcurrency(
seasons,
4,
async (season) => {
if (!season.id || !hasLikelyJapaneseText(season.name)) {
return season;
}
const translation = await getTvdbTranslation(config, 'seasons', season.id);
return translation?.name ? { ...season, name: translation.name } : season;
}
);
}
async function getTvdbSeriesSummary(config: AppConfig, tvdbId: number): Promise<TvdbSeriesRecord | null> {
const response = await tvdbFetch(config, `/series/${encodeURIComponent(String(tvdbId))}/extended`);
const data = asRecord(response?.data);
if (!data.id) {
return null;
}
const seasons = await applyTvdbSeasonTranslations(
config,
asArray(data.seasons).map((entry) => {
const season = asRecord(entry);
const type = asRecord(season.type);
return {
id: Number(season.id || 0) || undefined,
number: Number(season.number || 0) || undefined,
image: typeof season.image === 'string' ? season.image : undefined,
name: typeof season.name === 'string' ? season.name : undefined,
type: {
name: typeof type.name === 'string' ? type.name : undefined,
type: typeof type.type === 'string' ? type.type : undefined
}
} satisfies TvdbSeasonRecord;
})
);
return {
id: Number(data.id || 0) || undefined,
name: typeof data.name === 'string' ? data.name : undefined,
overview: typeof data.overview === 'string' ? data.overview : undefined,
image: typeof data.image === 'string' ? data.image : undefined,
artworks: asArray(data.artworks).map((entry) => {
const artwork = asRecord(entry);
return {
image: typeof artwork.image === 'string' ? artwork.image : undefined,
type: Number(artwork.type || 0) || undefined
};
}),
seasons
};
}
async function getTvdbSeriesEpisodes(config: AppConfig, tvdbId: number): Promise<TvdbEpisodeRecord[]> {
const episodes: TvdbEpisodeRecord[] = [];
let page = 0;
while (page < 20) {
const response = await tvdbFetch(
config,
`/series/${encodeURIComponent(String(tvdbId))}/episodes/default?page=${page}`
);
const data = asRecord(response?.data);
const currentBatch = asArray(data.episodes).length
? asArray(data.episodes)
: asArray(response?.data);
const parsed = currentBatch
.map((entry) => asRecord(entry))
.map((episode) => ({
id: Number(episode.id || 0) || undefined,
name: typeof episode.name === 'string' ? episode.name : undefined,
overview: typeof episode.overview === 'string' ? episode.overview : undefined,
image: typeof episode.image === 'string' ? episode.image : undefined,
aired: typeof episode.aired === 'string' ? episode.aired : undefined,
seasonNumber: Number(episode.seasonNumber || 0) || undefined,
number: Number(episode.number || 0) || undefined,
runtime: Number(episode.runtime || 0) || undefined
} satisfies TvdbEpisodeRecord))
.filter((episode) => typeof episode.seasonNumber === 'number' && typeof episode.number === 'number');
episodes.push(...parsed);
const links = asRecord(response?.links);
const next = Number(links.next || 0);
if (!parsed.length || !next || next === page) {
break;
}
page = next;
}
return applyTvdbEpisodeTranslations(config, episodes);
}
async function getJellyfinSeriesSeasons(config: AppConfig, seriesId: string): Promise<SeriesSeasonDescriptor[]> {
try {
const seasons = await jellyfinFetch<JellyfinItem[]>(
config,
`/Users/${encodeURIComponent(config.jellyfin.userId)}/Items?parentId=${encodeURIComponent(seriesId)}&includeItemTypes=Season&fields=Overview,ImageTags,IndexNumber,ChildCount&sortBy=SortName`
);
return seasons
.filter((season) => season.Id)
.map((season) => ({
id: buildSeasonId(typeof season.IndexNumber === 'number' ? season.IndexNumber : undefined, String(season.Id)),
title: season.Name || (typeof season.IndexNumber === 'number' ? `Season ${season.IndexNumber}` : 'Season'),
seasonNumber: typeof season.IndexNumber === 'number' ? season.IndexNumber : undefined,
episodeCount: typeof season.ChildCount === 'number' ? season.ChildCount : undefined,
jellyfinSeasonId: String(season.Id)
}))
.sort(sortSeasonDescriptors);
} catch {
return [];
}
}
function chooseSelectedSeason(
seasons: SeriesSeasonDescriptor[],
requestedSeasonId: string | undefined,
item: JellyfinItem | null,
nextEpisode?: TitleDetailsEpisode | null
): SeriesSeasonDescriptor | undefined {
if (requestedSeasonId) {
const requestedSeason = seasons.find((season) => season.id === requestedSeasonId);
if (requestedSeason) {
return requestedSeason;
}
}
if (typeof nextEpisode?.seasonNumber === 'number') {
const matchingSeason = seasons.find((season) => season.seasonNumber === nextEpisode.seasonNumber);
if (matchingSeason) {
return matchingSeason;
}
}
if (item?.Type === 'Episode' && typeof item.ParentIndexNumber === 'number') {
const matchingSeason = seasons.find((season) => season.seasonNumber === item.ParentIndexNumber);
if (matchingSeason) {
return matchingSeason;
}
}
return seasons.find((season) => season.seasonNumber && season.seasonNumber > 0) || seasons[0];
}
async function getJellyfinSeasonEpisodes(
config: AppConfig,
seriesId: string,
seasonId: string
): Promise<TitleDetailsEpisode[]> {
try {
const episodes = await jellyfinFetch<JellyfinItem[]>(
config,
`/Shows/${encodeURIComponent(seriesId)}/Episodes?userId=${encodeURIComponent(config.jellyfin.userId)}&seasonId=${encodeURIComponent(seasonId)}&enableUserData=true&fields=Overview,ImageTags,RunTimeTicks,ParentIndexNumber,IndexNumber,PremiereDate&sortBy=SortName`
);
return episodes
.filter((episode) => episode.Id)
.sort((left, right) => (left.IndexNumber ?? 0) - (right.IndexNumber ?? 0))
.map(mapJellyfinEpisode);
} catch {
return [];
}
}
async function getTmdbSeasonEpisodes(
config: AppConfig,
tmdbId: number,
seasonNumber: number
): Promise<TmdbEpisodeRecord[]> {
const response = await tmdbFetch(
config,
`/tv/${tmdbId}/season/${seasonNumber}?language=${encodeURIComponent(config.preferences.language)}`
);
return asArray(response?.episodes)
.map((entry) => asRecord(entry))
.map((episode) => ({
episode_number: Number(episode.episode_number || 0) || undefined,
name: typeof episode.name === 'string' ? episode.name : undefined,
overview: typeof episode.overview === 'string' ? episode.overview : undefined,
still_path: typeof episode.still_path === 'string' ? episode.still_path : episode.still_path === null ? null : undefined,
air_date: typeof episode.air_date === 'string' ? episode.air_date : undefined,
runtime: Number(episode.runtime || 0) || undefined
}));
}
async function getSonarrSeriesRecord(
config: AppConfig,
tvdbId: number,
tmdbId?: number
): Promise<SonarrSeriesRecord | null> {
if (!config.sonarr.baseUrl || !config.sonarr.apiKey || !tvdbId) {
return null;
}
const response = await fetchJson<SonarrSeriesRecord[]>(
`${config.sonarr.baseUrl}/api/v3/series?tvdbId=${encodeURIComponent(String(tvdbId))}`,
{
headers: {
'X-Api-Key': config.sonarr.apiKey
}
}
);
return response.find((series) => series.id && (!tmdbId || !series.tmdbId || series.tmdbId === tmdbId)) || response[0] || null;
}
async function getSonarrSeriesById(config: AppConfig, seriesId: number): Promise<SonarrSeriesRecord | null> {
if (!config.sonarr.baseUrl || !config.sonarr.apiKey || !seriesId) {
return null;
}
return fetchJson<SonarrSeriesRecord>(
`${config.sonarr.baseUrl}/api/v3/series/${encodeURIComponent(String(seriesId))}`,
{
headers: {
'X-Api-Key': config.sonarr.apiKey
}
}
);
}
async function getSonarrEpisodesBySeriesId(config: AppConfig, seriesId: number): Promise<SonarrEpisodeRecord[]> {
return fetchJson<SonarrEpisodeRecord[]>(
`${config.sonarr.baseUrl}/api/v3/episode?seriesId=${encodeURIComponent(String(seriesId))}`,
{
headers: {
'X-Api-Key': config.sonarr.apiKey
}
}
);
}
async function getSonarrEpisodeFilesBySeriesId(config: AppConfig, seriesId: number): Promise<SonarrEpisodeFileRecord[]> {
return fetchJson<SonarrEpisodeFileRecord[]>(
`${config.sonarr.baseUrl}/api/v3/episodefile?seriesId=${encodeURIComponent(String(seriesId))}`,
{
headers: {
'X-Api-Key': config.sonarr.apiKey
}
}
);
}
async function getSonarrSeriesContext(
config: AppConfig,
seriesId: number
): Promise<{ episodes: SonarrEpisodeRecord[]; queue: SonarrQueueRecord[]; commands: SonarrCommandRecord[] }> {
const [episodes, queue, commands] = await Promise.all([
getSonarrEpisodesBySeriesId(config, seriesId),
getSonarrQueue(config),
getSonarrCommands(config)
]);
const seriesEpisodeIds = new Set(
episodes
.map((episode) => episode.id)
.filter((id): id is number => typeof id === 'number')
);
return {
episodes,
queue: queue.filter((entry) => entry.seriesId === seriesId),
commands: commands.filter((command) => {
if (command.body?.seriesId === seriesId) {
return true;
}
const episodeIds = Array.isArray(command.body?.episodeIds) ? command.body.episodeIds : [];
return episodeIds.some((episodeId) => seriesEpisodeIds.has(episodeId));
})
};
}
async function getSonarrCommands(config: AppConfig): Promise<SonarrCommandRecord[]> {
if (!config.sonarr.baseUrl || !config.sonarr.apiKey) {
return [];
}
try {
return await fetchJson<SonarrCommandRecord[]>(
`${config.sonarr.baseUrl}/api/v3/command`,
{
headers: {
'X-Api-Key': config.sonarr.apiKey
}
}
);
} catch {
return [];
}
}
async function getRadarrCommands(config: AppConfig): Promise<RadarrCommandRecord[]> {
if (!config.radarr.baseUrl || !config.radarr.apiKey) {
return [];
}
try {
return await fetchJson<RadarrCommandRecord[]>(
`${config.radarr.baseUrl}/api/v3/command`,
{
headers: {
'X-Api-Key': config.radarr.apiKey
}
}
);
} catch {
return [];
}
}
async function getRadarrMovieRecord(config: AppConfig, tmdbId: number): Promise<RadarrMovieRecord | null> {
if (!config.radarr.baseUrl || !config.radarr.apiKey || !tmdbId) {
return null;
}
const requestInit = {
headers: {
'X-Api-Key': config.radarr.apiKey
}
};
try {
const response = await fetchJson<unknown>(
`${config.radarr.baseUrl}/api/v3/movie?tmdbId=${encodeURIComponent(String(tmdbId))}`,
requestInit
);
const directMatches = asArray(response) as RadarrMovieRecord[];
const directMovie = directMatches.find((movie) => Number(movie.tmdbId || 0) === tmdbId);
if (directMovie) {
return directMovie;
}
const singleMovie = asRecord(response) as RadarrMovieRecord;
if (Number(singleMovie.tmdbId || 0) === tmdbId) {
return singleMovie;
}
} catch {
// Older Radarr versions may not support filtering by tmdbId.
}
try {
const response = await fetchJson<RadarrMovieRecord[]>(
`${config.radarr.baseUrl}/api/v3/movie`,
requestInit
);
return response.find((movie) => Number(movie.tmdbId || 0) === tmdbId) || null;
} catch {
return null;
}
}
async function getRadarrMovieById(config: AppConfig, movieId: number): Promise<RadarrMovieRecord | null> {
if (!config.radarr.baseUrl || !config.radarr.apiKey || !movieId) {
return null;
}
try {
return await fetchJson<RadarrMovieRecord>(
`${config.radarr.baseUrl}/api/v3/movie/${encodeURIComponent(String(movieId))}`,
{ headers: { 'X-Api-Key': config.radarr.apiKey } }
);
} catch {
return null;
}
}
async function resolveSonarrRequestConfig(config: AppConfig): Promise<{
rootFolderPath: string;
qualityProfileId: number;
languageProfileId: number;
}> {
const rootFolderPath = config.sonarr.rootFolderPath || (await getSonarrRootFolders(config))[0]?.id || '';
const qualityProfileId = config.sonarr.qualityProfileId || Number((await getSonarrQualityProfiles(config))[0]?.id || 0);
const languageProfileId = config.sonarr.languageProfileId || Number((await getSonarrLanguageProfiles(config))[0]?.id || 0);
if (!rootFolderPath) {
throw new Error('Sonarr root folder is not configured.');
}
if (!qualityProfileId) {
throw new Error('Sonarr quality profile is not configured.');
}
if (!languageProfileId) {
throw new Error('Sonarr language profile is not configured.');
}
return {
rootFolderPath,
qualityProfileId,
languageProfileId
};
}
async function resolveRadarrRequestConfig(config: AppConfig): Promise<{
rootFolderPath: string;
qualityProfileId: number;
}> {
const rootFolderPath = config.radarr.rootFolderPath || (await getRadarrRootFolders(config))[0]?.id || '';
const qualityProfileId = config.radarr.qualityProfileId || Number((await getRadarrQualityProfiles(config))[0]?.id || 0);
if (!rootFolderPath) {
throw new Error('Radarr root folder is not configured.');
}
if (!qualityProfileId) {
throw new Error('Radarr quality profile is not configured.');
}
return {
rootFolderPath,
qualityProfileId
};
}
function buildSeriesSeasonDescriptors(
tmdbSeries: Record<string, unknown> | null,
jellyfinSeasons: SeriesSeasonDescriptor[],
sonarrEpisodes: SonarrEpisodeRecord[],
tvdbSeries: TvdbSeriesRecord | null,
tvdbEpisodes: TvdbEpisodeRecord[]
): SeriesSeasonDescriptor[] {
const jellyfinBySeasonNumber = new Map(
jellyfinSeasons
.filter((season) => typeof season.seasonNumber === 'number')
.map((season) => [season.seasonNumber as number, season] as const)
);
const sonarrEpisodeCountBySeasonNumber = new Map<number, number>();
for (const episode of sonarrEpisodes) {
if (typeof episode.seasonNumber !== 'number' || typeof episode.episodeNumber !== 'number' || episode.seasonNumber < 0) {
continue;
}
sonarrEpisodeCountBySeasonNumber.set(
episode.seasonNumber,
(sonarrEpisodeCountBySeasonNumber.get(episode.seasonNumber) || 0) + 1
);
}
const tvdbEpisodeCountBySeasonNumber = new Map<number, number>();
for (const episode of tvdbEpisodes) {
if (typeof episode.seasonNumber !== 'number' || typeof episode.number !== 'number' || episode.seasonNumber < 0) {
continue;
}
tvdbEpisodeCountBySeasonNumber.set(
episode.seasonNumber,
(tvdbEpisodeCountBySeasonNumber.get(episode.seasonNumber) || 0) + 1
);
}
const tvdbSeasonBySeasonNumber = new Map(
asArray(tvdbSeries?.seasons)
.map((entry) => entry as TvdbSeasonRecord)
.filter((season) => typeof season.number === 'number' && season.number >= 0)
.map((season) => [season.number as number, season] as const)
);
const tmdbSeasonBySeasonNumber = new Map(
asArray(tmdbSeries?.seasons)
.map((entry) => asRecord(entry))
.map((season) => ({
seasonNumber: Number(season.season_number || 0),
name: typeof season.name === 'string' ? season.name : '',
episodeCount: Number(season.episode_count || 0) || undefined
} satisfies TmdbSeasonSummary))
.filter((season) => Number.isFinite(season.seasonNumber) && season.seasonNumber >= 0)
.map((season) => [season.seasonNumber, season] as const)
);
const seasonNumbers = new Set<number>([
...tvdbEpisodeCountBySeasonNumber.keys(),
...tvdbSeasonBySeasonNumber.keys(),
...jellyfinBySeasonNumber.keys(),
...sonarrEpisodeCountBySeasonNumber.keys()
]);
if (seasonNumbers.size) {
return [...seasonNumbers]
.sort((left, right) => sortSeasonDescriptors({ seasonNumber: left }, { seasonNumber: right }))
.map((seasonNumber) => {
const jellyfinSeason = jellyfinBySeasonNumber.get(seasonNumber);
const tvdbSeason = tvdbSeasonBySeasonNumber.get(seasonNumber);
const tmdbSeason = tmdbSeasonBySeasonNumber.get(seasonNumber);
const episodeCount = tvdbEpisodeCountBySeasonNumber.get(seasonNumber)
?? sonarrEpisodeCountBySeasonNumber.get(seasonNumber)
?? jellyfinSeason?.episodeCount;
return {
id: buildSeasonId(seasonNumber),
title: seasonNumber === 0
? tvdbSeason?.name || jellyfinSeason?.title || 'Specials'
: tvdbSeason?.name || jellyfinSeason?.title || `Season ${seasonNumber}`,
seasonNumber,
episodeCount,
hasTmdbSeason: Boolean(
tmdbSeason
&& episodeCount
&& tmdbSeason.episodeCount === episodeCount
),
jellyfinSeasonId: jellyfinSeason?.jellyfinSeasonId
};
})
.filter((season) => (season.episodeCount ?? 0) > 0);
}
const tmdbSeasons = asArray(tmdbSeries?.seasons)
.map((entry) => asRecord(entry))
.map((season) => ({
seasonNumber: Number(season.season_number || 0),
name: typeof season.name === 'string' ? season.name : '',
episodeCount: Number(season.episode_count || 0) || undefined
} satisfies TmdbSeasonSummary))
.filter((season) => Number.isFinite(season.seasonNumber) && season.seasonNumber >= 0);
if (tmdbSeasons.length) {
return tmdbSeasons
.filter((season) => {
const jellyfinSeason = jellyfinBySeasonNumber.get(season.seasonNumber);
return (season.episodeCount ?? jellyfinSeason?.episodeCount ?? 0) > 0;
})
.map((season) => {
const jellyfinSeason = jellyfinBySeasonNumber.get(season.seasonNumber);
return {
id: buildSeasonId(season.seasonNumber),
title: season.seasonNumber === 0
? jellyfinSeason?.title || season.name || 'Specials'
: season.name || `Season ${season.seasonNumber}`,
seasonNumber: season.seasonNumber,
episodeCount: season.episodeCount ?? jellyfinSeason?.episodeCount,
hasTmdbSeason: true,
jellyfinSeasonId: jellyfinSeason?.jellyfinSeasonId
};
})
.sort(sortSeasonDescriptors);
}
return jellyfinSeasons
.filter((season) => (season.episodeCount ?? 0) > 0)
.sort(sortSeasonDescriptors);
}
function normalizeEpisodeTitle(value: string | undefined): string {
return String(value || '')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.replace(/\b(part|episode|ep)\b/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function normalizeEpisodeDate(value: string | undefined): string {
return String(value || '').slice(0, 10);
}
function matchJellyfinEpisode(
jellyfinEpisodes: TitleDetailsEpisode[],
episodeNumber: number,
preferredTitle: string | undefined,
preferredAirDate: string | undefined
): TitleDetailsEpisode | undefined {
const normalizedPreferredTitle = normalizeEpisodeTitle(preferredTitle);
const normalizedPreferredDate = normalizeEpisodeDate(preferredAirDate);
if (normalizedPreferredTitle) {
const exactTitleMatch = jellyfinEpisodes.find((episode) => normalizeEpisodeTitle(episode.title) === normalizedPreferredTitle);
if (exactTitleMatch) {
return exactTitleMatch;
}
}
if (normalizedPreferredDate) {
const dateMatch = jellyfinEpisodes.find((episode) => normalizeEpisodeDate(episode.airDate) === normalizedPreferredDate);
if (dateMatch) {
return dateMatch;
}
}
return jellyfinEpisodes.find((episode) => episode.episodeNumber === episodeNumber);
}
async function buildSeasonEpisodes(
config: AppConfig,
tmdbId: number | undefined,
season: SeriesSeasonDescriptor,
jellyfinSeriesId: string | undefined,
isManagedInSonarr: boolean,
sonarrEpisodes: SonarrEpisodeRecord[],
sonarrQueue: SonarrQueueRecord[],
sonarrCommands: SonarrCommandRecord[],
tvdbEpisodes: TvdbEpisodeRecord[]
): Promise<TitleDetailsEpisode[]> {
const seasonNumber = season.seasonNumber;
const [tmdbEpisodes, jellyfinEpisodes] = await Promise.all([
tmdbId && season.hasTmdbSeason && typeof seasonNumber === 'number'
? getTmdbSeasonEpisodes(config, tmdbId, seasonNumber)
: Promise.resolve([]),
jellyfinSeriesId && season.jellyfinSeasonId
? getJellyfinSeasonEpisodes(config, jellyfinSeriesId, season.jellyfinSeasonId)
: Promise.resolve([])
]);
const tmdbByEpisodeNumber = new Map(
tmdbEpisodes
.filter((episode) => typeof episode.episode_number === 'number')
.map((episode) => [episode.episode_number as number, episode] as const)
);
const jellyfinByEpisodeNumber = new Map(
jellyfinEpisodes
.filter((episode) => typeof episode.episodeNumber === 'number')
.map((episode) => [episode.episodeNumber as number, episode] as const)
);
const sonarrByEpisodeNumber = new Map(
sonarrEpisodes
.filter((episode) => episode.seasonNumber === seasonNumber && typeof episode.episodeNumber === 'number')
.map((episode) => [episode.episodeNumber as number, episode] as const)
);
const tvdbByEpisodeNumber = new Map(
tvdbEpisodes
.filter((episode) => episode.seasonNumber === seasonNumber && typeof episode.number === 'number')
.map((episode) => [episode.number as number, episode] as const)
);
const downloadingEpisodes = new Set(
sonarrQueue
.filter((entry) => entry.episode?.seasonNumber === seasonNumber && typeof entry.episode?.episodeNumber === 'number')
.map((entry) => entry.episode?.episodeNumber as number)
);
const authoritativeEpisodeNumbers = tvdbByEpisodeNumber.size
? [...tvdbByEpisodeNumber.keys()]
: (jellyfinByEpisodeNumber.size > 0 || sonarrByEpisodeNumber.size > 0)
? [
...jellyfinByEpisodeNumber.keys(),
...sonarrByEpisodeNumber.keys()
]
: [
...tmdbByEpisodeNumber.keys(),
...jellyfinByEpisodeNumber.keys(),
...sonarrByEpisodeNumber.keys()
];
const episodeNumbers = new Set<number>(authoritativeEpisodeNumbers);
return [...episodeNumbers]
.sort((left, right) => left - right)
.map((episodeNumber) => {
const tvdbEpisode = tvdbByEpisodeNumber.get(episodeNumber);
const tmdbEpisode = tmdbByEpisodeNumber.get(episodeNumber);
const sonarrEpisode = sonarrByEpisodeNumber.get(episodeNumber);
const jellyfinEpisode = matchJellyfinEpisode(
jellyfinEpisodes,
episodeNumber,
sonarrEpisode?.title || tvdbEpisode?.name || tmdbEpisode?.name,
sonarrEpisode?.airDateUtc || tvdbEpisode?.aired || tmdbEpisode?.air_date
) || jellyfinByEpisodeNumber.get(episodeNumber);
const queueEntry = sonarrQueue.find((entry) => entry.episode?.seasonNumber === seasonNumber && entry.episode?.episodeNumber === episodeNumber);
const airDate = sonarrEpisode?.airDateUtc || tmdbEpisode?.air_date || tvdbEpisode?.aired;
const isReleased = !airDate || new Date(airDate).getTime() <= Date.now();
const hasFile = Boolean(sonarrEpisode?.hasFile || sonarrEpisode?.episodeFileId);
const jellyfinKnowsAboutIt = Boolean(jellyfinEpisode?.id);
const isDownloading = downloadingEpisodes.has(episodeNumber);
const sonarrEpisodeId = sonarrEpisode?.id;
const isProcessing = Boolean(
!isDownloading
&& sonarrEpisodeId
&& sonarrCommands.some((command) => isActiveEpisodeSearchCommand(command, sonarrEpisodeId))
);
const availability: TitleDetailsEpisode['availability'] = !isReleased
? 'unaired'
: hasFile
? 'available'
: isDownloading
? 'downloading'
: 'missing';
const downloadStatus: TitleDetailsEpisode['downloadStatus'] = isDownloading
? 'downloading'
: isProcessing
? 'processing'
: 'idle';
return {
id: jellyfinEpisode?.id || buildEpisodeId(seasonNumber, episodeNumber),
sonarrEpisodeId,
seasonId: season.id,
title: tmdbEpisode?.name || tvdbEpisode?.name || sonarrEpisode?.title || jellyfinEpisode?.title || `Episode ${episodeNumber}`,
overview: tmdbEpisode?.overview || tvdbEpisode?.overview || sonarrEpisode?.overview || jellyfinEpisode?.overview || '',
subtitle: [
typeof seasonNumber === 'number' ? `Season ${seasonNumber}` : '',
`Episode ${episodeNumber}`
].filter(Boolean).join(', '),
imageUrl: resolveTvdbImageUrl(tvdbEpisode?.image)
|| jellyfinEpisode?.imageUrl
|| (tmdbEpisode?.still_path ? `${TMDB_IMAGE}/w780${tmdbEpisode.still_path}` : '')
|| season.imageUrl
|| '',
airDate: tvdbEpisode?.aired || airDate,
seasonNumber,
episodeNumber,
runtimeMinutes: tvdbEpisode?.runtime || sonarrEpisode?.runtime || jellyfinEpisode?.runtimeMinutes || tmdbEpisode?.runtime,
availableInJellyfin: availability === 'available' && jellyfinKnowsAboutIt,
availability,
downloadStatus,
downloadProgress: isDownloading ? getEpisodeQueueProgress(queueEntry) : undefined,
canDownload: isManagedInSonarr && availability === 'missing',
canDelete: hasFile,
watched: jellyfinEpisode?.watched,
progress: jellyfinEpisode?.progress,
playbackPositionTicks: jellyfinEpisode?.playbackPositionTicks
};
});
}
function getEpisodeQueueProgress(entry: SonarrQueueRecord | undefined): number | undefined {
if (!entry) {
return undefined;
}
const total = Number(entry.size || 0);
const left = Number(entry.sizeleft || 0);
if (!total || total <= 0) {
return undefined;
}
const percent = Math.round(((total - left) / total) * 100);
return Math.max(0, Math.min(100, percent));
}
function buildTitleSeasons(
seasons: SeriesSeasonDescriptor[],
allEpisodes: TitleDetailsEpisode[],
sonarrEpisodes: SonarrEpisodeRecord[],
sonarrQueue: SonarrQueueRecord[],
sonarrCommands: SonarrCommandRecord[]
): TitleDetailsSeason[] {
const seasonMap = new Map<number, TitleDetailsSeason>();
for (const season of seasons) {
const seasonQueue = sonarrQueue.filter((entry) => entry.episode?.seasonNumber === season.seasonNumber);
const availableEpisodeCount = sonarrEpisodes.filter(
(episode) => episode.seasonNumber === season.seasonNumber && (episode.hasFile || episode.episodeFileId)
).length;
const isDownloading = seasonQueue.length > 0;
const isProcessing = sonarrCommands.some((command) =>
command.body?.seasonNumber === season.seasonNumber && isActiveSeasonSearchCommand(command)
);
seasonMap.set(season.seasonNumber ?? Number.MIN_SAFE_INTEGER, {
id: season.id,
title: season.title,
seasonNumber: season.seasonNumber,
episodeCount: season.episodeCount,
availableEpisodeCount,
missingEpisodeCount: typeof season.episodeCount === 'number'
? Math.max(season.episodeCount - availableEpisodeCount, 0)
: undefined,
canDownload: false,
canDelete: typeof season.episodeCount === 'number'
? availableEpisodeCount >= season.episodeCount
: availableEpisodeCount > 0,
isDownloading,
downloadStatus: isDownloading ? 'downloading' : isProcessing ? 'processing' : 'idle',
downloadProgress: isDownloading ? getSeasonQueueProgress(seasonQueue) : undefined,
downloadLabel: isDownloading ? 'Downloading' : isProcessing ? 'Processing' : undefined
});
}
for (const season of seasons) {
const seasonEpisodes = allEpisodes.filter((episode) => episode.seasonId === season.id);
if (!seasonEpisodes.length || typeof season.seasonNumber !== 'number') {
continue;
}
const current = seasonMap.get(season.seasonNumber);
if (!current) {
continue;
}
seasonMap.set(season.seasonNumber, {
...current,
episodeCount: seasonEpisodes.length || current.episodeCount,
availableEpisodeCount: seasonEpisodes.filter((episode) => episode.availability === 'available').length,
missingEpisodeCount: seasonEpisodes.filter((episode) => episode.availability === 'missing').length,
canDownload: seasonEpisodes.some((episode) => episode.canDownload),
canDelete: seasonEpisodes.length > 0 && seasonEpisodes.every((episode) => episode.canDelete),
isDownloading: current.isDownloading || seasonEpisodes.some((episode) => episode.availability === 'downloading')
});
}
return seasons
.map((season) => seasonMap.get(season.seasonNumber ?? Number.MIN_SAFE_INTEGER))
.filter((season): season is TitleDetailsSeason => Boolean(season));
}
function buildSeasonId(seasonNumber?: number, fallback?: string): string {
return typeof seasonNumber === 'number'
? `season:${seasonNumber}`
: `season:${fallback || 'unknown'}`;
}
function buildEpisodeId(seasonNumber: number | undefined, episodeNumber: number): string {
return `episode:${typeof seasonNumber === 'number' ? seasonNumber : 'x'}:${episodeNumber}`;
}
function sortSeasonDescriptors(left: { seasonNumber?: number }, right: { seasonNumber?: number }) {
const leftNumber = left.seasonNumber === 0 ? Number.MAX_SAFE_INTEGER : left.seasonNumber ?? Number.MAX_SAFE_INTEGER - 1;
const rightNumber = right.seasonNumber === 0 ? Number.MAX_SAFE_INTEGER : right.seasonNumber ?? Number.MAX_SAFE_INTEGER - 1;
return leftNumber - rightNumber;
}
function isActiveSeasonSearchCommand(command: SonarrCommandRecord): boolean {
const name = String(command.name || command.commandName || '').toLowerCase();
const status = String(command.status || command.state || '').toLowerCase();
return name === 'seasonsearch'
&& status !== 'completed'
&& status !== 'failed'
&& status !== 'aborted'
&& status !== 'cancelled'
&& status !== 'orphaned';
}
function isActiveEpisodeSearchCommand(command: SonarrCommandRecord, episodeId: number): boolean {
const name = String(command.name || command.commandName || '').toLowerCase();
const status = String(command.status || command.state || '').toLowerCase();
const episodeIds = Array.isArray(command.body?.episodeIds) ? command.body.episodeIds : [];
return name === 'episodesearch'
&& episodeIds.includes(episodeId)
&& status !== 'completed'
&& status !== 'failed'
&& status !== 'aborted'
&& status !== 'cancelled'
&& status !== 'orphaned';
}
function isActiveMovieSearchCommand(command: RadarrCommandRecord, movieId: number): boolean {
const name = String(command.name || command.commandName || '').toLowerCase();
const status = String(command.status || command.state || '').toLowerCase();
const movieIds = Array.isArray(command.body?.movieIds) ? command.body.movieIds : [];
return name === 'moviessearch'
&& movieIds.includes(movieId)
&& status !== 'completed'
&& status !== 'failed'
&& status !== 'aborted'
&& status !== 'cancelled'
&& status !== 'orphaned';
}
function getSeasonQueueProgress(queue: SonarrQueueRecord[]): number | undefined {
if (!queue.length) {
return undefined;
}
const totalSize = queue.reduce((sum, item) => sum + Math.max(0, Number(item.size || 0)), 0);
const totalSizeLeft = queue.reduce((sum, item) => sum + Math.max(0, Number(item.sizeleft || 0)), 0);
if (!totalSize) {
return undefined;
}
return Math.max(0, Math.min(100, Math.round(((totalSize - totalSizeLeft) / totalSize) * 100)));
}
function getMovieQueueProgress(entry: RadarrQueueRecord | undefined): number | undefined {
if (!entry) {
return undefined;
}
const total = Number(entry.size || 0);
const left = Number(entry.sizeleft || 0);
if (!total || total <= 0) {
return undefined;
}
const percent = Math.round(((total - left) / total) * 100);
return Math.max(0, Math.min(100, percent));
}
async function getNextEpisodeToPlay(
config: AppConfig,
seriesId: string
): Promise<TitleDetailsEpisode | null> {
if (!config.jellyfin.baseUrl || !config.jellyfin.apiKey || !config.jellyfin.userId) {
return null;
}
try {
const response = await jellyfinFetch<{ Items?: JellyfinItem[] }>(
config,
`/Shows/NextUp?userId=${encodeURIComponent(config.jellyfin.userId)}&seriesId=${encodeURIComponent(seriesId)}&enableUserData=true&fields=Overview,ImageTags,RunTimeTicks,ParentIndexNumber,IndexNumber&limit=1`
);
const episode = response.Items?.find((item) => item.Id);
if (episode) {
return mapJellyfinEpisode(episode);
}
} catch {
// Fall back to a full episode scan when NextUp is unavailable.
}
try {
const episodes = await getSeriesEpisodesForNextUp(config, seriesId);
const nextEpisode = episodes.find((episode) => !episode.watched);
return nextEpisode || episodes[0] || null;
} catch {
return null;
}
}
async function getSeriesEpisodesForNextUp(config: AppConfig, seriesId: string): Promise<TitleDetailsEpisode[]> {
const episodes = await jellyfinFetch<JellyfinItem[]>(
config,
`/Users/${encodeURIComponent(config.jellyfin.userId)}/Items?parentId=${encodeURIComponent(seriesId)}&recursive=true&includeItemTypes=Episode&enableUserData=true&fields=Overview,ImageTags,RunTimeTicks,ParentIndexNumber,IndexNumber&sortBy=SortName`
);
return episodes
.filter((episode) => episode.Id)
.sort((left, right) => {
const seasonDifference = (left.ParentIndexNumber ?? 0) - (right.ParentIndexNumber ?? 0);
if (seasonDifference !== 0) {
return seasonDifference;
}
return (left.IndexNumber ?? 0) - (right.IndexNumber ?? 0);
})
.map(mapJellyfinEpisode);
}
function mapJellyfinEpisode(episode: JellyfinItem): TitleDetailsEpisode {
const imageTag = episode.ImageTags?.Primary || '';
return {
id: String(episode.Id),
title: episode.Name || `Episode ${episode.IndexNumber ?? ''}`.trim(),
overview: episode.Overview || '',
subtitle: [
typeof episode.ParentIndexNumber === 'number' ? `Season ${episode.ParentIndexNumber}` : '',
typeof episode.IndexNumber === 'number' ? `Episode ${episode.IndexNumber}` : ''
].filter(Boolean).join(', '),
imageUrl: imageTag ? buildJellyfinAssetUrl(String(episode.Id), 'Primary', 960, imageTag) : '',
airDate: episode.PremiereDate,
seasonNumber: episode.ParentIndexNumber,
episodeNumber: episode.IndexNumber,
runtimeMinutes: episode.RunTimeTicks ? Math.max(1, Math.round(episode.RunTimeTicks / 600000000)) : undefined,
availableInJellyfin: true,
availability: 'available',
watched: Boolean(episode.UserData?.Played),
progress: Math.max(0, Math.min(100, Math.round(episode.UserData?.PlayedPercentage || 0))) || undefined,
playbackPositionTicks: episode.UserData?.PlaybackPositionTicks
};
}
export async function requestMovie(config: AppConfig, tmdbId: number, user: string | null, qualityProfileId?: number): Promise<void> {
if (!config.radarr.baseUrl || !config.radarr.apiKey) {
throw new Error('Radarr is not configured.');
}
if (!tmdbId) {
throw new Error('Movie TMDb id is required.');
}
// First, check if the movie already exists in Radarr
let movie = await getRadarrMovieRecord(config, tmdbId);
// If the movie does not exist, add it to Radarr
if (!movie) {
const radarrRequestConfig = await resolveRadarrRequestConfig(config);
const details = await tmdbFetch(config, `/movie/${tmdbId}?language=${encodeURIComponent(config.preferences.language)}`);
if (!details) {
throw new Error('Failed to load the movie from TMDb.');
}
try {
movie = await fetchJson<RadarrMovieRecord>(`${config.radarr.baseUrl}/api/v3/movie`, {
method: 'POST',
headers: {
'X-Api-Key': config.radarr.apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: details.title || details.original_title || 'Movie',
qualityProfileId: qualityProfileId || radarrRequestConfig.qualityProfileId,
rootFolderPath: radarrRequestConfig.rootFolderPath,
tmdbId: details.id,
year: Number(String(details.release_date || '').slice(0, 4)) || 0,
minimumAvailability: 'released',
addOptions: {
monitored: config.radarr.monitor !== 'none',
searchForMovie: false
}
})
});
} catch (error) {
const message = error instanceof Error ? error.message.toLowerCase() : '';
const looksLikeAlreadyExists = message.includes('already') || message.includes('exists') || message.includes('duplicate');
if (looksLikeAlreadyExists) {
// Race condition: movie was added between our check and our add call. Get it again.
movie = await getRadarrMovieRecord(config, tmdbId);
} else {
throw error; // Rethrow other errors
}
}
}
// If we still don't have a movie record, something is wrong.
if (!movie?.id) {
throw new Error('Failed to add or find movie in Radarr.');
}
// If movie is already downloaded, we're done.
if (movie.hasFile) {
return;
}
// If movie is not downloaded, trigger a search.
await fetchJson(`${config.radarr.baseUrl}/api/v3/command`, {
method: 'POST',
headers: {
'X-Api-Key': config.radarr.apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'MoviesSearch',
movieIds: [movie.id],
qualityProfileId
})
});
addProcessingItem({
source: 'radarr',
title: movie.title || 'Movie',
subtitle: movie.year ? String(movie.year) : undefined,
tmdbId: movie.tmdbId,
posterUrl: extractRemoteImage(movie.images),
mediaType: 'movie',
user: user || 'Unknown',
});
}
export async function requestSeries(
config: AppConfig,
tmdbId: number,
user: string | null,
qualityProfileId?: number,
seasonNumber?: number
): Promise<void> {
if (!config.sonarr.baseUrl || !config.sonarr.apiKey) {
throw new Error('Sonarr is not configured.');
}
const details = await tmdbFetch(
config,
`/tv/${tmdbId}?append_to_response=external_ids&language=${encodeURIComponent(config.preferences.language)}`
);
if (!details) {
throw new Error('Failed to load the series from TMDb.');
}
const externalIds = asRecord(details?.external_ids);
const tvdbId = Number(externalIds.tvdb_id || 0);
if (!tvdbId) {
throw new Error('This series does not have a TVDB ID for Sonarr.');
}
const sonarrRequestConfig = await resolveSonarrRequestConfig(config);
addProcessingItem({
source: 'sonarr',
title: String(details.name || details.original_name || 'Series'),
subtitle: typeof seasonNumber === 'number' ? `Season ${seasonNumber}` : 'All seasons',
tmdbId,
posterUrl: typeof details.poster_path === 'string' && details.poster_path
? `${TMDB_IMAGE}/w342${details.poster_path}`
: undefined,
mediaType: 'series',
user: user || 'Unknown',
});
const series = await fetchJson<SonarrSeriesRecord>(`${config.sonarr.baseUrl}/api/v3/series`, {
method: 'POST',
headers: {
'X-Api-Key': config.sonarr.apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({
title: details.name || details.original_name || 'Series',
tvdbId,
qualityProfileId: qualityProfileId || sonarrRequestConfig.qualityProfileId,
languageProfileId: sonarrRequestConfig.languageProfileId,
rootFolderPath: sonarrRequestConfig.rootFolderPath,
seasonFolder: true,
monitored: config.sonarr.monitor !== 'none',
addOptions: {
monitor: typeof seasonNumber === 'number' ? 'none' : config.sonarr.monitor,
searchForMissingEpisodes: typeof seasonNumber !== 'number',
searchForCutoffUnmetEpisodes: typeof seasonNumber !== 'number'
}
})
});
if (typeof seasonNumber !== 'number') {
return
}
if (!series?.id) {
throw new Error('Sonarr did not return the added series.')
}
const seasons = (series.seasons || []).map((season) =>
season.seasonNumber === seasonNumber
? { ...season, monitored: true }
: { ...season, monitored: false }
)
if (!seasons.some((season) => season.seasonNumber === seasonNumber)) {
throw new Error(`Season ${seasonNumber} was not found in Sonarr.`)
}
await fetchJson(`${config.sonarr.baseUrl}/api/v3/series/${encodeURIComponent(String(series.id))}`, {
method: 'PUT',
headers: {
'X-Api-Key': config.sonarr.apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({ ...series, monitored: true, seasons })
})
await fetchJson(`${config.sonarr.baseUrl}/api/v3/command`, {
method: 'POST',
headers: {
'X-Api-Key': config.sonarr.apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'SeasonSearch',
seriesId: series.id,
seasonNumber,
qualityProfileId
})
})
}
export async function requestSeason(config: AppConfig, payload: SeasonRequestPayload, user: string | null): Promise<void> {
if (!config.sonarr.baseUrl || !config.sonarr.apiKey) {
throw new Error('Sonarr is not configured.');
}
if (!payload.seriesId || !payload.seasonNumber) {
throw new Error('Series id and season number are required.');
}
const series = await getSonarrSeriesById(config, payload.seriesId);
if (!series?.id) {
throw new Error('Series not found in Sonarr.');
}
const nextSeasons = (series.seasons || []).map((season) =>
season.seasonNumber === payload.seasonNumber
? { ...season, monitored: true }
: season
);
if (!nextSeasons.some((season) => season.seasonNumber === payload.seasonNumber && season.monitored)) {
throw new Error(`Season ${payload.seasonNumber} not found in Sonarr.`);
}
await fetchJson(`${config.sonarr.baseUrl}/api/v3/series/${encodeURIComponent(String(payload.seriesId))}`, {
method: 'PUT',
headers: {
'X-Api-Key': config.sonarr.apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({
...series,
monitored: true,
seasons: nextSeasons
})
});
await fetchJson(`${config.sonarr.baseUrl}/api/v3/command`, {
method: 'POST',
headers: {
'X-Api-Key': config.sonarr.apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'SeasonSearch',
seriesId: payload.seriesId,
seasonNumber: payload.seasonNumber,
qualityProfileId: payload.qualityProfileId
})
});
addProcessingItem({
source: 'sonarr',
title: series.title || 'Series',
subtitle: `Season ${payload.seasonNumber}`,
tmdbId: series.tmdbId,
posterUrl: extractRemoteImage(series.images),
mediaType: 'tv',
user: user || 'Unknown',
});
}
export async function requestEpisode(config: AppConfig, payload: EpisodeRequestPayload, user: string | null): Promise<void> {
if (!config.sonarr.baseUrl || !config.sonarr.apiKey) {
throw new Error('Sonarr is not configured.');
}
if (!payload.episodeId) {
throw new Error('Episode id is required.');
}
await fetchJson(`${config.sonarr.baseUrl}/api/v3/command`, {
method: 'POST',
headers: {
'X-Api-Key': config.sonarr.apiKey,
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'EpisodeSearch',
episodeIds: [payload.episodeId],
qualityProfileId: payload.qualityProfileId
})
});
// Try to get episode details for the processing item
try {
const episode = await fetchJson<{ title?: string; seasonNumber?: number; episodeNumber?: number; series?: { title?: string; tmdbId?: number } }>(
`${config.sonarr.baseUrl}/api/v3/episode/${encodeURIComponent(String(payload.episodeId))}`,
{ headers: { 'X-Api-Key': config.sonarr.apiKey } }
);
addProcessingItem({
source: 'sonarr',
title: episode?.series?.title || 'Series',
subtitle: episode?.seasonNumber != null && episode?.episodeNumber != null
? `S${String(episode.seasonNumber).padStart(2, '0')}E${String(episode.episodeNumber).padStart(2, '0')} \u00b7 ${episode.title || ''}`
: episode?.title || 'Episode',
tmdbId: episode?.series?.tmdbId,
mediaType: 'tv',
user: user || 'Unknown',
});
} catch {
addProcessingItem({
source: 'sonarr',
title: 'Episode',
subtitle: `Episode #${payload.episodeId}`,
mediaType: 'tv',
user: user || 'Unknown',
});
}
}
export async function deleteSeasonFiles(config: AppConfig, payload: SeasonDeletePayload): Promise<void> {
if (!config.sonarr.baseUrl || !config.sonarr.apiKey) {
throw new Error('Sonarr is not configured.');
}
if (!payload.seriesId || !payload.seasonNumber) {
throw new Error('Series id and season number are required.');
}
const [episodes, files] = await Promise.all([
getSonarrEpisodesBySeriesId(config, payload.seriesId),
getSonarrEpisodeFilesBySeriesId(config, payload.seriesId)
]);
const fileIds = new Set(
episodes
.filter((episode) => episode.seasonNumber === payload.seasonNumber && episode.episodeFileId)
.map((episode) => Number(episode.episodeFileId))
);
for (const file of files) {
if (file.seasonNumber === payload.seasonNumber && file.id) {
fileIds.add(Number(file.id));
}
}
await Promise.all(
[...fileIds].map((fileId) =>
fetchJson(`${config.sonarr.baseUrl}/api/v3/episodefile/${encodeURIComponent(String(fileId))}`, {
method: 'DELETE',
headers: {
'X-Api-Key': config.sonarr.apiKey
}
})
)
);
}
export async function deleteMovieFiles(config: AppConfig, payload: MovieDeletePayload): Promise<void> {
if (!config.radarr.baseUrl || !config.radarr.apiKey) {
throw new Error('Radarr is not configured.');
}
if (!payload.movieId) {
throw new Error('Movie id is required.');
}
const baseUrl = config.radarr.baseUrl.endsWith('/') ? config.radarr.baseUrl : `${config.radarr.baseUrl}/`;
const movieUrl = new URL(`api/v3/movie/${encodeURIComponent(String(payload.movieId))}`, baseUrl);
movieUrl.searchParams.set('deleteFiles', 'true');
movieUrl.searchParams.set('addImportExclusion', 'false');
const response = await fetch(movieUrl, {
method: 'DELETE',
headers: {
'X-Api-Key': config.radarr.apiKey
}
});
if (!response.ok) {
const detail = (await response.text()).trim();
throw new Error(detail
? `Radarr ${response.status} ${response.statusText}: ${detail}`
: `Radarr ${response.status} ${response.statusText}`);
}
}
export async function setEpisodeWatchState(config: AppConfig, payload: EpisodeWatchStateRequest): Promise<void> {
if (!config.jellyfin.baseUrl || !config.jellyfin.apiKey || !config.jellyfin.userId) {
throw new Error('Jellyfin is not configured.');
}
const episodeId = String(payload.episodeId || '')
if (!episodeId) {
throw new Error('Episode id is required.')
}
const path = `/Users/${encodeURIComponent(config.jellyfin.userId)}/PlayedItems/${encodeURIComponent(episodeId)}`
const url = `${config.jellyfin.baseUrl}${path}`
const response = await fetch(payload.watched ? `${url}?datePlayed=${encodeURIComponent(new Date().toISOString())}` : url, {
method: payload.watched ? 'POST' : 'DELETE',
headers: {
Accept: 'application/json',
Authorization: `MediaBrowser Token="${config.jellyfin.apiKey}"`
}
})
if (!response.ok) {
throw new Error(`${response.status} ${response.statusText}`)
}
}
export async function proxyJellyfinImage(
config: AppConfig,
itemId: string,
kind: string,
maxWidth: number,
tag: string,
isUser = false
): Promise<{ data: ArrayBuffer; contentType: string | null } | null> {
const candidates = kind === 'Primary' ? ['Primary'] : [kind, 'Primary'];
for (const candidate of candidates) {
const collection = isUser ? 'Users' : 'Items';
const path = `/${collection}/${encodeURIComponent(itemId)}/Images/${encodeURIComponent(candidate)}?quality=90&maxWidth=${maxWidth}&tag=${encodeURIComponent(tag)}`;
const response = await fetch(`${config.jellyfin.baseUrl}${path}`, {
headers: {
Authorization: `MediaBrowser Token="${config.jellyfin.apiKey}"`
}
});
if (!response.ok) {
continue;
}
return {
data: await response.arrayBuffer(),
contentType: response.headers.get('content-type')
};
}
return null;
}
function normalizeBaseUrl(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
return '';
}
const withProtocol = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`;
const parsed = new URL(withProtocol);
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new Error(`Unsupported URL protocol: ${trimmed}`);
}
return parsed.toString().replace(/\/$/, '');
}
async function getLibrarySnapshot(config: AppConfig): Promise<{
continueWatching: MediaCard[];
recentlyAdded: MediaCard[];
allItems: JellyfinItem[];
}> {
if (!config.jellyfin.baseUrl || !config.jellyfin.apiKey || !config.jellyfin.userId) {
return {
continueWatching: [],
recentlyAdded: [],
allItems: []
};
}
let resume!: JellyfinItem[];
let nextUp!: JellyfinItem[];
let allItems!: JellyfinItem[];
try {
[resume, nextUp, allItems] = await Promise.all([
jellyfinFetch<JellyfinItem[]>(config, `/Users/${config.jellyfin.userId}/Items/Resume?mediaTypes=Video&enableUserData=true&fields=ProviderIds,Overview,SeriesId,SeriesPrimaryImageTag,ParentBackdropItemId,ParentBackdropImageTags,RunTimeTicks,ParentIndexNumber,IndexNumber,UserData`),
jellyfinFetch<JellyfinItem[]>(config, `/Shows/NextUp?userId=${config.jellyfin.userId}&enableUserData=true&fields=ProviderIds,Overview,SeriesId,SeriesPrimaryImageTag,ParentBackdropItemId,ParentBackdropImageTags,RunTimeTicks,ParentIndexNumber,IndexNumber,UserData`),
jellyfinFetch<JellyfinItem[]>(
config,
`/Users/${config.jellyfin.userId}/Items?recursive=true&includeItemTypes=Movie,Series&fields=ProviderIds,DateCreated,DateLastMediaAdded,Overview,ImageTags,BackdropImageTags,ProductionYear`
)
]);
} catch {
return {
continueWatching: [],
recentlyAdded: [],
allItems: []
};
}
const tmdbIdByItemId = new Map(
allItems
.filter((item) => item.Id)
.map((item) => [String(item.Id), Number(item.ProviderIds?.Tmdb) || undefined] as const)
);
const continueWatching = dedupeContinueWatchingItems([...resume, ...nextUp])
.slice(0, 12)
.map((item) => jellyfinToCard(item, item.SeriesName || item.Name || 'Continue watching', tmdbIdByItemId))
.filter(Boolean) as MediaCard[];
const recentlyAdded = [...allItems]
.sort((a, b) => {
const aDate = new Date(a.DateLastMediaAdded || a.DateCreated || 0).getTime();
const bDate = new Date(b.DateLastMediaAdded || b.DateCreated || 0).getTime();
return bDate - aDate;
})
.slice(0, 12)
.map((item) => jellyfinToCard(item, item.Name || 'Added recently'))
.filter(Boolean) as MediaCard[];
return {
continueWatching,
recentlyAdded,
allItems
};
}
function dedupeContinueWatchingItems(items: JellyfinItem[]): JellyfinItem[] {
const seen = new Set<string>();
const deduped: JellyfinItem[] = [];
for (const item of items) {
const key = getContinueWatchingKey(item);
if (seen.has(key)) {
continue;
}
seen.add(key);
deduped.push(item);
}
return deduped;
}
function getContinueWatchingKey(item: JellyfinItem): string {
if (item.Type === 'Episode') {
const seriesKey = item.SeriesId || item.SeriesName || item.Id || 'series';
const seasonKey = typeof item.ParentIndexNumber === 'number' ? item.ParentIndexNumber : 'x';
const episodeKey = typeof item.IndexNumber === 'number' ? item.IndexNumber : item.Id || 'x';
return `episode:${seriesKey}:${seasonKey}:${episodeKey}`;
}
return `${item.Type || 'item'}:${item.Id || item.Name || 'unknown'}`;
}
async function buildHubSections(
config: AppConfig,
library: Awaited<ReturnType<typeof getLibrarySnapshot>>,
page: HubPageResponse['page']
): Promise<{ hero: MediaCard[]; sections: HubSection[] }> {
console.log(`[buildHubSections] Routing for page: ${page}`);
switch (page) {
case 'stream':
return buildStreamHub(config, library);
case 'home':
return buildHomeHub(config, library);
case 'anime':
return buildAnimeHub(config, library);
case 'dorama':
return buildDoramaHub(config, library);
case 'netflix':
return buildNetworkHub(config, library, 213, 'Netflix');
case 'primevideo':
return buildNetworkHub(config, library, 1024, 'Prime Video');
case 'hbo':
return buildNetworkHub(config, library, 49, 'HBO');
case 'viki':
return buildNetworkHub(config, library, 1595, 'Viki');
case 'disney':
return buildNetworkHub(config, library, 2739, 'Disney+');
case 'library':
return buildLibraryHub(config, library);
default:
console.warn(`[buildHubSections] No specific handler for page: ${page}, falling back to home`);
return buildHomeHub(config, library);
}
}
async function buildStreamHub(
config: AppConfig,
library: Awaited<ReturnType<typeof getLibrarySnapshot>>
): Promise<{ hero: MediaCard[]; sections: HubSection[] }> {
const language = config.preferences.language || 'en-US';
const region = config.preferences.region || 'US';
const [trending, recommended, trendingSeries] = await Promise.all([
tmdbResultCards(config, `/trending/movie/week?language=${encodeURIComponent(language)}`, 'landscape'),
tmdbResultCards(config, `/movie/popular?language=${encodeURIComponent(language)}&region=${encodeURIComponent(region)}`, 'landscape'),
tmdbResultCards(config, `/tv/popular?language=${encodeURIComponent(language)}`, 'landscape')
]);
const filter = (items: MediaCard[]) => {
const libraryTmdbIds = new Set(library.allItems.map((item) => Number(item.ProviderIds?.Tmdb)).filter(Boolean));
return config.preferences.excludeLibraryItems
? items.filter(item => !item.tmdbId || !libraryTmdbIds.has(item.tmdbId))
: items;
};
const filteredHero = filter(trending || []);
const filteredRecommendations = filter(recommended || []);
const filteredTrendingSeries = filter(trendingSeries || []);
return {
hero: filteredHero.slice(0, 6),
sections: [
{
id: 'recommendations',
title: 'Recommendations',
style: 'landscape',
items: filteredRecommendations.slice(0, 12)
},
{
id: 'trending-series',
title: 'Trending Series',
style: 'landscape',
items: filteredTrendingSeries.slice(0, 12)
}
]
};
}
async function buildHomeHub(
config: AppConfig,
library: Awaited<ReturnType<typeof getLibrarySnapshot>>
): Promise<{ hero: MediaCard[]; sections: HubSection[] }> {
const language = config.preferences.language || 'en-US';
const region = config.preferences.region || 'US';
const [
trending,
upcomingMovies,
upcomingSeries,
digitalReleases,
streamingNow
] = await Promise.all([
tmdbResultCards(config, `/trending/all/day?language=${encodeURIComponent(language)}`, 'landscape'),
tmdbResultCards(config, `/discover/movie?primary_release_date.gte=${todayIso()}&sort_by=popularity.desc&language=${encodeURIComponent(language)}&region=${encodeURIComponent(region)}`, 'landscape'),
tmdbResultCards(config, `/discover/tv?first_air_date.gte=${todayIso()}&sort_by=popularity.desc&language=${encodeURIComponent(language)}`, 'landscape'),
tmdbResultCards(config, `/discover/movie?with_release_type=4&sort_by=popularity.desc&release_date.lte=${todayIso()}&language=${encodeURIComponent(language)}`, 'landscape'),
tmdbResultCards(config, `/discover/tv?air_date.gte=${todayIso()}&first_air_date.lte=${todayIso()}&sort_by=popularity.desc&language=${encodeURIComponent(language)}`, 'landscape')
]);
const filter = (items: MediaCard[]) => {
const libraryTmdbIds = new Set(library.allItems.map((item) => Number(item.ProviderIds?.Tmdb)).filter(Boolean));
return config.preferences.excludeLibraryItems
? items.filter(item => !item.tmdbId || !libraryTmdbIds.has(item.tmdbId))
: items;
};
const filteredHero = filter(trending || []);
const heroCandidates = uniqueCards([
...filteredHero,
...filter(digitalReleases || []),
...filter(streamingNow || []),
...filter(upcomingMovies || []),
...filter(upcomingSeries || [])
])
.filter((item) => item.backdropUrl || item.posterUrl)
.slice(0, 6);
return {
hero: heroCandidates,
sections: [
{ id: 'trending', title: 'Trending', style: 'landscape', items: filteredHero },
{ id: 'digital', title: 'New Digital Releases', style: 'landscape', items: filter(digitalReleases || []) },
{ id: 'streaming', title: 'Streaming Now', style: 'landscape', items: filter(streamingNow || []) },
{ id: 'recently-added', title: 'Recently Added', style: 'landscape', items: library.recentlyAdded || [] },
{ id: 'upcoming-movies', title: 'Upcoming Movies', style: 'landscape', items: filter(upcomingMovies || []) },
{ id: 'upcoming-series', title: 'Upcoming Series', style: 'landscape', items: filter(upcomingSeries || []) }
]
};
}
async function buildAnimeHub(
config: AppConfig,
library: Awaited<ReturnType<typeof getLibrarySnapshot>>
): Promise<{ hero: MediaCard[]; sections: HubSection[] }> {
const region = config.preferences.region || 'US';
const language = config.preferences.language || 'en-US';
const recentWindowDays = 365;
const providerFilter = `watch_region=${encodeURIComponent(region)}&with_watch_providers=${ANIME_PROVIDERS}`;
const animeFilter = `include_adult=false&with_genres=16&with_original_language=ja&language=${encodeURIComponent(language)}`;
const [
trendingShows,
trendingMovies,
fallbackTrendingMovies,
allTimePopularShows,
allTimePopularMovies,
upcomingSeries,
upcomingMovies
] = await Promise.all([
tmdbPagedResultCards(
config,
`/discover/tv?sort_by=popularity.desc&first_air_date.gte=${daysAgoIso(recentWindowDays)}&first_air_date.lte=${todayIso()}&${providerFilter}&${animeFilter}`,
'landscape'
),
tmdbPagedResultCards(
config,
`/discover/movie?sort_by=popularity.desc&primary_release_date.gte=${daysAgoIso(recentWindowDays)}&primary_release_date.lte=${todayIso()}&${providerFilter}&${animeFilter}`,
'landscape'
),
tmdbPagedResultCards(
config,
`/discover/movie?sort_by=popularity.desc&${providerFilter}&${animeFilter}`,
'landscape'
),
tmdbPagedResultCards(
config,
`/discover/tv?sort_by=vote_count.desc&${providerFilter}&${animeFilter}`,
'landscape'
),
tmdbPagedResultCards(
config,
`/discover/movie?sort_by=vote_count.desc&${providerFilter}&${animeFilter}`,
'landscape'
),
tmdbPagedResultCards(
config,
`/discover/tv?sort_by=popularity.desc&first_air_date.gte=${todayIso()}&include_adult=false&with_genres=16&with_original_language=ja&language=${encodeURIComponent(language)}`,
'landscape'
),
tmdbPagedResultCards(
config,
`/discover/movie?sort_by=popularity.desc&primary_release_date.gte=${todayIso()}&include_adult=false&with_genres=16&with_original_language=ja&language=${encodeURIComponent(language)}`,
'landscape'
)
]);
const filter = (items: MediaCard[]) => {
const libraryTmdbIds = new Set(library.allItems.map((item) => Number(item.ProviderIds?.Tmdb)).filter(Boolean));
const uniqueItems = uniqueCards(items.filter(Boolean));
return config.preferences.excludeLibraryItems
? uniqueItems.filter(item => !item.tmdbId || !libraryTmdbIds.has(item.tmdbId))
: uniqueItems;
};
const filteredTrendingShows = filter(trendingShows || []);
const filteredTrendingMovies = uniqueCards([
...filter(trendingMovies || []),
...filter(fallbackTrendingMovies || [])
]).slice(0, 12);
const filteredAllTimePopular = uniqueCards([
...filter(allTimePopularShows || []),
...filter(allTimePopularMovies || [])
]);
const filteredUpcoming = uniqueCards([
...filter(upcomingSeries || []),
...filter(upcomingMovies || [])
]);
const filteredHero = uniqueCards([
...filteredTrendingShows,
...filteredTrendingMovies
]);
const sections: HubSection[] = [
{ id: 'anime-trending-shows', title: 'Trending Shows', style: 'landscape', items: filteredTrendingShows },
{ id: 'anime-trending-movies', title: 'Trending Movies', style: 'landscape', items: filteredTrendingMovies },
{ id: 'anime-all-time-popular', title: 'All Time Popular', style: 'landscape', items: filteredAllTimePopular },
{ id: 'anime-upcoming', title: 'Upcoming', style: 'landscape', items: filteredUpcoming }
];
return {
hero: filteredHero.slice(0, 6),
sections: sections.filter((section) => section.items.length > 0)
};
}
async function buildDoramaHub(
config: AppConfig,
library: Awaited<ReturnType<typeof getLibrarySnapshot>>
): Promise<{ hero: MediaCard[]; sections: HubSection[] }> {
const region = config.preferences.region || 'US';
const language = config.preferences.language || 'en-US';
const recentWindowDays = 365;
const providerFilter = `watch_region=${encodeURIComponent(region)}&with_watch_providers=${DORAMA_PROVIDERS}`;
const doramaFilter = `include_adult=false&with_original_language=ko&language=${encodeURIComponent(language)}`;
const [
trendingShows,
trendingMovies,
fallbackTrendingMovies,
allTimePopularShows,
allTimePopularMovies,
upcomingSeries,
upcomingMovies
] = await Promise.all([
tmdbPagedResultCards(
config,
`/discover/tv?sort_by=popularity.desc&first_air_date.gte=${daysAgoIso(recentWindowDays)}&first_air_date.lte=${todayIso()}&${providerFilter}&${doramaFilter}`,
'landscape'
),
tmdbPagedResultCards(
config,
`/discover/movie?sort_by=popularity.desc&primary_release_date.gte=${daysAgoIso(recentWindowDays)}&primary_release_date.lte=${todayIso()}&${providerFilter}&${doramaFilter}`,
'landscape'
),
tmdbPagedResultCards(
config,
`/discover/movie?sort_by=popularity.desc&${providerFilter}&${doramaFilter}`,
'landscape'
),
tmdbPagedResultCards(
config,
`/discover/tv?sort_by=vote_count.desc&${providerFilter}&${doramaFilter}`,
'landscape'
),
tmdbPagedResultCards(
config,
`/discover/movie?sort_by=vote_count.desc&${providerFilter}&${doramaFilter}`,
'landscape'
),
tmdbPagedResultCards(
config,
`/discover/tv?sort_by=popularity.desc&first_air_date.gte=${todayIso()}&include_adult=false&with_original_language=ko&language=${encodeURIComponent(language)}`,
'landscape'
),
tmdbPagedResultCards(
config,
`/discover/movie?sort_by=popularity.desc&primary_release_date.gte=${todayIso()}&include_adult=false&with_original_language=ko&language=${encodeURIComponent(language)}`,
'landscape'
)
]);
const filter = (items: MediaCard[]) => {
const libraryTmdbIds = new Set(library.allItems.map((item) => Number(item.ProviderIds?.Tmdb)).filter(Boolean));
const uniqueItems = uniqueCards(items.filter(Boolean));
return config.preferences.excludeLibraryItems
? uniqueItems.filter(item => !item.tmdbId || !libraryTmdbIds.has(item.tmdbId))
: uniqueItems;
};
const filteredTrendingShows = filter(trendingShows || []);
const filteredTrendingMovies = uniqueCards([
...filter(trendingMovies || []),
...filter(fallbackTrendingMovies || [])
]).slice(0, 12);
const filteredAllTimePopular = uniqueCards([
...filter(allTimePopularShows || []),
...filter(allTimePopularMovies || [])
]);
const filteredUpcoming = uniqueCards([
...filter(upcomingSeries || []),
...filter(upcomingMovies || [])
]);
const filteredHero = uniqueCards([
...filteredTrendingShows,
...filteredTrendingMovies
]);
const sections: HubSection[] = [
{ id: 'dorama-trending-shows', title: 'Trending Shows', style: 'landscape', items: filteredTrendingShows },
{ id: 'dorama-trending-movies', title: 'Trending Movies', style: 'landscape', items: filteredTrendingMovies },
{ id: 'dorama-all-time-popular', title: 'All Time Popular', style: 'landscape', items: filteredAllTimePopular },
{ id: 'dorama-upcoming', title: 'Upcoming', style: 'landscape', items: filteredUpcoming }
];
return {
hero: filteredHero.slice(0, 6),
sections: sections.filter((section) => section.items.length > 0)
};
}
async function buildLibraryHub(
_config: AppConfig,
library: Awaited<ReturnType<typeof getLibrarySnapshot>>
): Promise<{ hero: MediaCard[]; sections: HubSection[] }> {
const libraryItems = (type: 'Movie' | 'Series') => library.allItems
.filter((item) => item.Type === type)
.map((item) => jellyfinToCard(item, item.Name || 'Library'))
.filter(Boolean) as MediaCard[];
return {
hero: (library.recentlyAdded || []).slice(0, 6),
sections: [
{
id: 'library-series',
title: 'Series',
style: 'landscape',
items: libraryItems('Series')
},
{
id: 'library-movies',
title: 'Movies',
style: 'landscape',
items: libraryItems('Movie')
}
]
};
}
async function buildNetworkHub(
config: AppConfig,
_library: Awaited<ReturnType<typeof getLibrarySnapshot>>,
networkId: number,
title: string
): Promise<{ hero: MediaCard[]; sections: HubSection[] }> {
const region = 'US';
const language = config.preferences.language || 'en-US';
const providerIdsByNetworkId: Record<number, number[]> = {
213: [8, 175, 459, 1796],
49: [27, 31, 118, 384, 425, 616, 1825, 1899],
2739: [337, 390, 377, 508],
1024: [9, 10, 119],
453: [15],
1595: [344],
2552: [2, 350],
344: [350]
};
const providerIdList = providerIdsByNetworkId[networkId] || [networkId];
const providerIds = providerIdList.join('|');
const providerFilter = `with_watch_providers=${providerIds}&watch_region=${encodeURIComponent(region)}&with_watch_monetization_types=flatrate`;
const tvFilter = [1024, 2739].includes(networkId)
? `with_networks=${networkId}`
: providerFilter;
const recentTvWindowDays = 365;
const recentMovieWindowDays = 365;
console.log(`[buildNetworkHub] Building hub for ${title} (networkId: ${networkId}, providerIds: ${providerIds}, region: ${region})`);
const [
recentPopularShows,
recentPopularMovies,
fallbackPopularMovies,
allTimePopularShows,
allTimePopularMovies
] = await Promise.all([
tmdbPagedResultCards(
config,
`/discover/tv?include_adult=false&sort_by=popularity.desc&first_air_date.gte=${daysAgoIso(recentTvWindowDays)}&first_air_date.lte=${todayIso()}&${tvFilter}&language=${encodeURIComponent(language)}`,
'landscape'
),
tmdbPagedResultCards(
config,
`/discover/movie?include_adult=false&sort_by=popularity.desc&primary_release_date.gte=${daysAgoIso(recentMovieWindowDays)}&primary_release_date.lte=${todayIso()}&${providerFilter}&language=${encodeURIComponent(language)}`,
'landscape'
),
networkId === 2739
? tmdbPagedResultCards(
config,
`/discover/movie?include_adult=false&sort_by=popularity.desc&${providerFilter}&language=${encodeURIComponent(language)}`,
'landscape'
)
: Promise.resolve([]),
tmdbPagedResultCards(
config,
`/discover/tv?include_adult=false&sort_by=vote_count.desc&${tvFilter}&language=${encodeURIComponent(language)}`,
'landscape'
),
tmdbPagedResultCards(
config,
`/discover/movie?include_adult=false&sort_by=vote_count.desc&${providerFilter}&language=${encodeURIComponent(language)}`,
'landscape'
)
]);
const filter = (items: MediaCard[]) => uniqueCards(items.filter(Boolean));
const filteredRecentPopularShows = filter(recentPopularShows);
const filteredRecentPopularMovies = networkId === 2739
? uniqueCards([
...filter(recentPopularMovies),
...filter(fallbackPopularMovies)
]).slice(0, 12)
: filter(recentPopularMovies);
const filteredAllTimePopular = uniqueCards([
...filter(allTimePopularShows),
...filter(allTimePopularMovies)
]);
const hero = uniqueCards([
...filteredRecentPopularShows,
...filteredRecentPopularMovies
]).slice(0, 6);
const sectionCandidates: HubSection[] = [
{ id: 'network-trending-shows', title: `Trending Shows on ${title}`, style: 'landscape', items: filteredRecentPopularShows },
{ id: 'network-trending-movies', title: `Trending Movies on ${title}`, style: 'landscape', items: filteredRecentPopularMovies },
{ id: 'network-all-time-popular', title: `All Time Popular on ${title}`, style: 'landscape', items: filteredAllTimePopular }
];
const sections = sectionCandidates.filter((section) => section.items.length > 0);
return { hero, sections };
}
async function getTmdbSnapshot(config: AppConfig): Promise<{
hero: MediaCard[];
recommendations: MediaCard[];
trendingSeries: MediaCard[];
}> {
if (!getTmdbToken(config)) {
return {
hero: [],
recommendations: [],
trendingSeries: []
};
}
const [trendingMovie, recommendedMovie, trendingSeries] = await Promise.all([
tmdbFetch(config, `/trending/movie/week?language=${encodeURIComponent(config.preferences.language)}`),
tmdbFetch(config, `/movie/popular?language=${encodeURIComponent(config.preferences.language)}&region=${encodeURIComponent(config.preferences.region)}`),
tmdbFetch(config, `/tv/popular?language=${encodeURIComponent(config.preferences.language)}`)
]);
return {
hero: asArray(trendingMovie?.results)
.slice(0, 6)
.map((item) => tmdbToCard(asRecord(item), 'movie')),
recommendations: asArray(recommendedMovie?.results)
.slice(0, 12)
.map((item) => tmdbToCard(asRecord(item), 'movie')),
trendingSeries: asArray(trendingSeries?.results)
.slice(0, 12)
.map((item) => tmdbToCard(asRecord(item), 'tv'))
};
}
async function getJellyfinUsers(config: AppConfig): Promise<SelectOption[]> {
if (!config.jellyfin.baseUrl || !config.jellyfin.apiKey) {
return [];
}
return getJellyfinUsersFromConfig(config.jellyfin.baseUrl, config.jellyfin.apiKey);
}
async function getJellyfinUsersFromConfig(baseUrl: string, apiKey: string): Promise<SelectOption[]> {
const users = await fetchJson<Record<string, unknown>[]>(
`${baseUrl}/Users`,
{
headers: {
Authorization: `MediaBrowser Token="${apiKey}"`
}
}
);
return users.map((user) => {
const id = String(user.Id || '');
const imageTag = String(user.PrimaryImageTag || '');
return {
id,
label: String(user.Name || user.Id || 'User'),
...(imageTag ? { imageUrl: `/api/auth/profile-image/${encodeURIComponent(id)}?tag=${encodeURIComponent(imageTag)}` } : {})
};
});
}
async function getSonarrRootFolders(config: AppConfig): Promise<SelectOption[]> {
if (!config.sonarr.baseUrl || !config.sonarr.apiKey) {
return [];
}
const folders = await fetchJson<Record<string, unknown>[]>(
`${config.sonarr.baseUrl}/api/v3/rootFolder`,
{
headers: {
'X-Api-Key': config.sonarr.apiKey
}
}
);
return folders.map((folder) => ({
id: String(folder.path || ''),
label: String(folder.path || '')
}));
}
async function getSonarrQualityProfiles(config: AppConfig): Promise<SelectOption[]> {
if (!config.sonarr.baseUrl || !config.sonarr.apiKey) {
return [];
}
const profiles = await fetchJson<Record<string, unknown>[]>(
`${config.sonarr.baseUrl}/api/v3/qualityprofile`,
{
headers: {
'X-Api-Key': config.sonarr.apiKey
}
}
);
return profiles.map((profile) => ({
id: String(profile.id || ''),
label: String(profile.name || profile.id || 'Profile')
}));
}
async function getSonarrLanguageProfiles(config: AppConfig): Promise<SelectOption[]> {
if (!config.sonarr.baseUrl || !config.sonarr.apiKey) {
return [];
}
const profiles = await fetchJson<Record<string, unknown>[]>(
`${config.sonarr.baseUrl}/api/v3/languageprofile`,
{
headers: {
'X-Api-Key': config.sonarr.apiKey
}
}
);
return profiles.map((profile) => ({
id: String(profile.id || ''),
label: String(profile.name || profile.id || 'Language')
}));
}
async function getRadarrRootFolders(config: AppConfig): Promise<SelectOption[]> {
if (!config.radarr.baseUrl || !config.radarr.apiKey) {
return [];
}
const folders = await fetchJson<Record<string, unknown>[]>(
`${config.radarr.baseUrl}/api/v3/rootFolder`,
{
headers: {
'X-Api-Key': config.radarr.apiKey
}
}
);
return folders.map((folder) => ({
id: String(folder.path || ''),
label: String(folder.path || '')
}));
}
async function getRadarrQualityProfiles(config: AppConfig): Promise<SelectOption[]> {
if (!config.radarr.baseUrl || !config.radarr.apiKey) {
return [];
}
const profiles = await fetchJson<Record<string, unknown>[]>(
`${config.radarr.baseUrl}/api/v3/qualityprofile`,
{
headers: {
'X-Api-Key': config.radarr.apiKey
}
}
);
return profiles.map((profile) => ({
id: String(profile.id || ''),
label: String(profile.name || profile.id || 'Profile')
}));
}
async function getSonarrQueue(config: AppConfig): Promise<SonarrQueueRecord[]> {
if (!config.sonarr.baseUrl || !config.sonarr.apiKey) {
return [];
}
const response = await fetchJson<{ records?: SonarrQueueRecord[] }>(
`${config.sonarr.baseUrl}/api/v3/queue?includeSeries=true&includeEpisode=true`,
{
headers: {
'X-Api-Key': config.sonarr.apiKey
}
}
);
return response.records || [];
}
async function getRadarrQueue(config: AppConfig): Promise<RadarrQueueRecord[]> {
if (!config.radarr.baseUrl || !config.radarr.apiKey) {
return [];
}
const response = await fetchJson<{ records?: RadarrQueueRecord[] }>(
`${config.radarr.baseUrl}/api/v3/queue?includeMovie=true`,
{
headers: {
'X-Api-Key': config.radarr.apiKey
}
}
);
return response.records || [];
}
async function getSonarrCalendar(config: AppConfig, start: Date, end: Date): Promise<SonarrCalendarRecord[]> {
if (!config.sonarr.baseUrl || !config.sonarr.apiKey) {
return []
}
return fetchJson<SonarrCalendarRecord[]>(
`${config.sonarr.baseUrl}/api/v3/calendar?includeSeries=true&unmonitored=true&start=${encodeURIComponent(start.toISOString())}&end=${encodeURIComponent(end.toISOString())}`,
{
headers: {
'X-Api-Key': config.sonarr.apiKey
}
}
)
}
async function getRadarrCalendar(config: AppConfig, start: Date, end: Date): Promise<RadarrCalendarRecord[]> {
if (!config.radarr.baseUrl || !config.radarr.apiKey) {
return []
}
return fetchJson<RadarrCalendarRecord[]>(
`${config.radarr.baseUrl}/api/v3/calendar?unmonitored=true&start=${encodeURIComponent(start.toISOString())}&end=${encodeURIComponent(end.toISOString())}`,
{
headers: {
'X-Api-Key': config.radarr.apiKey
}
}
)
}
async function testJellyfin(config: AppConfig): Promise<ConnectionStatus> {
if (!config.jellyfin.baseUrl || !config.jellyfin.apiKey) {
return {
key: 'jellyfin',
label: 'Jellyfin',
ok: false,
message: 'Not configured'
};
}
try {
await fetchJson(`${config.jellyfin.baseUrl}/System/Info`, {
headers: {
Authorization: `MediaBrowser Token="${config.jellyfin.apiKey}"`
}
});
return { key: 'jellyfin', label: 'Jellyfin', ok: true, message: 'Connected' };
} catch (error) {
return {
key: 'jellyfin',
label: 'Jellyfin',
ok: false,
message: errorMessage(error)
};
}
}
async function testSonarr(config: AppConfig): Promise<ConnectionStatus> {
if (!config.sonarr.baseUrl || !config.sonarr.apiKey) {
return {
key: 'sonarr',
label: 'Sonarr',
ok: false,
message: 'Not configured'
};
}
try {
await fetchJson(`${config.sonarr.baseUrl}/api/v3/health`, {
headers: {
'X-Api-Key': config.sonarr.apiKey
}
});
return { key: 'sonarr', label: 'Sonarr', ok: true, message: 'Connected' };
} catch (error) {
return {
key: 'sonarr',
label: 'Sonarr',
ok: false,
message: errorMessage(error)
};
}
}
async function testRadarr(config: AppConfig): Promise<ConnectionStatus> {
if (!config.radarr.baseUrl || !config.radarr.apiKey) {
return {
key: 'radarr',
label: 'Radarr',
ok: false,
message: 'Not configured'
};
}
try {
await fetchJson(`${config.radarr.baseUrl}/api/v3/health`, {
headers: {
'X-Api-Key': config.radarr.apiKey
}
});
return { key: 'radarr', label: 'Radarr', ok: true, message: 'Connected' };
} catch (error) {
return {
key: 'radarr',
label: 'Radarr',
ok: false,
message: errorMessage(error)
};
}
}
async function testTmdb(config: AppConfig): Promise<ConnectionStatus> {
if (!getTmdbToken(config)) {
return {
key: 'tmdb',
label: 'TMDb',
ok: false,
message: 'Missing token'
};
}
try {
await tmdbFetch(config, '/configuration');
return { key: 'tmdb', label: 'TMDb', ok: true, message: 'Connected' };
} catch (error) {
return {
key: 'tmdb',
label: 'TMDb',
ok: false,
message: errorMessage(error)
};
}
}
async function testTvdb(config: AppConfig): Promise<ConnectionStatus> {
if (!getTvdbApiKey(config)) {
return {
key: 'tvdb',
label: 'TVDB',
ok: false,
message: 'Missing API key'
};
}
try {
await getTvdbToken(config);
return { key: 'tvdb', label: 'TVDB', ok: true, message: 'Connected' };
} catch (error) {
return {
key: 'tvdb',
label: 'TVDB',
ok: false,
message: errorMessage(error)
};
}
}
async function jellyfinFetch<T>(config: AppConfig, pathWithQuery: string): Promise<T> {
const response = await fetchJson<{ Items?: T } | T>(
`${config.jellyfin.baseUrl}${pathWithQuery.startsWith('/') ? pathWithQuery : `/${pathWithQuery}`}`,
{
headers: {
Authorization: `MediaBrowser Token="${config.jellyfin.apiKey}"`
}
}
);
if (typeof response === 'object' && response && 'Items' in response) {
return (response.Items ?? []) as T;
}
return response as T;
}
async function tmdbFetch(config: AppConfig, pathWithQuery: string): Promise<Record<string, unknown> | null> {
const token = getTmdbToken(config);
if (!token) {
console.warn('TMDb token is missing');
return null;
}
try {
return await fetchJson<Record<string, unknown>>(`https://api.themoviedb.org/3${pathWithQuery}`, {
headers: {
Authorization: `Bearer ${token}`
}
});
} catch (err) {
console.error(`TMDb fetch failed for ${pathWithQuery}:`, err);
return null;
}
}
async function tvdbFetch(
config: AppConfig,
pathWithQuery: string,
options?: { quiet?: boolean }
): Promise<Record<string, unknown> | null> {
try {
const token = await getTvdbToken(config);
if (!token) {
return null;
}
return await fetchJson<Record<string, unknown>>(`https://api4.thetvdb.com/v4${pathWithQuery}`, {
headers: {
Authorization: `Bearer ${token}`,
'Accept-Language': getTvdbAcceptLanguage(config)
}
});
} catch (err) {
if (!options?.quiet) {
console.error(`TVDB fetch failed for ${pathWithQuery}:`, err);
}
return null;
}
}
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch(url, {
...init,
signal: controller.signal,
headers: {
Accept: 'application/json',
...(init?.headers || {})
}
});
if (!response.ok) {
const responseText = await response.text().catch(() => '');
let detail = responseText.trim();
if (detail) {
try {
const parsed = JSON.parse(detail) as { message?: string; error?: string };
detail = String(parsed.message || parsed.error || detail);
} catch {
// Keep plain-text response bodies as-is.
}
}
throw new Error(detail
? `${response.status} ${response.statusText}: ${detail}`
: `${response.status} ${response.statusText}`);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
} finally {
clearTimeout(timeout);
}
}
function jellyfinToCard(
item: JellyfinItem,
fallbackTitle: string,
tmdbIdByItemId?: Map<string, number | undefined>
): MediaCard | null {
if (!item.Id) {
return null;
}
const posterItemId = item.Type === 'Episode' && item.SeriesId ? item.SeriesId : item.Id;
const posterTag = item.Type === 'Episode'
? item.SeriesPrimaryImageTag || item.ImageTags?.Primary || ''
: item.ImageTags?.Primary || '';
const backdropItemId = item.ParentBackdropItemId || item.Id;
const backdropTag = item.ParentBackdropImageTags?.[0] || item.BackdropImageTags?.[0] || posterTag;
const progress = Math.round(item.UserData?.PlayedPercentage || 0);
const tmdbId = Number(item.ProviderIds?.Tmdb) || (item.SeriesId ? tmdbIdByItemId?.get(item.SeriesId) : undefined);
const episodeCode = formatEpisodeCode(item.ParentIndexNumber, item.IndexNumber);
return {
id: `jellyfin-${item.Id}`,
tmdbId,
providerId: item.Id,
title: fallbackTitle,
subtitle: item.Type === 'Episode'
? [item.SeriesName, formatEpisodeCode(item.ParentIndexNumber, item.IndexNumber)].filter(Boolean).join(' ')
: item.ProductionYear
? String(item.ProductionYear)
: item.Type || 'Library',
overview: item.Overview || '',
mediaType: item.Type === 'Movie' ? 'movie' : item.Type === 'Episode' ? 'episode' : 'series',
posterUrl: `/api/assets/jellyfin/${posterItemId}?kind=Primary&maxWidth=480&tag=${encodeURIComponent(posterTag)}`,
backdropUrl: `/api/assets/jellyfin/${backdropItemId}?kind=Backdrop&maxWidth=1920&tag=${encodeURIComponent(backdropTag)}`,
badge: item.Type || 'Jellyfin',
progress: progress > 0 ? progress : undefined,
secondaryValue: item.UserData?.Played ? 'Watched' : undefined,
hoverLabel: item.Type === 'Movie'
? formatPlaybackTime(item.UserData?.PlaybackPositionTicks)
: item.Type === 'Episode' && episodeCode
? `${item.UserData?.PlaybackPositionTicks ? 'Current' : 'Next'} ${episodeCode}`
: undefined
};
}
function tmdbToCard(item: Record<string, unknown>, mediaType: 'movie' | 'tv'): MediaCard {
const title = String(item.title || item.name || item.original_title || item.original_name || 'TMDb');
const id = Number(item.id || 0);
const posterPath = String(item.poster_path || '');
const backdropPath = String(item.backdrop_path || '');
const releaseDate = String(item.release_date || item.first_air_date || '');
const firstAirDate = mediaType === 'tv' ? String(item.first_air_date || '') : '';
const genreIds = asArray(item.genre_ids).map((value) => Number(value)).filter(Boolean);
const originalLanguage = String(item.original_language || '');
const year = releaseDate.slice(0, 4);
return {
id: `tmdb-${mediaType}-${id}`,
tmdbId: id,
title,
subtitle: year || (mediaType === 'movie' ? 'Movie' : 'Series'),
overview: String(item.overview || ''),
mediaType,
posterUrl: posterPath ? `${TMDB_IMAGE}/w500${posterPath}` : '',
backdropUrl: backdropPath ? `${TMDB_IMAGE}/w1280${backdropPath}` : '',
badge: mediaType === 'movie' ? 'Radarr' : 'Sonarr',
actionLabel: mediaType === 'movie' ? 'Add to Radarr' : 'Add to Sonarr',
popularity: Number(item.popularity || 0) || undefined,
voteAverage: Number(item.vote_average || 0) || undefined,
voteCount: Number(item.vote_count || 0) || undefined,
originalLanguage: originalLanguage || undefined,
releaseDate: releaseDate || undefined,
firstAirDate: firstAirDate || undefined,
genreIds: genreIds.length ? genreIds : undefined
};
}
async function tmdbResultCards(
config: AppConfig,
pathWithQuery: string,
style: HubSection['style']
): Promise<MediaCard[]> {
try {
const result = await tmdbFetch(config, pathWithQuery);
if (!result || !result.results || (Array.isArray(result.results) && result.results.length === 0)) {
console.warn(`TMDb returned no results for ${pathWithQuery}`);
// Do not broaden provider-scoped discover queries. Upcoming provider rows can become
// cross-platform noise if watch_region is stripped and TMDb stops honoring the provider.
if (
pathWithQuery.includes('/discover/') &&
pathWithQuery.includes('watch_region') &&
!pathWithQuery.includes('with_watch_providers=')
) {
const fallbackPath = pathWithQuery.replace(/&watch_region=[^&]*/, '');
if (fallbackPath !== pathWithQuery) {
console.log(`[tmdbResultCards] Retrying TMDb without region filter: ${fallbackPath}`);
return tmdbResultCards(config, fallbackPath, style);
}
}
return [];
}
const cards = asArray(result?.results)
.map((item) => tmdbToCard(asRecord(item), inferMediaType(pathWithQuery, asRecord(item))))
.filter((card) => (style === 'landscape' ? Boolean(card.backdropUrl) : Boolean(card.posterUrl)))
.slice(0, 18);
if (cards.length === 0 && asArray(result?.results).length > 0) {
console.log(`TMDb results filtered out by style (${style}), retrying without style filter`);
const allCards = asArray(result?.results)
.map((item) => tmdbToCard(asRecord(item), inferMediaType(pathWithQuery, asRecord(item))))
.slice(0, 18);
console.log(`TMDb fetched ${allCards.length} cards (fallback) for ${pathWithQuery}`);
return allCards;
}
console.log(`TMDb fetched ${cards.length} cards for ${pathWithQuery}`);
return cards;
} catch (err) {
console.error(`Error fetching TMDb cards from ${pathWithQuery}:`, err);
return [];
}
}
async function tmdbPagedResultCards(
config: AppConfig,
pathWithQuery: string,
style: HubSection['style'],
pageCount = 3
): Promise<MediaCard[]> {
const paths = Array.from({ length: pageCount }, (_, index) => {
const page = index + 1;
return `${pathWithQuery}${pathWithQuery.includes('?') ? '&' : '?'}page=${page}`;
});
const pages = await Promise.all(paths.map((path) => tmdbResultCards(config, path, style)));
return uniqueCards(pages.flat()).slice(0, 36);
}
function inferMediaType(pathWithQuery: string, item: Record<string, unknown>): 'movie' | 'tv' {
if (pathWithQuery.includes('/tv') || item.media_type === 'tv') {
return 'tv';
}
if (item.media_type === 'movie') {
return 'movie';
}
return pathWithQuery.includes('/movie') ? 'movie' : 'tv';
}
function uniqueCards(items: MediaCard[]): MediaCard[] {
const seen = new Set<string>();
return items.filter((item) => {
if (seen.has(item.id)) {
return false;
}
seen.add(item.id);
return true;
});
}
function uniqueSearchCards(items: MediaCard[]): MediaCard[] {
const seen = new Set<string>();
return items.filter((item) => {
const key = item.tmdbId
? `tmdb:${item.tmdbId}`
: item.providerId
? `provider:${item.providerId}`
: item.id;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
function normalizeSearchValue(value: string): string {
return value
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
// Prefer language-neutral artwork for cards that can render a separate title logo.
async function applyHighResBackdropsToSections(
config: AppConfig,
sections: HubSection[],
backdropCache: Map<string, Promise<TmdbBackdropChoice>>,
preferredLanguage?: string | null
): Promise<HubSection[]> {
return mapWithConcurrency(
sections,
TMDB_ARTWORK_SECTION_CONCURRENCY,
async (section) => {
if (section.style !== 'landscape') {
return section;
}
const items = await applyHighResBackdropsToCards(
config,
section.items,
shouldUseHighResBackdropForCard,
backdropCache,
preferredLanguage
);
return {
...section,
items
};
}
);
}
async function applyTitleLogosToSections(
config: AppConfig,
sections: HubSection[]
): Promise<HubSection[]> {
return mapWithConcurrency(
sections,
TMDB_ARTWORK_SECTION_CONCURRENCY,
async (section) => {
if (section.style !== 'landscape') {
return section;
}
const items = await applyTitleLogosToCards(config, section.items);
return {
...section,
items
};
}
);
}
async function applyHighResBackdropsToCards(
config: AppConfig,
cards: MediaCard[],
shouldApply: (card: MediaCard) => boolean,
backdropCache: Map<string, Promise<TmdbBackdropChoice>>,
preferredLanguage?: string | null,
imageSize = 'w780'
): Promise<MediaCard[]> {
return mapWithConcurrency(
cards,
TMDB_ARTWORK_CONCURRENCY,
async (card) => {
if (!shouldApply(card)) {
return card;
}
const mediaType: 'movie' | 'tv' = card.mediaType === 'movie' ? 'movie' : 'tv';
// When there is a title logo, a language-neutral backdrop keeps the artwork clean.
// Without a logo, retain the English artwork as the readable fallback.
const effectivePreferredLanguage = card.logoUrl ? null : preferredLanguage;
const cacheKey = `${mediaType}:${card.tmdbId}:${effectivePreferredLanguage || 'any'}:${card.originalLanguage || 'none'}:${imageSize}`;
let backdropPromise = backdropCache.get(cacheKey);
if (!backdropPromise) {
backdropPromise = getTmdbHighResBackdropChoice(config, card.tmdbId!, mediaType, effectivePreferredLanguage, card.originalLanguage, imageSize);
backdropCache.set(cacheKey, backdropPromise);
}
const backdropChoice = await backdropPromise;
return backdropChoice.url
? {
...card,
backdropUrl: backdropChoice.url,
backdropLanguageFallback: backdropChoice.backdropLanguageFallback
}
: card;
}
);
}
async function applyTitleLogosToCards(
config: AppConfig,
cards: MediaCard[],
imageSize = 'w500'
): Promise<MediaCard[]> {
const logoCache = new Map<string, Promise<string>>();
return mapWithConcurrency(
cards,
TMDB_ARTWORK_CONCURRENCY,
async (card) => {
if (card.logoUrl || !card.tmdbId) {
return card;
}
const mediaType = resolveTmdbLogoMediaType(card);
if (!mediaType) {
return card;
}
const cacheKey = `${mediaType}:${card.tmdbId}:${imageSize}`;
let logoPromise = logoCache.get(cacheKey);
if (!logoPromise) {
logoPromise = getPreferredTitleLogo(config, card.tmdbId, mediaType, imageSize);
logoCache.set(cacheKey, logoPromise);
}
const logoUrl = await logoPromise;
return logoUrl
? {
...card,
logoUrl
}
: card;
}
);
}
function shouldUseHighResBackdropForCard(card: MediaCard): boolean {
return Boolean(
card.tmdbId
&& (
card.mediaType === 'movie'
|| card.mediaType === 'tv'
|| card.mediaType === 'series'
|| card.mediaType === 'episode'
)
);
}
function resolveTmdbLogoMediaType(card: MediaCard): 'movie' | 'tv' | null {
if (card.mediaType === 'movie') {
return 'movie';
}
if (card.mediaType === 'tv' || card.mediaType === 'series' || card.mediaType === 'episode') {
return 'tv';
}
return null;
}
function tmdbArtworkPath(tmdbId: number, mediaType: 'movie' | 'tv'): string {
return `/${mediaType}/${tmdbId}/images`;
}
function getTmdbArtwork(
config: AppConfig,
tmdbId: number,
mediaType: 'movie' | 'tv'
): Promise<TmdbArtwork> {
const cacheKey = `${mediaType}:${tmdbId}`;
const cached = tmdbArtworkCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
const value = tmdbFetch(config, tmdbArtworkPath(tmdbId, mediaType))
.then((response) => ({
available: Boolean(response),
logos: asArray(response?.logos)
.map((entry) => asRecord(entry))
.filter((entry) => typeof entry.file_path === 'string' && entry.file_path)
.map((entry) => ({
file_path: String(entry.file_path),
iso_639_1: typeof entry.iso_639_1 === 'string' ? entry.iso_639_1 : null
})),
backdrops: asArray(response?.backdrops).map((entry) => asRecord(entry))
}));
tmdbArtworkCache.set(cacheKey, {
expiresAt: Date.now() + TMDB_ARTWORK_TTL_MS,
value
});
void value.then((artwork) => {
if (!artwork.available) {
const current = tmdbArtworkCache.get(cacheKey);
if (current?.value === value) {
current.expiresAt = Date.now() + TMDB_ARTWORK_FAILURE_TTL_MS;
}
}
});
return value;
}
function formatPlaybackTime(playbackPositionTicks?: number): string | undefined {
if (!playbackPositionTicks || playbackPositionTicks <= 0) {
return undefined;
}
const totalSeconds = Math.floor(playbackPositionTicks / 10_000_000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return hours > 0
? `${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
: `${minutes}:${String(seconds).padStart(2, '0')}`;
}
export async function getTmdbLogos(
config: AppConfig,
tmdbId: number,
mediaType: 'movie' | 'tv'
): Promise<Array<{ file_path: string; iso_639_1: string | null }>> {
try {
return (await getTmdbArtwork(config, tmdbId, mediaType)).logos;
} catch {
return [];
}
}
async function getTmdbHighResBackdrop(
config: AppConfig,
tmdbId: number,
mediaType: 'movie' | 'tv',
preferredLanguage?: string | null,
originalLanguage?: string | null,
imageSize = 'w780'
): Promise<string> {
const choice = await getTmdbHighResBackdropChoice(config, tmdbId, mediaType, preferredLanguage, originalLanguage, imageSize);
return choice.url;
}
async function getTmdbHighResBackdropChoice(
config: AppConfig,
tmdbId: number,
mediaType: 'movie' | 'tv',
preferredLanguage?: string | null,
originalLanguage?: string | null,
imageSize = 'w780'
): Promise<{ url: string; backdropLanguageFallback: boolean }> {
try {
let backdrops = (await getTmdbArtwork(config, tmdbId, mediaType)).backdrops;
if (backdrops.length === 0) {
return { url: '', backdropLanguageFallback: false };
}
const sortByWidthDesc = (items: Record<string, unknown>[]) =>
[...items].sort((a, b) => Number(b.width || 0) - Number(a.width || 0));
// Filter by language if requested
if (preferredLanguage) {
const filtered = backdrops.filter((b) => {
const language = String(b.iso_639_1 || '');
if (preferredLanguage === 'en') {
return language === 'en' || language === 'en-US';
}
return language === preferredLanguage;
});
if (filtered.length > 0) {
backdrops = filtered;
} else if (preferredLanguage === 'en' && (originalLanguage === 'ja' || originalLanguage === 'ko')) {
const fallbackFiltered = backdrops.filter(
(b) => String(b.iso_639_1 || '') === originalLanguage
);
if (fallbackFiltered.length > 0) {
backdrops = fallbackFiltered;
}
}
}
if (!preferredLanguage) {
const noLanguageBackdrops = sortByWidthDesc(
backdrops.filter((entry) => entry.iso_639_1 == null)
);
if (noLanguageBackdrops.length > 0) {
backdrops = noLanguageBackdrops;
} else {
const englishBackdrops = sortByWidthDesc(
backdrops.filter((entry) => entry.iso_639_1 === 'en' || entry.iso_639_1 === 'en-US')
);
backdrops = englishBackdrops.length > 0 ? englishBackdrops : sortByWidthDesc(backdrops);
}
}
const selectedBackdrop = backdrops.find(
(entry) => typeof entry.file_path === 'string' && entry.file_path
);
return selectedBackdrop?.file_path
? {
url: `${TMDB_IMAGE}/${imageSize}${String(selectedBackdrop.file_path)}`,
backdropLanguageFallback: selectedBackdrop.iso_639_1 == null
}
: { url: '', backdropLanguageFallback: false };
} catch {
return { url: '', backdropLanguageFallback: false };
}
}
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
function daysAgoIso(days: number): string {
const date = new Date();
date.setDate(date.getDate() - days);
return date.toISOString().slice(0, 10);
}
function getTmdbToken(config: AppConfig): string {
return config.tmdb.bearerToken;
}
function getTvdbApiKey(config: AppConfig): string {
return process.env.TVDB_API_KEY || config.tvdb.apiKey;
}
function getTvdbAcceptLanguage(config: AppConfig): string {
const preferred = String(config.preferences.language || '').trim();
const normalized = preferred.replace('_', '-');
const base = normalized.split('-')[0] || '';
const tags = [
normalized,
base && base !== normalized ? base : '',
'en'
].filter(Boolean);
return tags
.filter((tag, index) => tags.indexOf(tag) === index)
.map((tag, index) => index === 0 ? tag : `${tag};q=${(1 - index * 0.1).toFixed(1)}`)
.join(', ');
}
async function getTvdbToken(config: AppConfig): Promise<string> {
if (tvdbTokenCache && tvdbTokenCache.expiresAt > Date.now()) {
return tvdbTokenCache.token;
}
const apiKey = getTvdbApiKey(config);
if (!apiKey) {
throw new Error('TVDB API key is missing.');
}
const response = await fetchJson<Record<string, unknown>>('https://api4.thetvdb.com/v4/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
apikey: apiKey
})
});
const data = asRecord(response.data);
const token = typeof data.token === 'string' ? data.token : '';
if (!token) {
throw new Error('TVDB login did not return a token.');
}
tvdbTokenCache = {
token,
expiresAt: Date.now() + 1000 * 60 * 60 * 12
};
return token;
}
function getQueueProgress(size?: number, sizeLeft?: number): number {
if (!size || size <= 0) {
return 0;
}
return Math.max(0, Math.min(100, Math.round(((size - (sizeLeft || 0)) / size) * 100)));
}
function formatEpisodeCode(season?: number, episode?: number): string {
if (!season || !episode) {
return '';
}
return `S${String(season).padStart(2, '0')}E${String(episode).padStart(2, '0')}`;
}
function formatBytes(bytes: number): string {
if (!bytes) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = bytes;
let index = 0;
while (value >= 1024 && index < units.length - 1) {
value /= 1024;
index += 1;
}
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
}
function errorMessage(error: unknown): string {
if (error instanceof Error) {
if (error.message.includes('did not match the expected pattern')) {
return 'One of the configured service URLs is invalid. Add http:// or https:// and verify the address.';
}
return error.message;
}
return 'Connection error';
}
function asArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
}