⭐️ feat: download a single episode of a series that is not in sonarr yet

- add the series with nothing monitored and search only that episode
- make the wishlist heart, play, delete and request plain icons split by dividers
This commit is contained in:
WonderAliceUwU committed 2026-10-30 13:01:16 +01:00
1 parent d7ff401d33
commit 739afbefe3
6 files changed
+171 -8

No files matched your search

+10
View File
@@ -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<SeriesEpisodeRequestPayload & { user: string | null }>(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<SeasonDeletePayload>(request);
+67 -1
View File
@@ -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<void> {
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<SonarrSeriesRecord[]>(
`${config.sonarr.baseUrl}/api/v3/series/lookup?term=${encodeURIComponent(`tvdb:${tvdbId}`)}`,
{ headers }
);
const series = await fetchJson<SonarrSeriesRecord>(`${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<Array<{ id?: number; seasonNumber?: number; episodeNumber?: number }>>(
`${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<void> {
if (!config.sonarr.baseUrl || !config.sonarr.apiKey) {
throw new Error('Sonarr is not configured.');
+9
View File
@@ -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;
+30 -7
View File
@@ -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}
</p>
<div className="title-details-summary-header">
<WishlistHeart card={props.card} className="title-action-button title-action-button-delete title-wishlist-button" />
{details?.mediaType !== 'series' ? (
<WishlistHeart card={props.card} className="title-season-action title-wishlist-button" divider="after" />
) : null}
{canPlay && details?.mediaType !== 'series' ? (
<ActionButton className="title-summary-header-play" onClick={() => setPlayerTarget({ id: playId!, title: playLabel })} variant="play">
<PlayGlyph />
<span>Play</span>
</ActionButton>
) : null}
{canDeleteMovieAction ? <div className="title-season-divider title-season-action-divider" aria-hidden="true" /> : null}
{canDeleteMovieAction ? (
<ActionButton
aria-label="Delete movie files"
@@ -2819,8 +2836,9 @@ function TitleDetailsModal(props: {
<strong>{activeSeasonDetails?.episodes.length ?? activeSeasonMeta?.episodeCount ?? details.episodes.length}</strong>
<span>{(activeSeasonDetails?.episodes.length ?? activeSeasonMeta?.episodeCount ?? details.episodes.length) === 1 ? 'episode' : 'episodes'}</span>
</div>
{shouldShowDownloadSeasonAction || canDeleteSeasonAction ? (
<>
<div className="title-season-actions">
<WishlistHeart card={props.card} className="title-season-action title-wishlist-button" divider="before" />
{shouldShowDownloadSeasonAction || activeSeasonMeta?.isDownloading ? <div className="title-season-divider title-season-action-divider" aria-hidden="true" /> : null}
{shouldShowDownloadSeasonAction || activeSeasonMeta?.isDownloading ? (
activeSeasonDownloadStatus === 'downloading' ? (
@@ -2886,7 +2904,7 @@ function TitleDetailsModal(props: {
</>
) : null}
</div>
) : null}
</>
<div className="title-season-strip-spacer" />
{canPlay ? (
<ActionButton
@@ -3171,14 +3189,17 @@ function HeartGlyph({ filled, className = 'sidebar-glyph-icon' }: { filled?: boo
)
}
function WishlistHeart({ card, className }: { card: MediaCard; className: string }) {
function WishlistHeart({ card, className, divider }: { card: MediaCard; className: string; divider?: 'before' | 'after' }) {
const wishlist = useContext(WishlistContext)
if (!wishlist || !canWishlist(card)) {
return null
}
const wishlisted = wishlist.isWishlisted(card)
const separator = <div className="title-season-divider title-season-action-divider" aria-hidden="true" />
return (
<>
{divider === 'before' ? separator : null}
<button
aria-label={wishlisted ? `Remove ${card.title} from wishlist` : `Add ${card.title} to wishlist`}
aria-pressed={wishlisted}
@@ -3192,6 +3213,8 @@ function WishlistHeart({ card, className }: { card: MediaCard; className: string
>
<HeartGlyph className="title-action-icon" filled={wishlisted} />
</button>
{divider === 'after' ? separator : null}
</>
)
}
+6
View File
@@ -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',
+49
View File
@@ -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;