🐛 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:
WonderAliceUwU committed 2026-10-30 12:37:22 +01:00
1 parent cda724e736
commit d7ff401d33
7 files changed
+582 -38

No files matched your search

+152 -7
View File
@@ -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> {
+7
View File
@@ -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
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 };
+24
View File
@@ -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
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}
@@ -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
View File
@@ -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()}`),
+73
View File
@@ -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;
}