diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 97e53d4a75..8196186059 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -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); }); diff --git a/e2e/src/api/specs/map.e2e-spec.ts b/e2e/src/api/specs/map.e2e-spec.ts index da5f779cff..977638aa24 100644 --- a/e2e/src/api/specs/map.e2e-spec.ts +++ b/e2e/src/api/specs/map.e2e-spec.ts @@ -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); diff --git a/e2e/src/api/specs/search.e2e-spec.ts b/e2e/src/api/specs/search.e2e-spec.ts index 23f8c65fa8..2f6ea75f77 100644 --- a/e2e/src/api/specs/search.e2e-spec.ts +++ b/e2e/src/api/specs/search.e2e-spec.ts @@ -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], diff --git a/e2e/src/api/specs/timeline.e2e-spec.ts b/e2e/src/api/specs/timeline.e2e-spec.ts index bf330e994a..93ba8b6527 100644 --- a/e2e/src/api/specs/timeline.e2e-spec.ts +++ b/e2e/src/api/specs/timeline.e2e-spec.ts @@ -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()); diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index 08b29a4a11..1d5004d385 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -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) }), diff --git a/mobile/lib/services/asset.service.dart b/mobile/lib/services/asset.service.dart index 4bf62eca31..8a24e72fbe 100644 --- a/mobile/lib/services/asset.service.dart +++ b/mobile/lib/services/asset.service.dart @@ -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; diff --git a/mobile/lib/services/search.service.dart b/mobile/lib/services/search.service.dart index 44ace78852..bcf67889c0 100644 --- a/mobile/lib/services/search.service.dart +++ b/mobile/lib/services/search.service.dart @@ -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(), diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 7c8afb09e4..5395f46801 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -302,6 +302,7 @@ Class | Method | HTTP request | Description - [AssetStackResponseDto](doc//AssetStackResponseDto.md) - [AssetStatsResponseDto](doc//AssetStatsResponseDto.md) - [AssetTypeEnum](doc//AssetTypeEnum.md) + - [AssetVisibility](doc//AssetVisibility.md) - [AudioCodec](doc//AudioCodec.md) - [AvatarUpdate](doc//AvatarUpdate.md) - [BulkIdResponseDto](doc//BulkIdResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index ab9b251e01..9a806a3f20 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -106,6 +106,7 @@ part 'model/asset_response_dto.dart'; part 'model/asset_stack_response_dto.dart'; part 'model/asset_stats_response_dto.dart'; part 'model/asset_type_enum.dart'; +part 'model/asset_visibility.dart'; part 'model/audio_codec.dart'; part 'model/avatar_update.dart'; part 'model/bulk_id_response_dto.dart'; diff --git a/mobile/openapi/lib/api/assets_api.dart b/mobile/openapi/lib/api/assets_api.dart index f744988449..06965e1f8b 100644 --- a/mobile/openapi/lib/api/assets_api.dart +++ b/mobile/openapi/lib/api/assets_api.dart @@ -342,12 +342,12 @@ class AssetsApi { /// Performs an HTTP 'GET /assets/statistics' operation and returns the [Response]. /// Parameters: /// - /// * [bool] isArchived: - /// /// * [bool] isFavorite: /// /// * [bool] isTrashed: - Future<Response> getAssetStatisticsWithHttpInfo({ bool? isArchived, bool? isFavorite, bool? isTrashed, }) async { + /// + /// * [AssetVisibility] visibility: + Future<Response> getAssetStatisticsWithHttpInfo({ bool? isFavorite, bool? isTrashed, AssetVisibility? visibility, }) async { // ignore: prefer_const_declarations final apiPath = r'/assets/statistics'; @@ -358,15 +358,15 @@ class AssetsApi { final headerParams = <String, String>{}; final formParams = <String, String>{}; - if (isArchived != null) { - queryParams.addAll(_queryParams('', 'isArchived', isArchived)); - } if (isFavorite != null) { queryParams.addAll(_queryParams('', 'isFavorite', isFavorite)); } if (isTrashed != null) { queryParams.addAll(_queryParams('', 'isTrashed', isTrashed)); } + if (visibility != null) { + queryParams.addAll(_queryParams('', 'visibility', visibility)); + } const contentTypes = <String>[]; @@ -384,13 +384,13 @@ class AssetsApi { /// Parameters: /// - /// * [bool] isArchived: - /// /// * [bool] isFavorite: /// /// * [bool] isTrashed: - Future<AssetStatsResponseDto?> getAssetStatistics({ bool? isArchived, bool? isFavorite, bool? isTrashed, }) async { - final response = await getAssetStatisticsWithHttpInfo( isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, ); + /// + /// * [AssetVisibility] visibility: + Future<AssetStatsResponseDto?> getAssetStatistics({ bool? isFavorite, bool? isTrashed, AssetVisibility? visibility, }) async { + final response = await getAssetStatisticsWithHttpInfo( isFavorite: isFavorite, isTrashed: isTrashed, visibility: visibility, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -788,16 +788,14 @@ class AssetsApi { /// /// * [String] duration: /// - /// * [bool] isArchived: - /// /// * [bool] isFavorite: /// - /// * [bool] isVisible: - /// /// * [String] livePhotoVideoId: /// /// * [MultipartFile] sidecarData: - Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async { + /// + /// * [AssetVisibility] visibility: + Future<Response> uploadAssetWithHttpInfo(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isFavorite, String? livePhotoVideoId, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { // ignore: prefer_const_declarations final apiPath = r'/assets'; @@ -845,18 +843,10 @@ class AssetsApi { hasFields = true; mp.fields[r'fileModifiedAt'] = parameterToString(fileModifiedAt); } - if (isArchived != null) { - hasFields = true; - mp.fields[r'isArchived'] = parameterToString(isArchived); - } if (isFavorite != null) { hasFields = true; mp.fields[r'isFavorite'] = parameterToString(isFavorite); } - if (isVisible != null) { - hasFields = true; - mp.fields[r'isVisible'] = parameterToString(isVisible); - } if (livePhotoVideoId != null) { hasFields = true; mp.fields[r'livePhotoVideoId'] = parameterToString(livePhotoVideoId); @@ -866,6 +856,10 @@ class AssetsApi { mp.fields[r'sidecarData'] = sidecarData.field; mp.files.add(sidecarData); } + if (visibility != null) { + hasFields = true; + mp.fields[r'visibility'] = parameterToString(visibility); + } if (hasFields) { postBody = mp; } @@ -900,17 +894,15 @@ class AssetsApi { /// /// * [String] duration: /// - /// * [bool] isArchived: - /// /// * [bool] isFavorite: /// - /// * [bool] isVisible: - /// /// * [String] livePhotoVideoId: /// /// * [MultipartFile] sidecarData: - Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isArchived, bool? isFavorite, bool? isVisible, String? livePhotoVideoId, MultipartFile? sidecarData, }) async { - final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, xImmichChecksum: xImmichChecksum, duration: duration, isArchived: isArchived, isFavorite: isFavorite, isVisible: isVisible, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, ); + /// + /// * [AssetVisibility] visibility: + Future<AssetMediaResponseDto?> uploadAsset(MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? xImmichChecksum, String? duration, bool? isFavorite, String? livePhotoVideoId, MultipartFile? sidecarData, AssetVisibility? visibility, }) async { + final response = await uploadAssetWithHttpInfo(assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, xImmichChecksum: xImmichChecksum, duration: duration, isFavorite: isFavorite, livePhotoVideoId: livePhotoVideoId, sidecarData: sidecarData, visibility: visibility, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api/timeline_api.dart b/mobile/openapi/lib/api/timeline_api.dart index 7ea7189b00..1d25a379e8 100644 --- a/mobile/openapi/lib/api/timeline_api.dart +++ b/mobile/openapi/lib/api/timeline_api.dart @@ -25,8 +25,6 @@ class TimelineApi { /// /// * [String] albumId: /// - /// * [bool] isArchived: - /// /// * [bool] isFavorite: /// /// * [bool] isTrashed: @@ -41,10 +39,12 @@ class TimelineApi { /// /// * [String] userId: /// + /// * [AssetVisibility] visibility: + /// /// * [bool] withPartners: /// /// * [bool] withStacked: - Future<Response> getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { + Future<Response> getTimeBucketWithHttpInfo(TimeBucketSize size, String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async { // ignore: prefer_const_declarations final apiPath = r'/timeline/bucket'; @@ -58,9 +58,6 @@ class TimelineApi { if (albumId != null) { queryParams.addAll(_queryParams('', 'albumId', albumId)); } - if (isArchived != null) { - queryParams.addAll(_queryParams('', 'isArchived', isArchived)); - } if (isFavorite != null) { queryParams.addAll(_queryParams('', 'isFavorite', isFavorite)); } @@ -84,6 +81,9 @@ class TimelineApi { if (userId != null) { queryParams.addAll(_queryParams('', 'userId', userId)); } + if (visibility != null) { + queryParams.addAll(_queryParams('', 'visibility', visibility)); + } if (withPartners != null) { queryParams.addAll(_queryParams('', 'withPartners', withPartners)); } @@ -113,8 +113,6 @@ class TimelineApi { /// /// * [String] albumId: /// - /// * [bool] isArchived: - /// /// * [bool] isFavorite: /// /// * [bool] isTrashed: @@ -129,11 +127,13 @@ class TimelineApi { /// /// * [String] userId: /// + /// * [AssetVisibility] visibility: + /// /// * [bool] withPartners: /// /// * [bool] withStacked: - Future<List<AssetResponseDto>?> getTimeBucket(TimeBucketSize size, String timeBucket, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { - final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); + Future<List<AssetResponseDto>?> getTimeBucket(TimeBucketSize size, String timeBucket, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async { + final response = await getTimeBucketWithHttpInfo(size, timeBucket, albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, visibility: visibility, withPartners: withPartners, withStacked: withStacked, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -157,8 +157,6 @@ class TimelineApi { /// /// * [String] albumId: /// - /// * [bool] isArchived: - /// /// * [bool] isFavorite: /// /// * [bool] isTrashed: @@ -173,10 +171,12 @@ class TimelineApi { /// /// * [String] userId: /// + /// * [AssetVisibility] visibility: + /// /// * [bool] withPartners: /// /// * [bool] withStacked: - Future<Response> getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { + Future<Response> getTimeBucketsWithHttpInfo(TimeBucketSize size, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async { // ignore: prefer_const_declarations final apiPath = r'/timeline/buckets'; @@ -190,9 +190,6 @@ class TimelineApi { if (albumId != null) { queryParams.addAll(_queryParams('', 'albumId', albumId)); } - if (isArchived != null) { - queryParams.addAll(_queryParams('', 'isArchived', isArchived)); - } if (isFavorite != null) { queryParams.addAll(_queryParams('', 'isFavorite', isFavorite)); } @@ -215,6 +212,9 @@ class TimelineApi { if (userId != null) { queryParams.addAll(_queryParams('', 'userId', userId)); } + if (visibility != null) { + queryParams.addAll(_queryParams('', 'visibility', visibility)); + } if (withPartners != null) { queryParams.addAll(_queryParams('', 'withPartners', withPartners)); } @@ -242,8 +242,6 @@ class TimelineApi { /// /// * [String] albumId: /// - /// * [bool] isArchived: - /// /// * [bool] isFavorite: /// /// * [bool] isTrashed: @@ -258,11 +256,13 @@ class TimelineApi { /// /// * [String] userId: /// + /// * [AssetVisibility] visibility: + /// /// * [bool] withPartners: /// /// * [bool] withStacked: - Future<List<TimeBucketResponseDto>?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isArchived, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, bool? withPartners, bool? withStacked, }) async { - final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isArchived: isArchived, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, withPartners: withPartners, withStacked: withStacked, ); + Future<List<TimeBucketResponseDto>?> getTimeBuckets(TimeBucketSize size, { String? albumId, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? tagId, String? userId, AssetVisibility? visibility, bool? withPartners, bool? withStacked, }) async { + final response = await getTimeBucketsWithHttpInfo(size, albumId: albumId, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, tagId: tagId, userId: userId, visibility: visibility, withPartners: withPartners, withStacked: withStacked, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index ec5eb09729..041a584296 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -268,6 +268,8 @@ class ApiClient { return AssetStatsResponseDto.fromJson(value); case 'AssetTypeEnum': return AssetTypeEnumTypeTransformer().decode(value); + case 'AssetVisibility': + return AssetVisibilityTypeTransformer().decode(value); case 'AudioCodec': return AudioCodecTypeTransformer().decode(value); case 'AvatarUpdate': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index eec991e903..4928adf767 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -73,6 +73,9 @@ String parameterToString(dynamic value) { if (value is AssetTypeEnum) { return AssetTypeEnumTypeTransformer().encode(value).toString(); } + if (value is AssetVisibility) { + return AssetVisibilityTypeTransformer().encode(value).toString(); + } if (value is AudioCodec) { return AudioCodecTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/asset_bulk_update_dto.dart b/mobile/openapi/lib/model/asset_bulk_update_dto.dart index 0b5a2c30d9..39d7cd996f 100644 --- a/mobile/openapi/lib/model/asset_bulk_update_dto.dart +++ b/mobile/openapi/lib/model/asset_bulk_update_dto.dart @@ -16,11 +16,11 @@ class AssetBulkUpdateDto { this.dateTimeOriginal, this.duplicateId, this.ids = const [], - this.isArchived, this.isFavorite, this.latitude, this.longitude, this.rating, + this.visibility, }); /// @@ -35,14 +35,6 @@ class AssetBulkUpdateDto { List<String> ids; - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - bool? isArchived; - /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -77,16 +69,24 @@ class AssetBulkUpdateDto { /// num? rating; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + AssetVisibility? visibility; + @override bool operator ==(Object other) => identical(this, other) || other is AssetBulkUpdateDto && other.dateTimeOriginal == dateTimeOriginal && other.duplicateId == duplicateId && _deepEquality.equals(other.ids, ids) && - other.isArchived == isArchived && other.isFavorite == isFavorite && other.latitude == latitude && other.longitude == longitude && - other.rating == rating; + other.rating == rating && + other.visibility == visibility; @override int get hashCode => @@ -94,14 +94,14 @@ class AssetBulkUpdateDto { (dateTimeOriginal == null ? 0 : dateTimeOriginal!.hashCode) + (duplicateId == null ? 0 : duplicateId!.hashCode) + (ids.hashCode) + - (isArchived == null ? 0 : isArchived!.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) + (latitude == null ? 0 : latitude!.hashCode) + (longitude == null ? 0 : longitude!.hashCode) + - (rating == null ? 0 : rating!.hashCode); + (rating == null ? 0 : rating!.hashCode) + + (visibility == null ? 0 : visibility!.hashCode); @override - String toString() => 'AssetBulkUpdateDto[dateTimeOriginal=$dateTimeOriginal, duplicateId=$duplicateId, ids=$ids, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, rating=$rating]'; + String toString() => 'AssetBulkUpdateDto[dateTimeOriginal=$dateTimeOriginal, duplicateId=$duplicateId, ids=$ids, isFavorite=$isFavorite, latitude=$latitude, longitude=$longitude, rating=$rating, visibility=$visibility]'; Map<String, dynamic> toJson() { final json = <String, dynamic>{}; @@ -116,11 +116,6 @@ class AssetBulkUpdateDto { // json[r'duplicateId'] = null; } json[r'ids'] = this.ids; - if (this.isArchived != null) { - json[r'isArchived'] = this.isArchived; - } else { - // json[r'isArchived'] = null; - } if (this.isFavorite != null) { json[r'isFavorite'] = this.isFavorite; } else { @@ -141,6 +136,11 @@ class AssetBulkUpdateDto { } else { // json[r'rating'] = null; } + if (this.visibility != null) { + json[r'visibility'] = this.visibility; + } else { + // json[r'visibility'] = null; + } return json; } @@ -158,11 +158,11 @@ class AssetBulkUpdateDto { ids: json[r'ids'] is Iterable ? (json[r'ids'] as Iterable).cast<String>().toList(growable: false) : const [], - isArchived: mapValueOfType<bool>(json, r'isArchived'), isFavorite: mapValueOfType<bool>(json, r'isFavorite'), latitude: num.parse('${json[r'latitude']}'), longitude: num.parse('${json[r'longitude']}'), rating: num.parse('${json[r'rating']}'), + visibility: AssetVisibility.fromJson(json[r'visibility']), ); } return null; diff --git a/mobile/openapi/lib/model/asset_visibility.dart b/mobile/openapi/lib/model/asset_visibility.dart new file mode 100644 index 0000000000..4d0c7ee8d3 --- /dev/null +++ b/mobile/openapi/lib/model/asset_visibility.dart @@ -0,0 +1,88 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class AssetVisibility { + /// Instantiate a new enum with the provided [value]. + const AssetVisibility._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const archive = AssetVisibility._(r'archive'); + static const timeline = AssetVisibility._(r'timeline'); + static const hidden = AssetVisibility._(r'hidden'); + + /// List of all possible values in this [enum][AssetVisibility]. + static const values = <AssetVisibility>[ + archive, + timeline, + hidden, + ]; + + static AssetVisibility? fromJson(dynamic value) => AssetVisibilityTypeTransformer().decode(value); + + static List<AssetVisibility> listFromJson(dynamic json, {bool growable = false,}) { + final result = <AssetVisibility>[]; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetVisibility.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [AssetVisibility] to String, +/// and [decode] dynamic data back to [AssetVisibility]. +class AssetVisibilityTypeTransformer { + factory AssetVisibilityTypeTransformer() => _instance ??= const AssetVisibilityTypeTransformer._(); + + const AssetVisibilityTypeTransformer._(); + + String encode(AssetVisibility data) => data.value; + + /// Decodes a [dynamic value][data] to a AssetVisibility. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + AssetVisibility? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'archive': return AssetVisibility.archive; + case r'timeline': return AssetVisibility.timeline; + case r'hidden': return AssetVisibility.hidden; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [AssetVisibilityTypeTransformer] instance. + static AssetVisibilityTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/metadata_search_dto.dart b/mobile/openapi/lib/model/metadata_search_dto.dart index 3fb003d164..7f1184467b 100644 --- a/mobile/openapi/lib/model/metadata_search_dto.dart +++ b/mobile/openapi/lib/model/metadata_search_dto.dart @@ -23,13 +23,11 @@ class MetadataSearchDto { this.deviceId, this.encodedVideoPath, this.id, - this.isArchived, this.isEncoded, this.isFavorite, this.isMotion, this.isNotInAlbum, this.isOffline, - this.isVisible, this.lensModel, this.libraryId, this.make, @@ -52,7 +50,7 @@ class MetadataSearchDto { this.type, this.updatedAfter, this.updatedBefore, - this.withArchived = false, + this.visibility, this.withDeleted, this.withExif, this.withPeople, @@ -127,14 +125,6 @@ class MetadataSearchDto { /// String? id; - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - bool? isArchived; - /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -175,14 +165,6 @@ class MetadataSearchDto { /// bool? isOffline; - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - bool? isVisible; - String? lensModel; String? libraryId; @@ -322,7 +304,13 @@ class MetadataSearchDto { /// DateTime? updatedBefore; - bool withArchived; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + AssetVisibility? visibility; /// /// Please note: This property should have been non-nullable! Since the specification file @@ -368,13 +356,11 @@ class MetadataSearchDto { other.deviceId == deviceId && other.encodedVideoPath == encodedVideoPath && other.id == id && - other.isArchived == isArchived && other.isEncoded == isEncoded && other.isFavorite == isFavorite && other.isMotion == isMotion && other.isNotInAlbum == isNotInAlbum && other.isOffline == isOffline && - other.isVisible == isVisible && other.lensModel == lensModel && other.libraryId == libraryId && other.make == make && @@ -397,7 +383,7 @@ class MetadataSearchDto { other.type == type && other.updatedAfter == updatedAfter && other.updatedBefore == updatedBefore && - other.withArchived == withArchived && + other.visibility == visibility && other.withDeleted == withDeleted && other.withExif == withExif && other.withPeople == withPeople && @@ -416,13 +402,11 @@ class MetadataSearchDto { (deviceId == null ? 0 : deviceId!.hashCode) + (encodedVideoPath == null ? 0 : encodedVideoPath!.hashCode) + (id == null ? 0 : id!.hashCode) + - (isArchived == null ? 0 : isArchived!.hashCode) + (isEncoded == null ? 0 : isEncoded!.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) + (isMotion == null ? 0 : isMotion!.hashCode) + (isNotInAlbum == null ? 0 : isNotInAlbum!.hashCode) + (isOffline == null ? 0 : isOffline!.hashCode) + - (isVisible == null ? 0 : isVisible!.hashCode) + (lensModel == null ? 0 : lensModel!.hashCode) + (libraryId == null ? 0 : libraryId!.hashCode) + (make == null ? 0 : make!.hashCode) + @@ -445,14 +429,14 @@ class MetadataSearchDto { (type == null ? 0 : type!.hashCode) + (updatedAfter == null ? 0 : updatedAfter!.hashCode) + (updatedBefore == null ? 0 : updatedBefore!.hashCode) + - (withArchived.hashCode) + + (visibility == null ? 0 : visibility!.hashCode) + (withDeleted == null ? 0 : withDeleted!.hashCode) + (withExif == null ? 0 : withExif!.hashCode) + (withPeople == null ? 0 : withPeople!.hashCode) + (withStacked == null ? 0 : withStacked!.hashCode); @override - String toString() => 'MetadataSearchDto[checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, description=$description, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; + String toString() => 'MetadataSearchDto[checksum=$checksum, city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, description=$description, deviceAssetId=$deviceAssetId, deviceId=$deviceId, encodedVideoPath=$encodedVideoPath, id=$id, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, order=$order, originalFileName=$originalFileName, originalPath=$originalPath, page=$page, personIds=$personIds, previewPath=$previewPath, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, thumbnailPath=$thumbnailPath, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; Map<String, dynamic> toJson() { final json = <String, dynamic>{}; @@ -506,11 +490,6 @@ class MetadataSearchDto { } else { // json[r'id'] = null; } - if (this.isArchived != null) { - json[r'isArchived'] = this.isArchived; - } else { - // json[r'isArchived'] = null; - } if (this.isEncoded != null) { json[r'isEncoded'] = this.isEncoded; } else { @@ -536,11 +515,6 @@ class MetadataSearchDto { } else { // json[r'isOffline'] = null; } - if (this.isVisible != null) { - json[r'isVisible'] = this.isVisible; - } else { - // json[r'isVisible'] = null; - } if (this.lensModel != null) { json[r'lensModel'] = this.lensModel; } else { @@ -639,7 +613,11 @@ class MetadataSearchDto { } else { // json[r'updatedBefore'] = null; } - json[r'withArchived'] = this.withArchived; + if (this.visibility != null) { + json[r'visibility'] = this.visibility; + } else { + // json[r'visibility'] = null; + } if (this.withDeleted != null) { json[r'withDeleted'] = this.withDeleted; } else { @@ -682,13 +660,11 @@ class MetadataSearchDto { deviceId: mapValueOfType<String>(json, r'deviceId'), encodedVideoPath: mapValueOfType<String>(json, r'encodedVideoPath'), id: mapValueOfType<String>(json, r'id'), - isArchived: mapValueOfType<bool>(json, r'isArchived'), isEncoded: mapValueOfType<bool>(json, r'isEncoded'), isFavorite: mapValueOfType<bool>(json, r'isFavorite'), isMotion: mapValueOfType<bool>(json, r'isMotion'), isNotInAlbum: mapValueOfType<bool>(json, r'isNotInAlbum'), isOffline: mapValueOfType<bool>(json, r'isOffline'), - isVisible: mapValueOfType<bool>(json, r'isVisible'), lensModel: mapValueOfType<String>(json, r'lensModel'), libraryId: mapValueOfType<String>(json, r'libraryId'), make: mapValueOfType<String>(json, r'make'), @@ -715,7 +691,7 @@ class MetadataSearchDto { type: AssetTypeEnum.fromJson(json[r'type']), updatedAfter: mapDateTime(json, r'updatedAfter', r''), updatedBefore: mapDateTime(json, r'updatedBefore', r''), - withArchived: mapValueOfType<bool>(json, r'withArchived') ?? false, + visibility: AssetVisibility.fromJson(json[r'visibility']), withDeleted: mapValueOfType<bool>(json, r'withDeleted'), withExif: mapValueOfType<bool>(json, r'withExif'), withPeople: mapValueOfType<bool>(json, r'withPeople'), diff --git a/mobile/openapi/lib/model/random_search_dto.dart b/mobile/openapi/lib/model/random_search_dto.dart index 10727ec10d..0284212efc 100644 --- a/mobile/openapi/lib/model/random_search_dto.dart +++ b/mobile/openapi/lib/model/random_search_dto.dart @@ -18,13 +18,11 @@ class RandomSearchDto { this.createdAfter, this.createdBefore, this.deviceId, - this.isArchived, this.isEncoded, this.isFavorite, this.isMotion, this.isNotInAlbum, this.isOffline, - this.isVisible, this.lensModel, this.libraryId, this.make, @@ -41,7 +39,7 @@ class RandomSearchDto { this.type, this.updatedAfter, this.updatedBefore, - this.withArchived = false, + this.visibility, this.withDeleted, this.withExif, this.withPeople, @@ -76,14 +74,6 @@ class RandomSearchDto { /// String? deviceId; - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - bool? isArchived; - /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -124,14 +114,6 @@ class RandomSearchDto { /// bool? isOffline; - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - bool? isVisible; - String? lensModel; String? libraryId; @@ -228,7 +210,13 @@ class RandomSearchDto { /// DateTime? updatedBefore; - bool withArchived; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + AssetVisibility? visibility; /// /// Please note: This property should have been non-nullable! Since the specification file @@ -269,13 +257,11 @@ class RandomSearchDto { other.createdAfter == createdAfter && other.createdBefore == createdBefore && other.deviceId == deviceId && - other.isArchived == isArchived && other.isEncoded == isEncoded && other.isFavorite == isFavorite && other.isMotion == isMotion && other.isNotInAlbum == isNotInAlbum && other.isOffline == isOffline && - other.isVisible == isVisible && other.lensModel == lensModel && other.libraryId == libraryId && other.make == make && @@ -292,7 +278,7 @@ class RandomSearchDto { other.type == type && other.updatedAfter == updatedAfter && other.updatedBefore == updatedBefore && - other.withArchived == withArchived && + other.visibility == visibility && other.withDeleted == withDeleted && other.withExif == withExif && other.withPeople == withPeople && @@ -306,13 +292,11 @@ class RandomSearchDto { (createdAfter == null ? 0 : createdAfter!.hashCode) + (createdBefore == null ? 0 : createdBefore!.hashCode) + (deviceId == null ? 0 : deviceId!.hashCode) + - (isArchived == null ? 0 : isArchived!.hashCode) + (isEncoded == null ? 0 : isEncoded!.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) + (isMotion == null ? 0 : isMotion!.hashCode) + (isNotInAlbum == null ? 0 : isNotInAlbum!.hashCode) + (isOffline == null ? 0 : isOffline!.hashCode) + - (isVisible == null ? 0 : isVisible!.hashCode) + (lensModel == null ? 0 : lensModel!.hashCode) + (libraryId == null ? 0 : libraryId!.hashCode) + (make == null ? 0 : make!.hashCode) + @@ -329,14 +313,14 @@ class RandomSearchDto { (type == null ? 0 : type!.hashCode) + (updatedAfter == null ? 0 : updatedAfter!.hashCode) + (updatedBefore == null ? 0 : updatedBefore!.hashCode) + - (withArchived.hashCode) + + (visibility == null ? 0 : visibility!.hashCode) + (withDeleted == null ? 0 : withDeleted!.hashCode) + (withExif == null ? 0 : withExif!.hashCode) + (withPeople == null ? 0 : withPeople!.hashCode) + (withStacked == null ? 0 : withStacked!.hashCode); @override - String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; + String toString() => 'RandomSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, personIds=$personIds, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif, withPeople=$withPeople, withStacked=$withStacked]'; Map<String, dynamic> toJson() { final json = <String, dynamic>{}; @@ -365,11 +349,6 @@ class RandomSearchDto { } else { // json[r'deviceId'] = null; } - if (this.isArchived != null) { - json[r'isArchived'] = this.isArchived; - } else { - // json[r'isArchived'] = null; - } if (this.isEncoded != null) { json[r'isEncoded'] = this.isEncoded; } else { @@ -395,11 +374,6 @@ class RandomSearchDto { } else { // json[r'isOffline'] = null; } - if (this.isVisible != null) { - json[r'isVisible'] = this.isVisible; - } else { - // json[r'isVisible'] = null; - } if (this.lensModel != null) { json[r'lensModel'] = this.lensModel; } else { @@ -472,7 +446,11 @@ class RandomSearchDto { } else { // json[r'updatedBefore'] = null; } - json[r'withArchived'] = this.withArchived; + if (this.visibility != null) { + json[r'visibility'] = this.visibility; + } else { + // json[r'visibility'] = null; + } if (this.withDeleted != null) { json[r'withDeleted'] = this.withDeleted; } else { @@ -510,13 +488,11 @@ class RandomSearchDto { createdAfter: mapDateTime(json, r'createdAfter', r''), createdBefore: mapDateTime(json, r'createdBefore', r''), deviceId: mapValueOfType<String>(json, r'deviceId'), - isArchived: mapValueOfType<bool>(json, r'isArchived'), isEncoded: mapValueOfType<bool>(json, r'isEncoded'), isFavorite: mapValueOfType<bool>(json, r'isFavorite'), isMotion: mapValueOfType<bool>(json, r'isMotion'), isNotInAlbum: mapValueOfType<bool>(json, r'isNotInAlbum'), isOffline: mapValueOfType<bool>(json, r'isOffline'), - isVisible: mapValueOfType<bool>(json, r'isVisible'), lensModel: mapValueOfType<String>(json, r'lensModel'), libraryId: mapValueOfType<String>(json, r'libraryId'), make: mapValueOfType<String>(json, r'make'), @@ -537,7 +513,7 @@ class RandomSearchDto { type: AssetTypeEnum.fromJson(json[r'type']), updatedAfter: mapDateTime(json, r'updatedAfter', r''), updatedBefore: mapDateTime(json, r'updatedBefore', r''), - withArchived: mapValueOfType<bool>(json, r'withArchived') ?? false, + visibility: AssetVisibility.fromJson(json[r'visibility']), withDeleted: mapValueOfType<bool>(json, r'withDeleted'), withExif: mapValueOfType<bool>(json, r'withExif'), withPeople: mapValueOfType<bool>(json, r'withPeople'), diff --git a/mobile/openapi/lib/model/smart_search_dto.dart b/mobile/openapi/lib/model/smart_search_dto.dart index 47c800ff09..a915d97b31 100644 --- a/mobile/openapi/lib/model/smart_search_dto.dart +++ b/mobile/openapi/lib/model/smart_search_dto.dart @@ -18,13 +18,11 @@ class SmartSearchDto { this.createdAfter, this.createdBefore, this.deviceId, - this.isArchived, this.isEncoded, this.isFavorite, this.isMotion, this.isNotInAlbum, this.isOffline, - this.isVisible, this.language, this.lensModel, this.libraryId, @@ -44,7 +42,7 @@ class SmartSearchDto { this.type, this.updatedAfter, this.updatedBefore, - this.withArchived = false, + this.visibility, this.withDeleted, this.withExif, }); @@ -77,14 +75,6 @@ class SmartSearchDto { /// String? deviceId; - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - bool? isArchived; - /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -125,14 +115,6 @@ class SmartSearchDto { /// bool? isOffline; - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - bool? isVisible; - /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -248,7 +230,13 @@ class SmartSearchDto { /// DateTime? updatedBefore; - bool withArchived; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + AssetVisibility? visibility; /// /// Please note: This property should have been non-nullable! Since the specification file @@ -273,13 +261,11 @@ class SmartSearchDto { other.createdAfter == createdAfter && other.createdBefore == createdBefore && other.deviceId == deviceId && - other.isArchived == isArchived && other.isEncoded == isEncoded && other.isFavorite == isFavorite && other.isMotion == isMotion && other.isNotInAlbum == isNotInAlbum && other.isOffline == isOffline && - other.isVisible == isVisible && other.language == language && other.lensModel == lensModel && other.libraryId == libraryId && @@ -299,7 +285,7 @@ class SmartSearchDto { other.type == type && other.updatedAfter == updatedAfter && other.updatedBefore == updatedBefore && - other.withArchived == withArchived && + other.visibility == visibility && other.withDeleted == withDeleted && other.withExif == withExif; @@ -311,13 +297,11 @@ class SmartSearchDto { (createdAfter == null ? 0 : createdAfter!.hashCode) + (createdBefore == null ? 0 : createdBefore!.hashCode) + (deviceId == null ? 0 : deviceId!.hashCode) + - (isArchived == null ? 0 : isArchived!.hashCode) + (isEncoded == null ? 0 : isEncoded!.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) + (isMotion == null ? 0 : isMotion!.hashCode) + (isNotInAlbum == null ? 0 : isNotInAlbum!.hashCode) + (isOffline == null ? 0 : isOffline!.hashCode) + - (isVisible == null ? 0 : isVisible!.hashCode) + (language == null ? 0 : language!.hashCode) + (lensModel == null ? 0 : lensModel!.hashCode) + (libraryId == null ? 0 : libraryId!.hashCode) + @@ -337,12 +321,12 @@ class SmartSearchDto { (type == null ? 0 : type!.hashCode) + (updatedAfter == null ? 0 : updatedAfter!.hashCode) + (updatedBefore == null ? 0 : updatedBefore!.hashCode) + - (withArchived.hashCode) + + (visibility == null ? 0 : visibility!.hashCode) + (withDeleted == null ? 0 : withDeleted!.hashCode) + (withExif == null ? 0 : withExif!.hashCode); @override - String toString() => 'SmartSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isArchived=$isArchived, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, isVisible=$isVisible, language=$language, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, query=$query, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, withArchived=$withArchived, withDeleted=$withDeleted, withExif=$withExif]'; + String toString() => 'SmartSearchDto[city=$city, country=$country, createdAfter=$createdAfter, createdBefore=$createdBefore, deviceId=$deviceId, isEncoded=$isEncoded, isFavorite=$isFavorite, isMotion=$isMotion, isNotInAlbum=$isNotInAlbum, isOffline=$isOffline, language=$language, lensModel=$lensModel, libraryId=$libraryId, make=$make, model=$model, page=$page, personIds=$personIds, query=$query, rating=$rating, size=$size, state=$state, tagIds=$tagIds, takenAfter=$takenAfter, takenBefore=$takenBefore, trashedAfter=$trashedAfter, trashedBefore=$trashedBefore, type=$type, updatedAfter=$updatedAfter, updatedBefore=$updatedBefore, visibility=$visibility, withDeleted=$withDeleted, withExif=$withExif]'; Map<String, dynamic> toJson() { final json = <String, dynamic>{}; @@ -371,11 +355,6 @@ class SmartSearchDto { } else { // json[r'deviceId'] = null; } - if (this.isArchived != null) { - json[r'isArchived'] = this.isArchived; - } else { - // json[r'isArchived'] = null; - } if (this.isEncoded != null) { json[r'isEncoded'] = this.isEncoded; } else { @@ -401,11 +380,6 @@ class SmartSearchDto { } else { // json[r'isOffline'] = null; } - if (this.isVisible != null) { - json[r'isVisible'] = this.isVisible; - } else { - // json[r'isVisible'] = null; - } if (this.language != null) { json[r'language'] = this.language; } else { @@ -489,7 +463,11 @@ class SmartSearchDto { } else { // json[r'updatedBefore'] = null; } - json[r'withArchived'] = this.withArchived; + if (this.visibility != null) { + json[r'visibility'] = this.visibility; + } else { + // json[r'visibility'] = null; + } if (this.withDeleted != null) { json[r'withDeleted'] = this.withDeleted; } else { @@ -517,13 +495,11 @@ class SmartSearchDto { createdAfter: mapDateTime(json, r'createdAfter', r''), createdBefore: mapDateTime(json, r'createdBefore', r''), deviceId: mapValueOfType<String>(json, r'deviceId'), - isArchived: mapValueOfType<bool>(json, r'isArchived'), isEncoded: mapValueOfType<bool>(json, r'isEncoded'), isFavorite: mapValueOfType<bool>(json, r'isFavorite'), isMotion: mapValueOfType<bool>(json, r'isMotion'), isNotInAlbum: mapValueOfType<bool>(json, r'isNotInAlbum'), isOffline: mapValueOfType<bool>(json, r'isOffline'), - isVisible: mapValueOfType<bool>(json, r'isVisible'), language: mapValueOfType<String>(json, r'language'), lensModel: mapValueOfType<String>(json, r'lensModel'), libraryId: mapValueOfType<String>(json, r'libraryId'), @@ -547,7 +523,7 @@ class SmartSearchDto { type: AssetTypeEnum.fromJson(json[r'type']), updatedAfter: mapDateTime(json, r'updatedAfter', r''), updatedBefore: mapDateTime(json, r'updatedBefore', r''), - withArchived: mapValueOfType<bool>(json, r'withArchived') ?? false, + visibility: AssetVisibility.fromJson(json[r'visibility']), withDeleted: mapValueOfType<bool>(json, r'withDeleted'), withExif: mapValueOfType<bool>(json, r'withExif'), ); diff --git a/mobile/openapi/lib/model/sync_asset_v1.dart b/mobile/openapi/lib/model/sync_asset_v1.dart index 6f9d7d7eaf..e1d3199428 100644 --- a/mobile/openapi/lib/model/sync_asset_v1.dart +++ b/mobile/openapi/lib/model/sync_asset_v1.dart @@ -19,11 +19,11 @@ class SyncAssetV1 { required this.fileModifiedAt, required this.id, required this.isFavorite, - required this.isVisible, required this.localDateTime, required this.ownerId, required this.thumbhash, required this.type, + required this.visibility, }); String checksum; @@ -38,8 +38,6 @@ class SyncAssetV1 { bool isFavorite; - bool isVisible; - DateTime? localDateTime; String ownerId; @@ -48,6 +46,8 @@ class SyncAssetV1 { SyncAssetV1TypeEnum type; + SyncAssetV1VisibilityEnum visibility; + @override bool operator ==(Object other) => identical(this, other) || other is SyncAssetV1 && other.checksum == checksum && @@ -56,11 +56,11 @@ class SyncAssetV1 { other.fileModifiedAt == fileModifiedAt && other.id == id && other.isFavorite == isFavorite && - other.isVisible == isVisible && other.localDateTime == localDateTime && other.ownerId == ownerId && other.thumbhash == thumbhash && - other.type == type; + other.type == type && + other.visibility == visibility; @override int get hashCode => @@ -71,14 +71,14 @@ class SyncAssetV1 { (fileModifiedAt == null ? 0 : fileModifiedAt!.hashCode) + (id.hashCode) + (isFavorite.hashCode) + - (isVisible.hashCode) + (localDateTime == null ? 0 : localDateTime!.hashCode) + (ownerId.hashCode) + (thumbhash == null ? 0 : thumbhash!.hashCode) + - (type.hashCode); + (type.hashCode) + + (visibility.hashCode); @override - String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isFavorite=$isFavorite, isVisible=$isVisible, localDateTime=$localDateTime, ownerId=$ownerId, thumbhash=$thumbhash, type=$type]'; + String toString() => 'SyncAssetV1[checksum=$checksum, deletedAt=$deletedAt, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isFavorite=$isFavorite, localDateTime=$localDateTime, ownerId=$ownerId, thumbhash=$thumbhash, type=$type, visibility=$visibility]'; Map<String, dynamic> toJson() { final json = <String, dynamic>{}; @@ -100,7 +100,6 @@ class SyncAssetV1 { } json[r'id'] = this.id; json[r'isFavorite'] = this.isFavorite; - json[r'isVisible'] = this.isVisible; if (this.localDateTime != null) { json[r'localDateTime'] = this.localDateTime!.toUtc().toIso8601String(); } else { @@ -113,6 +112,7 @@ class SyncAssetV1 { // json[r'thumbhash'] = null; } json[r'type'] = this.type; + json[r'visibility'] = this.visibility; return json; } @@ -131,11 +131,11 @@ class SyncAssetV1 { fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r''), id: mapValueOfType<String>(json, r'id')!, isFavorite: mapValueOfType<bool>(json, r'isFavorite')!, - isVisible: mapValueOfType<bool>(json, r'isVisible')!, localDateTime: mapDateTime(json, r'localDateTime', r''), ownerId: mapValueOfType<String>(json, r'ownerId')!, thumbhash: mapValueOfType<String>(json, r'thumbhash'), type: SyncAssetV1TypeEnum.fromJson(json[r'type'])!, + visibility: SyncAssetV1VisibilityEnum.fromJson(json[r'visibility'])!, ); } return null; @@ -189,11 +189,11 @@ class SyncAssetV1 { 'fileModifiedAt', 'id', 'isFavorite', - 'isVisible', 'localDateTime', 'ownerId', 'thumbhash', 'type', + 'visibility', }; } @@ -277,3 +277,80 @@ class SyncAssetV1TypeEnumTypeTransformer { } + +class SyncAssetV1VisibilityEnum { + /// Instantiate a new enum with the provided [value]. + const SyncAssetV1VisibilityEnum._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const archive = SyncAssetV1VisibilityEnum._(r'archive'); + static const timeline = SyncAssetV1VisibilityEnum._(r'timeline'); + static const hidden = SyncAssetV1VisibilityEnum._(r'hidden'); + + /// List of all possible values in this [enum][SyncAssetV1VisibilityEnum]. + static const values = <SyncAssetV1VisibilityEnum>[ + archive, + timeline, + hidden, + ]; + + static SyncAssetV1VisibilityEnum? fromJson(dynamic value) => SyncAssetV1VisibilityEnumTypeTransformer().decode(value); + + static List<SyncAssetV1VisibilityEnum> listFromJson(dynamic json, {bool growable = false,}) { + final result = <SyncAssetV1VisibilityEnum>[]; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SyncAssetV1VisibilityEnum.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [SyncAssetV1VisibilityEnum] to String, +/// and [decode] dynamic data back to [SyncAssetV1VisibilityEnum]. +class SyncAssetV1VisibilityEnumTypeTransformer { + factory SyncAssetV1VisibilityEnumTypeTransformer() => _instance ??= const SyncAssetV1VisibilityEnumTypeTransformer._(); + + const SyncAssetV1VisibilityEnumTypeTransformer._(); + + String encode(SyncAssetV1VisibilityEnum data) => data.value; + + /// Decodes a [dynamic value][data] to a SyncAssetV1VisibilityEnum. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + SyncAssetV1VisibilityEnum? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'archive': return SyncAssetV1VisibilityEnum.archive; + case r'timeline': return SyncAssetV1VisibilityEnum.timeline; + case r'hidden': return SyncAssetV1VisibilityEnum.hidden; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [SyncAssetV1VisibilityEnumTypeTransformer] instance. + static SyncAssetV1VisibilityEnumTypeTransformer? _instance; +} + + diff --git a/mobile/openapi/lib/model/update_asset_dto.dart b/mobile/openapi/lib/model/update_asset_dto.dart index c6ae6d8e07..7b364f1387 100644 --- a/mobile/openapi/lib/model/update_asset_dto.dart +++ b/mobile/openapi/lib/model/update_asset_dto.dart @@ -15,12 +15,12 @@ class UpdateAssetDto { UpdateAssetDto({ this.dateTimeOriginal, this.description, - this.isArchived, this.isFavorite, this.latitude, this.livePhotoVideoId, this.longitude, this.rating, + this.visibility, }); /// @@ -39,14 +39,6 @@ class UpdateAssetDto { /// String? description; - /// - /// Please note: This property should have been non-nullable! Since the specification file - /// does not include a default value (using the "default:" property), however, the generated - /// source code must fall back to having a nullable type. - /// Consider adding a "default:" property in the specification file to hide this note. - /// - bool? isArchived; - /// /// Please note: This property should have been non-nullable! Since the specification file /// does not include a default value (using the "default:" property), however, the generated @@ -83,31 +75,39 @@ class UpdateAssetDto { /// num? rating; + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + AssetVisibility? visibility; + @override bool operator ==(Object other) => identical(this, other) || other is UpdateAssetDto && other.dateTimeOriginal == dateTimeOriginal && other.description == description && - other.isArchived == isArchived && other.isFavorite == isFavorite && other.latitude == latitude && other.livePhotoVideoId == livePhotoVideoId && other.longitude == longitude && - other.rating == rating; + other.rating == rating && + other.visibility == visibility; @override int get hashCode => // ignore: unnecessary_parenthesis (dateTimeOriginal == null ? 0 : dateTimeOriginal!.hashCode) + (description == null ? 0 : description!.hashCode) + - (isArchived == null ? 0 : isArchived!.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) + (latitude == null ? 0 : latitude!.hashCode) + (livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) + (longitude == null ? 0 : longitude!.hashCode) + - (rating == null ? 0 : rating!.hashCode); + (rating == null ? 0 : rating!.hashCode) + + (visibility == null ? 0 : visibility!.hashCode); @override - String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isArchived=$isArchived, isFavorite=$isFavorite, latitude=$latitude, livePhotoVideoId=$livePhotoVideoId, longitude=$longitude, rating=$rating]'; + String toString() => 'UpdateAssetDto[dateTimeOriginal=$dateTimeOriginal, description=$description, isFavorite=$isFavorite, latitude=$latitude, livePhotoVideoId=$livePhotoVideoId, longitude=$longitude, rating=$rating, visibility=$visibility]'; Map<String, dynamic> toJson() { final json = <String, dynamic>{}; @@ -121,11 +121,6 @@ class UpdateAssetDto { } else { // json[r'description'] = null; } - if (this.isArchived != null) { - json[r'isArchived'] = this.isArchived; - } else { - // json[r'isArchived'] = null; - } if (this.isFavorite != null) { json[r'isFavorite'] = this.isFavorite; } else { @@ -151,6 +146,11 @@ class UpdateAssetDto { } else { // json[r'rating'] = null; } + if (this.visibility != null) { + json[r'visibility'] = this.visibility; + } else { + // json[r'visibility'] = null; + } return json; } @@ -165,12 +165,12 @@ class UpdateAssetDto { return UpdateAssetDto( dateTimeOriginal: mapValueOfType<String>(json, r'dateTimeOriginal'), description: mapValueOfType<String>(json, r'description'), - isArchived: mapValueOfType<bool>(json, r'isArchived'), isFavorite: mapValueOfType<bool>(json, r'isFavorite'), latitude: num.parse('${json[r'latitude']}'), livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'), longitude: num.parse('${json[r'longitude']}'), rating: num.parse('${json[r'rating']}'), + visibility: AssetVisibility.fromJson(json[r'visibility']), ); } return null; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 0951177c72..c4c9e7d193 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -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" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 20fb72b486..b2abdb0a24 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -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", diff --git a/server/src/controllers/asset-media.controller.spec.ts b/server/src/controllers/asset-media.controller.spec.ts index c674dc1f2c..67bdeff222 100644 --- a/server/src/controllers/asset-media.controller.spec.ts +++ b/server/src/controllers/asset-media.controller.spec.ts @@ -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(); + }); }); }); }); diff --git a/server/src/controllers/search.controller.spec.ts b/server/src/controllers/search.controller.spec.ts index 03c9625e16..14130fabcb 100644 --- a/server/src/controllers/search.controller.spec.ts +++ b/server/src/controllers/search.controller.spec.ts @@ -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:'), + ]), + ); + }); }); }); }); diff --git a/server/src/database.ts b/server/src/database.ts index a93873ef42..a13b074448 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -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'], diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 85be9d5208..1b039f9982 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -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; diff --git a/server/src/dtos/asset-media.dto.ts b/server/src/dtos/asset-media.dto.ts index 8837138599..a647b4515f 100644 --- a/server/src/dtos/asset-media.dto.ts +++ b/server/src/dtos/asset-media.dto.ts @@ -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; diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 3732e665cd..480ad0b9b9 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -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, diff --git a/server/src/dtos/asset.dto.ts b/server/src/dtos/asset.dto.ts index 32b14055d5..0789633878 100644 --- a/server/src/dtos/asset.dto.ts +++ b/server/src/dtos/asset.dto.ts @@ -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; diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index a7633dce78..579cba680e 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -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; diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index a035f8ecb9..cc11c3410b 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -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 { diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts index a9dfa49a07..51d46871ae 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -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 { diff --git a/server/src/enum.ts b/server/src/enum.ts index a9ea285c24..f214593975 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -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', +} diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index 03f1af3b28..f550c5b0c1 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -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 diff --git a/server/src/queries/asset.job.repository.sql b/server/src/queries/asset.job.repository.sql index d8e8430be7..577635a912 100644 --- a/server/src/queries/asset.job.repository.sql +++ b/server/src/queries/asset.job.repository.sql @@ -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 diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index cb438e1c6d..4a3fbf0e39 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -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 diff --git a/server/src/queries/library.repository.sql b/server/src/queries/library.repository.sql index 43500a8748..f17d9663d6 100644 --- a/server/src/queries/library.repository.sql +++ b/server/src/queries/library.repository.sql @@ -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" diff --git a/server/src/queries/map.repository.sql b/server/src/queries/map.repository.sql index b3bb207946..edfdec13d2 100644 --- a/server/src/queries/map.repository.sql +++ b/server/src/queries/map.repository.sql @@ -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) diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index f9ba32262d..09517a1e14 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -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 diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 4fce272365..c18fe02418 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -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 diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index e08335d9f1..54c1292d80 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -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" diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql index e8ab5018fc..72881feea7 100644 --- a/server/src/queries/user.repository.sql +++ b/server/src/queries/user.repository.sql @@ -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( diff --git a/server/src/queries/view.repository.sql b/server/src/queries/view.repository.sql index 1510521526..a2260ce5f6 100644 --- a/server/src/queries/view.repository.sql +++ b/server/src/queries/view.repository.sql @@ -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 diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index c24209e482..5680ce2c64 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -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))); diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index 1506f2997f..132bef6988 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -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(); } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 89062c210a..9bd115089f 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -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(); diff --git a/server/src/repositories/download.repository.ts b/server/src/repositories/download.repository.ts index c9c62c90ce..4c4bed07ff 100644 --- a/server/src/repositories/download.repository.ts +++ b/server/src/repositories/download.repository.ts @@ -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(); } } diff --git a/server/src/repositories/library.repository.ts b/server/src/repositories/library.repository.ts index fd9dd81b7b..b6c5ebbe08 100644 --- a/server/src/repositories/library.repository.ts +++ b/server/src/repositories/library.repository.ts @@ -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')) diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts index f9998ad179..3f559442aa 100644 --- a/server/src/repositories/map.repository.ts +++ b/server/src/repositories/map.repository.ts @@ -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!)) diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 0383a54a27..b55537bdba 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -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) => diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index b991ecc78b..4e6b6e0fcf 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -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); } diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index e2e396f7b2..4d7671ca92 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -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 diff --git a/server/src/repositories/view-repository.ts b/server/src/repositories/view-repository.ts index e32933065c..03e8b3763f 100644 --- a/server/src/repositories/view-repository.ts +++ b/server/src/repositories/view-repository.ts @@ -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) diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index c62681d049..1800f08c13 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -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' }) diff --git a/server/src/schema/migrations/1745902563899-AddAssetVisibilityColumn.ts b/server/src/schema/migrations/1745902563899-AddAssetVisibilityColumn.ts new file mode 100644 index 0000000000..6fe9dab1a0 --- /dev/null +++ b/server/src/schema/migrations/1745902563899-AddAssetVisibilityColumn.ts @@ -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); +} diff --git a/server/src/schema/tables/asset.table.ts b/server/src/schema/tables/asset.table.ts index 19ec8d2ef4..4552ac158d 100644 --- a/server/src/schema/tables/asset.table.ts +++ b/server/src/schema/tables/asset.table.ts @@ -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; } diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index d25067f1c9..8490e8aaea 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -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 () => { diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 78e23fa802..87d617ede6 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -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, diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index b677a6588d..1e4cfddcf5 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -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, diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 6047130546..3ab6fcb8a7 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -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 diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index ed8f2cf177..3f08e36a21 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -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 }); diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 41e3f13c4d..b5e4f573f2 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -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; } diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index cf9b87f4e6..fd573d9b97 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -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)); } diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 546dcc930b..adc8c4b904 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -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; } diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index b048923b38..28cb42a16b 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -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); }); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index 17f3325f99..3497b808da 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -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; } diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 227ea3c1c2..77a9c70300 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -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 } }); diff --git a/server/src/services/smart-info.service.ts b/server/src/services/smart-info.service.ts index 5ee5dac57e..f3702c2010 100644 --- a/server/src/services/smart-info.service.ts +++ b/server/src/services/smart-info.service.ts @@ -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; } diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index c88348b39e..6ad488c48d 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -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, diff --git a/server/src/services/timeline.service.spec.ts b/server/src/services/timeline.service.spec.ts index c6a09d2fdf..1447594d4e 100644 --- a/server/src/services/timeline.service.spec.ts +++ b/server/src/services/timeline.service.spec.ts @@ -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, }), diff --git a/server/src/services/timeline.service.ts b/server/src/services/timeline.service.ts index 4c2332afaa..c0cd4786a8 100644 --- a/server/src/services/timeline.service.ts +++ b/server/src/services/timeline.service.ts @@ -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; diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index 8905f84165..0f5432da4d 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -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 }); }; diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 985605eb07..bacdf06d67 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -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), ) diff --git a/server/src/validation.ts b/server/src/validation.ts index 29e402826d..26367aeff5 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -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 }; diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index d1b8e7cf28..a64194361a 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -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, }), }; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index a4d83863c7..fc4b74ba2d 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -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, }, ], }, diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 89b1921819..6f4f46c075 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -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 { diff --git a/server/test/medium/specs/services/sync.service.spec.ts b/server/test/medium/specs/services/sync.service.spec.ts index 98df296cbf..67cfeafdbf 100644 --- a/server/test/medium/specs/services/sync.service.spec.ts +++ b/server/test/medium/specs/services/sync.service.spec.ts @@ -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, }, diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 81ada65b68..94ae3b74aa 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -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, }); diff --git a/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte b/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte index 576c1af540..d5c9b02a2d 100644 --- a/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte +++ b/web/src/lib/components/shared-components/search-bar/search-filter-modal.svelte @@ -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, diff --git a/web/src/lib/components/user-settings-page/user-usage-statistic.svelte b/web/src/lib/components/user-settings-page/user-usage-statistic.svelte index 6805ce80e5..c87cdf8cf6 100644 --- a/web/src/lib/components/user-settings-page/user-usage-statistic.svelte +++ b/web/src/lib/components/user-settings-page/user-usage-statistic.svelte @@ -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(), ]); diff --git a/web/src/lib/stores/assets-store.svelte.ts b/web/src/lib/stores/assets-store.svelte.ts index b4b4a4ade2..dda45d74d2 100644 --- a/web/src/lib/stores/assets-store.svelte.ts +++ b/web/src/lib/stores/assets-store.svelte.ts @@ -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) ); diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index 254b90f08d..c23b9b432c 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -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) { diff --git a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte index d35e8fefd4..c24c8c8399 100644 --- a/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId=id]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -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); diff --git a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte index 86cfefff77..647b92c3f8 100644 --- a/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/archive/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -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(); diff --git a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte index 120281b07e..2ed4b0ada7 100644 --- a/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/favorites/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -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; diff --git a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index fe8c45f2f5..2f916d732c 100644 --- a/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/partners/[userId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -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(); diff --git a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte index 4c58f52265..6b260cf9c5 100644 --- a/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/people/[personId]/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -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(); diff --git a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte index 34498a60a3..e9827c06f5 100644 --- a/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/photos/[[assetId=id]]/+page.svelte @@ -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(); diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index 9d427e1ea7..dc03a2ae70 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -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>