Sanitized mirror from private repository - 2026-04-08 03:19:28 UTC
This commit is contained in:
726
dashboard/ui/app/media/page.tsx
Normal file
726
dashboard/ui/app/media/page.tsx
Normal file
@@ -0,0 +1,726 @@
|
||||
"use client";
|
||||
|
||||
import { usePoll } from "@/lib/use-poll";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { JellyfinLatestItem, ArrHistoryItem } from "@/lib/types";
|
||||
import { CardSkeleton } from "@/components/skeleton";
|
||||
import { EmptyState } from "@/components/empty-state";
|
||||
import { TdarrCard } from "@/components/tdarr-card";
|
||||
|
||||
interface QueueItem {
|
||||
title: string;
|
||||
status: string;
|
||||
size?: string;
|
||||
timeleft?: string;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
interface SonarrQueueItem {
|
||||
title: string;
|
||||
status: string;
|
||||
sizeleft?: string;
|
||||
timeleft?: string;
|
||||
}
|
||||
|
||||
interface RadarrQueueItem {
|
||||
title: string;
|
||||
status: string;
|
||||
sizeleft?: string;
|
||||
timeleft?: string;
|
||||
}
|
||||
|
||||
interface SabQueue {
|
||||
slots: QueueItem[];
|
||||
speed: string;
|
||||
paused: boolean;
|
||||
}
|
||||
|
||||
interface ProwlarrStats {
|
||||
total: number;
|
||||
enabled: number;
|
||||
indexers: { name: string; protocol: string }[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface BazarrStatus {
|
||||
version?: string;
|
||||
sonarr_signalr?: string;
|
||||
radarr_signalr?: string;
|
||||
wanted_episodes?: number;
|
||||
wanted_movies?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ABSLibrary {
|
||||
name: string;
|
||||
type: string;
|
||||
items: number;
|
||||
}
|
||||
|
||||
interface ABSStats {
|
||||
libraries: ABSLibrary[];
|
||||
total: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface DelugeStatus {
|
||||
available: boolean;
|
||||
total?: number;
|
||||
active?: number;
|
||||
downloading?: number;
|
||||
seeding?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface JellyfinStatus {
|
||||
version: string;
|
||||
server_name: string;
|
||||
libraries: { name: string; type: string }[];
|
||||
active_sessions: {
|
||||
user: string;
|
||||
client: string;
|
||||
device: string;
|
||||
now_playing: string;
|
||||
type: string;
|
||||
}[];
|
||||
idle_sessions: number;
|
||||
}
|
||||
|
||||
interface PlexSession {
|
||||
title: string;
|
||||
type: string;
|
||||
year?: string;
|
||||
player?: string;
|
||||
platform?: string;
|
||||
device?: string;
|
||||
state?: string;
|
||||
local?: boolean;
|
||||
user?: string;
|
||||
bandwidth?: string;
|
||||
location?: string;
|
||||
video_resolution?: string;
|
||||
video_codec?: string;
|
||||
media_bitrate?: string;
|
||||
transcode?: boolean;
|
||||
video_decision?: string;
|
||||
transcode_speed?: string;
|
||||
}
|
||||
|
||||
interface PlexServer {
|
||||
name: string;
|
||||
url: string;
|
||||
online: boolean;
|
||||
sessions: PlexSession[];
|
||||
libraries: { title: string; type: string }[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface PlexStatus {
|
||||
servers: PlexServer[];
|
||||
}
|
||||
|
||||
const serverColors: Record<string, string> = {
|
||||
calypso: "text-violet-400",
|
||||
atlantis: "text-blue-400",
|
||||
jellyfin: "text-cyan-400",
|
||||
};
|
||||
|
||||
function colorizeServerName(name: string): string {
|
||||
const lower = name.toLowerCase();
|
||||
for (const [key, cls] of Object.entries(serverColors)) {
|
||||
if (lower.includes(key)) return cls;
|
||||
}
|
||||
return "text-foreground";
|
||||
}
|
||||
|
||||
const libraryTypeColors: Record<string, string> = {
|
||||
movies: "text-blue-400",
|
||||
movie: "text-blue-400",
|
||||
tvshows: "text-violet-400",
|
||||
tvshow: "text-violet-400",
|
||||
series: "text-violet-400",
|
||||
music: "text-green-400",
|
||||
anime: "text-pink-400",
|
||||
};
|
||||
|
||||
function getLibraryTypeColor(type: string): string {
|
||||
return libraryTypeColors[type.toLowerCase()] ?? "text-foreground";
|
||||
}
|
||||
|
||||
const serviceNameColors: Record<string, string> = {
|
||||
sonarr: "text-blue-400",
|
||||
radarr: "text-amber-400",
|
||||
prowlarr: "text-violet-400",
|
||||
bazarr: "text-green-400",
|
||||
};
|
||||
|
||||
function formatShortDate(dateStr: string): string {
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("en-US", { month: "short", day: "2-digit" });
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
const eventColors: Record<string, string> = {
|
||||
grabbed: "text-blue-400",
|
||||
imported: "text-green-400",
|
||||
downloadfolderimported: "text-green-400",
|
||||
downloadfailed: "text-red-400",
|
||||
deleted: "text-red-400",
|
||||
renamed: "text-amber-400",
|
||||
upgraded: "text-violet-400",
|
||||
};
|
||||
|
||||
function getEventColor(event: string): string {
|
||||
return eventColors[event.toLowerCase()] ?? "text-muted-foreground";
|
||||
}
|
||||
|
||||
const mediaTypeColors: Record<string, string> = {
|
||||
movie: "text-blue-400",
|
||||
episode: "text-violet-400",
|
||||
series: "text-violet-400",
|
||||
season: "text-violet-400",
|
||||
audio: "text-green-400",
|
||||
musicalbum: "text-green-400",
|
||||
};
|
||||
|
||||
export default function MediaPage() {
|
||||
const { data: jellyfin } = usePoll<JellyfinStatus>("/api/jellyfin/status", 30000);
|
||||
const { data: plex } = usePoll<PlexStatus>("/api/plex/status", 30000);
|
||||
const { data: sonarrRaw } = usePoll<Record<string, unknown>>("/api/sonarr/queue", 30000);
|
||||
const { data: radarrRaw } = usePoll<Record<string, unknown>>("/api/radarr/queue", 30000);
|
||||
const { data: sabRaw } = usePoll<Record<string, unknown>>("/api/sabnzbd/queue", 30000);
|
||||
const { data: prowlarr } = usePoll<ProwlarrStats>("/api/prowlarr/stats", 60000);
|
||||
const { data: bazarr } = usePoll<BazarrStatus>("/api/bazarr/status", 60000);
|
||||
const { data: abs } = usePoll<ABSStats>("/api/audiobookshelf/stats", 60000);
|
||||
const { data: deluge } = usePoll<DelugeStatus>("/api/deluge/status", 30000);
|
||||
const { data: jellyfinLatest } = usePoll<JellyfinLatestItem[]>("/api/jellyfin/latest", 60000);
|
||||
const { data: sonarrHistory } = usePoll<ArrHistoryItem[]>("/api/sonarr/history", 60000);
|
||||
const { data: radarrHistory } = usePoll<ArrHistoryItem[]>("/api/radarr/history", 60000);
|
||||
|
||||
const sonarr = (sonarrRaw?.records ?? sonarrRaw?.items ?? []) as SonarrQueueItem[];
|
||||
const radarr = (radarrRaw?.records ?? radarrRaw?.items ?? []) as RadarrQueueItem[];
|
||||
const sab = sabRaw?.queue as SabQueue | undefined;
|
||||
|
||||
// Collect all active streams from both Jellyfin and Plex
|
||||
const allStreams: {
|
||||
title: string; player: string; device: string; source: string;
|
||||
transcode?: boolean; bandwidth?: string; state?: string;
|
||||
user?: string; quality?: string; location?: string;
|
||||
}[] = [];
|
||||
|
||||
if (jellyfin?.active_sessions) {
|
||||
for (const s of jellyfin.active_sessions) {
|
||||
allStreams.push({
|
||||
title: s.now_playing,
|
||||
player: s.client,
|
||||
device: s.device,
|
||||
source: `Jellyfin (${jellyfin.server_name})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (plex?.servers) {
|
||||
for (const server of plex.servers) {
|
||||
for (const s of server.sessions) {
|
||||
const quality = [
|
||||
s.video_resolution?.toUpperCase(),
|
||||
s.video_codec?.toUpperCase(),
|
||||
].filter(Boolean).join(" ");
|
||||
allStreams.push({
|
||||
title: s.title,
|
||||
player: s.player ?? "?",
|
||||
device: s.device ?? s.platform ?? "?",
|
||||
source: `Plex (${server.name})`,
|
||||
transcode: s.transcode,
|
||||
bandwidth: s.bandwidth,
|
||||
state: s.state,
|
||||
user: s.user,
|
||||
quality,
|
||||
location: s.location,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<h1 className="text-lg font-semibold">Media</h1>
|
||||
|
||||
{/* Tdarr Cluster */}
|
||||
<TdarrCard />
|
||||
|
||||
{/* Now Playing Hero */}
|
||||
<Card className="overflow-hidden relative">
|
||||
<CardHeader className="pb-2 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold">Now Playing</CardTitle>
|
||||
{allStreams.length > 0 && (
|
||||
<Badge variant="outline" className="text-xs border-green-500/30 text-green-400 bg-green-500/5">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-green-400 mr-1.5 glow-green" />
|
||||
{allStreams.length} stream{allStreams.length !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{allStreams.length === 0 ? (
|
||||
<EmptyState icon={">"} title="Nothing playing" description="Start something on Jellyfin or Plex" />
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{allStreams.map((stream, i) => (
|
||||
<div key={i} className="rounded-xl bg-white/[0.03] border border-white/[0.06] px-4 py-3 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-base font-semibold text-foreground truncate">{stream.title}</p>
|
||||
{stream.user && (
|
||||
<span className="text-xs text-muted-foreground/80 shrink-0">{stream.user}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs bg-white/[0.06] border border-white/[0.08]">
|
||||
{stream.source}
|
||||
</Badge>
|
||||
{stream.state && (
|
||||
<StatusBadge
|
||||
color={stream.state === "playing" ? "green" : "amber"}
|
||||
label={stream.state}
|
||||
/>
|
||||
)}
|
||||
{stream.quality && (
|
||||
<Badge variant="secondary" className="text-xs bg-blue-500/10 border border-blue-500/20 text-blue-400">
|
||||
{stream.quality}
|
||||
</Badge>
|
||||
)}
|
||||
{stream.transcode && (
|
||||
<Badge variant="secondary" className="text-xs bg-amber-500/10 border border-amber-500/20 text-amber-400">
|
||||
Transcoding
|
||||
</Badge>
|
||||
)}
|
||||
{stream.location && (
|
||||
<Badge variant="secondary" className={`text-xs ${stream.location === "lan" ? "bg-green-500/10 border-green-500/20 text-green-400" : "bg-purple-500/10 border-purple-500/20 text-purple-400"}`}>
|
||||
{stream.location.toUpperCase()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>{stream.player}</span>
|
||||
<span>·</span>
|
||||
<span>{stream.device}</span>
|
||||
{stream.bandwidth && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{(Number(stream.bandwidth) / 1000).toFixed(1)} Mbps</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recently Added + Download History */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||
{/* Recently Added to Jellyfin */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-semibold text-cyan-400">Recently Added</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!jellyfinLatest ? (
|
||||
<CardSkeleton lines={5} />
|
||||
) : jellyfinLatest.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground/60">No recent additions</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{jellyfinLatest.slice(0, 8).map((item, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-sm rounded-lg px-2 py-1.5 hover:bg-white/[0.03] transition-colors">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground font-medium truncate">
|
||||
{item.series ? `${item.series}: ${item.name}` : item.name}
|
||||
{item.year ? ` (${item.year})` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0 ml-3">
|
||||
<span className={`text-xs ${mediaTypeColors[item.type.toLowerCase()] ?? "text-muted-foreground"}`}>
|
||||
{item.type}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
{formatShortDate(item.date)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sonarr History */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className={`text-base font-semibold ${serviceNameColors.sonarr}`}>Sonarr History</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!sonarrHistory ? (
|
||||
<CardSkeleton lines={5} />
|
||||
) : sonarrHistory.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground/60">No recent activity</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sonarrHistory.slice(0, 8).map((item, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-sm rounded-lg px-2 py-1.5 hover:bg-white/[0.03] transition-colors">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground font-medium truncate">{item.title}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 ml-3">
|
||||
<span className={`text-xs ${getEventColor(item.event)}`}>{item.event}</span>
|
||||
<span className="text-xs text-muted-foreground/60 font-mono">{item.quality}</span>
|
||||
<span className="text-xs text-muted-foreground/50">{formatShortDate(item.date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Radarr History */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className={`text-base font-semibold ${serviceNameColors.radarr}`}>Radarr History</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!radarrHistory ? (
|
||||
<CardSkeleton lines={5} />
|
||||
) : radarrHistory.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground/60">No recent activity</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{radarrHistory.slice(0, 8).map((item, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-sm rounded-lg px-2 py-1.5 hover:bg-white/[0.03] transition-colors">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-foreground font-medium truncate">{item.title}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 ml-3">
|
||||
<span className={`text-xs ${getEventColor(item.event)}`}>{item.event}</span>
|
||||
<span className="text-xs text-muted-foreground/60 font-mono">{item.quality}</span>
|
||||
<span className="text-xs text-muted-foreground/50">{formatShortDate(item.date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Media Servers -- 2 columns */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{/* Jellyfin */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2 flex flex-row items-center justify-between">
|
||||
<CardTitle className={`text-base font-semibold ${serverColors.jellyfin}`}>Jellyfin</CardTitle>
|
||||
{jellyfin && (
|
||||
<StatusBadge color="green" label={`v${jellyfin.version}`} />
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{!jellyfin ? (
|
||||
<CardSkeleton lines={4} />
|
||||
) : (
|
||||
<>
|
||||
<p className={`text-sm ${colorizeServerName(jellyfin.server_name)}`}>{jellyfin.server_name}</p>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs uppercase tracking-wider text-muted-foreground/70 font-medium">Libraries</p>
|
||||
{jellyfin.libraries.map((lib) => (
|
||||
<div key={lib.name} className="flex items-center justify-between text-sm">
|
||||
<span className="text-foreground">{lib.name}</span>
|
||||
<span className={`text-xs font-medium ${getLibraryTypeColor(lib.type)}`}>{lib.type || "library"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{jellyfin.idle_sessions > 0 && (
|
||||
<p className="text-xs text-muted-foreground/60">
|
||||
{jellyfin.idle_sessions} idle session{jellyfin.idle_sessions > 1 ? "s" : ""}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Plex */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-semibold">Plex</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{!plex ? (
|
||||
<CardSkeleton lines={4} />
|
||||
) : (
|
||||
plex.servers.map((server) => (
|
||||
<div key={server.name} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-sm font-medium ${colorizeServerName(server.name)}`}>{server.name}</span>
|
||||
<StatusBadge
|
||||
color={server.online ? "green" : "red"}
|
||||
label={server.online ? "Online" : "Offline"}
|
||||
/>
|
||||
</div>
|
||||
{server.online && server.libraries.length > 0 && (
|
||||
<div className="space-y-1 pl-2">
|
||||
{server.libraries.map((lib, j) => (
|
||||
<div key={j} className="flex items-center justify-between text-sm">
|
||||
<span className="text-foreground/80">{lib.title}</span>
|
||||
<span className={`text-xs ${getLibraryTypeColor(lib.type)}`}>{lib.type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{server.error && (
|
||||
<p className="text-xs text-red-400 pl-2">{server.error}</p>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Downloads & Services */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||
{/* Sonarr Queue */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className={`text-base font-semibold ${serviceNameColors.sonarr}`}>Sonarr Queue</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!sonarr ? (
|
||||
<CardSkeleton lines={3} />
|
||||
) : sonarr.length === 0 ? (
|
||||
<EmptyState icon={"v"} title="Queue empty" description="Nothing downloading" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sonarr.map((item, i) => (
|
||||
<div key={i} className="space-y-1 rounded-lg px-2 py-1.5 hover:bg-white/[0.03] transition-colors">
|
||||
<p className="text-sm text-foreground font-medium truncate">{item.title}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge
|
||||
color={item.status === "completed" ? "green" : item.status === "downloading" ? "blue" : "amber"}
|
||||
label={item.status}
|
||||
/>
|
||||
{item.timeleft && (
|
||||
<span className="text-xs text-muted-foreground/70">{item.timeleft}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Radarr Queue */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className={`text-base font-semibold ${serviceNameColors.radarr}`}>Radarr Queue</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!radarr ? (
|
||||
<CardSkeleton lines={3} />
|
||||
) : radarr.length === 0 ? (
|
||||
<EmptyState icon={"v"} title="Queue empty" description="Nothing downloading" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{radarr.map((item, i) => (
|
||||
<div key={i} className="space-y-1 rounded-lg px-2 py-1.5 hover:bg-white/[0.03] transition-colors">
|
||||
<p className="text-sm text-foreground font-medium truncate">{item.title}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge
|
||||
color={item.status === "completed" ? "green" : item.status === "downloading" ? "blue" : "amber"}
|
||||
label={item.status}
|
||||
/>
|
||||
{item.timeleft && (
|
||||
<span className="text-xs text-muted-foreground/70">{item.timeleft}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* SABnzbd + Deluge combined Downloads card */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold">Downloads</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{sab && (
|
||||
<StatusBadge color={sab.paused ? "amber" : "green"} label={sab.paused ? "SAB Paused" : `SAB ${sab.speed}`} />
|
||||
)}
|
||||
{deluge && (
|
||||
<StatusBadge color={deluge.available ? "green" : "red"} label={deluge.available ? "Deluge" : "Deluge Off"} />
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* SABnzbd */}
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-muted-foreground/70 font-medium mb-2">SABnzbd</p>
|
||||
{!sab ? (
|
||||
<CardSkeleton lines={2} />
|
||||
) : sab.slots.length === 0 ? (
|
||||
<EmptyState icon={"v"} title="Queue empty" description="Nothing downloading" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sab.slots.map((item, i) => (
|
||||
<div key={i} className="space-y-1 rounded-lg px-2 py-1.5 hover:bg-white/[0.03] transition-colors">
|
||||
<p className="text-sm text-foreground font-medium truncate">{item.title}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge color={item.status === "Downloading" ? "blue" : "amber"} label={item.status} />
|
||||
{item.timeleft && <span className="text-xs text-muted-foreground/70">{item.timeleft}</span>}
|
||||
</div>
|
||||
{item.progress != null && (
|
||||
<div className="glass-bar-track h-1.5">
|
||||
<div
|
||||
className="h-full glass-bar-fill bg-gradient-to-r from-blue-500 to-cyan-400"
|
||||
style={{ width: `${item.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Deluge */}
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wider text-muted-foreground/70 font-medium mb-2">Deluge</p>
|
||||
{!deluge ? (
|
||||
<CardSkeleton lines={2} />
|
||||
) : !deluge.available ? (
|
||||
<p className="text-sm text-red-400">{deluge.error ?? "Unreachable"}</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-3 text-center">
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-foreground">{deluge.total}</p>
|
||||
<p className="text-xs text-muted-foreground">Total</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-blue-400">{deluge.downloading}</p>
|
||||
<p className="text-xs text-muted-foreground">Downloading</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-purple-400">{deluge.seeding}</p>
|
||||
<p className="text-xs text-muted-foreground">Seeding</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Prowlarr + Bazarr combined */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base font-semibold">Indexers & Subtitles</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Prowlarr */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className={`text-xs uppercase tracking-wider font-medium ${serviceNameColors.prowlarr}`}>Prowlarr</p>
|
||||
{prowlarr && !prowlarr.error && (
|
||||
<StatusBadge color="green" label={`${prowlarr.enabled} active`} />
|
||||
)}
|
||||
</div>
|
||||
{!prowlarr ? (
|
||||
<CardSkeleton lines={3} />
|
||||
) : prowlarr.error ? (
|
||||
<p className="text-sm text-red-400">{prowlarr.error}</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
{prowlarr.enabled}/{prowlarr.total} indexers enabled
|
||||
</p>
|
||||
{prowlarr.indexers.slice(0, 5).map((idx, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-sm">
|
||||
<span className="text-foreground truncate">{idx.name}</span>
|
||||
<span className="text-xs text-muted-foreground/60 ml-2 shrink-0">{idx.protocol}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Bazarr */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className={`text-xs uppercase tracking-wider font-medium ${serviceNameColors.bazarr}`}>Bazarr</p>
|
||||
{bazarr && !bazarr.error && (
|
||||
<StatusBadge color="green" label={bazarr.version} />
|
||||
)}
|
||||
</div>
|
||||
{!bazarr ? (
|
||||
<CardSkeleton lines={3} />
|
||||
) : bazarr.error ? (
|
||||
<p className="text-sm text-red-400">{bazarr.error}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground/70">Wanted episodes</span>
|
||||
<span className="text-foreground font-medium">{bazarr.wanted_episodes}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground/70">Wanted movies</span>
|
||||
<span className="text-foreground font-medium">{bazarr.wanted_movies}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-muted-foreground/70">SignalR</span>
|
||||
<StatusBadge color={bazarr.sonarr_signalr === "LIVE" ? "green" : "red"} label={`Sonarr ${bazarr.sonarr_signalr}`} />
|
||||
<StatusBadge color={bazarr.radarr_signalr === "LIVE" ? "green" : "red"} label={`Radarr ${bazarr.radarr_signalr ?? ""}`} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Audiobookshelf */}
|
||||
<Card>
|
||||
<CardHeader className="pb-2 flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base font-semibold">Audiobookshelf</CardTitle>
|
||||
{abs && !abs.error && (
|
||||
<Badge variant="secondary" className="text-xs bg-white/[0.06] border border-white/[0.08]">
|
||||
{abs.total} items
|
||||
</Badge>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!abs ? (
|
||||
<CardSkeleton lines={3} />
|
||||
) : abs.error ? (
|
||||
<p className="text-sm text-red-400">{abs.error}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{abs.libraries.map((lib, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-sm">
|
||||
<span className="text-foreground font-medium">{lib.name}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold text-foreground">{lib.items}</span>
|
||||
<span className="text-xs text-muted-foreground">{lib.type}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user