Compare commits

..

1 Commits

Author SHA1 Message Date
Yaros 2db907239f fix: update ocr & faces after asset edit 2026-06-24 13:18:42 +02:00
102 changed files with 529 additions and 2683 deletions
-2
View File
@@ -45,8 +45,6 @@ jobs:
- 'server/**'
- 'pnpm-lock.yaml'
- 'mise.toml'
- 'packages/plugin-core/**'
- 'packages/plugin-sdk/**'
cli:
- 'packages/cli/**'
- 'packages/sdk/**'
+1 -1
View File
@@ -10,7 +10,7 @@ DB_DATA_LOCATION=./postgres
# TZ=Etc/UTC
# The Immich version to use. You can pin this to a specific version like "v2.1.0"
IMMICH_VERSION=v2
IMMICH_VERSION=v3
# Connection secret for postgres. You should change it to a random password
# Please use only the characters `A-Za-z0-9`, without special characters or spaces
+1 -1
View File
@@ -19,7 +19,7 @@ If this does not work, try running `docker compose up -d --force-recreate`.
| Variable | Description | Default | Containers |
| :----------------- | :------------------------------ | :-----: | :----------------------- |
| `IMMICH_VERSION` | Image tags | `v2` | server, machine learning |
| `IMMICH_VERSION` | Image tags | `v3` | server, machine learning |
| `UPLOAD_LOCATION` | Host path for uploads | | server |
| `DB_DATA_LOCATION` | Host path for Postgres database | | database |
+1 -1
View File
@@ -29,7 +29,7 @@ docker image prune
## Versioning Policy
Immich follows [semantic versioning][semver], which tags releases in the format `<major>.<minor>.<patch>`. We intend for breaking changes to be limited to major version releases.
You can configure your Docker image to point to the current major version by using a metatag, such as `:v2`.
You can configure your Docker image to point to the current major version by using a metatag, such as `:v3`.
Currently, we have no plans to backport patches to earlier versions. We encourage all users to run the most recent release of Immich.
Switching back to an earlier version, even within the same minor release tag, is not supported.
-3
View File
@@ -1507,9 +1507,6 @@
"notes": "Notes",
"nothing_here_yet": "Nothing here yet",
"notification_backup_reliability": "Enable notifications to improve background backup reliability",
"notification_enabled_list_tile_content": "Immich uses notifications for background backup. Manage them in your device settings.",
"notification_enabled_list_tile_open_button": "Open settings",
"notification_enabled_list_tile_title": "Notifications enabled",
"notification_permission_dialog_content": "To enable notifications, go to Settings and select allow.",
"notification_permission_list_tile_content": "Grant permission to enable notifications.",
"notification_permission_list_tile_enable_button": "Enable Notifications",
@@ -446,6 +446,7 @@ class SyncStreamService {
await _syncStreamRepository.updateAssetsV1([asset], debugLabel: 'websocket-edit');
await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit');
await _refreshAssetOcrAndFaces(asset.id);
_logger.info(
'Successfully processed AssetEditReadyV1 event for asset ${asset.id} with ${assetEdits.length} edits',
@@ -484,6 +485,7 @@ class SyncStreamService {
await _syncStreamRepository.updateAssetsV2([asset], debugLabel: 'websocket-edit');
await _syncStreamRepository.replaceAssetEditsV1(asset.id, assetEdits, debugLabel: 'websocket-edit');
await _refreshAssetOcrAndFaces(asset.id);
_logger.info(
'Successfully processed AssetEditReadyV2 event for asset ${asset.id} with ${assetEdits.length} edits',
@@ -493,6 +495,22 @@ class SyncStreamService {
}
}
Future<void> _refreshAssetOcrAndFaces(String assetId) async {
try {
final ocr = await _api.assetsApi.getAssetOcr(assetId);
await _syncStreamRepository.replaceAssetOcr(assetId, ocr ?? const []);
} catch (error, stackTrace) {
_logger.severe("Error refreshing OCR for asset $assetId", error, stackTrace);
}
try {
final faces = await _api.facesApi.getFaces(assetId);
await _syncStreamRepository.replaceAssetFaces(assetId, faces ?? const []);
} catch (error, stackTrace) {
_logger.severe("Error refreshing faces for asset $assetId", error, stackTrace);
}
}
Future<void> _handleRemoteDeleted(Iterable<String> remoteIds) async {
if (remoteIds.isEmpty) {
return Future.value();
@@ -896,6 +896,71 @@ class SyncStreamRepository extends DriftDatabaseRepository {
}
}
/// Replaces all OCR rows for [assetId] with [data] (e.g. after an asset edit re-runs OCR).
Future<void> replaceAssetOcr(String assetId, Iterable<AssetOcrResponseDto> data) async {
try {
await _db.batch((batch) {
batch.deleteWhere(_db.assetOcrEntity, (row) => row.assetId.equals(assetId));
for (final ocr in data) {
batch.insert(
_db.assetOcrEntity,
AssetOcrEntityCompanion(
id: Value(ocr.id),
assetId: Value(ocr.assetId),
recognizedText: Value(ocr.text),
x1: Value(ocr.x1),
y1: Value(ocr.y1),
x2: Value(ocr.x2),
y2: Value(ocr.y2),
x3: Value(ocr.x3),
y3: Value(ocr.y3),
x4: Value(ocr.x4),
y4: Value(ocr.y4),
boxScore: Value(ocr.boxScore),
textScore: Value(ocr.textScore),
isVisible: const Value(true),
),
);
}
});
} catch (error, stack) {
_logger.severe('Error: replaceAssetOcr', error, stack);
rethrow;
}
}
Future<void> replaceAssetFaces(String assetId, Iterable<AssetFaceResponseDto> data) async {
try {
await _db.batch((batch) {
batch.deleteWhere(_db.assetFaceEntity, (row) => row.assetId.equals(assetId));
for (final face in data) {
batch.insert(
_db.assetFaceEntity,
AssetFaceEntityCompanion(
id: Value(face.id),
assetId: Value(assetId),
personId: Value(face.person?.id),
imageWidth: Value(face.imageWidth),
imageHeight: Value(face.imageHeight),
boundingBoxX1: Value(face.boundingBoxX1),
boundingBoxY1: Value(face.boundingBoxY1),
boundingBoxX2: Value(face.boundingBoxX2),
boundingBoxY2: Value(face.boundingBoxY2),
sourceType: Value(face.sourceType.orElse(null)?.value ?? SourceType.machineLearning.value),
isVisible: const Value(true),
deletedAt: const Value(null),
),
);
}
});
} catch (error, stack) {
_logger.severe('Error: replaceAssetFaces', error, stack);
rethrow;
}
}
Future<void> pruneAssets() async {
try {
await _db.transaction(() async {
@@ -12,7 +12,6 @@ import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_bottom_info.widget.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_card.widget.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/utils/system_ui.utils.dart';
import 'package:immich_mobile/widgets/memories/memory_epilogue.dart';
import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart';
@@ -50,7 +49,7 @@ class DriftMemoryPage extends HookConsumerWidget {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
return () {
// Clean up to normal edge to edge when we are done
restoreEdgeToEdge();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
};
});
@@ -329,7 +328,7 @@ class DriftMemoryPage extends HookConsumerWidget {
// turn off full screen mode here
// https://github.com/Milad-Akarie/auto_route_library/issues/1799
context.maybePop();
restoreEdgeToEdge();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
},
shape: const CircleBorder(),
color: Colors.white.withValues(alpha: 0.2),
@@ -19,7 +19,6 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/system_ui.utils.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@@ -77,7 +76,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
_pageController.dispose();
_crossfadeController.dispose();
unawaited(WakelockPlus.disable());
unawaited(restoreEdgeToEdge());
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
}
@@ -256,7 +255,7 @@ class _DriftSlideshowPageState extends ConsumerState<DriftSlideshowPage> with Si
}
void _onTapUp() async {
await (_showAppBar ? SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive) : restoreEdgeToEdge());
await SystemChrome.setEnabledSystemUIMode(_showAppBar ? SystemUiMode.immersive : SystemUiMode.edgeToEdge);
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
@@ -23,7 +23,6 @@ import 'package:immich_mobile/providers/asset_viewer/asset_viewer.provider.dart'
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/utils/system_ui.utils.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view.dart';
@RoutePage()
@@ -129,7 +128,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_reloadSubscription?.cancel();
_stackChildrenKeepAlive?.close();
unawaited(restoreEdgeToEdge());
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
}
@@ -252,8 +251,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
void _setSystemUIMode(bool controls, bool details) {
final immersive = !controls || (CurrentPlatform.isIOS && details);
unawaited(immersive ? SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky) : restoreEdgeToEdge());
final mode = !controls || (CurrentPlatform.isIOS && details)
? SystemUiMode.immersiveSticky
: SystemUiMode.edgeToEdge;
unawaited(SystemChrome.setEnabledSystemUIMode(mode));
}
@override
+26 -2
View File
@@ -7,6 +7,7 @@ import 'package:immich_mobile/infrastructure/repositories/network.repository.dar
import 'package:immich_mobile/models/server_info/server_version.model.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/infrastructure/ocr.provider.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/utils/debounce.dart';
@@ -181,11 +182,34 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
}
void _handleSyncAssetEditReadyV1(dynamic data) {
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV1(data));
final assetId = _assetIdFromEditReady(data);
unawaited(
_ref.read(backgroundSyncProvider).syncWebsocketEditV1(data).whenComplete(() => _onAssetEditApplied(assetId)),
);
}
void _handleSyncAssetEditReadyV2(dynamic data) {
unawaited(_ref.read(backgroundSyncProvider).syncWebsocketEditV2(data));
final assetId = _assetIdFromEditReady(data);
unawaited(
_ref.read(backgroundSyncProvider).syncWebsocketEditV2(data).whenComplete(() => _onAssetEditApplied(assetId)),
);
}
String? _assetIdFromEditReady(dynamic data) {
if (data is Map && data['asset'] is Map) {
final id = (data['asset'] as Map)['id'];
return id is String ? id : null;
}
return null;
}
/// The edit handler refreshes OCR/faces in the drift DB from a background isolate,
/// so the main-isolate UI providers must be invalidated here to re-read the new data.
void _onAssetEditApplied(String? assetId) {
if (assetId == null) {
return;
}
_ref.invalidate(ocrAssetProvider(assetId));
}
void _processBatchedAssetUploadReadyV1() {
+2
View File
@@ -36,6 +36,7 @@ class ApiService {
late MemoriesApi memoriesApi;
late SessionsApi sessionsApi;
late TagsApi tagsApi;
late FacesApi facesApi;
ApiService() {
// The below line ensures that the api clients are initialized when the service is instantiated
@@ -77,6 +78,7 @@ class ApiService {
memoriesApi = MemoriesApi(_apiClient);
sessionsApi = SessionsApi(_apiClient);
tagsApi = TagsApi(_apiClient);
facesApi = FacesApi(_apiClient);
}
Future<String> resolveAndSetEndpoint(String serverUrl) async {
-14
View File
@@ -1,14 +0,0 @@
import 'dart:async';
import 'package:flutter/services.dart';
/// Restore the system bars and return to edge-to-edge layout.
///
/// On Android 15+/API 36 edge-to-edge is enforced, so calling
/// setEnabledSystemUIMode(edgeToEdge) does NOT re-show bars that an immersive
/// mode (immersive / immersiveSticky) previously hid. Explicitly request all
/// overlays first, then return to edge-to-edge layout.
Future<void> restoreEdgeToEdge() async {
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values);
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
}
@@ -48,14 +48,6 @@ class NotificationSetting extends HookConsumerWidget {
showPermissionsDialog();
}
}),
)
else
SettingsButtonListTile(
icon: Icons.notifications_active_outlined,
title: 'notification_enabled_list_tile_title'.tr(),
subtileText: 'notification_enabled_list_tile_content'.tr(),
buttonText: 'notification_enabled_list_tile_open_button'.tr(),
onButtonTap: () => openAppSettings(),
),
];
+1 -6
View File
@@ -92,12 +92,10 @@ Class | Method | HTTP request | Description
*AlbumsApi* | [**getAlbumMapMarkers**](doc//AlbumsApi.md#getalbummapmarkers) | **GET** /albums/{id}/map-markers | Retrieve album map markers
*AlbumsApi* | [**getAlbumStatistics**](doc//AlbumsApi.md#getalbumstatistics) | **GET** /albums/statistics | Retrieve album statistics
*AlbumsApi* | [**getAllAlbums**](doc//AlbumsApi.md#getallalbums) | **GET** /albums | List all albums
*AlbumsApi* | [**getOwnAlbumUser**](doc//AlbumsApi.md#getownalbumuser) | **GET** /albums/{id}/user/self | Get own sharing permissions
*AlbumsApi* | [**removeAssetFromAlbum**](doc//AlbumsApi.md#removeassetfromalbum) | **DELETE** /albums/{id}/assets | Remove assets from an album
*AlbumsApi* | [**removeUserFromAlbum**](doc//AlbumsApi.md#removeuserfromalbum) | **DELETE** /albums/{id}/user/{userId} | Remove user from album
*AlbumsApi* | [**updateAlbumInfo**](doc//AlbumsApi.md#updatealbuminfo) | **PATCH** /albums/{id} | Update an album
*AlbumsApi* | [**updateAlbumUser**](doc//AlbumsApi.md#updatealbumuser) | **PUT** /albums/{id}/user/{userId} | Update user role
*AlbumsApi* | [**updateOwnAlbumUser**](doc//AlbumsApi.md#updateownalbumuser) | **PUT** /albums/{id}/user/self | Update own sharing permissions
*AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /assets/bulk-upload-check | Check bulk upload
*AssetsApi* | [**copyAsset**](doc//AssetsApi.md#copyasset) | **PUT** /assets/copy | Copy asset
*AssetsApi* | [**deleteAssetMetadata**](doc//AssetsApi.md#deleteassetmetadata) | **DELETE** /assets/{id}/metadata/{key} | Delete asset metadata by key
@@ -487,7 +485,7 @@ Class | Method | HTTP request | Description
- [MemoryStatisticsResponseDto](doc//MemoryStatisticsResponseDto.md)
- [MemoryType](doc//MemoryType.md)
- [MemoryUpdateDto](doc//MemoryUpdateDto.md)
- [MergeFaceClusterDto](doc//MergeFaceClusterDto.md)
- [MergePersonDto](doc//MergePersonDto.md)
- [MetadataSearchDto](doc//MetadataSearchDto.md)
- [MirrorAxis](doc//MirrorAxis.md)
- [MirrorParameters](doc//MirrorParameters.md)
@@ -584,8 +582,6 @@ Class | Method | HTTP request | Description
- [SharedLinkType](doc//SharedLinkType.md)
- [SharedLinksResponse](doc//SharedLinksResponse.md)
- [SharedLinksUpdate](doc//SharedLinksUpdate.md)
- [SharingOptionsResponseDto](doc//SharingOptionsResponseDto.md)
- [SharingPermission](doc//SharingPermission.md)
- [SignUpDto](doc//SignUpDto.md)
- [SmartSearchDto](doc//SmartSearchDto.md)
- [SourceType](doc//SourceType.md)
@@ -691,7 +687,6 @@ Class | Method | HTTP request | Description
- [UpdateAlbumUserDto](doc//UpdateAlbumUserDto.md)
- [UpdateAssetDto](doc//UpdateAssetDto.md)
- [UpdateLibraryDto](doc//UpdateLibraryDto.md)
- [UpdateSharingOptionsDto](doc//UpdateSharingOptionsDto.md)
- [UsageByUserDto](doc//UsageByUserDto.md)
- [UserAdminCreateDto](doc//UserAdminCreateDto.md)
- [UserAdminDeleteDto](doc//UserAdminDeleteDto.md)
+1 -4
View File
@@ -206,7 +206,7 @@ part 'model/memory_search_order.dart';
part 'model/memory_statistics_response_dto.dart';
part 'model/memory_type.dart';
part 'model/memory_update_dto.dart';
part 'model/merge_face_cluster_dto.dart';
part 'model/merge_person_dto.dart';
part 'model/metadata_search_dto.dart';
part 'model/mirror_axis.dart';
part 'model/mirror_parameters.dart';
@@ -303,8 +303,6 @@ part 'model/shared_link_response_dto.dart';
part 'model/shared_link_type.dart';
part 'model/shared_links_response.dart';
part 'model/shared_links_update.dart';
part 'model/sharing_options_response_dto.dart';
part 'model/sharing_permission.dart';
part 'model/sign_up_dto.dart';
part 'model/smart_search_dto.dart';
part 'model/source_type.dart';
@@ -410,7 +408,6 @@ part 'model/update_album_dto.dart';
part 'model/update_album_user_dto.dart';
part 'model/update_asset_dto.dart';
part 'model/update_library_dto.dart';
part 'model/update_sharing_options_dto.dart';
part 'model/usage_by_user_dto.dart';
part 'model/user_admin_create_dto.dart';
part 'model/user_admin_delete_dto.dart';
-112
View File
@@ -607,64 +607,6 @@ class AlbumsApi {
return null;
}
/// Get own sharing permissions
///
/// Get the own sharing permissions in a specific album.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
Future<Response> getOwnAlbumUserWithHttpInfo(String id, { Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/albums/{id}/user/self'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// Get own sharing permissions
///
/// Get the own sharing permissions in a specific album.
///
/// Parameters:
///
/// * [String] id (required):
Future<SharingOptionsResponseDto?> getOwnAlbumUser(String id, { Future<void>? abortTrigger, }) async {
final response = await getOwnAlbumUserWithHttpInfo(id, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SharingOptionsResponseDto',) as SharingOptionsResponseDto;
}
return null;
}
/// Remove assets from an album
///
/// Remove multiple assets from a specific album by its ID.
@@ -905,58 +847,4 @@ class AlbumsApi {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Update own sharing permissions
///
/// Change the own sharing permissions in a specific album.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [UpdateSharingOptionsDto] updateSharingOptionsDto (required):
Future<Response> updateOwnAlbumUserWithHttpInfo(String id, UpdateSharingOptionsDto updateSharingOptionsDto, { Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/albums/{id}/user/self'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = updateSharingOptionsDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
abortTrigger: abortTrigger,
);
}
/// Update own sharing permissions
///
/// Change the own sharing permissions in a specific album.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [UpdateSharingOptionsDto] updateSharingOptionsDto (required):
Future<void> updateOwnAlbumUser(String id, UpdateSharingOptionsDto updateSharingOptionsDto, { Future<void>? abortTrigger, }) async {
final response = await updateOwnAlbumUserWithHttpInfo(id, updateSharingOptionsDto, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
}
+6 -6
View File
@@ -455,14 +455,14 @@ class PeopleApi {
///
/// * [String] id (required):
///
/// * [MergeFaceClusterDto] mergeFaceClusterDto (required):
Future<Response> mergePersonWithHttpInfo(String id, MergeFaceClusterDto mergeFaceClusterDto, { Future<void>? abortTrigger, }) async {
/// * [MergePersonDto] mergePersonDto (required):
Future<Response> mergePersonWithHttpInfo(String id, MergePersonDto mergePersonDto, { Future<void>? abortTrigger, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/people/{id}/merge'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody = mergeFaceClusterDto;
Object? postBody = mergePersonDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
@@ -491,9 +491,9 @@ class PeopleApi {
///
/// * [String] id (required):
///
/// * [MergeFaceClusterDto] mergeFaceClusterDto (required):
Future<List<BulkIdResponseDto>?> mergePerson(String id, MergeFaceClusterDto mergeFaceClusterDto, { Future<void>? abortTrigger, }) async {
final response = await mergePersonWithHttpInfo(id, mergeFaceClusterDto, abortTrigger: abortTrigger,);
/// * [MergePersonDto] mergePersonDto (required):
Future<List<BulkIdResponseDto>?> mergePerson(String id, MergePersonDto mergePersonDto, { Future<void>? abortTrigger, }) async {
final response = await mergePersonWithHttpInfo(id, mergePersonDto, abortTrigger: abortTrigger,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
+2 -8
View File
@@ -457,8 +457,8 @@ class ApiClient {
return MemoryTypeTypeTransformer().decode(value);
case 'MemoryUpdateDto':
return MemoryUpdateDto.fromJson(value);
case 'MergeFaceClusterDto':
return MergeFaceClusterDto.fromJson(value);
case 'MergePersonDto':
return MergePersonDto.fromJson(value);
case 'MetadataSearchDto':
return MetadataSearchDto.fromJson(value);
case 'MirrorAxis':
@@ -651,10 +651,6 @@ class ApiClient {
return SharedLinksResponse.fromJson(value);
case 'SharedLinksUpdate':
return SharedLinksUpdate.fromJson(value);
case 'SharingOptionsResponseDto':
return SharingOptionsResponseDto.fromJson(value);
case 'SharingPermission':
return SharingPermissionTypeTransformer().decode(value);
case 'SignUpDto':
return SignUpDto.fromJson(value);
case 'SmartSearchDto':
@@ -865,8 +861,6 @@ class ApiClient {
return UpdateAssetDto.fromJson(value);
case 'UpdateLibraryDto':
return UpdateLibraryDto.fromJson(value);
case 'UpdateSharingOptionsDto':
return UpdateSharingOptionsDto.fromJson(value);
case 'UsageByUserDto':
return UsageByUserDto.fromJson(value);
case 'UserAdminCreateDto':
-3
View File
@@ -175,9 +175,6 @@ String parameterToString(dynamic value) {
if (value is SharedLinkType) {
return SharedLinkTypeTypeTransformer().encode(value).toString();
}
if (value is SharingPermission) {
return SharingPermissionTypeTransformer().encode(value).toString();
}
if (value is SourceType) {
return SourceTypeTypeTransformer().encode(value).toString();
}
+1 -9
View File
@@ -37,7 +37,6 @@ class AssetResponseDto {
this.owner = const Optional.absent(),
required this.ownerId,
this.people = const Optional.present(const []),
this.permissions = const [],
this.resized = const Optional.absent(),
this.stack = const Optional.absent(),
this.tags = const Optional.present(const []),
@@ -141,8 +140,6 @@ class AssetResponseDto {
Optional<List<PersonResponseDto>?> people;
List<SharingPermission> permissions;
/// Is resized
///
/// Please note: This property should have been non-nullable! Since the specification file
@@ -198,7 +195,6 @@ class AssetResponseDto {
other.owner == owner &&
other.ownerId == ownerId &&
_deepEquality.equals(other.people, people) &&
_deepEquality.equals(other.permissions, permissions) &&
other.resized == resized &&
other.stack == stack &&
_deepEquality.equals(other.tags, tags) &&
@@ -235,7 +231,6 @@ class AssetResponseDto {
(owner == null ? 0 : owner!.hashCode) +
(ownerId.hashCode) +
(people.hashCode) +
(permissions.hashCode) +
(resized == null ? 0 : resized!.hashCode) +
(stack == null ? 0 : stack!.hashCode) +
(tags.hashCode) +
@@ -246,7 +241,7 @@ class AssetResponseDto {
(width == null ? 0 : width!.hashCode);
@override
String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, height=$height, id=$id, isArchived=$isArchived, isEdited=$isEdited, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, permissions=$permissions, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt, visibility=$visibility, width=$width]';
String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, height=$height, id=$id, isArchived=$isArchived, isEdited=$isEdited, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt, visibility=$visibility, width=$width]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -303,7 +298,6 @@ class AssetResponseDto {
final value = this.people.value;
json[r'people'] = value;
}
json[r'permissions'] = this.permissions;
if (this.resized.isPresent) {
final value = this.resized.value;
json[r'resized'] = value;
@@ -365,7 +359,6 @@ class AssetResponseDto {
owner: json.containsKey(r'owner') ? Optional.present(UserResponseDto.fromJson(json[r'owner'])) : const Optional.absent(),
ownerId: mapValueOfType<String>(json, r'ownerId')!,
people: json.containsKey(r'people') ? Optional.present(PersonResponseDto.listFromJson(json[r'people'])) : const Optional.absent(),
permissions: SharingPermission.listFromJson(json[r'permissions']),
resized: json.containsKey(r'resized') ? Optional.present(mapValueOfType<bool>(json, r'resized')) : const Optional.absent(),
stack: json.containsKey(r'stack') ? Optional.present(AssetStackResponseDto.fromJson(json[r'stack'])) : const Optional.absent(),
tags: json.containsKey(r'tags') ? Optional.present(TagResponseDto.listFromJson(json[r'tags'])) : const Optional.absent(),
@@ -438,7 +431,6 @@ class AssetResponseDto {
'originalFileName',
'originalPath',
'ownerId',
'permissions',
'thumbhash',
'type',
'updatedAt',
-3
View File
@@ -42,7 +42,6 @@ class JobName {
static const databaseBackup = JobName._(r'DatabaseBackup');
static const facialRecognitionQueueAll = JobName._(r'FacialRecognitionQueueAll');
static const facialRecognition = JobName._(r'FacialRecognition');
static const facialRecognitionMerge = JobName._(r'FacialRecognitionMerge');
static const fileDelete = JobName._(r'FileDelete');
static const fileMigrationQueueAll = JobName._(r'FileMigrationQueueAll');
static const libraryDeleteCheck = JobName._(r'LibraryDeleteCheck');
@@ -112,7 +111,6 @@ class JobName {
databaseBackup,
facialRecognitionQueueAll,
facialRecognition,
facialRecognitionMerge,
fileDelete,
fileMigrationQueueAll,
libraryDeleteCheck,
@@ -217,7 +215,6 @@ class JobNameTypeTransformer {
case r'DatabaseBackup': return JobName.databaseBackup;
case r'FacialRecognitionQueueAll': return JobName.facialRecognitionQueueAll;
case r'FacialRecognition': return JobName.facialRecognition;
case r'FacialRecognitionMerge': return JobName.facialRecognitionMerge;
case r'FileDelete': return JobName.fileDelete;
case r'FileMigrationQueueAll': return JobName.fileMigrationQueueAll;
case r'LibraryDeleteCheck': return JobName.libraryDeleteCheck;
-3
View File
@@ -38,7 +38,6 @@ class ManualJobName {
static const integrityMissingFilesDeleteAll = ManualJobName._(r'integrity-missing-files-delete-all');
static const integrityUntrackedFilesDeleteAll = ManualJobName._(r'integrity-untracked-files-delete-all');
static const integrityChecksumMismatchDeleteAll = ManualJobName._(r'integrity-checksum-mismatch-delete-all');
static const personGroupMerge = ManualJobName._(r'person-group-merge');
/// List of all possible values in this [enum][ManualJobName].
static const values = <ManualJobName>[
@@ -57,7 +56,6 @@ class ManualJobName {
integrityMissingFilesDeleteAll,
integrityUntrackedFilesDeleteAll,
integrityChecksumMismatchDeleteAll,
personGroupMerge,
];
static ManualJobName? fromJson(dynamic value) => ManualJobNameTypeTransformer().decode(value);
@@ -111,7 +109,6 @@ class ManualJobNameTypeTransformer {
case r'integrity-missing-files-delete-all': return ManualJobName.integrityMissingFilesDeleteAll;
case r'integrity-untracked-files-delete-all': return ManualJobName.integrityUntrackedFilesDeleteAll;
case r'integrity-checksum-mismatch-delete-all': return ManualJobName.integrityChecksumMismatchDeleteAll;
case r'person-group-merge': return ManualJobName.personGroupMerge;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
@@ -10,17 +10,17 @@
part of openapi.api;
class MergeFaceClusterDto {
/// Returns a new [MergeFaceClusterDto] instance.
MergeFaceClusterDto({
class MergePersonDto {
/// Returns a new [MergePersonDto] instance.
MergePersonDto({
this.ids = const [],
});
/// Face cluster IDs to merge
/// Person IDs to merge
List<String> ids;
@override
bool operator ==(Object other) => identical(this, other) || other is MergeFaceClusterDto &&
bool operator ==(Object other) => identical(this, other) || other is MergePersonDto &&
_deepEquality.equals(other.ids, ids);
@override
@@ -29,7 +29,7 @@ class MergeFaceClusterDto {
(ids.hashCode);
@override
String toString() => 'MergeFaceClusterDto[ids=$ids]';
String toString() => 'MergePersonDto[ids=$ids]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -37,15 +37,15 @@ class MergeFaceClusterDto {
return json;
}
/// Returns a new [MergeFaceClusterDto] instance and imports its values from
/// Returns a new [MergePersonDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static MergeFaceClusterDto? fromJson(dynamic value) {
upgradeDto(value, "MergeFaceClusterDto");
static MergePersonDto? fromJson(dynamic value) {
upgradeDto(value, "MergePersonDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return MergeFaceClusterDto(
return MergePersonDto(
ids: json[r'ids'] is Iterable
? (json[r'ids'] as Iterable).cast<String>().toList(growable: false)
: const [],
@@ -54,11 +54,11 @@ class MergeFaceClusterDto {
return null;
}
static List<MergeFaceClusterDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MergeFaceClusterDto>[];
static List<MergePersonDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <MergePersonDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = MergeFaceClusterDto.fromJson(row);
final value = MergePersonDto.fromJson(row);
if (value != null) {
result.add(value);
}
@@ -67,12 +67,12 @@ class MergeFaceClusterDto {
return result.toList(growable: growable);
}
static Map<String, MergeFaceClusterDto> mapFromJson(dynamic json) {
final map = <String, MergeFaceClusterDto>{};
static Map<String, MergePersonDto> mapFromJson(dynamic json) {
final map = <String, MergePersonDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = MergeFaceClusterDto.fromJson(entry.value);
final value = MergePersonDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
@@ -81,14 +81,14 @@ class MergeFaceClusterDto {
return map;
}
// maps a json object with a list of MergeFaceClusterDto-objects as value to a dart map
static Map<String, List<MergeFaceClusterDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<MergeFaceClusterDto>>{};
// maps a json object with a list of MergePersonDto-objects as value to a dart map
static Map<String, List<MergePersonDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<MergePersonDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = MergeFaceClusterDto.listFromJson(entry.value, growable: growable,);
map[entry.key] = MergePersonDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
+1 -14
View File
@@ -15,7 +15,6 @@ class PersonResponseDto {
PersonResponseDto({
required this.birthDate,
this.color = const Optional.absent(),
required this.faceClusterId,
required this.id,
this.isFavorite = const Optional.absent(),
required this.isHidden,
@@ -36,9 +35,6 @@ class PersonResponseDto {
///
Optional<String?> color;
/// Face cluster ID
String? faceClusterId;
/// Person ID
String id;
@@ -73,7 +69,6 @@ class PersonResponseDto {
bool operator ==(Object other) => identical(this, other) || other is PersonResponseDto &&
other.birthDate == birthDate &&
other.color == color &&
other.faceClusterId == faceClusterId &&
other.id == id &&
other.isFavorite == isFavorite &&
other.isHidden == isHidden &&
@@ -86,7 +81,6 @@ class PersonResponseDto {
// ignore: unnecessary_parenthesis
(birthDate == null ? 0 : birthDate!.hashCode) +
(color == null ? 0 : color!.hashCode) +
(faceClusterId == null ? 0 : faceClusterId!.hashCode) +
(id.hashCode) +
(isFavorite == null ? 0 : isFavorite!.hashCode) +
(isHidden.hashCode) +
@@ -95,7 +89,7 @@ class PersonResponseDto {
(updatedAt == null ? 0 : updatedAt!.hashCode);
@override
String toString() => 'PersonResponseDto[birthDate=$birthDate, color=$color, faceClusterId=$faceClusterId, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]';
String toString() => 'PersonResponseDto[birthDate=$birthDate, color=$color, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -107,11 +101,6 @@ class PersonResponseDto {
if (this.color.isPresent) {
final value = this.color.value;
json[r'color'] = value;
}
if (this.faceClusterId != null) {
json[r'faceClusterId'] = this.faceClusterId;
} else {
json[r'faceClusterId'] = null;
}
json[r'id'] = this.id;
if (this.isFavorite.isPresent) {
@@ -139,7 +128,6 @@ class PersonResponseDto {
return PersonResponseDto(
birthDate: mapDateTime(json, r'birthDate', r''),
color: json.containsKey(r'color') ? Optional.present(mapValueOfType<String>(json, r'color')) : const Optional.absent(),
faceClusterId: mapValueOfType<String>(json, r'faceClusterId'),
id: mapValueOfType<String>(json, r'id')!,
isFavorite: json.containsKey(r'isFavorite') ? Optional.present(mapValueOfType<bool>(json, r'isFavorite')) : const Optional.absent(),
isHidden: mapValueOfType<bool>(json, r'isHidden')!,
@@ -194,7 +182,6 @@ class PersonResponseDto {
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'birthDate',
'faceClusterId',
'id',
'isHidden',
'name',
-107
View File
@@ -1,107 +0,0 @@
//
// 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 SharingOptionsResponseDto {
/// Returns a new [SharingOptionsResponseDto] instance.
SharingOptionsResponseDto({
required this.inTimeline,
this.permissions = const [],
});
bool inTimeline;
List<SharingPermission> permissions;
@override
bool operator ==(Object other) => identical(this, other) || other is SharingOptionsResponseDto &&
other.inTimeline == inTimeline &&
_deepEquality.equals(other.permissions, permissions);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(inTimeline.hashCode) +
(permissions.hashCode);
@override
String toString() => 'SharingOptionsResponseDto[inTimeline=$inTimeline, permissions=$permissions]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'inTimeline'] = this.inTimeline;
json[r'permissions'] = this.permissions;
return json;
}
/// Returns a new [SharingOptionsResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SharingOptionsResponseDto? fromJson(dynamic value) {
upgradeDto(value, "SharingOptionsResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return SharingOptionsResponseDto(
inTimeline: mapValueOfType<bool>(json, r'inTimeline')!,
permissions: SharingPermission.listFromJson(json[r'permissions']),
);
}
return null;
}
static List<SharingOptionsResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SharingOptionsResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SharingOptionsResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SharingOptionsResponseDto> mapFromJson(dynamic json) {
final map = <String, SharingOptionsResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SharingOptionsResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SharingOptionsResponseDto-objects as value to a dart map
static Map<String, List<SharingOptionsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SharingOptionsResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = SharingOptionsResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'inTimeline',
'permissions',
};
}
-112
View File
@@ -1,112 +0,0 @@
//
// 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;
/// Sharing permission schema
class SharingPermission {
/// Instantiate a new enum with the provided [value].
const SharingPermission._(this.value);
/// The underlying value of this enum member.
final String value;
@override
String toString() => value;
String toJson() => value;
static const all = SharingPermission._(r'all');
static const assetPeriodRead = SharingPermission._(r'asset.read');
static const assetPeriodUpdate = SharingPermission._(r'asset.update');
static const assetPeriodEdit = SharingPermission._(r'asset.edit');
static const assetPeriodDelete = SharingPermission._(r'asset.delete');
static const assetPeriodShare = SharingPermission._(r'asset.share');
static const exifPeriodRead = SharingPermission._(r'exif.read');
static const personPeriodRead = SharingPermission._(r'person.read');
static const personPeriodUpdate = SharingPermission._(r'person.update');
static const personPeriodMerge = SharingPermission._(r'person.merge');
static const personPeriodDelete = SharingPermission._(r'person.delete');
/// List of all possible values in this [enum][SharingPermission].
static const values = <SharingPermission>[
all,
assetPeriodRead,
assetPeriodUpdate,
assetPeriodEdit,
assetPeriodDelete,
assetPeriodShare,
exifPeriodRead,
personPeriodRead,
personPeriodUpdate,
personPeriodMerge,
personPeriodDelete,
];
static SharingPermission? fromJson(dynamic value) => SharingPermissionTypeTransformer().decode(value);
static List<SharingPermission> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SharingPermission>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SharingPermission.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
}
/// Transformation class that can [encode] an instance of [SharingPermission] to String,
/// and [decode] dynamic data back to [SharingPermission].
class SharingPermissionTypeTransformer {
factory SharingPermissionTypeTransformer() => _instance ??= const SharingPermissionTypeTransformer._();
const SharingPermissionTypeTransformer._();
String encode(SharingPermission data) => data.value;
/// Decodes a [dynamic value][data] to a SharingPermission.
///
/// 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.
SharingPermission? decode(dynamic data, {bool allowNull = true}) {
if (data != null) {
switch (data) {
case r'all': return SharingPermission.all;
case r'asset.read': return SharingPermission.assetPeriodRead;
case r'asset.update': return SharingPermission.assetPeriodUpdate;
case r'asset.edit': return SharingPermission.assetPeriodEdit;
case r'asset.delete': return SharingPermission.assetPeriodDelete;
case r'asset.share': return SharingPermission.assetPeriodShare;
case r'exif.read': return SharingPermission.exifPeriodRead;
case r'person.read': return SharingPermission.personPeriodRead;
case r'person.update': return SharingPermission.personPeriodUpdate;
case r'person.merge': return SharingPermission.personPeriodMerge;
case r'person.delete': return SharingPermission.personPeriodDelete;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');
}
}
}
return null;
}
/// Singleton [SharingPermissionTypeTransformer] instance.
static SharingPermissionTypeTransformer? _instance;
}
+14 -14
View File
@@ -19,11 +19,11 @@ class SyncAssetFaceV2 {
required this.boundingBoxY1,
required this.boundingBoxY2,
required this.deletedAt,
required this.faceClusterId,
required this.id,
required this.imageHeight,
required this.imageWidth,
required this.isVisible,
required this.personId,
required this.sourceType,
});
@@ -57,9 +57,6 @@ class SyncAssetFaceV2 {
/// Face deleted at
DateTime? deletedAt;
/// Person ID
String? faceClusterId;
/// Asset face ID
String id;
@@ -78,6 +75,9 @@ class SyncAssetFaceV2 {
/// Is the face visible in the asset
bool isVisible;
/// Person ID
String? personId;
/// Source type
String sourceType;
@@ -89,11 +89,11 @@ class SyncAssetFaceV2 {
other.boundingBoxY1 == boundingBoxY1 &&
other.boundingBoxY2 == boundingBoxY2 &&
other.deletedAt == deletedAt &&
other.faceClusterId == faceClusterId &&
other.id == id &&
other.imageHeight == imageHeight &&
other.imageWidth == imageWidth &&
other.isVisible == isVisible &&
other.personId == personId &&
other.sourceType == sourceType;
@override
@@ -105,15 +105,15 @@ class SyncAssetFaceV2 {
(boundingBoxY1.hashCode) +
(boundingBoxY2.hashCode) +
(deletedAt == null ? 0 : deletedAt!.hashCode) +
(faceClusterId == null ? 0 : faceClusterId!.hashCode) +
(id.hashCode) +
(imageHeight.hashCode) +
(imageWidth.hashCode) +
(isVisible.hashCode) +
(personId == null ? 0 : personId!.hashCode) +
(sourceType.hashCode);
@override
String toString() => 'SyncAssetFaceV2[assetId=$assetId, boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, deletedAt=$deletedAt, faceClusterId=$faceClusterId, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth, isVisible=$isVisible, sourceType=$sourceType]';
String toString() => 'SyncAssetFaceV2[assetId=$assetId, boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, deletedAt=$deletedAt, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth, isVisible=$isVisible, personId=$personId, sourceType=$sourceType]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -128,16 +128,16 @@ class SyncAssetFaceV2 {
: this.deletedAt!.toUtc().toIso8601String();
} else {
json[r'deletedAt'] = null;
}
if (this.faceClusterId != null) {
json[r'faceClusterId'] = this.faceClusterId;
} else {
json[r'faceClusterId'] = null;
}
json[r'id'] = this.id;
json[r'imageHeight'] = this.imageHeight;
json[r'imageWidth'] = this.imageWidth;
json[r'isVisible'] = this.isVisible;
if (this.personId != null) {
json[r'personId'] = this.personId;
} else {
json[r'personId'] = null;
}
json[r'sourceType'] = this.sourceType;
return json;
}
@@ -157,11 +157,11 @@ class SyncAssetFaceV2 {
boundingBoxY1: mapValueOfType<int>(json, r'boundingBoxY1')!,
boundingBoxY2: mapValueOfType<int>(json, r'boundingBoxY2')!,
deletedAt: mapDateTime(json, r'deletedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$/'),
faceClusterId: mapValueOfType<String>(json, r'faceClusterId'),
id: mapValueOfType<String>(json, r'id')!,
imageHeight: mapValueOfType<int>(json, r'imageHeight')!,
imageWidth: mapValueOfType<int>(json, r'imageWidth')!,
isVisible: mapValueOfType<bool>(json, r'isVisible')!,
personId: mapValueOfType<String>(json, r'personId'),
sourceType: mapValueOfType<String>(json, r'sourceType')!,
);
}
@@ -216,11 +216,11 @@ class SyncAssetFaceV2 {
'boundingBoxY1',
'boundingBoxY2',
'deletedAt',
'faceClusterId',
'id',
'imageHeight',
'imageWidth',
'isVisible',
'personId',
'sourceType',
};
}
-107
View File
@@ -1,107 +0,0 @@
//
// 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 UpdateSharingOptionsDto {
/// Returns a new [UpdateSharingOptionsDto] instance.
UpdateSharingOptionsDto({
required this.inTimeline,
this.permissions = const [],
});
bool inTimeline;
List<SharingPermission> permissions;
@override
bool operator ==(Object other) => identical(this, other) || other is UpdateSharingOptionsDto &&
other.inTimeline == inTimeline &&
_deepEquality.equals(other.permissions, permissions);
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(inTimeline.hashCode) +
(permissions.hashCode);
@override
String toString() => 'UpdateSharingOptionsDto[inTimeline=$inTimeline, permissions=$permissions]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'inTimeline'] = this.inTimeline;
json[r'permissions'] = this.permissions;
return json;
}
/// Returns a new [UpdateSharingOptionsDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static UpdateSharingOptionsDto? fromJson(dynamic value) {
upgradeDto(value, "UpdateSharingOptionsDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return UpdateSharingOptionsDto(
inTimeline: mapValueOfType<bool>(json, r'inTimeline')!,
permissions: SharingPermission.listFromJson(json[r'permissions']),
);
}
return null;
}
static List<UpdateSharingOptionsDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <UpdateSharingOptionsDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = UpdateSharingOptionsDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, UpdateSharingOptionsDto> mapFromJson(dynamic json) {
final map = <String, UpdateSharingOptionsDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = UpdateSharingOptionsDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of UpdateSharingOptionsDto-objects as value to a dart map
static Map<String, List<UpdateSharingOptionsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<UpdateSharingOptionsDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = UpdateSharingOptionsDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'inTimeline',
'permissions',
};
}
+10 -193
View File
@@ -2693,121 +2693,6 @@
"x-immich-permission": "album.read"
}
},
"/albums/{id}/user/self": {
"get": {
"description": "Get the own sharing permissions in a specific album.",
"operationId": "getOwnAlbumUser",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SharingOptionsResponseDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Get own sharing permissions",
"tags": [
"Albums"
],
"x-immich-history": [
{
"version": "v3",
"state": "Added"
},
{
"version": "v3",
"state": "Stable"
}
],
"x-immich-permission": "albumAsset.create",
"x-immich-state": "Stable"
},
"put": {
"description": "Change the own sharing permissions in a specific album.",
"operationId": "updateOwnAlbumUser",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateSharingOptionsDto"
}
}
},
"required": true
},
"responses": {
"204": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Update own sharing permissions",
"tags": [
"Albums"
],
"x-immich-history": [
{
"version": "v3",
"state": "Added"
},
{
"version": "v3",
"state": "Stable"
}
],
"x-immich-permission": "albumAsset.create",
"x-immich-state": "Stable"
}
},
"/albums/{id}/user/{userId}": {
"delete": {
"description": "Remove a user from an album. Use an ID of \"me\" to leave a shared album.",
@@ -9282,7 +9167,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MergeFaceClusterDto"
"$ref": "#/components/schemas/MergePersonDto"
}
}
},
@@ -18075,12 +17960,6 @@
},
"type": "array"
},
"permissions": {
"items": {
"$ref": "#/components/schemas/SharingPermission"
},
"type": "array"
},
"resized": {
"description": "Is resized",
"type": "boolean",
@@ -18152,7 +18031,6 @@
"originalFileName",
"originalPath",
"ownerId",
"permissions",
"thumbhash",
"type",
"updatedAt",
@@ -19354,7 +19232,6 @@
"DatabaseBackup",
"FacialRecognitionQueueAll",
"FacialRecognition",
"FacialRecognitionMerge",
"FileDelete",
"FileMigrationQueueAll",
"LibraryDeleteCheck",
@@ -19790,8 +19667,7 @@
"integrity-checksum-mismatch-refresh",
"integrity-missing-files-delete-all",
"integrity-untracked-files-delete-all",
"integrity-checksum-mismatch-delete-all",
"person-group-merge"
"integrity-checksum-mismatch-delete-all"
],
"type": "string"
},
@@ -20123,10 +19999,10 @@
},
"type": "object"
},
"MergeFaceClusterDto": {
"MergePersonDto": {
"properties": {
"ids": {
"description": "Face cluster IDs to merge",
"description": "Person IDs to merge",
"items": {
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
@@ -21172,11 +21048,6 @@
],
"x-immich-state": "Stable"
},
"faceClusterId": {
"description": "Face cluster ID",
"nullable": true,
"type": "string"
},
"id": {
"description": "Person ID",
"format": "uuid",
@@ -21229,7 +21100,6 @@
},
"required": [
"birthDate",
"faceClusterId",
"id",
"isHidden",
"name",
@@ -23310,41 +23180,6 @@
},
"type": "object"
},
"SharingOptionsResponseDto": {
"properties": {
"inTimeline": {
"type": "boolean"
},
"permissions": {
"items": {
"$ref": "#/components/schemas/SharingPermission"
},
"type": "array"
}
},
"required": [
"inTimeline",
"permissions"
],
"type": "object"
},
"SharingPermission": {
"description": "Sharing permission schema",
"enum": [
"all",
"asset.read",
"asset.update",
"asset.edit",
"asset.delete",
"asset.share",
"exif.read",
"person.read",
"person.update",
"person.merge",
"person.delete"
],
"type": "string"
},
"SignUpDto": {
"properties": {
"email": {
@@ -24497,11 +24332,6 @@
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$",
"type": "string"
},
"faceClusterId": {
"description": "Person ID",
"nullable": true,
"type": "string"
},
"id": {
"description": "Asset face ID",
"format": "uuid",
@@ -24524,6 +24354,11 @@
"description": "Is the face visible in the asset",
"type": "boolean"
},
"personId": {
"description": "Person ID",
"nullable": true,
"type": "string"
},
"sourceType": {
"description": "Source type",
"type": "string"
@@ -24536,11 +24371,11 @@
"boundingBoxY1",
"boundingBoxY2",
"deletedAt",
"faceClusterId",
"id",
"imageHeight",
"imageWidth",
"isVisible",
"personId",
"sourceType"
],
"type": "object"
@@ -27306,24 +27141,6 @@
},
"type": "object"
},
"UpdateSharingOptionsDto": {
"properties": {
"inTimeline": {
"type": "boolean"
},
"permissions": {
"items": {
"$ref": "#/components/schemas/SharingPermission"
},
"type": "array"
}
},
"required": [
"inTimeline",
"permissions"
],
"type": "object"
},
"UsageByUserDto": {
"properties": {
"photos": {
+1 -10
View File
@@ -222,16 +222,7 @@
"name": "assetLock",
"title": "Move to locked folder",
"description": "Change visibility to locked",
"types": ["AssetV1"],
"schema": {
"properties": {
"inverse": {
"title": "Inverse",
"description": "When true will unarchive any archived assets",
"type": "boolean"
}
}
}
"types": ["AssetV1"]
},
{
"name": "assetTimeline",
+1 -1
View File
@@ -5,7 +5,7 @@
"main": "src/index.ts",
"scripts": {
"build": "pnpm build:tsc && pnpm build:wasm",
"build:tsc": "mkdir -p dist && echo \"type Manifest = $(cat manifest.json); \nexport default Manifest;\" > dist/manifest.d.ts && tsc --noEmit && node esbuild.js",
"build:tsc": "tsc --noEmit && node esbuild.js",
"build:wasm": "extism-js dist/index.js -i src/index.d.ts -o dist/plugin.wasm"
},
"keywords": [],
+1 -1
View File
@@ -22,6 +22,6 @@ declare module 'main' {
export function assetArchive(): I32;
export function assetLock(): I32;
export function assetTimeline(): I32;
// export function assetTrash(): I32;
export function assetTrash(): I32;
export function assetAddToAlbums(): I32;
}
+27 -19
View File
@@ -1,11 +1,13 @@
import { getWrapper } from '@immich/plugin-sdk';
import { AssetVisibility } from '@immich/sdk';
import type manifestType from '../dist/manifest';
const wrapper = getWrapper<manifestType>();
import { wrapper } from '@immich/plugin-sdk';
import { AssetTypeEnum, AssetVisibility, WorkflowType } from '@immich/sdk';
type AssetFileFilterConfig = {
pattern: string;
matchType?: 'contains' | 'exact' | 'regex' | 'startsWith';
caseSensitive?: boolean;
};
export const assetFileFilter = () => {
return wrapper<'assetFileFilter'>(({ data, config }) => {
return wrapper<WorkflowType.AssetV1, AssetFileFilterConfig>(({ data, config }) => {
const { pattern, matchType = 'contains', caseSensitive = false } = config;
const { asset } = data;
@@ -41,7 +43,7 @@ export const assetFileFilter = () => {
};
export const assetMissingTimeZoneFilter = () => {
return wrapper<'assetMissingTimeZoneFilter'>(({ config, data }) => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
const hasTimeZone = !!data.asset?.exifInfo?.timeZone;
const needsTimeZone = config.inverse ? true : false;
return { workflow: { continue: hasTimeZone === needsTimeZone } };
@@ -49,7 +51,13 @@ export const assetMissingTimeZoneFilter = () => {
};
export const assetLocationFilter = () => {
return wrapper<'assetLocationFilter'>(({ config, data }) => {
return wrapper<
WorkflowType.AssetV1,
{
region?: { country?: string; state?: string; city?: string };
coordinate?: { latitude?: string; longitude?: string; radius?: number };
}
>(({ config, data }) => {
if (
(config.region?.country && config.region.country !== data.asset.exifInfo?.country) ||
(config.region?.state && config.region.state !== data.asset.exifInfo?.state) ||
@@ -88,13 +96,13 @@ export const assetLocationFilter = () => {
};
export const assetTypeFilter = () => {
return wrapper<'assetTypeFilter'>(({ config, data }) => {
return wrapper<WorkflowType.AssetV1, { allowedTypes: AssetTypeEnum[] }>(({ config, data }) => {
return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } };
});
};
export const assetFavorite = () => {
return wrapper<'assetFavorite'>(({ config, data }) => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
const target = config.inverse ? false : true;
if (target !== data.asset.isFavorite) {
return {
@@ -107,13 +115,13 @@ export const assetFavorite = () => {
};
export const assetVisibility = () => {
return wrapper<'assetVisibility'>(({ config }) => ({
changes: { asset: { visibility: config.visibility as AssetVisibility } },
return wrapper<WorkflowType.AssetV1, { visibility: AssetVisibility }>(({ config }) => ({
changes: { asset: { visibility: config.visibility } },
}));
};
export const assetArchive = () => {
return wrapper<'assetArchive'>(({ config, data }) => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) {
return { changes: { asset: { visibility: AssetVisibility.Archive } } };
}
@@ -127,7 +135,7 @@ export const assetArchive = () => {
};
export const assetLock = () => {
return wrapper<'assetLock'>(({ config, data }) => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) {
return { changes: { asset: { visibility: AssetVisibility.Locked } } };
}
@@ -140,13 +148,13 @@ export const assetLock = () => {
});
};
// export const assetTrash = () => {
// // TODO use trash/untrash host functions
// return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
// };
export const assetTrash = () => {
// TODO use trash/untrash host functions
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
};
export const assetAddToAlbums = () => {
return wrapper<'assetAddToAlbums'>(({ config, data, functions }) => {
return wrapper<WorkflowType.AssetV1, { albumIds: string[]; albumName?: string }>(({ config, data, functions }) => {
const assetId = data.asset.id;
if (config.albumIds.length === 0) {
+39 -90
View File
@@ -1,104 +1,53 @@
import type { WorkflowType } from '@immich/sdk';
import { hostFunctions } from 'src/host-functions.js';
import type {
ConfigValue,
WorkflowEventPayload,
WorkflowResponse,
WorkflowStepConfig,
} from 'src/types.js';
type Property = {
type: 'string' | 'boolean' | 'number';
array?: boolean;
enum?: string[];
} & {
type: 'object';
properties: { [K: string]: Property };
required?: string[];
};
export const wrapper = <
T extends WorkflowType,
TConfig extends ConfigValue = ConfigValue,
>(
fn: (
payload: WorkflowEventPayload<T, TConfig> & {
functions: ReturnType<typeof hostFunctions>;
},
) => WorkflowResponse<T> | undefined,
) => {
const input = Host.inputString();
type RequiredProperties<
Properties extends { [K: string]: unknown },
Required extends string[] | undefined,
RequiredKeys extends string = Required extends undefined
? never
: NonNullable<Required>[number],
> = {
properties: Pick<Properties, RequiredKeys> &
Partial<Omit<Properties, RequiredKeys>>;
};
try {
const payload = JSON.parse(input) as WorkflowEventPayload<T, TConfig>;
const event = {
...payload,
functions: hostFunctions(payload.workflow.authToken),
};
type GetConfigType<T extends Property> = 'enum' extends keyof T
? NonNullable<T['enum']>[number]
: T['type'] extends 'boolean'
? boolean
: T['type'] extends 'number'
? number
: T['type'] extends 'string'
? string
: T['type'] extends 'object'
? ConfigValue<T>
: never;
const eventConfigBefore = JSON.stringify(event.config);
type ConfigValue<
T extends { properties: { [K: string]: Property }; required?: string[] },
Properties extends { [K: string]: Property } = T['properties'],
> = T extends never
? never
: RequiredProperties<
{
[K in keyof Properties]: Properties[K]['array'] extends true
? Array<GetConfigType<Properties[K]>>
: GetConfigType<Properties[K]>;
},
'required' extends keyof T ? T['required'] : undefined
>['properties'];
console.debug(
`Inputs: trigger=${event.trigger}, event=${event.type}, config=${eventConfigBefore}`,
);
export const getWrapper =
<T extends Record<string, any>>() =>
<
K extends T['methods'][number]['name'],
L extends WorkflowType = (T['methods'][number] & {
name: K;
})['types'][number],
TConfig = ConfigValue<(T['methods'][number] & { name: K })['schema']>,
>(
fn: (
payload: WorkflowEventPayload<L, TConfig> & {
functions: ReturnType<typeof hostFunctions>;
},
) => WorkflowResponse<L> | undefined,
) => {
const input = Host.inputString();
const response = fn(event) ?? {};
try {
const payload = JSON.parse(input) as WorkflowEventPayload<K, TConfig>;
const event = {
...payload,
functions: hostFunctions(payload.workflow.authToken),
};
const eventConfigBefore = JSON.stringify(event.config);
console.debug(
`Inputs: trigger=${event.trigger}, event=${event.type}, config=${eventConfigBefore}`,
);
const response = fn(event) ?? {};
// if config changed, notify host
const eventConfigAfter = JSON.stringify(event.config);
if (!response.config && eventConfigBefore !== eventConfigAfter) {
response.config = event.config as WorkflowStepConfig;
}
console.debug(
`Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}, config=${JSON.stringify(response.config)}`,
);
const output = JSON.stringify(response);
Host.outputString(output);
} catch (error: Error | any) {
console.error(`Unhandled plugin exception: ${error.message || error}`);
throw error;
// if config changed, notify host
const eventConfigAfter = JSON.stringify(event.config);
if (!response.config && eventConfigBefore !== eventConfigAfter) {
response.config = event.config as WorkflowStepConfig;
}
};
console.debug(
`Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}, config=${JSON.stringify(response.config)}`,
);
const output = JSON.stringify(response);
Host.outputString(output);
} catch (error: Error | any) {
console.error(`Unhandled plugin exception: ${error.message || error}`);
throw error;
}
};
+8 -60
View File
@@ -588,14 +588,6 @@ export type MapMarkerResponseDto = {
/** State/Province name */
state: string | null;
};
export type SharingOptionsResponseDto = {
inTimeline: boolean;
permissions: SharingPermission[];
};
export type UpdateSharingOptionsDto = {
inTimeline: boolean;
permissions: SharingPermission[];
};
export type UpdateAlbumUserDto = {
role: AlbumUserRole;
};
@@ -833,8 +825,6 @@ export type PersonResponseDto = {
birthDate: string | null;
/** Person color (hex) */
color?: string;
/** Face cluster ID */
faceClusterId: string | null;
/** Person ID */
id: string;
/** Is favorite */
@@ -918,7 +908,6 @@ export type AssetResponseDto = {
/** Owner user ID */
ownerId: string;
people?: PersonResponseDto[];
permissions: SharingPermission[];
/** Is resized */
resized?: boolean;
stack?: (AssetStackResponseDto) | null;
@@ -1505,8 +1494,8 @@ export type PersonUpdateDto = {
/** Person name */
name?: string;
};
export type MergeFaceClusterDto = {
/** Face cluster IDs to merge */
export type MergePersonDto = {
/** Person IDs to merge */
ids: string[];
};
export type AssetFaceUpdateItem = {
@@ -3034,8 +3023,6 @@ export type SyncAssetFaceV2 = {
boundingBoxY2: number;
/** Face deleted at */
deletedAt: string | null;
/** Person ID */
faceClusterId: string | null;
/** Asset face ID */
id: string;
/** Image height */
@@ -3044,6 +3031,8 @@ export type SyncAssetFaceV2 = {
imageWidth: number;
/** Is the face visible in the asset */
isVisible: boolean;
/** Person ID */
personId: string | null;
/** Source type */
sourceType: string;
};
@@ -3968,32 +3957,6 @@ export function getAlbumMapMarkers({ id, key, slug }: {
...opts
}));
}
/**
* Get own sharing permissions
*/
export function getOwnAlbumUser({ id }: {
id: string;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: SharingOptionsResponseDto;
}>(`/albums/${encodeURIComponent(id)}/user/self`, {
...opts
}));
}
/**
* Update own sharing permissions
*/
export function updateOwnAlbumUser({ id, updateSharingOptionsDto }: {
id: string;
updateSharingOptionsDto: UpdateSharingOptionsDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText(`/albums/${encodeURIComponent(id)}/user/self`, oazapfts.json({
...opts,
method: "PUT",
body: updateSharingOptionsDto
})));
}
/**
* Remove user from album
*/
@@ -5482,9 +5445,9 @@ export function updatePerson({ id, personUpdateDto }: {
/**
* Merge people
*/
export function mergePerson({ id, mergeFaceClusterDto }: {
export function mergePerson({ id, mergePersonDto }: {
id: string;
mergeFaceClusterDto: MergeFaceClusterDto;
mergePersonDto: MergePersonDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
@@ -5492,7 +5455,7 @@ export function mergePerson({ id, mergeFaceClusterDto }: {
}>(`/people/${encodeURIComponent(id)}/merge`, oazapfts.json({
...opts,
method: "POST",
body: mergeFaceClusterDto
body: mergePersonDto
})));
}
/**
@@ -7178,19 +7141,6 @@ export enum BulkIdErrorReason {
Unknown = "unknown",
Validation = "validation"
}
export enum SharingPermission {
All = "all",
AssetRead = "asset.read",
AssetUpdate = "asset.update",
AssetEdit = "asset.edit",
AssetDelete = "asset.delete",
AssetShare = "asset.share",
ExifRead = "exif.read",
PersonRead = "person.read",
PersonUpdate = "person.update",
PersonMerge = "person.merge",
PersonDelete = "person.delete"
}
export enum Permission {
All = "all",
ActivityCreate = "activity.create",
@@ -7407,8 +7357,7 @@ export enum ManualJobName {
IntegrityChecksumMismatchRefresh = "integrity-checksum-mismatch-refresh",
IntegrityMissingFilesDeleteAll = "integrity-missing-files-delete-all",
IntegrityUntrackedFilesDeleteAll = "integrity-untracked-files-delete-all",
IntegrityChecksumMismatchDeleteAll = "integrity-checksum-mismatch-delete-all",
PersonGroupMerge = "person-group-merge"
IntegrityChecksumMismatchDeleteAll = "integrity-checksum-mismatch-delete-all"
}
export enum QueueName {
ThumbnailGeneration = "thumbnailGeneration",
@@ -7485,7 +7434,6 @@ export enum JobName {
DatabaseBackup = "DatabaseBackup",
FacialRecognitionQueueAll = "FacialRecognitionQueueAll",
FacialRecognition = "FacialRecognition",
FacialRecognitionMerge = "FacialRecognitionMerge",
FileDelete = "FileDelete",
FileMigrationQueueAll = "FileMigrationQueueAll",
LibraryDeleteCheck = "LibraryDeleteCheck",
@@ -11,7 +11,6 @@ import {
GetAlbumsDto,
UpdateAlbumDto,
UpdateAlbumUserDto,
UpdateSharingPermissionsDto as UpdateSharingOptionsDto,
} from 'src/dtos/album.dto';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -166,33 +165,6 @@ export class AlbumController {
return this.service.addUsers(auth, id, dto);
}
@Get(':id/user/self')
@Authenticated({ permission: Permission.AlbumAssetCreate })
@Endpoint({
summary: 'Get own sharing permissions',
description: 'Get the own sharing permissions in a specific album.',
history: new HistoryBuilder().added('v3').stable('v3'),
})
getOwnAlbumUser(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) {
return this.service.getSelf(auth, id);
}
@Put(':id/user/self')
@Authenticated({ permission: Permission.AlbumAssetCreate })
@HttpCode(HttpStatus.NO_CONTENT)
@Endpoint({
summary: 'Update own sharing permissions',
description: 'Change the own sharing permissions in a specific album.',
history: new HistoryBuilder().added('v3').stable('v3'),
})
updateOwnAlbumUser(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: UpdateSharingOptionsDto,
): Promise<void> {
return this.service.updateSelf(auth, id, dto);
}
@Put(':id/user/:userId')
@Authenticated({ permission: Permission.AlbumUserUpdate })
@HttpCode(HttpStatus.NO_CONTENT)
+2 -2
View File
@@ -20,7 +20,7 @@ import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
AssetFaceUpdateDto,
MergeFaceClusterDto,
MergePersonDto,
PeopleResponseDto,
PeopleUpdateDto,
PersonCreateDto,
@@ -198,7 +198,7 @@ export class PersonController {
mergePerson(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: MergeFaceClusterDto,
@Body() dto: MergePersonDto,
): Promise<BulkIdResponseDto[]> {
return this.service.mergePerson(auth, id, dto);
}
+1 -4
View File
@@ -9,7 +9,6 @@ import {
MemoryType,
Permission,
SharedLinkType,
SharingPermission,
SourceType,
UserAvatarColor,
UserStatus,
@@ -210,7 +209,6 @@ export type Partner = {
updatedAt: Date;
updateId: string;
inTimeline: boolean;
permissions: SharingPermission[];
};
export type Place = {
@@ -254,7 +252,6 @@ export type Person = {
faceAssetId: string | null;
isHidden: boolean;
thumbnailPath: string;
faceClusterId: string | null;
};
export type AssetFace = {
@@ -267,7 +264,7 @@ export type AssetFace = {
boundingBoxY2: number;
imageHeight: number;
imageWidth: number;
faceClusterId: string | null;
personId: string | null;
sourceType: SourceType;
person?: ShallowDehydrateObject<Person> | null;
updatedAt: Date;
+2 -12
View File
@@ -3,8 +3,8 @@ import { createZodDto } from 'nestjs-zod';
import { AlbumUser, AuthSharedLink } from 'src/database';
import { BulkIdErrorReasonSchema } from 'src/dtos/asset-ids.response.dto';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { mapUser, UserResponseSchema } from 'src/dtos/user.dto';
import { AlbumUserRole, AlbumUserRoleSchema, AssetOrder, AssetOrderSchema, SharingPermissionSchema } from 'src/enum';
import { UserResponseSchema, mapUser } from 'src/dtos/user.dto';
import { AlbumUserRole, AlbumUserRoleSchema, AssetOrder, AssetOrderSchema } from 'src/enum';
import { MaybeDehydrated } from 'src/types';
import { asDateTimeString } from 'src/utils/date';
import { stringToBool } from 'src/validation';
@@ -63,14 +63,6 @@ const UpdateAlbumSchema = z
})
.meta({ id: 'UpdateAlbumDto' });
const UpdateSharingOptionsSchema = z
.object({ inTimeline: z.boolean(), permissions: z.array(SharingPermissionSchema) })
.meta({ id: 'UpdateSharingOptionsDto' });
const SharingOptionsResponseSchema = z
.object({ inTimeline: z.boolean(), permissions: z.array(SharingPermissionSchema) })
.meta({ id: 'SharingOptionsResponseDto' });
const GetAlbumsSchema = z
.object({
id: z.uuidv4().optional().describe('Album ID'),
@@ -157,8 +149,6 @@ export class UpdateAlbumDto extends createZodDto(UpdateAlbumSchema) {}
export class GetAlbumsDto extends createZodDto(GetAlbumsSchema) {}
export class AlbumStatisticsResponseDto extends createZodDto(AlbumStatisticsResponseSchema) {}
export class UpdateAlbumUserDto extends createZodDto(UpdateAlbumUserSchema) {}
export class UpdateSharingPermissionsDto extends createZodDto(UpdateSharingOptionsSchema) {}
export class SharingPermissionsResponseDto extends createZodDto(SharingOptionsResponseSchema) {}
export class AlbumResponseDto extends createZodDto(AlbumResponseSchema) {}
class AlbumUserResponseDto extends createZodDto(AlbumUserResponseSchema) {}
+1 -16
View File
@@ -15,8 +15,6 @@ import {
AssetVisibility,
AssetVisibilitySchema,
ChecksumAlgorithm,
SharingPermission,
SharingPermissionSchema,
} from 'src/enum';
import { MaybeDehydrated } from 'src/types';
import { hexOrBufferToBase64 } from 'src/utils/bytes';
@@ -47,7 +45,6 @@ const SanitizedAssetResponseSchema = z
hasMetadata: z.boolean().describe('Whether asset has metadata'),
width: z.int().min(0).nullable().describe('Asset width'),
height: z.int().min(0).nullable().describe('Asset height'),
permissions: z.array(SharingPermissionSchema),
})
.meta({ id: 'SanitizedAssetResponseDto' });
@@ -116,7 +113,6 @@ export const AssetResponseSchema = SanitizedAssetResponseSchema.extend(
.boolean()
.describe('Is edited')
.meta(new HistoryBuilder().added('v2.5.0').beta('v2.5.0').getExtensions()),
permissions: z.array(SharingPermissionSchema),
}).shape,
).meta({ id: 'AssetResponseDto' });
@@ -158,7 +154,6 @@ export type MapAsset = {
width: number | null;
height: number | null;
isEdited: boolean;
permissions?: { permission: SharingPermission }[];
};
export type AssetMapOptions = {
@@ -197,16 +192,8 @@ const mapStack = (entity: { stack?: Stack | null }) => {
export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOptions = {}): AssetResponseDto {
const { stripMetadata = false, withStack = false } = options;
const permissions =
options.auth?.user.id === entity.ownerId
? [SharingPermission.All]
: (entity.permissions?.map(({ permission }) => permission) ?? []);
if (
stripMetadata ||
(entity.permissions &&
!(permissions.includes(SharingPermission.All) || permissions.includes(SharingPermission.ExifRead)))
) {
if (stripMetadata) {
const sanitizedAssetResponse: SanitizedAssetResponseDto = {
id: entity.id,
type: entity.type,
@@ -218,7 +205,6 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
hasMetadata: false,
width: entity.width,
height: entity.height,
permissions,
};
return sanitizedAssetResponse as AssetResponseDto;
}
@@ -256,6 +242,5 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
width: entity.width,
height: entity.height,
isEdited: entity.isEdited,
permissions,
};
}
+7 -7
View File
@@ -2,6 +2,7 @@ import { Selectable } from 'kysely';
import { createZodDto } from 'nestjs-zod';
import { AssetFace, Person } from 'src/database';
import { HistoryBuilder } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { SourceTypeSchema } from 'src/enum';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
@@ -41,11 +42,11 @@ const PeopleUpdateSchema = z
})
.meta({ id: 'PeopleUpdateDto' });
const MergeFaceClusterSchema = z
const MergePersonSchema = z
.object({
ids: z.array(z.uuidv4()).describe('Face cluster IDs to merge'),
ids: z.array(z.uuidv4()).describe('Person IDs to merge'),
})
.meta({ id: 'MergeFaceClusterDto' });
.meta({ id: 'MergePersonDto' });
const PersonSearchSchema = z
.object({
@@ -82,14 +83,13 @@ export const PersonResponseSchema = z
.optional()
.describe('Person color (hex)')
.meta(new HistoryBuilder().added('v1.126.0').stable('v2').getExtensions()),
faceClusterId: z.string().nullable().describe('Face cluster ID'),
})
.meta({ id: 'PersonResponseDto' });
export class PersonCreateDto extends createZodDto(PersonCreateSchema) {}
export class PersonUpdateDto extends createZodDto(PersonUpdateSchema) {}
export class PeopleUpdateDto extends createZodDto(PeopleUpdateSchema) {}
export class MergeFaceClusterDto extends createZodDto(MergeFaceClusterSchema) {}
export class MergePersonDto extends createZodDto(MergePersonSchema) {}
export class PersonSearchDto extends createZodDto(PersonSearchSchema) {}
export class PersonResponseDto extends createZodDto(PersonResponseSchema) {}
@@ -181,7 +181,6 @@ export function mapPerson(person: MaybeDehydrated<Person>): PersonResponseDto {
isFavorite: person.isFavorite,
color: person.color ?? undefined,
updatedAt: asDateTimeString(person.updatedAt),
faceClusterId: person.faceClusterId,
};
}
@@ -210,11 +209,12 @@ function mapFacesWithoutPerson(
export function mapFaces(
face: AssetFace,
auth: AuthDto,
edits?: AssetEditActionItem[],
assetDimensions?: ImageDimensions,
): AssetFaceResponseDto {
return {
...mapFacesWithoutPerson(face, edits, assetDimensions),
person: face.person ? mapPerson(face.person) : null,
person: face.person?.ownerId === auth.user.id ? mapPerson(face.person) : null,
};
}
+4 -7
View File
@@ -365,13 +365,10 @@ const SyncAssetFaceV1Schema = z
})
.meta({ id: 'SyncAssetFaceV1' });
const SyncAssetFaceV2Schema = SyncAssetFaceV1Schema.omit({ personId: true })
.extend({
deletedAt: isoDatetimeToDate.nullable().describe('Face deleted at'),
isVisible: z.boolean().describe('Is the face visible in the asset'),
faceClusterId: z.string().nullable().describe('Person ID'),
})
.meta({ id: 'SyncAssetFaceV2' });
const SyncAssetFaceV2Schema = SyncAssetFaceV1Schema.extend({
deletedAt: isoDatetimeToDate.nullable().describe('Face deleted at'),
isVisible: z.boolean().describe('Is the face visible in the asset'),
}).meta({ id: 'SyncAssetFaceV2' });
const SyncAssetFaceDeleteV1Schema = z
.object({ assetFaceId: z.uuidv4().describe('Asset face ID') })
-24
View File
@@ -309,28 +309,6 @@ export enum Permission {
AdminAuthUnlinkAll = 'adminAuth.unlinkAll',
}
export enum SharingPermission {
All = 'all',
AssetRead = 'asset.read',
AssetUpdate = 'asset.update',
AssetEdit = 'asset.edit',
AssetDelete = 'asset.delete',
AssetShare = 'asset.share',
ExifRead = 'exif.read',
PersonRead = 'person.read',
PersonUpdate = 'person.update',
PersonMerge = 'person.merge',
PersonDelete = 'person.delete',
}
export const SharingPermissionSchema = z
.enum(SharingPermission)
.describe('Sharing permission schema')
.meta({ id: 'SharingPermission' });
export enum SharedLinkType {
Album = 'ALBUM',
@@ -450,7 +428,6 @@ export enum ManualJobName {
IntegrityMissingFilesDeleteAll = `integrity-missing-files-delete-all`,
IntegrityUntrackedFilesDeleteAll = `integrity-untracked-files-delete-all`,
IntegrityChecksumFilesDeleteAll = `integrity-checksum-mismatch-delete-all`,
PersonGroupMerge = 'person-group-merge',
}
export const ManualJobNameSchema = z.enum(ManualJobName).describe('Manual job name').meta({ id: 'ManualJobName' });
@@ -857,7 +834,6 @@ export enum JobName {
FacialRecognitionQueueAll = 'FacialRecognitionQueueAll',
FacialRecognition = 'FacialRecognition',
FacialRecognitionMerge = 'FacialRecognitionMerge',
FileDelete = 'FileDelete',
FileMigrationQueueAll = 'FileMigrationQueueAll',
-34
View File
@@ -149,40 +149,6 @@ where
"albumAssets"."livePhotoVideoId"
] && array[$2]::uuid[]
-- AccessRepository.asset.checkSharedAccess
select
"album_asset"."assetId"
from
"album_asset"
inner join "album_user" on "album_asset"."albumId" = "album_user"."albumId"
and "album_user"."userId" = $1
where
"album_asset"."assetId" in ($2)
and "album_asset"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
(
"album_user"."permissions" @> $3::sharing_permission_enum[]
or $4 = any ("album_user"."permissions")
)
)
union
select
"asset"."id" as "assetId"
from
"partner"
inner join "asset" on "asset"."ownerId" = "partner"."sharedById"
and "asset"."id" in ($5)
where
"partner"."sharedWithId" = $6
and (
"partner"."permissions" @> $7::sharing_permission_enum[]
or $8 = any ("partner"."permissions")
)
-- AccessRepository.authDevice.checkOwnerAccess
select
"session"."id"
+14 -52
View File
@@ -182,25 +182,18 @@ select
from
(
select
(
select
to_json(obj)
from
(
select
"person".*
from
"face_cluster"
inner join "person" on "person"."faceClusterId" = "face_cluster"."id"
where
"face_cluster"."id" = "asset_face"."faceClusterId"
limit
$1
) as obj
) as "person",
"asset_face".*
"asset_face".*,
"person" as "person"
from
"asset_face"
left join lateral (
select
"person".*
from
"person"
where
"asset_face"."personId" = "person"."id"
) as "person" on true
where
"asset_face"."assetId" = "asset"."id"
and "asset_face"."deletedAt" is null
@@ -231,7 +224,7 @@ from
"asset"
left join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
where
"asset"."id" = any ($2::uuid[])
"asset"."id" = any ($1::uuid[])
-- AssetRepository.deleteAll
delete from "asset"
@@ -297,44 +290,13 @@ limit
-- AssetRepository.getById
select
"asset".*,
(
select
coalesce(json_agg(agg), '[]')
from
(
select distinct
unnest("album_user"."permissions") as "permission"
from
"album_user"
inner join "album_asset" on "album_user"."albumId" = "album_asset"."albumId"
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."userId" = "asset"."ownerId"
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = $1
)
union
select distinct
unnest("partner"."permissions") as "permission"
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $2
) as agg
) as "permissions"
"asset".*
from
"asset"
where
"asset"."id" = $3::uuid
"asset"."id" = $1::uuid
limit
$4
$2
-- AssetRepository.updateAll
update "asset"
+2 -2
View File
@@ -47,7 +47,7 @@ select
$1 as "one"
from
"asset_face"
inner join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId"
inner join "person" on "person"."id" = "asset_face"."personId"
where
"asset_face"."assetId" = "asset"."id"
and "person"."isHidden" = $2
@@ -86,7 +86,7 @@ select
$1 as "one"
from
"asset_face"
inner join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId"
inner join "person" on "person"."id" = "asset_face"."personId"
where
"asset_face"."assetId" = "asset"."id"
and "person"."isHidden" = $2
+33 -225
View File
@@ -3,6 +3,9 @@
-- PersonRepository.reassignFaces
update "asset_face"
set
"personId" = $1
where
"asset_face"."personId" = $2
-- PersonRepository.delete
delete from "person"
@@ -21,59 +24,24 @@ limit
3
-- PersonRepository.getAllForUser
select distinct
on ("person"."faceClusterId") "person".*
select
"person".*
from
"person"
inner join "asset_face" on "asset_face"."faceClusterId" = "person"."faceClusterId"
inner join "asset_face" on "asset_face"."personId" = "person"."id"
inner join "asset" on "asset_face"."assetId" = "asset"."id"
and "asset"."visibility" = 'timeline'
and "asset"."deletedAt" is null
where
(
"person"."ownerId" = $1
or (
exists (
select
from
"partner"
where
"partner"."sharedById" = "person"."ownerId"
and "partner"."sharedWithId" = $2
and (
$3 = any ("partner"."permissions")
or "partner"."permissions" @> $4
)
)
or exists (
select
from
"album_user"
where
"album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = $5
)
and "album_user"."userId" = "person"."ownerId"
and (
$6 = any ("album_user"."permissions")
or "album_user"."permissions" @> $7
)
)
)
)
"person"."ownerId" = $1
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
and "person"."isHidden" = $8
and "person"."isHidden" = $2
group by
"person"."id"
having
(
"person"."name" != $9
"person"."name" != $3
or count("asset_face"."assetId") >= COALESCE(
(
SELECT
@@ -81,15 +49,13 @@ having
FROM
user_metadata
WHERE
"userId" = $10
"userId" = $4
AND key = 'preferences'
),
'3'
)::int
)
order by
"person"."faceClusterId",
"person"."ownerId" = $11 desc,
"person"."isHidden" asc,
"person"."isFavorite" desc,
NULLIF(person.name, '') is null asc,
@@ -97,16 +63,16 @@ order by
NULLIF(person.name, '') asc nulls last,
"person"."createdAt"
limit
$12
$5
offset
$13
$6
-- PersonRepository.getAllWithoutFaces
select
"person".*
from
"person"
left join "asset_face" on "asset_face"."faceClusterId" = "person"."faceClusterId"
left join "asset_face" on "asset_face"."personId" = "person"."id"
where
"asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
@@ -128,26 +94,15 @@ select
from
"person"
where
"person"."faceClusterId" = "asset_face"."faceClusterId"
order by
"person"."ownerId" = (
select
"asset"."ownerId"
from
"asset"
where
"asset"."id" = "asset_face"."assetId"
) desc
limit
$1
"person"."id" = "asset_face"."personId"
) as obj
) as "person"
from
"asset_face"
where
"asset_face"."assetId" = $2
"asset_face"."assetId" = $1
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" = $3
and "asset_face"."isVisible" = $2
order by
"asset_face"."boundingBoxX1" asc
@@ -164,30 +119,19 @@ select
from
"person"
where
"person"."faceClusterId" = "asset_face"."faceClusterId"
order by
"person"."ownerId" = (
select
"asset"."ownerId"
from
"asset"
where
"asset"."id" = "asset_face"."assetId"
) desc
limit
$1
"person"."id" = "asset_face"."personId"
) as obj
) as "person"
from
"asset_face"
where
"asset_face"."id" = $2
"asset_face"."id" = $1
and "asset_face"."deletedAt" is null
-- PersonRepository.getFaceForFacialRecognitionJob
select
"asset_face"."id",
"asset_face"."faceClusterId",
"asset_face"."personId",
"asset_face"."sourceType",
(
select
@@ -257,7 +201,7 @@ where
-- PersonRepository.reassignFace
update "asset_face"
set
"faceClusterId" = $1
"personId" = $1
where
"asset_face"."id" = $2
@@ -276,10 +220,9 @@ where
"person"."ownerId" = $1
and f_unaccent ("person"."name") %> f_unaccent ($2)
order by
f_unaccent ("person"."name") <->>> f_unaccent ($3),
"person"."ownerId" = $4 desc
f_unaccent ("person"."name") <->>> f_unaccent ($3)
limit
$5
$4
-- PersonRepository.getDistinctNames
select distinct
@@ -302,52 +245,9 @@ from
and "asset"."visibility" = 'timeline'
and "asset"."deletedAt" is null
where
(
"asset"."ownerId" = $1
or exists (
select
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $2
and (
$3 = any ("partner"."permissions")
or "partner"."permissions" @> $4
)
)
or exists (
select
from
"album_asset"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
and "album_user"."userId" = $5
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = "asset"."ownerId"
and (
$6 = any ("album_user"."permissions")
or "album_user"."permissions" @> $7
)
)
)
)
and "asset_face"."deletedAt" is null
"asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
and "asset_face"."faceClusterId" = (
select
"person"."faceClusterId"
from
"person"
where
"person"."id" = $8
)
and "asset_face"."personId" = $1
-- PersonRepository.getNumberOfPeople
select
@@ -367,7 +267,7 @@ where
from
"asset_face"
where
"asset_face"."faceClusterId" = "person"."faceClusterId"
"asset_face"."personId" = "person"."id"
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" = $2
and exists (
@@ -380,42 +280,7 @@ where
and "asset"."deletedAt" is null
)
)
and (
"person"."ownerId" = $3
or (
exists (
select
from
"partner"
where
"partner"."sharedById" = "person"."ownerId"
and "partner"."sharedWithId" = $4
and (
$5 = any ("partner"."permissions")
or "partner"."permissions" @> $6
)
)
or exists (
select
from
"album_user"
where
"album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = $7
)
and "album_user"."userId" = "person"."ownerId"
and (
$8 = any ("album_user"."permissions")
or "album_user"."permissions" @> $9
)
)
)
)
and "person"."ownerId" = $3
-- PersonRepository.refreshFaces
with
@@ -445,26 +310,14 @@ select
from
"person"
where
"person"."faceClusterId" = "asset_face"."faceClusterId"
order by
"person"."ownerId" = (
select
"asset"."ownerId"
from
"asset"
where
"asset"."id" = "asset_face"."assetId"
) desc
limit
$1
"person"."id" = "asset_face"."personId"
) as obj
) as "person"
from
"asset_face"
inner join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId"
where
"person"."id" in ($2)
and "asset_face"."assetId" in ($3)
"asset_face"."assetId" in ($1)
and "asset_face"."personId" in ($2)
and "asset_face"."deletedAt" is null
-- PersonRepository.getRandomFace
@@ -472,52 +325,8 @@ select
"asset_face".*
from
"asset_face"
inner join "person" on "asset_face"."faceClusterId" = "person"."faceClusterId"
and "person"."id" = $1
where
"asset_face"."assetId" in (
select
"asset"."id"
from
"asset"
where
(
"asset"."ownerId" = "person"."ownerId"
or exists (
select
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = "person"."ownerId"
and (
$2 = any ("partner"."permissions")
or "partner"."permissions" @> $3
)
)
or exists (
select
from
"album_asset"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
and "album_user"."userId" = "person"."ownerId"
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = "asset"."ownerId"
and (
$4 = any ("album_user"."permissions")
or "album_user"."permissions" @> $5
)
)
)
)
)
"asset_face"."personId" = $1
and "asset_face"."deletedAt" is null
and "asset_face"."isVisible" is true
@@ -553,9 +362,8 @@ select
"asset_face"."id"
from
"asset_face"
inner join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId"
and "person"."id" = $1
inner join "asset" on "asset"."id" = "asset_face"."assetId"
and "asset"."isOffline" = $2
and "asset"."isOffline" = $1
where
"asset_face"."assetId" = $3
"asset_face"."assetId" = $2
and "asset_face"."personId" = $3
+22 -222
View File
@@ -10,52 +10,15 @@ where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and (
"asset"."ownerId" = $4
or exists (
select
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $5
and (
$6 = any ("partner"."permissions")
or "partner"."permissions" @> $7
)
and "partner"."inTimeline" = $8
)
or exists (
select
from
"album_asset"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
and "album_user"."userId" = $9
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."inTimeline" = $10
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = "asset"."ownerId"
and (
$11 = any ("album_user"."permissions")
or "album_user"."permissions" @> $12
)
)
)
)
and "asset"."isFavorite" = $13
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
and "asset"."deletedAt" is null
order by
"asset"."fileCreatedAt" desc
limit
$14
$6
offset
$15
$7
-- SearchRepository.searchStatistics
select
@@ -67,45 +30,8 @@ where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and (
"asset"."ownerId" = $4
or exists (
select
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $5
and (
$6 = any ("partner"."permissions")
or "partner"."permissions" @> $7
)
and "partner"."inTimeline" = $8
)
or exists (
select
from
"album_asset"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
and "album_user"."userId" = $9
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."inTimeline" = $10
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = "asset"."ownerId"
and (
$11 = any ("album_user"."permissions")
or "album_user"."permissions" @> $12
)
)
)
)
and "asset"."isFavorite" = $13
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
and "asset"."deletedAt" is null
-- SearchRepository.searchRandom
@@ -118,50 +44,13 @@ where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and (
"asset"."ownerId" = $4
or exists (
select
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $5
and (
$6 = any ("partner"."permissions")
or "partner"."permissions" @> $7
)
and "partner"."inTimeline" = $8
)
or exists (
select
from
"album_asset"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
and "album_user"."userId" = $9
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."inTimeline" = $10
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = "asset"."ownerId"
and (
$11 = any ("album_user"."permissions")
or "album_user"."permissions" @> $12
)
)
)
)
and "asset"."isFavorite" = $13
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
and "asset"."deletedAt" is null
order by
random()
limit
$14
$6
-- SearchRepository.searchLargeAssets
select
@@ -174,51 +63,14 @@ where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and (
"asset"."ownerId" = $4
or exists (
select
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $5
and (
$6 = any ("partner"."permissions")
or "partner"."permissions" @> $7
)
and "partner"."inTimeline" = $8
)
or exists (
select
from
"album_asset"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
and "album_user"."userId" = $9
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."inTimeline" = $10
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = "asset"."ownerId"
and (
$11 = any ("album_user"."permissions")
or "album_user"."permissions" @> $12
)
)
)
)
and "asset"."isFavorite" = $13
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
and "asset"."deletedAt" is null
and "asset_exif"."fileSizeInByte" > $14
and "asset_exif"."fileSizeInByte" > $6
order by
"asset_exif"."fileSizeInByte" desc
limit
$15
$7
-- SearchRepository.searchSmart
begin
@@ -234,52 +86,15 @@ where
"asset"."visibility" = $1
and "asset"."fileCreatedAt" >= $2
and "asset_exif"."lensModel" = $3
and (
"asset"."ownerId" = $4
or exists (
select
from
"partner"
where
"partner"."sharedById" = "asset"."ownerId"
and "partner"."sharedWithId" = $5
and (
$6 = any ("partner"."permissions")
or "partner"."permissions" @> $7
)
and "partner"."inTimeline" = $8
)
or exists (
select
from
"album_asset"
inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId"
and "album_user"."userId" = $9
where
"album_asset"."assetId" = "asset"."id"
and "album_user"."inTimeline" = $10
and "album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = "asset"."ownerId"
and (
$11 = any ("album_user"."permissions")
or "album_user"."permissions" @> $12
)
)
)
)
and "asset"."isFavorite" = $13
and "asset"."ownerId" = any ($4::uuid[])
and "asset"."isFavorite" = $5
and "asset"."deletedAt" is null
order by
smart_search.embedding <=> $14
smart_search.embedding <=> $6
limit
$15
$7
offset
$16
$8
commit
-- SearchRepository.getEmbedding
@@ -298,30 +113,15 @@ with
"cte" as (
select
"asset_face"."id",
"asset_face"."faceClusterId",
face_search.embedding <=> $1 as "distance",
"asset"."ownerId"
"asset_face"."personId",
face_search.embedding <=> $1 as "distance"
from
"asset_face"
inner join "asset" on "asset"."id" = "asset_face"."assetId"
inner join "face_search" on "face_search"."faceId" = "asset_face"."id"
left join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId"
left join "person" on "person"."id" = "asset_face"."personId"
where
"asset"."ownerId" in (
select
"user"."id"
from
"user"
where
"user"."trustedGroupId" in (
select
"user"."trustedGroupId"
from
"user"
where
"user"."id" = any ($2::uuid[])
)
)
"asset"."ownerId" = any ($2::uuid[])
and "asset"."deletedAt" is null
order by
"distance"
+1 -1
View File
@@ -536,7 +536,7 @@ order by
select
"asset_face"."id",
"assetId",
"faceClusterId",
"personId",
"imageWidth",
"imageHeight",
"boundingBoxX1",
-70
View File
@@ -397,73 +397,3 @@ set
where
"user"."deletedAt" is null
and "user"."id" = $2::uuid
-- UserRepository.getInSameTrustedGroup
select
"user"."id"
from
"user"
where
"user"."trustedGroupId" = (
select
"user"."trustedGroupId"
from
"user"
where
"user"."id" = $1
)
-- UserRepository.mergeTrustedGroups
update "user"
set
"trustedGroupId" = "u"."trustedGroupId"
from
"user" as "u"
where
"u"."id" = $1
and "user"."trustedGroupId" = (
select
"user"."trustedGroupId"
from
"user"
where
"user"."id" = $2
and "user"."trustedGroupId" != "u"."trustedGroupId"
)
-- UserRepository.updateTrustedGroups
update "user"
set
"trustedGroupId" = uuid_generate_v4 ()
where
"user"."trustedGroupId" = (
select
"user"."trustedGroupId"
from
"user"
where
"user"."id" = $1
)
and "user"."id" != $2
and "user"."id" not in (
select
"partner"."sharedById" as "userId"
from
"partner"
where
"sharedWithId" = $3
union
select
"album_user"."userId"
from
"album_user"
where
"album_user"."albumId" in (
select
"album_user"."albumId"
from
"album_user"
where
"album_user"."userId" = $4
)
)
+1 -74
View File
@@ -2,9 +2,7 @@ import { Injectable } from '@nestjs/common';
import { Kysely, NotNull, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { AlbumUserRole, AssetVisibility, SharingPermission } from 'src/enum';
import { hasAssetPermissions } from 'src/repositories/asset.repository';
import { hasPermissions } from 'src/repositories/person.repository';
import { AlbumUserRole, AssetVisibility } from 'src/enum';
import { DB } from 'src/schema';
import { asUuid } from 'src/utils/database';
@@ -275,46 +273,6 @@ class AssetAccess {
return allowedIds;
});
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET, [SharingPermission.All]] })
async checkSharedAccess(userId: string, assetIds: Set<string>, permissions: SharingPermission[]) {
const ids = await this.db
.selectFrom('album_asset')
.select('album_asset.assetId')
.where('album_asset.assetId', 'in', [...assetIds])
.where('album_asset.albumId', 'in', (eb) =>
eb
.selectFrom('album_user')
.select('album_user.albumId')
.where((eb) =>
eb.or([
eb('album_user.permissions', '@>', sql<SharingPermission[]>`${permissions}::sharing_permission_enum[]`),
eb(eb.val(SharingPermission.All), '=', eb.fn.any('album_user.permissions')),
]),
),
)
.innerJoin('album_user', (join) =>
join.onRef('album_asset.albumId', '=', 'album_user.albumId').on('album_user.userId', '=', userId),
)
.union((eb) =>
eb
.selectFrom('partner')
.where('partner.sharedWithId', '=', userId)
.where((eb) =>
eb.or([
eb('partner.permissions', '@>', sql<SharingPermission[]>`${permissions}::sharing_permission_enum[]`),
eb(eb.val(SharingPermission.All), '=', eb.fn.any('partner.permissions')),
]),
)
.innerJoin('asset', (join) =>
join.onRef('asset.ownerId', '=', 'partner.sharedById').on('asset.id', 'in', [...assetIds]),
)
.select('asset.id as assetId'),
)
.execute();
return new Set(ids.map(({ assetId }) => assetId));
}
}
class AuthDeviceAccess {
@@ -494,37 +452,6 @@ class PersonAccess {
.execute()
.then((faces) => new Set(faces.map((face) => face.id)));
}
async checkSharedAccess(userId: string, personIds: Set<string>, permissions: SharingPermission[]) {
if (personIds.size === 0) {
return new Set<string>();
}
const ids = await this.db
.selectFrom('person')
.select('person.id')
.where('person.id', 'in', [...personIds])
.where(hasPermissions(userId, permissions))
.execute();
return new Set(ids.map(({ id }) => id));
}
async checkSharedFaceAccess(userId: string, faceIds: Set<string>, permissions: SharingPermission[]) {
if (faceIds.size === 0) {
return new Set<string>();
}
const ids = await this.db
.selectFrom('asset_face')
.select('asset_face.id')
.leftJoin('asset', (join) => join.onRef('asset.id', '=', 'asset_face.assetId'))
.where('asset_face.id', 'in', [...faceIds])
.where(hasAssetPermissions(userId, permissions))
.execute();
return new Set(ids.map(({ id }) => id));
}
}
class PartnerAccess {
@@ -38,13 +38,4 @@ export class AlbumUserRepository {
async delete({ userId, albumId }: AlbumPermissionId): Promise<void> {
await this.db.deleteFrom('album_user').where('userId', '=', userId).where('albumId', '=', albumId).execute();
}
get({ userId, albumId }: AlbumPermissionId) {
return this.db
.selectFrom('album_user')
.select(['permissions', 'inTimeline'])
.where('userId', '=', userId)
.where('albumId', '=', albumId)
.executeTakeFirstOrThrow();
}
}
+4 -104
View File
@@ -8,7 +8,6 @@ import {
SelectQueryBuilder,
ShallowDehydrateObject,
sql,
StringReference,
Updateable,
UpdateResult,
} from 'kysely';
@@ -26,7 +25,6 @@ import {
AssetType,
AssetVisibility,
CalendarHeatmapType,
SharingPermission,
} from 'src/enum';
import { DB } from 'src/schema';
import { AssetAudioTable, AssetKeyframeTable, AssetVideoTable } from 'src/schema/tables/asset-av.table';
@@ -51,7 +49,6 @@ import {
withFiles,
withLibrary,
withOwner,
withPermissions,
withSmartSearch,
withTagId,
withTags,
@@ -176,93 +173,6 @@ const withBoundingBox = <T>(qb: SelectQueryBuilder<DB, 'asset' | 'asset_exif', T
);
};
export const hasAssetPermissions =
(userId: string, permissions: SharingPermission[], ignoreTimelineVisibility: boolean = false) =>
(eb: ExpressionBuilder<DB, 'asset'>) =>
eb.or([
eb('asset.ownerId', '=', userId),
eb.exists(
eb
.selectFrom('partner')
.whereRef('partner.sharedById', '=', 'asset.ownerId')
.where('partner.sharedWithId', '=', userId)
.where((eb) =>
eb.or([
eb(eb.val(SharingPermission.All), '=', eb.fn.any('partner.permissions')),
eb('partner.permissions', '@>', eb.val(permissions)),
]),
)
.$if(!ignoreTimelineVisibility, (qb) => qb.where('partner.inTimeline', '=', true)),
),
eb.exists(
eb
.selectFrom('album_asset')
.whereRef('album_asset.assetId', '=', 'asset.id')
.innerJoin('album_user', (join) =>
join.onRef('album_user.albumId', '=', 'album_asset.albumId').on('album_user.userId', '=', userId),
)
.$if(!ignoreTimelineVisibility, (qb) => qb.where('album_user.inTimeline', '=', true))
.where('album_user.albumId', 'in', (eb) =>
eb
.selectFrom('album_user')
.select('album_user.albumId')
.whereRef('album_user.userId', '=', 'asset.ownerId')
.where((eb) =>
eb.or([
eb(eb.val(SharingPermission.All), '=', eb.fn.any('album_user.permissions')),
eb('album_user.permissions', '@>', eb.val(permissions)),
]),
),
),
),
]);
export const hasAssetPermissionsRef = <T extends keyof DB>(
eb: ExpressionBuilder<DB, 'asset'>,
userIdRef: StringReference<DB, 'asset' | T>,
permissions: SharingPermission[],
ignoreTimelineVisibility: boolean = false,
) =>
eb.or([
eb('asset.ownerId', '=', eb.ref(userIdRef as never)),
eb.exists(
eb
.selectFrom('partner')
.whereRef('partner.sharedById', '=', 'asset.ownerId')
.whereRef('partner.sharedWithId', '=', userIdRef as never)
.where((eb) =>
eb.or([
eb(eb.val(SharingPermission.All), '=', eb.fn.any('partner.permissions')),
eb('partner.permissions', '@>', eb.val(permissions)),
]),
)
.$if(!ignoreTimelineVisibility, (qb) => qb.where('partner.inTimeline', '=', true)),
),
eb.exists(
eb
.selectFrom('album_asset')
.whereRef('album_asset.assetId', '=', 'asset.id')
.innerJoin('album_user', (join) =>
join
.onRef('album_user.albumId', '=', 'album_asset.albumId')
.onRef('album_user.userId', '=', userIdRef as never),
)
.$if(!ignoreTimelineVisibility, (qb) => qb.where('album_user.inTimeline', '=', true))
.where('album_user.albumId', 'in', (eb) =>
eb
.selectFrom('album_user')
.select('album_user.albumId')
.whereRef('album_user.userId', '=', 'asset.ownerId')
.where((eb) =>
eb.or([
eb(eb.val(SharingPermission.All), '=', eb.fn.any('album_user.permissions')),
eb('album_user.permissions', '@>', eb.val(permissions)),
]),
),
),
),
]);
@Injectable()
export class AssetRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@@ -654,22 +564,17 @@ export class AssetRepository {
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID, {}, DummyValue.UUID] })
@GenerateSql({ params: [DummyValue.UUID] })
getById(
id: string,
{ exifInfo, faces, files, library, owner, smartSearch, stack, tags, edits }: GetByIdsRelations = {},
userId?: string,
) {
return this.db
.selectFrom('asset')
.selectAll('asset')
.where('asset.id', '=', asUuid(id))
.$if(!!exifInfo, withExif)
.$if(!!faces, (qb) =>
qb
.select(faces?.person ? (eb) => withFacesAndPeople(eb, { userId }) : withFaces)
.$narrowType<{ faces: NotNull }>(),
)
.$if(!!faces, (qb) => qb.select(faces?.person ? withFacesAndPeople : withFaces).$narrowType<{ faces: NotNull }>())
.$if(!!library, (qb) => qb.select(withLibrary))
.$if(!!owner, (qb) => qb.select(withOwner))
.$if(!!smartSearch, withSmartSearch)
@@ -705,7 +610,6 @@ export class AssetRepository {
.$if(!!files, (qb) => qb.select(withFiles))
.$if(!!tags, (qb) => qb.select(withTags))
.$if(!!edits, (qb) => qb.select(withEdits))
.$if(!!userId, (qb) => qb.select(withPermissions(userId!)))
.limit(1)
.executeTakeFirst();
}
@@ -874,9 +778,7 @@ export class AssetRepository {
)
.where((eb) => eb.or([eb('asset.stackId', 'is', null), eb(eb.table('stack'), 'is not', null)])),
)
.$if(!!options.userIds, (qb) =>
qb.where(hasAssetPermissions(options.userIds![0], [SharingPermission.AssetRead], !!options.personId)),
)
.$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!)))
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
.$if(!!options.assetType, (qb) => qb.where('asset.type', '=', options.assetType!))
.$if(options.isDuplicate !== undefined, (qb) =>
@@ -962,9 +864,7 @@ export class AssetRepository {
),
)
.$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!]))
.$if(!!options.userIds, (qb) =>
qb.where(hasAssetPermissions(options.userIds![0], [SharingPermission.AssetRead], !!options.personId)),
)
.$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!)))
.$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!))
.$if(!!options.withStacked, (qb) =>
qb
+6 -9
View File
@@ -15,7 +15,7 @@ import { getKeyByValue, getMethodNames, ImmichStartupError } from 'src/utils/mis
type JobMapItem = {
jobName: JobName;
queueName: QueueName;
handler: (job?: JobOf<any>) => Promise<JobStatus>;
handler: (job: JobOf<any>) => Promise<JobStatus>;
label: string;
};
@@ -132,17 +132,14 @@ export class JobRepository {
this.microservicesPresent = present;
}
async run(job: JobItem) {
const item = this.handlers[job.name];
async run({ name, data }: JobItem) {
const item = this.handlers[name as JobName];
if (!item) {
this.logger.warn(`Skipping unknown job: "${job.name}"`);
this.logger.warn(`Skipping unknown job: "${name}"`);
return JobStatus.Skipped;
}
if ('data' in job) {
return item.handler(job.data);
}
return item.handler();
return item.handler(data);
}
setConcurrency(queueName: QueueName, concurrency: number) {
@@ -207,7 +204,7 @@ export class JobRepository {
const queueName = this.getQueueName(item.name);
const job = {
name: item.name,
data: ('data' in item ? item.data : undefined) || {},
data: item.data || {},
options: this.getJobOptions(item) || undefined,
} as JobItem & { data: any; options: JobsOptions | undefined };
+1 -1
View File
@@ -73,7 +73,7 @@ export class MemoryRepository implements IBulkAsset {
eb.exists(
eb
.selectFrom('asset_face')
.innerJoin('person', 'person.faceClusterId', 'asset_face.faceClusterId')
.innerJoin('person', 'person.id', 'asset_face.personId')
.select((eb) => eb.val(1).as('one'))
.whereRef('asset_face.assetId', '=', 'asset.id')
.where('person.isHidden', '=', true),
+29 -110
View File
@@ -4,8 +4,7 @@ import { jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { AssetFace } from 'src/database';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AssetFileType, AssetVisibility, SharingPermission, SourceType, UserMetadataKey } from 'src/enum';
import { hasAssetPermissions, hasAssetPermissionsRef } from 'src/repositories/asset.repository';
import { AssetFileType, AssetVisibility, SourceType, UserMetadataKey } from 'src/enum';
import { DB } from 'src/schema';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
@@ -33,9 +32,9 @@ export interface AssetFaceId {
}
export interface UpdateFacesData {
oldFaceClusterId?: string;
oldPersonId?: string;
faceIds?: string[];
newFaceClusterId: string;
newPersonId: string;
}
export interface PersonStatistics {
@@ -54,7 +53,7 @@ export interface GetAllPeopleOptions {
}
export interface GetAllFacesOptions {
faceClusterId?: string | null;
personId?: string | null;
assetId?: string;
sourceType?: SourceType;
}
@@ -63,27 +62,9 @@ export type UnassignFacesOptions = DeleteFacesOptions;
export type SelectFaceOptions = (keyof Selectable<AssetFaceTable>)[];
const withPerson = (eb: ExpressionBuilder<DB, 'asset_face'>, userId?: string) => {
const withPerson = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
return jsonObjectFrom(
eb
.selectFrom('person')
.selectAll('person')
.whereRef('person.faceClusterId', '=', 'asset_face.faceClusterId')
.$if(!!userId, (qb) =>
qb.where((eb) =>
eb.or([eb('person.ownerId', '=', userId!), hasPermissions(userId!, [SharingPermission.PersonRead])(eb)]),
),
)
.orderBy(
(eb) =>
eb(
'person.ownerId',
'=',
eb.selectFrom('asset').select('asset.ownerId').whereRef('asset.id', '=', 'asset_face.assetId'),
),
'desc',
)
.limit(1),
eb.selectFrom('person').selectAll('person').whereRef('person.id', '=', 'asset_face.personId'),
).as('person');
};
@@ -93,47 +74,16 @@ const withFaceSearch = (eb: ExpressionBuilder<DB, 'asset_face'>) => {
).as('faceSearch');
};
export const hasPermissions =
(userId: string, permissions: SharingPermission[]) => (eb: ExpressionBuilder<DB, 'person'>) =>
eb.or([
eb.exists((eb) =>
eb
.selectFrom('partner')
.whereRef('partner.sharedById', '=', 'person.ownerId')
.where('partner.sharedWithId', '=', userId)
.where((eb) =>
eb.or([
eb(eb.val(SharingPermission.All), '=', eb.fn.any('partner.permissions')),
eb('partner.permissions', '@>', eb.val(permissions)),
]),
),
),
eb.exists((eb) =>
eb
.selectFrom('album_user')
.where('album_user.albumId', 'in', (eb) =>
eb.selectFrom('album_user').select('album_user.albumId').where('album_user.userId', '=', userId),
)
.whereRef('album_user.userId', '=', 'person.ownerId')
.where((eb) =>
eb.or([
eb(eb.val(SharingPermission.All), '=', eb.fn.any('album_user.permissions')),
eb('album_user.permissions', '@>', eb.val(permissions)),
]),
),
),
]);
@Injectable()
export class PersonRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
@GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] })
async reassignFaces({ oldFaceClusterId, faceIds, newFaceClusterId }: UpdateFacesData): Promise<number> {
async reassignFaces({ oldPersonId, faceIds, newPersonId }: UpdateFacesData): Promise<number> {
const result = await this.db
.updateTable('asset_face')
.set({ faceClusterId: newFaceClusterId })
.$if(!!oldFaceClusterId, (qb) => qb.where('asset_face.faceClusterId', '=', oldFaceClusterId!))
.set({ personId: newPersonId })
.$if(!!oldPersonId, (qb) => qb.where('asset_face.personId', '=', oldPersonId!))
.$if(!!faceIds, (qb) => qb.where('asset_face.id', 'in', faceIds!))
.executeTakeFirst();
@@ -143,7 +93,7 @@ export class PersonRepository {
async unassignFaces({ sourceType }: UnassignFacesOptions): Promise<void> {
await this.db
.updateTable('asset_face')
.set({ faceClusterId: null })
.set({ personId: null })
.where('asset_face.sourceType', '=', sourceType)
.execute();
}
@@ -166,8 +116,8 @@ export class PersonRepository {
return this.db
.selectFrom('asset_face')
.selectAll('asset_face')
.$if(options.faceClusterId === null, (qb) => qb.where('asset_face.faceClusterId', 'is', null))
.$if(!!options.faceClusterId, (qb) => qb.where('asset_face.faceClusterId', '=', options.faceClusterId!))
.$if(options.personId === null, (qb) => qb.where('asset_face.personId', 'is', null))
.$if(!!options.personId, (qb) => qb.where('asset_face.personId', '=', options.personId!))
.$if(!!options.sourceType, (qb) => qb.where('asset_face.sourceType', '=', options.sourceType!))
.$if(!!options.assetId, (qb) => qb.where('asset_face.assetId', '=', options.assetId!))
.where('asset_face.deletedAt', 'is', null)
@@ -202,20 +152,16 @@ export class PersonRepository {
const items = await this.db
.selectFrom('person')
.selectAll('person')
.innerJoin('asset_face', 'asset_face.faceClusterId', 'person.faceClusterId')
.innerJoin('asset_face', 'asset_face.personId', 'person.id')
.innerJoin('asset', (join) =>
join
.onRef('asset_face.assetId', '=', 'asset.id')
.on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
.on('asset.deletedAt', 'is', null),
)
.where((eb) =>
eb.or([eb('person.ownerId', '=', userId), hasPermissions(userId, [SharingPermission.PersonRead])(eb)]),
)
.where('person.ownerId', '=', userId)
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true)
.orderBy('person.faceClusterId')
.orderBy((eb) => eb('person.ownerId', '=', userId), 'desc')
.orderBy('person.isHidden', 'asc')
.orderBy('person.isFavorite', 'desc')
.having((eb) =>
@@ -234,7 +180,6 @@ export class PersonRepository {
),
]),
)
.distinctOn('person.faceClusterId')
.groupBy('person.id')
.$if(!!options?.closestFaceAssetId, (qb) =>
qb.orderBy((eb) =>
@@ -273,7 +218,7 @@ export class PersonRepository {
return this.db
.selectFrom('person')
.selectAll('person')
.leftJoin('asset_face', 'asset_face.faceClusterId', 'person.faceClusterId')
.leftJoin('asset_face', 'asset_face.personId', 'person.id')
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true)
.having((eb) => eb.fn.count('asset_face.assetId'), '=', 0)
@@ -282,13 +227,13 @@ export class PersonRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
getFaces(assetId: string, options: { isVisible?: boolean; userId?: string } = {}) {
const { isVisible = true, userId } = options;
getFaces(assetId: string, options?: { isVisible?: boolean }) {
const isVisible = options === undefined ? true : options.isVisible;
return this.db
.selectFrom('asset_face')
.selectAll('asset_face')
.select((eb) => withPerson(eb, userId))
.select(withPerson)
.where('asset_face.assetId', '=', assetId)
.where('asset_face.deletedAt', 'is', null)
.$if(isVisible !== undefined, (qb) => qb.where('asset_face.isVisible', '=', isVisible!))
@@ -312,7 +257,7 @@ export class PersonRepository {
getFaceForFacialRecognitionJob(id: string) {
return this.db
.selectFrom('asset_face')
.select(['asset_face.id', 'asset_face.faceClusterId', 'asset_face.sourceType'])
.select(['asset_face.id', 'asset_face.personId', 'asset_face.sourceType'])
.select((eb) =>
jsonObjectFrom(
eb
@@ -353,10 +298,10 @@ export class PersonRepository {
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] })
async reassignFace(assetFaceId: string, newFaceClusterId: string): Promise<number> {
async reassignFace(assetFaceId: string, newPersonId: string): Promise<number> {
const result = await this.db
.updateTable('asset_face')
.set({ faceClusterId: newFaceClusterId })
.set({ personId: newPersonId })
.where('asset_face.id', '=', assetFaceId)
.executeTakeFirst();
@@ -382,7 +327,6 @@ export class PersonRepository {
.where('person.ownerId', '=', userId)
.where(() => sql`f_unaccent("person"."name") %> f_unaccent(${personName})`)
.orderBy(sql`f_unaccent("person"."name") <->>> f_unaccent(${personName})`)
.orderBy((eb) => eb('person.ownerId', '=', userId), 'desc')
.limit(100)
.$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false))
.execute();
@@ -400,7 +344,7 @@ export class PersonRepository {
}
@GenerateSql({ params: [DummyValue.UUID] })
async getStatistics(userId: string, personId: string): Promise<PersonStatistics> {
async getStatistics(personId: string): Promise<PersonStatistics> {
const result = await this.db
.selectFrom('asset_face')
.leftJoin('asset', (join) =>
@@ -409,13 +353,10 @@ export class PersonRepository {
.on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline))
.on('asset.deletedAt', 'is', null),
)
.where(hasAssetPermissions(userId, [SharingPermission.AssetRead], true))
.select((eb) => eb.fn.count(eb.fn('distinct', ['asset.id'])).as('count'))
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true)
.where('asset_face.faceClusterId', '=', (eb) =>
eb.selectFrom('person').select('person.faceClusterId').where('person.id', '=', personId),
)
.where('asset_face.personId', '=', personId)
.executeTakeFirst();
return {
@@ -432,7 +373,7 @@ export class PersonRepository {
eb.exists((eb) =>
eb
.selectFrom('asset_face')
.whereRef('asset_face.faceClusterId', '=', 'person.faceClusterId')
.whereRef('asset_face.personId', '=', 'person.id')
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', '=', true)
.where((eb) =>
@@ -446,20 +387,13 @@ export class PersonRepository {
),
),
)
.where((eb) =>
eb.or([eb('person.ownerId', '=', userId), hasPermissions(userId, [SharingPermission.PersonRead])(eb)]),
)
.where('person.ownerId', '=', userId)
.select((eb) => eb.fn.coalesce(eb.fn.countAll<number>(), zero).as('total'))
.select((eb) => eb.fn.coalesce(eb.fn.countAll<number>().filterWhere('isHidden', '=', true), zero).as('hidden'))
.executeTakeFirstOrThrow();
}
async create(person: Insertable<PersonTable>) {
if (!person.faceClusterId) {
const { id } = await this.db.insertInto('face_cluster').defaultValues().returning('id').executeTakeFirstOrThrow();
person.faceClusterId = id;
}
create(person: Insertable<PersonTable>) {
return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow();
}
@@ -550,9 +484,8 @@ export class PersonRepository {
.selectFrom('asset_face')
.selectAll('asset_face')
.select(withPerson)
.innerJoin('person', (join) => join.onRef('person.faceClusterId', '=', 'asset_face.faceClusterId'))
.where('person.id', 'in', personIds)
.where('asset_face.assetId', 'in', assetIds)
.where('asset_face.personId', 'in', personIds)
.where('asset_face.deletedAt', 'is', null)
.execute();
}
@@ -562,15 +495,7 @@ export class PersonRepository {
return this.db
.selectFrom('asset_face')
.selectAll('asset_face')
.innerJoin('person', (join) =>
join.onRef('asset_face.faceClusterId', '=', 'person.faceClusterId').on('person.id', '=', personId),
)
.where('asset_face.assetId', 'in', (eb) =>
eb
.selectFrom('asset')
.select('asset.id')
.where((eb) => hasAssetPermissionsRef(eb, 'person.ownerId', [SharingPermission.AssetRead], true)),
)
.where('asset_face.personId', '=', personId)
.where('asset_face.deletedAt', 'is', null)
.where('asset_face.isVisible', 'is', true)
.executeTakeFirst();
@@ -657,14 +582,8 @@ export class PersonRepository {
.selectFrom('asset_face')
.select('asset_face.id')
.where('asset_face.assetId', '=', assetId)
.innerJoin('person', (join) =>
join.onRef('person.faceClusterId', '=', 'asset_face.faceClusterId').on('person.id', '=', personId),
)
.where('asset_face.personId', '=', personId)
.innerJoin('asset', (join) => join.onRef('asset.id', '=', 'asset_face.assetId').on('asset.isOffline', '=', false))
.executeTakeFirst();
}
getByFaceClusterId(faceClusterId: string) {
return this.db.selectFrom('person').selectAll().where('person.faceClusterId', '=', faceClusterId).execute();
}
}
@@ -224,7 +224,6 @@ export class PluginRepository {
error: (message) => logger.error(message),
} as Console,
logLevel: asExtismLogLevel(logger.getLogLevel()),
enableWasiOutput: true,
},
),
destroy: (plugin) => plugin.close(),
+4 -12
View File
@@ -325,23 +325,15 @@ export class SearchRepository {
.selectFrom('asset_face')
.select([
'asset_face.id',
'asset_face.faceClusterId',
'asset_face.personId',
sql<number>`face_search.embedding <=> ${embedding}`.as('distance'),
])
.innerJoin('asset', 'asset.id', 'asset_face.assetId')
.select('asset.ownerId')
.innerJoin('face_search', 'face_search.faceId', 'asset_face.id')
.leftJoin('person', 'person.faceClusterId', 'asset_face.faceClusterId')
.where('asset.ownerId', 'in', (eb) =>
eb
.selectFrom('user')
.select('user.id')
.where('user.trustedGroupId', 'in', (eb) =>
eb.selectFrom('user').select('user.trustedGroupId').where('user.id', '=', anyUuid(userIds)),
),
)
.leftJoin('person', 'person.id', 'asset_face.personId')
.where('asset.ownerId', '=', anyUuid(userIds))
.where('asset.deletedAt', 'is', null)
.$if(!!hasPerson, (qb) => qb.where('asset_face.faceClusterId', 'is not', null))
.$if(!!hasPerson, (qb) => qb.where('asset_face.personId', 'is not', null))
.$if(!!minBirthDate, (qb) =>
qb.where((eb) =>
eb.or([eb('person.birthDate', 'is', null), eb('person.birthDate', '<=', minBirthDate!)]),
+1 -1
View File
@@ -472,7 +472,7 @@ class AssetFaceSync extends BaseSync {
.select([
'asset_face.id',
'assetId',
'faceClusterId',
'personId',
'imageWidth',
'imageHeight',
'boundingBoxX1',
@@ -325,61 +325,4 @@ export class UserRepository {
await query.execute();
}
@GenerateSql({ params: [DummyValue.UUID] })
async getInSameTrustedGroup(userId: string) {
return this.db
.selectFrom('user')
.select('user.id')
.where('user.trustedGroupId', '=', (eb) =>
eb.selectFrom('user').select('user.trustedGroupId').where('user.id', '=', userId),
)
.execute()
.then((result) => result.map(({ id }) => id));
}
@GenerateSql({ params: [{ userId: DummyValue.UUID, userIdToMerge: DummyValue.UUID }] })
async mergeTrustedGroups({ userId, userIdToMerge }: { userId: string; userIdToMerge: string }) {
return this.db
.updateTable('user')
.from('user as u')
.where('u.id', '=', userId)
.where('user.trustedGroupId', '=', (eb) =>
eb
.selectFrom('user')
.select('user.trustedGroupId')
.where('user.id', '=', userIdToMerge)
.whereRef('user.trustedGroupId', '!=', 'u.trustedGroupId'),
)
.set((eb) => ({
trustedGroupId: eb.ref('u.trustedGroupId'),
}))
.executeTakeFirst();
}
@GenerateSql({ params: [DummyValue.UUID] })
async updateTrustedGroups(userId: string) {
return this.db
.updateTable('user')
.set((eb) => ({ trustedGroupId: eb.fn('uuid_generate_v4') }))
.where('user.trustedGroupId', '=', (eb) =>
eb.selectFrom('user').select('user.trustedGroupId').where('user.id', '=', userId),
)
.where('user.id', '!=', userId)
.where('user.id', 'not in', (eb) =>
eb
.selectFrom('partner')
.select('partner.sharedById as userId')
.where('sharedWithId', '=', userId)
.union((eb) =>
eb
.selectFrom('album_user')
.select('album_user.userId')
.where('album_user.albumId', 'in', (eb) =>
eb.selectFrom('album_user').select('album_user.albumId').where('album_user.userId', '=', userId),
),
),
)
.executeTakeFirst();
}
}
+1 -14
View File
@@ -1,13 +1,5 @@
import { registerEnum } from '@immich/sql-tools';
import {
AlbumUserRole,
AssetStatus,
AssetVisibility,
ChecksumAlgorithm,
SharingPermission,
SourceType,
VideoCodec,
} from 'src/enum';
import { AlbumUserRole, AssetStatus, AssetVisibility, ChecksumAlgorithm, SourceType, VideoCodec } from 'src/enum';
export const album_user_role_enum = registerEnum({
name: 'album_user_role_enum',
@@ -38,8 +30,3 @@ export const video_stream_variant_codec_enum = registerEnum({
name: 'video_stream_variant_codec_enum',
values: [VideoCodec.Av1, VideoCodec.Hevc, VideoCodec.H264],
});
export const sharing_permission_enum = registerEnum({
name: 'sharing_permission_enum',
values: Object.values(SharingPermission),
});
+1 -11
View File
@@ -4,7 +4,6 @@ import {
asset_face_source_type,
asset_visibility_enum,
assets_status_enum,
sharing_permission_enum,
} from 'src/schema/enums';
import {
album_user_after_insert,
@@ -48,7 +47,6 @@ import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table';
import { AssetOcrAuditTable } from 'src/schema/tables/asset-ocr-audit.table';
import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { FaceClusterTable } from 'src/schema/tables/face-cluster.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
import { IntegrityReportTable } from 'src/schema/tables/integrity-report.table';
@@ -116,7 +114,6 @@ export class ImmichDatabase {
AssetTable,
AssetFileTable,
AssetExifTable,
FaceClusterTable,
FaceSearchTable,
GeodataPlacesTable,
IntegrityReportTable,
@@ -179,13 +176,7 @@ export class ImmichDatabase {
asset_ocr_delete_audit,
];
enum = [
album_user_role_enum,
assets_status_enum,
asset_face_source_type,
asset_visibility_enum,
sharing_permission_enum,
];
enum = [album_user_role_enum, assets_status_enum, asset_face_source_type, asset_visibility_enum];
}
export interface Migrations {
@@ -227,7 +218,6 @@ export interface DB {
ocr_search: OcrSearchTable;
face_search: FaceSearchTable;
face_cluster: FaceClusterTable;
geodata_places: GeodataPlacesTable;
@@ -1,17 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TYPE "sharing_permission_enum" AS ENUM ('all','asset.read','asset.update','asset.edit','asset.delete','asset.share','exif.read','person.read','person.update','person.merge','person.delete');`.execute(db);
await sql`ALTER TABLE "user" ADD "trustedGroupId" uuid NOT NULL DEFAULT uuid_generate_v4();`.execute(db);
await sql`ALTER TABLE "album_user" ADD "permissions" sharing_permission_enum[] NOT NULL DEFAULT '{asset.read,exif.read}';`.execute(db);
await sql`ALTER TABLE "album_user" ADD "inTimeline" boolean NOT NULL DEFAULT false;`.execute(db);
await sql`ALTER TABLE "partner" ADD "permissions" sharing_permission_enum[] NOT NULL DEFAULT '{all}';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TYPE "sharing_permission_enum";`.execute(db);
await sql`ALTER TABLE "partner" DROP COLUMN "permissions";`.execute(db);
await sql`ALTER TABLE "user" DROP COLUMN "trustedGroupId";`.execute(db);
await sql`ALTER TABLE "album_user" DROP COLUMN "permissions";`.execute(db);
await sql`ALTER TABLE "album_user" DROP COLUMN "inTimeline";`.execute(db);
}
@@ -1,51 +0,0 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset_face" RENAME COLUMN "personId" TO "faceClusterId";`.execute(db);
await sql`CREATE INDEX "asset_face_faceClusterId_assetId_idx" ON "asset_face" ("faceClusterId", "assetId");`.execute(db);
await sql`CREATE INDEX "asset_face_faceClusterId_assetId_notDeleted_isVisible_idx" ON "asset_face" ("faceClusterId", "assetId") WHERE ("deletedAt" IS NULL AND "isVisible" IS TRUE);`.execute(db);
await sql`CREATE INDEX "asset_face_assetId_faceClusterId_idx" ON "asset_face" ("assetId", "faceClusterId");`.execute(db);
await sql`DROP INDEX "asset_face_personId_assetId_notDeleted_isVisible_idx";`.execute(db);
await sql`DROP INDEX "asset_face_assetId_personId_idx";`.execute(db);
await sql`DROP INDEX "asset_face_personId_assetId_idx";`.execute(db);
await sql`CREATE TABLE "face_cluster" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"createdAt" timestamp with time zone NOT NULL DEFAULT now(),
"updatedAt" timestamp with time zone NOT NULL DEFAULT now(),
"updateId" uuid NOT NULL DEFAULT immich_uuid_v7(),
CONSTRAINT "face_cluster_pkey" PRIMARY KEY ("id")
);`.execute(db);
await sql`ALTER TABLE "asset_face" ADD CONSTRAINT "asset_face_faceClusterId_fkey" FOREIGN KEY ("faceClusterId") REFERENCES "face_cluster" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute(db);
await sql`ALTER TABLE "asset_face" DROP CONSTRAINT "asset_face_personId_fkey";`.execute(db);
await sql`ALTER TABLE "person" ADD "faceClusterId" uuid;`.execute(db);
await sql`CREATE INDEX "person_faceClusterId_idx" ON "person" ("faceClusterId");`.execute(db);
await sql`ALTER TABLE "person" ADD CONSTRAINT "person_faceClusterId_fkey" FOREIGN KEY ("faceClusterId") REFERENCES "face_cluster" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db);
await sql`CREATE INDEX "face_cluster_updateId_idx" ON "face_cluster" ("updateId");`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "face_cluster_updatedAt"
BEFORE UPDATE ON "face_cluster"
FOR EACH ROW
EXECUTE FUNCTION updated_at();`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_face_cluster_updatedAt', '{"type":"trigger","name":"face_cluster_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"face_cluster_updatedAt\\"\\n BEFORE UPDATE ON \\"face_cluster\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_asset_face_faceClusterId_assetId_notDeleted_isVisible_idx', '{"type":"index","name":"asset_face_faceClusterId_assetId_notDeleted_isVisible_idx","sql":"CREATE INDEX \\"asset_face_faceClusterId_assetId_notDeleted_isVisible_idx\\" ON \\"asset_face\\" (\\"faceClusterId\\", \\"assetId\\") WHERE (\\"deletedAt\\" IS NULL AND \\"isVisible\\" IS TRUE);"}'::jsonb);`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_asset_face_personId_assetId_notDeleted_isVisible_idx';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "person" DROP COLUMN "faceClusterId";`.execute(db);
await sql`DROP INDEX "person_faceClusterId_idx";`.execute(db);
await sql`ALTER TABLE "person" DROP CONSTRAINT "person_faceClusterId_fkey";`.execute(db);
await sql`ALTER TABLE "asset_face" RENAME COLUMN "faceClusterId" TO "personId";`.execute(db);
await sql`CREATE INDEX "asset_face_personId_assetId_notDeleted_isVisible_idx" ON "asset_face" ("personId", "assetId") WHERE ((("deletedAt" IS NULL) AND ("isVisible" IS TRUE)));`.execute(db);
await sql`CREATE INDEX "asset_face_assetId_personId_idx" ON "asset_face" ("assetId", "personId");`.execute(db);
await sql`CREATE INDEX "asset_face_personId_assetId_idx" ON "asset_face" ("personId", "assetId");`.execute(db);
await sql`DROP INDEX "asset_face_faceClusterId_assetId_idx";`.execute(db);
await sql`DROP INDEX "asset_face_faceClusterId_assetId_notDeleted_isVisible_idx";`.execute(db);
await sql`DROP INDEX "asset_face_assetId_faceClusterId_idx";`.execute(db);
await sql`ALTER TABLE "asset_face" ADD CONSTRAINT "asset_face_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute(db);
await sql`ALTER TABLE "asset_face" DROP CONSTRAINT "asset_face_faceClusterId_fkey";`.execute(db);
await sql`DROP TABLE "face_cluster";`.execute(db);
await sql`DROP TRIGGER "face_cluster_updatedAt" ON "face_cluster";`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_asset_face_personId_assetId_notDeleted_isVisible_idx', '{"sql":"CREATE INDEX \\"asset_face_personId_assetId_notDeleted_isVisible_idx\\" ON \\"asset_face\\" (\\"personId\\", \\"assetId\\") WHERE (\\"deletedAt\\" IS NULL AND \\"isVisible\\" IS TRUE);","name":"asset_face_personId_assetId_notDeleted_isVisible_idx","type":"index"}'::jsonb);`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_face_cluster_updatedAt';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_asset_face_faceClusterId_assetId_notDeleted_isVisible_idx';`.execute(db);
}
+2 -12
View File
@@ -11,8 +11,8 @@ import {
UpdateDateColumn,
} from '@immich/sql-tools';
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AlbumUserRole, SharingPermission } from 'src/enum';
import { album_user_role_enum, sharing_permission_enum } from 'src/schema/enums';
import { AlbumUserRole } from 'src/enum';
import { album_user_role_enum } from 'src/schema/enums';
import { album_user_after_insert, album_user_delete_audit } from 'src/schema/functions';
import { AlbumTable } from 'src/schema/tables/album.table';
import { UserTable } from 'src/schema/tables/user.table';
@@ -69,14 +69,4 @@ export class AlbumUserTable {
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@Column({
array: true,
enum: sharing_permission_enum,
default: [SharingPermission.AssetRead, SharingPermission.ExifRead],
})
permissions!: Generated<SharingPermission[]>;
@Column({ type: 'boolean', default: false })
inTimeline!: Generated<boolean>;
}
+8 -8
View File
@@ -15,7 +15,7 @@ import { SourceType } from 'src/enum';
import { asset_face_source_type } from 'src/schema/enums';
import { asset_face_audit } from 'src/schema/functions';
import { AssetTable } from 'src/schema/tables/asset.table';
import { FaceClusterTable } from 'src/schema/tables/face-cluster.table';
import { PersonTable } from 'src/schema/tables/person.table';
@Table({ name: 'asset_face' })
@UpdatedAtTrigger('asset_face_updatedAt')
@@ -26,13 +26,13 @@ import { FaceClusterTable } from 'src/schema/tables/face-cluster.table';
when: 'pg_trigger_depth() = 0',
})
// schemaFromDatabase does not preserve column order
@Index({ name: 'asset_face_assetId_faceClusterId_idx', columns: ['assetId', 'faceClusterId'] })
@Index({ name: 'asset_face_assetId_personId_idx', columns: ['assetId', 'personId'] })
@Index({
name: 'asset_face_faceClusterId_assetId_notDeleted_isVisible_idx',
columns: ['faceClusterId', 'assetId'],
name: 'asset_face_personId_assetId_notDeleted_isVisible_idx',
columns: ['personId', 'assetId'],
where: '"deletedAt" IS NULL AND "isVisible" IS TRUE',
})
@Index({ columns: ['faceClusterId', 'assetId'] })
@Index({ columns: ['personId', 'assetId'] })
export class AssetFaceTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@@ -45,14 +45,14 @@ export class AssetFaceTable {
})
assetId!: string;
@ForeignKeyColumn(() => FaceClusterTable, {
@ForeignKeyColumn(() => PersonTable, {
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
nullable: true,
// [faceClusterId, assetId] makes this redundant
// [personId, assetId] makes this redundant
index: false,
})
faceClusterId!: string | null;
personId!: string | null;
@Column({ default: 0, type: 'integer' })
imageWidth!: Generated<number>;
@@ -1,25 +0,0 @@
import {
CreateDateColumn,
Generated,
PrimaryGeneratedColumn,
Table,
Timestamp,
UpdateDateColumn,
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
@Table('face_cluster')
@UpdatedAtTrigger('face_cluster_updatedAt')
export class FaceClusterTable {
@PrimaryGeneratedColumn('uuid')
id!: Generated<string>;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
}
@@ -9,8 +9,6 @@ import {
UpdateDateColumn,
} from '@immich/sql-tools';
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { SharingPermission } from 'src/enum';
import { sharing_permission_enum } from 'src/schema/enums';
import { partner_delete_audit } from 'src/schema/functions';
import { UserTable } from 'src/schema/tables/user.table';
@@ -48,7 +46,4 @@ export class PartnerTable {
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
@Column({ array: true, enum: sharing_permission_enum, default: [SharingPermission.All] })
permissions!: Generated<SharingPermission[]>;
}
+3 -7
View File
@@ -14,7 +14,6 @@ import {
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { person_delete_audit } from 'src/schema/functions';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { FaceClusterTable } from 'src/schema/tables/face-cluster.table';
import { UserTable } from 'src/schema/tables/user.table';
@Table('person')
@@ -44,6 +43,9 @@ export class PersonTable {
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
ownerId!: string;
@Column({ default: '' })
name!: Generated<string>;
@Column({ default: '' })
thumbnailPath!: Generated<string>;
@@ -53,9 +55,6 @@ export class PersonTable {
@Column({ type: 'date', nullable: true })
birthDate!: Timestamp | null;
@Column({ default: '' })
name!: Generated<string>;
@ForeignKeyColumn(() => AssetFaceTable, { onDelete: 'SET NULL', nullable: true })
faceAssetId!: string | null;
@@ -67,7 +66,4 @@ export class PersonTable {
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
@ForeignKeyColumn(() => FaceClusterTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true, index: true })
faceClusterId!: string | null;
}
-4
View File
@@ -4,7 +4,6 @@ import {
CreateDateColumn,
DeleteDateColumn,
Generated,
GeneratedColumn,
Index,
PrimaryGeneratedColumn,
Table,
@@ -83,7 +82,4 @@ export class UserTable {
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
@GeneratedColumn('uuid')
trustedGroupId!: Generated<string>;
}
+2 -32
View File
@@ -8,15 +8,13 @@ import {
CreateAlbumDto,
GetAlbumsDto,
mapAlbum,
SharingPermissionsResponseDto,
UpdateAlbumDto,
UpdateAlbumUserDto,
UpdateSharingPermissionsDto,
} from 'src/dtos/album.dto';
import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { MapMarkerResponseDto } from 'src/dtos/map.dto';
import { AlbumUserRole, Permission, SharingPermission } from 'src/enum';
import { AlbumUserRole, Permission } from 'src/enum';
import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository';
import { BaseService } from 'src/services/base.service';
import { addAssets, removeAssets } from 'src/utils/asset.util';
@@ -132,11 +130,6 @@ export class AlbumService extends BaseService {
);
for (const { userId } of albumUsers) {
await this.userRepository.mergeTrustedGroups({
userId: auth.user.id,
userIdToMerge: userId,
});
await this.eventRepository.emit('AlbumInvite', { id: album.id, userId, senderName: auth.user.name });
}
@@ -306,17 +299,7 @@ export class AlbumService extends BaseService {
throw new BadRequestException('Invalid user');
}
await this.userRepository.mergeTrustedGroups({
userId: auth.user.id,
userIdToMerge: userId,
});
await this.albumUserRepository.create({
userId,
albumId: id,
role,
permissions: [SharingPermission.AssetRead, SharingPermission.ExifRead],
});
await this.albumUserRepository.create({ userId, albumId: id, role });
await this.eventRepository.emit('AlbumInvite', { id, userId, senderName: auth.user.name });
}
@@ -355,19 +338,6 @@ export class AlbumService extends BaseService {
await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role });
}
async updateSelf(auth: AuthDto, albumId: string, dto: UpdateSharingPermissionsDto): Promise<void> {
await this.requireAccess({ auth, permission: Permission.AlbumAssetCreate, ids: [albumId] });
await this.albumUserRepository.update(
{ albumId, userId: auth.user.id },
{ permissions: dto.permissions, inTimeline: dto.inTimeline },
);
}
async getSelf(auth: AuthDto, albumId: string): Promise<SharingPermissionsResponseDto> {
await this.requireAccess({ auth, permission: Permission.AlbumAssetCreate, ids: [albumId] });
return this.albumUserRepository.get({ userId: auth.user.id, albumId });
}
private async findOrFail(id: string, authUserId: string, options: AlbumInfoOptions) {
const album = await this.albumRepository.getById(id, options, authUserId);
if (!album) {
+10 -15
View File
@@ -32,11 +32,10 @@ import {
JobStatus,
Permission,
QueueName,
SharingPermission,
} from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { JobItem, JobOf } from 'src/types';
import { hasPermissions, requireElevatedPermission } from 'src/utils/access';
import { requireElevatedPermission } from 'src/utils/access';
import {
getAssetFiles,
getDimensions,
@@ -63,18 +62,14 @@ export class AssetService extends BaseService {
async get(auth: AuthDto, id: string): Promise<AssetResponseDto | SanitizedAssetResponseDto> {
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] });
const asset = await this.assetRepository.getById(
id,
{
exifInfo: true,
owner: true,
faces: { person: true },
stack: { assets: true },
edits: true,
tags: true,
},
auth.user.id,
);
const asset = await this.assetRepository.getById(id, {
exifInfo: true,
owner: true,
faces: { person: true },
stack: { assets: true },
edits: true,
tags: true,
});
if (!asset) {
throw new BadRequestException('Asset not found');
@@ -90,7 +85,7 @@ export class AssetService extends BaseService {
delete data.owner;
}
if (!hasPermissions(data, SharingPermission.PersonRead)) {
if (data.ownerId !== auth.user.id || auth.sharedLink) {
data.people = [];
}
+1 -5
View File
@@ -85,11 +85,7 @@ export class NotificationService extends BaseService {
return;
}
this.logger.error(
`Unable to run job handler (${job.name}): ${error}`,
error?.stack,
'data' in job ? JSON.stringify(job.data) : {},
);
this.logger.error(`Unable to run job handler (${job.name}): ${error}`, error?.stack, JSON.stringify(job.data));
switch (job.name) {
case JobName.DatabaseBackup: {
+2 -14
View File
@@ -3,7 +3,7 @@ import { Partner } from 'src/database';
import { AuthDto } from 'src/dtos/auth.dto';
import { PartnerCreateDto, PartnerResponseDto, PartnerSearchDto, PartnerUpdateDto } from 'src/dtos/partner.dto';
import { mapUser } from 'src/dtos/user.dto';
import { JobName, Permission, SharingPermission } from 'src/enum';
import { Permission } from 'src/enum';
import { PartnerDirection, PartnerIds } from 'src/repositories/partner.repository';
import { BaseService } from 'src/services/base.service';
@@ -16,15 +16,7 @@ export class PartnerService extends BaseService {
throw new BadRequestException(`Partner already exists`);
}
const { numUpdatedRows } = await this.userRepository.mergeTrustedGroups({
userId: auth.user.id,
userIdToMerge: sharedWithId,
});
const partner = await this.partnerRepository.create({ ...partnerId, permissions: [SharingPermission.All] });
if (numUpdatedRows > 0) {
await this.jobRepository.queue({ name: JobName.FacialRecognitionMerge, data: { id: sharedWithId } });
}
const partner = await this.partnerRepository.create(partnerId);
return this.mapPartner(partner, PartnerDirection.SharedBy);
}
@@ -36,10 +28,6 @@ export class PartnerService extends BaseService {
}
await this.partnerRepository.remove(partnerId);
const { numUpdatedRows } = await this.userRepository.updateTrustedGroups(auth.user.id);
if (numUpdatedRows > 0) {
await this.jobRepository.queue({ name: JobName.FacialRecognitionQueueAll, data: { force: true } });
}
}
async search(auth: AuthDto, { direction }: PartnerSearchDto): Promise<PartnerResponseDto[]> {
+18 -112
View File
@@ -13,7 +13,7 @@ import {
FaceDto,
mapFaces,
mapPerson,
MergeFaceClusterDto,
MergePersonDto,
PeopleResponseDto,
PeopleUpdateDto,
PersonCreateDto,
@@ -125,11 +125,11 @@ export class PersonService extends BaseService {
async getFacesById(auth: AuthDto, dto: FaceDto): Promise<AssetFaceResponseDto[]> {
await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.id] });
const faces = await this.personRepository.getFaces(dto.id, { userId: auth.user.id });
const faces = await this.personRepository.getFaces(dto.id);
const asset = await this.assetRepository.getForFaces(dto.id);
const assetDimensions = getDimensions(asset);
return faces.map((face) => mapFaces(face, asset.edits, assetDimensions));
return faces.map((face) => mapFaces(face, auth, asset.edits, assetDimensions));
}
async createNewFeaturePhoto(changeFeaturePhoto: string[]) {
@@ -157,7 +157,7 @@ export class PersonService extends BaseService {
async getStatistics(auth: AuthDto, id: string): Promise<PersonStatisticsResponseDto> {
await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] });
return this.personRepository.getStatistics(auth.user.id, id);
return this.personRepository.getStatistics(id);
}
async getThumbnail(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
@@ -436,7 +436,7 @@ export class PersonService extends BaseService {
const lastRun = new Date().toISOString();
const facePagination = this.personRepository.getAllFaces(
force ? undefined : { faceClusterId: null, sourceType: SourceType.MachineLearning },
force ? undefined : { personId: null, sourceType: SourceType.MachineLearning },
);
let jobs: { name: JobName.FacialRecognition; data: { id: string; deferred: false } }[] = [];
@@ -479,8 +479,8 @@ export class PersonService extends BaseService {
return JobStatus.Failed;
}
if (face.faceClusterId) {
this.logger.debug(`Face ${id} already belongs to a face cluster`);
if (face.personId) {
this.logger.debug(`Face ${id} already has a person assigned`);
return JobStatus.Skipped;
}
@@ -509,8 +509,8 @@ export class PersonService extends BaseService {
return JobStatus.Skipped;
}
let faceClusterId = matches.find((match) => match.faceClusterId)?.faceClusterId;
if (!faceClusterId) {
let personId = matches.find((match) => match.personId)?.personId;
if (!personId) {
const matchWithPerson = await this.searchRepository.searchFaces({
userIds: [face.asset.ownerId],
embedding: face.faceSearch.embedding,
@@ -521,109 +521,20 @@ export class PersonService extends BaseService {
});
if (matchWithPerson.length > 0) {
faceClusterId = matchWithPerson[0].faceClusterId;
personId = matchWithPerson[0].personId;
}
}
if (isCore && !faceClusterId) {
if (isCore && !personId) {
this.logger.log(`Creating new person for face ${id}`);
const newPerson = await this.personRepository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id });
await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id: newPerson.id } });
faceClusterId = newPerson.faceClusterId;
personId = newPerson.id;
}
if (faceClusterId) {
this.logger.debug(`Assigning face ${id} to face cluster ${faceClusterId}`);
await this.personRepository.reassignFaces({ faceIds: [id], newFaceClusterId: faceClusterId });
}
return JobStatus.Success;
}
@OnJob({ name: JobName.FacialRecognitionMerge, queue: QueueName.FacialRecognition })
async mergeClusters({ id: userId }: JobOf<JobName.FacialRecognitionMerge>): Promise<JobStatus> {
const { machineLearning } = await this.getConfig({ withCache: true });
if (!isFacialRecognitionEnabled(machineLearning)) {
return JobStatus.Skipped;
}
const faces = this.personRepository.getAllFaces({ sourceType: SourceType.MachineLearning });
for await (const { id } of faces) {
const face = await this.personRepository.getFaceForFacialRecognitionJob(id);
if (!face?.faceSearch || !face.asset) {
this.logger.warn(`Face ${id} does not have an embedding`);
continue;
}
let faceClusterId: string | null = null;
let personId: string | null = null;
const matchWithPerson = await this.searchRepository.searchFaces({
userIds: [face.asset.ownerId],
embedding: face.faceSearch.embedding,
maxDistance: machineLearning.facialRecognition.maxDistance,
numResults: 100,
hasPerson: true,
minBirthDate: new Date(face.asset.fileCreatedAt),
});
if (matchWithPerson.length > 0) {
// favor a person that's not owned by us to merge people with a newly shared with user
// probably do smarter stuff here like pick the person with a name, if both have a name set aliases or whatever
const match = matchWithPerson.find((match) => match.ownerId !== userId) ?? matchWithPerson[0];
if (match.faceClusterId && face.asset.ownerId !== match.ownerId) {
// TODO should probably be a DB constraint?
const people = await this.personRepository.getByFaceClusterId(match.faceClusterId);
if (!people.some((person) => person.ownerId === face.asset?.ownerId)) {
const { id } = await this.personRepository.create({
ownerId: face.asset.ownerId,
faceClusterId: match.faceClusterId,
});
personId = id;
}
}
faceClusterId = match.faceClusterId;
}
if (!faceClusterId) {
const matches = await this.searchRepository.searchFaces({
userIds: [userId],
embedding: face.faceSearch.embedding,
maxDistance: machineLearning.facialRecognition.maxDistance,
numResults: machineLearning.facialRecognition.minFaces,
minBirthDate: new Date(face.asset.fileCreatedAt),
});
const match = matches.find((match) => match.faceClusterId);
if (
match &&
match.faceClusterId &&
face.asset.ownerId !== match.ownerId &&
matches.length >= machineLearning.facialRecognition.minFaces
) {
// TODO should probably be a DB constraint?
const people = await this.personRepository.getByFaceClusterId(match.faceClusterId);
if (!people.some((person) => person.ownerId === face.asset?.ownerId)) {
const { id } = await this.personRepository.create({
ownerId: face.asset.ownerId,
faceClusterId: match.faceClusterId,
});
personId = id;
}
}
faceClusterId = match?.faceClusterId ?? null;
}
if (faceClusterId) {
this.logger.log(`Assigning face ${id} to face cluster ${faceClusterId}`);
await this.personRepository.reassignFaces({ faceIds: [id], newFaceClusterId: faceClusterId });
}
if (personId) {
await this.createNewFeaturePhoto([personId]);
}
if (personId) {
this.logger.debug(`Assigning face ${id} to person ${personId}`);
await this.personRepository.reassignFaces({ faceIds: [id], newPersonId: personId });
}
return JobStatus.Success;
@@ -641,7 +552,7 @@ export class PersonService extends BaseService {
return JobStatus.Success;
}
async mergePerson(auth: AuthDto, id: string, dto: MergeFaceClusterDto): Promise<BulkIdResponseDto[]> {
async mergePerson(auth: AuthDto, id: string, dto: MergePersonDto): Promise<BulkIdResponseDto[]> {
const mergeIds = dto.ids;
if (mergeIds.includes(id)) {
throw new BadRequestException('Cannot merge a person into themselves');
@@ -687,7 +598,7 @@ export class PersonService extends BaseService {
}
const mergeName = mergePerson.name || mergePerson.id;
const mergeData: UpdateFacesData = { oldFaceClusterId: mergeId, newFaceClusterId: id };
const mergeData: UpdateFacesData = { oldPersonId: mergeId, newPersonId: id };
this.logger.log(`Merging ${mergeName} into ${primaryName}`);
await this.personRepository.reassignFaces(mergeData);
@@ -700,7 +611,6 @@ export class PersonService extends BaseService {
results.push({ id: mergeId, success: false, error: BulkIdErrorReason.UNKNOWN });
}
}
return results;
}
@@ -770,12 +680,8 @@ export class PersonService extends BaseService {
dto.imageHeight = originalDimensions.height;
}
if (!person?.faceClusterId) {
throw new Error('Person must already have some recognized faces and belong to a face cluster');
}
await this.personRepository.createAssetFace({
faceClusterId: person.faceClusterId,
personId: dto.personId,
assetId: dto.assetId,
imageHeight: dto.imageHeight,
imageWidth: dto.imageWidth,
-1
View File
@@ -212,7 +212,6 @@ export class SearchService extends BaseService {
repository: this.partnerRepository,
timelineEnabled: true,
});
console.log(auth.user.id, partnerIds);
return [auth.user.id, ...partnerIds];
}
+1 -4
View File
@@ -220,9 +220,7 @@ export type ConcurrentQueueName = Exclude<
| QueueName.BackupDatabase
>;
export type Jobs = {
[K in JobItem['name']]: 'data' extends keyof (JobItem & { name: K }) ? (JobItem & { name: K })['data'] : never;
};
export type Jobs = { [K in JobItem['name']]: (JobItem & { name: K })['data'] };
export type JobOf<T extends JobName> = Jobs[T];
export interface IBaseJob {
@@ -406,7 +404,6 @@ export type JobItem =
| { name: JobName.AssetDetectFaces; data: IEntityJob }
| { name: JobName.FacialRecognitionQueueAll; data: INightlyJob }
| { name: JobName.FacialRecognition; data: IDeferrableJob }
| { name: JobName.FacialRecognitionMerge; data: IEntityJob }
| { name: JobName.PersonGenerateThumbnail; data: IEntityJob }
// Smart Search
+22 -82
View File
@@ -1,7 +1,7 @@
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { AuthSharedLink } from 'src/database';
import { AuthDto } from 'src/dtos/auth.dto';
import { AlbumUserRole, Permission, SharingPermission } from 'src/enum';
import { AlbumUserRole, Permission } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { setDifference, setIsEqual, setIsSuperset, setUnion } from 'src/utils/set';
@@ -115,41 +115,37 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
case Permission.AssetRead: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetRead]);
return setUnion(isOwner, isShared);
const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
return setUnion(isOwner, isAlbum, isPartner);
}
case Permission.AssetShare: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, false);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetShare]);
return setUnion(isOwner, isShared);
const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner));
return setUnion(isOwner, isPartner);
}
case Permission.AssetView: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetRead]);
return setUnion(isOwner, isShared);
const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
return setUnion(isOwner, isAlbum, isPartner);
}
case Permission.AssetDownload: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [
SharingPermission.AssetRead,
SharingPermission.ExifRead,
]);
return setUnion(isOwner, isShared);
const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner));
const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum));
return setUnion(isOwner, isAlbum, isPartner);
}
case Permission.AssetUpdate: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetUpdate]);
return setUnion(isOwner, isShared);
return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
}
case Permission.AssetDelete: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetDelete]);
return setUnion(isOwner, isShared);
return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
}
case Permission.AssetCopy: {
@@ -157,21 +153,15 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
}
case Permission.AssetEditGet: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetEdit]);
return setUnion(isOwner, isShared);
return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
}
case Permission.AssetEditCreate: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetEdit]);
return setUnion(isOwner, isShared);
return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
}
case Permission.AssetEditDelete: {
const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetEdit]);
return setUnion(isOwner, isShared);
return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission);
}
case Permission.AlbumRead: {
@@ -256,11 +246,7 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
}
case Permission.FaceDelete: {
const isOwner = await access.person.checkFaceOwnerAccess(auth.user.id, ids);
const isShared = await access.person.checkSharedFaceAccess(auth.user.id, setDifference(ids, isOwner), [
SharingPermission.AssetUpdate,
]);
return setUnion(isOwner, isShared);
return access.person.checkFaceOwnerAccess(auth.user.id, ids);
}
case Permission.NotificationRead:
@@ -302,40 +288,11 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
return access.person.checkFaceOwnerAccess(auth.user.id, ids);
}
case Permission.PersonRead: {
const isOwner = await access.person.checkOwnerAccess(auth.user.id, ids);
const isShared = await access.person.checkSharedAccess(auth.user.id, setDifference(ids, isOwner), [
SharingPermission.PersonRead,
]);
return setUnion(isOwner, isShared);
}
case Permission.PersonRead:
case Permission.PersonUpdate:
case Permission.PersonDelete:
case Permission.PersonMerge: {
const isOwner = await access.person.checkOwnerAccess(auth.user.id, ids);
const isShared = await access.person.checkSharedAccess(auth.user.id, setDifference(ids, isOwner), [
SharingPermission.PersonMerge,
]);
return setUnion(isOwner, isShared);
}
case Permission.PersonUpdate: {
const isOwner = await access.person.checkOwnerAccess(auth.user.id, ids);
const isShared = await access.person.checkSharedAccess(auth.user.id, setDifference(ids, isOwner), [
SharingPermission.PersonUpdate,
]);
return setUnion(isOwner, isShared);
}
case Permission.PersonDelete: {
const isOwner = await access.person.checkOwnerAccess(auth.user.id, ids);
const isShared = await access.person.checkSharedAccess(auth.user.id, setDifference(ids, isOwner), [
SharingPermission.PersonDelete,
]);
return setUnion(isOwner, isShared);
return await access.person.checkOwnerAccess(auth.user.id, ids);
}
case Permission.PersonReassign: {
@@ -382,20 +339,3 @@ export const requireElevatedPermission = (auth: AuthDto) => {
throw new UnauthorizedException('Elevated permission is required');
}
};
export const hasPermissions = (
assetLike: { permissions: SharingPermission[] },
...permissions: SharingPermission[]
) => {
if (assetLike.permissions.includes(SharingPermission.All)) {
return true;
}
for (const permission of permissions) {
if (!assetLike.permissions.includes(permission)) {
return false;
}
}
return true;
};
+1 -6
View File
@@ -4,7 +4,7 @@ import { AssetFile } from 'src/database';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetFileType, AssetType, AssetVisibility, Permission, SharingPermission } 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';
@@ -134,11 +134,6 @@ export const getMyPartnerIds = async ({ userId, repository, timelineEnabled }: P
continue;
}
const permissions = [SharingPermission.All, SharingPermission.AssetRead];
if (!permissions.some((permission) => partner.permissions.includes(permission))) {
continue;
}
partnerIds.add(partner.sharedById);
}
+12 -48
View File
@@ -15,17 +15,9 @@ import {
import { PostgresJSDialect } from 'kysely-postgres-js';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { Notice, PostgresError } from 'postgres';
import { columns, lockableProperties, LockableProperty } from 'src/database';
import { columns, lockableProperties, LockableProperty, Person } from 'src/database';
import { AssetEditActionItem } from 'src/dtos/editing.dto';
import {
AssetFileType,
AssetOrderBy,
AssetVisibility,
DatabaseExtension,
ExifOrientation,
SharingPermission,
} from 'src/enum';
import { hasAssetPermissions } from 'src/repositories/asset.repository';
import { AssetFileType, AssetOrderBy, AssetVisibility, DatabaseExtension, ExifOrientation } from 'src/enum';
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
import { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
@@ -223,22 +215,19 @@ export function withFilePath(eb: ExpressionBuilder<DB, 'asset'>, type: AssetFile
export function withFacesAndPeople(
eb: ExpressionBuilder<DB, 'asset'>,
{ withHidden, withDeletedFace, userId: _ }: { withHidden?: boolean; withDeletedFace?: boolean; userId?: string } = {},
withHidden?: boolean,
withDeletedFace?: boolean,
) {
return jsonArrayFrom(
eb
.selectFrom('asset_face')
.select((eb) =>
jsonObjectFrom(
eb
.selectFrom('face_cluster')
.whereRef('face_cluster.id', '=', 'asset_face.faceClusterId')
.innerJoin('person', 'person.faceClusterId', 'face_cluster.id')
.selectAll('person')
.limit(1),
).as('person'),
.leftJoinLateral(
(eb) =>
eb.selectFrom('person').selectAll('person').whereRef('asset_face.personId', '=', 'person.id').as('person'),
(join) => join.onTrue(),
)
.selectAll('asset_face')
.select((eb) => eb.table('person').$castTo<ShallowDehydrateObject<Person>>().as('person'))
.whereRef('asset_face.assetId', '=', 'asset.id')
.$if(!withDeletedFace, (qb) => qb.where('asset_face.deletedAt', 'is', null))
.$if(!withHidden, (qb) => qb.where('asset_face.isVisible', 'is', true)),
@@ -251,12 +240,11 @@ export function hasPeople<O>(qb: SelectQueryBuilder<DB, 'asset', O>, personIds:
eb
.selectFrom('asset_face')
.select('assetId')
.innerJoin('person', 'person.faceClusterId', 'asset_face.faceClusterId')
.where('person.id', '=', anyUuid(personIds!))
.where('personId', '=', anyUuid(personIds!))
.where('deletedAt', 'is', null)
.where('isVisible', 'is', true)
.groupBy('assetId')
.having((eb) => eb.fn.count('person.id').distinct(), '=', personIds.length)
.having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length)
.as('has_people'),
(join) => join.onRef('has_people.assetId', '=', 'asset.id'),
);
@@ -317,30 +305,6 @@ export function truncatedDate<O>(order: AssetOrderBy = AssetOrderBy.TakenAt, siz
return sql<O>`date_trunc(${sql.lit(size ?? 'MONTH')}, ${sql.ref(order === AssetOrderBy.CreatedAt ? 'asset.createdAt' : 'localDateTime')} AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`;
}
export function withPermissions(userId: string) {
return (eb: ExpressionBuilder<DB, 'asset'>) =>
jsonArrayFrom(
eb
.selectFrom('album_user')
.select((eb) => eb.fn<SharingPermission>('unnest', ['album_user.permissions']).as('permission'))
.distinct()
.innerJoin('album_asset', 'album_user.albumId', 'album_asset.albumId')
.whereRef('album_asset.assetId', '=', 'asset.id')
.whereRef('album_user.userId', '=', 'asset.ownerId')
.where('album_user.albumId', 'in', (eb) =>
eb.selectFrom('album_user').select('album_user.albumId').where('album_user.userId', '=', userId),
)
.union(
eb
.selectFrom('partner')
.select((eb) => eb.fn<SharingPermission>('unnest', ['partner.permissions']).as('permission'))
.distinct()
.whereRef('partner.sharedById', '=', 'asset.ownerId')
.where('partner.sharedWithId', '=', userId),
),
).as('permissions');
}
export function withTagId<O>(qb: SelectQueryBuilder<DB, 'asset', O>, tagId: string) {
return qb.where((eb) =>
eb.exists(
@@ -467,7 +431,7 @@ export function searchAssetBuilder(kysely: Kysely<DB>, options: AssetSearchBuild
.$if(!!options.checksum, (qb) => qb.where('asset.checksum', '=', options.checksum!))
.$if(!!options.id, (qb) => qb.where('asset.id', '=', asUuid(options.id!)))
.$if(!!options.libraryId, (qb) => qb.where('asset.libraryId', '=', asUuid(options.libraryId!)))
.$if(!!options.userIds, (qb) => qb.where(hasAssetPermissions(options.userIds![0], [SharingPermission.AssetRead])))
.$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!)))
.$if(!!options.encodedVideoPath, (qb) =>
qb
.innerJoin('asset_file', (join) =>
-1
View File
@@ -38,7 +38,6 @@ const createAsset = (
fileSizeInByte !== null || Object.keys(exifFields).length > 0
? ExifResponseSchema.parse({ fileSizeInByte, ...exifFields })
: undefined,
permissions: [],
});
describe('duplicate utils', () => {
+4 -13
View File
@@ -1,3 +1,4 @@
import { AssetFace } from 'src/database';
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
import { ImageDimensions } from 'src/types';
@@ -30,21 +31,11 @@ const scale = (box: BoundingBox, target: ImageDimensions, source?: ImageDimensio
};
};
export const checkFaceVisibility = <
T extends {
isVisible: boolean;
boundingBoxX1: number;
boundingBoxX2: number;
boundingBoxY1: number;
boundingBoxY2: number;
imageHeight: number;
imageWidth: number;
},
>(
faces: T[],
export const checkFaceVisibility = (
faces: AssetFace[],
originalAssetDimensions: ImageDimensions,
crop?: BoundingBox,
): { visible: T[]; hidden: T[] } => {
): { visible: AssetFace[]; hidden: AssetFace[] } => {
if (!crop) {
return {
visible: faces.filter((face) => !face.isVisible),
@@ -28,8 +28,6 @@ export class AlbumUserFactory {
createdAt: newDate(),
updateId: newUuidV7(),
updatedAt: newDate(),
permissions: [],
inTimeline: false,
...dto,
});
}
-1
View File
@@ -26,7 +26,6 @@ export class PartnerFactory {
sharedWithId,
updatedAt: newDate(),
updateId: newUuidV7(),
permissions: [],
...dto,
})
.sharedBy({ id: sharedById })
-1
View File
@@ -35,7 +35,6 @@ export class UserFactory {
status: UserStatus.Active,
profileChangedAt: newDate(),
updateId: newUuidV7(),
trustedGroupId: newUuid(),
...dto,
});
}
@@ -21,7 +21,6 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
checkAlbumAccess: vitest.fn().mockResolvedValue(new Set()),
checkPartnerAccess: vitest.fn().mockResolvedValue(new Set()),
checkSharedLinkAccess: vitest.fn().mockResolvedValue(new Set()),
checkSharedAccess: vitest.fn().mockResolvedValue(new Set()),
},
album: {
@@ -49,8 +48,6 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
person: {
checkFaceOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
checkSharedAccess: vitest.fn().mockResolvedValue(new Set()),
checkSharedFaceAccess: vitest.fn().mockResolvedValue(new Set()),
},
partner: {
@@ -21,13 +21,12 @@
import { getAlbumAssetActions } from '$lib/services/album.service';
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions } from '$lib/services/asset.service';
import { getSharedLink, hasPermissions, withoutIcons } from '$lib/utils';
import { getSharedLink, withoutIcons } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
import { toTimelineAsset } from '$lib/utils/timeline-util';
import {
AssetTypeEnum,
AssetVisibility,
SharingPermission,
type AlbumResponseDto,
type AssetResponseDto,
type PersonResponseDto,
@@ -131,7 +130,7 @@
<ActionButton action={Actions.Edit} />
{#if hasPermissions(asset, SharingPermission.AssetDelete)}
{#if isOwner}
<DeleteAction {asset} {onAction} {preAction} {onUndoDelete} />
{/if}
@@ -147,7 +146,7 @@
{/if}
<ActionMenuItem action={Actions.AddToAlbum} />
{#if album && (hasPermissions(asset, SharingPermission.AssetShare) || isAlbumOwner)}
{#if album && (isOwner || isAlbumOwner)}
<RemoveFromAlbumAction {album} onRemove={onRemoveFromAlbum} assetIds={[asset.id]} menuItem />
{/if}
@@ -3,7 +3,6 @@
import DetailPanelDate from '$lib/components/asset-viewer/DetailPanelDate.svelte';
import DetailPanelDescription from '$lib/components/asset-viewer/DetailPanelDescription.svelte';
import DetailPanelLocation from '$lib/components/asset-viewer/DetailPanelLocation.svelte';
import DetailPanelPeople from '$lib/components/asset-viewer/DetailPanelPeople.svelte';
import DetailPanelRating from '$lib/components/asset-viewer/DetailPanelStarRating.svelte';
import DetailPanelTags from '$lib/components/asset-viewer/DetailPanelTags.svelte';
import { timeToLoadTheMap } from '$lib/constants';
@@ -12,7 +11,7 @@
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { Route } from '$lib/route';
import { locale } from '$lib/stores/preferences.store';
import { getAssetMediaUrl, hasPermissions } from '$lib/utils';
import { getAssetMediaUrl } from '$lib/utils';
import { delay, getDimensions } from '$lib/utils/asset-utils';
import { getByteUnitString } from '$lib/utils/byte-units';
import { handleError } from '$lib/utils/handle-error';
@@ -21,7 +20,6 @@
AssetMediaSize,
getAllAlbums,
getAssetInfo,
SharingPermission,
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
@@ -34,6 +32,7 @@
import OnEvents from '../OnEvents.svelte';
import UserAvatar from '../shared-components/UserAvatar.svelte';
import AlbumListItemDetails from './AlbumListItemDetails.svelte';
import DetailPanelPeople from '$lib/components/asset-viewer/DetailPanelPeople.svelte';
import { faceManager } from '$lib/stores/face.svelte';
interface Props {
@@ -44,7 +43,6 @@
let { asset, currentAlbum = null }: Props = $props();
let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId);
const allowExifUpdate = $derived(hasPermissions(asset, SharingPermission.AssetUpdate, SharingPermission.ExifRead));
let latlng = $derived(
(() => {
const lat = asset.exifInfo?.latitude;
@@ -152,9 +150,9 @@
</section>
{/if}
<DetailPanelDescription {asset} {allowExifUpdate} />
<DetailPanelRating {asset} {allowExifUpdate} />
<DetailPanelPeople {asset} {previousRoute} />
<DetailPanelDescription {asset} {isOwner} />
<DetailPanelRating {asset} {isOwner} />
<DetailPanelPeople {asset} {isOwner} {previousRoute} />
<div class="p-4">
{#if asset.exifInfo}
@@ -165,7 +163,7 @@
<Text size="small" color="muted">{$t('no_exif_info_available')}</Text>
{/if}
<DetailPanelDate {asset} {allowExifUpdate} />
<DetailPanelDate {asset} />
<div class="flex gap-4 py-4">
<div><Icon icon={mdiImageOutline} size="24" /></div>
@@ -173,7 +171,7 @@
<div>
<p class="flex place-items-center gap-2 break-all whitespace-pre-wrap">
{asset.originalFileName}
{#if allowExifUpdate}
{#if isOwner}
<IconButton
icon={mdiInformationOutline}
aria-label={$t('show_file_location')}
@@ -276,7 +274,7 @@
</div>
{/if}
<DetailPanelLocation {allowExifUpdate} {asset} />
<DetailPanelLocation {isOwner} {asset} />
</div>
</section>
@@ -10,10 +10,9 @@
type Props = {
asset: AssetResponseDto;
allowExifUpdate: boolean;
};
const { asset, allowExifUpdate }: Props = $props();
const { asset }: Props = $props();
const timeZone = $derived(asset.exifInfo?.timeZone ?? undefined);
const dateTime = $derived(
@@ -21,8 +20,13 @@
? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone)
: fromISODateTimeUTC(asset.localDateTime),
);
const isOwner = $derived(authManager.authenticated && asset.ownerId === authManager.user.id);
const handleChangeDate = async () => {
if (!isOwner) {
return;
}
await modalManager.show(AssetChangeDateModal, {
asset: toTimelineAsset(asset),
initialDate: dateTime,
@@ -36,8 +40,8 @@
type="button"
class="flex w-full place-items-start justify-between gap-4 py-4 text-start"
onclick={handleChangeDate}
title={allowExifUpdate ? $t('edit_date') : ''}
class:hover:text-primary={allowExifUpdate}
title={isOwner ? $t('edit_date') : ''}
class:hover:text-primary={isOwner}
data-testid="detail-panel-edit-date-button"
>
<div class="flex gap-4">
@@ -64,13 +68,13 @@
</div>
</div>
{#if allowExifUpdate}
{#if isOwner}
<div class="p-1">
<Icon icon={mdiPencil} size="20" />
</div>
{/if}
</button>
{:else if !dateTime && allowExifUpdate}
{:else if !dateTime && isOwner}
<div class="flex place-items-start justify-between gap-4 py-4">
<div class="flex gap-4">
<Icon icon={mdiCalendar} size="24" />
@@ -8,10 +8,10 @@
interface Props {
asset: AssetResponseDto;
allowExifUpdate: boolean;
isOwner: boolean;
}
let { asset, allowExifUpdate }: Props = $props();
let { asset, isOwner }: Props = $props();
let description = $derived(asset.exifInfo?.description ?? '');
@@ -29,7 +29,7 @@
};
</script>
{#if allowExifUpdate}
{#if isOwner}
<section class="mt-10 px-4">
<Textarea
bind:value={description}
@@ -7,11 +7,11 @@
import { t } from 'svelte-i18n';
type Props = {
allowExifUpdate: boolean;
isOwner: boolean;
asset: AssetResponseDto;
};
let { allowExifUpdate, asset = $bindable() }: Props = $props();
let { isOwner, asset = $bindable() }: Props = $props();
const onAction = async () => {
const point = await modalManager.show(GeolocationPointPickerModal, { asset });
@@ -34,9 +34,9 @@
<button
type="button"
class="flex w-full place-items-start justify-between gap-4 py-4 text-start"
onclick={allowExifUpdate ? onAction : undefined}
title={allowExifUpdate ? $t('edit_location') : ''}
class:hover:text-primary={allowExifUpdate}
onclick={isOwner ? onAction : undefined}
title={isOwner ? $t('edit_location') : ''}
class:hover:text-primary={isOwner}
>
<div class="flex gap-4">
<div><Icon icon={mdiMapMarkerOutline} size="24" /></div>
@@ -58,13 +58,13 @@
</div>
</div>
{#if allowExifUpdate}
{#if isOwner}
<div>
<Icon icon={mdiPencil} size="20" />
</div>
{/if}
</button>
{:else if !asset.exifInfo?.city && allowExifUpdate}
{:else if !asset.exifInfo?.city && isOwner}
<button
type="button"
class="flex w-full place-items-start justify-between gap-4 rounded-lg py-4 text-start hover:text-primary"
@@ -5,8 +5,8 @@
import { Route } from '$lib/route';
import { faceManager } from '$lib/stores/face.svelte';
import { locale } from '$lib/stores/preferences.store';
import { getPeopleThumbnailUrl, hasPermissions } from '$lib/utils';
import { SharingPermission, type AssetResponseDto } from '@immich/sdk';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { type AssetResponseDto } from '@immich/sdk';
import { IconButton, Text } from '@immich/ui';
import { mdiEye, mdiEyeOff, mdiPencil, mdiPlus } from '@mdi/js';
import { DateTime } from 'luxon';
@@ -14,13 +14,13 @@
type Props = {
asset: AssetResponseDto;
isOwner: boolean;
previousRoute: string;
};
const { asset, previousRoute }: Props = $props();
const { asset, isOwner, previousRoute }: Props = $props();
const people = $derived(Array.from(faceManager.people));
$effect(() => console.log(people));
const visiblePeople = $derived(
people
.filter((p) => assetViewerManager.isShowingHiddenPeople || !p.isHidden)
@@ -56,7 +56,7 @@
);
</script>
{#if !authManager.isSharedLink && hasPermissions(asset, SharingPermission.PersonRead)}
{#if !authManager.isSharedLink && isOwner}
<section class="px-4 pt-4 text-sm">
<div class="flex h-10 w-full items-center justify-between">
<Text size="small" color="muted">{$t('people')}</Text>
@@ -8,10 +8,10 @@
interface Props {
asset: AssetResponseDto;
allowExifUpdate: boolean;
isOwner: boolean;
}
let { asset, allowExifUpdate }: Props = $props();
let { asset, isOwner }: Props = $props();
let rating = $derived(asset.exifInfo?.rating ?? null) as Rating;
@@ -26,10 +26,6 @@
{#if !authManager.isSharedLink && authManager.authenticated && authManager.preferences.ratings.enabled}
<section class="px-4 pt-4">
<StarRating
{rating}
readOnly={!allowExifUpdate}
onRating={(rating) => handlePromiseError(handleChangeRating(rating))}
/>
<StarRating {rating} readOnly={!isOwner} onRating={(rating) => handlePromiseError(handleChangeRating(rating))} />
</section>
{/if}
-1
View File
@@ -40,7 +40,6 @@
title: $t('admin.maintenance_integrity_checksum_mismatch_refresh_job'),
value: ManualJobName.IntegrityChecksumMismatchRefresh,
},
{ title: 'Person grouping', value: ManualJobName.PersonGroupMerge },
].map(({ value, title }) => ({ id: value, label: title, value }));
let selectedJob: ComboBoxOption | undefined = $state(undefined);
@@ -36,7 +36,7 @@
try {
await mergePerson({
id: personToBeMergedInto.id,
mergeFaceClusterDto: { ids: [personToMerge.id] },
mergePersonDto: { ids: [personToMerge.id] },
});
toastManager.primary($t('merge_people_successfully'));
onClose([personToMerge, personToBeMergedInto]);
@@ -1,78 +0,0 @@
<script lang="ts">
import { getOwnAlbumUser, SharingPermission, updateOwnAlbumUser } from '@immich/sdk';
import { Checkbox, Field, FormModal, Heading, Stack, Switch, toastManager } from '@immich/ui';
import { onMount } from 'svelte';
import { init } from 'svelte-i18n';
type Props = {
onClose: () => void;
albumId?: string;
partnerId?: string;
};
const { onClose, ...rest }: Props = $props();
let checkedPermissions = $state<SharingPermission[]>([]);
let viewInTimeline = $state<boolean>(false);
const onCheckedChange = (permission: SharingPermission, checked: boolean) => {
if (checked) {
checkedPermissions.push(permission);
} else {
checkedPermissions = checkedPermissions.filter((perm) => perm !== permission);
}
};
const onSubmit = async () => {
const permissions =
checkedPermissions.length === Object.values(SharingPermission).length - 1
? [SharingPermission.All]
: checkedPermissions;
if (rest.albumId) {
await updateOwnAlbumUser({
id: rest.albumId,
updateSharingOptionsDto: { permissions, inTimeline: viewInTimeline },
});
toastManager.success();
}
onClose();
};
onMount(async () => {
if (rest.albumId) {
const { permissions, inTimeline } = await getOwnAlbumUser({ id: rest.albumId });
checkedPermissions = permissions;
viewInTimeline = inTimeline;
}
});
</script>
<FormModal title="Sharing options" {onClose} {onSubmit}>
<Stack>
<Field label="View in timeline">
<Switch bind:checked={viewInTimeline} />
</Field>
<Heading>Permissions</Heading>
<Field label={SharingPermission.All}>
<Checkbox
id="permission-{SharingPermission.All}"
checked={checkedPermissions.length === Object.values(SharingPermission).length - 1}
onCheckedChange={(checked) =>
checked
? (checkedPermissions = Object.values(SharingPermission).filter(
(permission) => permission !== SharingPermission.All,
))
: (checkedPermissions = [])}
/>
</Field>
{#each Object.values(SharingPermission).filter((permission) => permission !== SharingPermission.All) as permission (permission)}
<Field label={permission}>
<Checkbox
id="permission-{permission}"
checked={checkedPermissions.includes(permission)}
onCheckedChange={(checked) => onCheckedChange(permission, checked)}
/>
</Field>
{/each}
</Stack>
</FormModal>
+5 -10
View File
@@ -5,7 +5,6 @@ import {
AssetVisibility,
getAssetInfo,
runAssetJobs,
SharingPermission,
updateAsset,
type AssetJobsDto,
type AssetResponseDto,
@@ -51,7 +50,8 @@ import ProfileImageCropperModal from '$lib/modals/ProfileImageCropperModal.svelt
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { Route } from '$lib/route';
import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { downloadUrl, getAssetMediaUrl, getSharedLink, hasPermissions, sleep } from '$lib/utils';
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
import { downloadUrl } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
@@ -108,12 +108,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto &
const Share: ActionItem = {
title: $t('share'),
icon: mdiShareVariantOutline,
$if: () =>
!!(
hasPermissions(asset, SharingPermission.AssetShare) &&
!asset.isTrashed &&
asset.visibility !== AssetVisibility.Locked
),
$if: () => !!(authUser && !asset.isTrashed && asset.visibility !== AssetVisibility.Locked),
onAction: () => modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] }),
};
@@ -134,7 +129,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto &
const SharedLinkDownload: ActionItem = {
...Download,
$if: () => hasPermissions(asset, SharingPermission.AssetShare) || !!sharedLink?.allowDownload,
$if: () => isOwner || !!sharedLink?.allowDownload,
};
const PlayMotionPhoto: ActionItem = {
@@ -244,7 +239,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto &
icon: mdiTune,
$if: () =>
!sharedLink &&
hasPermissions(asset, SharingPermission.AssetEdit) &&
isOwner &&
asset.type === AssetTypeEnum.Image &&
!asset.livePhotoVideoId &&
asset.exifInfo?.projectionType !== ProjectionType.EQUIRECTANGULAR &&
-15
View File
@@ -2,7 +2,6 @@ import {
AssetMediaSize,
AssetTypeEnum,
MemoryType,
SharingPermission,
finishOAuth,
getAssetOriginalPath,
getAssetPlaybackPath,
@@ -441,17 +440,3 @@ export const transformToTitleCase = (text: string) => {
}
return result.trim();
};
export const hasPermissions = (asset: AssetResponseDto, ...permissions: SharingPermission[]) => {
if (asset.permissions.includes(SharingPermission.All)) {
return true;
}
for (const permission of permissions) {
if (!asset.permissions.includes(permission)) {
return false;
}
}
return true;
};
@@ -35,7 +35,6 @@
import { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
import SharingOptionsModal from '$lib/modals/SharingOptionsModal.svelte';
import { Route } from '$lib/route';
import {
getAlbumActions,
@@ -72,7 +71,6 @@
mdiLink,
mdiPlus,
mdiPresentationPlay,
mdiShareVariant,
} from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
@@ -407,16 +405,9 @@
{/if}
</button>
<IconButton
shape="round"
aria-label="Sharing permissions"
color="secondary"
size="medium"
icon={mdiShareVariant}
onclick={() => modalManager.show(SharingOptionsModal, { albumId: album.id })}
/>
<ActionButton action={Share} />
{#if isOwned}
<ActionButton action={Share} />
{/if}
</div>
{/if}
<AlbumDescription

Some files were not shown because too many files have changed in this diff Show More