refactor(server): download assets (#3032)

* refactor: download assets

* chore: open api

* chore: finish tests, make size configurable

* chore: defualt to 4GiB

* chore: open api

* fix: optional archive size

* fix: bugs

* chore: cleanup
This commit is contained in:
Jason Rasmussen
2023-06-30 12:24:28 -04:00
committed by GitHub
parent df9c05bef3
commit ad343b7b32
53 changed files with 1455 additions and 1403 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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