mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user