feat(server): visibility column (#17939)

* feat: private view

* pr feedback

* sql generation

* feat: visibility column

* fix: set visibility value as the same as the still part after unlinked live photos

* fix: test

* pr feedback
This commit is contained in:
Alex
2025-05-06 12:12:48 -05:00
committed by GitHub
parent 016d7a6ceb
commit d33ce13561
90 changed files with 1137 additions and 867 deletions

View File

@@ -9,7 +9,7 @@ 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 { AssetFileType, AssetStatus, AssetType, CacheControl, JobName } from 'src/enum';
import { AssetFileType, AssetStatus, AssetType, AssetVisibility, CacheControl, JobName } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard';
import { AssetMediaService } from 'src/services/asset-media.service';
import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';
@@ -142,7 +142,6 @@ const createDto = Object.freeze({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
isFavorite: false,
isArchived: false,
duration: '0:00:00.000000',
}) as AssetMediaCreateDto;
@@ -164,7 +163,6 @@ const assetEntity = Object.freeze({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
updatedAt: new Date('2022-06-19T23:41:36.910Z'),
isFavorite: false,
isArchived: false,
encodedVideoPath: '',
duration: '0:00:00.000000',
files: [] as AssetFile[],
@@ -437,7 +435,10 @@ describe(AssetMediaService.name, () => {
});
it('should hide the linked motion asset', async () => {
mocks.asset.getById.mockResolvedValueOnce({ ...assetStub.livePhotoMotionAsset, isVisible: true });
mocks.asset.getById.mockResolvedValueOnce({
...assetStub.livePhotoMotionAsset,
visibility: AssetVisibility.TIMELINE,
});
mocks.asset.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
await expect(
@@ -452,7 +453,10 @@ describe(AssetMediaService.name, () => {
});
expect(mocks.asset.getById).toHaveBeenCalledWith('live-photo-motion-asset');
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'live-photo-motion-asset', isVisible: false });
expect(mocks.asset.update).toHaveBeenCalledWith({
id: 'live-photo-motion-asset',
visibility: AssetVisibility.HIDDEN,
});
});
it('should handle a sidecar file', async () => {

View File

@@ -21,7 +21,7 @@ import {
UploadFieldName,
} from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetStatus, AssetType, CacheControl, JobName, Permission, StorageFolder } from 'src/enum';
import { AssetStatus, AssetType, AssetVisibility, CacheControl, JobName, Permission, StorageFolder } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard';
import { BaseService } from 'src/services/base.service';
import { UploadFile } from 'src/types';
@@ -146,7 +146,6 @@ export class AssetMediaService extends BaseService {
{ userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId },
);
}
const asset = await this.create(auth.user.id, dto, file, sidecarFile);
await this.userRepository.updateUsage(auth.user.id, file.size);
@@ -416,9 +415,8 @@ export class AssetMediaService extends BaseService {
type: mimeTypes.assetType(file.originalPath),
isFavorite: dto.isFavorite,
isArchived: dto.isArchived ?? false,
duration: dto.duration || null,
isVisible: dto.isVisible ?? true,
visibility: dto.visibility ?? AssetVisibility.TIMELINE,
livePhotoVideoId: dto.livePhotoVideoId,
originalFileName: file.originalName,
sidecarPath: sidecarFile?.originalPath,

View File

@@ -2,7 +2,7 @@ import { BadRequestException } from '@nestjs/common';
import { DateTime } from 'luxon';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto';
import { AssetStatus, AssetType, JobName, JobStatus } from 'src/enum';
import { AssetStatus, AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
import { AssetStats } from 'src/repositories/asset.repository';
import { AssetService } from 'src/services/asset.service';
import { assetStub } from 'test/fixtures/asset.stub';
@@ -46,14 +46,22 @@ describe(AssetService.name, () => {
describe('getStatistics', () => {
it('should get the statistics for a user, excluding archived assets', async () => {
mocks.asset.getStatistics.mockResolvedValue(stats);
await expect(sut.getStatistics(authStub.admin, { isArchived: false })).resolves.toEqual(statResponse);
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isArchived: false });
await expect(sut.getStatistics(authStub.admin, { visibility: AssetVisibility.TIMELINE })).resolves.toEqual(
statResponse,
);
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, {
visibility: AssetVisibility.TIMELINE,
});
});
it('should get the statistics for a user for archived assets', async () => {
mocks.asset.getStatistics.mockResolvedValue(stats);
await expect(sut.getStatistics(authStub.admin, { isArchived: true })).resolves.toEqual(statResponse);
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isArchived: true });
await expect(sut.getStatistics(authStub.admin, { visibility: AssetVisibility.ARCHIVE })).resolves.toEqual(
statResponse,
);
expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, {
visibility: AssetVisibility.ARCHIVE,
});
});
it('should get the statistics for a user for favorite assets', async () => {
@@ -192,9 +200,9 @@ describe(AssetService.name, () => {
describe('update', () => {
it('should require asset write access for the id', async () => {
await expect(sut.update(authStub.admin, 'asset-1', { isArchived: false })).rejects.toBeInstanceOf(
BadRequestException,
);
await expect(
sut.update(authStub.admin, 'asset-1', { visibility: AssetVisibility.TIMELINE }),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.asset.update).not.toHaveBeenCalled();
});
@@ -242,7 +250,10 @@ describe(AssetService.name, () => {
id: assetStub.livePhotoStillAsset.id,
livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
});
expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true });
expect(mocks.asset.update).not.toHaveBeenCalledWith({
id: assetStub.livePhotoMotionAsset.id,
visibility: AssetVisibility.TIMELINE,
});
expect(mocks.event.emit).not.toHaveBeenCalledWith('asset.show', {
assetId: assetStub.livePhotoMotionAsset.id,
userId: userStub.admin.id,
@@ -263,7 +274,10 @@ describe(AssetService.name, () => {
id: assetStub.livePhotoStillAsset.id,
livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
});
expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true });
expect(mocks.asset.update).not.toHaveBeenCalledWith({
id: assetStub.livePhotoMotionAsset.id,
visibility: AssetVisibility.TIMELINE,
});
expect(mocks.event.emit).not.toHaveBeenCalledWith('asset.show', {
assetId: assetStub.livePhotoMotionAsset.id,
userId: userStub.admin.id,
@@ -284,7 +298,10 @@ describe(AssetService.name, () => {
id: assetStub.livePhotoStillAsset.id,
livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
});
expect(mocks.asset.update).not.toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true });
expect(mocks.asset.update).not.toHaveBeenCalledWith({
id: assetStub.livePhotoMotionAsset.id,
visibility: AssetVisibility.TIMELINE,
});
expect(mocks.event.emit).not.toHaveBeenCalledWith('asset.show', {
assetId: assetStub.livePhotoMotionAsset.id,
userId: userStub.admin.id,
@@ -296,7 +313,7 @@ describe(AssetService.name, () => {
mocks.asset.getById.mockResolvedValueOnce({
...assetStub.livePhotoMotionAsset,
ownerId: authStub.admin.user.id,
isVisible: true,
visibility: AssetVisibility.TIMELINE,
});
mocks.asset.getById.mockResolvedValueOnce(assetStub.image);
mocks.asset.update.mockResolvedValue(assetStub.image);
@@ -305,7 +322,10 @@ describe(AssetService.name, () => {
livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
});
expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false });
expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.livePhotoMotionAsset.id,
visibility: AssetVisibility.HIDDEN,
});
expect(mocks.event.emit).toHaveBeenCalledWith('asset.hide', {
assetId: assetStub.livePhotoMotionAsset.id,
userId: userStub.admin.id,
@@ -335,7 +355,10 @@ describe(AssetService.name, () => {
id: assetStub.livePhotoStillAsset.id,
livePhotoVideoId: null,
});
expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: true });
expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.livePhotoMotionAsset.id,
visibility: assetStub.livePhotoStillAsset.visibility,
});
expect(mocks.event.emit).toHaveBeenCalledWith('asset.show', {
assetId: assetStub.livePhotoMotionAsset.id,
userId: userStub.admin.id,
@@ -361,7 +384,6 @@ describe(AssetService.name, () => {
await expect(
sut.updateAll(authStub.admin, {
ids: ['asset-1'],
isArchived: false,
}),
).rejects.toBeInstanceOf(BadRequestException);
});
@@ -369,9 +391,11 @@ describe(AssetService.name, () => {
it('should update all assets', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2']));
await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], isArchived: true });
await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], visibility: AssetVisibility.ARCHIVE });
expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { isArchived: true });
expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], {
visibility: AssetVisibility.ARCHIVE,
});
});
it('should not update Assets table if no relevant fields are provided', async () => {
@@ -381,7 +405,6 @@ describe(AssetService.name, () => {
ids: ['asset-1'],
latitude: 0,
longitude: 0,
isArchived: undefined,
isFavorite: undefined,
duplicateId: undefined,
rating: undefined,
@@ -389,14 +412,14 @@ describe(AssetService.name, () => {
expect(mocks.asset.updateAll).not.toHaveBeenCalled();
});
it('should update Assets table if isArchived field is provided', async () => {
it('should update Assets table if visibility field is provided', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
await sut.updateAll(authStub.admin, {
ids: ['asset-1'],
latitude: 0,
longitude: 0,
isArchived: undefined,
visibility: undefined,
isFavorite: false,
duplicateId: undefined,
rating: undefined,
@@ -416,7 +439,6 @@ describe(AssetService.name, () => {
latitude: 30,
longitude: 50,
dateTimeOriginal,
isArchived: undefined,
isFavorite: false,
duplicateId: undefined,
rating: undefined,
@@ -439,7 +461,6 @@ describe(AssetService.name, () => {
ids: ['asset-1'],
latitude: 0,
longitude: 0,
isArchived: undefined,
isFavorite: undefined,
duplicateId: null,
rating: undefined,

View File

@@ -92,8 +92,12 @@ export class AssetService extends BaseService {
const asset = await this.assetRepository.update({ id, ...rest });
if (previousMotion) {
await onAfterUnlink(repos, { userId: auth.user.id, livePhotoVideoId: previousMotion.id });
if (previousMotion && asset) {
await onAfterUnlink(repos, {
userId: auth.user.id,
livePhotoVideoId: previousMotion.id,
visibility: asset.visibility,
});
}
if (!asset) {
@@ -115,7 +119,7 @@ export class AssetService extends BaseService {
}
if (
options.isArchived !== undefined ||
options.visibility !== undefined ||
options.isFavorite !== undefined ||
options.duplicateId !== undefined ||
options.rating !== undefined

View File

@@ -1,4 +1,4 @@
import { AssetFileType, AssetType, JobName, JobStatus } from 'src/enum';
import { AssetFileType, AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
import { DuplicateService } from 'src/services/duplicate.service';
import { SearchService } from 'src/services/search.service';
import { assetStub } from 'test/fixtures/asset.stub';
@@ -22,11 +22,11 @@ const hasEmbedding = {
updateId: 'update-1',
},
],
isVisible: true,
stackId: null,
type: AssetType.IMAGE,
duplicateId: null,
embedding: '[1, 2, 3, 4]',
visibility: AssetVisibility.TIMELINE,
};
const hasDupe = {
@@ -207,7 +207,10 @@ describe(SearchService.name, () => {
it('should skip if asset is not visible', async () => {
const id = assetStub.livePhotoMotionAsset.id;
mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, isVisible: false });
mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({
...hasEmbedding,
visibility: AssetVisibility.HIDDEN,
});
const result = await sut.handleSearchDuplicates({ id });

View File

@@ -4,7 +4,7 @@ import { OnJob } from 'src/decorators';
import { mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { DuplicateResponseDto } from 'src/dtos/duplicate.dto';
import { AssetFileType, JobName, JobStatus, QueueName } from 'src/enum';
import { AssetFileType, AssetVisibility, JobName, JobStatus, QueueName } from 'src/enum';
import { AssetDuplicateResult } from 'src/repositories/search.repository';
import { BaseService } from 'src/services/base.service';
import { JobItem, JobOf } from 'src/types';
@@ -65,7 +65,7 @@ export class DuplicateService extends BaseService {
return JobStatus.SKIPPED;
}
if (!asset.isVisible) {
if (asset.visibility == AssetVisibility.HIDDEN) {
this.logger.debug(`Asset ${id} is not visible, skipping`);
return JobStatus.SKIPPED;
}

View File

@@ -6,6 +6,7 @@ import { mapAsset } from 'src/dtos/asset-response.dto';
import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto';
import {
AssetType,
AssetVisibility,
BootstrapEventPriority,
ImmichWorker,
JobCommand,
@@ -301,7 +302,7 @@ export class JobService extends BaseService {
}
await this.jobRepository.queueAll(jobs);
if (asset.isVisible) {
if (asset.visibility === AssetVisibility.TIMELINE || asset.visibility === AssetVisibility.ARCHIVE) {
this.eventRepository.clientSend('on_upload_success', asset.ownerId, mapAsset(asset));
}

View File

@@ -8,6 +8,7 @@ import {
AssetFileType,
AssetPathType,
AssetType,
AssetVisibility,
AudioCodec,
Colorspace,
JobName,
@@ -152,7 +153,7 @@ export class MediaService extends BaseService {
return JobStatus.FAILED;
}
if (!asset.isVisible) {
if (asset.visibility === AssetVisibility.HIDDEN) {
this.logger.verbose(`Thumbnail generation skipped for asset ${id}: not visible`);
return JobStatus.SKIPPED;
}

View File

@@ -4,7 +4,7 @@ import { Stats } from 'node:fs';
import { constants } from 'node:fs/promises';
import { defaults } from 'src/config';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetType, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum';
import { AssetType, AssetVisibility, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum';
import { ImmichTags } from 'src/repositories/metadata.repository';
import { MetadataService } from 'src/services/metadata.service';
import { assetStub } from 'test/fixtures/asset.stub';
@@ -504,7 +504,10 @@ describe(MetadataService.name, () => {
});
it('should not apply motion photos if asset is video', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ ...assetStub.livePhotoMotionAsset, isVisible: true });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({
...assetStub.livePhotoMotionAsset,
visibility: AssetVisibility.TIMELINE,
});
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id });
@@ -513,7 +516,7 @@ describe(MetadataService.name, () => {
expect(mocks.job.queue).not.toHaveBeenCalled();
expect(mocks.job.queueAll).not.toHaveBeenCalled();
expect(mocks.asset.update).not.toHaveBeenCalledWith(
expect.objectContaining({ assetType: AssetType.VIDEO, isVisible: false }),
expect.objectContaining({ assetType: AssetType.VIDEO, visibility: AssetVisibility.HIDDEN }),
);
});
@@ -580,7 +583,7 @@ describe(MetadataService.name, () => {
fileCreatedAt: assetStub.livePhotoWithOriginalFileName.fileCreatedAt,
fileModifiedAt: assetStub.livePhotoWithOriginalFileName.fileModifiedAt,
id: fileStub.livePhotoMotion.uuid,
isVisible: false,
visibility: AssetVisibility.HIDDEN,
libraryId: assetStub.livePhotoWithOriginalFileName.libraryId,
localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt,
originalFileName: 'asset_1.mp4',
@@ -638,7 +641,7 @@ describe(MetadataService.name, () => {
fileCreatedAt: assetStub.livePhotoWithOriginalFileName.fileCreatedAt,
fileModifiedAt: assetStub.livePhotoWithOriginalFileName.fileModifiedAt,
id: fileStub.livePhotoMotion.uuid,
isVisible: false,
visibility: AssetVisibility.HIDDEN,
libraryId: assetStub.livePhotoWithOriginalFileName.libraryId,
localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt,
originalFileName: 'asset_1.mp4',
@@ -696,7 +699,7 @@ describe(MetadataService.name, () => {
fileCreatedAt: assetStub.livePhotoWithOriginalFileName.fileCreatedAt,
fileModifiedAt: assetStub.livePhotoWithOriginalFileName.fileModifiedAt,
id: fileStub.livePhotoMotion.uuid,
isVisible: false,
visibility: AssetVisibility.HIDDEN,
libraryId: assetStub.livePhotoWithOriginalFileName.libraryId,
localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt,
originalFileName: 'asset_1.mp4',
@@ -773,14 +776,17 @@ describe(MetadataService.name, () => {
MicroVideoOffset: 1,
});
mocks.crypto.hashSha1.mockReturnValue(randomBytes(512));
mocks.asset.getByChecksum.mockResolvedValue({ ...assetStub.livePhotoMotionAsset, isVisible: true });
mocks.asset.getByChecksum.mockResolvedValue({
...assetStub.livePhotoMotionAsset,
visibility: AssetVisibility.TIMELINE,
});
const video = randomBytes(512);
mocks.storage.readFile.mockResolvedValue(video);
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.livePhotoMotionAsset.id,
isVisible: false,
visibility: AssetVisibility.HIDDEN,
});
expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.livePhotoStillAsset.id,
@@ -1301,7 +1307,9 @@ describe(MetadataService.name, () => {
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
expect(mocks.asset.findLivePhotoMatch).not.toHaveBeenCalled();
expect(mocks.asset.update).not.toHaveBeenCalledWith(expect.objectContaining({ isVisible: false }));
expect(mocks.asset.update).not.toHaveBeenCalledWith(
expect.objectContaining({ visibility: AssetVisibility.HIDDEN }),
);
expect(mocks.album.removeAsset).not.toHaveBeenCalled();
});
@@ -1320,7 +1328,9 @@ describe(MetadataService.name, () => {
libraryId: null,
type: AssetType.IMAGE,
});
expect(mocks.asset.update).not.toHaveBeenCalledWith(expect.objectContaining({ isVisible: false }));
expect(mocks.asset.update).not.toHaveBeenCalledWith(
expect.objectContaining({ visibility: AssetVisibility.HIDDEN }),
);
expect(mocks.album.removeAsset).not.toHaveBeenCalled();
});
@@ -1342,7 +1352,10 @@ describe(MetadataService.name, () => {
id: assetStub.livePhotoStillAsset.id,
livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
});
expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoMotionAsset.id, isVisible: false });
expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.livePhotoMotionAsset.id,
visibility: AssetVisibility.HIDDEN,
});
expect(mocks.album.removeAsset).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id);
});

View File

@@ -14,6 +14,7 @@ import { AssetFaces, Exif, Person } from 'src/db';
import { OnEvent, OnJob } from 'src/decorators';
import {
AssetType,
AssetVisibility,
DatabaseLock,
ExifOrientation,
ImmichWorker,
@@ -156,7 +157,7 @@ export class MetadataService extends BaseService {
const [photoAsset, motionAsset] = asset.type === AssetType.IMAGE ? [asset, match] : [match, asset];
await Promise.all([
this.assetRepository.update({ id: photoAsset.id, livePhotoVideoId: motionAsset.id }),
this.assetRepository.update({ id: motionAsset.id, isVisible: false }),
this.assetRepository.update({ id: motionAsset.id, visibility: AssetVisibility.HIDDEN }),
this.albumRepository.removeAsset(motionAsset.id),
]);
@@ -527,8 +528,11 @@ export class MetadataService extends BaseService {
});
// Hide the motion photo video asset if it's not already hidden to prepare for linking
if (motionAsset.isVisible) {
await this.assetRepository.update({ id: motionAsset.id, isVisible: false });
if (motionAsset.visibility === AssetVisibility.TIMELINE) {
await this.assetRepository.update({
id: motionAsset.id,
visibility: AssetVisibility.HIDDEN,
});
this.logger.log(`Hid unlinked motion photo video asset (${motionAsset.id})`);
}
} else {
@@ -544,7 +548,7 @@ export class MetadataService extends BaseService {
ownerId: asset.ownerId,
originalPath: StorageCore.getAndroidMotionPath(asset, motionAssetId),
originalFileName: `${path.parse(asset.originalFileName).name}.mp4`,
isVisible: false,
visibility: AssetVisibility.HIDDEN,
deviceAssetId: 'NONE',
deviceId: 'NONE',
});
@@ -863,7 +867,7 @@ export class MetadataService extends BaseService {
return JobStatus.FAILED;
}
if (!isSync && (!asset.isVisible || asset.sidecarPath) && !asset.isExternal) {
if (!isSync && (asset.visibility === AssetVisibility.HIDDEN || asset.sidecarPath) && !asset.isExternal) {
return JobStatus.FAILED;
}

View File

@@ -26,6 +26,7 @@ import {
} from 'src/dtos/person.dto';
import {
AssetType,
AssetVisibility,
CacheControl,
ImageFormat,
JobName,
@@ -296,7 +297,7 @@ export class PersonService extends BaseService {
return JobStatus.FAILED;
}
if (!asset.isVisible) {
if (asset.visibility === AssetVisibility.HIDDEN) {
return JobStatus.SKIPPED;
}
@@ -484,7 +485,9 @@ export class PersonService extends BaseService {
this.logger.debug(`Face ${id} has ${matches.length} matches`);
const isCore = matches.length >= machineLearning.facialRecognition.minFaces && !face.asset.isArchived;
const isCore =
matches.length >= machineLearning.facialRecognition.minFaces &&
face.asset.visibility === AssetVisibility.TIMELINE;
if (!isCore && !deferred) {
this.logger.debug(`Deferring non-core face ${id} for later processing`);
await this.jobRepository.queue({ name: JobName.FACIAL_RECOGNITION, data: { id, deferred: true } });

View File

@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { SystemConfig } from 'src/config';
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { OnEvent, OnJob } from 'src/decorators';
import { DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum';
import { AssetVisibility, DatabaseLock, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service';
import { JobItem, JobOf } from 'src/types';
@@ -104,7 +104,7 @@ export class SmartInfoService extends BaseService {
return JobStatus.FAILED;
}
if (!asset.isVisible) {
if (asset.visibility === AssetVisibility.HIDDEN) {
return JobStatus.SKIPPED;
}

View File

@@ -14,7 +14,7 @@ import {
SyncAckSetDto,
SyncStreamDto,
} from 'src/dtos/sync.dto';
import { DatabaseAction, EntityType, Permission, SyncEntityType, SyncRequestType } from 'src/enum';
import { AssetVisibility, DatabaseAction, EntityType, Permission, SyncEntityType, SyncRequestType } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { SyncAck } from 'src/types';
import { getMyPartnerIds } from 'src/utils/asset.util';
@@ -262,7 +262,10 @@ export class SyncService extends BaseService {
needsFullSync: false,
upserted: upserted
// do not return archived assets for partner users
.filter((a) => a.ownerId === auth.user.id || (a.ownerId !== auth.user.id && !a.isArchived))
.filter(
(a) =>
a.ownerId === auth.user.id || (a.ownerId !== auth.user.id && a.visibility === AssetVisibility.TIMELINE),
)
.map((a) =>
mapAsset(a, {
auth,

View File

@@ -1,4 +1,5 @@
import { BadRequestException } from '@nestjs/common';
import { AssetVisibility } from 'src/enum';
import { TimeBucketSize } from 'src/repositories/asset.repository';
import { TimelineService } from 'src/services/timeline.service';
import { assetStub } from 'test/fixtures/asset.stub';
@@ -54,7 +55,7 @@ describe(TimelineService.name, () => {
sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isArchived: true,
visibility: AssetVisibility.ARCHIVE,
userId: authStub.admin.user.id,
}),
).resolves.toEqual(expect.arrayContaining([expect.objectContaining({ id: 'asset-id' })]));
@@ -63,7 +64,7 @@ describe(TimelineService.name, () => {
expect.objectContaining({
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isArchived: true,
visibility: AssetVisibility.ARCHIVE,
userIds: [authStub.admin.user.id],
}),
);
@@ -77,7 +78,7 @@ describe(TimelineService.name, () => {
sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isArchived: false,
visibility: AssetVisibility.TIMELINE,
userId: authStub.admin.user.id,
withPartners: true,
}),
@@ -85,7 +86,7 @@ describe(TimelineService.name, () => {
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isArchived: false,
visibility: AssetVisibility.TIMELINE,
withPartners: true,
userIds: [authStub.admin.user.id],
});
@@ -120,7 +121,7 @@ describe(TimelineService.name, () => {
const buckets = await sut.getTimeBucket(auth, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isArchived: true,
visibility: AssetVisibility.ARCHIVE,
albumId: 'album-id',
});
@@ -129,7 +130,7 @@ describe(TimelineService.name, () => {
expect(mocks.asset.getTimeBucket).toHaveBeenCalledWith('bucket', {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isArchived: true,
visibility: AssetVisibility.ARCHIVE,
albumId: 'album-id',
});
});
@@ -154,12 +155,12 @@ describe(TimelineService.name, () => {
);
});
it('should throw an error if withParners is true and isArchived true or undefined', async () => {
it('should throw an error if withParners is true and visibility true or undefined', async () => {
await expect(
sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isArchived: true,
visibility: AssetVisibility.ARCHIVE,
withPartners: true,
userId: authStub.admin.user.id,
}),
@@ -169,7 +170,7 @@ describe(TimelineService.name, () => {
sut.getTimeBucket(authStub.admin, {
size: TimeBucketSize.DAY,
timeBucket: 'bucket',
isArchived: undefined,
visibility: undefined,
withPartners: true,
userId: authStub.admin.user.id,
}),

View File

@@ -2,7 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common';
import { AssetResponseDto, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { TimeBucketAssetDto, TimeBucketDto, TimeBucketResponseDto } from 'src/dtos/time-bucket.dto';
import { Permission } from 'src/enum';
import { AssetVisibility, Permission } from 'src/enum';
import { TimeBucketOptions } from 'src/repositories/asset.repository';
import { BaseService } from 'src/services/base.service';
import { getMyPartnerIds } from 'src/utils/asset.util';
@@ -55,7 +55,7 @@ export class TimelineService extends BaseService {
if (dto.userId) {
await this.requireAccess({ auth, permission: Permission.TIMELINE_READ, ids: [dto.userId] });
if (dto.isArchived !== false) {
if (dto.visibility === AssetVisibility.ARCHIVE) {
await this.requireAccess({ auth, permission: Permission.ARCHIVE_READ, ids: [dto.userId] });
}
}
@@ -65,7 +65,7 @@ export class TimelineService extends BaseService {
}
if (dto.withPartners) {
const requestedArchived = dto.isArchived === true || dto.isArchived === undefined;
const requestedArchived = dto.visibility === AssetVisibility.ARCHIVE || dto.visibility === undefined;
const requestedFavorite = dto.isFavorite === true || dto.isFavorite === false;
const requestedTrash = dto.isTrashed === true;