Files
homelab-optimized/dashboard/ui/app/media/page.tsx
Gitea Mirror Bot e7652c8dab
Some checks failed
Documentation / Build Docusaurus (push) Failing after 5m3s
Documentation / Deploy to GitHub Pages (push) Has been skipped
Sanitized mirror from private repository - 2026-04-20 01:32:01 UTC
2026-04-20 01:32:01 +00:00

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>&middot;</span>
<span>{stream.device}</span>
{stream.bandwidth && (
<>
<span>&middot;</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>
);
}