727 lines
28 KiB
TypeScript
727 lines
28 KiB
TypeScript
"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>
|
|
);
|
|
}
|