diff --git a/apps/nextjs/package.json b/apps/nextjs/package.json index 895379e9c..9382e48cd 100644 --- a/apps/nextjs/package.json +++ b/apps/nextjs/package.json @@ -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", diff --git a/apps/nextjs/src/app/api/image-proxy/[id]/route.ts b/apps/nextjs/src/app/api/image-proxy/[id]/route.ts new file mode 100644 index 000000000..26eb70dd8 --- /dev/null +++ b/apps/nextjs/src/app/api/image-proxy/[id]/route.ts @@ -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 + }, + }); +}; diff --git a/apps/tasks/package.json b/apps/tasks/package.json index b04236f7a..cbd6c21d0 100644 --- a/apps/tasks/package.json +++ b/apps/tasks/package.json @@ -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", diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 77a1bd2ef..2ad920b5d 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -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, diff --git a/packages/api/src/router/widgets/media-release.ts b/packages/api/src/router/widgets/media-release.ts new file mode 100644 index 000000000..fddc174cc --- /dev/null +++ b/packages/api/src/router/widgets/media-release.ts @@ -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 }>; + 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(); + }); + }; + }); + }), +}); diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 7ffe71664..f7d667155 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -12,3 +12,4 @@ export * from "./error"; export * from "./fetch-with-timeout"; export * from "./theme"; export * from "./function"; +export * from "./id"; diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index 1506182f1..7a1112aba 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -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", diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index 609e8bf9a..24da8f214 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -24,6 +24,7 @@ export const widgetKinds = [ "indexerManager", "healthMonitoring", "releases", + "mediaReleases", "dockerContainers", "notifications", ] as const; diff --git a/packages/image-proxy/eslint.config.js b/packages/image-proxy/eslint.config.js new file mode 100644 index 000000000..f7a5a7d36 --- /dev/null +++ b/packages/image-proxy/eslint.config.js @@ -0,0 +1,4 @@ +import baseConfig from "@homarr/eslint-config/base"; + +/** @type {import('typescript-eslint').Config} */ +export default [...baseConfig]; diff --git a/packages/image-proxy/package.json b/packages/image-proxy/package.json new file mode 100644 index 000000000..638d21704 --- /dev/null +++ b/packages/image-proxy/package.json @@ -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" + } +} diff --git a/packages/image-proxy/src/index.ts b/packages/image-proxy/src/index.ts new file mode 100644 index 000000000..19f66e608 --- /dev/null +++ b/packages/image-proxy/src/index.ts @@ -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(`image-proxy:hash:${hash}`); +const createUrlByIdChannel = (id: string) => + createGetSetChannel<{ + url: `${string}.${string}`; + headers: `${string}.${string}`; + }>(`image-proxy:url:${id}`); +const saltChannel = createGetSetChannel("image-proxy:salt"); + +export class ImageProxy { + private static salt: string | null = null; + private async getOrCreateSaltAsync(): Promise { + 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): Promise { + 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 { + 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 | null, + }; + } + + private async getExistingIdAsync(url: string, headers: Record | undefined): Promise { + 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 | undefined): Promise { + 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 | null): string | null { + if (!headers) return null; + + return Object.keys(headers).join(", "); + } +} diff --git a/packages/image-proxy/tsconfig.json b/packages/image-proxy/tsconfig.json new file mode 100644 index 000000000..612bef8df --- /dev/null +++ b/packages/image-proxy/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@homarr/tsconfig/base.json", + "compilerOptions": { + "types": ["node"], + "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json" + }, + "include": ["*.ts", "src"], + "exclude": ["node_modules"] +} diff --git a/packages/integrations/package.json b/packages/integrations/package.json index da43daf54..7f063c4bd 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -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", diff --git a/packages/integrations/src/emby/emby-integration.ts b/packages/integrations/src/emby/emby-integration.ts index e05ba96f5..2b354cbe1 100644 --- a/packages/integrations/src/emby/emby-integration.ts +++ b/packages/integrations/src/emby/emby-integration.ts @@ -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 { + 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, + })); + } } diff --git a/packages/integrations/src/interfaces/media-releases.ts b/packages/integrations/src/interfaces/media-releases.ts new file mode 100644 index 000000000..face87f72 --- /dev/null +++ b/packages/integrations/src/interfaces/media-releases.ts @@ -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; + +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; +} diff --git a/packages/integrations/src/jellyfin/jellyfin-integration.ts b/packages/integrations/src/jellyfin/jellyfin-integration.ts index 5bfcb2e3f..0a31af85b 100644 --- a/packages/integrations/src/jellyfin/jellyfin-integration.ts +++ b/packages/integrations/src/jellyfin/jellyfin-integration.ts @@ -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 { + 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. diff --git a/packages/integrations/src/mock/data/media-releases.ts b/packages/integrations/src/mock/data/media-releases.ts new file mode 100644 index 000000000..d820a9aa4 --- /dev/null +++ b/packages/integrations/src/mock/data/media-releases.ts @@ -0,0 +1,128 @@ +import type { IMediaReleasesIntegration, MediaRelease } from "../../interfaces/media-releases"; + +export class MediaReleasesMockService implements IMediaReleasesIntegration { + public async getMediaReleasesAsync(): Promise { + 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", + }, +]; diff --git a/packages/integrations/src/mock/mock-integration.ts b/packages/integrations/src/mock/mock-integration.ts index c3fc71c4d..f04959171 100644 --- a/packages/integrations/src/mock/mock-integration.ts +++ b/packages/integrations/src/mock/mock-integration.ts @@ -9,6 +9,7 @@ import type { ISystemHealthMonitoringIntegration, } from "../interfaces/health-monitoring/health-monitoring-integration"; import type { IIndexerManagerIntegration } from "../interfaces/indexer-manager/indexer-manager-integration"; +import type { 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); diff --git a/packages/integrations/src/plex/plex-integration.ts b/packages/integrations/src/plex/plex-integration.ts index 1025d9df2..c8d4582e8 100644 --- a/packages/integrations/src/plex/plex-integration.ts +++ b/packages/integrations/src/plex/plex-integration.ts @@ -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 { const token = super.getSecretValue("apiKey"); @@ -66,6 +69,93 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat return medias; } + public async getMediaReleasesAsync(): Promise { + 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 { + 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 { 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(), + }), +}); diff --git a/packages/integrations/src/types.ts b/packages/integrations/src/types.ts index b8dc464d4..827c100b3 100644 --- a/packages/integrations/src/types.ts +++ b/packages/integrations/src/types.ts @@ -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"; diff --git a/packages/request-handler/src/media-release.ts b/packages/request-handler/src/media-release.ts new file mode 100644 index 000000000..c0ffe48f3 --- /dev/null +++ b/packages/request-handler/src/media-release.ts @@ -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 +>({ + async requestAsync(integration, _input) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getMediaReleasesAsync(); + }, + cacheDuration: dayjs.duration(5, "minutes"), + queryKey: "mediaReleases", +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index b09f08258..5df3d35d6 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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", diff --git a/packages/ui/src/components/overflow-badge.tsx b/packages/ui/src/components/overflow-badge.tsx index 976f8e2fd..253597ea8 100644 --- a/packages/ui/src/components/overflow-badge.tsx +++ b/packages/ui/src/components/overflow-badge.tsx @@ -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 ( - - + + {data.slice(0, overflowCount).map((item) => ( {item} @@ -25,19 +29,11 @@ export function OverflowBadge({ ))} {data.length > overflowCount && ( - - +{data.length - overflowCount} - + + + +{data.length - overflowCount} + + )} diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index fc2fbc8e4..91ec26ac3 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -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; diff --git a/packages/widgets/src/media-releases/component.tsx b/packages/widgets/src/media-releases/component.tsx new file mode 100644 index 000000000..cd2432ddb --- /dev/null +++ b/packages/widgets/src/media-releases/component.tsx @@ -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 ( + + {releases.map((item, index) => ( + + {index !== 0 && options.layout === "poster" && } + + + ))} + + ); +} + +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 ( + + + {options.layout === "backdrop" && ( + + )} + + + {options.layout === "poster" && {item.title}} + + + + {item.title} + + {item.subtitle !== undefined && ( + + {item.subtitle} + + )} + + + + {length !== undefined && ( + <> + + + + )} + {item.producer !== undefined && ( + <> + + + + )} + {item.rating !== undefined && ( + <> + + + + )} + {item.price !== undefined && ( + <> + + + + )} + + {item.tags.length > 0 && ( + + )} + + + {(options.showType || options.showSource) && ( + + {options.showType && ( + + {item.type} + + )} + + {options.showSource && ( + + )} + + )} + + + + ); +}; + +interface IconAndLabelProps { + icon?: TablerIcon; + label: string; +} + +const InfoDivider = () => ( + + • + +); + +const Info = ({ icon: Icon, label }: IconAndLabelProps) => { + return ( + + {Icon && } + + {label} + + + ); +}; + +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; +}; diff --git a/packages/widgets/src/media-releases/index.ts b/packages/widgets/src/media-releases/index.ts new file mode 100644 index 000000000..af650eef7 --- /dev/null +++ b/packages/widgets/src/media-releases/index.ts @@ -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")); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8104b4a6d..3b483d1d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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