mirror of
https://github.com/immich-app/immich.git
synced 2025-07-09 03:04:16 -04:00
Merge remote-tracking branch 'origin/main' into keynav_timeline
This commit is contained in:
commit
8cba1f4068
25
.github/workflows/test.yml
vendored
25
.github/workflows/test.yml
vendored
@ -338,12 +338,15 @@ jobs:
|
|||||||
name: End-to-End Tests (Server & CLI)
|
name: End-to-End Tests (Server & CLI)
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_e2e_server_cli == 'true' }}
|
if: ${{ needs.pre-job.outputs.should_run_e2e_server_cli == 'true' }}
|
||||||
runs-on: mich
|
runs-on: ${{ matrix.runner }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: ./e2e
|
working-directory: ./e2e
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
runner: [mich, ubuntu-24.04-arm]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@ -383,12 +386,15 @@ jobs:
|
|||||||
name: End-to-End Tests (Web)
|
name: End-to-End Tests (Web)
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
if: ${{ needs.pre-job.outputs.should_run_e2e_web == 'true' }}
|
if: ${{ needs.pre-job.outputs.should_run_e2e_web == 'true' }}
|
||||||
runs-on: mich
|
runs-on: ${{ matrix.runner }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
working-directory: ./e2e
|
working-directory: ./e2e
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
runner: [mich, ubuntu-24.04-arm]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@ -423,6 +429,21 @@ jobs:
|
|||||||
run: npx playwright test
|
run: npx playwright test
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
|
||||||
|
success-check-e2e:
|
||||||
|
name: End-to-End Tests Success
|
||||||
|
needs: [e2e-tests-server-cli, e2e-tests-web]
|
||||||
|
permissions: {}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: always()
|
||||||
|
steps:
|
||||||
|
- name: Any jobs failed?
|
||||||
|
if: ${{ contains(needs.*.result, 'failure') }}
|
||||||
|
run: exit 1
|
||||||
|
- name: All jobs passed or skipped
|
||||||
|
if: ${{ !(contains(needs.*.result, 'failure')) }}
|
||||||
|
# zizmor: ignore[template-injection]
|
||||||
|
run: echo "All jobs passed or skipped" && echo "${{ toJSON(needs.*.result) }}"
|
||||||
|
|
||||||
mobile-unit-tests:
|
mobile-unit-tests:
|
||||||
name: Unit Test Mobile
|
name: Unit Test Mobile
|
||||||
needs: pre-job
|
needs: pre-job
|
||||||
|
@ -1,43 +0,0 @@
|
|||||||
import { deleteAssets, getAuditFiles, updateAsset, type LoginResponseDto } from '@immich/sdk';
|
|
||||||
import { asBearerAuth, utils } from 'src/utils';
|
|
||||||
import { beforeAll, describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
describe('/audits', () => {
|
|
||||||
let admin: LoginResponseDto;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
await utils.resetDatabase();
|
|
||||||
await utils.resetFilesystem();
|
|
||||||
|
|
||||||
admin = await utils.adminSetup();
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO: Enable these tests again once #7436 is resolved as these were flaky
|
|
||||||
describe.skip('GET :/file-report', () => {
|
|
||||||
it('excludes assets without issues from report', async () => {
|
|
||||||
const [trashedAsset, archivedAsset] = await Promise.all([
|
|
||||||
utils.createAsset(admin.accessToken),
|
|
||||||
utils.createAsset(admin.accessToken),
|
|
||||||
utils.createAsset(admin.accessToken),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await Promise.all([
|
|
||||||
deleteAssets({ assetBulkDeleteDto: { ids: [trashedAsset.id] } }, { headers: asBearerAuth(admin.accessToken) }),
|
|
||||||
updateAsset(
|
|
||||||
{
|
|
||||||
id: archivedAsset.id,
|
|
||||||
updateAssetDto: { isArchived: true },
|
|
||||||
},
|
|
||||||
{ headers: asBearerAuth(admin.accessToken) },
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const body = await getAuditFiles({
|
|
||||||
headers: asBearerAuth(admin.accessToken),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(body.orphans).toHaveLength(0);
|
|
||||||
expect(body.extras).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
@ -1260,6 +1260,7 @@
|
|||||||
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
|
"no_favorites_message": "Add favorites to quickly find your best pictures and videos",
|
||||||
"no_libraries_message": "Create an external library to view your photos and videos",
|
"no_libraries_message": "Create an external library to view your photos and videos",
|
||||||
"no_name": "No Name",
|
"no_name": "No Name",
|
||||||
|
"no_people_found": "No matching people found",
|
||||||
"no_places": "No places",
|
"no_places": "No places",
|
||||||
"no_results": "No results",
|
"no_results": "No results",
|
||||||
"no_results_description": "Try a synonym or more general keyword",
|
"no_results_description": "Try a synonym or more general keyword",
|
||||||
@ -1572,6 +1573,7 @@
|
|||||||
"select_keep_all": "Select keep all",
|
"select_keep_all": "Select keep all",
|
||||||
"select_library_owner": "Select library owner",
|
"select_library_owner": "Select library owner",
|
||||||
"select_new_face": "Select new face",
|
"select_new_face": "Select new face",
|
||||||
|
"select_person_to_tag": "Select a person to tag",
|
||||||
"select_photos": "Select photos",
|
"select_photos": "Select photos",
|
||||||
"select_trash_all": "Select trash all",
|
"select_trash_all": "Select trash all",
|
||||||
"select_user_for_sharing_page_err_album": "Failed to create album",
|
"select_user_for_sharing_page_err_album": "Failed to create album",
|
||||||
|
12
mobile/openapi/README.md
generated
12
mobile/openapi/README.md
generated
@ -100,7 +100,6 @@ Class | Method | HTTP request | Description
|
|||||||
*AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | getAllUserAssetsByDeviceId
|
*AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | getAllUserAssetsByDeviceId
|
||||||
*AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} |
|
*AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} |
|
||||||
*AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics |
|
*AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics |
|
||||||
*AssetsApi* | [**getMemoryLane**](doc//AssetsApi.md#getmemorylane) | **GET** /assets/memory-lane |
|
|
||||||
*AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random |
|
*AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random |
|
||||||
*AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback |
|
*AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback |
|
||||||
*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | replaceAsset
|
*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | replaceAsset
|
||||||
@ -122,9 +121,6 @@ Class | Method | HTTP request | Description
|
|||||||
*FacesApi* | [**deleteFace**](doc//FacesApi.md#deleteface) | **DELETE** /faces/{id} |
|
*FacesApi* | [**deleteFace**](doc//FacesApi.md#deleteface) | **DELETE** /faces/{id} |
|
||||||
*FacesApi* | [**getFaces**](doc//FacesApi.md#getfaces) | **GET** /faces |
|
*FacesApi* | [**getFaces**](doc//FacesApi.md#getfaces) | **GET** /faces |
|
||||||
*FacesApi* | [**reassignFacesById**](doc//FacesApi.md#reassignfacesbyid) | **PUT** /faces/{id} |
|
*FacesApi* | [**reassignFacesById**](doc//FacesApi.md#reassignfacesbyid) | **PUT** /faces/{id} |
|
||||||
*FileReportsApi* | [**fixAuditFiles**](doc//FileReportsApi.md#fixauditfiles) | **POST** /reports/fix |
|
|
||||||
*FileReportsApi* | [**getAuditFiles**](doc//FileReportsApi.md#getauditfiles) | **GET** /reports |
|
|
||||||
*FileReportsApi* | [**getFileChecksums**](doc//FileReportsApi.md#getfilechecksums) | **POST** /reports/checksum |
|
|
||||||
*JobsApi* | [**createJob**](doc//JobsApi.md#createjob) | **POST** /jobs |
|
*JobsApi* | [**createJob**](doc//JobsApi.md#createjob) | **POST** /jobs |
|
||||||
*JobsApi* | [**getAllJobsStatus**](doc//JobsApi.md#getalljobsstatus) | **GET** /jobs |
|
*JobsApi* | [**getAllJobsStatus**](doc//JobsApi.md#getalljobsstatus) | **GET** /jobs |
|
||||||
*JobsApi* | [**sendJobCommand**](doc//JobsApi.md#sendjobcommand) | **PUT** /jobs/{id} |
|
*JobsApi* | [**sendJobCommand**](doc//JobsApi.md#sendjobcommand) | **PUT** /jobs/{id} |
|
||||||
@ -332,11 +328,6 @@ Class | Method | HTTP request | Description
|
|||||||
- [ExifResponseDto](doc//ExifResponseDto.md)
|
- [ExifResponseDto](doc//ExifResponseDto.md)
|
||||||
- [FaceDto](doc//FaceDto.md)
|
- [FaceDto](doc//FaceDto.md)
|
||||||
- [FacialRecognitionConfig](doc//FacialRecognitionConfig.md)
|
- [FacialRecognitionConfig](doc//FacialRecognitionConfig.md)
|
||||||
- [FileChecksumDto](doc//FileChecksumDto.md)
|
|
||||||
- [FileChecksumResponseDto](doc//FileChecksumResponseDto.md)
|
|
||||||
- [FileReportDto](doc//FileReportDto.md)
|
|
||||||
- [FileReportFixDto](doc//FileReportFixDto.md)
|
|
||||||
- [FileReportItemDto](doc//FileReportItemDto.md)
|
|
||||||
- [FoldersResponse](doc//FoldersResponse.md)
|
- [FoldersResponse](doc//FoldersResponse.md)
|
||||||
- [FoldersUpdate](doc//FoldersUpdate.md)
|
- [FoldersUpdate](doc//FoldersUpdate.md)
|
||||||
- [ImageFormat](doc//ImageFormat.md)
|
- [ImageFormat](doc//ImageFormat.md)
|
||||||
@ -361,7 +352,6 @@ Class | Method | HTTP request | Description
|
|||||||
- [MemoriesResponse](doc//MemoriesResponse.md)
|
- [MemoriesResponse](doc//MemoriesResponse.md)
|
||||||
- [MemoriesUpdate](doc//MemoriesUpdate.md)
|
- [MemoriesUpdate](doc//MemoriesUpdate.md)
|
||||||
- [MemoryCreateDto](doc//MemoryCreateDto.md)
|
- [MemoryCreateDto](doc//MemoryCreateDto.md)
|
||||||
- [MemoryLaneResponseDto](doc//MemoryLaneResponseDto.md)
|
|
||||||
- [MemoryResponseDto](doc//MemoryResponseDto.md)
|
- [MemoryResponseDto](doc//MemoryResponseDto.md)
|
||||||
- [MemoryType](doc//MemoryType.md)
|
- [MemoryType](doc//MemoryType.md)
|
||||||
- [MemoryUpdateDto](doc//MemoryUpdateDto.md)
|
- [MemoryUpdateDto](doc//MemoryUpdateDto.md)
|
||||||
@ -381,8 +371,6 @@ Class | Method | HTTP request | Description
|
|||||||
- [OnThisDayDto](doc//OnThisDayDto.md)
|
- [OnThisDayDto](doc//OnThisDayDto.md)
|
||||||
- [PartnerDirection](doc//PartnerDirection.md)
|
- [PartnerDirection](doc//PartnerDirection.md)
|
||||||
- [PartnerResponseDto](doc//PartnerResponseDto.md)
|
- [PartnerResponseDto](doc//PartnerResponseDto.md)
|
||||||
- [PathEntityType](doc//PathEntityType.md)
|
|
||||||
- [PathType](doc//PathType.md)
|
|
||||||
- [PeopleResponse](doc//PeopleResponse.md)
|
- [PeopleResponse](doc//PeopleResponse.md)
|
||||||
- [PeopleResponseDto](doc//PeopleResponseDto.md)
|
- [PeopleResponseDto](doc//PeopleResponseDto.md)
|
||||||
- [PeopleUpdate](doc//PeopleUpdate.md)
|
- [PeopleUpdate](doc//PeopleUpdate.md)
|
||||||
|
9
mobile/openapi/lib/api.dart
generated
9
mobile/openapi/lib/api.dart
generated
@ -39,7 +39,6 @@ part 'api/deprecated_api.dart';
|
|||||||
part 'api/download_api.dart';
|
part 'api/download_api.dart';
|
||||||
part 'api/duplicates_api.dart';
|
part 'api/duplicates_api.dart';
|
||||||
part 'api/faces_api.dart';
|
part 'api/faces_api.dart';
|
||||||
part 'api/file_reports_api.dart';
|
|
||||||
part 'api/jobs_api.dart';
|
part 'api/jobs_api.dart';
|
||||||
part 'api/libraries_api.dart';
|
part 'api/libraries_api.dart';
|
||||||
part 'api/map_api.dart';
|
part 'api/map_api.dart';
|
||||||
@ -133,11 +132,6 @@ part 'model/email_notifications_update.dart';
|
|||||||
part 'model/exif_response_dto.dart';
|
part 'model/exif_response_dto.dart';
|
||||||
part 'model/face_dto.dart';
|
part 'model/face_dto.dart';
|
||||||
part 'model/facial_recognition_config.dart';
|
part 'model/facial_recognition_config.dart';
|
||||||
part 'model/file_checksum_dto.dart';
|
|
||||||
part 'model/file_checksum_response_dto.dart';
|
|
||||||
part 'model/file_report_dto.dart';
|
|
||||||
part 'model/file_report_fix_dto.dart';
|
|
||||||
part 'model/file_report_item_dto.dart';
|
|
||||||
part 'model/folders_response.dart';
|
part 'model/folders_response.dart';
|
||||||
part 'model/folders_update.dart';
|
part 'model/folders_update.dart';
|
||||||
part 'model/image_format.dart';
|
part 'model/image_format.dart';
|
||||||
@ -162,7 +156,6 @@ part 'model/map_reverse_geocode_response_dto.dart';
|
|||||||
part 'model/memories_response.dart';
|
part 'model/memories_response.dart';
|
||||||
part 'model/memories_update.dart';
|
part 'model/memories_update.dart';
|
||||||
part 'model/memory_create_dto.dart';
|
part 'model/memory_create_dto.dart';
|
||||||
part 'model/memory_lane_response_dto.dart';
|
|
||||||
part 'model/memory_response_dto.dart';
|
part 'model/memory_response_dto.dart';
|
||||||
part 'model/memory_type.dart';
|
part 'model/memory_type.dart';
|
||||||
part 'model/memory_update_dto.dart';
|
part 'model/memory_update_dto.dart';
|
||||||
@ -182,8 +175,6 @@ part 'model/o_auth_token_endpoint_auth_method.dart';
|
|||||||
part 'model/on_this_day_dto.dart';
|
part 'model/on_this_day_dto.dart';
|
||||||
part 'model/partner_direction.dart';
|
part 'model/partner_direction.dart';
|
||||||
part 'model/partner_response_dto.dart';
|
part 'model/partner_response_dto.dart';
|
||||||
part 'model/path_entity_type.dart';
|
|
||||||
part 'model/path_type.dart';
|
|
||||||
part 'model/people_response.dart';
|
part 'model/people_response.dart';
|
||||||
part 'model/people_response_dto.dart';
|
part 'model/people_response_dto.dart';
|
||||||
part 'model/people_update.dart';
|
part 'model/people_update.dart';
|
||||||
|
57
mobile/openapi/lib/api/assets_api.dart
generated
57
mobile/openapi/lib/api/assets_api.dart
generated
@ -404,63 +404,6 @@ class AssetsApi {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Performs an HTTP 'GET /assets/memory-lane' operation and returns the [Response].
|
|
||||||
/// Parameters:
|
|
||||||
///
|
|
||||||
/// * [int] day (required):
|
|
||||||
///
|
|
||||||
/// * [int] month (required):
|
|
||||||
Future<Response> getMemoryLaneWithHttpInfo(int day, int month,) async {
|
|
||||||
// ignore: prefer_const_declarations
|
|
||||||
final apiPath = r'/assets/memory-lane';
|
|
||||||
|
|
||||||
// ignore: prefer_final_locals
|
|
||||||
Object? postBody;
|
|
||||||
|
|
||||||
final queryParams = <QueryParam>[];
|
|
||||||
final headerParams = <String, String>{};
|
|
||||||
final formParams = <String, String>{};
|
|
||||||
|
|
||||||
queryParams.addAll(_queryParams('', 'day', day));
|
|
||||||
queryParams.addAll(_queryParams('', 'month', month));
|
|
||||||
|
|
||||||
const contentTypes = <String>[];
|
|
||||||
|
|
||||||
|
|
||||||
return apiClient.invokeAPI(
|
|
||||||
apiPath,
|
|
||||||
'GET',
|
|
||||||
queryParams,
|
|
||||||
postBody,
|
|
||||||
headerParams,
|
|
||||||
formParams,
|
|
||||||
contentTypes.isEmpty ? null : contentTypes.first,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parameters:
|
|
||||||
///
|
|
||||||
/// * [int] day (required):
|
|
||||||
///
|
|
||||||
/// * [int] month (required):
|
|
||||||
Future<List<MemoryLaneResponseDto>?> getMemoryLane(int day, int month,) async {
|
|
||||||
final response = await getMemoryLaneWithHttpInfo(day, month,);
|
|
||||||
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<MemoryLaneResponseDto>') as List)
|
|
||||||
.cast<MemoryLaneResponseDto>()
|
|
||||||
.toList(growable: false);
|
|
||||||
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// This property was deprecated in v1.116.0
|
/// This property was deprecated in v1.116.0
|
||||||
///
|
///
|
||||||
/// Note: This method returns the HTTP [Response].
|
/// Note: This method returns the HTTP [Response].
|
||||||
|
148
mobile/openapi/lib/api/file_reports_api.dart
generated
148
mobile/openapi/lib/api/file_reports_api.dart
generated
@ -1,148 +0,0 @@
|
|||||||
//
|
|
||||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
|
||||||
//
|
|
||||||
// @dart=2.18
|
|
||||||
|
|
||||||
// ignore_for_file: unused_element, unused_import
|
|
||||||
// ignore_for_file: always_put_required_named_parameters_first
|
|
||||||
// ignore_for_file: constant_identifier_names
|
|
||||||
// ignore_for_file: lines_longer_than_80_chars
|
|
||||||
|
|
||||||
part of openapi.api;
|
|
||||||
|
|
||||||
|
|
||||||
class FileReportsApi {
|
|
||||||
FileReportsApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
|
|
||||||
|
|
||||||
final ApiClient apiClient;
|
|
||||||
|
|
||||||
/// Performs an HTTP 'POST /reports/fix' operation and returns the [Response].
|
|
||||||
/// Parameters:
|
|
||||||
///
|
|
||||||
/// * [FileReportFixDto] fileReportFixDto (required):
|
|
||||||
Future<Response> fixAuditFilesWithHttpInfo(FileReportFixDto fileReportFixDto,) async {
|
|
||||||
// ignore: prefer_const_declarations
|
|
||||||
final apiPath = r'/reports/fix';
|
|
||||||
|
|
||||||
// ignore: prefer_final_locals
|
|
||||||
Object? postBody = fileReportFixDto;
|
|
||||||
|
|
||||||
final queryParams = <QueryParam>[];
|
|
||||||
final headerParams = <String, String>{};
|
|
||||||
final formParams = <String, String>{};
|
|
||||||
|
|
||||||
const contentTypes = <String>['application/json'];
|
|
||||||
|
|
||||||
|
|
||||||
return apiClient.invokeAPI(
|
|
||||||
apiPath,
|
|
||||||
'POST',
|
|
||||||
queryParams,
|
|
||||||
postBody,
|
|
||||||
headerParams,
|
|
||||||
formParams,
|
|
||||||
contentTypes.isEmpty ? null : contentTypes.first,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parameters:
|
|
||||||
///
|
|
||||||
/// * [FileReportFixDto] fileReportFixDto (required):
|
|
||||||
Future<void> fixAuditFiles(FileReportFixDto fileReportFixDto,) async {
|
|
||||||
final response = await fixAuditFilesWithHttpInfo(fileReportFixDto,);
|
|
||||||
if (response.statusCode >= HttpStatus.badRequest) {
|
|
||||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Performs an HTTP 'GET /reports' operation and returns the [Response].
|
|
||||||
Future<Response> getAuditFilesWithHttpInfo() async {
|
|
||||||
// ignore: prefer_const_declarations
|
|
||||||
final apiPath = r'/reports';
|
|
||||||
|
|
||||||
// ignore: prefer_final_locals
|
|
||||||
Object? postBody;
|
|
||||||
|
|
||||||
final queryParams = <QueryParam>[];
|
|
||||||
final headerParams = <String, String>{};
|
|
||||||
final formParams = <String, String>{};
|
|
||||||
|
|
||||||
const contentTypes = <String>[];
|
|
||||||
|
|
||||||
|
|
||||||
return apiClient.invokeAPI(
|
|
||||||
apiPath,
|
|
||||||
'GET',
|
|
||||||
queryParams,
|
|
||||||
postBody,
|
|
||||||
headerParams,
|
|
||||||
formParams,
|
|
||||||
contentTypes.isEmpty ? null : contentTypes.first,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<FileReportDto?> getAuditFiles() async {
|
|
||||||
final response = await getAuditFilesWithHttpInfo();
|
|
||||||
if (response.statusCode >= HttpStatus.badRequest) {
|
|
||||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
|
||||||
}
|
|
||||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
|
||||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
|
||||||
// FormatException when trying to decode an empty string.
|
|
||||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
|
||||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'FileReportDto',) as FileReportDto;
|
|
||||||
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Performs an HTTP 'POST /reports/checksum' operation and returns the [Response].
|
|
||||||
/// Parameters:
|
|
||||||
///
|
|
||||||
/// * [FileChecksumDto] fileChecksumDto (required):
|
|
||||||
Future<Response> getFileChecksumsWithHttpInfo(FileChecksumDto fileChecksumDto,) async {
|
|
||||||
// ignore: prefer_const_declarations
|
|
||||||
final apiPath = r'/reports/checksum';
|
|
||||||
|
|
||||||
// ignore: prefer_final_locals
|
|
||||||
Object? postBody = fileChecksumDto;
|
|
||||||
|
|
||||||
final queryParams = <QueryParam>[];
|
|
||||||
final headerParams = <String, String>{};
|
|
||||||
final formParams = <String, String>{};
|
|
||||||
|
|
||||||
const contentTypes = <String>['application/json'];
|
|
||||||
|
|
||||||
|
|
||||||
return apiClient.invokeAPI(
|
|
||||||
apiPath,
|
|
||||||
'POST',
|
|
||||||
queryParams,
|
|
||||||
postBody,
|
|
||||||
headerParams,
|
|
||||||
formParams,
|
|
||||||
contentTypes.isEmpty ? null : contentTypes.first,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parameters:
|
|
||||||
///
|
|
||||||
/// * [FileChecksumDto] fileChecksumDto (required):
|
|
||||||
Future<List<FileChecksumResponseDto>?> getFileChecksums(FileChecksumDto fileChecksumDto,) async {
|
|
||||||
final response = await getFileChecksumsWithHttpInfo(fileChecksumDto,);
|
|
||||||
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<FileChecksumResponseDto>') as List)
|
|
||||||
.cast<FileChecksumResponseDto>()
|
|
||||||
.toList(growable: false);
|
|
||||||
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
16
mobile/openapi/lib/api_client.dart
generated
16
mobile/openapi/lib/api_client.dart
generated
@ -320,16 +320,6 @@ class ApiClient {
|
|||||||
return FaceDto.fromJson(value);
|
return FaceDto.fromJson(value);
|
||||||
case 'FacialRecognitionConfig':
|
case 'FacialRecognitionConfig':
|
||||||
return FacialRecognitionConfig.fromJson(value);
|
return FacialRecognitionConfig.fromJson(value);
|
||||||
case 'FileChecksumDto':
|
|
||||||
return FileChecksumDto.fromJson(value);
|
|
||||||
case 'FileChecksumResponseDto':
|
|
||||||
return FileChecksumResponseDto.fromJson(value);
|
|
||||||
case 'FileReportDto':
|
|
||||||
return FileReportDto.fromJson(value);
|
|
||||||
case 'FileReportFixDto':
|
|
||||||
return FileReportFixDto.fromJson(value);
|
|
||||||
case 'FileReportItemDto':
|
|
||||||
return FileReportItemDto.fromJson(value);
|
|
||||||
case 'FoldersResponse':
|
case 'FoldersResponse':
|
||||||
return FoldersResponse.fromJson(value);
|
return FoldersResponse.fromJson(value);
|
||||||
case 'FoldersUpdate':
|
case 'FoldersUpdate':
|
||||||
@ -378,8 +368,6 @@ class ApiClient {
|
|||||||
return MemoriesUpdate.fromJson(value);
|
return MemoriesUpdate.fromJson(value);
|
||||||
case 'MemoryCreateDto':
|
case 'MemoryCreateDto':
|
||||||
return MemoryCreateDto.fromJson(value);
|
return MemoryCreateDto.fromJson(value);
|
||||||
case 'MemoryLaneResponseDto':
|
|
||||||
return MemoryLaneResponseDto.fromJson(value);
|
|
||||||
case 'MemoryResponseDto':
|
case 'MemoryResponseDto':
|
||||||
return MemoryResponseDto.fromJson(value);
|
return MemoryResponseDto.fromJson(value);
|
||||||
case 'MemoryType':
|
case 'MemoryType':
|
||||||
@ -418,10 +406,6 @@ class ApiClient {
|
|||||||
return PartnerDirectionTypeTransformer().decode(value);
|
return PartnerDirectionTypeTransformer().decode(value);
|
||||||
case 'PartnerResponseDto':
|
case 'PartnerResponseDto':
|
||||||
return PartnerResponseDto.fromJson(value);
|
return PartnerResponseDto.fromJson(value);
|
||||||
case 'PathEntityType':
|
|
||||||
return PathEntityTypeTypeTransformer().decode(value);
|
|
||||||
case 'PathType':
|
|
||||||
return PathTypeTypeTransformer().decode(value);
|
|
||||||
case 'PeopleResponse':
|
case 'PeopleResponse':
|
||||||
return PeopleResponse.fromJson(value);
|
return PeopleResponse.fromJson(value);
|
||||||
case 'PeopleResponseDto':
|
case 'PeopleResponseDto':
|
||||||
|
6
mobile/openapi/lib/api_helper.dart
generated
6
mobile/openapi/lib/api_helper.dart
generated
@ -112,12 +112,6 @@ String parameterToString(dynamic value) {
|
|||||||
if (value is PartnerDirection) {
|
if (value is PartnerDirection) {
|
||||||
return PartnerDirectionTypeTransformer().encode(value).toString();
|
return PartnerDirectionTypeTransformer().encode(value).toString();
|
||||||
}
|
}
|
||||||
if (value is PathEntityType) {
|
|
||||||
return PathEntityTypeTypeTransformer().encode(value).toString();
|
|
||||||
}
|
|
||||||
if (value is PathType) {
|
|
||||||
return PathTypeTypeTransformer().encode(value).toString();
|
|
||||||
}
|
|
||||||
if (value is Permission) {
|
if (value is Permission) {
|
||||||
return PermissionTypeTransformer().encode(value).toString();
|
return PermissionTypeTransformer().encode(value).toString();
|
||||||
}
|
}
|
||||||
|
101
mobile/openapi/lib/model/file_checksum_dto.dart
generated
101
mobile/openapi/lib/model/file_checksum_dto.dart
generated
@ -1,101 +0,0 @@
|
|||||||
//
|
|
||||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
|
||||||
//
|
|
||||||
// @dart=2.18
|
|
||||||
|
|
||||||
// ignore_for_file: unused_element, unused_import
|
|
||||||
// ignore_for_file: always_put_required_named_parameters_first
|
|
||||||
// ignore_for_file: constant_identifier_names
|
|
||||||
// ignore_for_file: lines_longer_than_80_chars
|
|
||||||
|
|
||||||
part of openapi.api;
|
|
||||||
|
|
||||||
class FileChecksumDto {
|
|
||||||
/// Returns a new [FileChecksumDto] instance.
|
|
||||||
FileChecksumDto({
|
|
||||||
this.filenames = const [],
|
|
||||||
});
|
|
||||||
|
|
||||||
List<String> filenames;
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) => identical(this, other) || other is FileChecksumDto &&
|
|
||||||
_deepEquality.equals(other.filenames, filenames);
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode =>
|
|
||||||
// ignore: unnecessary_parenthesis
|
|
||||||
(filenames.hashCode);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => 'FileChecksumDto[filenames=$filenames]';
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
final json = <String, dynamic>{};
|
|
||||||
json[r'filenames'] = this.filenames;
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a new [FileChecksumDto] instance and imports its values from
|
|
||||||
/// [value] if it's a [Map], null otherwise.
|
|
||||||
// ignore: prefer_constructors_over_static_methods
|
|
||||||
static FileChecksumDto? fromJson(dynamic value) {
|
|
||||||
upgradeDto(value, "FileChecksumDto");
|
|
||||||
if (value is Map) {
|
|
||||||
final json = value.cast<String, dynamic>();
|
|
||||||
|
|
||||||
return FileChecksumDto(
|
|
||||||
filenames: json[r'filenames'] is Iterable
|
|
||||||
? (json[r'filenames'] as Iterable).cast<String>().toList(growable: false)
|
|
||||||
: const [],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<FileChecksumDto> listFromJson(dynamic json, {bool growable = false,}) {
|
|
||||||
final result = <FileChecksumDto>[];
|
|
||||||
if (json is List && json.isNotEmpty) {
|
|
||||||
for (final row in json) {
|
|
||||||
final value = FileChecksumDto.fromJson(row);
|
|
||||||
if (value != null) {
|
|
||||||
result.add(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result.toList(growable: growable);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Map<String, FileChecksumDto> mapFromJson(dynamic json) {
|
|
||||||
final map = <String, FileChecksumDto>{};
|
|
||||||
if (json is Map && json.isNotEmpty) {
|
|
||||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
|
||||||
for (final entry in json.entries) {
|
|
||||||
final value = FileChecksumDto.fromJson(entry.value);
|
|
||||||
if (value != null) {
|
|
||||||
map[entry.key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
// maps a json object with a list of FileChecksumDto-objects as value to a dart map
|
|
||||||
static Map<String, List<FileChecksumDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
|
||||||
final map = <String, List<FileChecksumDto>>{};
|
|
||||||
if (json is Map && json.isNotEmpty) {
|
|
||||||
// ignore: parameter_assignments
|
|
||||||
json = json.cast<String, dynamic>();
|
|
||||||
for (final entry in json.entries) {
|
|
||||||
map[entry.key] = FileChecksumDto.listFromJson(entry.value, growable: growable,);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The list of required keys that must be present in a JSON.
|
|
||||||
static const requiredKeys = <String>{
|
|
||||||
'filenames',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
107
mobile/openapi/lib/model/file_checksum_response_dto.dart
generated
107
mobile/openapi/lib/model/file_checksum_response_dto.dart
generated
@ -1,107 +0,0 @@
|
|||||||
//
|
|
||||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
|
||||||
//
|
|
||||||
// @dart=2.18
|
|
||||||
|
|
||||||
// ignore_for_file: unused_element, unused_import
|
|
||||||
// ignore_for_file: always_put_required_named_parameters_first
|
|
||||||
// ignore_for_file: constant_identifier_names
|
|
||||||
// ignore_for_file: lines_longer_than_80_chars
|
|
||||||
|
|
||||||
part of openapi.api;
|
|
||||||
|
|
||||||
class FileChecksumResponseDto {
|
|
||||||
/// Returns a new [FileChecksumResponseDto] instance.
|
|
||||||
FileChecksumResponseDto({
|
|
||||||
required this.checksum,
|
|
||||||
required this.filename,
|
|
||||||
});
|
|
||||||
|
|
||||||
String checksum;
|
|
||||||
|
|
||||||
String filename;
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) => identical(this, other) || other is FileChecksumResponseDto &&
|
|
||||||
other.checksum == checksum &&
|
|
||||||
other.filename == filename;
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode =>
|
|
||||||
// ignore: unnecessary_parenthesis
|
|
||||||
(checksum.hashCode) +
|
|
||||||
(filename.hashCode);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => 'FileChecksumResponseDto[checksum=$checksum, filename=$filename]';
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
final json = <String, dynamic>{};
|
|
||||||
json[r'checksum'] = this.checksum;
|
|
||||||
json[r'filename'] = this.filename;
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a new [FileChecksumResponseDto] instance and imports its values from
|
|
||||||
/// [value] if it's a [Map], null otherwise.
|
|
||||||
// ignore: prefer_constructors_over_static_methods
|
|
||||||
static FileChecksumResponseDto? fromJson(dynamic value) {
|
|
||||||
upgradeDto(value, "FileChecksumResponseDto");
|
|
||||||
if (value is Map) {
|
|
||||||
final json = value.cast<String, dynamic>();
|
|
||||||
|
|
||||||
return FileChecksumResponseDto(
|
|
||||||
checksum: mapValueOfType<String>(json, r'checksum')!,
|
|
||||||
filename: mapValueOfType<String>(json, r'filename')!,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<FileChecksumResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
|
||||||
final result = <FileChecksumResponseDto>[];
|
|
||||||
if (json is List && json.isNotEmpty) {
|
|
||||||
for (final row in json) {
|
|
||||||
final value = FileChecksumResponseDto.fromJson(row);
|
|
||||||
if (value != null) {
|
|
||||||
result.add(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result.toList(growable: growable);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Map<String, FileChecksumResponseDto> mapFromJson(dynamic json) {
|
|
||||||
final map = <String, FileChecksumResponseDto>{};
|
|
||||||
if (json is Map && json.isNotEmpty) {
|
|
||||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
|
||||||
for (final entry in json.entries) {
|
|
||||||
final value = FileChecksumResponseDto.fromJson(entry.value);
|
|
||||||
if (value != null) {
|
|
||||||
map[entry.key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
// maps a json object with a list of FileChecksumResponseDto-objects as value to a dart map
|
|
||||||
static Map<String, List<FileChecksumResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
|
||||||
final map = <String, List<FileChecksumResponseDto>>{};
|
|
||||||
if (json is Map && json.isNotEmpty) {
|
|
||||||
// ignore: parameter_assignments
|
|
||||||
json = json.cast<String, dynamic>();
|
|
||||||
for (final entry in json.entries) {
|
|
||||||
map[entry.key] = FileChecksumResponseDto.listFromJson(entry.value, growable: growable,);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The list of required keys that must be present in a JSON.
|
|
||||||
static const requiredKeys = <String>{
|
|
||||||
'checksum',
|
|
||||||
'filename',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
109
mobile/openapi/lib/model/file_report_dto.dart
generated
109
mobile/openapi/lib/model/file_report_dto.dart
generated
@ -1,109 +0,0 @@
|
|||||||
//
|
|
||||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
|
||||||
//
|
|
||||||
// @dart=2.18
|
|
||||||
|
|
||||||
// ignore_for_file: unused_element, unused_import
|
|
||||||
// ignore_for_file: always_put_required_named_parameters_first
|
|
||||||
// ignore_for_file: constant_identifier_names
|
|
||||||
// ignore_for_file: lines_longer_than_80_chars
|
|
||||||
|
|
||||||
part of openapi.api;
|
|
||||||
|
|
||||||
class FileReportDto {
|
|
||||||
/// Returns a new [FileReportDto] instance.
|
|
||||||
FileReportDto({
|
|
||||||
this.extras = const [],
|
|
||||||
this.orphans = const [],
|
|
||||||
});
|
|
||||||
|
|
||||||
List<String> extras;
|
|
||||||
|
|
||||||
List<FileReportItemDto> orphans;
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) => identical(this, other) || other is FileReportDto &&
|
|
||||||
_deepEquality.equals(other.extras, extras) &&
|
|
||||||
_deepEquality.equals(other.orphans, orphans);
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode =>
|
|
||||||
// ignore: unnecessary_parenthesis
|
|
||||||
(extras.hashCode) +
|
|
||||||
(orphans.hashCode);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => 'FileReportDto[extras=$extras, orphans=$orphans]';
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
final json = <String, dynamic>{};
|
|
||||||
json[r'extras'] = this.extras;
|
|
||||||
json[r'orphans'] = this.orphans;
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a new [FileReportDto] instance and imports its values from
|
|
||||||
/// [value] if it's a [Map], null otherwise.
|
|
||||||
// ignore: prefer_constructors_over_static_methods
|
|
||||||
static FileReportDto? fromJson(dynamic value) {
|
|
||||||
upgradeDto(value, "FileReportDto");
|
|
||||||
if (value is Map) {
|
|
||||||
final json = value.cast<String, dynamic>();
|
|
||||||
|
|
||||||
return FileReportDto(
|
|
||||||
extras: json[r'extras'] is Iterable
|
|
||||||
? (json[r'extras'] as Iterable).cast<String>().toList(growable: false)
|
|
||||||
: const [],
|
|
||||||
orphans: FileReportItemDto.listFromJson(json[r'orphans']),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<FileReportDto> listFromJson(dynamic json, {bool growable = false,}) {
|
|
||||||
final result = <FileReportDto>[];
|
|
||||||
if (json is List && json.isNotEmpty) {
|
|
||||||
for (final row in json) {
|
|
||||||
final value = FileReportDto.fromJson(row);
|
|
||||||
if (value != null) {
|
|
||||||
result.add(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result.toList(growable: growable);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Map<String, FileReportDto> mapFromJson(dynamic json) {
|
|
||||||
final map = <String, FileReportDto>{};
|
|
||||||
if (json is Map && json.isNotEmpty) {
|
|
||||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
|
||||||
for (final entry in json.entries) {
|
|
||||||
final value = FileReportDto.fromJson(entry.value);
|
|
||||||
if (value != null) {
|
|
||||||
map[entry.key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
// maps a json object with a list of FileReportDto-objects as value to a dart map
|
|
||||||
static Map<String, List<FileReportDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
|
||||||
final map = <String, List<FileReportDto>>{};
|
|
||||||
if (json is Map && json.isNotEmpty) {
|
|
||||||
// ignore: parameter_assignments
|
|
||||||
json = json.cast<String, dynamic>();
|
|
||||||
for (final entry in json.entries) {
|
|
||||||
map[entry.key] = FileReportDto.listFromJson(entry.value, growable: growable,);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The list of required keys that must be present in a JSON.
|
|
||||||
static const requiredKeys = <String>{
|
|
||||||
'extras',
|
|
||||||
'orphans',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
99
mobile/openapi/lib/model/file_report_fix_dto.dart
generated
99
mobile/openapi/lib/model/file_report_fix_dto.dart
generated
@ -1,99 +0,0 @@
|
|||||||
//
|
|
||||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
|
||||||
//
|
|
||||||
// @dart=2.18
|
|
||||||
|
|
||||||
// ignore_for_file: unused_element, unused_import
|
|
||||||
// ignore_for_file: always_put_required_named_parameters_first
|
|
||||||
// ignore_for_file: constant_identifier_names
|
|
||||||
// ignore_for_file: lines_longer_than_80_chars
|
|
||||||
|
|
||||||
part of openapi.api;
|
|
||||||
|
|
||||||
class FileReportFixDto {
|
|
||||||
/// Returns a new [FileReportFixDto] instance.
|
|
||||||
FileReportFixDto({
|
|
||||||
this.items = const [],
|
|
||||||
});
|
|
||||||
|
|
||||||
List<FileReportItemDto> items;
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) => identical(this, other) || other is FileReportFixDto &&
|
|
||||||
_deepEquality.equals(other.items, items);
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode =>
|
|
||||||
// ignore: unnecessary_parenthesis
|
|
||||||
(items.hashCode);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => 'FileReportFixDto[items=$items]';
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
final json = <String, dynamic>{};
|
|
||||||
json[r'items'] = this.items;
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a new [FileReportFixDto] instance and imports its values from
|
|
||||||
/// [value] if it's a [Map], null otherwise.
|
|
||||||
// ignore: prefer_constructors_over_static_methods
|
|
||||||
static FileReportFixDto? fromJson(dynamic value) {
|
|
||||||
upgradeDto(value, "FileReportFixDto");
|
|
||||||
if (value is Map) {
|
|
||||||
final json = value.cast<String, dynamic>();
|
|
||||||
|
|
||||||
return FileReportFixDto(
|
|
||||||
items: FileReportItemDto.listFromJson(json[r'items']),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<FileReportFixDto> listFromJson(dynamic json, {bool growable = false,}) {
|
|
||||||
final result = <FileReportFixDto>[];
|
|
||||||
if (json is List && json.isNotEmpty) {
|
|
||||||
for (final row in json) {
|
|
||||||
final value = FileReportFixDto.fromJson(row);
|
|
||||||
if (value != null) {
|
|
||||||
result.add(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result.toList(growable: growable);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Map<String, FileReportFixDto> mapFromJson(dynamic json) {
|
|
||||||
final map = <String, FileReportFixDto>{};
|
|
||||||
if (json is Map && json.isNotEmpty) {
|
|
||||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
|
||||||
for (final entry in json.entries) {
|
|
||||||
final value = FileReportFixDto.fromJson(entry.value);
|
|
||||||
if (value != null) {
|
|
||||||
map[entry.key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
// maps a json object with a list of FileReportFixDto-objects as value to a dart map
|
|
||||||
static Map<String, List<FileReportFixDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
|
||||||
final map = <String, List<FileReportFixDto>>{};
|
|
||||||
if (json is Map && json.isNotEmpty) {
|
|
||||||
// ignore: parameter_assignments
|
|
||||||
json = json.cast<String, dynamic>();
|
|
||||||
for (final entry in json.entries) {
|
|
||||||
map[entry.key] = FileReportFixDto.listFromJson(entry.value, growable: growable,);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The list of required keys that must be present in a JSON.
|
|
||||||
static const requiredKeys = <String>{
|
|
||||||
'items',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
140
mobile/openapi/lib/model/file_report_item_dto.dart
generated
140
mobile/openapi/lib/model/file_report_item_dto.dart
generated
@ -1,140 +0,0 @@
|
|||||||
//
|
|
||||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
|
||||||
//
|
|
||||||
// @dart=2.18
|
|
||||||
|
|
||||||
// ignore_for_file: unused_element, unused_import
|
|
||||||
// ignore_for_file: always_put_required_named_parameters_first
|
|
||||||
// ignore_for_file: constant_identifier_names
|
|
||||||
// ignore_for_file: lines_longer_than_80_chars
|
|
||||||
|
|
||||||
part of openapi.api;
|
|
||||||
|
|
||||||
class FileReportItemDto {
|
|
||||||
/// Returns a new [FileReportItemDto] instance.
|
|
||||||
FileReportItemDto({
|
|
||||||
this.checksum,
|
|
||||||
required this.entityId,
|
|
||||||
required this.entityType,
|
|
||||||
required this.pathType,
|
|
||||||
required this.pathValue,
|
|
||||||
});
|
|
||||||
|
|
||||||
///
|
|
||||||
/// Please note: This property should have been non-nullable! Since the specification file
|
|
||||||
/// does not include a default value (using the "default:" property), however, the generated
|
|
||||||
/// source code must fall back to having a nullable type.
|
|
||||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
|
||||||
///
|
|
||||||
String? checksum;
|
|
||||||
|
|
||||||
String entityId;
|
|
||||||
|
|
||||||
PathEntityType entityType;
|
|
||||||
|
|
||||||
PathType pathType;
|
|
||||||
|
|
||||||
String pathValue;
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) => identical(this, other) || other is FileReportItemDto &&
|
|
||||||
other.checksum == checksum &&
|
|
||||||
other.entityId == entityId &&
|
|
||||||
other.entityType == entityType &&
|
|
||||||
other.pathType == pathType &&
|
|
||||||
other.pathValue == pathValue;
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode =>
|
|
||||||
// ignore: unnecessary_parenthesis
|
|
||||||
(checksum == null ? 0 : checksum!.hashCode) +
|
|
||||||
(entityId.hashCode) +
|
|
||||||
(entityType.hashCode) +
|
|
||||||
(pathType.hashCode) +
|
|
||||||
(pathValue.hashCode);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => 'FileReportItemDto[checksum=$checksum, entityId=$entityId, entityType=$entityType, pathType=$pathType, pathValue=$pathValue]';
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
final json = <String, dynamic>{};
|
|
||||||
if (this.checksum != null) {
|
|
||||||
json[r'checksum'] = this.checksum;
|
|
||||||
} else {
|
|
||||||
// json[r'checksum'] = null;
|
|
||||||
}
|
|
||||||
json[r'entityId'] = this.entityId;
|
|
||||||
json[r'entityType'] = this.entityType;
|
|
||||||
json[r'pathType'] = this.pathType;
|
|
||||||
json[r'pathValue'] = this.pathValue;
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a new [FileReportItemDto] instance and imports its values from
|
|
||||||
/// [value] if it's a [Map], null otherwise.
|
|
||||||
// ignore: prefer_constructors_over_static_methods
|
|
||||||
static FileReportItemDto? fromJson(dynamic value) {
|
|
||||||
upgradeDto(value, "FileReportItemDto");
|
|
||||||
if (value is Map) {
|
|
||||||
final json = value.cast<String, dynamic>();
|
|
||||||
|
|
||||||
return FileReportItemDto(
|
|
||||||
checksum: mapValueOfType<String>(json, r'checksum'),
|
|
||||||
entityId: mapValueOfType<String>(json, r'entityId')!,
|
|
||||||
entityType: PathEntityType.fromJson(json[r'entityType'])!,
|
|
||||||
pathType: PathType.fromJson(json[r'pathType'])!,
|
|
||||||
pathValue: mapValueOfType<String>(json, r'pathValue')!,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<FileReportItemDto> listFromJson(dynamic json, {bool growable = false,}) {
|
|
||||||
final result = <FileReportItemDto>[];
|
|
||||||
if (json is List && json.isNotEmpty) {
|
|
||||||
for (final row in json) {
|
|
||||||
final value = FileReportItemDto.fromJson(row);
|
|
||||||
if (value != null) {
|
|
||||||
result.add(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result.toList(growable: growable);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Map<String, FileReportItemDto> mapFromJson(dynamic json) {
|
|
||||||
final map = <String, FileReportItemDto>{};
|
|
||||||
if (json is Map && json.isNotEmpty) {
|
|
||||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
|
||||||
for (final entry in json.entries) {
|
|
||||||
final value = FileReportItemDto.fromJson(entry.value);
|
|
||||||
if (value != null) {
|
|
||||||
map[entry.key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
// maps a json object with a list of FileReportItemDto-objects as value to a dart map
|
|
||||||
static Map<String, List<FileReportItemDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
|
||||||
final map = <String, List<FileReportItemDto>>{};
|
|
||||||
if (json is Map && json.isNotEmpty) {
|
|
||||||
// ignore: parameter_assignments
|
|
||||||
json = json.cast<String, dynamic>();
|
|
||||||
for (final entry in json.entries) {
|
|
||||||
map[entry.key] = FileReportItemDto.listFromJson(entry.value, growable: growable,);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The list of required keys that must be present in a JSON.
|
|
||||||
static const requiredKeys = <String>{
|
|
||||||
'entityId',
|
|
||||||
'entityType',
|
|
||||||
'pathType',
|
|
||||||
'pathValue',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
107
mobile/openapi/lib/model/memory_lane_response_dto.dart
generated
107
mobile/openapi/lib/model/memory_lane_response_dto.dart
generated
@ -1,107 +0,0 @@
|
|||||||
//
|
|
||||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
|
||||||
//
|
|
||||||
// @dart=2.18
|
|
||||||
|
|
||||||
// ignore_for_file: unused_element, unused_import
|
|
||||||
// ignore_for_file: always_put_required_named_parameters_first
|
|
||||||
// ignore_for_file: constant_identifier_names
|
|
||||||
// ignore_for_file: lines_longer_than_80_chars
|
|
||||||
|
|
||||||
part of openapi.api;
|
|
||||||
|
|
||||||
class MemoryLaneResponseDto {
|
|
||||||
/// Returns a new [MemoryLaneResponseDto] instance.
|
|
||||||
MemoryLaneResponseDto({
|
|
||||||
this.assets = const [],
|
|
||||||
required this.yearsAgo,
|
|
||||||
});
|
|
||||||
|
|
||||||
List<AssetResponseDto> assets;
|
|
||||||
|
|
||||||
int yearsAgo;
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) => identical(this, other) || other is MemoryLaneResponseDto &&
|
|
||||||
_deepEquality.equals(other.assets, assets) &&
|
|
||||||
other.yearsAgo == yearsAgo;
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode =>
|
|
||||||
// ignore: unnecessary_parenthesis
|
|
||||||
(assets.hashCode) +
|
|
||||||
(yearsAgo.hashCode);
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => 'MemoryLaneResponseDto[assets=$assets, yearsAgo=$yearsAgo]';
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
final json = <String, dynamic>{};
|
|
||||||
json[r'assets'] = this.assets;
|
|
||||||
json[r'yearsAgo'] = this.yearsAgo;
|
|
||||||
return json;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns a new [MemoryLaneResponseDto] instance and imports its values from
|
|
||||||
/// [value] if it's a [Map], null otherwise.
|
|
||||||
// ignore: prefer_constructors_over_static_methods
|
|
||||||
static MemoryLaneResponseDto? fromJson(dynamic value) {
|
|
||||||
upgradeDto(value, "MemoryLaneResponseDto");
|
|
||||||
if (value is Map) {
|
|
||||||
final json = value.cast<String, dynamic>();
|
|
||||||
|
|
||||||
return MemoryLaneResponseDto(
|
|
||||||
assets: AssetResponseDto.listFromJson(json[r'assets']),
|
|
||||||
yearsAgo: mapValueOfType<int>(json, r'yearsAgo')!,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static List<MemoryLaneResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
|
|
||||||
final result = <MemoryLaneResponseDto>[];
|
|
||||||
if (json is List && json.isNotEmpty) {
|
|
||||||
for (final row in json) {
|
|
||||||
final value = MemoryLaneResponseDto.fromJson(row);
|
|
||||||
if (value != null) {
|
|
||||||
result.add(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result.toList(growable: growable);
|
|
||||||
}
|
|
||||||
|
|
||||||
static Map<String, MemoryLaneResponseDto> mapFromJson(dynamic json) {
|
|
||||||
final map = <String, MemoryLaneResponseDto>{};
|
|
||||||
if (json is Map && json.isNotEmpty) {
|
|
||||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
|
||||||
for (final entry in json.entries) {
|
|
||||||
final value = MemoryLaneResponseDto.fromJson(entry.value);
|
|
||||||
if (value != null) {
|
|
||||||
map[entry.key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
// maps a json object with a list of MemoryLaneResponseDto-objects as value to a dart map
|
|
||||||
static Map<String, List<MemoryLaneResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
|
||||||
final map = <String, List<MemoryLaneResponseDto>>{};
|
|
||||||
if (json is Map && json.isNotEmpty) {
|
|
||||||
// ignore: parameter_assignments
|
|
||||||
json = json.cast<String, dynamic>();
|
|
||||||
for (final entry in json.entries) {
|
|
||||||
map[entry.key] = MemoryLaneResponseDto.listFromJson(entry.value, growable: growable,);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The list of required keys that must be present in a JSON.
|
|
||||||
static const requiredKeys = <String>{
|
|
||||||
'assets',
|
|
||||||
'yearsAgo',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
88
mobile/openapi/lib/model/path_entity_type.dart
generated
88
mobile/openapi/lib/model/path_entity_type.dart
generated
@ -1,88 +0,0 @@
|
|||||||
//
|
|
||||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
|
||||||
//
|
|
||||||
// @dart=2.18
|
|
||||||
|
|
||||||
// ignore_for_file: unused_element, unused_import
|
|
||||||
// ignore_for_file: always_put_required_named_parameters_first
|
|
||||||
// ignore_for_file: constant_identifier_names
|
|
||||||
// ignore_for_file: lines_longer_than_80_chars
|
|
||||||
|
|
||||||
part of openapi.api;
|
|
||||||
|
|
||||||
|
|
||||||
class PathEntityType {
|
|
||||||
/// Instantiate a new enum with the provided [value].
|
|
||||||
const PathEntityType._(this.value);
|
|
||||||
|
|
||||||
/// The underlying value of this enum member.
|
|
||||||
final String value;
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => value;
|
|
||||||
|
|
||||||
String toJson() => value;
|
|
||||||
|
|
||||||
static const asset = PathEntityType._(r'asset');
|
|
||||||
static const person = PathEntityType._(r'person');
|
|
||||||
static const user = PathEntityType._(r'user');
|
|
||||||
|
|
||||||
/// List of all possible values in this [enum][PathEntityType].
|
|
||||||
static const values = <PathEntityType>[
|
|
||||||
asset,
|
|
||||||
person,
|
|
||||||
user,
|
|
||||||
];
|
|
||||||
|
|
||||||
static PathEntityType? fromJson(dynamic value) => PathEntityTypeTypeTransformer().decode(value);
|
|
||||||
|
|
||||||
static List<PathEntityType> listFromJson(dynamic json, {bool growable = false,}) {
|
|
||||||
final result = <PathEntityType>[];
|
|
||||||
if (json is List && json.isNotEmpty) {
|
|
||||||
for (final row in json) {
|
|
||||||
final value = PathEntityType.fromJson(row);
|
|
||||||
if (value != null) {
|
|
||||||
result.add(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result.toList(growable: growable);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Transformation class that can [encode] an instance of [PathEntityType] to String,
|
|
||||||
/// and [decode] dynamic data back to [PathEntityType].
|
|
||||||
class PathEntityTypeTypeTransformer {
|
|
||||||
factory PathEntityTypeTypeTransformer() => _instance ??= const PathEntityTypeTypeTransformer._();
|
|
||||||
|
|
||||||
const PathEntityTypeTypeTransformer._();
|
|
||||||
|
|
||||||
String encode(PathEntityType data) => data.value;
|
|
||||||
|
|
||||||
/// Decodes a [dynamic value][data] to a PathEntityType.
|
|
||||||
///
|
|
||||||
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
|
|
||||||
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
|
|
||||||
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
|
|
||||||
///
|
|
||||||
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
|
|
||||||
/// and users are still using an old app with the old code.
|
|
||||||
PathEntityType? decode(dynamic data, {bool allowNull = true}) {
|
|
||||||
if (data != null) {
|
|
||||||
switch (data) {
|
|
||||||
case r'asset': return PathEntityType.asset;
|
|
||||||
case r'person': return PathEntityType.person;
|
|
||||||
case r'user': return PathEntityType.user;
|
|
||||||
default:
|
|
||||||
if (!allowNull) {
|
|
||||||
throw ArgumentError('Unknown enum value to decode: $data');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Singleton [PathEntityTypeTypeTransformer] instance.
|
|
||||||
static PathEntityTypeTypeTransformer? _instance;
|
|
||||||
}
|
|
||||||
|
|
103
mobile/openapi/lib/model/path_type.dart
generated
103
mobile/openapi/lib/model/path_type.dart
generated
@ -1,103 +0,0 @@
|
|||||||
//
|
|
||||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
|
||||||
//
|
|
||||||
// @dart=2.18
|
|
||||||
|
|
||||||
// ignore_for_file: unused_element, unused_import
|
|
||||||
// ignore_for_file: always_put_required_named_parameters_first
|
|
||||||
// ignore_for_file: constant_identifier_names
|
|
||||||
// ignore_for_file: lines_longer_than_80_chars
|
|
||||||
|
|
||||||
part of openapi.api;
|
|
||||||
|
|
||||||
|
|
||||||
class PathType {
|
|
||||||
/// Instantiate a new enum with the provided [value].
|
|
||||||
const PathType._(this.value);
|
|
||||||
|
|
||||||
/// The underlying value of this enum member.
|
|
||||||
final String value;
|
|
||||||
|
|
||||||
@override
|
|
||||||
String toString() => value;
|
|
||||||
|
|
||||||
String toJson() => value;
|
|
||||||
|
|
||||||
static const original = PathType._(r'original');
|
|
||||||
static const fullsize = PathType._(r'fullsize');
|
|
||||||
static const preview = PathType._(r'preview');
|
|
||||||
static const thumbnail = PathType._(r'thumbnail');
|
|
||||||
static const encodedVideo = PathType._(r'encoded_video');
|
|
||||||
static const sidecar = PathType._(r'sidecar');
|
|
||||||
static const face = PathType._(r'face');
|
|
||||||
static const profile = PathType._(r'profile');
|
|
||||||
|
|
||||||
/// List of all possible values in this [enum][PathType].
|
|
||||||
static const values = <PathType>[
|
|
||||||
original,
|
|
||||||
fullsize,
|
|
||||||
preview,
|
|
||||||
thumbnail,
|
|
||||||
encodedVideo,
|
|
||||||
sidecar,
|
|
||||||
face,
|
|
||||||
profile,
|
|
||||||
];
|
|
||||||
|
|
||||||
static PathType? fromJson(dynamic value) => PathTypeTypeTransformer().decode(value);
|
|
||||||
|
|
||||||
static List<PathType> listFromJson(dynamic json, {bool growable = false,}) {
|
|
||||||
final result = <PathType>[];
|
|
||||||
if (json is List && json.isNotEmpty) {
|
|
||||||
for (final row in json) {
|
|
||||||
final value = PathType.fromJson(row);
|
|
||||||
if (value != null) {
|
|
||||||
result.add(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result.toList(growable: growable);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Transformation class that can [encode] an instance of [PathType] to String,
|
|
||||||
/// and [decode] dynamic data back to [PathType].
|
|
||||||
class PathTypeTypeTransformer {
|
|
||||||
factory PathTypeTypeTransformer() => _instance ??= const PathTypeTypeTransformer._();
|
|
||||||
|
|
||||||
const PathTypeTypeTransformer._();
|
|
||||||
|
|
||||||
String encode(PathType data) => data.value;
|
|
||||||
|
|
||||||
/// Decodes a [dynamic value][data] to a PathType.
|
|
||||||
///
|
|
||||||
/// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully,
|
|
||||||
/// then null is returned. However, if [allowNull] is false and the [dynamic value][data]
|
|
||||||
/// cannot be decoded successfully, then an [UnimplementedError] is thrown.
|
|
||||||
///
|
|
||||||
/// The [allowNull] is very handy when an API changes and a new enum value is added or removed,
|
|
||||||
/// and users are still using an old app with the old code.
|
|
||||||
PathType? decode(dynamic data, {bool allowNull = true}) {
|
|
||||||
if (data != null) {
|
|
||||||
switch (data) {
|
|
||||||
case r'original': return PathType.original;
|
|
||||||
case r'fullsize': return PathType.fullsize;
|
|
||||||
case r'preview': return PathType.preview;
|
|
||||||
case r'thumbnail': return PathType.thumbnail;
|
|
||||||
case r'encoded_video': return PathType.encodedVideo;
|
|
||||||
case r'sidecar': return PathType.sidecar;
|
|
||||||
case r'face': return PathType.face;
|
|
||||||
case r'profile': return PathType.profile;
|
|
||||||
default:
|
|
||||||
if (!allowNull) {
|
|
||||||
throw ArgumentError('Unknown enum value to decode: $data');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Singleton [PathTypeTypeTransformer] instance.
|
|
||||||
static PathTypeTypeTransformer? _instance;
|
|
||||||
}
|
|
||||||
|
|
@ -1726,62 +1726,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/assets/memory-lane": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "getMemoryLane",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "day",
|
|
||||||
"required": true,
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"minimum": 1,
|
|
||||||
"maximum": 31,
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "month",
|
|
||||||
"required": true,
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"minimum": 1,
|
|
||||||
"maximum": 12,
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/MemoryLaneResponseDto"
|
|
||||||
},
|
|
||||||
"type": "array"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"bearer": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cookie": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"api_key": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"Assets"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/assets/random": {
|
"/assets/random": {
|
||||||
"get": {
|
"get": {
|
||||||
"deprecated": true,
|
"deprecated": true,
|
||||||
@ -4651,118 +4595,6 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/reports": {
|
|
||||||
"get": {
|
|
||||||
"operationId": "getAuditFiles",
|
|
||||||
"parameters": [],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/FileReportDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"bearer": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cookie": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"api_key": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"File Reports"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/reports/checksum": {
|
|
||||||
"post": {
|
|
||||||
"operationId": "getFileChecksums",
|
|
||||||
"parameters": [],
|
|
||||||
"requestBody": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/FileChecksumDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"201": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/FileChecksumResponseDto"
|
|
||||||
},
|
|
||||||
"type": "array"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"description": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"bearer": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cookie": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"api_key": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"File Reports"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/reports/fix": {
|
|
||||||
"post": {
|
|
||||||
"operationId": "fixAuditFiles",
|
|
||||||
"parameters": [],
|
|
||||||
"requestBody": {
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"$ref": "#/components/schemas/FileReportFixDto"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": true
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"201": {
|
|
||||||
"description": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"security": [
|
|
||||||
{
|
|
||||||
"bearer": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"cookie": []
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"api_key": []
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"tags": [
|
|
||||||
"File Reports"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/search/cities": {
|
"/search/cities": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "getAssetsByCity",
|
"operationId": "getAssetsByCity",
|
||||||
@ -9749,105 +9581,6 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"FileChecksumDto": {
|
|
||||||
"properties": {
|
|
||||||
"filenames": {
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"type": "array"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"filenames"
|
|
||||||
],
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"FileChecksumResponseDto": {
|
|
||||||
"properties": {
|
|
||||||
"checksum": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"filename": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"checksum",
|
|
||||||
"filename"
|
|
||||||
],
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"FileReportDto": {
|
|
||||||
"properties": {
|
|
||||||
"extras": {
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"type": "array"
|
|
||||||
},
|
|
||||||
"orphans": {
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/FileReportItemDto"
|
|
||||||
},
|
|
||||||
"type": "array"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"extras",
|
|
||||||
"orphans"
|
|
||||||
],
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"FileReportFixDto": {
|
|
||||||
"properties": {
|
|
||||||
"items": {
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/FileReportItemDto"
|
|
||||||
},
|
|
||||||
"type": "array"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"items"
|
|
||||||
],
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"FileReportItemDto": {
|
|
||||||
"properties": {
|
|
||||||
"checksum": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"entityId": {
|
|
||||||
"format": "uuid",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"entityType": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/PathEntityType"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"pathType": {
|
|
||||||
"allOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/PathType"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"pathValue": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"entityId",
|
|
||||||
"entityType",
|
|
||||||
"pathType",
|
|
||||||
"pathValue"
|
|
||||||
],
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"FoldersResponse": {
|
"FoldersResponse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"enabled": {
|
"enabled": {
|
||||||
@ -10328,24 +10061,6 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"MemoryLaneResponseDto": {
|
|
||||||
"properties": {
|
|
||||||
"assets": {
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/AssetResponseDto"
|
|
||||||
},
|
|
||||||
"type": "array"
|
|
||||||
},
|
|
||||||
"yearsAgo": {
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"assets",
|
|
||||||
"yearsAgo"
|
|
||||||
],
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"MemoryResponseDto": {
|
"MemoryResponseDto": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"assets": {
|
"assets": {
|
||||||
@ -10889,27 +10604,6 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"PathEntityType": {
|
|
||||||
"enum": [
|
|
||||||
"asset",
|
|
||||||
"person",
|
|
||||||
"user"
|
|
||||||
],
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"PathType": {
|
|
||||||
"enum": [
|
|
||||||
"original",
|
|
||||||
"fullsize",
|
|
||||||
"preview",
|
|
||||||
"thumbnail",
|
|
||||||
"encoded_video",
|
|
||||||
"sidecar",
|
|
||||||
"face",
|
|
||||||
"profile"
|
|
||||||
],
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"PeopleResponse": {
|
"PeopleResponse": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"enabled": {
|
"enabled": {
|
||||||
|
@ -462,10 +462,6 @@ export type AssetJobsDto = {
|
|||||||
assetIds: string[];
|
assetIds: string[];
|
||||||
name: AssetJobName;
|
name: AssetJobName;
|
||||||
};
|
};
|
||||||
export type MemoryLaneResponseDto = {
|
|
||||||
assets: AssetResponseDto[];
|
|
||||||
yearsAgo: number;
|
|
||||||
};
|
|
||||||
export type AssetStatsResponseDto = {
|
export type AssetStatsResponseDto = {
|
||||||
images: number;
|
images: number;
|
||||||
total: number;
|
total: number;
|
||||||
@ -800,27 +796,6 @@ export type AssetFaceUpdateDto = {
|
|||||||
export type PersonStatisticsResponseDto = {
|
export type PersonStatisticsResponseDto = {
|
||||||
assets: number;
|
assets: number;
|
||||||
};
|
};
|
||||||
export type FileReportItemDto = {
|
|
||||||
checksum?: string;
|
|
||||||
entityId: string;
|
|
||||||
entityType: PathEntityType;
|
|
||||||
pathType: PathType;
|
|
||||||
pathValue: string;
|
|
||||||
};
|
|
||||||
export type FileReportDto = {
|
|
||||||
extras: string[];
|
|
||||||
orphans: FileReportItemDto[];
|
|
||||||
};
|
|
||||||
export type FileChecksumDto = {
|
|
||||||
filenames: string[];
|
|
||||||
};
|
|
||||||
export type FileChecksumResponseDto = {
|
|
||||||
checksum: string;
|
|
||||||
filename: string;
|
|
||||||
};
|
|
||||||
export type FileReportFixDto = {
|
|
||||||
items: FileReportItemDto[];
|
|
||||||
};
|
|
||||||
export type SearchExploreItem = {
|
export type SearchExploreItem = {
|
||||||
data: AssetResponseDto;
|
data: AssetResponseDto;
|
||||||
value: string;
|
value: string;
|
||||||
@ -1887,20 +1862,6 @@ export function runAssetJobs({ assetJobsDto }: {
|
|||||||
body: assetJobsDto
|
body: assetJobsDto
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
export function getMemoryLane({ day, month }: {
|
|
||||||
day: number;
|
|
||||||
month: number;
|
|
||||||
}, opts?: Oazapfts.RequestOpts) {
|
|
||||||
return oazapfts.ok(oazapfts.fetchJson<{
|
|
||||||
status: 200;
|
|
||||||
data: MemoryLaneResponseDto[];
|
|
||||||
}>(`/assets/memory-lane${QS.query(QS.explode({
|
|
||||||
day,
|
|
||||||
month
|
|
||||||
}))}`, {
|
|
||||||
...opts
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* This property was deprecated in v1.116.0
|
* This property was deprecated in v1.116.0
|
||||||
*/
|
*/
|
||||||
@ -2663,35 +2624,6 @@ export function getPersonThumbnail({ id }: {
|
|||||||
...opts
|
...opts
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
export function getAuditFiles(opts?: Oazapfts.RequestOpts) {
|
|
||||||
return oazapfts.ok(oazapfts.fetchJson<{
|
|
||||||
status: 200;
|
|
||||||
data: FileReportDto;
|
|
||||||
}>("/reports", {
|
|
||||||
...opts
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
export function getFileChecksums({ fileChecksumDto }: {
|
|
||||||
fileChecksumDto: FileChecksumDto;
|
|
||||||
}, opts?: Oazapfts.RequestOpts) {
|
|
||||||
return oazapfts.ok(oazapfts.fetchJson<{
|
|
||||||
status: 201;
|
|
||||||
data: FileChecksumResponseDto[];
|
|
||||||
}>("/reports/checksum", oazapfts.json({
|
|
||||||
...opts,
|
|
||||||
method: "POST",
|
|
||||||
body: fileChecksumDto
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
export function fixAuditFiles({ fileReportFixDto }: {
|
|
||||||
fileReportFixDto: FileReportFixDto;
|
|
||||||
}, opts?: Oazapfts.RequestOpts) {
|
|
||||||
return oazapfts.ok(oazapfts.fetchText("/reports/fix", oazapfts.json({
|
|
||||||
...opts,
|
|
||||||
method: "POST",
|
|
||||||
body: fileReportFixDto
|
|
||||||
})));
|
|
||||||
}
|
|
||||||
export function getAssetsByCity(opts?: Oazapfts.RequestOpts) {
|
export function getAssetsByCity(opts?: Oazapfts.RequestOpts) {
|
||||||
return oazapfts.ok(oazapfts.fetchJson<{
|
return oazapfts.ok(oazapfts.fetchJson<{
|
||||||
status: 200;
|
status: 200;
|
||||||
@ -3751,21 +3683,6 @@ export enum PartnerDirection {
|
|||||||
SharedBy = "shared-by",
|
SharedBy = "shared-by",
|
||||||
SharedWith = "shared-with"
|
SharedWith = "shared-with"
|
||||||
}
|
}
|
||||||
export enum PathEntityType {
|
|
||||||
Asset = "asset",
|
|
||||||
Person = "person",
|
|
||||||
User = "user"
|
|
||||||
}
|
|
||||||
export enum PathType {
|
|
||||||
Original = "original",
|
|
||||||
Fullsize = "fullsize",
|
|
||||||
Preview = "preview",
|
|
||||||
Thumbnail = "thumbnail",
|
|
||||||
EncodedVideo = "encoded_video",
|
|
||||||
Sidecar = "sidecar",
|
|
||||||
Face = "face",
|
|
||||||
Profile = "profile"
|
|
||||||
}
|
|
||||||
export enum SearchSuggestionType {
|
export enum SearchSuggestionType {
|
||||||
Country = "country",
|
Country = "country",
|
||||||
State = "state",
|
State = "state",
|
||||||
|
57
server/package-lock.json
generated
57
server/package-lock.json
generated
@ -28,7 +28,7 @@
|
|||||||
"archiver": "^7.0.0",
|
"archiver": "^7.0.0",
|
||||||
"async-lock": "^1.4.0",
|
"async-lock": "^1.4.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"bullmq": "^4.8.0",
|
"bullmq": "^5.51.0",
|
||||||
"chokidar": "^3.5.3",
|
"chokidar": "^3.5.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
@ -6886,63 +6886,20 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bullmq": {
|
"node_modules/bullmq": {
|
||||||
"version": "4.18.2",
|
"version": "5.51.0",
|
||||||
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-4.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.51.0.tgz",
|
||||||
"integrity": "sha512-Cx0O98IlGiFw7UBa+zwGz+nH0Pcl1wfTvMVBlsMna3s0219hXroVovh1xPRgomyUcbyciHiugGCkW0RRNZDHYQ==",
|
"integrity": "sha512-YjX+CO2U4nmbCq2ZgNb/Hnu6Xk953j8EFmp0eehTuudavPyNstoZsbnyvvM6PX9rfD9clhcc5kRLyyWoFEM3Lg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cron-parser": "^4.6.0",
|
"cron-parser": "^4.9.0",
|
||||||
"glob": "^8.0.3",
|
"ioredis": "^5.4.1",
|
||||||
"ioredis": "^5.3.2",
|
"msgpackr": "^1.11.2",
|
||||||
"lodash": "^4.17.21",
|
|
||||||
"msgpackr": "^1.6.2",
|
|
||||||
"node-abort-controller": "^3.1.1",
|
"node-abort-controller": "^3.1.1",
|
||||||
"semver": "^7.5.4",
|
"semver": "^7.5.4",
|
||||||
"tslib": "^2.0.0",
|
"tslib": "^2.0.0",
|
||||||
"uuid": "^9.0.0"
|
"uuid": "^9.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bullmq/node_modules/brace-expansion": {
|
|
||||||
"version": "2.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
|
||||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^1.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bullmq/node_modules/glob": {
|
|
||||||
"version": "8.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
|
|
||||||
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
|
|
||||||
"deprecated": "Glob versions prior to v9 are no longer supported",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"fs.realpath": "^1.0.0",
|
|
||||||
"inflight": "^1.0.4",
|
|
||||||
"inherits": "2",
|
|
||||||
"minimatch": "^5.0.1",
|
|
||||||
"once": "^1.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/bullmq/node_modules/minimatch": {
|
|
||||||
"version": "5.1.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
|
||||||
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"brace-expansion": "^2.0.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/busboy": {
|
"node_modules/busboy": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||||
|
@ -53,7 +53,7 @@
|
|||||||
"archiver": "^7.0.0",
|
"archiver": "^7.0.0",
|
||||||
"async-lock": "^1.4.0",
|
"async-lock": "^1.4.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"bullmq": "^4.8.0",
|
"bullmq": "^5.51.0",
|
||||||
"chokidar": "^3.5.3",
|
"chokidar": "^3.5.3",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.0",
|
"class-validator": "^0.14.0",
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
|
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
|
||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
import { EndpointLifecycle } from 'src/decorators';
|
import { EndpointLifecycle } from 'src/decorators';
|
||||||
import { AssetResponseDto, MemoryLaneResponseDto } from 'src/dtos/asset-response.dto';
|
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||||
import {
|
import {
|
||||||
AssetBulkDeleteDto,
|
AssetBulkDeleteDto,
|
||||||
AssetBulkUpdateDto,
|
AssetBulkUpdateDto,
|
||||||
@ -13,7 +13,6 @@ import {
|
|||||||
UpdateAssetDto,
|
UpdateAssetDto,
|
||||||
} from 'src/dtos/asset.dto';
|
} from 'src/dtos/asset.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { MemoryLaneDto } from 'src/dtos/search.dto';
|
|
||||||
import { RouteKey } from 'src/enum';
|
import { RouteKey } from 'src/enum';
|
||||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||||
import { AssetService } from 'src/services/asset.service';
|
import { AssetService } from 'src/services/asset.service';
|
||||||
@ -24,12 +23,6 @@ import { UUIDParamDto } from 'src/validation';
|
|||||||
export class AssetController {
|
export class AssetController {
|
||||||
constructor(private service: AssetService) {}
|
constructor(private service: AssetService) {}
|
||||||
|
|
||||||
@Get('memory-lane')
|
|
||||||
@Authenticated()
|
|
||||||
getMemoryLane(@Auth() auth: AuthDto, @Query() dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> {
|
|
||||||
return this.service.getMemoryLane(auth, dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('random')
|
@Get('random')
|
||||||
@Authenticated()
|
@Authenticated()
|
||||||
@EndpointLifecycle({ deprecatedAt: 'v1.116.0' })
|
@EndpointLifecycle({ deprecatedAt: 'v1.116.0' })
|
||||||
|
@ -1,29 +0,0 @@
|
|||||||
import { Body, Controller, Get, Post } from '@nestjs/common';
|
|
||||||
import { ApiTags } from '@nestjs/swagger';
|
|
||||||
import { FileChecksumDto, FileChecksumResponseDto, FileReportDto, FileReportFixDto } from 'src/dtos/audit.dto';
|
|
||||||
import { Authenticated } from 'src/middleware/auth.guard';
|
|
||||||
import { AuditService } from 'src/services/audit.service';
|
|
||||||
|
|
||||||
@ApiTags('File Reports')
|
|
||||||
@Controller('reports')
|
|
||||||
export class ReportController {
|
|
||||||
constructor(private service: AuditService) {}
|
|
||||||
|
|
||||||
@Get()
|
|
||||||
@Authenticated({ admin: true })
|
|
||||||
getAuditFiles(): Promise<FileReportDto> {
|
|
||||||
return this.service.getFileReport();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('checksum')
|
|
||||||
@Authenticated({ admin: true })
|
|
||||||
getFileChecksums(@Body() dto: FileChecksumDto): Promise<FileChecksumResponseDto[]> {
|
|
||||||
return this.service.getChecksums(dto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Post('fix')
|
|
||||||
@Authenticated({ admin: true })
|
|
||||||
fixAuditFiles(@Body() dto: FileReportFixDto): Promise<void> {
|
|
||||||
return this.service.fixItems(dto.items);
|
|
||||||
}
|
|
||||||
}
|
|
@ -8,7 +8,6 @@ import { AuthController } from 'src/controllers/auth.controller';
|
|||||||
import { DownloadController } from 'src/controllers/download.controller';
|
import { DownloadController } from 'src/controllers/download.controller';
|
||||||
import { DuplicateController } from 'src/controllers/duplicate.controller';
|
import { DuplicateController } from 'src/controllers/duplicate.controller';
|
||||||
import { FaceController } from 'src/controllers/face.controller';
|
import { FaceController } from 'src/controllers/face.controller';
|
||||||
import { ReportController } from 'src/controllers/file-report.controller';
|
|
||||||
import { JobController } from 'src/controllers/job.controller';
|
import { JobController } from 'src/controllers/job.controller';
|
||||||
import { LibraryController } from 'src/controllers/library.controller';
|
import { LibraryController } from 'src/controllers/library.controller';
|
||||||
import { MapController } from 'src/controllers/map.controller';
|
import { MapController } from 'src/controllers/map.controller';
|
||||||
@ -53,7 +52,6 @@ export const controllers = [
|
|||||||
OAuthController,
|
OAuthController,
|
||||||
PartnerController,
|
PartnerController,
|
||||||
PersonController,
|
PersonController,
|
||||||
ReportController,
|
|
||||||
SearchController,
|
SearchController,
|
||||||
ServerController,
|
ServerController,
|
||||||
SessionController,
|
SessionController,
|
||||||
|
@ -46,7 +46,7 @@ export class SearchController {
|
|||||||
@Get('explore')
|
@Get('explore')
|
||||||
@Authenticated()
|
@Authenticated()
|
||||||
getExploreData(@Auth() auth: AuthDto): Promise<SearchExploreResponseDto[]> {
|
getExploreData(@Auth() auth: AuthDto): Promise<SearchExploreResponseDto[]> {
|
||||||
return this.service.getExploreData(auth) as Promise<SearchExploreResponseDto[]>;
|
return this.service.getExploreData(auth);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('person')
|
@Get('person')
|
||||||
|
@ -199,10 +199,3 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
|
|||||||
resized: true,
|
resized: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MemoryLaneResponseDto {
|
|
||||||
@ApiProperty({ type: 'integer' })
|
|
||||||
yearsAgo!: number;
|
|
||||||
|
|
||||||
assets!: AssetResponseDto[];
|
|
||||||
}
|
|
||||||
|
@ -1,73 +0,0 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
|
||||||
import { Type } from 'class-transformer';
|
|
||||||
import { IsArray, IsEnum, IsString, IsUUID, ValidateNested } from 'class-validator';
|
|
||||||
import { AssetPathType, EntityType, PathType, PersonPathType, UserPathType } from 'src/enum';
|
|
||||||
import { Optional, ValidateDate, ValidateUUID } from 'src/validation';
|
|
||||||
|
|
||||||
const PathEnum = Object.values({ ...AssetPathType, ...PersonPathType, ...UserPathType });
|
|
||||||
|
|
||||||
export class AuditDeletesDto {
|
|
||||||
@ValidateDate()
|
|
||||||
after!: Date;
|
|
||||||
|
|
||||||
@ApiProperty({ enum: EntityType, enumName: 'EntityType' })
|
|
||||||
@IsEnum(EntityType)
|
|
||||||
entityType!: EntityType;
|
|
||||||
|
|
||||||
@Optional()
|
|
||||||
@IsUUID('4')
|
|
||||||
@ApiProperty({ format: 'uuid' })
|
|
||||||
userId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum PathEntityType {
|
|
||||||
ASSET = 'asset',
|
|
||||||
PERSON = 'person',
|
|
||||||
USER = 'user',
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AuditDeletesResponseDto {
|
|
||||||
needsFullSync!: boolean;
|
|
||||||
ids!: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FileReportDto {
|
|
||||||
orphans!: FileReportItemDto[];
|
|
||||||
extras!: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FileChecksumDto {
|
|
||||||
@IsString({ each: true })
|
|
||||||
filenames!: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FileChecksumResponseDto {
|
|
||||||
filename!: string;
|
|
||||||
checksum!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FileReportFixDto {
|
|
||||||
@IsArray()
|
|
||||||
@ValidateNested({ each: true })
|
|
||||||
@Type(() => FileReportItemDto)
|
|
||||||
items!: FileReportItemDto[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// used both as request and response dto
|
|
||||||
export class FileReportItemDto {
|
|
||||||
@ValidateUUID()
|
|
||||||
entityId!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ enumName: 'PathEntityType', enum: PathEntityType })
|
|
||||||
@IsEnum(PathEntityType)
|
|
||||||
entityType!: PathEntityType;
|
|
||||||
|
|
||||||
@ApiProperty({ enumName: 'PathType', enum: PathEnum })
|
|
||||||
@IsEnum(PathEnum)
|
|
||||||
pathType!: PathType;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
pathValue!: string;
|
|
||||||
|
|
||||||
checksum?: string;
|
|
||||||
}
|
|
@ -194,15 +194,16 @@ where
|
|||||||
"asset_files"."assetId" = $1
|
"asset_files"."assetId" = $1
|
||||||
and "asset_files"."type" = $2
|
and "asset_files"."type" = $2
|
||||||
|
|
||||||
-- AssetJobRepository.streamForEncodeClip
|
-- AssetJobRepository.streamForSearchDuplicates
|
||||||
select
|
select
|
||||||
"assets"."id"
|
"assets"."id"
|
||||||
from
|
from
|
||||||
"assets"
|
"assets"
|
||||||
inner join "asset_job_status" as "job_status" on "assetId" = "assets"."id"
|
inner join "asset_job_status" as "job_status" on "assetId" = "assets"."id"
|
||||||
where
|
where
|
||||||
"job_status"."previewAt" is not null
|
"assets"."isVisible" = $1
|
||||||
and "assets"."isVisible" = $1
|
and "assets"."deletedAt" is null
|
||||||
|
and "job_status"."previewAt" is not null
|
||||||
and not exists (
|
and not exists (
|
||||||
select
|
select
|
||||||
from
|
from
|
||||||
@ -210,7 +211,25 @@ where
|
|||||||
where
|
where
|
||||||
"assetId" = "assets"."id"
|
"assetId" = "assets"."id"
|
||||||
)
|
)
|
||||||
|
and "job_status"."duplicatesDetectedAt" is null
|
||||||
|
|
||||||
|
-- AssetJobRepository.streamForEncodeClip
|
||||||
|
select
|
||||||
|
"assets"."id"
|
||||||
|
from
|
||||||
|
"assets"
|
||||||
|
inner join "asset_job_status" as "job_status" on "assetId" = "assets"."id"
|
||||||
|
where
|
||||||
|
"assets"."isVisible" = $1
|
||||||
and "assets"."deletedAt" is null
|
and "assets"."deletedAt" is null
|
||||||
|
and "job_status"."previewAt" is not null
|
||||||
|
and not exists (
|
||||||
|
select
|
||||||
|
from
|
||||||
|
"smart_search"
|
||||||
|
where
|
||||||
|
"assetId" = "assets"."id"
|
||||||
|
)
|
||||||
|
|
||||||
-- AssetJobRepository.getForClipEncoding
|
-- AssetJobRepository.getForClipEncoding
|
||||||
select
|
select
|
||||||
@ -450,3 +469,37 @@ from
|
|||||||
"assets"
|
"assets"
|
||||||
where
|
where
|
||||||
"assets"."deletedAt" <= $1
|
"assets"."deletedAt" <= $1
|
||||||
|
|
||||||
|
-- AssetJobRepository.streamForSidecar
|
||||||
|
select
|
||||||
|
"assets"."id"
|
||||||
|
from
|
||||||
|
"assets"
|
||||||
|
where
|
||||||
|
(
|
||||||
|
"assets"."sidecarPath" = $1
|
||||||
|
or "assets"."sidecarPath" is null
|
||||||
|
)
|
||||||
|
and "assets"."isVisible" = $2
|
||||||
|
|
||||||
|
-- AssetJobRepository.streamForDetectFacesJob
|
||||||
|
select
|
||||||
|
"assets"."id"
|
||||||
|
from
|
||||||
|
"assets"
|
||||||
|
inner join "asset_job_status" as "job_status" on "assetId" = "assets"."id"
|
||||||
|
where
|
||||||
|
"assets"."isVisible" = $1
|
||||||
|
and "assets"."deletedAt" is null
|
||||||
|
and "job_status"."previewAt" is not null
|
||||||
|
and "job_status"."facesRecognizedAt" is null
|
||||||
|
order by
|
||||||
|
"assets"."createdAt" desc
|
||||||
|
|
||||||
|
-- AssetJobRepository.streamForMigrationJob
|
||||||
|
select
|
||||||
|
"id"
|
||||||
|
from
|
||||||
|
"assets"
|
||||||
|
where
|
||||||
|
"assets"."deletedAt" is null
|
||||||
|
@ -232,25 +232,6 @@ where
|
|||||||
limit
|
limit
|
||||||
$3
|
$3
|
||||||
|
|
||||||
-- AssetRepository.getWithout (sidecar)
|
|
||||||
select
|
|
||||||
"assets".*
|
|
||||||
from
|
|
||||||
"assets"
|
|
||||||
where
|
|
||||||
(
|
|
||||||
"assets"."sidecarPath" = $1
|
|
||||||
or "assets"."sidecarPath" is null
|
|
||||||
)
|
|
||||||
and "assets"."isVisible" = $2
|
|
||||||
and "deletedAt" is null
|
|
||||||
order by
|
|
||||||
"createdAt"
|
|
||||||
limit
|
|
||||||
$3
|
|
||||||
offset
|
|
||||||
$4
|
|
||||||
|
|
||||||
-- AssetRepository.getTimeBuckets
|
-- AssetRepository.getTimeBuckets
|
||||||
with
|
with
|
||||||
"assets" as (
|
"assets" as (
|
||||||
|
@ -135,20 +135,33 @@ export class AssetJobRepository {
|
|||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [], stream: true })
|
private assetsWithPreviews() {
|
||||||
streamForEncodeClip(force?: boolean) {
|
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('assets')
|
.selectFrom('assets')
|
||||||
.select(['assets.id'])
|
|
||||||
.innerJoin('asset_job_status as job_status', 'assetId', 'assets.id')
|
|
||||||
.where('job_status.previewAt', 'is not', null)
|
|
||||||
.where('assets.isVisible', '=', true)
|
.where('assets.isVisible', '=', true)
|
||||||
|
.where('assets.deletedAt', 'is', null)
|
||||||
|
.innerJoin('asset_job_status as job_status', 'assetId', 'assets.id')
|
||||||
|
.where('job_status.previewAt', 'is not', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [], stream: true })
|
||||||
|
streamForSearchDuplicates(force?: boolean) {
|
||||||
|
return this.assetsWithPreviews()
|
||||||
|
.where((eb) => eb.not((eb) => eb.exists(eb.selectFrom('smart_search').whereRef('assetId', '=', 'assets.id'))))
|
||||||
|
.$if(!force, (qb) => qb.where('job_status.duplicatesDetectedAt', 'is', null))
|
||||||
|
.select(['assets.id'])
|
||||||
|
.stream();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [], stream: true })
|
||||||
|
streamForEncodeClip(force?: boolean) {
|
||||||
|
return this.assetsWithPreviews()
|
||||||
|
.select(['assets.id'])
|
||||||
.$if(!force, (qb) =>
|
.$if(!force, (qb) =>
|
||||||
qb.where((eb) =>
|
qb.where((eb) =>
|
||||||
eb.not((eb) => eb.exists(eb.selectFrom('smart_search').whereRef('assetId', '=', 'assets.id'))),
|
eb.not((eb) => eb.exists(eb.selectFrom('smart_search').whereRef('assetId', '=', 'assets.id'))),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.where('assets.deletedAt', 'is', null)
|
|
||||||
.stream();
|
.stream();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -309,4 +322,30 @@ export class AssetJobRepository {
|
|||||||
.where('assets.deletedAt', '<=', trashedBefore)
|
.where('assets.deletedAt', '<=', trashedBefore)
|
||||||
.stream();
|
.stream();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [], stream: true })
|
||||||
|
streamForSidecar(force?: boolean) {
|
||||||
|
return this.db
|
||||||
|
.selectFrom('assets')
|
||||||
|
.select(['assets.id'])
|
||||||
|
.$if(!force, (qb) =>
|
||||||
|
qb.where((eb) => eb.or([eb('assets.sidecarPath', '=', ''), eb('assets.sidecarPath', 'is', null)])),
|
||||||
|
)
|
||||||
|
.where('assets.isVisible', '=', true)
|
||||||
|
.stream();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [], stream: true })
|
||||||
|
streamForDetectFacesJob(force?: boolean) {
|
||||||
|
return this.assetsWithPreviews()
|
||||||
|
.$if(!force, (qb) => qb.where('job_status.facesRecognizedAt', 'is', null))
|
||||||
|
.select(['assets.id'])
|
||||||
|
.orderBy('assets.createdAt', 'desc')
|
||||||
|
.stream();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.DATE], stream: true })
|
||||||
|
streamForMigrationJob() {
|
||||||
|
return this.db.selectFrom('assets').select(['id']).where('assets.deletedAt', 'is', null).stream();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,13 +7,11 @@ import { AssetFiles, AssetJobStatus, Assets, DB, Exif } from 'src/db';
|
|||||||
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
||||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||||
import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum';
|
import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum';
|
||||||
import { AssetSearchOptions, SearchExploreItem, SearchExploreItemSet } from 'src/repositories/search.repository';
|
|
||||||
import {
|
import {
|
||||||
anyUuid,
|
anyUuid,
|
||||||
asUuid,
|
asUuid,
|
||||||
hasPeople,
|
hasPeople,
|
||||||
removeUndefinedKeys,
|
removeUndefinedKeys,
|
||||||
searchAssetBuilder,
|
|
||||||
truncatedDate,
|
truncatedDate,
|
||||||
unnest,
|
unnest,
|
||||||
withExif,
|
withExif,
|
||||||
@ -27,7 +25,6 @@ import {
|
|||||||
withTags,
|
withTags,
|
||||||
} from 'src/utils/database';
|
} from 'src/utils/database';
|
||||||
import { globToSqlPattern } from 'src/utils/misc';
|
import { globToSqlPattern } from 'src/utils/misc';
|
||||||
import { PaginationOptions, paginationHelper } from 'src/utils/pagination';
|
|
||||||
|
|
||||||
export type AssetStats = Record<AssetType, number>;
|
export type AssetStats = Record<AssetType, number>;
|
||||||
|
|
||||||
@ -45,15 +42,6 @@ export interface LivePhotoSearchOptions {
|
|||||||
type: AssetType;
|
type: AssetType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum WithoutProperty {
|
|
||||||
THUMBNAIL = 'thumbnail',
|
|
||||||
ENCODED_VIDEO = 'encoded-video',
|
|
||||||
EXIF = 'exif',
|
|
||||||
DUPLICATE = 'duplicate',
|
|
||||||
FACES = 'faces',
|
|
||||||
SIDECAR = 'sidecar',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum WithProperty {
|
export enum WithProperty {
|
||||||
SIDECAR = 'sidecar',
|
SIDECAR = 'sidecar',
|
||||||
}
|
}
|
||||||
@ -335,10 +323,6 @@ export class AssetRepository {
|
|||||||
return assets.map((asset) => asset.deviceAssetId);
|
return assets.map((asset) => asset.deviceAssetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
getByUserId(pagination: PaginationOptions, userId: string, options: Omit<AssetSearchOptions, 'userIds'> = {}) {
|
|
||||||
return this.getAll(pagination, { ...options, userIds: [userId] });
|
|
||||||
}
|
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
|
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
|
||||||
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string) {
|
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string) {
|
||||||
return this.db
|
return this.db
|
||||||
@ -350,16 +334,6 @@ export class AssetRepository {
|
|||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAll(pagination: PaginationOptions, { orderDirection, ...options }: AssetSearchOptions = {}) {
|
|
||||||
const builder = searchAssetBuilder(this.db, options)
|
|
||||||
.select(withFiles)
|
|
||||||
.orderBy('assets.createdAt', orderDirection ?? 'asc')
|
|
||||||
.limit(pagination.take + 1)
|
|
||||||
.offset(pagination.skip ?? 0);
|
|
||||||
const items = await builder.execute();
|
|
||||||
return paginationHelper(items, pagination.take);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get assets by device's Id on the database
|
* Get assets by device's Id on the database
|
||||||
* @param ownerId
|
* @param ownerId
|
||||||
@ -529,68 +503,6 @@ export class AssetRepository {
|
|||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql(
|
|
||||||
...Object.values(WithProperty).map((property) => ({
|
|
||||||
name: property,
|
|
||||||
params: [DummyValue.PAGINATION, property],
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
async getWithout(pagination: PaginationOptions, property: WithoutProperty) {
|
|
||||||
const items = await this.db
|
|
||||||
.selectFrom('assets')
|
|
||||||
.selectAll('assets')
|
|
||||||
.$if(property === WithoutProperty.DUPLICATE, (qb) =>
|
|
||||||
qb
|
|
||||||
.innerJoin('asset_job_status as job_status', 'assets.id', 'job_status.assetId')
|
|
||||||
.where('job_status.duplicatesDetectedAt', 'is', null)
|
|
||||||
.where('job_status.previewAt', 'is not', null)
|
|
||||||
.where((eb) => eb.exists(eb.selectFrom('smart_search').where('assetId', '=', eb.ref('assets.id'))))
|
|
||||||
.where('assets.isVisible', '=', true),
|
|
||||||
)
|
|
||||||
.$if(property === WithoutProperty.ENCODED_VIDEO, (qb) =>
|
|
||||||
qb
|
|
||||||
.where('assets.type', '=', AssetType.VIDEO)
|
|
||||||
.where((eb) => eb.or([eb('assets.encodedVideoPath', 'is', null), eb('assets.encodedVideoPath', '=', '')])),
|
|
||||||
)
|
|
||||||
.$if(property === WithoutProperty.EXIF, (qb) =>
|
|
||||||
qb
|
|
||||||
.leftJoin('asset_job_status as job_status', 'assets.id', 'job_status.assetId')
|
|
||||||
.where((eb) => eb.or([eb('job_status.metadataExtractedAt', 'is', null), eb('assetId', 'is', null)]))
|
|
||||||
.where('assets.isVisible', '=', true),
|
|
||||||
)
|
|
||||||
.$if(property === WithoutProperty.FACES, (qb) =>
|
|
||||||
qb
|
|
||||||
.innerJoin('asset_job_status as job_status', 'assetId', 'assets.id')
|
|
||||||
.where('job_status.previewAt', 'is not', null)
|
|
||||||
.where('job_status.facesRecognizedAt', 'is', null)
|
|
||||||
.where('assets.isVisible', '=', true),
|
|
||||||
)
|
|
||||||
.$if(property === WithoutProperty.SIDECAR, (qb) =>
|
|
||||||
qb
|
|
||||||
.where((eb) => eb.or([eb('assets.sidecarPath', '=', ''), eb('assets.sidecarPath', 'is', null)]))
|
|
||||||
.where('assets.isVisible', '=', true),
|
|
||||||
)
|
|
||||||
.$if(property === WithoutProperty.THUMBNAIL, (qb) =>
|
|
||||||
qb
|
|
||||||
.innerJoin('asset_job_status as job_status', 'assetId', 'assets.id')
|
|
||||||
.where('assets.isVisible', '=', true)
|
|
||||||
.where((eb) =>
|
|
||||||
eb.or([
|
|
||||||
eb('job_status.previewAt', 'is', null),
|
|
||||||
eb('job_status.thumbnailAt', 'is', null),
|
|
||||||
eb('assets.thumbhash', 'is', null),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.where('deletedAt', 'is', null)
|
|
||||||
.limit(pagination.take + 1)
|
|
||||||
.offset(pagination.skip ?? 0)
|
|
||||||
.orderBy('createdAt')
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
return paginationHelper(items, pagination.take);
|
|
||||||
}
|
|
||||||
|
|
||||||
getStatistics(ownerId: string, { isArchived, isFavorite, isTrashed }: AssetStatsOptions): Promise<AssetStats> {
|
getStatistics(ownerId: string, { isArchived, isFavorite, isTrashed }: AssetStatsOptions): Promise<AssetStats> {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('assets')
|
.selectFrom('assets')
|
||||||
@ -774,10 +686,7 @@ export class AssetRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({ params: [DummyValue.UUID, { minAssetsPerField: 5, maxFields: 12 }] })
|
@GenerateSql({ params: [DummyValue.UUID, { minAssetsPerField: 5, maxFields: 12 }] })
|
||||||
async getAssetIdByCity(
|
async getAssetIdByCity(ownerId: string, { minAssetsPerField, maxFields }: AssetExploreFieldOptions) {
|
||||||
ownerId: string,
|
|
||||||
{ minAssetsPerField, maxFields }: AssetExploreFieldOptions,
|
|
||||||
): Promise<SearchExploreItem<string>> {
|
|
||||||
const items = await this.db
|
const items = await this.db
|
||||||
.with('cities', (qb) =>
|
.with('cities', (qb) =>
|
||||||
qb
|
qb
|
||||||
@ -792,6 +701,7 @@ export class AssetRepository {
|
|||||||
.innerJoin('cities', 'exif.city', 'cities.city')
|
.innerJoin('cities', 'exif.city', 'cities.city')
|
||||||
.distinctOn('exif.city')
|
.distinctOn('exif.city')
|
||||||
.select(['assetId as data', 'exif.city as value'])
|
.select(['assetId as data', 'exif.city as value'])
|
||||||
|
.$narrowType<{ value: NotNull }>()
|
||||||
.where('ownerId', '=', asUuid(ownerId))
|
.where('ownerId', '=', asUuid(ownerId))
|
||||||
.where('isVisible', '=', true)
|
.where('isVisible', '=', true)
|
||||||
.where('isArchived', '=', false)
|
.where('isArchived', '=', false)
|
||||||
@ -800,7 +710,7 @@ export class AssetRepository {
|
|||||||
.limit(maxFields)
|
.limit(maxFields)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return { fieldName: 'exifInfo.city', items: items as SearchExploreItemSet<string> };
|
return { fieldName: 'exifInfo.city', items };
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({
|
@GenerateSql({
|
||||||
|
@ -19,7 +19,7 @@ import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.d
|
|||||||
import { ImmichWorker, MetadataKey, QueueName } from 'src/enum';
|
import { ImmichWorker, MetadataKey, QueueName } from 'src/enum';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { JobItem } from 'src/types';
|
import { JobItem, JobSource } from 'src/types';
|
||||||
import { handlePromiseError } from 'src/utils/misc';
|
import { handlePromiseError } from 'src/utils/misc';
|
||||||
|
|
||||||
type EmitHandlers = Partial<{ [T in EmitEvent]: Array<EventItem<T>> }>;
|
type EmitHandlers = Partial<{ [T in EmitEvent]: Array<EventItem<T>> }>;
|
||||||
@ -48,7 +48,7 @@ type EventMap = {
|
|||||||
'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }];
|
'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }];
|
||||||
|
|
||||||
// album events
|
// album events
|
||||||
'album.update': [{ id: string; recipientIds: string[] }];
|
'album.update': [{ id: string; recipientId: string }];
|
||||||
'album.invite': [{ id: string; userId: string }];
|
'album.invite': [{ id: string; userId: string }];
|
||||||
|
|
||||||
// asset events
|
// asset events
|
||||||
@ -58,6 +58,7 @@ type EventMap = {
|
|||||||
'asset.show': [{ assetId: string; userId: string }];
|
'asset.show': [{ assetId: string; userId: string }];
|
||||||
'asset.trash': [{ assetId: string; userId: string }];
|
'asset.trash': [{ assetId: string; userId: string }];
|
||||||
'asset.delete': [{ assetId: string; userId: string }];
|
'asset.delete': [{ assetId: string; userId: string }];
|
||||||
|
'asset.metadataExtracted': [{ assetId: string; userId: string; source?: JobSource }];
|
||||||
|
|
||||||
// asset bulk events
|
// asset bulk events
|
||||||
'assets.trash': [{ assetIds: string[]; userId: string }];
|
'assets.trash': [{ assetIds: string[]; userId: string }];
|
||||||
|
@ -9,7 +9,7 @@ import { JobName, JobStatus, MetadataKey, QueueCleanType, QueueName } from 'src/
|
|||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
import { EventRepository } from 'src/repositories/event.repository';
|
import { EventRepository } from 'src/repositories/event.repository';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { IEntityJob, JobCounts, JobItem, JobOf, QueueStatus } from 'src/types';
|
import { JobCounts, JobItem, JobOf, QueueStatus } from 'src/types';
|
||||||
import { getKeyByValue, getMethodNames, ImmichStartupError } from 'src/utils/misc';
|
import { getKeyByValue, getMethodNames, ImmichStartupError } from 'src/utils/misc';
|
||||||
|
|
||||||
type JobMapItem = {
|
type JobMapItem = {
|
||||||
@ -206,7 +206,10 @@ export class JobRepository {
|
|||||||
private getJobOptions(item: JobItem): JobsOptions | null {
|
private getJobOptions(item: JobItem): JobsOptions | null {
|
||||||
switch (item.name) {
|
switch (item.name) {
|
||||||
case JobName.NOTIFY_ALBUM_UPDATE: {
|
case JobName.NOTIFY_ALBUM_UPDATE: {
|
||||||
return { jobId: item.data.id, delay: item.data?.delay };
|
return {
|
||||||
|
jobId: `${item.data.id}/${item.data.recipientId}`,
|
||||||
|
delay: item.data?.delay,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: {
|
case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: {
|
||||||
return { jobId: item.data.id };
|
return { jobId: item.data.id };
|
||||||
@ -227,19 +230,12 @@ export class JobRepository {
|
|||||||
return this.moduleRef.get<Queue>(getQueueToken(queue), { strict: false });
|
return this.moduleRef.get<Queue>(getQueueToken(queue), { strict: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
public async removeJob(jobId: string, name: JobName): Promise<IEntityJob | undefined> {
|
/** @deprecated */
|
||||||
const existingJob = await this.getQueue(this.getQueueName(name)).getJob(jobId);
|
// todo: remove this when asset notifications no longer need it.
|
||||||
if (!existingJob) {
|
public async removeJob(name: JobName, jobID: string): Promise<void> {
|
||||||
return;
|
const existingJob = await this.getQueue(this.getQueueName(name)).getJob(jobID);
|
||||||
}
|
if (existingJob) {
|
||||||
try {
|
|
||||||
await existingJob.remove();
|
await existingJob.remove();
|
||||||
} catch (error: any) {
|
}
|
||||||
if (error.message?.includes('Missing key for job')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
return existingJob.data;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,7 @@ import { AssetFaces, DB, FaceSearch, Person } from 'src/db';
|
|||||||
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
|
||||||
import { AssetFileType, SourceType } from 'src/enum';
|
import { AssetFileType, SourceType } from 'src/enum';
|
||||||
import { removeUndefinedKeys } from 'src/utils/database';
|
import { removeUndefinedKeys } from 'src/utils/database';
|
||||||
import { PaginationOptions } from 'src/utils/pagination';
|
import { paginationHelper, PaginationOptions } from 'src/utils/pagination';
|
||||||
|
|
||||||
export interface PersonSearchOptions {
|
export interface PersonSearchOptions {
|
||||||
minimumFaceCount: number;
|
minimumFaceCount: number;
|
||||||
@ -200,11 +200,7 @@ export class PersonRepository {
|
|||||||
.limit(pagination.take + 1)
|
.limit(pagination.take + 1)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
if (items.length > pagination.take) {
|
return paginationHelper(items, pagination.take);
|
||||||
return { items: items.slice(0, -1), hasNextPage: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { items, hasNextPage: false };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql()
|
@GenerateSql()
|
||||||
|
@ -8,41 +8,10 @@ import { MapAsset } from 'src/dtos/asset-response.dto';
|
|||||||
import { AssetStatus, AssetType } from 'src/enum';
|
import { AssetStatus, AssetType } from 'src/enum';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
import { anyUuid, asUuid, searchAssetBuilder, vectorIndexQuery } from 'src/utils/database';
|
import { anyUuid, asUuid, searchAssetBuilder, vectorIndexQuery } from 'src/utils/database';
|
||||||
|
import { paginationHelper } from 'src/utils/pagination';
|
||||||
import { isValidInteger } from 'src/validation';
|
import { isValidInteger } from 'src/validation';
|
||||||
|
|
||||||
export interface SearchResult<T> {
|
export interface SearchAssetIdOptions {
|
||||||
/** total matches */
|
|
||||||
total: number;
|
|
||||||
/** collection size */
|
|
||||||
count: number;
|
|
||||||
/** current page */
|
|
||||||
page: number;
|
|
||||||
/** items for page */
|
|
||||||
items: T[];
|
|
||||||
/** score */
|
|
||||||
distances: number[];
|
|
||||||
facets: SearchFacet[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchFacet {
|
|
||||||
fieldName: string;
|
|
||||||
counts: Array<{
|
|
||||||
count: number;
|
|
||||||
value: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SearchExploreItemSet<T> = Array<{
|
|
||||||
value: string;
|
|
||||||
data: T;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export interface SearchExploreItem<T> {
|
|
||||||
fieldName: string;
|
|
||||||
items: SearchExploreItemSet<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchAssetIDOptions {
|
|
||||||
checksum?: Buffer;
|
checksum?: Buffer;
|
||||||
deviceAssetId?: string;
|
deviceAssetId?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
@ -54,7 +23,7 @@ export interface SearchUserIdOptions {
|
|||||||
userIds?: string[];
|
userIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SearchIdOptions = SearchAssetIDOptions & SearchUserIdOptions;
|
export type SearchIdOptions = SearchAssetIdOptions & SearchUserIdOptions;
|
||||||
|
|
||||||
export interface SearchStatusOptions {
|
export interface SearchStatusOptions {
|
||||||
isArchived?: boolean;
|
isArchived?: boolean;
|
||||||
@ -144,8 +113,6 @@ type BaseAssetSearchOptions = SearchDateOptions &
|
|||||||
|
|
||||||
export type AssetSearchOptions = BaseAssetSearchOptions & SearchRelationOptions;
|
export type AssetSearchOptions = BaseAssetSearchOptions & SearchRelationOptions;
|
||||||
|
|
||||||
export type AssetSearchOneToOneRelationOptions = BaseAssetSearchOptions & SearchOneToOneRelationOptions;
|
|
||||||
|
|
||||||
export type AssetSearchBuilderOptions = Omit<AssetSearchOptions, 'orderDirection'>;
|
export type AssetSearchBuilderOptions = Omit<AssetSearchOptions, 'orderDirection'>;
|
||||||
|
|
||||||
export type SmartSearchOptions = SearchDateOptions &
|
export type SmartSearchOptions = SearchDateOptions &
|
||||||
@ -226,9 +193,8 @@ export class SearchRepository {
|
|||||||
.limit(pagination.size + 1)
|
.limit(pagination.size + 1)
|
||||||
.offset((pagination.page - 1) * pagination.size)
|
.offset((pagination.page - 1) * pagination.size)
|
||||||
.execute();
|
.execute();
|
||||||
const hasNextPage = items.length > pagination.size;
|
|
||||||
items.splice(pagination.size);
|
return paginationHelper(items, pagination.size);
|
||||||
return { items, hasNextPage };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({
|
@GenerateSql({
|
||||||
@ -283,9 +249,7 @@ export class SearchRepository {
|
|||||||
.offset((pagination.page - 1) * pagination.size)
|
.offset((pagination.page - 1) * pagination.size)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
const hasNextPage = items.length > pagination.size;
|
return paginationHelper(items, pagination.size);
|
||||||
items.splice(pagination.size);
|
|
||||||
return { items, hasNextPage };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GenerateSql({
|
@GenerateSql({
|
||||||
|
@ -606,7 +606,7 @@ describe(AlbumService.name, () => {
|
|||||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
||||||
expect(mocks.event.emit).toHaveBeenCalledWith('album.update', {
|
expect(mocks.event.emit).toHaveBeenCalledWith('album.update', {
|
||||||
id: 'album-123',
|
id: 'album-123',
|
||||||
recipientIds: ['admin_id'],
|
recipientId: 'admin_id',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -170,8 +170,8 @@ export class AlbumService extends BaseService {
|
|||||||
(userId) => userId !== auth.user.id,
|
(userId) => userId !== auth.user.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (allUsersExceptUs.length > 0) {
|
for (const recipientId of allUsersExceptUs) {
|
||||||
await this.eventRepository.emit('album.update', { id, recipientIds: allUsersExceptUs });
|
await this.eventRepository.emit('album.update', { id, recipientId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
|
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||||
import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto';
|
import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto';
|
||||||
import { AssetStatus, AssetType, JobName, JobStatus } from 'src/enum';
|
import { AssetStatus, AssetType, JobName, JobStatus } from 'src/enum';
|
||||||
import { AssetStats } from 'src/repositories/asset.repository';
|
import { AssetStats } from 'src/repositories/asset.repository';
|
||||||
@ -11,7 +11,6 @@ import { faceStub } from 'test/fixtures/face.stub';
|
|||||||
import { userStub } from 'test/fixtures/user.stub';
|
import { userStub } from 'test/fixtures/user.stub';
|
||||||
import { factory } from 'test/small.factory';
|
import { factory } from 'test/small.factory';
|
||||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||||
import { vitest } from 'vitest';
|
|
||||||
|
|
||||||
const stats: AssetStats = {
|
const stats: AssetStats = {
|
||||||
[AssetType.IMAGE]: 10,
|
[AssetType.IMAGE]: 10,
|
||||||
@ -44,62 +43,6 @@ describe(AssetService.name, () => {
|
|||||||
mockGetById([assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]);
|
mockGetById([assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getMemoryLane', () => {
|
|
||||||
beforeAll(() => {
|
|
||||||
vitest.useFakeTimers();
|
|
||||||
vitest.setSystemTime(new Date('2024-01-15'));
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
vitest.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should group the assets correctly', async () => {
|
|
||||||
const image1 = { ...assetStub.image, localDateTime: new Date(2023, 1, 15, 0, 0, 0) };
|
|
||||||
const image2 = { ...assetStub.image, localDateTime: new Date(2023, 1, 15, 1, 0, 0) };
|
|
||||||
const image3 = { ...assetStub.image, localDateTime: new Date(2015, 1, 15) };
|
|
||||||
const image4 = { ...assetStub.image, localDateTime: new Date(2009, 1, 15) };
|
|
||||||
|
|
||||||
mocks.partner.getAll.mockResolvedValue([]);
|
|
||||||
mocks.asset.getByDayOfYear.mockResolvedValue([
|
|
||||||
{
|
|
||||||
year: 2023,
|
|
||||||
assets: [image1, image2],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
year: 2015,
|
|
||||||
assets: [image3],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
year: 2009,
|
|
||||||
assets: [image4],
|
|
||||||
},
|
|
||||||
] as any);
|
|
||||||
|
|
||||||
await expect(sut.getMemoryLane(authStub.admin, { day: 15, month: 1 })).resolves.toEqual([
|
|
||||||
{ yearsAgo: 1, title: '1 year ago', assets: [mapAsset(image1), mapAsset(image2)] },
|
|
||||||
{ yearsAgo: 9, title: '9 years ago', assets: [mapAsset(image3)] },
|
|
||||||
{ yearsAgo: 15, title: '15 years ago', assets: [mapAsset(image4)] },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(mocks.asset.getByDayOfYear.mock.calls).toEqual([[[authStub.admin.user.id], { day: 15, month: 1 }]]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should get memories with partners with inTimeline enabled', async () => {
|
|
||||||
const partner = factory.partner();
|
|
||||||
const auth = factory.auth({ user: { id: partner.sharedWithId } });
|
|
||||||
|
|
||||||
mocks.partner.getAll.mockResolvedValue([partner]);
|
|
||||||
mocks.asset.getByDayOfYear.mockResolvedValue([]);
|
|
||||||
|
|
||||||
await sut.getMemoryLane(auth, { day: 15, month: 1 });
|
|
||||||
|
|
||||||
expect(mocks.asset.getByDayOfYear.mock.calls).toEqual([
|
|
||||||
[[auth.user.id, partner.sharedById], { day: 15, month: 1 }],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getStatistics', () => {
|
describe('getStatistics', () => {
|
||||||
it('should get the statistics for a user, excluding archived assets', async () => {
|
it('should get the statistics for a user, excluding archived assets', async () => {
|
||||||
mocks.asset.getStatistics.mockResolvedValue(stats);
|
mocks.asset.getStatistics.mockResolvedValue(stats);
|
||||||
|
@ -3,13 +3,7 @@ import _ from 'lodash';
|
|||||||
import { DateTime, Duration } from 'luxon';
|
import { DateTime, Duration } from 'luxon';
|
||||||
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||||
import { OnJob } from 'src/decorators';
|
import { OnJob } from 'src/decorators';
|
||||||
import {
|
import { AssetResponseDto, MapAsset, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||||
AssetResponseDto,
|
|
||||||
MapAsset,
|
|
||||||
MemoryLaneResponseDto,
|
|
||||||
SanitizedAssetResponseDto,
|
|
||||||
mapAsset,
|
|
||||||
} from 'src/dtos/asset-response.dto';
|
|
||||||
import {
|
import {
|
||||||
AssetBulkDeleteDto,
|
AssetBulkDeleteDto,
|
||||||
AssetBulkUpdateDto,
|
AssetBulkUpdateDto,
|
||||||
@ -20,7 +14,6 @@ import {
|
|||||||
mapStats,
|
mapStats,
|
||||||
} from 'src/dtos/asset.dto';
|
} from 'src/dtos/asset.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { MemoryLaneDto } from 'src/dtos/search.dto';
|
|
||||||
import { AssetStatus, JobName, JobStatus, Permission, QueueName } from 'src/enum';
|
import { AssetStatus, JobName, JobStatus, Permission, QueueName } from 'src/enum';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { ISidecarWriteJob, JobItem, JobOf } from 'src/types';
|
import { ISidecarWriteJob, JobItem, JobOf } from 'src/types';
|
||||||
@ -28,26 +21,6 @@ import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUn
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AssetService extends BaseService {
|
export class AssetService extends BaseService {
|
||||||
async getMemoryLane(auth: AuthDto, dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> {
|
|
||||||
const partnerIds = await getMyPartnerIds({
|
|
||||||
userId: auth.user.id,
|
|
||||||
repository: this.partnerRepository,
|
|
||||||
timelineEnabled: true,
|
|
||||||
});
|
|
||||||
const userIds = [auth.user.id, ...partnerIds];
|
|
||||||
|
|
||||||
const groups = await this.assetRepository.getByDayOfYear(userIds, dto);
|
|
||||||
return groups.map(({ year, assets }) => {
|
|
||||||
const yearsAgo = DateTime.utc().year - year;
|
|
||||||
return {
|
|
||||||
yearsAgo,
|
|
||||||
// TODO move this to clients
|
|
||||||
title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`,
|
|
||||||
assets: assets.map((asset) => mapAsset(asset, { auth })),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getStatistics(auth: AuthDto, dto: AssetStatsDto) {
|
async getStatistics(auth: AuthDto, dto: AssetStatsDto) {
|
||||||
const stats = await this.assetRepository.getStatistics(auth.user.id, dto);
|
const stats = await this.assetRepository.getStatistics(auth.user.id, dto);
|
||||||
return mapStats(stats);
|
return mapStats(stats);
|
||||||
|
@ -1,6 +1,4 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { JobStatus } from 'src/enum';
|
||||||
import { FileReportItemDto } from 'src/dtos/audit.dto';
|
|
||||||
import { AssetFileType, AssetPathType, JobStatus, PersonPathType, UserPathType } from 'src/enum';
|
|
||||||
import { AuditService } from 'src/services/audit.service';
|
import { AuditService } from 'src/services/audit.service';
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
@ -25,148 +23,4 @@ describe(AuditService.name, () => {
|
|||||||
expect(mocks.audit.removeBefore).toHaveBeenCalledWith(expect.any(Date));
|
expect(mocks.audit.removeBefore).toHaveBeenCalledWith(expect.any(Date));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getChecksums', () => {
|
|
||||||
it('should fail if the file is not in the immich path', async () => {
|
|
||||||
await expect(sut.getChecksums({ filenames: ['foo/bar'] })).rejects.toBeInstanceOf(BadRequestException);
|
|
||||||
|
|
||||||
expect(mocks.crypto.hashFile).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should get checksum for valid file', async () => {
|
|
||||||
await expect(sut.getChecksums({ filenames: ['./upload/my-file.jpg'] })).resolves.toEqual([
|
|
||||||
{ filename: './upload/my-file.jpg', checksum: expect.any(String) },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(mocks.crypto.hashFile).toHaveBeenCalledWith('./upload/my-file.jpg');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('fixItems', () => {
|
|
||||||
it('should fail if the file is not in the immich path', async () => {
|
|
||||||
await expect(
|
|
||||||
sut.fixItems([
|
|
||||||
{ entityId: 'my-id', pathType: AssetPathType.ORIGINAL, pathValue: 'foo/bar' } as FileReportItemDto,
|
|
||||||
]),
|
|
||||||
).rejects.toBeInstanceOf(BadRequestException);
|
|
||||||
|
|
||||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.user.update).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update encoded video path', async () => {
|
|
||||||
await sut.fixItems([
|
|
||||||
{
|
|
||||||
entityId: 'my-id',
|
|
||||||
pathType: AssetPathType.ENCODED_VIDEO,
|
|
||||||
pathValue: './upload/my-video.mp4',
|
|
||||||
} as FileReportItemDto,
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'my-id', encodedVideoPath: './upload/my-video.mp4' });
|
|
||||||
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.user.update).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update preview path', async () => {
|
|
||||||
await sut.fixItems([
|
|
||||||
{
|
|
||||||
entityId: 'my-id',
|
|
||||||
pathType: AssetPathType.PREVIEW,
|
|
||||||
pathValue: './upload/my-preview.png',
|
|
||||||
} as FileReportItemDto,
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
|
|
||||||
assetId: 'my-id',
|
|
||||||
type: AssetFileType.PREVIEW,
|
|
||||||
path: './upload/my-preview.png',
|
|
||||||
});
|
|
||||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.user.update).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update thumbnail path', async () => {
|
|
||||||
await sut.fixItems([
|
|
||||||
{
|
|
||||||
entityId: 'my-id',
|
|
||||||
pathType: AssetPathType.THUMBNAIL,
|
|
||||||
pathValue: './upload/my-thumbnail.webp',
|
|
||||||
} as FileReportItemDto,
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
|
|
||||||
assetId: 'my-id',
|
|
||||||
type: AssetFileType.THUMBNAIL,
|
|
||||||
path: './upload/my-thumbnail.webp',
|
|
||||||
});
|
|
||||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.user.update).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update original path', async () => {
|
|
||||||
await sut.fixItems([
|
|
||||||
{
|
|
||||||
entityId: 'my-id',
|
|
||||||
pathType: AssetPathType.ORIGINAL,
|
|
||||||
pathValue: './upload/my-original.png',
|
|
||||||
} as FileReportItemDto,
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'my-id', originalPath: './upload/my-original.png' });
|
|
||||||
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.user.update).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update sidecar path', async () => {
|
|
||||||
await sut.fixItems([
|
|
||||||
{
|
|
||||||
entityId: 'my-id',
|
|
||||||
pathType: AssetPathType.SIDECAR,
|
|
||||||
pathValue: './upload/my-sidecar.xmp',
|
|
||||||
} as FileReportItemDto,
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'my-id', sidecarPath: './upload/my-sidecar.xmp' });
|
|
||||||
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.user.update).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update face path', async () => {
|
|
||||||
await sut.fixItems([
|
|
||||||
{
|
|
||||||
entityId: 'my-id',
|
|
||||||
pathType: PersonPathType.FACE,
|
|
||||||
pathValue: './upload/my-face.jpg',
|
|
||||||
} as FileReportItemDto,
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'my-id', thumbnailPath: './upload/my-face.jpg' });
|
|
||||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.user.update).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update profile path', async () => {
|
|
||||||
await sut.fixItems([
|
|
||||||
{
|
|
||||||
entityId: 'my-id',
|
|
||||||
pathType: UserPathType.PROFILE,
|
|
||||||
pathValue: './upload/my-profile-pic.jpg',
|
|
||||||
} as FileReportItemDto,
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(mocks.user.update).toHaveBeenCalledWith('my-id', { profileImagePath: './upload/my-profile-pic.jpg' });
|
|
||||||
expect(mocks.asset.update).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
@ -1,23 +1,9 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import { resolve } from 'node:path';
|
import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
|
||||||
import { AUDIT_LOG_MAX_DURATION, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
|
||||||
import { OnJob } from 'src/decorators';
|
import { OnJob } from 'src/decorators';
|
||||||
import { FileChecksumDto, FileChecksumResponseDto, FileReportItemDto, PathEntityType } from 'src/dtos/audit.dto';
|
import { JobName, JobStatus, QueueName } from 'src/enum';
|
||||||
import {
|
|
||||||
AssetFileType,
|
|
||||||
AssetPathType,
|
|
||||||
JobName,
|
|
||||||
JobStatus,
|
|
||||||
PersonPathType,
|
|
||||||
QueueName,
|
|
||||||
StorageFolder,
|
|
||||||
UserPathType,
|
|
||||||
} from 'src/enum';
|
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { getAssetFiles } from 'src/utils/asset.util';
|
|
||||||
import { usePagination } from 'src/utils/pagination';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuditService extends BaseService {
|
export class AuditService extends BaseService {
|
||||||
@ -26,187 +12,4 @@ export class AuditService extends BaseService {
|
|||||||
await this.auditRepository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate());
|
await this.auditRepository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate());
|
||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getChecksums(dto: FileChecksumDto) {
|
|
||||||
const results: FileChecksumResponseDto[] = [];
|
|
||||||
for (const filename of dto.filenames) {
|
|
||||||
if (!StorageCore.isImmichPath(filename)) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Could not get the checksum of ${filename} because the file isn't accessible by Immich`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const checksum = await this.cryptoRepository.hashFile(filename);
|
|
||||||
results.push({ filename, checksum: checksum.toString('base64') });
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fixItems(items: FileReportItemDto[]) {
|
|
||||||
for (const { entityId: id, pathType, pathValue } of items) {
|
|
||||||
if (!StorageCore.isImmichPath(pathValue)) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Could not fix item ${id} with path ${pathValue} because the file isn't accessible by Immich`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (pathType) {
|
|
||||||
case AssetPathType.ENCODED_VIDEO: {
|
|
||||||
await this.assetRepository.update({ id, encodedVideoPath: pathValue });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case AssetPathType.PREVIEW: {
|
|
||||||
await this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.PREVIEW, path: pathValue });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case AssetPathType.THUMBNAIL: {
|
|
||||||
await this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.THUMBNAIL, path: pathValue });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case AssetPathType.ORIGINAL: {
|
|
||||||
await this.assetRepository.update({ id, originalPath: pathValue });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case AssetPathType.SIDECAR: {
|
|
||||||
await this.assetRepository.update({ id, sidecarPath: pathValue });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case PersonPathType.FACE: {
|
|
||||||
await this.personRepository.update({ id, thumbnailPath: pathValue });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case UserPathType.PROFILE: {
|
|
||||||
await this.userRepository.update(id, { profileImagePath: pathValue });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fullPath(filename: string) {
|
|
||||||
return resolve(filename);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getFileReport() {
|
|
||||||
const hasFile = (items: Set<string>, filename: string) => items.has(filename) || items.has(this.fullPath(filename));
|
|
||||||
const crawl = async (folder: StorageFolder) =>
|
|
||||||
new Set(
|
|
||||||
await this.storageRepository.crawl({
|
|
||||||
includeHidden: true,
|
|
||||||
pathsToCrawl: [StorageCore.getBaseFolder(folder)],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const uploadFiles = await crawl(StorageFolder.UPLOAD);
|
|
||||||
const libraryFiles = await crawl(StorageFolder.LIBRARY);
|
|
||||||
const thumbFiles = await crawl(StorageFolder.THUMBNAILS);
|
|
||||||
const videoFiles = await crawl(StorageFolder.ENCODED_VIDEO);
|
|
||||||
const profileFiles = await crawl(StorageFolder.PROFILE);
|
|
||||||
const allFiles = new Set<string>();
|
|
||||||
for (const list of [libraryFiles, thumbFiles, videoFiles, profileFiles, uploadFiles]) {
|
|
||||||
for (const item of list) {
|
|
||||||
allFiles.add(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const track = (filename: string | null | undefined) => {
|
|
||||||
if (!filename) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
allFiles.delete(filename);
|
|
||||||
allFiles.delete(this.fullPath(filename));
|
|
||||||
};
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`Found ${libraryFiles.size} original files, ${thumbFiles.size} thumbnails, ${videoFiles.size} encoded videos, ${profileFiles.size} profile files`,
|
|
||||||
);
|
|
||||||
const pagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (options) =>
|
|
||||||
this.assetRepository.getAll(options, { withDeleted: true, withArchived: true }),
|
|
||||||
);
|
|
||||||
|
|
||||||
let assetCount = 0;
|
|
||||||
|
|
||||||
const orphans: FileReportItemDto[] = [];
|
|
||||||
for await (const assets of pagination) {
|
|
||||||
assetCount += assets.length;
|
|
||||||
for (const { id, files, originalPath, encodedVideoPath, isExternal, checksum } of assets) {
|
|
||||||
const { fullsizeFile, previewFile, thumbnailFile } = getAssetFiles(files);
|
|
||||||
for (const file of [
|
|
||||||
originalPath,
|
|
||||||
fullsizeFile?.path,
|
|
||||||
previewFile?.path,
|
|
||||||
encodedVideoPath,
|
|
||||||
thumbnailFile?.path,
|
|
||||||
]) {
|
|
||||||
track(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
const entity = { entityId: id, entityType: PathEntityType.ASSET, checksum: checksum.toString('base64') };
|
|
||||||
if (
|
|
||||||
originalPath &&
|
|
||||||
!hasFile(libraryFiles, originalPath) &&
|
|
||||||
!hasFile(uploadFiles, originalPath) &&
|
|
||||||
// Android motion assets
|
|
||||||
!hasFile(videoFiles, originalPath) &&
|
|
||||||
// ignore external library assets
|
|
||||||
!isExternal
|
|
||||||
) {
|
|
||||||
orphans.push({ ...entity, pathType: AssetPathType.ORIGINAL, pathValue: originalPath });
|
|
||||||
}
|
|
||||||
if (previewFile && !hasFile(thumbFiles, previewFile.path)) {
|
|
||||||
orphans.push({ ...entity, pathType: AssetPathType.PREVIEW, pathValue: previewFile.path });
|
|
||||||
}
|
|
||||||
if (thumbnailFile && !hasFile(thumbFiles, thumbnailFile.path)) {
|
|
||||||
orphans.push({ ...entity, pathType: AssetPathType.THUMBNAIL, pathValue: thumbnailFile.path });
|
|
||||||
}
|
|
||||||
if (encodedVideoPath && !hasFile(videoFiles, encodedVideoPath)) {
|
|
||||||
orphans.push({ ...entity, pathType: AssetPathType.THUMBNAIL, pathValue: encodedVideoPath });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const users = await this.userRepository.getList();
|
|
||||||
for (const { id, profileImagePath } of users) {
|
|
||||||
track(profileImagePath);
|
|
||||||
|
|
||||||
const entity = { entityId: id, entityType: PathEntityType.USER };
|
|
||||||
if (profileImagePath && !hasFile(profileFiles, profileImagePath)) {
|
|
||||||
orphans.push({ ...entity, pathType: UserPathType.PROFILE, pathValue: profileImagePath });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let peopleCount = 0;
|
|
||||||
for await (const { id, thumbnailPath } of this.personRepository.getAll()) {
|
|
||||||
track(thumbnailPath);
|
|
||||||
const entity = { entityId: id, entityType: PathEntityType.PERSON };
|
|
||||||
if (thumbnailPath && !hasFile(thumbFiles, thumbnailPath)) {
|
|
||||||
orphans.push({ ...entity, pathType: PersonPathType.FACE, pathValue: thumbnailPath });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (peopleCount === JOBS_ASSET_PAGINATION_SIZE) {
|
|
||||||
this.logger.log(`Found ${assetCount} assets, ${users.length} users, ${peopleCount} people`);
|
|
||||||
peopleCount = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`Found ${assetCount} assets, ${users.length} users, ${peopleCount} people`);
|
|
||||||
|
|
||||||
const extras: string[] = [];
|
|
||||||
for (const file of allFiles) {
|
|
||||||
extras.push(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
// send as absolute paths
|
|
||||||
for (const orphan of orphans) {
|
|
||||||
orphan.pathValue = this.fullPath(orphan.pathValue);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { orphans, extras };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
import { AssetFileType, AssetType, JobName, JobStatus } from 'src/enum';
|
import { AssetFileType, AssetType, JobName, JobStatus } from 'src/enum';
|
||||||
import { WithoutProperty } from 'src/repositories/asset.repository';
|
|
||||||
import { DuplicateService } from 'src/services/duplicate.service';
|
import { DuplicateService } from 'src/services/duplicate.service';
|
||||||
import { SearchService } from 'src/services/search.service';
|
import { SearchService } from 'src/services/search.service';
|
||||||
import { assetStub } from 'test/fixtures/asset.stub';
|
import { assetStub } from 'test/fixtures/asset.stub';
|
||||||
import { authStub } from 'test/fixtures/auth.stub';
|
import { authStub } from 'test/fixtures/auth.stub';
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||||
import { beforeEach, vitest } from 'vitest';
|
import { beforeEach, vitest } from 'vitest';
|
||||||
|
|
||||||
vitest.useFakeTimers();
|
vitest.useFakeTimers();
|
||||||
@ -113,14 +112,11 @@ describe(SearchService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should queue missing assets', async () => {
|
it('should queue missing assets', async () => {
|
||||||
mocks.asset.getWithout.mockResolvedValue({
|
mocks.assetJob.streamForSearchDuplicates.mockReturnValue(makeStream([assetStub.image]));
|
||||||
items: [assetStub.image],
|
|
||||||
hasNextPage: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await sut.handleQueueSearchDuplicates({});
|
await sut.handleQueueSearchDuplicates({});
|
||||||
|
|
||||||
expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.DUPLICATE);
|
expect(mocks.assetJob.streamForSearchDuplicates).toHaveBeenCalledWith(undefined);
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.DUPLICATE_DETECTION,
|
name: JobName.DUPLICATE_DETECTION,
|
||||||
@ -130,14 +126,11 @@ describe(SearchService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should queue all assets', async () => {
|
it('should queue all assets', async () => {
|
||||||
mocks.asset.getAll.mockResolvedValue({
|
mocks.assetJob.streamForSearchDuplicates.mockReturnValue(makeStream([assetStub.image]));
|
||||||
items: [assetStub.image],
|
|
||||||
hasNextPage: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await sut.handleQueueSearchDuplicates({ force: true });
|
await sut.handleQueueSearchDuplicates({ force: true });
|
||||||
|
|
||||||
expect(mocks.asset.getAll).toHaveBeenCalled();
|
expect(mocks.assetJob.streamForSearchDuplicates).toHaveBeenCalledWith(true);
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.DUPLICATE_DETECTION,
|
name: JobName.DUPLICATE_DETECTION,
|
||||||
|
@ -5,13 +5,11 @@ import { mapAsset } from 'src/dtos/asset-response.dto';
|
|||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { DuplicateResponseDto } from 'src/dtos/duplicate.dto';
|
import { DuplicateResponseDto } from 'src/dtos/duplicate.dto';
|
||||||
import { AssetFileType, JobName, JobStatus, QueueName } from 'src/enum';
|
import { AssetFileType, JobName, JobStatus, QueueName } from 'src/enum';
|
||||||
import { WithoutProperty } from 'src/repositories/asset.repository';
|
|
||||||
import { AssetDuplicateResult } from 'src/repositories/search.repository';
|
import { AssetDuplicateResult } from 'src/repositories/search.repository';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { JobOf } from 'src/types';
|
import { JobItem, JobOf } from 'src/types';
|
||||||
import { getAssetFile } from 'src/utils/asset.util';
|
import { getAssetFile } from 'src/utils/asset.util';
|
||||||
import { isDuplicateDetectionEnabled } from 'src/utils/misc';
|
import { isDuplicateDetectionEnabled } from 'src/utils/misc';
|
||||||
import { usePagination } from 'src/utils/pagination';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DuplicateService extends BaseService {
|
export class DuplicateService extends BaseService {
|
||||||
@ -30,17 +28,21 @@ export class DuplicateService extends BaseService {
|
|||||||
return JobStatus.SKIPPED;
|
return JobStatus.SKIPPED;
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
let jobs: JobItem[] = [];
|
||||||
return force
|
const queueAll = async () => {
|
||||||
? this.assetRepository.getAll(pagination, { isVisible: true })
|
await this.jobRepository.queueAll(jobs);
|
||||||
: this.assetRepository.getWithout(pagination, WithoutProperty.DUPLICATE);
|
jobs = [];
|
||||||
});
|
};
|
||||||
|
|
||||||
for await (const assets of assetPagination) {
|
const assets = this.assetJobRepository.streamForSearchDuplicates(force);
|
||||||
await this.jobRepository.queueAll(
|
for await (const asset of assets) {
|
||||||
assets.map((asset) => ({ name: JobName.DUPLICATE_DETECTION, data: { id: asset.id } })),
|
jobs.push({ name: JobName.DUPLICATE_DETECTION, data: { id: asset.id } });
|
||||||
);
|
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||||
|
await queueAll();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await queueAll();
|
||||||
|
|
||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
@ -239,10 +239,6 @@ describe(JobService.name, () => {
|
|||||||
item: { name: JobName.SIDECAR_DISCOVERY, data: { id: 'asset-1' } },
|
item: { name: JobName.SIDECAR_DISCOVERY, data: { id: 'asset-1' } },
|
||||||
jobs: [JobName.METADATA_EXTRACTION],
|
jobs: [JobName.METADATA_EXTRACTION],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
item: { name: JobName.METADATA_EXTRACTION, data: { id: 'asset-1' } },
|
|
||||||
jobs: [JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1', source: 'upload' } },
|
item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1', source: 'upload' } },
|
||||||
jobs: [JobName.GENERATE_THUMBNAILS],
|
jobs: [JobName.GENERATE_THUMBNAILS],
|
||||||
|
@ -264,17 +264,6 @@ export class JobService extends BaseService {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case JobName.METADATA_EXTRACTION: {
|
|
||||||
if (item.data.source === 'sidecar-write') {
|
|
||||||
const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([item.data.id]);
|
|
||||||
if (asset) {
|
|
||||||
this.eventRepository.clientSend('on_asset_update', asset.ownerId, mapAsset(asset));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: item.data });
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: {
|
case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: {
|
||||||
if (item.data.source === 'upload' || item.data.source === 'copy') {
|
if (item.data.source === 'upload' || item.data.source === 'copy') {
|
||||||
await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: item.data });
|
await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: item.data });
|
||||||
|
@ -273,7 +273,6 @@ describe(LibraryService.name, () => {
|
|||||||
|
|
||||||
mocks.library.get.mockResolvedValue(library);
|
mocks.library.get.mockResolvedValue(library);
|
||||||
mocks.storage.walk.mockImplementation(async function* generator() {});
|
mocks.storage.walk.mockImplementation(async function* generator() {});
|
||||||
mocks.asset.getAll.mockResolvedValue({ items: [assetStub.external], hasNextPage: false });
|
|
||||||
mocks.asset.getLibraryAssetCount.mockResolvedValue(1);
|
mocks.asset.getLibraryAssetCount.mockResolvedValue(1);
|
||||||
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: BigInt(1) });
|
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: BigInt(1) });
|
||||||
|
|
||||||
@ -292,7 +291,6 @@ describe(LibraryService.name, () => {
|
|||||||
|
|
||||||
mocks.library.get.mockResolvedValue(library);
|
mocks.library.get.mockResolvedValue(library);
|
||||||
mocks.storage.walk.mockImplementation(async function* generator() {});
|
mocks.storage.walk.mockImplementation(async function* generator() {});
|
||||||
mocks.asset.getAll.mockResolvedValue({ items: [assetStub.external], hasNextPage: false });
|
|
||||||
mocks.asset.getLibraryAssetCount.mockResolvedValue(0);
|
mocks.asset.getLibraryAssetCount.mockResolvedValue(0);
|
||||||
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: BigInt(1) });
|
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: BigInt(1) });
|
||||||
|
|
||||||
|
@ -38,10 +38,6 @@ describe(MediaService.name, () => {
|
|||||||
describe('handleQueueGenerateThumbnails', () => {
|
describe('handleQueueGenerateThumbnails', () => {
|
||||||
it('should queue all assets', async () => {
|
it('should queue all assets', async () => {
|
||||||
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.image]));
|
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.image]));
|
||||||
mocks.asset.getAll.mockResolvedValue({
|
|
||||||
items: [assetStub.image],
|
|
||||||
hasNextPage: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
mocks.person.getAll.mockReturnValue(makeStream([personStub.newThumbnail]));
|
mocks.person.getAll.mockReturnValue(makeStream([personStub.newThumbnail]));
|
||||||
mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]);
|
mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]);
|
||||||
@ -67,10 +63,6 @@ describe(MediaService.name, () => {
|
|||||||
|
|
||||||
it('should queue trashed assets when force is true', async () => {
|
it('should queue trashed assets when force is true', async () => {
|
||||||
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.archived]));
|
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.archived]));
|
||||||
mocks.asset.getAll.mockResolvedValue({
|
|
||||||
items: [assetStub.trashed],
|
|
||||||
hasNextPage: false,
|
|
||||||
});
|
|
||||||
mocks.person.getAll.mockReturnValue(makeStream());
|
mocks.person.getAll.mockReturnValue(makeStream());
|
||||||
|
|
||||||
await sut.handleQueueGenerateThumbnails({ force: true });
|
await sut.handleQueueGenerateThumbnails({ force: true });
|
||||||
@ -171,7 +163,7 @@ describe(MediaService.name, () => {
|
|||||||
|
|
||||||
describe('handleQueueMigration', () => {
|
describe('handleQueueMigration', () => {
|
||||||
it('should remove empty directories and queue jobs', async () => {
|
it('should remove empty directories and queue jobs', async () => {
|
||||||
mocks.asset.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] });
|
mocks.assetJob.streamForMigrationJob.mockReturnValue(makeStream([assetStub.image]));
|
||||||
mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts);
|
mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts);
|
||||||
mocks.person.getAll.mockReturnValue(makeStream([personStub.withName]));
|
mocks.person.getAll.mockReturnValue(makeStream([personStub.withName]));
|
||||||
|
|
||||||
|
@ -36,7 +36,6 @@ import {
|
|||||||
import { getAssetFiles } from 'src/utils/asset.util';
|
import { getAssetFiles } from 'src/utils/asset.util';
|
||||||
import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
|
import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
|
||||||
import { mimeTypes } from 'src/utils/mime-types';
|
import { mimeTypes } from 'src/utils/mime-types';
|
||||||
import { usePagination } from 'src/utils/pagination';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MediaService extends BaseService {
|
export class MediaService extends BaseService {
|
||||||
@ -50,18 +49,26 @@ export class MediaService extends BaseService {
|
|||||||
|
|
||||||
@OnJob({ name: JobName.QUEUE_GENERATE_THUMBNAILS, queue: QueueName.THUMBNAIL_GENERATION })
|
@OnJob({ name: JobName.QUEUE_GENERATE_THUMBNAILS, queue: QueueName.THUMBNAIL_GENERATION })
|
||||||
async handleQueueGenerateThumbnails({ force }: JobOf<JobName.QUEUE_GENERATE_THUMBNAILS>): Promise<JobStatus> {
|
async handleQueueGenerateThumbnails({ force }: JobOf<JobName.QUEUE_GENERATE_THUMBNAILS>): Promise<JobStatus> {
|
||||||
const thumbJobs: JobItem[] = [];
|
let jobs: JobItem[] = [];
|
||||||
|
|
||||||
|
const queueAll = async () => {
|
||||||
|
await this.jobRepository.queueAll(jobs);
|
||||||
|
jobs = [];
|
||||||
|
};
|
||||||
|
|
||||||
for await (const asset of this.assetJobRepository.streamForThumbnailJob(!!force)) {
|
for await (const asset of this.assetJobRepository.streamForThumbnailJob(!!force)) {
|
||||||
const { previewFile, thumbnailFile } = getAssetFiles(asset.files);
|
const { previewFile, thumbnailFile } = getAssetFiles(asset.files);
|
||||||
|
|
||||||
if (!previewFile || !thumbnailFile || !asset.thumbhash || force) {
|
if (!previewFile || !thumbnailFile || !asset.thumbhash || force) {
|
||||||
thumbJobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id: asset.id } });
|
jobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id: asset.id } });
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
await this.jobRepository.queueAll(thumbJobs);
|
|
||||||
|
|
||||||
const jobs: JobItem[] = [];
|
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||||
|
await queueAll();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await queueAll();
|
||||||
|
|
||||||
const people = this.personRepository.getAll(force ? undefined : { thumbnailPath: '' });
|
const people = this.personRepository.getAll(force ? undefined : { thumbnailPath: '' });
|
||||||
|
|
||||||
@ -76,32 +83,36 @@ export class MediaService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: person.id } });
|
jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: person.id } });
|
||||||
|
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||||
|
await queueAll();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.jobRepository.queueAll(jobs);
|
await queueAll();
|
||||||
|
|
||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnJob({ name: JobName.QUEUE_MIGRATION, queue: QueueName.MIGRATION })
|
@OnJob({ name: JobName.QUEUE_MIGRATION, queue: QueueName.MIGRATION })
|
||||||
async handleQueueMigration(): Promise<JobStatus> {
|
async handleQueueMigration(): Promise<JobStatus> {
|
||||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
|
|
||||||
this.assetRepository.getAll(pagination),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { active, waiting } = await this.jobRepository.getJobCounts(QueueName.MIGRATION);
|
const { active, waiting } = await this.jobRepository.getJobCounts(QueueName.MIGRATION);
|
||||||
if (active === 1 && waiting === 0) {
|
if (active === 1 && waiting === 0) {
|
||||||
await this.storageCore.removeEmptyDirs(StorageFolder.THUMBNAILS);
|
await this.storageCore.removeEmptyDirs(StorageFolder.THUMBNAILS);
|
||||||
await this.storageCore.removeEmptyDirs(StorageFolder.ENCODED_VIDEO);
|
await this.storageCore.removeEmptyDirs(StorageFolder.ENCODED_VIDEO);
|
||||||
}
|
}
|
||||||
|
|
||||||
for await (const assets of assetPagination) {
|
let jobs: JobItem[] = [];
|
||||||
await this.jobRepository.queueAll(
|
const assets = this.assetJobRepository.streamForMigrationJob();
|
||||||
assets.map((asset) => ({ name: JobName.MIGRATE_ASSET, data: { id: asset.id } })),
|
for await (const asset of assets) {
|
||||||
);
|
jobs.push({ name: JobName.MIGRATE_ASSET, data: { id: asset.id } });
|
||||||
|
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||||
|
await this.jobRepository.queueAll(jobs);
|
||||||
|
jobs = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let jobs: { name: JobName.MIGRATE_PERSON; data: { id: string } }[] = [];
|
await this.jobRepository.queueAll(jobs);
|
||||||
|
jobs = [];
|
||||||
|
|
||||||
for await (const person of this.personRepository.getAll()) {
|
for await (const person of this.personRepository.getAll()) {
|
||||||
jobs.push({ name: JobName.MIGRATE_PERSON, data: { id: person.id } });
|
jobs.push({ name: JobName.MIGRATE_PERSON, data: { id: person.id } });
|
||||||
@ -255,7 +266,9 @@ export class MediaService extends BaseService {
|
|||||||
|
|
||||||
const { info, data, colorspace } = await this.decodeImage(
|
const { info, data, colorspace } = await this.decodeImage(
|
||||||
extracted ? extracted.buffer : asset.originalPath,
|
extracted ? extracted.buffer : asset.originalPath,
|
||||||
asset.exifInfo,
|
// only specify orientation to extracted images which don't have EXIF orientation data
|
||||||
|
// or it can double rotate the image
|
||||||
|
extracted ? asset.exifInfo : { ...asset.exifInfo, orientation: null },
|
||||||
convertFullsize ? undefined : image.preview.size,
|
convertFullsize ? undefined : image.preview.size,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -5,7 +5,6 @@ import { constants } from 'node:fs/promises';
|
|||||||
import { defaults } from 'src/config';
|
import { defaults } from 'src/config';
|
||||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||||
import { AssetType, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum';
|
import { AssetType, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum';
|
||||||
import { WithoutProperty } from 'src/repositories/asset.repository';
|
|
||||||
import { ImmichTags } from 'src/repositories/metadata.repository';
|
import { ImmichTags } from 'src/repositories/metadata.repository';
|
||||||
import { MetadataService } from 'src/services/metadata.service';
|
import { MetadataService } from 'src/services/metadata.service';
|
||||||
import { assetStub } from 'test/fixtures/asset.stub';
|
import { assetStub } from 'test/fixtures/asset.stub';
|
||||||
@ -144,7 +143,8 @@ describe(MetadataService.name, () => {
|
|||||||
|
|
||||||
it('should handle an asset that could not be found', async () => {
|
it('should handle an asset that could not be found', async () => {
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(void 0);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(void 0);
|
||||||
await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED);
|
|
||||||
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
|
|
||||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
|
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
|
||||||
expect(mocks.asset.upsertExif).not.toHaveBeenCalled();
|
expect(mocks.asset.upsertExif).not.toHaveBeenCalled();
|
||||||
@ -527,7 +527,7 @@ describe(MetadataService.name, () => {
|
|||||||
ContainerDirectory: [{ Foo: 100 }],
|
ContainerDirectory: [{ Foo: 100 }],
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS);
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should extract the correct video orientation', async () => {
|
it('should extract the correct video orientation', async () => {
|
||||||
@ -1202,7 +1202,7 @@ describe(MetadataService.name, () => {
|
|||||||
it('should handle livePhotoCID not set', async () => {
|
it('should handle livePhotoCID not set', async () => {
|
||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
|
||||||
|
|
||||||
await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS);
|
await sut.handleMetadataExtraction({ id: assetStub.image.id });
|
||||||
|
|
||||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
|
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
|
||||||
expect(mocks.asset.findLivePhotoMatch).not.toHaveBeenCalled();
|
expect(mocks.asset.findLivePhotoMatch).not.toHaveBeenCalled();
|
||||||
@ -1215,9 +1215,7 @@ describe(MetadataService.name, () => {
|
|||||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
||||||
mockReadTags({ ContentIdentifier: 'CID' });
|
mockReadTags({ ContentIdentifier: 'CID' });
|
||||||
|
|
||||||
await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id })).resolves.toBe(
|
await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id });
|
||||||
JobStatus.SUCCESS,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id);
|
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id);
|
||||||
expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({
|
expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({
|
||||||
@ -1236,9 +1234,7 @@ describe(MetadataService.name, () => {
|
|||||||
mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
||||||
mockReadTags({ ContentIdentifier: 'CID' });
|
mockReadTags({ ContentIdentifier: 'CID' });
|
||||||
|
|
||||||
await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(
|
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
|
||||||
JobStatus.SUCCESS,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoStillAsset.id);
|
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoStillAsset.id);
|
||||||
expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({
|
expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({
|
||||||
@ -1262,9 +1258,7 @@ describe(MetadataService.name, () => {
|
|||||||
mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
||||||
mockReadTags({ ContentIdentifier: 'CID' });
|
mockReadTags({ ContentIdentifier: 'CID' });
|
||||||
|
|
||||||
await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(
|
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
|
||||||
JobStatus.SUCCESS,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mocks.event.emit).toHaveBeenCalledWith('asset.hide', {
|
expect(mocks.event.emit).toHaveBeenCalledWith('asset.hide', {
|
||||||
userId: assetStub.livePhotoMotionAsset.ownerId,
|
userId: assetStub.livePhotoMotionAsset.ownerId,
|
||||||
@ -1280,10 +1274,12 @@ describe(MetadataService.name, () => {
|
|||||||
mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
||||||
mockReadTags({ ContentIdentifier: 'CID' });
|
mockReadTags({ ContentIdentifier: 'CID' });
|
||||||
|
|
||||||
await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(
|
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
|
||||||
JobStatus.SUCCESS,
|
|
||||||
);
|
|
||||||
|
|
||||||
|
expect(mocks.event.emit).toHaveBeenCalledWith('asset.metadataExtracted', {
|
||||||
|
assetId: assetStub.livePhotoStillAsset.id,
|
||||||
|
userId: assetStub.livePhotoStillAsset.ownerId,
|
||||||
|
});
|
||||||
expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({
|
expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({
|
||||||
ownerId: 'user-id',
|
ownerId: 'user-id',
|
||||||
otherAssetId: 'live-photo-still-asset',
|
otherAssetId: 'live-photo-still-asset',
|
||||||
@ -1346,12 +1342,11 @@ describe(MetadataService.name, () => {
|
|||||||
|
|
||||||
describe('handleQueueSidecar', () => {
|
describe('handleQueueSidecar', () => {
|
||||||
it('should queue assets with sidecar files', async () => {
|
it('should queue assets with sidecar files', async () => {
|
||||||
mocks.asset.getAll.mockResolvedValue({ items: [assetStub.sidecar], hasNextPage: false });
|
mocks.assetJob.streamForSidecar.mockReturnValue(makeStream([assetStub.image]));
|
||||||
|
|
||||||
await sut.handleQueueSidecar({ force: true });
|
await sut.handleQueueSidecar({ force: true });
|
||||||
|
expect(mocks.assetJob.streamForSidecar).toHaveBeenCalledWith(true);
|
||||||
|
|
||||||
expect(mocks.asset.getAll).toHaveBeenCalledWith({ take: 1000, skip: 0 });
|
|
||||||
expect(mocks.asset.getWithout).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.SIDECAR_SYNC,
|
name: JobName.SIDECAR_SYNC,
|
||||||
@ -1361,12 +1356,11 @@ describe(MetadataService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should queue assets without sidecar files', async () => {
|
it('should queue assets without sidecar files', async () => {
|
||||||
mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false });
|
mocks.assetJob.streamForSidecar.mockReturnValue(makeStream([assetStub.image]));
|
||||||
|
|
||||||
await sut.handleQueueSidecar({ force: false });
|
await sut.handleQueueSidecar({ force: false });
|
||||||
|
|
||||||
expect(mocks.asset.getWithout).toHaveBeenCalledWith({ take: 1000, skip: 0 }, WithoutProperty.SIDECAR);
|
expect(mocks.assetJob.streamForSidecar).toHaveBeenCalledWith(false);
|
||||||
expect(mocks.asset.getAll).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.SIDECAR_DISCOVERY,
|
name: JobName.SIDECAR_DISCOVERY,
|
||||||
|
@ -22,14 +22,12 @@ import {
|
|||||||
QueueName,
|
QueueName,
|
||||||
SourceType,
|
SourceType,
|
||||||
} from 'src/enum';
|
} from 'src/enum';
|
||||||
import { WithoutProperty } from 'src/repositories/asset.repository';
|
|
||||||
import { ArgOf } from 'src/repositories/event.repository';
|
import { ArgOf } from 'src/repositories/event.repository';
|
||||||
import { ReverseGeocodeResult } from 'src/repositories/map.repository';
|
import { ReverseGeocodeResult } from 'src/repositories/map.repository';
|
||||||
import { ImmichTags } from 'src/repositories/metadata.repository';
|
import { ImmichTags } from 'src/repositories/metadata.repository';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { JobOf } from 'src/types';
|
import { JobItem, JobOf } from 'src/types';
|
||||||
import { isFaceImportEnabled } from 'src/utils/misc';
|
import { isFaceImportEnabled } from 'src/utils/misc';
|
||||||
import { usePagination } from 'src/utils/pagination';
|
|
||||||
import { upsertTags } from 'src/utils/tag';
|
import { upsertTags } from 'src/utils/tag';
|
||||||
|
|
||||||
/** look for a date from these tags (in order) */
|
/** look for a date from these tags (in order) */
|
||||||
@ -184,14 +182,14 @@ export class MetadataService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@OnJob({ name: JobName.METADATA_EXTRACTION, queue: QueueName.METADATA_EXTRACTION })
|
@OnJob({ name: JobName.METADATA_EXTRACTION, queue: QueueName.METADATA_EXTRACTION })
|
||||||
async handleMetadataExtraction(data: JobOf<JobName.METADATA_EXTRACTION>): Promise<JobStatus> {
|
async handleMetadataExtraction(data: JobOf<JobName.METADATA_EXTRACTION>) {
|
||||||
const [{ metadata, reverseGeocoding }, asset] = await Promise.all([
|
const [{ metadata, reverseGeocoding }, asset] = await Promise.all([
|
||||||
this.getConfig({ withCache: true }),
|
this.getConfig({ withCache: true }),
|
||||||
this.assetJobRepository.getForMetadataExtraction(data.id),
|
this.assetJobRepository.getForMetadataExtraction(data.id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
return JobStatus.FAILED;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [exifTags, stats] = await Promise.all([
|
const [exifTags, stats] = await Promise.all([
|
||||||
@ -285,26 +283,30 @@ export class MetadataService extends BaseService {
|
|||||||
|
|
||||||
await this.assetRepository.upsertJobStatus({ assetId: asset.id, metadataExtractedAt: new Date() });
|
await this.assetRepository.upsertJobStatus({ assetId: asset.id, metadataExtractedAt: new Date() });
|
||||||
|
|
||||||
return JobStatus.SUCCESS;
|
await this.eventRepository.emit('asset.metadataExtracted', {
|
||||||
|
assetId: asset.id,
|
||||||
|
userId: asset.ownerId,
|
||||||
|
source: data.source,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnJob({ name: JobName.QUEUE_SIDECAR, queue: QueueName.SIDECAR })
|
@OnJob({ name: JobName.QUEUE_SIDECAR, queue: QueueName.SIDECAR })
|
||||||
async handleQueueSidecar(job: JobOf<JobName.QUEUE_SIDECAR>): Promise<JobStatus> {
|
async handleQueueSidecar({ force }: JobOf<JobName.QUEUE_SIDECAR>): Promise<JobStatus> {
|
||||||
const { force } = job;
|
let jobs: JobItem[] = [];
|
||||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
const queueAll = async () => {
|
||||||
return force
|
await this.jobRepository.queueAll(jobs);
|
||||||
? this.assetRepository.getAll(pagination)
|
jobs = [];
|
||||||
: this.assetRepository.getWithout(pagination, WithoutProperty.SIDECAR);
|
};
|
||||||
});
|
|
||||||
|
|
||||||
for await (const assets of assetPagination) {
|
const assets = this.assetJobRepository.streamForSidecar(force);
|
||||||
await this.jobRepository.queueAll(
|
for await (const asset of assets) {
|
||||||
assets.map((asset) => ({
|
jobs.push({ name: force ? JobName.SIDECAR_SYNC : JobName.SIDECAR_DISCOVERY, data: { id: asset.id } });
|
||||||
name: force ? JobName.SIDECAR_SYNC : JobName.SIDECAR_DISCOVERY,
|
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||||
data: { id: asset.id },
|
await queueAll();
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await queueAll();
|
||||||
|
|
||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
@ -154,10 +154,10 @@ describe(NotificationService.name, () => {
|
|||||||
|
|
||||||
describe('onAlbumUpdateEvent', () => {
|
describe('onAlbumUpdateEvent', () => {
|
||||||
it('should queue notify album update event', async () => {
|
it('should queue notify album update event', async () => {
|
||||||
await sut.onAlbumUpdate({ id: 'album', recipientIds: ['42'] });
|
await sut.onAlbumUpdate({ id: 'album', recipientId: '42' });
|
||||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||||
name: JobName.NOTIFY_ALBUM_UPDATE,
|
name: JobName.NOTIFY_ALBUM_UPDATE,
|
||||||
data: { id: 'album', recipientIds: ['42'], delay: 300_000 },
|
data: { id: 'album', recipientId: '42', delay: 300_000 },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -414,14 +414,14 @@ describe(NotificationService.name, () => {
|
|||||||
|
|
||||||
describe('handleAlbumUpdate', () => {
|
describe('handleAlbumUpdate', () => {
|
||||||
it('should skip if album could not be found', async () => {
|
it('should skip if album could not be found', async () => {
|
||||||
await expect(sut.handleAlbumUpdate({ id: '', recipientIds: ['1'] })).resolves.toBe(JobStatus.SKIPPED);
|
await expect(sut.handleAlbumUpdate({ id: '', recipientId: '1' })).resolves.toBe(JobStatus.SKIPPED);
|
||||||
expect(mocks.user.get).not.toHaveBeenCalled();
|
expect(mocks.user.get).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should skip if owner could not be found', async () => {
|
it('should skip if owner could not be found', async () => {
|
||||||
mocks.album.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail);
|
mocks.album.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail);
|
||||||
|
|
||||||
await expect(sut.handleAlbumUpdate({ id: '', recipientIds: ['1'] })).resolves.toBe(JobStatus.SKIPPED);
|
await expect(sut.handleAlbumUpdate({ id: '', recipientId: '1' })).resolves.toBe(JobStatus.SKIPPED);
|
||||||
expect(mocks.systemMetadata.get).not.toHaveBeenCalled();
|
expect(mocks.systemMetadata.get).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -434,7 +434,7 @@ describe(NotificationService.name, () => {
|
|||||||
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
|
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||||
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
|
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
|
||||||
|
|
||||||
await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] });
|
await sut.handleAlbumUpdate({ id: '', recipientId: userStub.user1.id });
|
||||||
expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
|
expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
|
||||||
expect(mocks.email.renderEmail).not.toHaveBeenCalled();
|
expect(mocks.email.renderEmail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@ -456,7 +456,7 @@ describe(NotificationService.name, () => {
|
|||||||
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
|
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||||
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
|
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
|
||||||
|
|
||||||
await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] });
|
await sut.handleAlbumUpdate({ id: '', recipientId: userStub.user1.id });
|
||||||
expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
|
expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
|
||||||
expect(mocks.email.renderEmail).not.toHaveBeenCalled();
|
expect(mocks.email.renderEmail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@ -478,7 +478,7 @@ describe(NotificationService.name, () => {
|
|||||||
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
|
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||||
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
|
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
|
||||||
|
|
||||||
await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] });
|
await sut.handleAlbumUpdate({ id: '', recipientId: userStub.user1.id });
|
||||||
expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
|
expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
|
||||||
expect(mocks.email.renderEmail).not.toHaveBeenCalled();
|
expect(mocks.email.renderEmail).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@ -492,21 +492,21 @@ describe(NotificationService.name, () => {
|
|||||||
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
|
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||||
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
|
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
|
||||||
|
|
||||||
await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] });
|
await sut.handleAlbumUpdate({ id: '', recipientId: userStub.user1.id });
|
||||||
expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
|
expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
|
||||||
expect(mocks.email.renderEmail).toHaveBeenCalled();
|
expect(mocks.email.renderEmail).toHaveBeenCalled();
|
||||||
expect(mocks.job.queue).toHaveBeenCalled();
|
expect(mocks.job.queue).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add new recipients for new images if job is already queued', async () => {
|
it('should add new recipients for new images if job is already queued', async () => {
|
||||||
mocks.job.removeJob.mockResolvedValue({ id: '1', recipientIds: ['2', '3', '4'] } as INotifyAlbumUpdateJob);
|
await sut.onAlbumUpdate({ id: '1', recipientId: '2' } as INotifyAlbumUpdateJob);
|
||||||
await sut.onAlbumUpdate({ id: '1', recipientIds: ['1', '2', '3'] } as INotifyAlbumUpdateJob);
|
expect(mocks.job.removeJob).toHaveBeenCalledWith(JobName.NOTIFY_ALBUM_UPDATE, '1/2');
|
||||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||||
name: JobName.NOTIFY_ALBUM_UPDATE,
|
name: JobName.NOTIFY_ALBUM_UPDATE,
|
||||||
data: {
|
data: {
|
||||||
id: '1',
|
id: '1',
|
||||||
delay: 300_000,
|
delay: 300_000,
|
||||||
recipientIds: ['1', '2', '3', '4'],
|
recipientId: '2',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
import { OnEvent, OnJob } from 'src/decorators';
|
import { OnEvent, OnJob } from 'src/decorators';
|
||||||
|
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import {
|
import {
|
||||||
mapNotification,
|
mapNotification,
|
||||||
@ -22,7 +23,7 @@ import {
|
|||||||
import { EmailTemplate } from 'src/repositories/email.repository';
|
import { EmailTemplate } from 'src/repositories/email.repository';
|
||||||
import { ArgOf } from 'src/repositories/event.repository';
|
import { ArgOf } from 'src/repositories/event.repository';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { EmailImageAttachment, IEntityJob, INotifyAlbumUpdateJob, JobItem, JobOf } from 'src/types';
|
import { EmailImageAttachment, JobOf } from 'src/types';
|
||||||
import { getFilenameExtension } from 'src/utils/file';
|
import { getFilenameExtension } from 'src/utils/file';
|
||||||
import { getExternalDomain } from 'src/utils/misc';
|
import { getExternalDomain } from 'src/utils/misc';
|
||||||
import { isEqualObject } from 'src/utils/object';
|
import { isEqualObject } from 'src/utils/object';
|
||||||
@ -152,6 +153,18 @@ export class NotificationService extends BaseService {
|
|||||||
this.eventRepository.clientSend('on_asset_trash', userId, assetIds);
|
this.eventRepository.clientSend('on_asset_trash', userId, assetIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OnEvent({ name: 'asset.metadataExtracted' })
|
||||||
|
async onAssetMetadataExtracted({ assetId, userId, source }: ArgOf<'asset.metadataExtracted'>) {
|
||||||
|
if (source !== 'sidecar-write') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([assetId]);
|
||||||
|
if (asset) {
|
||||||
|
this.eventRepository.clientSend('on_asset_update', userId, mapAsset(asset));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OnEvent({ name: 'assets.restore' })
|
@OnEvent({ name: 'assets.restore' })
|
||||||
onAssetsRestore({ assetIds, userId }: ArgOf<'assets.restore'>) {
|
onAssetsRestore({ assetIds, userId }: ArgOf<'assets.restore'>) {
|
||||||
this.eventRepository.clientSend('on_asset_restore', userId, assetIds);
|
this.eventRepository.clientSend('on_asset_restore', userId, assetIds);
|
||||||
@ -185,30 +198,12 @@ export class NotificationService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent({ name: 'album.update' })
|
@OnEvent({ name: 'album.update' })
|
||||||
async onAlbumUpdate({ id, recipientIds }: ArgOf<'album.update'>) {
|
async onAlbumUpdate({ id, recipientId }: ArgOf<'album.update'>) {
|
||||||
// if recipientIds is empty, album likely only has one user part of it, don't queue notification if so
|
await this.jobRepository.removeJob(JobName.NOTIFY_ALBUM_UPDATE, `${id}/${recipientId}`);
|
||||||
if (recipientIds.length === 0) {
|
await this.jobRepository.queue({
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const job: JobItem = {
|
|
||||||
name: JobName.NOTIFY_ALBUM_UPDATE,
|
name: JobName.NOTIFY_ALBUM_UPDATE,
|
||||||
data: { id, recipientIds, delay: NotificationService.albumUpdateEmailDelayMs },
|
data: { id, recipientId, delay: NotificationService.albumUpdateEmailDelayMs },
|
||||||
};
|
});
|
||||||
|
|
||||||
const previousJobData = await this.jobRepository.removeJob(id, JobName.NOTIFY_ALBUM_UPDATE);
|
|
||||||
if (previousJobData && this.isAlbumUpdateJob(previousJobData)) {
|
|
||||||
for (const id of previousJobData.recipientIds) {
|
|
||||||
if (!recipientIds.includes(id)) {
|
|
||||||
recipientIds.push(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await this.jobRepository.queue(job);
|
|
||||||
}
|
|
||||||
|
|
||||||
private isAlbumUpdateJob(job: IEntityJob): job is INotifyAlbumUpdateJob {
|
|
||||||
return 'recipientIds' in job;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnEvent({ name: 'album.invite' })
|
@OnEvent({ name: 'album.invite' })
|
||||||
@ -399,7 +394,7 @@ export class NotificationService extends BaseService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@OnJob({ name: JobName.NOTIFY_ALBUM_UPDATE, queue: QueueName.NOTIFICATION })
|
@OnJob({ name: JobName.NOTIFY_ALBUM_UPDATE, queue: QueueName.NOTIFICATION })
|
||||||
async handleAlbumUpdate({ id, recipientIds }: JobOf<JobName.NOTIFY_ALBUM_UPDATE>) {
|
async handleAlbumUpdate({ id, recipientId }: JobOf<JobName.NOTIFY_ALBUM_UPDATE>) {
|
||||||
const album = await this.albumRepository.getById(id, { withAssets: false });
|
const album = await this.albumRepository.getById(id, { withAssets: false });
|
||||||
|
|
||||||
if (!album) {
|
if (!album) {
|
||||||
@ -411,23 +406,19 @@ export class NotificationService extends BaseService {
|
|||||||
return JobStatus.SKIPPED;
|
return JobStatus.SKIPPED;
|
||||||
}
|
}
|
||||||
|
|
||||||
const recipients = [...album.albumUsers.map((user) => user.user), owner].filter((user) =>
|
|
||||||
recipientIds.includes(user.id),
|
|
||||||
);
|
|
||||||
const attachment = await this.getAlbumThumbnailAttachment(album);
|
const attachment = await this.getAlbumThumbnailAttachment(album);
|
||||||
|
|
||||||
const { server, templates } = await this.getConfig({ withCache: false });
|
const { server, templates } = await this.getConfig({ withCache: false });
|
||||||
|
|
||||||
for (const recipient of recipients) {
|
const user = await this.userRepository.get(recipientId, { withDeleted: false });
|
||||||
const user = await this.userRepository.get(recipient.id, { withDeleted: false });
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
continue;
|
return JobStatus.SKIPPED;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { emailNotifications } = getPreferences(user.metadata);
|
const { emailNotifications } = getPreferences(user.metadata);
|
||||||
|
|
||||||
if (!emailNotifications.enabled || !emailNotifications.albumUpdate) {
|
if (!emailNotifications.enabled || !emailNotifications.albumUpdate) {
|
||||||
continue;
|
return JobStatus.SKIPPED;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { html, text } = await this.emailRepository.renderEmail({
|
const { html, text } = await this.emailRepository.renderEmail({
|
||||||
@ -436,7 +427,7 @@ export class NotificationService extends BaseService {
|
|||||||
baseUrl: getExternalDomain(server),
|
baseUrl: getExternalDomain(server),
|
||||||
albumId: album.id,
|
albumId: album.id,
|
||||||
albumName: album.albumName,
|
albumName: album.albumName,
|
||||||
recipientName: recipient.name,
|
recipientName: user.name,
|
||||||
cid: attachment ? attachment.cid : undefined,
|
cid: attachment ? attachment.cid : undefined,
|
||||||
},
|
},
|
||||||
customTemplate: templates.email.albumUpdateTemplate,
|
customTemplate: templates.email.albumUpdateTemplate,
|
||||||
@ -445,14 +436,13 @@ export class NotificationService extends BaseService {
|
|||||||
await this.jobRepository.queue({
|
await this.jobRepository.queue({
|
||||||
name: JobName.SEND_EMAIL,
|
name: JobName.SEND_EMAIL,
|
||||||
data: {
|
data: {
|
||||||
to: recipient.email,
|
to: user.email,
|
||||||
subject: `New media has been added to an album - ${album.albumName}`,
|
subject: `New media has been added to an album - ${album.albumName}`,
|
||||||
html,
|
html,
|
||||||
text,
|
text,
|
||||||
imageAttachments: attachment ? [attachment] : undefined,
|
imageAttachments: attachment ? [attachment] : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return JobStatus.SUCCESS;
|
return JobStatus.SUCCESS;
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,6 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
|
|||||||
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
||||||
import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.dto';
|
import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.dto';
|
||||||
import { CacheControl, Colorspace, ImageFormat, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum';
|
import { CacheControl, Colorspace, ImageFormat, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum';
|
||||||
import { WithoutProperty } from 'src/repositories/asset.repository';
|
|
||||||
import { DetectedFaces } from 'src/repositories/machine-learning.repository';
|
import { DetectedFaces } from 'src/repositories/machine-learning.repository';
|
||||||
import { FaceSearchResult } from 'src/repositories/search.repository';
|
import { FaceSearchResult } from 'src/repositories/search.repository';
|
||||||
import { PersonService } from 'src/services/person.service';
|
import { PersonService } from 'src/services/person.service';
|
||||||
@ -455,14 +454,11 @@ describe(PersonService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should queue missing assets', async () => {
|
it('should queue missing assets', async () => {
|
||||||
mocks.asset.getWithout.mockResolvedValue({
|
mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([assetStub.image]));
|
||||||
items: [assetStub.image],
|
|
||||||
hasNextPage: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await sut.handleQueueDetectFaces({ force: false });
|
await sut.handleQueueDetectFaces({ force: false });
|
||||||
|
|
||||||
expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.FACES);
|
expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(false);
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.FACE_DETECTION,
|
name: JobName.FACE_DETECTION,
|
||||||
@ -472,10 +468,7 @@ describe(PersonService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should queue all assets', async () => {
|
it('should queue all assets', async () => {
|
||||||
mocks.asset.getAll.mockResolvedValue({
|
mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([assetStub.image]));
|
||||||
items: [assetStub.image],
|
|
||||||
hasNextPage: false,
|
|
||||||
});
|
|
||||||
mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.withName]);
|
mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.withName]);
|
||||||
|
|
||||||
await sut.handleQueueDetectFaces({ force: true });
|
await sut.handleQueueDetectFaces({ force: true });
|
||||||
@ -483,7 +476,7 @@ describe(PersonService.name, () => {
|
|||||||
expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING });
|
expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING });
|
||||||
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.withName.id]);
|
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.withName.id]);
|
||||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath);
|
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath);
|
||||||
expect(mocks.asset.getAll).toHaveBeenCalled();
|
expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(true);
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.FACE_DETECTION,
|
name: JobName.FACE_DETECTION,
|
||||||
@ -493,17 +486,14 @@ describe(PersonService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should refresh all assets', async () => {
|
it('should refresh all assets', async () => {
|
||||||
mocks.asset.getAll.mockResolvedValue({
|
mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([assetStub.image]));
|
||||||
items: [assetStub.image],
|
|
||||||
hasNextPage: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
await sut.handleQueueDetectFaces({ force: undefined });
|
await sut.handleQueueDetectFaces({ force: undefined });
|
||||||
|
|
||||||
expect(mocks.person.delete).not.toHaveBeenCalled();
|
expect(mocks.person.delete).not.toHaveBeenCalled();
|
||||||
expect(mocks.person.deleteFaces).not.toHaveBeenCalled();
|
expect(mocks.person.deleteFaces).not.toHaveBeenCalled();
|
||||||
expect(mocks.storage.unlink).not.toHaveBeenCalled();
|
expect(mocks.storage.unlink).not.toHaveBeenCalled();
|
||||||
expect(mocks.asset.getAll).toHaveBeenCalled();
|
expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(undefined);
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.FACE_DETECTION,
|
name: JobName.FACE_DETECTION,
|
||||||
@ -516,16 +506,13 @@ describe(PersonService.name, () => {
|
|||||||
it('should delete existing people and faces if forced', async () => {
|
it('should delete existing people and faces if forced', async () => {
|
||||||
mocks.person.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson]));
|
mocks.person.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson]));
|
||||||
mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1]));
|
mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1]));
|
||||||
mocks.asset.getAll.mockResolvedValue({
|
mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([assetStub.image]));
|
||||||
items: [assetStub.image],
|
|
||||||
hasNextPage: false,
|
|
||||||
});
|
|
||||||
mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]);
|
mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]);
|
||||||
mocks.person.deleteFaces.mockResolvedValue();
|
mocks.person.deleteFaces.mockResolvedValue();
|
||||||
|
|
||||||
await sut.handleQueueDetectFaces({ force: true });
|
await sut.handleQueueDetectFaces({ force: true });
|
||||||
|
|
||||||
expect(mocks.asset.getAll).toHaveBeenCalled();
|
expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(true);
|
||||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
name: JobName.FACE_DETECTION,
|
name: JobName.FACE_DETECTION,
|
||||||
|
@ -36,7 +36,6 @@ import {
|
|||||||
SourceType,
|
SourceType,
|
||||||
SystemMetadataKey,
|
SystemMetadataKey,
|
||||||
} from 'src/enum';
|
} from 'src/enum';
|
||||||
import { WithoutProperty } from 'src/repositories/asset.repository';
|
|
||||||
import { BoundingBox } from 'src/repositories/machine-learning.repository';
|
import { BoundingBox } from 'src/repositories/machine-learning.repository';
|
||||||
import { UpdateFacesData } from 'src/repositories/person.repository';
|
import { UpdateFacesData } from 'src/repositories/person.repository';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
@ -44,7 +43,6 @@ import { CropOptions, ImageDimensions, InputDimensions, JobItem, JobOf } from 's
|
|||||||
import { ImmichFileResponse } from 'src/utils/file';
|
import { ImmichFileResponse } from 'src/utils/file';
|
||||||
import { mimeTypes } from 'src/utils/mime-types';
|
import { mimeTypes } from 'src/utils/mime-types';
|
||||||
import { isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc';
|
import { isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc';
|
||||||
import { usePagination } from 'src/utils/pagination';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PersonService extends BaseService {
|
export class PersonService extends BaseService {
|
||||||
@ -265,22 +263,18 @@ export class PersonService extends BaseService {
|
|||||||
await this.handlePersonCleanup();
|
await this.handlePersonCleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
let jobs: JobItem[] = [];
|
||||||
return force === false
|
const assets = this.assetJobRepository.streamForDetectFacesJob(force);
|
||||||
? this.assetRepository.getWithout(pagination, WithoutProperty.FACES)
|
for await (const asset of assets) {
|
||||||
: this.assetRepository.getAll(pagination, {
|
jobs.push({ name: JobName.FACE_DETECTION, data: { id: asset.id } });
|
||||||
orderDirection: 'desc',
|
|
||||||
withFaces: true,
|
|
||||||
withArchived: true,
|
|
||||||
isVisible: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
for await (const assets of assetPagination) {
|
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||||
await this.jobRepository.queueAll(
|
await this.jobRepository.queueAll(jobs);
|
||||||
assets.map((asset) => ({ name: JobName.FACE_DETECTION, data: { id: asset.id } })),
|
jobs = [];
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.jobRepository.queueAll(jobs);
|
||||||
|
|
||||||
if (force === undefined) {
|
if (force === undefined) {
|
||||||
await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP });
|
await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP });
|
||||||
|
@ -15,7 +15,6 @@ import {
|
|||||||
SmartSearchDto,
|
SmartSearchDto,
|
||||||
} from 'src/dtos/search.dto';
|
} from 'src/dtos/search.dto';
|
||||||
import { AssetOrder } from 'src/enum';
|
import { AssetOrder } from 'src/enum';
|
||||||
import { SearchExploreItem } from 'src/repositories/search.repository';
|
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { getMyPartnerIds } from 'src/utils/asset.util';
|
import { getMyPartnerIds } from 'src/utils/asset.util';
|
||||||
import { isSmartSearchEnabled } from 'src/utils/misc';
|
import { isSmartSearchEnabled } from 'src/utils/misc';
|
||||||
@ -32,7 +31,7 @@ export class SearchService extends BaseService {
|
|||||||
return places.map((place) => mapPlaces(place));
|
return places.map((place) => mapPlaces(place));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getExploreData(auth: AuthDto): Promise<SearchExploreItem<AssetResponseDto>[]> {
|
async getExploreData(auth: AuthDto) {
|
||||||
const options = { maxFields: 12, minAssetsPerField: 5 };
|
const options = { maxFields: 12, minAssetsPerField: 5 };
|
||||||
const cities = await this.assetRepository.getAssetIdByCity(auth.user.id, options);
|
const cities = await this.assetRepository.getAssetIdByCity(auth.user.id, options);
|
||||||
const assets = await this.assetRepository.getByIdsWithAllRelationsButStacks(cities.items.map(({ data }) => data));
|
const assets = await this.assetRepository.getByIdsWithAllRelationsButStacks(cities.items.map(({ data }) => data));
|
||||||
|
@ -151,7 +151,6 @@ describe(SmartInfoService.name, () => {
|
|||||||
|
|
||||||
await sut.handleQueueEncodeClip({});
|
await sut.handleQueueEncodeClip({});
|
||||||
|
|
||||||
expect(mocks.asset.getWithout).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
|
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -116,6 +116,11 @@ export class StorageTemplateService extends BaseService {
|
|||||||
return { ...storageTokens, presetOptions: storagePresets };
|
return { ...storageTokens, presetOptions: storagePresets };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OnEvent({ name: 'asset.metadataExtracted' })
|
||||||
|
async onAssetMetadataExtracted({ source, assetId }: ArgOf<'asset.metadataExtracted'>) {
|
||||||
|
await this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { source, id: assetId } });
|
||||||
|
}
|
||||||
|
|
||||||
@OnJob({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, queue: QueueName.STORAGE_TEMPLATE_MIGRATION })
|
@OnJob({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, queue: QueueName.STORAGE_TEMPLATE_MIGRATION })
|
||||||
async handleMigrationSingle({ id }: JobOf<JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE>): Promise<JobStatus> {
|
async handleMigrationSingle({ id }: JobOf<JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE>): Promise<JobStatus> {
|
||||||
const config = await this.getConfig({ withCache: true });
|
const config = await this.getConfig({ withCache: true });
|
||||||
|
@ -177,9 +177,10 @@ export interface IDelayedJob extends IBaseJob {
|
|||||||
delay?: number;
|
delay?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type JobSource = 'upload' | 'sidecar-write' | 'copy';
|
||||||
export interface IEntityJob extends IBaseJob {
|
export interface IEntityJob extends IBaseJob {
|
||||||
id: string;
|
id: string;
|
||||||
source?: 'upload' | 'sidecar-write' | 'copy';
|
source?: JobSource;
|
||||||
notify?: boolean;
|
notify?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -251,7 +252,7 @@ export interface INotifyAlbumInviteJob extends IEntityJob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob {
|
export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob {
|
||||||
recipientIds: string[];
|
recipientId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface JobCounts {
|
export interface JobCounts {
|
||||||
|
@ -8,22 +8,6 @@ export interface PaginationResult<T> {
|
|||||||
hasNextPage: boolean;
|
hasNextPage: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Paginated<T> = Promise<PaginationResult<T>>;
|
|
||||||
|
|
||||||
/** @deprecated use `this.db. ... .stream()` instead */
|
|
||||||
export async function* usePagination<T>(
|
|
||||||
pageSize: number,
|
|
||||||
getNextPage: (pagination: PaginationOptions) => PaginationResult<T> | Paginated<T>,
|
|
||||||
) {
|
|
||||||
let hasNextPage = true;
|
|
||||||
|
|
||||||
for (let skip = 0; hasNextPage; skip += pageSize) {
|
|
||||||
const result = await getNextPage({ take: pageSize, skip });
|
|
||||||
hasNextPage = result.hasNextPage;
|
|
||||||
yield result.items;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function paginationHelper<Entity extends object>(items: Entity[], take: number): PaginationResult<Entity> {
|
export function paginationHelper<Entity extends object>(items: Entity[], take: number): PaginationResult<Entity> {
|
||||||
const hasNextPage = items.length > take;
|
const hasNextPage = items.length > take;
|
||||||
items.splice(take);
|
items.splice(take);
|
||||||
|
@ -13,14 +13,11 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
|
|||||||
getByIds: vitest.fn().mockResolvedValue([]),
|
getByIds: vitest.fn().mockResolvedValue([]),
|
||||||
getByIdsWithAllRelationsButStacks: vitest.fn().mockResolvedValue([]),
|
getByIdsWithAllRelationsButStacks: vitest.fn().mockResolvedValue([]),
|
||||||
getByDeviceIds: vitest.fn(),
|
getByDeviceIds: vitest.fn(),
|
||||||
getByUserId: vitest.fn(),
|
|
||||||
getById: vitest.fn(),
|
getById: vitest.fn(),
|
||||||
getWithout: vitest.fn(),
|
|
||||||
getByChecksum: vitest.fn(),
|
getByChecksum: vitest.fn(),
|
||||||
getByChecksums: vitest.fn(),
|
getByChecksums: vitest.fn(),
|
||||||
getUploadAssetIdByChecksum: vitest.fn(),
|
getUploadAssetIdByChecksum: vitest.fn(),
|
||||||
getRandom: vitest.fn(),
|
getRandom: vitest.fn(),
|
||||||
getAll: vitest.fn().mockResolvedValue({ items: [], hasNextPage: false }),
|
|
||||||
getAllByDeviceId: vitest.fn(),
|
getAllByDeviceId: vitest.fn(),
|
||||||
getLivePhotoCount: vitest.fn(),
|
getLivePhotoCount: vitest.fn(),
|
||||||
getLibraryAssetCount: vitest.fn(),
|
getLibraryAssetCount: vitest.fn(),
|
||||||
|
@ -10,6 +10,7 @@ const envData: EnvData = {
|
|||||||
buildMetadata: {},
|
buildMetadata: {},
|
||||||
bull: {
|
bull: {
|
||||||
config: {
|
config: {
|
||||||
|
connection: {},
|
||||||
prefix: 'immich_bull',
|
prefix: 'immich_bull',
|
||||||
},
|
},
|
||||||
queues: [{ name: 'queue-1' }],
|
queues: [{ name: 'queue-1' }],
|
||||||
|
@ -5,10 +5,17 @@ TYPESCRIPT_SDK=/usr/src/open-api/typescript-sdk
|
|||||||
npm --prefix "$TYPESCRIPT_SDK" install
|
npm --prefix "$TYPESCRIPT_SDK" install
|
||||||
npm --prefix "$TYPESCRIPT_SDK" run build
|
npm --prefix "$TYPESCRIPT_SDK" run build
|
||||||
|
|
||||||
|
|
||||||
|
COUNT=0
|
||||||
UPSTREAM="${IMMICH_SERVER_URL:-http://immich-server:2283/}"
|
UPSTREAM="${IMMICH_SERVER_URL:-http://immich-server:2283/}"
|
||||||
until wget --spider --quiet "${UPSTREAM}/api/server/config"; do
|
until wget --spider --quiet "${UPSTREAM}/api/server/config" > /dev/null 2>&1; do
|
||||||
echo 'waiting for api server...'
|
if [ $((COUNT % 10)) -eq 0 ]; then
|
||||||
|
echo "Waiting for $UPSTREAM to start..."
|
||||||
|
fi
|
||||||
|
COUNT=$((COUNT + 1))
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
|
echo "Connected to $UPSTREAM"
|
||||||
|
|
||||||
node ./node_modules/.bin/vite dev --host 0.0.0.0 --port 3000
|
node ./node_modules/.bin/vite dev --host 0.0.0.0 --port 3000
|
||||||
|
@ -1,32 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Icon from '$lib/components/elements/icon.svelte';
|
|
||||||
import { AppRoute, timeBeforeShowLoadingSpinner } from '$lib/constants';
|
|
||||||
import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
|
|
||||||
import { getAssetType } from '$lib/utils/asset-utils';
|
|
||||||
import { autoGrowHeight } from '$lib/actions/autogrow';
|
import { autoGrowHeight } from '$lib/actions/autogrow';
|
||||||
|
import { shortcut } from '$lib/actions/shortcut';
|
||||||
|
import Icon from '$lib/components/elements/icon.svelte';
|
||||||
|
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||||
|
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||||
|
import { AppRoute, timeBeforeShowLoadingSpinner } from '$lib/constants';
|
||||||
|
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
||||||
|
import { locale } from '$lib/stores/preferences.store';
|
||||||
|
import { getAssetThumbnailUrl } from '$lib/utils';
|
||||||
|
import { getAssetType } from '$lib/utils/asset-utils';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { isTenMinutesApart } from '$lib/utils/timesince';
|
import { isTenMinutesApart } from '$lib/utils/timesince';
|
||||||
import {
|
import { ReactionType, type ActivityResponseDto, type AssetTypeEnum, type UserResponseDto } from '@immich/sdk';
|
||||||
ReactionType,
|
import { mdiClose, mdiDeleteOutline, mdiDotsVertical, mdiHeart, mdiSend } from '@mdi/js';
|
||||||
createActivity,
|
|
||||||
deleteActivity,
|
|
||||||
getActivities,
|
|
||||||
type ActivityResponseDto,
|
|
||||||
type AssetTypeEnum,
|
|
||||||
type UserResponseDto,
|
|
||||||
} from '@immich/sdk';
|
|
||||||
import { mdiClose, mdiDotsVertical, mdiHeart, mdiSend, mdiDeleteOutline } from '@mdi/js';
|
|
||||||
import * as luxon from 'luxon';
|
import * as luxon from 'luxon';
|
||||||
import { onMount } from 'svelte';
|
import { t } from 'svelte-i18n';
|
||||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||||
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
||||||
import UserAvatar from '../shared-components/user-avatar.svelte';
|
import UserAvatar from '../shared-components/user-avatar.svelte';
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
|
||||||
import { shortcut } from '$lib/actions/shortcut';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
|
||||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
|
||||||
|
|
||||||
const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'];
|
const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'];
|
||||||
|
|
||||||
@ -48,34 +40,16 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
reactions: ActivityResponseDto[];
|
|
||||||
user: UserResponseDto;
|
user: UserResponseDto;
|
||||||
assetId?: string | undefined;
|
assetId?: string | undefined;
|
||||||
albumId: string;
|
albumId: string;
|
||||||
assetType?: AssetTypeEnum | undefined;
|
assetType?: AssetTypeEnum | undefined;
|
||||||
albumOwnerId: string;
|
albumOwnerId: string;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
isLiked: ActivityResponseDto | null;
|
|
||||||
onDeleteComment: () => void;
|
|
||||||
onDeleteLike: () => void;
|
|
||||||
onAddComment: () => void;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let { user, assetId = undefined, albumId, assetType = undefined, albumOwnerId, disabled, onClose }: Props = $props();
|
||||||
reactions = $bindable(),
|
|
||||||
user,
|
|
||||||
assetId = undefined,
|
|
||||||
albumId,
|
|
||||||
assetType = undefined,
|
|
||||||
albumOwnerId,
|
|
||||||
disabled,
|
|
||||||
isLiked,
|
|
||||||
onDeleteComment,
|
|
||||||
onDeleteLike,
|
|
||||||
onAddComment,
|
|
||||||
onClose,
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let innerHeight: number = $state(0);
|
let innerHeight: number = $state(0);
|
||||||
let activityHeight: number = $state(0);
|
let activityHeight: number = $state(0);
|
||||||
@ -85,36 +59,18 @@
|
|||||||
let message = $state('');
|
let message = $state('');
|
||||||
let isSendingMessage = $state(false);
|
let isSendingMessage = $state(false);
|
||||||
|
|
||||||
onMount(async () => {
|
const timeOptions: Intl.DateTimeFormatOptions = {
|
||||||
await getReactions();
|
|
||||||
});
|
|
||||||
|
|
||||||
const getReactions = async () => {
|
|
||||||
try {
|
|
||||||
reactions = await getActivities({ assetId, albumId });
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, $t('errors.unable_to_load_asset_activity'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const timeOptions = {
|
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
hour12: false,
|
hour12: false,
|
||||||
} as Intl.DateTimeFormatOptions;
|
};
|
||||||
|
|
||||||
const handleDeleteReaction = async (reaction: ActivityResponseDto, index: number) => {
|
const handleDeleteReaction = async (reaction: ActivityResponseDto, index: number) => {
|
||||||
try {
|
try {
|
||||||
await deleteActivity({ id: reaction.id });
|
await activityManager.deleteActivity(reaction, index);
|
||||||
reactions.splice(index, 1);
|
|
||||||
if (isLiked && reaction.type === ReactionType.Like && reaction.id == isLiked.id) {
|
|
||||||
onDeleteLike();
|
|
||||||
} else {
|
|
||||||
onDeleteComment();
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteMessages: Record<ReactionType, string> = {
|
const deleteMessages: Record<ReactionType, string> = {
|
||||||
[ReactionType.Comment]: $t('comment_deleted'),
|
[ReactionType.Comment]: $t('comment_deleted'),
|
||||||
@ -135,13 +91,9 @@
|
|||||||
}
|
}
|
||||||
const timeout = setTimeout(() => (isSendingMessage = true), timeBeforeShowLoadingSpinner);
|
const timeout = setTimeout(() => (isSendingMessage = true), timeBeforeShowLoadingSpinner);
|
||||||
try {
|
try {
|
||||||
const data = await createActivity({
|
await activityManager.addActivity({ albumId, assetId, type: ReactionType.Comment, comment: message });
|
||||||
activityCreateDto: { albumId, assetId, type: ReactionType.Comment, comment: message },
|
|
||||||
});
|
|
||||||
reactions.push(data);
|
|
||||||
|
|
||||||
message = '';
|
message = '';
|
||||||
onAddComment();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.unable_to_add_comment'));
|
handleError(error, $t('errors.unable_to_add_comment'));
|
||||||
} finally {
|
} finally {
|
||||||
@ -156,7 +108,6 @@
|
|||||||
});
|
});
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (assetId && previousAssetId != assetId) {
|
if (assetId && previousAssetId != assetId) {
|
||||||
handlePromiseError(getReactions());
|
|
||||||
previousAssetId = assetId;
|
previousAssetId = assetId;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -184,7 +135,7 @@
|
|||||||
class="overflow-y-auto immich-scrollbar relative w-full px-2"
|
class="overflow-y-auto immich-scrollbar relative w-full px-2"
|
||||||
style="height: {divHeight}px;padding-bottom: {chatHeight}px"
|
style="height: {divHeight}px;padding-bottom: {chatHeight}px"
|
||||||
>
|
>
|
||||||
{#each reactions as reaction, index (reaction.id)}
|
{#each activityManager.activities as reaction, index (reaction.id)}
|
||||||
{#if reaction.type === ReactionType.Comment}
|
{#if reaction.type === ReactionType.Comment}
|
||||||
<div class="flex dark:bg-gray-800 bg-gray-200 py-3 ps-3 mt-3 rounded-lg gap-4 justify-start">
|
<div class="flex dark:bg-gray-800 bg-gray-200 py-3 ps-3 mt-3 rounded-lg gap-4 justify-start">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@ -221,7 +172,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if (index != reactions.length - 1 && !shouldGroup(reactions[index].createdAt, reactions[index + 1].createdAt)) || index === reactions.length - 1}
|
{#if (index != activityManager.activities.length - 1 && !shouldGroup(activityManager.activities[index].createdAt, activityManager.activities[index + 1].createdAt)) || index === activityManager.activities.length - 1}
|
||||||
<div
|
<div
|
||||||
class="pt-1 px-2 text-right w-full text-sm text-gray-500 dark:text-gray-300"
|
class="pt-1 px-2 text-right w-full text-sm text-gray-500 dark:text-gray-300"
|
||||||
title={new Date(reaction.createdAt).toLocaleDateString(undefined, timeOptions)}
|
title={new Date(reaction.createdAt).toLocaleDateString(undefined, timeOptions)}
|
||||||
@ -273,7 +224,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if (index != reactions.length - 1 && isTenMinutesApart(reactions[index].createdAt, reactions[index + 1].createdAt)) || index === reactions.length - 1}
|
{#if (index != activityManager.activities.length - 1 && isTenMinutesApart(activityManager.activities[index].createdAt, activityManager.activities[index + 1].createdAt)) || index === activityManager.activities.length - 1}
|
||||||
<div
|
<div
|
||||||
class="pt-1 px-2 text-right w-full text-sm text-gray-500 dark:text-gray-300"
|
class="pt-1 px-2 text-right w-full text-sm text-gray-500 dark:text-gray-300"
|
||||||
title={new Date(reaction.createdAt).toLocaleDateString(navigator.language, timeOptions)}
|
title={new Date(reaction.createdAt).toLocaleDateString(navigator.language, timeOptions)}
|
||||||
|
@ -5,8 +5,8 @@
|
|||||||
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
|
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
|
||||||
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
|
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
|
||||||
import { AssetAction, ProjectionType } from '$lib/constants';
|
import { AssetAction, ProjectionType } from '$lib/constants';
|
||||||
|
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
||||||
import { authManager } from '$lib/managers/auth-manager.svelte';
|
import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||||
import { updateNumberOfComments } from '$lib/stores/activity.store';
|
|
||||||
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
|
import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { isShowDetail } from '$lib/stores/preferences.store';
|
import { isShowDetail } from '$lib/stores/preferences.store';
|
||||||
@ -19,15 +19,9 @@
|
|||||||
import {
|
import {
|
||||||
AssetJobName,
|
AssetJobName,
|
||||||
AssetTypeEnum,
|
AssetTypeEnum,
|
||||||
ReactionType,
|
|
||||||
createActivity,
|
|
||||||
deleteActivity,
|
|
||||||
getActivities,
|
|
||||||
getActivityStatistics,
|
|
||||||
getAllAlbums,
|
getAllAlbums,
|
||||||
getStack,
|
getStack,
|
||||||
runAssetJobs,
|
runAssetJobs,
|
||||||
type ActivityResponseDto,
|
|
||||||
type AlbumResponseDto,
|
type AlbumResponseDto,
|
||||||
type AssetResponseDto,
|
type AssetResponseDto,
|
||||||
type PersonResponseDto,
|
type PersonResponseDto,
|
||||||
@ -61,7 +55,6 @@
|
|||||||
person?: PersonResponseDto | null;
|
person?: PersonResponseDto | null;
|
||||||
preAction?: PreAction | undefined;
|
preAction?: PreAction | undefined;
|
||||||
onAction?: OnAction | undefined;
|
onAction?: OnAction | undefined;
|
||||||
reactions?: ActivityResponseDto[];
|
|
||||||
showCloseButton?: boolean;
|
showCloseButton?: boolean;
|
||||||
onClose: (dto: { asset: AssetResponseDto }) => void;
|
onClose: (dto: { asset: AssetResponseDto }) => void;
|
||||||
onNext: () => Promise<HasAsset>;
|
onNext: () => Promise<HasAsset>;
|
||||||
@ -80,7 +73,6 @@
|
|||||||
person = null,
|
person = null,
|
||||||
preAction = undefined,
|
preAction = undefined,
|
||||||
onAction = undefined,
|
onAction = undefined,
|
||||||
reactions = $bindable([]),
|
|
||||||
showCloseButton,
|
showCloseButton,
|
||||||
onClose,
|
onClose,
|
||||||
onNext,
|
onNext,
|
||||||
@ -107,8 +99,6 @@
|
|||||||
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
||||||
let isShowActivity = $state(false);
|
let isShowActivity = $state(false);
|
||||||
let isShowEditor = $state(false);
|
let isShowEditor = $state(false);
|
||||||
let isLiked: ActivityResponseDto | null = $state(null);
|
|
||||||
let numberOfComments = $state(0);
|
|
||||||
let fullscreenElement = $state<Element>();
|
let fullscreenElement = $state<Element>();
|
||||||
let unsubscribes: (() => void)[] = [];
|
let unsubscribes: (() => void)[] = [];
|
||||||
let selectedEditType: string = $state('');
|
let selectedEditType: string = $state('');
|
||||||
@ -136,59 +126,20 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddComment = () => {
|
|
||||||
numberOfComments++;
|
|
||||||
updateNumberOfComments(1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveComment = () => {
|
|
||||||
numberOfComments--;
|
|
||||||
updateNumberOfComments(-1);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFavorite = async () => {
|
const handleFavorite = async () => {
|
||||||
if (album && album.isActivityEnabled) {
|
if (album && album.isActivityEnabled) {
|
||||||
try {
|
try {
|
||||||
if (isLiked) {
|
await activityManager.toggleLike();
|
||||||
const activityId = isLiked.id;
|
|
||||||
await deleteActivity({ id: activityId });
|
|
||||||
reactions = reactions.filter((reaction) => reaction.id !== activityId);
|
|
||||||
isLiked = null;
|
|
||||||
} else {
|
|
||||||
const data = await createActivity({
|
|
||||||
activityCreateDto: { albumId: album.id, assetId: asset.id, type: ReactionType.Like },
|
|
||||||
});
|
|
||||||
|
|
||||||
isLiked = data;
|
|
||||||
reactions = [...reactions, isLiked];
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.unable_to_change_favorite'));
|
handleError(error, $t('errors.unable_to_change_favorite'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFavorite = async () => {
|
const updateComments = async () => {
|
||||||
if (album && $user) {
|
|
||||||
try {
|
|
||||||
const data = await getActivities({
|
|
||||||
userId: $user.id,
|
|
||||||
assetId: asset.id,
|
|
||||||
albumId: album.id,
|
|
||||||
$type: ReactionType.Like,
|
|
||||||
});
|
|
||||||
isLiked = data.length > 0 ? data[0] : null;
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, $t('errors.unable_to_load_liked_status'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getNumberOfComments = async () => {
|
|
||||||
if (album) {
|
if (album) {
|
||||||
try {
|
try {
|
||||||
const { comments } = await getActivityStatistics({ assetId: asset.id, albumId: album.id });
|
await activityManager.refreshActivities(album.id, asset.id);
|
||||||
numberOfComments = comments;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.unable_to_get_comments_number'));
|
handleError(error, $t('errors.unable_to_get_comments_number'));
|
||||||
}
|
}
|
||||||
@ -227,6 +178,10 @@
|
|||||||
if (!sharedLink) {
|
if (!sharedLink) {
|
||||||
await handleGetAllAlbums();
|
await handleGetAllAlbums();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (album) {
|
||||||
|
activityManager.init(album.id, asset.id);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
@ -241,6 +196,8 @@
|
|||||||
for (const unsubscribe of unsubscribes) {
|
for (const unsubscribe of unsubscribes) {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
activityManager.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleGetAllAlbums = async () => {
|
const handleGetAllAlbums = async () => {
|
||||||
@ -402,14 +359,13 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (album && !album.isActivityEnabled && numberOfComments === 0) {
|
if (album && !album.isActivityEnabled && activityManager.commentCount === 0) {
|
||||||
isShowActivity = false;
|
isShowActivity = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (isShared && asset.id) {
|
if (isShared && asset.id) {
|
||||||
handlePromiseError(getFavorite());
|
handlePromiseError(updateComments());
|
||||||
handlePromiseError(getNumberOfComments());
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@ -547,12 +503,12 @@
|
|||||||
onVideoStarted={handleVideoStarted}
|
onVideoStarted={handleVideoStarted}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || numberOfComments > 0)}
|
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || activityManager.commentCount > 0)}
|
||||||
<div class="z-[9999] absolute bottom-0 end-0 mb-20 me-8">
|
<div class="z-[9999] absolute bottom-0 end-0 mb-20 me-8">
|
||||||
<ActivityStatus
|
<ActivityStatus
|
||||||
disabled={!album?.isActivityEnabled}
|
disabled={!album?.isActivityEnabled}
|
||||||
{isLiked}
|
isLiked={activityManager.isLiked}
|
||||||
{numberOfComments}
|
numberOfComments={activityManager.commentCount}
|
||||||
onFavorite={handleFavorite}
|
onFavorite={handleFavorite}
|
||||||
onOpenActivityTab={handleOpenActivity}
|
onOpenActivityTab={handleOpenActivity}
|
||||||
/>
|
/>
|
||||||
@ -642,11 +598,6 @@
|
|||||||
albumOwnerId={album.ownerId}
|
albumOwnerId={album.ownerId}
|
||||||
albumId={album.id}
|
albumId={album.id}
|
||||||
assetId={asset.id}
|
assetId={asset.id}
|
||||||
{isLiked}
|
|
||||||
bind:reactions
|
|
||||||
onAddComment={handleAddComment}
|
|
||||||
onDeleteComment={handleRemoveComment}
|
|
||||||
onDeleteLike={() => (isLiked = null)}
|
|
||||||
onClose={() => (isShowActivity = false)}
|
onClose={() => (isShowActivity = false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,14 +2,15 @@
|
|||||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||||
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
|
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
|
||||||
import { notificationController } from '$lib/components/shared-components/notification/notification';
|
import { notificationController } from '$lib/components/shared-components/notification/notification';
|
||||||
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
|
||||||
import { getPeopleThumbnailUrl } from '$lib/utils';
|
import { getPeopleThumbnailUrl } from '$lib/utils';
|
||||||
import { getAllPeople, createFace, type PersonResponseDto } from '@immich/sdk';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
|
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
|
||||||
import { Button, Input } from '@immich/ui';
|
import { Button, Input } from '@immich/ui';
|
||||||
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
|
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { t } from 'svelte-i18n';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
htmlElement: HTMLImageElement | HTMLVideoElement;
|
htmlElement: HTMLImageElement | HTMLVideoElement;
|
||||||
@ -316,7 +317,7 @@
|
|||||||
bind:this={faceSelectorEl}
|
bind:this={faceSelectorEl}
|
||||||
class="absolute top-[calc(50%-250px)] start-[calc(50%-125px)] max-w-[250px] w-[250px] bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800"
|
class="absolute top-[calc(50%-250px)] start-[calc(50%-125px)] max-w-[250px] w-[250px] bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800"
|
||||||
>
|
>
|
||||||
<p class="text-center text-sm">Select a person to tag</p>
|
<p class="text-center text-sm">{$t('select_person_to_tag')}</p>
|
||||||
|
|
||||||
<div class="my-3 relative">
|
<div class="my-3 relative">
|
||||||
<Input placeholder="Search person..." bind:value={searchTerm} size="tiny" />
|
<Input placeholder="Search person..." bind:value={searchTerm} size="tiny" />
|
||||||
@ -348,11 +349,11 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex items-center justify-center py-4">
|
<div class="flex items-center justify-center py-4">
|
||||||
<p class="text-sm text-gray-500">No matching people found</p>
|
<p class="text-sm text-gray-500">{$t('no_people_found')}</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button size="small" fullWidth onclick={cancel} color="danger" class="mt-2">Cancel</Button>
|
<Button size="small" fullWidth onclick={cancel} color="danger" class="mt-2">{$t('cancel')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -243,6 +243,26 @@
|
|||||||
class={['group absolute top-[0px] bottom-[0px]', { 'cursor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
|
class={['group absolute top-[0px] bottom-[0px]', { 'cursor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
|
||||||
style:width="inherit"
|
style:width="inherit"
|
||||||
style:height="inherit"
|
style:height="inherit"
|
||||||
|
onmouseenter={onMouseEnter}
|
||||||
|
onmouseleave={onMouseLeave}
|
||||||
|
use:longPress={{ onLongPress: () => onSelect?.($state.snapshot(asset)) }}
|
||||||
|
onkeydown={(evt) => {
|
||||||
|
if (evt.key === 'Enter') {
|
||||||
|
callClickHandlers();
|
||||||
|
}
|
||||||
|
if (evt.key === 'x') {
|
||||||
|
onSelect?.(asset);
|
||||||
|
}
|
||||||
|
if (document.activeElement === element && evt.key === 'Escape') {
|
||||||
|
focusNext((element) => element.dataset.thumbnailFocusContainer === undefined, true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onclick={handleClick}
|
||||||
|
bind:this={element}
|
||||||
|
onfocus={handleFocus}
|
||||||
|
data-thumbnail-focus-container
|
||||||
|
tabindex={0}
|
||||||
|
role="link"
|
||||||
>
|
>
|
||||||
<!-- Select asset button -->
|
<!-- Select asset button -->
|
||||||
{#if !usingMobileDevice && mouseOver && !disableLinkMouseOver}
|
{#if !usingMobileDevice && mouseOver && !disableLinkMouseOver}
|
||||||
|
@ -1,38 +1,38 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
|
import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
|
||||||
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
|
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
|
||||||
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
import type { Action } from '$lib/components/asset-viewer/actions/action';
|
||||||
|
import {
|
||||||
|
setFocusToAsset as setFocusAssetInit,
|
||||||
|
setFocusTo as setFocusToInit,
|
||||||
|
} from '$lib/components/photos-page/actions/focus-actions';
|
||||||
|
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
|
||||||
|
import SelectDate from '$lib/components/shared-components/select-date.svelte';
|
||||||
import { AppRoute, AssetAction } from '$lib/constants';
|
import { AppRoute, AssetAction } from '$lib/constants';
|
||||||
|
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { AssetBucket, assetsSnapshot, AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
import { AssetBucket, assetsSnapshot, AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
|
||||||
|
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
||||||
import { showDeleteModal } from '$lib/stores/preferences.store';
|
import { showDeleteModal } from '$lib/stores/preferences.store';
|
||||||
import { searchStore } from '$lib/stores/search.svelte';
|
import { searchStore } from '$lib/stores/search.svelte';
|
||||||
import { featureFlags } from '$lib/stores/server-config.store';
|
import { featureFlags } from '$lib/stores/server-config.store';
|
||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
|
import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
|
||||||
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
|
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
|
||||||
|
import { focusNext } from '$lib/utils/focus-util';
|
||||||
import { navigate } from '$lib/utils/navigation';
|
import { navigate } from '$lib/utils/navigation';
|
||||||
import { type ScrubberListener } from '$lib/utils/timeline-util';
|
import { type ScrubberListener } from '$lib/utils/timeline-util';
|
||||||
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk';
|
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk';
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
import { onMount, type Snippet } from 'svelte';
|
import { onMount, type Snippet } from 'svelte';
|
||||||
|
import type { UpdatePayload } from 'vite';
|
||||||
import Portal from '../shared-components/portal/portal.svelte';
|
import Portal from '../shared-components/portal/portal.svelte';
|
||||||
import Scrubber from '../shared-components/scrubber/scrubber.svelte';
|
import Scrubber from '../shared-components/scrubber/scrubber.svelte';
|
||||||
import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
|
import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
|
||||||
import AssetDateGroup from './asset-date-group.svelte';
|
import AssetDateGroup from './asset-date-group.svelte';
|
||||||
import DeleteAssetDialog from './delete-asset-dialog.svelte';
|
import DeleteAssetDialog from './delete-asset-dialog.svelte';
|
||||||
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
|
|
||||||
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
|
|
||||||
import { page } from '$app/stores';
|
|
||||||
import type { UpdatePayload } from 'vite';
|
|
||||||
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
|
||||||
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
|
|
||||||
import { focusNext } from '$lib/utils/focus-util';
|
|
||||||
import SelectDate from '$lib/components/shared-components/select-date.svelte';
|
|
||||||
import { DateTime } from 'luxon';
|
|
||||||
import {
|
|
||||||
setFocusTo as setFocusToInit,
|
|
||||||
setFocusToAsset as setFocusAssetInit,
|
|
||||||
} from '$lib/components/photos-page/actions/focus-actions';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isSelectionMode?: boolean;
|
isSelectionMode?: boolean;
|
||||||
|
113
web/src/lib/managers/activity-manager.svelte.ts
Normal file
113
web/src/lib/managers/activity-manager.svelte.ts
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { user } from '$lib/stores/user.store';
|
||||||
|
import { handlePromiseError } from '$lib/utils';
|
||||||
|
import {
|
||||||
|
createActivity,
|
||||||
|
deleteActivity,
|
||||||
|
getActivities,
|
||||||
|
getActivityStatistics,
|
||||||
|
ReactionLevel,
|
||||||
|
ReactionType,
|
||||||
|
type ActivityCreateDto,
|
||||||
|
type ActivityResponseDto,
|
||||||
|
} from '@immich/sdk';
|
||||||
|
import { get } from 'svelte/store';
|
||||||
|
|
||||||
|
class ActivityManager {
|
||||||
|
#albumId = $state<string | undefined>();
|
||||||
|
#assetId = $state<string | undefined>();
|
||||||
|
#activities = $state<ActivityResponseDto[]>([]);
|
||||||
|
#commentCount = $state(0);
|
||||||
|
#isLiked = $state<ActivityResponseDto | null>(null);
|
||||||
|
|
||||||
|
get activities() {
|
||||||
|
return this.#activities;
|
||||||
|
}
|
||||||
|
|
||||||
|
get commentCount() {
|
||||||
|
return this.#commentCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
get isLiked() {
|
||||||
|
return this.#isLiked;
|
||||||
|
}
|
||||||
|
|
||||||
|
init(albumId: string, assetId?: string) {
|
||||||
|
this.#albumId = albumId;
|
||||||
|
this.#assetId = assetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async addActivity(dto: ActivityCreateDto) {
|
||||||
|
if (this.#albumId === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activity = await createActivity({ activityCreateDto: dto });
|
||||||
|
this.#activities = [...this.#activities, activity];
|
||||||
|
|
||||||
|
if (activity.type === ReactionType.Comment) {
|
||||||
|
this.#commentCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePromiseError(this.refreshActivities(this.#albumId, this.#assetId));
|
||||||
|
return activity;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteActivity(activity: ActivityResponseDto, index?: number) {
|
||||||
|
if (!this.#albumId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activity.type === ReactionType.Comment) {
|
||||||
|
this.#commentCount--;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#activities = index
|
||||||
|
? this.#activities.splice(index, 1)
|
||||||
|
: this.#activities.filter(({ id }) => id !== activity.id);
|
||||||
|
|
||||||
|
await deleteActivity({ id: activity.id });
|
||||||
|
handlePromiseError(this.refreshActivities(this.#albumId, this.#assetId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleLike() {
|
||||||
|
if (!this.#albumId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.#isLiked) {
|
||||||
|
await this.deleteActivity(this.#isLiked);
|
||||||
|
this.#isLiked = null;
|
||||||
|
} else {
|
||||||
|
this.#isLiked = (await this.addActivity({
|
||||||
|
albumId: this.#albumId,
|
||||||
|
assetId: this.#assetId,
|
||||||
|
type: ReactionType.Like,
|
||||||
|
}))!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshActivities(albumId: string, assetId?: string) {
|
||||||
|
this.#activities = await getActivities({ albumId, assetId });
|
||||||
|
|
||||||
|
const [liked] = await getActivities({
|
||||||
|
albumId,
|
||||||
|
assetId,
|
||||||
|
userId: get(user).id,
|
||||||
|
$type: ReactionType.Like,
|
||||||
|
level: assetId ? undefined : ReactionLevel.Album,
|
||||||
|
});
|
||||||
|
this.#isLiked = liked ?? null;
|
||||||
|
|
||||||
|
const { comments } = await getActivityStatistics({ albumId, assetId });
|
||||||
|
this.#commentCount = comments;
|
||||||
|
}
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.#albumId = undefined;
|
||||||
|
this.#assetId = undefined;
|
||||||
|
this.#activities = [];
|
||||||
|
this.#commentCount = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const activityManager = new ActivityManager();
|
@ -1,11 +0,0 @@
|
|||||||
import { writable } from 'svelte/store';
|
|
||||||
|
|
||||||
export const numberOfComments = writable<number>(0);
|
|
||||||
|
|
||||||
export const setNumberOfComments = (number: number) => {
|
|
||||||
numberOfComments.set(number);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateNumberOfComments = (addOrRemove: 1 | -1) => {
|
|
||||||
numberOfComments.update((n) => n + addOrRemove);
|
|
||||||
};
|
|
@ -22,9 +22,10 @@
|
|||||||
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
|
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
|
||||||
import RemoveFromAlbum from '$lib/components/photos-page/actions/remove-from-album.svelte';
|
import RemoveFromAlbum from '$lib/components/photos-page/actions/remove-from-album.svelte';
|
||||||
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
|
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
|
||||||
|
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
|
||||||
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
|
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
|
||||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
|
||||||
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
||||||
|
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
|
||||||
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
|
||||||
import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
|
import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
|
||||||
@ -33,14 +34,16 @@
|
|||||||
notificationController,
|
notificationController,
|
||||||
} from '$lib/components/shared-components/notification/notification';
|
} from '$lib/components/shared-components/notification/notification';
|
||||||
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
|
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
|
||||||
import { AppRoute, AlbumPageViewMode } from '$lib/constants';
|
import { AlbumPageViewMode, AppRoute } from '$lib/constants';
|
||||||
import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store';
|
import { activityManager } from '$lib/managers/activity-manager.svelte';
|
||||||
|
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||||
import { AssetStore } from '$lib/stores/assets-store.svelte';
|
import { AssetStore } from '$lib/stores/assets-store.svelte';
|
||||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||||
import { preferences, user } from '$lib/stores/user.store';
|
import { preferences, user } from '$lib/stores/user.store';
|
||||||
import { handlePromiseError } from '$lib/utils';
|
import { handlePromiseError } from '$lib/utils';
|
||||||
import { downloadAlbum, cancelMultiselect } from '$lib/utils/asset-utils';
|
import { confirmAlbumDelete } from '$lib/utils/album-utils';
|
||||||
|
import { cancelMultiselect, downloadAlbum } from '$lib/utils/asset-utils';
|
||||||
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
import { openFileUploadDialog } from '$lib/utils/file-uploader';
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import {
|
import {
|
||||||
@ -53,18 +56,11 @@
|
|||||||
import {
|
import {
|
||||||
AlbumUserRole,
|
AlbumUserRole,
|
||||||
AssetOrder,
|
AssetOrder,
|
||||||
ReactionLevel,
|
|
||||||
ReactionType,
|
|
||||||
addAssetsToAlbum,
|
addAssetsToAlbum,
|
||||||
addUsersToAlbum,
|
addUsersToAlbum,
|
||||||
createActivity,
|
|
||||||
deleteActivity,
|
|
||||||
deleteAlbum,
|
deleteAlbum,
|
||||||
getActivities,
|
|
||||||
getActivityStatistics,
|
|
||||||
getAlbumInfo,
|
getAlbumInfo,
|
||||||
updateAlbumInfo,
|
updateAlbumInfo,
|
||||||
type ActivityResponseDto,
|
|
||||||
type AlbumUserAddDto,
|
type AlbumUserAddDto,
|
||||||
} from '@immich/sdk';
|
} from '@immich/sdk';
|
||||||
import {
|
import {
|
||||||
@ -80,13 +76,10 @@
|
|||||||
mdiPresentationPlay,
|
mdiPresentationPlay,
|
||||||
mdiShareVariantOutline,
|
mdiShareVariantOutline,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import { t } from 'svelte-i18n';
|
||||||
import { fly } from 'svelte/transition';
|
import { fly } from 'svelte/transition';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
import { onDestroy } from 'svelte';
|
|
||||||
import { confirmAlbumDelete } from '$lib/utils/album-utils';
|
|
||||||
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
|
|
||||||
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
data: PageData;
|
data: PageData;
|
||||||
@ -103,8 +96,6 @@
|
|||||||
let viewMode: AlbumPageViewMode = $state(AlbumPageViewMode.VIEW);
|
let viewMode: AlbumPageViewMode = $state(AlbumPageViewMode.VIEW);
|
||||||
let isCreatingSharedAlbum = $state(false);
|
let isCreatingSharedAlbum = $state(false);
|
||||||
let isShowActivity = $state(false);
|
let isShowActivity = $state(false);
|
||||||
let isLiked: ActivityResponseDto | null = $state(null);
|
|
||||||
let reactions: ActivityResponseDto[] = $state([]);
|
|
||||||
let albumOrder: AssetOrder | undefined = $state(data.album.order);
|
let albumOrder: AssetOrder | undefined = $state(data.album.order);
|
||||||
|
|
||||||
const assetInteraction = new AssetInteraction();
|
const assetInteraction = new AssetInteraction();
|
||||||
@ -154,44 +145,15 @@
|
|||||||
|
|
||||||
const handleFavorite = async () => {
|
const handleFavorite = async () => {
|
||||||
try {
|
try {
|
||||||
if (isLiked) {
|
await activityManager.toggleLike();
|
||||||
const activityId = isLiked.id;
|
|
||||||
await deleteActivity({ id: activityId });
|
|
||||||
reactions = reactions.filter((reaction) => reaction.id !== activityId);
|
|
||||||
isLiked = null;
|
|
||||||
} else {
|
|
||||||
isLiked = await createActivity({
|
|
||||||
activityCreateDto: { albumId: album.id, type: ReactionType.Like },
|
|
||||||
});
|
|
||||||
reactions = [...reactions, isLiked];
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.cant_change_asset_favorite'));
|
handleError(error, $t('errors.cant_change_asset_favorite'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getFavorite = async () => {
|
const updateComments = async () => {
|
||||||
if ($user) {
|
|
||||||
try {
|
try {
|
||||||
const data = await getActivities({
|
await activityManager.refreshActivities(album.id);
|
||||||
userId: $user.id,
|
|
||||||
albumId: album.id,
|
|
||||||
$type: ReactionType.Like,
|
|
||||||
level: ReactionLevel.Album,
|
|
||||||
});
|
|
||||||
if (data.length > 0) {
|
|
||||||
isLiked = data[0];
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, $t('errors.unable_to_load_liked_status'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getNumberOfComments = async () => {
|
|
||||||
try {
|
|
||||||
const { comments } = await getActivityStatistics({ albumId: album.id });
|
|
||||||
setNumberOfComments(comments);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error, $t('errors.cant_get_number_of_comments'));
|
handleError(error, $t('errors.cant_get_number_of_comments'));
|
||||||
}
|
}
|
||||||
@ -398,7 +360,7 @@
|
|||||||
let albumId = $derived(album.id);
|
let albumId = $derived(album.id);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!album.isActivityEnabled && $numberOfComments === 0) {
|
if (!album.isActivityEnabled && activityManager.commentCount === 0) {
|
||||||
isShowActivity = false;
|
isShowActivity = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -412,7 +374,16 @@
|
|||||||
void assetStore.updateOptions({ isArchived: false, withPartners: true, timelineAlbumId: albumId });
|
void assetStore.updateOptions({ isArchived: false, withPartners: true, timelineAlbumId: albumId });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
onDestroy(() => assetStore.destroy());
|
|
||||||
|
$effect(() => {
|
||||||
|
activityManager.reset();
|
||||||
|
activityManager.init(album.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
activityManager.reset();
|
||||||
|
assetStore.destroy();
|
||||||
|
});
|
||||||
// let timelineStore = new AssetStore();
|
// let timelineStore = new AssetStore();
|
||||||
// $effect(() => void timelineStore.updateOptions({ isArchived: false, withPartners: true, timelineAlbumId: albumId }));
|
// $effect(() => void timelineStore.updateOptions({ isArchived: false, withPartners: true, timelineAlbumId: albumId }));
|
||||||
// onDestroy(() => timelineStore.destroy());
|
// onDestroy(() => timelineStore.destroy());
|
||||||
@ -420,7 +391,7 @@
|
|||||||
let isOwned = $derived($user.id == album.ownerId);
|
let isOwned = $derived($user.id == album.ownerId);
|
||||||
|
|
||||||
let showActivityStatus = $derived(
|
let showActivityStatus = $derived(
|
||||||
album.albumUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || $numberOfComments > 0),
|
album.albumUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || activityManager.commentCount > 0),
|
||||||
);
|
);
|
||||||
let isEditor = $derived(
|
let isEditor = $derived(
|
||||||
album.albumUsers.find(({ user: { id } }) => id === $user.id)?.role === AlbumUserRole.Editor ||
|
album.albumUsers.find(({ user: { id } }) => id === $user.id)?.role === AlbumUserRole.Editor ||
|
||||||
@ -430,8 +401,7 @@
|
|||||||
let albumHasViewers = $derived(album.albumUsers.some(({ role }) => role === AlbumUserRole.Viewer));
|
let albumHasViewers = $derived(album.albumUsers.some(({ role }) => role === AlbumUserRole.Viewer));
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (album.albumUsers.length > 0) {
|
if (album.albumUsers.length > 0) {
|
||||||
handlePromiseError(getFavorite());
|
handlePromiseError(updateComments());
|
||||||
handlePromiseError(getNumberOfComments());
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const isShared = $derived(viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : album.albumUsers.length > 0);
|
const isShared = $derived(viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : album.albumUsers.length > 0);
|
||||||
@ -711,8 +681,8 @@
|
|||||||
<div class="absolute z-[2] bottom-0 end-0 mb-6 me-6 justify-self-end">
|
<div class="absolute z-[2] bottom-0 end-0 mb-6 me-6 justify-self-end">
|
||||||
<ActivityStatus
|
<ActivityStatus
|
||||||
disabled={!album.isActivityEnabled}
|
disabled={!album.isActivityEnabled}
|
||||||
{isLiked}
|
isLiked={activityManager.isLiked}
|
||||||
numberOfComments={$numberOfComments}
|
numberOfComments={activityManager.commentCount}
|
||||||
onFavorite={handleFavorite}
|
onFavorite={handleFavorite}
|
||||||
onOpenActivityTab={handleOpenAndCloseActivityTab}
|
onOpenActivityTab={handleOpenAndCloseActivityTab}
|
||||||
/>
|
/>
|
||||||
@ -733,11 +703,6 @@
|
|||||||
disabled={!album.isActivityEnabled}
|
disabled={!album.isActivityEnabled}
|
||||||
albumOwnerId={album.ownerId}
|
albumOwnerId={album.ownerId}
|
||||||
albumId={album.id}
|
albumId={album.id}
|
||||||
{isLiked}
|
|
||||||
bind:reactions
|
|
||||||
onAddComment={() => updateNumberOfComments(1)}
|
|
||||||
onDeleteComment={() => updateNumberOfComments(-1)}
|
|
||||||
onDeleteLike={() => (isLiked = null)}
|
|
||||||
onClose={handleOpenAndCloseActivityTab}
|
onClose={handleOpenAndCloseActivityTab}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,358 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import empty4Url from '$lib/assets/empty-4.svg';
|
|
||||||
import CircleIconButton from '$lib/components/elements/buttons/circle-icon-button.svelte';
|
|
||||||
import UserPageLayout from '$lib/components/layouts/user-page-layout.svelte';
|
|
||||||
import EmptyPlaceholder from '$lib/components/shared-components/empty-placeholder.svelte';
|
|
||||||
import {
|
|
||||||
NotificationType,
|
|
||||||
notificationController,
|
|
||||||
} from '$lib/components/shared-components/notification/notification';
|
|
||||||
import { downloadManager } from '$lib/managers/download-manager.svelte';
|
|
||||||
import { locale } from '$lib/stores/preferences.store';
|
|
||||||
import { copyToClipboard } from '$lib/utils';
|
|
||||||
import { downloadBlob } from '$lib/utils/asset-utils';
|
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
|
||||||
import { fixAuditFiles, getAuditFiles, getFileChecksums, type FileReportItemDto } from '@immich/sdk';
|
|
||||||
import { Button, HStack, Text } from '@immich/ui';
|
|
||||||
import { mdiCheckAll, mdiContentCopy, mdiDownload, mdiRefresh, mdiWrench } from '@mdi/js';
|
|
||||||
import { t } from 'svelte-i18n';
|
|
||||||
import type { PageData } from './$types';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
data: PageData;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { data }: Props = $props();
|
|
||||||
|
|
||||||
interface UntrackedFile {
|
|
||||||
filename: string;
|
|
||||||
checksum: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Match {
|
|
||||||
orphan: FileReportItemDto;
|
|
||||||
extra: UntrackedFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalize = (filenames: string[]) => filenames.map((filename) => ({ filename, checksum: null }));
|
|
||||||
|
|
||||||
let checking = $state(false);
|
|
||||||
let repairing = $state(false);
|
|
||||||
|
|
||||||
let orphans: FileReportItemDto[] = $state(data.orphans);
|
|
||||||
let extras: UntrackedFile[] = $state(normalize(data.extras));
|
|
||||||
let matches: Match[] = $state([]);
|
|
||||||
|
|
||||||
const handleDownload = () => {
|
|
||||||
if (extras.length > 0) {
|
|
||||||
const blob = new Blob([extras.map(({ filename }) => filename).join('\n')], { type: 'text/plain' });
|
|
||||||
const downloadKey = 'untracked.txt';
|
|
||||||
downloadManager.add(downloadKey, blob.size);
|
|
||||||
downloadManager.update(downloadKey, blob.size);
|
|
||||||
downloadBlob(blob, downloadKey);
|
|
||||||
setTimeout(() => downloadManager.clear(downloadKey), 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (orphans.length > 0) {
|
|
||||||
const blob = new Blob([JSON.stringify(orphans, null, 4)], { type: 'application/json' });
|
|
||||||
const downloadKey = 'orphans.json';
|
|
||||||
downloadManager.add(downloadKey, blob.size);
|
|
||||||
downloadManager.update(downloadKey, blob.size);
|
|
||||||
downloadBlob(blob, downloadKey);
|
|
||||||
setTimeout(() => downloadManager.clear(downloadKey), 5000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRepair = async () => {
|
|
||||||
if (matches.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
repairing = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fixAuditFiles({
|
|
||||||
fileReportFixDto: {
|
|
||||||
items: matches.map(({ orphan, extra }) => ({
|
|
||||||
entityId: orphan.entityId,
|
|
||||||
entityType: orphan.entityType,
|
|
||||||
pathType: orphan.pathType,
|
|
||||||
pathValue: extra.filename,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
notificationController.show({
|
|
||||||
type: NotificationType.Info,
|
|
||||||
message: $t('admin.repaired_items', { values: { count: matches.length } }),
|
|
||||||
});
|
|
||||||
|
|
||||||
matches = [];
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, $t('errors.unable_to_repair_items'));
|
|
||||||
} finally {
|
|
||||||
repairing = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSplit = (match: Match) => {
|
|
||||||
matches = matches.filter((_match) => _match !== match);
|
|
||||||
orphans = [match.orphan, ...orphans];
|
|
||||||
extras = [match.extra, ...extras];
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
|
||||||
matches = [];
|
|
||||||
orphans = [];
|
|
||||||
extras = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const report = await getAuditFiles();
|
|
||||||
|
|
||||||
orphans = report.orphans;
|
|
||||||
extras = normalize(report.extras);
|
|
||||||
|
|
||||||
notificationController.show({ message: $t('refreshed'), type: NotificationType.Info });
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, $t('errors.unable_to_load_items'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCheckOne = async (filename: string) => {
|
|
||||||
try {
|
|
||||||
const matched = await loadAndMatch([filename]);
|
|
||||||
if (matched) {
|
|
||||||
notificationController.show({
|
|
||||||
message: $t('admin.repair_matched_items', { values: { count: 1 } }),
|
|
||||||
type: NotificationType.Info,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, $t('errors.repair_unable_to_check_items', { values: { count: 'one' } }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCheckAll = async () => {
|
|
||||||
checking = true;
|
|
||||||
|
|
||||||
let count = 0;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const chunkSize = 10;
|
|
||||||
const filenames = extras.filter(({ checksum }) => !checksum).map(({ filename }) => filename);
|
|
||||||
for (let index = 0; index < filenames.length; index += chunkSize) {
|
|
||||||
count += await loadAndMatch(filenames.slice(index, index + chunkSize));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
handleError(error, $t('errors.repair_unable_to_check_items', { values: { count: 'other' } }));
|
|
||||||
} finally {
|
|
||||||
checking = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
notificationController.show({
|
|
||||||
message: $t('admin.repair_matched_items', { values: { count } }),
|
|
||||||
type: NotificationType.Info,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadAndMatch = async (filenames: string[]) => {
|
|
||||||
const items = await getFileChecksums({
|
|
||||||
fileChecksumDto: { filenames },
|
|
||||||
});
|
|
||||||
|
|
||||||
let count = 0;
|
|
||||||
|
|
||||||
for (const { checksum, filename } of items) {
|
|
||||||
const extra = extras.find((extra) => extra.filename === filename);
|
|
||||||
if (extra) {
|
|
||||||
extra.checksum = checksum;
|
|
||||||
extras = [...extras];
|
|
||||||
}
|
|
||||||
|
|
||||||
const orphan = orphans.find((orphan) => orphan.checksum === checksum);
|
|
||||||
if (orphan) {
|
|
||||||
count++;
|
|
||||||
matches = [...matches, { orphan, extra: { filename, checksum } }];
|
|
||||||
orphans = orphans.filter((_orphan) => _orphan !== orphan);
|
|
||||||
extras = extras.filter((extra) => extra.filename !== filename);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return count;
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<UserPageLayout title={data.meta.title} admin>
|
|
||||||
{#snippet buttons()}
|
|
||||||
<HStack gap={0}>
|
|
||||||
<Button
|
|
||||||
leadingIcon={mdiWrench}
|
|
||||||
onclick={() => handleRepair()}
|
|
||||||
disabled={matches.length === 0 || repairing}
|
|
||||||
size="small"
|
|
||||||
variant="ghost"
|
|
||||||
color="secondary"
|
|
||||||
>
|
|
||||||
<Text class="hidden md:block">{$t('admin.repair_all')}</Text>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
leadingIcon={mdiCheckAll}
|
|
||||||
onclick={() => handleCheckAll()}
|
|
||||||
disabled={extras.length === 0 || checking}
|
|
||||||
size="small"
|
|
||||||
variant="ghost"
|
|
||||||
color="secondary"
|
|
||||||
>
|
|
||||||
<Text class="hidden md:block">{$t('admin.check_all')}</Text>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
leadingIcon={mdiDownload}
|
|
||||||
onclick={() => handleDownload()}
|
|
||||||
disabled={extras.length + orphans.length === 0}
|
|
||||||
size="small"
|
|
||||||
variant="ghost"
|
|
||||||
color="secondary"
|
|
||||||
>
|
|
||||||
<Text class="hidden md:block">{$t('export')}</Text>
|
|
||||||
</Button>
|
|
||||||
<Button leadingIcon={mdiRefresh} onclick={() => handleRefresh()} size="small" variant="ghost" color="secondary">
|
|
||||||
<Text class="hidden md:block">{$t('refresh')}</Text>
|
|
||||||
</Button>
|
|
||||||
</HStack>
|
|
||||||
{/snippet}
|
|
||||||
<section id="setting-content" class="flex place-content-center sm:mx-4">
|
|
||||||
<section class="w-full pb-28 sm:w-5/6 md:w-[850px]">
|
|
||||||
{#if matches.length + extras.length + orphans.length === 0}
|
|
||||||
<div class="w-full">
|
|
||||||
<EmptyPlaceholder fullWidth text={$t('repair_no_results_message')} src={empty4Url} />
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="gap-2">
|
|
||||||
<table class="table-fixed mt-5 w-full text-start">
|
|
||||||
<thead
|
|
||||||
class="mb-4 flex w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
|
|
||||||
>
|
|
||||||
<tr class="flex w-full place-items-center p-2 md:p-5">
|
|
||||||
<th class="w-full text-sm place-items-center font-medium flex justify-between" colspan="2">
|
|
||||||
<div class="px-3">
|
|
||||||
<p>
|
|
||||||
{$t('matches').toUpperCase()}
|
|
||||||
{matches.length > 0 ? `(${matches.length.toLocaleString($locale)})` : ''}
|
|
||||||
</p>
|
|
||||||
<p class="text-gray-600 dark:text-gray-300 mt-1">{$t('admin.these_files_matched_by_checksum')}</p>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody
|
|
||||||
class="w-full overflow-y-auto rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg max-h-[500px] block overflow-x-hidden"
|
|
||||||
>
|
|
||||||
{#each matches as match (match.extra.filename)}
|
|
||||||
<tr
|
|
||||||
class="w-full h-[75px] place-items-center border-[3px] border-transparent p-2 odd:bg-immich-gray even:bg-immich-bg hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5 flex justify-between"
|
|
||||||
tabindex="0"
|
|
||||||
onclick={() => handleSplit(match)}
|
|
||||||
>
|
|
||||||
<td class="text-sm text-ellipsis flex flex-col gap-1 font-mono">
|
|
||||||
<span>{match.orphan.pathValue} =></span>
|
|
||||||
<span>{match.extra.filename}</span>
|
|
||||||
</td>
|
|
||||||
<td class="text-sm text-ellipsis d-flex font-mono">
|
|
||||||
<span>({match.orphan.entityType}/{match.orphan.pathType})</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table class="table-fixed mt-5 w-full text-start">
|
|
||||||
<thead
|
|
||||||
class="mb-4 flex w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
|
|
||||||
>
|
|
||||||
<tr class="flex w-full place-items-center p-1 md:p-5">
|
|
||||||
<th class="w-full text-sm font-medium justify-between place-items-center flex" colspan="2">
|
|
||||||
<div class="px-3">
|
|
||||||
<p>
|
|
||||||
{$t('admin.offline_paths').toUpperCase()}
|
|
||||||
{orphans.length > 0 ? `(${orphans.length.toLocaleString($locale)})` : ''}
|
|
||||||
</p>
|
|
||||||
<p class="text-gray-600 dark:text-gray-300 mt-1">
|
|
||||||
{$t('admin.offline_paths_description')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody
|
|
||||||
class="w-full rounded-md border dark:border-immich-dark-gray dark:text-immich-dark-fg overflow-y-auto max-h-[500px] block overflow-x-hidden"
|
|
||||||
>
|
|
||||||
{#each orphans as orphan, index (index)}
|
|
||||||
<tr
|
|
||||||
class="w-full h-[50px] place-items-center border-[3px] border-transparent odd:bg-immich-gray even:bg-immich-bg hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5 flex justify-between"
|
|
||||||
tabindex="0"
|
|
||||||
title={orphan.pathValue}
|
|
||||||
>
|
|
||||||
<td onclick={() => copyToClipboard(orphan.pathValue)}>
|
|
||||||
<CircleIconButton title={$t('copy_file_path')} icon={mdiContentCopy} size="18" onclick={() => {}} />
|
|
||||||
</td>
|
|
||||||
<td class="truncate text-sm font-mono text-start" title={orphan.pathValue}>
|
|
||||||
{orphan.pathValue}
|
|
||||||
</td>
|
|
||||||
<td class="text-sm font-mono">
|
|
||||||
<span>({orphan.entityType})</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
<table class="table-fixed mt-5 w-full text-start max-h-[300px]">
|
|
||||||
<thead
|
|
||||||
class="mb-4 flex w-full rounded-md border bg-gray-50 text-immich-primary dark:border-immich-dark-gray dark:bg-immich-dark-gray dark:text-immich-dark-primary"
|
|
||||||
>
|
|
||||||
<tr class="flex w-full place-items-center p-2 md:p-5">
|
|
||||||
<th class="w-full text-sm font-medium place-items-center flex justify-between" colspan="2">
|
|
||||||
<div class="px-3">
|
|
||||||
<p>
|
|
||||||
{$t('admin.untracked_files').toUpperCase()}
|
|
||||||
{extras.length > 0 ? `(${extras.length.toLocaleString($locale)})` : ''}
|
|
||||||
</p>
|
|
||||||
<p class="text-gray-600 dark:text-gray-300 mt-1">
|
|
||||||
{$t('admin.untracked_files_description')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody
|
|
||||||
class="w-full rounded-md border-2 dark:border-immich-dark-gray dark:text-immich-dark-fg overflow-y-auto max-h-[500px] block overflow-x-hidden"
|
|
||||||
>
|
|
||||||
{#each extras as extra (extra.filename)}
|
|
||||||
<tr
|
|
||||||
class="flex h-[50px] w-full place-items-center border-[3px] border-transparent p-1 odd:bg-immich-gray even:bg-immich-bg hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5 justify-between"
|
|
||||||
tabindex="0"
|
|
||||||
onclick={() => handleCheckOne(extra.filename)}
|
|
||||||
title={extra.filename}
|
|
||||||
>
|
|
||||||
<td onclick={() => copyToClipboard(extra.filename)}>
|
|
||||||
<CircleIconButton title={$t('copy_file_path')} icon={mdiContentCopy} size="18" onclick={() => {}} />
|
|
||||||
</td>
|
|
||||||
<td class="w-full text-md text-ellipsis flex justify-between pe-5">
|
|
||||||
<span class="text-ellipsis grow truncate font-mono text-sm pe-5" title={extra.filename}
|
|
||||||
>{extra.filename}</span
|
|
||||||
>
|
|
||||||
<span class="text-sm font-mono dark:text-immich-dark-primary text-immich-primary pes-5">
|
|
||||||
{#if extra.checksum}
|
|
||||||
[sha1:{extra.checksum}]
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
</section>
|
|
||||||
</UserPageLayout>
|
|
@ -1,18 +0,0 @@
|
|||||||
import { authenticate } from '$lib/utils/auth';
|
|
||||||
import { getFormatter } from '$lib/utils/i18n';
|
|
||||||
import { getAuditFiles } from '@immich/sdk';
|
|
||||||
import type { PageLoad } from './$types';
|
|
||||||
|
|
||||||
export const load = (async () => {
|
|
||||||
await authenticate({ admin: true });
|
|
||||||
const { orphans, extras } = await getAuditFiles();
|
|
||||||
const $t = await getFormatter();
|
|
||||||
|
|
||||||
return {
|
|
||||||
orphans,
|
|
||||||
extras,
|
|
||||||
meta: {
|
|
||||||
title: $t('repair'),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}) satisfies PageLoad;
|
|
Loading…
x
Reference in New Issue
Block a user