Files
homarr/packages/integrations/src/emby/emby-integration.ts
2025-12-19 16:37:21 +01:00

223 lines
7.7 KiB
TypeScript

import { BaseItemKind } from "@jellyfin/sdk/lib/generated-client/models";
import { z } from "zod/v4";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
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 { convertJellyfinType } from "../jellyfin/jellyfin-integration";
import type { IMediaReleasesIntegration, MediaRelease, MediaType } from "../types";
const sessionSchema = z.object({
NowPlayingItem: z
.object({
Type: z.nativeEnum(BaseItemKind).optional(),
SeriesName: z.string().nullish(),
Name: z.string().nullish(),
SeasonName: z.string().nullish(),
EpisodeTitle: z.string().nullish(),
Album: z.string().nullish(),
EpisodeCount: z.number().nullish(),
})
.optional(),
Id: z.string(),
Client: z.string().nullish(),
DeviceId: z.string().nullish(),
DeviceName: z.string().nullish(),
UserId: z.string().optional(),
UserName: z.string().nullish(),
});
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"`;
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const apiKey = super.getSecretValue("apiKey");
const response = await input.fetchAsync(super.url("/emby/System/Ping"), {
headers: {
[EmbyIntegration.apiKeyHeader]: apiKey,
Authorization: EmbyIntegration.authorizationHeaderValue,
},
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
return {
success: true,
};
}
public async getCurrentSessionsAsync(options: CurrentSessionsInput): Promise<StreamSession[]> {
const apiKey = super.getSecretValue("apiKey");
const response = await fetchWithTrustedCertificatesAsync(super.url("/emby/Sessions"), {
headers: {
[EmbyIntegration.apiKeyHeader]: apiKey,
Authorization: EmbyIntegration.authorizationHeaderValue,
},
});
if (!response.ok) {
throw new Error(`Emby server ${this.integration.id} returned a non successful status code: ${response.status}`);
}
const result = z.array(sessionSchema).safeParse(await response.json());
if (!result.success) {
throw new Error(`Emby server ${this.integration.id} returned an unexpected response: ${result.error.message}`);
}
return result.data
.filter((sessionInfo) => sessionInfo.UserId !== undefined)
.filter((sessionInfo) => sessionInfo.DeviceId !== EmbyIntegration.deviceId)
.filter((sessionInfo) => !options.showOnlyPlaying || sessionInfo.NowPlayingItem !== undefined)
.map((sessionInfo): StreamSession => {
let currentlyPlaying: StreamSession["currentlyPlaying"] | null = null;
if (sessionInfo.NowPlayingItem) {
currentlyPlaying = {
type: convertJellyfinType(sessionInfo.NowPlayingItem.Type),
name: sessionInfo.NowPlayingItem.SeriesName ?? sessionInfo.NowPlayingItem.Name ?? "",
seasonName: sessionInfo.NowPlayingItem.SeasonName ?? "",
episodeName: sessionInfo.NowPlayingItem.EpisodeTitle,
albumName: sessionInfo.NowPlayingItem.Album ?? "",
episodeCount: sessionInfo.NowPlayingItem.EpisodeCount,
metadata: null,
};
}
return {
sessionId: `${sessionInfo.Id}`,
sessionName: `${sessionInfo.Client} (${sessionInfo.DeviceName})`,
user: {
profilePictureUrl: super.externalUrl(`/Users/${sessionInfo.UserId}/Images/Primary`).toString(),
userId: sessionInfo.UserId ?? "",
username: sessionInfo.UserName ?? "",
},
currentlyPlaying,
};
});
}
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: this.mapMediaReleaseType(item.Type),
title: item.Name,
subtitle: item.Taglines.at(0),
description: item.Overview,
releaseDate: item.PremiereDate ?? item.DateCreated,
imageUrls: {
poster: super.externalUrl(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
backdrop: super.externalUrl(`/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.externalUrl(`/web/index.html#!/item?id=${item.Id}&serverId=${item.ServerId}`).toString(),
}));
}
private mapMediaReleaseType(type: string | undefined): MediaType {
switch (type) {
case "Audio":
case "AudioBook":
case "MusicAlbum":
return "music";
case "Book":
return "book";
case "Episode":
case "Series":
case "Season":
return "tv";
case "Movie":
return "movie";
case "Video":
return "video";
default:
return "unknown";
}
}
// 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,
}));
}
}