mirror of
https://github.com/WonderAliceUwU/OmniArr.git
synced 2026-11-11 04:01:01 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb07b27675 | ||
|
|
9f1d50b35d | ||
|
|
3c7836e18b | ||
|
|
739afbefe3 | ||
|
|
d7ff401d33 |
No files matched your search
@@ -43,7 +43,7 @@ Create a directory for OmniArr and add this `docker-compose.yml`:
|
||||
```yaml
|
||||
services:
|
||||
omniarr:
|
||||
image: wonderalice/omniarr:1.2.1
|
||||
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.1
|
||||
wonderalice/omniarr:1.2.3
|
||||
wonderalice/omniarr:latest
|
||||
```
|
||||
|
||||
Pin `1.2.1` 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
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
omniarr:
|
||||
image: wonderalice/omniarr:1.2.1
|
||||
image: wonderalice/omniarr:1.2.3
|
||||
# Para construir desde código fuente en su lugar, descomenta estas líneas:
|
||||
# build:
|
||||
# context: .
|
||||
|
||||
+167
-9
@@ -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 {
|
||||
|
||||
+74
-1
@@ -17,6 +17,7 @@ import type {
|
||||
ProcessingItem,
|
||||
SeasonDeletePayload,
|
||||
SeasonRequestPayload,
|
||||
SeriesEpisodeRequestPayload,
|
||||
SelectOption,
|
||||
ServiceOptionsResponse,
|
||||
TitleDetailsEpisode,
|
||||
@@ -2442,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,
|
||||
@@ -2973,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.');
|
||||
@@ -4048,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
@@ -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 {
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+276
-14
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1998,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)
|
||||
@@ -2449,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
|
||||
}
|
||||
|
||||
@@ -2462,7 +2596,7 @@ function TitleDetailsModal(props: {
|
||||
|
||||
function queueEpisodeDownload(
|
||||
episodeId: string,
|
||||
sonarrEpisodeId: number,
|
||||
sonarrEpisodeId: number | undefined,
|
||||
availability: TitleDetailsResponse['episodes'][number]['availability'],
|
||||
qualityProfileId?: number
|
||||
) {
|
||||
@@ -2475,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) => ({
|
||||
@@ -2591,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"
|
||||
@@ -2708,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' ? (
|
||||
@@ -2775,7 +2924,7 @@ function TitleDetailsModal(props: {
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
<div className="title-season-strip-spacer" />
|
||||
{canPlay ? (
|
||||
<ActionButton
|
||||
@@ -3052,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">
|
||||
@@ -3948,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>
|
||||
@@ -3968,11 +4194,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 +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)}>
|
||||
@@ -4078,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>
|
||||
)
|
||||
}
|
||||
|
||||
+22
-1
@@ -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',
|
||||
|
||||
+123
@@ -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;
|
||||
@@ -5950,3 +5999,77 @@ 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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
Reference in new issue
Block a user