mirror of
https://github.com/WonderAliceUwU/OmniArr.git
synced 2026-11-11 04:01:01 +00:00
🐛 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
This commit is contained in:
1 parent
cda724e736
commit
d7ff401d33
7 files changed
+582
-38
No files matched your search
+152
-7
@@ -6,6 +6,10 @@ import {
|
||||
defaultConfig,
|
||||
type AppConfig,
|
||||
type AuthPayload,
|
||||
type BackupFile,
|
||||
type MediaCard,
|
||||
type WishlistEntry,
|
||||
type WishlistTogglePayload,
|
||||
type EpisodeRequestPayload,
|
||||
type EpisodeWatchStateRequest,
|
||||
type DiskSpaceStatus,
|
||||
@@ -17,7 +21,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,
|
||||
@@ -252,6 +267,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));
|
||||
@@ -580,9 +661,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 +697,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> {
|
||||
|
||||
@@ -4048,6 +4048,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
@@ -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 };
|
||||
@@ -12,6 +12,7 @@ export type AppRoute =
|
||||
| 'library'
|
||||
| 'calendar'
|
||||
| 'downloads'
|
||||
| 'wishlist'
|
||||
| 'settings';
|
||||
|
||||
export interface ServiceConfig {
|
||||
@@ -347,6 +348,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;
|
||||
}
|
||||
|
||||
+228
-9
@@ -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}
|
||||
@@ -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>
|
||||
@@ -1679,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>
|
||||
)
|
||||
}
|
||||
@@ -2591,6 +2701,7 @@ 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" />
|
||||
{canPlay && details?.mediaType !== 'series' ? (
|
||||
<ActionButton className="title-summary-header-play" onClick={() => setPlayerTarget({ id: playId!, title: playLabel })} variant="play">
|
||||
<PlayGlyph />
|
||||
@@ -3052,6 +3163,74 @@ 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 }: { card: MediaCard; className: string }) {
|
||||
const wishlist = useContext(WishlistContext)
|
||||
if (!wishlist || !canWishlist(card)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const wishlisted = wishlist.isWishlisted(card)
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
@@ -3948,7 +4127,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>
|
||||
@@ -3968,11 +4151,15 @@ function AdministrationScreen(props: {
|
||||
<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"
|
||||
@@ -3989,10 +4176,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)}>
|
||||
@@ -4078,6 +4268,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>
|
||||
)
|
||||
}
|
||||
|
||||
+16
-1
@@ -2,6 +2,7 @@ import type {
|
||||
AppConfig,
|
||||
AuthPayload,
|
||||
AuthResponse,
|
||||
BackupFile,
|
||||
CalendarResponse,
|
||||
ConnectionStatus,
|
||||
DashboardResponse,
|
||||
@@ -19,7 +20,9 @@ import type {
|
||||
SeasonDeletePayload,
|
||||
SeasonRequestPayload,
|
||||
TitleDetailsRequest,
|
||||
TitleDetailsResponse
|
||||
TitleDetailsResponse,
|
||||
WishlistResponse,
|
||||
WishlistTogglePayload
|
||||
} from '../shared/contracts'
|
||||
|
||||
async function request<T>(input: string, init?: RequestInit): Promise<T> {
|
||||
@@ -92,6 +95,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()}`),
|
||||
|
||||
@@ -5950,3 +5950,76 @@ img {
|
||||
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;
|
||||
}
|
||||
|
||||
/* Touch screens have no hover, so the heart is always visible there. */
|
||||
@media (hover: none) {
|
||||
.media-wishlist-button {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
Reference in new issue
Block a user