Compare commits

...
Author SHA1 Message Date
ajnart 8dcb7ea929 fix(immich): add all photos album option 2026-07-21 11:20:20 +02:00
ajnart d602c573fb feat(immich): show random photos without album 2026-07-21 09:53:02 +02:00
9 changed files with 79 additions and 56 deletions

No files matched your search

@@ -1,6 +1,6 @@
---
title: 'Immich Album Carousel'
description: 'Shows a slideshow of your album pictures from Immich'
description: 'Shows a slideshow of pictures from an Immich album or your full library'
hide_title: true
---
@@ -37,4 +37,3 @@ import screen from './img/screen.png';
### Configuration
<WidgetConfig configuration={immichAlbumCarouselWidget.configuration} />
@@ -4,15 +4,16 @@ import { IconPhoto } from "@tabler/icons-react";
export const immichAlbumCarouselWidget: WidgetDefinition = {
icon: IconPhoto,
name: "Immich Album",
description: "Shows a slideshow of your album pictures from Immich",
description: "Shows a slideshow of pictures from an Immich album or your full library",
path: "../../widgets/immich-album-carousel",
configuration: {
items: [
{
name: "Album",
description: "Select the Immich album to show in the slideshow.",
description:
"Select an Immich album or All photos. An empty selection also shows random photos from your full library.",
values: { type: "string" },
defaultValue: "",
defaultValue: "All photos",
},
{
name: "Interval in seconds",
+1 -1
View File
@@ -25,7 +25,7 @@ export const immichRouter = createTRPCRouter({
.concat(createImmichIntegrationMiddleware("query"))
.input(
z.object({
albumId: z.string(),
albumId: z.string().optional(),
}),
)
.query(async ({ ctx, input }) => {
@@ -1,11 +1,14 @@
import {
AssetMediaSize,
AssetTypeEnum,
AssetVisibility,
getAlbumInfo,
getAllAlbums,
getMyUser,
getServerStatistics,
init,
searchAssets,
searchRandom,
searchUsers,
} from "@immich/sdk";
import type { AssetResponseDto, MetadataSearchDto } from "@immich/sdk";
@@ -43,6 +46,7 @@ export interface ImmichAsset {
}
const logger = createLogger({ module: "immich-integration" });
const randomPhotoCount = 50;
export class ImmichIntegration extends Integration {
constructor(integration: IntegrationInput) {
@@ -63,38 +67,36 @@ export class ImmichIntegration extends Integration {
};
}
public async getAlbumAsync(albumId: string): Promise<ImmichAlbum> {
public async getAlbumAsync(albumId?: string): Promise<ImmichAlbum> {
this.initClient();
const requestOptions = this.getRequestOptions();
if (!albumId) {
const assets = await searchRandom(
{
randomSearchDto: {
size: randomPhotoCount,
type: AssetTypeEnum.Image,
visibility: AssetVisibility.Timeline,
},
},
requestOptions,
);
return {
id: "",
albumName: "",
assets: await this.createAssetsAsync(assets),
};
}
const [album, albumAssets] = await Promise.all([
getAlbumInfo({ id: albumId }, requestOptions),
this.searchAlbumAssetsAsync(albumId),
]);
const imageProxy = new ImageProxy();
return {
albumName: album.albumName,
assets: await Promise.all(
albumAssets.map(async (asset) => {
const publicLink = await imageProxy.createImageAsync(
// This is the URL generated by the SDK's viewAsset call. Preview images are
// browser-compatible while avoiding the large original asset download.
this.url(`/api/assets/${asset.id}/thumbnail`, { size: AssetMediaSize.Preview }).toString(),
{
"x-api-key": this.getSecretValue("apiKey"),
},
);
return {
id: asset.id,
type: asset.type,
thumbhash: asset.thumbhash,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
originalPath: asset.originalPath,
updatedAt: asset.updatedAt,
publicLink,
};
}),
),
assets: await this.createAssetsAsync(albumAssets),
id: album.id,
};
}
@@ -136,6 +138,32 @@ export class ImmichIntegration extends Integration {
return assets;
}
private async createAssetsAsync(assets: AssetResponseDto[]): Promise<ImmichAsset[]> {
const imageProxy = new ImageProxy();
return await Promise.all(
assets.map(async (asset) => {
const publicLink = await imageProxy.createImageAsync(
// This is the URL generated by the SDK's viewAsset call. Preview images are
// browser-compatible while avoiding the large original asset download.
this.url(`/api/assets/${asset.id}/thumbnail`, { size: AssetMediaSize.Preview }).toString(),
{
"x-api-key": this.getSecretValue("apiKey"),
},
);
return {
id: asset.id,
type: asset.type,
thumbhash: asset.thumbhash,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
originalPath: asset.originalPath,
updatedAt: asset.updatedAt,
publicLink,
};
}),
);
}
private getRequestOptions(fetchAsync = fetchWithTrustedCertificatesAsync) {
return {
// Undici and node types are not the same
+1 -1
View File
@@ -33,7 +33,7 @@ export const immichAlbumsRequestHandler = createIntegrationRequestHandler<
export const immichAlbumRequestHandler = createIntegrationRequestHandler<
ImmichAlbum,
IntegrationKindByCategory<"photoService">,
{ albumId: string }
{ albumId?: string }
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
+4 -4
View File
@@ -3006,10 +3006,11 @@
},
"immich-albumCarousel": {
"name": "Immich Album",
"allPhotos": "All photos",
"option": {
"albumId": {
"label": "Album",
"description": "Select the Immich album to show in the slideshow."
"description": "Select an Immich album or All photos. An empty selection also shows random photos from your full library."
},
"rotationIntervalSeconds": {
"label": "Interval in seconds",
@@ -3024,9 +3025,8 @@
"description": "Show photos in a random order instead of always starting with the same album order."
}
},
"description": "Slideshow of pictures in your Immich album",
"noAlbumSelected": "No album selected, edit widget and choose an album",
"noPhotos": "No photos in this album"
"description": "Slideshow of pictures from an Immich album or your full library",
"noPhotos": "No photos found"
},
"downloads": {
"name": "Download Client",
@@ -11,6 +11,7 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
import classes from "./component.module.css";
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
export default function ImmichAlbumCarouselWidget({
integrationIds,
@@ -21,9 +22,9 @@ export default function ImmichAlbumCarouselWidget({
const { data: album } = clientApi.widget.immich.getAlbum.useQuery(
{
integrationId: integrationIds[0] ?? "",
albumId: options.albumId ?? "",
albumId: options.albumId && options.albumId !== ALL_PHOTOS_ALBUM_ID ? options.albumId : undefined,
},
{ enabled: Boolean(options.albumId) },
{ enabled: integrationIds.length > 0 },
);
const photoAssets = useMemo(() => {
@@ -35,10 +36,6 @@ export default function ImmichAlbumCarouselWidget({
setCurrentPhotoIndex(0);
}, [photoAssets]);
if (!options.albumId) {
return <NoAlbumSelected />;
}
if (!album) return <WidgetEmptyState />;
if (album.assets.length === 0) {
@@ -117,20 +114,6 @@ function Carousel({ assets, currentIndex, setCurrentIndex, rotationInterval, sho
);
}
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 (
@@ -0,0 +1 @@
export const ALL_PHOTOS_ALBUM_ID = "all";
@@ -2,9 +2,11 @@ import { IconPhoto } from "@tabler/icons-react";
import z from "zod";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import { createWidgetDefinition } from "../../definition";
import { optionsBuilder } from "../../options";
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
export const { definition, componentLoader } = createWidgetDefinition("immich-albumCarousel", {
icon: IconPhoto,
@@ -15,9 +17,11 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
createOptions() {
return optionsBuilder.from((factory) => ({
albumId: factory.integrationSelect({
defaultValue: ALL_PHOTOS_ALBUM_ID,
withDescription: true,
clearable: true,
useOptions: (integrationIds: string[]) => {
const t = useScopedI18n("widget.immich-albumCarousel");
const {
data = [],
isPending,
@@ -26,7 +30,14 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
{ integrationId: integrationIds[0] ?? "" },
{ enabled: integrationIds.length > 0, staleTime: 15 * 60 * 1000 },
);
return { data: data.map((album) => ({ value: album.id, label: album.albumName })), isPending, isError };
return {
data: [
{ value: ALL_PHOTOS_ALBUM_ID, label: t("allPhotos") },
...data.map((album) => ({ value: album.id, label: album.albumName })),
],
isPending,
isError,
};
},
}),
rotationIntervalSeconds: factory.number({