mirror of
https://github.com/immich-app/immich.git
synced 2026-02-28 09:38:43 +03:00
fix: Download the edited version when downloading multiple photos (#26259)
* fix: download the edited version when downloading multiple photos * test: update tests * chore: clean up --------- Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
import { Body, Controller, HttpCode, HttpStatus, Post, StreamableFile } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { Endpoint, HistoryBuilder } from 'src/decorators';
|
||||
import { AssetIdsDto } from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto';
|
||||
import { DownloadArchiveDto, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto';
|
||||
import { ApiTag, Permission } from 'src/enum';
|
||||
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
||||
import { DownloadService } from 'src/services/download.service';
|
||||
@@ -36,7 +35,7 @@ export class DownloadController {
|
||||
'Download a ZIP archive containing the specified assets. The assets must have been previously requested via the "getDownloadInfo" endpoint.',
|
||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||
})
|
||||
downloadArchive(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise<StreamableFile> {
|
||||
downloadArchive(@Auth() auth: AuthDto, @Body() dto: DownloadArchiveDto): Promise<StreamableFile> {
|
||||
return this.service.downloadArchive(auth, dto).then(asStreamableFile);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsInt, IsPositive } from 'class-validator';
|
||||
import { Optional, ValidateUUID } from 'src/validation';
|
||||
import { AssetIdsDto } from 'src/dtos/asset.dto';
|
||||
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class DownloadInfoDto {
|
||||
@ValidateUUID({ each: true, optional: true, description: 'Asset IDs to download' })
|
||||
@@ -32,3 +33,8 @@ export class DownloadArchiveInfo {
|
||||
@ApiProperty({ description: 'Asset IDs in this archive' })
|
||||
assetIds!: string[];
|
||||
}
|
||||
|
||||
export class DownloadArchiveDto extends AssetIdsDto {
|
||||
@ValidateBoolean({ optional: true, description: 'Download edited asset if available' })
|
||||
edited?: boolean;
|
||||
}
|
||||
|
||||
@@ -587,6 +587,7 @@ where
|
||||
|
||||
-- AssetRepository.getForOriginal
|
||||
select
|
||||
"asset"."id",
|
||||
"originalFileName",
|
||||
"asset_file"."path" as "editedPath",
|
||||
"originalPath"
|
||||
@@ -596,7 +597,21 @@ from
|
||||
and "asset_file"."isEdited" = $1
|
||||
and "asset_file"."type" = $2
|
||||
where
|
||||
"asset"."id" = $3
|
||||
"asset"."id" in ($3)
|
||||
|
||||
-- AssetRepository.getForOriginals
|
||||
select
|
||||
"asset"."id",
|
||||
"originalFileName",
|
||||
"asset_file"."path" as "editedPath",
|
||||
"originalPath"
|
||||
from
|
||||
"asset"
|
||||
left join "asset_file" on "asset"."id" = "asset_file"."assetId"
|
||||
and "asset_file"."isEdited" = $1
|
||||
and "asset_file"."type" = $2
|
||||
where
|
||||
"asset"."id" in ($3)
|
||||
|
||||
-- AssetRepository.getForThumbnail
|
||||
select
|
||||
|
||||
@@ -1008,12 +1008,12 @@ export class AssetRepository {
|
||||
return count;
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, true] })
|
||||
async getForOriginal(id: string, isEdited: boolean) {
|
||||
private buildGetForOriginal(ids: string[], isEdited: boolean) {
|
||||
return this.db
|
||||
.selectFrom('asset')
|
||||
.select('asset.id')
|
||||
.select('originalFileName')
|
||||
.where('asset.id', '=', id)
|
||||
.where('asset.id', 'in', ids)
|
||||
.$if(isEdited, (qb) =>
|
||||
qb
|
||||
.leftJoin('asset_file', (join) =>
|
||||
@@ -1024,8 +1024,17 @@ export class AssetRepository {
|
||||
)
|
||||
.select('asset_file.path as editedPath'),
|
||||
)
|
||||
.select('originalPath')
|
||||
.executeTakeFirstOrThrow();
|
||||
.select('originalPath');
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, true] })
|
||||
getForOriginal(id: string, isEdited: boolean) {
|
||||
return this.buildGetForOriginal([id], isEdited).executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [[DummyValue.UUID], true] })
|
||||
getForOriginals(ids: string[], isEdited: boolean) {
|
||||
return this.buildGetForOriginal(ids, isEdited).execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, AssetFileType.Preview, true] })
|
||||
|
||||
@@ -39,7 +39,7 @@ describe(DownloadService.name, () => {
|
||||
const asset = AssetFactory.create();
|
||||
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id, 'unknown-asset']));
|
||||
mocks.asset.getByIds.mockResolvedValue([asset]);
|
||||
mocks.asset.getForOriginals.mockResolvedValue([asset]);
|
||||
mocks.storage.createZipStream.mockReturnValue(archiveMock);
|
||||
|
||||
await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset.id, 'unknown-asset'] })).resolves.toEqual({
|
||||
@@ -62,7 +62,7 @@ describe(DownloadService.name, () => {
|
||||
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id]));
|
||||
mocks.storage.realpath.mockRejectedValue(new Error('Could not read file'));
|
||||
mocks.asset.getByIds.mockResolvedValue([asset1, asset2]);
|
||||
mocks.asset.getForOriginals.mockResolvedValue([asset1, asset2]);
|
||||
mocks.storage.createZipStream.mockReturnValue(archiveMock);
|
||||
|
||||
await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({
|
||||
@@ -86,7 +86,7 @@ describe(DownloadService.name, () => {
|
||||
const asset2 = AssetFactory.create();
|
||||
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id]));
|
||||
mocks.asset.getByIds.mockResolvedValue([asset1, asset2]);
|
||||
mocks.asset.getForOriginals.mockResolvedValue([asset1, asset2]);
|
||||
mocks.storage.createZipStream.mockReturnValue(archiveMock);
|
||||
|
||||
await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({
|
||||
@@ -108,7 +108,7 @@ describe(DownloadService.name, () => {
|
||||
const asset2 = AssetFactory.create({ originalFileName: 'IMG_123.jpg' });
|
||||
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id]));
|
||||
mocks.asset.getByIds.mockResolvedValue([asset1, asset2]);
|
||||
mocks.asset.getForOriginals.mockResolvedValue([asset1, asset2]);
|
||||
mocks.storage.createZipStream.mockReturnValue(archiveMock);
|
||||
|
||||
await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({
|
||||
@@ -130,7 +130,7 @@ describe(DownloadService.name, () => {
|
||||
const asset2 = AssetFactory.create({ originalFileName: 'IMG_123.jpg' });
|
||||
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id]));
|
||||
mocks.asset.getByIds.mockResolvedValue([asset2, asset1]);
|
||||
mocks.asset.getForOriginals.mockResolvedValue([asset1, asset2]);
|
||||
mocks.storage.createZipStream.mockReturnValue(archiveMock);
|
||||
|
||||
await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({
|
||||
@@ -151,7 +151,7 @@ describe(DownloadService.name, () => {
|
||||
|
||||
const asset = AssetFactory.create({ originalPath: '/path/to/symlink.jpg' });
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
|
||||
mocks.asset.getByIds.mockResolvedValue([asset]);
|
||||
mocks.asset.getForOriginals.mockResolvedValue([asset]);
|
||||
mocks.storage.realpath.mockResolvedValue('/path/to/realpath.jpg');
|
||||
mocks.storage.createZipStream.mockReturnValue(archiveMock);
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { parse } from 'node:path';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { AssetIdsDto } from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto';
|
||||
import { DownloadArchiveDto, DownloadArchiveInfo, DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto';
|
||||
import { Permission } from 'src/enum';
|
||||
import { ImmichReadStream } from 'src/repositories/storage.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
@@ -80,11 +79,11 @@ export class DownloadService extends BaseService {
|
||||
return { totalSize, archives };
|
||||
}
|
||||
|
||||
async downloadArchive(auth: AuthDto, dto: AssetIdsDto): Promise<ImmichReadStream> {
|
||||
async downloadArchive(auth: AuthDto, dto: DownloadArchiveDto): Promise<ImmichReadStream> {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: dto.assetIds });
|
||||
|
||||
const zip = this.storageRepository.createZipStream();
|
||||
const assets = await this.assetRepository.getByIds(dto.assetIds);
|
||||
const assets = await this.assetRepository.getForOriginals(dto.assetIds, dto.edited ?? false);
|
||||
const assetMap = new Map(assets.map((asset) => [asset.id, asset]));
|
||||
const paths: Record<string, number> = {};
|
||||
|
||||
@@ -94,7 +93,7 @@ export class DownloadService extends BaseService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { originalPath, originalFileName } = asset;
|
||||
const { originalPath, editedPath, originalFileName } = asset;
|
||||
|
||||
let filename = originalFileName;
|
||||
const count = paths[filename] || 0;
|
||||
@@ -104,9 +103,10 @@ export class DownloadService extends BaseService {
|
||||
filename = `${parsedFilename.name}+${count}${parsedFilename.ext}`;
|
||||
}
|
||||
|
||||
let realpath = originalPath;
|
||||
let realpath = dto.edited && editedPath ? editedPath : originalPath;
|
||||
|
||||
try {
|
||||
realpath = await this.storageRepository.realpath(originalPath);
|
||||
realpath = await this.storageRepository.realpath(realpath);
|
||||
} catch {
|
||||
this.logger.warn('Unable to resolve realpath', { originalPath });
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
|
||||
deleteMetadataByKey: vitest.fn(),
|
||||
deleteBulkMetadata: vitest.fn(),
|
||||
getForOriginal: vitest.fn(),
|
||||
getForOriginals: vitest.fn(),
|
||||
getForThumbnail: vitest.fn(),
|
||||
getForVideo: vitest.fn(),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user