refactor: remove album entity, update types (#17450)

This commit is contained in:
Daniel Dietzler
2025-04-18 23:10:34 +02:00
committed by GitHub
parent 854ea13d6a
commit 52ae06c119
44 changed files with 473 additions and 396 deletions

View File

@@ -6,15 +6,15 @@ import {
AlbumStatisticsResponseDto,
CreateAlbumDto,
GetAlbumsDto,
UpdateAlbumDto,
UpdateAlbumUserDto,
mapAlbum,
MapAlbumDto,
mapAlbumWithAssets,
mapAlbumWithoutAssets,
UpdateAlbumDto,
UpdateAlbumUserDto,
} from 'src/dtos/album.dto';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AlbumEntity } from 'src/entities/album.entity';
import { Permission } from 'src/enum';
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
import { BaseService } from 'src/services/base.service';
@@ -39,7 +39,7 @@ export class AlbumService extends BaseService {
async getAll({ user: { id: ownerId } }: AuthDto, { assetId, shared }: GetAlbumsDto): Promise<AlbumResponseDto[]> {
await this.albumRepository.updateThumbnails();
let albums: AlbumEntity[];
let albums: MapAlbumDto[];
if (assetId) {
albums = await this.albumRepository.getByAssetId(ownerId, assetId);
} else if (shared === true) {

View File

@@ -8,6 +8,7 @@ import { Stats } from 'node:fs';
import { AssetFile } from 'src/database';
import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto';
import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity';
import { AssetFileType, AssetStatus, AssetType, CacheControl, JobName } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard';
@@ -173,7 +174,7 @@ const assetEntity = Object.freeze({
},
livePhotoVideoId: null,
sidecarPath: null,
}) as AssetEntity;
} as MapAsset);
const existingAsset = Object.freeze({
...assetEntity,
@@ -182,18 +183,18 @@ const existingAsset = Object.freeze({
checksum: Buffer.from('_getExistingAsset', 'utf8'),
libraryId: 'libraryId',
originalFileName: 'existing-filename.jpeg',
}) as AssetEntity;
}) as MapAsset;
const sidecarAsset = Object.freeze({
...existingAsset,
sidecarPath: 'sidecar-path',
checksum: Buffer.from('_getExistingAssetWithSideCar', 'utf8'),
}) as AssetEntity;
}) as MapAsset;
const copiedAsset = Object.freeze({
id: 'copied-asset',
originalPath: 'copied-path',
}) as AssetEntity;
}) as MapAsset;
describe(AssetMediaService.name, () => {
let sut: AssetMediaService;

View File

@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, InternalServerErrorException, NotFound
import { extname } from 'node:path';
import sanitize from 'sanitize-filename';
import { StorageCore } from 'src/cores/storage.core';
import { Asset } from 'src/database';
import {
AssetBulkUploadCheckResponseDto,
AssetMediaResponseDto,
@@ -20,7 +21,7 @@ import {
UploadFieldName,
} from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity';
import { ASSET_CHECKSUM_CONSTRAINT } from 'src/entities/asset.entity';
import { AssetStatus, AssetType, CacheControl, JobName, Permission, StorageFolder } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard';
import { BaseService } from 'src/services/base.service';
@@ -212,7 +213,7 @@ export class AssetMediaService extends BaseService {
const asset = await this.findOrFail(id);
const size = dto.size ?? AssetMediaSize.THUMBNAIL;
const { thumbnailFile, previewFile, fullsizeFile } = getAssetFiles(asset.files);
const { thumbnailFile, previewFile, fullsizeFile } = getAssetFiles(asset.files ?? []);
let filepath = previewFile?.path;
if (size === AssetMediaSize.THUMBNAIL && thumbnailFile) {
filepath = thumbnailFile.path;
@@ -375,7 +376,7 @@ export class AssetMediaService extends BaseService {
* Uses only vital properties excluding things like: stacks, faces, smart search info, etc,
* and then queues a METADATA_EXTRACTION job.
*/
private async createCopy(asset: AssetEntity): Promise<AssetEntity> {
private async createCopy(asset: Omit<Asset, 'id'>) {
const created = await this.assetRepository.create({
ownerId: asset.ownerId,
originalPath: asset.originalPath,
@@ -398,12 +399,7 @@ export class AssetMediaService extends BaseService {
return created;
}
private async create(
ownerId: string,
dto: AssetMediaCreateDto,
file: UploadFile,
sidecarFile?: UploadFile,
): Promise<AssetEntity> {
private async create(ownerId: string, dto: AssetMediaCreateDto, file: UploadFile, sidecarFile?: UploadFile) {
const asset = await this.assetRepository.create({
ownerId,
libraryId: null,
@@ -444,7 +440,7 @@ export class AssetMediaService extends BaseService {
}
}
private async findOrFail(id: string): Promise<AssetEntity> {
private async findOrFail(id: string) {
const asset = await this.assetRepository.getById(id, { files: true });
if (!asset) {
throw new NotFoundException('Asset not found');

View File

@@ -1,8 +1,7 @@
import { BadRequestException } from '@nestjs/common';
import { DateTime } from 'luxon';
import { mapAsset } from 'src/dtos/asset-response.dto';
import { MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetStatus, AssetType, JobName, JobStatus } from 'src/enum';
import { AssetStats } from 'src/repositories/asset.repository';
import { AssetService } from 'src/services/asset.service';
@@ -35,7 +34,7 @@ describe(AssetService.name, () => {
expect(sut).toBeDefined();
});
const mockGetById = (assets: AssetEntity[]) => {
const mockGetById = (assets: MapAsset[]) => {
mocks.asset.getById.mockImplementation((assetId) => Promise.resolve(assets.find((asset) => asset.id === assetId)));
};
@@ -608,7 +607,7 @@ describe(AssetService.name, () => {
mocks.asset.getById.mockResolvedValue({
...assetStub.primaryImage,
stack: { ...assetStub.primaryImage.stack, assets: assetStub.primaryImage.stack!.assets.slice(0, 2) },
} as AssetEntity);
});
await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true });

View File

@@ -5,6 +5,7 @@ import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { OnJob } from 'src/decorators';
import {
AssetResponseDto,
MapAsset,
MemoryLaneResponseDto,
SanitizedAssetResponseDto,
mapAsset,
@@ -20,7 +21,6 @@ import {
} from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { MemoryLaneDto } from 'src/dtos/search.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetStatus, JobName, JobStatus, Permission, QueueName } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { ISidecarWriteJob, JobItem, JobOf } from 'src/types';
@@ -43,7 +43,7 @@ export class AssetService extends BaseService {
yearsAgo,
// TODO move this to clients
title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`,
assets: assets.map((asset) => mapAsset(asset as unknown as AssetEntity, { auth })),
assets: assets.map((asset) => mapAsset(asset, { auth })),
};
});
}
@@ -105,7 +105,7 @@ export class AssetService extends BaseService {
const { description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto;
const repos = { asset: this.assetRepository, event: this.eventRepository };
let previousMotion: AssetEntity | null = null;
let previousMotion: MapAsset | null = null;
if (rest.livePhotoVideoId) {
await onBeforeLink(repos, { userId: auth.user.id, livePhotoVideoId: rest.livePhotoVideoId });
} else if (rest.livePhotoVideoId === null) {
@@ -233,7 +233,7 @@ export class AssetService extends BaseService {
}
}
const { fullsizeFile, previewFile, thumbnailFile } = getAssetFiles(asset.files);
const { fullsizeFile, previewFile, thumbnailFile } = getAssetFiles(asset.files ?? []);
const files = [thumbnailFile?.path, previewFile?.path, fullsizeFile?.path, asset.encodedVideoPath];
if (deleteOnDisk) {

View File

@@ -68,7 +68,7 @@ export class DuplicateService extends BaseService {
return JobStatus.SKIPPED;
}
const previewFile = getAssetFile(asset.files, AssetFileType.PREVIEW);
const previewFile = getAssetFile(asset.files || [], AssetFileType.PREVIEW);
if (!previewFile) {
this.logger.warn(`Asset ${id} is missing preview image`);
return JobStatus.FAILED;

View File

@@ -285,9 +285,9 @@ describe(JobService.name, () => {
it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => {
if (item.name === JobName.GENERATE_THUMBNAILS && item.data.source === 'upload') {
if (item.data.id === 'asset-live-image') {
mocks.asset.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoStillAsset]);
mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([assetStub.livePhotoStillAsset as any]);
} else {
mocks.asset.getByIdsWithAllRelations.mockResolvedValue([assetStub.livePhotoMotionAsset]);
mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([assetStub.livePhotoMotionAsset as any]);
}
}

View File

@@ -254,7 +254,7 @@ export class JobService extends BaseService {
case JobName.METADATA_EXTRACTION: {
if (item.data.source === 'sidecar-write') {
const [asset] = await this.assetRepository.getByIdsWithAllRelations([item.data.id]);
const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([item.data.id]);
if (asset) {
this.eventRepository.clientSend('on_asset_update', asset.ownerId, mapAsset(asset));
}
@@ -284,7 +284,7 @@ export class JobService extends BaseService {
break;
}
const [asset] = await this.assetRepository.getByIdsWithAllRelations([item.data.id]);
const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([item.data.id]);
if (!asset) {
this.logger.warn(`Could not find asset ${item.data.id} after generating thumbnails`);
break;

View File

@@ -350,7 +350,7 @@ describe(LibraryService.name, () => {
progressCounter: 0,
};
mocks.asset.getByIds.mockResolvedValue([assetStub.external]);
mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.external]);
mocks.storage.stat.mockRejectedValue(new Error('ENOENT, no such file or directory'));
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
@@ -371,7 +371,7 @@ describe(LibraryService.name, () => {
progressCounter: 0,
};
mocks.asset.getByIds.mockResolvedValue([assetStub.external]);
mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.external]);
mocks.storage.stat.mockRejectedValue(new Error('Could not read file'));
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
@@ -392,7 +392,7 @@ describe(LibraryService.name, () => {
progressCounter: 0,
};
mocks.asset.getByIds.mockResolvedValue([assetStub.trashedOffline]);
mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]);
mocks.storage.stat.mockRejectedValue(new Error('Could not read file'));
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
@@ -410,7 +410,7 @@ describe(LibraryService.name, () => {
progressCounter: 0,
};
mocks.asset.getByIds.mockResolvedValue([assetStub.trashedOffline]);
mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]);
mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats);
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
@@ -431,7 +431,7 @@ describe(LibraryService.name, () => {
progressCounter: 0,
};
mocks.asset.getByIds.mockResolvedValue([assetStub.trashedOffline]);
mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]);
mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats);
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
@@ -451,7 +451,7 @@ describe(LibraryService.name, () => {
progressCounter: 0,
};
mocks.asset.getByIds.mockResolvedValue([assetStub.trashedOffline]);
mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]);
mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats);
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
@@ -471,7 +471,7 @@ describe(LibraryService.name, () => {
progressCounter: 0,
};
mocks.asset.getByIds.mockResolvedValue([assetStub.external]);
mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.external]);
mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats);
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
@@ -489,7 +489,7 @@ describe(LibraryService.name, () => {
progressCounter: 0,
};
mocks.asset.getByIds.mockResolvedValue([assetStub.trashedOffline]);
mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]);
mocks.storage.stat.mockResolvedValue({ mtime: assetStub.trashedOffline.fileModifiedAt } as Stats);
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);
@@ -518,7 +518,7 @@ describe(LibraryService.name, () => {
const mtime = new Date(assetStub.external.fileModifiedAt.getDate() + 1);
mocks.asset.getByIds.mockResolvedValue([assetStub.external]);
mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.external]);
mocks.storage.stat.mockResolvedValue({ mtime } as Stats);
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.SUCCESS);

View File

@@ -18,7 +18,6 @@ import {
ValidateLibraryImportPathResponseDto,
ValidateLibraryResponseDto,
} from 'src/dtos/library.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetStatus, AssetType, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { AssetSyncResult } from 'src/repositories/library.repository';
@@ -467,7 +466,7 @@ export class LibraryService extends BaseService {
@OnJob({ name: JobName.LIBRARY_SYNC_ASSETS, queue: QueueName.LIBRARY })
async handleSyncAssets(job: JobOf<JobName.LIBRARY_SYNC_ASSETS>): Promise<JobStatus> {
const assets = await this.assetRepository.getByIds(job.assetIds);
const assets = await this.assetJobRepository.getForSyncAssets(job.assetIds);
const assetIdsToOffline: string[] = [];
const trashedAssetIdsToOffline: string[] = [];
@@ -561,7 +560,16 @@ export class LibraryService extends BaseService {
return JobStatus.SUCCESS;
}
private checkExistingAsset(asset: AssetEntity, stat: Stats | null): AssetSyncResult {
private checkExistingAsset(
asset: {
isOffline: boolean;
libraryId: string | null;
originalPath: string;
status: AssetStatus;
fileModifiedAt: Date;
},
stat: Stats | null,
): AssetSyncResult {
if (!stat) {
// File not found on disk or permission error
if (asset.isOffline) {

View File

@@ -3,7 +3,7 @@ import { randomBytes } from 'node:crypto';
import { Stats } from 'node:fs';
import { constants } from 'node:fs/promises';
import { defaults } from 'src/config';
import { AssetEntity } from 'src/entities/asset.entity';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetType, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum';
import { WithoutProperty } from 'src/repositories/asset.repository';
import { ImmichTags } from 'src/repositories/metadata.repository';
@@ -549,7 +549,6 @@ describe(MetadataService.name, () => {
livePhotoVideoId: null,
libraryId: null,
});
mocks.asset.getByIds.mockResolvedValue([{ ...assetStub.livePhotoWithOriginalFileName, livePhotoVideoId: null }]);
mocks.storage.stat.mockResolvedValue({
size: 123_456,
mtime: assetStub.livePhotoWithOriginalFileName.fileModifiedAt,
@@ -719,7 +718,7 @@ describe(MetadataService.name, () => {
});
mocks.crypto.hashSha1.mockReturnValue(randomBytes(512));
mocks.asset.create.mockImplementation(
(asset) => Promise.resolve({ ...assetStub.livePhotoMotionAsset, ...asset }) as Promise<AssetEntity>,
(asset) => Promise.resolve({ ...assetStub.livePhotoMotionAsset, ...asset }) as Promise<MapAsset>,
);
const video = randomBytes(512);
mocks.storage.readFile.mockResolvedValue(video);
@@ -1394,7 +1393,7 @@ describe(MetadataService.name, () => {
});
it('should set sidecar path if exists (sidecar named photo.xmp)', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.sidecarWithoutExt]);
mocks.asset.getByIds.mockResolvedValue([assetStub.sidecarWithoutExt as any]);
mocks.storage.checkFileExists.mockResolvedValueOnce(false);
mocks.storage.checkFileExists.mockResolvedValueOnce(true);
@@ -1446,7 +1445,7 @@ describe(MetadataService.name, () => {
describe('handleSidecarDiscovery', () => {
it('should skip hidden assets', async () => {
mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset]);
mocks.asset.getByIds.mockResolvedValue([assetStub.livePhotoMotionAsset as any]);
await sut.handleSidecarDiscovery({ id: assetStub.livePhotoMotionAsset.id });
expect(mocks.storage.checkFileExists).not.toHaveBeenCalled();
});

View File

@@ -271,7 +271,7 @@ export class MetadataService extends BaseService {
];
if (this.isMotionPhoto(asset, exifTags)) {
promises.push(this.applyMotionPhotos(asset as unknown as Asset, exifTags, dates, stats));
promises.push(this.applyMotionPhotos(asset, exifTags, dates, stats));
}
if (isFaceImportEnabled(metadata) && this.hasTaggedFaces(exifTags)) {

View File

@@ -2,7 +2,8 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { Insertable, Updateable } from 'kysely';
import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { AssetFaces, FaceSearch, Person } from 'src/db';
import { Person } from 'src/database';
import { AssetFaces, FaceSearch } from 'src/db';
import { Chunked, OnJob } from 'src/decorators';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -315,6 +316,7 @@ export class PersonService extends BaseService {
const facesToAdd: (Insertable<AssetFaces> & { id: string })[] = [];
const embeddings: FaceSearch[] = [];
const mlFaceIds = new Set<string>();
for (const face of asset.faces) {
if (face.sourceType === SourceType.MACHINE_LEARNING) {
mlFaceIds.add(face.id);
@@ -477,7 +479,7 @@ export class PersonService extends BaseService {
embedding: face.faceSearch.embedding,
maxDistance: machineLearning.facialRecognition.maxDistance,
numResults: machineLearning.facialRecognition.minFaces,
minBirthDate: face.asset.fileCreatedAt,
minBirthDate: face.asset.fileCreatedAt ?? undefined,
});
// `matches` also includes the face itself
@@ -503,7 +505,7 @@ export class PersonService extends BaseService {
maxDistance: machineLearning.facialRecognition.maxDistance,
numResults: 1,
hasPerson: true,
minBirthDate: face.asset.fileCreatedAt,
minBirthDate: face.asset.fileCreatedAt ?? undefined,
});
if (matchWithPerson.length > 0) {

View File

@@ -45,7 +45,7 @@ describe(SearchService.name, () => {
fieldName: 'exifInfo.city',
items: [{ value: 'test-city', data: assetStub.withLocation.id }],
});
mocks.asset.getByIdsWithAllRelations.mockResolvedValue([assetStub.withLocation]);
mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([assetStub.withLocation]);
const expectedResponse = [
{ fieldName: 'exifInfo.city', items: [{ value: 'test-city', data: mapAsset(assetStub.withLocation) }] },
];

View File

@@ -1,5 +1,5 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { AssetMapOptions, AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AssetMapOptions, AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { mapPerson, PersonResponseDto } from 'src/dtos/person.dto';
import {
@@ -14,7 +14,6 @@ import {
SearchSuggestionType,
SmartSearchDto,
} from 'src/dtos/search.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetOrder } from 'src/enum';
import { SearchExploreItem } from 'src/repositories/search.repository';
import { BaseService } from 'src/services/base.service';
@@ -36,7 +35,7 @@ export class SearchService extends BaseService {
async getExploreData(auth: AuthDto): Promise<SearchExploreItem<AssetResponseDto>[]> {
const options = { maxFields: 12, minAssetsPerField: 5 };
const cities = await this.assetRepository.getAssetIdByCity(auth.user.id, options);
const assets = await this.assetRepository.getByIdsWithAllRelations(cities.items.map(({ data }) => data));
const assets = await this.assetRepository.getByIdsWithAllRelationsButStacks(cities.items.map(({ data }) => data));
const items = assets.map((asset) => ({ value: asset.exifInfo!.city!, data: mapAsset(asset, { auth }) }));
return [{ fieldName: cities.fieldName, items }];
}
@@ -139,7 +138,7 @@ export class SearchService extends BaseService {
return [auth.user.id, ...partnerIds];
}
private mapResponse(assets: AssetEntity[], nextPage: string | null, options: AssetMapOptions): SearchResponseDto {
private mapResponse(assets: MapAsset[], nextPage: string | null, options: AssetMapOptions): SearchResponseDto {
return {
albums: { total: 0, count: 0, items: [], facets: [] },
assets: {

View File

@@ -244,7 +244,7 @@ describe(SharedLinkService.name, () => {
await sut.remove(authStub.user1, sharedLinkStub.valid.id);
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id);
expect(mocks.sharedLink.remove).toHaveBeenCalledWith(sharedLinkStub.valid);
expect(mocks.sharedLink.remove).toHaveBeenCalledWith(sharedLinkStub.valid.id);
});
});
@@ -333,8 +333,7 @@ describe(SharedLinkService.name, () => {
});
it('should return metadata tags with a default image path if the asset id is not set', async () => {
mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, album: undefined, assets: [] });
mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, album: null, assets: [] });
await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({
description: '0 shared photos & videos',
imageUrl: `https://my.immich.app/feature-panel.png`,

View File

@@ -1,4 +1,5 @@
import { BadRequestException, ForbiddenException, Injectable, UnauthorizedException } from '@nestjs/common';
import { SharedLink } from 'src/database';
import { AssetIdErrorReason, AssetIdsResponseDto } from 'src/dtos/asset-ids.response.dto';
import { AssetIdsDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -11,7 +12,6 @@ import {
SharedLinkResponseDto,
SharedLinkSearchDto,
} from 'src/dtos/shared-link.dto';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { Permission, SharedLinkType } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { getExternalDomain, OpenGraphTags } from 'src/utils/misc';
@@ -98,7 +98,7 @@ export class SharedLinkService extends BaseService {
async remove(auth: AuthDto, id: string): Promise<void> {
const sharedLink = await this.findOrFail(auth.user.id, id);
await this.sharedLinkRepository.remove(sharedLink);
await this.sharedLinkRepository.remove(sharedLink.id);
}
// TODO: replace `userId` with permissions and access control checks
@@ -182,7 +182,7 @@ export class SharedLinkService extends BaseService {
const config = await this.getConfig({ withCache: true });
const sharedLink = await this.findOrFail(auth.sharedLink.userId, auth.sharedLink.id);
const assetId = sharedLink.album?.albumThumbnailAssetId || sharedLink.assets[0]?.id;
const assetCount = sharedLink.assets.length > 0 ? sharedLink.assets.length : sharedLink.album?.assets.length || 0;
const assetCount = sharedLink.assets.length > 0 ? sharedLink.assets.length : sharedLink.album?.assets?.length || 0;
const imagePath = assetId
? `/api/assets/${assetId}/thumbnail?key=${sharedLink.key.toString('base64url')}`
: '/feature-panel.png';
@@ -194,11 +194,11 @@ export class SharedLinkService extends BaseService {
};
}
private mapToSharedLink(sharedLink: SharedLinkEntity, { withExif }: { withExif: boolean }) {
private mapToSharedLink(sharedLink: SharedLink, { withExif }: { withExif: boolean }) {
return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithoutMetadata(sharedLink);
}
private validateAndRefreshToken(sharedLink: SharedLinkEntity, dto: SharedLinkPasswordDto): string {
private validateAndRefreshToken(sharedLink: SharedLink, dto: SharedLinkPasswordDto): string {
const token = this.cryptoRepository.hashSha256(`${sharedLink.id}-${sharedLink.password}`);
const sharedLinkTokens = dto.token?.split(',') || [];
if (sharedLink.password !== dto.password && !sharedLinkTokens.includes(token)) {

View File

@@ -1,5 +1,4 @@
import { mapAsset } from 'src/dtos/asset-response.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { SyncService } from 'src/services/sync.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
@@ -63,7 +62,7 @@ describe(SyncService.name, () => {
it('should return a response requiring a full sync when there are too many changes', async () => {
mocks.partner.getAll.mockResolvedValue([]);
mocks.asset.getChangedDeltaSync.mockResolvedValue(
Array.from<AssetEntity>({ length: 10_000 }).fill(assetStub.image),
Array.from<typeof assetStub.image>({ length: 10_000 }).fill(assetStub.image),
);
await expect(
sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }),

View File

@@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { BaseService } from 'src/services/base.service';
@Injectable()
@@ -12,6 +11,6 @@ export class ViewService extends BaseService {
async getAssetsByOriginalPath(auth: AuthDto, path: string): Promise<AssetResponseDto[]> {
const assets = await this.viewRepository.getAssetsByOriginalPath(auth.user.id, path);
return assets.map((asset) => mapAsset(asset as unknown as AssetEntity, { auth }));
return assets.map((asset) => mapAsset(asset, { auth }));
}
}