feature: Improve performance and build.sh

This commit is contained in:
WonderAliceUwU committed 2026-08-02 12:44:28 +02:00
1 parent 9e7f64d06a
commit 9893d3a497
5 files changed
+262 -332

No files matched your search

-1
View File
@@ -17,7 +17,6 @@ import {
getCalendar,
getConnectionStatuses,
getDashboard,
getDownloads,
getHubPage,
searchTitles,
getTitleDetails,
+199 -167
View File
@@ -25,6 +25,11 @@ import type {
} from '../../shared/contracts.js';
const TMDB_IMAGE = 'https://image.tmdb.org/t/p';
const TMDB_ARTWORK_TTL_MS = 6 * 60 * 60 * 1000;
const TMDB_ARTWORK_FAILURE_TTL_MS = 60 * 1000;
const TMDB_ARTWORK_CONCURRENCY = 8;
const TMDB_ARTWORK_SECTION_CONCURRENCY = 2;
const TVDB_TRANSLATION_CACHE_TTL_MS = 6 * 60 * 60 * 1000;
const PROCESSING_TTL_MS = 60 * 60 * 1000; // 1 hour max
const processingQueue: ProcessingItem[] = [];
@@ -241,6 +246,12 @@ interface TmdbBackdropChoice {
backdropLanguageFallback: boolean;
}
interface TmdbArtwork {
available: boolean;
logos: Array<{ file_path: string; iso_639_1: string | null }>;
backdrops: Record<string, unknown>[];
}
interface TvdbSeriesRecord {
id?: number;
name?: string;
@@ -348,6 +359,8 @@ interface SeriesSeasonDescriptor {
const ANIME_PROVIDERS = '337|8|9|430|283';
const DORAMA_PROVIDERS = '344|337|8|9|464';
let tvdbTokenCache: { token: string; expiresAt: number } | null = null;
const tmdbArtworkCache = new Map<string, { expiresAt: number; value: Promise<TmdbArtwork> }>();
const tvdbTranslationCache = new Map<string, { expiresAt: number; value: Promise<TvdbTranslationRecord | null> }>();
export function sanitizeConfig(input: AppConfig): AppConfig {
return {
@@ -512,37 +525,35 @@ export async function getHubPage(
})()
: library.continueWatching;
const [continueWatching, heroWithHighResBackdrops, sectionsWithHighResBackdrops] = await Promise.all([
applyHighResBackdropsToCards(
config,
networkScopedContinueWatching,
shouldUseHighResBackdropForCard,
backdropCache,
'en'
),
const [continueWatchingWithLogos, heroWithHighResBackdrops, sectionsWithLogos] = await Promise.all([
applyTitleLogosToCards(config, networkScopedContinueWatching),
applyHighResBackdropsToCards(
config,
(allSections && allSections.hero) || [],
shouldUseHighResBackdropForCard,
backdropCache,
null
null,
'original'
),
applyHighResBackdropsToSections(
applyTitleLogosToSections(config, (allSections && allSections.sections) || [])
]);
const [continueWatching, heroWithLogos, sectionsWithHighResBackdrops] = await Promise.all([
applyHighResBackdropsToCards(
config,
(allSections && allSections.sections) || [],
continueWatchingWithLogos || [],
shouldUseHighResBackdropForCard,
backdropCache,
'en'
)
),
applyTitleLogosToCards(config, heroWithHighResBackdrops || [], 'original'),
applyHighResBackdropsToSections(config, sectionsWithLogos || [], backdropCache, 'en')
]);
const continueWatchingWithLogos = await applyTitleLogosToCards(config, continueWatching || []);
const heroWithLogos = await applyTitleLogosToCards(config, heroWithHighResBackdrops || []);
const sectionsWithLogos = await applyTitleLogosToSections(config, sectionsWithHighResBackdrops || []);
return {
page,
hero: heroWithLogos,
continueWatching: continueWatchingWithLogos,
sections: sectionsWithLogos
continueWatching,
sections: sectionsWithHighResBackdrops
.filter((section) => section.items.some((item) => item.backdropUrl || item.posterUrl))
};
}
@@ -702,15 +713,16 @@ export async function searchTitles(
});
const searchBackdropCache = new Map<string, Promise<TmdbBackdropChoice>>();
const enrichedCards = await applyTitleLogosToCards(
const cardsWithLogos = await applyTitleLogosToCards(
config,
await applyHighResBackdropsToCards(
config,
uniqueSearchCards([...libraryMatches, ...remoteCards]).slice(0, 36),
shouldUseHighResBackdropForCard,
searchBackdropCache,
'en'
)
uniqueSearchCards([...libraryMatches, ...remoteCards]).slice(0, 36)
);
const enrichedCards = await applyHighResBackdropsToCards(
config,
cardsWithLogos,
shouldUseHighResBackdropForCard,
searchBackdropCache,
'en'
);
return {
@@ -764,8 +776,8 @@ export async function getTitleDetails(
const tmdbId = Number(primaryItem?.ProviderIds?.Tmdb) || card.tmdbId || await resolveTmdbSeriesIdByTvdbId(config, tvdbId);
const originalLanguage = card.originalLanguage;
const [backdropUrl, logoUrl] = await Promise.all([
tmdbId ? getTmdbHighResBackdrop(config, tmdbId, detailMediaType, null, originalLanguage) : Promise.resolve(''),
tmdbId ? getPreferredTitleLogo(config, tmdbId, detailMediaType) : Promise.resolve('')
tmdbId ? getTmdbHighResBackdrop(config, tmdbId, detailMediaType, null, originalLanguage, 'original') : Promise.resolve(''),
tmdbId ? getPreferredTitleLogo(config, tmdbId, detailMediaType, 'original') : Promise.resolve('')
]);
const fallbackTitle = seriesItem?.Name || primaryItem?.Name || card.title;
@@ -1171,18 +1183,6 @@ function mapRadarrCalendarEntry(item: RadarrCalendarRecord): CalendarEntry | nul
}
}
function startOfToday() {
const date = new Date()
date.setHours(0, 0, 0, 0)
return date
}
function addDays(date: Date, amount: number) {
const next = new Date(date)
next.setDate(next.getDate() + amount)
return next
}
async function getJellyfinItemDetails(config: AppConfig, itemId: string): Promise<JellyfinItem | null> {
if (!config.jellyfin.baseUrl || !config.jellyfin.apiKey || !config.jellyfin.userId) {
return null;
@@ -1261,13 +1261,14 @@ function buildTitleSubtitle(item: JellyfinItem | null, card: MediaCard): string
async function getPreferredTitleLogo(
config: AppConfig,
tmdbId: number,
mediaType: 'movie' | 'tv'
mediaType: 'movie' | 'tv',
imageSize = 'w500'
): Promise<string> {
const logos = await getTmdbLogos(config, tmdbId, mediaType);
const englishLogo = logos.find((logo) => logo.iso_639_1 === 'en' || logo.iso_639_1 === 'en-US');
const noLanguageLogo = logos.find((logo) => logo.iso_639_1 == null);
const selectedLogo = englishLogo || noLanguageLogo || logos[0];
return selectedLogo?.file_path ? `${TMDB_IMAGE}/original${selectedLogo.file_path}` : '';
return selectedLogo?.file_path ? `${TMDB_IMAGE}/${imageSize}${selectedLogo.file_path}` : '';
}
async function getTmdbSeriesSummary(config: AppConfig, tmdbId: number): Promise<Record<string, unknown> | null> {
@@ -1310,7 +1311,7 @@ function resolveTvdbImageUrl(image: string | undefined): string {
function getTvdbTranslationCandidates(config: AppConfig): string[] {
const preferred = String(config.preferences.language || '').trim().toLowerCase();
if (preferred.startsWith('en')) {
return ['eng', 'en'];
return ['eng'];
}
return [];
@@ -1320,34 +1321,71 @@ function hasLikelyJapaneseText(value: string | undefined): boolean {
return /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff]/.test(String(value || ''));
}
async function mapWithConcurrency<T, R>(
items: T[],
limit: number,
mapper: (item: T) => Promise<R>
): Promise<R[]> {
const results = new Array<R>(items.length);
let nextIndex = 0;
async function worker(): Promise<void> {
while (nextIndex < items.length) {
const index = nextIndex++;
results[index] = await mapper(items[index]);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker()));
return results;
}
async function getTvdbTranslation(
config: AppConfig,
entity: 'episodes' | 'seasons' | 'series',
id: number
): Promise<TvdbTranslationRecord | null> {
const cacheKey = `${entity}:${id}:${getTvdbTranslationCandidates(config).join(',')}`;
const cached = tvdbTranslationCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
const value = getTvdbTranslationUncached(config, entity, id);
tvdbTranslationCache.set(cacheKey, {
expiresAt: Date.now() + TVDB_TRANSLATION_CACHE_TTL_MS,
value
});
return value;
}
async function getTvdbTranslationUncached(
config: AppConfig,
entity: 'episodes' | 'seasons' | 'series',
id: number
): Promise<TvdbTranslationRecord | null> {
const candidates = getTvdbTranslationCandidates(config);
for (const language of candidates) {
for (const path of [
const response = await tvdbFetch(
config,
`/${entity}/${encodeURIComponent(String(id))}/translations/${encodeURIComponent(language)}`,
`/${entity}/${encodeURIComponent(String(id))}/translations/${encodeURIComponent(language)}/overview`
]) {
const response = await tvdbFetch(config, path);
const data = asRecord(response?.data);
const translation = asRecord(data.translation || data);
const name = typeof translation.name === 'string'
? translation.name
: typeof data.name === 'string'
? data.name
: undefined;
const overview = typeof translation.overview === 'string'
? translation.overview
: typeof data.overview === 'string'
? data.overview
: undefined;
{ quiet: true }
);
const data = asRecord(response?.data);
const translation = asRecord(data.translation || data);
const name = typeof translation.name === 'string'
? translation.name
: typeof data.name === 'string'
? data.name
: undefined;
const overview = typeof translation.overview === 'string'
? translation.overview
: typeof data.overview === 'string'
? data.overview
: undefined;
if (name || overview) {
return { name, overview };
}
if (name || overview) {
return { name, overview };
}
}
@@ -1363,8 +1401,10 @@ async function applyTvdbEpisodeTranslations(
return episodes;
}
return Promise.all(
episodes.map(async (episode) => {
return mapWithConcurrency(
episodes,
4,
async (episode) => {
if (!episode.id || (!hasLikelyJapaneseText(episode.name) && !hasLikelyJapaneseText(episode.overview))) {
return episode;
}
@@ -1377,7 +1417,7 @@ async function applyTvdbEpisodeTranslations(
overview: translation.overview || episode.overview
}
: episode;
})
}
);
}
@@ -1389,15 +1429,17 @@ async function applyTvdbSeasonTranslations(
return seasons;
}
return Promise.all(
seasons.map(async (season) => {
if (!season.id) {
return mapWithConcurrency(
seasons,
4,
async (season) => {
if (!season.id || !hasLikelyJapaneseText(season.name)) {
return season;
}
const translation = await getTvdbTranslation(config, 'seasons', season.id);
return translation?.name ? { ...season, name: translation.name } : season;
})
}
);
}
@@ -2427,7 +2469,7 @@ export async function requestSeries(config: AppConfig, tmdbId: number, user: str
addProcessingItem({
source: 'sonarr',
title: details.name || 'Series',
title: String(details.name || details.original_name || 'Series'),
subtitle: 'All seasons',
tmdbId,
mediaType: 'series',
@@ -2456,13 +2498,6 @@ export async function requestSeries(config: AppConfig, tmdbId: number, user: str
})
});
addProcessingItem({
source: 'sonarr',
title: String(details.name || details.original_name || 'Series'),
tmdbId,
mediaType: 'tv',
requestedBy: 'You',
});
}
export async function requestSeason(config: AppConfig, payload: SeasonRequestPayload, user: string | null): Promise<void> {
@@ -3590,7 +3625,11 @@ async function tmdbFetch(config: AppConfig, pathWithQuery: string): Promise<Reco
}
}
async function tvdbFetch(config: AppConfig, pathWithQuery: string): Promise<Record<string, unknown> | null> {
async function tvdbFetch(
config: AppConfig,
pathWithQuery: string,
options?: { quiet?: boolean }
): Promise<Record<string, unknown> | null> {
try {
const token = await getTvdbToken(config);
if (!token) {
@@ -3604,7 +3643,9 @@ async function tvdbFetch(config: AppConfig, pathWithQuery: string): Promise<Reco
}
});
} catch (err) {
console.error(`TVDB fetch failed for ${pathWithQuery}:`, err);
if (!options?.quiet) {
console.error(`TVDB fetch failed for ${pathWithQuery}:`, err);
}
return null;
}
}
@@ -3725,37 +3766,6 @@ function tmdbToCard(item: Record<string, unknown>, mediaType: 'movie' | 'tv'): M
};
}
function customCard(partial: Partial<MediaCard> & Pick<MediaCard, 'id' | 'title'>): MediaCard {
return {
id: partial.id,
title: partial.title,
subtitle: partial.subtitle || '',
overview: partial.overview || '',
mediaType: partial.mediaType || 'mixed',
posterUrl: partial.posterUrl || '',
backdropUrl: partial.backdropUrl || partial.posterUrl || '',
logoUrl: partial.logoUrl,
backdropLanguageFallback: partial.backdropLanguageFallback,
badge: partial.badge,
progress: partial.progress,
secondaryValue: partial.secondaryValue,
actionLabel: partial.actionLabel,
tmdbId: partial.tmdbId,
providerId: partial.providerId,
accent: partial.accent,
popularity: partial.popularity,
voteAverage: partial.voteAverage,
voteCount: partial.voteCount,
originalLanguage: partial.originalLanguage,
releaseDate: partial.releaseDate,
firstAirDate: partial.firstAirDate,
lastAirDate: partial.lastAirDate,
seasonCount: partial.seasonCount,
mediaStatus: partial.mediaStatus,
genreIds: partial.genreIds
};
}
async function tmdbResultCards(
config: AppConfig,
pathWithQuery: string,
@@ -3817,21 +3827,6 @@ async function tmdbPagedResultCards(
return uniqueCards(pages.flat()).slice(0, 36);
}
function downloadToCard(download: DownloadItem): MediaCard {
return customCard({
id: `download-${download.id}`,
title: download.title,
subtitle: [download.detail, download.eta].filter(Boolean).join(' · '),
overview: '',
mediaType: 'episode',
posterUrl: '/omniarr/downloads.png',
backdropUrl: '/omniarr/downloads.png',
badge: download.source,
progress: download.progress,
secondaryValue: download.speed || download.size || download.state
});
}
function inferMediaType(pathWithQuery: string, item: Record<string, unknown>): 'movie' | 'tv' {
if (pathWithQuery.includes('/tv') || item.media_type === 'tv') {
return 'tv';
@@ -3878,15 +3873,17 @@ function normalizeSearchValue(value: string): string {
.toLowerCase();
}
// Match TMDb's English backdrop tab for landscape cards where available.
// Prefer language-neutral artwork for cards that can render a separate title logo.
async function applyHighResBackdropsToSections(
config: AppConfig,
sections: HubSection[],
backdropCache: Map<string, Promise<TmdbBackdropChoice>>,
preferredLanguage?: string | null
): Promise<HubSection[]> {
return Promise.all(
sections.map(async (section) => {
return mapWithConcurrency(
sections,
TMDB_ARTWORK_SECTION_CONCURRENCY,
async (section) => {
if (section.style !== 'landscape') {
return section;
}
@@ -3903,7 +3900,7 @@ async function applyHighResBackdropsToSections(
...section,
items
};
})
}
);
}
@@ -3911,8 +3908,10 @@ async function applyTitleLogosToSections(
config: AppConfig,
sections: HubSection[]
): Promise<HubSection[]> {
return Promise.all(
sections.map(async (section) => {
return mapWithConcurrency(
sections,
TMDB_ARTWORK_SECTION_CONCURRENCY,
async (section) => {
if (section.style !== 'landscape') {
return section;
}
@@ -3923,7 +3922,7 @@ async function applyTitleLogosToSections(
...section,
items
};
})
}
);
}
@@ -3932,19 +3931,25 @@ async function applyHighResBackdropsToCards(
cards: MediaCard[],
shouldApply: (card: MediaCard) => boolean,
backdropCache: Map<string, Promise<TmdbBackdropChoice>>,
preferredLanguage?: string | null
preferredLanguage?: string | null,
imageSize = 'w780'
): Promise<MediaCard[]> {
return Promise.all(
cards.map(async (card) => {
return mapWithConcurrency(
cards,
TMDB_ARTWORK_CONCURRENCY,
async (card) => {
if (!shouldApply(card)) {
return card;
}
const mediaType: 'movie' | 'tv' = card.mediaType === 'movie' ? 'movie' : 'tv';
const cacheKey = `${mediaType}:${card.tmdbId}:${preferredLanguage || 'any'}:${card.originalLanguage || 'none'}`;
// When there is a title logo, a language-neutral backdrop keeps the artwork clean.
// Without a logo, retain the English artwork as the readable fallback.
const effectivePreferredLanguage = card.logoUrl ? null : preferredLanguage;
const cacheKey = `${mediaType}:${card.tmdbId}:${effectivePreferredLanguage || 'any'}:${card.originalLanguage || 'none'}:${imageSize}`;
let backdropPromise = backdropCache.get(cacheKey);
if (!backdropPromise) {
backdropPromise = getTmdbHighResBackdropChoice(config, card.tmdbId!, mediaType, preferredLanguage, card.originalLanguage);
backdropPromise = getTmdbHighResBackdropChoice(config, card.tmdbId!, mediaType, effectivePreferredLanguage, card.originalLanguage, imageSize);
backdropCache.set(cacheKey, backdropPromise);
}
@@ -3956,18 +3961,21 @@ async function applyHighResBackdropsToCards(
backdropLanguageFallback: backdropChoice.backdropLanguageFallback
}
: card;
})
}
);
}
async function applyTitleLogosToCards(
config: AppConfig,
cards: MediaCard[]
cards: MediaCard[],
imageSize = 'w500'
): Promise<MediaCard[]> {
const logoCache = new Map<string, Promise<string>>();
return Promise.all(
cards.map(async (card) => {
return mapWithConcurrency(
cards,
TMDB_ARTWORK_CONCURRENCY,
async (card) => {
if (card.logoUrl || !card.tmdbId) {
return card;
}
@@ -3977,10 +3985,10 @@ async function applyTitleLogosToCards(
return card;
}
const cacheKey = `${mediaType}:${card.tmdbId}`;
const cacheKey = `${mediaType}:${card.tmdbId}:${imageSize}`;
let logoPromise = logoCache.get(cacheKey);
if (!logoPromise) {
logoPromise = getPreferredTitleLogo(config, card.tmdbId, mediaType);
logoPromise = getPreferredTitleLogo(config, card.tmdbId, mediaType, imageSize);
logoCache.set(cacheKey, logoPromise);
}
@@ -3991,7 +3999,7 @@ async function applyTitleLogosToCards(
logoUrl
}
: card;
})
}
);
}
@@ -4019,12 +4027,47 @@ function resolveTmdbLogoMediaType(card: MediaCard): 'movie' | 'tv' | null {
return null;
}
function tmdbImagesPath(tmdbId: number, mediaType: 'movie' | 'tv'): string {
return `/${mediaType}/${tmdbId}/images?include_image_language=en,en-US,null`;
function tmdbArtworkPath(tmdbId: number, mediaType: 'movie' | 'tv'): string {
return `/${mediaType}/${tmdbId}/images`;
}
function tmdbBackdropImagesPath(tmdbId: number, mediaType: 'movie' | 'tv'): string {
return `/${mediaType}/${tmdbId}/images`;
function getTmdbArtwork(
config: AppConfig,
tmdbId: number,
mediaType: 'movie' | 'tv'
): Promise<TmdbArtwork> {
const cacheKey = `${mediaType}:${tmdbId}`;
const cached = tmdbArtworkCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.value;
}
const value = tmdbFetch(config, tmdbArtworkPath(tmdbId, mediaType))
.then((response) => ({
available: Boolean(response),
logos: asArray(response?.logos)
.map((entry) => asRecord(entry))
.filter((entry) => typeof entry.file_path === 'string' && entry.file_path)
.map((entry) => ({
file_path: String(entry.file_path),
iso_639_1: typeof entry.iso_639_1 === 'string' ? entry.iso_639_1 : null
})),
backdrops: asArray(response?.backdrops).map((entry) => asRecord(entry))
}));
tmdbArtworkCache.set(cacheKey, {
expiresAt: Date.now() + TMDB_ARTWORK_TTL_MS,
value
});
void value.then((artwork) => {
if (!artwork.available) {
const current = tmdbArtworkCache.get(cacheKey);
if (current?.value === value) {
current.expiresAt = Date.now() + TMDB_ARTWORK_FAILURE_TTL_MS;
}
}
});
return value;
}
function formatPlaybackTime(playbackPositionTicks?: number): string | undefined {
@@ -4048,18 +4091,7 @@ export async function getTmdbLogos(
mediaType: 'movie' | 'tv'
): Promise<Array<{ file_path: string; iso_639_1: string | null }>> {
try {
const response = await tmdbFetch(
config,
tmdbImagesPath(tmdbId, mediaType)
);
const logos = asArray(response?.logos)
.map((entry) => asRecord(entry))
.filter((entry) => typeof entry.file_path === 'string' && entry.file_path)
.map((entry) => ({
file_path: String(entry.file_path),
iso_639_1: typeof entry.iso_639_1 === 'string' ? entry.iso_639_1 : null
}));
return logos;
return (await getTmdbArtwork(config, tmdbId, mediaType)).logos;
} catch {
return [];
}
@@ -4070,9 +4102,10 @@ async function getTmdbHighResBackdrop(
tmdbId: number,
mediaType: 'movie' | 'tv',
preferredLanguage?: string | null,
originalLanguage?: string | null
originalLanguage?: string | null,
imageSize = 'w780'
): Promise<string> {
const choice = await getTmdbHighResBackdropChoice(config, tmdbId, mediaType, preferredLanguage, originalLanguage);
const choice = await getTmdbHighResBackdropChoice(config, tmdbId, mediaType, preferredLanguage, originalLanguage, imageSize);
return choice.url;
}
@@ -4081,12 +4114,11 @@ async function getTmdbHighResBackdropChoice(
tmdbId: number,
mediaType: 'movie' | 'tv',
preferredLanguage?: string | null,
originalLanguage?: string | null
originalLanguage?: string | null,
imageSize = 'w780'
): Promise<{ url: string; backdropLanguageFallback: boolean }> {
try {
const response = await tmdbFetch(config, tmdbBackdropImagesPath(tmdbId, mediaType));
let backdrops = asArray(response?.backdrops)
.map((entry) => asRecord(entry));
let backdrops = (await getTmdbArtwork(config, tmdbId, mediaType)).backdrops;
if (backdrops.length === 0) {
return { url: '', backdropLanguageFallback: false };
@@ -4136,8 +4168,8 @@ async function getTmdbHighResBackdropChoice(
return selectedBackdrop?.file_path
? {
url: `${TMDB_IMAGE}/original${String(selectedBackdrop.file_path)}`,
backdropLanguageFallback: preferredLanguage === 'en' && selectedBackdrop.iso_639_1 == null
url: `${TMDB_IMAGE}/${imageSize}${String(selectedBackdrop.file_path)}`,
backdropLanguageFallback: selectedBackdrop.iso_639_1 == null
}
: { url: '', backdropLanguageFallback: false };
} catch {
+24 -123
View File
@@ -337,9 +337,7 @@ function App() {
} else {
await api.requestSeries(card.tmdbId, currentJellyfinUser)
}
if (route === 'calendar') {
await loadCalendar({ silent: true })
} else if (route !== 'downloads' && route !== 'settings' && route !== 'search') {
if (route !== 'downloads' && route !== 'settings' && route !== 'search' && route !== 'calendar') {
await loadHub(route as HubPageResponse['page'], { silent: true })
}
setNotice(`${card.title} sent to ${card.mediaType === 'movie' ? 'Radarr' : 'Sonarr'}.`)
@@ -568,7 +566,13 @@ function Sidebar(props: {
<aside className="omniarr-sidebar">
<div className="sidebar-wallpaper" />
<div className="sidebar-inner">
<button className="sidebar-profile" onClick={() => startTransition(() => props.setRoute('home'))} type="button">
<button
aria-label="Switch user"
className="sidebar-profile"
disabled={props.busy}
onClick={props.onExitProfile}
type="button"
>
<div className="sidebar-avatar">{props.currentUser.slice(0, 1).toUpperCase()}</div>
<div className="sidebar-profile-copy">
<strong>{props.currentUser}</strong>
@@ -625,17 +629,15 @@ function Sidebar(props: {
<div className="sidebar-footer">
<button className="ghost-button sidebar-settings" onClick={() => startTransition(() => props.setRoute('settings'))} type="button">
<span className="door-icon sidebar-action-icon" aria-hidden="true">S</span>
<span
className={hasServiceFailure ? 'status-dot down sidebar-settings-status' : 'status-dot ok sidebar-settings-status'}
title={hasServiceFailure ? 'One or more services are failing.' : 'All services are healthy.'}
/>
<span className="sidebar-settings-icon" aria-hidden="true">
<SettingsGlyph />
<span
className={hasServiceFailure ? 'status-dot down sidebar-settings-status' : 'status-dot ok sidebar-settings-status'}
title={hasServiceFailure ? 'One or more services are failing.' : 'All services are healthy.'}
/>
</span>
<span>Settings</span>
</button>
<button className="ghost-button sidebar-exit" disabled={props.busy} onClick={props.onExitProfile} type="button">
<span className="door-icon sidebar-action-icon" aria-hidden="true">[]</span>
<span>Switch user</span>
</button>
</div>
</div>
</aside>
@@ -681,6 +683,15 @@ function LibraryGlyph() {
)
}
function SettingsGlyph() {
return (
<svg aria-hidden="true" className="sidebar-glyph-icon" fill="none" viewBox="0 0 16 16">
<path d="M6.45 2.1H9.55L10 3.7C10.38 3.85 10.74 4.05 11.06 4.29L12.65 3.84L14.2 6.52L13.04 7.7C13.08 7.9 13.1 8.1 13.1 8.3C13.1 8.5 13.08 8.7 13.04 8.9L14.2 10.08L12.65 12.76L11.06 12.31C10.74 12.55 10.38 12.75 10 12.9L9.55 14.5H6.45L6 12.9C5.62 12.75 5.26 12.55 4.94 12.31L3.35 12.76L1.8 10.08L2.96 8.9C2.92 8.7 2.9 8.5 2.9 8.3C2.9 8.1 2.92 7.9 2.96 7.7L1.8 6.52L3.35 3.84L4.94 4.29C5.26 4.05 5.62 3.85 6 3.7L6.45 2.1Z" stroke="currentColor" strokeLinejoin="round" strokeWidth="1.25" />
<circle cx="8" cy="8.3" r="2.05" stroke="currentColor" strokeWidth="1.25" />
</svg>
)
}
function ArrowRightGlyph() {
return (
<svg aria-hidden="true" className="sidebar-glyph-icon" fill="none" viewBox="0 0 16 16">
@@ -2617,116 +2628,6 @@ function SearchPage(props: {
)
}
function CollapsibleSection(props: {
title: string
count: number
children: ReactNode
}) {
const [isCollapsed, setIsCollapsed] = useState(false)
return (
<section className="downloads-section">
<button
className="downloads-section-header"
onClick={() => setIsCollapsed(!isCollapsed)}
type="button"
>
<h2>{props.title} ({props.count})</h2>
<span className={isCollapsed ? 'collapse-arrow' : 'collapse-arrow expanded'}>
<svg viewBox="0 0 16 16" fill="none" >
<path d="M4 6L8 10L12 6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</button>
{!isCollapsed && <div className="downloads-grid">{props.children}</div>}
</section>
)
}
function ProcessingCard(props: {
item: ProcessingItem,
onCancel: (id: string) => void
}) {
return (
<div className="download-card processing-card">
{props.item.backdropUrl && <img src={props.item.backdropUrl} className="download-card-backdrop" alt="" />}
<div className="download-card-overlay" />
<div className="download-card-content">
<div className="download-card-spinner">
<div className="loading-orb" />
</div>
<div className="download-card-info">
<h3>{props.item.title}</h3>
<p>{props.item.subtitle}</p>
<p className="download-card-user">Requested by: {props.item.user}</p>
</div>
<button
className="download-card-cancel"
onClick={() => props.onCancel(props.item.id)}
type="button"
>
×
</button>
</div>
</div>
)
}
function DownloadingCard(props: {
item: DownloadItem,
onCancel: (id: string) => void
}) {
return (
<div className="download-card downloading-card">
{props.item.backdropUrl && <img src={props.item.backdropUrl} className="download-card-backdrop" alt="" />}
<div className="download-card-overlay" />
<div className="download-card-content">
<div className="download-card-info">
<h3>{props.item.title}</h3>
<p>{props.item.subtitle}</p>
</div>
<button
className="download-card-cancel"
onClick={() => props.onCancel(props.item.id)}
type="button"
>
×
</button>
</div>
<div className="download-card-progress">
<div className="download-card-progress-bar" style={{ width: `${props.item.progress}%` }} />
</div>
<p className="download-card-details">{props.item.size} @ {props.item.eta}</p>
</div>
)
}
function CompletedCard(props: {
item: CompletedDownloadItem,
onDismiss: (id: string) => void
}) {
return (
<div className="download-card completed-card">
{props.item.backdropUrl && <img src={props.item.backdropUrl} className="download-card-backdrop" alt="" />}
<div className="download-card-overlay" />
<div className="download-card-content">
<div className="download-card-info">
<h3>{props.item.title}</h3>
<p>{props.item.subtitle}</p>
<p className="download-card-completed-badge">Completed</p>
</div>
<button
className="download-card-cancel"
onClick={() => props.onDismiss(props.item.id)}
type="button"
>
×
</button>
</div>
</div>
)
}
function DownloadsPage(props: {
downloads: DownloadItem[]
processingItems: ProcessingItem[]
-1
View File
@@ -4,7 +4,6 @@ import type {
AuthResponse,
CalendarResponse,
DashboardResponse,
DownloadItem,
DownloadsResponse,
HubPageResponse,
MediaCard,
+39 -40
View File
@@ -168,14 +168,12 @@ img {
background: rgba(255, 255, 255, 0.16);
}
.sidebar-settings,
.sidebar-exit {
.sidebar-settings {
background: transparent;
box-shadow: none;
}
.sidebar-settings:hover,
.sidebar-exit:hover {
.sidebar-settings:hover {
background: rgba(255, 255, 255, 0.12);
box-shadow: -3px 6px 13px rgba(0, 0, 0, 0.2);
}
@@ -191,8 +189,7 @@ img {
.sidebar-profile-copy,
.sidebar-link > span:last-child,
.sidebar-settings > span:last-child,
.sidebar-exit > span:last-child {
.sidebar-settings > span:last-child {
white-space: nowrap;
opacity: 0;
max-width: 0;
@@ -203,8 +200,7 @@ img {
.omniarr-sidebar:hover .sidebar-profile-copy,
.omniarr-sidebar:hover .sidebar-link > span:last-child,
.omniarr-sidebar:hover .sidebar-settings > span:last-child,
.omniarr-sidebar:hover .sidebar-exit > span:last-child {
.omniarr-sidebar:hover .sidebar-settings > span:last-child {
opacity: 1;
max-width: 12rem;
transform: translateX(0);
@@ -212,15 +208,13 @@ img {
.omniarr-sidebar:hover .sidebar-profile,
.omniarr-sidebar:hover .sidebar-link,
.omniarr-sidebar:hover .sidebar-settings,
.omniarr-sidebar:hover .sidebar-exit {
.omniarr-sidebar:hover .sidebar-settings {
justify-content: flex-start;
}
.omniarr-sidebar:hover .sidebar-profile,
.omniarr-sidebar:hover .sidebar-link,
.omniarr-sidebar:hover .sidebar-settings,
.omniarr-sidebar:hover .sidebar-exit {
.omniarr-sidebar:hover .sidebar-settings {
width: 100%;
min-width: 0;
max-width: none;
@@ -229,8 +223,7 @@ img {
.omniarr-sidebar:not(:hover) .sidebar-profile,
.omniarr-sidebar:not(:hover) .sidebar-link,
.omniarr-sidebar:not(:hover) .sidebar-settings,
.omniarr-sidebar:not(:hover) .sidebar-exit {
.omniarr-sidebar:not(:hover) .sidebar-settings {
gap: 0;
justify-content: center;
padding-left: 0;
@@ -317,15 +310,13 @@ img {
flex: 0 0 1.85rem;
}
.sidebar-settings,
.sidebar-exit {
.sidebar-settings {
display: flex;
align-items: center;
gap: 0.8rem;
}
.sidebar-settings,
.sidebar-exit {
.sidebar-settings {
width: 2.9rem;
min-width: 2.9rem;
max-width: 2.9rem;
@@ -334,32 +325,44 @@ img {
padding: 0.525rem;
}
.omniarr-sidebar:hover .sidebar-settings,
.omniarr-sidebar:hover .sidebar-exit {
.omniarr-sidebar:hover .sidebar-settings {
justify-content: flex-start;
}
.sidebar-settings-status {
width: 0.6rem;
height: 0.6rem;
margin-left: -0.4rem;
margin-right: 0.05rem;
}
.door-icon {
.sidebar-settings-icon {
position: relative;
display: inline-grid;
place-items: center;
width: 2rem;
height: 2rem;
border-radius: 0.7rem;
background: rgba(255, 255, 255, 0.1);
font-family: monospace;
letter-spacing: -0.12em;
color: #e8edf8;
flex: 0 0 auto;
}
.sidebar-link-short,
.sidebar-action-icon {
.sidebar-settings-status {
position: absolute;
inset: -0.2rem;
width: auto;
height: auto;
border: 0.12rem solid currentColor;
background: transparent;
box-shadow: none;
pointer-events: none;
}
.sidebar-settings-status.ok {
background: transparent;
color: #59df8a;
}
.sidebar-settings-status.down {
background: transparent;
color: #ff5d73;
}
.sidebar-link-short {
display: inline-grid;
place-items: center;
width: 1.85rem;
@@ -404,8 +407,7 @@ img {
}
.omniarr-sidebar:not(:hover) .sidebar-link,
.omniarr-sidebar:not(:hover) .sidebar-settings,
.omniarr-sidebar:not(:hover) .sidebar-exit {
.omniarr-sidebar:not(:hover) .sidebar-settings {
background: transparent;
box-shadow: none;
}
@@ -1133,15 +1135,13 @@ img {
font-weight: 700;
}
.sidebar-settings,
.sidebar-exit {
.sidebar-settings {
margin-top: 0;
background: transparent;
box-shadow: none;
}
.sidebar-settings:hover,
.sidebar-exit:hover {
.sidebar-settings:hover {
background: rgba(255, 255, 255, 0.12);
box-shadow: -3px 6px 13px rgba(0, 0, 0, 0.2);
}
@@ -1177,8 +1177,7 @@ img {
z-index: 1;
}
.omniarr-sidebar:not(:hover) .sidebar-settings,
.omniarr-sidebar:not(:hover) .sidebar-exit {
.omniarr-sidebar:not(:hover) .sidebar-settings {
background: transparent;
box-shadow: none;
}