refactor(server): move files to separate table (#11861)

This commit is contained in:
Jason Rasmussen
2024-08-19 20:03:33 -04:00
committed by GitHub
parent af3a793fe8
commit 7af6733665
32 changed files with 403 additions and 210 deletions

View File

@@ -2,6 +2,7 @@ import { BadRequestException, NotFoundException, UnauthorizedException } from '@
import { Stats } from 'node:fs';
import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto';
import { AssetMediaCreateDto, AssetMediaReplaceDto, UploadFieldName } from 'src/dtos/asset-media.dto';
import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity';
import { AssetType } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface';
@@ -150,15 +151,14 @@ const assetEntity = Object.freeze({
deviceId: 'device_id_1',
type: AssetType.VIDEO,
originalPath: 'fake_path/asset_1.jpeg',
previewPath: '',
fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
updatedAt: new Date('2022-06-19T23:41:36.910Z'),
isFavorite: false,
isArchived: false,
thumbnailPath: '',
encodedVideoPath: '',
duration: '0:00:00.000000',
files: [] as AssetFileEntity[],
exifInfo: {
latitude: 49.533_547,
longitude: 10.703_075,
@@ -418,7 +418,7 @@ describe(AssetMediaService.name, () => {
await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(NotFoundException);
expect(assetMock.getById).toHaveBeenCalledWith('asset-1');
expect(assetMock.getById).toHaveBeenCalledWith('asset-1', { files: true });
});
it('should download a file', async () => {

View File

@@ -36,6 +36,7 @@ import { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { getAssetFiles } from 'src/utils/asset.util';
import { CacheControl, ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types';
import { fromChecksum } from 'src/utils/request';
@@ -238,9 +239,10 @@ export class AssetMediaService {
const asset = await this.findOrFail(id);
const size = dto.size ?? AssetMediaSize.THUMBNAIL;
let filepath = asset.previewPath;
if (size === AssetMediaSize.THUMBNAIL && asset.thumbnailPath) {
filepath = asset.thumbnailPath;
const { thumbnailFile, previewFile } = getAssetFiles(asset.files);
let filepath = previewFile?.path;
if (size === AssetMediaSize.THUMBNAIL && thumbnailFile) {
filepath = thumbnailFile.path;
}
if (!filepath) {
@@ -460,7 +462,7 @@ export class AssetMediaService {
}
private async findOrFail(id: string): Promise<AssetEntity> {
const asset = await this.assetRepository.getById(id);
const asset = await this.assetRepository.getById(id, { files: true });
if (!asset) {
throw new NotFoundException('Asset not found');
}

View File

@@ -299,8 +299,8 @@ describe(AssetService.name, () => {
name: JobName.DELETE_FILES,
data: {
files: [
assetWithFace.thumbnailPath,
assetWithFace.previewPath,
'/uploads/user-id/webp/path.ext',
'/uploads/user-id/thumbs/path.jpg',
assetWithFace.encodedVideoPath,
assetWithFace.sidecarPath,
assetWithFace.originalPath,

View File

@@ -39,7 +39,7 @@ import { IPartnerRepository } from 'src/interfaces/partner.interface';
import { IStackRepository } from 'src/interfaces/stack.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { getMyPartnerIds } from 'src/utils/asset.util';
import { getAssetFiles, getMyPartnerIds } from 'src/utils/asset.util';
import { usePagination } from 'src/utils/pagination';
export class AssetService {
@@ -71,9 +71,10 @@ export class AssetService {
const userIds = [auth.user.id, ...partnerIds];
const assets = await this.assetRepository.getByDayOfYear(userIds, dto);
const assetsWithThumbnails = assets.filter(({ files }) => !!getAssetFiles(files).thumbnailFile);
const groups: Record<number, AssetEntity[]> = {};
const currentYear = new Date().getFullYear();
for (const asset of assets) {
for (const asset of assetsWithThumbnails) {
const yearsAgo = currentYear - asset.localDateTime.getFullYear();
if (!groups[yearsAgo]) {
groups[yearsAgo] = [];
@@ -126,6 +127,7 @@ export class AssetService {
exifInfo: true,
},
},
files: true,
},
{
faces: {
@@ -170,6 +172,7 @@ export class AssetService {
faces: {
person: true,
},
files: true,
});
if (!asset) {
throw new BadRequestException('Asset not found');
@@ -223,6 +226,7 @@ export class AssetService {
library: true,
stack: { assets: true },
exifInfo: true,
files: true,
});
if (!asset) {
@@ -260,7 +264,8 @@ export class AssetService {
}
}
const files = [asset.thumbnailPath, asset.previewPath, asset.encodedVideoPath];
const { thumbnailFile, previewFile } = getAssetFiles(asset.files);
const files = [thumbnailFile?.path, previewFile?.path, asset.encodedVideoPath];
if (deleteOnDisk) {
files.push(asset.sidecarPath, asset.originalPath);
}

View File

@@ -14,7 +14,7 @@ import {
} from 'src/dtos/audit.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetPathType, PersonPathType, UserPathType } from 'src/entities/move.entity';
import { DatabaseAction, Permission } from 'src/enum';
import { AssetFileType, DatabaseAction, Permission } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IAuditRepository } from 'src/interfaces/audit.interface';
@@ -24,6 +24,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { getAssetFiles } from 'src/utils/asset.util';
import { usePagination } from 'src/utils/pagination';
@Injectable()
@@ -97,12 +98,12 @@ export class AuditService {
}
case AssetPathType.PREVIEW: {
await this.assetRepository.update({ id, previewPath: pathValue });
await this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.PREVIEW, path: pathValue });
break;
}
case AssetPathType.THUMBNAIL: {
await this.assetRepository.update({ id, thumbnailPath: pathValue });
await this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.THUMBNAIL, path: pathValue });
break;
}
@@ -155,7 +156,7 @@ export class AuditService {
}
}
const track = (filename: string | null) => {
const track = (filename: string | null | undefined) => {
if (!filename) {
return;
}
@@ -175,8 +176,9 @@ export class AuditService {
const orphans: FileReportItemDto[] = [];
for await (const assets of pagination) {
assetCount += assets.length;
for (const { id, originalPath, previewPath, encodedVideoPath, thumbnailPath, isExternal, checksum } of assets) {
for (const file of [originalPath, previewPath, encodedVideoPath, thumbnailPath]) {
for (const { id, files, originalPath, encodedVideoPath, isExternal, checksum } of assets) {
const { previewFile, thumbnailFile } = getAssetFiles(files);
for (const file of [originalPath, previewFile?.path, encodedVideoPath, thumbnailFile?.path]) {
track(file);
}
@@ -192,11 +194,11 @@ export class AuditService {
) {
orphans.push({ ...entity, pathType: AssetPathType.ORIGINAL, pathValue: originalPath });
}
if (previewPath && !hasFile(thumbFiles, previewPath)) {
orphans.push({ ...entity, pathType: AssetPathType.PREVIEW, pathValue: previewPath });
if (previewFile && !hasFile(thumbFiles, previewFile.path)) {
orphans.push({ ...entity, pathType: AssetPathType.PREVIEW, pathValue: previewFile.path });
}
if (thumbnailPath && !hasFile(thumbFiles, thumbnailPath)) {
orphans.push({ ...entity, pathType: AssetPathType.THUMBNAIL, pathValue: thumbnailPath });
if (thumbnailFile && !hasFile(thumbFiles, thumbnailFile.path)) {
orphans.push({ ...entity, pathType: AssetPathType.THUMBNAIL, pathValue: thumbnailFile.path });
}
if (encodedVideoPath && !hasFile(videoFiles, encodedVideoPath)) {
orphans.push({ ...entity, pathType: AssetPathType.THUMBNAIL, pathValue: encodedVideoPath });

View File

@@ -17,6 +17,7 @@ import {
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { AssetDuplicateResult, ISearchRepository } from 'src/interfaces/search.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { getAssetFiles } from 'src/utils/asset.util';
import { isDuplicateDetectionEnabled } from 'src/utils/misc';
import { usePagination } from 'src/utils/pagination';
@@ -69,7 +70,7 @@ export class DuplicateService {
return JobStatus.SKIPPED;
}
const asset = await this.assetRepository.getById(id, { smartSearch: true });
const asset = await this.assetRepository.getById(id, { files: true, smartSearch: true });
if (!asset) {
this.logger.error(`Asset ${id} not found`);
return JobStatus.FAILED;
@@ -80,7 +81,8 @@ export class DuplicateService {
return JobStatus.SKIPPED;
}
if (!asset.previewPath) {
const { previewFile } = getAssetFiles(asset.files);
if (!previewFile) {
this.logger.warn(`Asset ${id} is missing preview image`);
return JobStatus.FAILED;
}

View File

@@ -9,7 +9,7 @@ import {
VideoCodec,
} from 'src/config';
import { ExifEntity } from 'src/entities/exif.entity';
import { AssetType } from 'src/enum';
import { AssetFileType, AssetType } from 'src/enum';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
@@ -298,18 +298,20 @@ describe(MediaService.name, () => {
colorspace: Colorspace.SRGB,
processInvalidImages: false,
});
expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', previewPath });
expect(assetMock.upsertFile).toHaveBeenCalledWith({
assetId: 'asset-id',
type: AssetFileType.PREVIEW,
path: previewPath,
});
});
it('should delete previous preview if different path', async () => {
const previousPreviewPath = assetStub.image.previewPath;
systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } });
assetMock.getByIds.mockResolvedValue([assetStub.image]);
await sut.handleGeneratePreview({ id: assetStub.image.id });
expect(storageMock.unlink).toHaveBeenCalledWith(previousPreviewPath);
expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.jpg');
});
it('should generate a P3 thumbnail for a wide gamut image', async () => {
@@ -330,9 +332,10 @@ describe(MediaService.name, () => {
processInvalidImages: false,
},
);
expect(assetMock.update).toHaveBeenCalledWith({
id: 'asset-id',
previewPath: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
expect(assetMock.upsertFile).toHaveBeenCalledWith({
assetId: 'asset-id',
type: AssetFileType.PREVIEW,
path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
});
});
@@ -357,9 +360,10 @@ describe(MediaService.name, () => {
twoPass: false,
},
);
expect(assetMock.update).toHaveBeenCalledWith({
id: 'asset-id',
previewPath: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
expect(assetMock.upsertFile).toHaveBeenCalledWith({
assetId: 'asset-id',
type: AssetFileType.PREVIEW,
path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
});
});
@@ -384,9 +388,10 @@ describe(MediaService.name, () => {
twoPass: false,
},
);
expect(assetMock.update).toHaveBeenCalledWith({
id: 'asset-id',
previewPath: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
expect(assetMock.upsertFile).toHaveBeenCalledWith({
assetId: 'asset-id',
type: AssetFileType.PREVIEW,
path: 'upload/thumbs/user-id/as/se/asset-id-preview.jpeg',
});
});
@@ -472,19 +477,21 @@ describe(MediaService.name, () => {
colorspace: Colorspace.SRGB,
processInvalidImages: false,
});
expect(assetMock.update).toHaveBeenCalledWith({ id: 'asset-id', thumbnailPath });
expect(assetMock.upsertFile).toHaveBeenCalledWith({
assetId: 'asset-id',
type: AssetFileType.THUMBNAIL,
path: thumbnailPath,
});
},
);
it('should delete previous thumbnail if different path', async () => {
const previousThumbnailPath = assetStub.image.thumbnailPath;
systemMock.get.mockResolvedValue({ image: { thumbnailFormat: ImageFormat.WEBP } });
assetMock.getByIds.mockResolvedValue([assetStub.image]);
await sut.handleGenerateThumbnail({ id: assetStub.image.id });
expect(storageMock.unlink).toHaveBeenCalledWith(previousThumbnailPath);
expect(storageMock.unlink).toHaveBeenCalledWith('/uploads/user-id/webp/path.ext');
});
});
@@ -504,9 +511,10 @@ describe(MediaService.name, () => {
processInvalidImages: false,
},
);
expect(assetMock.update).toHaveBeenCalledWith({
id: 'asset-id',
thumbnailPath: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
expect(assetMock.upsertFile).toHaveBeenCalledWith({
assetId: 'asset-id',
type: AssetFileType.THUMBNAIL,
path: 'upload/thumbs/user-id/as/se/asset-id-thumbnail.webp',
});
});

View File

@@ -15,7 +15,7 @@ import { SystemConfigCore } from 'src/cores/system-config.core';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetPathType } from 'src/entities/move.entity';
import { AssetType } from 'src/enum';
import { AssetFileType, AssetType } from 'src/enum';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import {
@@ -34,6 +34,7 @@ import { IMoveRepository } from 'src/interfaces/move.interface';
import { IPersonRepository } from 'src/interfaces/person.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { getAssetFiles } from 'src/utils/asset.util';
import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
import { mimeTypes } from 'src/utils/mime-types';
import { usePagination } from 'src/utils/pagination';
@@ -72,7 +73,11 @@ export class MediaService {
async handleQueueGenerateThumbnails({ force }: IBaseJob): Promise<JobStatus> {
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
return force
? this.assetRepository.getAll(pagination, { isVisible: true, withDeleted: true, withArchived: true })
? this.assetRepository.getAll(pagination, {
isVisible: true,
withDeleted: true,
withArchived: true,
})
: this.assetRepository.getWithout(pagination, WithoutProperty.THUMBNAIL);
});
@@ -80,13 +85,17 @@ export class MediaService {
const jobs: JobItem[] = [];
for (const asset of assets) {
if (!asset.previewPath || force) {
const { previewFile, thumbnailFile } = getAssetFiles(asset.files);
if (!previewFile || force) {
jobs.push({ name: JobName.GENERATE_PREVIEW, data: { id: asset.id } });
continue;
}
if (!asset.thumbnailPath) {
if (!thumbnailFile) {
jobs.push({ name: JobName.GENERATE_THUMBNAIL, data: { id: asset.id } });
}
if (!asset.thumbhash) {
jobs.push({ name: JobName.GENERATE_THUMBHASH, data: { id: asset.id } });
}
@@ -152,7 +161,7 @@ export class MediaService {
async handleAssetMigration({ id }: IEntityJob): Promise<JobStatus> {
const { image } = await this.configCore.getConfig({ withCache: true });
const [asset] = await this.assetRepository.getByIds([id]);
const [asset] = await this.assetRepository.getByIds([id], { files: true });
if (!asset) {
return JobStatus.FAILED;
}
@@ -182,12 +191,14 @@ export class MediaService {
return JobStatus.SKIPPED;
}
if (asset.previewPath && asset.previewPath !== previewPath) {
const { previewFile } = getAssetFiles(asset.files);
if (previewFile && previewFile.path !== previewPath) {
this.logger.debug(`Deleting old preview for asset ${asset.id}`);
await this.storageRepository.unlink(asset.previewPath);
await this.storageRepository.unlink(previewFile.path);
}
await this.assetRepository.update({ id: asset.id, previewPath });
await this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.PREVIEW, path: previewPath });
await this.assetRepository.update({ id: asset.id, updatedAt: new Date() });
await this.assetRepository.upsertJobStatus({ assetId: asset.id, previewAt: new Date() });
return JobStatus.SUCCESS;
@@ -253,7 +264,7 @@ export class MediaService {
async handleGenerateThumbnail({ id }: IEntityJob): Promise<JobStatus> {
const [{ image }, [asset]] = await Promise.all([
this.configCore.getConfig({ withCache: true }),
this.assetRepository.getByIds([id], { exifInfo: true }),
this.assetRepository.getByIds([id], { exifInfo: true, files: true }),
]);
if (!asset) {
return JobStatus.FAILED;
@@ -268,19 +279,21 @@ export class MediaService {
return JobStatus.SKIPPED;
}
if (asset.thumbnailPath && asset.thumbnailPath !== thumbnailPath) {
const { thumbnailFile } = getAssetFiles(asset.files);
if (thumbnailFile && thumbnailFile.path !== thumbnailPath) {
this.logger.debug(`Deleting old thumbnail for asset ${asset.id}`);
await this.storageRepository.unlink(asset.thumbnailPath);
await this.storageRepository.unlink(thumbnailFile.path);
}
await this.assetRepository.update({ id: asset.id, thumbnailPath });
await this.assetRepository.upsertFile({ assetId: asset.id, type: AssetFileType.THUMBNAIL, path: thumbnailPath });
await this.assetRepository.update({ id: asset.id, updatedAt: new Date() });
await this.assetRepository.upsertJobStatus({ assetId: asset.id, thumbnailAt: new Date() });
return JobStatus.SUCCESS;
}
async handleGenerateThumbhash({ id }: IEntityJob): Promise<JobStatus> {
const [asset] = await this.assetRepository.getByIds([id]);
const [asset] = await this.assetRepository.getByIds([id], { files: true });
if (!asset) {
return JobStatus.FAILED;
}
@@ -289,11 +302,12 @@ export class MediaService {
return JobStatus.SKIPPED;
}
if (!asset.previewPath) {
const { previewFile } = getAssetFiles(asset.files);
if (!previewFile) {
return JobStatus.FAILED;
}
const thumbhash = await this.mediaRepository.generateThumbhash(asset.previewPath);
const thumbhash = await this.mediaRepository.generateThumbhash(previewFile.path);
await this.assetRepository.update({ id: asset.id, thumbhash });
return JobStatus.SUCCESS;

View File

@@ -1,6 +1,7 @@
import { defaults, SystemConfig } from 'src/config';
import { AlbumUserEntity } from 'src/entities/album-user.entity';
import { UserMetadataKey } from 'src/enum';
import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { AssetFileType, UserMetadataKey } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
@@ -333,7 +334,9 @@ describe(NotificationService.name, () => {
notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' });
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId);
expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, {
files: true,
});
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.SEND_EMAIL,
data: expect.objectContaining({
@@ -358,10 +361,15 @@ describe(NotificationService.name, () => {
});
systemMock.get.mockResolvedValue({ server: {} });
notificationMock.renderEmail.mockResolvedValue({ html: '', text: '' });
assetMock.getById.mockResolvedValue({ ...assetStub.image, thumbnailPath: 'path-to-thumb.jpg' });
assetMock.getById.mockResolvedValue({
...assetStub.image,
files: [{ assetId: 'asset-id', type: AssetFileType.THUMBNAIL, path: 'path-to-thumb.jpg' } as AssetFileEntity],
});
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId);
expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, {
files: true,
});
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.SEND_EMAIL,
data: expect.objectContaining({
@@ -389,7 +397,9 @@ describe(NotificationService.name, () => {
assetMock.getById.mockResolvedValue(assetStub.image);
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.SUCCESS);
expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId);
expect(assetMock.getById).toHaveBeenCalledWith(albumStub.emptyWithValidThumbnail.albumThumbnailAssetId, {
files: true,
});
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.SEND_EMAIL,
data: expect.objectContaining({

View File

@@ -21,6 +21,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { EmailImageAttachment, EmailTemplate, INotificationRepository } from 'src/interfaces/notification.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { getAssetFiles } from 'src/utils/asset.util';
import { getFilenameExtension } from 'src/utils/file';
import { getPreferences } from 'src/utils/preferences';
@@ -268,14 +269,15 @@ export class NotificationService {
return;
}
const albumThumbnail = await this.assetRepository.getById(album.albumThumbnailAssetId);
if (!albumThumbnail?.thumbnailPath) {
const albumThumbnail = await this.assetRepository.getById(album.albumThumbnailAssetId, { files: true });
const { thumbnailFile } = getAssetFiles(albumThumbnail?.files);
if (!thumbnailFile) {
return;
}
return {
filename: `album-thumbnail${getFilenameExtension(albumThumbnail.thumbnailPath)}`,
path: albumThumbnail.thumbnailPath,
filename: `album-thumbnail${getFilenameExtension(thumbnailFile.path)}`,
path: thumbnailFile.path,
cid: 'album-thumbnail',
};
}

View File

@@ -716,7 +716,7 @@ describe(PersonService.name, () => {
await sut.handleDetectFaces({ id: assetStub.image.id });
expect(machineLearningMock.detectFaces).toHaveBeenCalledWith(
'http://immich-machine-learning:3003',
assetStub.image.previewPath,
'/uploads/user-id/thumbs/path.jpg',
expect.objectContaining({ minScore: 0.7, modelName: 'buffalo_l' }),
);
expect(personMock.createFaces).not.toHaveBeenCalled();
@@ -946,7 +946,7 @@ describe(PersonService.name, () => {
await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id });
expect(assetMock.getById).toHaveBeenCalledWith(faceStub.middle.assetId, { exifInfo: true });
expect(assetMock.getById).toHaveBeenCalledWith(faceStub.middle.assetId, { exifInfo: true, files: true });
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/admin_id/pe/rs');
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
assetStub.primaryImage.originalPath,
@@ -1032,7 +1032,7 @@ describe(PersonService.name, () => {
await sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id });
expect(mediaMock.generateThumbnail).toHaveBeenCalledWith(
assetStub.video.previewPath,
'/uploads/user-id/thumbs/path.jpg',
'upload/thumbs/admin_id/pe/rs/person-1.jpeg',
{
format: 'jpeg',

View File

@@ -50,6 +50,7 @@ import { IPersonRepository, UpdateFacesData } from 'src/interfaces/person.interf
import { ISearchRepository } from 'src/interfaces/search.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { getAssetFiles } from 'src/utils/asset.util';
import { CacheControl, ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types';
import { isFacialRecognitionEnabled } from 'src/utils/misc';
@@ -333,9 +334,11 @@ export class PersonService {
faces: {
person: false,
},
files: true,
};
const [asset] = await this.assetRepository.getByIds([id], relations);
if (!asset || !asset.previewPath || asset.faces?.length > 0) {
const { previewFile } = getAssetFiles(asset.files);
if (!asset || !previewFile || asset.faces?.length > 0) {
return JobStatus.FAILED;
}
@@ -349,11 +352,11 @@ export class PersonService {
const { imageHeight, imageWidth, faces } = await this.machineLearningRepository.detectFaces(
machineLearning.url,
asset.previewPath,
previewFile.path,
machineLearning.facialRecognition,
);
this.logger.debug(`${faces.length} faces detected in ${asset.previewPath}`);
this.logger.debug(`${faces.length} faces detected in ${previewFile.path}`);
if (faces.length > 0) {
await this.jobRepository.queue({ name: JobName.QUEUE_FACIAL_RECOGNITION, data: { force: false } });
@@ -549,7 +552,10 @@ export class PersonService {
imageHeight: oldHeight,
} = face;
const asset = await this.assetRepository.getById(assetId, { exifInfo: true });
const asset = await this.assetRepository.getById(assetId, {
exifInfo: true,
files: true,
});
if (!asset) {
this.logger.error(`Could not generate person thumbnail: asset ${assetId} does not exist`);
return JobStatus.FAILED;
@@ -646,7 +652,8 @@ export class PersonService {
throw new Error(`Asset ${asset.id} dimensions are unknown`);
}
if (!asset.previewPath) {
const { previewFile } = getAssetFiles(asset.files);
if (!previewFile) {
throw new Error(`Asset ${asset.id} has no preview path`);
}
@@ -659,8 +666,8 @@ export class PersonService {
return { width, height, inputPath: asset.originalPath };
}
const { width, height } = await this.mediaRepository.getImageDimensions(asset.previewPath);
return { width, height, inputPath: asset.previewPath };
const { width, height } = await this.mediaRepository.getImageDimensions(previewFile.path);
return { width, height, inputPath: previewFile.path };
}
private getCrop(dims: { old: ImageDimensions; new: ImageDimensions }, { x1, y1, x2, y2 }: BoundingBox): CropOptions {

View File

@@ -318,7 +318,7 @@ describe(SmartInfoService.name, () => {
expect(machineMock.encodeImage).toHaveBeenCalledWith(
'http://immich-machine-learning:3003',
assetStub.image.previewPath,
'/uploads/user-id/thumbs/path.jpg',
expect.objectContaining({ modelName: 'ViT-B-32__openai' }),
);
expect(searchMock.upsert).toHaveBeenCalledWith(assetStub.image.id, [0.01, 0.02, 0.03]);

View File

@@ -18,6 +18,7 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { ISearchRepository } from 'src/interfaces/search.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { getAssetFiles } from 'src/utils/asset.util';
import { getCLIPModelInfo, isSmartSearchEnabled } from 'src/utils/misc';
import { usePagination } from 'src/utils/pagination';
@@ -135,7 +136,7 @@ export class SmartInfoService {
return JobStatus.SKIPPED;
}
const [asset] = await this.assetRepository.getByIds([id]);
const [asset] = await this.assetRepository.getByIds([id], { files: true });
if (!asset) {
return JobStatus.FAILED;
}
@@ -144,13 +145,14 @@ export class SmartInfoService {
return JobStatus.SKIPPED;
}
if (!asset.previewPath) {
const { previewFile } = getAssetFiles(asset.files);
if (!previewFile) {
return JobStatus.FAILED;
}
const embedding = await this.machineLearning.encodeImage(
machineLearning.url,
asset.previewPath,
previewFile.path,
machineLearning.clip,
);