feat: add immich integration and widget (#5165)
* feat: add immich integration and widget * feat: use sdk
This commit is contained in:
1 parent
6aaaf31d8b
commit
cdd90b8aa0
18 files changed
+609
No files matched your search
@@ -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;
|
||||
}),
|
||||
});
|
||||
@@ -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)),
|
||||
|
||||
@@ -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];
|
||||
@@ -31,5 +31,7 @@ export const widgetKinds = [
|
||||
"systemResources",
|
||||
"coolify",
|
||||
"systemDisks",
|
||||
"immich-serverStats",
|
||||
"immich-albumCarousel",
|
||||
] as const;
|
||||
export type WidgetKind = (typeof widgetKinds)[number];
|
||||
@@ -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",
|
||||
|
||||
@@ -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<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
|
||||
|
||||
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
|
||||
|
||||
@@ -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<ImmichServerStats> {
|
||||
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<ImmichAlbum> {
|
||||
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<TestingResult> {
|
||||
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") });
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string, never>
|
||||
>({
|
||||
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<string, never>
|
||||
>({
|
||||
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",
|
||||
});
|
||||
@@ -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.",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 <NoAlbumSelected />;
|
||||
}
|
||||
|
||||
const [album] = clientApi.widget.immich.getAlbum.useSuspenseQuery({
|
||||
integrationId: integrationIds[0] ?? "",
|
||||
albumId: options.albumId,
|
||||
});
|
||||
|
||||
if (album.assets.length === 0) {
|
||||
return <NoPhotosInAlbum />;
|
||||
}
|
||||
|
||||
const photoAssets = album.assets.filter((asset) => asset.type === "IMAGE");
|
||||
|
||||
if (photoAssets.length === 0) {
|
||||
return <NoPhotosInAlbum />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Carousel
|
||||
assets={photoAssets}
|
||||
currentIndex={currentPhotoIndex}
|
||||
setCurrentIndex={setCurrentPhotoIndex}
|
||||
rotationInterval={options.rotationIntervalSeconds}
|
||||
showPhotoInfo={options.showPhotoInfo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface CarouselProps {
|
||||
assets: {
|
||||
id: string;
|
||||
deviceAssetId: string;
|
||||
originalPath: string;
|
||||
fileModifiedAt: string;
|
||||
publicLink: string;
|
||||
}[];
|
||||
currentIndex: number;
|
||||
setCurrentIndex: Dispatch<SetStateAction<number>>;
|
||||
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 (
|
||||
<Box w="100%" h="100%" className={classes.carouselContainer}>
|
||||
<Image src={currentAsset.publicLink} alt="Album photo" className={classes.carouselImage} />
|
||||
|
||||
{showPhotoInfo && (
|
||||
<Stack gap="xs" className={classes.photoInfo} p="md">
|
||||
<Group gap="xs">
|
||||
<IconCalendar size={16} />
|
||||
<Text size="xs">{new Date(currentAsset.fileModifiedAt).toLocaleDateString()}</Text>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{currentIndex + 1} / {assets.length}
|
||||
</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function NoAlbumSelected() {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconAlertCircle size={32} />
|
||||
<Text size="sm" fw={500}>
|
||||
{t("widget.immich-albumCarousel.noAlbumSelected")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
function NoPhotosInAlbum() {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconAlertCircle size={32} />
|
||||
<Text size="sm" fw={500}>
|
||||
{t("widget.immich-albumCarousel.noPhotos")}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -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"));
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<Stack gap="md" h="100%" p="md">
|
||||
{options.showUsers && (
|
||||
<StatItem icon={<IconUsers size={20} />} label={t("widget.immich-serverStats.users")} value={stats.userCount} />
|
||||
)}
|
||||
{options.showPhotos && (
|
||||
<StatItem
|
||||
icon={<IconPhoto size={20} />}
|
||||
label={t("widget.immich-serverStats.photos")}
|
||||
value={stats.photoCount}
|
||||
/>
|
||||
)}
|
||||
{options.showVideos && (
|
||||
<StatItem
|
||||
icon={<IconVideo size={20} />}
|
||||
label={t("widget.immich-serverStats.videos")}
|
||||
value={stats.videoCount}
|
||||
/>
|
||||
)}
|
||||
{options.showStorage && (
|
||||
<StatItem
|
||||
icon={<IconDatabase size={20} />}
|
||||
label={t("widget.immich-serverStats.storage")}
|
||||
value={humanFileSize(stats.totalLibraryUsageInBytes)}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
interface StatItemProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
function StatItem({ icon, label, value }: StatItemProps) {
|
||||
return (
|
||||
<Group justify="space-between" align="center" className={classes.statItem}>
|
||||
<Group gap="sm" align="center">
|
||||
{icon}
|
||||
<Text size="sm" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="sm" fw={700} c="var(--mantine-primary-color)">
|
||||
{value}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -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"));
|
||||
@@ -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;
|
||||
|
||||
Generated
+15
@@ -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
|
||||
|
||||
Reference in new issue
Block a user