1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

refactor(server): download assets (#3032)

* refactor: download assets

* chore: open api

* chore: finish tests, make size configurable

* chore: defualt to 4GiB

* chore: open api

* fix: optional archive size

* fix: bugs

* chore: cleanup
This commit is contained in:
Jason Rasmussen 2023-06-30 12:24:28 -04:00 committed by GitHub
parent df9c05bef3
commit ad343b7b32
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 985 additions and 976 deletions

View File

@ -45,7 +45,8 @@ doc/CuratedObjectsResponseDto.md
doc/DeleteAssetDto.md
doc/DeleteAssetResponseDto.md
doc/DeleteAssetStatus.md
doc/DownloadFilesDto.md
doc/DownloadArchiveInfo.md
doc/DownloadResponseDto.md
doc/ExifResponseDto.md
doc/GetAssetByTimeBucketDto.md
doc/GetAssetCountByTimeBucketDto.md
@ -178,7 +179,8 @@ lib/model/curated_objects_response_dto.dart
lib/model/delete_asset_dto.dart
lib/model/delete_asset_response_dto.dart
lib/model/delete_asset_status.dart
lib/model/download_files_dto.dart
lib/model/download_archive_info.dart
lib/model/download_response_dto.dart
lib/model/exif_response_dto.dart
lib/model/get_asset_by_time_bucket_dto.dart
lib/model/get_asset_count_by_time_bucket_dto.dart
@ -282,7 +284,8 @@ test/curated_objects_response_dto_test.dart
test/delete_asset_dto_test.dart
test/delete_asset_response_dto_test.dart
test/delete_asset_status_test.dart
test/download_files_dto_test.dart
test/download_archive_info_test.dart
test/download_response_dto_test.dart
test/exif_response_dto_test.dart
test/get_asset_by_time_bucket_dto_test.dart
test/get_asset_count_by_time_bucket_dto_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
mobile/openapi/doc/DownloadResponseDto.md generated Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -370,73 +370,6 @@
]
}
},
"/album/{id}/download": {
"get": {
"operationId": "downloadArchive",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "name",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "skip",
"required": false,
"in": "query",
"schema": {
"type": "number"
}
},
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/zip": {
"schema": {
"type": "string",
"format": "binary"
}
}
},
"description": ""
}
},
"tags": [
"Album"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
}
},
"/album/{id}/user/{userId}": {
"delete": {
"operationId": "removeUserFromAlbum",
@ -1153,10 +1086,48 @@
]
}
},
"/asset/download-files": {
"post": {
"operationId": "downloadFiles",
"/asset/download": {
"get": {
"operationId": "getDownloadInfo",
"parameters": [
{
"name": "assetIds",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "array",
"items": {
"type": "string"
}
}
},
{
"name": "albumId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "userId",
"required": false,
"in": "query",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "archiveSize",
"required": false,
"in": "query",
"schema": {
"type": "number"
}
},
{
"name": "key",
"required": false,
@ -1166,30 +1137,16 @@
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DownloadFilesDto"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/octet-stream": {
"application/json": {
"schema": {
"type": "string",
"format": "binary"
"$ref": "#/components/schemas/DownloadResponseDto"
}
}
},
"description": ""
},
"201": {
"description": ""
}
}
},
"tags": [
@ -1206,29 +1163,10 @@
"api_key": []
}
]
}
},
"/asset/download-library": {
"get": {
"operationId": "downloadLibrary",
"description": "Current this is not used in any UI element",
},
"post": {
"operationId": "downloadArchive",
"parameters": [
{
"name": "name",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "skip",
"required": false,
"in": "query",
"schema": {
"type": "number"
}
},
{
"name": "key",
"required": false,
@ -1238,6 +1176,16 @@
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetIdsDto"
}
}
}
},
"responses": {
"200": {
"content": {
@ -1268,7 +1216,7 @@
}
},
"/asset/download/{id}": {
"get": {
"post": {
"operationId": "downloadFile",
"parameters": [
{
@ -5341,11 +5289,13 @@
"FAILED"
]
},
"DownloadFilesDto": {
"DownloadArchiveInfo": {
"type": "object",
"properties": {
"size": {
"type": "integer"
},
"assetIds": {
"title": "Array of asset ids to be downloaded",
"type": "array",
"items": {
"type": "string"
@ -5353,9 +5303,28 @@
}
},
"required": [
"size",
"assetIds"
]
},
"DownloadResponseDto": {
"type": "object",
"properties": {
"totalSize": {
"type": "integer"
},
"archives": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DownloadArchiveInfo"
}
}
},
"required": [
"totalSize",
"archives"
]
},
"ExifResponseDto": {
"type": "object",
"properties": {

View File

@ -16,6 +16,7 @@ export enum Permission {
ALBUM_UPDATE = 'album.update',
ALBUM_DELETE = 'album.delete',
ALBUM_SHARE = 'album.share',
ALBUM_DOWNLOAD = 'album.download',
LIBRARY_READ = 'library.read',
LIBRARY_DOWNLOAD = 'library.download',
@ -68,6 +69,10 @@ export class AccessCore {
// TODO: fix this to not use authUser.id for shared link access control
return this.repository.asset.hasOwnerAccess(authUser.id, id);
case Permission.ALBUM_DOWNLOAD: {
return !!authUser.isAllowDownload && (await this.repository.album.hasSharedLinkAccess(sharedLinkId, id));
}
// case Permission.ALBUM_READ:
// return this.repository.album.hasSharedLinkAccess(sharedLinkId, id);
@ -122,6 +127,12 @@ export class AccessCore {
case Permission.ALBUM_SHARE:
return this.repository.album.hasOwnerAccess(authUser.id, id);
case Permission.ALBUM_DOWNLOAD:
return (
(await this.repository.album.hasOwnerAccess(authUser.id, id)) ||
(await this.repository.album.hasSharedAlbumAccess(authUser.id, id))
);
case Permission.LIBRARY_READ:
return authUser.id === id || (await this.repository.library.hasPartnerAccess(authUser.id, id));

View File

@ -44,6 +44,8 @@ export const IAssetRepository = 'IAssetRepository';
export interface IAssetRepository {
getByDate(ownerId: string, date: Date): Promise<AssetEntity[]>;
getByIds(ids: string[]): Promise<AssetEntity[]>;
getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>;
getByUserId(pagination: PaginationOptions, userId: string): Paginated<AssetEntity>;
getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity>;
getWith(pagination: PaginationOptions, property: WithProperty): Paginated<AssetEntity>;
getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;

View File

@ -1,21 +1,48 @@
import { assetEntityStub, authStub, newAssetRepositoryMock } from '@test';
import { BadRequestException } from '@nestjs/common';
import {
assetEntityStub,
authStub,
IAccessRepositoryMock,
newAccessRepositoryMock,
newAssetRepositoryMock,
newStorageRepositoryMock,
} from '@test';
import { when } from 'jest-when';
import { AssetService, IAssetRepository, mapAsset } from '.';
import { Readable } from 'stream';
import { IStorageRepository } from '../storage';
import { IAssetRepository } from './asset.repository';
import { AssetService } from './asset.service';
import { DownloadResponseDto } from './index';
import { mapAsset } from './response-dto';
const downloadResponse: DownloadResponseDto = {
totalSize: 105_000,
archives: [
{
assetIds: ['asset-id', 'asset-id'],
size: 105_000,
},
],
};
describe(AssetService.name, () => {
let sut: AssetService;
let accessMock: IAccessRepositoryMock;
let assetMock: jest.Mocked<IAssetRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
it('should work', () => {
expect(sut).toBeDefined();
});
beforeEach(async () => {
accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock();
sut = new AssetService(assetMock);
storageMock = newStorageRepositoryMock();
sut = new AssetService(accessMock, assetMock, storageMock);
});
describe('get map markers', () => {
describe('getMapMarkers', () => {
it('should get geo information of assets', async () => {
assetMock.getMapMarkers.mockResolvedValue(
[assetEntityStub.withLocation].map((asset) => ({
@ -76,25 +103,191 @@ describe(AssetService.name, () => {
[authStub.admin.id, new Date('2021-06-15T05:00:00.000Z')],
]);
});
it('should set the title correctly', async () => {
when(assetMock.getByDate)
.calledWith(authStub.admin.id, new Date('2022-06-15T00:00:00.000Z'))
.mockResolvedValue([assetEntityStub.image]);
when(assetMock.getByDate)
.calledWith(authStub.admin.id, new Date('2021-06-15T00:00:00.000Z'))
.mockResolvedValue([assetEntityStub.video]);
await expect(sut.getMemoryLane(authStub.admin, { timestamp: new Date(2023, 5, 15), years: 2 })).resolves.toEqual([
{ title: '1 year since...', assets: [mapAsset(assetEntityStub.image)] },
{ title: '2 years since...', assets: [mapAsset(assetEntityStub.video)] },
]);
expect(assetMock.getByDate).toHaveBeenCalledTimes(2);
expect(assetMock.getByDate.mock.calls).toEqual([
[authStub.admin.id, new Date('2022-06-15T00:00:00.000Z')],
[authStub.admin.id, new Date('2021-06-15T00:00:00.000Z')],
]);
});
});
it('should set the title correctly', async () => {
when(assetMock.getByDate)
.calledWith(authStub.admin.id, new Date('2022-06-15T00:00:00.000Z'))
.mockResolvedValue([assetEntityStub.image]);
when(assetMock.getByDate)
.calledWith(authStub.admin.id, new Date('2021-06-15T00:00:00.000Z'))
.mockResolvedValue([assetEntityStub.video]);
describe('downloadFile', () => {
it('should require the asset.download permission', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
accessMock.asset.hasAlbumAccess.mockResolvedValue(false);
accessMock.asset.hasPartnerAccess.mockResolvedValue(false);
await expect(sut.getMemoryLane(authStub.admin, { timestamp: new Date(2023, 5, 15), years: 2 })).resolves.toEqual([
{ title: '1 year since...', assets: [mapAsset(assetEntityStub.image)] },
{ title: '2 years since...', assets: [mapAsset(assetEntityStub.video)] },
]);
await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException);
expect(assetMock.getByDate).toHaveBeenCalledTimes(2);
expect(assetMock.getByDate.mock.calls).toEqual([
[authStub.admin.id, new Date('2022-06-15T00:00:00.000Z')],
[authStub.admin.id, new Date('2021-06-15T00:00:00.000Z')],
]);
expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1');
expect(accessMock.asset.hasAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1');
expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'asset-1');
});
it('should throw an error if the asset is not found', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
assetMock.getByIds.mockResolvedValue([]);
await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException);
expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1']);
});
it('should download a file', async () => {
const stream = new Readable();
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
storageMock.createReadStream.mockResolvedValue({ stream });
await expect(sut.downloadFile(authStub.admin, 'asset-1')).resolves.toEqual({ stream });
expect(storageMock.createReadStream).toHaveBeenCalledWith(
assetEntityStub.image.originalPath,
assetEntityStub.image.mimeType,
);
});
it('should download an archive', async () => {
const archiveMock = {
addFile: jest.fn(),
finalize: jest.fn(),
stream: new Readable(),
};
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath, assetEntityStub.noWebpPath]);
storageMock.createZipStream.mockReturnValue(archiveMock);
await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({
stream: archiveMock.stream,
});
expect(archiveMock.addFile).toHaveBeenCalledTimes(2);
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg');
expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, 'upload/library/IMG_456.jpg', 'IMG_456.jpg');
});
it('should handle duplicate file names', async () => {
const archiveMock = {
addFile: jest.fn(),
finalize: jest.fn(),
stream: new Readable(),
};
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath, assetEntityStub.noResizePath]);
storageMock.createZipStream.mockReturnValue(archiveMock);
await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({
stream: archiveMock.stream,
});
expect(archiveMock.addFile).toHaveBeenCalledTimes(2);
expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, 'upload/library/IMG_123.jpg', 'IMG_123.jpg');
expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, 'upload/library/IMG_123.jpg', 'IMG_123+1.jpg');
});
});
describe('getDownloadInfo', () => {
it('should throw an error for an invalid dto', async () => {
await expect(sut.getDownloadInfo(authStub.admin, {})).rejects.toBeInstanceOf(BadRequestException);
});
it('should return a list of archives (assetIds)', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
assetMock.getByIds.mockResolvedValue([assetEntityStub.image, assetEntityStub.video]);
const assetIds = ['asset-1', 'asset-2'];
await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual(downloadResponse);
expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1', 'asset-2']);
});
it('should return a list of archives (albumId)', async () => {
accessMock.album.hasOwnerAccess.mockResolvedValue(true);
assetMock.getByAlbumId.mockResolvedValue({
items: [assetEntityStub.image, assetEntityStub.video],
hasNextPage: false,
});
await expect(sut.getDownloadInfo(authStub.admin, { albumId: 'album-1' })).resolves.toEqual(downloadResponse);
expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, 'album-1');
expect(assetMock.getByAlbumId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, 'album-1');
});
it('should return a list of archives (userId)', async () => {
assetMock.getByUserId.mockResolvedValue({
items: [assetEntityStub.image, assetEntityStub.video],
hasNextPage: false,
});
await expect(sut.getDownloadInfo(authStub.admin, { userId: authStub.admin.id })).resolves.toEqual(
downloadResponse,
);
expect(assetMock.getByUserId).toHaveBeenCalledWith({ take: 2500, skip: 0 }, authStub.admin.id);
});
it('should split archives by size', async () => {
assetMock.getByUserId.mockResolvedValue({
items: [
{ ...assetEntityStub.image, id: 'asset-1' },
{ ...assetEntityStub.video, id: 'asset-2' },
{ ...assetEntityStub.withLocation, id: 'asset-3' },
{ ...assetEntityStub.noWebpPath, id: 'asset-4' },
],
hasNextPage: false,
});
await expect(
sut.getDownloadInfo(authStub.admin, {
userId: authStub.admin.id,
archiveSize: 30_000,
}),
).resolves.toEqual({
totalSize: 251_456,
archives: [
{ assetIds: ['asset-1', 'asset-2'], size: 105_000 },
{ assetIds: ['asset-3', 'asset-4'], size: 146_456 },
],
});
});
it('should include the video portion of a live photo', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
when(assetMock.getByIds)
.calledWith([assetEntityStub.livePhotoStillAsset.id])
.mockResolvedValue([assetEntityStub.livePhotoStillAsset]);
when(assetMock.getByIds)
.calledWith([assetEntityStub.livePhotoMotionAsset.id])
.mockResolvedValue([assetEntityStub.livePhotoMotionAsset]);
const assetIds = [assetEntityStub.livePhotoStillAsset.id];
await expect(sut.getDownloadInfo(authStub.admin, { assetIds })).resolves.toEqual({
totalSize: 125_000,
archives: [
{
assetIds: [assetEntityStub.livePhotoStillAsset.id, assetEntityStub.livePhotoMotionAsset.id],
size: 125_000,
},
],
});
});
});
});

View File

@ -1,14 +1,27 @@
import { Inject } from '@nestjs/common';
import { BadRequestException, Inject } from '@nestjs/common';
import { DateTime } from 'luxon';
import { extname } from 'path';
import { AssetEntity } from '../../infra/entities/asset.entity';
import { AuthUserDto } from '../auth';
import { HumanReadableSize, usePagination } from '../domain.util';
import { AccessCore, IAccessRepository, Permission } from '../index';
import { ImmichReadStream, IStorageRepository } from '../storage';
import { IAssetRepository } from './asset.repository';
import { MemoryLaneDto } from './dto';
import { AssetIdsDto, DownloadArchiveInfo, DownloadDto, DownloadResponseDto, MemoryLaneDto } from './dto';
import { MapMarkerDto } from './dto/map-marker.dto';
import { mapAsset, MapMarkerResponseDto } from './response-dto';
import { MemoryLaneResponseDto } from './response-dto/memory-lane-response.dto';
export class AssetService {
constructor(@Inject(IAssetRepository) private assetRepository: IAssetRepository) {}
private access: AccessCore;
constructor(
@Inject(IAccessRepository) accessRepository: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
) {
this.access = new AccessCore(accessRepository);
}
getMapMarkers(authUser: AuthUserDto, options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
return this.assetRepository.getMapMarkers(authUser.id, options);
@ -32,4 +45,102 @@ export class AssetService {
return Promise.all(requests).then((results) => results.filter((result) => result.assets.length > 0));
}
async downloadFile(authUser: AuthUserDto, id: string): Promise<ImmichReadStream> {
await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, id);
const [asset] = await this.assetRepository.getByIds([id]);
if (!asset) {
throw new BadRequestException('Asset not found');
}
return this.storageRepository.createReadStream(asset.originalPath, asset.mimeType);
}
async getDownloadInfo(authUser: AuthUserDto, dto: DownloadDto): Promise<DownloadResponseDto> {
const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4;
const archives: DownloadArchiveInfo[] = [];
let archive: DownloadArchiveInfo = { size: 0, assetIds: [] };
const assetPagination = await this.getDownloadAssets(authUser, dto);
for await (const assets of assetPagination) {
// motion part of live photos
const motionIds = assets.map((asset) => asset.livePhotoVideoId).filter<string>((id): id is string => !!id);
if (motionIds.length > 0) {
assets.push(...(await this.assetRepository.getByIds(motionIds)));
}
for (const asset of assets) {
archive.size += Number(asset.exifInfo?.fileSizeInByte || 0);
archive.assetIds.push(asset.id);
if (archive.size > targetSize) {
archives.push(archive);
archive = { size: 0, assetIds: [] };
}
}
if (archive.assetIds.length > 0) {
archives.push(archive);
}
}
return {
totalSize: archives.reduce((total, item) => (total += item.size), 0),
archives,
};
}
async downloadArchive(authUser: AuthUserDto, dto: AssetIdsDto): Promise<ImmichReadStream> {
await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, dto.assetIds);
const zip = this.storageRepository.createZipStream();
const assets = await this.assetRepository.getByIds(dto.assetIds);
const paths: Record<string, boolean> = {};
for (const { originalPath, originalFileName } of assets) {
const ext = extname(originalPath);
let filename = `${originalFileName}${ext}`;
for (let i = 0; i < 10_000; i++) {
if (!paths[filename]) {
break;
}
filename = `${originalFileName}+${i + 1}${ext}`;
}
paths[filename] = true;
zip.addFile(originalPath, filename);
}
zip.finalize();
return { stream: zip.stream };
}
private async getDownloadAssets(authUser: AuthUserDto, dto: DownloadDto): Promise<AsyncGenerator<AssetEntity[]>> {
const PAGINATION_SIZE = 2500;
if (dto.assetIds) {
const assetIds = dto.assetIds;
await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, assetIds);
const assets = await this.assetRepository.getByIds(assetIds);
return (async function* () {
yield assets;
})();
}
if (dto.albumId) {
const albumId = dto.albumId;
await this.access.requirePermission(authUser, Permission.ALBUM_DOWNLOAD, albumId);
return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByAlbumId(pagination, albumId));
}
if (dto.userId) {
const userId = dto.userId;
await this.access.requirePermission(authUser, Permission.LIBRARY_DOWNLOAD, userId);
return usePagination(PAGINATION_SIZE, (pagination) => this.assetRepository.getByUserId(pagination, userId));
}
throw new BadRequestException('assetIds, albumId, or userId is required');
}
}

View File

@ -0,0 +1,31 @@
import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
import { ApiProperty } from '@nestjs/swagger';
import { IsInt, IsOptional, IsPositive } from 'class-validator';
export class DownloadDto {
@ValidateUUID({ each: true, optional: true })
assetIds?: string[];
@ValidateUUID({ optional: true })
albumId?: string;
@ValidateUUID({ optional: true })
userId?: string;
@IsInt()
@IsPositive()
@IsOptional()
archiveSize?: number;
}
export class DownloadResponseDto {
@ApiProperty({ type: 'integer' })
totalSize!: number;
archives!: DownloadArchiveInfo[];
}
export class DownloadArchiveInfo {
@ApiProperty({ type: 'integer' })
size!: number;
assetIds!: string[];
}

View File

@ -1,3 +1,4 @@
export * from './asset-ids.dto';
export * from './download.dto';
export * from './map-marker.dto';
export * from './memory-lane.dto';

View File

@ -1,9 +1,14 @@
import { ReadStream } from 'fs';
import { Readable } from 'stream';
export interface ImmichReadStream {
stream: ReadStream;
type: string;
length: number;
stream: Readable;
type?: string;
length?: number;
}
export interface ImmichZipStream extends ImmichReadStream {
addFile: (inputPath: string, filename: string) => void;
finalize: () => Promise<void>;
}
export interface DiskUsage {
@ -15,7 +20,8 @@ export interface DiskUsage {
export const IStorageRepository = 'IStorageRepository';
export interface IStorageRepository {
createReadStream(filepath: string, mimeType: string): Promise<ImmichReadStream>;
createZipStream(): ImmichZipStream;
createReadStream(filepath: string, mimeType?: string | null): Promise<ImmichReadStream>;
unlink(filepath: string): Promise<void>;
unlinkDir(folder: string, options?: { recursive?: boolean; force?: boolean }): Promise<void>;
removeEmptyDirs(folder: string): Promise<void>;

View File

@ -1,13 +1,10 @@
import { AlbumResponseDto } from '@app/domain';
import { Body, Controller, Delete, Get, Param, Put, Query, Response } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { Response as Res } from 'express';
import { handleDownload } from '../../app.utils';
import { Body, Controller, Delete, Get, Param, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto';
import { AuthUser, AuthUserDto } from '../../decorators/auth-user.decorator';
import { Authenticated, SharedLinkRoute } from '../../decorators/authenticated.decorator';
import { UseValidation } from '../../decorators/use-validation.decorator';
import { DownloadDto } from '../asset/dto/download-library.dto';
import { AlbumService } from './album.service';
import { AddAssetsDto } from './dto/add-assets.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
@ -18,7 +15,7 @@ import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
@Authenticated()
@UseValidation()
export class AlbumController {
constructor(private readonly service: AlbumService) {}
constructor(private service: AlbumService) {}
@SharedLinkRoute()
@Put(':id/assets')
@ -46,16 +43,4 @@ export class AlbumController {
): Promise<AlbumResponseDto> {
return this.service.removeAssets(authUser, id, dto);
}
@SharedLinkRoute()
@Get(':id/download')
@ApiOkResponse({ content: { 'application/zip': { schema: { type: 'string', format: 'binary' } } } })
downloadArchive(
@AuthUser() authUser: AuthUserDto,
@Param() { id }: UUIDParamDto,
@Query() dto: DownloadDto,
@Response({ passthrough: true }) res: Res,
) {
return this.service.downloadArchive(authUser, id, dto).then((download) => handleDownload(download, res));
}
}

View File

@ -1,13 +1,12 @@
import { AlbumEntity, AssetEntity } from '@app/infra/entities';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DownloadModule } from '../../modules/download/download.module';
import { AlbumRepository, IAlbumRepository } from './album-repository';
import { AlbumController } from './album.controller';
import { AlbumService } from './album.service';
@Module({
imports: [TypeOrmModule.forFeature([AlbumEntity, AssetEntity]), DownloadModule],
imports: [TypeOrmModule.forFeature([AlbumEntity, AssetEntity])],
controllers: [AlbumController],
providers: [AlbumService, { provide: IAlbumRepository, useClass: AlbumRepository }],
})

View File

@ -3,7 +3,6 @@ import { AlbumEntity, UserEntity } from '@app/infra/entities';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { userEntityStub } from '@test';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { DownloadService } from '../../modules/download/download.service';
import { IAlbumRepository } from './album-repository';
import { AlbumService } from './album.service';
import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
@ -11,7 +10,6 @@ import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
describe('Album service', () => {
let sut: AlbumService;
let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
const authUser: AuthUserDto = Object.freeze({
id: '1111',
@ -98,11 +96,7 @@ describe('Album service', () => {
updateThumbnails: jest.fn(),
};
downloadServiceMock = {
downloadArchive: jest.fn(),
};
sut = new AlbumService(albumRepositoryMock, downloadServiceMock as DownloadService);
sut = new AlbumService(albumRepositoryMock);
});
it('gets an owned album', async () => {

View File

@ -2,8 +2,6 @@ import { AlbumResponseDto, mapAlbum } from '@app/domain';
import { AlbumEntity } from '@app/infra/entities';
import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { AuthUserDto } from '../../decorators/auth-user.decorator';
import { DownloadService } from '../../modules/download/download.service';
import { DownloadDto } from '../asset/dto/download-library.dto';
import { IAlbumRepository } from './album-repository';
import { AddAssetsDto } from './dto/add-assets.dto';
import { RemoveAssetsDto } from './dto/remove-assets.dto';
@ -13,10 +11,7 @@ import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
export class AlbumService {
private logger = new Logger(AlbumService.name);
constructor(
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
private downloadService: DownloadService,
) {}
constructor(@Inject(IAlbumRepository) private repository: IAlbumRepository) {}
private async _getAlbum({
authUser,
@ -27,9 +22,9 @@ export class AlbumService {
albumId: string;
validateIsOwner?: boolean;
}): Promise<AlbumEntity> {
await this.albumRepository.updateThumbnails();
await this.repository.updateThumbnails();
const album = await this.albumRepository.get(albumId);
const album = await this.repository.get(albumId);
if (!album) {
throw new NotFoundException('Album Not Found');
}
@ -50,7 +45,7 @@ export class AlbumService {
async removeAssets(authUser: AuthUserDto, albumId: string, dto: RemoveAssetsDto): Promise<AlbumResponseDto> {
const album = await this._getAlbum({ authUser, albumId });
const deletedCount = await this.albumRepository.removeAssets(album, dto);
const deletedCount = await this.repository.removeAssets(album, dto);
const newAlbum = await this._getAlbum({ authUser, albumId });
if (deletedCount !== dto.assetIds.length) {
@ -67,7 +62,7 @@ export class AlbumService {
}
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
const result = await this.albumRepository.addAssets(album, dto);
const result = await this.repository.addAssets(album, dto);
const newAlbum = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
return {
@ -75,19 +70,4 @@ export class AlbumService {
album: mapAlbum(newAlbum),
};
}
async downloadArchive(authUser: AuthUserDto, albumId: string, dto: DownloadDto) {
this.checkDownloadAccess(authUser);
const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
const assets = (album.assets || []).map((asset) => asset).slice(dto.skip || 0);
return this.downloadService.downloadArchive(album.albumName, assets);
}
private checkDownloadAccess(authUser: AuthUserDto) {
if (authUser.isPublicUser && !authUser.isAllowDownload) {
throw new ForbiddenException();
}
}
}

View File

@ -1,4 +1,4 @@
import { AssetResponseDto, ImmichReadStream } from '@app/domain';
import { AssetResponseDto } from '@app/domain';
import {
Body,
Controller,
@ -14,7 +14,6 @@ import {
Put,
Query,
Response,
StreamableFile,
UploadedFiles,
UseInterceptors,
ValidationPipe,
@ -22,7 +21,6 @@ import {
import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { Response as Res } from 'express';
import { handleDownload } from '../../app.utils';
import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config';
import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto';
import { AuthUser, AuthUserDto } from '../../decorators/auth-user.decorator';
@ -36,8 +34,6 @@ import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CreateAssetDto, ImportAssetDto, mapToUploadFile } from './dto/create-asset.dto';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { DeviceIdDto } from './dto/device-id.dto';
import { DownloadFilesDto } from './dto/download-files.dto';
import { DownloadDto } from './dto/download-library.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
import { GetAssetThumbnailDto } from './dto/get-asset-thumbnail.dto';
@ -54,10 +50,6 @@ import { CuratedLocationsResponseDto } from './response-dto/curated-locations-re
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { DeleteAssetResponseDto } from './response-dto/delete-asset-response.dto';
function asStreamableFile({ stream, type, length }: ImmichReadStream) {
return new StreamableFile(stream, { type, length });
}
interface UploadFiles {
assetData: ImmichFile[];
livePhotoData?: ImmichFile[];
@ -128,38 +120,6 @@ export class AssetController {
return responseDto;
}
@SharedLinkRoute()
@Get('/download/:id')
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
downloadFile(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
return this.assetService.downloadFile(authUser, id).then(asStreamableFile);
}
@SharedLinkRoute()
@Post('/download-files')
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
downloadFiles(
@AuthUser() authUser: AuthUserDto,
@Response({ passthrough: true }) res: Res,
@Body(new ValidationPipe()) dto: DownloadFilesDto,
) {
return this.assetService.downloadFiles(authUser, dto).then((download) => handleDownload(download, res));
}
/**
* Current this is not used in any UI element
*/
@SharedLinkRoute()
@Get('/download-library')
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
downloadLibrary(
@AuthUser() authUser: AuthUserDto,
@Query(new ValidationPipe({ transform: true })) dto: DownloadDto,
@Response({ passthrough: true }) res: Res,
) {
return this.assetService.downloadLibrary(authUser, dto).then((download) => handleDownload(download, res));
}
@SharedLinkRoute()
@Get('/file/:id')
@Header('Cache-Control', 'private, max-age=86400, no-transform')

View File

@ -1,17 +1,12 @@
import { AssetEntity, ExifEntity } from '@app/infra/entities';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DownloadModule } from '../../modules/download/download.module';
import { AssetRepository, IAssetRepository } from './asset-repository';
import { AssetController } from './asset.controller';
import { AssetService } from './asset.service';
@Module({
imports: [
//
TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
DownloadModule,
],
imports: [TypeOrmModule.forFeature([AssetEntity, ExifEntity])],
controllers: [AssetController],
providers: [AssetService, { provide: IAssetRepository, useClass: AssetRepository }],
})

View File

@ -13,7 +13,6 @@ import {
} from '@test';
import { when } from 'jest-when';
import { QueryFailedError, Repository } from 'typeorm';
import { DownloadService } from '../../modules/download/download.service';
import { IAssetRepository } from './asset-repository';
import { AssetService } from './asset.service';
import { CreateAssetDto } from './dto/create-asset.dto';
@ -124,7 +123,6 @@ describe('AssetService', () => {
let accessMock: IAccessRepositoryMock;
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
let jobMock: jest.Mocked<IJobRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
@ -152,24 +150,12 @@ describe('AssetService', () => {
cryptoMock = newCryptoRepositoryMock();
downloadServiceMock = {
downloadArchive: jest.fn(),
};
accessMock = newAccessRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock();
storageMock = newStorageRepositoryMock();
sut = new AssetService(
accessMock,
assetRepositoryMock,
a,
cryptoMock,
downloadServiceMock as DownloadService,
jobMock,
storageMock,
);
sut = new AssetService(accessMock, assetRepositoryMock, a, cryptoMock, jobMock, storageMock);
when(assetRepositoryMock.get)
.calledWith(assetEntityStub.livePhotoStillAsset.id)
@ -398,27 +384,6 @@ describe('AssetService', () => {
});
});
// describe('checkDownloadAccess', () => {
// it('should validate download access', async () => {
// await sut.checkDownloadAccess(authStub.adminSharedLink);
// });
// it('should not allow when user is not allowed to download', async () => {
// expect(() => sut.checkDownloadAccess(authStub.readonlySharedLink)).toThrow(ForbiddenException);
// });
// });
describe('downloadFile', () => {
it('should download a single file', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
assetRepositoryMock.get.mockResolvedValue(_getAsset_1());
await sut.downloadFile(authStub.admin, 'id_1');
expect(storageMock.createReadStream).toHaveBeenCalledWith('fake_path/asset_1.jpeg', 'image/jpeg');
});
});
describe('bulkUploadCheck', () => {
it('should accept hex and base64 checksums', async () => {
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');

View File

@ -6,7 +6,6 @@ import {
IAccessRepository,
ICryptoRepository,
IJobRepository,
ImmichReadStream,
isSupportedFileType,
IStorageRepository,
JobName,
@ -33,7 +32,6 @@ import mime from 'mime-types';
import path from 'path';
import { QueryFailedError, Repository } from 'typeorm';
import { promisify } from 'util';
import { DownloadService } from '../../modules/download/download.service';
import { IAssetRepository } from './asset-repository';
import { AssetCore } from './asset.core';
import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
@ -42,8 +40,6 @@ import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
import { CreateAssetDto, ImportAssetDto, UploadFile } from './dto/create-asset.dto';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { DownloadFilesDto } from './dto/download-files.dto';
import { DownloadDto } from './dto/download-library.dto';
import { GetAssetByTimeBucketDto } from './dto/get-asset-by-time-bucket.dto';
import { GetAssetCountByTimeBucketDto } from './dto/get-asset-count-by-time-bucket.dto';
import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto';
@ -86,7 +82,6 @@ export class AssetService {
@Inject(IAssetRepository) private _assetRepository: IAssetRepository,
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
private downloadService: DownloadService,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
) {
@ -250,50 +245,6 @@ export class AssetService {
return mapAsset(updatedAsset);
}
public async downloadLibrary(authUser: AuthUserDto, dto: DownloadDto) {
await this.access.requirePermission(authUser, Permission.LIBRARY_DOWNLOAD, authUser.id);
const assets = await this._assetRepository.getAllByUserId(authUser.id, dto);
return this.downloadService.downloadArchive(dto.name || `library`, assets);
}
public async downloadFiles(authUser: AuthUserDto, dto: DownloadFilesDto) {
await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, dto.assetIds);
const assetToDownload = [];
for (const assetId of dto.assetIds) {
const asset = await this._assetRepository.getById(assetId);
assetToDownload.push(asset);
// Get live photo asset
if (asset.livePhotoVideoId) {
const livePhotoAsset = await this._assetRepository.getById(asset.livePhotoVideoId);
assetToDownload.push(livePhotoAsset);
}
}
const now = new Date().toISOString();
return this.downloadService.downloadArchive(`immich-${now}`, assetToDownload);
}
public async downloadFile(authUser: AuthUserDto, assetId: string): Promise<ImmichReadStream> {
await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, assetId);
try {
const asset = await this._assetRepository.get(assetId);
if (asset && asset.originalPath && asset.mimeType) {
return this.storageRepository.createReadStream(asset.originalPath, asset.mimeType);
}
} catch (e) {
Logger.error(`Error download asset ${e}`, 'downloadFile');
throw new InternalServerErrorException(`Failed to download asset ${e}`, 'DownloadFile');
}
throw new NotFoundException();
}
async getAssetThumbnail(
authUser: AuthUserDto,
assetId: string,

View File

@ -1,12 +0,0 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty } from 'class-validator';
export class DownloadFilesDto {
@IsNotEmpty()
@ApiProperty({
isArray: true,
type: String,
title: 'Array of asset ids to be downloaded',
})
assetIds!: string[];
}

View File

@ -1,14 +0,0 @@
import { Type } from 'class-transformer';
import { IsNumber, IsOptional, IsPositive, IsString } from 'class-validator';
export class DownloadDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsPositive()
@IsNumber()
@Type(() => Number)
skip?: number;
}

View File

@ -1,5 +1,11 @@
import { IMMICH_ACCESS_COOKIE, IMMICH_API_KEY_HEADER, IMMICH_API_KEY_NAME, SERVER_VERSION } from '@app/domain';
import { INestApplication } from '@nestjs/common';
import {
ImmichReadStream,
IMMICH_ACCESS_COOKIE,
IMMICH_API_KEY_HEADER,
IMMICH_API_KEY_NAME,
SERVER_VERSION,
} from '@app/domain';
import { INestApplication, StreamableFile } from '@nestjs/common';
import {
DocumentBuilder,
OpenAPIObject,
@ -7,18 +13,12 @@ import {
SwaggerDocumentOptions,
SwaggerModule,
} from '@nestjs/swagger';
import { Response } from 'express';
import { writeFileSync } from 'fs';
import path from 'path';
import { Metadata } from './decorators/authenticated.decorator';
import { DownloadArchive } from './modules/download/download.service';
export const handleDownload = (download: DownloadArchive, res: Response) => {
res.attachment(download.fileName);
res.setHeader('X-Immich-Content-Length-Hint', download.fileSize);
res.setHeader('X-Immich-Archive-File-Count', download.fileCount);
res.setHeader('X-Immich-Archive-Complete', `${download.complete}`);
return download.stream;
export const asStreamableFile = ({ stream, type, length }: ImmichReadStream) => {
return new StreamableFile(stream, { type, length });
};
function sortKeys<T extends object>(obj: T): T {

View File

@ -1,11 +1,21 @@
import { AssetService, AuthUserDto, MapMarkerResponseDto, MemoryLaneDto } from '@app/domain';
import {
AssetIdsDto,
AssetService,
AuthUserDto,
DownloadDto,
DownloadResponseDto,
MapMarkerResponseDto,
MemoryLaneDto,
} from '@app/domain';
import { MapMarkerDto } from '@app/domain/asset/dto/map-marker.dto';
import { MemoryLaneResponseDto } from '@app/domain/asset/response-dto/memory-lane-response.dto';
import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Body, Controller, Get, HttpCode, HttpStatus, Param, Post, Query, StreamableFile } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { asStreamableFile } from '../app.utils';
import { AuthUser } from '../decorators/auth-user.decorator';
import { Authenticated } from '../decorators/authenticated.decorator';
import { Authenticated, SharedLinkRoute } from '../decorators/authenticated.decorator';
import { UseValidation } from '../decorators/use-validation.decorator';
import { UUIDParamDto } from './dto/uuid-param.dto';
@ApiTags('Asset')
@Controller('asset')
@ -23,4 +33,26 @@ export class AssetController {
getMemoryLane(@AuthUser() authUser: AuthUserDto, @Query() dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> {
return this.service.getMemoryLane(authUser, dto);
}
@SharedLinkRoute()
@Get('download')
getDownloadInfo(@AuthUser() authUser: AuthUserDto, @Query() dto: DownloadDto): Promise<DownloadResponseDto> {
return this.service.getDownloadInfo(authUser, dto);
}
@SharedLinkRoute()
@Post('download')
@HttpCode(HttpStatus.OK)
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
downloadArchive(@AuthUser() authUser: AuthUserDto, @Body() dto: AssetIdsDto): Promise<StreamableFile> {
return this.service.downloadArchive(authUser, dto).then(asStreamableFile);
}
@SharedLinkRoute()
@Post('download/:id')
@HttpCode(HttpStatus.OK)
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
downloadFile(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
return this.service.downloadFile(authUser, id).then(asStreamableFile);
}
}

View File

@ -1,8 +0,0 @@
import { Module } from '@nestjs/common';
import { DownloadService } from './download.service';
@Module({
providers: [DownloadService],
exports: [DownloadService],
})
export class DownloadModule {}

View File

@ -1,63 +0,0 @@
import { asHumanReadable, HumanReadableSize } from '@app/domain';
import { AssetEntity } from '@app/infra/entities';
import { BadRequestException, Injectable, InternalServerErrorException, Logger, StreamableFile } from '@nestjs/common';
import archiver from 'archiver';
import { extname } from 'path';
export interface DownloadArchive {
stream: StreamableFile;
fileName: string;
fileSize: number;
fileCount: number;
complete: boolean;
}
@Injectable()
export class DownloadService {
private readonly logger = new Logger(DownloadService.name);
public async downloadArchive(name: string, assets: AssetEntity[]): Promise<DownloadArchive> {
if (!assets || assets.length === 0) {
throw new BadRequestException('No assets to download.');
}
try {
const archive = archiver('zip', { store: true });
const stream = new StreamableFile(archive);
let totalSize = 0;
let fileCount = 0;
let complete = true;
for (const { originalPath, exifInfo, originalFileName } of assets) {
const name = `${originalFileName}${extname(originalPath)}`;
archive.file(originalPath, { name });
totalSize += Number(exifInfo?.fileSizeInByte || 0);
fileCount++;
// for easier testing, can be changed before merging.
if (totalSize > HumanReadableSize.GiB * 20) {
complete = false;
this.logger.log(
`Archive size exceeded after ${fileCount} files, capping at ${totalSize} bytes (${asHumanReadable(
totalSize,
)})`,
);
break;
}
}
archive.finalize();
return {
stream,
fileName: `${name}.zip`,
fileSize: totalSize,
fileCount,
complete,
};
} catch (error) {
this.logger.error(`Error creating download archive ${error}`);
throw new InternalServerErrorException(`Failed to download ${name}: ${error}`, 'DownloadArchive');
}
}
}

View File

@ -140,7 +140,9 @@ export class AccessRepository implements IAccessRepository {
return this.albumRepository.exist({
where: {
id: albumId,
ownerId: userId,
sharedUsers: {
id: userId,
},
},
});
},

View File

@ -72,6 +72,32 @@ export class AssetRepository implements IAssetRepository {
await this.repository.delete({ ownerId });
}
getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity> {
return paginate(this.repository, pagination, {
where: {
albums: {
id: albumId,
},
},
relations: {
albums: true,
exifInfo: true,
},
});
}
getByUserId(pagination: PaginationOptions, userId: string): Paginated<AssetEntity> {
return paginate(this.repository, pagination, {
where: {
ownerId: userId,
isVisible: true,
},
relations: {
exifInfo: true,
},
});
}
getAll(pagination: PaginationOptions, options: AssetSearchOptions = {}): Paginated<AssetEntity> {
return paginate(this.repository, pagination, {
where: {

View File

@ -1,4 +1,5 @@
import { DiskUsage, ImmichReadStream, IStorageRepository } from '@app/domain';
import { DiskUsage, ImmichReadStream, ImmichZipStream, IStorageRepository } from '@app/domain';
import archiver from 'archiver';
import { constants, createReadStream, existsSync, mkdirSync } from 'fs';
import fs from 'fs/promises';
import mv from 'mv';
@ -8,13 +9,25 @@ import path from 'path';
const moveFile = promisify<string, string, mv.Options>(mv);
export class FilesystemProvider implements IStorageRepository {
async createReadStream(filepath: string, mimeType: string): Promise<ImmichReadStream> {
createZipStream(): ImmichZipStream {
const archive = archiver('zip', { store: true });
const addFile = (input: string, filename: string) => {
archive.file(input, { name: filename });
};
const finalize = () => archive.finalize();
return { stream: archive, addFile, finalize };
}
async createReadStream(filepath: string, mimeType?: string | null): Promise<ImmichReadStream> {
const { size } = await fs.stat(filepath);
await fs.access(filepath, constants.R_OK | constants.W_OK);
return {
stream: createReadStream(filepath),
length: size,
type: mimeType,
type: mimeType || undefined,
};
}

View File

@ -203,14 +203,14 @@ export const fileStub = {
export const assetEntityStub = {
noResizePath: Object.freeze<AssetEntity>({
id: 'asset-id',
originalFileName: 'asset_1.jpeg',
originalFileName: 'IMG_123',
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userEntityStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: 'upload/upload/path.ext',
originalPath: 'upload/library/IMG_123.jpg',
resizePath: null,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.IMAGE,
@ -240,7 +240,7 @@ export const assetEntityStub = {
owner: userEntityStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.ext',
originalPath: 'upload/library/IMG_456.jpg',
resizePath: '/uploads/user-id/thumbs/path.ext',
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.IMAGE,
@ -258,10 +258,13 @@ export const assetEntityStub = {
livePhotoVideoId: null,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.ext',
originalFileName: 'IMG_456',
faces: [],
sidecarPath: null,
isReadOnly: false,
exifInfo: {
fileSizeInByte: 123_000,
} as ExifEntity,
}),
noThumbhash: Object.freeze<AssetEntity>({
id: 'asset-id',
@ -324,6 +327,9 @@ export const assetEntityStub = {
originalFileName: 'asset-id.ext',
faces: [],
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5_000,
} as ExifEntity,
}),
video: Object.freeze<AssetEntity>({
id: 'asset-id',
@ -355,6 +361,9 @@ export const assetEntityStub = {
sharedLinks: [],
faces: [],
sidecarPath: null,
exifInfo: {
fileSizeInByte: 100_000,
} as ExifEntity,
}),
livePhotoMotionAsset: Object.freeze({
id: 'live-photo-motion-asset',
@ -364,6 +373,9 @@ export const assetEntityStub = {
isVisible: false,
fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
exifInfo: {
fileSizeInByte: 100_000,
},
} as AssetEntity),
livePhotoStillAsset: Object.freeze({
@ -375,6 +387,9 @@ export const assetEntityStub = {
isVisible: true,
fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
exifInfo: {
fileSizeInByte: 25_000,
},
} as AssetEntity),
withLocation: Object.freeze<AssetEntity>({
@ -410,6 +425,7 @@ export const assetEntityStub = {
exifInfo: {
latitude: 100,
longitude: 100,
fileSizeInByte: 23_456,
} as ExifEntity,
}),
sidecar: Object.freeze<AssetEntity>({

View File

@ -4,6 +4,8 @@ export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
return {
getByDate: jest.fn(),
getByIds: jest.fn().mockResolvedValue([]),
getByAlbumId: jest.fn(),
getByUserId: jest.fn(),
getWithout: jest.fn(),
getWith: jest.fn(),
getFirstAssetForAlbumId: jest.fn(),

View File

@ -2,6 +2,7 @@ import { IStorageRepository } from '@app/domain';
export const newStorageRepositoryMock = (): jest.Mocked<IStorageRepository> => {
return {
createZipStream: jest.fn(),
createReadStream: jest.fn(),
unlink: jest.fn(),
unlinkDir: jest.fn().mockResolvedValue(true),

View File

@ -1111,16 +1111,41 @@ export type DeleteAssetStatus = typeof DeleteAssetStatus[keyof typeof DeleteAsse
/**
*
* @export
* @interface DownloadFilesDto
* @interface DownloadArchiveInfo
*/
export interface DownloadFilesDto {
export interface DownloadArchiveInfo {
/**
*
* @type {number}
* @memberof DownloadArchiveInfo
*/
'size': number;
/**
*
* @type {Array<string>}
* @memberof DownloadFilesDto
* @memberof DownloadArchiveInfo
*/
'assetIds': Array<string>;
}
/**
*
* @export
* @interface DownloadResponseDto
*/
export interface DownloadResponseDto {
/**
*
* @type {number}
* @memberof DownloadResponseDto
*/
'totalSize': number;
/**
*
* @type {Array<DownloadArchiveInfo>}
* @memberof DownloadResponseDto
*/
'archives': Array<DownloadArchiveInfo>;
}
/**
*
* @export
@ -3645,63 +3670,6 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
* @param {string} [name]
* @param {number} [skip]
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
downloadArchive: async (id: string, name?: string, skip?: number, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('downloadArchive', 'id', id)
const localVarPath = `/album/{id}/download`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (name !== undefined) {
localVarQueryParameter['name'] = name;
}
if (skip !== undefined) {
localVarQueryParameter['skip'] = skip;
}
if (key !== undefined) {
localVarQueryParameter['key'] = key;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@ -4039,19 +4007,6 @@ export const AlbumApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAlbum(id, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id
* @param {string} [name]
* @param {number} [skip]
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async downloadArchive(id: string, name?: string, skip?: number, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<File>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.downloadArchive(id, name, skip, key, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {*} [options] Override http request option.
@ -4165,18 +4120,6 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath
deleteAlbum(id: string, options?: any): AxiosPromise<void> {
return localVarFp.deleteAlbum(id, options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} id
* @param {string} [name]
* @param {number} [skip]
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
downloadArchive(id: string, name?: string, skip?: number, key?: string, options?: any): AxiosPromise<File> {
return localVarFp.downloadArchive(id, name, skip, key, options).then((request) => request(axios, basePath));
},
/**
*
* @param {*} [options] Override http request option.
@ -4315,41 +4258,6 @@ export interface AlbumApiDeleteAlbumRequest {
readonly id: string
}
/**
* Request parameters for downloadArchive operation in AlbumApi.
* @export
* @interface AlbumApiDownloadArchiveRequest
*/
export interface AlbumApiDownloadArchiveRequest {
/**
*
* @type {string}
* @memberof AlbumApiDownloadArchive
*/
readonly id: string
/**
*
* @type {string}
* @memberof AlbumApiDownloadArchive
*/
readonly name?: string
/**
*
* @type {number}
* @memberof AlbumApiDownloadArchive
*/
readonly skip?: number
/**
*
* @type {string}
* @memberof AlbumApiDownloadArchive
*/
readonly key?: string
}
/**
* Request parameters for getAlbumInfo operation in AlbumApi.
* @export
@ -4506,17 +4414,6 @@ export class AlbumApi extends BaseAPI {
return AlbumApiFp(this.configuration).deleteAlbum(requestParameters.id, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AlbumApiDownloadArchiveRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AlbumApi
*/
public downloadArchive(requestParameters: AlbumApiDownloadArchiveRequest, options?: AxiosRequestConfig) {
return AlbumApiFp(this.configuration).downloadArchive(requestParameters.id, requestParameters.name, requestParameters.skip, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {*} [options] Override http request option.
@ -4773,62 +4670,15 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
},
/**
*
* @param {string} id
* @param {AssetIdsDto} assetIdsDto
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
downloadFile: async (id: string, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('downloadFile', 'id', id)
const localVarPath = `/asset/download/{id}`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (key !== undefined) {
localVarQueryParameter['key'] = key;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {DownloadFilesDto} downloadFilesDto
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
downloadFiles: async (downloadFilesDto: DownloadFilesDto, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'downloadFilesDto' is not null or undefined
assertParamExists('downloadFiles', 'downloadFilesDto', downloadFilesDto)
const localVarPath = `/asset/download-files`;
downloadArchive: async (assetIdsDto: AssetIdsDto, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'assetIdsDto' is not null or undefined
assertParamExists('downloadArchive', 'assetIdsDto', assetIdsDto)
const localVarPath = `/asset/download`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
@ -4860,7 +4710,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(downloadFilesDto, localVarRequestOptions, configuration)
localVarRequestOptions.data = serializeDataIfNeeded(assetIdsDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
@ -4868,15 +4718,17 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
};
},
/**
* Current this is not used in any UI element
* @param {string} [name]
* @param {number} [skip]
*
* @param {string} id
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
downloadLibrary: async (name?: string, skip?: number, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/asset/download-library`;
downloadFile: async (id: string, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('downloadFile', 'id', id)
const localVarPath = `/asset/download/{id}`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
@ -4884,7 +4736,7 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
@ -4897,14 +4749,6 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (name !== undefined) {
localVarQueryParameter['name'] = name;
}
if (skip !== undefined) {
localVarQueryParameter['skip'] = skip;
}
if (key !== undefined) {
localVarQueryParameter['key'] = key;
}
@ -5356,6 +5200,69 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {Array<string>} [assetIds]
* @param {string} [albumId]
* @param {string} [userId]
* @param {number} [archiveSize]
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getDownloadInfo: async (assetIds?: Array<string>, albumId?: string, userId?: string, archiveSize?: number, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/asset/download`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (assetIds) {
localVarQueryParameter['assetIds'] = assetIds;
}
if (albumId !== undefined) {
localVarQueryParameter['albumId'] = albumId;
}
if (userId !== undefined) {
localVarQueryParameter['userId'] = userId;
}
if (archiveSize !== undefined) {
localVarQueryParameter['archiveSize'] = archiveSize;
}
if (key !== undefined) {
localVarQueryParameter['key'] = key;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@ -5888,6 +5795,17 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAsset(deleteAssetDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {AssetIdsDto} assetIdsDto
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async downloadArchive(assetIdsDto: AssetIdsDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<File>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.downloadArchive(assetIdsDto, key, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id
@ -5899,29 +5817,6 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFile(id, key, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {DownloadFilesDto} downloadFilesDto
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async downloadFiles(downloadFilesDto: DownloadFilesDto, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<File>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFiles(downloadFilesDto, key, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
* Current this is not used in any UI element
* @param {string} [name]
* @param {number} [skip]
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async downloadLibrary(name?: string, skip?: number, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<File>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.downloadLibrary(name, skip, key, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
* Get all AssetEntity belong to the user
* @param {string} [userId]
@ -6025,6 +5920,20 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.getCuratedObjects(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {Array<string>} [assetIds]
* @param {string} [albumId]
* @param {string} [userId]
* @param {number} [archiveSize]
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getDownloadInfo(assetIds?: Array<string>, albumId?: string, userId?: string, archiveSize?: number, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<DownloadResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getDownloadInfo(assetIds, albumId, userId, archiveSize, key, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {boolean} [isFavorite]
@ -6172,6 +6081,16 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
deleteAsset(deleteAssetDto: DeleteAssetDto, options?: any): AxiosPromise<Array<DeleteAssetResponseDto>> {
return localVarFp.deleteAsset(deleteAssetDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {AssetIdsDto} assetIdsDto
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
downloadArchive(assetIdsDto: AssetIdsDto, key?: string, options?: any): AxiosPromise<File> {
return localVarFp.downloadArchive(assetIdsDto, key, options).then((request) => request(axios, basePath));
},
/**
*
* @param {string} id
@ -6182,27 +6101,6 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
downloadFile(id: string, key?: string, options?: any): AxiosPromise<File> {
return localVarFp.downloadFile(id, key, options).then((request) => request(axios, basePath));
},
/**
*
* @param {DownloadFilesDto} downloadFilesDto
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
downloadFiles(downloadFilesDto: DownloadFilesDto, key?: string, options?: any): AxiosPromise<File> {
return localVarFp.downloadFiles(downloadFilesDto, key, options).then((request) => request(axios, basePath));
},
/**
* Current this is not used in any UI element
* @param {string} [name]
* @param {number} [skip]
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
downloadLibrary(name?: string, skip?: number, key?: string, options?: any): AxiosPromise<File> {
return localVarFp.downloadLibrary(name, skip, key, options).then((request) => request(axios, basePath));
},
/**
* Get all AssetEntity belong to the user
* @param {string} [userId]
@ -6296,6 +6194,19 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
getCuratedObjects(options?: any): AxiosPromise<Array<CuratedObjectsResponseDto>> {
return localVarFp.getCuratedObjects(options).then((request) => request(axios, basePath));
},
/**
*
* @param {Array<string>} [assetIds]
* @param {string} [albumId]
* @param {string} [userId]
* @param {number} [archiveSize]
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getDownloadInfo(assetIds?: Array<string>, albumId?: string, userId?: string, archiveSize?: number, key?: string, options?: any): AxiosPromise<DownloadResponseDto> {
return localVarFp.getDownloadInfo(assetIds, albumId, userId, archiveSize, key, options).then((request) => request(axios, basePath));
},
/**
*
* @param {boolean} [isFavorite]
@ -6454,6 +6365,27 @@ export interface AssetApiDeleteAssetRequest {
readonly deleteAssetDto: DeleteAssetDto
}
/**
* Request parameters for downloadArchive operation in AssetApi.
* @export
* @interface AssetApiDownloadArchiveRequest
*/
export interface AssetApiDownloadArchiveRequest {
/**
*
* @type {AssetIdsDto}
* @memberof AssetApiDownloadArchive
*/
readonly assetIdsDto: AssetIdsDto
/**
*
* @type {string}
* @memberof AssetApiDownloadArchive
*/
readonly key?: string
}
/**
* Request parameters for downloadFile operation in AssetApi.
* @export
@ -6475,55 +6407,6 @@ export interface AssetApiDownloadFileRequest {
readonly key?: string
}
/**
* Request parameters for downloadFiles operation in AssetApi.
* @export
* @interface AssetApiDownloadFilesRequest
*/
export interface AssetApiDownloadFilesRequest {
/**
*
* @type {DownloadFilesDto}
* @memberof AssetApiDownloadFiles
*/
readonly downloadFilesDto: DownloadFilesDto
/**
*
* @type {string}
* @memberof AssetApiDownloadFiles
*/
readonly key?: string
}
/**
* Request parameters for downloadLibrary operation in AssetApi.
* @export
* @interface AssetApiDownloadLibraryRequest
*/
export interface AssetApiDownloadLibraryRequest {
/**
*
* @type {string}
* @memberof AssetApiDownloadLibrary
*/
readonly name?: string
/**
*
* @type {number}
* @memberof AssetApiDownloadLibrary
*/
readonly skip?: number
/**
*
* @type {string}
* @memberof AssetApiDownloadLibrary
*/
readonly key?: string
}
/**
* Request parameters for getAllAssets operation in AssetApi.
* @export
@ -6650,6 +6533,48 @@ export interface AssetApiGetAssetThumbnailRequest {
readonly key?: string
}
/**
* Request parameters for getDownloadInfo operation in AssetApi.
* @export
* @interface AssetApiGetDownloadInfoRequest
*/
export interface AssetApiGetDownloadInfoRequest {
/**
*
* @type {Array<string>}
* @memberof AssetApiGetDownloadInfo
*/
readonly assetIds?: Array<string>
/**
*
* @type {string}
* @memberof AssetApiGetDownloadInfo
*/
readonly albumId?: string
/**
*
* @type {string}
* @memberof AssetApiGetDownloadInfo
*/
readonly userId?: string
/**
*
* @type {number}
* @memberof AssetApiGetDownloadInfo
*/
readonly archiveSize?: number
/**
*
* @type {string}
* @memberof AssetApiGetDownloadInfo
*/
readonly key?: string
}
/**
* Request parameters for getMapMarkers operation in AssetApi.
* @export
@ -6953,6 +6878,17 @@ export class AssetApi extends BaseAPI {
return AssetApiFp(this.configuration).deleteAsset(requestParameters.deleteAssetDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AssetApiDownloadArchiveRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public downloadArchive(requestParameters: AssetApiDownloadArchiveRequest, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).downloadArchive(requestParameters.assetIdsDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AssetApiDownloadFileRequest} requestParameters Request parameters.
@ -6964,28 +6900,6 @@ export class AssetApi extends BaseAPI {
return AssetApiFp(this.configuration).downloadFile(requestParameters.id, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AssetApiDownloadFilesRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public downloadFiles(requestParameters: AssetApiDownloadFilesRequest, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).downloadFiles(requestParameters.downloadFilesDto, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
}
/**
* Current this is not used in any UI element
* @param {AssetApiDownloadLibraryRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public downloadLibrary(requestParameters: AssetApiDownloadLibraryRequest = {}, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).downloadLibrary(requestParameters.name, requestParameters.skip, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
}
/**
* Get all AssetEntity belong to the user
* @param {AssetApiGetAllAssetsRequest} requestParameters Request parameters.
@ -7091,6 +7005,17 @@ export class AssetApi extends BaseAPI {
return AssetApiFp(this.configuration).getCuratedObjects(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AssetApiGetDownloadInfoRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public getDownloadInfo(requestParameters: AssetApiGetDownloadInfoRequest = {}, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getDownloadInfo(requestParameters.assetIds, requestParameters.albumId, requestParameters.userId, requestParameters.archiveSize, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AssetApiGetMapMarkersRequest} requestParameters Request parameters.

View File

@ -3,7 +3,6 @@
import { afterNavigate, goto } from '$app/navigation';
import { albumAssetSelectionStore } from '$lib/stores/album-asset-selection.store';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import { downloadAssets } from '$lib/stores/download';
import { locale } from '$lib/stores/preferences.store';
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
import {
@ -45,6 +44,7 @@
import ThumbnailSelection from './thumbnail-selection.svelte';
import UserSelectionModal from './user-selection-modal.svelte';
import { handleError } from '../../utils/handle-error';
import { downloadArchive } from '../../utils/asset-utils';
export let album: AlbumResponseDto;
export let sharedLink: SharedLinkResponseDto | undefined = undefined;
@ -242,78 +242,12 @@
};
const downloadAlbum = async () => {
try {
let skip = 0;
let count = 0;
let done = false;
while (!done) {
count++;
const fileName = album.albumName + `${count === 1 ? '' : count}.zip`;
$downloadAssets[fileName] = 0;
let total = 0;
const { data, status, headers } = await api.albumApi.downloadArchive(
{ id: album.id, skip: skip || undefined, key: sharedLink?.key },
{
responseType: 'blob',
onDownloadProgress: function (progressEvent) {
const request = this as XMLHttpRequest;
if (!total) {
total = Number(request.getResponseHeader('X-Immich-Content-Length-Hint')) || 0;
}
if (total) {
const current = progressEvent.loaded;
$downloadAssets[fileName] = Math.floor((current / total) * 100);
}
}
}
);
const isNotComplete = headers['x-immich-archive-complete'] === 'false';
const fileCount = Number(headers['x-immich-archive-file-count']) || 0;
if (isNotComplete && fileCount > 0) {
skip += fileCount;
} else {
done = true;
}
if (!(data instanceof Blob)) {
return;
}
if (status === 200) {
const fileUrl = URL.createObjectURL(data);
const anchor = document.createElement('a');
anchor.href = fileUrl;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(fileUrl);
// Remove item from download list
setTimeout(() => {
const copy = $downloadAssets;
delete copy[fileName];
$downloadAssets = copy;
}, 2000);
}
}
} catch (e) {
$downloadAssets = {};
console.error('Error downloading file ', e);
notificationController.show({
type: NotificationType.Error,
message: 'Error downloading file, check console for more details.'
});
}
await downloadArchive(
`${album.albumName}.zip`,
{ albumId: album.id },
undefined,
sharedLink?.key
);
};
const showAlbumOptionsMenu = ({ x, y }: MouseEvent) => {
@ -360,7 +294,7 @@
>
<CircleIconButton title="Select all" logo={SelectAll} on:click={handleSelectAll} />
{#if sharedLink?.allowDownload || !isPublicShared}
<DownloadAction filename={album.albumName} sharedLinkKey={sharedLink?.key} />
<DownloadAction filename="{album.albumName}.zip" sharedLinkKey={sharedLink?.key} />
{/if}
{#if isOwned}
<RemoveFromAlbum bind:album />

View File

@ -1,6 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { downloadAssets } from '$lib/stores/download';
import {
AlbumResponseDto,
api,
@ -25,7 +24,7 @@
import { assetStore } from '$lib/stores/assets.store';
import { isShowDetail } from '$lib/stores/preferences.store';
import { addAssetsToAlbum, getFilenameExtension } from '$lib/utils/asset-utils';
import { addAssetsToAlbum, downloadFile } from '$lib/utils/asset-utils';
import { browser } from '$app/environment';
export let asset: AssetResponseDto;
@ -115,75 +114,6 @@
$isShowDetail = !$isShowDetail;
};
const handleDownload = () => {
if (asset.livePhotoVideoId) {
downloadFile(asset.livePhotoVideoId, true, publicSharedKey);
downloadFile(asset.id, false, publicSharedKey);
return;
}
downloadFile(asset.id, false, publicSharedKey);
};
const downloadFile = async (assetId: string, isLivePhoto: boolean, key: string) => {
try {
const imageExtension = isLivePhoto ? 'mov' : getFilenameExtension(asset.originalPath);
const imageFileName = asset.originalFileName + '.' + imageExtension;
// If assets is already download -> return;
if ($downloadAssets[imageFileName]) {
return;
}
$downloadAssets[imageFileName] = 0;
const { data, status } = await api.assetApi.downloadFile(
{ id: assetId, key },
{
responseType: 'blob',
onDownloadProgress: (progressEvent) => {
if (progressEvent.lengthComputable) {
const total = progressEvent.total;
const current = progressEvent.loaded;
$downloadAssets[imageFileName] = Math.floor((current / total) * 100);
}
}
}
);
if (!(data instanceof Blob)) {
return;
}
if (status === 200) {
const fileUrl = URL.createObjectURL(data);
const anchor = document.createElement('a');
anchor.href = fileUrl;
anchor.download = imageFileName;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(fileUrl);
// Remove item from download list
setTimeout(() => {
const copy = $downloadAssets;
delete copy[imageFileName];
$downloadAssets = copy;
}, 2000);
}
} catch (e) {
$downloadAssets = {};
console.error('Error downloading file ', e);
notificationController.show({
type: NotificationType.Error,
message: 'Error downloading file, check console for more details.'
});
}
};
const deleteAsset = async () => {
try {
if (
@ -313,7 +243,7 @@
showDownloadButton={shouldShowDownloadButton}
on:goBack={closeViewer}
on:showDetail={showDetailInfoHandler}
on:download={handleDownload}
on:download={() => downloadFile(asset, publicSharedKey)}
on:delete={deleteAsset}
on:favorite={toggleFavorite}
on:addToAlbum={() => openAlbumPicker(false)}

View File

@ -1,18 +1,30 @@
<script lang="ts">
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import { bulkDownload } from '$lib/utils/asset-utils';
import { downloadArchive, downloadFile } from '$lib/utils/asset-utils';
import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte';
import MenuOption from '../../shared-components/context-menu/menu-option.svelte';
import { getAssetControlContext } from '../asset-select-control-bar.svelte';
export let filename = 'immich';
export let filename = 'immich.zip';
export let sharedLinkKey: string | undefined = undefined;
export let menuItem = false;
const { getAssets, clearSelect } = getAssetControlContext();
const handleDownloadFiles = async () => {
await bulkDownload(filename, Array.from(getAssets()), clearSelect, sharedLinkKey);
const assets = Array.from(getAssets());
if (assets.length === 1) {
await downloadFile(assets[0], sharedLinkKey);
clearSelect();
return;
}
await downloadArchive(
filename,
{ assetIds: assets.map((asset) => asset.id) },
clearSelect,
sharedLinkKey
);
};
</script>

View File

@ -1,7 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { bulkDownload } from '$lib/utils/asset-utils';
import { fileUploadHandler, openFileUploadDialog } from '$lib/utils/file-uploader';
import { downloadArchive } from '$lib/utils/asset-utils';
import { api, AssetResponseDto, SharedLinkResponseDto } from '@api';
import { dragAndDropFilesStore } from '$lib/stores/drag-and-drop-files.store';
import ArrowLeft from 'svelte-material-icons/ArrowLeft.svelte';
@ -38,7 +38,12 @@
});
const downloadAssets = async () => {
await bulkDownload('immich-shared', assets, undefined, sharedLink.key);
await downloadArchive(
`immich-shared.zip`,
{ assetIds: assets.map((asset) => asset.id) },
undefined,
sharedLink.key
);
};
const handleUploadAssets = async (files: File[] = []) => {
@ -78,7 +83,7 @@
<AssetSelectControlBar assets={selectedAssets} clearSelect={() => (selectedAssets = new Set())}>
<CircleIconButton title="Select all" logo={SelectAll} on:click={handleSelectAll} />
{#if sharedLink?.allowDownload}
<DownloadAction filename="immich-shared" sharedLinkKey={sharedLink.key} />
<DownloadAction filename="immich-shared.zip" sharedLinkKey={sharedLink.key} />
{/if}
{#if isOwned}
<RemoveFromSharedLink bind:sharedLink />

View File

@ -9,3 +9,18 @@ export const isDownloading = derived(downloadAssets, ($downloadAssets) => {
return true;
});
const update = (key: string, value: number | null) => {
downloadAssets.update((state) => {
const newState = { ...state };
if (value === null) {
delete newState[key];
} else {
newState[key] = value;
}
return newState;
});
};
export const clearDownload = (key: string) => update(key, null);
export const updateDownload = (key: string, value: number) => update(key, value);

View File

@ -1,9 +1,16 @@
import { api, AddAssetsResponseDto, AssetResponseDto } from '@api';
import {
notificationController,
NotificationType
} from '$lib/components/shared-components/notification/notification';
import { downloadAssets } from '$lib/stores/download';
import { clearDownload, updateDownload } from '$lib/stores/download';
import {
AddAssetsResponseDto,
api,
AssetApiGetDownloadInfoRequest,
AssetResponseDto,
DownloadResponseDto
} from '@api';
import { handleError } from './handle-error';
export const addAssetsToAlbum = async (
albumId: string,
@ -24,84 +31,104 @@ export const addAssetsToAlbum = async (
return dto;
});
export async function bulkDownload(
const downloadBlob = (data: Blob, filename: string) => {
const url = URL.createObjectURL(data);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
};
export const downloadArchive = async (
fileName: string,
assets: AssetResponseDto[],
options: Omit<AssetApiGetDownloadInfoRequest, 'key'>,
onDone?: () => void,
key?: string
) {
const assetIds = assets.map((asset) => asset.id);
) => {
let downloadInfo: DownloadResponseDto | null = null;
try {
// let skip = 0;
let count = 0;
let done = false;
const { data } = await api.assetApi.getDownloadInfo({ ...options, key });
downloadInfo = data;
} catch (error) {
handleError(error, 'Unable to download files');
return;
}
while (!done) {
count++;
// TODO: prompt for big download
// const total = downloadInfo.totalSize;
const downloadFileName = fileName + `${count === 1 ? '' : count}.zip`;
downloadAssets.set({ [downloadFileName]: 0 });
for (let i = 0; i < downloadInfo.archives.length; i++) {
const archive = downloadInfo.archives[i];
const suffix = downloadInfo.archives.length === 1 ? '' : `+${i + 1}`;
const archiveName = fileName.replace('.zip', `${suffix}.zip`);
let total = 0;
let downloadKey = `${archiveName}`;
if (downloadInfo.archives.length > 1) {
downloadKey = `${archiveName} (${i + 1}/${downloadInfo.archives.length})`;
}
const { data, status, headers } = await api.assetApi.downloadFiles(
{ downloadFilesDto: { assetIds }, key },
updateDownload(downloadKey, 0);
try {
const { data } = await api.assetApi.downloadArchive(
{ assetIdsDto: { assetIds: archive.assetIds }, key },
{
responseType: 'blob',
onDownloadProgress: function (progressEvent) {
const request = this as XMLHttpRequest;
if (!total) {
total = Number(request.getResponseHeader('X-Immich-Content-Length-Hint')) || 0;
}
onDownloadProgress: (event) =>
updateDownload(downloadKey, Math.floor((event.loaded / archive.size) * 100))
}
);
if (total) {
const current = progressEvent.loaded;
downloadAssets.set({ [downloadFileName]: Math.floor((current / total) * 100) });
downloadBlob(data, archiveName);
} catch (e) {
handleError(e, 'Unable to download files');
clearDownload(downloadKey);
return;
} finally {
setTimeout(() => clearDownload(downloadKey), 3_000);
}
}
onDone?.();
};
export const downloadFile = async (asset: AssetResponseDto, key?: string) => {
const filenames = [`${asset.originalFileName}.${getFilenameExtension(asset.originalPath)}`];
if (asset.livePhotoVideoId) {
filenames.push(`${asset.originalFileName}.mov`);
}
for (const filename of filenames) {
try {
updateDownload(filename, 0);
const { data } = await api.assetApi.downloadFile(
{ id: asset.id, key },
{
responseType: 'blob',
onDownloadProgress: (event: ProgressEvent) => {
if (event.lengthComputable) {
updateDownload(filename, Math.floor((event.loaded / event.total) * 100));
}
}
}
);
const isNotComplete = headers['x-immich-archive-complete'] === 'false';
const fileCount = Number(headers['x-immich-archive-file-count']) || 0;
if (isNotComplete && fileCount > 0) {
// skip += fileCount;
} else {
onDone?.();
done = true;
}
if (!(data instanceof Blob)) {
return;
}
if (status === 201) {
const fileUrl = URL.createObjectURL(data);
const anchor = document.createElement('a');
anchor.href = fileUrl;
anchor.download = downloadFileName;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(fileUrl);
// Remove item from download list
setTimeout(() => {
downloadAssets.set({});
}, 2000);
}
downloadBlob(data, filename);
} catch (e) {
handleError(e, `Error downloading ${filename}`);
} finally {
setTimeout(() => clearDownload(filename), 3_000);
}
} catch (e) {
console.error('Error downloading file ', e);
notificationController.show({
type: NotificationType.Error,
message: 'Error downloading file, check console for more details.'
});
}
}
};
/**
* Returns the lowercase filename extension without a dot (.) and

View File

@ -4,10 +4,20 @@ import {
NotificationType
} from '../components/shared-components/notification/notification';
export function handleError(error: unknown, message: string) {
export async function handleError(error: unknown, message: string) {
console.error(`[handleError]: ${message}`, error);
let serverMessage = (error as ApiError)?.response?.data?.message;
let data = (error as ApiError)?.response?.data;
if (data instanceof Blob) {
const response = await data.text();
try {
data = JSON.parse(response);
} catch {
data = { message: response };
}
}
let serverMessage = data?.message;
if (serverMessage) {
serverMessage = `${String(serverMessage).slice(0, 75)}\n(Immich Server Error)`;
}

View File

@ -67,7 +67,7 @@
</AssetSelectContextMenu>
<DeleteAssets {onAssetDelete} />
<AssetSelectContextMenu icon={DotsVertical} title="Add">
<DownloadAction menuItem />
<DownloadAction menuItem filename="{data.person.name || 'immich'}.zip" />
<FavoriteAction menuItem removeFavorite={isAllFavorite} />
<ArchiveAction
menuItem