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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
90 changed files with 822 additions and 644 deletions

View file

@ -3,6 +3,7 @@ import {
AssetMediaStatus,
AssetResponseDto,
AssetTypeEnum,
AssetVisibility,
getAssetInfo,
getMyUser,
LoginResponseDto,
@ -119,9 +120,9 @@ describe('/asset', () => {
// stats
utils.createAsset(statsUser.accessToken),
utils.createAsset(statsUser.accessToken, { isFavorite: true }),
utils.createAsset(statsUser.accessToken, { isArchived: true }),
utils.createAsset(statsUser.accessToken, { visibility: AssetVisibility.Archive }),
utils.createAsset(statsUser.accessToken, {
isArchived: true,
visibility: AssetVisibility.Archive,
isFavorite: true,
assetData: { filename: 'example.mp4' },
}),
@ -309,7 +310,7 @@ describe('/asset', () => {
});
it('disallows viewing archived assets', async () => {
const asset = await utils.createAsset(user1.accessToken, { isArchived: true });
const asset = await utils.createAsset(user1.accessToken, { visibility: AssetVisibility.Archive });
const { status } = await request(app)
.get(`/assets/${asset.id}`)
@ -353,7 +354,7 @@ describe('/asset', () => {
const { status, body } = await request(app)
.get('/assets/statistics')
.set('Authorization', `Bearer ${statsUser.accessToken}`)
.query({ isArchived: true });
.query({ visibility: AssetVisibility.Archive });
expect(status).toBe(200);
expect(body).toEqual({ images: 1, videos: 1, total: 2 });
@ -363,7 +364,7 @@ describe('/asset', () => {
const { status, body } = await request(app)
.get('/assets/statistics')
.set('Authorization', `Bearer ${statsUser.accessToken}`)
.query({ isFavorite: true, isArchived: true });
.query({ isFavorite: true, visibility: AssetVisibility.Archive });
expect(status).toBe(200);
expect(body).toEqual({ images: 0, videos: 1, total: 1 });
@ -373,7 +374,7 @@ describe('/asset', () => {
const { status, body } = await request(app)
.get('/assets/statistics')
.set('Authorization', `Bearer ${statsUser.accessToken}`)
.query({ isFavorite: false, isArchived: false });
.query({ isFavorite: false, visibility: AssetVisibility.Timeline });
expect(status).toBe(200);
expect(body).toEqual({ images: 1, videos: 0, total: 1 });
@ -459,7 +460,7 @@ describe('/asset', () => {
const { status, body } = await request(app)
.put(`/assets/${user1Assets[0].id}`)
.set('Authorization', `Bearer ${user1.accessToken}`)
.send({ isArchived: true });
.send({ visibility: AssetVisibility.Archive });
expect(body).toMatchObject({ id: user1Assets[0].id, isArchived: true });
expect(status).toEqual(200);
});

View file

@ -1,4 +1,4 @@
import { LoginResponseDto } from '@immich/sdk';
import { AssetVisibility, LoginResponseDto } from '@immich/sdk';
import { readFile } from 'node:fs/promises';
import { basename, join } from 'node:path';
import { Socket } from 'socket.io-client';
@ -44,7 +44,7 @@ describe('/map', () => {
it('should get map markers for all non-archived assets', async () => {
const { status, body } = await request(app)
.get('/map/markers')
.query({ isArchived: false })
.query({ visibility: AssetVisibility.Timeline })
.set('Authorization', `Bearer ${admin.accessToken}`);
expect(status).toBe(200);

View file

@ -1,4 +1,11 @@
import { AssetMediaResponseDto, AssetResponseDto, deleteAssets, LoginResponseDto, updateAsset } from '@immich/sdk';
import {
AssetMediaResponseDto,
AssetResponseDto,
AssetVisibility,
deleteAssets,
LoginResponseDto,
updateAsset,
} from '@immich/sdk';
import { DateTime } from 'luxon';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
@ -49,7 +56,7 @@ describe('/search', () => {
{ filename: '/formats/motionphoto/samsung-one-ui-6.heic' },
{ filename: '/formats/motionphoto/samsung-one-ui-5.jpg' },
{ filename: '/metadata/gps-position/thompson-springs.jpg', dto: { isArchived: true } },
{ filename: '/metadata/gps-position/thompson-springs.jpg', dto: { visibility: AssetVisibility.Archive } },
// used for search suggestions
{ filename: '/formats/png/density_plot.png' },
@ -171,12 +178,12 @@ describe('/search', () => {
deferred: () => ({ dto: { size: 1, isFavorite: false }, assets: [assetLast] }),
},
{
should: 'should search by isArchived (true)',
deferred: () => ({ dto: { isArchived: true }, assets: [assetSprings] }),
should: 'should search by visibility (AssetVisibility.Archive)',
deferred: () => ({ dto: { visibility: AssetVisibility.Archive }, assets: [assetSprings] }),
},
{
should: 'should search by isArchived (false)',
deferred: () => ({ dto: { size: 1, isArchived: false }, assets: [assetLast] }),
should: 'should search by visibility (AssetVisibility.Timeline)',
deferred: () => ({ dto: { size: 1, visibility: AssetVisibility.Timeline }, assets: [assetLast] }),
},
{
should: 'should search by type (image)',
@ -185,7 +192,7 @@ describe('/search', () => {
{
should: 'should search by type (video)',
deferred: () => ({
dto: { type: 'VIDEO' },
dto: { type: 'VIDEO', visibility: AssetVisibility.Hidden },
assets: [
// the three live motion photos
{ id: expect.any(String) },
@ -229,13 +236,6 @@ describe('/search', () => {
should: 'should search by takenAfter (no results)',
deferred: () => ({ dto: { takenAfter: today.plus({ hour: 1 }).toJSDate() }, assets: [] }),
},
// {
// should: 'should search by originalPath',
// deferred: () => ({
// dto: { originalPath: asset1.originalPath },
// assets: [asset1],
// }),
// },
{
should: 'should search by originalFilename',
deferred: () => ({
@ -265,7 +265,7 @@ describe('/search', () => {
deferred: () => ({
dto: {
city: '',
isVisible: true,
visibility: AssetVisibility.Timeline,
includeNull: true,
},
assets: [assetLast],
@ -276,7 +276,7 @@ describe('/search', () => {
deferred: () => ({
dto: {
city: null,
isVisible: true,
visibility: AssetVisibility.Timeline,
includeNull: true,
},
assets: [assetLast],
@ -297,7 +297,7 @@ describe('/search', () => {
deferred: () => ({
dto: {
state: '',
isVisible: true,
visibility: AssetVisibility.Timeline,
withExif: true,
includeNull: true,
},
@ -309,7 +309,7 @@ describe('/search', () => {
deferred: () => ({
dto: {
state: null,
isVisible: true,
visibility: AssetVisibility.Timeline,
includeNull: true,
},
assets: [assetLast, assetNotocactus],
@ -330,7 +330,7 @@ describe('/search', () => {
deferred: () => ({
dto: {
country: '',
isVisible: true,
visibility: AssetVisibility.Timeline,
includeNull: true,
},
assets: [assetLast],
@ -341,7 +341,7 @@ describe('/search', () => {
deferred: () => ({
dto: {
country: null,
isVisible: true,
visibility: AssetVisibility.Timeline,
includeNull: true,
},
assets: [assetLast],

View file

@ -1,4 +1,4 @@
import { AssetMediaResponseDto, LoginResponseDto, SharedLinkType, TimeBucketSize } from '@immich/sdk';
import { AssetMediaResponseDto, AssetVisibility, LoginResponseDto, SharedLinkType, TimeBucketSize } from '@immich/sdk';
import { DateTime } from 'luxon';
import { createUserDto } from 'src/fixtures';
import { errorDto } from 'src/responses';
@ -104,7 +104,7 @@ describe('/timeline', () => {
const req1 = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isArchived: true });
.query({ size: TimeBucketSize.Month, withPartners: true, visibility: AssetVisibility.Archive });
expect(req1.status).toBe(400);
expect(req1.body).toEqual(errorDto.badRequest());
@ -112,7 +112,7 @@ describe('/timeline', () => {
const req2 = await request(app)
.get('/timeline/buckets')
.set('Authorization', `Bearer ${user.accessToken}`)
.query({ size: TimeBucketSize.Month, withPartners: true, isArchived: undefined });
.query({ size: TimeBucketSize.Month, withPartners: true, visibility: undefined });
expect(req2.status).toBe(400);
expect(req2.body).toEqual(errorDto.badRequest());

View file

@ -3,6 +3,7 @@ import {
AssetMediaCreateDto,
AssetMediaResponseDto,
AssetResponseDto,
AssetVisibility,
CheckExistingAssetsDto,
CreateAlbumDto,
CreateLibraryDto,
@ -429,7 +430,10 @@ export const utils = {
},
archiveAssets: (accessToken: string, ids: string[]) =>
updateAssets({ assetBulkUpdateDto: { ids, isArchived: true } }, { headers: asBearerAuth(accessToken) }),
updateAssets(
{ assetBulkUpdateDto: { ids, visibility: AssetVisibility.Archive } },
{ headers: asBearerAuth(accessToken) },
),
deleteAssets: (accessToken: string, ids: string[]) =>
deleteAssets({ assetBulkDeleteDto: { ids } }, { headers: asBearerAuth(accessToken) }),

View file

@ -197,7 +197,7 @@ class AssetService {
ids: assets.map((e) => e.remoteId!).toList(),
dateTimeOriginal: updateAssetDto.dateTimeOriginal,
isFavorite: updateAssetDto.isFavorite,
isArchived: updateAssetDto.isArchived,
visibility: updateAssetDto.visibility,
latitude: updateAssetDto.latitude,
longitude: updateAssetDto.longitude,
),
@ -229,7 +229,13 @@ class AssetService {
bool isArchived,
) async {
try {
await updateAssets(assets, UpdateAssetDto(isArchived: isArchived));
await updateAssets(
assets,
UpdateAssetDto(
visibility:
isArchived ? AssetVisibility.archive : AssetVisibility.timeline,
),
);
for (var element in assets) {
element.isArchived = isArchived;

View file

@ -68,7 +68,9 @@ class SearchService {
model: filter.camera.model,
takenAfter: filter.date.takenAfter,
takenBefore: filter.date.takenBefore,
isArchived: filter.display.isArchive ? true : null,
visibility: filter.display.isArchive
? AssetVisibility.archive
: AssetVisibility.timeline,
isFavorite: filter.display.isFavorite ? true : null,
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
personIds: filter.people.map((e) => e.id).toList(),
@ -95,7 +97,9 @@ class SearchService {
model: filter.camera.model,
takenAfter: filter.date.takenAfter,
takenBefore: filter.date.takenBefore,
isArchived: filter.display.isArchive ? true : null,
visibility: filter.display.isArchive
? AssetVisibility.archive
: AssetVisibility.timeline,
isFavorite: filter.display.isFavorite ? true : null,
isNotInAlbum: filter.display.isNotInAlbum ? true : null,
personIds: filter.people.map((e) => e.id).toList(),

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1781,14 +1781,6 @@
"get": {
"operationId": "getAssetStatistics",
"parameters": [
{
"name": "isArchived",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isFavorite",
"required": false,
@ -1804,6 +1796,14 @@
"schema": {
"type": "boolean"
}
},
{
"name": "visibility",
"required": false,
"in": "query",
"schema": {
"$ref": "#/components/schemas/AssetVisibility"
}
}
],
"responses": {
@ -6909,14 +6909,6 @@
"type": "string"
}
},
{
"name": "isArchived",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isFavorite",
"required": false,
@ -6992,6 +6984,14 @@
"type": "string"
}
},
{
"name": "visibility",
"required": false,
"in": "query",
"schema": {
"$ref": "#/components/schemas/AssetVisibility"
}
},
{
"name": "withPartners",
"required": false,
@ -7053,14 +7053,6 @@
"type": "string"
}
},
{
"name": "isArchived",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isFavorite",
"required": false,
@ -7128,6 +7120,14 @@
"type": "string"
}
},
{
"name": "visibility",
"required": false,
"in": "query",
"schema": {
"$ref": "#/components/schemas/AssetVisibility"
}
},
{
"name": "withPartners",
"required": false,
@ -8273,9 +8273,6 @@
},
"type": "array"
},
"isArchived": {
"type": "boolean"
},
"isFavorite": {
"type": "boolean"
},
@ -8289,6 +8286,13 @@
"maximum": 5,
"minimum": -1,
"type": "number"
},
"visibility": {
"allOf": [
{
"$ref": "#/components/schemas/AssetVisibility"
}
]
}
},
"required": [
@ -8713,15 +8717,9 @@
"format": "date-time",
"type": "string"
},
"isArchived": {
"type": "boolean"
},
"isFavorite": {
"type": "boolean"
},
"isVisible": {
"type": "boolean"
},
"livePhotoVideoId": {
"format": "uuid",
"type": "string"
@ -8729,6 +8727,13 @@
"sidecarData": {
"format": "binary",
"type": "string"
},
"visibility": {
"allOf": [
{
"$ref": "#/components/schemas/AssetVisibility"
}
]
}
},
"required": [
@ -9009,6 +9014,14 @@
],
"type": "string"
},
"AssetVisibility": {
"enum": [
"archive",
"timeline",
"hidden"
],
"type": "string"
},
"AudioCodec": {
"enum": [
"mp3",
@ -10204,9 +10217,6 @@
"format": "uuid",
"type": "string"
},
"isArchived": {
"type": "boolean"
},
"isEncoded": {
"type": "boolean"
},
@ -10222,9 +10232,6 @@
"isOffline": {
"type": "boolean"
},
"isVisible": {
"type": "boolean"
},
"lensModel": {
"nullable": true,
"type": "string"
@ -10324,9 +10331,12 @@
"format": "date-time",
"type": "string"
},
"withArchived": {
"default": false,
"type": "boolean"
"visibility": {
"allOf": [
{
"$ref": "#/components/schemas/AssetVisibility"
}
]
},
"withDeleted": {
"type": "boolean"
@ -11041,9 +11051,6 @@
"deviceId": {
"type": "string"
},
"isArchived": {
"type": "boolean"
},
"isEncoded": {
"type": "boolean"
},
@ -11059,9 +11066,6 @@
"isOffline": {
"type": "boolean"
},
"isVisible": {
"type": "boolean"
},
"lensModel": {
"nullable": true,
"type": "string"
@ -11137,9 +11141,12 @@
"format": "date-time",
"type": "string"
},
"withArchived": {
"default": false,
"type": "boolean"
"visibility": {
"allOf": [
{
"$ref": "#/components/schemas/AssetVisibility"
}
]
},
"withDeleted": {
"type": "boolean"
@ -11989,9 +11996,6 @@
"deviceId": {
"type": "string"
},
"isArchived": {
"type": "boolean"
},
"isEncoded": {
"type": "boolean"
},
@ -12007,9 +12011,6 @@
"isOffline": {
"type": "boolean"
},
"isVisible": {
"type": "boolean"
},
"language": {
"type": "string"
},
@ -12095,9 +12096,12 @@
"format": "date-time",
"type": "string"
},
"withArchived": {
"default": false,
"type": "boolean"
"visibility": {
"allOf": [
{
"$ref": "#/components/schemas/AssetVisibility"
}
]
},
"withDeleted": {
"type": "boolean"
@ -12381,9 +12385,6 @@
"isFavorite": {
"type": "boolean"
},
"isVisible": {
"type": "boolean"
},
"localDateTime": {
"format": "date-time",
"nullable": true,
@ -12404,6 +12405,14 @@
"OTHER"
],
"type": "string"
},
"visibility": {
"enum": [
"archive",
"timeline",
"hidden"
],
"type": "string"
}
},
"required": [
@ -12413,11 +12422,11 @@
"fileModifiedAt",
"id",
"isFavorite",
"isVisible",
"localDateTime",
"ownerId",
"thumbhash",
"type"
"type",
"visibility"
],
"type": "object"
},
@ -13671,9 +13680,6 @@
"description": {
"type": "string"
},
"isArchived": {
"type": "boolean"
},
"isFavorite": {
"type": "boolean"
},
@ -13692,6 +13698,13 @@
"maximum": 5,
"minimum": -1,
"type": "number"
},
"visibility": {
"allOf": [
{
"$ref": "#/components/schemas/AssetVisibility"
}
]
}
},
"type": "object"

View file

@ -413,11 +413,10 @@ export type AssetMediaCreateDto = {
duration?: string;
fileCreatedAt: string;
fileModifiedAt: string;
isArchived?: boolean;
isFavorite?: boolean;
isVisible?: boolean;
livePhotoVideoId?: string;
sidecarData?: Blob;
visibility?: AssetVisibility;
};
export type AssetMediaResponseDto = {
id: string;
@ -427,11 +426,11 @@ export type AssetBulkUpdateDto = {
dateTimeOriginal?: string;
duplicateId?: string | null;
ids: string[];
isArchived?: boolean;
isFavorite?: boolean;
latitude?: number;
longitude?: number;
rating?: number;
visibility?: AssetVisibility;
};
export type AssetBulkUploadCheckItem = {
/** base64 or hex encoded sha1 hash */
@ -470,12 +469,12 @@ export type AssetStatsResponseDto = {
export type UpdateAssetDto = {
dateTimeOriginal?: string;
description?: string;
isArchived?: boolean;
isFavorite?: boolean;
latitude?: number;
livePhotoVideoId?: string | null;
longitude?: number;
rating?: number;
visibility?: AssetVisibility;
};
export type AssetMediaReplaceDto = {
assetData: Blob;
@ -815,13 +814,11 @@ export type MetadataSearchDto = {
deviceId?: string;
encodedVideoPath?: string;
id?: string;
isArchived?: boolean;
isEncoded?: boolean;
isFavorite?: boolean;
isMotion?: boolean;
isNotInAlbum?: boolean;
isOffline?: boolean;
isVisible?: boolean;
lensModel?: string | null;
libraryId?: string | null;
make?: string;
@ -844,7 +841,7 @@ export type MetadataSearchDto = {
"type"?: AssetTypeEnum;
updatedAfter?: string;
updatedBefore?: string;
withArchived?: boolean;
visibility?: AssetVisibility;
withDeleted?: boolean;
withExif?: boolean;
withPeople?: boolean;
@ -888,13 +885,11 @@ export type RandomSearchDto = {
createdAfter?: string;
createdBefore?: string;
deviceId?: string;
isArchived?: boolean;
isEncoded?: boolean;
isFavorite?: boolean;
isMotion?: boolean;
isNotInAlbum?: boolean;
isOffline?: boolean;
isVisible?: boolean;
lensModel?: string | null;
libraryId?: string | null;
make?: string;
@ -911,7 +906,7 @@ export type RandomSearchDto = {
"type"?: AssetTypeEnum;
updatedAfter?: string;
updatedBefore?: string;
withArchived?: boolean;
visibility?: AssetVisibility;
withDeleted?: boolean;
withExif?: boolean;
withPeople?: boolean;
@ -923,13 +918,11 @@ export type SmartSearchDto = {
createdAfter?: string;
createdBefore?: string;
deviceId?: string;
isArchived?: boolean;
isEncoded?: boolean;
isFavorite?: boolean;
isMotion?: boolean;
isNotInAlbum?: boolean;
isOffline?: boolean;
isVisible?: boolean;
language?: string;
lensModel?: string | null;
libraryId?: string | null;
@ -949,7 +942,7 @@ export type SmartSearchDto = {
"type"?: AssetTypeEnum;
updatedAfter?: string;
updatedBefore?: string;
withArchived?: boolean;
visibility?: AssetVisibility;
withDeleted?: boolean;
withExif?: boolean;
};
@ -1877,18 +1870,18 @@ export function getRandom({ count }: {
...opts
}));
}
export function getAssetStatistics({ isArchived, isFavorite, isTrashed }: {
isArchived?: boolean;
export function getAssetStatistics({ isFavorite, isTrashed, visibility }: {
isFavorite?: boolean;
isTrashed?: boolean;
visibility?: AssetVisibility;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AssetStatsResponseDto;
}>(`/assets/statistics${QS.query(QS.explode({
isArchived,
isFavorite,
isTrashed
isTrashed,
visibility
}))}`, {
...opts
}));
@ -3242,9 +3235,8 @@ export function tagAssets({ id, bulkIdsDto }: {
body: bulkIdsDto
})));
}
export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, timeBucket, userId, withPartners, withStacked }: {
export function getTimeBucket({ albumId, isFavorite, isTrashed, key, order, personId, size, tagId, timeBucket, userId, visibility, withPartners, withStacked }: {
albumId?: string;
isArchived?: boolean;
isFavorite?: boolean;
isTrashed?: boolean;
key?: string;
@ -3254,6 +3246,7 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key,
tagId?: string;
timeBucket: string;
userId?: string;
visibility?: AssetVisibility;
withPartners?: boolean;
withStacked?: boolean;
}, opts?: Oazapfts.RequestOpts) {
@ -3262,7 +3255,6 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key,
data: AssetResponseDto[];
}>(`/timeline/bucket${QS.query(QS.explode({
albumId,
isArchived,
isFavorite,
isTrashed,
key,
@ -3272,15 +3264,15 @@ export function getTimeBucket({ albumId, isArchived, isFavorite, isTrashed, key,
tagId,
timeBucket,
userId,
visibility,
withPartners,
withStacked
}))}`, {
...opts
}));
}
export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key, order, personId, size, tagId, userId, withPartners, withStacked }: {
export function getTimeBuckets({ albumId, isFavorite, isTrashed, key, order, personId, size, tagId, userId, visibility, withPartners, withStacked }: {
albumId?: string;
isArchived?: boolean;
isFavorite?: boolean;
isTrashed?: boolean;
key?: string;
@ -3289,6 +3281,7 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key
size: TimeBucketSize;
tagId?: string;
userId?: string;
visibility?: AssetVisibility;
withPartners?: boolean;
withStacked?: boolean;
}, opts?: Oazapfts.RequestOpts) {
@ -3297,7 +3290,6 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key
data: TimeBucketResponseDto[];
}>(`/timeline/buckets${QS.query(QS.explode({
albumId,
isArchived,
isFavorite,
isTrashed,
key,
@ -3306,6 +3298,7 @@ export function getTimeBuckets({ albumId, isArchived, isFavorite, isTrashed, key
size,
tagId,
userId,
visibility,
withPartners,
withStacked
}))}`, {
@ -3620,6 +3613,11 @@ export enum Permission {
AdminUserUpdate = "admin.user.update",
AdminUserDelete = "admin.user.delete"
}
export enum AssetVisibility {
Archive = "archive",
Timeline = "timeline",
Hidden = "hidden"
}
export enum AssetMediaStatus {
Created = "created",
Replaced = "replaced",

View file

@ -100,38 +100,29 @@ describe(AssetMediaController.name, () => {
expect(body).toEqual(factory.responses.badRequest());
});
it('should throw if `isVisible` is not a boolean', async () => {
it('should throw if `visibility` is not an enum', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto(), isVisible: 'not-a-boolean' });
.field({ ...makeUploadDto(), visibility: 'not-a-boolean' });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
});
it('should throw if `isArchived` is not a boolean', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/assets')
.attach('assetData', assetData, filename)
.field({ ...makeUploadDto(), isArchived: 'not-a-boolean' });
expect(status).toBe(400);
expect(body).toEqual(factory.responses.badRequest());
// TODO figure out how to deal with `sendFile`
describe.skip('GET /assets/:id/original', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/original`);
expect(ctx.authenticate).toHaveBeenCalled();
});
});
});
// TODO figure out how to deal with `sendFile`
describe.skip('GET /assets/:id/original', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/original`);
expect(ctx.authenticate).toHaveBeenCalled();
});
});
// TODO figure out how to deal with `sendFile`
describe.skip('GET /assets/:id/thumbnail', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/thumbnail`);
expect(ctx.authenticate).toHaveBeenCalled();
// TODO figure out how to deal with `sendFile`
describe.skip('GET /assets/:id/thumbnail', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get(`/assets/${factory.uuid()}/thumbnail`);
expect(ctx.authenticate).toHaveBeenCalled();
});
});
});
});

View file

@ -60,12 +60,14 @@ describe(SearchController.name, () => {
expect(body).toEqual(errorDto.badRequest(['size must not be less than 1', 'size must be an integer number']));
});
it('should reject an isArchived as not a boolean', async () => {
it('should reject an visibility as not an enum', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/search/metadata')
.send({ isArchived: 'immich' });
.send({ visibility: 'immich' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['isArchived must be a boolean value']));
expect(body).toEqual(
errorDto.badRequest(['visibility must be one of the following values: archive, timeline, hidden']),
);
});
it('should reject an isFavorite as not a boolean', async () => {
@ -98,104 +100,98 @@ describe(SearchController.name, () => {
expect(body).toEqual(errorDto.badRequest(['isMotion must be a boolean value']));
});
it('should reject an isVisible as not a boolean', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/search/metadata')
.send({ isVisible: 'immich' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['isVisible must be a boolean value']));
});
});
describe('POST /search/random', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/search/random');
expect(ctx.authenticate).toHaveBeenCalled();
});
describe('POST /search/random', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/search/random');
expect(ctx.authenticate).toHaveBeenCalled();
it('should reject if withStacked is not a boolean', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/search/random')
.send({ withStacked: 'immich' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['withStacked must be a boolean value']));
});
it('should reject if withPeople is not a boolean', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/search/random')
.send({ withPeople: 'immich' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['withPeople must be a boolean value']));
});
});
it('should reject if withStacked is not a boolean', async () => {
const { status, body } = await request(ctx.getHttpServer())
.post('/search/random')
.send({ withStacked: 'immich' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['withStacked must be a boolean value']));
describe('POST /search/smart', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/search/smart');
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a query', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/smart').send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['query should not be empty', 'query must be a string']));
});
});
it('should reject if withPeople is not a boolean', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/random').send({ withPeople: 'immich' });
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['withPeople must be a boolean value']));
});
});
describe('POST /search/smart', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).post('/search/smart');
expect(ctx.authenticate).toHaveBeenCalled();
describe('GET /search/explore', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/search/explore');
expect(ctx.authenticate).toHaveBeenCalled();
});
});
it('should require a query', async () => {
const { status, body } = await request(ctx.getHttpServer()).post('/search/smart').send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['query should not be empty', 'query must be a string']));
});
});
describe('POST /search/person', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/search/person');
expect(ctx.authenticate).toHaveBeenCalled();
});
describe('GET /search/explore', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/search/explore');
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('POST /search/person', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/search/person');
expect(ctx.authenticate).toHaveBeenCalled();
it('should require a name', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/search/person').send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['name should not be empty', 'name must be a string']));
});
});
it('should require a name', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/search/person').send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['name should not be empty', 'name must be a string']));
});
});
describe('GET /search/places', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/search/places');
expect(ctx.authenticate).toHaveBeenCalled();
});
describe('GET /search/places', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/search/places');
expect(ctx.authenticate).toHaveBeenCalled();
it('should require a name', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/search/places').send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['name should not be empty', 'name must be a string']));
});
});
it('should require a name', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/search/places').send({});
expect(status).toBe(400);
expect(body).toEqual(errorDto.badRequest(['name should not be empty', 'name must be a string']));
});
});
describe('GET /search/cities', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/search/cities');
expect(ctx.authenticate).toHaveBeenCalled();
});
});
describe('GET /search/suggestions', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/search/suggestions');
expect(ctx.authenticate).toHaveBeenCalled();
describe('GET /search/cities', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/search/cities');
expect(ctx.authenticate).toHaveBeenCalled();
});
});
it('should require a type', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/search/suggestions').send({});
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([
'type should not be empty',
expect.stringContaining('type must be one of the following values:'),
]),
);
describe('GET /search/suggestions', () => {
it('should be an authenticated route', async () => {
await request(ctx.getHttpServer()).get('/search/suggestions');
expect(ctx.authenticate).toHaveBeenCalled();
});
it('should require a type', async () => {
const { status, body } = await request(ctx.getHttpServer()).get('/search/suggestions').send({});
expect(status).toBe(400);
expect(body).toEqual(
errorDto.badRequest([
'type should not be empty',
expect.stringContaining('type must be one of the following values:'),
]),
);
});
});
});
});

View file

@ -5,6 +5,7 @@ import {
AlbumUserRole,
AssetFileType,
AssetType,
AssetVisibility,
MemoryType,
Permission,
SharedLinkType,
@ -108,7 +109,7 @@ export type Asset = {
fileCreatedAt: Date;
fileModifiedAt: Date;
isExternal: boolean;
isVisible: boolean;
visibility: AssetVisibility;
libraryId: string | null;
livePhotoVideoId: string | null;
localDateTime: Date;
@ -285,7 +286,7 @@ export const columns = {
'assets.fileCreatedAt',
'assets.fileModifiedAt',
'assets.isExternal',
'assets.isVisible',
'assets.visibility',
'assets.libraryId',
'assets.livePhotoVideoId',
'assets.localDateTime',
@ -345,7 +346,7 @@ export const columns = {
'type',
'deletedAt',
'isFavorite',
'isVisible',
'visibility',
'updateId',
],
stack: ['stack.id', 'stack.primaryAssetId', 'ownerId'],

4
server/src/db.d.ts vendored
View file

@ -10,6 +10,7 @@ import {
AssetOrder,
AssetStatus,
AssetType,
AssetVisibility,
MemoryType,
NotificationLevel,
NotificationType,
@ -148,11 +149,10 @@ export interface Assets {
fileCreatedAt: Timestamp;
fileModifiedAt: Timestamp;
id: Generated<string>;
isArchived: Generated<boolean>;
isExternal: Generated<boolean>;
isFavorite: Generated<boolean>;
isOffline: Generated<boolean>;
isVisible: Generated<boolean>;
visibility: Generated<AssetVisibility>;
libraryId: string | null;
livePhotoVideoId: string | null;
localDateTime: Timestamp;

View file

@ -1,7 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsEnum, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
import { AssetVisibility } from 'src/enum';
import { Optional, ValidateAssetVisibility, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
export enum AssetMediaSize {
/**
@ -55,11 +56,8 @@ export class AssetMediaCreateDto extends AssetMediaBase {
@ValidateBoolean({ optional: true })
isFavorite?: boolean;
@ValidateBoolean({ optional: true })
isArchived?: boolean;
@ValidateBoolean({ optional: true })
isVisible?: boolean;
@ValidateAssetVisibility({ optional: true })
visibility?: AssetVisibility;
@ValidateUUID({ optional: true })
livePhotoVideoId?: string;

View file

@ -12,7 +12,7 @@ import {
} from 'src/dtos/person.dto';
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { AssetStatus, AssetType } from 'src/enum';
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { mimeTypes } from 'src/utils/mime-types';
export class SanitizedAssetResponseDto {
@ -74,11 +74,10 @@ export type MapAsset = {
fileCreatedAt: Date;
fileModifiedAt: Date;
files?: AssetFile[];
isArchived: boolean;
isExternal: boolean;
isFavorite: boolean;
isOffline: boolean;
isVisible: boolean;
visibility: AssetVisibility;
libraryId: string | null;
livePhotoVideoId: string | null;
localDateTime: Date;
@ -183,7 +182,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
localDateTime: entity.localDateTime,
updatedAt: entity.updatedAt,
isFavorite: options.auth?.user.id === entity.ownerId ? entity.isFavorite : false,
isArchived: entity.isArchived,
isArchived: entity.visibility === AssetVisibility.ARCHIVE,
isTrashed: !!entity.deletedAt,
duration: entity.duration ?? '0:00:00.00000',
exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,

View file

@ -14,9 +14,9 @@ import {
ValidateIf,
} from 'class-validator';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AssetType } from 'src/enum';
import { AssetType, AssetVisibility } from 'src/enum';
import { AssetStats } from 'src/repositories/asset.repository';
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
import { Optional, ValidateAssetVisibility, ValidateBoolean, ValidateUUID } from 'src/validation';
export class DeviceIdDto {
@IsNotEmpty()
@ -32,8 +32,8 @@ export class UpdateAssetBase {
@ValidateBoolean({ optional: true })
isFavorite?: boolean;
@ValidateBoolean({ optional: true })
isArchived?: boolean;
@ValidateAssetVisibility({ optional: true })
visibility?: AssetVisibility;
@Optional()
@IsDateString()
@ -105,8 +105,8 @@ export class AssetJobsDto extends AssetIdsDto {
}
export class AssetStatsDto {
@ValidateBoolean({ optional: true })
isArchived?: boolean;
@ValidateAssetVisibility({ optional: true })
visibility?: AssetVisibility;
@ValidateBoolean({ optional: true })
isFavorite?: boolean;

View file

@ -5,8 +5,8 @@ import { Place } from 'src/database';
import { PropertyLifecycle } from 'src/decorators';
import { AlbumResponseDto } from 'src/dtos/album.dto';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AssetOrder, AssetType } from 'src/enum';
import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
import { AssetOrder, AssetType, AssetVisibility } from 'src/enum';
import { Optional, ValidateAssetVisibility, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
class BaseSearchDto {
@ValidateUUID({ optional: true, nullable: true })
@ -22,13 +22,6 @@ class BaseSearchDto {
@ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
type?: AssetType;
@ValidateBoolean({ optional: true })
isArchived?: boolean;
@ValidateBoolean({ optional: true })
@ApiProperty({ default: false })
withArchived?: boolean;
@ValidateBoolean({ optional: true })
isEncoded?: boolean;
@ -41,8 +34,8 @@ class BaseSearchDto {
@ValidateBoolean({ optional: true })
isOffline?: boolean;
@ValidateBoolean({ optional: true })
isVisible?: boolean;
@ValidateAssetVisibility({ optional: true })
visibility?: AssetVisibility;
@ValidateBoolean({ optional: true })
withDeleted?: boolean;

View file

@ -1,7 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsInt, IsPositive, IsString } from 'class-validator';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AssetType, SyncEntityType, SyncRequestType } from 'src/enum';
import { AssetType, AssetVisibility, SyncEntityType, SyncRequestType } from 'src/enum';
import { Optional, ValidateDate, ValidateUUID } from 'src/validation';
export class AssetFullSyncDto {
@ -67,7 +67,7 @@ export class SyncAssetV1 {
type!: AssetType;
deletedAt!: Date | null;
isFavorite!: boolean;
isVisible!: boolean;
visibility!: AssetVisibility;
}
export class SyncAssetDeleteV1 {

View file

@ -1,8 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { AssetOrder } from 'src/enum';
import { AssetOrder, AssetVisibility } from 'src/enum';
import { TimeBucketSize } from 'src/repositories/asset.repository';
import { Optional, ValidateBoolean, ValidateUUID } from 'src/validation';
import { Optional, ValidateAssetVisibility, ValidateBoolean, ValidateUUID } from 'src/validation';
export class TimeBucketDto {
@IsNotEmpty()
@ -22,9 +22,6 @@ export class TimeBucketDto {
@ValidateUUID({ optional: true })
tagId?: string;
@ValidateBoolean({ optional: true })
isArchived?: boolean;
@ValidateBoolean({ optional: true })
isFavorite?: boolean;
@ -41,6 +38,9 @@ export class TimeBucketDto {
@Optional()
@ApiProperty({ enum: AssetOrder, enumName: 'AssetOrder' })
order?: AssetOrder;
@ValidateAssetVisibility({ optional: true })
visibility?: AssetVisibility;
}
export class TimeBucketAssetDto extends TimeBucketDto {

View file

@ -618,3 +618,13 @@ export enum DatabaseSslMode {
Require = 'require',
VerifyFull = 'verify-full',
}
export enum AssetVisibility {
ARCHIVE = 'archive',
TIMELINE = 'timeline',
/**
* Video part of the LivePhotos and MotionPhotos
*/
HIDDEN = 'hidden',
}

View file

@ -110,8 +110,11 @@ from
and "assets"."deletedAt" is null
where
"partner"."sharedWithId" = $1
and "assets"."isArchived" = $2
and "assets"."id" in ($3)
and (
"assets"."visibility" = 'timeline'
or "assets"."visibility" = 'hidden'
)
and "assets"."id" in ($2)
-- AccessRepository.asset.checkSharedLinkAccess
select

View file

@ -7,7 +7,7 @@ select
"ownerId",
"duplicateId",
"stackId",
"isVisible",
"visibility",
"smart_search"."embedding",
(
select
@ -83,7 +83,7 @@ from
inner join "asset_job_status" on "asset_job_status"."assetId" = "assets"."id"
where
"assets"."deletedAt" is null
and "assets"."isVisible" = $1
and "assets"."visibility" != $1
and (
"asset_job_status"."previewAt" is null
or "asset_job_status"."thumbnailAt" is null
@ -118,7 +118,7 @@ where
-- AssetJobRepository.getForGenerateThumbnailJob
select
"assets"."id",
"assets"."isVisible",
"assets"."visibility",
"assets"."originalFileName",
"assets"."originalPath",
"assets"."ownerId",
@ -155,7 +155,7 @@ select
"assets"."fileCreatedAt",
"assets"."fileModifiedAt",
"assets"."isExternal",
"assets"."isVisible",
"assets"."visibility",
"assets"."libraryId",
"assets"."livePhotoVideoId",
"assets"."localDateTime",
@ -201,7 +201,7 @@ from
"assets"
inner join "asset_job_status" as "job_status" on "assetId" = "assets"."id"
where
"assets"."isVisible" = $1
"assets"."visibility" != $1
and "assets"."deletedAt" is null
and "job_status"."previewAt" is not null
and not exists (
@ -220,7 +220,7 @@ from
"assets"
inner join "asset_job_status" as "job_status" on "assetId" = "assets"."id"
where
"assets"."isVisible" = $1
"assets"."visibility" != $1
and "assets"."deletedAt" is null
and "job_status"."previewAt" is not null
and not exists (
@ -234,7 +234,7 @@ where
-- AssetJobRepository.getForClipEncoding
select
"assets"."id",
"assets"."isVisible",
"assets"."visibility",
(
select
coalesce(json_agg(agg), '[]')
@ -259,7 +259,7 @@ where
-- AssetJobRepository.getForDetectFacesJob
select
"assets"."id",
"assets"."isVisible",
"assets"."visibility",
to_json("exif") as "exifInfo",
(
select
@ -312,7 +312,7 @@ where
-- AssetJobRepository.getForAssetDeletion
select
"assets"."id",
"assets"."isVisible",
"assets"."visibility",
"assets"."libraryId",
"assets"."ownerId",
"assets"."livePhotoVideoId",
@ -372,7 +372,7 @@ from
"assets" as "stacked"
where
"stacked"."deletedAt" is not null
and "stacked"."isArchived" = $1
and "stacked"."visibility" != $1
and "stacked"."stackId" = "asset_stack"."id"
group by
"asset_stack"."id"
@ -391,7 +391,7 @@ where
"assets"."encodedVideoPath" is null
or "assets"."encodedVideoPath" = $2
)
and "assets"."isVisible" = $3
and "assets"."visibility" != $3
and "assets"."deletedAt" is null
-- AssetJobRepository.getForVideoConversion
@ -417,7 +417,7 @@ where
"asset_job_status"."metadataExtractedAt" is null
or "asset_job_status"."assetId" is null
)
and "assets"."isVisible" = $1
and "assets"."visibility" != $1
and "assets"."deletedAt" is null
-- AssetJobRepository.getForStorageTemplateJob
@ -480,7 +480,7 @@ where
"assets"."sidecarPath" = $1
or "assets"."sidecarPath" is null
)
and "assets"."isVisible" = $2
and "assets"."visibility" != $2
-- AssetJobRepository.streamForDetectFacesJob
select
@ -489,7 +489,7 @@ from
"assets"
inner join "asset_job_status" as "job_status" on "assetId" = "assets"."id"
where
"assets"."isVisible" = $1
"assets"."visibility" != $1
and "assets"."deletedAt" is null
and "job_status"."previewAt" is not null
and "job_status"."facesRecognizedAt" is null

View file

@ -43,21 +43,20 @@ with
"asset_job_status"."previewAt" is not null
and (assets."localDateTime" at time zone 'UTC')::date = today.date
and "assets"."ownerId" = any ($3::uuid[])
and "assets"."isVisible" = $4
and "assets"."isArchived" = $5
and "assets"."visibility" = $4
and exists (
select
from
"asset_files"
where
"assetId" = "assets"."id"
and "asset_files"."type" = $6
and "asset_files"."type" = $5
)
and "assets"."deletedAt" is null
order by
(assets."localDateTime" at time zone 'UTC')::date desc
limit
$7
$6
) as "a" on true
inner join "exif" on "a"."id" = "exif"."assetId"
)
@ -159,7 +158,7 @@ from
where
"ownerId" = $1::uuid
and "deviceId" = $2
and "isVisible" = $3
and "visibility" != $3
and "deletedAt" is null
-- AssetRepository.getLivePhotoCount
@ -241,7 +240,10 @@ with
"assets"
where
"assets"."deletedAt" is null
and "assets"."isVisible" = $2
and (
"assets"."visibility" = $2
or "assets"."visibility" = $3
)
)
select
"timeBucket",
@ -271,7 +273,7 @@ from
where
"stacked"."stackId" = "asset_stack"."id"
and "stacked"."deletedAt" is null
and "stacked"."isArchived" = $1
and "stacked"."visibility" != $1
group by
"asset_stack"."id"
) as "stacked_assets" on "asset_stack"."id" is not null
@ -281,8 +283,11 @@ where
or "assets"."stackId" is null
)
and "assets"."deletedAt" is null
and "assets"."isVisible" = $2
and date_trunc($3, "localDateTime" at time zone 'UTC') at time zone 'UTC' = $4
and (
"assets"."visibility" = $2
or "assets"."visibility" = $3
)
and date_trunc($4, "localDateTime" at time zone 'UTC') at time zone 'UTC' = $5
order by
"assets"."localDateTime" desc
@ -307,7 +312,7 @@ with
"assets"."ownerId" = $1::uuid
and "assets"."duplicateId" is not null
and "assets"."deletedAt" is null
and "assets"."isVisible" = $2
and "assets"."visibility" != $2
and "assets"."stackId" is null
group by
"assets"."duplicateId"
@ -365,12 +370,11 @@ from
inner join "cities" on "exif"."city" = "cities"."city"
where
"ownerId" = $2::uuid
and "isVisible" = $3
and "isArchived" = $4
and "type" = $5
and "visibility" = $3
and "type" = $4
and "deletedAt" is null
limit
$6
$5
-- AssetRepository.getAllForUserFullSync
select
@ -394,7 +398,7 @@ from
) as "stacked_assets" on "asset_stack"."id" is not null
where
"assets"."ownerId" = $1::uuid
and "assets"."isVisible" = $2
and "assets"."visibility" != $2
and "assets"."updatedAt" <= $3
and "assets"."id" > $4
order by
@ -424,7 +428,7 @@ from
) as "stacked_assets" on "asset_stack"."id" is not null
where
"assets"."ownerId" = any ($1::uuid[])
and "assets"."isVisible" = $2
and "assets"."visibility" != $2
and "assets"."updatedAt" > $3
limit
$4

View file

@ -35,14 +35,14 @@ select
where
(
"assets"."type" = $1
and "assets"."isVisible" = $2
and "assets"."visibility" != $2
)
) as "photos",
count(*) filter (
where
(
"assets"."type" = $3
and "assets"."isVisible" = $4
and "assets"."visibility" != $4
)
) as "videos",
coalesce(sum("exif"."fileSizeInByte"), $5) as "usage"

View file

@ -14,7 +14,7 @@ from
and "exif"."latitude" is not null
and "exif"."longitude" is not null
where
"isVisible" = $1
"assets"."visibility" = $1
and "deletedAt" is null
and (
"ownerId" in ($2)

View file

@ -107,7 +107,7 @@ select
(
select
"assets"."ownerId",
"assets"."isArchived",
"assets"."visibility",
"assets"."fileCreatedAt"
from
"assets"
@ -203,7 +203,7 @@ from
"asset_faces"
left join "assets" on "assets"."id" = "asset_faces"."assetId"
and "asset_faces"."personId" = $1
and "assets"."isArchived" = $2
and "assets"."visibility" != $2
and "assets"."deletedAt" is null
where
"asset_faces"."deletedAt" is null
@ -220,7 +220,7 @@ from
inner join "asset_faces" on "asset_faces"."personId" = "person"."id"
inner join "assets" on "assets"."id" = "asset_faces"."assetId"
and "assets"."deletedAt" is null
and "assets"."isArchived" = $2
and "assets"."visibility" != $2
where
"person"."ownerId" = $3
and "asset_faces"."deletedAt" is null

View file

@ -7,11 +7,11 @@ from
"assets"
inner join "exif" on "assets"."id" = "exif"."assetId"
where
"assets"."fileCreatedAt" >= $1
and "exif"."lensModel" = $2
and "assets"."ownerId" = any ($3::uuid[])
and "assets"."isFavorite" = $4
and "assets"."isArchived" = $5
"assets"."visibility" = $1
and "assets"."fileCreatedAt" >= $2
and "exif"."lensModel" = $3
and "assets"."ownerId" = any ($4::uuid[])
and "assets"."isFavorite" = $5
and "assets"."deletedAt" is null
order by
"assets"."fileCreatedAt" desc
@ -28,11 +28,11 @@ offset
"assets"
inner join "exif" on "assets"."id" = "exif"."assetId"
where
"assets"."fileCreatedAt" >= $1
and "exif"."lensModel" = $2
and "assets"."ownerId" = any ($3::uuid[])
and "assets"."isFavorite" = $4
and "assets"."isArchived" = $5
"assets"."visibility" = $1
and "assets"."fileCreatedAt" >= $2
and "exif"."lensModel" = $3
and "assets"."ownerId" = any ($4::uuid[])
and "assets"."isFavorite" = $5
and "assets"."deletedAt" is null
and "assets"."id" < $6
order by
@ -48,11 +48,11 @@ union all
"assets"
inner join "exif" on "assets"."id" = "exif"."assetId"
where
"assets"."fileCreatedAt" >= $8
and "exif"."lensModel" = $9
and "assets"."ownerId" = any ($10::uuid[])
and "assets"."isFavorite" = $11
and "assets"."isArchived" = $12
"assets"."visibility" = $8
and "assets"."fileCreatedAt" >= $9
and "exif"."lensModel" = $10
and "assets"."ownerId" = any ($11::uuid[])
and "assets"."isFavorite" = $12
and "assets"."deletedAt" is null
and "assets"."id" > $13
order by
@ -71,11 +71,11 @@ from
inner join "exif" on "assets"."id" = "exif"."assetId"
inner join "smart_search" on "assets"."id" = "smart_search"."assetId"
where
"assets"."fileCreatedAt" >= $1
and "exif"."lensModel" = $2
and "assets"."ownerId" = any ($3::uuid[])
and "assets"."isFavorite" = $4
and "assets"."isArchived" = $5
"assets"."visibility" = $1
and "assets"."fileCreatedAt" >= $2
and "exif"."lensModel" = $3
and "assets"."ownerId" = any ($4::uuid[])
and "assets"."isFavorite" = $5
and "assets"."deletedAt" is null
order by
smart_search.embedding <=> $6
@ -97,7 +97,7 @@ with
where
"assets"."ownerId" = any ($2::uuid[])
and "assets"."deletedAt" is null
and "assets"."isVisible" = $3
and "assets"."visibility" != $3
and "assets"."type" = $4
and "assets"."id" != $5::uuid
and "assets"."stackId" is null
@ -176,14 +176,13 @@ with recursive
inner join "assets" on "assets"."id" = "exif"."assetId"
where
"assets"."ownerId" = any ($1::uuid[])
and "assets"."isVisible" = $2
and "assets"."isArchived" = $3
and "assets"."type" = $4
and "assets"."visibility" = $2
and "assets"."type" = $3
and "assets"."deletedAt" is null
order by
"city"
limit
$5
$4
)
union all
(
@ -200,16 +199,15 @@ with recursive
"exif"
inner join "assets" on "assets"."id" = "exif"."assetId"
where
"assets"."ownerId" = any ($6::uuid[])
and "assets"."isVisible" = $7
and "assets"."isArchived" = $8
and "assets"."type" = $9
"assets"."ownerId" = any ($5::uuid[])
and "assets"."visibility" = $6
and "assets"."type" = $7
and "assets"."deletedAt" is null
and "exif"."city" > "cte"."city"
order by
"city"
limit
$10
$8
) as "l" on true
)
)
@ -231,7 +229,7 @@ from
inner join "assets" on "assets"."id" = "exif"."assetId"
where
"ownerId" = any ($1::uuid[])
and "isVisible" = $2
and "visibility" != $2
and "deletedAt" is null
and "state" is not null
@ -243,7 +241,7 @@ from
inner join "assets" on "assets"."id" = "exif"."assetId"
where
"ownerId" = any ($1::uuid[])
and "isVisible" = $2
and "visibility" != $2
and "deletedAt" is null
and "city" is not null
@ -255,7 +253,7 @@ from
inner join "assets" on "assets"."id" = "exif"."assetId"
where
"ownerId" = any ($1::uuid[])
and "isVisible" = $2
and "visibility" != $2
and "deletedAt" is null
and "make" is not null
@ -267,6 +265,6 @@ from
inner join "assets" on "assets"."id" = "exif"."assetId"
where
"ownerId" = any ($1::uuid[])
and "isVisible" = $2
and "visibility" != $2
and "deletedAt" is null
and "model" is not null

View file

@ -84,7 +84,7 @@ select
"type",
"deletedAt",
"isFavorite",
"isVisible",
"visibility",
"updateId"
from
"assets"
@ -106,7 +106,7 @@ select
"type",
"deletedAt",
"isFavorite",
"isVisible",
"visibility",
"updateId"
from
"assets"

View file

@ -285,14 +285,14 @@ select
where
(
"assets"."type" = 'IMAGE'
and "assets"."isVisible" = true
and "assets"."visibility" != 'hidden'
)
) as "photos",
count(*) filter (
where
(
"assets"."type" = 'VIDEO'
and "assets"."isVisible" = true
and "assets"."visibility" != 'hidden'
)
) as "videos",
coalesce(

View file

@ -7,8 +7,7 @@ from
"assets"
where
"ownerId" = $2::uuid
and "isVisible" = $3
and "isArchived" = $4
and "visibility" = $3
and "deletedAt" is null
and "fileCreatedAt" is not null
and "fileModifiedAt" is not null
@ -23,13 +22,12 @@ from
left join "exif" on "assets"."id" = "exif"."assetId"
where
"ownerId" = $1::uuid
and "isVisible" = $2
and "isArchived" = $3
and "visibility" = $2
and "deletedAt" is null
and "fileCreatedAt" is not null
and "fileModifiedAt" is not null
and "localDateTime" is not null
and "originalPath" like $4
and "originalPath" not like $5
and "originalPath" like $3
and "originalPath" not like $4
order by
regexp_replace("assets"."originalPath", $6, $7) asc
regexp_replace("assets"."originalPath", $5, $6) asc

View file

@ -3,7 +3,7 @@ import { Kysely, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { DB } from 'src/db';
import { ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { AlbumUserRole } from 'src/enum';
import { AlbumUserRole, AssetVisibility } from 'src/enum';
import { asUuid } from 'src/utils/database';
class ActivityAccess {
@ -199,7 +199,13 @@ class AssetAccess {
)
.select('assets.id')
.where('partner.sharedWithId', '=', userId)
.where('assets.isArchived', '=', false)
.where((eb) =>
eb.or([
eb('assets.visibility', '=', sql.lit(AssetVisibility.TIMELINE)),
eb('assets.visibility', '=', sql.lit(AssetVisibility.HIDDEN)),
]),
)
.where('assets.id', 'in', [...assetIds])
.execute()
.then((assets) => new Set(assets.map((asset) => asset.id)));

View file

@ -5,7 +5,7 @@ import { InjectKysely } from 'nestjs-kysely';
import { Asset, columns } from 'src/database';
import { DB } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetFileType, AssetType } from 'src/enum';
import { AssetFileType, AssetType, AssetVisibility } from 'src/enum';
import { StorageAsset } from 'src/types';
import {
anyUuid,
@ -34,7 +34,7 @@ export class AssetJobRepository {
'ownerId',
'duplicateId',
'stackId',
'isVisible',
'visibility',
'smart_search.embedding',
withFiles(eb, AssetFileType.PREVIEW),
])
@ -70,7 +70,7 @@ export class AssetJobRepository {
.select(['assets.id', 'assets.thumbhash'])
.select(withFiles)
.where('assets.deletedAt', 'is', null)
.where('assets.isVisible', '=', true)
.where('assets.visibility', '!=', AssetVisibility.HIDDEN)
.$if(!force, (qb) =>
qb
// If there aren't any entries, metadata extraction hasn't run yet which is required for thumbnails
@ -102,7 +102,7 @@ export class AssetJobRepository {
.selectFrom('assets')
.select([
'assets.id',
'assets.isVisible',
'assets.visibility',
'assets.originalFileName',
'assets.originalPath',
'assets.ownerId',
@ -138,7 +138,7 @@ export class AssetJobRepository {
private assetsWithPreviews() {
return this.db
.selectFrom('assets')
.where('assets.isVisible', '=', true)
.where('assets.visibility', '!=', AssetVisibility.HIDDEN)
.where('assets.deletedAt', 'is', null)
.innerJoin('asset_job_status as job_status', 'assetId', 'assets.id')
.where('job_status.previewAt', 'is not', null);
@ -169,7 +169,7 @@ export class AssetJobRepository {
getForClipEncoding(id: string) {
return this.db
.selectFrom('assets')
.select(['assets.id', 'assets.isVisible'])
.select(['assets.id', 'assets.visibility'])
.select((eb) => withFiles(eb, AssetFileType.PREVIEW))
.where('assets.id', '=', id)
.executeTakeFirst();
@ -179,7 +179,7 @@ export class AssetJobRepository {
getForDetectFacesJob(id: string) {
return this.db
.selectFrom('assets')
.select(['assets.id', 'assets.isVisible'])
.select(['assets.id', 'assets.visibility'])
.$call(withExifInner)
.select((eb) => withFaces(eb, true))
.select((eb) => withFiles(eb, AssetFileType.PREVIEW))
@ -209,7 +209,7 @@ export class AssetJobRepository {
.selectFrom('assets')
.select([
'assets.id',
'assets.isVisible',
'assets.visibility',
'assets.libraryId',
'assets.ownerId',
'assets.livePhotoVideoId',
@ -228,7 +228,7 @@ export class AssetJobRepository {
.select(['asset_stack.id', 'asset_stack.primaryAssetId'])
.select((eb) => eb.fn<Asset[]>('array_agg', [eb.table('stacked')]).as('assets'))
.where('stacked.deletedAt', 'is not', null)
.where('stacked.isArchived', '=', false)
.where('stacked.visibility', '!=', AssetVisibility.ARCHIVE)
.whereRef('stacked.stackId', '=', 'asset_stack.id')
.groupBy('asset_stack.id')
.as('stacked_assets'),
@ -248,7 +248,7 @@ export class AssetJobRepository {
.$if(!force, (qb) =>
qb
.where((eb) => eb.or([eb('assets.encodedVideoPath', 'is', null), eb('assets.encodedVideoPath', '=', '')]))
.where('assets.isVisible', '=', true),
.where('assets.visibility', '!=', AssetVisibility.HIDDEN),
)
.where('assets.deletedAt', 'is', null)
.stream();
@ -275,7 +275,7 @@ export class AssetJobRepository {
.where((eb) =>
eb.or([eb('asset_job_status.metadataExtractedAt', 'is', null), eb('asset_job_status.assetId', 'is', null)]),
)
.where('assets.isVisible', '=', true),
.where('assets.visibility', '!=', AssetVisibility.HIDDEN),
)
.where('assets.deletedAt', 'is', null)
.stream();
@ -331,7 +331,7 @@ export class AssetJobRepository {
.$if(!force, (qb) =>
qb.where((eb) => eb.or([eb('assets.sidecarPath', '=', ''), eb('assets.sidecarPath', 'is', null)])),
)
.where('assets.isVisible', '=', true)
.where('assets.visibility', '!=', AssetVisibility.HIDDEN)
.stream();
}

View file

@ -6,7 +6,7 @@ import { Stack } from 'src/database';
import { AssetFiles, AssetJobStatus, Assets, DB, Exif } from 'src/db';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum';
import { AssetFileType, AssetOrder, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import {
anyUuid,
asUuid,
@ -14,6 +14,7 @@ import {
removeUndefinedKeys,
truncatedDate,
unnest,
withDefaultVisibility,
withExif,
withFaces,
withFacesAndPeople,
@ -30,8 +31,8 @@ export type AssetStats = Record<AssetType, number>;
export interface AssetStatsOptions {
isFavorite?: boolean;
isArchived?: boolean;
isTrashed?: boolean;
visibility?: AssetVisibility;
}
export interface LivePhotoSearchOptions {
@ -52,7 +53,6 @@ export enum TimeBucketSize {
}
export interface AssetBuilderOptions {
isArchived?: boolean;
isFavorite?: boolean;
isTrashed?: boolean;
isDuplicate?: boolean;
@ -64,6 +64,7 @@ export interface AssetBuilderOptions {
exifInfo?: boolean;
status?: AssetStatus;
assetType?: AssetType;
visibility?: AssetVisibility;
}
export interface TimeBucketOptions extends AssetBuilderOptions {
@ -258,8 +259,7 @@ export class AssetRepository {
.where('asset_job_status.previewAt', 'is not', null)
.where(sql`(assets."localDateTime" at time zone 'UTC')::date`, '=', sql`today.date`)
.where('assets.ownerId', '=', anyUuid(ownerIds))
.where('assets.isVisible', '=', true)
.where('assets.isArchived', '=', false)
.where('assets.visibility', '=', AssetVisibility.TIMELINE)
.where((eb) =>
eb.exists((qb) =>
qb
@ -348,7 +348,7 @@ export class AssetRepository {
.select(['deviceAssetId'])
.where('ownerId', '=', asUuid(ownerId))
.where('deviceId', '=', deviceId)
.where('isVisible', '=', true)
.where('visibility', '!=', AssetVisibility.HIDDEN)
.where('deletedAt', 'is', null)
.execute();
@ -393,7 +393,7 @@ export class AssetRepository {
.whereRef('stacked.stackId', '=', 'asset_stack.id')
.whereRef('stacked.id', '!=', 'asset_stack.primaryAssetId')
.where('stacked.deletedAt', 'is', null)
.where('stacked.isArchived', '=', false)
.where('stacked.visibility', '=', AssetVisibility.TIMELINE)
.groupBy('asset_stack.id')
.as('stacked_assets'),
(join) => join.on('asset_stack.id', 'is not', null),
@ -503,7 +503,7 @@ export class AssetRepository {
.executeTakeFirst();
}
getStatistics(ownerId: string, { isArchived, isFavorite, isTrashed }: AssetStatsOptions): Promise<AssetStats> {
getStatistics(ownerId: string, { visibility, isFavorite, isTrashed }: AssetStatsOptions): Promise<AssetStats> {
return this.db
.selectFrom('assets')
.select((eb) => eb.fn.countAll<number>().filterWhere('type', '=', AssetType.AUDIO).as(AssetType.AUDIO))
@ -511,8 +511,8 @@ export class AssetRepository {
.select((eb) => eb.fn.countAll<number>().filterWhere('type', '=', AssetType.VIDEO).as(AssetType.VIDEO))
.select((eb) => eb.fn.countAll<number>().filterWhere('type', '=', AssetType.OTHER).as(AssetType.OTHER))
.where('ownerId', '=', asUuid(ownerId))
.where('isVisible', '=', true)
.$if(isArchived !== undefined, (qb) => qb.where('isArchived', '=', isArchived!))
.$if(visibility === undefined, withDefaultVisibility)
.$if(!!visibility, (qb) => qb.where('assets.visibility', '=', visibility!))
.$if(isFavorite !== undefined, (qb) => qb.where('isFavorite', '=', isFavorite!))
.$if(!!isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
.where('deletedAt', isTrashed ? 'is not' : 'is', null)
@ -525,7 +525,7 @@ export class AssetRepository {
.selectAll('assets')
.$call(withExif)
.where('ownerId', '=', anyUuid(userIds))
.where('isVisible', '=', true)
.where('visibility', '!=', AssetVisibility.HIDDEN)
.where('deletedAt', 'is', null)
.orderBy((eb) => eb.fn('random'))
.limit(take)
@ -542,7 +542,8 @@ export class AssetRepository {
.select(truncatedDate<Date>(options.size).as('timeBucket'))
.$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
.where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null)
.where('assets.isVisible', '=', true)
.$if(options.visibility === undefined, withDefaultVisibility)
.$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!))
.$if(!!options.albumId, (qb) =>
qb
.innerJoin('albums_assets_assets', 'assets.id', 'albums_assets_assets.assetsId')
@ -559,7 +560,6 @@ export class AssetRepository {
.where((eb) => eb.or([eb('assets.stackId', 'is', null), eb(eb.table('asset_stack'), 'is not', null)])),
)
.$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!)))
.$if(options.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!))
.$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!))
.$if(!!options.assetType, (qb) => qb.where('assets.type', '=', options.assetType!))
.$if(options.isDuplicate !== undefined, (qb) =>
@ -594,7 +594,6 @@ export class AssetRepository {
)
.$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!]))
.$if(!!options.userIds, (qb) => qb.where('assets.ownerId', '=', anyUuid(options.userIds!)))
.$if(options.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!))
.$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!))
.$if(!!options.withStacked, (qb) =>
qb
@ -610,7 +609,7 @@ export class AssetRepository {
.select((eb) => eb.fn.count(eb.table('stacked')).as('assetCount'))
.whereRef('stacked.stackId', '=', 'asset_stack.id')
.where('stacked.deletedAt', 'is', null)
.where('stacked.isArchived', '=', false)
.where('stacked.visibility', '!=', AssetVisibility.ARCHIVE)
.groupBy('asset_stack.id')
.as('stacked_assets'),
(join) => join.on('asset_stack.id', 'is not', null),
@ -624,7 +623,8 @@ export class AssetRepository {
.$if(!!options.isTrashed, (qb) => qb.where('assets.status', '!=', AssetStatus.DELETED))
.$if(!!options.tagId, (qb) => withTagId(qb, options.tagId!))
.where('assets.deletedAt', options.isTrashed ? 'is not' : 'is', null)
.where('assets.isVisible', '=', true)
.$if(options.visibility == undefined, withDefaultVisibility)
.$if(!!options.visibility, (qb) => qb.where('assets.visibility', '=', options.visibility!))
.where(truncatedDate(options.size), '=', timeBucket.replace(/^[+-]/, ''))
.orderBy('assets.localDateTime', options.order ?? 'desc')
.execute();
@ -658,7 +658,7 @@ export class AssetRepository {
.where('assets.duplicateId', 'is not', null)
.$narrowType<{ duplicateId: NotNull }>()
.where('assets.deletedAt', 'is', null)
.where('assets.isVisible', '=', true)
.where('assets.visibility', '!=', AssetVisibility.HIDDEN)
.where('assets.stackId', 'is', null)
.groupBy('assets.duplicateId'),
)
@ -703,8 +703,7 @@ export class AssetRepository {
.select(['assetId as data', 'exif.city as value'])
.$narrowType<{ value: NotNull }>()
.where('ownerId', '=', asUuid(ownerId))
.where('isVisible', '=', true)
.where('isArchived', '=', false)
.where('visibility', '=', AssetVisibility.TIMELINE)
.where('type', '=', AssetType.IMAGE)
.where('deletedAt', 'is', null)
.limit(maxFields)
@ -743,7 +742,7 @@ export class AssetRepository {
)
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).$castTo<Stack | null>().as('stack'))
.where('assets.ownerId', '=', asUuid(ownerId))
.where('assets.isVisible', '=', true)
.where('assets.visibility', '!=', AssetVisibility.HIDDEN)
.where('assets.updatedAt', '<=', updatedUntil)
.$if(!!lastId, (qb) => qb.where('assets.id', '>', lastId!))
.orderBy('assets.id')
@ -771,7 +770,7 @@ export class AssetRepository {
)
.select((eb) => eb.fn.toJson(eb.table('stacked_assets').$castTo<Stack | null>()).as('stack'))
.where('assets.ownerId', '=', anyUuid(options.userIds))
.where('assets.isVisible', '=', true)
.where('assets.visibility', '!=', AssetVisibility.HIDDEN)
.where('assets.updatedAt', '>', options.updatedAfter)
.limit(options.limit)
.execute();

View file

@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { Kysely } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { DB } from 'src/db';
import { AssetVisibility } from 'src/enum';
import { anyUuid } from 'src/utils/database';
const builder = (db: Kysely<DB>) =>
@ -31,6 +32,9 @@ export class DownloadRepository {
}
downloadUserId(userId: string) {
return builder(this.db).where('assets.ownerId', '=', userId).where('assets.isVisible', '=', true).stream();
return builder(this.db)
.where('assets.ownerId', '=', userId)
.where('assets.visibility', '!=', AssetVisibility.HIDDEN)
.stream();
}
}

View file

@ -4,7 +4,7 @@ import { InjectKysely } from 'nestjs-kysely';
import { DB, Libraries } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators';
import { LibraryStatsResponseDto } from 'src/dtos/library.dto';
import { AssetType } from 'src/enum';
import { AssetType, AssetVisibility } from 'src/enum';
export enum AssetSyncResult {
DO_NOTHING,
@ -77,13 +77,17 @@ export class LibraryRepository {
.select((eb) =>
eb.fn
.countAll<number>()
.filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.IMAGE), eb('assets.isVisible', '=', true)]))
.filterWhere((eb) =>
eb.and([eb('assets.type', '=', AssetType.IMAGE), eb('assets.visibility', '!=', AssetVisibility.HIDDEN)]),
)
.as('photos'),
)
.select((eb) =>
eb.fn
.countAll<number>()
.filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.VIDEO), eb('assets.isVisible', '=', true)]))
.filterWhere((eb) =>
eb.and([eb('assets.type', '=', AssetType.VIDEO), eb('assets.visibility', '!=', AssetVisibility.HIDDEN)]),
)
.as('videos'),
)
.select((eb) => eb.fn.coalesce((eb) => eb.fn.sum('exif.fileSizeInByte'), eb.val(0)).as('usage'))

View file

@ -8,7 +8,7 @@ import readLine from 'node:readline';
import { citiesFile } from 'src/constants';
import { DB, GeodataPlaces, NaturalearthCountries } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators';
import { SystemMetadataKey } from 'src/enum';
import { AssetVisibility, SystemMetadataKey } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
@ -75,9 +75,11 @@ export class MapRepository {
}
@GenerateSql({ params: [[DummyValue.UUID], [DummyValue.UUID]] })
getMapMarkers(ownerIds: string[], albumIds: string[], options: MapMarkerSearchOptions = {}) {
const { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore } = options;
getMapMarkers(
ownerIds: string[],
albumIds: string[],
{ isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore }: MapMarkerSearchOptions = {},
) {
return this.db
.selectFrom('assets')
.innerJoin('exif', (builder) =>
@ -88,8 +90,17 @@ export class MapRepository {
)
.select(['id', 'exif.latitude as lat', 'exif.longitude as lon', 'exif.city', 'exif.state', 'exif.country'])
.$narrowType<{ lat: NotNull; lon: NotNull }>()
.where('isVisible', '=', true)
.$if(isArchived !== undefined, (q) => q.where('isArchived', '=', isArchived!))
.$if(isArchived === true, (qb) =>
qb.where((eb) =>
eb.or([
eb('assets.visibility', '=', AssetVisibility.TIMELINE),
eb('assets.visibility', '=', AssetVisibility.ARCHIVE),
]),
),
)
.$if(isArchived === false || isArchived === undefined, (qb) =>
qb.where('assets.visibility', '=', AssetVisibility.TIMELINE),
)
.$if(isFavorite !== undefined, (q) => q.where('isFavorite', '=', isFavorite!))
.$if(fileCreatedAfter !== undefined, (q) => q.where('fileCreatedAt', '>=', fileCreatedAfter!))
.$if(fileCreatedBefore !== undefined, (q) => q.where('fileCreatedAt', '<=', fileCreatedBefore!))

View file

@ -4,7 +4,7 @@ import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { AssetFaces, DB, FaceSearch, Person } from 'src/db';
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AssetFileType, SourceType } from 'src/enum';
import { AssetFileType, AssetVisibility, SourceType } from 'src/enum';
import { removeUndefinedKeys } from 'src/utils/database';
import { paginationHelper, PaginationOptions } from 'src/utils/pagination';
@ -157,7 +157,7 @@ export class PersonRepository {
.innerJoin('assets', (join) =>
join
.onRef('asset_faces.assetId', '=', 'assets.id')
.on('assets.isArchived', '=', false)
.on('assets.visibility', '!=', AssetVisibility.ARCHIVE)
.on('assets.deletedAt', 'is', null),
)
.where('person.ownerId', '=', userId)
@ -248,7 +248,7 @@ export class PersonRepository {
jsonObjectFrom(
eb
.selectFrom('assets')
.select(['assets.ownerId', 'assets.isArchived', 'assets.fileCreatedAt'])
.select(['assets.ownerId', 'assets.visibility', 'assets.fileCreatedAt'])
.whereRef('assets.id', '=', 'asset_faces.assetId'),
).as('asset'),
)
@ -346,7 +346,7 @@ export class PersonRepository {
join
.onRef('assets.id', '=', 'asset_faces.assetId')
.on('asset_faces.personId', '=', personId)
.on('assets.isArchived', '=', false)
.on('assets.visibility', '!=', AssetVisibility.ARCHIVE)
.on('assets.deletedAt', 'is', null),
)
.select((eb) => eb.fn.count(eb.fn('distinct', ['assets.id'])).as('count'))
@ -369,7 +369,7 @@ export class PersonRepository {
join
.onRef('assets.id', '=', 'asset_faces.assetId')
.on('assets.deletedAt', 'is', null)
.on('assets.isArchived', '=', false),
.on('assets.visibility', '!=', AssetVisibility.ARCHIVE),
)
.select((eb) => eb.fn.count(eb.fn('distinct', ['person.id'])).as('total'))
.select((eb) =>

View file

@ -5,7 +5,7 @@ import { randomUUID } from 'node:crypto';
import { DB, Exif } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetStatus, AssetType } from 'src/enum';
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { anyUuid, asUuid, searchAssetBuilder, vectorIndexQuery } from 'src/utils/database';
import { paginationHelper } from 'src/utils/pagination';
@ -26,17 +26,16 @@ export interface SearchUserIdOptions {
export type SearchIdOptions = SearchAssetIdOptions & SearchUserIdOptions;
export interface SearchStatusOptions {
isArchived?: boolean;
isEncoded?: boolean;
isFavorite?: boolean;
isMotion?: boolean;
isOffline?: boolean;
isVisible?: boolean;
isNotInAlbum?: boolean;
type?: AssetType;
status?: AssetStatus;
withArchived?: boolean;
withDeleted?: boolean;
visibility?: AssetVisibility;
}
export interface SearchOneToOneRelationOptions {
@ -276,7 +275,7 @@ export class SearchRepository {
.innerJoin('smart_search', 'assets.id', 'smart_search.assetId')
.where('assets.ownerId', '=', anyUuid(userIds))
.where('assets.deletedAt', 'is', null)
.where('assets.isVisible', '=', true)
.where('assets.visibility', '!=', AssetVisibility.HIDDEN)
.where('assets.type', '=', type)
.where('assets.id', '!=', asUuid(assetId))
.where('assets.stackId', 'is', null)
@ -367,8 +366,7 @@ export class SearchRepository {
.select(['city', 'assetId'])
.innerJoin('assets', 'assets.id', 'exif.assetId')
.where('assets.ownerId', '=', anyUuid(userIds))
.where('assets.isVisible', '=', true)
.where('assets.isArchived', '=', false)
.where('assets.visibility', '=', AssetVisibility.TIMELINE)
.where('assets.type', '=', AssetType.IMAGE)
.where('assets.deletedAt', 'is', null)
.orderBy('city')
@ -384,8 +382,7 @@ export class SearchRepository {
.select(['city', 'assetId'])
.innerJoin('assets', 'assets.id', 'exif.assetId')
.where('assets.ownerId', '=', anyUuid(userIds))
.where('assets.isVisible', '=', true)
.where('assets.isArchived', '=', false)
.where('assets.visibility', '=', AssetVisibility.TIMELINE)
.where('assets.type', '=', AssetType.IMAGE)
.where('assets.deletedAt', 'is', null)
.whereRef('exif.city', '>', 'cte.city')
@ -518,7 +515,7 @@ export class SearchRepository {
.distinctOn(field)
.innerJoin('assets', 'assets.id', 'exif.assetId')
.where('ownerId', '=', anyUuid(userIds))
.where('isVisible', '=', true)
.where('visibility', '!=', AssetVisibility.HIDDEN)
.where('deletedAt', 'is', null)
.where(field, 'is not', null);
}

View file

@ -6,7 +6,7 @@ import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { DB, UserMetadata as DbUserMetadata } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetType, UserStatus } from 'src/enum';
import { AssetType, AssetVisibility, UserStatus } from 'src/enum';
import { UserTable } from 'src/schema/tables/user.table';
import { UserMetadata, UserMetadataItem } from 'src/types';
import { asUuid } from 'src/utils/database';
@ -205,13 +205,19 @@ export class UserRepository {
eb.fn
.countAll<number>()
.filterWhere((eb) =>
eb.and([eb('assets.type', '=', sql.lit(AssetType.IMAGE)), eb('assets.isVisible', '=', sql.lit(true))]),
eb.and([
eb('assets.type', '=', sql.lit(AssetType.IMAGE)),
eb('assets.visibility', '!=', sql.lit(AssetVisibility.HIDDEN)),
]),
)
.as('photos'),
eb.fn
.countAll<number>()
.filterWhere((eb) =>
eb.and([eb('assets.type', '=', sql.lit(AssetType.VIDEO)), eb('assets.isVisible', '=', sql.lit(true))]),
eb.and([
eb('assets.type', '=', sql.lit(AssetType.VIDEO)),
eb('assets.visibility', '!=', sql.lit(AssetVisibility.HIDDEN)),
]),
)
.as('videos'),
eb.fn

View file

@ -2,6 +2,7 @@ import { Kysely } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { DB } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetVisibility } from 'src/enum';
import { asUuid, withExif } from 'src/utils/database';
export class ViewRepository {
@ -14,8 +15,7 @@ export class ViewRepository {
.select((eb) => eb.fn<string>('substring', ['assets.originalPath', eb.val('^(.*/)[^/]*$')]).as('directoryPath'))
.distinct()
.where('ownerId', '=', asUuid(userId))
.where('isVisible', '=', true)
.where('isArchived', '=', false)
.where('visibility', '=', AssetVisibility.TIMELINE)
.where('deletedAt', 'is', null)
.where('fileCreatedAt', 'is not', null)
.where('fileModifiedAt', 'is not', null)
@ -34,8 +34,7 @@ export class ViewRepository {
.selectAll('assets')
.$call(withExif)
.where('ownerId', '=', asUuid(userId))
.where('isVisible', '=', true)
.where('isArchived', '=', false)
.where('visibility', '=', AssetVisibility.TIMELINE)
.where('deletedAt', 'is', null)
.where('fileCreatedAt', 'is not', null)
.where('fileModifiedAt', 'is not', null)

View file

@ -1,3 +1,4 @@
import { AssetVisibility } from 'src/enum';
import { asset_face_source_type, assets_status_enum } from 'src/schema/enums';
import {
assets_delete_audit,
@ -45,7 +46,12 @@ import { UserAuditTable } from 'src/schema/tables/user-audit.table';
import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
import { UserTable } from 'src/schema/tables/user.table';
import { VersionHistoryTable } from 'src/schema/tables/version-history.table';
import { ConfigurationParameter, Database, Extensions } from 'src/sql-tools';
import { ConfigurationParameter, Database, Extensions, registerEnum } from 'src/sql-tools';
export const asset_visibility_enum = registerEnum({
name: 'asset_visibility_enum',
values: Object.values(AssetVisibility),
});
@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql'])
@ConfigurationParameter({ name: 'search_path', value: () => '"$user", public, vectors', scope: 'database' })

View file

@ -0,0 +1,37 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TYPE "asset_visibility_enum" AS ENUM ('archive','timeline','hidden');`.execute(db);
await sql`ALTER TABLE "assets"
ADD "visibility" asset_visibility_enum NOT NULL DEFAULT 'timeline';`.execute(db);
await sql`
UPDATE "assets"
SET "visibility" = CASE
WHEN "isArchived" THEN 'archive'::asset_visibility_enum
WHEN "isVisible" THEN 'timeline'::asset_visibility_enum
ELSE 'hidden'::asset_visibility_enum
END;
`.execute(db);
await sql`ALTER TABLE "assets" DROP COLUMN "isVisible";`.execute(db);
await sql`ALTER TABLE "assets" DROP COLUMN "isArchived";`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "assets" ADD COLUMN "isArchived" BOOLEAN NOT NULL DEFAULT FALSE;`.execute(db);
await sql`ALTER TABLE "assets" ADD COLUMN "isVisible" BOOLEAN NOT NULL DEFAULT TRUE;`.execute(db);
await sql`
UPDATE "assets"
SET
"isArchived" = ("visibility" = 'archive'::asset_visibility_enum),
"isVisible" = CASE
WHEN "visibility" = 'timeline'::asset_visibility_enum THEN TRUE
WHEN "visibility" = 'archive'::asset_visibility_enum THEN TRUE
ELSE FALSE
END;
`.execute(db);
await sql`ALTER TABLE "assets" DROP COLUMN "visibility";`.execute(db);
await sql`DROP TYPE "asset_visibility_enum";`.execute(db);
}

View file

@ -1,5 +1,6 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetStatus, AssetType } from 'src/enum';
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { asset_visibility_enum } from 'src/schema';
import { assets_status_enum } from 'src/schema/enums';
import { assets_delete_audit } from 'src/schema/functions';
import { LibraryTable } from 'src/schema/tables/library.table';
@ -95,9 +96,6 @@ export class AssetTable {
@Column({ type: 'bytea', index: true })
checksum!: Buffer; // sha1 checksum
@Column({ type: 'boolean', default: true })
isVisible!: boolean;
@ForeignKeyColumn(() => AssetTable, { nullable: true, onUpdate: 'CASCADE', onDelete: 'SET NULL' })
livePhotoVideoId!: string | null;
@ -107,9 +105,6 @@ export class AssetTable {
@CreateDateColumn()
createdAt!: Date;
@Column({ type: 'boolean', default: false })
isArchived!: boolean;
@Column({ index: true })
originalFileName!: string;
@ -145,4 +140,7 @@ export class AssetTable {
@UpdateIdColumn({ indexName: 'IDX_assets_update_id' })
updateId?: string;
@Column({ enum: asset_visibility_enum, default: AssetVisibility.TIMELINE })
visibility!: AssetVisibility;
}

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;

View file

@ -4,7 +4,7 @@ import { AssetFile } from 'src/database';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFileType, AssetType, Permission } from 'src/enum';
import { AssetFileType, AssetType, AssetVisibility, Permission } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard';
import { AccessRepository } from 'src/repositories/access.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
@ -150,8 +150,8 @@ export const onBeforeLink = async (
throw new BadRequestException('Live photo video does not belong to the user');
}
if (motionAsset?.isVisible) {
await assetRepository.update({ id: livePhotoVideoId, isVisible: false });
if (motionAsset && motionAsset.visibility === AssetVisibility.TIMELINE) {
await assetRepository.update({ id: livePhotoVideoId, visibility: AssetVisibility.HIDDEN });
await eventRepository.emit('asset.hide', { assetId: motionAsset.id, userId });
}
};
@ -174,9 +174,9 @@ export const onBeforeUnlink = async (
export const onAfterUnlink = async (
{ asset: assetRepository, event: eventRepository }: AssetHookRepositories,
{ userId, livePhotoVideoId }: { userId: string; livePhotoVideoId: string },
{ userId, livePhotoVideoId, visibility }: { userId: string; livePhotoVideoId: string; visibility: AssetVisibility },
) => {
await assetRepository.update({ id: livePhotoVideoId, isVisible: true });
await assetRepository.update({ id: livePhotoVideoId, visibility });
await eventRepository.emit('asset.show', { assetId: livePhotoVideoId, userId });
};

View file

@ -17,7 +17,7 @@ import { parse } from 'pg-connection-string';
import postgres, { Notice } from 'postgres';
import { columns, Exif, Person } from 'src/database';
import { DB } from 'src/db';
import { AssetFileType, DatabaseExtension, DatabaseSslMode } from 'src/enum';
import { AssetFileType, AssetVisibility, DatabaseExtension, DatabaseSslMode } from 'src/enum';
import { TimeBucketSize } from 'src/repositories/asset.repository';
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
import { DatabaseConnectionParams, VectorExtension } from 'src/types';
@ -155,6 +155,15 @@ export function toJson<DB, TB extends keyof DB & string, T extends TB | Expressi
export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum';
// TODO come up with a better query that only selects the fields we need
export function withDefaultVisibility<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
return qb.where((qb) =>
qb.or([
qb('assets.visibility', '=', AssetVisibility.TIMELINE),
qb('assets.visibility', '=', AssetVisibility.ARCHIVE),
]),
);
}
export function withExif<O>(qb: SelectQueryBuilder<DB, 'assets', O>) {
return qb
.leftJoin('exif', 'assets.id', 'exif.assetId')
@ -280,12 +289,14 @@ const joinDeduplicationPlugin = new DeduplicateJoinsPlugin();
/** TODO: This should only be used for search-related queries, not as a general purpose query builder */
export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuilderOptions) {
options.isArchived ??= options.withArchived ? undefined : false;
options.withDeleted ||= !!(options.trashedAfter || options.trashedBefore || options.isOffline);
const visibility = options.visibility == null ? AssetVisibility.TIMELINE : options.visibility;
return kysely
.withPlugin(joinDeduplicationPlugin)
.selectFrom('assets')
.selectAll('assets')
.where('assets.visibility', '=', visibility)
.$if(!!options.tagIds && options.tagIds.length > 0, (qb) => hasTags(qb, options.tagIds!))
.$if(!!options.personIds && options.personIds.length > 0, (qb) => hasPeople(qb, options.personIds!))
.$if(!!options.createdBefore, (qb) => qb.where('assets.createdAt', '<=', options.createdBefore!))
@ -356,8 +367,6 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
.$if(!!options.type, (qb) => qb.where('assets.type', '=', options.type!))
.$if(options.isFavorite !== undefined, (qb) => qb.where('assets.isFavorite', '=', options.isFavorite!))
.$if(options.isOffline !== undefined, (qb) => qb.where('assets.isOffline', '=', options.isOffline!))
.$if(options.isVisible !== undefined, (qb) => qb.where('assets.isVisible', '=', options.isVisible!))
.$if(options.isArchived !== undefined, (qb) => qb.where('assets.isArchived', '=', options.isArchived!))
.$if(options.isEncoded !== undefined, (qb) =>
qb.where('assets.encodedVideoPath', options.isEncoded ? 'is not' : 'is', null),
)

View file

@ -12,6 +12,7 @@ import {
IsArray,
IsBoolean,
IsDate,
IsEnum,
IsHexColor,
IsNotEmpty,
IsOptional,
@ -29,6 +30,7 @@ import {
import { CronJob } from 'cron';
import { DateTime } from 'luxon';
import sanitize from 'sanitize-filename';
import { AssetVisibility } from 'src/enum';
import { isIP, isIPRange } from 'validator';
@Injectable()
@ -146,6 +148,17 @@ export const ValidateDate = (options?: DateOptions) => {
return applyDecorators(...decorators);
};
type AssetVisibilityOptions = { optional?: boolean };
export const ValidateAssetVisibility = (options?: AssetVisibilityOptions) => {
const { optional } = { optional: false, ...options };
const decorators = [IsEnum(AssetVisibility), ApiProperty({ enumName: 'AssetVisibility', enum: AssetVisibility })];
if (optional) {
decorators.push(Optional());
}
return applyDecorators(...decorators);
};
type BooleanOptions = { optional?: boolean };
export const ValidateBoolean = (options?: BooleanOptions) => {
const { optional } = { optional: false, ...options };

View file

@ -1,6 +1,6 @@
import { AssetFace, AssetFile, Exif } from 'src/database';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetFileType, AssetStatus, AssetType } from 'src/enum';
import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { StorageAsset } from 'src/types';
import { authStub } from 'test/fixtures/auth.stub';
import { fileStub } from 'test/fixtures/file.stub';
@ -74,9 +74,7 @@ export const assetStub = {
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
sharedLinks: [],
@ -90,6 +88,7 @@ export const assetStub = {
libraryId: null,
stackId: null,
updateId: '42',
visibility: AssetVisibility.TIMELINE,
}),
noWebpPath: Object.freeze({
@ -111,9 +110,7 @@ export const assetStub = {
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
sharedLinks: [],
@ -130,6 +127,7 @@ export const assetStub = {
libraryId: null,
stackId: null,
updateId: '42',
visibility: AssetVisibility.TIMELINE,
}),
noThumbhash: Object.freeze({
@ -151,9 +149,7 @@ export const assetStub = {
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
duration: null,
isVisible: true,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
@ -167,6 +163,7 @@ export const assetStub = {
libraryId: null,
stackId: null,
updateId: '42',
visibility: AssetVisibility.TIMELINE,
}),
primaryImage: Object.freeze({
@ -188,9 +185,7 @@ export const assetStub = {
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
duration: null,
isVisible: true,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
@ -214,6 +209,7 @@ export const assetStub = {
isOffline: false,
updateId: '42',
libraryId: null,
visibility: AssetVisibility.TIMELINE,
}),
image: Object.freeze({
@ -235,9 +231,7 @@ export const assetStub = {
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2025-01-01T01:02:03.456Z'),
isFavorite: true,
isArchived: false,
duration: null,
isVisible: true,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
@ -257,6 +251,7 @@ export const assetStub = {
duplicateId: null,
isOffline: false,
stack: null,
visibility: AssetVisibility.TIMELINE,
}),
trashed: Object.freeze({
@ -278,9 +273,7 @@ export const assetStub = {
deletedAt: new Date('2023-02-24T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: false,
isArchived: false,
duration: null,
isVisible: true,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
@ -299,6 +292,7 @@ export const assetStub = {
libraryId: null,
stackId: null,
updateId: '42',
visibility: AssetVisibility.TIMELINE,
}),
trashedOffline: Object.freeze({
@ -321,10 +315,8 @@ export const assetStub = {
deletedAt: new Date('2023-02-24T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: false,
isArchived: false,
duration: null,
libraryId: 'library-id',
isVisible: true,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
@ -341,6 +333,7 @@ export const assetStub = {
isOffline: true,
stackId: null,
updateId: '42',
visibility: AssetVisibility.TIMELINE,
}),
archived: Object.freeze({
id: 'asset-id',
@ -361,9 +354,7 @@ export const assetStub = {
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: true,
duration: null,
isVisible: true,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
@ -382,6 +373,7 @@ export const assetStub = {
libraryId: null,
stackId: null,
updateId: '42',
visibility: AssetVisibility.TIMELINE,
}),
external: Object.freeze({
@ -403,10 +395,8 @@ export const assetStub = {
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
isExternal: true,
duration: null,
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
libraryId: 'library-id',
@ -423,6 +413,7 @@ export const assetStub = {
updateId: '42',
stackId: null,
stack: null,
visibility: AssetVisibility.TIMELINE,
}),
image1: Object.freeze({
@ -445,9 +436,7 @@ export const assetStub = {
deletedAt: null,
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
isExternal: false,
@ -464,6 +453,7 @@ export const assetStub = {
stackId: null,
libraryId: null,
stack: null,
visibility: AssetVisibility.TIMELINE,
}),
imageFrom2015: Object.freeze({
@ -485,10 +475,8 @@ export const assetStub = {
updatedAt: new Date('2015-02-23T05:06:29.716Z'),
localDateTime: new Date('2015-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
isExternal: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
sharedLinks: [],
@ -501,6 +489,7 @@ export const assetStub = {
deletedAt: null,
duplicateId: null,
isOffline: false,
visibility: AssetVisibility.TIMELINE,
}),
video: Object.freeze({
@ -523,10 +512,8 @@ export const assetStub = {
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
isExternal: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
sharedLinks: [],
@ -543,6 +530,7 @@ export const assetStub = {
updateId: '42',
libraryId: null,
stackId: null,
visibility: AssetVisibility.TIMELINE,
}),
livePhotoMotionAsset: Object.freeze({
@ -551,7 +539,6 @@ export const assetStub = {
originalPath: fileStub.livePhotoMotion.originalPath,
ownerId: authStub.user1.user.id,
type: AssetType.VIDEO,
isVisible: false,
fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
exifInfo: {
@ -559,6 +546,7 @@ export const assetStub = {
timeZone: `America/New_York`,
},
libraryId: null,
visibility: AssetVisibility.HIDDEN,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif }),
livePhotoStillAsset: Object.freeze({
@ -568,7 +556,6 @@ export const assetStub = {
ownerId: authStub.user1.user.id,
type: AssetType.IMAGE,
livePhotoVideoId: 'live-photo-motion-asset',
isVisible: true,
fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
exifInfo: {
@ -577,6 +564,7 @@ export const assetStub = {
},
files,
faces: [] as AssetFace[],
visibility: AssetVisibility.TIMELINE,
} as MapAsset & { faces: AssetFace[] }),
livePhotoWithOriginalFileName: Object.freeze({
@ -587,7 +575,6 @@ export const assetStub = {
ownerId: authStub.user1.user.id,
type: AssetType.IMAGE,
livePhotoVideoId: 'live-photo-motion-asset',
isVisible: true,
fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
exifInfo: {
@ -596,6 +583,7 @@ export const assetStub = {
},
libraryId: null,
faces: [] as AssetFace[],
visibility: AssetVisibility.TIMELINE,
} as MapAsset & { faces: AssetFace[] }),
withLocation: Object.freeze({
@ -618,10 +606,8 @@ export const assetStub = {
updatedAt: new Date('2023-02-22T05:06:29.716Z'),
localDateTime: new Date('2020-12-31T23:59:00.000Z'),
isFavorite: false,
isArchived: false,
isExternal: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
updateId: 'foo',
@ -642,6 +628,7 @@ export const assetStub = {
duplicateId: null,
isOffline: false,
tags: [],
visibility: AssetVisibility.TIMELINE,
}),
sidecar: Object.freeze({
@ -663,10 +650,8 @@ export const assetStub = {
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
isExternal: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
sharedLinks: [],
@ -679,6 +664,7 @@ export const assetStub = {
updateId: 'foo',
libraryId: null,
stackId: null,
visibility: AssetVisibility.TIMELINE,
}),
sidecarWithoutExt: Object.freeze({
@ -700,10 +686,8 @@ export const assetStub = {
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
isExternal: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
sharedLinks: [],
@ -713,6 +697,7 @@ export const assetStub = {
deletedAt: null,
duplicateId: null,
isOffline: false,
visibility: AssetVisibility.TIMELINE,
}),
hasEncodedVideo: Object.freeze({
@ -735,10 +720,8 @@ export const assetStub = {
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
isExternal: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
sharedLinks: [],
@ -754,6 +737,7 @@ export const assetStub = {
libraryId: null,
stackId: null,
stack: null,
visibility: AssetVisibility.TIMELINE,
}),
hasFileExtension: Object.freeze({
@ -775,10 +759,8 @@ export const assetStub = {
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
isExternal: true,
duration: null,
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
libraryId: 'library-id',
@ -792,6 +774,7 @@ export const assetStub = {
} as Exif,
duplicateId: null,
isOffline: false,
visibility: AssetVisibility.TIMELINE,
}),
imageDng: Object.freeze({
@ -813,9 +796,7 @@ export const assetStub = {
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
duration: null,
isVisible: true,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
@ -834,6 +815,7 @@ export const assetStub = {
updateId: '42',
libraryId: null,
stackId: null,
visibility: AssetVisibility.TIMELINE,
}),
imageHif: Object.freeze({
@ -855,9 +837,7 @@ export const assetStub = {
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
duration: null,
isVisible: true,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
@ -876,5 +856,6 @@ export const assetStub = {
updateId: '42',
libraryId: null,
stackId: null,
visibility: AssetVisibility.TIMELINE,
}),
};

View file

@ -4,7 +4,7 @@ import { AssetResponseDto, MapAsset } from 'src/dtos/asset-response.dto';
import { ExifResponseDto } from 'src/dtos/exif.dto';
import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto';
import { mapUser } from 'src/dtos/user.dto';
import { AssetOrder, AssetStatus, AssetType, SharedLinkType } from 'src/enum';
import { AssetOrder, AssetStatus, AssetType, AssetVisibility, SharedLinkType } from 'src/enum';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { userStub } from 'test/fixtures/user.stub';
@ -206,7 +206,6 @@ export const sharedLinkStub = {
thumbhash: null,
encodedVideoPath: '',
duration: null,
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
originalFileName: 'asset_1.jpeg',
@ -251,6 +250,7 @@ export const sharedLinkStub = {
updateId: '42',
libraryId: null,
stackId: null,
visibility: AssetVisibility.TIMELINE,
},
],
},

View file

@ -5,7 +5,7 @@ import { createHash, randomBytes } from 'node:crypto';
import { Writable } from 'node:stream';
import { AssetFace } from 'src/database';
import { AssetJobStatus, Assets, DB, FaceSearch, Person, Sessions } from 'src/db';
import { AssetType, SourceType } from 'src/enum';
import { AssetType, AssetVisibility, SourceType } from 'src/enum';
import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumRepository } from 'src/repositories/album.repository';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
@ -227,16 +227,37 @@ const getRepositoryMock = <K extends keyof RepositoryMocks>(key: K) => {
case 'database': {
return automock(DatabaseRepository, {
args: [undefined, { setContext: () => {} }, { getEnv: () => ({ database: { vectorExtension: '' } }) }],
args: [
undefined,
{
setContext: () => {},
},
{ getEnv: () => ({ database: { vectorExtension: '' } }) },
],
});
}
case 'email': {
return automock(EmailRepository, { args: [{ setContext: () => {} }] });
return automock(EmailRepository, {
args: [
{
setContext: () => {},
},
],
});
}
case 'job': {
return automock(JobRepository, { args: [undefined, undefined, undefined, { setContext: () => {} }] });
return automock(JobRepository, {
args: [
undefined,
undefined,
undefined,
{
setContext: () => {},
},
],
});
}
case 'logger': {
@ -345,11 +366,11 @@ const assetInsert = (asset: Partial<Insertable<Assets>> = {}) => {
type: AssetType.IMAGE,
originalPath: '/path/to/something.jpg',
ownerId: '@immich.cloud',
isVisible: true,
isFavorite: false,
fileCreatedAt: now,
fileModifiedAt: now,
localDateTime: now,
visibility: AssetVisibility.TIMELINE,
};
return {

View file

@ -456,9 +456,9 @@ describe(SyncService.name, () => {
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
isFavorite: asset.isFavorite,
isVisible: asset.isVisible,
localDateTime: asset.localDateTime,
type: asset.type,
visibility: asset.visibility,
},
type: 'AssetV1',
},
@ -573,9 +573,9 @@ describe(SyncService.name, () => {
fileCreatedAt: date,
fileModifiedAt: date,
isFavorite: false,
isVisible: true,
localDateTime: date,
type: asset.type,
visibility: asset.visibility,
},
type: SyncEntityType.PartnerAssetV1,
},

View file

@ -15,7 +15,7 @@ import {
} from 'src/database';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetStatus, AssetType, MemoryType, Permission, UserStatus } from 'src/enum';
import { AssetStatus, AssetType, AssetVisibility, MemoryType, Permission, UserStatus } from 'src/enum';
import { OnThisDayData } from 'src/types';
export const newUuid = () => randomUUID() as string;
@ -202,11 +202,9 @@ const assetFactory = (asset: Partial<MapAsset> = {}) => ({
encodedVideoPath: null,
fileCreatedAt: newDate(),
fileModifiedAt: newDate(),
isArchived: false,
isExternal: false,
isFavorite: false,
isOffline: false,
isVisible: true,
libraryId: null,
livePhotoVideoId: null,
localDateTime: newDate(),
@ -217,6 +215,7 @@ const assetFactory = (asset: Partial<MapAsset> = {}) => ({
stackId: null,
thumbhash: null,
type: AssetType.IMAGE,
visibility: AssetVisibility.TIMELINE,
...asset,
});

View file

@ -1,8 +1,8 @@
<script lang="ts" module>
import type { SearchLocationFilter } from './search-location-section.svelte';
import type { SearchDisplayFilters } from './search-display-section.svelte';
import type { SearchDateFilter } from './search-date-section.svelte';
import { MediaType, QueryType, validQueryTypes } from '$lib/constants';
import type { SearchDateFilter } from './search-date-section.svelte';
import type { SearchDisplayFilters } from './search-display-section.svelte';
import type { SearchLocationFilter } from './search-location-section.svelte';
export type SearchFilter = {
query: string;
@ -19,24 +19,24 @@
</script>
<script lang="ts">
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import { preferences } from '$lib/stores/user.store';
import { parseUtcDate } from '$lib/utils/date-time';
import { generateId } from '$lib/utils/generate-id';
import { AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
import { Button } from '@immich/ui';
import { AssetTypeEnum, type SmartSearchDto, type MetadataSearchDto } from '@immich/sdk';
import SearchPeopleSection from './search-people-section.svelte';
import SearchTagsSection from './search-tags-section.svelte';
import SearchLocationSection from './search-location-section.svelte';
import { mdiTune } from '@mdi/js';
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
import SearchCameraSection, { type SearchCameraFilter } from './search-camera-section.svelte';
import SearchDateSection from './search-date-section.svelte';
import SearchMediaSection from './search-media-section.svelte';
import SearchRatingsSection from './search-ratings-section.svelte';
import { parseUtcDate } from '$lib/utils/date-time';
import SearchDisplaySection from './search-display-section.svelte';
import SearchLocationSection from './search-location-section.svelte';
import SearchMediaSection from './search-media-section.svelte';
import SearchPeopleSection from './search-people-section.svelte';
import SearchRatingsSection from './search-ratings-section.svelte';
import SearchTagsSection from './search-tags-section.svelte';
import SearchTextSection from './search-text-section.svelte';
import { t } from 'svelte-i18n';
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import { mdiTune } from '@mdi/js';
import { generateId } from '$lib/utils/generate-id';
import { SvelteSet } from 'svelte/reactivity';
import { preferences } from '$lib/stores/user.store';
interface Props {
searchQuery: MetadataSearchDto | SmartSearchDto;
@ -83,7 +83,7 @@
takenBefore: searchQuery.takenBefore ? toStartOfDayDate(searchQuery.takenBefore) : undefined,
},
display: {
isArchive: searchQuery.isArchived,
isArchive: searchQuery.visibility === AssetVisibility.Archive,
isFavorite: searchQuery.isFavorite,
isNotInAlbum: 'isNotInAlbum' in searchQuery ? searchQuery.isNotInAlbum : undefined,
},
@ -132,7 +132,7 @@
model: filter.camera.model,
takenAfter: parseOptionalDate(filter.date.takenAfter)?.startOf('day').toISO() || undefined,
takenBefore: parseOptionalDate(filter.date.takenBefore)?.endOf('day').toISO() || undefined,
isArchived: filter.display.isArchive || undefined,
visibility: filter.display.isArchive ? AssetVisibility.Archive : undefined,
isFavorite: filter.display.isFavorite || undefined,
isNotInAlbum: filter.display.isNotInAlbum || undefined,
personIds: filter.personIds.size > 0 ? [...filter.personIds] : undefined,

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { locale } from '$lib/stores/preferences.store';
import {
AssetVisibility,
getAlbumStatistics,
getAssetStatistics,
type AlbumStatisticsResponseDto,
@ -41,9 +42,9 @@
const getUsage = async () => {
[timelineStats, favoriteStats, archiveStats, trashStats, albumStats] = await Promise.all([
getAssetStatistics({ isArchived: false }),
getAssetStatistics({ visibility: AssetVisibility.Timeline }),
getAssetStatistics({ isFavorite: true }),
getAssetStatistics({ isArchived: true }),
getAssetStatistics({ visibility: AssetVisibility.Archive }),
getAssetStatistics({ isTrashed: true }),
getAlbumStatistics(),
]);

View file

@ -10,6 +10,7 @@ import { formatDateGroupTitle, fromLocalDateTime } from '$lib/utils/timeline-uti
import { TUNABLES } from '$lib/utils/tunables';
import {
AssetOrder,
AssetVisibility,
getAssetInfo,
getTimeBucket,
getTimeBuckets,
@ -1375,7 +1376,7 @@ export class AssetStore {
isExcluded(asset: AssetResponseDto) {
return (
isMismatched(this.#options.isArchived, asset.isArchived) ||
isMismatched(this.#options.visibility === AssetVisibility.Archive, asset.isArchived) ||
isMismatched(this.#options.isFavorite, asset.isFavorite) ||
isMismatched(this.#options.isTrashed, asset.isTrashed)
);

View file

@ -1,7 +1,7 @@
import { goto } from '$app/navigation';
import FormatBoldMessage from '$lib/components/i18n/format-bold-message.svelte';
import type { InterpolationValues } from '$lib/components/i18n/format-message';
import { NotificationType, notificationController } from '$lib/components/shared-components/notification/notification';
import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification';
import { AppRoute } from '$lib/constants';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { downloadManager } from '$lib/managers/download-manager.svelte';
@ -15,6 +15,7 @@ import { getFormatter } from '$lib/utils/i18n';
import { navigate } from '$lib/utils/navigation';
import {
addAssetsToAlbum as addAssets,
AssetVisibility,
createStack,
deleteAssets,
deleteStacks,
@ -507,7 +508,7 @@ export const toggleArchive = async (asset: AssetResponseDto) => {
const data = await updateAsset({
id: asset.id,
updateAssetDto: {
isArchived: !asset.isArchived,
visibility: asset.isArchived ? AssetVisibility.Timeline : AssetVisibility.Archive,
},
});
@ -531,7 +532,9 @@ export const archiveAssets = async (assets: AssetResponseDto[], archive: boolean
try {
if (ids.length > 0) {
await updateAssets({ assetBulkUpdateDto: { ids, isArchived } });
await updateAssets({
assetBulkUpdateDto: { ids, visibility: isArchived ? AssetVisibility.Archive : AssetVisibility.Timeline },
});
}
for (const asset of assets) {

View file

@ -372,7 +372,10 @@
if (viewMode === AlbumPageViewMode.VIEW) {
void assetStore.updateOptions({ albumId, order: albumOrder });
} else if (viewMode === AlbumPageViewMode.SELECT_ASSETS) {
void assetStore.updateOptions({ isArchived: false, withPartners: true, timelineAlbumId: albumId });
void assetStore.updateOptions({
withPartners: true,
timelineAlbumId: albumId,
});
}
});
@ -385,9 +388,6 @@
activityManager.reset();
assetStore.destroy();
});
// let timelineStore = new AssetStore();
// $effect(() => void timelineStore.updateOptions({ isArchived: false, withPartners: true, timelineAlbumId: albumId }));
// onDestroy(() => timelineStore.destroy());
let isOwned = $derived($user.id == album.ownerId);

View file

@ -8,17 +8,18 @@
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AssetAction } from '$lib/constants';
import type { PageData } from './$types';
import { mdiPlus, mdiDotsVertical } from '@mdi/js';
import { t } from 'svelte-i18n';
import { onDestroy } from 'svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetStore } from '$lib/stores/assets-store.svelte';
import { AssetVisibility } from '@immich/sdk';
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props {
data: PageData;
@ -26,7 +27,7 @@
let { data }: Props = $props();
const assetStore = new AssetStore();
void assetStore.updateOptions({ isArchived: true });
void assetStore.updateOptions({ visibility: AssetVisibility.Archive });
onDestroy(() => assetStore.destroy());
const assetInteraction = new AssetInteraction();

View file

@ -9,19 +9,19 @@
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
import { AssetAction } from '$lib/constants';
import { AssetStore } from '$lib/stores/assets-store.svelte';
import type { PageData } from './$types';
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
import { t } from 'svelte-i18n';
import { onDestroy } from 'svelte';
import { preferences } from '$lib/stores/user.store';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetStore } from '$lib/stores/assets-store.svelte';
import { preferences } from '$lib/stores/user.store';
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props {
data: PageData;

View file

@ -4,16 +4,17 @@
import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import { AppRoute } from '$lib/constants';
import { AssetStore } from '$lib/stores/assets-store.svelte';
import { onDestroy } from 'svelte';
import type { PageData } from './$types';
import { mdiPlus, mdiArrowLeft } from '@mdi/js';
import { t } from 'svelte-i18n';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { AssetStore } from '$lib/stores/assets-store.svelte';
import { AssetVisibility } from '@immich/sdk';
import { mdiArrowLeft, mdiPlus } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
interface Props {
data: PageData;
@ -22,7 +23,14 @@
let { data }: Props = $props();
const assetStore = new AssetStore();
$effect(() => void assetStore.updateOptions({ userId: data.partner.id, isArchived: false, withStacked: true }));
$effect(
() =>
void assetStore.updateOptions({
userId: data.partner.id,
visibility: AssetVisibility.Timeline,
withStacked: true,
}),
);
onDestroy(() => assetStore.destroy());
const assetInteraction = new AssetInteraction();

View file

@ -34,12 +34,14 @@
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetStore } from '$lib/stores/assets-store.svelte';
import { locale } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket';
import { getPeopleThumbnailUrl, handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { isExternalUrl } from '$lib/utils/navigation';
import {
AssetVisibility,
getPersonStatistics,
mergePerson,
searchPerson,
@ -59,11 +61,10 @@
mdiHeartOutline,
mdiPlus,
} from '@mdi/js';
import { DateTime } from 'luxon';
import { onDestroy, onMount } from 'svelte';
import { t } from 'svelte-i18n';
import type { PageData } from './$types';
import { locale } from '$lib/stores/preferences.store';
import { DateTime } from 'luxon';
interface Props {
data: PageData;
@ -75,7 +76,7 @@
let { isViewing: showAssetViewer } = assetViewingStore;
const assetStore = new AssetStore();
$effect(() => void assetStore.updateOptions({ isArchived: false, personId: data.person.id }));
$effect(() => void assetStore.updateOptions({ visibility: AssetVisibility.Timeline, personId: data.person.id }));
onDestroy(() => assetStore.destroy());
const assetInteraction = new AssetInteraction();

View file

@ -32,14 +32,14 @@
type OnUnlink,
} from '$lib/utils/actions';
import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { AssetTypeEnum } from '@immich/sdk';
import { AssetTypeEnum, AssetVisibility } from '@immich/sdk';
import { mdiDotsVertical, mdiPlus } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
let { isViewing: showAssetViewer } = assetViewingStore;
const assetStore = new AssetStore();
void assetStore.updateOptions({ isArchived: false, withStacked: true, withPartners: true });
void assetStore.updateOptions({ visibility: AssetVisibility.Timeline, withStacked: true, withPartners: true });
onDestroy(() => assetStore.destroy());
const assetInteraction = new AssetInteraction();

View file

@ -1,25 +1,38 @@
<script lang="ts">
import { afterNavigate, goto } from '$app/navigation';
import { page } from '$app/state';
import { shortcut } from '$lib/actions/shortcut';
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
import Icon from '$lib/components/elements/icon.svelte';
import AddToAlbum from '$lib/components/photos-page/actions/add-to-album.svelte';
import ArchiveAction from '$lib/components/photos-page/actions/archive-action.svelte';
import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
import ChangeDate from '$lib/components/photos-page/actions/change-date-action.svelte';
import ChangeLocation from '$lib/components/photos-page/actions/change-location-action.svelte';
import CreateSharedLink from '$lib/components/photos-page/actions/create-shared-link.svelte';
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import SearchBar from '$lib/components/shared-components/search-bar/search-bar.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { shortcut } from '$lib/actions/shortcut';
import type { Viewport } from '$lib/stores/assets-store.svelte';
import { lang, locale } from '$lib/stores/preferences.store';
import { featureFlags } from '$lib/stores/server-config.store';
import { preferences } from '$lib/stores/user.store';
import { handlePromiseError } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { parseUtcDate } from '$lib/utils/date-time';
import { handleError } from '$lib/utils/handle-error';
import { isAlbumsRoute, isPeopleRoute } from '$lib/utils/navigation';
import {
type AlbumResponseDto,
type AssetResponseDto,
@ -31,21 +44,8 @@
type SmartSearchDto,
} from '@immich/sdk';
import { mdiArrowLeft, mdiDotsVertical, mdiImageOffOutline, mdiPlus, mdiSelectAll } from '@mdi/js';
import type { Viewport } from '$lib/stores/assets-store.svelte';
import { lang, locale } from '$lib/stores/preferences.store';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { handlePromiseError } from '$lib/utils';
import { parseUtcDate } from '$lib/utils/date-time';
import { featureFlags } from '$lib/stores/server-config.store';
import { handleError } from '$lib/utils/handle-error';
import AlbumCardGroup from '$lib/components/album-page/album-card-group.svelte';
import { isAlbumsRoute, isPeopleRoute } from '$lib/utils/navigation';
import { t } from 'svelte-i18n';
import { tick } from 'svelte';
import AssetJobActions from '$lib/components/photos-page/actions/asset-job-actions.svelte';
import { preferences } from '$lib/stores/user.store';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { t } from 'svelte-i18n';
const MAX_ASSET_COUNT = 5000;
let { isViewing: showAssetViewer } = assetViewingStore;
@ -186,7 +186,7 @@
const keyMap: Partial<Record<keyof SearchTerms, string>> = {
takenAfter: $t('start_date'),
takenBefore: $t('end_date'),
isArchived: $t('in_archive'),
visibility: $t('in_archive'),
isFavorite: $t('favorite'),
isNotInAlbum: $t('not_in_any_album'),
type: $t('media_type'),
@ -313,7 +313,7 @@
<div class="flex place-content-center place-items-center text-xs">
<div
class="bg-immich-primary py-2 px-4 text-white dark:text-black dark:bg-immich-dark-primary
{value === true ? 'rounded-full' : 'roudned-s-full'}"
{value === true ? 'rounded-full' : 'rounded-s-full'}"
>
{getHumanReadableSearchKey(key as keyof SearchTerms)}
</div>