feature: add show detail

This commit is contained in:
WonderAliceUwU committed 2026-07-06 20:08:22 +02:00
1 parent ca2976a783
commit a691e9d113
6 files changed
+2156 -371

No files matched your search

+18 -5
View File
@@ -1,7 +1,7 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { defaultConfig, type AppConfig, type AuthPayload } from '../shared/contracts.js';
import { defaultConfig, type AppConfig, type AuthPayload, type TitleDetailsRequest } from '../shared/contracts.js';
import { decryptJson, deriveEncryptionKey, encryptJson, randomToken, verifyPassword, createPasswordRecord } from './lib/security.js';
import { loadState, readAdmin, readEncryptedConfig, saveAdminAndConfig, updateEncryptedConfig } from './lib/store.js';
import {
@@ -9,10 +9,12 @@ import {
getDashboard,
getDownloads,
getHubPage,
getTitleDetails,
getTmdbLogos,
getServiceOptions,
loadJellyfinUsers,
proxyJellyfinImage,
removeDownload,
requestMovie,
requestSeries,
sanitizeConfig
@@ -215,6 +217,21 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse):
return;
}
if (url.pathname === '/api/title-details' && method === 'POST') {
const config = await readConfig(session!.encryptionKey);
const body = await readJson<TitleDetailsRequest>(request);
sendJson(response, 200, await getTitleDetails(config, body));
return;
}
if (url.pathname === '/api/downloads/delete' && method === 'POST') {
const config = await readConfig(session!.encryptionKey);
const body = await readJson<{ id: string }>(request);
await removeDownload(config, String(body.id || ''));
sendJson(response, 200, { ok: true });
return;
}
if (url.pathname === '/api/requests/movie' && method === 'POST') {
const config = await readConfig(session!.encryptionKey);
const body = await readJson<{ tmdbId: number }>(request);
@@ -359,10 +376,6 @@ async function readConfig(encryptionKey: Buffer): Promise<AppConfig> {
...defaultConfig.radarr,
...(stored.radarr || {})
},
qbittorrent: {
...defaultConfig.qbittorrent,
...(stored.qbittorrent || {})
},
tmdb: {
...defaultConfig.tmdb,
...(stored.tmdb || {})
+570 -155
View File
@@ -7,7 +7,11 @@ import type {
HubSection,
MediaCard,
SelectOption,
ServiceOptionsResponse
ServiceOptionsResponse,
TitleDetailsEpisode,
TitleDetailsRequest,
TitleDetailsResponse,
TitleDetailsSeason
} from '../../shared/contracts.js';
const TMDB_IMAGE = 'https://image.tmdb.org/t/p';
@@ -36,12 +40,17 @@ interface JellyfinItem {
ParentBackdropImageTags?: string[];
DateCreated?: string;
DateLastMediaAdded?: string;
ChildCount?: number;
IndexNumberEnd?: number;
PremiereDate?: string;
}
interface SonarrQueueRecord {
id?: number;
title?: string;
series?: { 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;
@@ -52,7 +61,7 @@ interface SonarrQueueRecord {
interface RadarrQueueRecord {
id?: number;
title?: string;
movie?: { title?: string };
movie?: { title?: string; tmdbId?: number; year?: number; images?: Array<{ coverType?: string; remoteUrl?: string; url?: string }> };
trackedDownloadState?: string;
sizeleft?: number;
size?: number;
@@ -60,14 +69,10 @@ interface RadarrQueueRecord {
timeleft?: string;
}
interface QbitTorrent {
hash?: string;
name?: string;
progress?: number;
dlspeed?: number;
size?: number;
eta?: number;
state?: string;
interface DownloadMetadataHint {
query: string;
mediaType: 'movie' | 'tv';
subtitle?: string;
}
const ANIME_PROVIDERS = '337|8|9|430|283';
@@ -103,11 +108,6 @@ export function sanitizeConfig(input: AppConfig): AppConfig {
monitor: input.radarr.monitor.trim() || 'movieOnly',
startSearch: Boolean(input.radarr.startSearch)
},
qbittorrent: {
baseUrl: normalizeBaseUrl(input.qbittorrent.baseUrl),
username: input.qbittorrent.username.trim(),
password: input.qbittorrent.password.trim()
},
tmdb: {
bearerToken: input.tmdb.bearerToken.trim()
}
@@ -115,15 +115,14 @@ export function sanitizeConfig(input: AppConfig): AppConfig {
}
export async function getConnectionStatuses(config: AppConfig): Promise<ConnectionStatus[]> {
const [jellyfin, sonarr, radarr, qbittorrent, tmdb] = await Promise.all([
const [jellyfin, sonarr, radarr, tmdb] = await Promise.all([
testJellyfin(config),
testSonarr(config),
testRadarr(config),
testQbittorrent(config),
testTmdb(config)
]);
return [jellyfin, sonarr, radarr, qbittorrent, tmdb];
return [jellyfin, sonarr, radarr, tmdb];
}
export async function getServiceOptions(config: AppConfig): Promise<ServiceOptionsResponse> {
@@ -284,55 +283,523 @@ export async function getHubPage(
}
export async function getDownloads(config: AppConfig): Promise<DownloadItem[]> {
const [sonarrResult, radarrResult, qbitResult] = await Promise.allSettled([
const [sonarrResult, radarrResult] = await Promise.allSettled([
getSonarrQueue(config),
getRadarrQueue(config),
getQbitQueue(config)
getRadarrQueue(config)
]);
const sonarr = sonarrResult.status === 'fulfilled' ? sonarrResult.value : [];
const radarr = radarrResult.status === 'fulfilled' ? radarrResult.value : [];
const qbit = qbitResult.status === 'fulfilled' ? qbitResult.value : [];
return [
...sonarr.map(
(item): DownloadItem => ({
id: `sonarr-${item.id ?? Math.random()}`,
source: 'sonarr',
title: item.series?.title || item.title || 'Series',
detail: item.title || 'Downloading episode',
progress: getQueueProgress(item.size, item.sizeleft),
state: item.status || item.trackedDownloadState || 'queue',
eta: item.timeleft || '',
size: item.size ? formatBytes(item.size) : ''
})
),
...radarr.map(
(item): DownloadItem => ({
id: `radarr-${item.id ?? Math.random()}`,
source: 'radarr',
title: item.movie?.title || item.title || 'Movie',
detail: item.title || 'Downloading movie',
progress: getQueueProgress(item.size, item.sizeleft),
state: item.status || item.trackedDownloadState || 'queue',
eta: item.timeleft || '',
size: item.size ? formatBytes(item.size) : ''
})
),
...qbit.map(
(item): DownloadItem => ({
id: `qbit-${item.hash || Math.random()}`,
source: 'qbittorrent',
title: item.name || 'Torrent',
detail: item.state || 'downloading',
progress: Math.round((item.progress || 0) * 100),
state: item.state || 'downloading',
eta: typeof item.eta === 'number' && item.eta > 0 ? formatEta(item.eta) : '',
speed: item.dlspeed ? `${formatBytes(item.dlspeed)}/s` : '',
size: item.size ? formatBytes(item.size) : ''
})
)
].sort((a, b) => b.progress - a.progress);
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))
);
return downloads.sort((a, b) => b.progress - a.progress);
}
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 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}`);
}
}
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 detailMediaType: 'movie' | 'tv' = primaryItem?.Type === 'Movie' || card.mediaType === 'movie' ? 'movie' : 'tv';
const tmdbId = Number(primaryItem?.ProviderIds?.Tmdb) || card.tmdbId;
const originalLanguage = card.originalLanguage;
const [backdropUrl, logoUrl] = await Promise.all([
tmdbId ? getTmdbHighResBackdrop(config, tmdbId, detailMediaType, null, originalLanguage) : Promise.resolve(''),
tmdbId ? getPreferredTitleLogo(config, tmdbId, detailMediaType) : Promise.resolve('')
]);
const title = seriesItem?.Name || primaryItem?.Name || card.title;
const overview = 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 (!seriesItem?.Id) {
return {
id: card.id,
tmdbId,
providerId: item?.Id || card.providerId,
playTargetId: item?.Id || card.providerId,
availableInJellyfin: Boolean(item?.Id || card.providerId),
title,
overview,
subtitle,
mediaType: 'movie',
posterUrl,
backdropUrl: effectiveBackdropUrl,
logoUrl: logoUrl || card.logoUrl,
seasons: [],
episodes: []
};
}
const seasons = await getSeriesSeasons(config, seriesItem.Id);
const selectedSeason = chooseSelectedSeason(seasons, seasonId, item);
const episodes = selectedSeason?.id
? await getSeasonEpisodes(config, selectedSeason.id)
: [];
const nextEpisode = await getNextEpisodeToPlay(config, seriesItem.Id, episodes);
return {
id: card.id,
tmdbId,
providerId: seriesItem.Id,
playTargetId: nextEpisode?.id || seriesItem.Id,
availableInJellyfin: Boolean(seriesItem.Id),
title,
overview,
subtitle,
mediaType: 'series',
posterUrl,
backdropUrl: effectiveBackdropUrl,
logoUrl: logoUrl || card.logoUrl,
selectedSeasonId: selectedSeason?.id,
seasons,
episodes
};
}
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;
}
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'
): 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}/original${selectedLogo.file_path}` : '';
}
async function getSeriesSeasons(config: AppConfig, seriesId: string): Promise<TitleDetailsSeason[]> {
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: 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
}))
.sort((left, right) => {
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;
});
} catch {
return [];
}
}
function chooseSelectedSeason(
seasons: TitleDetailsSeason[],
requestedSeasonId: string | undefined,
item: JellyfinItem | null
): TitleDetailsSeason | undefined {
if (requestedSeasonId) {
const requestedSeason = seasons.find((season) => season.id === requestedSeasonId);
if (requestedSeason) {
return requestedSeason;
}
}
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 getSeasonEpisodes(config: AppConfig, seasonId: string): Promise<TitleDetailsEpisode[]> {
try {
const episodes = await jellyfinFetch<JellyfinItem[]>(
config,
`/Users/${encodeURIComponent(config.jellyfin.userId)}/Items?parentId=${encodeURIComponent(seasonId)}&includeItemTypes=Episode&fields=Overview,ImageTags,RunTimeTicks,ParentIndexNumber,IndexNumber&sortBy=SortName`
);
return episodes
.filter((episode) => episode.Id)
.sort((left, right) => (left.IndexNumber ?? 0) - (right.IndexNumber ?? 0))
.map((episode) => {
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: buildJellyfinAssetUrl(String(episode.Id), 'Primary', 960, imageTag),
seasonNumber: episode.ParentIndexNumber,
episodeNumber: episode.IndexNumber,
runtimeMinutes: episode.RunTimeTicks ? Math.max(1, Math.round(episode.RunTimeTicks / 600000000)) : undefined
};
});
} catch {
return [];
}
}
async function getNextEpisodeToPlay(
config: AppConfig,
seriesId: string,
currentEpisodes: TitleDetailsEpisode[]
): Promise<TitleDetailsEpisode | null> {
if (!config.jellyfin.baseUrl || !config.jellyfin.apiKey || !config.jellyfin.userId) {
return currentEpisodes[0] || null;
}
try {
const response = await jellyfinFetch<{ Items?: JellyfinItem[] }>(
config,
`/Shows/NextUp?userId=${encodeURIComponent(config.jellyfin.userId)}&seriesId=${encodeURIComponent(seriesId)}&fields=Overview,ImageTags,RunTimeTicks,ParentIndexNumber,IndexNumber&limit=1`
);
const episode = response.Items?.[0];
if (!episode?.Id) {
return currentEpisodes[0] || null;
}
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: buildJellyfinAssetUrl(String(episode.Id), 'Primary', 960, imageTag),
seasonNumber: episode.ParentIndexNumber,
episodeNumber: episode.IndexNumber,
runtimeMinutes: episode.RunTimeTicks ? Math.max(1, Math.round(episode.RunTimeTicks / 600000000)) : undefined
};
} catch {
return currentEpisodes[0] || null;
}
}
export async function requestMovie(config: AppConfig, tmdbId: number): Promise<void> {
@@ -626,11 +1093,11 @@ async function buildHomeHub(
hero: heroCandidates,
sections: [
{ id: 'trending', title: 'Trending', style: 'landscape', items: filteredHero },
{ id: 'upcoming-movies', title: 'Upcoming Movies', style: 'landscape', items: filter(upcomingMovies || []) },
{ id: 'upcoming-series', title: 'Upcoming Series', style: 'landscape', items: filter(upcomingSeries || []) },
{ 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: '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 || []) }
]
};
}
@@ -1121,24 +1588,6 @@ async function getRadarrQueue(config: AppConfig): Promise<RadarrQueueRecord[]> {
return response.records || [];
}
async function getQbitQueue(config: AppConfig): Promise<QbitTorrent[]> {
if (!config.qbittorrent.baseUrl || !config.qbittorrent.username || !config.qbittorrent.password) {
return [];
}
const cookie = await loginQbit(config);
const response = await fetchJson<QbitTorrent[]>(
`${config.qbittorrent.baseUrl}/api/v2/torrents/info`,
{
headers: {
Cookie: cookie
}
}
);
return response;
}
async function testJellyfin(config: AppConfig): Promise<ConnectionStatus> {
if (!config.jellyfin.baseUrl || !config.jellyfin.apiKey) {
return {
@@ -1220,29 +1669,6 @@ async function testRadarr(config: AppConfig): Promise<ConnectionStatus> {
}
}
async function testQbittorrent(config: AppConfig): Promise<ConnectionStatus> {
if (!config.qbittorrent.baseUrl || !config.qbittorrent.username || !config.qbittorrent.password) {
return {
key: 'qbittorrent',
label: 'qBittorrent',
ok: false,
message: 'Not configured'
};
}
try {
await loginQbit(config);
return { key: 'qbittorrent', label: 'qBittorrent', ok: true, message: 'Connected' };
} catch (error) {
return {
key: 'qbittorrent',
label: 'qBittorrent',
ok: false,
message: errorMessage(error)
};
}
}
async function testTmdb(config: AppConfig): Promise<ConnectionStatus> {
if (!getTmdbToken(config)) {
return {
@@ -1302,30 +1728,6 @@ async function tmdbFetch(config: AppConfig, pathWithQuery: string): Promise<Reco
}
}
async function loginQbit(config: AppConfig): Promise<string> {
const response = await fetch(`${config.qbittorrent.baseUrl}/api/v2/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
},
body: new URLSearchParams({
username: config.qbittorrent.username,
password: config.qbittorrent.password
})
});
if (!response.ok) {
throw new Error(`qBittorrent ${response.status}`);
}
const cookie = response.headers.get('set-cookie');
if (!cookie) {
throw new Error('qBittorrent did not return a session cookie.');
}
return cookie.split(';', 1)[0];
}
async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
@@ -1414,7 +1816,7 @@ function tmdbToCard(item: Record<string, unknown>, mediaType: 'movie' | 'tv'): M
overview: String(item.overview || ''),
mediaType,
posterUrl: posterPath ? `${TMDB_IMAGE}/w500${posterPath}` : '',
backdropUrl: backdropPath ? `${TMDB_IMAGE}/original${backdropPath}` : '',
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,
@@ -1598,10 +2000,10 @@ async function applyHighResBackdropsToCards(
}
const mediaType: 'movie' | 'tv' = card.mediaType === 'movie' ? 'movie' : 'tv';
const cacheKey = `${mediaType}:${card.tmdbId}:${preferredLanguage || 'any'}`;
const cacheKey = `${mediaType}:${card.tmdbId}:${preferredLanguage || 'any'}:${card.originalLanguage || 'none'}`;
let backdropPromise = backdropCache.get(cacheKey);
if (!backdropPromise) {
backdropPromise = getTmdbHighResBackdrop(config, card.tmdbId!, mediaType, preferredLanguage);
backdropPromise = getTmdbHighResBackdrop(config, card.tmdbId!, mediaType, preferredLanguage, card.originalLanguage);
backdropCache.set(cacheKey, backdropPromise);
}
@@ -1632,6 +2034,10 @@ function tmdbImagesPath(tmdbId: number, mediaType: 'movie' | 'tv'): string {
return `/${mediaType}/${tmdbId}/images?include_image_language=en,en-US,null`;
}
function tmdbBackdropImagesPath(tmdbId: number, mediaType: 'movie' | 'tv'): string {
return `/${mediaType}/${tmdbId}/images`;
}
function formatPlaybackTime(playbackPositionTicks?: number): string | undefined {
if (!playbackPositionTicks || playbackPositionTicks <= 0) {
return undefined;
@@ -1674,10 +2080,11 @@ async function getTmdbHighResBackdrop(
config: AppConfig,
tmdbId: number,
mediaType: 'movie' | 'tv',
preferredLanguage?: string | null
preferredLanguage?: string | null,
originalLanguage?: string | null
): Promise<string> {
try {
const response = await tmdbFetch(config, tmdbImagesPath(tmdbId, mediaType));
const response = await tmdbFetch(config, tmdbBackdropImagesPath(tmdbId, mediaType));
let backdrops = asArray(response?.backdrops)
.map((entry) => asRecord(entry));
@@ -1685,22 +2092,44 @@ async function getTmdbHighResBackdrop(
return '';
}
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) => String(b.iso_639_1) === 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;
}
}
}
// Sort by width descending if no language preference (Carousel use case)
if (!preferredLanguage) {
backdrops.sort((a, b) => Number(b.width || 0) - Number(a.width || 0));
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);
}
}
// The first one is the first available one (after language filter and/or sorting)
const selectedBackdrop = backdrops.find(
(entry) => typeof entry.file_path === 'string' && entry.file_path
);
@@ -1757,20 +2186,6 @@ function formatBytes(bytes: number): string {
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
}
function formatEta(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) {
return '';
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
return `${minutes}m`;
}
function errorMessage(error: unknown): string {
if (error instanceof Error) {
if (error.message.includes('did not match the expected pattern')) {
+48 -13
View File
@@ -38,11 +38,6 @@ export interface RadarrConfig extends ServiceConfig {
startSearch: boolean;
}
export interface QbittorrentConfig extends ServiceConfig {
username: string;
password: string;
}
export interface TmdbConfig {
bearerToken: string;
}
@@ -59,7 +54,6 @@ export interface AppConfig {
jellyfin: JellyfinConfig;
sonarr: SonarrConfig;
radarr: RadarrConfig;
qbittorrent: QbittorrentConfig;
tmdb: TmdbConfig;
}
@@ -78,7 +72,7 @@ export interface ServiceOptionsResponse {
}
export interface ConnectionStatus {
key: 'jellyfin' | 'sonarr' | 'radarr' | 'qbittorrent' | 'tmdb';
key: 'jellyfin' | 'sonarr' | 'radarr' | 'tmdb';
label: string;
ok: boolean;
message: string;
@@ -113,16 +107,57 @@ export interface MediaCard {
genreIds?: number[];
}
export interface TitleDetailsEpisode {
id: string;
title: string;
overview: string;
subtitle: string;
imageUrl: string;
seasonNumber?: number;
episodeNumber?: number;
runtimeMinutes?: number;
}
export interface TitleDetailsSeason {
id: string;
title: string;
seasonNumber?: number;
episodeCount?: number;
}
export interface TitleDetailsResponse {
id: string;
tmdbId?: number;
providerId?: string;
playTargetId?: string;
availableInJellyfin: boolean;
title: string;
overview: string;
subtitle: string;
mediaType: 'movie' | 'series';
posterUrl: string;
backdropUrl: string;
logoUrl?: string;
selectedSeasonId?: string;
seasons: TitleDetailsSeason[];
episodes: TitleDetailsEpisode[];
}
export interface DownloadItem {
id: string;
source: 'sonarr' | 'radarr' | 'qbittorrent';
source: 'sonarr' | 'radarr';
title: string;
detail: string;
progress: number;
state: string;
subtitle?: string;
speed?: string;
eta?: string;
size?: string;
posterUrl?: string;
backdropUrl?: string;
tmdbId?: number;
mediaType?: 'movie' | 'tv' | 'series';
}
export interface DashboardResponse {
@@ -135,6 +170,11 @@ export interface DashboardResponse {
downloads: DownloadItem[];
}
export interface TitleDetailsRequest {
card: MediaCard;
seasonId?: string;
}
export interface HubSection {
id: string;
title: string;
@@ -197,11 +237,6 @@ export const defaultConfig: AppConfig = {
monitor: 'movieOnly',
startSearch: false
},
qbittorrent: {
baseUrl: '',
username: '',
password: ''
},
tmdb: {
bearerToken: ''
}
+655 -85
View File
@@ -1,8 +1,11 @@
import {
type CSSProperties,
type FormEvent,
type ReactNode,
startTransition,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react'
import { api } from './api'
@@ -16,10 +19,14 @@ import {
type MediaCard,
type SelectOption,
type ServiceOptionsResponse,
type TitleDetailsResponse,
} from '../shared/contracts'
type AuthState = 'loading' | 'setup' | 'login' | 'app'
type EntryStage = 'onboarding' | 'profiles' | 'dashboard'
const MAX_CACHED_HUB_PAGES = 3
type ModalOriginRect = { left: number; top: number; width: number; height: number }
type SelectedCardState = { card: MediaCard; originRect: ModalOriginRect | null }
const sidebarItems: Array<{ route: AppRoute; label: string; short: string }> = [
{ route: 'home', label: 'Inicio', short: 'H' },
@@ -59,7 +66,8 @@ function App() {
const [connections, setConnections] = useState<ConnectionStatus[]>([])
const [hubPages, setHubPages] = useState<Partial<Record<HubPageResponse['page'], HubPageResponse>>>({})
const [hubLoading, setHubLoading] = useState(false)
const [selectedCard, setSelectedCard] = useState<MediaCard | null>(null)
const [selectedCard, setSelectedCard] = useState<SelectedCardState | null>(null)
const [downloadDeleteId, setDownloadDeleteId] = useState('')
async function refreshBaseData() {
const [settings, serviceOptions, dashboard] = await Promise.all([
@@ -81,7 +89,7 @@ function App() {
}
try {
const hub = await api.getHub(page)
setHubPages((current) => ({ ...current, [page]: hub }))
setHubPages((current) => cacheHubPage(current, page, hub))
} catch (loadError) {
setError(loadError instanceof Error ? loadError.message : 'Failed to load this section.')
} finally {
@@ -143,7 +151,7 @@ function App() {
void api.getHub(page)
.then((hub) => {
if (cancelled) return
setHubPages((current) => ({ ...current, [page]: hub }))
setHubPages((current) => cacheHubPage(current, page, hub))
})
.catch((loadError) => {
if (cancelled) return
@@ -278,6 +286,22 @@ function App() {
}
}
async function handleDeleteDownload(id: string) {
setDownloadDeleteId(id)
setError('')
setNotice('')
try {
await api.deleteDownload({ id })
const nextDownloads = await api.getDownloads()
setDownloads(nextDownloads)
setNotice('Download removed from the queue.')
} catch (deleteError) {
setError(deleteError instanceof Error ? deleteError.message : 'Failed to remove the download.')
} finally {
setDownloadDeleteId('')
}
}
function updateConfig<K extends keyof AppConfig>(key: K, value: AppConfig[K]) {
setConfig((current) => ({
...current,
@@ -359,7 +383,7 @@ function App() {
{error ? <p className="error-banner">{error}</p> : null}
</div>
{route === 'downloads' ? <DownloadsPage downloads={downloads} /> : null}
{route === 'downloads' ? <DownloadsPage busyId={downloadDeleteId} downloads={downloads} onDelete={handleDeleteDownload} /> : null}
{route === 'settings' ? (
<SettingsView
busy={busy}
@@ -377,7 +401,7 @@ function App() {
busy={busy}
hub={currentHub}
loading={hubLoading}
onOpenCard={setSelectedCard}
onOpenCard={(card, originRect) => setSelectedCard({ card, originRect })}
onRequest={handleRequest}
route={route}
tmdbToken={config.tmdb.bearerToken}
@@ -389,9 +413,10 @@ function App() {
{selectedCard ? (
<TitleDetailsModal
card={selectedCard}
card={selectedCard.card}
config={config}
onClose={() => setSelectedCard(null)}
originRect={selectedCard.originRect}
onRequest={handleRequest}
/>
) : null}
@@ -533,7 +558,7 @@ function AuthScreen({
<ul className="feature-list">
<li>Server-side session auth</li>
<li>Encrypted persisted settings</li>
<li>Jellyfin, Sonarr, Radarr and qBittorrent</li>
<li>Jellyfin, Sonarr and Radarr</li>
<li>Rebuilt React UI with shared layout</li>
</ul>
</div>
@@ -640,7 +665,7 @@ function HubPageView(props: {
hub?: HubPageResponse
loading: boolean
onRequest: (card: MediaCard) => Promise<void>
onOpenCard: (card: MediaCard) => void
onOpenCard: (card: MediaCard, originRect?: ModalOriginRect | null) => void
busy: boolean
tmdbToken: string
}) {
@@ -683,9 +708,10 @@ function HubPageView(props: {
)
}
function HeroShowcase({ hero, onOpenCard, tmdbToken }: { hero: MediaCard[]; onOpenCard: (card: MediaCard) => void; tmdbToken: string }) {
function HeroShowcase({ hero, onOpenCard, tmdbToken }: { hero: MediaCard[]; onOpenCard: (card: MediaCard, originRect?: ModalOriginRect | null) => void; tmdbToken: string }) {
const [activeIndex, setActiveIndex] = useState(0)
const [activeLogoUrl, setActiveLogoUrl] = useState('')
const logoCacheRef = useRef(new Map<string, string>())
useEffect(() => {
setActiveIndex((current) => (hero.length ? Math.min(current, hero.length - 1) : 0))
@@ -718,6 +744,12 @@ function HeroShowcase({ hero, onOpenCard, tmdbToken }: { hero: MediaCard[]; onOp
}
let cancelled = false
const cacheKey = `${mediaType}:${featured.tmdbId}`
const cachedLogoUrl = logoCacheRef.current.get(cacheKey)
if (cachedLogoUrl !== undefined) {
setActiveLogoUrl(cachedLogoUrl)
return
}
const fetchImages = tmdbToken
? fetch(`https://api.themoviedb.org/3/${mediaType}/${featured.tmdbId}/images`, {
@@ -742,11 +774,14 @@ function HeroShowcase({ hero, onOpenCard, tmdbToken }: { hero: MediaCard[]; onOp
if (!cancelled) {
const englishLogos = payload.logos?.filter((logo) => logo.iso_639_1 === 'en') || []
const logo = englishLogos[0] || payload.logos?.[0]
setActiveLogoUrl(logo?.file_path ? `https://image.tmdb.org/t/p/original${logo.file_path}` : '')
const nextLogoUrl = logo?.file_path ? `https://image.tmdb.org/t/p/original${logo.file_path}` : ''
logoCacheRef.current.set(cacheKey, nextLogoUrl)
setActiveLogoUrl(nextLogoUrl)
}
})
.catch(() => {
if (!cancelled) {
logoCacheRef.current.set(cacheKey, '')
setActiveLogoUrl('')
}
})
@@ -825,10 +860,28 @@ function resolveHeroSlideStyle(card: MediaCard | undefined) {
return slideImage ? { backgroundImage: `url("${slideImage}")` } : undefined
}
function cacheHubPage(
current: Partial<Record<HubPageResponse['page'], HubPageResponse>>,
page: HubPageResponse['page'],
hub: HubPageResponse
): Partial<Record<HubPageResponse['page'], HubPageResponse>> {
const next = { ...current, [page]: hub }
const pages = Object.keys(next) as HubPageResponse['page'][]
if (pages.length <= MAX_CACHED_HUB_PAGES) {
return next
}
const trimmed: Partial<Record<HubPageResponse['page'], HubPageResponse>> = {}
pages.slice(-MAX_CACHED_HUB_PAGES).forEach((cachedPage) => {
trimmed[cachedPage] = next[cachedPage]
})
return trimmed
}
function SectionCarousel(props: {
section: HubPageResponse['sections'][number]
onRequest: (card: MediaCard) => Promise<void>
onOpenCard: (card: MediaCard) => void
onOpenCard: (card: MediaCard, originRect?: ModalOriginRect | null) => void
busy: boolean
}) {
if (!props.section.items.length) return null
@@ -852,7 +905,7 @@ function MediaTile(props: {
isContinueWatching: boolean
styleType: 'poster' | 'landscape' | 'profile' | 'genre' | 'network' | 'episode'
busy: boolean
onOpenCard: (card: MediaCard) => void
onOpenCard: (card: MediaCard, originRect?: ModalOriginRect | null) => void
onRequest: (card: MediaCard) => Promise<void>
}) {
const imageUrl = props.styleType === 'landscape' || props.styleType === 'episode'
@@ -861,7 +914,14 @@ function MediaTile(props: {
return (
<article className={props.isContinueWatching ? `media-tile ${props.styleType} continue-tile` : `media-tile ${props.styleType}`}>
<button className="media-art media-trigger" onClick={() => props.onOpenCard(props.card)} type="button">
<button
className="media-art media-trigger"
onClick={(event) => {
const tile = event.currentTarget.closest('.media-tile') as HTMLElement | null
props.onOpenCard(props.card, tile ? captureOriginRect(tile) : captureOriginRect(event.currentTarget))
}}
type="button"
>
{imageUrl ? <img alt={props.card.title} loading="lazy" src={imageUrl} /> : <div className="media-fallback" />}
{props.card.hoverLabel ? <div className="media-hover-label">{props.card.hoverLabel}</div> : null}
{props.card.progress ? <div className="tile-progress"><div className="tile-progress-fill" style={{ width: `${props.card.progress}%` }} /></div> : null}
@@ -874,59 +934,479 @@ function TitleDetailsModal(props: {
card: MediaCard
config: AppConfig
onClose: () => void
originRect: ModalOriginRect | null
onRequest: (card: MediaCard) => Promise<void>
}) {
const canPlay = Boolean(props.card.providerId && props.config.jellyfin.baseUrl)
const [details, setDetails] = useState<TitleDetailsResponse | null>(null)
const [detailsLoading, setDetailsLoading] = useState(true)
const [detailsError, setDetailsError] = useState('')
const [requestedSeasonId, setRequestedSeasonId] = useState<string | undefined>(undefined)
const [activeSeasonId, setActiveSeasonId] = useState<string | undefined>(undefined)
const [playerTarget, setPlayerTarget] = useState<{ id: string; title: string } | null>(null)
const [isOverviewExpanded, setIsOverviewExpanded] = useState(false)
const [episodeScrollState, setEpisodeScrollState] = useState({ canScrollLeft: false, canScrollRight: false })
const [transitionState, setTransitionState] = useState<'opening' | 'open' | 'closing'>(
props.originRect ? 'opening' : 'open'
)
const [transitionStyle, setTransitionStyle] = useState<CSSProperties | undefined>(undefined)
const closeTimeoutRef = useRef<number | null>(null)
const modalRef = useRef<HTMLDivElement | null>(null)
const episodeListRef = useRef<HTMLDivElement | null>(null)
const seasonDetailsCacheRef = useRef(new Map<string, TitleDetailsResponse>())
const pendingSeasonScrollRef = useRef<string | null>(null)
useLayoutEffect(() => {
if (!props.originRect || !modalRef.current) {
setTransitionState('open')
setTransitionStyle(undefined)
return
}
const modalRect = modalRef.current.getBoundingClientRect()
const scaleX = props.originRect.width / modalRect.width
const scaleY = props.originRect.height / modalRect.height
const translateX = props.originRect.left - modalRect.left
const translateY = props.originRect.top - modalRect.top
setTransitionStyle({
['--title-modal-from-x' as '--title-modal-from-x']: `${translateX}px`,
['--title-modal-from-y' as '--title-modal-from-y']: `${translateY}px`,
['--title-modal-from-scale-x' as '--title-modal-from-scale-x']: `${scaleX}`,
['--title-modal-from-scale-y' as '--title-modal-from-scale-y']: `${scaleY}`,
})
let frameA = 0
let frameB = 0
frameA = window.requestAnimationFrame(() => {
frameB = window.requestAnimationFrame(() => {
setTransitionState('open')
})
})
return () => {
window.cancelAnimationFrame(frameA)
window.cancelAnimationFrame(frameB)
}
}, [props.card.id, props.originRect])
useEffect(() => () => {
if (closeTimeoutRef.current) {
window.clearTimeout(closeTimeoutRef.current)
}
}, [])
useEffect(() => {
setDetails(null)
setDetailsLoading(true)
setDetailsError('')
setRequestedSeasonId(undefined)
setActiveSeasonId(undefined)
setPlayerTarget(null)
setIsOverviewExpanded(false)
seasonDetailsCacheRef.current.clear()
}, [props.card.id])
useEffect(() => {
let cancelled = false
const cacheKey = requestedSeasonId || '__default__'
const cachedDetails = seasonDetailsCacheRef.current.get(cacheKey)
if (cachedDetails) {
setDetails(cachedDetails)
setDetailsLoading(false)
setDetailsError('')
if (cachedDetails.selectedSeasonId) {
setActiveSeasonId((current) => current || cachedDetails.selectedSeasonId)
}
return () => {
cancelled = true
}
}
setDetailsLoading(true)
setDetailsError('')
void api.getTitleDetails(props.card, requestedSeasonId)
.then((nextDetails) => {
if (cancelled) return
const resolvedSeasonId = nextDetails.selectedSeasonId || requestedSeasonId || undefined
seasonDetailsCacheRef.current.set(cacheKey, nextDetails)
if (resolvedSeasonId) {
seasonDetailsCacheRef.current.set(resolvedSeasonId, nextDetails)
setActiveSeasonId((current) => current || resolvedSeasonId)
}
setDetails(nextDetails)
})
.catch((error) => {
if (cancelled) return
setDetailsError(error instanceof Error ? error.message : 'Failed to load title details.')
})
.finally(() => {
if (cancelled) return
setDetailsLoading(false)
})
return () => {
cancelled = true
}
}, [props.card, requestedSeasonId])
const seasonValue = activeSeasonId ?? requestedSeasonId ?? details?.selectedSeasonId ?? ''
useEffect(() => {
if (!details || details.mediaType !== 'series' || !details.seasons.length) {
return
}
const currentIndex = details.seasons.findIndex((season) => season.id === seasonValue)
const neighborSeasonIds = [details.seasons[currentIndex - 1]?.id, details.seasons[currentIndex + 1]?.id].filter(Boolean) as string[]
for (const seasonId of neighborSeasonIds) {
if (seasonDetailsCacheRef.current.has(seasonId)) {
continue
}
void api.getTitleDetails(props.card, seasonId)
.then((nextDetails) => {
seasonDetailsCacheRef.current.set(seasonId, nextDetails)
})
.catch(() => {})
}
}, [details, props.card, seasonValue])
useEffect(() => {
const list = episodeListRef.current
const targetSeasonId = pendingSeasonScrollRef.current
if (!list || !targetSeasonId) {
return
}
const targetEpisode = list.querySelector<HTMLElement>(`[data-season-id="${CSS.escape(targetSeasonId)}"]`)
if (!targetEpisode) {
return
}
const listRect = list.getBoundingClientRect()
const targetRect = targetEpisode.getBoundingClientRect()
list.scrollTo({
left: list.scrollLeft + (targetRect.left - listRect.left),
behavior: 'smooth',
})
pendingSeasonScrollRef.current = null
}, [seasonValue, details])
useEffect(() => {
const list = episodeListRef.current
if (!list || detailsLoading || details?.mediaType !== 'series') {
setEpisodeScrollState({ canScrollLeft: false, canScrollRight: false })
return
}
const syncScrollState = () => {
const maxScrollLeft = list.scrollWidth - list.clientWidth
setEpisodeScrollState({
canScrollLeft: list.scrollLeft > 8,
canScrollRight: maxScrollLeft - list.scrollLeft > 8,
})
const viewportLeft = list.scrollLeft
const viewportRight = viewportLeft + list.clientWidth
const coverageBySeason = new Map<string, number>()
const cards = list.querySelectorAll<HTMLElement>('[data-season-id]')
for (const card of cards) {
const seasonId = card.dataset.seasonId
if (!seasonId) continue
const cardLeft = card.offsetLeft
const cardRight = cardLeft + card.offsetWidth
const visibleWidth = Math.min(cardRight, viewportRight) - Math.max(cardLeft, viewportLeft)
if (visibleWidth <= 0) continue
coverageBySeason.set(seasonId, (coverageBySeason.get(seasonId) || 0) + visibleWidth)
}
let dominantSeasonId = seasonValue
let dominantCoverage = -1
for (const [seasonId, coverage] of coverageBySeason.entries()) {
if (coverage > dominantCoverage) {
dominantCoverage = coverage
dominantSeasonId = seasonId
}
}
if (dominantSeasonId && dominantSeasonId !== activeSeasonId) {
setActiveSeasonId(dominantSeasonId)
}
}
syncScrollState()
list.addEventListener('scroll', syncScrollState, { passive: true })
window.addEventListener('resize', syncScrollState)
return () => {
list.removeEventListener('scroll', syncScrollState)
window.removeEventListener('resize', syncScrollState)
}
}, [activeSeasonId, details, detailsLoading, seasonValue])
const canRequest = Boolean(props.card.tmdbId && props.card.mediaType !== 'mixed')
const backgroundImage = props.card.backdropUrl || props.card.posterUrl
const [playerOpen, setPlayerOpen] = useState(false)
const requestLabel = props.card.mediaType === 'movie' ? 'Add to Radarr' : 'Add to Sonarr'
const jellyfinPlayerUrl = canPlay
? `${props.config.jellyfin.baseUrl.replace(/\/$/, '')}/web/#/details?id=${encodeURIComponent(props.card.providerId!)}&autoplay=true`
const playId = details?.playTargetId || details?.providerId || props.card.providerId
const isAvailableInJellyfin = details?.availableInJellyfin ?? Boolean(props.card.providerId)
const canPlay = Boolean(isAvailableInJellyfin && playId && props.config.jellyfin.baseUrl)
const backgroundImage = details?.backdropUrl || props.card.backdropUrl || props.card.posterUrl
const logoUrl = details?.logoUrl || props.card.logoUrl
const displayTitle = details?.title || props.card.title
const displaySubtitle = details?.subtitle || props.card.subtitle
const displayOverview = details?.overview || props.card.overview || 'No details available yet.'
const displayYear = props.card.releaseDate?.slice(0, 4)
|| props.card.firstAirDate?.slice(0, 4)
|| displaySubtitle.match(/\b(19|20)\d{2}\b/)?.[0]
|| ''
const truncatedOverview = truncateOverview(displayOverview, 260)
const shouldShowOverviewToggle = truncatedOverview !== displayOverview
const jellyfinPlayerUrl = playerTarget && props.config.jellyfin.baseUrl
? buildJellyfinPlayerUrl(props.config.jellyfin.baseUrl, playerTarget.id)
: ''
const activeSeasonDetails = seasonValue ? seasonDetailsCacheRef.current.get(seasonValue) : null
const currentSeasonIndex = details?.seasons.findIndex((season) => season.id === seasonValue) ?? -1
const resolvedSeasonIndex = currentSeasonIndex >= 0 ? currentSeasonIndex : 0
const hasPreviousSeason = Boolean(details?.seasons.length && resolvedSeasonIndex > 0)
const hasNextSeason = Boolean(details?.seasons.length && resolvedSeasonIndex < details.seasons.length - 1)
const visibleEpisodes = details?.seasons.flatMap((season) =>
(seasonDetailsCacheRef.current.get(season.id)?.episodes || []).map((episode) => ({
...episode,
seasonId: season.id,
}))
) ?? []
function handleRequestClose() {
if (!props.originRect || transitionState === 'closing') {
props.onClose()
return
}
setTransitionState('closing')
closeTimeoutRef.current = window.setTimeout(() => {
props.onClose()
}, 680)
}
function scrollEpisodes(direction: 'left' | 'right') {
const list = episodeListRef.current
if (!list) return
const distance = Math.max(list.clientWidth * 0.72, 320)
list.scrollBy({
left: direction === 'left' ? -distance : distance,
behavior: 'smooth',
})
}
function changeSeason(offset: -1 | 1) {
if (!details?.seasons.length) {
return
}
const nextSeason = details.seasons[resolvedSeasonIndex + offset]
if (!nextSeason) {
return
}
pendingSeasonScrollRef.current = nextSeason.id
setActiveSeasonId(nextSeason.id)
startTransition(() => {
setRequestedSeasonId(nextSeason.id)
})
}
function handleSeasonSelectChange(nextSeasonId: string) {
pendingSeasonScrollRef.current = nextSeasonId || null
setActiveSeasonId(nextSeasonId || undefined)
startTransition(() => {
setRequestedSeasonId(nextSeasonId || undefined)
})
}
if (playerTarget && jellyfinPlayerUrl) {
return (
<div className="modal-backdrop player-backdrop" role="presentation">
<div className="title-player-view" role="dialog" aria-modal="true">
<div className="title-player-topbar">
<button className="secondary-button" onClick={() => setPlayerTarget(null)} type="button">Back</button>
<button className="modal-close omniarr-modal-close" onClick={props.onClose} type="button">Close</button>
</div>
<iframe
allow="autoplay; fullscreen; encrypted-media"
className="title-player-frame"
src={jellyfinPlayerUrl}
title={`${playerTarget.title} player`}
/>
</div>
</div>
)
}
return (
<div className="modal-backdrop" onClick={props.onClose} role="presentation">
<div className="title-modal omniarr-title-modal" onClick={(event) => event.stopPropagation()} role="dialog" aria-modal="true">
<div className="title-modal-hero omniarr-title-hero" style={backgroundImage ? { backgroundImage: `url("${backgroundImage}")` } : undefined}>
<div className="omniarr-title-darken" />
<button className="modal-close omniarr-modal-close" onClick={props.onClose} type="button">Close</button>
<div className="omniarr-title-top">
<div className="omniarr-title-shell">
<div className="omniarr-title-copy">
<span className="badge">{props.card.badge || props.card.subtitle || 'Title'}</span>
<h2>{props.card.title}</h2>
{props.card.subtitle ? <p className="omniarr-title-subtitle">{props.card.subtitle}</p> : null}
<div className="title-modal-actions omniarr-title-actions">
{canPlay ? <button className="primary-button" onClick={() => setPlayerOpen(true)} type="button">Play</button> : null}
{canRequest ? <button className="secondary-button" onClick={() => void props.onRequest(props.card)} type="button">{props.card.actionLabel || requestLabel}</button> : null}
</div>
</div>
</div>
</div>
</div>
{playerOpen && jellyfinPlayerUrl ? (
<div className="omniarr-player-shell">
<iframe
allow="autoplay; fullscreen; encrypted-media"
className="omniarr-player-frame"
src={jellyfinPlayerUrl}
title={`${props.card.title} player`}
/>
<div className={transitionState === 'closing' ? 'modal-backdrop is-closing' : 'modal-backdrop'} onClick={handleRequestClose} role="presentation">
<div
className={
transitionStyle
? `title-modal title-details-modal title-details-modal-transition is-${transitionState}`
: 'title-modal title-details-modal'
}
onClick={(event) => event.stopPropagation()}
ref={modalRef}
role="dialog"
aria-modal="true"
style={transitionStyle}
>
{detailsLoading && !details ? (
<div className="title-details-loading-screen" role="status" aria-live="polite">
<div className="title-details-spinner" aria-hidden="true" />
<p>Loading details…</p>
</div>
) : null}
<div className="omniarr-title-bottom">
<div className="omniarr-title-shell omniarr-title-bottom-grid">
<div className="title-modal-poster omniarr-title-poster">
{props.card.posterUrl ? <img alt={props.card.title} src={props.card.posterUrl} /> : <div className="media-fallback" />}
</div>
<div className="omniarr-title-info">
<h3>{props.card.title}</h3>
<p>{props.card.overview || props.card.subtitle || 'No details available yet.'}</p>
<div className="title-modal-meta">
{props.card.subtitle ? <span>{props.card.subtitle}</span> : null}
{props.card.secondaryValue ? <span>{props.card.secondaryValue}</span> : null}
<div className="title-details-modal-content">
<div className="title-details-hero" style={backgroundImage ? { backgroundImage: `url("${backgroundImage}")` } : undefined}>
<div className="title-details-darken" />
<button className="modal-close omniarr-modal-close" onClick={handleRequestClose} type="button">Close</button>
</div>
<div className="title-details-body">
<div className="title-details-shell">
{detailsError ? <p className="error-banner">{detailsError}</p> : null}
<div className="title-details-summary">
<div className="title-details-hero-heading">
{logoUrl ? (
<img alt={displayTitle} className="title-details-logo" src={logoUrl} />
) : (
<h2 className="title-details-title">{displayTitle}</h2>
)}
</div>
<div className="title-details-summary-content">
<p className="title-details-overview">
{isOverviewExpanded ? displayOverview : truncatedOverview}
{shouldShowOverviewToggle ? (
<>
{' '}
<button
className="title-details-overview-toggle"
onClick={() => setIsOverviewExpanded((current) => !current)}
type="button"
>
{isOverviewExpanded ? 'less' : 'more'}
</button>
</>
) : null}
</p>
<div className="title-details-summary-header">
{canPlay ? (
<button className="secondary-button title-action-button title-action-button-play" onClick={() => setPlayerTarget({ id: playId!, title: displayTitle })} type="button">
<PlayGlyph />
<span>Play</span>
</button>
) : canRequest ? (
<button className="secondary-button title-action-button" onClick={() => void props.onRequest(props.card)} type="button">
<DownloadGlyph />
<span>Request</span>
</button>
) : null}
</div>
</div>
{displayYear ? <p className="title-details-year">{displayYear}</p> : null}
</div>
{details?.mediaType === 'series' ? (
<div className="title-seasons-panel">
<div className="title-seasons-header">
<div
className="title-season-strip"
>
<button
aria-label="Previous season"
className="title-season-nav"
disabled={!hasPreviousSeason}
onClick={() => changeSeason(-1)}
type="button"
/>
{details.seasons.length ? (
<label className="title-season-selector">
<span className="title-season-kicker">Season</span>
<select
className="title-season-select"
onChange={(event) => handleSeasonSelectChange(event.target.value)}
value={seasonValue}
>
{details.seasons.map((season) => (
<option key={season.id} value={season.id}>
{season.title}
</option>
))}
</select>
</label>
) : null}
<div className="title-season-divider" aria-hidden="true" />
<div className="title-season-badge">
<strong>{activeSeasonDetails?.episodes.length ?? details.episodes.length}</strong>
<span>{(activeSeasonDetails?.episodes.length ?? details.episodes.length) === 1 ? 'episode' : 'episodes'}</span>
</div>
<button
aria-label="Next season"
className="title-season-nav next"
disabled={!hasNextSeason}
onClick={() => changeSeason(1)}
type="button"
/>
</div>
</div>
<div
className={[
'episode-dock',
episodeScrollState.canScrollLeft ? 'show-left-fade' : '',
episodeScrollState.canScrollRight ? 'show-right-fade' : '',
].filter(Boolean).join(' ')}
>
{episodeScrollState.canScrollLeft ? (
<button
aria-label="Scroll episodes left"
className="episode-dock-arrow left"
onClick={() => scrollEpisodes('left')}
type="button"
/>
) : null}
{episodeScrollState.canScrollRight ? (
<button
aria-label="Scroll episodes right"
className="episode-dock-arrow right"
onClick={() => scrollEpisodes('right')}
type="button"
/>
) : null}
<div
className="episode-list"
ref={episodeListRef}
>
{visibleEpisodes.map((episode) => (
<button
className="episode-card"
data-season-id={episode.seasonId}
key={`${episode.seasonId}-${episode.id}`}
onClick={() => setPlayerTarget({ id: episode.id, title: episode.title })}
type="button"
>
<div className="episode-card-art">
{episode.imageUrl ? <img alt={episode.title} loading="lazy" src={episode.imageUrl} /> : <div className="media-fallback" />}
</div>
<div className="episode-card-copy">
<strong>{episode.title}</strong>
<span>{formatEpisodeMeta(episode.subtitle)}</span>
</div>
</button>
))}
</div>
</div>
</div>
) : null}
</div>
</div>
</div>
@@ -935,36 +1415,141 @@ function TitleDetailsModal(props: {
)
}
function DownloadsPage({ downloads }: { downloads: DownloadItem[] }) {
function buildJellyfinPlayerUrl(baseUrl: string, itemId: string) {
return `${baseUrl.replace(/\/$/, '')}/web/#/details?id=${encodeURIComponent(itemId)}&autoplay=true`
}
function captureOriginRect(element: HTMLElement): ModalOriginRect {
const rect = element.getBoundingClientRect()
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
}
}
function PlayGlyph() {
return (
<svg aria-hidden="true" className="title-action-icon" viewBox="0 0 16 16">
<path d="M5 3.2v9.6L12.6 8 5 3.2Z" fill="currentColor" />
</svg>
)
}
function DownloadGlyph() {
return (
<svg aria-hidden="true" className="title-action-icon" viewBox="0 0 16 16">
<path d="M8.75 2.5h-1.5v6.1L5.1 6.45 4 7.55 8 11.55l4-4-1.1-1.1-2.15 2.15V2.5Z" fill="currentColor" />
<path d="M3 12h10v1.5H3z" fill="currentColor" />
</svg>
)
}
function formatEpisodeMeta(subtitle: string) {
return subtitle.replace(/^Season\s+\d+\s*,\s*/i, '')
}
function truncateOverview(text: string, maxLength: number) {
if (text.length <= maxLength) {
return text
}
const clipped = text.slice(0, maxLength)
const safeBreak = clipped.lastIndexOf(' ')
return `${(safeBreak > maxLength * 0.7 ? clipped.slice(0, safeBreak) : clipped).trimEnd()}...`
}
function DownloadsPage(props: {
downloads: DownloadItem[]
busyId: string
onDelete: (id: string) => Promise<void>
}) {
return (
<div className="downloads-page">
<section className="carousel-section">
<div className="carousel-title">Downloads</div>
<div className="downloads-header">
<h2>Downloads</h2>
</div>
<section className="downloads-section">
<div className="downloads-grid">
{downloads.length ? downloads.map((item) => <DownloadRow item={item} key={item.id} />) : <EmptyState copy="The queue will appear here as soon as your services start working." title="No active downloads" />}
{props.downloads.length
? props.downloads.map((item) => (
<DownloadRow
busy={props.busyId === item.id}
item={item}
key={item.id}
onDelete={props.onDelete}
/>
))
: <EmptyState copy="The queue will appear here as soon as your services start working." title="No active downloads" />}
</div>
</section>
</div>
)
}
function DownloadRow({ item }: { item: DownloadItem }) {
function DownloadRow(props: {
item: DownloadItem
busy: boolean
onDelete: (id: string) => Promise<void>
}) {
const item = props.item
const progressMeta = [item.size, item.speed, item.eta].filter(Boolean).join(' · ')
const artwork = item.backdropUrl || item.posterUrl
const isCompleted = item.progress >= 100
function confirmDelete() {
if (!window.confirm(`Remove "${item.title}" from the queue?`)) {
return
}
void props.onDelete(item.id)
}
return (
<article className="download-row">
<div>
<p className="download-source">{item.source}</p>
<h4>{item.title}</h4>
<p className="download-detail">{item.detail}</p>
<div className="download-card">
<div className="download-artwork">
{artwork ? <img alt={item.title} loading="lazy" src={artwork} /> : <div className="media-fallback download-poster-fallback">{item.title.slice(0, 1).toUpperCase()}</div>}
</div>
<div className="download-copy">
<h4>{item.title}</h4>
<p className="download-subtitle">{item.subtitle || item.detail}</p>
<p className="download-detail">{progressMeta || humanizeDownloadState(item.state)}</p>
</div>
</div>
<div className="download-meta">
<strong>{item.progress}%</strong>
<p>{[item.size, item.speed, item.eta].filter(Boolean).join(' · ')}</p>
<div className="tile-progress"><div className="tile-progress-fill" style={{ width: `${item.progress}%` }} /></div>
<div className="download-side">
<div className="download-progress-panel">
<div className="download-progress-header">
<strong>{item.progress}%</strong>
<span>{progressMeta || humanizeDownloadState(item.state)}</span>
</div>
<div className="download-progress-row">
<div className={isCompleted ? 'download-progress-bar download-progress-complete' : 'download-progress-bar'}>
<div className="download-progress-fill" style={{ width: `${item.progress}%` }} />
</div>
<button
aria-label={`Delete ${item.title}`}
className="download-delete-button"
disabled={props.busy}
onClick={confirmDelete}
type="button"
>
{props.busy ? '…' : '×'}
</button>
</div>
</div>
</div>
</article>
)
}
function humanizeDownloadState(state: string) {
return state
.replace(/[_-]+/g, ' ')
.replace(/\b\w/g, (match) => match.toUpperCase())
}
function SettingsView(props: {
config: AppConfig
connections: ConnectionStatus[]
@@ -1041,21 +1626,6 @@ function SettingsView(props: {
/>
</SettingsCard>
<SettingsCard title="qBittorrent">
<label>
Base URL
<input className="input" onChange={(event) => props.updateConfig('qbittorrent', { ...props.config.qbittorrent, baseUrl: event.target.value })} value={props.config.qbittorrent.baseUrl} />
</label>
<label>
Username
<input className="input" onChange={(event) => props.updateConfig('qbittorrent', { ...props.config.qbittorrent, username: event.target.value })} value={props.config.qbittorrent.username} />
</label>
<label>
Password
<input className="input" onChange={(event) => props.updateConfig('qbittorrent', { ...props.config.qbittorrent, password: event.target.value })} type="password" value={props.config.qbittorrent.password} />
</label>
</SettingsCard>
<SettingsCard title="TMDb">
<label>
Bearer token
+14 -1
View File
@@ -5,8 +5,11 @@ import type {
DashboardResponse,
DownloadItem,
HubPageResponse,
MediaCard,
ServiceOptionsResponse,
SessionResponse
SessionResponse,
TitleDetailsRequest,
TitleDetailsResponse
} from '../shared/contracts'
async function request<T>(input: string, init?: RequestInit): Promise<T> {
@@ -75,7 +78,17 @@ export const api = {
}),
getDashboard: () => request<DashboardResponse>('/api/dashboard'),
getHub: (page: HubPageResponse['page']) => request<HubPageResponse>(`/api/hubs/${page}`),
getTitleDetails: (card: MediaCard, seasonId?: string) =>
request<TitleDetailsResponse>('/api/title-details', {
method: 'POST',
body: JSON.stringify({ card, seasonId } satisfies TitleDetailsRequest),
}),
getDownloads: () => request<DownloadItem[]>('/api/downloads'),
deleteDownload: (payload: { id: string }) =>
request<{ ok: boolean }>('/api/downloads/delete', {
method: 'POST',
body: JSON.stringify(payload),
}),
requestMovie: (tmdbId: number) =>
request<{ ok: boolean }>('/api/requests/movie', {
method: 'POST',
+851 -112
View File
File diff suppressed because it is too large. Load diff