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
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 181 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
@@ -0,0 +1,65 @@
|
||||
---
|
||||
title: 'Media Missing & Queue'
|
||||
description: 'Track missing media and active downloads from Radarr and Sonarr'
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { WidgetHeader } from '@site/src/components/widgets/header';
|
||||
import { AddingWidget } from '@site/src/components/widgets/adding';
|
||||
import { WidgetConfig } from '@site/src/components/widgets/configuration';
|
||||
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
|
||||
import { mediaMissingWidget } from '.';
|
||||
import { radarrIntegration } from '@site/docs/integrations/radarr';
|
||||
import { sonarrIntegration } from '@site/docs/integrations/sonarr';
|
||||
|
||||
<WidgetHeader
|
||||
widget={mediaMissingWidget}
|
||||
categories={['Media', 'Downloads']}
|
||||
/>
|
||||
|
||||
Monitor which movies and episodes are missing from your library, and track what is currently being downloaded. The widget pulls data from Radarr and Sonarr and groups results by type in two tabs.
|
||||
|
||||
### Screenshots
|
||||
|
||||
import widgetMissing from './img/missing.png';
|
||||
import widgetQueued from './img/queued.png';
|
||||
import widgetCompact from './img/compact.png';
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<img src={widgetMissing} alt="media-missing widget showing missing movies and episodes in a grid" width={360} />
|
||||
<img src={widgetQueued} alt="media-missing tab showing download queue with progress bars" width={256} />
|
||||
<img src={widgetCompact} alt="media-missing widget in a compact wide-and-short size" width={256} />
|
||||
</div>
|
||||
|
||||
### Supported Integrations
|
||||
|
||||
<WidgetIntegrations items={[{
|
||||
integration: radarrIntegration,
|
||||
}, {
|
||||
integration: sonarrIntegration,
|
||||
}]} />
|
||||
|
||||
### Tabs
|
||||
|
||||
| Tab | Description |
|
||||
|---|---|
|
||||
| **Missing** | Lists movies (Radarr) and episodes (Sonarr) that are monitored but not yet available in your library. |
|
||||
| **Queued** | Shows items currently being downloaded with a progress bar and estimated time remaining. |
|
||||
|
||||
### Sizing
|
||||
|
||||
The widget adapts to its container size, so it looks good at any grid size:
|
||||
|
||||
- **Wide** — fits as many columns of cards as the width allows, filling larger widgets instead of stretching a single column.
|
||||
- **Short** — packs items into more, tighter columns so they stay visible in the limited height.
|
||||
- **Narrow (1×N)** — tab labels collapse to counts only and posters scale down.
|
||||
|
||||
Each item shows its poster art (with the artwork bleeding softly behind the card), a type badge, and — on the queue — an animated progress bar with the estimated time remaining.
|
||||
|
||||
### Adding the widget
|
||||
|
||||
<AddingWidget />
|
||||
|
||||
### Configuration
|
||||
|
||||
<WidgetConfig configuration={mediaMissingWidget.configuration} />
|
||||
@@ -0,0 +1,31 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconMovie } from "@tabler/icons-react";
|
||||
|
||||
export const mediaMissingWidget: WidgetDefinition = {
|
||||
icon: IconMovie,
|
||||
name: "Media Missing & Queue",
|
||||
description: "Track missing media and active downloads from Radarr and Sonarr",
|
||||
path: "../../widgets/media-missing",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Show Missing",
|
||||
description: "Display the missing media tab, listing movies or episodes not yet downloaded",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "true",
|
||||
},
|
||||
{
|
||||
name: "Show Queued",
|
||||
description: "Display the download queue tab with progress for each item",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "true",
|
||||
},
|
||||
{
|
||||
name: "Page Size",
|
||||
description: "Maximum number of items to fetch per integration",
|
||||
values: { type: "string" },
|
||||
defaultValue: "10",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -30,7 +30,19 @@ export const InheritAccessTable = <TPermission extends string>({
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{accessQueryData.inherited.map(({ group, permission }) => {
|
||||
{Array.from(
|
||||
accessQueryData.inherited
|
||||
.reduce((map, entry) => {
|
||||
const existing = map.get(entry.group.id);
|
||||
if (!existing) {
|
||||
map.set(entry.group.id, entry);
|
||||
} else if (getPermissionsWithChildren([entry.permission]).includes(existing.permission)) {
|
||||
map.set(entry.group.id, entry);
|
||||
}
|
||||
return map;
|
||||
}, new Map<string, (typeof accessQueryData.inherited)[number]>())
|
||||
.values(),
|
||||
).map(({ group, permission }) => {
|
||||
const entityPermission =
|
||||
permission in mapPermissions
|
||||
? mapPermissions[permission]
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
everyoneGroup,
|
||||
getPermissionsWithChildren,
|
||||
getPermissionsWithParents,
|
||||
widgetDefaultSizes,
|
||||
widgetKinds,
|
||||
} from "@homarr/definitions";
|
||||
import { importOldmarrAsync } from "@homarr/old-import";
|
||||
@@ -1502,21 +1503,33 @@ export const boardRouter = createTRPCRouter({
|
||||
}
|
||||
}
|
||||
|
||||
const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 };
|
||||
|
||||
const fitsAt = (x: number, y: number) => {
|
||||
if (x + defaultSize.width > layout.columnCount) return false;
|
||||
for (let dy = 0; dy < defaultSize.height; dy++) {
|
||||
const row = occupied[y + dy];
|
||||
if (!row) continue;
|
||||
for (let dx = 0; dx < defaultSize.width; dx++) {
|
||||
if (row[x + dx]) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
let placed = false;
|
||||
for (let y = 0; y < 9999 && !placed; y++) {
|
||||
if (!occupied[y]) occupied.push(Array.from<boolean>({ length: layout.columnCount }).fill(false));
|
||||
const occupiedRow = occupied[y];
|
||||
if (!occupiedRow) continue;
|
||||
for (let x = 0; x < layout.columnCount && !placed; x++) {
|
||||
if (!occupiedRow[x]) {
|
||||
if (fitsAt(x, y)) {
|
||||
layoutRows.push({
|
||||
itemId,
|
||||
sectionId: emptySection.id,
|
||||
layoutId: layout.id,
|
||||
xOffset: x,
|
||||
yOffset: y,
|
||||
width: 1,
|
||||
height: 1,
|
||||
width: defaultSize.width,
|
||||
height: defaultSize.height,
|
||||
});
|
||||
placed = true;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ export const widgetRouter = createTRPCRouter({
|
||||
calendar: lazy(() => import("./calendar").then((mod) => mod.calendarRouter)),
|
||||
downloads: lazy(() => import("./downloads").then((mod) => mod.downloadsRouter)),
|
||||
mediaRequests: lazy(() => import("./media-requests").then((mod) => mod.mediaRequestsRouter)),
|
||||
mediaOrganizer: lazy(() => import("./media-organizer").then((mod) => mod.mediaOrganizerRouter)),
|
||||
rssFeed: lazy(() => import("./rssFeed").then((mod) => mod.rssFeedRouter)),
|
||||
indexerManager: lazy(() => import("./indexer-manager").then((mod) => mod.indexerManagerRouter)),
|
||||
healthMonitoring: lazy(() => import("./health-monitoring").then((mod) => mod.healthMonitoringRouter)),
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { getIntegrationKindsByCategory } from "@homarr/definitions";
|
||||
import { mediaOrganizerRequestHandler } from "@homarr/request-handler/media-organizer";
|
||||
|
||||
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const mediaOrganizerRouter = createTRPCRouter({
|
||||
getData: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get missing and queued movies/episodes from Radarr and Sonarr. Requires query (use) access to each integration. REQUIRED: integrationIds (array of Radarr/Sonarr integration IDs from integration_all). OPTIONAL: pageSize (1-50, default 10)",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaOrganizer")))
|
||||
.input(z.object({ pageSize: z.number().min(1).max(50).default(10) }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const results = await Promise.all(
|
||||
ctx.integrations.map(async (integration) => {
|
||||
const innerHandler = mediaOrganizerRequestHandler.handler(integration, { pageSize: input.pageSize });
|
||||
const { data } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
...data,
|
||||
};
|
||||
}),
|
||||
);
|
||||
return results;
|
||||
}),
|
||||
});
|
||||
@@ -19,6 +19,7 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
||||
"mediaRequests-requestList": "media-request-list",
|
||||
"mediaRequests-requestStats": "media-request-stats",
|
||||
mediaTranscoding: "media-transcoding",
|
||||
mediaMissing: "media-missing",
|
||||
minecraftServerStatus: "minecraft-server-status",
|
||||
networkControllerSummary: "network-controller-summary",
|
||||
networkControllerStatus: "network-controller-status",
|
||||
|
||||
@@ -96,7 +96,7 @@ export const integrationDefs = {
|
||||
name: "Sonarr",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sonarr.svg",
|
||||
category: ["calendar"],
|
||||
category: ["calendar", "mediaOrganizer"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/sonarr"),
|
||||
defaultPort: 8989,
|
||||
apiKeySettingsPath: "/settings/general",
|
||||
@@ -105,7 +105,7 @@ export const integrationDefs = {
|
||||
name: "Radarr",
|
||||
secretKinds: [["apiKey"]],
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/radarr.svg",
|
||||
category: ["calendar"],
|
||||
category: ["calendar", "mediaOrganizer"],
|
||||
documentationUrl: createDocumentationLink("/docs/integrations/radarr"),
|
||||
defaultPort: 7878,
|
||||
apiKeySettingsPath: "/settings/general",
|
||||
@@ -478,6 +478,7 @@ export const integrationDefs = {
|
||||
"smartHomeServer",
|
||||
"ups",
|
||||
"uptimeMonitoring",
|
||||
"mediaOrganizer",
|
||||
],
|
||||
documentationUrl: null,
|
||||
},
|
||||
@@ -543,6 +544,7 @@ export const integrationCategories = [
|
||||
"dnsHole",
|
||||
"mediaService",
|
||||
"calendar",
|
||||
"mediaOrganizer",
|
||||
"mediaSearch",
|
||||
"mediaRelease",
|
||||
"mediaRequest",
|
||||
|
||||
@@ -20,6 +20,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
|
||||
"mediaRequests-requestList": getIntegrationKindsByCategory("mediaRequest"),
|
||||
"mediaRequests-requestStats": getIntegrationKindsByCategory("mediaRequest"),
|
||||
mediaTranscoding: getIntegrationKindsByCategory("mediaTranscoding"),
|
||||
mediaMissing: getIntegrationKindsByCategory("mediaOrganizer"),
|
||||
networkControllerSummary: getIntegrationKindsByCategory("networkController"),
|
||||
networkControllerStatus: getIntegrationKindsByCategory("networkController"),
|
||||
indexerManager: getIntegrationKindsByCategory("indexerManager"),
|
||||
|
||||
@@ -17,6 +17,7 @@ export const widgetKinds = [
|
||||
"mediaRequests-requestList",
|
||||
"mediaRequests-requestStats",
|
||||
"mediaTranscoding",
|
||||
"mediaMissing",
|
||||
"minecraftServerStatus",
|
||||
"networkControllerSummary",
|
||||
"networkControllerStatus",
|
||||
@@ -58,5 +59,6 @@ export const widgetDefaultSizes: Partial<Record<WidgetKind, { width: number; hei
|
||||
uptimeKuma: { width: 2, height: 3 },
|
||||
audioStats: { width: 2, height: 2 },
|
||||
paperlessNgx: { width: 2, height: 2 },
|
||||
mediaMissing: { width: 4, height: 3 },
|
||||
bazarr: { width: 2, height: 2 },
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { MissingMediaItem, QueuedMediaItem } from "./media-organizer-types";
|
||||
|
||||
export interface IMediaOrganizerIntegration {
|
||||
getMissingAsync(pageSize?: number): Promise<{ items: MissingMediaItem[]; totalCount: number }>;
|
||||
getMediaQueueAsync(pageSize?: number): Promise<{ items: QueuedMediaItem[]; totalCount: number }>;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface MissingMediaItem {
|
||||
id: number;
|
||||
title: string;
|
||||
type: "movie" | "episode";
|
||||
year?: number;
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
seriesTitle?: string;
|
||||
imageUrl?: string | null;
|
||||
link: string;
|
||||
}
|
||||
|
||||
export interface QueuedMediaItem {
|
||||
id: number;
|
||||
title: string;
|
||||
type: "movie" | "episode";
|
||||
status: string;
|
||||
timeLeft: string | null;
|
||||
percentComplete: number;
|
||||
year?: number;
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
seriesTitle?: string;
|
||||
imageUrl?: string | null;
|
||||
link: string;
|
||||
}
|
||||
@@ -10,11 +10,13 @@ import type { TestingResult } from "../../base/test-connection/test-connection-s
|
||||
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
|
||||
import type { CalendarEvent, CalendarLink } from "../../interfaces/calendar/calendar-types";
|
||||
import { radarrReleaseTypes } from "../../interfaces/calendar/calendar-types";
|
||||
import type { IMediaOrganizerIntegration } from "../../interfaces/media-organizer/media-organizer-integration";
|
||||
import type { MissingMediaItem, QueuedMediaItem } from "../../interfaces/media-organizer/media-organizer-types";
|
||||
import { mediaOrganizerPriorities } from "../media-organizer";
|
||||
|
||||
const logger = createLogger({ module: "radarrIntegration" });
|
||||
|
||||
export class RadarrIntegration extends Integration implements ICalendarIntegration {
|
||||
export class RadarrIntegration extends Integration implements ICalendarIntegration, IMediaOrganizerIntegration {
|
||||
/**
|
||||
* Gets the events in the Radarr calendar between two dates.
|
||||
* @param start The start date
|
||||
@@ -111,6 +113,79 @@ export class RadarrIntegration extends Integration implements ICalendarIntegrati
|
||||
return bestImage.remoteUrl;
|
||||
};
|
||||
|
||||
async getMissingAsync(pageSize = 10): Promise<{ items: MissingMediaItem[]; totalCount: number }> {
|
||||
const url = this.url("/api/v3/wanted/missing", {
|
||||
page: 1,
|
||||
pageSize,
|
||||
sortKey: "title",
|
||||
sortDirection: "ascending",
|
||||
});
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
||||
});
|
||||
const data = await z
|
||||
.object({
|
||||
totalRecords: z.number(),
|
||||
records: z.array(radarrMissingMovieSchema),
|
||||
})
|
||||
.parseAsync(await response.json());
|
||||
|
||||
return {
|
||||
totalCount: data.totalRecords,
|
||||
items: data.records.map(
|
||||
(movie): MissingMediaItem => ({
|
||||
id: movie.id,
|
||||
title: movie.title,
|
||||
type: "movie",
|
||||
year: movie.year,
|
||||
imageUrl: movie.images.find((img) => img.coverType === "poster")?.remoteUrl ?? null,
|
||||
link: this.externalUrl(`/movie/${movie.titleSlug}`).toString(),
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async getMediaQueueAsync(pageSize = 10): Promise<{ items: QueuedMediaItem[]; totalCount: number }> {
|
||||
const url = this.url("/api/v3/queue", {
|
||||
page: 1,
|
||||
pageSize,
|
||||
includeMovie: true,
|
||||
});
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
||||
});
|
||||
const data = await z
|
||||
.object({
|
||||
totalRecords: z.number(),
|
||||
records: z.array(radarrQueueItemSchema),
|
||||
})
|
||||
.parseAsync(await response.json());
|
||||
|
||||
return {
|
||||
totalCount: data.totalRecords,
|
||||
items: data.records.map((item): QueuedMediaItem => {
|
||||
const sizeLeft = item.sizeleft ?? 0;
|
||||
const sizeTotal = item.size ?? 0;
|
||||
const percentComplete = sizeTotal > 0 ? Math.round(((sizeTotal - sizeLeft) / sizeTotal) * 100) : 0;
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.movie?.title ?? item.title,
|
||||
type: "movie",
|
||||
status: item.status,
|
||||
timeLeft: item.timeleft ?? null,
|
||||
percentComplete,
|
||||
year: item.movie?.year,
|
||||
imageUrl: item.movie?.images.find((img) => img.coverType === "poster")?.remoteUrl ?? null,
|
||||
link: item.movie?.titleSlug
|
||||
? this.externalUrl(`/movie/${item.movie.titleSlug}`).toString()
|
||||
: this.externalUrl("/activity/queue").toString(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/api"), {
|
||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
||||
@@ -131,6 +206,41 @@ const radarrCalendarEventImageSchema = z.array(
|
||||
}),
|
||||
);
|
||||
|
||||
const radarrMissingMovieSchema = z.object({
|
||||
id: z.number(),
|
||||
title: z.string(),
|
||||
year: z.number().optional(),
|
||||
titleSlug: z.string(),
|
||||
images: z.array(
|
||||
z.object({
|
||||
coverType: z.string(),
|
||||
remoteUrl: z.string().url(),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const radarrQueueItemSchema = z.object({
|
||||
id: z.number(),
|
||||
title: z.string(),
|
||||
status: z.string(),
|
||||
timeleft: z.string().optional(),
|
||||
size: z.number().optional(),
|
||||
sizeleft: z.number().optional(),
|
||||
movie: z
|
||||
.object({
|
||||
title: z.string(),
|
||||
year: z.number().optional(),
|
||||
titleSlug: z.string(),
|
||||
images: z.array(
|
||||
z.object({
|
||||
coverType: z.string(),
|
||||
remoteUrl: z.string().url(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const radarrCalendarEventSchema = z.object({
|
||||
title: z.string(),
|
||||
originalTitle: z.string(),
|
||||
|
||||
@@ -9,11 +9,13 @@ import { TestConnectionError } from "../../base/test-connection/test-connection-
|
||||
import type { TestingResult } from "../../base/test-connection/test-connection-service";
|
||||
import type { ICalendarIntegration } from "../../interfaces/calendar/calendar-integration";
|
||||
import type { CalendarEvent, CalendarLink } from "../../interfaces/calendar/calendar-types";
|
||||
import type { IMediaOrganizerIntegration } from "../../interfaces/media-organizer/media-organizer-integration";
|
||||
import type { MissingMediaItem, QueuedMediaItem } from "../../interfaces/media-organizer/media-organizer-types";
|
||||
import { mediaOrganizerPriorities } from "../media-organizer";
|
||||
|
||||
const logger = createLogger({ module: "sonarrIntegration" });
|
||||
|
||||
export class SonarrIntegration extends Integration implements ICalendarIntegration {
|
||||
export class SonarrIntegration extends Integration implements ICalendarIntegration, IMediaOrganizerIntegration {
|
||||
/**
|
||||
* Gets the events in the Sonarr calendar between two dates.
|
||||
* @param start The start date
|
||||
@@ -107,6 +109,88 @@ export class SonarrIntegration extends Integration implements ICalendarIntegrati
|
||||
return bestImage.remoteUrl;
|
||||
};
|
||||
|
||||
async getMissingAsync(pageSize = 10): Promise<{ items: MissingMediaItem[]; totalCount: number }> {
|
||||
const url = this.url("/api/v3/wanted/missing", {
|
||||
page: 1,
|
||||
pageSize,
|
||||
sortKey: "airDateUtc",
|
||||
sortDirection: "ascending",
|
||||
includeSeries: true,
|
||||
});
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
||||
});
|
||||
const data = await z
|
||||
.object({
|
||||
totalRecords: z.number(),
|
||||
records: z.array(sonarrMissingEpisodeSchema),
|
||||
})
|
||||
.parseAsync(await response.json());
|
||||
|
||||
return {
|
||||
totalCount: data.totalRecords,
|
||||
items: data.records.map(
|
||||
(episode): MissingMediaItem => ({
|
||||
id: episode.id,
|
||||
title: episode.title,
|
||||
type: "episode",
|
||||
seasonNumber: episode.seasonNumber,
|
||||
episodeNumber: episode.episodeNumber,
|
||||
seriesTitle: episode.series?.title,
|
||||
year: episode.series?.year,
|
||||
imageUrl: episode.series?.images.find((img) => img.coverType === "poster")?.remoteUrl ?? null,
|
||||
link: episode.series?.titleSlug
|
||||
? this.externalUrl(`/series/${episode.series.titleSlug}`).toString()
|
||||
: this.externalUrl("/wanted/missing").toString(),
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async getMediaQueueAsync(pageSize = 10): Promise<{ items: QueuedMediaItem[]; totalCount: number }> {
|
||||
const url = this.url("/api/v3/queue", {
|
||||
page: 1,
|
||||
pageSize,
|
||||
includeSeries: true,
|
||||
includeEpisode: true,
|
||||
});
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(url, {
|
||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
||||
});
|
||||
const data = await z
|
||||
.object({
|
||||
totalRecords: z.number(),
|
||||
records: z.array(sonarrQueueItemSchema),
|
||||
})
|
||||
.parseAsync(await response.json());
|
||||
|
||||
return {
|
||||
totalCount: data.totalRecords,
|
||||
items: data.records.map((item): QueuedMediaItem => {
|
||||
const sizeLeft = item.sizeleft ?? 0;
|
||||
const sizeTotal = item.size ?? 0;
|
||||
const percentComplete = sizeTotal > 0 ? Math.round(((sizeTotal - sizeLeft) / sizeTotal) * 100) : 0;
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.episode?.title ?? item.title,
|
||||
type: "episode",
|
||||
status: item.status,
|
||||
timeLeft: item.timeleft ?? null,
|
||||
percentComplete,
|
||||
seasonNumber: item.episode?.seasonNumber,
|
||||
episodeNumber: item.episode?.episodeNumber,
|
||||
seriesTitle: item.series?.title,
|
||||
imageUrl: item.series?.images.find((img) => img.coverType === "poster")?.remoteUrl ?? null,
|
||||
link: item.series?.titleSlug
|
||||
? this.externalUrl(`/series/${item.series.titleSlug}`).toString()
|
||||
: this.externalUrl("/activity/queue").toString(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
const response = await input.fetchAsync(this.url("/api"), {
|
||||
headers: { "X-Api-Key": super.getSecretValue("apiKey") },
|
||||
@@ -119,6 +203,54 @@ export class SonarrIntegration extends Integration implements ICalendarIntegrati
|
||||
}
|
||||
}
|
||||
|
||||
const sonarrMissingEpisodeSchema = z.object({
|
||||
id: z.number(),
|
||||
title: z.string(),
|
||||
seasonNumber: z.number(),
|
||||
episodeNumber: z.number(),
|
||||
series: z
|
||||
.object({
|
||||
title: z.string(),
|
||||
year: z.number().optional(),
|
||||
titleSlug: z.string(),
|
||||
images: z.array(
|
||||
z.object({
|
||||
coverType: z.string(),
|
||||
remoteUrl: z.string().url(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const sonarrQueueItemSchema = z.object({
|
||||
id: z.number(),
|
||||
title: z.string(),
|
||||
status: z.string(),
|
||||
timeleft: z.string().optional(),
|
||||
size: z.number().optional(),
|
||||
sizeleft: z.number().optional(),
|
||||
series: z
|
||||
.object({
|
||||
title: z.string(),
|
||||
titleSlug: z.string(),
|
||||
images: z.array(
|
||||
z.object({
|
||||
coverType: z.string(),
|
||||
remoteUrl: z.string().url(),
|
||||
}),
|
||||
),
|
||||
})
|
||||
.optional(),
|
||||
episode: z
|
||||
.object({
|
||||
title: z.string(),
|
||||
seasonNumber: z.number(),
|
||||
episodeNumber: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const sonarrCalendarEventImageSchema = z.array(
|
||||
z.object({
|
||||
// See https://github.com/Sonarr/Sonarr/blob/9e5ebdc6245d4714776b53127a1e6b63c25fbcb9/src/NzbDrone.Core/MediaCover/MediaCover.cs#L5-L14
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { IMediaOrganizerIntegration } from "../../interfaces/media-organizer/media-organizer-integration";
|
||||
import type { MissingMediaItem, QueuedMediaItem } from "../../interfaces/media-organizer/media-organizer-types";
|
||||
|
||||
const missingItems: MissingMediaItem[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Dune: Part Two",
|
||||
type: "movie",
|
||||
year: 2024,
|
||||
imageUrl: "https://image.tmdb.org/t/p/w300/1pdfLvkbY9ohJlCjQH2CZjjYVvJ.jpg",
|
||||
link: "https://www.imdb.com/title/tt15239678/",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "The Batman Part II",
|
||||
type: "movie",
|
||||
year: 2026,
|
||||
imageUrl: "https://image.tmdb.org/t/p/w300/74xTEgt7R36Fpooo50r9T25onhq.jpg",
|
||||
link: "https://www.imdb.com/title/tt7126948/",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "The Last of Us",
|
||||
type: "episode",
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 5,
|
||||
seriesTitle: "The Last of Us",
|
||||
year: 2025,
|
||||
imageUrl: "https://image.tmdb.org/t/p/w300/uKvVjHNqB5VmOrdxqAt2F7J78ED.jpg",
|
||||
link: "https://www.imdb.com/title/tt3581920/",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Andor",
|
||||
type: "episode",
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 3,
|
||||
seriesTitle: "Andor",
|
||||
year: 2025,
|
||||
imageUrl: "https://image.tmdb.org/t/p/w300/59SVNwLfoMnZPPB6ukW6dlPxAdI.jpg",
|
||||
link: "https://www.imdb.com/title/tt9253284/",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "Gladiator II",
|
||||
type: "movie",
|
||||
year: 2024,
|
||||
imageUrl: "https://image.tmdb.org/t/p/w300/2cxhvwyEwRlysAmRH4iodkvo0z5.jpg",
|
||||
link: "https://www.imdb.com/title/tt9218128/",
|
||||
},
|
||||
];
|
||||
|
||||
const queuedItems: QueuedMediaItem[] = [
|
||||
{
|
||||
id: 101,
|
||||
title: "Oppenheimer",
|
||||
type: "movie",
|
||||
status: "downloading",
|
||||
timeLeft: "1h 23m",
|
||||
percentComplete: 62,
|
||||
imageUrl: "https://image.tmdb.org/t/p/w300/8Gxv8gSFCU0XGDykEGv7zR1n2ua.jpg",
|
||||
link: "https://www.imdb.com/title/tt15398776/",
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
title: "Shogun",
|
||||
type: "episode",
|
||||
seasonNumber: 1,
|
||||
episodeNumber: 8,
|
||||
seriesTitle: "Shogun",
|
||||
status: "downloading",
|
||||
timeLeft: "47m",
|
||||
percentComplete: 81,
|
||||
imageUrl: "https://image.tmdb.org/t/p/w300/7O4iVfOMQmdCSxhOg1WnzG1AgYT.jpg",
|
||||
link: "https://www.imdb.com/title/tt2098771/",
|
||||
},
|
||||
{
|
||||
id: 103,
|
||||
title: "The Substance",
|
||||
type: "movie",
|
||||
status: "queued",
|
||||
timeLeft: null,
|
||||
percentComplete: 0,
|
||||
imageUrl: "https://image.tmdb.org/t/p/w300/lqoMzCcZYEFK729d6qzt349fB4o.jpg",
|
||||
link: "https://www.imdb.com/title/tt17526714/",
|
||||
},
|
||||
{
|
||||
id: 104,
|
||||
title: "House of the Dragon",
|
||||
type: "episode",
|
||||
seasonNumber: 2,
|
||||
episodeNumber: 7,
|
||||
seriesTitle: "House of the Dragon",
|
||||
status: "importing",
|
||||
timeLeft: "3m",
|
||||
percentComplete: 99,
|
||||
imageUrl: "https://image.tmdb.org/t/p/w300/z2yahl2uefxDCl0nogcRBstwruJ.jpg",
|
||||
link: "https://www.imdb.com/title/tt11198330/",
|
||||
},
|
||||
];
|
||||
|
||||
export class MediaOrganizerMockService implements IMediaOrganizerIntegration {
|
||||
async getMissingAsync(pageSize = 10) {
|
||||
return {
|
||||
items: missingItems.slice(0, pageSize),
|
||||
totalCount: missingItems.length,
|
||||
};
|
||||
}
|
||||
|
||||
async getMediaQueueAsync(pageSize = 10) {
|
||||
return {
|
||||
items: queuedItems.slice(0, pageSize),
|
||||
totalCount: queuedItems.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
ISystemHealthMonitoringIntegration,
|
||||
} from "../interfaces/health-monitoring/health-monitoring-integration";
|
||||
import type { IIndexerManagerIntegration } from "../interfaces/indexer-manager/indexer-manager-integration";
|
||||
import type { IMediaOrganizerIntegration } from "../interfaces/media-organizer/media-organizer-integration";
|
||||
import type { IMediaReleasesIntegration } from "../interfaces/media-releases";
|
||||
import type { IMediaRequestIntegration } from "../interfaces/media-requests/media-request-integration";
|
||||
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
|
||||
@@ -21,6 +22,7 @@ import { ClusterHealthMonitoringMockService } from "./data/cluster-health-monito
|
||||
import { DnsHoleMockService } from "./data/dns-hole";
|
||||
import { DownloadClientMockService } from "./data/download";
|
||||
import { IndexerManagerMockService } from "./data/indexer-manager";
|
||||
import { MediaOrganizerMockService } from "./data/media-organizer";
|
||||
import { MediaReleasesMockService } from "./data/media-releases";
|
||||
import { MediaRequestMockService } from "./data/media-request";
|
||||
import { MediaServerMockService } from "./data/media-server";
|
||||
@@ -41,6 +43,7 @@ export class MockIntegration
|
||||
IClusterHealthMonitoringIntegration,
|
||||
ISystemHealthMonitoringIntegration,
|
||||
IIndexerManagerIntegration,
|
||||
IMediaOrganizerIntegration,
|
||||
IMediaReleasesIntegration,
|
||||
IMediaRequestIntegration,
|
||||
IMediaServerIntegration,
|
||||
@@ -55,6 +58,7 @@ export class MockIntegration
|
||||
private static readonly clusterMonitoring = new ClusterHealthMonitoringMockService();
|
||||
private static readonly systemMonitoring = new SystemHealthMonitoringMockService();
|
||||
private static readonly indexerManager = new IndexerManagerMockService();
|
||||
private static readonly mediaOrganizer = new MediaOrganizerMockService();
|
||||
private static readonly mediaReleases = new MediaReleasesMockService();
|
||||
private static readonly mediaRequest = new MediaRequestMockService();
|
||||
private static readonly mediaServer = new MediaServerMockService();
|
||||
@@ -97,6 +101,10 @@ export class MockIntegration
|
||||
getIndexersAsync = MockIntegration.indexerManager.getIndexersAsync.bind(MockIntegration.indexerManager);
|
||||
testAllAsync = MockIntegration.indexerManager.testAllAsync.bind(MockIntegration.indexerManager);
|
||||
|
||||
// MediaOrganizerIntegration
|
||||
getMissingAsync = MockIntegration.mediaOrganizer.getMissingAsync.bind(MockIntegration.mediaOrganizer);
|
||||
getMediaQueueAsync = MockIntegration.mediaOrganizer.getMediaQueueAsync.bind(MockIntegration.mediaOrganizer);
|
||||
|
||||
// MediaReleasesIntegration
|
||||
getMediaReleasesAsync = MockIntegration.mediaReleases.getMediaReleasesAsync.bind(MockIntegration.mediaReleases);
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
export * from "./interfaces/calendar/calendar-types";
|
||||
export * from "./interfaces/media-organizer/media-organizer-types";
|
||||
export type { IMediaOrganizerIntegration } from "./interfaces/media-organizer/media-organizer-integration";
|
||||
export * from "./interfaces/dns-hole-summary/dns-hole-summary-types";
|
||||
export * from "./interfaces/network-controller-summary/network-controller-summary-types";
|
||||
export * from "./interfaces/firewall-summary/firewall-summary-types";
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import dayjs from "dayjs";
|
||||
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { IMediaOrganizerIntegration, MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types";
|
||||
|
||||
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
|
||||
|
||||
interface MediaOrganizerData {
|
||||
missing: MissingMediaItem[];
|
||||
missingCount: number;
|
||||
queued: QueuedMediaItem[];
|
||||
queuedCount: number;
|
||||
}
|
||||
|
||||
export const mediaOrganizerRequestHandler = createCachedIntegrationRequestHandler<
|
||||
MediaOrganizerData,
|
||||
IntegrationKindByCategory<"mediaOrganizer">,
|
||||
{ pageSize: number }
|
||||
>({
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = (await createIntegrationAsync(integration)) as unknown as IMediaOrganizerIntegration;
|
||||
const [missingResult, queueResult] = await Promise.all([
|
||||
integrationInstance.getMissingAsync(input.pageSize),
|
||||
integrationInstance.getMediaQueueAsync(input.pageSize),
|
||||
]);
|
||||
return {
|
||||
missing: missingResult.items,
|
||||
missingCount: missingResult.totalCount,
|
||||
queued: queueResult.items,
|
||||
queuedCount: queueResult.totalCount,
|
||||
};
|
||||
},
|
||||
cacheDuration: dayjs.duration(1, "minute"),
|
||||
queryKey: "mediaOrganizer",
|
||||
});
|
||||
@@ -3130,6 +3130,35 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"mediaMissing": {
|
||||
"name": "Missing & Queued Media",
|
||||
"description": "Shows missing and queued movies and episodes from Radarr and Sonarr",
|
||||
"option": {
|
||||
"showMissing": {
|
||||
"label": "Show missing items"
|
||||
},
|
||||
"showQueued": {
|
||||
"label": "Show queued items"
|
||||
},
|
||||
"pageSize": {
|
||||
"label": "Items per page"
|
||||
}
|
||||
},
|
||||
"tab": {
|
||||
"missing": "Missing",
|
||||
"queued": "Queued"
|
||||
},
|
||||
"type": {
|
||||
"movie": "Movie",
|
||||
"episode": "Episode"
|
||||
},
|
||||
"empty": {
|
||||
"missing": "No missing items",
|
||||
"queued": "No queued items",
|
||||
"noTabsEnabled": "Enable at least one tab in the widget settings"
|
||||
},
|
||||
"timeLeft": "Time left"
|
||||
},
|
||||
"rssFeed": {
|
||||
"name": "RSS feeds",
|
||||
"description": "Monitor and display one or more generic RSS, ATOM or JSON feeds",
|
||||
|
||||
@@ -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