feat(widgets): add media release widget (#3219)
This commit is contained in:
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
76
packages/integrations/src/interfaces/media-releases.ts
Normal file
76
packages/integrations/src/interfaces/media-releases.ts
Normal 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[]>;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
128
packages/integrations/src/mock/data/media-releases.ts
Normal file
128
packages/integrations/src/mock/data/media-releases.ts
Normal 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",
|
||||
},
|
||||
];
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user