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

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