diff --git a/packages/api/src/router/widgets/immich.ts b/packages/api/src/router/widgets/immich.ts new file mode 100644 index 000000000..f1428a80c --- /dev/null +++ b/packages/api/src/router/widgets/immich.ts @@ -0,0 +1,42 @@ +import { z } from "zod/v4"; + +import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import { + immichAlbumRequestHandler, + immichAlbumsRequestHandler, + immichStatsRequestHandler, +} from "@homarr/request-handler/immich"; + +import type { IntegrationAction } from "../../middlewares/integration"; +import { createOneIntegrationMiddleware } from "../../middlewares/integration"; +import { createTRPCRouter, publicProcedure } from "../../trpc"; + +const createImmichIntegrationMiddleware = (action: IntegrationAction) => + createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("photoService")); + +export const immichRouter = createTRPCRouter({ + getServerStats: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => { + const innerHandler = immichStatsRequestHandler.handler(ctx.integration, {}); + const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return data.data; + }), + + getAlbum: publicProcedure + .concat(createImmichIntegrationMiddleware("query")) + .input( + z.object({ + albumId: z.string(), + }), + ) + .query(async ({ ctx, input }) => { + const innerHandler = immichAlbumRequestHandler.handler(ctx.integration, { albumId: input.albumId }); + const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return data.data; + }), + + getAlbums: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => { + const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, {}); + const data = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false }); + return data.data; + }), +}); diff --git a/packages/api/src/router/widgets/index.ts b/packages/api/src/router/widgets/index.ts index 8f2d775db..0484c875c 100644 --- a/packages/api/src/router/widgets/index.ts +++ b/packages/api/src/router/widgets/index.ts @@ -4,6 +4,7 @@ import { createTRPCRouter } from "../../trpc"; export const widgetRouter = createTRPCRouter({ coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)), + immich: lazy(() => import("./immich").then((mod) => mod.immichRouter)), notebook: lazy(() => import("./notebook").then((mod) => mod.notebookRouter)), weather: lazy(() => import("./weather").then((mod) => mod.weatherRouter)), app: lazy(() => import("./app").then((mod) => mod.appRouter)), diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts index eb5d2317f..f4edb4c17 100644 --- a/packages/definitions/src/integration.ts +++ b/packages/definitions/src/integration.ts @@ -334,6 +334,14 @@ export const integrationDefs = { // @ts-expect-error - docs page will be created when integration is merged documentationUrl: createDocumentationLink("/docs/integrations/coolify"), }, + immich: { + name: "Immich", + secretKinds: [["apiKey"]], + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/immich.svg", + category: ["photoService"], + // @ts-expect-error TS2345 + documentationUrl: createDocumentationLink("/docs/integrations/immich"), + }, // This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page) mock: { name: "Mock", @@ -420,6 +428,7 @@ export const integrationCategories = [ "releasesProvider", "notifications", "firewall", + "photoService", ] as const; export type IntegrationCategory = (typeof integrationCategories)[number]; diff --git a/packages/definitions/src/widget.ts b/packages/definitions/src/widget.ts index 8d5b657d2..4dcfccfd9 100644 --- a/packages/definitions/src/widget.ts +++ b/packages/definitions/src/widget.ts @@ -31,5 +31,7 @@ export const widgetKinds = [ "systemResources", "coolify", "systemDisks", + "immich-serverStats", + "immich-albumCarousel", ] as const; export type WidgetKind = (typeof widgetKinds)[number]; diff --git a/packages/integrations/package.json b/packages/integrations/package.json index f1b3320d8..302863214 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -38,6 +38,7 @@ "@homarr/redis": "workspace:^0.1.0", "@homarr/translation": "workspace:^0.1.0", "@homarr/validation": "workspace:^0.1.0", + "@immich/sdk": "^2.5.6", "@jellyfin/sdk": "^0.13.0", "@octokit/auth-app": "^8.2.0", "ical.js": "^2.2.1", diff --git a/packages/integrations/src/base/creator.ts b/packages/integrations/src/base/creator.ts index 847451ac5..69db53a92 100644 --- a/packages/integrations/src/base/creator.ts +++ b/packages/integrations/src/base/creator.ts @@ -19,6 +19,7 @@ import { GitlabIntegration } from "../gitlab/gitlab-integration"; import { GlancesIntegration } from "../glances/glances-integration"; import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration"; import { ICalIntegration } from "../ical/ical-integration"; +import { ImmichIntegration } from "../immich/immich-integration"; import { JellyfinIntegration } from "../jellyfin/jellyfin-integration"; import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration"; import { LinuxServerIOIntegration } from "../linuxserverio/linuxserverio-integration"; @@ -111,6 +112,7 @@ export const integrationCreators = { unraid: UnraidIntegration, coolify: CoolifyIntegration, glances: GlancesIntegration, + immich: ImmichIntegration, } satisfies Record Promise]>; type IntegrationInstanceOfKind = { diff --git a/packages/integrations/src/immich/immich-integration.ts b/packages/integrations/src/immich/immich-integration.ts new file mode 100644 index 000000000..21019e4b7 --- /dev/null +++ b/packages/integrations/src/immich/immich-integration.ts @@ -0,0 +1,111 @@ +import { getAlbumInfo, getAllAlbums, getMyUser, getServerStatistics, init, searchUsers } from "@immich/sdk"; + +import { createLogger } from "@homarr/core/infrastructure/logs"; +import { ImageProxy } from "@homarr/image-proxy"; + +import type { IntegrationInput, IntegrationTestingInput } from "../base/integration"; +import { Integration } from "../base/integration"; +import type { TestingResult } from "../base/test-connection/test-connection-service"; + +export interface ImmichServerStats { + userCount: number; + photoCount: number; + videoCount: number; + totalLibraryUsageInBytes: number; +} + +export interface ImmichAlbum { + id: string; + albumName: string; + assets: ImmichAsset[]; +} + +export interface ImmichAsset { + id: string; + deviceAssetId: string; + originalPath: string; + thumbhash: string | null; + fileModifiedAt: string; + fileCreatedAt: string; + updatedAt: string; + type: "IMAGE" | "VIDEO" | "AUDIO" | "OTHER"; + publicLink: string; +} + +const logger = createLogger({ module: "immich-integration" }); + +export class ImmichIntegration extends Integration { + constructor(integration: IntegrationInput) { + super(integration); + } + + public async getServerStatsAsync(): Promise { + this.initClient(); + const [statistics, users] = await Promise.all([getServerStatistics(), searchUsers()]); + return { + photoCount: statistics.photos, + userCount: users.length, + totalLibraryUsageInBytes: statistics.usage, + videoCount: statistics.videos, + }; + } + + public async getAlbumAsync(albumId: string): Promise { + this.initClient(); + const album = await getAlbumInfo({ id: albumId }); + const imageProxy = new ImageProxy(); + return { + albumName: album.albumName, + assets: await Promise.all( + album.assets.map(async (asset) => { + const publicLink = await imageProxy.createImageAsync( + this.url(`/api/assets/${asset.id}/original`).toString(), + { + "x-api-key": this.getSecretValue("apiKey"), + }, + ); + return { + id: asset.id, + type: asset.type, + deviceAssetId: asset.deviceAssetId, + thumbhash: asset.thumbhash, + fileCreatedAt: asset.fileCreatedAt, + fileModifiedAt: asset.fileModifiedAt, + originalPath: asset.originalPath, + updatedAt: asset.updatedAt, + publicLink, + }; + }), + ), + id: album.id, + }; + } + + public async getAlbumsAsync(): Promise< + { + id: string; + albumName: string; + assetCount: number; + }[] + > { + this.initClient(); + const albums = await getAllAlbums({}); + return albums.map((album) => ({ id: album.id, albumName: album.albumName, assetCount: album.assetCount })); + } + + protected async testingAsync(_: IntegrationTestingInput): Promise { + this.initClient(); + const user = await getMyUser(); + logger.debug(`Logged in as ${user.name} (${user.id})`); + return { success: true }; + } + + /** + * Sets the credentials and prepares the client for calls. + * Must be called before any other functions. + * @private + */ + private initClient() { + init({ baseUrl: this.url("/api").toString(), apiKey: this.getSecretValue("apiKey") }); + } +} diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 452fe0df3..6a72787d0 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -29,6 +29,7 @@ export { UnraidIntegration } from "./unraid/unraid-integration"; export { OPNsenseIntegration } from "./opnsense/opnsense-integration"; export { ICalIntegration } from "./ical/ical-integration"; export { CoolifyIntegration } from "./coolify/coolify-integration"; +export { ImmichIntegration } from "./immich/immich-integration"; // Types export type { IntegrationInput } from "./base/integration"; @@ -54,6 +55,7 @@ export type { } from "./interfaces/media-transcoding/media-transcoding-types"; export type { ReleasesRepository, ReleaseResponse } from "./interfaces/releases-providers/releases-providers-types"; export type { Notification } from "./interfaces/notifications/notification-types"; +export type { ImmichServerStats, ImmichAlbum, ImmichAsset } from "./immich/immich-integration"; // Schemas export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items"; diff --git a/packages/request-handler/src/immich.ts b/packages/request-handler/src/immich.ts new file mode 100644 index 000000000..e95cb32b8 --- /dev/null +++ b/packages/request-handler/src/immich.ts @@ -0,0 +1,50 @@ +import dayjs from "dayjs"; + +import type { IntegrationKindByCategory } from "@homarr/definitions"; +import { createIntegrationAsync } from "@homarr/integrations"; +import type { ImmichAlbum, ImmichServerStats } from "@homarr/integrations"; + +import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler"; + +export const immichStatsRequestHandler = createCachedIntegrationRequestHandler< + ImmichServerStats, + IntegrationKindByCategory<"photoService">, + Record +>({ + async requestAsync(integration) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getServerStatsAsync(); + }, + cacheDuration: dayjs.duration(15, "minute"), + queryKey: "immich-server-stats", +}); + +export const immichAlbumsRequestHandler = createCachedIntegrationRequestHandler< + { + id: string; + albumName: string; + assetCount: number; + }[], + IntegrationKindByCategory<"photoService">, + Record +>({ + async requestAsync(integration) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getAlbumsAsync(); + }, + cacheDuration: dayjs.duration(15, "minute"), + queryKey: "immich-albums", +}); + +export const immichAlbumRequestHandler = createCachedIntegrationRequestHandler< + ImmichAlbum, + IntegrationKindByCategory<"photoService">, + { albumId: string } +>({ + async requestAsync(integration, input) { + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getAlbumAsync(input.albumId); + }, + cacheDuration: dayjs.duration(15, "minute"), + queryKey: "immich-album", +}); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index ef354baaf..4580d847d 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2091,6 +2091,52 @@ } } }, + "immich-serverStats": { + "name": "Immich Server Stats", + "description": "Information about your Immich instance", + "option": { + "showUsers": { + "label": "Show users", + "description": "Show count of users on your instance" + }, + "showPhotos": { + "label": "Show photos", + "description": "Show count of photos on your instance" + }, + "showVideos": { + "label": "Show videos", + "description": "Show count of videos on your instance" + }, + "showStorage": { + "label": "Show storage", + "description": "Show the total amount of storage used" + } + }, + "users": "Users", + "photos": "Photos", + "videos": "Videos", + "storage": "Used Storage" + }, + "immich-albumCarousel": { + "name": "Immich Album", + "option": { + "albumId": { + "label": "Album ID", + "description": "This is a unique ID which refers to your Immich album. You can find this id by opening an album and copying the unique string from the URL, for example 'a21aca09-9f05-47dc-b1a1-7b53c547226e'" + }, + "rotationIntervalSeconds": { + "label": "Interval in seconds", + "description": "Rotate between pictures in the album every X seconds. Must be at least 1. Must be below 3600." + }, + "showPhotoInfo": { + "label": "Show photo info", + "description": "Display date of the picture and the current index at the bottom of the slideshow" + } + }, + "description": "Slideshow of pictures in your Immich album", + "noAlbumSelected": "No album selected, edit widget and choose an album", + "noPhotos": "No photos in this album" + }, "downloads": { "name": "Download Client", "description": "Allows you to view and manage your Downloads from both Torrent and Usenet clients.", diff --git a/packages/widgets/src/immich/album-carousel/component.module.css b/packages/widgets/src/immich/album-carousel/component.module.css new file mode 100644 index 000000000..9412b96dc --- /dev/null +++ b/packages/widgets/src/immich/album-carousel/component.module.css @@ -0,0 +1,52 @@ +.carouselContainer { + position: relative; + overflow: hidden; + background-color: var(--mantine-color-gray-9); + display: flex; + align-items: center; + justify-content: center; +} + +.carouselImage { + width: 100%; + height: 100%; + object-fit: cover; + background-color: var(--mantine-color-gray-8); +} + +.photoInfo { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent); + color: white; + border-radius: 0; +} + +.dots { + position: absolute; + bottom: 12px; + left: 50%; + transform: translateX(-50%); + z-index: 10; +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: rgba(255, 255, 255, 0.5); + cursor: pointer; + transition: all 0.2s ease; + + &[data-active="true"] { + background-color: rgba(255, 255, 255, 1); + width: 24px; + border-radius: 4px; + } + + &:hover { + background-color: rgba(255, 255, 255, 0.8); + } +} diff --git a/packages/widgets/src/immich/album-carousel/component.tsx b/packages/widgets/src/immich/album-carousel/component.tsx new file mode 100644 index 000000000..a8a920d5d --- /dev/null +++ b/packages/widgets/src/immich/album-carousel/component.tsx @@ -0,0 +1,121 @@ +"use client"; + +import type { Dispatch, SetStateAction } from "react"; +import { useEffect, useState } from "react"; +import { Box, Center, Group, Image, Stack, Text } from "@mantine/core"; +import { IconAlertCircle, IconCalendar } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { useI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps } from "../../definition"; +import classes from "./component.module.css"; + +export default function ImmichAlbumCarouselWidget({ + integrationIds, + options, +}: WidgetComponentProps<"immich-albumCarousel">) { + const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0); + + if (!options.albumId) { + return ; + } + + const [album] = clientApi.widget.immich.getAlbum.useSuspenseQuery({ + integrationId: integrationIds[0] ?? "", + albumId: options.albumId, + }); + + if (album.assets.length === 0) { + return ; + } + + const photoAssets = album.assets.filter((asset) => asset.type === "IMAGE"); + + if (photoAssets.length === 0) { + return ; + } + + return ( + + ); +} + +interface CarouselProps { + assets: { + id: string; + deviceAssetId: string; + originalPath: string; + fileModifiedAt: string; + publicLink: string; + }[]; + currentIndex: number; + setCurrentIndex: Dispatch>; + rotationInterval: number; + showPhotoInfo: boolean; +} + +function Carousel({ assets, currentIndex, setCurrentIndex, rotationInterval, showPhotoInfo }: CarouselProps) { + useEffect(() => { + const interval = setInterval(() => { + setCurrentIndex((prev) => (prev + 1) % assets.length); + }, rotationInterval * 1000); + + return () => clearInterval(interval); + }, [assets.length, rotationInterval, setCurrentIndex]); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const currentAsset = assets[currentIndex]!; + + return ( + + Album photo + + {showPhotoInfo && ( + + + + {new Date(currentAsset.fileModifiedAt).toLocaleDateString()} + + + {currentIndex + 1} / {assets.length} + + + )} + + ); +} + +function NoAlbumSelected() { + const t = useI18n(); + return ( +
+ + + + {t("widget.immich-albumCarousel.noAlbumSelected")} + + +
+ ); +} + +function NoPhotosInAlbum() { + const t = useI18n(); + return ( +
+ + + + {t("widget.immich-albumCarousel.noPhotos")} + + +
+ ); +} diff --git a/packages/widgets/src/immich/album-carousel/index.ts b/packages/widgets/src/immich/album-carousel/index.ts new file mode 100644 index 000000000..6ad37c50a --- /dev/null +++ b/packages/widgets/src/immich/album-carousel/index.ts @@ -0,0 +1,28 @@ +import { IconPhoto } from "@tabler/icons-react"; +import z from "zod"; + +import { createWidgetDefinition } from "../../definition"; +import { optionsBuilder } from "../../options"; + +export const { definition, componentLoader } = createWidgetDefinition("immich-albumCarousel", { + icon: IconPhoto, + supportedIntegrations: ["immich"], + integrationsRequired: true, + createOptions() { + return optionsBuilder.from((factory) => ({ + albumId: factory.text({ + defaultValue: "", + withDescription: true, + }), + rotationIntervalSeconds: factory.number({ + defaultValue: 5, + validate: z.number().min(1).max(3600), + withDescription: true, + }), + showPhotoInfo: factory.switch({ + defaultValue: false, + withDescription: true, + }), + })); + }, +}).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/immich/server-stats/component.module.css b/packages/widgets/src/immich/server-stats/component.module.css new file mode 100644 index 000000000..9154643ff --- /dev/null +++ b/packages/widgets/src/immich/server-stats/component.module.css @@ -0,0 +1,20 @@ +.statItem { + padding: 12px; + border-radius: var(--mantine-radius-md); + background-color: var(--mantine-color-gray-0); + transition: all 0.2s ease; + + &:hover { + background-color: var(--mantine-color-gray-1); + } +} + +@media (prefers-color-scheme: dark) { + .statItem { + background-color: var(--mantine-color-dark-6); + + &:hover { + background-color: var(--mantine-color-dark-5); + } + } +} diff --git a/packages/widgets/src/immich/server-stats/component.tsx b/packages/widgets/src/immich/server-stats/component.tsx new file mode 100644 index 000000000..0240a27c4 --- /dev/null +++ b/packages/widgets/src/immich/server-stats/component.tsx @@ -0,0 +1,73 @@ +"use client"; + +import React from "react"; +import { Group, Stack, Text } from "@mantine/core"; +import { IconDatabase, IconPhoto, IconUsers, IconVideo } from "@tabler/icons-react"; + +import { clientApi } from "@homarr/api/client"; +import { humanFileSize } from "@homarr/common"; +import { useI18n } from "@homarr/translation/client"; + +import type { WidgetComponentProps } from "../../definition"; +import classes from "./component.module.css"; + +export default function ImmichServerStatsWidget({ + integrationIds, + options, +}: WidgetComponentProps<"immich-serverStats">) { + const t = useI18n(); + const [stats] = clientApi.widget.immich.getServerStats.useSuspenseQuery({ + integrationId: integrationIds[0] ?? "", + }); + + return ( + + {options.showUsers && ( + } label={t("widget.immich-serverStats.users")} value={stats.userCount} /> + )} + {options.showPhotos && ( + } + label={t("widget.immich-serverStats.photos")} + value={stats.photoCount} + /> + )} + {options.showVideos && ( + } + label={t("widget.immich-serverStats.videos")} + value={stats.videoCount} + /> + )} + {options.showStorage && ( + } + label={t("widget.immich-serverStats.storage")} + value={humanFileSize(stats.totalLibraryUsageInBytes)} + /> + )} + + ); +} + +interface StatItemProps { + icon: React.ReactNode; + label: string; + value: string | number; +} + +function StatItem({ icon, label, value }: StatItemProps) { + return ( + + + {icon} + + {label} + + + + {value} + + + ); +} diff --git a/packages/widgets/src/immich/server-stats/index.ts b/packages/widgets/src/immich/server-stats/index.ts new file mode 100644 index 000000000..61b07289a --- /dev/null +++ b/packages/widgets/src/immich/server-stats/index.ts @@ -0,0 +1,30 @@ +import { IconGraphFilled } from "@tabler/icons-react"; + +import { createWidgetDefinition } from "../../definition"; +import { optionsBuilder } from "../../options"; + +export const { definition, componentLoader } = createWidgetDefinition("immich-serverStats", { + icon: IconGraphFilled, + supportedIntegrations: ["immich"], + integrationsRequired: true, + createOptions() { + return optionsBuilder.from((factory) => ({ + showUsers: factory.switch({ + defaultValue: true, + withDescription: true, + }), + showPhotos: factory.switch({ + defaultValue: true, + withDescription: true, + }), + showVideos: factory.switch({ + defaultValue: true, + withDescription: true, + }), + showStorage: factory.switch({ + defaultValue: true, + withDescription: true, + }), + })); + }, +}).withDynamicImport(() => import("./component")); diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index ec075d467..f1782a6c9 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -20,6 +20,8 @@ import * as downloads from "./downloads"; import * as firewall from "./firewall"; import * as healthMonitoring from "./health-monitoring"; import * as iframe from "./iframe"; +import * as immichAlbumCarousel from "./immich/album-carousel"; +import * as immichServerStats from "./immich/server-stats"; import type { WidgetImportRecord } from "./import"; import * as indexerManager from "./indexer-manager"; import * as mediaReleases from "./media-releases"; @@ -79,6 +81,8 @@ export const widgetImports = { systemResources, coolify, systemDisks, + "immich-serverStats": immichServerStats, + "immich-albumCarousel": immichAlbumCarousel, } satisfies WidgetImportRecord; export type WidgetImports = typeof widgetImports; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9244d550e..dd97d54f4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1465,6 +1465,9 @@ importers: '@homarr/validation': specifier: workspace:^0.1.0 version: link:../validation + '@immich/sdk': + specifier: ^2.5.6 + version: 2.5.6 '@jellyfin/sdk': specifier: ^0.13.0 version: 0.13.0(axios@1.12.2) @@ -3457,6 +3460,9 @@ packages: cpu: [x64] os: [win32] + '@immich/sdk@2.5.6': + resolution: {integrity: sha512-u6o0rNBsTbAkl/6vz3Z63A/yNkLYeuKiHKoJgLuvRqTpPahgxAFcfDeN8rLd0Ya1kM5I2c610/VDcvItzzVcWg==} + '@inquirer/ansi@1.0.2': resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} engines: {node: '>=18'} @@ -3827,6 +3833,9 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@oazapfts/runtime@1.2.0': + resolution: {integrity: sha512-fi7dp7dNayyh/vzqhf0ZdoPfC7tJvYfjaE8MBL1yR+iIsH7cFoqHt+DV70VU49OMCqLc7wQa+yVJcSmIRnV4wA==} + '@octokit/app@16.1.2': resolution: {integrity: sha512-8j7sEpUYVj18dxvh0KWj6W/l6uAiVRBl1JBDVRqH1VHKAO/G5eRVl4yEoYACjakWers1DjUkcCHyJNQK47JqyQ==} engines: {node: '>= 20'} @@ -11921,6 +11930,10 @@ snapshots: '@img/sharp-win32-x64@0.34.4': optional: true + '@immich/sdk@2.5.6': + dependencies: + '@oazapfts/runtime': 1.2.0 + '@inquirer/ansi@1.0.2': {} '@inquirer/checkbox@4.3.2(@types/node@24.11.0)': @@ -12328,6 +12341,8 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.1 + '@oazapfts/runtime@1.2.0': {} + '@octokit/app@16.1.2': dependencies: '@octokit/auth-app': 8.2.0