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
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user