mirror of
https://github.com/WonderAliceUwU/OmniArr.git
synced 2026-11-11 04:01:01 +00:00
feature: add show detail
This commit is contained in:
1 parent
ca2976a783
commit
a691e9d113
6 files changed
+2156
-371
No files matched your search
+18
-5
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user