From 6e690524784644f0886eb69ccf25bcf8b47ecc6c Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 4 Feb 2026 16:31:32 -0500 Subject: [PATCH] fix: album thumbnail refresh --- open-api/immich-openapi-specs.json | 65 +++++++++++++++++++ server/src/controllers/album.controller.ts | 28 +++++++- server/src/repositories/album.repository.ts | 10 +++ server/src/services/album.service.ts | 18 +++++ .../components/album-page/album-cover.svelte | 5 +- 5 files changed, 121 insertions(+), 5 deletions(-) diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 8359ebc173..9e47ebd734 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2221,6 +2221,71 @@ "x-immich-state": "Stable" } }, + "/albums/{id}/thumbnail": { + "get": { + "description": "Virtual route that redirects to the thumbnail of the album cover asset.", + "operationId": "getAlbumThumbnailRedirect", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "slug", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Redirect to album thumbnail", + "tags": [ + "Albums" + ], + "x-immich-history": [ + { + "version": "v2.6.0", + "state": "Added" + }, + { + "version": "v2.6.0", + "state": "Beta" + } + ], + "x-immich-permission": "album.read", + "x-immich-state": "Beta" + } + }, "/albums/{id}/user/{userId}": { "delete": { "description": "Remove a user from an album. Use an ID of \"me\" to leave a shared album.", diff --git a/server/src/controllers/album.controller.ts b/server/src/controllers/album.controller.ts index dad70257a7..4f06b5faec 100644 --- a/server/src/controllers/album.controller.ts +++ b/server/src/controllers/album.controller.ts @@ -1,4 +1,17 @@ -import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Put, Query } from '@nestjs/common'; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Put, + Query, + Redirect, +} from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { Endpoint, HistoryBuilder } from 'src/decorators'; import { @@ -73,6 +86,19 @@ export class AlbumController { return this.service.get(auth, id, dto); } + @Authenticated({ permission: Permission.AlbumRead, sharedLink: true }) + @Get(':id/thumbnail') + @Redirect() + @Endpoint({ + summary: 'Redirect to album thumbnail', + description: 'Virtual route that redirects to the thumbnail of the album cover asset.', + history: new HistoryBuilder().added('v2.6.0').beta('v2.6.0'), + }) + async getAlbumThumbnailRedirect(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) { + const url = await this.service.getThumbnailRedirectUrl(auth, id); + return { url, status: 307 }; + } + @Patch(':id') @Authenticated({ permission: Permission.AlbumUpdate }) @Endpoint({ diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index 100ab908c0..1ca111fbe2 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -87,6 +87,16 @@ export class AlbumRepository { .executeTakeFirst(); } + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) + async getForThumbnailRedirect(id: string) { + return this.db + .selectFrom('asset') + .innerJoin('album', 'album.albumThumbnailAssetId', 'asset.id') + .where('album.id', '=', id) + .select(['asset.id', 'asset.thumbhash']) + .executeTakeFirst(); + } + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) async getByAssetId(ownerId: string, assetId: string) { return this.db diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 18747dbc3a..0aa3eccaf5 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -21,6 +21,7 @@ import { Permission } from 'src/enum'; import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository'; import { BaseService } from 'src/services/base.service'; import { addAssets, removeAssets } from 'src/utils/asset.util'; +import { hexOrBufferToBase64 } from 'src/utils/bytes'; import { getPreferences } from 'src/utils/preferences'; @Injectable() @@ -93,6 +94,23 @@ export class AlbumService extends BaseService { }; } + async getThumbnailRedirectUrl(auth: AuthDto, id: string) { + await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] }); + + const asset = await this.albumRepository.getForThumbnailRedirect(id); + if (!asset) { + throw new BadRequestException('Album has no thumbnail'); + } + + const params = new URLSearchParams(); + params.append('edited', 'true'); + if (asset.thumbhash) { + params.append('c', hexOrBufferToBase64(asset.thumbhash)); + } + + return `/api/assets/${asset.id}/thumbnail?${params.toString()}`; + } + async create(auth: AuthDto, dto: CreateAlbumDto): Promise { const albumUsers = dto.albumUsers || []; diff --git a/web/src/lib/components/album-page/album-cover.svelte b/web/src/lib/components/album-page/album-cover.svelte index c6242c5fad..d92e86b2d3 100644 --- a/web/src/lib/components/album-page/album-cover.svelte +++ b/web/src/lib/components/album-page/album-cover.svelte @@ -1,7 +1,6 @@ {#if thumbnailUrl}