feature: add per chapter download

This commit is contained in:
WonderAliceUwU committed 2026-07-08 12:16:27 +02:00
1 parent ef886644a6
commit 6f4689aa0c
6 files changed
+278 -35

No files matched your search

+10
View File
@@ -5,6 +5,7 @@ import {
defaultConfig,
type AppConfig,
type AuthPayload,
type EpisodeRequestPayload,
type EpisodeWatchStateRequest,
type SeasonDeletePayload,
type SeasonRequestPayload,
@@ -24,6 +25,7 @@ import {
loadJellyfinUsers,
proxyJellyfinImage,
removeDownload,
requestEpisode,
requestMovie,
requestSeason,
requestSeries,
@@ -275,6 +277,14 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse):
return;
}
if (url.pathname === '/api/requests/episode' && method === 'POST') {
const config = await readConfig(session!.encryptionKey);
const body = await readJson<EpisodeRequestPayload>(request);
await requestEpisode(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<SeasonDeletePayload>(request);
+61 -2
View File
@@ -3,6 +3,7 @@ import type {
ConnectionStatus,
DashboardResponse,
DownloadItem,
EpisodeRequestPayload,
EpisodeWatchStateRequest,
HubPageResponse,
HubSection,
@@ -1111,11 +1112,13 @@ async function buildSeasonEpisodes(
const tmdbEpisode = tmdbByEpisodeNumber.get(episodeNumber);
const jellyfinEpisode = jellyfinByEpisodeNumber.get(episodeNumber);
const sonarrEpisode = sonarrByEpisodeNumber.get(episodeNumber);
const queueEntry = sonarrQueue.find((entry) => entry.episode?.seasonNumber === seasonNumber && entry.episode?.episodeNumber === 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 isProcessing = Boolean(sonarrEpisode?.id) && !isDownloading && Boolean(sonarrEpisode?.monitored) && availabilityWouldBeMissing(isReleased, isAvailableInJellyfin, hasFile);
const availability: TitleDetailsEpisode['availability'] = !isReleased
? 'unaired'
: isAvailableInJellyfin || hasFile
@@ -1123,9 +1126,15 @@ async function buildSeasonEpisodes(
: isDownloading
? 'downloading'
: 'missing';
const downloadStatus: TitleDetailsEpisode['downloadStatus'] = isDownloading
? 'downloading'
: isProcessing
? 'processing'
: 'idle';
return {
id: jellyfinEpisode?.id || buildEpisodeId(seasonNumber, episodeNumber),
sonarrEpisodeId: sonarrEpisode?.id,
seasonId: season.id,
title: jellyfinEpisode?.title || sonarrEpisode?.title || tmdbEpisode?.name || `Episode ${episodeNumber}`,
overview: jellyfinEpisode?.overview || sonarrEpisode?.overview || tmdbEpisode?.overview || '',
@@ -1140,6 +1149,8 @@ async function buildSeasonEpisodes(
runtimeMinutes: jellyfinEpisode?.runtimeMinutes || tmdbEpisode?.runtime,
availableInJellyfin: isAvailableInJellyfin,
availability,
downloadStatus,
downloadProgress: isDownloading ? getEpisodeQueueProgress(queueEntry) : undefined,
canDownload: isManagedInSonarr && availability === 'missing',
canDelete: hasFile,
watched: jellyfinEpisode?.watched,
@@ -1149,6 +1160,29 @@ async function buildSeasonEpisodes(
});
}
function availabilityWouldBeMissing(
isReleased: boolean,
isAvailableInJellyfin: boolean,
hasFile: boolean
) {
return isReleased && !isAvailableInJellyfin && !hasFile;
}
function getEpisodeQueueProgress(entry: SonarrQueueRecord | undefined): number | undefined {
if (!entry) {
return undefined;
}
const total = Number(entry.size || 0);
const left = Number(entry.sizeleft || 0);
if (!total || total <= 0) {
return undefined;
}
const percent = Math.round(((total - left) / total) * 100);
return Math.max(0, Math.min(100, percent));
}
function buildTitleSeasons(
seasons: SeriesSeasonDescriptor[],
allEpisodes: TitleDetailsEpisode[],
@@ -1177,7 +1211,9 @@ function buildTitleSeasons(
? Math.max(season.episodeCount - availableEpisodeCount, 0)
: undefined,
canDownload: false,
canDelete: availableEpisodeCount > 0,
canDelete: typeof season.episodeCount === 'number'
? availableEpisodeCount >= season.episodeCount
: availableEpisodeCount > 0,
isDownloading,
downloadStatus: isDownloading ? 'downloading' : isProcessing ? 'processing' : 'idle',
downloadProgress: isDownloading ? getSeasonQueueProgress(seasonQueue) : undefined,
@@ -1202,7 +1238,7 @@ function buildTitleSeasons(
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),
canDelete: seasonEpisodes.length > 0 && seasonEpisodes.every((episode) => episode.canDelete),
isDownloading: current.isDownloading || seasonEpisodes.some((episode) => episode.availability === 'downloading')
});
}
@@ -1412,6 +1448,28 @@ export async function requestSeason(config: AppConfig, payload: SeasonRequestPay
});
}
export async function requestEpisode(config: AppConfig, payload: EpisodeRequestPayload): Promise<void> {
if (!config.sonarr.baseUrl || !config.sonarr.apiKey) {
throw new Error('Sonarr is not configured.');
}
if (!payload.episodeId) {
throw new Error('Episode id is 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: 'EpisodeSearch',
episodeIds: [payload.episodeId]
})
});
}
export async function deleteSeasonFiles(config: AppConfig, payload: SeasonDeletePayload): Promise<void> {
if (!config.sonarr.baseUrl || !config.sonarr.apiKey) {
throw new Error('Sonarr is not configured.');
@@ -2392,6 +2450,7 @@ async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
}
}
function jellyfinToCard(
item: JellyfinItem,
fallbackTitle: string,
+7
View File
@@ -109,6 +109,7 @@ export interface MediaCard {
export interface TitleDetailsEpisode {
id: string;
sonarrEpisodeId?: number;
seasonId?: string;
title: string;
overview: string;
@@ -120,6 +121,8 @@ export interface TitleDetailsEpisode {
runtimeMinutes?: number;
availableInJellyfin?: boolean;
availability?: 'available' | 'downloading' | 'missing' | 'unaired';
downloadStatus?: 'idle' | 'processing' | 'downloading';
downloadProgress?: number;
canDownload?: boolean;
canDelete?: boolean;
watched?: boolean;
@@ -211,6 +214,10 @@ export interface SeasonRequestPayload {
seasonNumber: number;
}
export interface EpisodeRequestPayload {
episodeId: number;
}
export interface SeasonDeletePayload {
seriesId: number;
seasonNumber: number;
+109 -18
View File
@@ -574,7 +574,6 @@ function AuthScreen({
<div className={isSetup ? 'auth-layout' : 'auth-layout auth-layout-login'}>
<section className={isSetup ? 'auth-panel' : 'auth-panel auth-panel-login'}>
<div className="auth-panel-shell">
<p className="eyebrow">{isSetup ? 'First boot' : 'Secure access'}</p>
<h1>{isSetup ? 'Create your master password' : 'Sign in to OmniArr'}</h1>
<p className="auth-copy">
{isSetup
@@ -1048,6 +1047,7 @@ function TitleDetailsModal(props: {
const [detailsError, setDetailsError] = useState('')
const [activeSeasonId, setActiveSeasonId] = useState<string | undefined>(undefined)
const [episodeWatchBusyId, setEpisodeWatchBusyId] = useState<string | null>(null)
const [episodeDownloadBusyId, setEpisodeDownloadBusyId] = useState<string | null>(null)
const [seasonActionBusy, setSeasonActionBusy] = useState<'download' | 'delete' | null>(null)
const [playerTarget, setPlayerTarget] = useState<{ id: string; title: string } | null>(null)
const [isOverviewExpanded, setIsOverviewExpanded] = useState(false)
@@ -1110,6 +1110,7 @@ function TitleDetailsModal(props: {
setDetailsError('')
setActiveSeasonId(undefined)
setEpisodeWatchBusyId(null)
setEpisodeDownloadBusyId(null)
setSeasonActionBusy(null)
setPlayerTarget(null)
setIsOverviewExpanded(false)
@@ -1340,6 +1341,28 @@ function TitleDetailsModal(props: {
}
}, [activeSeasonDownloadStatus, details, seasonValue])
useEffect(() => {
if (!details || details.mediaType !== 'series') {
return
}
const hasEpisodeTransfers = details.episodes.some((episode) =>
episode.downloadStatus === 'processing' || episode.downloadStatus === 'downloading'
)
if (!hasEpisodeTransfers) {
return
}
const timeoutId = window.setTimeout(() => {
void refreshSeasonState(seasonValue || undefined)
}, 2500)
return () => {
window.clearTimeout(timeoutId)
}
}, [details, seasonValue])
function handleRequestClose() {
if (!props.originRect || transitionState === 'closing') {
props.onClose()
@@ -1466,6 +1489,42 @@ function TitleDetailsModal(props: {
}
}
function handleEpisodeDownload(
event: MouseEvent<HTMLButtonElement>,
episodeId: string,
sonarrEpisodeId: number | undefined,
availability: TitleDetailsResponse['episodes'][number]['availability']
) {
event.preventDefault()
event.stopPropagation()
if (!sonarrEpisodeId || availability === 'downloading' || episodeDownloadBusyId === episodeId) {
return
}
setEpisodeDownloadBusyId(episodeId)
updateEpisodeInCaches(episodeId, (episode) => ({
...episode,
downloadStatus: 'processing',
downloadProgress: undefined,
canDownload: false,
}))
void api.requestEpisode({ episodeId: sonarrEpisodeId })
.catch(() => {
updateEpisodeInCaches(episodeId, (episode) => ({
...episode,
availability,
downloadStatus: 'idle',
downloadProgress: undefined,
canDownload: availability === 'missing',
}))
})
.finally(() => {
setEpisodeDownloadBusyId((current) => current === episodeId ? null : current)
})
}
return (
<div className={transitionState === 'closing' ? 'modal-backdrop is-closing' : 'modal-backdrop'} onClick={handleRequestClose} role="presentation">
<div
@@ -1606,16 +1665,17 @@ function TitleDetailsModal(props: {
activeSeasonDownloadStatus === 'downloading' ? (
<div
aria-label="Season downloading"
className="title-season-progress"
role="img"
title={activeSeasonMeta?.downloadLabel || 'Downloading'}
>
<div
className="title-season-progress-bar"
style={{ width: `${activeSeasonMeta?.downloadProgress || 0}%` }}
/>
</div>
) : activeSeasonDownloadStatus === 'processing' ? (
className="title-season-progress"
role="img"
title={activeSeasonMeta?.downloadLabel || 'Downloading'}
>
<span
className="title-season-progress-ring"
style={{ ['--season-download-progress' as '--season-download-progress']: `${activeSeasonMeta?.downloadProgress || 0}` }}
/>
<span className="title-season-progress-percent">{`${activeSeasonMeta?.downloadProgress || 0}%`}</span>
</div>
) : activeSeasonDownloadStatus === 'processing' ? (
<div
aria-label="Season processing"
className="title-season-processing"
@@ -1735,15 +1795,45 @@ function TitleDetailsModal(props: {
<div className="episode-card-title-row">
<strong>{episode.title}</strong>
<div className="episode-card-state-icons">
{episode.canDownload || episode.availability === 'downloading' ? (
{episode.canDownload || episode.downloadStatus === 'processing' || episode.downloadStatus === 'downloading' ? (
episode.downloadStatus === 'downloading' ? (
<span
aria-label={episode.availability === 'downloading' ? 'Episode downloading' : 'Episode available to download'}
className={episode.availability === 'downloading' ? 'episode-card-availability is-downloading' : 'episode-card-availability'}
title={episode.availability === 'downloading' ? 'Downloading' : 'Available to download'}
aria-label="Episode downloading"
className="episode-card-availability-progress"
title={`${episode.downloadProgress || 0}% downloaded`}
>
<span
className="episode-card-availability-ring"
style={{ ['--episode-download-progress' as '--episode-download-progress']: `${episode.downloadProgress || 0}` }}
/>
<span className="episode-card-availability-percent">{`${episode.downloadProgress || 0}%`}</span>
</span>
) : episode.downloadStatus === 'processing' ? (
<span
aria-label="Episode processing"
className="episode-card-availability-processing"
title="Processing"
>
<span className="episode-card-processing-spinner" aria-hidden="true" />
</span>
) : (
<button
aria-label="Download episode"
className="episode-card-availability episode-card-availability-button"
disabled={episodeDownloadBusyId === episode.id}
onClick={(event) => handleEpisodeDownload(
event,
episode.id,
episode.sonarrEpisodeId,
episode.availability
)}
title="Available to download"
type="button"
>
<DownloadGlyph />
</span>
) : null}
</button>
)
) : null}
{episode.availableInJellyfin ? (
<button
aria-label={episode.watched ? 'Mark episode as unwatched' : 'Mark episode as watched'}
@@ -1971,7 +2061,7 @@ function DownloadRow(props: {
</div>
<button
aria-label={`Delete ${item.title}`}
className="download-delete-button"
className="download-action-button download-delete-button"
disabled={props.busy}
onClick={confirmDelete}
type="button"
@@ -1987,6 +2077,7 @@ function DownloadRow(props: {
function humanizeDownloadState(state: string) {
return state
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/[_-]+/g, ' ')
.replace(/\b\w/g, (match) => match.toUpperCase())
}
+6
View File
@@ -9,6 +9,7 @@ import type {
ServiceOptionsResponse,
SessionResponse,
EpisodeWatchStateRequest,
EpisodeRequestPayload,
SeasonDeletePayload,
SeasonRequestPayload,
TitleDetailsRequest,
@@ -112,6 +113,11 @@ export const api = {
method: 'POST',
body: JSON.stringify(payload),
}),
requestEpisode: (payload: EpisodeRequestPayload) =>
request<{ ok: boolean }>('/api/requests/episode', {
method: 'POST',
body: JSON.stringify(payload),
}),
deleteSeason: (payload: SeasonDeletePayload) =>
request<{ ok: boolean }>('/api/seasons/delete', {
method: 'POST',
+85 -15
View File
@@ -1783,25 +1783,26 @@ img {
}
.title-season-progress {
width: 2.5rem;
gap: 0.48rem;
min-width: 4.1rem;
}
.title-season-progress::before {
content: "";
position: absolute;
left: 0;
right: 0;
height: 0.28rem;
.title-season-progress-ring {
--season-download-progress: 0;
width: 1.7rem;
height: 1.7rem;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
background:
radial-gradient(circle at center, #090b10 48%, transparent 50%),
conic-gradient(#ffffff calc(var(--season-download-progress) * 1%), rgba(255, 255, 255, 0.14) 0);
}
.title-season-progress-bar {
position: absolute;
left: 0;
height: 0.28rem;
border-radius: 999px;
background: #e50914;
.title-season-progress-percent {
color: #eef4ff;
font-size: 0.74rem;
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
}
.episode-dock {
@@ -2092,6 +2093,12 @@ img {
opacity: 0.88;
}
.episode-card-availability-button {
padding: 0;
border: 0;
background: transparent;
}
.episode-card-availability .title-action-icon {
width: 0.98rem;
height: 0.98rem;
@@ -2101,6 +2108,58 @@ img {
animation: episodeCardAvailabilityPulse 900ms ease-in-out infinite alternate;
}
.episode-card-availability-processing {
display: inline-grid;
place-items: center;
width: 1.55rem;
height: 1.55rem;
}
.episode-card-processing-spinner {
width: 1rem;
height: 1rem;
border: 2px solid rgba(255, 255, 255, 0.18);
border-top-color: rgba(255, 255, 255, 0.88);
border-radius: 999px;
animation: episodeDockSpinner 720ms linear infinite;
}
.episode-card-availability-progress {
position: relative;
display: inline-flex;
align-items: center;
min-width: 0;
color: rgba(244, 247, 255, 0.9);
}
.episode-card-availability-ring {
--episode-download-progress: 0;
width: 1.2rem;
height: 1.2rem;
border-radius: 999px;
background:
radial-gradient(circle at center, #090b10 46%, transparent 48%),
conic-gradient(#ffffff calc(var(--episode-download-progress) * 1%), rgba(255, 255, 255, 0.14) 0);
}
.episode-card-availability-percent {
position: absolute;
left: 50%;
top: calc(100% + 0.28rem);
transform: translateX(-50%);
opacity: 0;
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
transition: opacity 160ms ease;
pointer-events: none;
}
.episode-card-availability-progress:hover .episode-card-availability-percent {
opacity: 1;
}
.episode-card-watch-toggle svg {
width: 1rem;
height: 1rem;
@@ -2419,13 +2478,24 @@ img {
gap: 0.8rem;
}
.download-delete-button {
.download-action-button {
width: 1.9rem;
height: 1.9rem;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.07);
color: #dbe2ee;
display: inline-grid;
place-items: center;
padding: 0;
}
.download-action-button .title-action-icon {
width: 0.92rem;
height: 0.92rem;
}
.download-delete-button {
font-size: 1.1rem;
line-height: 1;
text-indent: 0.06rem;