From 739afbefe35cfdd7d7b52146fae16f07a2d65f7e Mon Sep 17 00:00:00 2001 From: WonderAliceUwU Date: Fri, 30 Oct 2026 12:50:51 +0100 Subject: [PATCH] =?UTF-8?q?=E2=AD=90=EF=B8=8F=20feat:=20download=20a=20sin?= =?UTF-8?q?gle=20episode=20of=20a=20series=20that=20is=20not=20in=20sonarr?= =?UTF-8?q?=20yet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add the series with nothing monitored and search only that episode - make the wishlist heart, play, delete and request plain icons split by dividers --- server/index.ts | 10 +++++++ server/lib/services.ts | 68 +++++++++++++++++++++++++++++++++++++++++- shared/contracts.ts | 9 ++++++ src/App.tsx | 37 ++++++++++++++++++----- src/api.ts | 6 ++++ src/index.css | 49 ++++++++++++++++++++++++++++++ 6 files changed, 171 insertions(+), 8 deletions(-) diff --git a/server/index.ts b/server/index.ts index eaa9c5c..3a74fc7 100644 --- a/server/index.ts +++ b/server/index.ts @@ -11,6 +11,7 @@ import { type WishlistEntry, type WishlistTogglePayload, type EpisodeRequestPayload, + type SeriesEpisodeRequestPayload, type EpisodeWatchStateRequest, type DiskSpaceStatus, type HubPageResponse, @@ -50,6 +51,7 @@ import { proxyJellyfinImage, removeDownload, requestEpisode, + requestSeriesEpisode, requestMovie, requestSeason, requestSeries, @@ -507,6 +509,14 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse): return; } + if (url.pathname === '/api/requests/series-episode' && method === 'POST') { + const config = await readConfig(session!.encryptionKey); + const body = await readJson(request); + await requestSeriesEpisode(config, body, body.user); + 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); diff --git a/server/lib/services.ts b/server/lib/services.ts index d15824e..a4006ff 100644 --- a/server/lib/services.ts +++ b/server/lib/services.ts @@ -17,6 +17,7 @@ import type { ProcessingItem, SeasonDeletePayload, SeasonRequestPayload, + SeriesEpisodeRequestPayload, SelectOption, ServiceOptionsResponse, TitleDetailsEpisode, @@ -2442,7 +2443,11 @@ async function buildSeasonEpisodes( availability, downloadStatus, downloadProgress: isDownloading ? getEpisodeQueueProgress(queueEntry) : undefined, - canDownload: isManagedInSonarr && availability === 'missing', + // A series that is not in Sonarr yet is added by the request itself, + // unless Jellyfin already has the episode. + canDownload: Boolean(config.sonarr.baseUrl && config.sonarr.apiKey) + && (isManagedInSonarr || (Boolean(tmdbId) && !jellyfinKnowsAboutIt)) + && availability === 'missing', canDelete: hasFile, watched: jellyfinEpisode?.watched, progress: jellyfinEpisode?.progress, @@ -2973,6 +2978,67 @@ export async function requestEpisode(config: AppConfig, payload: EpisodeRequestP }); } +export async function requestSeriesEpisode(config: AppConfig, payload: SeriesEpisodeRequestPayload, user: string | null): Promise { + if (!config.sonarr.baseUrl || !config.sonarr.apiKey) { + throw new Error('Sonarr is not configured.'); + } + + const headers = { 'X-Api-Key': config.sonarr.apiKey }; + const details = await tmdbFetch( + config, + `/tv/${payload.tmdbId}?append_to_response=external_ids&language=${encodeURIComponent(config.preferences.language)}` + ); + if (!details) { + throw new Error('Failed to load the series from TMDb.'); + } + const tvdbId = Number(asRecord(details?.external_ids).tvdb_id || 0); + if (!tvdbId) { + throw new Error('This series does not have a TVDB ID for Sonarr.'); + } + + // Nothing is monitored on add, so Sonarr searches for nothing but the + // episode that is monitored and searched below. + const sonarrRequestConfig = await resolveSonarrRequestConfig(config); + const lookup = await fetchJson( + `${config.sonarr.baseUrl}/api/v3/series/lookup?term=${encodeURIComponent(`tvdb:${tvdbId}`)}`, + { headers } + ); + const series = await fetchJson(`${config.sonarr.baseUrl}/api/v3/series`, { + method: 'POST', + headers: { ...headers, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: details.name || details.original_name || 'Series', + tvdbId, + qualityProfileId: payload.qualityProfileId || sonarrRequestConfig.qualityProfileId, + languageProfileId: sonarrRequestConfig.languageProfileId, + rootFolderPath: sonarrRequestConfig.rootFolderPath, + seasonFolder: true, + monitored: true, + seasons: (lookup[0]?.seasons || []).map((season) => ({ seasonNumber: season.seasonNumber, monitored: false })), + addOptions: { monitor: 'none', searchForMissingEpisodes: false, searchForCutoffUnmetEpisodes: false } + }) + }); + if (!series.id) { + throw new Error('Sonarr did not return the added series.'); + } + + // Sonarr lists the episodes only after its first scan of the new series. + for (let attempt = 0; attempt < 20; attempt += 1) { + const episodes = await fetchJson>( + `${config.sonarr.baseUrl}/api/v3/episode?seriesId=${series.id}`, + { headers } + ); + const episode = episodes.find((entry) => entry.seasonNumber === payload.seasonNumber && entry.episodeNumber === payload.episodeNumber); + if (episode?.id) { + await requestEpisode(config, { episodeId: episode.id, qualityProfileId: payload.qualityProfileId }, user); + return; + } + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + + throw new Error('Sonarr added the series but has not listed its episodes yet. Try the episode again in a moment.'); +} + export async function deleteSeasonFiles(config: AppConfig, payload: SeasonDeletePayload): Promise { if (!config.sonarr.baseUrl || !config.sonarr.apiKey) { throw new Error('Sonarr is not configured.'); diff --git a/shared/contracts.ts b/shared/contracts.ts index 5031fa5..dac574a 100644 --- a/shared/contracts.ts +++ b/shared/contracts.ts @@ -304,6 +304,15 @@ export interface EpisodeRequestPayload { qualityProfileId?: number; } +// An episode of a series that is not in Sonarr yet: it is added with nothing +// monitored but this episode. +export interface SeriesEpisodeRequestPayload { + tmdbId: number; + seasonNumber: number; + episodeNumber: number; + qualityProfileId?: number; +} + export interface SeasonDeletePayload { seriesId: number; seasonNumber: number; diff --git a/src/App.tsx b/src/App.tsx index bb4b15c..f5e6072 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2559,7 +2559,11 @@ function TitleDetailsModal(props: { event.preventDefault() event.stopPropagation() - if (!sonarrEpisodeId || availability === 'downloading' || episodeDownloadBusyId === episodeId) { + if (availability === 'downloading' || episodeDownloadBusyId === episodeId) { + return + } + // Without a Sonarr id the series is not in Sonarr yet; it is added by the request. + if (!sonarrEpisodeId && !props.card.tmdbId) { return } @@ -2572,7 +2576,7 @@ function TitleDetailsModal(props: { function queueEpisodeDownload( episodeId: string, - sonarrEpisodeId: number, + sonarrEpisodeId: number | undefined, availability: TitleDetailsResponse['episodes'][number]['availability'], qualityProfileId?: number ) { @@ -2585,7 +2589,17 @@ function TitleDetailsModal(props: { canDownload: false, })) - void api.requestEpisode({ episodeId: sonarrEpisodeId, qualityProfileId }, props.currentUser) + const episode = details?.episodes.find((entry) => entry.id === episodeId) + const request = sonarrEpisodeId + ? api.requestEpisode({ episodeId: sonarrEpisodeId, qualityProfileId }, props.currentUser) + : api.requestSeriesEpisode({ + tmdbId: props.card.tmdbId!, + seasonNumber: episode?.seasonNumber ?? 0, + episodeNumber: episode?.episodeNumber ?? 0, + qualityProfileId, + }, props.currentUser) + void request + .then(() => refreshSeasonState(seasonValue || undefined)) .catch(() => { setPendingEpisodeRequestIds((current) => current.filter((id) => id !== episodeId)) updateEpisodeInCaches(episodeId, (episode) => ({ @@ -2701,13 +2715,16 @@ function TitleDetailsModal(props: { ) : null}

- + {details?.mediaType !== 'series' ? ( + + ) : null} {canPlay && details?.mediaType !== 'series' ? ( setPlayerTarget({ id: playId!, title: playLabel })} variant="play"> Play ) : null} + {canDeleteMovieAction ? - {shouldShowDownloadSeasonAction || canDeleteSeasonAction ? ( + <>
+ {shouldShowDownloadSeasonAction || activeSeasonMeta?.isDownloading ? - ) : null} +
{canPlay ? (