feat(widgets): add media release widget (#3219)

This commit is contained in:
Meier Lukas
2025-07-20 16:59:03 +02:00
committed by GitHub
parent fa8e704112
commit 66ebb5061f
27 changed files with 1117 additions and 24 deletions

View File

@@ -34,6 +34,7 @@
"@homarr/forms-collection": "workspace:^0.1.0",
"@homarr/gridstack": "^1.12.0",
"@homarr/icons": "workspace:^0.1.0",
"@homarr/image-proxy": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/log": "workspace:^",
"@homarr/modals": "workspace:^0.1.0",

View File

@@ -0,0 +1,19 @@
import { notFound } from "next/navigation";
import { ImageProxy } from "@homarr/image-proxy";
export const GET = async (_request: Request, props: { params: Promise<{ id: string }> }) => {
const { id } = await props.params;
const imageProxy = new ImageProxy();
const image = await imageProxy.forwardImageAsync(id);
if (!image) {
notFound();
}
return new Response(image, {
headers: {
"Cache-Control": "public, max-age=3600, immutable", // Cache for 1 hour
},
});
};

View File

@@ -10,7 +10,7 @@
"main": "./src/main.ts",
"types": "./src/main.ts",
"scripts": {
"build": "esbuild src/main.ts --bundle --platform=node --loader:.scss=text --external:*.node --external:@opentelemetry/api --external:deasync --outfile=tasks.cjs",
"build": "esbuild src/main.ts --bundle --platform=node --loader:.scss=text --external:*.node --external:@opentelemetry/api --external:deasync --external:bcrypt --outfile=tasks.cjs",
"clean": "rm -rf .turbo node_modules",
"dev": "pnpm with-env tsx ./src/main.ts",
"format": "prettier --check . --ignore-path ../../.gitignore",

View File

@@ -5,6 +5,7 @@ import { dnsHoleRouter } from "./dns-hole";
import { downloadsRouter } from "./downloads";
import { healthMonitoringRouter } from "./health-monitoring";
import { indexerManagerRouter } from "./indexer-manager";
import { mediaReleaseRouter } from "./media-release";
import { mediaRequestsRouter } from "./media-requests";
import { mediaServerRouter } from "./media-server";
import { mediaTranscodingRouter } from "./media-transcoding";
@@ -27,6 +28,7 @@ export const widgetRouter = createTRPCRouter({
smartHome: smartHomeRouter,
stockPrice: stockPriceRouter,
mediaServer: mediaServerRouter,
mediaRelease: mediaReleaseRouter,
calendar: calendarRouter,
downloads: downloadsRouter,
mediaRequests: mediaRequestsRouter,

View File

@@ -0,0 +1,67 @@
import { observable } from "@trpc/server/observable";
import type { Modify } from "@homarr/common/types";
import type { Integration } from "@homarr/db/schema";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { MediaRelease } from "@homarr/integrations/types";
import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const mediaReleaseRouter = createTRPCRouter({
getMediaReleases: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
.query(async ({ ctx }) => {
const results = await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
releases: data,
};
}),
);
return results.flatMap((result) =>
result.releases.map((release) => ({
...release,
integration: result.integration,
})),
);
}),
subscribeToReleases: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
.subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"mediaRelease"> }>;
releases: MediaRelease[];
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = mediaReleaseRequestHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((releases) => {
emit.next({
integration,
releases,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
});

View File

@@ -12,3 +12,4 @@ export * from "./error";
export * from "./fetch-with-timeout";
export * from "./theme";
export * from "./function";
export * from "./id";

View File

@@ -92,19 +92,19 @@ export const integrationDefs = {
name: "Jellyfin",
secretKinds: [["username", "password"], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyfin.svg",
category: ["mediaService"],
category: ["mediaService", "mediaRelease"],
},
emby: {
name: "Emby",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/emby.svg",
category: ["mediaService"],
category: ["mediaService", "mediaRelease"],
},
plex: {
name: "Plex",
secretKinds: [["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg",
category: ["mediaService"],
category: ["mediaService", "mediaRelease"],
},
jellyseerr: {
name: "Jellyseerr",
@@ -224,6 +224,7 @@ export const integrationDefs = {
"downloadClient",
"healthMonitoring",
"indexerManager",
"mediaRelease",
"mediaRequest",
"mediaService",
"mediaTranscoding",
@@ -282,6 +283,7 @@ export const integrationCategories = [
"mediaService",
"calendar",
"mediaSearch",
"mediaRelease",
"mediaRequest",
"downloadClient",
"usenet",

View File

@@ -24,6 +24,7 @@ export const widgetKinds = [
"indexerManager",
"healthMonitoring",
"releases",
"mediaReleases",
"dockerContainers",
"notifications",
] as const;

View File

@@ -0,0 +1,4 @@
import baseConfig from "@homarr/eslint-config/base";
/** @type {import('typescript-eslint').Config} */
export default [...baseConfig];

View File

@@ -0,0 +1,39 @@
{
"name": "@homarr/image-proxy",
"version": "0.1.0",
"private": true,
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./src/index.ts"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"scripts": {
"clean": "rm -rf .turbo node_modules",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"typecheck": "tsc --noEmit"
},
"prettier": "@homarr/prettier-config",
"dependencies": {
"@homarr/certificates": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"bcrypt": "^6.0.0"
},
"devDependencies": {
"@homarr/eslint-config": "workspace:^0.2.0",
"@homarr/prettier-config": "workspace:^0.1.0",
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/bcrypt": "5.0.2",
"eslint": "^9.31.0",
"typescript": "^5.8.3"
}
}

View File

@@ -0,0 +1,133 @@
import bcrypt from "bcrypt";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { createId } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
import { logger } from "@homarr/log";
import { createGetSetChannel } from "@homarr/redis";
const createHashChannel = (hash: `${string}.${string}`) => createGetSetChannel<string>(`image-proxy:hash:${hash}`);
const createUrlByIdChannel = (id: string) =>
createGetSetChannel<{
url: `${string}.${string}`;
headers: `${string}.${string}`;
}>(`image-proxy:url:${id}`);
const saltChannel = createGetSetChannel<string>("image-proxy:salt");
export class ImageProxy {
private static salt: string | null = null;
private async getOrCreateSaltAsync(): Promise<string> {
if (ImageProxy.salt) return ImageProxy.salt;
const existingSalt = await saltChannel.getAsync();
if (existingSalt) {
ImageProxy.salt = existingSalt;
return existingSalt;
}
const salt = await bcrypt.genSalt(10);
logger.debug(`Generated new salt for image proxy salt="${salt}"`);
ImageProxy.salt = salt;
await saltChannel.setAsync(salt);
return salt;
}
public async createImageAsync(url: string, headers?: Record<string, string>): Promise<string> {
const existingId = await this.getExistingIdAsync(url, headers);
if (existingId) {
logger.debug(
`Image already exists in the proxy id="${existingId}" url="${this.redactUrl(url)}" headers="${this.redactHeaders(headers ?? null)}"`,
);
return this.createImageUrl(existingId);
}
const id = createId();
await this.storeImageAsync(id, url, headers);
return this.createImageUrl(id);
}
public async forwardImageAsync(id: string): Promise<Blob | null> {
const urlAndHeaders = await this.getImageUrlAndHeadersAsync(id);
if (!urlAndHeaders) {
return null;
}
const response = await fetchWithTrustedCertificatesAsync(urlAndHeaders.url, {
headers: urlAndHeaders.headers ?? {},
});
const proxyUrl = this.createImageUrl(id);
if (!response.ok) {
logger.error(
`Failed to fetch image id="${id}" url="${this.redactUrl(urlAndHeaders.url)}" headers="${this.redactHeaders(urlAndHeaders.headers)}" proxyUrl="${proxyUrl}" statusCode="${response.status}"`,
);
return null;
}
const blob = (await response.blob()) as Blob;
logger.debug(
`Forwarding image succeeded id="${id}" url="${this.redactUrl(urlAndHeaders.url)}" headers="${this.redactHeaders(urlAndHeaders.headers)}" proxyUrl="${proxyUrl} size="${(blob.size / 1024).toFixed(1)}KB"`,
);
return blob;
}
private createImageUrl(id: string): string {
return `/api/image-proxy/${id}`;
}
private async getImageUrlAndHeadersAsync(id: string) {
const urlHeaderChannel = createUrlByIdChannel(id);
const urlHeader = await urlHeaderChannel.getAsync();
if (!urlHeader) {
logger.warn(`Image not found in the proxy id="${id}"`);
return null;
}
return {
url: decryptSecret(urlHeader.url),
headers: JSON.parse(decryptSecret(urlHeader.headers)) as Record<string, string> | null,
};
}
private async getExistingIdAsync(url: string, headers: Record<string, string> | undefined): Promise<string | null> {
const salt = await this.getOrCreateSaltAsync();
const urlHash = await bcrypt.hash(url, salt);
const headerHash = await bcrypt.hash(JSON.stringify(headers ?? null), salt);
const channel = createHashChannel(`${urlHash}.${headerHash}`);
return await channel.getAsync();
}
private async storeImageAsync(id: string, url: string, headers: Record<string, string> | undefined): Promise<void> {
const salt = await this.getOrCreateSaltAsync();
const urlHash = await bcrypt.hash(url, salt);
const headerHash = await bcrypt.hash(JSON.stringify(headers ?? null), salt);
const hashChannel = createHashChannel(`${urlHash}.${headerHash}`);
const urlHeaderChannel = createUrlByIdChannel(id);
await urlHeaderChannel.setAsync({
url: encryptSecret(url),
headers: encryptSecret(JSON.stringify(headers ?? null)),
});
await hashChannel.setAsync(id);
logger.debug(
`Stored image in the proxy id="${id}" url="${this.redactUrl(url)}" headers="${this.redactHeaders(headers ?? null)}"`,
);
}
private redactUrl(url: string): string {
const urlObject = new URL(url);
const redactedSearch = [...urlObject.searchParams.keys()].map((key) => `${key}=REDACTED`).join("&");
return `${urlObject.origin}${urlObject.pathname}${redactedSearch ? `?${redactedSearch}` : ""}`;
}
private redactHeaders(headers: Record<string, string> | null): string | null {
if (!headers) return null;
return Object.keys(headers).join(", ");
}
}

View File

@@ -0,0 +1,9 @@
{
"extends": "@homarr/tsconfig/base.json",
"compilerOptions": {
"types": ["node"],
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}

View File

@@ -33,6 +33,7 @@
"@homarr/common": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/image-proxy": "workspace:^0.1.0",
"@homarr/log": "workspace:^0.1.0",
"@homarr/node-unifi": "^2.6.0",
"@homarr/redis": "workspace:^0.1.0",

View File

@@ -2,6 +2,7 @@ import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
import { z } from "zod";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ResponseError } from "@homarr/common/server";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
@@ -10,6 +11,7 @@ import type { TestingResult } from "../base/test-connection/test-connection-serv
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
import { convertJellyfinType } from "../jellyfin/jellyfin-integration";
import type { IMediaReleasesIntegration, MediaRelease } from "../types";
const sessionSchema = z.object({
NowPlayingItem: z
@@ -31,7 +33,34 @@ const sessionSchema = z.object({
UserName: z.string().nullish(),
});
export class EmbyIntegration extends Integration implements IMediaServerIntegration {
const itemSchema = z.object({
Id: z.string(),
ServerId: z.string(),
Name: z.string(),
Taglines: z.array(z.string()),
Studios: z.array(z.object({ Name: z.string() })),
Overview: z.string().optional(),
PremiereDate: z
.string()
.datetime()
.transform((date) => new Date(date))
.optional(),
DateCreated: z
.string()
.datetime()
.transform((date) => new Date(date)),
Genres: z.array(z.string()),
CommunityRating: z.number().optional(),
RunTimeTicks: z.number(),
Type: z.string(), // for example "Movie"
});
const userSchema = z.object({
Id: z.string(),
Name: z.string(),
});
export class EmbyIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
private static readonly apiKeyHeader = "X-Emby-Token";
private static readonly deviceId = "homarr-emby-integration";
private static readonly authorizationHeaderValue = `Emby Client="Dashboard", Device="Homarr", DeviceId="${EmbyIntegration.deviceId}", Version="0.0.1"`;
@@ -103,4 +132,69 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
};
});
}
public async getMediaReleasesAsync(): Promise<MediaRelease[]> {
const limit = 100;
const users = await this.fetchUsersPublicAsync();
const userId = users.at(0)?.id;
if (!userId) {
throw new Error("No users found");
}
const apiKey = super.getSecretValue("apiKey");
const response = await fetchWithTrustedCertificatesAsync(
super.url(
`/Users/${userId}/Items/Latest?Limit=${limit}&Fields=CommunityRating,Studios,PremiereDate,Genres,ChildCount,ProductionYear,DateCreated,Overview,Taglines`,
),
{
headers: {
[EmbyIntegration.apiKeyHeader]: apiKey,
Authorization: EmbyIntegration.authorizationHeaderValue,
},
},
);
if (!response.ok) {
throw new ResponseError(response);
}
const items = z.array(itemSchema).parse(await response.json());
return items.map((item) => ({
id: item.Id,
type: item.Type === "Movie" ? "movie" : item.Type === "Series" ? "tv" : "unknown",
title: item.Name,
subtitle: item.Taglines.at(0),
description: item.Overview,
releaseDate: item.PremiereDate ?? item.DateCreated,
imageUrls: {
poster: super.url(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
backdrop: super.url(`/Items/${item.Id}/Images/Backdrop/0?maxWidth=960&quality=70`).toString(),
},
producer: item.Studios.at(0)?.Name,
rating: item.CommunityRating?.toFixed(1),
tags: item.Genres,
href: super.url(`/web/index.html#!/item?id=${item.Id}&serverId=${item.ServerId}`).toString(),
}));
}
// https://dev.emby.media/reference/RestAPI/UserService/getUsersPublic.html
private async fetchUsersPublicAsync(): Promise<{ id: string; name: string }[]> {
const apiKey = super.getSecretValue("apiKey");
const response = await fetchWithTrustedCertificatesAsync(super.url("/Users/Public"), {
headers: {
[EmbyIntegration.apiKeyHeader]: apiKey,
Authorization: EmbyIntegration.authorizationHeaderValue,
},
});
if (!response.ok) {
throw new ResponseError(response);
}
const users = z.array(userSchema).parse(await response.json());
return users.map((user) => ({
id: user.Id,
name: user.Name,
}));
}
}

View File

@@ -0,0 +1,76 @@
import type { MantineColor } from "@mantine/core";
export const mediaTypeConfigurations = {
movie: {
color: "blue",
},
tv: {
color: "violet",
},
music: {
color: "green",
},
book: {
color: "orange",
},
game: {
color: "yellow",
},
video: {
color: "red",
},
article: {
color: "pink",
},
unknown: {
color: "gray",
},
} satisfies Record<string, { color: MantineColor }>;
export type MediaType = keyof typeof mediaTypeConfigurations;
export interface MediaRelease {
id: string;
type: MediaType;
title: string;
/**
* The subtitle of the media item, if applicable.
* Can also contain the season number for TV shows.
*/
subtitle?: string;
description?: string;
releaseDate: Date;
imageUrls: {
poster: string | undefined;
backdrop: string | undefined;
};
/**
* The name of the studio, publisher or author.
*/
producer?: string;
/**
* Price in USD
*/
price?: number;
/**
* Rating in any format (e.g. 5/10, 4.5/5, 90%, etc.)
*/
rating?: string;
/**
* List of tags / genres / categories
*/
tags: string[];
/**
* Link to the media item
*/
href: string;
/*
* Video / Music: duration in seconds
* Book: number of pages
*/
length?: number;
}
export interface IMediaReleasesIntegration {
getMediaReleasesAsync(): Promise<MediaRelease[]>;
}

View File

@@ -2,6 +2,8 @@ import { Jellyfin } from "@jellyfin/sdk";
import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
import { getSessionApi } from "@jellyfin/sdk/lib/utils/api/session-api";
import { getSystemApi } from "@jellyfin/sdk/lib/utils/api/system-api";
import { getUserApi } from "@jellyfin/sdk/lib/utils/api/user-api";
import { getUserLibraryApi } from "@jellyfin/sdk/lib/utils/api/user-library-api";
import type { AxiosInstance } from "axios";
import { createAxiosCertificateInstanceAsync } from "@homarr/certificates/server";
@@ -13,9 +15,10 @@ import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
import type { IMediaReleasesIntegration, MediaRelease } from "../types";
@HandleIntegrationErrors([integrationAxiosHttpErrorHandler])
export class JellyfinIntegration extends Integration implements IMediaServerIntegration {
export class JellyfinIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
private readonly jellyfin: Jellyfin = new Jellyfin({
clientInfo: {
name: "Homarr",
@@ -70,6 +73,43 @@ export class JellyfinIntegration extends Integration implements IMediaServerInte
});
}
public async getMediaReleasesAsync(): Promise<MediaRelease[]> {
const apiClient = await this.getApiAsync();
const userLibraryApi = getUserLibraryApi(apiClient);
const userApi = getUserApi(apiClient);
const users = await userApi.getUsers();
const userId = users.data.at(0)?.Id;
if (!userId) {
throw new Error("No users found");
}
const result = await userLibraryApi.getLatestMedia({
fields: ["CustomRating", "Studios", "Genres", "ChildCount", "DateCreated", "Overview", "Taglines"],
userId,
limit: 100,
});
return result.data.map((item) => ({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: item.Id!,
type: item.Type === "Movie" ? "movie" : item.Type === "Series" ? "tv" : "unknown",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
title: item.Name!,
subtitle: item.Taglines?.at(0),
description: item.Overview ?? undefined,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
releaseDate: new Date(item.PremiereDate ?? item.DateCreated!),
imageUrls: {
poster: super.url(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
backdrop: super.url(`/Items/${item.Id}/Images/Backdrop/0?maxWidth=960&quality=70`).toString(),
},
producer: item.Studios?.at(0)?.Name ?? undefined,
rating: item.CommunityRating?.toFixed(1),
tags: item.Genres ?? [],
href: super.url(`/web/index.html#!/details?id=${item.Id}&serverId=${item.ServerId}`).toString(),
}));
}
/**
* Constructs an ApiClient synchronously with an ApiKey or asynchronously
* with a username and password.

View File

@@ -0,0 +1,128 @@
import type { IMediaReleasesIntegration, MediaRelease } from "../../interfaces/media-releases";
export class MediaReleasesMockService implements IMediaReleasesIntegration {
public async getMediaReleasesAsync(): Promise<MediaRelease[]> {
return await Promise.resolve(mockMediaReleases);
}
}
export const mockMediaReleases: MediaRelease[] = [
{
id: "1",
type: "movie",
title: "Inception",
subtitle: "A mind-bending thriller",
description:
"A thief who steals corporate secrets through the use of dream-sharing technology is given the inverse task of planting an idea into the mind of a CEO.",
releaseDate: new Date("2010-07-16"),
imageUrls: {
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/inception_backdrop.jpg",
},
producer: "Warner Bros.",
price: 14.99,
rating: "8.8/10",
tags: ["Sci-Fi", "Thriller"],
href: "https://example.com/inception",
length: 148,
},
{
id: "2",
type: "tv",
title: "Breaking Bad",
subtitle: "S5E14 - Ozymandias",
description: "When Walter White's secret is revealed, he must face the consequences of his actions.",
releaseDate: new Date("2013-09-15"),
imageUrls: {
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/breaking_bad_backdrop.jpg",
},
producer: "AMC",
rating: "9.5/10",
tags: ["Crime", "Drama"],
href: "https://example.com/breaking_bad",
},
{
id: "3",
type: "music",
title: "Random Access Memories",
subtitle: "Daft Punk",
description: "The fourth studio album by French electronic music duo Daft Punk.",
releaseDate: new Date("2013-05-17"),
imageUrls: {
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/ram_backdrop.jpg",
},
producer: "Columbia Records",
price: 9.99,
rating: "8.5/10",
tags: ["Electronic", "Dance", "Pop", "Funk"],
href: "https://example.com/ram",
},
{
id: "4",
type: "book",
title: "The Great Gatsby",
subtitle: "F. Scott Fitzgerald",
description: "A novel about the American dream and the disillusionment that comes with it.",
releaseDate: new Date("1925-04-10"),
imageUrls: {
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/gatsby_backdrop.jpg",
},
producer: "Scribner",
price: 10.99,
rating: "4.2/5",
tags: ["Classic", "Fiction"],
href: "https://example.com/gatsby",
},
{
id: "5",
type: "game",
title: "The Legend of Zelda: Breath of the Wild",
subtitle: "Nintendo Switch",
description: "An open-world action-adventure game set in the fantasy land of Hyrule.",
releaseDate: new Date("2017-03-03"),
imageUrls: {
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/zelda_backdrop.jpg",
},
producer: "Nintendo",
price: 59.99,
rating: "10/10",
tags: ["Action", "Adventure"],
href: "https://example.com/zelda",
},
{
id: "6",
type: "article",
title: "The Rise of AI in Healthcare",
subtitle: "Tech Innovations",
description: "Exploring the impact of artificial intelligence on the healthcare industry.",
releaseDate: new Date("2023-10-01"),
imageUrls: {
poster: "https://media.outnow.ch/Movies/Bilder/2025/MinecraftMovie/015.jpg",
backdrop: "https://example.com/ai_healthcare_backdrop.jpg",
},
producer: "Tech Innovations",
rating: "4.8/5",
tags: ["Technology", "Healthcare"],
href: "https://example.com/ai_healthcare",
},
{
id: "7",
type: "video",
title: "Wir LIEBEN unsere MAMAS | 50 Fragen zu Mamas",
releaseDate: new Date("2024-05-18T17:00:00Z"),
imageUrls: {
poster:
"https://i.ytimg.com/vi/a3qyfXc1Pfg/hq720.jpg?sqp=-oaymwEnCNAFEJQDSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBQKm0viRlfRjTV-V24vGO83rPaVw",
backdrop:
"https://i.ytimg.com/vi/a3qyfXc1Pfg/hq720.jpg?sqp=-oaymwEnCNAFEJQDSFryq4qpAxkIARUAAIhCGAHYAQHiAQoIGBACGAY4AUAB&rs=AOn4CLBQKm0viRlfRjTV-V24vGO83rPaVw",
},
producer: "PietSmiet",
rating: "1K",
tags: [],
href: "https://www.youtube.com/watch?v=a3qyfXc1Pfg",
},
];

View File

@@ -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 { 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";
import type { IMediaTranscodingIntegration } from "../interfaces/media-transcoding/media-transcoding-integration";
@@ -19,6 +20,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 { MediaReleasesMockService } from "./data/media-releases";
import { MediaRequestMockService } from "./data/media-request";
import { MediaServerMockService } from "./data/media-server";
import { MediaTranscodingMockService } from "./data/media-transcoding";
@@ -36,6 +38,7 @@ export class MockIntegration
IClusterHealthMonitoringIntegration,
ISystemHealthMonitoringIntegration,
IIndexerManagerIntegration,
IMediaReleasesIntegration,
IMediaRequestIntegration,
IMediaServerIntegration,
IMediaTranscodingIntegration,
@@ -48,6 +51,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 mediaReleases = new MediaReleasesMockService();
private static readonly mediaRequest = new MediaRequestMockService();
private static readonly mediaServer = new MediaServerMockService();
private static readonly mediaTranscoding = new MediaTranscodingMockService();
@@ -87,6 +91,9 @@ export class MockIntegration
getIndexersAsync = MockIntegration.indexerManager.getIndexersAsync.bind(MockIntegration.indexerManager);
testAllAsync = MockIntegration.indexerManager.testAllAsync.bind(MockIntegration.indexerManager);
// MediaReleasesIntegration
getMediaReleasesAsync = MockIntegration.mediaReleases.getMediaReleasesAsync.bind(MockIntegration.mediaReleases);
// MediaRequestIntegration
getSeriesInformationAsync = MockIntegration.mediaRequest.getSeriesInformationAsync.bind(MockIntegration.mediaRequest);
requestMediaAsync = MockIntegration.mediaRequest.requestMediaAsync.bind(MockIntegration.mediaRequest);

View File

@@ -1,7 +1,9 @@
import { parseStringPromise } from "xml2js";
import { z } from "zod";
import { fetchWithTrustedCertificatesAsync } from "@homarr/certificates/server";
import { ParseError } from "@homarr/common/server";
import { ImageProxy } from "@homarr/image-proxy";
import { logger } from "@homarr/log";
import type { IntegrationTestingInput } from "../base/integration";
@@ -10,9 +12,10 @@ import { TestConnectionError } from "../base/test-connection/test-connection-err
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { IMediaServerIntegration } from "../interfaces/media-server/media-server-integration";
import type { CurrentSessionsInput, StreamSession } from "../interfaces/media-server/media-server-types";
import type { IMediaReleasesIntegration, MediaRelease } from "../types";
import type { PlexResponse } from "./interface";
export class PlexIntegration extends Integration implements IMediaServerIntegration {
export class PlexIntegration extends Integration implements IMediaServerIntegration, IMediaReleasesIntegration {
public async getCurrentSessionsAsync(_options: CurrentSessionsInput): Promise<StreamSession[]> {
const token = super.getSecretValue("apiKey");
@@ -66,6 +69,93 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
return medias;
}
public async getMediaReleasesAsync(): Promise<MediaRelease[]> {
const token = super.getSecretValue("apiKey");
const machineIdentifier = await this.getMachineIdentifierAsync();
const response = await fetchWithTrustedCertificatesAsync(super.url("/library/recentlyAdded"), {
headers: {
"X-Plex-Token": token,
Accept: "application/json",
},
});
const data = await recentlyAddedSchema.parseAsync(await response.json());
const imageProxy = new ImageProxy();
const images =
data.MediaContainer.Metadata?.flatMap((item) => [
{
mediaKey: item.key,
type: "poster",
url: item.Image.find((image) => image?.type === "coverPoster")?.url,
},
{
mediaKey: item.key,
type: "backdrop",
url: item.Image.find((image) => image?.type === "background")?.url,
},
]).filter(
(image): image is { mediaKey: string; type: "poster" | "backdrop"; url: string } => image.url !== undefined,
) ?? [];
const proxiedImages = await Promise.all(
images.map(async (image) => {
const imageUrl = super.url(image.url as `/${string}`);
const proxiedImageUrl = await imageProxy
.createImageAsync(imageUrl.toString(), {
"X-Plex-Token": token,
})
.catch((error) => {
logger.debug(new Error("Failed to proxy image", { cause: error }));
return undefined;
});
return {
mediaKey: image.mediaKey,
type: image.type,
url: proxiedImageUrl,
};
}),
);
return (
data.MediaContainer.Metadata?.map((item) => {
return {
id: item.Media.at(0)?.id.toString() ?? item.key,
type: item.type === "movie" ? "movie" : item.type === "tv" ? "tv" : "unknown",
title: item.title,
subtitle: item.tagline,
description: item.summary,
releaseDate: item.originallyAvailableAt
? new Date(item.originallyAvailableAt)
: new Date(item.addedAt * 1000),
imageUrls: {
poster: proxiedImages.find((image) => image.mediaKey === item.key && image.type === "poster")?.url,
backdrop: proxiedImages.find((image) => image.mediaKey === item.key && image.type === "backdrop")?.url,
},
producer: item.studio,
rating: item.rating?.toFixed(1),
tags: item.Genre.map((genre) => genre.tag),
href: super
.url(`/web/index.html#!/server/${machineIdentifier}/details?key=${encodeURIComponent(item.key)}`)
.toString(),
length: item.duration ? Math.round(item.duration / 1000) : undefined,
};
}) ?? []
);
}
private async getMachineIdentifierAsync(): Promise<string> {
const token = super.getSecretValue("apiKey");
const response = await fetchWithTrustedCertificatesAsync(super.url("/identity"), {
headers: {
"X-Plex-Token": token,
Accept: "application/json",
},
});
const data = await identitySchema.parseAsync(await response.json());
return data.MediaContainer.machineIdentifier;
}
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const token = super.getSecretValue("apiKey");
@@ -111,3 +201,50 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
}
}
}
// https://plexapi.dev/api-reference/library/get-recently-added
const recentlyAddedSchema = z.object({
MediaContainer: z.object({
Metadata: z
.array(
z.object({
key: z.string(),
studio: z.string().optional(),
type: z.string(), // For example "movie"
title: z.string(),
summary: z.string().optional(),
duration: z.number().optional(),
addedAt: z.number(),
rating: z.number().optional(),
tagline: z.string().optional(),
originallyAvailableAt: z.string().optional(),
Media: z.array(
z.object({
id: z.number(),
}),
),
Image: z.array(
z
.object({
type: z.string(), // for example "coverPoster" or "background"
url: z.string(),
})
.optional(),
),
Genre: z.array(
z.object({
tag: z.string(),
}),
),
}),
)
.optional(),
}),
});
// https://plexapi.dev/api-reference/server/get-server-identity
const identitySchema = z.object({
MediaContainer: z.object({
machineIdentifier: z.string(),
}),
});

View File

@@ -8,3 +8,4 @@ export * from "./base/searchable-integration";
export * from "./homeassistant/homeassistant-types";
export * from "./proxmox/proxmox-types";
export * from "./unifi-controller/unifi-controller-types";
export * from "./interfaces/media-releases";

View File

@@ -0,0 +1,20 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { MediaRelease } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
export const mediaReleaseRequestHandler = createCachedIntegrationRequestHandler<
MediaRelease[],
IntegrationKindByCategory<"mediaRelease">,
Record<string, never>
>({
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getMediaReleasesAsync();
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "mediaReleases",
});

View File

@@ -2079,6 +2079,35 @@
},
"globalRatio": "Global Ratio"
},
"mediaReleases": {
"name": "Media releases",
"description": "Display newly added medias or upcoming releases from different integrations",
"option": {
"layout": {
"label": "Layout",
"option": {
"backdrop": {
"label": "Backdrop"
},
"poster": {
"label": "Poster"
}
}
},
"showDescriptionTooltip": {
"label": "Show description tooltip"
},
"showType": {
"label": "Show media type badge"
},
"showSource": {
"label": "Show source integration"
}
},
"length": {
"duration": "{length}min"
}
},
"mediaRequests-requestList": {
"name": "Media Requests List",
"description": "See a list of all media requests from your Overseerr or Jellyseerr instance",

View File

@@ -1,13 +1,17 @@
import type { BadgeProps } from "@mantine/core";
import { ActionIcon, Badge, Group, Popover, Stack } from "@mantine/core";
import type { BadgeProps, MantineSpacing } from "@mantine/core";
import { Badge, Group, Popover, Stack, UnstyledButton } from "@mantine/core";
export function OverflowBadge({
data,
overflowCount = 3,
disablePopover = false,
groupGap = "xs",
...props
}: {
data: string[];
overflowCount?: number;
disablePopover?: boolean;
groupGap?: MantineSpacing;
} & BadgeProps) {
const badgeProps = {
variant: "default",
@@ -16,8 +20,8 @@ export function OverflowBadge({
...props,
};
return (
<Popover width="content" shadow="md">
<Group gap="xs">
<Popover width="content" shadow="md" disabled={disablePopover}>
<Group gap={groupGap}>
{data.slice(0, overflowCount).map((item) => (
<Badge key={item} px="xs" {...badgeProps}>
{item}
@@ -25,19 +29,11 @@ export function OverflowBadge({
))}
{data.length > overflowCount && (
<Popover.Target>
<ActionIcon
{...{
variant: badgeProps.variant,
color: badgeProps.color,
}}
size="sm"
fw="bold"
fz="sm"
p="sm"
px="md"
>
+{data.length - overflowCount}
</ActionIcon>
<UnstyledButton display="flex">
<Badge px="xs" style={{ cursor: "pointer", ...badgeProps.style }} {...badgeProps}>
+{data.length - overflowCount}
</Badge>
</UnstyledButton>
</Popover.Target>
)}
</Group>

View File

@@ -20,6 +20,7 @@ import * as healthMonitoring from "./health-monitoring";
import * as iframe from "./iframe";
import type { WidgetImportRecord } from "./import";
import * as indexerManager from "./indexer-manager";
import * as mediaReleases from "./media-releases";
import * as mediaRequestsList from "./media-requests/list";
import * as mediaRequestsStats from "./media-requests/stats";
import * as mediaServer from "./media-server";
@@ -69,6 +70,7 @@ export const widgetImports = {
dockerContainers,
releases,
notifications,
mediaReleases,
} satisfies WidgetImportRecord;
export type WidgetImports = typeof widgetImports;

View File

@@ -0,0 +1,206 @@
"use client";
import { Fragment } from "react";
import { Avatar, Badge, Box, Divider, Group, Image, Stack, Text, TooltipFloating, UnstyledButton } from "@mantine/core";
import { IconBook, IconCalendar, IconClock, IconStarFilled } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { getMantineColor } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
import type { MediaRelease } from "@homarr/integrations/types";
import { mediaTypeConfigurations } from "@homarr/integrations/types";
import type { TranslationFunction } from "@homarr/translation";
import { useCurrentLocale, useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { OverflowBadge } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
export default function MediaReleasesWidget({ options, integrationIds }: WidgetComponentProps<"mediaReleases">) {
const [releases] = clientApi.widget.mediaRelease.getMediaReleases.useSuspenseQuery({
integrationIds,
});
return (
<Stack p="xs" gap="sm">
{releases.map((item, index) => (
<Fragment key={item.id}>
{index !== 0 && options.layout === "poster" && <Divider />}
<Item item={item} options={options} />
</Fragment>
))}
</Stack>
);
}
interface ItemProps {
item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"][number];
options: WidgetComponentProps<"mediaReleases">["options"];
}
const Item = ({ item, options }: ItemProps) => {
const locale = useCurrentLocale();
const t = useI18n();
const length = formatLength(item.length, item.type, t);
return (
<TooltipFloating
label={item.description}
w={300}
multiline
disabled={item.description === undefined || item.description.trim() === "" || !options.showDescriptionTooltip}
>
<UnstyledButton
component="a"
href={item.href}
target="_blank"
rel="noopener noreferrer"
pos="relative"
p={options.layout === "poster" ? 0 : 4}
>
{options.layout === "backdrop" && (
<Box
w="100%"
h="100%"
pos="absolute"
top={0}
left={0}
style={{
backgroundImage: `url(${item.imageUrls.backdrop})`,
borderRadius: 8,
backgroundRepeat: "no-repeat",
backgroundSize: "cover",
backgroundPosition: "center",
opacity: 0.2,
}}
/>
)}
<Group justify="space-between" h="100%" wrap="nowrap">
<Group align="start" wrap="nowrap" style={{ zIndex: 0 }}>
{options.layout === "poster" && <Image w={60} src={item.imageUrls.poster} alt={item.title} />}
<Stack gap={4}>
<Stack gap={0}>
<Text size="sm" fw="bold" lineClamp={2}>
{item.title}
</Text>
{item.subtitle !== undefined && (
<Text size="sm" lineClamp={1}>
{item.subtitle}
</Text>
)}
</Stack>
<Group gap={6} style={{ rowGap: 0 }}>
<Info
icon={IconCalendar}
label={Intl.DateTimeFormat(locale, {
month: "2-digit",
year: "numeric",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(item.releaseDate)}
/>
{length !== undefined && (
<>
<InfoDivider />
<Info icon={length.type === "duration" ? IconClock : IconBook} label={length.label} />
</>
)}
{item.producer !== undefined && (
<>
<InfoDivider />
<Info label={item.producer} />
</>
)}
{item.rating !== undefined && (
<>
<InfoDivider />
<Info icon={IconStarFilled} label={item.rating} />
</>
)}
{item.price !== undefined && (
<>
<InfoDivider />
<Info label={`$${item.price.toFixed(2)}`} />
</>
)}
</Group>
{item.tags.length > 0 && (
<OverflowBadge
size="xs"
groupGap={4}
data={item.tags}
overflowCount={3}
disablePopover
style={{ cursor: "pointer" }}
/>
)}
</Stack>
</Group>
{(options.showType || options.showSource) && (
<Stack justify="space-between" align="end" h="100%" style={{ zIndex: 0 }}>
{options.showType && (
<Badge
w="max-content"
size="xs"
color={mediaTypeConfigurations[item.type].color}
style={{ cursor: "pointer" }}
>
{item.type}
</Badge>
)}
{options.showSource && (
<Avatar size="sm" radius="xl" src={getIconUrl(item.integration.kind)} alt={item.integration.name} />
)}
</Stack>
)}
</Group>
</UnstyledButton>
</TooltipFloating>
);
};
interface IconAndLabelProps {
icon?: TablerIcon;
label: string;
}
const InfoDivider = () => (
<Text size="xs" c="dimmed">
</Text>
);
const Info = ({ icon: Icon, label }: IconAndLabelProps) => {
return (
<Group gap={4}>
{Icon && <Icon size={12} color={getMantineColor("gray", 5)} />}
<Text size="xs" c="gray.5">
{label}
</Text>
</Group>
);
};
const formatLength = (length: number | undefined, type: MediaRelease["type"], t: TranslationFunction) => {
if (!length) return undefined;
if (type === "movie" || type === "tv" || type === "video" || type === "music" || type === "article") {
return {
type: "duration" as const,
label: t("widget.mediaReleases.length.duration", {
length: Math.round(length / 60).toString(),
}),
};
}
if (type === "book") {
return {
type: "page" as const,
label: length.toString(),
};
}
return undefined;
};

View File

@@ -0,0 +1,35 @@
import { IconTicket } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("mediaReleases", {
icon: IconTicket,
createOptions() {
return optionsBuilder.from((factory) => ({
layout: factory.select({
defaultValue: "backdrop",
options: [
{
value: "backdrop",
label: (t) => t("widget.mediaReleases.option.layout.option.backdrop.label"),
},
{
value: "poster",
label: (t) => t("widget.mediaReleases.option.layout.option.poster.label"),
},
],
}),
showDescriptionTooltip: factory.switch({
defaultValue: true,
}),
showType: factory.switch({
defaultValue: true,
}),
showSource: factory.switch({
defaultValue: true,
}),
}));
},
supportedIntegrations: ["mock", "emby", "jellyfin", "plex"],
}).withDynamicImport(() => import("./component"));

43
pnpm-lock.yaml generated
View File

@@ -139,6 +139,9 @@ importers:
'@homarr/icons':
specifier: workspace:^0.1.0
version: link:../../packages/icons
'@homarr/image-proxy':
specifier: workspace:^0.1.0
version: link:../../packages/image-proxy
'@homarr/integrations':
specifier: workspace:^0.1.0
version: link:../../packages/integrations
@@ -1325,6 +1328,43 @@ importers:
specifier: ^5.8.3
version: 5.8.3
packages/image-proxy:
dependencies:
'@homarr/certificates':
specifier: workspace:^0.1.0
version: link:../certificates
'@homarr/common':
specifier: workspace:^0.1.0
version: link:../common
'@homarr/log':
specifier: workspace:^0.1.0
version: link:../log
'@homarr/redis':
specifier: workspace:^0.1.0
version: link:../redis
bcrypt:
specifier: ^6.0.0
version: 6.0.0
devDependencies:
'@homarr/eslint-config':
specifier: workspace:^0.2.0
version: link:../../tooling/eslint
'@homarr/prettier-config':
specifier: workspace:^0.1.0
version: link:../../tooling/prettier
'@homarr/tsconfig':
specifier: workspace:^0.1.0
version: link:../../tooling/typescript
'@types/bcrypt':
specifier: 5.0.2
version: 5.0.2
eslint:
specifier: ^9.31.0
version: 9.31.0
typescript:
specifier: ^5.8.3
version: 5.8.3
packages/integrations:
dependencies:
'@ctrl/deluge':
@@ -1351,6 +1391,9 @@ importers:
'@homarr/definitions':
specifier: workspace:^0.1.0
version: link:../definitions
'@homarr/image-proxy':
specifier: workspace:^0.1.0
version: link:../image-proxy
'@homarr/log':
specifier: workspace:^0.1.0
version: link:../log