forked from Cutlery/immich
Compare commits
13 Commits
main
...
feat/dupli
Author | SHA1 | Date | |
---|---|---|---|
|
f6aeb59b6d | ||
|
5def1ebc53 | ||
|
9d886cf9dc | ||
|
7d9d9f9e97 | ||
|
a27c72a426 | ||
|
28daeaccef | ||
|
16d07dde8f | ||
|
1c23977f25 | ||
|
754765ece1 | ||
|
179834faeb | ||
|
33d09fb5ef | ||
|
ba2ff0efee | ||
|
e8d5d7a214 |
1
mobile/openapi/README.md
generated
1
mobile/openapi/README.md
generated
@ -96,6 +96,7 @@ Class | Method | HTTP request | Description
|
|||||||
*AssetApi* | [**deleteAssets**](doc//AssetApi.md#deleteassets) | **DELETE** /asset |
|
*AssetApi* | [**deleteAssets**](doc//AssetApi.md#deleteassets) | **DELETE** /asset |
|
||||||
*AssetApi* | [**getAllAssets**](doc//AssetApi.md#getallassets) | **GET** /asset |
|
*AssetApi* | [**getAllAssets**](doc//AssetApi.md#getallassets) | **GET** /asset |
|
||||||
*AssetApi* | [**getAllUserAssetsByDeviceId**](doc//AssetApi.md#getalluserassetsbydeviceid) | **GET** /asset/device/{deviceId} |
|
*AssetApi* | [**getAllUserAssetsByDeviceId**](doc//AssetApi.md#getalluserassetsbydeviceid) | **GET** /asset/device/{deviceId} |
|
||||||
|
*AssetApi* | [**getAssetDuplicates**](doc//AssetApi.md#getassetduplicates) | **GET** /asset/duplicates |
|
||||||
*AssetApi* | [**getAssetInfo**](doc//AssetApi.md#getassetinfo) | **GET** /asset/{id} |
|
*AssetApi* | [**getAssetInfo**](doc//AssetApi.md#getassetinfo) | **GET** /asset/{id} |
|
||||||
*AssetApi* | [**getAssetSearchTerms**](doc//AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms |
|
*AssetApi* | [**getAssetSearchTerms**](doc//AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms |
|
||||||
*AssetApi* | [**getAssetStatistics**](doc//AssetApi.md#getassetstatistics) | **GET** /asset/statistics |
|
*AssetApi* | [**getAssetStatistics**](doc//AssetApi.md#getassetstatistics) | **GET** /asset/statistics |
|
||||||
|
1
mobile/openapi/doc/AllJobStatusResponseDto.md
generated
1
mobile/openapi/doc/AllJobStatusResponseDto.md
generated
@ -9,6 +9,7 @@ import 'package:openapi/api.dart';
|
|||||||
Name | Type | Description | Notes
|
Name | Type | Description | Notes
|
||||||
------------ | ------------- | ------------- | -------------
|
------------ | ------------- | ------------- | -------------
|
||||||
**backgroundTask** | [**JobStatusDto**](JobStatusDto.md) | |
|
**backgroundTask** | [**JobStatusDto**](JobStatusDto.md) | |
|
||||||
|
**duplicateDetection** | [**JobStatusDto**](JobStatusDto.md) | |
|
||||||
**faceDetection** | [**JobStatusDto**](JobStatusDto.md) | |
|
**faceDetection** | [**JobStatusDto**](JobStatusDto.md) | |
|
||||||
**facialRecognition** | [**JobStatusDto**](JobStatusDto.md) | |
|
**facialRecognition** | [**JobStatusDto**](JobStatusDto.md) | |
|
||||||
**library_** | [**JobStatusDto**](JobStatusDto.md) | |
|
**library_** | [**JobStatusDto**](JobStatusDto.md) | |
|
||||||
|
52
mobile/openapi/doc/AssetApi.md
generated
52
mobile/openapi/doc/AssetApi.md
generated
@ -14,6 +14,7 @@ Method | HTTP request | Description
|
|||||||
[**deleteAssets**](AssetApi.md#deleteassets) | **DELETE** /asset |
|
[**deleteAssets**](AssetApi.md#deleteassets) | **DELETE** /asset |
|
||||||
[**getAllAssets**](AssetApi.md#getallassets) | **GET** /asset |
|
[**getAllAssets**](AssetApi.md#getallassets) | **GET** /asset |
|
||||||
[**getAllUserAssetsByDeviceId**](AssetApi.md#getalluserassetsbydeviceid) | **GET** /asset/device/{deviceId} |
|
[**getAllUserAssetsByDeviceId**](AssetApi.md#getalluserassetsbydeviceid) | **GET** /asset/device/{deviceId} |
|
||||||
|
[**getAssetDuplicates**](AssetApi.md#getassetduplicates) | **GET** /asset/duplicates |
|
||||||
[**getAssetInfo**](AssetApi.md#getassetinfo) | **GET** /asset/{id} |
|
[**getAssetInfo**](AssetApi.md#getassetinfo) | **GET** /asset/{id} |
|
||||||
[**getAssetSearchTerms**](AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms |
|
[**getAssetSearchTerms**](AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms |
|
||||||
[**getAssetStatistics**](AssetApi.md#getassetstatistics) | **GET** /asset/statistics |
|
[**getAssetStatistics**](AssetApi.md#getassetstatistics) | **GET** /asset/statistics |
|
||||||
@ -328,6 +329,57 @@ Name | Type | Description | Notes
|
|||||||
|
|
||||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||||
|
|
||||||
|
# **getAssetDuplicates**
|
||||||
|
> List<AssetResponseDto> getAssetDuplicates()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Example
|
||||||
|
```dart
|
||||||
|
import 'package:openapi/api.dart';
|
||||||
|
// TODO Configure API key authorization: cookie
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
|
||||||
|
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
|
||||||
|
// TODO Configure API key authorization: api_key
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
|
||||||
|
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||||
|
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
|
||||||
|
// TODO Configure HTTP Bearer authorization: bearer
|
||||||
|
// Case 1. Use String Token
|
||||||
|
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||||
|
// Case 2. Use Function which generate token.
|
||||||
|
// String yourTokenGeneratorFunction() { ... }
|
||||||
|
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||||
|
|
||||||
|
final api_instance = AssetApi();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = api_instance.getAssetDuplicates();
|
||||||
|
print(result);
|
||||||
|
} catch (e) {
|
||||||
|
print('Exception when calling AssetApi->getAssetDuplicates: $e\n');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
This endpoint does not need any parameter.
|
||||||
|
|
||||||
|
### Return type
|
||||||
|
|
||||||
|
[**List<AssetResponseDto>**](AssetResponseDto.md)
|
||||||
|
|
||||||
|
### Authorization
|
||||||
|
|
||||||
|
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
|
||||||
|
|
||||||
|
### HTTP request headers
|
||||||
|
|
||||||
|
- **Content-Type**: Not defined
|
||||||
|
- **Accept**: application/json
|
||||||
|
|
||||||
|
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||||
|
|
||||||
# **getAssetInfo**
|
# **getAssetInfo**
|
||||||
> AssetResponseDto getAssetInfo(id, key)
|
> AssetResponseDto getAssetInfo(id, key)
|
||||||
|
|
||||||
|
1
mobile/openapi/doc/CLIPConfig.md
generated
1
mobile/openapi/doc/CLIPConfig.md
generated
@ -8,6 +8,7 @@ import 'package:openapi/api.dart';
|
|||||||
## Properties
|
## Properties
|
||||||
Name | Type | Description | Notes
|
Name | Type | Description | Notes
|
||||||
------------ | ------------- | ------------- | -------------
|
------------ | ------------- | ------------- | -------------
|
||||||
|
**duplicateThreshold** | **double** | |
|
||||||
**enabled** | **bool** | |
|
**enabled** | **bool** | |
|
||||||
**mode** | [**CLIPMode**](CLIPMode.md) | | [optional]
|
**mode** | [**CLIPMode**](CLIPMode.md) | | [optional]
|
||||||
**modelName** | **String** | |
|
**modelName** | **String** | |
|
||||||
|
44
mobile/openapi/lib/api/asset_api.dart
generated
44
mobile/openapi/lib/api/asset_api.dart
generated
@ -326,6 +326,50 @@ class AssetApi {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Performs an HTTP 'GET /asset/duplicates' operation and returns the [Response].
|
||||||
|
Future<Response> getAssetDuplicatesWithHttpInfo() async {
|
||||||
|
// ignore: prefer_const_declarations
|
||||||
|
final path = r'/asset/duplicates';
|
||||||
|
|
||||||
|
// ignore: prefer_final_locals
|
||||||
|
Object? postBody;
|
||||||
|
|
||||||
|
final queryParams = <QueryParam>[];
|
||||||
|
final headerParams = <String, String>{};
|
||||||
|
final formParams = <String, String>{};
|
||||||
|
|
||||||
|
const contentTypes = <String>[];
|
||||||
|
|
||||||
|
|
||||||
|
return apiClient.invokeAPI(
|
||||||
|
path,
|
||||||
|
'GET',
|
||||||
|
queryParams,
|
||||||
|
postBody,
|
||||||
|
headerParams,
|
||||||
|
formParams,
|
||||||
|
contentTypes.isEmpty ? null : contentTypes.first,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<AssetResponseDto>?> getAssetDuplicates() async {
|
||||||
|
final response = await getAssetDuplicatesWithHttpInfo();
|
||||||
|
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) {
|
||||||
|
final responseBody = await _decodeBodyBytes(response);
|
||||||
|
return (await apiClient.deserializeAsync(responseBody, 'List<AssetResponseDto>') as List)
|
||||||
|
.cast<AssetResponseDto>()
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Performs an HTTP 'GET /asset/{id}' operation and returns the [Response].
|
/// Performs an HTTP 'GET /asset/{id}' operation and returns the [Response].
|
||||||
/// Parameters:
|
/// Parameters:
|
||||||
///
|
///
|
||||||
|
@ -14,6 +14,7 @@ class AllJobStatusResponseDto {
|
|||||||
/// Returns a new [AllJobStatusResponseDto] instance.
|
/// Returns a new [AllJobStatusResponseDto] instance.
|
||||||
AllJobStatusResponseDto({
|
AllJobStatusResponseDto({
|
||||||
required this.backgroundTask,
|
required this.backgroundTask,
|
||||||
|
required this.duplicateDetection,
|
||||||
required this.faceDetection,
|
required this.faceDetection,
|
||||||
required this.facialRecognition,
|
required this.facialRecognition,
|
||||||
required this.library_,
|
required this.library_,
|
||||||
@ -29,6 +30,8 @@ class AllJobStatusResponseDto {
|
|||||||
|
|
||||||
JobStatusDto backgroundTask;
|
JobStatusDto backgroundTask;
|
||||||
|
|
||||||
|
JobStatusDto duplicateDetection;
|
||||||
|
|
||||||
JobStatusDto faceDetection;
|
JobStatusDto faceDetection;
|
||||||
|
|
||||||
JobStatusDto facialRecognition;
|
JobStatusDto facialRecognition;
|
||||||
@ -54,6 +57,7 @@ class AllJobStatusResponseDto {
|
|||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is AllJobStatusResponseDto &&
|
bool operator ==(Object other) => identical(this, other) || other is AllJobStatusResponseDto &&
|
||||||
other.backgroundTask == backgroundTask &&
|
other.backgroundTask == backgroundTask &&
|
||||||
|
other.duplicateDetection == duplicateDetection &&
|
||||||
other.faceDetection == faceDetection &&
|
other.faceDetection == faceDetection &&
|
||||||
other.facialRecognition == facialRecognition &&
|
other.facialRecognition == facialRecognition &&
|
||||||
other.library_ == library_ &&
|
other.library_ == library_ &&
|
||||||
@ -70,6 +74,7 @@ class AllJobStatusResponseDto {
|
|||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
(backgroundTask.hashCode) +
|
(backgroundTask.hashCode) +
|
||||||
|
(duplicateDetection.hashCode) +
|
||||||
(faceDetection.hashCode) +
|
(faceDetection.hashCode) +
|
||||||
(facialRecognition.hashCode) +
|
(facialRecognition.hashCode) +
|
||||||
(library_.hashCode) +
|
(library_.hashCode) +
|
||||||
@ -83,11 +88,12 @@ class AllJobStatusResponseDto {
|
|||||||
(videoConversion.hashCode);
|
(videoConversion.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'AllJobStatusResponseDto[backgroundTask=$backgroundTask, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]';
|
String toString() => 'AllJobStatusResponseDto[backgroundTask=$backgroundTask, duplicateDetection=$duplicateDetection, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
json[r'backgroundTask'] = this.backgroundTask;
|
json[r'backgroundTask'] = this.backgroundTask;
|
||||||
|
json[r'duplicateDetection'] = this.duplicateDetection;
|
||||||
json[r'faceDetection'] = this.faceDetection;
|
json[r'faceDetection'] = this.faceDetection;
|
||||||
json[r'facialRecognition'] = this.facialRecognition;
|
json[r'facialRecognition'] = this.facialRecognition;
|
||||||
json[r'library'] = this.library_;
|
json[r'library'] = this.library_;
|
||||||
@ -111,6 +117,7 @@ class AllJobStatusResponseDto {
|
|||||||
|
|
||||||
return AllJobStatusResponseDto(
|
return AllJobStatusResponseDto(
|
||||||
backgroundTask: JobStatusDto.fromJson(json[r'backgroundTask'])!,
|
backgroundTask: JobStatusDto.fromJson(json[r'backgroundTask'])!,
|
||||||
|
duplicateDetection: JobStatusDto.fromJson(json[r'duplicateDetection'])!,
|
||||||
faceDetection: JobStatusDto.fromJson(json[r'faceDetection'])!,
|
faceDetection: JobStatusDto.fromJson(json[r'faceDetection'])!,
|
||||||
facialRecognition: JobStatusDto.fromJson(json[r'facialRecognition'])!,
|
facialRecognition: JobStatusDto.fromJson(json[r'facialRecognition'])!,
|
||||||
library_: JobStatusDto.fromJson(json[r'library'])!,
|
library_: JobStatusDto.fromJson(json[r'library'])!,
|
||||||
@ -170,6 +177,7 @@ class AllJobStatusResponseDto {
|
|||||||
/// The list of required keys that must be present in a JSON.
|
/// The list of required keys that must be present in a JSON.
|
||||||
static const requiredKeys = <String>{
|
static const requiredKeys = <String>{
|
||||||
'backgroundTask',
|
'backgroundTask',
|
||||||
|
'duplicateDetection',
|
||||||
'faceDetection',
|
'faceDetection',
|
||||||
'facialRecognition',
|
'facialRecognition',
|
||||||
'library',
|
'library',
|
||||||
|
10
mobile/openapi/lib/model/clip_config.dart
generated
10
mobile/openapi/lib/model/clip_config.dart
generated
@ -13,12 +13,15 @@ part of openapi.api;
|
|||||||
class CLIPConfig {
|
class CLIPConfig {
|
||||||
/// Returns a new [CLIPConfig] instance.
|
/// Returns a new [CLIPConfig] instance.
|
||||||
CLIPConfig({
|
CLIPConfig({
|
||||||
|
required this.duplicateThreshold,
|
||||||
required this.enabled,
|
required this.enabled,
|
||||||
this.mode,
|
this.mode,
|
||||||
required this.modelName,
|
required this.modelName,
|
||||||
this.modelType,
|
this.modelType,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
double duplicateThreshold;
|
||||||
|
|
||||||
bool enabled;
|
bool enabled;
|
||||||
|
|
||||||
///
|
///
|
||||||
@ -41,6 +44,7 @@ class CLIPConfig {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(Object other) => identical(this, other) || other is CLIPConfig &&
|
bool operator ==(Object other) => identical(this, other) || other is CLIPConfig &&
|
||||||
|
other.duplicateThreshold == duplicateThreshold &&
|
||||||
other.enabled == enabled &&
|
other.enabled == enabled &&
|
||||||
other.mode == mode &&
|
other.mode == mode &&
|
||||||
other.modelName == modelName &&
|
other.modelName == modelName &&
|
||||||
@ -49,16 +53,18 @@ class CLIPConfig {
|
|||||||
@override
|
@override
|
||||||
int get hashCode =>
|
int get hashCode =>
|
||||||
// ignore: unnecessary_parenthesis
|
// ignore: unnecessary_parenthesis
|
||||||
|
(duplicateThreshold.hashCode) +
|
||||||
(enabled.hashCode) +
|
(enabled.hashCode) +
|
||||||
(mode == null ? 0 : mode!.hashCode) +
|
(mode == null ? 0 : mode!.hashCode) +
|
||||||
(modelName.hashCode) +
|
(modelName.hashCode) +
|
||||||
(modelType == null ? 0 : modelType!.hashCode);
|
(modelType == null ? 0 : modelType!.hashCode);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() => 'CLIPConfig[enabled=$enabled, mode=$mode, modelName=$modelName, modelType=$modelType]';
|
String toString() => 'CLIPConfig[duplicateThreshold=$duplicateThreshold, enabled=$enabled, mode=$mode, modelName=$modelName, modelType=$modelType]';
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
final json = <String, dynamic>{};
|
final json = <String, dynamic>{};
|
||||||
|
json[r'duplicateThreshold'] = this.duplicateThreshold;
|
||||||
json[r'enabled'] = this.enabled;
|
json[r'enabled'] = this.enabled;
|
||||||
if (this.mode != null) {
|
if (this.mode != null) {
|
||||||
json[r'mode'] = this.mode;
|
json[r'mode'] = this.mode;
|
||||||
@ -82,6 +88,7 @@ class CLIPConfig {
|
|||||||
final json = value.cast<String, dynamic>();
|
final json = value.cast<String, dynamic>();
|
||||||
|
|
||||||
return CLIPConfig(
|
return CLIPConfig(
|
||||||
|
duplicateThreshold: mapValueOfType<double>(json, r'duplicateThreshold')!,
|
||||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||||
mode: CLIPMode.fromJson(json[r'mode']),
|
mode: CLIPMode.fromJson(json[r'mode']),
|
||||||
modelName: mapValueOfType<String>(json, r'modelName')!,
|
modelName: mapValueOfType<String>(json, r'modelName')!,
|
||||||
@ -133,6 +140,7 @@ class CLIPConfig {
|
|||||||
|
|
||||||
/// The list of required keys that must be present in a JSON.
|
/// The list of required keys that must be present in a JSON.
|
||||||
static const requiredKeys = <String>{
|
static const requiredKeys = <String>{
|
||||||
|
'duplicateThreshold',
|
||||||
'enabled',
|
'enabled',
|
||||||
'modelName',
|
'modelName',
|
||||||
};
|
};
|
||||||
|
3
mobile/openapi/lib/model/job_name.dart
generated
3
mobile/openapi/lib/model/job_name.dart
generated
@ -29,6 +29,7 @@ class JobName {
|
|||||||
static const faceDetection = JobName._(r'faceDetection');
|
static const faceDetection = JobName._(r'faceDetection');
|
||||||
static const facialRecognition = JobName._(r'facialRecognition');
|
static const facialRecognition = JobName._(r'facialRecognition');
|
||||||
static const smartSearch = JobName._(r'smartSearch');
|
static const smartSearch = JobName._(r'smartSearch');
|
||||||
|
static const duplicateDetection = JobName._(r'duplicateDetection');
|
||||||
static const backgroundTask = JobName._(r'backgroundTask');
|
static const backgroundTask = JobName._(r'backgroundTask');
|
||||||
static const storageTemplateMigration = JobName._(r'storageTemplateMigration');
|
static const storageTemplateMigration = JobName._(r'storageTemplateMigration');
|
||||||
static const migration = JobName._(r'migration');
|
static const migration = JobName._(r'migration');
|
||||||
@ -44,6 +45,7 @@ class JobName {
|
|||||||
faceDetection,
|
faceDetection,
|
||||||
facialRecognition,
|
facialRecognition,
|
||||||
smartSearch,
|
smartSearch,
|
||||||
|
duplicateDetection,
|
||||||
backgroundTask,
|
backgroundTask,
|
||||||
storageTemplateMigration,
|
storageTemplateMigration,
|
||||||
migration,
|
migration,
|
||||||
@ -94,6 +96,7 @@ class JobNameTypeTransformer {
|
|||||||
case r'faceDetection': return JobName.faceDetection;
|
case r'faceDetection': return JobName.faceDetection;
|
||||||
case r'facialRecognition': return JobName.facialRecognition;
|
case r'facialRecognition': return JobName.facialRecognition;
|
||||||
case r'smartSearch': return JobName.smartSearch;
|
case r'smartSearch': return JobName.smartSearch;
|
||||||
|
case r'duplicateDetection': return JobName.duplicateDetection;
|
||||||
case r'backgroundTask': return JobName.backgroundTask;
|
case r'backgroundTask': return JobName.backgroundTask;
|
||||||
case r'storageTemplateMigration': return JobName.storageTemplateMigration;
|
case r'storageTemplateMigration': return JobName.storageTemplateMigration;
|
||||||
case r'migration': return JobName.migration;
|
case r'migration': return JobName.migration;
|
||||||
|
@ -21,6 +21,11 @@ void main() {
|
|||||||
// TODO
|
// TODO
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// JobStatusDto duplicateDetection
|
||||||
|
test('to test the property `duplicateDetection`', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
// JobStatusDto faceDetection
|
// JobStatusDto faceDetection
|
||||||
test('to test the property `faceDetection`', () async {
|
test('to test the property `faceDetection`', () async {
|
||||||
// TODO
|
// TODO
|
||||||
|
5
mobile/openapi/test/asset_api_test.dart
generated
5
mobile/openapi/test/asset_api_test.dart
generated
@ -50,6 +50,11 @@ void main() {
|
|||||||
// TODO
|
// TODO
|
||||||
});
|
});
|
||||||
|
|
||||||
|
//Future<List<AssetResponseDto>> getAssetDuplicates() async
|
||||||
|
test('test getAssetDuplicates', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
//Future<AssetResponseDto> getAssetInfo(String id, { String key }) async
|
//Future<AssetResponseDto> getAssetInfo(String id, { String key }) async
|
||||||
test('test getAssetInfo', () async {
|
test('test getAssetInfo', () async {
|
||||||
// TODO
|
// TODO
|
||||||
|
5
mobile/openapi/test/clip_config_test.dart
generated
5
mobile/openapi/test/clip_config_test.dart
generated
@ -16,6 +16,11 @@ void main() {
|
|||||||
// final instance = CLIPConfig();
|
// final instance = CLIPConfig();
|
||||||
|
|
||||||
group('test CLIPConfig', () {
|
group('test CLIPConfig', () {
|
||||||
|
// double duplicateThreshold
|
||||||
|
test('to test the property `duplicateThreshold`', () async {
|
||||||
|
// TODO
|
||||||
|
});
|
||||||
|
|
||||||
// bool enabled
|
// bool enabled
|
||||||
test('to test the property `enabled`', () async {
|
test('to test the property `enabled`', () async {
|
||||||
// TODO
|
// TODO
|
||||||
|
@ -1213,6 +1213,41 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/asset/duplicates": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getAssetDuplicates",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/AssetResponseDto"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"cookie": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"api_key": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"Asset"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/asset/exist": {
|
"/asset/exist": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "Checks if multiple assets exist on the server and returns all existing - used by background backup",
|
"description": "Checks if multiple assets exist on the server and returns all existing - used by background backup",
|
||||||
@ -6831,6 +6866,9 @@
|
|||||||
"backgroundTask": {
|
"backgroundTask": {
|
||||||
"$ref": "#/components/schemas/JobStatusDto"
|
"$ref": "#/components/schemas/JobStatusDto"
|
||||||
},
|
},
|
||||||
|
"duplicateDetection": {
|
||||||
|
"$ref": "#/components/schemas/JobStatusDto"
|
||||||
|
},
|
||||||
"faceDetection": {
|
"faceDetection": {
|
||||||
"$ref": "#/components/schemas/JobStatusDto"
|
"$ref": "#/components/schemas/JobStatusDto"
|
||||||
},
|
},
|
||||||
@ -6867,6 +6905,7 @@
|
|||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"backgroundTask",
|
"backgroundTask",
|
||||||
|
"duplicateDetection",
|
||||||
"faceDetection",
|
"faceDetection",
|
||||||
"facialRecognition",
|
"facialRecognition",
|
||||||
"library",
|
"library",
|
||||||
@ -7479,6 +7518,10 @@
|
|||||||
},
|
},
|
||||||
"CLIPConfig": {
|
"CLIPConfig": {
|
||||||
"properties": {
|
"properties": {
|
||||||
|
"duplicateThreshold": {
|
||||||
|
"format": "float",
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
"enabled": {
|
"enabled": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@ -7493,6 +7536,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
"duplicateThreshold",
|
||||||
"enabled",
|
"enabled",
|
||||||
"modelName"
|
"modelName"
|
||||||
],
|
],
|
||||||
@ -8178,6 +8222,7 @@
|
|||||||
"faceDetection",
|
"faceDetection",
|
||||||
"facialRecognition",
|
"facialRecognition",
|
||||||
"smartSearch",
|
"smartSearch",
|
||||||
|
"duplicateDetection",
|
||||||
"backgroundTask",
|
"backgroundTask",
|
||||||
"storageTemplateMigration",
|
"storageTemplateMigration",
|
||||||
"migration",
|
"migration",
|
||||||
|
@ -429,6 +429,7 @@ export type JobStatusDto = {
|
|||||||
};
|
};
|
||||||
export type AllJobStatusResponseDto = {
|
export type AllJobStatusResponseDto = {
|
||||||
backgroundTask: JobStatusDto;
|
backgroundTask: JobStatusDto;
|
||||||
|
duplicateDetection: JobStatusDto;
|
||||||
faceDetection: JobStatusDto;
|
faceDetection: JobStatusDto;
|
||||||
facialRecognition: JobStatusDto;
|
facialRecognition: JobStatusDto;
|
||||||
library: JobStatusDto;
|
library: JobStatusDto;
|
||||||
@ -858,6 +859,7 @@ export type SystemConfigLoggingDto = {
|
|||||||
level: LogLevel;
|
level: LogLevel;
|
||||||
};
|
};
|
||||||
export type ClipConfig = {
|
export type ClipConfig = {
|
||||||
|
duplicateThreshold: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
mode?: CLIPMode;
|
mode?: CLIPMode;
|
||||||
modelName: string;
|
modelName: string;
|
||||||
@ -1321,6 +1323,14 @@ export function getAllUserAssetsByDeviceId({ deviceId }: {
|
|||||||
...opts
|
...opts
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
export function getAssetDuplicates(opts?: Oazapfts.RequestOpts) {
|
||||||
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
|
status: 200;
|
||||||
|
data: AssetResponseDto[];
|
||||||
|
}>("/asset/duplicates", {
|
||||||
|
...opts
|
||||||
|
}));
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* Checks if multiple assets exist on the server and returns all existing - used by background backup
|
* Checks if multiple assets exist on the server and returns all existing - used by background backup
|
||||||
*/
|
*/
|
||||||
@ -2816,6 +2826,7 @@ export enum JobName {
|
|||||||
FaceDetection = "faceDetection",
|
FaceDetection = "faceDetection",
|
||||||
FacialRecognition = "facialRecognition",
|
FacialRecognition = "facialRecognition",
|
||||||
SmartSearch = "smartSearch",
|
SmartSearch = "smartSearch",
|
||||||
|
DuplicateDetection = "duplicateDetection",
|
||||||
BackgroundTask = "backgroundTask",
|
BackgroundTask = "backgroundTask",
|
||||||
StorageTemplateMigration = "storageTemplateMigration",
|
StorageTemplateMigration = "storageTemplateMigration",
|
||||||
Migration = "migration",
|
Migration = "migration",
|
||||||
|
@ -70,6 +70,11 @@ export class AssetController {
|
|||||||
return this.service.getStatistics(auth, dto);
|
return this.service.getStatistics(auth, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('duplicates')
|
||||||
|
getAssetDuplicates(@Auth() auth: AuthDto): Promise<AssetResponseDto[]> {
|
||||||
|
return this.service.getDuplicates(auth);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('jobs')
|
@Post('jobs')
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
runAssetJobs(@Auth() auth: AuthDto, @Body() dto: AssetJobsDto): Promise<void> {
|
runAssetJobs(@Auth() auth: AuthDto, @Body() dto: AssetJobsDto): Promise<void> {
|
||||||
|
@ -71,6 +71,7 @@ export const defaults = Object.freeze<SystemConfig>({
|
|||||||
clip: {
|
clip: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
modelName: 'ViT-B-32__openai',
|
modelName: 'ViT-B-32__openai',
|
||||||
|
duplicateThreshold: 0.03,
|
||||||
},
|
},
|
||||||
facialRecognition: {
|
facialRecognition: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
@ -73,6 +73,9 @@ export class AllJobStatusResponseDto implements Record<QueueName, JobStatusDto>
|
|||||||
@ApiProperty({ type: JobStatusDto })
|
@ApiProperty({ type: JobStatusDto })
|
||||||
[QueueName.SEARCH]!: JobStatusDto;
|
[QueueName.SEARCH]!: JobStatusDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: JobStatusDto })
|
||||||
|
[QueueName.DUPLICATE_DETECTION]!: JobStatusDto;
|
||||||
|
|
||||||
@ApiProperty({ type: JobStatusDto })
|
@ApiProperty({ type: JobStatusDto })
|
||||||
[QueueName.FACE_DETECTION]!: JobStatusDto;
|
[QueueName.FACE_DETECTION]!: JobStatusDto;
|
||||||
|
|
||||||
|
@ -23,6 +23,13 @@ export class CLIPConfig extends ModelConfig {
|
|||||||
@Optional()
|
@Optional()
|
||||||
@ApiProperty({ enumName: 'CLIPMode', enum: CLIPMode })
|
@ApiProperty({ enumName: 'CLIPMode', enum: CLIPMode })
|
||||||
mode?: CLIPMode;
|
mode?: CLIPMode;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
@Max(0.1)
|
||||||
|
@Type(() => Number)
|
||||||
|
@ApiProperty({ type: 'number', format: 'float' })
|
||||||
|
duplicateThreshold!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RecognitionConfig extends ModelConfig {
|
export class RecognitionConfig extends ModelConfig {
|
||||||
|
16
server/src/entities/asset-duplicate.entity.ts
Normal file
16
server/src/entities/asset-duplicate.entity.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
|
import { Entity, Index, JoinColumn, OneToMany, PrimaryColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('asset_duplicates')
|
||||||
|
@Index('asset_duplicates_assetId_uindex', ['assetId'], { unique: true })
|
||||||
|
export class AssetDuplicateEntity {
|
||||||
|
@OneToMany(() => AssetEntity, (asset) => asset.duplicates, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
|
||||||
|
assets!: AssetEntity;
|
||||||
|
|
||||||
|
@PrimaryColumn()
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@PrimaryColumn()
|
||||||
|
assetId!: string;
|
||||||
|
}
|
@ -15,4 +15,7 @@ export class AssetJobStatusEntity {
|
|||||||
|
|
||||||
@Column({ type: 'timestamptz', nullable: true })
|
@Column({ type: 'timestamptz', nullable: true })
|
||||||
metadataExtractedAt!: Date | null;
|
metadataExtractedAt!: Date | null;
|
||||||
|
|
||||||
|
@Column({ type: 'timestamptz', nullable: true })
|
||||||
|
duplicatesDetectedAt!: Date | null;
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { AlbumEntity } from 'src/entities/album.entity';
|
import { AlbumEntity } from 'src/entities/album.entity';
|
||||||
|
import { AssetDuplicateEntity } from 'src/entities/asset-duplicate.entity';
|
||||||
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
|
||||||
import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
|
import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
|
||||||
import { AssetStackEntity } from 'src/entities/asset-stack.entity';
|
import { AssetStackEntity } from 'src/entities/asset-stack.entity';
|
||||||
@ -168,6 +169,13 @@ export class AssetEntity {
|
|||||||
|
|
||||||
@OneToOne(() => AssetJobStatusEntity, (jobStatus) => jobStatus.asset, { nullable: true })
|
@OneToOne(() => AssetJobStatusEntity, (jobStatus) => jobStatus.asset, { nullable: true })
|
||||||
jobStatus?: AssetJobStatusEntity;
|
jobStatus?: AssetJobStatusEntity;
|
||||||
|
|
||||||
|
@Column({ nullable: true })
|
||||||
|
duplicateId?: string | null;
|
||||||
|
|
||||||
|
@ManyToOne(() => AssetDuplicateEntity, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'duplicateId' })
|
||||||
|
duplicates?: AssetDuplicateEntity | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum AssetType {
|
export enum AssetType {
|
||||||
|
@ -20,12 +20,14 @@ import { SystemMetadataEntity } from 'src/entities/system-metadata.entity';
|
|||||||
import { TagEntity } from 'src/entities/tag.entity';
|
import { TagEntity } from 'src/entities/tag.entity';
|
||||||
import { UserTokenEntity } from 'src/entities/user-token.entity';
|
import { UserTokenEntity } from 'src/entities/user-token.entity';
|
||||||
import { UserEntity } from 'src/entities/user.entity';
|
import { UserEntity } from 'src/entities/user.entity';
|
||||||
|
import { AssetDuplicateEntity } from 'src/entities/asset-duplicate.entity';
|
||||||
|
|
||||||
export const entities = [
|
export const entities = [
|
||||||
ActivityEntity,
|
ActivityEntity,
|
||||||
AlbumEntity,
|
AlbumEntity,
|
||||||
APIKeyEntity,
|
APIKeyEntity,
|
||||||
AssetEntity,
|
AssetEntity,
|
||||||
|
AssetDuplicateEntity,
|
||||||
AssetStackEntity,
|
AssetStackEntity,
|
||||||
AssetFaceEntity,
|
AssetFaceEntity,
|
||||||
AssetJobStatusEntity,
|
AssetJobStatusEntity,
|
||||||
|
@ -11,10 +11,6 @@ export class SmartSearchEntity {
|
|||||||
assetId!: string;
|
assetId!: string;
|
||||||
|
|
||||||
@Index('clip_index', { synchronize: false })
|
@Index('clip_index', { synchronize: false })
|
||||||
@Column({
|
@Column({ type: 'float4', array: true, transformer: { from: (v) => JSON.parse(v), to: (v) => v } })
|
||||||
type: 'float4',
|
|
||||||
array: true,
|
|
||||||
select: false,
|
|
||||||
})
|
|
||||||
embedding!: number[];
|
embedding!: number[];
|
||||||
}
|
}
|
||||||
|
@ -39,7 +39,7 @@ export enum SystemConfigKey {
|
|||||||
JOB_METADATA_EXTRACTION_CONCURRENCY = 'job.metadataExtraction.concurrency',
|
JOB_METADATA_EXTRACTION_CONCURRENCY = 'job.metadataExtraction.concurrency',
|
||||||
JOB_VIDEO_CONVERSION_CONCURRENCY = 'job.videoConversion.concurrency',
|
JOB_VIDEO_CONVERSION_CONCURRENCY = 'job.videoConversion.concurrency',
|
||||||
JOB_FACE_DETECTION_CONCURRENCY = 'job.faceDetection.concurrency',
|
JOB_FACE_DETECTION_CONCURRENCY = 'job.faceDetection.concurrency',
|
||||||
JOB_CLIP_ENCODING_CONCURRENCY = 'job.smartSearch.concurrency',
|
JOB_SMART_SEARCH_CONCURRENCY = 'job.smartSearch.concurrency',
|
||||||
JOB_BACKGROUND_TASK_CONCURRENCY = 'job.backgroundTask.concurrency',
|
JOB_BACKGROUND_TASK_CONCURRENCY = 'job.backgroundTask.concurrency',
|
||||||
JOB_STORAGE_TEMPLATE_MIGRATION_CONCURRENCY = 'job.storageTemplateMigration.concurrency',
|
JOB_STORAGE_TEMPLATE_MIGRATION_CONCURRENCY = 'job.storageTemplateMigration.concurrency',
|
||||||
JOB_SEARCH_CONCURRENCY = 'job.search.concurrency',
|
JOB_SEARCH_CONCURRENCY = 'job.search.concurrency',
|
||||||
@ -208,6 +208,7 @@ export interface SystemConfig {
|
|||||||
clip: {
|
clip: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
modelName: string;
|
modelName: string;
|
||||||
|
duplicateThreshold: number;
|
||||||
};
|
};
|
||||||
facialRecognition: {
|
facialRecognition: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
9
server/src/interfaces/asset-duplicate.interface.ts
Normal file
9
server/src/interfaces/asset-duplicate.interface.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { AssetDuplicateEntity } from 'src/entities/asset-duplicate.entity';
|
||||||
|
|
||||||
|
export const IAssetDuplicateRepository = 'IAssetDuplicateRepository';
|
||||||
|
|
||||||
|
export interface IAssetDuplicateRepository {
|
||||||
|
upsert(id: string, assetIds: string[], oldDuplicateIds?: string[]): Promise<void>;
|
||||||
|
delete(id: string): Promise<void>;
|
||||||
|
getById(id: string): Promise<AssetDuplicateEntity | null>;
|
||||||
|
}
|
@ -40,6 +40,7 @@ export enum WithoutProperty {
|
|||||||
ENCODED_VIDEO = 'encoded-video',
|
ENCODED_VIDEO = 'encoded-video',
|
||||||
EXIF = 'exif',
|
EXIF = 'exif',
|
||||||
SMART_SEARCH = 'smart-search',
|
SMART_SEARCH = 'smart-search',
|
||||||
|
DUPLICATE = 'duplicate',
|
||||||
OBJECT_TAGS = 'object-tags',
|
OBJECT_TAGS = 'object-tags',
|
||||||
FACES = 'faces',
|
FACES = 'faces',
|
||||||
PERSON = 'person',
|
PERSON = 'person',
|
||||||
@ -60,6 +61,7 @@ export interface AssetBuilderOptions {
|
|||||||
isArchived?: boolean;
|
isArchived?: boolean;
|
||||||
isFavorite?: boolean;
|
isFavorite?: boolean;
|
||||||
isTrashed?: boolean;
|
isTrashed?: boolean;
|
||||||
|
isDuplicate?: boolean;
|
||||||
albumId?: string;
|
albumId?: string;
|
||||||
personId?: string;
|
personId?: string;
|
||||||
userIds?: string[];
|
userIds?: string[];
|
||||||
@ -171,8 +173,9 @@ export interface IAssetRepository {
|
|||||||
getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]>;
|
getTimeBuckets(options: TimeBucketOptions): Promise<TimeBucketItem[]>;
|
||||||
getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]>;
|
getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]>;
|
||||||
upsertExif(exif: Partial<ExifEntity>): Promise<void>;
|
upsertExif(exif: Partial<ExifEntity>): Promise<void>;
|
||||||
upsertJobStatus(jobStatus: Partial<AssetJobStatusEntity>): Promise<void>;
|
upsertJobStatus(jobStatus: Partial<AssetJobStatusEntity> | Partial<AssetJobStatusEntity>[]): Promise<void>;
|
||||||
getAssetIdByCity(userId: string, options: AssetExploreFieldOptions): Promise<SearchExploreItem<string>>;
|
getAssetIdByCity(userId: string, options: AssetExploreFieldOptions): Promise<SearchExploreItem<string>>;
|
||||||
getAssetIdByTag(userId: string, options: AssetExploreFieldOptions): Promise<SearchExploreItem<string>>;
|
getAssetIdByTag(userId: string, options: AssetExploreFieldOptions): Promise<SearchExploreItem<string>>;
|
||||||
|
getDuplicates(options: AssetBuilderOptions): Promise<AssetEntity[]>;
|
||||||
searchMetadata(query: string, userIds: string[], options: MetadataSearchOptions): Promise<AssetEntity[]>;
|
searchMetadata(query: string, userIds: string[], options: MetadataSearchOptions): Promise<AssetEntity[]>;
|
||||||
}
|
}
|
||||||
|
@ -5,6 +5,7 @@ export enum QueueName {
|
|||||||
FACE_DETECTION = 'faceDetection',
|
FACE_DETECTION = 'faceDetection',
|
||||||
FACIAL_RECOGNITION = 'facialRecognition',
|
FACIAL_RECOGNITION = 'facialRecognition',
|
||||||
SMART_SEARCH = 'smartSearch',
|
SMART_SEARCH = 'smartSearch',
|
||||||
|
DUPLICATE_DETECTION = 'duplicateDetection',
|
||||||
BACKGROUND_TASK = 'backgroundTask',
|
BACKGROUND_TASK = 'backgroundTask',
|
||||||
STORAGE_TEMPLATE_MIGRATION = 'storageTemplateMigration',
|
STORAGE_TEMPLATE_MIGRATION = 'storageTemplateMigration',
|
||||||
MIGRATION = 'migration',
|
MIGRATION = 'migration',
|
||||||
@ -15,7 +16,7 @@ export enum QueueName {
|
|||||||
|
|
||||||
export type ConcurrentQueueName = Exclude<
|
export type ConcurrentQueueName = Exclude<
|
||||||
QueueName,
|
QueueName,
|
||||||
QueueName.STORAGE_TEMPLATE_MIGRATION | QueueName.FACIAL_RECOGNITION
|
QueueName.STORAGE_TEMPLATE_MIGRATION | QueueName.FACIAL_RECOGNITION | QueueName.DUPLICATE_DETECTION
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export enum JobCommand {
|
export enum JobCommand {
|
||||||
@ -84,6 +85,10 @@ export enum JobName {
|
|||||||
QUEUE_SMART_SEARCH = 'queue-smart-search',
|
QUEUE_SMART_SEARCH = 'queue-smart-search',
|
||||||
SMART_SEARCH = 'smart-search',
|
SMART_SEARCH = 'smart-search',
|
||||||
|
|
||||||
|
// duplicate detection
|
||||||
|
QUEUE_DUPLICATE_DETECTION = 'queue-duplicate-detection',
|
||||||
|
DUPLICATE_DETECTION = 'duplicate-detection',
|
||||||
|
|
||||||
// XMP sidecars
|
// XMP sidecars
|
||||||
QUEUE_SIDECAR = 'queue-sidecar',
|
QUEUE_SIDECAR = 'queue-sidecar',
|
||||||
SIDECAR_DISCOVERY = 'sidecar-discovery',
|
SIDECAR_DISCOVERY = 'sidecar-discovery',
|
||||||
@ -199,6 +204,10 @@ export type JobItem =
|
|||||||
| { name: JobName.QUEUE_SMART_SEARCH; data: IBaseJob }
|
| { name: JobName.QUEUE_SMART_SEARCH; data: IBaseJob }
|
||||||
| { name: JobName.SMART_SEARCH; data: IEntityJob }
|
| { name: JobName.SMART_SEARCH; data: IEntityJob }
|
||||||
|
|
||||||
|
// Duplicate Detection
|
||||||
|
| { name: JobName.QUEUE_DUPLICATE_DETECTION; data: IBaseJob }
|
||||||
|
| { name: JobName.DUPLICATE_DETECTION; data: IEntityJob }
|
||||||
|
|
||||||
// Filesystem
|
// Filesystem
|
||||||
| { name: JobName.DELETE_FILES; data: IDeleteFilesJob }
|
| { name: JobName.DELETE_FILES; data: IDeleteFilesJob }
|
||||||
|
|
||||||
|
@ -177,15 +177,29 @@ export interface FaceEmbeddingSearch extends SearchEmbeddingOptions {
|
|||||||
maxDistance?: number;
|
maxDistance?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AssetDuplicateSearch {
|
||||||
|
assetId: string;
|
||||||
|
embedding: Embedding;
|
||||||
|
userIds: string[];
|
||||||
|
maxDistance?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FaceSearchResult {
|
export interface FaceSearchResult {
|
||||||
distance: number;
|
distance: number;
|
||||||
face: AssetFaceEntity;
|
face: AssetFaceEntity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AssetDuplicateResult {
|
||||||
|
assetId: string;
|
||||||
|
duplicateId: string;
|
||||||
|
distance: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ISearchRepository {
|
export interface ISearchRepository {
|
||||||
init(modelName: string): Promise<void>;
|
init(modelName: string): Promise<void>;
|
||||||
searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated<AssetEntity>;
|
searchMetadata(pagination: SearchPaginationOptions, options: AssetSearchOptions): Paginated<AssetEntity>;
|
||||||
searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated<AssetEntity>;
|
searchSmart(pagination: SearchPaginationOptions, options: SmartSearchOptions): Paginated<AssetEntity>;
|
||||||
|
searchDuplicates(options: AssetDuplicateSearch): Promise<AssetDuplicateResult[]>;
|
||||||
searchFaces(search: FaceEmbeddingSearch): Promise<FaceSearchResult[]>;
|
searchFaces(search: FaceEmbeddingSearch): Promise<FaceSearchResult[]>;
|
||||||
upsert(assetId: string, embedding: number[]): Promise<void>;
|
upsert(assetId: string, embedding: number[]): Promise<void>;
|
||||||
searchPlaces(placeName: string): Promise<GeodataPlacesEntity[]>;
|
searchPlaces(placeName: string): Promise<GeodataPlacesEntity[]>;
|
||||||
|
@ -0,0 +1,36 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class CreateAssetDuplicateTable1711989989911 implements MigrationInterface {
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE asset_duplicates (
|
||||||
|
id uuid,
|
||||||
|
"assetId" uuid REFERENCES assets ON UPDATE CASCADE ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (id, "assetId")
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE assets ADD COLUMN "duplicateId" uuid`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE asset_job_status ADD COLUMN "duplicatesDetectedAt" timestamptz`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE assets
|
||||||
|
ADD CONSTRAINT asset_duplicates_id
|
||||||
|
FOREIGN KEY ("duplicateId", id)
|
||||||
|
REFERENCES asset_duplicates DEFERRABLE INITIALLY DEFERRED
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX "asset_duplicates_assetId_uindex"
|
||||||
|
ON asset_duplicates ("assetId")
|
||||||
|
`);
|
||||||
|
|
||||||
|
await queryRunner.query(`CREATE INDEX "IDX_assets_duplicateId" ON assets ("duplicateId")`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`ALTER TABLE assets DROP COLUMN "duplicateId"`);
|
||||||
|
await queryRunner.query(`DROP TABLE asset_duplicates`);
|
||||||
|
}
|
||||||
|
}
|
@ -31,6 +31,7 @@ SELECT
|
|||||||
"entity"."originalFileName" AS "entity_originalFileName",
|
"entity"."originalFileName" AS "entity_originalFileName",
|
||||||
"entity"."sidecarPath" AS "entity_sidecarPath",
|
"entity"."sidecarPath" AS "entity_sidecarPath",
|
||||||
"entity"."stackId" AS "entity_stackId",
|
"entity"."stackId" AS "entity_stackId",
|
||||||
|
"entity"."duplicateId" AS "entity_duplicateId",
|
||||||
"exifInfo"."assetId" AS "exifInfo_assetId",
|
"exifInfo"."assetId" AS "exifInfo_assetId",
|
||||||
"exifInfo"."description" AS "exifInfo_description",
|
"exifInfo"."description" AS "exifInfo_description",
|
||||||
"exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth",
|
"exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth",
|
||||||
@ -113,7 +114,8 @@ SELECT
|
|||||||
"AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId",
|
"AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId",
|
||||||
"AssetEntity"."originalFileName" AS "AssetEntity_originalFileName",
|
"AssetEntity"."originalFileName" AS "AssetEntity_originalFileName",
|
||||||
"AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath",
|
"AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath",
|
||||||
"AssetEntity"."stackId" AS "AssetEntity_stackId"
|
"AssetEntity"."stackId" AS "AssetEntity_stackId",
|
||||||
|
"AssetEntity"."duplicateId" AS "AssetEntity_duplicateId"
|
||||||
FROM
|
FROM
|
||||||
"assets" "AssetEntity"
|
"assets" "AssetEntity"
|
||||||
WHERE
|
WHERE
|
||||||
@ -150,6 +152,7 @@ SELECT
|
|||||||
"AssetEntity"."originalFileName" AS "AssetEntity_originalFileName",
|
"AssetEntity"."originalFileName" AS "AssetEntity_originalFileName",
|
||||||
"AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath",
|
"AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath",
|
||||||
"AssetEntity"."stackId" AS "AssetEntity_stackId",
|
"AssetEntity"."stackId" AS "AssetEntity_stackId",
|
||||||
|
"AssetEntity"."duplicateId" AS "AssetEntity_duplicateId",
|
||||||
"AssetEntity__AssetEntity_exifInfo"."assetId" AS "AssetEntity__AssetEntity_exifInfo_assetId",
|
"AssetEntity__AssetEntity_exifInfo"."assetId" AS "AssetEntity__AssetEntity_exifInfo_assetId",
|
||||||
"AssetEntity__AssetEntity_exifInfo"."description" AS "AssetEntity__AssetEntity_exifInfo_description",
|
"AssetEntity__AssetEntity_exifInfo"."description" AS "AssetEntity__AssetEntity_exifInfo_description",
|
||||||
"AssetEntity__AssetEntity_exifInfo"."exifImageWidth" AS "AssetEntity__AssetEntity_exifInfo_exifImageWidth",
|
"AssetEntity__AssetEntity_exifInfo"."exifImageWidth" AS "AssetEntity__AssetEntity_exifInfo_exifImageWidth",
|
||||||
@ -234,7 +237,8 @@ SELECT
|
|||||||
"bd93d5747511a4dad4923546c51365bf1a803774"."livePhotoVideoId" AS "bd93d5747511a4dad4923546c51365bf1a803774_livePhotoVideoId",
|
"bd93d5747511a4dad4923546c51365bf1a803774"."livePhotoVideoId" AS "bd93d5747511a4dad4923546c51365bf1a803774_livePhotoVideoId",
|
||||||
"bd93d5747511a4dad4923546c51365bf1a803774"."originalFileName" AS "bd93d5747511a4dad4923546c51365bf1a803774_originalFileName",
|
"bd93d5747511a4dad4923546c51365bf1a803774"."originalFileName" AS "bd93d5747511a4dad4923546c51365bf1a803774_originalFileName",
|
||||||
"bd93d5747511a4dad4923546c51365bf1a803774"."sidecarPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_sidecarPath",
|
"bd93d5747511a4dad4923546c51365bf1a803774"."sidecarPath" AS "bd93d5747511a4dad4923546c51365bf1a803774_sidecarPath",
|
||||||
"bd93d5747511a4dad4923546c51365bf1a803774"."stackId" AS "bd93d5747511a4dad4923546c51365bf1a803774_stackId"
|
"bd93d5747511a4dad4923546c51365bf1a803774"."stackId" AS "bd93d5747511a4dad4923546c51365bf1a803774_stackId",
|
||||||
|
"bd93d5747511a4dad4923546c51365bf1a803774"."duplicateId" AS "bd93d5747511a4dad4923546c51365bf1a803774_duplicateId"
|
||||||
FROM
|
FROM
|
||||||
"assets" "AssetEntity"
|
"assets" "AssetEntity"
|
||||||
LEFT JOIN "exif" "AssetEntity__AssetEntity_exifInfo" ON "AssetEntity__AssetEntity_exifInfo"."assetId" = "AssetEntity"."id"
|
LEFT JOIN "exif" "AssetEntity__AssetEntity_exifInfo" ON "AssetEntity__AssetEntity_exifInfo"."assetId" = "AssetEntity"."id"
|
||||||
@ -315,7 +319,8 @@ FROM
|
|||||||
"AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId",
|
"AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId",
|
||||||
"AssetEntity"."originalFileName" AS "AssetEntity_originalFileName",
|
"AssetEntity"."originalFileName" AS "AssetEntity_originalFileName",
|
||||||
"AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath",
|
"AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath",
|
||||||
"AssetEntity"."stackId" AS "AssetEntity_stackId"
|
"AssetEntity"."stackId" AS "AssetEntity_stackId",
|
||||||
|
"AssetEntity"."duplicateId" AS "AssetEntity_duplicateId"
|
||||||
FROM
|
FROM
|
||||||
"assets" "AssetEntity"
|
"assets" "AssetEntity"
|
||||||
LEFT JOIN "libraries" "AssetEntity__AssetEntity_library" ON "AssetEntity__AssetEntity_library"."id" = "AssetEntity"."libraryId"
|
LEFT JOIN "libraries" "AssetEntity__AssetEntity_library" ON "AssetEntity__AssetEntity_library"."id" = "AssetEntity"."libraryId"
|
||||||
@ -412,7 +417,8 @@ SELECT
|
|||||||
"AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId",
|
"AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId",
|
||||||
"AssetEntity"."originalFileName" AS "AssetEntity_originalFileName",
|
"AssetEntity"."originalFileName" AS "AssetEntity_originalFileName",
|
||||||
"AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath",
|
"AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath",
|
||||||
"AssetEntity"."stackId" AS "AssetEntity_stackId"
|
"AssetEntity"."stackId" AS "AssetEntity_stackId",
|
||||||
|
"AssetEntity"."duplicateId" AS "AssetEntity_duplicateId"
|
||||||
FROM
|
FROM
|
||||||
"assets" "AssetEntity"
|
"assets" "AssetEntity"
|
||||||
WHERE
|
WHERE
|
||||||
@ -458,7 +464,8 @@ SELECT
|
|||||||
"AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId",
|
"AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId",
|
||||||
"AssetEntity"."originalFileName" AS "AssetEntity_originalFileName",
|
"AssetEntity"."originalFileName" AS "AssetEntity_originalFileName",
|
||||||
"AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath",
|
"AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath",
|
||||||
"AssetEntity"."stackId" AS "AssetEntity_stackId"
|
"AssetEntity"."stackId" AS "AssetEntity_stackId",
|
||||||
|
"AssetEntity"."duplicateId" AS "AssetEntity_duplicateId"
|
||||||
FROM
|
FROM
|
||||||
"assets" "AssetEntity"
|
"assets" "AssetEntity"
|
||||||
WHERE
|
WHERE
|
||||||
@ -502,7 +509,8 @@ SELECT
|
|||||||
"AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId",
|
"AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId",
|
||||||
"AssetEntity"."originalFileName" AS "AssetEntity_originalFileName",
|
"AssetEntity"."originalFileName" AS "AssetEntity_originalFileName",
|
||||||
"AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath",
|
"AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath",
|
||||||
"AssetEntity"."stackId" AS "AssetEntity_stackId"
|
"AssetEntity"."stackId" AS "AssetEntity_stackId",
|
||||||
|
"AssetEntity"."duplicateId" AS "AssetEntity_duplicateId"
|
||||||
FROM
|
FROM
|
||||||
"assets" "AssetEntity"
|
"assets" "AssetEntity"
|
||||||
WHERE
|
WHERE
|
||||||
@ -592,6 +600,7 @@ SELECT
|
|||||||
"asset"."originalFileName" AS "asset_originalFileName",
|
"asset"."originalFileName" AS "asset_originalFileName",
|
||||||
"asset"."sidecarPath" AS "asset_sidecarPath",
|
"asset"."sidecarPath" AS "asset_sidecarPath",
|
||||||
"asset"."stackId" AS "asset_stackId",
|
"asset"."stackId" AS "asset_stackId",
|
||||||
|
"asset"."duplicateId" AS "asset_duplicateId",
|
||||||
"exifInfo"."assetId" AS "exifInfo_assetId",
|
"exifInfo"."assetId" AS "exifInfo_assetId",
|
||||||
"exifInfo"."description" AS "exifInfo_description",
|
"exifInfo"."description" AS "exifInfo_description",
|
||||||
"exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth",
|
"exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth",
|
||||||
@ -650,7 +659,8 @@ SELECT
|
|||||||
"stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId",
|
"stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId",
|
||||||
"stackedAssets"."originalFileName" AS "stackedAssets_originalFileName",
|
"stackedAssets"."originalFileName" AS "stackedAssets_originalFileName",
|
||||||
"stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath",
|
"stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath",
|
||||||
"stackedAssets"."stackId" AS "stackedAssets_stackId"
|
"stackedAssets"."stackId" AS "stackedAssets_stackId",
|
||||||
|
"stackedAssets"."duplicateId" AS "stackedAssets_duplicateId"
|
||||||
FROM
|
FROM
|
||||||
"assets" "asset"
|
"assets" "asset"
|
||||||
LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id"
|
LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id"
|
||||||
@ -677,6 +687,114 @@ ORDER BY
|
|||||||
)::timestamptz DESC,
|
)::timestamptz DESC,
|
||||||
"asset"."fileCreatedAt" DESC
|
"asset"."fileCreatedAt" DESC
|
||||||
|
|
||||||
|
-- AssetRepository.getDuplicates
|
||||||
|
SELECT
|
||||||
|
"asset"."id" AS "asset_id",
|
||||||
|
"asset"."deviceAssetId" AS "asset_deviceAssetId",
|
||||||
|
"asset"."ownerId" AS "asset_ownerId",
|
||||||
|
"asset"."libraryId" AS "asset_libraryId",
|
||||||
|
"asset"."deviceId" AS "asset_deviceId",
|
||||||
|
"asset"."type" AS "asset_type",
|
||||||
|
"asset"."originalPath" AS "asset_originalPath",
|
||||||
|
"asset"."resizePath" AS "asset_resizePath",
|
||||||
|
"asset"."webpPath" AS "asset_webpPath",
|
||||||
|
"asset"."thumbhash" AS "asset_thumbhash",
|
||||||
|
"asset"."encodedVideoPath" AS "asset_encodedVideoPath",
|
||||||
|
"asset"."createdAt" AS "asset_createdAt",
|
||||||
|
"asset"."updatedAt" AS "asset_updatedAt",
|
||||||
|
"asset"."deletedAt" AS "asset_deletedAt",
|
||||||
|
"asset"."fileCreatedAt" AS "asset_fileCreatedAt",
|
||||||
|
"asset"."localDateTime" AS "asset_localDateTime",
|
||||||
|
"asset"."fileModifiedAt" AS "asset_fileModifiedAt",
|
||||||
|
"asset"."isFavorite" AS "asset_isFavorite",
|
||||||
|
"asset"."isArchived" AS "asset_isArchived",
|
||||||
|
"asset"."isExternal" AS "asset_isExternal",
|
||||||
|
"asset"."isReadOnly" AS "asset_isReadOnly",
|
||||||
|
"asset"."isOffline" AS "asset_isOffline",
|
||||||
|
"asset"."checksum" AS "asset_checksum",
|
||||||
|
"asset"."duration" AS "asset_duration",
|
||||||
|
"asset"."isVisible" AS "asset_isVisible",
|
||||||
|
"asset"."livePhotoVideoId" AS "asset_livePhotoVideoId",
|
||||||
|
"asset"."originalFileName" AS "asset_originalFileName",
|
||||||
|
"asset"."sidecarPath" AS "asset_sidecarPath",
|
||||||
|
"asset"."stackId" AS "asset_stackId",
|
||||||
|
"asset"."duplicateId" AS "asset_duplicateId",
|
||||||
|
"exifInfo"."assetId" AS "exifInfo_assetId",
|
||||||
|
"exifInfo"."description" AS "exifInfo_description",
|
||||||
|
"exifInfo"."exifImageWidth" AS "exifInfo_exifImageWidth",
|
||||||
|
"exifInfo"."exifImageHeight" AS "exifInfo_exifImageHeight",
|
||||||
|
"exifInfo"."fileSizeInByte" AS "exifInfo_fileSizeInByte",
|
||||||
|
"exifInfo"."orientation" AS "exifInfo_orientation",
|
||||||
|
"exifInfo"."dateTimeOriginal" AS "exifInfo_dateTimeOriginal",
|
||||||
|
"exifInfo"."modifyDate" AS "exifInfo_modifyDate",
|
||||||
|
"exifInfo"."timeZone" AS "exifInfo_timeZone",
|
||||||
|
"exifInfo"."latitude" AS "exifInfo_latitude",
|
||||||
|
"exifInfo"."longitude" AS "exifInfo_longitude",
|
||||||
|
"exifInfo"."projectionType" AS "exifInfo_projectionType",
|
||||||
|
"exifInfo"."city" AS "exifInfo_city",
|
||||||
|
"exifInfo"."livePhotoCID" AS "exifInfo_livePhotoCID",
|
||||||
|
"exifInfo"."autoStackId" AS "exifInfo_autoStackId",
|
||||||
|
"exifInfo"."state" AS "exifInfo_state",
|
||||||
|
"exifInfo"."country" AS "exifInfo_country",
|
||||||
|
"exifInfo"."make" AS "exifInfo_make",
|
||||||
|
"exifInfo"."model" AS "exifInfo_model",
|
||||||
|
"exifInfo"."lensModel" AS "exifInfo_lensModel",
|
||||||
|
"exifInfo"."fNumber" AS "exifInfo_fNumber",
|
||||||
|
"exifInfo"."focalLength" AS "exifInfo_focalLength",
|
||||||
|
"exifInfo"."iso" AS "exifInfo_iso",
|
||||||
|
"exifInfo"."exposureTime" AS "exifInfo_exposureTime",
|
||||||
|
"exifInfo"."profileDescription" AS "exifInfo_profileDescription",
|
||||||
|
"exifInfo"."colorspace" AS "exifInfo_colorspace",
|
||||||
|
"exifInfo"."bitsPerSample" AS "exifInfo_bitsPerSample",
|
||||||
|
"exifInfo"."fps" AS "exifInfo_fps",
|
||||||
|
"stack"."id" AS "stack_id",
|
||||||
|
"stack"."primaryAssetId" AS "stack_primaryAssetId",
|
||||||
|
"stackedAssets"."id" AS "stackedAssets_id",
|
||||||
|
"stackedAssets"."deviceAssetId" AS "stackedAssets_deviceAssetId",
|
||||||
|
"stackedAssets"."ownerId" AS "stackedAssets_ownerId",
|
||||||
|
"stackedAssets"."libraryId" AS "stackedAssets_libraryId",
|
||||||
|
"stackedAssets"."deviceId" AS "stackedAssets_deviceId",
|
||||||
|
"stackedAssets"."type" AS "stackedAssets_type",
|
||||||
|
"stackedAssets"."originalPath" AS "stackedAssets_originalPath",
|
||||||
|
"stackedAssets"."resizePath" AS "stackedAssets_resizePath",
|
||||||
|
"stackedAssets"."webpPath" AS "stackedAssets_webpPath",
|
||||||
|
"stackedAssets"."thumbhash" AS "stackedAssets_thumbhash",
|
||||||
|
"stackedAssets"."encodedVideoPath" AS "stackedAssets_encodedVideoPath",
|
||||||
|
"stackedAssets"."createdAt" AS "stackedAssets_createdAt",
|
||||||
|
"stackedAssets"."updatedAt" AS "stackedAssets_updatedAt",
|
||||||
|
"stackedAssets"."deletedAt" AS "stackedAssets_deletedAt",
|
||||||
|
"stackedAssets"."fileCreatedAt" AS "stackedAssets_fileCreatedAt",
|
||||||
|
"stackedAssets"."localDateTime" AS "stackedAssets_localDateTime",
|
||||||
|
"stackedAssets"."fileModifiedAt" AS "stackedAssets_fileModifiedAt",
|
||||||
|
"stackedAssets"."isFavorite" AS "stackedAssets_isFavorite",
|
||||||
|
"stackedAssets"."isArchived" AS "stackedAssets_isArchived",
|
||||||
|
"stackedAssets"."isExternal" AS "stackedAssets_isExternal",
|
||||||
|
"stackedAssets"."isReadOnly" AS "stackedAssets_isReadOnly",
|
||||||
|
"stackedAssets"."isOffline" AS "stackedAssets_isOffline",
|
||||||
|
"stackedAssets"."checksum" AS "stackedAssets_checksum",
|
||||||
|
"stackedAssets"."duration" AS "stackedAssets_duration",
|
||||||
|
"stackedAssets"."isVisible" AS "stackedAssets_isVisible",
|
||||||
|
"stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId",
|
||||||
|
"stackedAssets"."originalFileName" AS "stackedAssets_originalFileName",
|
||||||
|
"stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath",
|
||||||
|
"stackedAssets"."stackId" AS "stackedAssets_stackId",
|
||||||
|
"stackedAssets"."duplicateId" AS "stackedAssets_duplicateId"
|
||||||
|
FROM
|
||||||
|
"assets" "asset"
|
||||||
|
LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id"
|
||||||
|
LEFT JOIN "asset_stack" "stack" ON "stack"."id" = "asset"."stackId"
|
||||||
|
LEFT JOIN "assets" "stackedAssets" ON "stackedAssets"."stackId" = "stack"."id"
|
||||||
|
AND ("stackedAssets"."deletedAt" IS NULL)
|
||||||
|
WHERE
|
||||||
|
(
|
||||||
|
"asset"."isVisible" = true
|
||||||
|
AND "asset"."ownerId" IN ($1, $2)
|
||||||
|
AND "asset"."duplicateId" IS NOT NULL
|
||||||
|
)
|
||||||
|
AND ("asset"."deletedAt" IS NULL)
|
||||||
|
ORDER BY
|
||||||
|
"asset"."duplicateId" ASC
|
||||||
|
|
||||||
-- AssetRepository.getAssetIdByCity
|
-- AssetRepository.getAssetIdByCity
|
||||||
WITH
|
WITH
|
||||||
"cities" AS (
|
"cities" AS (
|
||||||
|
@ -173,7 +173,8 @@ FROM
|
|||||||
"AssetFaceEntity__AssetFaceEntity_asset"."livePhotoVideoId" AS "AssetFaceEntity__AssetFaceEntity_asset_livePhotoVideoId",
|
"AssetFaceEntity__AssetFaceEntity_asset"."livePhotoVideoId" AS "AssetFaceEntity__AssetFaceEntity_asset_livePhotoVideoId",
|
||||||
"AssetFaceEntity__AssetFaceEntity_asset"."originalFileName" AS "AssetFaceEntity__AssetFaceEntity_asset_originalFileName",
|
"AssetFaceEntity__AssetFaceEntity_asset"."originalFileName" AS "AssetFaceEntity__AssetFaceEntity_asset_originalFileName",
|
||||||
"AssetFaceEntity__AssetFaceEntity_asset"."sidecarPath" AS "AssetFaceEntity__AssetFaceEntity_asset_sidecarPath",
|
"AssetFaceEntity__AssetFaceEntity_asset"."sidecarPath" AS "AssetFaceEntity__AssetFaceEntity_asset_sidecarPath",
|
||||||
"AssetFaceEntity__AssetFaceEntity_asset"."stackId" AS "AssetFaceEntity__AssetFaceEntity_asset_stackId"
|
"AssetFaceEntity__AssetFaceEntity_asset"."stackId" AS "AssetFaceEntity__AssetFaceEntity_asset_stackId",
|
||||||
|
"AssetFaceEntity__AssetFaceEntity_asset"."duplicateId" AS "AssetFaceEntity__AssetFaceEntity_asset_duplicateId"
|
||||||
FROM
|
FROM
|
||||||
"asset_faces" "AssetFaceEntity"
|
"asset_faces" "AssetFaceEntity"
|
||||||
LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId"
|
LEFT JOIN "person" "AssetFaceEntity__AssetFaceEntity_person" ON "AssetFaceEntity__AssetFaceEntity_person"."id" = "AssetFaceEntity"."personId"
|
||||||
@ -272,6 +273,7 @@ FROM
|
|||||||
"AssetEntity"."originalFileName" AS "AssetEntity_originalFileName",
|
"AssetEntity"."originalFileName" AS "AssetEntity_originalFileName",
|
||||||
"AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath",
|
"AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath",
|
||||||
"AssetEntity"."stackId" AS "AssetEntity_stackId",
|
"AssetEntity"."stackId" AS "AssetEntity_stackId",
|
||||||
|
"AssetEntity"."duplicateId" AS "AssetEntity_duplicateId",
|
||||||
"AssetEntity__AssetEntity_faces"."id" AS "AssetEntity__AssetEntity_faces_id",
|
"AssetEntity__AssetEntity_faces"."id" AS "AssetEntity__AssetEntity_faces_id",
|
||||||
"AssetEntity__AssetEntity_faces"."assetId" AS "AssetEntity__AssetEntity_faces_assetId",
|
"AssetEntity__AssetEntity_faces"."assetId" AS "AssetEntity__AssetEntity_faces_assetId",
|
||||||
"AssetEntity__AssetEntity_faces"."personId" AS "AssetEntity__AssetEntity_faces_personId",
|
"AssetEntity__AssetEntity_faces"."personId" AS "AssetEntity__AssetEntity_faces_personId",
|
||||||
@ -401,7 +403,8 @@ SELECT
|
|||||||
"AssetFaceEntity__AssetFaceEntity_asset"."livePhotoVideoId" AS "AssetFaceEntity__AssetFaceEntity_asset_livePhotoVideoId",
|
"AssetFaceEntity__AssetFaceEntity_asset"."livePhotoVideoId" AS "AssetFaceEntity__AssetFaceEntity_asset_livePhotoVideoId",
|
||||||
"AssetFaceEntity__AssetFaceEntity_asset"."originalFileName" AS "AssetFaceEntity__AssetFaceEntity_asset_originalFileName",
|
"AssetFaceEntity__AssetFaceEntity_asset"."originalFileName" AS "AssetFaceEntity__AssetFaceEntity_asset_originalFileName",
|
||||||
"AssetFaceEntity__AssetFaceEntity_asset"."sidecarPath" AS "AssetFaceEntity__AssetFaceEntity_asset_sidecarPath",
|
"AssetFaceEntity__AssetFaceEntity_asset"."sidecarPath" AS "AssetFaceEntity__AssetFaceEntity_asset_sidecarPath",
|
||||||
"AssetFaceEntity__AssetFaceEntity_asset"."stackId" AS "AssetFaceEntity__AssetFaceEntity_asset_stackId"
|
"AssetFaceEntity__AssetFaceEntity_asset"."stackId" AS "AssetFaceEntity__AssetFaceEntity_asset_stackId",
|
||||||
|
"AssetFaceEntity__AssetFaceEntity_asset"."duplicateId" AS "AssetFaceEntity__AssetFaceEntity_asset_duplicateId"
|
||||||
FROM
|
FROM
|
||||||
"asset_faces" "AssetFaceEntity"
|
"asset_faces" "AssetFaceEntity"
|
||||||
LEFT JOIN "assets" "AssetFaceEntity__AssetFaceEntity_asset" ON "AssetFaceEntity__AssetFaceEntity_asset"."id" = "AssetFaceEntity"."assetId"
|
LEFT JOIN "assets" "AssetFaceEntity__AssetFaceEntity_asset" ON "AssetFaceEntity__AssetFaceEntity_asset"."id" = "AssetFaceEntity"."assetId"
|
||||||
|
@ -36,6 +36,7 @@ FROM
|
|||||||
"asset"."originalFileName" AS "asset_originalFileName",
|
"asset"."originalFileName" AS "asset_originalFileName",
|
||||||
"asset"."sidecarPath" AS "asset_sidecarPath",
|
"asset"."sidecarPath" AS "asset_sidecarPath",
|
||||||
"asset"."stackId" AS "asset_stackId",
|
"asset"."stackId" AS "asset_stackId",
|
||||||
|
"asset"."duplicateId" AS "asset_duplicateId",
|
||||||
"stack"."id" AS "stack_id",
|
"stack"."id" AS "stack_id",
|
||||||
"stack"."primaryAssetId" AS "stack_primaryAssetId",
|
"stack"."primaryAssetId" AS "stack_primaryAssetId",
|
||||||
"stackedAssets"."id" AS "stackedAssets_id",
|
"stackedAssets"."id" AS "stackedAssets_id",
|
||||||
@ -66,7 +67,8 @@ FROM
|
|||||||
"stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId",
|
"stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId",
|
||||||
"stackedAssets"."originalFileName" AS "stackedAssets_originalFileName",
|
"stackedAssets"."originalFileName" AS "stackedAssets_originalFileName",
|
||||||
"stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath",
|
"stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath",
|
||||||
"stackedAssets"."stackId" AS "stackedAssets_stackId"
|
"stackedAssets"."stackId" AS "stackedAssets_stackId",
|
||||||
|
"stackedAssets"."duplicateId" AS "stackedAssets_duplicateId"
|
||||||
FROM
|
FROM
|
||||||
"assets" "asset"
|
"assets" "asset"
|
||||||
LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id"
|
LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id"
|
||||||
@ -132,6 +134,7 @@ SELECT
|
|||||||
"asset"."originalFileName" AS "asset_originalFileName",
|
"asset"."originalFileName" AS "asset_originalFileName",
|
||||||
"asset"."sidecarPath" AS "asset_sidecarPath",
|
"asset"."sidecarPath" AS "asset_sidecarPath",
|
||||||
"asset"."stackId" AS "asset_stackId",
|
"asset"."stackId" AS "asset_stackId",
|
||||||
|
"asset"."duplicateId" AS "asset_duplicateId",
|
||||||
"stack"."id" AS "stack_id",
|
"stack"."id" AS "stack_id",
|
||||||
"stack"."primaryAssetId" AS "stack_primaryAssetId",
|
"stack"."primaryAssetId" AS "stack_primaryAssetId",
|
||||||
"stackedAssets"."id" AS "stackedAssets_id",
|
"stackedAssets"."id" AS "stackedAssets_id",
|
||||||
@ -162,7 +165,8 @@ SELECT
|
|||||||
"stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId",
|
"stackedAssets"."livePhotoVideoId" AS "stackedAssets_livePhotoVideoId",
|
||||||
"stackedAssets"."originalFileName" AS "stackedAssets_originalFileName",
|
"stackedAssets"."originalFileName" AS "stackedAssets_originalFileName",
|
||||||
"stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath",
|
"stackedAssets"."sidecarPath" AS "stackedAssets_sidecarPath",
|
||||||
"stackedAssets"."stackId" AS "stackedAssets_stackId"
|
"stackedAssets"."stackId" AS "stackedAssets_stackId",
|
||||||
|
"stackedAssets"."duplicateId" AS "stackedAssets_duplicateId"
|
||||||
FROM
|
FROM
|
||||||
"assets" "asset"
|
"assets" "asset"
|
||||||
LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id"
|
LEFT JOIN "exif" "exifInfo" ON "exifInfo"."assetId" = "asset"."id"
|
||||||
@ -189,6 +193,49 @@ LIMIT
|
|||||||
101
|
101
|
||||||
COMMIT
|
COMMIT
|
||||||
|
|
||||||
|
-- SearchRepository.searchDuplicates
|
||||||
|
WITH
|
||||||
|
"cte" AS (
|
||||||
|
SELECT
|
||||||
|
"asset"."duplicateId" AS "duplicateId",
|
||||||
|
"search"."assetId" AS "assetId",
|
||||||
|
(
|
||||||
|
SELECT
|
||||||
|
embedding
|
||||||
|
FROM
|
||||||
|
smart_search
|
||||||
|
WHERE
|
||||||
|
"assetId" = $1
|
||||||
|
) <= > "search"."embedding" AS "distance"
|
||||||
|
FROM
|
||||||
|
"assets" "asset"
|
||||||
|
INNER JOIN "smart_search" "search" ON "search"."assetId" = "asset"."id"
|
||||||
|
WHERE
|
||||||
|
(
|
||||||
|
"asset"."ownerId" IN ($2)
|
||||||
|
AND "asset"."id" != $1
|
||||||
|
AND "asset"."isVisible" = $3
|
||||||
|
)
|
||||||
|
AND ("asset"."deletedAt" IS NULL)
|
||||||
|
ORDER BY
|
||||||
|
"search"."embedding" <= > (
|
||||||
|
SELECT
|
||||||
|
embedding
|
||||||
|
FROM
|
||||||
|
smart_search
|
||||||
|
WHERE
|
||||||
|
"assetId" = $1
|
||||||
|
) ASC
|
||||||
|
LIMIT
|
||||||
|
64
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
res.*
|
||||||
|
FROM
|
||||||
|
"cte" "res"
|
||||||
|
WHERE
|
||||||
|
res.distance <= $4
|
||||||
|
|
||||||
-- SearchRepository.searchFaces
|
-- SearchRepository.searchFaces
|
||||||
START TRANSACTION
|
START TRANSACTION
|
||||||
SET
|
SET
|
||||||
@ -342,6 +389,7 @@ SELECT
|
|||||||
"asset"."originalFileName" AS "asset_originalFileName",
|
"asset"."originalFileName" AS "asset_originalFileName",
|
||||||
"asset"."sidecarPath" AS "asset_sidecarPath",
|
"asset"."sidecarPath" AS "asset_sidecarPath",
|
||||||
"asset"."stackId" AS "asset_stackId",
|
"asset"."stackId" AS "asset_stackId",
|
||||||
|
"asset"."duplicateId" AS "asset_duplicateId",
|
||||||
"exif"."assetId" AS "exif_assetId",
|
"exif"."assetId" AS "exif_assetId",
|
||||||
"exif"."description" AS "exif_description",
|
"exif"."description" AS "exif_description",
|
||||||
"exif"."exifImageWidth" AS "exif_exifImageWidth",
|
"exif"."exifImageWidth" AS "exif_exifImageWidth",
|
||||||
|
@ -50,6 +50,7 @@ FROM
|
|||||||
"SharedLinkEntity__SharedLinkEntity_assets"."originalFileName" AS "SharedLinkEntity__SharedLinkEntity_assets_originalFileName",
|
"SharedLinkEntity__SharedLinkEntity_assets"."originalFileName" AS "SharedLinkEntity__SharedLinkEntity_assets_originalFileName",
|
||||||
"SharedLinkEntity__SharedLinkEntity_assets"."sidecarPath" AS "SharedLinkEntity__SharedLinkEntity_assets_sidecarPath",
|
"SharedLinkEntity__SharedLinkEntity_assets"."sidecarPath" AS "SharedLinkEntity__SharedLinkEntity_assets_sidecarPath",
|
||||||
"SharedLinkEntity__SharedLinkEntity_assets"."stackId" AS "SharedLinkEntity__SharedLinkEntity_assets_stackId",
|
"SharedLinkEntity__SharedLinkEntity_assets"."stackId" AS "SharedLinkEntity__SharedLinkEntity_assets_stackId",
|
||||||
|
"SharedLinkEntity__SharedLinkEntity_assets"."duplicateId" AS "SharedLinkEntity__SharedLinkEntity_assets_duplicateId",
|
||||||
"9b1d35b344d838023994a3233afd6ffe098be6d8"."assetId" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_assetId",
|
"9b1d35b344d838023994a3233afd6ffe098be6d8"."assetId" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_assetId",
|
||||||
"9b1d35b344d838023994a3233afd6ffe098be6d8"."description" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_description",
|
"9b1d35b344d838023994a3233afd6ffe098be6d8"."description" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_description",
|
||||||
"9b1d35b344d838023994a3233afd6ffe098be6d8"."exifImageWidth" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_exifImageWidth",
|
"9b1d35b344d838023994a3233afd6ffe098be6d8"."exifImageWidth" AS "9b1d35b344d838023994a3233afd6ffe098be6d8_exifImageWidth",
|
||||||
@ -117,6 +118,7 @@ FROM
|
|||||||
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."originalFileName" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_originalFileName",
|
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."originalFileName" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_originalFileName",
|
||||||
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."sidecarPath" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_sidecarPath",
|
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."sidecarPath" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_sidecarPath",
|
||||||
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."stackId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_stackId",
|
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."stackId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_stackId",
|
||||||
|
"4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6"."duplicateId" AS "4a35f463ae8c5544ede95c4b6d9ce8c686b6bfe6_duplicateId",
|
||||||
"d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."assetId" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_assetId",
|
"d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."assetId" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_assetId",
|
||||||
"d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."description" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_description",
|
"d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."description" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_description",
|
||||||
"d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."exifImageWidth" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_exifImageWidth",
|
"d9f2f4dd8920bad1d6907cdb1d699732daff3c2f"."exifImageWidth" AS "d9f2f4dd8920bad1d6907cdb1d699732daff3c2f_exifImageWidth",
|
||||||
@ -240,6 +242,7 @@ SELECT
|
|||||||
"SharedLinkEntity__SharedLinkEntity_assets"."originalFileName" AS "SharedLinkEntity__SharedLinkEntity_assets_originalFileName",
|
"SharedLinkEntity__SharedLinkEntity_assets"."originalFileName" AS "SharedLinkEntity__SharedLinkEntity_assets_originalFileName",
|
||||||
"SharedLinkEntity__SharedLinkEntity_assets"."sidecarPath" AS "SharedLinkEntity__SharedLinkEntity_assets_sidecarPath",
|
"SharedLinkEntity__SharedLinkEntity_assets"."sidecarPath" AS "SharedLinkEntity__SharedLinkEntity_assets_sidecarPath",
|
||||||
"SharedLinkEntity__SharedLinkEntity_assets"."stackId" AS "SharedLinkEntity__SharedLinkEntity_assets_stackId",
|
"SharedLinkEntity__SharedLinkEntity_assets"."stackId" AS "SharedLinkEntity__SharedLinkEntity_assets_stackId",
|
||||||
|
"SharedLinkEntity__SharedLinkEntity_assets"."duplicateId" AS "SharedLinkEntity__SharedLinkEntity_assets_duplicateId",
|
||||||
"SharedLinkEntity__SharedLinkEntity_album"."id" AS "SharedLinkEntity__SharedLinkEntity_album_id",
|
"SharedLinkEntity__SharedLinkEntity_album"."id" AS "SharedLinkEntity__SharedLinkEntity_album_id",
|
||||||
"SharedLinkEntity__SharedLinkEntity_album"."ownerId" AS "SharedLinkEntity__SharedLinkEntity_album_ownerId",
|
"SharedLinkEntity__SharedLinkEntity_album"."ownerId" AS "SharedLinkEntity__SharedLinkEntity_album_ownerId",
|
||||||
"SharedLinkEntity__SharedLinkEntity_album"."albumName" AS "SharedLinkEntity__SharedLinkEntity_album_albumName",
|
"SharedLinkEntity__SharedLinkEntity_album"."albumName" AS "SharedLinkEntity__SharedLinkEntity_album_albumName",
|
||||||
|
43
server/src/repositories/asset-duplicate.repository.ts
Normal file
43
server/src/repositories/asset-duplicate.repository.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { AssetDuplicateEntity } from 'src/entities/asset-duplicate.entity';
|
||||||
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
|
import { IAssetDuplicateRepository } from 'src/interfaces/asset-duplicate.interface';
|
||||||
|
import { Instrumentation } from 'src/utils/instrumentation';
|
||||||
|
import { In, Repository } from 'typeorm';
|
||||||
|
|
||||||
|
@Instrumentation()
|
||||||
|
@Injectable()
|
||||||
|
export class AssetDuplicateRepository implements IAssetDuplicateRepository {
|
||||||
|
constructor(@InjectRepository(AssetDuplicateEntity) private repository: Repository<AssetDuplicateEntity>) {}
|
||||||
|
|
||||||
|
async upsert(id: string, assetIds: string[], oldDuplicateIds: string[] = []): Promise<void> {
|
||||||
|
await this.repository.manager.transaction(async (manager) => {
|
||||||
|
await manager.upsert(
|
||||||
|
AssetDuplicateEntity,
|
||||||
|
assetIds.map((assetId) => ({ id, assetId })),
|
||||||
|
['assetId'],
|
||||||
|
);
|
||||||
|
if (oldDuplicateIds.length > 0) {
|
||||||
|
await manager.update(AssetDuplicateEntity, { id: In(oldDuplicateIds) }, { id });
|
||||||
|
}
|
||||||
|
await manager.update(AssetEntity, { id: In(assetIds) }, { duplicateId: id });
|
||||||
|
await manager.update(AssetEntity, { duplicateId: In(oldDuplicateIds) }, { duplicateId: id });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<void> {
|
||||||
|
await this.repository.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(id: string): Promise<AssetDuplicateEntity | null> {
|
||||||
|
return this.repository.findOne({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
relations: {
|
||||||
|
assets: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -67,7 +67,7 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
await this.exifRepository.upsert(exif, { conflictPaths: ['assetId'] });
|
await this.exifRepository.upsert(exif, { conflictPaths: ['assetId'] });
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertJobStatus(jobStatus: Partial<AssetJobStatusEntity>): Promise<void> {
|
async upsertJobStatus(jobStatus: Partial<AssetJobStatusEntity> | Partial<AssetJobStatusEntity>[]): Promise<void> {
|
||||||
await this.jobStatusRepository.upsert(jobStatus, { conflictPaths: ['assetId'] });
|
await this.jobStatusRepository.upsert(jobStatus, { conflictPaths: ['assetId'] });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -201,6 +201,7 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
let builder = this.repository.createQueryBuilder('asset');
|
let builder = this.repository.createQueryBuilder('asset');
|
||||||
builder = searchAssetBuilder(builder, options);
|
builder = searchAssetBuilder(builder, options);
|
||||||
builder.orderBy('asset.createdAt', options.orderDirection ?? 'ASC');
|
builder.orderBy('asset.createdAt', options.orderDirection ?? 'ASC');
|
||||||
|
builder.innerJoin('asset.smartSearch', 'smartSearch');
|
||||||
return paginatedBuilder<AssetEntity>(builder, {
|
return paginatedBuilder<AssetEntity>(builder, {
|
||||||
mode: PaginationMode.SKIP_TAKE,
|
mode: PaginationMode.SKIP_TAKE,
|
||||||
skip: pagination.skip,
|
skip: pagination.skip,
|
||||||
@ -347,6 +348,18 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case WithoutProperty.DUPLICATE: {
|
||||||
|
where = {
|
||||||
|
resizePath: Not(IsNull()),
|
||||||
|
isVisible: true,
|
||||||
|
smartSearch: true,
|
||||||
|
jobStatus: {
|
||||||
|
duplicatesDetectedAt: IsNull(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case WithoutProperty.OBJECT_TAGS: {
|
case WithoutProperty.OBJECT_TAGS: {
|
||||||
relations = {
|
relations = {
|
||||||
smartInfo: true,
|
smartInfo: true,
|
||||||
@ -579,6 +592,13 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [{ userIds: [DummyValue.UUID, DummyValue.UUID] }] })
|
||||||
|
getDuplicates(options: AssetBuilderOptions): Promise<AssetEntity[]> {
|
||||||
|
return this.getBuilder({ ...options, isDuplicate: true })
|
||||||
|
.orderBy('asset.duplicateId')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID, { minAssetsPerField: 5, maxFields: 12 }] })
|
@GenerateSql({ params: [DummyValue.UUID, { minAssetsPerField: 5, maxFields: 12 }] })
|
||||||
async getAssetIdByCity(
|
async getAssetIdByCity(
|
||||||
ownerId: string,
|
ownerId: string,
|
||||||
@ -638,16 +658,14 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getBuilder(options: AssetBuilderOptions) {
|
private getBuilder(options: AssetBuilderOptions) {
|
||||||
const { isArchived, isFavorite, isTrashed, albumId, personId, userIds, withStacked, exifInfo, assetType } = options;
|
|
||||||
|
|
||||||
let builder = this.repository.createQueryBuilder('asset').where('asset.isVisible = true');
|
let builder = this.repository.createQueryBuilder('asset').where('asset.isVisible = true');
|
||||||
if (assetType !== undefined) {
|
if (options.assetType !== undefined) {
|
||||||
builder = builder.andWhere('asset.type = :assetType', { assetType });
|
builder = builder.andWhere('asset.type = :assetType', { assetType: options.assetType });
|
||||||
}
|
}
|
||||||
|
|
||||||
let stackJoined = false;
|
let stackJoined = false;
|
||||||
|
|
||||||
if (exifInfo !== false) {
|
if (options.exifInfo !== false) {
|
||||||
stackJoined = true;
|
stackJoined = true;
|
||||||
builder = builder
|
builder = builder
|
||||||
.leftJoinAndSelect('asset.exifInfo', 'exifInfo')
|
.leftJoinAndSelect('asset.exifInfo', 'exifInfo')
|
||||||
@ -655,34 +673,38 @@ export class AssetRepository implements IAssetRepository {
|
|||||||
.leftJoinAndSelect('stack.assets', 'stackedAssets');
|
.leftJoinAndSelect('stack.assets', 'stackedAssets');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (albumId) {
|
if (options.albumId) {
|
||||||
builder = builder.leftJoin('asset.albums', 'album').andWhere('album.id = :albumId', { albumId });
|
builder = builder.leftJoin('asset.albums', 'album').andWhere('album.id = :albumId', { albumId: options.albumId });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userIds) {
|
if (options.userIds) {
|
||||||
builder = builder.andWhere('asset.ownerId IN (:...userIds )', { userIds });
|
builder = builder.andWhere('asset.ownerId IN (:...userIds )', { userIds: options.userIds });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isArchived !== undefined) {
|
if (options.isArchived !== undefined) {
|
||||||
builder = builder.andWhere('asset.isArchived = :isArchived', { isArchived });
|
builder = builder.andWhere('asset.isArchived = :isArchived', { isArchived: options.isArchived });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isFavorite !== undefined) {
|
if (options.isFavorite !== undefined) {
|
||||||
builder = builder.andWhere('asset.isFavorite = :isFavorite', { isFavorite });
|
builder = builder.andWhere('asset.isFavorite = :isFavorite', { isFavorite: options.isFavorite });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isTrashed !== undefined) {
|
if (options.isTrashed !== undefined) {
|
||||||
builder = builder.andWhere(`asset.deletedAt ${isTrashed ? 'IS NOT NULL' : 'IS NULL'}`).withDeleted();
|
builder = builder.andWhere(`asset.deletedAt ${options.isTrashed ? 'IS NOT NULL' : 'IS NULL'}`).withDeleted();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (personId !== undefined) {
|
if (options.isDuplicate !== undefined) {
|
||||||
|
builder = builder.andWhere(`asset.duplicateId ${options.isDuplicate ? 'IS NOT NULL' : 'IS NULL'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.personId !== undefined) {
|
||||||
builder = builder
|
builder = builder
|
||||||
.innerJoin('asset.faces', 'faces')
|
.innerJoin('asset.faces', 'faces')
|
||||||
.innerJoin('faces.person', 'person')
|
.innerJoin('faces.person', 'person')
|
||||||
.andWhere('person.id = :personId', { personId });
|
.andWhere('person.id = :personId', { personId: options.personId });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (withStacked) {
|
if (options.withStacked) {
|
||||||
if (!stackJoined) {
|
if (!stackJoined) {
|
||||||
builder = builder.leftJoinAndSelect('asset.stack', 'stack').leftJoinAndSelect('stack.assets', 'stackedAssets');
|
builder = builder.leftJoinAndSelect('asset.stack', 'stack').leftJoinAndSelect('stack.assets', 'stackedAssets');
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ import { IAccessRepository } from 'src/interfaces/access.interface';
|
|||||||
import { IActivityRepository } from 'src/interfaces/activity.interface';
|
import { IActivityRepository } from 'src/interfaces/activity.interface';
|
||||||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||||
import { IKeyRepository } from 'src/interfaces/api-key.interface';
|
import { IKeyRepository } from 'src/interfaces/api-key.interface';
|
||||||
|
import { IAssetDuplicateRepository } from 'src/interfaces/asset-duplicate.interface';
|
||||||
import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface';
|
import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface';
|
||||||
import { IAssetRepositoryV1 } from 'src/interfaces/asset-v1.interface';
|
import { IAssetRepositoryV1 } from 'src/interfaces/asset-v1.interface';
|
||||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||||
@ -56,11 +57,13 @@ import { SystemMetadataRepository } from 'src/repositories/system-metadata.repos
|
|||||||
import { TagRepository } from 'src/repositories/tag.repository';
|
import { TagRepository } from 'src/repositories/tag.repository';
|
||||||
import { UserTokenRepository } from 'src/repositories/user-token.repository';
|
import { UserTokenRepository } from 'src/repositories/user-token.repository';
|
||||||
import { UserRepository } from 'src/repositories/user.repository';
|
import { UserRepository } from 'src/repositories/user.repository';
|
||||||
|
import { AssetDuplicateRepository } from 'src/repositories/asset-duplicate.repository';
|
||||||
|
|
||||||
export const repositories = [
|
export const repositories = [
|
||||||
{ provide: IActivityRepository, useClass: ActivityRepository },
|
{ provide: IActivityRepository, useClass: ActivityRepository },
|
||||||
{ provide: IAccessRepository, useClass: AccessRepository },
|
{ provide: IAccessRepository, useClass: AccessRepository },
|
||||||
{ provide: IAlbumRepository, useClass: AlbumRepository },
|
{ provide: IAlbumRepository, useClass: AlbumRepository },
|
||||||
|
{ provide: IAssetDuplicateRepository, useClass: AssetDuplicateRepository },
|
||||||
{ provide: IAssetRepository, useClass: AssetRepository },
|
{ provide: IAssetRepository, useClass: AssetRepository },
|
||||||
{ provide: IAssetRepositoryV1, useClass: AssetRepositoryV1 },
|
{ provide: IAssetRepositoryV1, useClass: AssetRepositoryV1 },
|
||||||
{ provide: IAssetStackRepository, useClass: AssetStackRepository },
|
{ provide: IAssetStackRepository, useClass: AssetStackRepository },
|
||||||
|
@ -64,6 +64,10 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
|
|||||||
[JobName.QUEUE_SMART_SEARCH]: QueueName.SMART_SEARCH,
|
[JobName.QUEUE_SMART_SEARCH]: QueueName.SMART_SEARCH,
|
||||||
[JobName.SMART_SEARCH]: QueueName.SMART_SEARCH,
|
[JobName.SMART_SEARCH]: QueueName.SMART_SEARCH,
|
||||||
|
|
||||||
|
// duplicate detection
|
||||||
|
[JobName.QUEUE_DUPLICATE_DETECTION]: QueueName.DUPLICATE_DETECTION,
|
||||||
|
[JobName.DUPLICATE_DETECTION]: QueueName.DUPLICATE_DETECTION,
|
||||||
|
|
||||||
// XMP sidecars
|
// XMP sidecars
|
||||||
[JobName.QUEUE_SIDECAR]: QueueName.SIDECAR,
|
[JobName.QUEUE_SIDECAR]: QueueName.SIDECAR,
|
||||||
[JobName.SIDECAR_DISCOVERY]: QueueName.SIDECAR,
|
[JobName.SIDECAR_DISCOVERY]: QueueName.SIDECAR,
|
||||||
|
@ -9,6 +9,8 @@ import { SmartInfoEntity } from 'src/entities/smart-info.entity';
|
|||||||
import { SmartSearchEntity } from 'src/entities/smart-search.entity';
|
import { SmartSearchEntity } from 'src/entities/smart-search.entity';
|
||||||
import { DatabaseExtension } from 'src/interfaces/database.interface';
|
import { DatabaseExtension } from 'src/interfaces/database.interface';
|
||||||
import {
|
import {
|
||||||
|
AssetDuplicateResult,
|
||||||
|
AssetDuplicateSearch,
|
||||||
AssetSearchOptions,
|
AssetSearchOptions,
|
||||||
FaceEmbeddingSearch,
|
FaceEmbeddingSearch,
|
||||||
FaceSearchResult,
|
FaceSearchResult,
|
||||||
@ -144,6 +146,39 @@ export class SearchRepository implements ISearchRepository {
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GenerateSql({
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
embedding: Array.from({ length: 512 }, Math.random),
|
||||||
|
maxDistance: 0.6,
|
||||||
|
userIds: [DummyValue.UUID],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
searchDuplicates({ assetId, maxDistance, userIds }: AssetDuplicateSearch): Promise<AssetDuplicateResult[]> {
|
||||||
|
const cte = this.assetRepository.createQueryBuilder('asset');
|
||||||
|
cte
|
||||||
|
.select('search.assetId', 'assetId')
|
||||||
|
.addSelect('asset.duplicateId', 'duplicateId')
|
||||||
|
.addSelect(`(SELECT embedding FROM smart_search WHERE "assetId" = :assetId) <=> search.embedding`, 'distance')
|
||||||
|
.innerJoin('asset.smartSearch', 'search')
|
||||||
|
.where('asset.ownerId IN (:...userIds )')
|
||||||
|
.andWhere('asset.id != :assetId')
|
||||||
|
.andWhere('asset.isVisible = :isVisible')
|
||||||
|
.orderBy('search.embedding <=> (SELECT embedding FROM smart_search WHERE "assetId" = :assetId)')
|
||||||
|
.limit(64)
|
||||||
|
.setParameters({ assetId, isVisible: true, userIds });
|
||||||
|
|
||||||
|
const builder = this.assetRepository.manager
|
||||||
|
.createQueryBuilder()
|
||||||
|
.addCommonTableExpression(cte, 'cte')
|
||||||
|
.from('cte', 'res')
|
||||||
|
.select('res.*')
|
||||||
|
.where('res.distance <= :maxDistance', { maxDistance });
|
||||||
|
|
||||||
|
return builder.getRawMany() as any as Promise<AssetDuplicateResult[]>;
|
||||||
|
}
|
||||||
|
|
||||||
@GenerateSql({
|
@GenerateSql({
|
||||||
params: [
|
params: [
|
||||||
{
|
{
|
||||||
|
@ -248,6 +248,11 @@ export class AssetService {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getDuplicates(auth: AuthDto): Promise<AssetResponseDto[]> {
|
||||||
|
const res = await this.assetRepository.getDuplicates({ userIds: [auth.user.id] });
|
||||||
|
return res.map((a) => mapAsset(a, { auth }));
|
||||||
|
}
|
||||||
|
|
||||||
async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
|
async update(auth: AuthDto, id: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
|
||||||
await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id);
|
await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id);
|
||||||
|
|
||||||
|
@ -115,6 +115,11 @@ export class JobService {
|
|||||||
return this.jobRepository.queue({ name: JobName.QUEUE_SMART_SEARCH, data: { force } });
|
return this.jobRepository.queue({ name: JobName.QUEUE_SMART_SEARCH, data: { force } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case QueueName.DUPLICATE_DETECTION: {
|
||||||
|
await this.configCore.requireFeature(FeatureFlag.SMART_SEARCH);
|
||||||
|
return this.jobRepository.queue({ name: JobName.QUEUE_DUPLICATE_DETECTION, data: { force } });
|
||||||
|
}
|
||||||
|
|
||||||
case QueueName.METADATA_EXTRACTION: {
|
case QueueName.METADATA_EXTRACTION: {
|
||||||
return this.jobRepository.queue({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force } });
|
return this.jobRepository.queue({ name: JobName.QUEUE_METADATA_EXTRACTION, data: { force } });
|
||||||
}
|
}
|
||||||
@ -194,7 +199,11 @@ export class JobService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isConcurrentQueue(name: QueueName): name is ConcurrentQueueName {
|
private isConcurrentQueue(name: QueueName): name is ConcurrentQueueName {
|
||||||
return ![QueueName.FACIAL_RECOGNITION, QueueName.STORAGE_TEMPLATE_MIGRATION].includes(name);
|
return ![
|
||||||
|
QueueName.FACIAL_RECOGNITION,
|
||||||
|
QueueName.STORAGE_TEMPLATE_MIGRATION,
|
||||||
|
QueueName.DUPLICATE_DETECTION,
|
||||||
|
].includes(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleNightlyJobs() {
|
async handleNightlyJobs() {
|
||||||
@ -296,6 +305,13 @@ export class JobService {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case JobName.SMART_SEARCH: {
|
||||||
|
if (item.data.source === 'upload') {
|
||||||
|
await this.jobRepository.queue({ name: JobName.DUPLICATE_DETECTION, data: item.data });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case JobName.USER_DELETION: {
|
case JobName.USER_DELETION: {
|
||||||
this.eventRepository.clientBroadcast(ClientEvent.USER_DELETE, item.data.id);
|
this.eventRepository.clientBroadcast(ClientEvent.USER_DELETE, item.data.id);
|
||||||
break;
|
break;
|
||||||
|
@ -14,6 +14,7 @@ import { StorageService } from 'src/services/storage.service';
|
|||||||
import { SystemConfigService } from 'src/services/system-config.service';
|
import { SystemConfigService } from 'src/services/system-config.service';
|
||||||
import { UserService } from 'src/services/user.service';
|
import { UserService } from 'src/services/user.service';
|
||||||
import { otelSDK } from 'src/utils/instrumentation';
|
import { otelSDK } from 'src/utils/instrumentation';
|
||||||
|
import { SearchService } from './search.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MicroservicesService {
|
export class MicroservicesService {
|
||||||
@ -31,6 +32,7 @@ export class MicroservicesService {
|
|||||||
private storageService: StorageService,
|
private storageService: StorageService,
|
||||||
private userService: UserService,
|
private userService: UserService,
|
||||||
private databaseService: DatabaseService,
|
private databaseService: DatabaseService,
|
||||||
|
private searchService: SearchService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
@ -47,6 +49,8 @@ export class MicroservicesService {
|
|||||||
[JobName.USER_SYNC_USAGE]: () => this.userService.handleUserSyncUsage(),
|
[JobName.USER_SYNC_USAGE]: () => this.userService.handleUserSyncUsage(),
|
||||||
[JobName.QUEUE_SMART_SEARCH]: (data) => this.smartInfoService.handleQueueEncodeClip(data),
|
[JobName.QUEUE_SMART_SEARCH]: (data) => this.smartInfoService.handleQueueEncodeClip(data),
|
||||||
[JobName.SMART_SEARCH]: (data) => this.smartInfoService.handleEncodeClip(data),
|
[JobName.SMART_SEARCH]: (data) => this.smartInfoService.handleEncodeClip(data),
|
||||||
|
[JobName.QUEUE_DUPLICATE_DETECTION]: (data) => this.searchService.handleQueueSearchDuplicates(data),
|
||||||
|
[JobName.DUPLICATE_DETECTION]: (data) => this.searchService.handleSearchDuplicates(data),
|
||||||
[JobName.STORAGE_TEMPLATE_MIGRATION]: () => this.storageTemplateService.handleMigration(),
|
[JobName.STORAGE_TEMPLATE_MIGRATION]: () => this.storageTemplateService.handleMigration(),
|
||||||
[JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE]: (data) => this.storageTemplateService.handleMigrationSingle(data),
|
[JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE]: (data) => this.storageTemplateService.handleMigrationSingle(data),
|
||||||
[JobName.QUEUE_MIGRATION]: () => this.mediaService.handleQueueMigration(),
|
[JobName.QUEUE_MIGRATION]: () => this.mediaService.handleQueueMigration(),
|
||||||
|
@ -17,16 +17,29 @@ import {
|
|||||||
} from 'src/dtos/search.dto';
|
} from 'src/dtos/search.dto';
|
||||||
import { AssetOrder } from 'src/entities/album.entity';
|
import { AssetOrder } from 'src/entities/album.entity';
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
import { IAssetDuplicateRepository } from 'src/interfaces/asset-duplicate.interface';
|
||||||
|
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
|
||||||
|
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||||
|
import {
|
||||||
|
IBaseJob,
|
||||||
|
IEntityJob,
|
||||||
|
IJobRepository,
|
||||||
|
JOBS_ASSET_PAGINATION_SIZE,
|
||||||
|
JobName,
|
||||||
|
JobStatus,
|
||||||
|
} from 'src/interfaces/job.interface';
|
||||||
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
|
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
|
||||||
import { IMetadataRepository } from 'src/interfaces/metadata.interface';
|
import { IMetadataRepository } from 'src/interfaces/metadata.interface';
|
||||||
import { IPartnerRepository } from 'src/interfaces/partner.interface';
|
import { IPartnerRepository } from 'src/interfaces/partner.interface';
|
||||||
import { IPersonRepository } from 'src/interfaces/person.interface';
|
import { IPersonRepository } from 'src/interfaces/person.interface';
|
||||||
import { ISearchRepository, SearchExploreItem, SearchStrategy } from 'src/interfaces/search.interface';
|
import { ISearchRepository, SearchExploreItem, SearchStrategy } from 'src/interfaces/search.interface';
|
||||||
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
|
import { ISystemConfigRepository } from 'src/interfaces/system-config.interface';
|
||||||
|
import { ImmichLogger } from 'src/utils/logger';
|
||||||
|
import { usePagination } from 'src/utils/pagination';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SearchService {
|
export class SearchService {
|
||||||
|
private logger = new ImmichLogger(SearchService.name);
|
||||||
private configCore: SystemConfigCore;
|
private configCore: SystemConfigCore;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ -37,6 +50,9 @@ export class SearchService {
|
|||||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||||
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
|
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
|
||||||
@Inject(IMetadataRepository) private metadataRepository: IMetadataRepository,
|
@Inject(IMetadataRepository) private metadataRepository: IMetadataRepository,
|
||||||
|
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
||||||
|
@Inject(IAssetDuplicateRepository) private assetDuplicateRepository: IAssetDuplicateRepository,
|
||||||
|
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||||
) {
|
) {
|
||||||
this.configCore = SystemConfigCore.create(configRepository);
|
this.configCore = SystemConfigCore.create(configRepository);
|
||||||
}
|
}
|
||||||
@ -139,6 +155,84 @@ export class SearchService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleQueueSearchDuplicates({ force }: IBaseJob): Promise<JobStatus> {
|
||||||
|
const { machineLearning } = await this.configCore.getConfig();
|
||||||
|
if (!machineLearning.enabled || !machineLearning.clip.enabled) {
|
||||||
|
return JobStatus.SKIPPED;
|
||||||
|
}
|
||||||
|
|
||||||
|
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||||
|
return force
|
||||||
|
? this.assetRepository.getAll(pagination, { isVisible: true })
|
||||||
|
: this.assetRepository.getWithout(pagination, WithoutProperty.DUPLICATE);
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const assets of assetPagination) {
|
||||||
|
await this.jobRepository.queueAll(
|
||||||
|
assets.map((asset) => ({ name: JobName.DUPLICATE_DETECTION, data: { id: asset.id } })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return JobStatus.SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSearchDuplicates({ id }: IEntityJob): Promise<JobStatus> {
|
||||||
|
const { machineLearning } = await this.configCore.getConfig();
|
||||||
|
|
||||||
|
const asset = await this.assetRepository.getById(id, { smartSearch: true });
|
||||||
|
if (!asset) {
|
||||||
|
this.logger.error(`Asset ${id} not found`);
|
||||||
|
return JobStatus.FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!asset.isVisible) {
|
||||||
|
this.logger.debug(`Asset ${id} is not visible, skipping`);
|
||||||
|
return JobStatus.SKIPPED;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset.duplicateId) {
|
||||||
|
this.logger.debug(`Asset ${id} already has a duplicateId, skipping`);
|
||||||
|
return JobStatus.SKIPPED;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!asset.resizePath) {
|
||||||
|
this.logger.debug(`Asset ${id} is missing preview image`);
|
||||||
|
return JobStatus.FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!asset.smartSearch?.embedding) {
|
||||||
|
this.logger.debug(`Asset ${id} is missing embedding`);
|
||||||
|
return JobStatus.FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicateAssets = await this.searchRepository.searchDuplicates({
|
||||||
|
assetId: asset.id,
|
||||||
|
embedding: asset.smartSearch.embedding,
|
||||||
|
maxDistance: machineLearning.clip.duplicateThreshold,
|
||||||
|
userIds: [asset.ownerId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const assetIds = [asset.id];
|
||||||
|
|
||||||
|
if (duplicateAssets.length > 0) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Found ${duplicateAssets.length} duplicate${duplicateAssets.length === 1 ? '' : 's'} for asset ${asset.id}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const duplicateIds = duplicateAssets.map((duplicate) => duplicate.duplicateId).filter(Boolean);
|
||||||
|
const duplicateId = duplicateIds[0] || this.cryptoRepository.randomUUID();
|
||||||
|
|
||||||
|
assetIds.push(...duplicateAssets.map((duplicate) => duplicate.assetId));
|
||||||
|
|
||||||
|
await this.assetDuplicateRepository.upsert(duplicateId, assetIds, duplicateIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicatesDetectedAt = new Date();
|
||||||
|
await this.assetRepository.upsertJobStatus(assetIds.map((assetId) => ({ assetId, duplicatesDetectedAt })));
|
||||||
|
|
||||||
|
return JobStatus.SUCCESS;
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: remove after implementing new search filters
|
// TODO: remove after implementing new search filters
|
||||||
/** @deprecated */
|
/** @deprecated */
|
||||||
async search(auth: AuthDto, dto: SearchDto): Promise<SearchResponseDto> {
|
async search(auth: AuthDto, dto: SearchDto): Promise<SearchResponseDto> {
|
||||||
|
@ -74,6 +74,7 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
|||||||
clip: {
|
clip: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
modelName: 'ViT-B-32__openai',
|
modelName: 'ViT-B-32__openai',
|
||||||
|
duplicateThreshold: 0.03,
|
||||||
},
|
},
|
||||||
facialRecognition: {
|
facialRecognition: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
@ -88,6 +88,12 @@
|
|||||||
subtitle: 'Run machine learning on assets to support smart search',
|
subtitle: 'Run machine learning on assets to support smart search',
|
||||||
disabled: !$featureFlags.smartSearch,
|
disabled: !$featureFlags.smartSearch,
|
||||||
},
|
},
|
||||||
|
[JobName.DuplicateDetection]: {
|
||||||
|
icon: mdiImageSearch,
|
||||||
|
title: getJobName(JobName.DuplicateDetection),
|
||||||
|
subtitle: 'Run machine learning on assets to detect near-duplicate images',
|
||||||
|
disabled: !$featureFlags.smartSearch,
|
||||||
|
},
|
||||||
[JobName.FaceDetection]: {
|
[JobName.FaceDetection]: {
|
||||||
icon: mdiFaceRecognition,
|
icon: mdiFaceRecognition,
|
||||||
title: getJobName(JobName.FaceDetection),
|
title: getJobName(JobName.FaceDetection),
|
||||||
|
@ -131,6 +131,19 @@
|
|||||||
</svelte:fragment>
|
</svelte:fragment>
|
||||||
</SideBarLink>
|
</SideBarLink>
|
||||||
|
|
||||||
|
<SideBarLink title="Duplicates" routeId="/(user)/duplicates" icon={mdiArchiveArrowDownOutline}>
|
||||||
|
<svelte:fragment slot="moreInformation">
|
||||||
|
{#await getStats({ isArchived: true })}
|
||||||
|
<LoadingSpinner />
|
||||||
|
{:then data}
|
||||||
|
<div>
|
||||||
|
<p>{data.videos.toLocaleString($locale)} Videos</p>
|
||||||
|
<p>{data.images.toLocaleString($locale)} Photos</p>
|
||||||
|
</div>
|
||||||
|
{/await}
|
||||||
|
</svelte:fragment>
|
||||||
|
</SideBarLink>
|
||||||
|
|
||||||
{#if $featureFlags.trash}
|
{#if $featureFlags.trash}
|
||||||
<SideBarLink title="Trash" routeId="/(user)/trash" icon={mdiTrashCanOutline}>
|
<SideBarLink title="Trash" routeId="/(user)/trash" icon={mdiTrashCanOutline}>
|
||||||
<svelte:fragment slot="moreInformation">
|
<svelte:fragment slot="moreInformation">
|
||||||
|
@ -20,6 +20,7 @@ export enum AppRoute {
|
|||||||
ALBUMS = '/albums',
|
ALBUMS = '/albums',
|
||||||
LIBRARIES = '/libraries',
|
LIBRARIES = '/libraries',
|
||||||
ARCHIVE = '/archive',
|
ARCHIVE = '/archive',
|
||||||
|
DUPLICATES = '/duplicates',
|
||||||
FAVORITES = '/favorites',
|
FAVORITES = '/favorites',
|
||||||
PEOPLE = '/people',
|
PEOPLE = '/people',
|
||||||
PLACES = '/places',
|
PLACES = '/places',
|
||||||
|
@ -115,6 +115,7 @@ export const getJobName = (jobName: JobName) => {
|
|||||||
[JobName.MetadataExtraction]: 'Extract Metadata',
|
[JobName.MetadataExtraction]: 'Extract Metadata',
|
||||||
[JobName.Sidecar]: 'Sidecar Metadata',
|
[JobName.Sidecar]: 'Sidecar Metadata',
|
||||||
[JobName.SmartSearch]: 'Smart Search',
|
[JobName.SmartSearch]: 'Smart Search',
|
||||||
|
[JobName.DuplicateDetection]: 'Duplicate Detection',
|
||||||
[JobName.FaceDetection]: 'Face Detection',
|
[JobName.FaceDetection]: 'Face Detection',
|
||||||
[JobName.FacialRecognition]: 'Facial Recognition',
|
[JobName.FacialRecognition]: 'Facial Recognition',
|
||||||
[JobName.VideoConversion]: 'Transcode Videos',
|
[JobName.VideoConversion]: 'Transcode Videos',
|
||||||
|
21
web/src/routes/(user)/duplicates/+page.svelte
Normal file
21
web/src/routes/(user)/duplicates/+page.svelte
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
|
||||||
|
import { getAssetDuplicates, type AssetResponseDto } from '@immich/sdk';
|
||||||
|
import type { Viewport } from '$lib/stores/assets.store';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let assets: AssetResponseDto[] = [];
|
||||||
|
const viewport: Viewport = { width: 0, height: 0 };
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
assets = await getAssetDuplicates();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="relative mb-12 bg-immich-bg dark:bg-immich-dark-bg m-4"
|
||||||
|
bind:clientHeight={viewport.height}
|
||||||
|
bind:clientWidth={viewport.width}
|
||||||
|
>
|
||||||
|
<GalleryViewer {assets} {viewport}></GalleryViewer>
|
||||||
|
</section>
|
12
web/src/routes/(user)/duplicates/+page.ts
Normal file
12
web/src/routes/(user)/duplicates/+page.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { authenticate } from '$lib/utils/auth';
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load = (async () => {
|
||||||
|
await authenticate();
|
||||||
|
|
||||||
|
return {
|
||||||
|
meta: {
|
||||||
|
title: 'Duplicates',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}) satisfies PageLoad;
|
@ -0,0 +1,7 @@
|
|||||||
|
import { AppRoute } from '$lib/constants';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import type { PageLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageLoad = () => {
|
||||||
|
redirect(302, AppRoute.DUPLICATES);
|
||||||
|
};
|
Loading…
x
Reference in New Issue
Block a user