2 Commits
Author SHA1 Message Date
WonderAliceUwU cda724e736 🔖 chore: release 1.2.1 2026-10-30 12:37:22 +01:00
WonderAliceUwU e0baa13ef9 🐛 fix: open downloads and upcoming titles with their jellyfin episodes
Downloads rows now open the title details, and cards without a Jellyfin id
are linked to the library by TMDb id. Also hide the empty profiles card on
the Jellyfin setup, fall back to the setup when there are no users, and drop
the back button when Jellyfin is unavailable.
2026-10-30 12:37:22 +01:00
5 changed files with 117 additions and 24 deletions

No files matched your search

+3 -3
View File
@@ -43,7 +43,7 @@ Create a directory for OmniArr and add this `docker-compose.yml`:
```yaml
services:
omniarr:
image: wonderalice/omniarr:1.2
image: wonderalice/omniarr:1.2.1
container_name: omniarr
restart: unless-stopped
ports:
@@ -90,11 +90,11 @@ the mount loses that state when the container is replaced.
The current release is available as:
```text
wonderalice/omniarr:1.2
wonderalice/omniarr:1.2.1
wonderalice/omniarr:latest
```
Pin `1.2` in production for repeatable updates. Use `latest` only when you want
Pin `1.2.1` in production for repeatable updates. Use `latest` only when you want
Compose to follow the newest published image.
## Build locally
+1 -1
View File
@@ -1,6 +1,6 @@
services:
omniarr:
image: wonderalice/omniarr:1.2
image: wonderalice/omniarr:1.2.1
# Para construir desde código fuente en su lugar, descomenta estas líneas:
# build:
# context: .
+36 -2
View File
@@ -751,8 +751,23 @@ export async function getCalendar(config: AppConfig, start: Date, end: Date): Pr
? radarrResult.value.map((item) => mapRadarrCalendarEntry(item))
: []
// Link each entry to its Jellyfin item, as search does, so the details can
// tell which episodes are already in the library.
const library = await getLibrarySnapshot(config).catch(() => null);
const libraryProviderIds = new Map<string, string>();
for (const libraryItem of library?.allItems ?? []) {
const libraryCard = jellyfinToCard(libraryItem, libraryItem.Name || 'Library');
if (libraryCard?.tmdbId && libraryCard.providerId && (libraryCard.mediaType === 'movie' || libraryCard.mediaType === 'series')) {
libraryProviderIds.set(`${libraryCard.mediaType}:${libraryCard.tmdbId}`, libraryCard.providerId);
}
}
const allItems = [...sonarrItems, ...radarrItems]
.filter((item): item is CalendarEntry => Boolean(item));
.filter((item): item is CalendarEntry => Boolean(item))
.map((item) => {
const providerId = item.card.tmdbId ? libraryProviderIds.get(`${item.card.mediaType}:${item.card.tmdbId}`) : undefined;
return providerId ? { ...item, card: { ...item.card, providerId } } : item;
});
const backdropCache = new Map<string, Promise<TmdbBackdropChoice>>();
const cards = await applyHighResBackdropsToCards(config, allItems.map(i => i.card), shouldUseHighResBackdropForCard, backdropCache, 'en');
@@ -968,11 +983,30 @@ async function removeSonarrSeriesIfEmpty(
}
}
async function withLibraryProviderId(config: AppConfig, card: MediaCard): Promise<MediaCard> {
if (!card.tmdbId || (card.mediaType !== 'movie' && card.mediaType !== 'series' && card.mediaType !== 'tv')) {
return card;
}
const library = await getLibrarySnapshot(config).catch(() => null);
const wantedType = card.mediaType === 'movie' ? 'movie' : 'series';
for (const libraryItem of library?.allItems ?? []) {
const libraryCard = jellyfinToCard(libraryItem, libraryItem.Name || 'Library');
if (libraryCard?.tmdbId === card.tmdbId && libraryCard.mediaType === wantedType) {
return { ...card, providerId: libraryCard.providerId };
}
}
return card;
}
export async function getTitleDetails(
config: AppConfig,
request: TitleDetailsRequest
): Promise<TitleDetailsResponse> {
const { card, seasonId } = request;
const { seasonId } = request;
// Cards that don't come from Jellyfin (downloads, upcoming) are linked to
// their library item by TMDb id.
const card = request.card.providerId ? request.card : await withLibraryProviderId(config, request.card);
const item = card.providerId ? await getJellyfinItemDetails(config, card.providerId) : null;
const seriesItem = await resolveSeriesItem(config, card, item);
const primaryItem = seriesItem || item;
+67 -18
View File
@@ -594,7 +594,7 @@ function App() {
/>
) : null}
{route === 'calendar' && isRouteAvailable(route, connections) ? <CalendarPage onOpenCard={(card, originRect) => setSelectedCard({ card, originRect: originRect ?? null })} /> : null}
{route === 'downloads' && isRouteAvailable(route, connections) ? <DownloadsPage busyId={downloadDeleteId} completedDownloads={completedDownloads} downloads={downloads} onDelete={handleDeleteDownload} onDismissAllCompleted={handleDismissAllCompleted} onDismissCompleted={handleDismissCompleted} processingItems={processingItems} /> : null}
{route === 'downloads' && isRouteAvailable(route, connections) ? <DownloadsPage busyId={downloadDeleteId} completedDownloads={completedDownloads} downloads={downloads} onDelete={handleDeleteDownload} onDismissAllCompleted={handleDismissAllCompleted} onDismissCompleted={handleDismissCompleted} onOpenCard={(card, originRect) => setSelectedCard({ card, originRect: originRect ?? null })} processingItems={processingItems} /> : null}
{route === 'search' ? (
<SearchPage
loading={searchLoading}
@@ -1032,17 +1032,19 @@ function JellyfinOnboardingScreen(props: {
</section>
<section className="auth-visual">
<div className="auth-wallpaper" />
<div className="glass-card">
<p className="eyebrow">Profiles</p>
<div className="profile-grid compact">
{props.users.map((user) => (
<div className="profile-card static" key={user.id}>
<ProfileAvatar user={user} />
<strong>{user.label}</strong>
</div>
))}
{props.users.length ? (
<div className="glass-card">
<p className="eyebrow">Profiles</p>
<div className="profile-grid compact">
{props.users.map((user) => (
<div className="profile-card static" key={user.id}>
<ProfileAvatar user={user} />
<strong>{user.label}</strong>
</div>
))}
</div>
</div>
</div>
) : null}
</section>
</div>
)
@@ -1123,6 +1125,11 @@ function ProfileSelectionScreen(props: {
</button>
))}
</div>}
{!selectedUser && !props.users.length ? (
<div className="jellyfin-unavailable">
<p>No Jellyfin users found. An administrator has to check the Jellyfin connection.</p>
</div>
) : null}
{!selectedUser ? <div className="profiles-actions">
<button aria-label="Administration" className="profile-admin-button" onClick={props.onAdmin} title="Administration" type="button">
<span className="profile-admin-icon">
@@ -3193,6 +3200,7 @@ function DownloadsPage(props: {
onDelete: (id: string) => Promise<void>
onDismissCompleted: (id: string) => void
onDismissAllCompleted: () => void
onOpenCard: (card: MediaCard, originRect?: ModalOriginRect | null) => void
}) {
const hasAny = props.processingItems.length > 0 || props.downloads.length > 0 || props.completedDownloads.length > 0
const [selectionMode, setSelectionMode] = useState(false)
@@ -3264,7 +3272,7 @@ function DownloadsPage(props: {
<div className="downloads-grid">
{props.processingItems.map((item) => (
<article className="download-row download-row-processing" key={item.id}>
<div className="download-card">
<div {...downloadCardProps(item, selectionMode, props.onOpenCard)}>
<div className="download-artwork">
{item.posterUrl || item.backdropUrl
? (
@@ -3315,6 +3323,7 @@ function DownloadsPage(props: {
item={item}
key={item.id}
onDelete={props.onDelete}
onOpenCard={props.onOpenCard}
onToggleSelection={() => toggleSelection(item.id, 'download')}
selected={selectedDownloadIds.includes(item.id)}
selectionMode={selectionMode}
@@ -3355,7 +3364,7 @@ function DownloadsPage(props: {
×
</button>
)}
<div className="download-card">
<div {...downloadCardProps(item, selectionMode, props.onOpenCard)}>
<div className="download-artwork">
{item.posterUrl || item.backdropUrl
? (
@@ -3409,6 +3418,43 @@ function DownloadsPage(props: {
)
}
type DownloadCardSource = Pick<DownloadItem, 'title' | 'subtitle' | 'posterUrl' | 'backdropUrl' | 'tmdbId' | 'mediaType'> & { id: string }
// Opens the title behind a download; without a TMDb id there is nothing to open.
function downloadCardProps(
item: DownloadCardSource,
selectionMode: boolean,
onOpenCard: (card: MediaCard, originRect?: ModalOriginRect | null) => void
) {
if (!item.tmdbId || !item.mediaType || selectionMode) {
return { className: 'download-card' }
}
const card: MediaCard = {
id: `download-${item.id}`,
tmdbId: item.tmdbId,
title: item.title,
subtitle: item.subtitle || '',
overview: '',
mediaType: item.mediaType === 'movie' ? 'movie' : 'series',
posterUrl: item.posterUrl || '',
backdropUrl: item.backdropUrl || item.posterUrl || '',
}
return {
className: 'download-card is-openable',
role: 'button',
tabIndex: 0,
onClick: (event: React.MouseEvent<HTMLElement>) => onOpenCard(card, captureOriginRect(event.currentTarget)),
onKeyDown: (event: React.KeyboardEvent<HTMLElement>) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onOpenCard(card, captureOriginRect(event.currentTarget))
}
},
}
}
function DownloadRow(props: {
item: DownloadItem
busy: boolean
@@ -3416,6 +3462,7 @@ function DownloadRow(props: {
selectionMode: boolean
selected: boolean
onToggleSelection: () => void
onOpenCard: (card: MediaCard, originRect?: ModalOriginRect | null) => void
}) {
const item = props.item
const progressMeta = [item.size, item.speed, item.eta].filter(Boolean).join(' · ')
@@ -3442,7 +3489,7 @@ function DownloadRow(props: {
{props.selected ? '✓' : null}
</button>
) : null}
<div className="download-card">
<div {...downloadCardProps(item, props.selectionMode, props.onOpenCard)}>
<div className="download-artwork">
{artwork ? (
<picture>
@@ -3911,9 +3958,11 @@ function AdministrationScreen(props: {
return (
<div className="administration-layout">
<header className="administration-header">
<button aria-label="Back to profiles" className="administration-back-button" onClick={props.onExit} title="Back to profiles" type="button">
<ArrowLeftGlyph />
</button>
{isJellyfinUnavailable(props.connections) ? <span /> : (
<button aria-label="Back to profiles" className="administration-back-button" onClick={props.onExit} title="Back to profiles" type="button">
<ArrowLeftGlyph />
</button>
)}
<div>
<p className="eyebrow">Administration</p>
<h1>Settings</h1>
@@ -4144,7 +4193,7 @@ function resolveEntryStage(config: AppConfig, options: ServiceOptionsResponse):
return 'onboarding'
}
if (!options.jellyfinUsers.length) {
return 'profiles'
return 'onboarding'
}
if (!config.jellyfin.userId.trim()) {
return 'profiles'
+10
View File
@@ -5940,3 +5940,13 @@ img {
font-size: 1rem;
}
}
.download-card.is-openable {
cursor: pointer;
}
.download-card.is-openable:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.6);
outline-offset: 4px;
border-radius: 1rem;
}