mirror of
https://github.com/immich-app/immich.git
synced 2024-12-29 15:11:58 +00: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:
parent
df9c05bef3
commit
ad343b7b32
53 changed files with 985 additions and 976 deletions
9
mobile/openapi/.openapi-generator/FILES
generated
9
mobile/openapi/.openapi-generator/FILES
generated
|
@ -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
BIN
mobile/openapi/README.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/AlbumApi.md
generated
BIN
mobile/openapi/doc/AlbumApi.md
generated
Binary file not shown.
BIN
mobile/openapi/doc/AssetApi.md
generated
BIN
mobile/openapi/doc/AssetApi.md
generated
Binary file not shown.
Binary file not shown.
BIN
mobile/openapi/doc/DownloadResponseDto.md
generated
Normal file
BIN
mobile/openapi/doc/DownloadResponseDto.md
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/api.dart
generated
BIN
mobile/openapi/lib/api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api/album_api.dart
generated
BIN
mobile/openapi/lib/api/album_api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api/asset_api.dart
generated
BIN
mobile/openapi/lib/api/asset_api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api_client.dart
generated
BIN
mobile/openapi/lib/api_client.dart
generated
Binary file not shown.
Binary file not shown.
BIN
mobile/openapi/lib/model/download_response_dto.dart
generated
Normal file
BIN
mobile/openapi/lib/model/download_response_dto.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/test/album_api_test.dart
generated
BIN
mobile/openapi/test/album_api_test.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/asset_api_test.dart
generated
BIN
mobile/openapi/test/asset_api_test.dart
generated
Binary file not shown.
Binary file not shown.
BIN
mobile/openapi/test/download_response_dto_test.dart
generated
Normal file
BIN
mobile/openapi/test/download_response_dto_test.dart
generated
Normal file
Binary file not shown.
|
@ -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": {
|
||||
|
|
|
@ -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));
|
||||
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
31
server/src/domain/asset/dto/download.dto.ts
Normal file
31
server/src/domain/asset/dto/download.dto.ts
Normal 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[];
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
export * from './asset-ids.dto';
|
||||
export * from './download.dto';
|
||||
export * from './map-marker.dto';
|
||||
export * from './memory-lane.dto';
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 }],
|
||||
})
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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 }],
|
||||
})
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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[];
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { DownloadService } from './download.service';
|
||||
|
||||
@Module({
|
||||
providers: [DownloadService],
|
||||
exports: [DownloadService],
|
||||
})
|
||||
export class DownloadModule {}
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
}
|
|
@ -140,7 +140,9 @@ export class AccessRepository implements IAccessRepository {
|
|||
return this.albumRepository.exist({
|
||||
where: {
|
||||
id: albumId,
|
||||
ownerId: userId,
|
||||
sharedUsers: {
|
||||
id: userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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>({
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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),
|
||||
|
|
551
web/src/api/open-api/api.ts
generated
551
web/src/api/open-api/api.ts
generated
|
@ -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.
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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)}
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)`;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue