feature: improve performance and add remaining storage

This commit is contained in:
WonderAliceUwU committed 2026-08-03 09:06:26 +02:00
1 parent 3462bbbcb0
commit 5013d16b35
7 files changed
+235 -8

No files matched your search

+2
View File
@@ -13,6 +13,8 @@ services:
environment:
PORT: "8787"
OMNIARR_DATA_DIR: /app/data
OMNIARR_STORAGE_PATH: /media
TMDB_BEARER_TOKEN: ""
volumes:
- ./data:/app/data
- ./media:/media:ro
+33 -1
View File
@@ -1,5 +1,5 @@
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
import { readFile } from 'node:fs/promises';
import { readFile, statfs } from 'node:fs/promises';
import { resolve } from 'node:path';
import {
defaultConfig,
@@ -7,6 +7,7 @@ import {
type AuthPayload,
type EpisodeRequestPayload,
type EpisodeWatchStateRequest,
type DiskSpaceStatus,
type SeasonDeletePayload,
type SeasonRequestPayload,
type TitleDetailsRequest
@@ -18,6 +19,7 @@ import {
getConnectionStatuses,
getDashboard,
getHubPage,
getMediaDiskSpace,
searchTitles,
getTitleDetails,
getTmdbLogos,
@@ -47,6 +49,8 @@ const SESSION_COOKIE = 'omniarr_session';
const SESSION_TTL_MS = 1000 * 60 * 60 * 12;
const sessions = new Map<string, SessionRecord>();
const failedAttempts = new Map<string, { count: number; lastAttempt: number }>();
const DATA_DIR = resolve(process.env.OMNIARR_DATA_DIR || resolve(process.cwd(), 'data'));
const STORAGE_DIR = resolve(process.env.OMNIARR_STORAGE_PATH || DATA_DIR);
const server = createServer(async (request, response) => {
try {
@@ -214,6 +218,12 @@ async function routeRequest(request: IncomingMessage, response: ServerResponse):
return;
}
if (url.pathname === '/api/storage' && method === 'GET') {
const config = await readConfig(session!.encryptionKey);
sendJson(response, 200, await getDiskSpaceStatus(config));
return;
}
if (url.pathname.startsWith('/api/hubs/') && method === 'GET') {
const config = await readConfig(session!.encryptionKey);
const page = decodeURIComponent(url.pathname.replace('/api/hubs/', ''));
@@ -402,6 +412,28 @@ function createSession(encryptionKey: Buffer): string {
return token;
}
async function getDiskSpaceStatus(config: AppConfig): Promise<DiskSpaceStatus> {
if (process.env.OMNIARR_STORAGE_PATH) {
return getFilesystemDiskSpace(STORAGE_DIR);
}
const mediaDiskSpace = await getMediaDiskSpace(config);
if (mediaDiskSpace) {
return mediaDiskSpace;
}
return getFilesystemDiskSpace(DATA_DIR);
}
async function getFilesystemDiskSpace(path: string): Promise<DiskSpaceStatus> {
const stats = await statfs(path);
return {
totalBytes: Number(stats.bsize) * Number(stats.blocks),
availableBytes: Number(stats.bsize) * Number(stats.bavail),
path
};
}
function getSession(request: IncomingMessage): SessionRecord | null {
const token = getSessionToken(request);
if (!token) {
+35
View File
@@ -5,6 +5,7 @@ import type {
CompletedDownloadItem,
ConnectionStatus,
DashboardResponse,
DiskSpaceStatus,
DownloadItem,
DownloadsResponse,
EpisodeRequestPayload,
@@ -440,6 +441,40 @@ export async function getServiceOptions(config: AppConfig): Promise<ServiceOptio
};
}
export async function getMediaDiskSpace(config: AppConfig): Promise<DiskSpaceStatus | null> {
const services = [
{ baseUrl: config.radarr.baseUrl, apiKey: config.radarr.apiKey, rootFolderPath: config.radarr.rootFolderPath },
{ baseUrl: config.sonarr.baseUrl, apiKey: config.sonarr.apiKey, rootFolderPath: config.sonarr.rootFolderPath }
];
for (const service of services) {
if (!service.baseUrl || !service.apiKey) {
continue;
}
try {
const folders = await fetchJson<Record<string, unknown>[]>(`${service.baseUrl}/api/v3/rootFolder`, {
headers: { 'X-Api-Key': service.apiKey }
});
const folder = folders.find((entry) => String(entry.path || '') === service.rootFolderPath) || folders[0];
const totalBytes = Number(folder?.totalSpace);
const availableBytes = Number(folder?.freeSpace);
if (Number.isFinite(totalBytes) && totalBytes > 0 && Number.isFinite(availableBytes) && availableBytes >= 0) {
return {
totalBytes,
availableBytes,
path: String(folder?.path || service.rootFolderPath)
};
}
} catch {
// The local data volume remains a useful fallback when an *arr service is unavailable.
}
}
return null;
}
export async function loadJellyfinUsers(baseUrl: string, apiKey: string): Promise<SelectOption[]> {
if (!baseUrl.trim() || !apiKey.trim()) {
return [];
+6
View File
@@ -85,6 +85,12 @@ export interface ConnectionStatus {
message: string;
}
export interface DiskSpaceStatus {
totalBytes: number;
availableBytes: number;
path?: string;
}
export interface MediaCard {
id: string;
tmdbId?: number;
+49 -1
View File
@@ -19,6 +19,7 @@ import {
type ConnectionStatus,
type CompletedDownloadItem,
type DownloadItem,
type DiskSpaceStatus,
type HubPageResponse,
type MediaCard,
type ProcessingItem,
@@ -86,6 +87,7 @@ function App() {
const [processingItems, setProcessingItems] = useState<ProcessingItem[]>([])
const [completedDownloads, setCompletedDownloads] = useState<CompletedDownloadItem[]>([])
const [connections, setConnections] = useState<ConnectionStatus[]>([])
const [diskSpace, setDiskSpace] = useState<DiskSpaceStatus | null>(null)
const [hubPages, setHubPages] = useState<Partial<Record<HubPageResponse['page'], HubPageResponse>>>({})
const [hubLoading, setHubLoading] = useState(false)
@@ -120,10 +122,11 @@ function App() {
}
async function refreshBaseData() {
const [settings, serviceOptions, dashboard] = await Promise.all([
const [settings, serviceOptions, dashboard, storage] = await Promise.all([
api.getSettings(),
api.getOptions(),
api.getDashboard(),
api.getDiskSpace(),
])
setConfig(settings)
@@ -132,6 +135,7 @@ function App() {
setProcessingItems(dashboard.processing || [])
setCompletedDownloads(dashboard.completedDownloads || [])
setConnections(dashboard.connections)
setDiskSpace(storage)
setEntryStage(resolveEntryStage(settings, serviceOptions))
}
@@ -477,6 +481,7 @@ function App() {
busy={busy}
connections={connections}
currentUser={currentJellyfinUser}
diskSpace={diskSpace}
onExitProfile={() => void transitionEntryStage('profiles')}
route={route}
setRoute={setRoute}
@@ -556,6 +561,7 @@ function Sidebar(props: {
route: AppRoute
setRoute: (route: AppRoute) => void
currentUser: string
diskSpace: DiskSpaceStatus | null
connections: ConnectionStatus[]
busy: boolean
onExitProfile: () => void
@@ -628,6 +634,7 @@ function Sidebar(props: {
</div>
<div className="sidebar-footer">
{props.diskSpace ? <DiskSpaceIndicator diskSpace={props.diskSpace} /> : null}
<button className="ghost-button sidebar-settings" onClick={() => startTransition(() => props.setRoute('settings'))} type="button">
<span className="sidebar-settings-icon" aria-hidden="true">
<SettingsGlyph />
@@ -644,6 +651,37 @@ function Sidebar(props: {
)
}
function DiskSpaceIndicator({ diskSpace }: { diskSpace: DiskSpaceStatus }) {
const availablePercentage = diskSpace.totalBytes > 0
? Math.max(0, Math.min(100, (diskSpace.availableBytes / diskSpace.totalBytes) * 100))
: 0
return (
<div className="sidebar-disk" title={`${formatBytes(diskSpace.availableBytes)} free of ${formatBytes(diskSpace.totalBytes)}${diskSpace.path ? ` (${diskSpace.path})` : ''}`}>
<span className="sidebar-disk-ring" style={{ '--disk-progress': `${availablePercentage}%` } as CSSProperties}>
<span className="sidebar-disk-icon" aria-hidden="true">
<DiskGlyph />
</span>
</span>
<span className="sidebar-disk-copy">
<strong>Disk space</strong>
<span>{formatBytes(diskSpace.availableBytes)} free</span>
</span>
</div>
)
}
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) {
return '0 B'
}
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const unitIndex = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
const value = bytes / (1024 ** unitIndex)
return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`
}
function LoadingScreen() {
return (
<div className="loading-screen">
@@ -692,6 +730,16 @@ function SettingsGlyph() {
)
}
function DiskGlyph() {
return (
<svg aria-hidden="true" className="sidebar-glyph-icon" fill="none" viewBox="0 0 16 16">
<rect x="2.4" y="3" width="11.2" height="10" rx="2" stroke="currentColor" strokeWidth="1.4" />
<path d="M4.8 6H11.2" stroke="currentColor" strokeLinecap="round" strokeWidth="1.4" />
<circle cx="10.9" cy="9.5" r="0.8" fill="currentColor" />
</svg>
)
}
function ArrowRightGlyph() {
return (
<svg aria-hidden="true" className="sidebar-glyph-icon" fill="none" viewBox="0 0 16 16">
+2
View File
@@ -4,6 +4,7 @@ import type {
AuthResponse,
CalendarResponse,
DashboardResponse,
DiskSpaceStatus,
DownloadsResponse,
HubPageResponse,
MediaCard,
@@ -83,6 +84,7 @@ export const api = {
body: JSON.stringify(payload),
}),
getDashboard: () => request<DashboardResponse>('/api/dashboard'),
getDiskSpace: () => request<DiskSpaceStatus>('/api/storage'),
getCalendar: (start: Date, end: Date) => request<CalendarResponse>(`/api/calendar?start=${start.toISOString()}&end=${end.toISOString()}`),
getHub: (page: HubPageResponse['page']) => request<HubPageResponse>(`/api/hubs/${page}`),
searchTitles: (query: string) => request<SearchResponse>(`/api/search?query=${encodeURIComponent(query)}`),
+108 -6
View File
@@ -107,7 +107,8 @@ img {
width: 5.75rem;
padding: 1rem;
overflow: hidden;
transition: width 220ms ease;
transition: width 260ms cubic-bezier(0.22, 1, 0.36, 1);
will-change: width;
filter: drop-shadow(0 1.4rem 2rem rgba(0, 0, 0, 0.34));
}
@@ -295,6 +296,85 @@ img {
gap: 0.75rem;
}
.sidebar-disk {
display: flex;
align-items: center;
gap: 0.8rem;
width: 2.9rem;
min-width: 2.9rem;
max-width: 2.9rem;
overflow: hidden;
padding: 0.525rem;
}
.sidebar-disk-ring {
display: grid;
place-items: center;
width: 2rem;
height: 2rem;
padding: 0.14rem;
border-radius: 999px;
background: conic-gradient(#ffffff var(--disk-progress), rgba(255, 255, 255, 0.14) 0);
flex: 0 0 auto;
}
.sidebar-disk-icon {
display: grid;
place-items: center;
width: 100%;
height: 100%;
border-radius: 999px;
background: #171a20;
color: #e8edf8;
}
.sidebar-disk-copy {
display: grid;
gap: 0.15rem;
min-width: 0;
white-space: nowrap;
opacity: 0;
max-width: 0;
overflow: hidden;
transform: translateX(-0.2rem);
transition: opacity 180ms ease, transform 180ms ease, max-width 180ms ease;
}
.sidebar-disk-copy strong,
.sidebar-disk-copy span {
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar-disk-copy strong {
font-size: 0.84rem;
}
.sidebar-disk-copy span {
color: #9ba5b8;
font-size: 0.74rem;
}
.omniarr-sidebar:hover .sidebar-disk {
width: 100%;
min-width: 0;
max-width: none;
justify-content: flex-start;
padding: 0.75rem;
}
.omniarr-sidebar:hover .sidebar-disk-copy {
opacity: 1;
max-width: 12rem;
transform: translateX(0);
}
.omniarr-sidebar:not(:hover) .sidebar-disk {
justify-content: center;
padding-left: 0;
padding-right: 0;
}
.sidebar-avatar {
width: 1.85rem;
height: 1.85rem;
@@ -412,6 +492,33 @@ img {
box-shadow: none;
}
/* Keep footer controls on the same responsive alignment grid. */
.sidebar-footer > .sidebar-disk,
.sidebar-footer > .sidebar-settings {
width: 2.9rem;
min-width: 2.9rem;
max-width: 2.9rem;
margin-top: 0;
padding: 0.525rem;
}
.omniarr-sidebar:hover .sidebar-footer > .sidebar-disk,
.omniarr-sidebar:hover .sidebar-footer > .sidebar-settings {
width: 100%;
min-width: 0;
max-width: none;
justify-content: flex-start;
padding: 0.75rem;
}
.omniarr-sidebar:not(:hover) .sidebar-footer > .sidebar-disk,
.omniarr-sidebar:not(:hover) .sidebar-footer > .sidebar-settings {
gap: 0;
justify-content: center;
padding-left: 0;
padding-right: 0;
}
.omniarr-sidebar:not(:hover) .sidebar-link.active {
background: transparent;
box-shadow: none;
@@ -422,11 +529,6 @@ img {
min-width: 0;
padding: calc(1rem - 1px) 0 1rem;
margin-left: 5.75rem;
transition: margin-left 220ms ease;
}
.omniarr-app:has(.omniarr-sidebar:hover) .content-shell {
margin-left: 15rem;
}
.content-panel {