feat(widgets): add missing & queued media widget for Radarr and Sonarr (#6078)

Co-authored-by: Thomas <thomas@ajnart.fr>
This commit is contained in:
Anil K TiwariandThomas authored and GitHub committed 2026-07-10 17:31:08 +02:00
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]
+18 -5
View File
@@ -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;
}
+1
View File
@@ -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",
+4 -2
View File
@@ -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"),
+2
View File
@@ -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);
+2
View File
@@ -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",
});
+29
View File
@@ -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",
+2
View File
@@ -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"));