mirror of
https://github.com/WonderAliceUwU/OmniArr.git
synced 2026-11-11 04:01:01 +00:00
feature: improve performance and add remaining storage
This commit is contained in:
1 parent
3462bbbcb0
commit
5013d16b35
7 files changed
+235
-8
No files matched your search
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user