diff --git a/apps/docs/docs/widgets/media-missing/img/compact.png b/apps/docs/docs/widgets/media-missing/img/compact.png new file mode 100644 index 000000000..a33396ed6 Binary files /dev/null and b/apps/docs/docs/widgets/media-missing/img/compact.png differ diff --git a/apps/docs/docs/widgets/media-missing/img/missing.png b/apps/docs/docs/widgets/media-missing/img/missing.png new file mode 100644 index 000000000..25d4a47cd Binary files /dev/null and b/apps/docs/docs/widgets/media-missing/img/missing.png differ diff --git a/apps/docs/docs/widgets/media-missing/img/queued.png b/apps/docs/docs/widgets/media-missing/img/queued.png new file mode 100644 index 000000000..299f457e5 Binary files /dev/null and b/apps/docs/docs/widgets/media-missing/img/queued.png differ diff --git a/apps/docs/docs/widgets/media-missing/index.mdx b/apps/docs/docs/widgets/media-missing/index.mdx new file mode 100644 index 000000000..648bd4190 --- /dev/null +++ b/apps/docs/docs/widgets/media-missing/index.mdx @@ -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'; + + + +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'; + +
+ media-missing widget showing missing movies and episodes in a grid + media-missing tab showing download queue with progress bars + media-missing widget in a compact wide-and-short size +
+ +### Supported Integrations + + + +### 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 + + + +### Configuration + + diff --git a/apps/docs/docs/widgets/media-missing/index.ts b/apps/docs/docs/widgets/media-missing/index.ts new file mode 100644 index 000000000..a46a19395 --- /dev/null +++ b/apps/docs/docs/widgets/media-missing/index.ts @@ -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", + }, + ], + }, +}; diff --git a/apps/nextjs/src/components/access/inherit-access-table.tsx b/apps/nextjs/src/components/access/inherit-access-table.tsx index 5b439ce8c..92905fce1 100644 --- a/apps/nextjs/src/components/access/inherit-access-table.tsx +++ b/apps/nextjs/src/components/access/inherit-access-table.tsx @@ -30,7 +30,19 @@ export const InheritAccessTable = ({ - {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()) + .values(), + ).map(({ group, permission }) => { const entityPermission = permission in mapPermissions ? mapPermissions[permission] diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 0f529a929..9cb34d1bd 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -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({ 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; } diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 27907e924..4d6b4984a 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -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)), diff --git a/packages/api/src/router/widgets/media-organizer.ts b/packages/api/src/router/widgets/media-organizer.ts new file mode 100644 index 000000000..782e4075a --- /dev/null +++ b/packages/api/src/router/widgets/media-organizer.ts @@ -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; + }), +}); diff --git a/packages/definitions/src/docs/widget-doc-slugs.ts b/packages/definitions/src/docs/widget-doc-slugs.ts index 635badb49..405e7bad4 100644 --- a/packages/definitions/src/docs/widget-doc-slugs.ts +++ b/packages/definitions/src/docs/widget-doc-slugs.ts @@ -19,6 +19,7 @@ export const widgetDocSlugs: Record = { "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", diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index c4d216024..7798f6f2f 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -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", diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts index 8f59060e3..c1791f2df 100644 --- a/packages/definitions/src/widget-integration-map.ts +++ b/packages/definitions/src/widget-integration-map.ts @@ -20,6 +20,7 @@ export const widgetIntegrationSupport: Partial; + getMediaQueueAsync(pageSize?: number): Promise<{ items: QueuedMediaItem[]; totalCount: number }>; +} diff --git a/packages/integrations/src/interfaces/media-organizer/media-organizer-types.ts b/packages/integrations/src/interfaces/media-organizer/media-organizer-types.ts new file mode 100644 index 000000000..fff59e38a --- /dev/null +++ b/packages/integrations/src/interfaces/media-organizer/media-organizer-types.ts @@ -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; +} diff --git a/packages/integrations/src/media-organizer/radarr/radarr-integration.ts b/packages/integrations/src/media-organizer/radarr/radarr-integration.ts index b557f7b77..aa116d1e5 100644 --- a/packages/integrations/src/media-organizer/radarr/radarr-integration.ts +++ b/packages/integrations/src/media-organizer/radarr/radarr-integration.ts @@ -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 { 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(), diff --git a/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts b/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts index 17826e2de..43810ae19 100644 --- a/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts +++ b/packages/integrations/src/media-organizer/sonarr/sonarr-integration.ts @@ -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 { 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 diff --git a/packages/integrations/src/mock/data/media-organizer.ts b/packages/integrations/src/mock/data/media-organizer.ts new file mode 100644 index 000000000..1c12d1621 --- /dev/null +++ b/packages/integrations/src/mock/data/media-organizer.ts @@ -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, + }; + } +} diff --git a/packages/integrations/src/mock/mock-integration.ts b/packages/integrations/src/mock/mock-integration.ts index d7eb412cf..90c3905da 100644 --- a/packages/integrations/src/mock/mock-integration.ts +++ b/packages/integrations/src/mock/mock-integration.ts @@ -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); diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index b8de50802..3d9186b3e 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -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"; diff --git a/packages/request-handler/src/media-organizer.ts b/packages/request-handler/src/media-organizer.ts new file mode 100644 index 000000000..58e046b65 --- /dev/null +++ b/packages/request-handler/src/media-organizer.ts @@ -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", +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 6b09833d4..d2f60a602 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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", diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index ca20ff3a1..34b5be60b 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -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, diff --git a/packages/widgets/src/media-missing/component.module.css b/packages/widgets/src/media-missing/component.module.css new file mode 100644 index 000000000..5b1c9f568 --- /dev/null +++ b/packages/widgets/src/media-missing/component.module.css @@ -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); +} diff --git a/packages/widgets/src/media-missing/component.tsx b/packages/widgets/src/media-missing/component.tsx new file mode 100644 index 000000000..69c1edf21 --- /dev/null +++ b/packages/widgets/src/media-missing/component.tsx @@ -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 ; + if (data.length === 0) throw new NoIntegrationDataError(); + if (!options.showMissing && !options.showQueued) + return ( +
+ + {t("empty.noTabsEnabled")} + +
+ ); + + 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) => ( + + + {entries.length === 0 ? ( + + {emptyLabel} + + ) : ( + + {entries.map(({ item, integrationId }) => ( + + ))} + + )} + + + ); + + return ( + + + {options.showMissing && ( + }> + {tabLabel(t("tab.missing"), missing.length, missingCount)} + + )} + {options.showQueued && ( + }> + {tabLabel(t("tab.queued"), queued.length, queuedCount)} + + )} + + + {options.showMissing && ( + + {renderPanel(missing, t("empty.missing"))} + + )} + {options.showQueued && ( + + {renderPanel(queued, t("empty.queued"))} + + )} + + ); +} + +type Density = "thin" | "compact" | "comfortable"; + +const CARD_HEIGHT: Record = { thin: 52, compact: 56, comfortable: 68 }; + +const posterSizes: Record = { 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 ; + } + + return ( + + {type === "movie" ? : } + + ); +}; + +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 ( + + {code ?? t(`type.${item.type}`)} + + ); + } + + return ( + + + {t(`type.${item.type}`)} + + {code && ( + + {code} + + )} + + ); +}; + +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 = { + 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 ( + + {percent} + + } + /> + ); +}; + +const CardShell = ({ item, density, children }: { item: MissingMediaItem | QueuedMediaItem; density: Density; children: ReactNode }) => ( + + {item.imageUrl && ( + + )} +
{children}
+
+); + +const MediaCard = ({ item, density }: { item: MissingMediaItem | QueuedMediaItem; density: Density }) => { + const isQueued = "percentComplete" in item; + + return ( + + + + + + + {primaryTitle(item)} + + {density === "comfortable" && ( + + {item.type === "episode" ? item.title : item.year} + + )} + + {isQueued && } + + + ); +}; diff --git a/packages/widgets/src/media-missing/index.ts b/packages/widgets/src/media-missing/index.ts new file mode 100644 index 000000000..eaa44be6d --- /dev/null +++ b/packages/widgets/src/media-missing/index.ts @@ -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"));