diff --git a/apps/docs/docs/widgets/immich-album-carousel/index.mdx b/apps/docs/docs/widgets/immich-album-carousel/index.mdx index 59b1beeb9..703bd5a57 100644 --- a/apps/docs/docs/widgets/immich-album-carousel/index.mdx +++ b/apps/docs/docs/widgets/immich-album-carousel/index.mdx @@ -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 - diff --git a/apps/docs/docs/widgets/immich-album-carousel/index.ts b/apps/docs/docs/widgets/immich-album-carousel/index.ts index f6c4b224c..72a0b2211 100644 --- a/apps/docs/docs/widgets/immich-album-carousel/index.ts +++ b/apps/docs/docs/widgets/immich-album-carousel/index.ts @@ -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", diff --git a/packages/api/src/router/widgets/immich.ts b/packages/api/src/router/widgets/immich.ts index 7e8c54d49..aa917271f 100644 --- a/packages/api/src/router/widgets/immich.ts +++ b/packages/api/src/router/widgets/immich.ts @@ -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 }) => { diff --git a/packages/integrations/src/immich/immich-integration.ts b/packages/integrations/src/immich/immich-integration.ts index 6274802a5..9496482d6 100644 --- a/packages/integrations/src/immich/immich-integration.ts +++ b/packages/integrations/src/immich/immich-integration.ts @@ -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 { + public async getAlbumAsync(albumId?: string): Promise { 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 { + 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 diff --git a/packages/request-handler/src/immich.ts b/packages/request-handler/src/immich.ts index 56fc89bd0..a6e2a996c 100644 --- a/packages/request-handler/src/immich.ts +++ b/packages/request-handler/src/immich.ts @@ -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); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 1e48e4486..ae89ad4df 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -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", diff --git a/packages/widgets/src/immich/album-carousel/component.tsx b/packages/widgets/src/immich/album-carousel/component.tsx index ee7085757..49bb4ceec 100644 --- a/packages/widgets/src/immich/album-carousel/component.tsx +++ b/packages/widgets/src/immich/album-carousel/component.tsx @@ -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 ; - } - if (!album) return ; if (album.assets.length === 0) { @@ -117,20 +114,6 @@ function Carousel({ assets, currentIndex, setCurrentIndex, rotationInterval, sho ); } -function NoAlbumSelected() { - const t = useI18n(); - return ( -
- - - - {t("widget.immich-albumCarousel.noAlbumSelected")} - - -
- ); -} - function NoPhotosInAlbum() { const t = useI18n(); return ( diff --git a/packages/widgets/src/immich/album-carousel/constants.ts b/packages/widgets/src/immich/album-carousel/constants.ts new file mode 100644 index 000000000..0371578de --- /dev/null +++ b/packages/widgets/src/immich/album-carousel/constants.ts @@ -0,0 +1 @@ +export const ALL_PHOTOS_ALBUM_ID = "all"; diff --git a/packages/widgets/src/immich/album-carousel/index.ts b/packages/widgets/src/immich/album-carousel/index.ts index 0ae11ef79..8c4223ef3 100644 --- a/packages/widgets/src/immich/album-carousel/index.ts +++ b/packages/widgets/src/immich/album-carousel/index.ts @@ -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({