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 ?
: null}
{canDeleteMovieAction ? (
{activeSeasonDetails?.episodes.length ?? activeSeasonMeta?.episodeCount ?? details.episodes.length}
{(activeSeasonDetails?.episodes.length ?? activeSeasonMeta?.episodeCount ?? details.episodes.length) === 1 ? 'episode' : 'episodes'}
- {shouldShowDownloadSeasonAction || canDeleteSeasonAction ? (
+ <>
+
{shouldShowDownloadSeasonAction || activeSeasonMeta?.isDownloading ?
: null}
{shouldShowDownloadSeasonAction || activeSeasonMeta?.isDownloading ? (
activeSeasonDownloadStatus === 'downloading' ? (
@@ -2886,7 +2904,7 @@ function TitleDetailsModal(props: {
>
) : null}
- ) : null}
+ >
{canPlay ? (
return (
+ <>
+ {divider === 'before' ? separator : null}
+ {divider === 'after' ? separator : null}
+ >
)
}
diff --git a/src/api.ts b/src/api.ts
index 5fd8b04..7af35fe 100644
--- a/src/api.ts
+++ b/src/api.ts
@@ -18,6 +18,7 @@ import type {
EpisodeWatchStateRequest,
EpisodeRequestPayload,
SeasonDeletePayload,
+ SeriesEpisodeRequestPayload,
SeasonRequestPayload,
TitleDetailsRequest,
TitleDetailsResponse,
@@ -157,6 +158,11 @@ export const api = {
method: 'POST',
body: JSON.stringify({ ...payload, user }),
}),
+ requestSeriesEpisode: (payload: SeriesEpisodeRequestPayload, user: string | null) =>
+ request<{ ok: boolean }>('/api/requests/series-episode', {
+ method: 'POST',
+ body: JSON.stringify({ ...payload, user }),
+ }),
deleteSeason: (payload: SeasonDeletePayload) =>
request<{ ok: boolean }>('/api/seasons/delete', {
method: 'POST',
diff --git a/src/index.css b/src/index.css
index 0de40f3..810b736 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1857,6 +1857,55 @@ img {
gap: 0.6rem;
}
+/* Movie header: every control is a plain icon (heart, play, delete, request)
+ like the ones in a series' season bar, centred on one line and split by
+ dividers instead of sitting in boxes. */
+.title-details-summary-header > .title-season-action,
+.title-details-summary-header > .title-season-divider,
+.title-details-summary-header .title-summary-header-play,
+.title-details-summary-header .title-summary-header-delete,
+.title-details-summary-header > .secondary-button {
+ align-self: center;
+}
+
+.title-details-summary-header .title-summary-header-play,
+.title-details-summary-header .title-summary-header-delete,
+.title-details-summary-header > .secondary-button {
+ min-width: 1.9rem;
+ width: auto;
+ min-height: 1.9rem;
+ height: auto;
+ margin-top: 0;
+ padding: 0.18rem 0.4rem;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ color: rgba(237, 242, 255, 0.82);
+ font-weight: 600;
+ transition: color 180ms ease, transform 180ms ease, opacity 180ms ease;
+}
+
+.title-details-summary-header .title-summary-header-delete {
+ padding: 0.18rem;
+}
+
+.title-details-summary-header .title-summary-header-play:hover,
+.title-details-summary-header .title-summary-header-play:focus-visible,
+.title-details-summary-header .title-summary-header-delete:hover,
+.title-details-summary-header .title-summary-header-delete:focus-visible,
+.title-details-summary-header > .secondary-button:hover,
+.title-details-summary-header > .secondary-button:focus-visible {
+ border: 0;
+ background: transparent;
+ color: #ffffff;
+ transform: translateY(-1px);
+}
+
+.title-details-summary-header .title-action-icon {
+ width: 1.2rem;
+ height: 1.2rem;
+}
+
.title-movie-request-processing,
.title-movie-request-progress {
display: inline-flex;