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

@@ -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";