From 55956bc52d51d3582d6ae3fdc370e6d4e07b4792 Mon Sep 17 00:00:00 2001 From: WonderAliceUwU Date: Tue, 7 Jul 2026 16:02:41 +0200 Subject: [PATCH] feature: add season download and request functionality --- server/index.ts | 28 +- server/lib/services.ts | 554 +++++++++++++++++++++++++++++++++++-- shared/contracts.ts | 25 ++ src/App.tsx | 605 ++++++++++++++++++++--------------------- src/api.ts | 12 + src/index.css | 356 ++++++++++++++++++++++-- 6 files changed, 1227 insertions(+), 353 deletions(-) diff --git a/server/index.ts b/server/index.ts index 4fdce3a..67d481c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,7 +1,15 @@ 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, type EpisodeWatchStateRequest, type TitleDetailsRequest } from '../shared/contracts.js'; +import { + defaultConfig, + type AppConfig, + type AuthPayload, + type EpisodeWatchStateRequest, + type SeasonDeletePayload, + type SeasonRequestPayload, + 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 { @@ -11,11 +19,13 @@ import { getHubPage, getTitleDetails, getTmdbLogos, + deleteSeasonFiles, getServiceOptions, loadJellyfinUsers, proxyJellyfinImage, removeDownload, requestMovie, + requestSeason, requestSeries, setEpisodeWatchState, sanitizeConfig @@ -257,6 +267,22 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse): return; } + if (url.pathname === '/api/requests/season' && method === 'POST') { + const config = await readConfig(session!.encryptionKey); + const body = await readJson(request); + await requestSeason(config, body); + sendJson(response, 200, { ok: true }); + return; + } + + if (url.pathname === '/api/seasons/delete' && method === 'POST') { + const config = await readConfig(session!.encryptionKey); + const body = await readJson(request); + await deleteSeasonFiles(config, body); + sendJson(response, 200, { ok: true }); + return; + } + if (url.pathname.startsWith('/api/assets/jellyfin/') && method === 'GET') { const config = await readConfig(session!.encryptionKey); const itemId = decodeURIComponent(url.pathname.replace('/api/assets/jellyfin/', '')); diff --git a/server/lib/services.ts b/server/lib/services.ts index 3d1c027..4aab42b 100644 --- a/server/lib/services.ts +++ b/server/lib/services.ts @@ -3,16 +3,18 @@ import type { ConnectionStatus, DashboardResponse, DownloadItem, + EpisodeWatchStateRequest, HubPageResponse, HubSection, MediaCard, + SeasonDeletePayload, + SeasonRequestPayload, SelectOption, ServiceOptionsResponse, TitleDetailsEpisode, TitleDetailsRequest, TitleDetailsResponse, - TitleDetailsSeason, - EpisodeWatchStateRequest + TitleDetailsSeason } from '../../shared/contracts.js'; const TMDB_IMAGE = 'https://image.tmdb.org/t/p'; @@ -48,6 +50,7 @@ interface JellyfinItem { interface SonarrQueueRecord { id?: number; + seriesId?: number; title?: string; downloadId?: string; series?: { title?: string; tmdbId?: number; images?: Array<{ coverType?: string; remoteUrl?: string; url?: string }> }; @@ -59,6 +62,18 @@ interface SonarrQueueRecord { timeleft?: string; } +interface SonarrCommandRecord { + id?: number; + name?: string; + commandName?: string; + status?: string; + state?: string; + body?: { + seriesId?: number; + seasonNumber?: number; + }; +} + interface RadarrQueueRecord { id?: number; title?: string; @@ -76,6 +91,59 @@ interface DownloadMetadataHint { subtitle?: string; } +interface SonarrSeriesRecord { + id?: number; + title?: string; + tmdbId?: number; + tvdbId?: number; +} + +interface SonarrEpisodeRecord { + id?: number; + seriesId?: number; + seasonNumber?: number; + episodeNumber?: number; + title?: string; + overview?: string; + airDateUtc?: string; + runtime?: number; + monitored?: boolean; + hasFile?: boolean; + episodeFileId?: number; +} + +interface SonarrEpisodeFileRecord { + id?: number; + seriesId?: number; + seasonNumber?: number; + relativePath?: string; + path?: string; + size?: number; +} + +interface TmdbSeasonSummary { + seasonNumber: number; + name: string; + episodeCount?: number; +} + +interface TmdbEpisodeRecord { + episode_number?: number; + name?: string; + overview?: string; + still_path?: string | null; + air_date?: string; + runtime?: number; +} + +interface SeriesSeasonDescriptor { + id: string; + title: string; + seasonNumber?: number; + episodeCount?: number; + jellyfinSeasonId?: string; +} + const ANIME_PROVIDERS = '337|8|9|430|283'; const DORAMA_PROVIDERS = '344|337|8|9|464'; @@ -373,6 +441,7 @@ export async function getTitleDetails( const item = card.providerId ? await getJellyfinItemDetails(config, card.providerId) : null; const seriesItem = await resolveSeriesItem(config, card, item); const primaryItem = seriesItem || item; + const isSeries = card.mediaType === 'series' || card.mediaType === 'tv' || seriesItem?.Type === 'Series' || item?.Type === 'Episode'; const detailMediaType: 'movie' | 'tv' = primaryItem?.Type === 'Movie' || card.mediaType === 'movie' ? 'movie' : 'tv'; const tmdbId = Number(primaryItem?.ProviderIds?.Tmdb) || card.tmdbId; const originalLanguage = card.originalLanguage; @@ -381,21 +450,21 @@ export async function getTitleDetails( tmdbId ? getPreferredTitleLogo(config, tmdbId, detailMediaType) : Promise.resolve('') ]); - const title = seriesItem?.Name || primaryItem?.Name || card.title; - const overview = seriesItem?.Overview || primaryItem?.Overview || card.overview; + const fallbackTitle = seriesItem?.Name || primaryItem?.Name || card.title; + const fallbackOverview = seriesItem?.Overview || primaryItem?.Overview || card.overview; const subtitle = buildTitleSubtitle(seriesItem || item, card); const posterUrl = resolvePrimaryPoster(seriesItem || item, card); const effectiveBackdropUrl = backdropUrl || resolveBackdrop(seriesItem || item, card); - if (!seriesItem?.Id) { + if (!isSeries) { return { id: card.id, tmdbId, providerId: item?.Id || card.providerId, playTargetId: item?.Id || card.providerId, availableInJellyfin: Boolean(item?.Id || card.providerId), - title, - overview, + title: fallbackTitle, + overview: fallbackOverview, subtitle, mediaType: 'movie', posterUrl, @@ -406,12 +475,36 @@ export async function getTitleDetails( }; } - const seasons = await getSeriesSeasons(config, seriesItem.Id); - const nextEpisode = await getNextEpisodeToPlay(config, seriesItem.Id); - const selectedSeason = chooseSelectedSeason(seasons, seasonId, item, nextEpisode); - const episodes = selectedSeason?.id - ? await getSeasonEpisodes(config, seriesItem.Id, selectedSeason.id) - : []; + const [tmdbSeries, jellyfinSeasons, nextEpisode] = await Promise.all([ + tmdbId ? getTmdbSeriesSummary(config, tmdbId) : Promise.resolve(null), + seriesItem?.Id ? getJellyfinSeriesSeasons(config, seriesItem.Id) : Promise.resolve([]), + seriesItem?.Id ? getNextEpisodeToPlay(config, seriesItem.Id) : Promise.resolve(null) + ]); + const title = seriesItem?.Name || String(tmdbSeries?.name || '') || primaryItem?.Name || card.title; + const overview = seriesItem?.Overview || String(tmdbSeries?.overview || '') || primaryItem?.Overview || card.overview; + const sonarrSeries = await getSonarrSeriesRecord( + config, + Number(asRecord(tmdbSeries?.external_ids).tvdb_id || 0), + tmdbId + ); + const sonarrContext = sonarrSeries?.id + ? await getSonarrSeriesContext(config, sonarrSeries.id) + : { episodes: [], queue: [], commands: [] }; + const seasonDescriptors = buildSeriesSeasonDescriptors(tmdbSeries, jellyfinSeasons); + const selectedSeason = chooseSelectedSeason(seasonDescriptors, seasonId, item, nextEpisode); + const episodeGroups = await Promise.all( + seasonDescriptors.map((season) => buildSeasonEpisodes( + config, + tmdbId, + season, + seriesItem?.Id, + Boolean(sonarrSeries?.id), + sonarrContext.episodes, + sonarrContext.queue + )) + ); + const episodes = episodeGroups.flat(); + const seasons = buildTitleSeasons(seasonDescriptors, episodes, sonarrContext.episodes, sonarrContext.queue, sonarrContext.commands); const resolvedNextEpisode = nextEpisode && episodes.some((episode) => episode.id === nextEpisode.id) ? episodes.find((episode) => episode.id === nextEpisode.id) || nextEpisode @@ -420,9 +513,10 @@ export async function getTitleDetails( return { id: card.id, tmdbId, - providerId: seriesItem.Id, - playTargetId: resolvedNextEpisode?.id || seriesItem.Id, - availableInJellyfin: Boolean(seriesItem.Id), + providerId: seriesItem?.Id || card.providerId, + sonarrSeriesId: sonarrSeries?.id, + playTargetId: resolvedNextEpisode?.id || seriesItem?.Id, + availableInJellyfin: Boolean(seriesItem?.Id), title, overview, subtitle, @@ -691,7 +785,14 @@ async function getPreferredTitleLogo( return selectedLogo?.file_path ? `${TMDB_IMAGE}/original${selectedLogo.file_path}` : ''; } -async function getSeriesSeasons(config: AppConfig, seriesId: string): Promise { +async function getTmdbSeriesSummary(config: AppConfig, tmdbId: number): Promise | null> { + return tmdbFetch( + config, + `/tv/${tmdbId}?append_to_response=external_ids&language=${encodeURIComponent(config.preferences.language)}` + ); +} + +async function getJellyfinSeriesSeasons(config: AppConfig, seriesId: string): Promise { try { const seasons = await jellyfinFetch( config, @@ -701,27 +802,24 @@ async function getSeriesSeasons(config: AppConfig, seriesId: string): Promise season.Id) .map((season) => ({ - id: String(season.Id), + id: buildSeasonId(typeof season.IndexNumber === 'number' ? season.IndexNumber : undefined, String(season.Id)), title: season.Name || (typeof season.IndexNumber === 'number' ? `Season ${season.IndexNumber}` : 'Season'), seasonNumber: typeof season.IndexNumber === 'number' ? season.IndexNumber : undefined, - episodeCount: typeof season.ChildCount === 'number' ? season.ChildCount : undefined + episodeCount: typeof season.ChildCount === 'number' ? season.ChildCount : undefined, + jellyfinSeasonId: String(season.Id) })) - .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; - }); + .sort(sortSeasonDescriptors); } catch { return []; } } function chooseSelectedSeason( - seasons: TitleDetailsSeason[], + seasons: SeriesSeasonDescriptor[], requestedSeasonId: string | undefined, item: JellyfinItem | null, nextEpisode?: TitleDetailsEpisode | null -): TitleDetailsSeason | undefined { +): SeriesSeasonDescriptor | undefined { if (requestedSeasonId) { const requestedSeason = seasons.find((season) => season.id === requestedSeasonId); if (requestedSeason) { @@ -746,7 +844,11 @@ function chooseSelectedSeason( return seasons.find((season) => season.seasonNumber && season.seasonNumber > 0) || seasons[0]; } -async function getSeasonEpisodes(config: AppConfig, seriesId: string, seasonId: string): Promise { +async function getJellyfinSeasonEpisodes( + config: AppConfig, + seriesId: string, + seasonId: string +): Promise { try { const episodes = await jellyfinFetch( config, @@ -762,6 +864,342 @@ async function getSeasonEpisodes(config: AppConfig, seriesId: string, seasonId: } } +async function getTmdbSeasonEpisodes( + config: AppConfig, + tmdbId: number, + seasonNumber: number +): Promise { + const response = await tmdbFetch( + config, + `/tv/${tmdbId}/season/${seasonNumber}?language=${encodeURIComponent(config.preferences.language)}` + ); + + return asArray(response?.episodes) + .map((entry) => asRecord(entry)) + .map((episode) => ({ + episode_number: Number(episode.episode_number || 0) || undefined, + name: typeof episode.name === 'string' ? episode.name : undefined, + overview: typeof episode.overview === 'string' ? episode.overview : undefined, + still_path: typeof episode.still_path === 'string' ? episode.still_path : episode.still_path === null ? null : undefined, + air_date: typeof episode.air_date === 'string' ? episode.air_date : undefined, + runtime: Number(episode.runtime || 0) || undefined + })); +} + +async function getSonarrSeriesRecord( + config: AppConfig, + tvdbId: number, + tmdbId?: number +): Promise { + if (!config.sonarr.baseUrl || !config.sonarr.apiKey || !tvdbId) { + return null; + } + + const response = await fetchJson( + `${config.sonarr.baseUrl}/api/v3/series?tvdbId=${encodeURIComponent(String(tvdbId))}`, + { + headers: { + 'X-Api-Key': config.sonarr.apiKey + } + } + ); + + return response.find((series) => series.id && (!tmdbId || !series.tmdbId || series.tmdbId === tmdbId)) || response[0] || null; +} + +async function getSonarrEpisodesBySeriesId(config: AppConfig, seriesId: number): Promise { + return fetchJson( + `${config.sonarr.baseUrl}/api/v3/episode?seriesId=${encodeURIComponent(String(seriesId))}`, + { + headers: { + 'X-Api-Key': config.sonarr.apiKey + } + } + ); +} + +async function getSonarrEpisodeFilesBySeriesId(config: AppConfig, seriesId: number): Promise { + return fetchJson( + `${config.sonarr.baseUrl}/api/v3/episodefile?seriesId=${encodeURIComponent(String(seriesId))}`, + { + headers: { + 'X-Api-Key': config.sonarr.apiKey + } + } + ); +} + +async function getSonarrSeriesContext( + config: AppConfig, + seriesId: number +): Promise<{ episodes: SonarrEpisodeRecord[]; queue: SonarrQueueRecord[]; commands: SonarrCommandRecord[] }> { + const [episodes, queue, commands] = await Promise.all([ + getSonarrEpisodesBySeriesId(config, seriesId), + getSonarrQueue(config), + getSonarrCommands(config) + ]); + + return { + episodes, + queue: queue.filter((entry) => entry.seriesId === seriesId), + commands: commands.filter((command) => command.body?.seriesId === seriesId) + }; +} + +async function getSonarrCommands(config: AppConfig): Promise { + if (!config.sonarr.baseUrl || !config.sonarr.apiKey) { + return []; + } + + try { + return await fetchJson( + `${config.sonarr.baseUrl}/api/v3/command`, + { + headers: { + 'X-Api-Key': config.sonarr.apiKey + } + } + ); + } catch { + return []; + } +} + +function buildSeriesSeasonDescriptors( + tmdbSeries: Record | null, + jellyfinSeasons: SeriesSeasonDescriptor[] +): SeriesSeasonDescriptor[] { + const bySeasonNumber = new Map( + jellyfinSeasons + .filter((season) => typeof season.seasonNumber === 'number') + .map((season) => [season.seasonNumber as number, season] as const) + ); + const tmdbSeasons = asArray(tmdbSeries?.seasons) + .map((entry) => asRecord(entry)) + .map((season) => ({ + seasonNumber: Number(season.season_number || 0), + name: typeof season.name === 'string' ? season.name : '', + episodeCount: Number(season.episode_count || 0) || undefined + } satisfies TmdbSeasonSummary)) + .filter((season) => Number.isFinite(season.seasonNumber)); + + if (tmdbSeasons.length) { + return tmdbSeasons + .filter((season) => { + const jellyfinSeason = bySeasonNumber.get(season.seasonNumber); + return (season.episodeCount ?? jellyfinSeason?.episodeCount ?? 0) > 0; + }) + .map((season) => { + const jellyfinSeason = bySeasonNumber.get(season.seasonNumber); + return { + id: buildSeasonId(season.seasonNumber), + title: season.name || `Season ${season.seasonNumber}`, + seasonNumber: season.seasonNumber, + episodeCount: season.episodeCount ?? jellyfinSeason?.episodeCount, + jellyfinSeasonId: jellyfinSeason?.jellyfinSeasonId + }; + }) + .sort(sortSeasonDescriptors); + } + + return jellyfinSeasons + .filter((season) => (season.episodeCount ?? 0) > 0) + .sort(sortSeasonDescriptors); +} + +async function buildSeasonEpisodes( + config: AppConfig, + tmdbId: number | undefined, + season: SeriesSeasonDescriptor, + jellyfinSeriesId: string | undefined, + isManagedInSonarr: boolean, + sonarrEpisodes: SonarrEpisodeRecord[], + sonarrQueue: SonarrQueueRecord[] +): Promise { + const seasonNumber = season.seasonNumber; + const [tmdbEpisodes, jellyfinEpisodes] = await Promise.all([ + tmdbId && typeof seasonNumber === 'number' + ? getTmdbSeasonEpisodes(config, tmdbId, seasonNumber) + : Promise.resolve([]), + jellyfinSeriesId && season.jellyfinSeasonId + ? getJellyfinSeasonEpisodes(config, jellyfinSeriesId, season.jellyfinSeasonId) + : Promise.resolve([]) + ]); + const tmdbByEpisodeNumber = new Map( + tmdbEpisodes + .filter((episode) => typeof episode.episode_number === 'number') + .map((episode) => [episode.episode_number as number, episode] as const) + ); + const jellyfinByEpisodeNumber = new Map( + jellyfinEpisodes + .filter((episode) => typeof episode.episodeNumber === 'number') + .map((episode) => [episode.episodeNumber as number, episode] as const) + ); + const sonarrByEpisodeNumber = new Map( + sonarrEpisodes + .filter((episode) => episode.seasonNumber === seasonNumber && typeof episode.episodeNumber === 'number') + .map((episode) => [episode.episodeNumber as number, episode] as const) + ); + const downloadingEpisodes = new Set( + sonarrQueue + .filter((entry) => entry.episode?.seasonNumber === seasonNumber && typeof entry.episode?.episodeNumber === 'number') + .map((entry) => entry.episode?.episodeNumber as number) + ); + const episodeNumbers = new Set([ + ...tmdbByEpisodeNumber.keys(), + ...jellyfinByEpisodeNumber.keys(), + ...sonarrByEpisodeNumber.keys() + ]); + + return [...episodeNumbers] + .sort((left, right) => left - right) + .map((episodeNumber) => { + const tmdbEpisode = tmdbByEpisodeNumber.get(episodeNumber); + const jellyfinEpisode = jellyfinByEpisodeNumber.get(episodeNumber); + const sonarrEpisode = sonarrByEpisodeNumber.get(episodeNumber); + const airDate = tmdbEpisode?.air_date || sonarrEpisode?.airDateUtc; + const isReleased = !airDate || new Date(airDate).getTime() <= Date.now(); + const hasFile = Boolean(sonarrEpisode?.hasFile || sonarrEpisode?.episodeFileId); + const isAvailableInJellyfin = Boolean(jellyfinEpisode?.id); + const isDownloading = downloadingEpisodes.has(episodeNumber); + const availability: TitleDetailsEpisode['availability'] = !isReleased + ? 'unaired' + : isAvailableInJellyfin || hasFile + ? 'available' + : isDownloading + ? 'downloading' + : 'missing'; + + return { + id: jellyfinEpisode?.id || buildEpisodeId(seasonNumber, episodeNumber), + seasonId: season.id, + title: jellyfinEpisode?.title || sonarrEpisode?.title || tmdbEpisode?.name || `Episode ${episodeNumber}`, + overview: jellyfinEpisode?.overview || sonarrEpisode?.overview || tmdbEpisode?.overview || '', + subtitle: [ + typeof seasonNumber === 'number' ? `Season ${seasonNumber}` : '', + `Episode ${episodeNumber}` + ].filter(Boolean).join(', '), + imageUrl: jellyfinEpisode?.imageUrl || (tmdbEpisode?.still_path ? `${TMDB_IMAGE}/w780${tmdbEpisode.still_path}` : ''), + airDate, + seasonNumber, + episodeNumber, + runtimeMinutes: jellyfinEpisode?.runtimeMinutes || tmdbEpisode?.runtime, + availableInJellyfin: isAvailableInJellyfin, + availability, + canDownload: isManagedInSonarr && availability === 'missing', + canDelete: hasFile, + watched: jellyfinEpisode?.watched, + progress: jellyfinEpisode?.progress, + playbackPositionTicks: jellyfinEpisode?.playbackPositionTicks + }; + }); +} + +function buildTitleSeasons( + seasons: SeriesSeasonDescriptor[], + allEpisodes: TitleDetailsEpisode[], + sonarrEpisodes: SonarrEpisodeRecord[], + sonarrQueue: SonarrQueueRecord[], + sonarrCommands: SonarrCommandRecord[] +): TitleDetailsSeason[] { + const seasonMap = new Map(); + + for (const season of seasons) { + const seasonQueue = sonarrQueue.filter((entry) => entry.episode?.seasonNumber === season.seasonNumber); + const availableEpisodeCount = sonarrEpisodes.filter( + (episode) => episode.seasonNumber === season.seasonNumber && (episode.hasFile || episode.episodeFileId) + ).length; + const isDownloading = seasonQueue.length > 0; + const isProcessing = sonarrCommands.some((command) => + command.body?.seasonNumber === season.seasonNumber && isActiveSeasonSearchCommand(command) + ); + seasonMap.set(season.seasonNumber ?? Number.MIN_SAFE_INTEGER, { + id: season.id, + title: season.title, + seasonNumber: season.seasonNumber, + episodeCount: season.episodeCount, + availableEpisodeCount, + missingEpisodeCount: typeof season.episodeCount === 'number' + ? Math.max(season.episodeCount - availableEpisodeCount, 0) + : undefined, + canDownload: false, + canDelete: availableEpisodeCount > 0, + isDownloading, + downloadStatus: isDownloading ? 'downloading' : isProcessing ? 'processing' : 'idle', + downloadProgress: isDownloading ? getSeasonQueueProgress(seasonQueue) : undefined, + downloadLabel: isDownloading ? 'Downloading' : isProcessing ? 'Processing' : undefined + }); + } + + for (const season of seasons) { + const seasonEpisodes = allEpisodes.filter((episode) => episode.seasonId === season.id); + if (!seasonEpisodes.length || typeof season.seasonNumber !== 'number') { + continue; + } + + const current = seasonMap.get(season.seasonNumber); + if (!current) { + continue; + } + + seasonMap.set(season.seasonNumber, { + ...current, + episodeCount: seasonEpisodes.length || current.episodeCount, + availableEpisodeCount: seasonEpisodes.filter((episode) => episode.availability === 'available').length, + missingEpisodeCount: seasonEpisodes.filter((episode) => episode.availability === 'missing').length, + canDownload: seasonEpisodes.some((episode) => episode.canDownload), + canDelete: seasonEpisodes.some((episode) => episode.canDelete), + isDownloading: current.isDownloading || seasonEpisodes.some((episode) => episode.availability === 'downloading') + }); + } + + return seasons + .map((season) => seasonMap.get(season.seasonNumber ?? Number.MIN_SAFE_INTEGER)) + .filter((season): season is TitleDetailsSeason => Boolean(season)); +} + +function buildSeasonId(seasonNumber?: number, fallback?: string): string { + return typeof seasonNumber === 'number' + ? `season:${seasonNumber}` + : `season:${fallback || 'unknown'}`; +} + +function buildEpisodeId(seasonNumber: number | undefined, episodeNumber: number): string { + return `episode:${typeof seasonNumber === 'number' ? seasonNumber : 'x'}:${episodeNumber}`; +} + +function sortSeasonDescriptors(left: { seasonNumber?: number }, right: { seasonNumber?: number }) { + const leftNumber = left.seasonNumber === 0 ? Number.MAX_SAFE_INTEGER : left.seasonNumber ?? Number.MAX_SAFE_INTEGER - 1; + const rightNumber = right.seasonNumber === 0 ? Number.MAX_SAFE_INTEGER : right.seasonNumber ?? Number.MAX_SAFE_INTEGER - 1; + return leftNumber - rightNumber; +} + +function isActiveSeasonSearchCommand(command: SonarrCommandRecord): boolean { + const name = String(command.name || command.commandName || '').toLowerCase(); + const status = String(command.status || command.state || '').toLowerCase(); + return name === 'seasonsearch' + && status !== 'completed' + && status !== 'failed' + && status !== 'aborted' + && status !== 'cancelled' + && status !== 'orphaned'; +} + +function getSeasonQueueProgress(queue: SonarrQueueRecord[]): number | undefined { + if (!queue.length) { + return undefined; + } + + const totalSize = queue.reduce((sum, item) => sum + Math.max(0, Number(item.size || 0)), 0); + const totalSizeLeft = queue.reduce((sum, item) => sum + Math.max(0, Number(item.sizeleft || 0)), 0); + + if (!totalSize) { + return undefined; + } + + return Math.max(0, Math.min(100, Math.round(((totalSize - totalSizeLeft) / totalSize) * 100))); +} + async function getNextEpisodeToPlay( config: AppConfig, seriesId: string @@ -825,6 +1263,8 @@ function mapJellyfinEpisode(episode: JellyfinItem): TitleDetailsEpisode { seasonNumber: episode.ParentIndexNumber, episodeNumber: episode.IndexNumber, runtimeMinutes: episode.RunTimeTicks ? Math.max(1, Math.round(episode.RunTimeTicks / 600000000)) : undefined, + availableInJellyfin: true, + availability: 'available', watched: Boolean(episode.UserData?.Played), progress: Math.max(0, Math.min(100, Math.round(episode.UserData?.PlayedPercentage || 0))) || undefined, playbackPositionTicks: episode.UserData?.PlaybackPositionTicks @@ -895,6 +1335,66 @@ export async function requestSeries(config: AppConfig, tmdbId: number): Promise< }); } +export async function requestSeason(config: AppConfig, payload: SeasonRequestPayload): Promise { + if (!config.sonarr.baseUrl || !config.sonarr.apiKey) { + throw new Error('Sonarr is not configured.'); + } + + if (!payload.seriesId || !payload.seasonNumber) { + throw new Error('Series id and season number are required.'); + } + + await fetchJson(`${config.sonarr.baseUrl}/api/v3/command`, { + method: 'POST', + headers: { + 'X-Api-Key': config.sonarr.apiKey, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + name: 'SeasonSearch', + seriesId: payload.seriesId, + seasonNumber: payload.seasonNumber + }) + }); +} + +export async function deleteSeasonFiles(config: AppConfig, payload: SeasonDeletePayload): Promise { + if (!config.sonarr.baseUrl || !config.sonarr.apiKey) { + throw new Error('Sonarr is not configured.'); + } + + if (!payload.seriesId || !payload.seasonNumber) { + throw new Error('Series id and season number are required.'); + } + + const [episodes, files] = await Promise.all([ + getSonarrEpisodesBySeriesId(config, payload.seriesId), + getSonarrEpisodeFilesBySeriesId(config, payload.seriesId) + ]); + const fileIds = new Set( + episodes + .filter((episode) => episode.seasonNumber === payload.seasonNumber && episode.episodeFileId) + .map((episode) => Number(episode.episodeFileId)) + ); + + for (const file of files) { + if (file.seasonNumber === payload.seasonNumber && file.id) { + fileIds.add(Number(file.id)); + } + } + + await Promise.all( + [...fileIds].map((fileId) => + fetchJson(`${config.sonarr.baseUrl}/api/v3/episodefile/${encodeURIComponent(String(fileId))}`, { + method: 'DELETE', + headers: { + 'X-Api-Key': config.sonarr.apiKey + } + }) + ) + ); +} + export async function setEpisodeWatchState(config: AppConfig, payload: EpisodeWatchStateRequest): Promise { if (!config.jellyfin.baseUrl || !config.jellyfin.apiKey || !config.jellyfin.userId) { throw new Error('Jellyfin is not configured.'); diff --git a/shared/contracts.ts b/shared/contracts.ts index 723a86f..14f946c 100644 --- a/shared/contracts.ts +++ b/shared/contracts.ts @@ -109,13 +109,19 @@ export interface MediaCard { export interface TitleDetailsEpisode { id: string; + seasonId?: string; title: string; overview: string; subtitle: string; imageUrl: string; + airDate?: string; seasonNumber?: number; episodeNumber?: number; runtimeMinutes?: number; + availableInJellyfin?: boolean; + availability?: 'available' | 'downloading' | 'missing' | 'unaired'; + canDownload?: boolean; + canDelete?: boolean; watched?: boolean; progress?: number; playbackPositionTicks?: number; @@ -126,12 +132,21 @@ export interface TitleDetailsSeason { title: string; seasonNumber?: number; episodeCount?: number; + availableEpisodeCount?: number; + missingEpisodeCount?: number; + canDownload?: boolean; + canDelete?: boolean; + isDownloading?: boolean; + downloadStatus?: 'idle' | 'processing' | 'downloading'; + downloadProgress?: number; + downloadLabel?: string; } export interface TitleDetailsResponse { id: string; tmdbId?: number; providerId?: string; + sonarrSeriesId?: number; playTargetId?: string; availableInJellyfin: boolean; title: string; @@ -183,6 +198,16 @@ export interface EpisodeWatchStateRequest { watched: boolean; } +export interface SeasonRequestPayload { + seriesId: number; + seasonNumber: number; +} + +export interface SeasonDeletePayload { + seriesId: number; + seasonNumber: number; +} + export interface HubSection { id: string; title: string; diff --git a/src/App.tsx b/src/App.tsx index 8da9541..5618b49 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,6 +20,7 @@ import { type MediaCard, type SelectOption, type ServiceOptionsResponse, + type TitleDetailsEpisode, type TitleDetailsResponse, } from '../shared/contracts' @@ -28,12 +29,9 @@ 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 } -type VisibleSeasonEpisode = TitleDetailsResponse['episodes'][number] & { seasonId: string } - -const SEASON_CACHE_RADIUS = 2 -const SEASON_PREFETCH_OFFSETS = [1, -1, 2, -2] as const -const SEASON_PRELOAD_THRESHOLD_RATIO = 0.45 const EPISODE_SCROLL_DISTANCE_RATIO = 0.72 +const SEASON_DOMINANCE_SWITCH_RATIO = 0.12 +const SEASON_DOMINANCE_SWITCH_MIN_PX = 56 const sidebarItems: Array<{ route: AppRoute; label: string; short: string }> = [ { route: 'home', label: 'Inicio', short: 'H' }, @@ -408,7 +406,7 @@ function App() { busy={busy} hub={currentHub} loading={hubLoading} - onOpenCard={(card, originRect) => setSelectedCard({ card, originRect })} + onOpenCard={(card, originRect) => setSelectedCard({ card, originRect: originRect ?? null })} onRequest={handleRequest} route={route} tmdbToken={config.tmdb.bearerToken} @@ -867,25 +865,11 @@ function resolveHeroSlideStyle(card: MediaCard | undefined) { return slideImage ? { backgroundImage: `url("${slideImage}")` } : undefined } -function getSeasonWindowIds(seasons: TitleDetailsResponse['seasons'], centerIndex: number, radius = SEASON_CACHE_RADIUS) { - return seasons - .slice(Math.max(0, centerIndex - radius), centerIndex + radius + 1) - .map((season) => season.id) -} - -function getVisibleSeasonEpisodes( - seasons: TitleDetailsResponse['seasons'], - centerIndex: number, - cache: Map -): VisibleSeasonEpisode[] { - return seasons - .filter((_, index) => Math.abs(index - centerIndex) <= SEASON_CACHE_RADIUS) - .flatMap((season) => - (cache.get(season.id)?.episodes || []).map((episode) => ({ - ...episode, - seasonId: season.id, - })) - ) +function getSeasonEpisodes( + episodes: TitleDetailsEpisode[], + seasonId: string +): TitleDetailsEpisode[] { + return episodes.filter((episode) => episode.seasonId === seasonId) } function findSeasonAnchorElement(list: HTMLDivElement, seasonId: string) { @@ -987,11 +971,9 @@ function TitleDetailsModal(props: { const [details, setDetails] = useState(null) const [detailsLoading, setDetailsLoading] = useState(true) const [detailsError, setDetailsError] = useState('') - const [requestedSeasonId, setRequestedSeasonId] = useState(undefined) const [activeSeasonId, setActiveSeasonId] = useState(undefined) - const [seasonCacheVersion, setSeasonCacheVersion] = useState(0) - const [loadingSeasonIds, setLoadingSeasonIds] = useState([]) const [episodeWatchBusyId, setEpisodeWatchBusyId] = useState(null) + const [seasonActionBusy, setSeasonActionBusy] = useState<'download' | 'delete' | null>(null) const [playerTarget, setPlayerTarget] = useState<{ id: string; title: string } | null>(null) const [isOverviewExpanded, setIsOverviewExpanded] = useState(false) const [episodeScrollState, setEpisodeScrollState] = useState({ canScrollLeft: false, canScrollRight: false }) @@ -1002,11 +984,10 @@ function TitleDetailsModal(props: { const closeTimeoutRef = useRef(null) const modalRef = useRef(null) const episodeListRef = useRef(null) - const seasonDetailsCacheRef = useRef(new Map()) - const seasonRequestsRef = useRef(new Map>()) const pendingSeasonScrollRef = useRef(null) - const previousRenderedSeasonIdsRef = useRef([]) const initialEpisodeFocusKeyRef = useRef('') + const hasUserScrolledEpisodeListRef = useRef(false) + const initialEpisodeListScrollLeftRef = useRef(0) useLayoutEffect(() => { if (!props.originRect || !modalRef.current) { @@ -1026,7 +1007,7 @@ function TitleDetailsModal(props: { ['--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}`, - }) + } as CSSProperties) let frameA = 0 let frameB = 0 @@ -1052,115 +1033,44 @@ function TitleDetailsModal(props: { setDetails(null) setDetailsLoading(true) setDetailsError('') - setRequestedSeasonId(undefined) setActiveSeasonId(undefined) - setSeasonCacheVersion(0) - setLoadingSeasonIds([]) setEpisodeWatchBusyId(null) + setSeasonActionBusy(null) setPlayerTarget(null) setIsOverviewExpanded(false) - seasonDetailsCacheRef.current.clear() - seasonRequestsRef.current.clear() pendingSeasonScrollRef.current = null - previousRenderedSeasonIdsRef.current = [] initialEpisodeFocusKeyRef.current = '' + hasUserScrolledEpisodeListRef.current = false + initialEpisodeListScrollLeftRef.current = 0 }, [props.card.id]) - function cacheSeasonDetails(nextDetails: TitleDetailsResponse, requestedSeason?: string) { - const resolvedSeasonId = nextDetails.selectedSeasonId || requestedSeason - if (requestedSeason) { - seasonDetailsCacheRef.current.set(requestedSeason, nextDetails) - } - if (resolvedSeasonId) { - seasonDetailsCacheRef.current.set(resolvedSeasonId, nextDetails) - } - setSeasonCacheVersion((current) => current + 1) - return resolvedSeasonId - } - - function ensureSeasonLoaded(seasonId: string) { - const cached = seasonDetailsCacheRef.current.get(seasonId) - if (cached) { - return Promise.resolve(cached) - } - - const existingRequest = seasonRequestsRef.current.get(seasonId) - if (existingRequest) { - return existingRequest - } - - setLoadingSeasonIds((current) => current.includes(seasonId) ? current : [...current, seasonId]) - const request = api.getTitleDetails(props.card, seasonId) - .then((nextDetails) => { - cacheSeasonDetails(nextDetails, seasonId) - return nextDetails - }) - .finally(() => { - seasonRequestsRef.current.delete(seasonId) - setLoadingSeasonIds((current) => current.filter((id) => id !== seasonId)) - }) - - seasonRequestsRef.current.set(seasonId, request) - return request - } - function updateEpisodeInCaches(episodeId: string, updater: (episode: NonNullable[number]) => NonNullable[number]) { - let changed = false - for (const [key, cachedDetails] of seasonDetailsCacheRef.current.entries()) { - if (!cachedDetails.episodes.some((episode) => episode.id === episodeId)) { - continue - } + setDetails((current) => current && current.episodes.some((episode) => episode.id === episodeId) + ? { + ...current, + episodes: current.episodes.map((episode) => episode.id === episodeId ? updater(episode) : episode) + } + : current) + } - seasonDetailsCacheRef.current.set(key, { - ...cachedDetails, - episodes: cachedDetails.episodes.map((episode) => episode.id === episodeId ? updater(episode) : episode) - }) - changed = true - } - - if (changed) { - setSeasonCacheVersion((current) => current + 1) - setDetails((current) => current && current.episodes.some((episode) => episode.id === episodeId) - ? { - ...current, - episodes: current.episodes.map((episode) => episode.id === episodeId ? updater(episode) : episode) - } - : current) - } + async function refreshSeasonState(targetSeasonId?: string) { + const refreshSeasonId = targetSeasonId || activeSeasonId || details?.selectedSeasonId + const nextDetails = await api.getTitleDetails(props.card, refreshSeasonId) + setDetails(nextDetails) + setDetailsError('') + setActiveSeasonId(refreshSeasonId || nextDetails.selectedSeasonId) } useEffect(() => { let cancelled = false - const cacheKey = requestedSeasonId || '__default__' - const cachedDetails = requestedSeasonId - ? seasonDetailsCacheRef.current.get(requestedSeasonId) - : seasonDetailsCacheRef.current.get(cacheKey) - - if (cachedDetails) { - setDetails(cachedDetails) - setDetailsLoading(false) - setDetailsError('') - const resolvedSeasonId = cachedDetails.selectedSeasonId || requestedSeasonId - if (resolvedSeasonId) { - setActiveSeasonId(resolvedSeasonId) - } - return () => { - cancelled = true - } - } - setDetailsLoading(true) setDetailsError('') - void api.getTitleDetails(props.card, requestedSeasonId) + void api.getTitleDetails(props.card) .then((nextDetails) => { if (cancelled) return - seasonDetailsCacheRef.current.set(cacheKey, nextDetails) - const resolvedSeasonId = cacheSeasonDetails(nextDetails, requestedSeasonId) - if (resolvedSeasonId) { - setActiveSeasonId(resolvedSeasonId) - } setDetails(nextDetails) + setActiveSeasonId(nextDetails.selectedSeasonId) }) .catch((error) => { if (cancelled) return @@ -1174,49 +1084,9 @@ function TitleDetailsModal(props: { return () => { cancelled = true } - }, [props.card, requestedSeasonId]) + }, [props.card]) - 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) - if (currentIndex < 0) { - return - } - - const keepSeasonIds = new Set(getSeasonWindowIds(details.seasons, currentIndex)) - - for (const seasonId of Array.from(seasonDetailsCacheRef.current.keys())) { - if (seasonId !== '__default__' && !keepSeasonIds.has(seasonId)) { - seasonDetailsCacheRef.current.delete(seasonId) - } - } - }, [details, seasonCacheVersion, seasonValue]) - - useEffect(() => { - if (!details || details.mediaType !== 'series' || !details.seasons.length) { - return - } - - const activeIndex = details.seasons.findIndex((season) => season.id === seasonValue) - if (activeIndex < 0) { - return - } - - const prioritySeasonIds = SEASON_PREFETCH_OFFSETS - .map((offset) => details.seasons[activeIndex + offset]?.id) - .filter(Boolean) as string[] - - for (const seasonId of prioritySeasonIds) { - if (!seasonDetailsCacheRef.current.has(seasonId)) { - void ensureSeasonLoaded(seasonId) - } - } - }, [details, seasonValue]) + const seasonValue = activeSeasonId ?? details?.selectedSeasonId ?? '' useEffect(() => { const list = episodeListRef.current @@ -1230,14 +1100,12 @@ function TitleDetailsModal(props: { return } - const listRect = list.getBoundingClientRect() - const targetRect = targetEpisode.getBoundingClientRect() list.scrollTo({ - left: list.scrollLeft + (targetRect.left - listRect.left), + left: Math.max(0, targetEpisode.offsetLeft - (list.clientWidth - targetEpisode.offsetWidth) / 2), behavior: 'smooth', }) pendingSeasonScrollRef.current = null - }, [details, seasonCacheVersion, seasonValue]) + }, [details, seasonValue]) useLayoutEffect(() => { const list = episodeListRef.current @@ -1255,46 +1123,9 @@ function TitleDetailsModal(props: { return } + initialEpisodeListScrollLeftRef.current = list.scrollLeft initialEpisodeFocusKeyRef.current = focusKey - }, [details?.playTargetId, props.card.id, seasonCacheVersion]) - - useLayoutEffect(() => { - const list = episodeListRef.current - if (!list || pendingSeasonScrollRef.current) { - return - } - - const previousRenderedSeasonIds = previousRenderedSeasonIdsRef.current - const currentRenderedSeasonIds = Array.from(new Set( - Array.from(list.querySelectorAll('[data-season-id]')) - .map((card) => card.dataset.seasonId) - .filter((seasonId): seasonId is string => Boolean(seasonId)) - )) - - if (!previousRenderedSeasonIds.length) { - previousRenderedSeasonIdsRef.current = currentRenderedSeasonIds - return - } - - const previousFirstSeasonId = previousRenderedSeasonIds[0] - const currentFirstSeasonId = currentRenderedSeasonIds[0] - - if (previousFirstSeasonId && currentFirstSeasonId && previousFirstSeasonId !== currentFirstSeasonId) { - if (currentRenderedSeasonIds.includes(previousFirstSeasonId)) { - const anchorCard = findSeasonAnchorElement(list, previousFirstSeasonId) - if (anchorCard) { - list.scrollLeft += anchorCard.offsetLeft - } - } else if (previousRenderedSeasonIds.includes(currentFirstSeasonId)) { - const currentFirstCard = findSeasonAnchorElement(list, currentFirstSeasonId) - if (currentFirstCard) { - list.scrollLeft = Math.max(0, list.scrollLeft - currentFirstCard.offsetLeft) - } - } - } - - previousRenderedSeasonIdsRef.current = currentRenderedSeasonIds - }, [details, seasonCacheVersion, seasonValue]) + }, [details?.playTargetId, props.card.id]) useEffect(() => { const list = episodeListRef.current @@ -1310,6 +1141,13 @@ function TitleDetailsModal(props: { canScrollRight: maxScrollLeft - list.scrollLeft > 8, }) + if ( + !hasUserScrolledEpisodeListRef.current + && Math.abs(list.scrollLeft - initialEpisodeListScrollLeftRef.current) > 32 + ) { + hasUserScrolledEpisodeListRef.current = true + } + const viewportLeft = list.scrollLeft const viewportRight = viewportLeft + list.clientWidth const coverageBySeason = new Map() @@ -1343,37 +1181,18 @@ function TitleDetailsModal(props: { } } - if (dominantSeasonId && dominantSeasonId !== activeSeasonId) { + const currentCoverage = coverageBySeason.get(activeSeasonId || seasonValue) || 0 + const coverageThreshold = Math.max(currentCoverage * SEASON_DOMINANCE_SWITCH_RATIO, SEASON_DOMINANCE_SWITCH_MIN_PX) + + if ( + hasUserScrolledEpisodeListRef.current + && !pendingSeasonScrollRef.current + && dominantSeasonId + && dominantSeasonId !== activeSeasonId + && dominantCoverage - currentCoverage > coverageThreshold + ) { setActiveSeasonId(dominantSeasonId) } - - const activeBounds = boundsBySeason.get(dominantSeasonId || seasonValue) - const activeIndex = details.seasons.findIndex((season) => season.id === (dominantSeasonId || seasonValue)) - if (!activeBounds || activeIndex < 0) { - return - } - - const preloadThreshold = list.clientWidth * SEASON_PRELOAD_THRESHOLD_RATIO - const previousSeasonId = details.seasons[activeIndex - 1]?.id - const nextSeasonId = details.seasons[activeIndex + 1]?.id - const nextNextSeasonId = details.seasons[activeIndex + 2]?.id - const previousPreviousSeasonId = details.seasons[activeIndex - 2]?.id - - if (previousSeasonId && viewportLeft - activeBounds.left < preloadThreshold && !seasonDetailsCacheRef.current.has(previousSeasonId)) { - void ensureSeasonLoaded(previousSeasonId) - } - - if (nextSeasonId && activeBounds.right - viewportRight < preloadThreshold && !seasonDetailsCacheRef.current.has(nextSeasonId)) { - void ensureSeasonLoaded(nextSeasonId) - } - - if (nextNextSeasonId && activeBounds.right - viewportRight < preloadThreshold * 0.5 && !seasonDetailsCacheRef.current.has(nextNextSeasonId)) { - void ensureSeasonLoaded(nextNextSeasonId) - } - - if (previousPreviousSeasonId && viewportLeft - activeBounds.left < preloadThreshold * 0.5 && !seasonDetailsCacheRef.current.has(previousPreviousSeasonId)) { - void ensureSeasonLoaded(previousPreviousSeasonId) - } } syncScrollState() @@ -1384,7 +1203,7 @@ function TitleDetailsModal(props: { list.removeEventListener('scroll', syncScrollState) window.removeEventListener('resize', syncScrollState) } - }, [activeSeasonId, details, detailsLoading, seasonCacheVersion, seasonValue]) + }, [activeSeasonId, details, detailsLoading, seasonValue]) const canRequest = Boolean(props.card.tmdbId && props.card.mediaType !== 'mixed') const playId = details?.playTargetId || details?.providerId || props.card.providerId @@ -1404,19 +1223,45 @@ function TitleDetailsModal(props: { const jellyfinPlayerUrl = playerTarget && props.config.jellyfin.baseUrl ? buildJellyfinPlayerUrl(props.config.jellyfin.baseUrl, playerTarget.id) : '' + const playLabel = playerTarget?.title || details?.episodes.find((episode) => episode.id === playId)?.title || displayTitle const activeSeasonMeta = details?.seasons.find((season) => season.id === seasonValue) - const activeSeasonDetails = seasonValue ? seasonDetailsCacheRef.current.get(seasonValue) : null + const activeSeasonDetails = details && seasonValue + ? { episodes: getSeasonEpisodes(details.episodes, seasonValue) } + : null + const activeSeasonNumber = activeSeasonMeta?.seasonNumber + const activeSeasonDownloadStatus = activeSeasonMeta?.downloadStatus || 'idle' 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 previousSeasonId = hasPreviousSeason ? details?.seasons[resolvedSeasonIndex - 1]?.id : undefined - const nextSeasonId = hasNextSeason ? details?.seasons[resolvedSeasonIndex + 1]?.id : undefined - const isPreviousSeasonLoading = Boolean(previousSeasonId && loadingSeasonIds.includes(previousSeasonId)) - const isNextSeasonLoading = Boolean(nextSeasonId && loadingSeasonIds.includes(nextSeasonId)) const visibleEpisodes = details - ? getVisibleSeasonEpisodes(details.seasons, resolvedSeasonIndex, seasonDetailsCacheRef.current) + ? details.episodes : [] + const canDownloadSeasonAction = Boolean( + activeSeasonDownloadStatus === 'processing' + || activeSeasonDownloadStatus === 'downloading' + || activeSeasonMeta?.canDownload + || (canRequest && !details?.sonarrSeriesId && typeof activeSeasonNumber === 'number') + ) + const canDeleteSeasonAction = Boolean(activeSeasonMeta?.canDelete) + + useEffect(() => { + if (!details || details.mediaType !== 'series') { + return + } + + if (activeSeasonDownloadStatus !== 'processing' && activeSeasonDownloadStatus !== 'downloading') { + return + } + + const timeoutId = window.setTimeout(() => { + void refreshSeasonState(seasonValue || undefined) + }, 3500) + + return () => { + window.clearTimeout(timeoutId) + } + }, [activeSeasonDownloadStatus, details, seasonValue]) function handleRequestClose() { if (!props.originRect || transitionState === 'closing') { @@ -1452,17 +1297,11 @@ function TitleDetailsModal(props: { } 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) - }) } function handleEpisodeWatchToggle( @@ -1498,23 +1337,56 @@ function TitleDetailsModal(props: { }) } - if (playerTarget && jellyfinPlayerUrl) { - return ( -
-
-
- - -
-