mirror of
https://github.com/WonderAliceUwU/OmniArr.git
synced 2026-11-11 04:01:01 +00:00
⭐️ 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:
1 parent
d7ff401d33
commit
739afbefe3
6 files changed
+171
-8
No files matched your search
@@ -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
@@ -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.');
|
||||
|
||||
@@ -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
@@ -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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user