7 Commits
Author SHA1 Message Date
WonderAliceUwU fb07b27675 🔖 chore: release 1.2.3 2026-11-01 12:17:41 +01:00
WonderAliceUwU 9f1d50b35d 🐛 fix: show the wishlist heart on series opened from continue watching, hide card hearts on mobile and revalidate the app shell 2026-11-01 12:17:41 +01:00
WonderAliceUwU 3c7836e18b 🔖 chore: release 1.2.2 2026-10-30 13:01:16 +01:00
WonderAliceUwU 739afbefe3 ⭐️ 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
2026-10-30 13:01:16 +01:00
WonderAliceUwU d7ff401d33 🐛 fix: keep the master password and save jellyfin from onboarding
- only a missing store counts as a fresh install; read errors no longer reopen the password setup
- read aliceflix.secure.json when omniarr.secure.json does not exist yet
- save the settings when loading the jellyfin users in onboarding
- show a short message instead of the proxy's html on 502, 503 and 504
- keep a wishlist per profile and add admin backup export and import
2026-10-30 12:37:22 +01:00
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
9 changed files with 894 additions and 70 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.3
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.3
wonderalice/omniarr:latest
```
Pin `1.2` in production for repeatable updates. Use `latest` only when you want
Pin `1.2.3` 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.3
# Para construir desde código fuente en su lugar, descomenta estas líneas:
# build:
# context: .
+167 -9
View File
@@ -6,7 +6,12 @@ import {
defaultConfig,
type AppConfig,
type AuthPayload,
type BackupFile,
type MediaCard,
type WishlistEntry,
type WishlistTogglePayload,
type EpisodeRequestPayload,
type SeriesEpisodeRequestPayload,
type EpisodeWatchStateRequest,
type DiskSpaceStatus,
type HubPageResponse,
@@ -17,7 +22,18 @@ import {
type TitleDetailsRequest
} from '../shared/contracts.js';
import { decryptJson, deriveEncryptionKey, encryptJson, randomToken, verifyPassword, createPasswordRecord } from './lib/security.js';
import { loadState, migrateConfig, readAdmin, readEncryptedConfig, saveAdminAndConfig, updateEncryptedConfig } from './lib/store.js';
import {
loadState,
migrateConfig,
readAdmin,
readAllWishlists,
readEncryptedConfig,
readWishlist,
restoreBackup,
saveAdminAndConfig,
updateEncryptedConfig,
updateWishlist
} from './lib/store.js';
import {
getCalendar,
getConnectionStatuses,
@@ -35,6 +51,7 @@ import {
proxyJellyfinImage,
removeDownload,
requestEpisode,
requestSeriesEpisode,
requestMovie,
requestSeason,
requestSeries,
@@ -252,6 +269,72 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse):
return;
}
if (url.pathname.startsWith('/api/admin/') && session?.role !== 'admin') {
sendJson(response, 403, { error: 'Administrator access is required.' });
return;
}
if (url.pathname === '/api/admin/backup' && method === 'GET') {
const backup: BackupFile = {
app: 'omniarr',
format: 1,
exportedAt: new Date().toISOString(),
config: await readConfig(),
wishlists: await readAllWishlists()
};
const fileName = `omniarr-backup-${backup.exportedAt.slice(0, 10)}.json`;
response.writeHead(200, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Disposition': `attachment; filename="${fileName}"`,
'Cache-Control': 'no-store'
});
response.end(JSON.stringify(backup, null, 2));
return;
}
if (url.pathname === '/api/admin/backup' && method === 'POST') {
let body: Partial<BackupFile>;
let config: AppConfig;
try {
body = await readJson<Partial<BackupFile>>(request);
if (body.app !== 'omniarr' || body.format !== 1 || !body.config || typeof body.config !== 'object') {
throw new Error('invalid');
}
config = mergeStoredConfig(body.config);
} catch {
sendJson(response, 400, { error: 'This file is not an OmniArr backup.' });
return;
}
config = { ...config, jellyfin: { ...config.jellyfin, userId: '' } };
await restoreBackup(
encryptJson(config, await getConfigEncryptionKey()),
sanitizeWishlists(body.wishlists)
);
sendJson(response, 200, { config, connections: await getConnectionStatuses(config) });
return;
}
if (url.pathname === '/api/wishlist' && method === 'GET') {
sendJson(response, 200, { items: await readWishlist(getWishlistOwner(session!)) });
return;
}
if (url.pathname === '/api/wishlist' && method === 'POST') {
const body = await readJson<WishlistTogglePayload>(request);
const card = sanitizeWishlistCard(body.card);
if (!card) {
sendJson(response, 400, { error: 'A title is required.' });
return;
}
const key = getWishlistKey(card);
const items = await updateWishlist(getWishlistOwner(session!), (entries) => {
const remaining = entries.filter((entry) => getWishlistKey(entry.card) !== key);
return body.wishlisted ? [{ card, addedAt: new Date().toISOString() }, ...remaining] : remaining;
});
sendJson(response, 200, { items });
return;
}
if (url.pathname === '/api/settings' && method === 'GET') {
const config = await readConfig(session!.encryptionKey);
sendJson(response, 200, session!.role === 'admin' ? config : redactConfig(config));
@@ -426,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);
@@ -580,9 +671,17 @@ async function readConfig(legacyEncryptionKey?: Buffer): Promise<AppConfig> {
throw new Error('The configuration must be unlocked by an administrator.');
}
const stored = decryptJson<Partial<AppConfig>>(encrypted, encryptionKey);
const config = mergeStoredConfig(decryptJson<Partial<AppConfig>>(encrypted, encryptionKey));
// The Jellyfin user always comes from the profile the session signed in
// with; admin sessions have none, so a userId saved by older versions is ignored.
const userId = legacyEncryptionKey ? sessionUsers.get(legacyEncryptionKey) : undefined;
return { ...config, jellyfin: { ...config.jellyfin, userId: userId || '' } };
}
const config = sanitizeConfig({
// Fills in whatever a stored config (or an imported backup) leaves out with
// the defaults, so configs saved by older versions keep loading.
function mergeStoredConfig(stored: Partial<AppConfig>): AppConfig {
return sanitizeConfig({
preferences: {
...defaultConfig.preferences,
...(stored.preferences || {})
@@ -608,10 +707,66 @@ async function readConfig(legacyEncryptionKey?: Buffer): Promise<AppConfig> {
...(stored.tvdb || {})
}
});
// The Jellyfin user always comes from the profile the session signed in
// with; admin sessions have none, so a userId saved by older versions is ignored.
const userId = legacyEncryptionKey ? sessionUsers.get(legacyEncryptionKey) : undefined;
return { ...config, jellyfin: { ...config.jellyfin, userId: userId || '' } };
}
// Profiles keep their own wishlist; the administrator has no Jellyfin profile
// and shares a single one.
function getWishlistOwner(session: SessionRecord): string {
return session.role === 'user' && session.userId ? session.userId : 'admin';
}
// The same title can reach the client as a Jellyfin card or a TMDb card, so
// titles are matched by their TMDb id when they have one.
function getWishlistKey(card: MediaCard): string {
return card.tmdbId ? `${card.mediaType === 'movie' ? 'movie' : 'tv'}:${card.tmdbId}` : card.id;
}
const WISHLIST_MEDIA_TYPES = new Set<MediaCard['mediaType']>(['movie', 'tv', 'series']);
// Only what the wishlist page needs to draw a tile and open the title is kept;
// progress and labels belong to the moment the card was saved.
function sanitizeWishlistCard(input: unknown): MediaCard | null {
if (!input || typeof input !== 'object') {
return null;
}
const card = input as Partial<MediaCard>;
const text = (value: unknown) => (typeof value === 'string' ? value : '');
const id = text(card.id);
if (!id || !card.mediaType || !WISHLIST_MEDIA_TYPES.has(card.mediaType)) {
return null;
}
return {
id,
tmdbId: Number(card.tmdbId) || undefined,
providerId: text(card.providerId) || undefined,
title: text(card.title),
subtitle: text(card.subtitle),
overview: text(card.overview),
mediaType: card.mediaType,
posterUrl: text(card.posterUrl),
backdropUrl: text(card.backdropUrl),
logoUrl: text(card.logoUrl) || undefined,
backdropLanguageFallback: card.backdropLanguageFallback ? true : undefined,
releaseDate: text(card.releaseDate) || undefined,
firstAirDate: text(card.firstAirDate) || undefined
};
}
function sanitizeWishlists(input: unknown): Record<string, WishlistEntry[]> {
const wishlists: Record<string, WishlistEntry[]> = {};
if (!input || typeof input !== 'object') {
return wishlists;
}
for (const [owner, entries] of Object.entries(input)) {
if (!Array.isArray(entries)) {
continue;
}
wishlists[owner] = entries.flatMap((entry: Partial<WishlistEntry>) => {
const card = sanitizeWishlistCard(entry?.card);
return card ? [{ card, addedAt: typeof entry.addedAt === 'string' ? entry.addedAt : new Date().toISOString() }] : [];
});
}
return wishlists;
}
async function getConfigEncryptionKey(): Promise<Buffer> {
@@ -662,14 +817,17 @@ async function serveApp(pathname: string, response: ServerResponse): Promise<voi
try {
const content = await readFile(filePath);
response.writeHead(200, {
'Content-Type': contentType(filePath)
'Content-Type': contentType(filePath),
// Hashed bundles never change; the entry page must always be revalidated so a new release reaches every browser.
'Cache-Control': relativePath.startsWith('/assets/') ? 'public, max-age=31536000, immutable' : 'no-cache'
});
response.end(content);
} catch {
try {
const indexHtml = await readFile(resolve(process.cwd(), 'dist', 'index.html'));
response.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8'
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-cache'
});
response.end(indexHtml);
} catch {
+110 -3
View File
@@ -17,6 +17,7 @@ import type {
ProcessingItem,
SeasonDeletePayload,
SeasonRequestPayload,
SeriesEpisodeRequestPayload,
SelectOption,
ServiceOptionsResponse,
TitleDetailsEpisode,
@@ -751,8 +752,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 +984,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;
@@ -2408,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,
@@ -2939,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.');
@@ -4014,6 +4114,13 @@ async function fetchJson<T>(url: string, init?: RequestInit): Promise<T> {
}
}
// Proxies answer with a whole HTML page; it says nothing the status does not.
if (/^\s*<(!doctype|html)/i.test(detail)) detail = '';
if ([502, 503, 504].includes(response.status)) {
throw new Error(`${response.status} ${response.statusText}: the service did not answer. Check that it is running and that the URL points to it.`);
}
throw new Error(detail
? `${response.status} ${response.statusText}: ${detail}`
: `${response.status} ${response.statusText}`);
+82 -21
View File
@@ -1,5 +1,7 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import type { WishlistEntry } from '../../shared/contracts.js';
interface StoredAdmin {
passwordHash: string;
passwordSalt: string;
@@ -17,37 +19,75 @@ interface StoredState {
admin: StoredAdmin | null;
config: EncryptedConfig | null;
configKey: string | null;
/** Each profile's wishlist, keyed by its Jellyfin user id ('admin' for the administrator). */
wishlists: Record<string, WishlistEntry[]>;
}
const DATA_DIR = resolve(process.env.OMNIARR_DATA_DIR || resolve(process.cwd(), 'data'));
const STORE_FILE = resolve(DATA_DIR, 'omniarr.secure.json');
// The store used to be called aliceflix.secure.json; it is still read when the
// new file does not exist yet, so a rename never looks like a fresh install.
const LEGACY_STORE_FILE = resolve(DATA_DIR, 'aliceflix.secure.json');
const EMPTY_STATE: StoredState = {
version: 1,
admin: null,
config: null,
configKey: null
configKey: null,
wishlists: {}
};
export async function loadState(): Promise<StoredState> {
try {
const raw = await readFile(STORE_FILE, 'utf8');
const parsed = JSON.parse(raw) as Partial<StoredState>;
// Every write reads the file, changes it and writes it back, so writes are
// queued one after another; two profiles saving at once would otherwise drop
// one of the changes.
let writeQueue: Promise<unknown> = Promise.resolve();
return {
version: parsed.configKey ? 2 : 1,
admin: parsed.admin ?? null,
config: parsed.config ?? null,
configKey: typeof parsed.configKey === 'string' ? parsed.configKey : null
};
} catch {
return EMPTY_STATE;
async function readStoreFile(): Promise<string | null> {
for (const file of [STORE_FILE, LEGACY_STORE_FILE]) {
try {
return await readFile(file, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
}
return null;
}
// Only a store that does not exist counts as a fresh install. Any other read or
// parse failure is thrown: treating it as empty would reopen the master
// password setup and let the next visitor overwrite the real one.
export async function loadState(): Promise<StoredState> {
const raw = await readStoreFile();
if (raw === null) return EMPTY_STATE;
const parsed = JSON.parse(raw) as Partial<StoredState>;
return {
version: parsed.configKey ? 2 : 1,
admin: parsed.admin ?? null,
config: parsed.config ?? null,
configKey: typeof parsed.configKey === 'string' ? parsed.configKey : null,
wishlists: parsed.wishlists && typeof parsed.wishlists === 'object' ? parsed.wishlists : {}
};
}
export async function saveState(state: StoredState): Promise<void> {
await mkdir(dirname(STORE_FILE), { recursive: true });
await writeFile(STORE_FILE, JSON.stringify(state, null, 2), 'utf8');
// Written to a temporary file first, so a crash mid-write never leaves a
// half-written store behind.
const temporaryFile = `${STORE_FILE}.tmp`;
await writeFile(temporaryFile, JSON.stringify(state, null, 2), 'utf8');
await rename(temporaryFile, STORE_FILE);
}
export function updateState(change: (state: StoredState) => StoredState): Promise<StoredState> {
const next = writeQueue.then(async () => {
const updated = change(await loadState());
await saveState(updated);
return updated;
});
writeQueue = next.catch(() => undefined);
return next;
}
export async function readEncryptedConfig(): Promise<EncryptedConfig | null> {
@@ -60,20 +100,20 @@ export async function saveAdminAndConfig(
config: EncryptedConfig,
configKey: string
): Promise<void> {
await saveState({
await updateState((state) => ({
...state,
version: 2,
admin,
config,
configKey
});
}));
}
export async function updateEncryptedConfig(config: EncryptedConfig): Promise<void> {
const state = await loadState();
await saveState({
await updateState((state) => ({
...state,
config
});
}));
}
export async function readAdmin(): Promise<StoredAdmin | null> {
@@ -82,8 +122,29 @@ export async function readAdmin(): Promise<StoredAdmin | null> {
}
export async function migrateConfig(config: EncryptedConfig, configKey: string): Promise<void> {
await updateState((state) => ({ ...state, version: 2, config, configKey }));
}
export async function readWishlist(owner: string): Promise<WishlistEntry[]> {
const state = await loadState();
await saveState({ ...state, version: 2, config, configKey });
return state.wishlists[owner] ?? [];
}
export async function updateWishlist(owner: string, change: (entries: WishlistEntry[]) => WishlistEntry[]): Promise<WishlistEntry[]> {
const state = await updateState((current) => ({
...current,
wishlists: { ...current.wishlists, [owner]: change(current.wishlists[owner] ?? []) }
}));
return state.wishlists[owner];
}
export async function readAllWishlists(): Promise<Record<string, WishlistEntry[]>> {
const state = await loadState();
return state.wishlists;
}
export async function restoreBackup(config: EncryptedConfig, wishlists: Record<string, WishlistEntry[]>): Promise<void> {
await updateState((state) => ({ ...state, config, wishlists }));
}
export type { EncryptedConfig, StoredAdmin, StoredState };
+33
View File
@@ -12,6 +12,7 @@ export type AppRoute =
| 'library'
| 'calendar'
| 'downloads'
| 'wishlist'
| 'settings';
export interface ServiceConfig {
@@ -303,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;
@@ -347,6 +357,29 @@ export interface AuthResponse {
message?: string;
}
export interface WishlistEntry {
card: MediaCard;
addedAt: string;
}
export interface WishlistResponse {
items: WishlistEntry[];
}
export interface WishlistTogglePayload {
card: MediaCard;
wishlisted: boolean;
}
/** What the administration exports: the service settings and every profile's wishlist. */
export interface BackupFile {
app: 'omniarr';
format: 1;
exportedAt: string;
config: AppConfig;
wishlists: Record<string, WishlistEntry[]>;
}
export interface ApiErrorPayload {
error: string;
}
+343 -32
View File
@@ -3,8 +3,10 @@ import {
type FormEvent,
type MouseEvent,
type ReactNode,
createContext,
startTransition,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useRef,
@@ -29,6 +31,7 @@ import {
type TitleDetailsCastMember,
type TitleDetailsEpisode,
type TitleDetailsResponse,
type WishlistEntry,
} from '../shared/contracts'
type AuthState = 'loading' | 'setup' | 'login' | 'profiles' | 'admin' | 'app'
@@ -42,12 +45,13 @@ const EPISODE_SCROLL_DISTANCE_RATIO = 0.72
const SEASON_DOMINANCE_SWITCH_RATIO = 0.12
const SEASON_DOMINANCE_SWITCH_MIN_PX = 56
type SidebarItem = { route: AppRoute; label: string; short: string; icon?: 'calendar' | 'search' | 'downloads' | 'home' | 'library'; iconClassName?: string }
type SidebarItem = { route: AppRoute; label: string; short: string; icon?: 'calendar' | 'search' | 'downloads' | 'home' | 'library' | 'wishlist'; iconClassName?: string }
const topSidebarItems: SidebarItem[] = [
{ route: 'search', label: 'Search', short: 'S', icon: 'search', iconClassName: 'sidebar-search-icon' },
{ route: 'calendar', label: 'Upcoming', short: 'C', icon: 'calendar' },
{ route: 'downloads', label: 'Downloads', short: 'D', icon: 'downloads' },
{ route: 'wishlist', label: 'Wishlist', short: 'W', icon: 'wishlist' },
]
const sidebarItems: SidebarItem[] = [
@@ -65,6 +69,27 @@ const networkSidebarItems: Array<{ route: AppRoute; label: string; icon: string;
{ route: 'disney', label: 'Disney+', icon: '/networks/disney.webp' },
]
// Routes that render their own page instead of a hub fetched from the server.
const NON_HUB_ROUTES = new Set<AppRoute>(['downloads', 'settings', 'search', 'calendar', 'wishlist'])
function isHubRoute(route: AppRoute) {
return !NON_HUB_ROUTES.has(route)
}
// Lets every tile and the title modal show and change whether a title is on
// the current profile's wishlist without passing it through each component.
const WishlistContext = createContext<{ isWishlisted: (card: MediaCard) => boolean; toggle: (card: MediaCard) => void } | null>(null)
// The same title can come from Jellyfin or TMDb, so it is matched by its TMDb
// id when it has one; the server matches titles the same way.
function getWishlistKey(card: MediaCard) {
return card.tmdbId ? `${card.mediaType === 'movie' ? 'movie' : 'tv'}:${card.tmdbId}` : card.id
}
function canWishlist(card: MediaCard) {
return card.mediaType === 'movie' || card.mediaType === 'tv' || card.mediaType === 'series'
}
function App() {
const [authState, setAuthState] = useState<AuthState>('loading')
const [sessionRole, setSessionRole] = useState<'admin' | 'user' | null>(null)
@@ -98,6 +123,7 @@ function App() {
const [lastSearchQuery, setLastSearchQuery] = useState('')
const [selectedCard, setSelectedCard] = useState<SelectedCardState | null>(null)
const [downloadDeleteId, setDownloadDeleteId] = useState('')
const [wishlist, setWishlist] = useState<WishlistEntry[]>([])
const [entryTransitionState, setEntryTransitionState] = useState<EntryTransitionState>('idle')
async function transitionEntryStage(nextStage: EntryStage) {
@@ -123,13 +149,15 @@ function App() {
}
async function refreshBaseData() {
const [settings, serviceOptions, dashboard, storage] = await Promise.all([
const [settings, serviceOptions, dashboard, storage, savedWishlist] = await Promise.all([
api.getSettings(),
api.getOptions(),
api.getDashboard(),
api.getDiskSpace(),
api.getWishlist(),
])
setWishlist(savedWishlist.items)
setConfig(settings)
setOptions(serviceOptions)
setDownloads(dashboard.downloads)
@@ -209,7 +237,7 @@ function App() {
return
}
if (route === 'settings' || route === 'calendar' || route === 'search') {
if (route === 'settings' || route === 'calendar' || route === 'search' || route === 'wishlist') {
return
}
@@ -253,7 +281,7 @@ function App() {
const interval = window.setInterval(() => {
void refreshBaseData()
if (route !== 'downloads' && route !== 'settings' && route !== 'search' && route !== 'calendar') {
if (isHubRoute(route)) {
void loadHub(route as HubPageResponse['page'], { silent: true })
}
}, 30000)
@@ -314,6 +342,13 @@ function App() {
apiKey: config.jellyfin.apiKey,
})
setOptions((current) => ({ ...current, jellyfinUsers: response.jellyfinUsers }))
// Loading the users only tests the typed values; save them so the
// connection stays configured after this screen.
if (sessionRole === 'admin') {
const saved = await api.saveSettings(config)
setConfig(saved.config)
setConnections(saved.connections)
}
await transitionEntryStage(response.jellyfinUsers.length ? 'profiles' : 'onboarding')
setNotice(response.jellyfinUsers.length ? 'Jellyfin users loaded.' : 'No Jellyfin users found.')
} catch (loadError) {
@@ -375,7 +410,7 @@ function App() {
} else {
await api.requestSeries(card.tmdbId, currentJellyfinUser, qualityProfileId, seasonNumber)
}
if (route !== 'downloads' && route !== 'settings' && route !== 'search' && route !== 'calendar') {
if (isHubRoute(route)) {
await loadHub(route as HubPageResponse['page'], { silent: true })
}
setNotice(`${card.title} sent to ${card.mediaType === 'movie' ? 'Radarr' : 'Sonarr'}.`)
@@ -441,6 +476,66 @@ function App() {
}
}
async function handleToggleWishlist(card: MediaCard) {
const key = getWishlistKey(card)
const wishlisted = !wishlist.some((entry) => getWishlistKey(entry.card) === key)
const previous = wishlist
// The heart fills straight away; the server's answer then replaces the list.
setWishlist(wishlisted
? [{ card, addedAt: new Date().toISOString() }, ...previous]
: previous.filter((entry) => getWishlistKey(entry.card) !== key))
try {
const response = await api.setWishlisted({ card, wishlisted })
setWishlist(response.items)
} catch (wishlistError) {
setWishlist(previous)
setError(wishlistError instanceof Error ? wishlistError.message : 'Failed to update the wishlist.')
}
}
async function handleImportBackup(file: File) {
setBusy(true)
setError('')
setNotice('')
try {
let backup: unknown
try {
backup = JSON.parse(await file.text())
} catch {
throw new Error('This file is not an OmniArr backup.')
}
const response = await api.importBackup(backup)
setConfig(response.config)
setConnections(response.connections)
await refreshBaseData()
setNotice('Backup restored.')
} catch (importError) {
setError(importError instanceof Error ? importError.message : 'Failed to restore the backup.')
} finally {
setBusy(false)
}
}
async function handleExportBackup() {
setError('')
try {
const backup = await api.exportBackup()
const url = URL.createObjectURL(new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' }))
const link = document.createElement('a')
link.href = url
link.download = `omniarr-backup-${backup.exportedAt.slice(0, 10)}.json`
link.click()
URL.revokeObjectURL(url)
} catch (exportError) {
setError(exportError instanceof Error ? exportError.message : 'Failed to export the backup.')
}
}
const wishlistContext = {
isWishlisted: (card: MediaCard) => wishlist.some((entry) => getWishlistKey(entry.card) === getWishlistKey(card)),
toggle: (card: MediaCard) => void handleToggleWishlist(card),
}
function updateConfig<K extends keyof AppConfig>(key: K, value: AppConfig[K]) {
setConfig((current) => ({
...current,
@@ -514,8 +609,12 @@ function App() {
busy={busy}
config={config}
connections={connections}
error={error}
notice={notice}
onExit={() => setAuthState('profiles')}
onCheck={saveSettings}
onExportBackup={handleExportBackup}
onImportBackup={handleImportBackup}
onSave={handleSaveSettings}
options={options}
updateConfig={updateConfig}
@@ -564,9 +663,10 @@ function App() {
const currentJellyfinUser = sessionRole === 'admin'
? 'Administration'
: options.jellyfinUsers.find((option) => option.id === config.jellyfin.userId)?.label || 'Media cockpit'
const currentHub = route !== 'downloads' && route !== 'settings' && route !== 'calendar' && route !== 'search' ? hubPages[route as HubPageResponse['page']] : undefined
const currentHub = isHubRoute(route) ? hubPages[route as HubPageResponse['page']] : undefined
return (
<WishlistContext.Provider value={wishlistContext}>
<div className={`omniarr-app ${getEntryTransitionClassName(entryTransitionState)}`}>
<Sidebar
busy={busy}
@@ -594,7 +694,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}
@@ -612,12 +712,20 @@ function App() {
config={config}
connections={connections}
onCheck={saveSettings}
onExportBackup={handleExportBackup}
onImportBackup={handleImportBackup}
onSave={handleSaveSettings}
options={options}
updateConfig={updateConfig}
/>
) : null}
{route !== 'downloads' && route !== 'settings' && route !== 'calendar' && route !== 'search' ? (
{route === 'wishlist' ? (
<WishlistPage
items={wishlist}
onOpenCard={(card, originRect) => setSelectedCard({ card, originRect: originRect ?? null })}
/>
) : null}
{isHubRoute(route) ? (
<HubPageView
busy={busy}
hub={currentHub}
@@ -647,6 +755,7 @@ function App() {
/>
) : null}
</div>
</WishlistContext.Provider>
)
}
@@ -734,7 +843,7 @@ function Sidebar(props: {
type="button"
>
<span className={item.iconClassName ? `sidebar-link-short ${item.iconClassName}` : 'sidebar-link-short'} aria-hidden="true">
{item.icon === 'calendar' ? <CalendarGlyph /> : item.icon === 'search' ? <SearchGlyph /> : item.icon === 'downloads' ? <DownloadGlyph /> : item.icon === 'home' ? <HomeGlyph /> : item.icon === 'library' ? <LibraryGlyph /> : item.short}
{item.icon === 'calendar' ? <CalendarGlyph /> : item.icon === 'search' ? <SearchGlyph /> : item.icon === 'downloads' ? <DownloadGlyph /> : item.icon === 'home' ? <HomeGlyph /> : item.icon === 'library' ? <LibraryGlyph /> : item.icon === 'wishlist' ? <HeartGlyph /> : item.short}
</span>
<span>{item.label}</span>
</button>
@@ -750,7 +859,7 @@ function Sidebar(props: {
type="button"
>
<span className={item.iconClassName ? `sidebar-link-short ${item.iconClassName}` : 'sidebar-link-short'} aria-hidden="true">
{item.icon === 'calendar' ? <CalendarGlyph /> : item.icon === 'search' ? <SearchGlyph /> : item.icon === 'downloads' ? <DownloadGlyph /> : item.icon === 'home' ? <HomeGlyph /> : item.icon === 'library' ? <LibraryGlyph /> : item.short}
{item.icon === 'calendar' ? <CalendarGlyph /> : item.icon === 'search' ? <SearchGlyph /> : item.icon === 'downloads' ? <DownloadGlyph /> : item.icon === 'home' ? <HomeGlyph /> : item.icon === 'library' ? <LibraryGlyph /> : item.icon === 'wishlist' ? <HeartGlyph /> : item.short}
</span>
<span>{item.label}</span>
</button>
@@ -1032,17 +1141,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 +1234,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">
@@ -1672,6 +1788,7 @@ function MediaTile(props: {
{props.card.hoverLabel && !continueEpisodeMeta ? <div className="media-hover-label">{props.card.hoverLabel}</div> : null}
{props.card.progress ? <div className="tile-progress"><div className="tile-progress-fill" style={{ width: `${props.card.progress}%` }} /></div> : null}
</button>
{!props.isContinueWatching && (props.styleType === 'landscape' || props.styleType === 'poster') ? <WishlistHeart card={props.card} className="media-wishlist-button" /> : null}
</article>
)
}
@@ -1991,6 +2108,26 @@ function TitleDetailsModal(props: {
const backgroundImage = details?.backdropUrl || props.card.backdropUrl || props.card.posterUrl
const logoUrl = details?.logoUrl || props.card.logoUrl
const displayTitle = details?.title || props.card.title
// Titles opened from Continue Watching arrive as a Jellyfin episode card, which
// cannot be wishlisted; the series the details resolved to can.
const wishlistCard: MediaCard = props.card.mediaType === 'episode' && details?.mediaType === 'series'
? {
...props.card,
id: details.providerId ? `jellyfin-${details.providerId}` : details.id,
tmdbId: details.tmdbId ?? props.card.tmdbId,
providerId: details.providerId ?? props.card.providerId,
title: details.title,
subtitle: '',
overview: details.overview,
mediaType: 'series',
posterUrl: details.posterUrl,
backdropUrl: details.backdropUrl,
logoUrl: details.logoUrl ?? props.card.logoUrl,
progress: undefined,
secondaryValue: undefined,
hoverLabel: undefined
}
: props.card
const displaySubtitle = details?.subtitle || props.card.subtitle
const displayOverview = details?.overview || props.card.overview || 'No details available yet.'
const displayYear = props.card.releaseDate?.slice(0, 4)
@@ -2442,7 +2579,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
}
@@ -2455,7 +2596,7 @@ function TitleDetailsModal(props: {
function queueEpisodeDownload(
episodeId: string,
sonarrEpisodeId: number,
sonarrEpisodeId: number | undefined,
availability: TitleDetailsResponse['episodes'][number]['availability'],
qualityProfileId?: number
) {
@@ -2468,7 +2609,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) => ({
@@ -2584,12 +2735,16 @@ function TitleDetailsModal(props: {
) : null}
</p>
<div className="title-details-summary-header">
{details?.mediaType !== 'series' ? (
<WishlistHeart card={wishlistCard} 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"
@@ -2701,8 +2856,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={wishlistCard} 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' ? (
@@ -2768,7 +2924,7 @@ function TitleDetailsModal(props: {
</>
) : null}
</div>
) : null}
</>
<div className="title-season-strip-spacer" />
{canPlay ? (
<ActionButton
@@ -3045,6 +3201,79 @@ function PlayGlyph() {
)
}
function HeartGlyph({ filled, className = 'sidebar-glyph-icon' }: { filled?: boolean; className?: string }) {
return (
<svg aria-hidden="true" className={className} fill={filled ? 'currentColor' : 'none'} viewBox="0 0 24 24">
<path d="M12 20.3 4.6 13a4.8 4.8 0 0 1 6.8-6.8l.6.6.6-.6a4.8 4.8 0 0 1 6.8 6.8Z" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.8" />
</svg>
)
}
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}
className={wishlisted ? `${className} is-wishlisted` : className}
onClick={(event) => {
event.stopPropagation()
wishlist.toggle(card)
}}
title={wishlisted ? 'Remove from wishlist' : 'Add to wishlist'}
type="button"
>
<HeartGlyph className="title-action-icon" filled={wishlisted} />
</button>
{divider === 'after' ? separator : null}
</>
)
}
function WishlistPage(props: {
items: WishlistEntry[]
onOpenCard: (card: MediaCard, originRect?: ModalOriginRect | null) => void
}) {
return (
<div className="search-page wishlist-page">
<section className="search-header">
<h2><HeartGlyph /> <span>Wishlist</span></h2>
</section>
{props.items.length ? (
<section className="search-results-section">
<div className="search-results-grid">
{props.items.map((entry) => (
<MediaTile
busy={false}
key={getWishlistKey(entry.card)}
card={entry.card}
isContinueWatching={false}
onOpenCard={props.onOpenCard}
onRequest={async () => {}}
styleType="landscape"
/>
))}
</div>
</section>
) : (
<EmptyState
copy="Tap the heart on any movie or series to save it here."
title="Your wishlist is empty"
/>
)}
</div>
)
}
function CalendarGlyph() {
return (
<svg aria-hidden="true" className="sidebar-glyph-icon" viewBox="0 0 16 16">
@@ -3193,6 +3422,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 +3494,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 +3545,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 +3586,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 +3640,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 +3684,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 +3711,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>
@@ -3901,7 +4170,11 @@ function GlobeGlyph() {
function AdministrationScreen(props: {
config: AppConfig
connections: ConnectionStatus[]
error: string
notice: string
onExit: () => void
onExportBackup: () => Promise<void>
onImportBackup: (file: File) => Promise<void>
updateConfig: <K extends keyof AppConfig>(key: K, value: AppConfig[K]) => void
options: ServiceOptionsResponse
onSave: (event: FormEvent<HTMLFormElement>) => Promise<void>
@@ -3911,19 +4184,25 @@ 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>
</div>
</header>
{props.notice ? <p className="notice">{props.notice}</p> : null}
{props.error ? <p className="error-banner">{props.error}</p> : null}
<SettingsView
busy={props.busy}
config={props.config}
connections={props.connections}
onCheck={props.onCheck}
onExportBackup={props.onExportBackup}
onImportBackup={props.onImportBackup}
onSave={props.onSave}
options={props.options}
formId="administration-settings"
@@ -3940,10 +4219,13 @@ function SettingsView(props: {
options: ServiceOptionsResponse
onSave: (event: FormEvent<HTMLFormElement>) => Promise<void>
onCheck: () => Promise<void>
onExportBackup: () => Promise<void>
onImportBackup: (file: File) => Promise<void>
busy: boolean
formId?: string
}) {
const check = { busy: props.busy, onCheck: props.onCheck }
const importInputRef = useRef<HTMLInputElement | null>(null)
return (
<form className="settings-page" id={props.formId} onSubmit={(event) => void props.onSave(event)}>
@@ -4029,6 +4311,35 @@ function SettingsView(props: {
</label>
</SettingsCard>
<SettingsCard title="Backup">
<p className="settings-backup-copy">
Saves every service connection and each profile's wishlist to a file. It holds your API keys, so keep it somewhere safe.
Restoring replaces the current settings and wishlists; the master password stays as it is.
</p>
<div className="settings-backup-actions">
<button className="secondary-button" disabled={props.busy} onClick={() => void props.onExportBackup()} type="button">
<DownloadGlyph />
<span>Export</span>
</button>
<button className="secondary-button" disabled={props.busy} onClick={() => importInputRef.current?.click()} type="button">
<span>Import</span>
</button>
<input
accept="application/json,.json"
hidden
onChange={(event) => {
const file = event.target.files?.[0]
event.target.value = ''
if (file && window.confirm('Restore this backup? The current settings and wishlists will be replaced.')) {
void props.onImportBackup(file)
}
}}
ref={importInputRef}
type="file"
/>
</div>
</SettingsCard>
</form>
)
}
@@ -4144,7 +4455,7 @@ function resolveEntryStage(config: AppConfig, options: ServiceOptionsResponse):
return 'onboarding'
}
if (!options.jellyfinUsers.length) {
return 'profiles'
return 'onboarding'
}
if (!config.jellyfin.userId.trim()) {
return 'profiles'
+22 -1
View File
@@ -2,6 +2,7 @@ import type {
AppConfig,
AuthPayload,
AuthResponse,
BackupFile,
CalendarResponse,
ConnectionStatus,
DashboardResponse,
@@ -17,9 +18,12 @@ import type {
EpisodeWatchStateRequest,
EpisodeRequestPayload,
SeasonDeletePayload,
SeriesEpisodeRequestPayload,
SeasonRequestPayload,
TitleDetailsRequest,
TitleDetailsResponse
TitleDetailsResponse,
WishlistResponse,
WishlistTogglePayload
} from '../shared/contracts'
async function request<T>(input: string, init?: RequestInit): Promise<T> {
@@ -92,6 +96,18 @@ export const api = {
method: 'POST',
body: JSON.stringify(payload),
}),
getWishlist: () => request<WishlistResponse>('/api/wishlist'),
setWishlisted: (payload: WishlistTogglePayload) =>
request<WishlistResponse>('/api/wishlist', {
method: 'POST',
body: JSON.stringify(payload),
}),
exportBackup: () => request<BackupFile>('/api/admin/backup'),
importBackup: (backup: unknown) =>
request<{ config: AppConfig; connections: ConnectionStatus[] }>('/api/admin/backup', {
method: 'POST',
body: JSON.stringify(backup),
}),
getDashboard: () => request<DashboardResponse>('/api/dashboard'),
getDiskSpace: () => request<DiskSpaceStatus | null>('/api/storage'),
getCalendar: (start: Date, end: Date) => request<CalendarResponse>(`/api/calendar?start=${start.toISOString()}&end=${end.toISOString()}`),
@@ -142,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',
+133
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;
@@ -5940,3 +5989,87 @@ 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;
}
/* Wishlist */
.media-wishlist-button {
position: absolute;
top: 0.6rem;
right: 0.6rem;
z-index: 3;
display: grid;
place-items: center;
width: 2.2rem;
height: 2.2rem;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 999px;
background: rgba(6, 8, 12, 0.55);
color: #f3f6ff;
backdrop-filter: blur(8px);
opacity: 0;
transition: opacity 180ms ease, transform 180ms ease, color 180ms ease;
}
.media-wishlist-button .title-action-icon {
width: 1.05rem;
height: 1.05rem;
}
.media-tile:hover .media-wishlist-button,
.media-tile:focus-within .media-wishlist-button,
.media-wishlist-button.is-wishlisted {
opacity: 1;
}
.media-wishlist-button:hover,
.media-wishlist-button:focus-visible {
transform: scale(1.08);
}
.media-wishlist-button.is-wishlisted,
.title-wishlist-button.is-wishlisted {
color: #ff5c7a;
}
/* Phones and touch screens have no hover, and a heart on every poster is noise;
the wishlist stays reachable from the title details. */
@media (hover: none), (max-width: 980px) {
.media-wishlist-button {
display: none;
}
}
.settings-backup-copy {
margin: 0;
color: #98a8c4;
font-size: 0.92rem;
line-height: 1.45;
}
.settings-backup-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
}
.settings-backup-actions .secondary-button {
display: inline-flex;
align-items: center;
gap: 0.6rem;
}
.administration-layout > .notice,
.administration-layout > .error-banner {
max-width: 76rem;
margin: 0 auto 1rem;
}