feat(widgets): add missing & queued media widget for Radarr and Sonarr (#6078)
Co-authored-by: Thomas <thomas@ajnart.fr>
This commit is contained in:
1 parent
713fcd6f3f
commit
9fb2e37605
26 files changed
+930
-10
No files matched your search
@@ -33,6 +33,7 @@ import * as mediaRequestsList from "./media-requests/list";
|
||||
import * as mediaRequestsStats from "./media-requests/stats";
|
||||
import * as mediaServer from "./media-server";
|
||||
import * as mediaTranscoding from "./media-transcoding";
|
||||
import * as mediaMissing from "./media-missing";
|
||||
import * as minecraftServerStatus from "./minecraft/server-status";
|
||||
import * as networkControllerStatus from "./network-controller/network-status";
|
||||
import * as networkControllerSummary from "./network-controller/summary";
|
||||
@@ -91,6 +92,7 @@ export const widgetImports = {
|
||||
downloads,
|
||||
"mediaRequests-requestList": mediaRequestsList,
|
||||
"mediaRequests-requestStats": mediaRequestsStats,
|
||||
mediaMissing,
|
||||
networkControllerSummary,
|
||||
networkControllerStatus,
|
||||
rssFeed,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
.card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border: 1px solid var(--border-color);
|
||||
transition:
|
||||
border-color 120ms ease,
|
||||
transform 120ms ease,
|
||||
box-shadow 120ms ease;
|
||||
|
||||
@mixin dark {
|
||||
--border-color: var(--mantine-color-dark-4);
|
||||
}
|
||||
@mixin light {
|
||||
--border-color: var(--mantine-color-gray-3);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--mantine-primary-color-filled);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--mantine-shadow-sm);
|
||||
}
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
filter: blur(20px) saturate(1.2);
|
||||
transform: scale(1.3);
|
||||
opacity: 0.16;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.poster {
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
box-shadow: var(--mantine-shadow-sm);
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Badge, Box, Center, Group, Image, Paper, RingProgress, ScrollArea, SimpleGrid, Stack, Tabs, Text, ThemeIcon } from "@mantine/core";
|
||||
import { IconDownload, IconMovie, IconQuestionMark, IconVideo } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { WidgetEmptyState } from "../common/empty-state";
|
||||
import type { WidgetComponentProps } from "../definition";
|
||||
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||
import classes from "./component.module.css";
|
||||
|
||||
export default function MediaMissingWidget({ integrationIds, options, width, height }: WidgetComponentProps<"mediaMissing">) {
|
||||
const t = useScopedI18n("widget.mediaMissing");
|
||||
const pageSize = Number(options.pageSize);
|
||||
const { data } = clientApi.widget.mediaOrganizer.getData.useQuery(
|
||||
{ integrationIds, pageSize },
|
||||
{ staleTime: 60 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false },
|
||||
);
|
||||
|
||||
if (!data) return <WidgetEmptyState />;
|
||||
if (data.length === 0) throw new NoIntegrationDataError();
|
||||
if (!options.showMissing && !options.showQueued)
|
||||
return (
|
||||
<Center h="100%" p="sm">
|
||||
<Text c="dimmed" size="sm" ta="center">
|
||||
{t("empty.noTabsEnabled")}
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
|
||||
const missing = data.flatMap((entry) =>
|
||||
entry.missing.map((item) => ({ item, integrationId: entry.integrationId })),
|
||||
);
|
||||
const queued = data.flatMap((entry) =>
|
||||
entry.queued.map((item) => ({ item, integrationId: entry.integrationId })),
|
||||
);
|
||||
const missingCount = data.reduce((sum, entry) => sum + entry.missingCount, 0);
|
||||
const queuedCount = data.reduce((sum, entry) => sum + entry.queuedCount, 0);
|
||||
|
||||
const isThin = width > 0 && width < 160;
|
||||
const isShort = height > 0 && height < 180;
|
||||
const targetCardWidth = isShort ? 130 : 200;
|
||||
const columns = width > 0 ? Math.max(1, Math.min(Math.floor(width / targetCardWidth), 4)) : 1;
|
||||
const density: Density = isThin ? "thin" : width > 0 && width / columns < 180 ? "compact" : "comfortable";
|
||||
|
||||
const tabLabel = (label: string, shown: number, total: number) =>
|
||||
isThin ? total : `${label} (${shown}/${total})`;
|
||||
|
||||
const renderPanel = (entries: { item: MissingMediaItem | QueuedMediaItem; integrationId: string }[], emptyLabel: string) => (
|
||||
<ScrollArea h="100%" scrollbarSize={4}>
|
||||
<Box p="xs">
|
||||
{entries.length === 0 ? (
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
{emptyLabel}
|
||||
</Text>
|
||||
) : (
|
||||
<SimpleGrid cols={columns} spacing="xs" verticalSpacing="xs">
|
||||
{entries.map(({ item, integrationId }) => (
|
||||
<MediaCard key={`${integrationId}-${item.type}-${item.id}`} item={item} density={density} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
defaultValue={options.showMissing ? "missing" : "queued"}
|
||||
h="100%"
|
||||
style={{ display: "flex", flexDirection: "column" }}
|
||||
>
|
||||
<Tabs.List grow>
|
||||
{options.showMissing && (
|
||||
<Tabs.Tab value="missing" px={isThin ? 6 : undefined} leftSection={<IconQuestionMark size={14} />}>
|
||||
{tabLabel(t("tab.missing"), missing.length, missingCount)}
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
{options.showQueued && (
|
||||
<Tabs.Tab value="queued" px={isThin ? 6 : undefined} leftSection={<IconDownload size={14} />}>
|
||||
{tabLabel(t("tab.queued"), queued.length, queuedCount)}
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
</Tabs.List>
|
||||
|
||||
{options.showMissing && (
|
||||
<Tabs.Panel value="missing" flex={1} style={{ overflow: "hidden" }}>
|
||||
{renderPanel(missing, t("empty.missing"))}
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
{options.showQueued && (
|
||||
<Tabs.Panel value="queued" flex={1} style={{ overflow: "hidden" }}>
|
||||
{renderPanel(queued, t("empty.queued"))}
|
||||
</Tabs.Panel>
|
||||
)}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
type Density = "thin" | "compact" | "comfortable";
|
||||
|
||||
const CARD_HEIGHT: Record<Density, number> = { thin: 52, compact: 56, comfortable: 68 };
|
||||
|
||||
const posterSizes: Record<Density, number> = { thin: 34, compact: 40, comfortable: 52 };
|
||||
|
||||
const Poster = ({ src, type, density }: { src?: string | null; type: "movie" | "episode"; density: Density }) => {
|
||||
const size = posterSizes[density];
|
||||
const w = Math.round(size * 0.68);
|
||||
|
||||
if (src) {
|
||||
return <Image className={classes.poster} src={src} h={size} w={w} radius="sm" alt="" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeIcon className={classes.poster} h={size} w={w} radius="sm" variant="light" color={type === "movie" ? "yellow" : "blue"}>
|
||||
{type === "movie" ? <IconMovie size={size * 0.5} /> : <IconVideo size={size * 0.5} />}
|
||||
</ThemeIcon>
|
||||
);
|
||||
};
|
||||
|
||||
const episodeCode = (item: MissingMediaItem | QueuedMediaItem) =>
|
||||
item.type === "episode" && item.seasonNumber !== undefined && item.episodeNumber !== undefined
|
||||
? `S${String(item.seasonNumber).padStart(2, "0")}E${String(item.episodeNumber).padStart(2, "0")}`
|
||||
: null;
|
||||
|
||||
const TypeBadge = ({ item, density }: { item: MissingMediaItem | QueuedMediaItem; density: Density }) => {
|
||||
const t = useScopedI18n("widget.mediaMissing");
|
||||
const color = item.type === "movie" ? "yellow" : "blue";
|
||||
const code = episodeCode(item);
|
||||
|
||||
if (density !== "comfortable") {
|
||||
return (
|
||||
<Badge size="xs" variant="light" color={color}>
|
||||
{code ?? t(`type.${item.type}`)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Badge size="xs" variant="light" color={color}>
|
||||
{t(`type.${item.type}`)}
|
||||
</Badge>
|
||||
{code && (
|
||||
<Badge size="xs" variant="outline" color="gray">
|
||||
{code}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const primaryTitle = (item: MissingMediaItem | QueuedMediaItem) =>
|
||||
item.type === "episode" ? (item.seriesTitle ?? item.title) : item.title;
|
||||
|
||||
const progressColor = (percent: number) => (percent >= 90 ? "green" : percent >= 40 ? "cyan" : "orange");
|
||||
|
||||
const ringSizes: Record<Density, { size: number; thickness: number; fz: string }> = {
|
||||
thin: { size: 30, thickness: 3, fz: "8px" },
|
||||
compact: { size: 34, thickness: 3, fz: "9px" },
|
||||
comfortable: { size: 40, thickness: 4, fz: "10px" },
|
||||
};
|
||||
|
||||
const ProgressRing = ({ percent, density }: { percent: number; density: Density }) => {
|
||||
const color = progressColor(percent);
|
||||
const ring = ringSizes[density];
|
||||
return (
|
||||
<RingProgress
|
||||
size={ring.size}
|
||||
thickness={ring.thickness}
|
||||
roundCaps
|
||||
sections={[{ value: percent, color }]}
|
||||
label={
|
||||
<Text ta="center" fw={700} fz={ring.fz} lh={1}>
|
||||
{percent}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const CardShell = ({ item, density, children }: { item: MissingMediaItem | QueuedMediaItem; density: Density; children: ReactNode }) => (
|
||||
<Paper className={classes.card} component="a" href={item.link} target="_blank" rel="noreferrer" radius="sm" p="xs" h={CARD_HEIGHT[density]}>
|
||||
{item.imageUrl && (
|
||||
<span className={classes.backdrop} style={{ backgroundImage: `url("${item.imageUrl}")` }} aria-hidden />
|
||||
)}
|
||||
<div className={classes.content}>{children}</div>
|
||||
</Paper>
|
||||
);
|
||||
|
||||
const MediaCard = ({ item, density }: { item: MissingMediaItem | QueuedMediaItem; density: Density }) => {
|
||||
const isQueued = "percentComplete" in item;
|
||||
|
||||
return (
|
||||
<CardShell item={item} density={density}>
|
||||
<Group gap="xs" wrap="nowrap" align="center" h="100%">
|
||||
<Poster src={item.imageUrl} type={item.type} density={density} />
|
||||
<Stack gap={2} style={{ minWidth: 0, flex: 1 }}>
|
||||
<TypeBadge item={item} density={density} />
|
||||
<Text fz="xs" fw={600} lineClamp={1} lh={1.25}>
|
||||
{primaryTitle(item)}
|
||||
</Text>
|
||||
{density === "comfortable" && (
|
||||
<Text fz="xs" c="dimmed" lineClamp={1} lh={1.1}>
|
||||
{item.type === "episode" ? item.title : item.year}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{isQueued && <ProgressRing percent={Math.min(100, Math.max(0, item.percentComplete))} density={density} />}
|
||||
</Group>
|
||||
</CardShell>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
import { IconMovie } from "@tabler/icons-react";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
|
||||
import { createWidgetDefinition } from "../definition";
|
||||
import { optionsBuilder } from "../options";
|
||||
|
||||
const pageSizeOptions = ["10", "20", "30", "50"] as const;
|
||||
|
||||
export const { componentLoader, definition } = createWidgetDefinition("mediaMissing", {
|
||||
icon: IconMovie,
|
||||
createOptions() {
|
||||
return optionsBuilder.from((factory) => ({
|
||||
showMissing: factory.switch({ defaultValue: true }),
|
||||
showQueued: factory.switch({ defaultValue: true }),
|
||||
pageSize: factory.select({
|
||||
defaultValue: "10",
|
||||
options: pageSizeOptions.map((value) => ({
|
||||
value,
|
||||
label: value,
|
||||
})),
|
||||
}),
|
||||
}));
|
||||
},
|
||||
supportedIntegrations: getIntegrationKindsByCategory("mediaOrganizer"),
|
||||
}).withDynamicImport(() => import("./component"));
|
||||
Reference in new issue
Block a user