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)
|
||||
needs: pre-job
|
||||
if: ${{ needs.pre-job.outputs.should_run_e2e_server_cli == 'true' }}
|
||||
runs-on: mich
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./e2e
|
||||
strategy:
|
||||
matrix:
|
||||
runner: [mich, ubuntu-24.04-arm]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@ -383,12 +386,15 @@ jobs:
|
||||
name: End-to-End Tests (Web)
|
||||
needs: pre-job
|
||||
if: ${{ needs.pre-job.outputs.should_run_e2e_web == 'true' }}
|
||||
runs-on: mich
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ./e2e
|
||||
strategy:
|
||||
matrix:
|
||||
runner: [mich, ubuntu-24.04-arm]
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@ -423,6 +429,21 @@ jobs:
|
||||
run: npx playwright test
|
||||
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:
|
||||
name: Unit Test Mobile
|
||||
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_libraries_message": "Create an external library to view your photos and videos",
|
||||
"no_name": "No Name",
|
||||
"no_people_found": "No matching people found",
|
||||
"no_places": "No places",
|
||||
"no_results": "No results",
|
||||
"no_results_description": "Try a synonym or more general keyword",
|
||||
@ -1572,6 +1573,7 @@
|
||||
"select_keep_all": "Select keep all",
|
||||
"select_library_owner": "Select library owner",
|
||||
"select_new_face": "Select new face",
|
||||
"select_person_to_tag": "Select a person to tag",
|
||||
"select_photos": "Select photos",
|
||||
"select_trash_all": "Select trash all",
|
||||
"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* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} |
|
||||
*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* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback |
|
||||
*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* | [**getFaces**](doc//FacesApi.md#getfaces) | **GET** /faces |
|
||||
*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* | [**getAllJobsStatus**](doc//JobsApi.md#getalljobsstatus) | **GET** /jobs |
|
||||
*JobsApi* | [**sendJobCommand**](doc//JobsApi.md#sendjobcommand) | **PUT** /jobs/{id} |
|
||||
@ -332,11 +328,6 @@ Class | Method | HTTP request | Description
|
||||
- [ExifResponseDto](doc//ExifResponseDto.md)
|
||||
- [FaceDto](doc//FaceDto.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)
|
||||
- [FoldersUpdate](doc//FoldersUpdate.md)
|
||||
- [ImageFormat](doc//ImageFormat.md)
|
||||
@ -361,7 +352,6 @@ Class | Method | HTTP request | Description
|
||||
- [MemoriesResponse](doc//MemoriesResponse.md)
|
||||
- [MemoriesUpdate](doc//MemoriesUpdate.md)
|
||||
- [MemoryCreateDto](doc//MemoryCreateDto.md)
|
||||
- [MemoryLaneResponseDto](doc//MemoryLaneResponseDto.md)
|
||||
- [MemoryResponseDto](doc//MemoryResponseDto.md)
|
||||
- [MemoryType](doc//MemoryType.md)
|
||||
- [MemoryUpdateDto](doc//MemoryUpdateDto.md)
|
||||
@ -381,8 +371,6 @@ Class | Method | HTTP request | Description
|
||||
- [OnThisDayDto](doc//OnThisDayDto.md)
|
||||
- [PartnerDirection](doc//PartnerDirection.md)
|
||||
- [PartnerResponseDto](doc//PartnerResponseDto.md)
|
||||
- [PathEntityType](doc//PathEntityType.md)
|
||||
- [PathType](doc//PathType.md)
|
||||
- [PeopleResponse](doc//PeopleResponse.md)
|
||||
- [PeopleResponseDto](doc//PeopleResponseDto.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/duplicates_api.dart';
|
||||
part 'api/faces_api.dart';
|
||||
part 'api/file_reports_api.dart';
|
||||
part 'api/jobs_api.dart';
|
||||
part 'api/libraries_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/face_dto.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_update.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_update.dart';
|
||||
part 'model/memory_create_dto.dart';
|
||||
part 'model/memory_lane_response_dto.dart';
|
||||
part 'model/memory_response_dto.dart';
|
||||
part 'model/memory_type.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/partner_direction.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_dto.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;
|
||||
}
|
||||
|
||||
/// 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
|
||||
///
|
||||
/// 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);
|
||||
case 'FacialRecognitionConfig':
|
||||
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':
|
||||
return FoldersResponse.fromJson(value);
|
||||
case 'FoldersUpdate':
|
||||
@ -378,8 +368,6 @@ class ApiClient {
|
||||
return MemoriesUpdate.fromJson(value);
|
||||
case 'MemoryCreateDto':
|
||||
return MemoryCreateDto.fromJson(value);
|
||||
case 'MemoryLaneResponseDto':
|
||||
return MemoryLaneResponseDto.fromJson(value);
|
||||
case 'MemoryResponseDto':
|
||||
return MemoryResponseDto.fromJson(value);
|
||||
case 'MemoryType':
|
||||
@ -418,10 +406,6 @@ class ApiClient {
|
||||
return PartnerDirectionTypeTransformer().decode(value);
|
||||
case 'PartnerResponseDto':
|
||||
return PartnerResponseDto.fromJson(value);
|
||||
case 'PathEntityType':
|
||||
return PathEntityTypeTypeTransformer().decode(value);
|
||||
case 'PathType':
|
||||
return PathTypeTypeTransformer().decode(value);
|
||||
case 'PeopleResponse':
|
||||
return PeopleResponse.fromJson(value);
|
||||
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) {
|
||||
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) {
|
||||
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": {
|
||||
"get": {
|
||||
"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": {
|
||||
"get": {
|
||||
"operationId": "getAssetsByCity",
|
||||
@ -9749,105 +9581,6 @@
|
||||
],
|
||||
"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": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
@ -10328,24 +10061,6 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MemoryLaneResponseDto": {
|
||||
"properties": {
|
||||
"assets": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/AssetResponseDto"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"yearsAgo": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"assets",
|
||||
"yearsAgo"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MemoryResponseDto": {
|
||||
"properties": {
|
||||
"assets": {
|
||||
@ -10889,27 +10604,6 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PathEntityType": {
|
||||
"enum": [
|
||||
"asset",
|
||||
"person",
|
||||
"user"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PathType": {
|
||||
"enum": [
|
||||
"original",
|
||||
"fullsize",
|
||||
"preview",
|
||||
"thumbnail",
|
||||
"encoded_video",
|
||||
"sidecar",
|
||||
"face",
|
||||
"profile"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PeopleResponse": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
|
@ -462,10 +462,6 @@ export type AssetJobsDto = {
|
||||
assetIds: string[];
|
||||
name: AssetJobName;
|
||||
};
|
||||
export type MemoryLaneResponseDto = {
|
||||
assets: AssetResponseDto[];
|
||||
yearsAgo: number;
|
||||
};
|
||||
export type AssetStatsResponseDto = {
|
||||
images: number;
|
||||
total: number;
|
||||
@ -800,27 +796,6 @@ export type AssetFaceUpdateDto = {
|
||||
export type PersonStatisticsResponseDto = {
|
||||
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 = {
|
||||
data: AssetResponseDto;
|
||||
value: string;
|
||||
@ -1887,20 +1862,6 @@ export function runAssetJobs({ 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
|
||||
*/
|
||||
@ -2663,35 +2624,6 @@ export function getPersonThumbnail({ id }: {
|
||||
...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) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
@ -3751,21 +3683,6 @@ export enum PartnerDirection {
|
||||
SharedBy = "shared-by",
|
||||
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 {
|
||||
Country = "country",
|
||||
State = "state",
|
||||
|
57
server/package-lock.json
generated
57
server/package-lock.json
generated
@ -28,7 +28,7 @@
|
||||
"archiver": "^7.0.0",
|
||||
"async-lock": "^1.4.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bullmq": "^4.8.0",
|
||||
"bullmq": "^5.51.0",
|
||||
"chokidar": "^3.5.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
@ -6886,63 +6886,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/bullmq": {
|
||||
"version": "4.18.2",
|
||||
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-4.18.2.tgz",
|
||||
"integrity": "sha512-Cx0O98IlGiFw7UBa+zwGz+nH0Pcl1wfTvMVBlsMna3s0219hXroVovh1xPRgomyUcbyciHiugGCkW0RRNZDHYQ==",
|
||||
"version": "5.51.0",
|
||||
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.51.0.tgz",
|
||||
"integrity": "sha512-YjX+CO2U4nmbCq2ZgNb/Hnu6Xk953j8EFmp0eehTuudavPyNstoZsbnyvvM6PX9rfD9clhcc5kRLyyWoFEM3Lg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cron-parser": "^4.6.0",
|
||||
"glob": "^8.0.3",
|
||||
"ioredis": "^5.3.2",
|
||||
"lodash": "^4.17.21",
|
||||
"msgpackr": "^1.6.2",
|
||||
"cron-parser": "^4.9.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"msgpackr": "^1.11.2",
|
||||
"node-abort-controller": "^3.1.1",
|
||||
"semver": "^7.5.4",
|
||||
"tslib": "^2.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": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
|
@ -53,7 +53,7 @@
|
||||
"archiver": "^7.0.0",
|
||||
"async-lock": "^1.4.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"bullmq": "^4.8.0",
|
||||
"bullmq": "^5.51.0",
|
||||
"chokidar": "^3.5.3",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { EndpointLifecycle } from 'src/decorators';
|
||||
import { AssetResponseDto, MemoryLaneResponseDto } from 'src/dtos/asset-response.dto';
|
||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||
import {
|
||||
AssetBulkDeleteDto,
|
||||
AssetBulkUpdateDto,
|
||||
@ -13,7 +13,6 @@ import {
|
||||
UpdateAssetDto,
|
||||
} from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { MemoryLaneDto } from 'src/dtos/search.dto';
|
||||
import { RouteKey } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
@ -24,12 +23,6 @@ import { UUIDParamDto } from 'src/validation';
|
||||
export class AssetController {
|
||||
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')
|
||||
@Authenticated()
|
||||
@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 { DuplicateController } from 'src/controllers/duplicate.controller';
|
||||
import { FaceController } from 'src/controllers/face.controller';
|
||||
import { ReportController } from 'src/controllers/file-report.controller';
|
||||
import { JobController } from 'src/controllers/job.controller';
|
||||
import { LibraryController } from 'src/controllers/library.controller';
|
||||
import { MapController } from 'src/controllers/map.controller';
|
||||
@ -53,7 +52,6 @@ export const controllers = [
|
||||
OAuthController,
|
||||
PartnerController,
|
||||
PersonController,
|
||||
ReportController,
|
||||
SearchController,
|
||||
ServerController,
|
||||
SessionController,
|
||||
|
@ -46,7 +46,7 @@ export class SearchController {
|
||||
@Get('explore')
|
||||
@Authenticated()
|
||||
getExploreData(@Auth() auth: AuthDto): Promise<SearchExploreResponseDto[]> {
|
||||
return this.service.getExploreData(auth) as Promise<SearchExploreResponseDto[]>;
|
||||
return this.service.getExploreData(auth);
|
||||
}
|
||||
|
||||
@Get('person')
|
||||
|
@ -199,10 +199,3 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
|
||||
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
|
||||
and "asset_files"."type" = $2
|
||||
|
||||
-- AssetJobRepository.streamForEncodeClip
|
||||
-- AssetJobRepository.streamForSearchDuplicates
|
||||
select
|
||||
"assets"."id"
|
||||
from
|
||||
"assets"
|
||||
inner join "asset_job_status" as "job_status" on "assetId" = "assets"."id"
|
||||
where
|
||||
"job_status"."previewAt" is not null
|
||||
and "assets"."isVisible" = $1
|
||||
"assets"."isVisible" = $1
|
||||
and "assets"."deletedAt" is null
|
||||
and "job_status"."previewAt" is not null
|
||||
and not exists (
|
||||
select
|
||||
from
|
||||
@ -210,7 +211,25 @@ where
|
||||
where
|
||||
"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 "job_status"."previewAt" is not null
|
||||
and not exists (
|
||||
select
|
||||
from
|
||||
"smart_search"
|
||||
where
|
||||
"assetId" = "assets"."id"
|
||||
)
|
||||
|
||||
-- AssetJobRepository.getForClipEncoding
|
||||
select
|
||||
@ -450,3 +469,37 @@ from
|
||||
"assets"
|
||||
where
|
||||
"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
|
||||
$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
|
||||
with
|
||||
"assets" as (
|
||||
|
@ -135,20 +135,33 @@ export class AssetJobRepository {
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [], stream: true })
|
||||
streamForEncodeClip(force?: boolean) {
|
||||
private assetsWithPreviews() {
|
||||
return this.db
|
||||
.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.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) =>
|
||||
qb.where((eb) =>
|
||||
eb.not((eb) => eb.exists(eb.selectFrom('smart_search').whereRef('assetId', '=', 'assets.id'))),
|
||||
),
|
||||
)
|
||||
.where('assets.deletedAt', 'is', null)
|
||||
.stream();
|
||||
}
|
||||
|
||||
@ -309,4 +322,30 @@ export class AssetJobRepository {
|
||||
.where('assets.deletedAt', '<=', trashedBefore)
|
||||
.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 { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum';
|
||||
import { AssetSearchOptions, SearchExploreItem, SearchExploreItemSet } from 'src/repositories/search.repository';
|
||||
import {
|
||||
anyUuid,
|
||||
asUuid,
|
||||
hasPeople,
|
||||
removeUndefinedKeys,
|
||||
searchAssetBuilder,
|
||||
truncatedDate,
|
||||
unnest,
|
||||
withExif,
|
||||
@ -27,7 +25,6 @@ import {
|
||||
withTags,
|
||||
} from 'src/utils/database';
|
||||
import { globToSqlPattern } from 'src/utils/misc';
|
||||
import { PaginationOptions, paginationHelper } from 'src/utils/pagination';
|
||||
|
||||
export type AssetStats = Record<AssetType, number>;
|
||||
|
||||
@ -45,15 +42,6 @@ export interface LivePhotoSearchOptions {
|
||||
type: AssetType;
|
||||
}
|
||||
|
||||
export enum WithoutProperty {
|
||||
THUMBNAIL = 'thumbnail',
|
||||
ENCODED_VIDEO = 'encoded-video',
|
||||
EXIF = 'exif',
|
||||
DUPLICATE = 'duplicate',
|
||||
FACES = 'faces',
|
||||
SIDECAR = 'sidecar',
|
||||
}
|
||||
|
||||
export enum WithProperty {
|
||||
SIDECAR = 'sidecar',
|
||||
}
|
||||
@ -335,10 +323,6 @@ export class AssetRepository {
|
||||
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] })
|
||||
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string) {
|
||||
return this.db
|
||||
@ -350,16 +334,6 @@ export class AssetRepository {
|
||||
.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
|
||||
* @param ownerId
|
||||
@ -529,68 +503,6 @@ export class AssetRepository {
|
||||
.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> {
|
||||
return this.db
|
||||
.selectFrom('assets')
|
||||
@ -774,10 +686,7 @@ export class AssetRepository {
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, { minAssetsPerField: 5, maxFields: 12 }] })
|
||||
async getAssetIdByCity(
|
||||
ownerId: string,
|
||||
{ minAssetsPerField, maxFields }: AssetExploreFieldOptions,
|
||||
): Promise<SearchExploreItem<string>> {
|
||||
async getAssetIdByCity(ownerId: string, { minAssetsPerField, maxFields }: AssetExploreFieldOptions) {
|
||||
const items = await this.db
|
||||
.with('cities', (qb) =>
|
||||
qb
|
||||
@ -792,6 +701,7 @@ export class AssetRepository {
|
||||
.innerJoin('cities', 'exif.city', 'cities.city')
|
||||
.distinctOn('exif.city')
|
||||
.select(['assetId as data', 'exif.city as value'])
|
||||
.$narrowType<{ value: NotNull }>()
|
||||
.where('ownerId', '=', asUuid(ownerId))
|
||||
.where('isVisible', '=', true)
|
||||
.where('isArchived', '=', false)
|
||||
@ -800,7 +710,7 @@ export class AssetRepository {
|
||||
.limit(maxFields)
|
||||
.execute();
|
||||
|
||||
return { fieldName: 'exifInfo.city', items: items as SearchExploreItemSet<string> };
|
||||
return { fieldName: 'exifInfo.city', items };
|
||||
}
|
||||
|
||||
@GenerateSql({
|
||||
|
@ -19,7 +19,7 @@ import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.d
|
||||
import { ImmichWorker, MetadataKey, QueueName } from 'src/enum';
|
||||
import { ConfigRepository } from 'src/repositories/config.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';
|
||||
|
||||
type EmitHandlers = Partial<{ [T in EmitEvent]: Array<EventItem<T>> }>;
|
||||
@ -48,7 +48,7 @@ type EventMap = {
|
||||
'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }];
|
||||
|
||||
// album events
|
||||
'album.update': [{ id: string; recipientIds: string[] }];
|
||||
'album.update': [{ id: string; recipientId: string }];
|
||||
'album.invite': [{ id: string; userId: string }];
|
||||
|
||||
// asset events
|
||||
@ -58,6 +58,7 @@ type EventMap = {
|
||||
'asset.show': [{ assetId: string; userId: string }];
|
||||
'asset.trash': [{ assetId: string; userId: string }];
|
||||
'asset.delete': [{ assetId: string; userId: string }];
|
||||
'asset.metadataExtracted': [{ assetId: string; userId: string; source?: JobSource }];
|
||||
|
||||
// asset bulk events
|
||||
'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 { EventRepository } from 'src/repositories/event.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';
|
||||
|
||||
type JobMapItem = {
|
||||
@ -206,7 +206,10 @@ export class JobRepository {
|
||||
private getJobOptions(item: JobItem): JobsOptions | null {
|
||||
switch (item.name) {
|
||||
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: {
|
||||
return { jobId: item.data.id };
|
||||
@ -227,19 +230,12 @@ export class JobRepository {
|
||||
return this.moduleRef.get<Queue>(getQueueToken(queue), { strict: false });
|
||||
}
|
||||
|
||||
public async removeJob(jobId: string, name: JobName): Promise<IEntityJob | undefined> {
|
||||
const existingJob = await this.getQueue(this.getQueueName(name)).getJob(jobId);
|
||||
if (!existingJob) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
/** @deprecated */
|
||||
// todo: remove this when asset notifications no longer need it.
|
||||
public async removeJob(name: JobName, jobID: string): Promise<void> {
|
||||
const existingJob = await this.getQueue(this.getQueueName(name)).getJob(jobID);
|
||||
if (existingJob) {
|
||||
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 { AssetFileType, SourceType } from 'src/enum';
|
||||
import { removeUndefinedKeys } from 'src/utils/database';
|
||||
import { PaginationOptions } from 'src/utils/pagination';
|
||||
import { paginationHelper, PaginationOptions } from 'src/utils/pagination';
|
||||
|
||||
export interface PersonSearchOptions {
|
||||
minimumFaceCount: number;
|
||||
@ -200,11 +200,7 @@ export class PersonRepository {
|
||||
.limit(pagination.take + 1)
|
||||
.execute();
|
||||
|
||||
if (items.length > pagination.take) {
|
||||
return { items: items.slice(0, -1), hasNextPage: true };
|
||||
}
|
||||
|
||||
return { items, hasNextPage: false };
|
||||
return paginationHelper(items, pagination.take);
|
||||
}
|
||||
|
||||
@GenerateSql()
|
||||
|
@ -8,41 +8,10 @@ import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetStatus, AssetType } from 'src/enum';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { anyUuid, asUuid, searchAssetBuilder, vectorIndexQuery } from 'src/utils/database';
|
||||
import { paginationHelper } from 'src/utils/pagination';
|
||||
import { isValidInteger } from 'src/validation';
|
||||
|
||||
export interface SearchResult<T> {
|
||||
/** 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 {
|
||||
export interface SearchAssetIdOptions {
|
||||
checksum?: Buffer;
|
||||
deviceAssetId?: string;
|
||||
id?: string;
|
||||
@ -54,7 +23,7 @@ export interface SearchUserIdOptions {
|
||||
userIds?: string[];
|
||||
}
|
||||
|
||||
export type SearchIdOptions = SearchAssetIDOptions & SearchUserIdOptions;
|
||||
export type SearchIdOptions = SearchAssetIdOptions & SearchUserIdOptions;
|
||||
|
||||
export interface SearchStatusOptions {
|
||||
isArchived?: boolean;
|
||||
@ -144,8 +113,6 @@ type BaseAssetSearchOptions = SearchDateOptions &
|
||||
|
||||
export type AssetSearchOptions = BaseAssetSearchOptions & SearchRelationOptions;
|
||||
|
||||
export type AssetSearchOneToOneRelationOptions = BaseAssetSearchOptions & SearchOneToOneRelationOptions;
|
||||
|
||||
export type AssetSearchBuilderOptions = Omit<AssetSearchOptions, 'orderDirection'>;
|
||||
|
||||
export type SmartSearchOptions = SearchDateOptions &
|
||||
@ -226,9 +193,8 @@ export class SearchRepository {
|
||||
.limit(pagination.size + 1)
|
||||
.offset((pagination.page - 1) * pagination.size)
|
||||
.execute();
|
||||
const hasNextPage = items.length > pagination.size;
|
||||
items.splice(pagination.size);
|
||||
return { items, hasNextPage };
|
||||
|
||||
return paginationHelper(items, pagination.size);
|
||||
}
|
||||
|
||||
@GenerateSql({
|
||||
@ -283,9 +249,7 @@ export class SearchRepository {
|
||||
.offset((pagination.page - 1) * pagination.size)
|
||||
.execute();
|
||||
|
||||
const hasNextPage = items.length > pagination.size;
|
||||
items.splice(pagination.size);
|
||||
return { items, hasNextPage };
|
||||
return paginationHelper(items, pagination.size);
|
||||
}
|
||||
|
||||
@GenerateSql({
|
||||
|
@ -606,7 +606,7 @@ describe(AlbumService.name, () => {
|
||||
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('album.update', {
|
||||
id: 'album-123',
|
||||
recipientIds: ['admin_id'],
|
||||
recipientId: 'admin_id',
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -170,8 +170,8 @@ export class AlbumService extends BaseService {
|
||||
(userId) => userId !== auth.user.id,
|
||||
);
|
||||
|
||||
if (allUsersExceptUs.length > 0) {
|
||||
await this.eventRepository.emit('album.update', { id, recipientIds: allUsersExceptUs });
|
||||
for (const recipientId of allUsersExceptUs) {
|
||||
await this.eventRepository.emit('album.update', { id, recipientId });
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
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 { AssetStatus, AssetType, JobName, JobStatus } from 'src/enum';
|
||||
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 { factory } from 'test/small.factory';
|
||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||
import { vitest } from 'vitest';
|
||||
|
||||
const stats: AssetStats = {
|
||||
[AssetType.IMAGE]: 10,
|
||||
@ -44,62 +43,6 @@ describe(AssetService.name, () => {
|
||||
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', () => {
|
||||
it('should get the statistics for a user, excluding archived assets', async () => {
|
||||
mocks.asset.getStatistics.mockResolvedValue(stats);
|
||||
|
@ -3,13 +3,7 @@ import _ from 'lodash';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import {
|
||||
AssetResponseDto,
|
||||
MapAsset,
|
||||
MemoryLaneResponseDto,
|
||||
SanitizedAssetResponseDto,
|
||||
mapAsset,
|
||||
} from 'src/dtos/asset-response.dto';
|
||||
import { AssetResponseDto, MapAsset, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import {
|
||||
AssetBulkDeleteDto,
|
||||
AssetBulkUpdateDto,
|
||||
@ -20,7 +14,6 @@ import {
|
||||
mapStats,
|
||||
} from 'src/dtos/asset.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 { BaseService } from 'src/services/base.service';
|
||||
import { ISidecarWriteJob, JobItem, JobOf } from 'src/types';
|
||||
@ -28,26 +21,6 @@ import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUn
|
||||
|
||||
@Injectable()
|
||||
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) {
|
||||
const stats = await this.assetRepository.getStatistics(auth.user.id, dto);
|
||||
return mapStats(stats);
|
||||
|
@ -1,6 +1,4 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { FileReportItemDto } from 'src/dtos/audit.dto';
|
||||
import { AssetFileType, AssetPathType, JobStatus, PersonPathType, UserPathType } from 'src/enum';
|
||||
import { JobStatus } from 'src/enum';
|
||||
import { AuditService } from 'src/services/audit.service';
|
||||
import { newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
@ -25,148 +23,4 @@ describe(AuditService.name, () => {
|
||||
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 { resolve } from 'node:path';
|
||||
import { AUDIT_LOG_MAX_DURATION, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import { FileChecksumDto, FileChecksumResponseDto, FileReportItemDto, PathEntityType } from 'src/dtos/audit.dto';
|
||||
import {
|
||||
AssetFileType,
|
||||
AssetPathType,
|
||||
JobName,
|
||||
JobStatus,
|
||||
PersonPathType,
|
||||
QueueName,
|
||||
StorageFolder,
|
||||
UserPathType,
|
||||
} from 'src/enum';
|
||||
import { JobName, JobStatus, QueueName } from 'src/enum';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { getAssetFiles } from 'src/utils/asset.util';
|
||||
import { usePagination } from 'src/utils/pagination';
|
||||
|
||||
@Injectable()
|
||||
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());
|
||||
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 { WithoutProperty } from 'src/repositories/asset.repository';
|
||||
import { DuplicateService } from 'src/services/duplicate.service';
|
||||
import { SearchService } from 'src/services/search.service';
|
||||
import { assetStub } from 'test/fixtures/asset.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';
|
||||
|
||||
vitest.useFakeTimers();
|
||||
@ -113,14 +112,11 @@ describe(SearchService.name, () => {
|
||||
});
|
||||
|
||||
it('should queue missing assets', async () => {
|
||||
mocks.asset.getWithout.mockResolvedValue({
|
||||
items: [assetStub.image],
|
||||
hasNextPage: false,
|
||||
});
|
||||
mocks.assetJob.streamForSearchDuplicates.mockReturnValue(makeStream([assetStub.image]));
|
||||
|
||||
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([
|
||||
{
|
||||
name: JobName.DUPLICATE_DETECTION,
|
||||
@ -130,14 +126,11 @@ describe(SearchService.name, () => {
|
||||
});
|
||||
|
||||
it('should queue all assets', async () => {
|
||||
mocks.asset.getAll.mockResolvedValue({
|
||||
items: [assetStub.image],
|
||||
hasNextPage: false,
|
||||
});
|
||||
mocks.assetJob.streamForSearchDuplicates.mockReturnValue(makeStream([assetStub.image]));
|
||||
|
||||
await sut.handleQueueSearchDuplicates({ force: true });
|
||||
|
||||
expect(mocks.asset.getAll).toHaveBeenCalled();
|
||||
expect(mocks.assetJob.streamForSearchDuplicates).toHaveBeenCalledWith(true);
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.DUPLICATE_DETECTION,
|
||||
|
@ -5,13 +5,11 @@ import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { DuplicateResponseDto } from 'src/dtos/duplicate.dto';
|
||||
import { AssetFileType, JobName, JobStatus, QueueName } from 'src/enum';
|
||||
import { WithoutProperty } from 'src/repositories/asset.repository';
|
||||
import { AssetDuplicateResult } from 'src/repositories/search.repository';
|
||||
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 { isDuplicateDetectionEnabled } from 'src/utils/misc';
|
||||
import { usePagination } from 'src/utils/pagination';
|
||||
|
||||
@Injectable()
|
||||
export class DuplicateService extends BaseService {
|
||||
@ -30,18 +28,22 @@ export class DuplicateService extends BaseService {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
? this.assetRepository.getAll(pagination, { isVisible: true })
|
||||
: this.assetRepository.getWithout(pagination, WithoutProperty.DUPLICATE);
|
||||
});
|
||||
let jobs: JobItem[] = [];
|
||||
const queueAll = async () => {
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
jobs = [];
|
||||
};
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
await this.jobRepository.queueAll(
|
||||
assets.map((asset) => ({ name: JobName.DUPLICATE_DETECTION, data: { id: asset.id } })),
|
||||
);
|
||||
const assets = this.assetJobRepository.streamForSearchDuplicates(force);
|
||||
for await (const asset of assets) {
|
||||
jobs.push({ name: JobName.DUPLICATE_DETECTION, data: { id: asset.id } });
|
||||
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||
await queueAll();
|
||||
}
|
||||
}
|
||||
|
||||
await queueAll();
|
||||
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
|
||||
|
@ -239,10 +239,6 @@ describe(JobService.name, () => {
|
||||
item: { name: JobName.SIDECAR_DISCOVERY, data: { id: 'asset-1' } },
|
||||
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' } },
|
||||
jobs: [JobName.GENERATE_THUMBNAILS],
|
||||
|
@ -264,17 +264,6 @@ export class JobService extends BaseService {
|
||||
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: {
|
||||
if (item.data.source === 'upload' || item.data.source === 'copy') {
|
||||
await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: item.data });
|
||||
|
@ -273,7 +273,6 @@ describe(LibraryService.name, () => {
|
||||
|
||||
mocks.library.get.mockResolvedValue(library);
|
||||
mocks.storage.walk.mockImplementation(async function* generator() {});
|
||||
mocks.asset.getAll.mockResolvedValue({ items: [assetStub.external], hasNextPage: false });
|
||||
mocks.asset.getLibraryAssetCount.mockResolvedValue(1);
|
||||
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: BigInt(1) });
|
||||
|
||||
@ -292,7 +291,6 @@ describe(LibraryService.name, () => {
|
||||
|
||||
mocks.library.get.mockResolvedValue(library);
|
||||
mocks.storage.walk.mockImplementation(async function* generator() {});
|
||||
mocks.asset.getAll.mockResolvedValue({ items: [assetStub.external], hasNextPage: false });
|
||||
mocks.asset.getLibraryAssetCount.mockResolvedValue(0);
|
||||
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: BigInt(1) });
|
||||
|
||||
|
@ -38,10 +38,6 @@ describe(MediaService.name, () => {
|
||||
describe('handleQueueGenerateThumbnails', () => {
|
||||
it('should queue all assets', async () => {
|
||||
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.getFacesByIds.mockResolvedValue([faceStub.face1]);
|
||||
@ -67,10 +63,6 @@ describe(MediaService.name, () => {
|
||||
|
||||
it('should queue trashed assets when force is true', async () => {
|
||||
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.archived]));
|
||||
mocks.asset.getAll.mockResolvedValue({
|
||||
items: [assetStub.trashed],
|
||||
hasNextPage: false,
|
||||
});
|
||||
mocks.person.getAll.mockReturnValue(makeStream());
|
||||
|
||||
await sut.handleQueueGenerateThumbnails({ force: true });
|
||||
@ -171,7 +163,7 @@ describe(MediaService.name, () => {
|
||||
|
||||
describe('handleQueueMigration', () => {
|
||||
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.person.getAll.mockReturnValue(makeStream([personStub.withName]));
|
||||
|
||||
|
@ -36,7 +36,6 @@ import {
|
||||
import { getAssetFiles } from 'src/utils/asset.util';
|
||||
import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { usePagination } from 'src/utils/pagination';
|
||||
|
||||
@Injectable()
|
||||
export class MediaService extends BaseService {
|
||||
@ -50,18 +49,26 @@ export class MediaService extends BaseService {
|
||||
|
||||
@OnJob({ name: JobName.QUEUE_GENERATE_THUMBNAILS, queue: QueueName.THUMBNAIL_GENERATION })
|
||||
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)) {
|
||||
const { previewFile, thumbnailFile } = getAssetFiles(asset.files);
|
||||
|
||||
if (!previewFile || !thumbnailFile || !asset.thumbhash || force) {
|
||||
thumbJobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id: asset.id } });
|
||||
continue;
|
||||
jobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id: asset.id } });
|
||||
}
|
||||
|
||||
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||
await queueAll();
|
||||
}
|
||||
}
|
||||
await this.jobRepository.queueAll(thumbJobs);
|
||||
|
||||
const jobs: JobItem[] = [];
|
||||
await queueAll();
|
||||
|
||||
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 } });
|
||||
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||
await queueAll();
|
||||
}
|
||||
}
|
||||
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
await queueAll();
|
||||
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.QUEUE_MIGRATION, queue: QueueName.MIGRATION })
|
||||
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);
|
||||
if (active === 1 && waiting === 0) {
|
||||
await this.storageCore.removeEmptyDirs(StorageFolder.THUMBNAILS);
|
||||
await this.storageCore.removeEmptyDirs(StorageFolder.ENCODED_VIDEO);
|
||||
}
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
await this.jobRepository.queueAll(
|
||||
assets.map((asset) => ({ name: JobName.MIGRATE_ASSET, data: { id: asset.id } })),
|
||||
);
|
||||
let jobs: JobItem[] = [];
|
||||
const assets = this.assetJobRepository.streamForMigrationJob();
|
||||
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()) {
|
||||
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(
|
||||
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,
|
||||
);
|
||||
|
||||
|
@ -5,7 +5,6 @@ import { constants } from 'node:fs/promises';
|
||||
import { defaults } from 'src/config';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetType, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum';
|
||||
import { WithoutProperty } from 'src/repositories/asset.repository';
|
||||
import { ImmichTags } from 'src/repositories/metadata.repository';
|
||||
import { MetadataService } from 'src/services/metadata.service';
|
||||
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 () => {
|
||||
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.asset.upsertExif).not.toHaveBeenCalled();
|
||||
@ -527,7 +527,7 @@ describe(MetadataService.name, () => {
|
||||
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 () => {
|
||||
@ -1202,7 +1202,7 @@ describe(MetadataService.name, () => {
|
||||
it('should handle livePhotoCID not set', async () => {
|
||||
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.asset.findLivePhotoMatch).not.toHaveBeenCalled();
|
||||
@ -1215,9 +1215,7 @@ describe(MetadataService.name, () => {
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
||||
mockReadTags({ ContentIdentifier: 'CID' });
|
||||
|
||||
await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id })).resolves.toBe(
|
||||
JobStatus.SUCCESS,
|
||||
);
|
||||
await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id });
|
||||
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id);
|
||||
expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({
|
||||
@ -1236,9 +1234,7 @@ describe(MetadataService.name, () => {
|
||||
mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
||||
mockReadTags({ ContentIdentifier: 'CID' });
|
||||
|
||||
await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(
|
||||
JobStatus.SUCCESS,
|
||||
);
|
||||
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
|
||||
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoStillAsset.id);
|
||||
expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({
|
||||
@ -1262,9 +1258,7 @@ describe(MetadataService.name, () => {
|
||||
mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
||||
mockReadTags({ ContentIdentifier: 'CID' });
|
||||
|
||||
await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(
|
||||
JobStatus.SUCCESS,
|
||||
);
|
||||
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
|
||||
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('asset.hide', {
|
||||
userId: assetStub.livePhotoMotionAsset.ownerId,
|
||||
@ -1280,10 +1274,12 @@ describe(MetadataService.name, () => {
|
||||
mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
|
||||
mockReadTags({ ContentIdentifier: 'CID' });
|
||||
|
||||
await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe(
|
||||
JobStatus.SUCCESS,
|
||||
);
|
||||
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
|
||||
|
||||
expect(mocks.event.emit).toHaveBeenCalledWith('asset.metadataExtracted', {
|
||||
assetId: assetStub.livePhotoStillAsset.id,
|
||||
userId: assetStub.livePhotoStillAsset.ownerId,
|
||||
});
|
||||
expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({
|
||||
ownerId: 'user-id',
|
||||
otherAssetId: 'live-photo-still-asset',
|
||||
@ -1346,12 +1342,11 @@ describe(MetadataService.name, () => {
|
||||
|
||||
describe('handleQueueSidecar', () => {
|
||||
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 });
|
||||
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([
|
||||
{
|
||||
name: JobName.SIDECAR_SYNC,
|
||||
@ -1361,12 +1356,11 @@ describe(MetadataService.name, () => {
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
expect(mocks.asset.getWithout).toHaveBeenCalledWith({ take: 1000, skip: 0 }, WithoutProperty.SIDECAR);
|
||||
expect(mocks.asset.getAll).not.toHaveBeenCalled();
|
||||
expect(mocks.assetJob.streamForSidecar).toHaveBeenCalledWith(false);
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.SIDECAR_DISCOVERY,
|
||||
|
@ -22,14 +22,12 @@ import {
|
||||
QueueName,
|
||||
SourceType,
|
||||
} from 'src/enum';
|
||||
import { WithoutProperty } from 'src/repositories/asset.repository';
|
||||
import { ArgOf } from 'src/repositories/event.repository';
|
||||
import { ReverseGeocodeResult } from 'src/repositories/map.repository';
|
||||
import { ImmichTags } from 'src/repositories/metadata.repository';
|
||||
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 { usePagination } from 'src/utils/pagination';
|
||||
import { upsertTags } from 'src/utils/tag';
|
||||
|
||||
/** 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 })
|
||||
async handleMetadataExtraction(data: JobOf<JobName.METADATA_EXTRACTION>): Promise<JobStatus> {
|
||||
async handleMetadataExtraction(data: JobOf<JobName.METADATA_EXTRACTION>) {
|
||||
const [{ metadata, reverseGeocoding }, asset] = await Promise.all([
|
||||
this.getConfig({ withCache: true }),
|
||||
this.assetJobRepository.getForMetadataExtraction(data.id),
|
||||
]);
|
||||
|
||||
if (!asset) {
|
||||
return JobStatus.FAILED;
|
||||
return;
|
||||
}
|
||||
|
||||
const [exifTags, stats] = await Promise.all([
|
||||
@ -285,27 +283,31 @@ export class MetadataService extends BaseService {
|
||||
|
||||
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 })
|
||||
async handleQueueSidecar(job: JobOf<JobName.QUEUE_SIDECAR>): Promise<JobStatus> {
|
||||
const { force } = job;
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
? this.assetRepository.getAll(pagination)
|
||||
: this.assetRepository.getWithout(pagination, WithoutProperty.SIDECAR);
|
||||
});
|
||||
async handleQueueSidecar({ force }: JobOf<JobName.QUEUE_SIDECAR>): Promise<JobStatus> {
|
||||
let jobs: JobItem[] = [];
|
||||
const queueAll = async () => {
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
jobs = [];
|
||||
};
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
await this.jobRepository.queueAll(
|
||||
assets.map((asset) => ({
|
||||
name: force ? JobName.SIDECAR_SYNC : JobName.SIDECAR_DISCOVERY,
|
||||
data: { id: asset.id },
|
||||
})),
|
||||
);
|
||||
const assets = this.assetJobRepository.streamForSidecar(force);
|
||||
for await (const asset of assets) {
|
||||
jobs.push({ name: force ? JobName.SIDECAR_SYNC : JobName.SIDECAR_DISCOVERY, data: { id: asset.id } });
|
||||
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||
await queueAll();
|
||||
}
|
||||
}
|
||||
|
||||
await queueAll();
|
||||
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
|
||||
|
@ -154,10 +154,10 @@ describe(NotificationService.name, () => {
|
||||
|
||||
describe('onAlbumUpdateEvent', () => {
|
||||
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({
|
||||
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', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
it('should skip if owner could not be found', async () => {
|
||||
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();
|
||||
});
|
||||
|
||||
@ -434,7 +434,7 @@ describe(NotificationService.name, () => {
|
||||
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
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.email.renderEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
@ -456,7 +456,7 @@ describe(NotificationService.name, () => {
|
||||
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
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.email.renderEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
@ -478,7 +478,7 @@ describe(NotificationService.name, () => {
|
||||
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
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.email.renderEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
@ -492,21 +492,21 @@ describe(NotificationService.name, () => {
|
||||
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
|
||||
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.email.renderEmail).toHaveBeenCalled();
|
||||
expect(mocks.job.queue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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', recipientIds: ['1', '2', '3'] } as INotifyAlbumUpdateJob);
|
||||
await sut.onAlbumUpdate({ id: '1', recipientId: '2' } as INotifyAlbumUpdateJob);
|
||||
expect(mocks.job.removeJob).toHaveBeenCalledWith(JobName.NOTIFY_ALBUM_UPDATE, '1/2');
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.NOTIFY_ALBUM_UPDATE,
|
||||
data: {
|
||||
id: '1',
|
||||
delay: 300_000,
|
||||
recipientIds: ['1', '2', '3', '4'],
|
||||
recipientId: '2',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { OnEvent, OnJob } from 'src/decorators';
|
||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import {
|
||||
mapNotification,
|
||||
@ -22,7 +23,7 @@ import {
|
||||
import { EmailTemplate } from 'src/repositories/email.repository';
|
||||
import { ArgOf } from 'src/repositories/event.repository';
|
||||
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 { getExternalDomain } from 'src/utils/misc';
|
||||
import { isEqualObject } from 'src/utils/object';
|
||||
@ -152,6 +153,18 @@ export class NotificationService extends BaseService {
|
||||
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' })
|
||||
onAssetsRestore({ assetIds, userId }: ArgOf<'assets.restore'>) {
|
||||
this.eventRepository.clientSend('on_asset_restore', userId, assetIds);
|
||||
@ -185,30 +198,12 @@ export class NotificationService extends BaseService {
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'album.update' })
|
||||
async onAlbumUpdate({ id, recipientIds }: ArgOf<'album.update'>) {
|
||||
// if recipientIds is empty, album likely only has one user part of it, don't queue notification if so
|
||||
if (recipientIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const job: JobItem = {
|
||||
async onAlbumUpdate({ id, recipientId }: ArgOf<'album.update'>) {
|
||||
await this.jobRepository.removeJob(JobName.NOTIFY_ALBUM_UPDATE, `${id}/${recipientId}`);
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.NOTIFY_ALBUM_UPDATE,
|
||||
data: { id, recipientIds, 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;
|
||||
data: { id, recipientId, delay: NotificationService.albumUpdateEmailDelayMs },
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent({ name: 'album.invite' })
|
||||
@ -399,7 +394,7 @@ export class NotificationService extends BaseService {
|
||||
}
|
||||
|
||||
@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 });
|
||||
|
||||
if (!album) {
|
||||
@ -411,49 +406,44 @@ export class NotificationService extends BaseService {
|
||||
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 { server, templates } = await this.getConfig({ withCache: false });
|
||||
|
||||
for (const recipient of recipients) {
|
||||
const user = await this.userRepository.get(recipient.id, { withDeleted: false });
|
||||
if (!user) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { emailNotifications } = getPreferences(user.metadata);
|
||||
|
||||
if (!emailNotifications.enabled || !emailNotifications.albumUpdate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { html, text } = await this.emailRepository.renderEmail({
|
||||
template: EmailTemplate.ALBUM_UPDATE,
|
||||
data: {
|
||||
baseUrl: getExternalDomain(server),
|
||||
albumId: album.id,
|
||||
albumName: album.albumName,
|
||||
recipientName: recipient.name,
|
||||
cid: attachment ? attachment.cid : undefined,
|
||||
},
|
||||
customTemplate: templates.email.albumUpdateTemplate,
|
||||
});
|
||||
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.SEND_EMAIL,
|
||||
data: {
|
||||
to: recipient.email,
|
||||
subject: `New media has been added to an album - ${album.albumName}`,
|
||||
html,
|
||||
text,
|
||||
imageAttachments: attachment ? [attachment] : undefined,
|
||||
},
|
||||
});
|
||||
const user = await this.userRepository.get(recipientId, { withDeleted: false });
|
||||
if (!user) {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
const { emailNotifications } = getPreferences(user.metadata);
|
||||
|
||||
if (!emailNotifications.enabled || !emailNotifications.albumUpdate) {
|
||||
return JobStatus.SKIPPED;
|
||||
}
|
||||
|
||||
const { html, text } = await this.emailRepository.renderEmail({
|
||||
template: EmailTemplate.ALBUM_UPDATE,
|
||||
data: {
|
||||
baseUrl: getExternalDomain(server),
|
||||
albumId: album.id,
|
||||
albumName: album.albumName,
|
||||
recipientName: user.name,
|
||||
cid: attachment ? attachment.cid : undefined,
|
||||
},
|
||||
customTemplate: templates.email.albumUpdateTemplate,
|
||||
});
|
||||
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.SEND_EMAIL,
|
||||
data: {
|
||||
to: user.email,
|
||||
subject: `New media has been added to an album - ${album.albumName}`,
|
||||
html,
|
||||
text,
|
||||
imageAttachments: attachment ? [attachment] : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return JobStatus.SUCCESS;
|
||||
}
|
||||
|
||||
|
@ -2,7 +2,6 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
||||
import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.dto';
|
||||
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 { FaceSearchResult } from 'src/repositories/search.repository';
|
||||
import { PersonService } from 'src/services/person.service';
|
||||
@ -455,14 +454,11 @@ describe(PersonService.name, () => {
|
||||
});
|
||||
|
||||
it('should queue missing assets', async () => {
|
||||
mocks.asset.getWithout.mockResolvedValue({
|
||||
items: [assetStub.image],
|
||||
hasNextPage: false,
|
||||
});
|
||||
mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([assetStub.image]));
|
||||
|
||||
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([
|
||||
{
|
||||
name: JobName.FACE_DETECTION,
|
||||
@ -472,10 +468,7 @@ describe(PersonService.name, () => {
|
||||
});
|
||||
|
||||
it('should queue all assets', async () => {
|
||||
mocks.asset.getAll.mockResolvedValue({
|
||||
items: [assetStub.image],
|
||||
hasNextPage: false,
|
||||
});
|
||||
mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([assetStub.image]));
|
||||
mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.withName]);
|
||||
|
||||
await sut.handleQueueDetectFaces({ force: true });
|
||||
@ -483,7 +476,7 @@ describe(PersonService.name, () => {
|
||||
expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING });
|
||||
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.withName.id]);
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath);
|
||||
expect(mocks.asset.getAll).toHaveBeenCalled();
|
||||
expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(true);
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.FACE_DETECTION,
|
||||
@ -493,17 +486,14 @@ describe(PersonService.name, () => {
|
||||
});
|
||||
|
||||
it('should refresh all assets', async () => {
|
||||
mocks.asset.getAll.mockResolvedValue({
|
||||
items: [assetStub.image],
|
||||
hasNextPage: false,
|
||||
});
|
||||
mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([assetStub.image]));
|
||||
|
||||
await sut.handleQueueDetectFaces({ force: undefined });
|
||||
|
||||
expect(mocks.person.delete).not.toHaveBeenCalled();
|
||||
expect(mocks.person.deleteFaces).not.toHaveBeenCalled();
|
||||
expect(mocks.storage.unlink).not.toHaveBeenCalled();
|
||||
expect(mocks.asset.getAll).toHaveBeenCalled();
|
||||
expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(undefined);
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.FACE_DETECTION,
|
||||
@ -516,16 +506,13 @@ describe(PersonService.name, () => {
|
||||
it('should delete existing people and faces if forced', async () => {
|
||||
mocks.person.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson]));
|
||||
mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1]));
|
||||
mocks.asset.getAll.mockResolvedValue({
|
||||
items: [assetStub.image],
|
||||
hasNextPage: false,
|
||||
});
|
||||
mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([assetStub.image]));
|
||||
mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]);
|
||||
mocks.person.deleteFaces.mockResolvedValue();
|
||||
|
||||
await sut.handleQueueDetectFaces({ force: true });
|
||||
|
||||
expect(mocks.asset.getAll).toHaveBeenCalled();
|
||||
expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(true);
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.FACE_DETECTION,
|
||||
|
@ -36,7 +36,6 @@ import {
|
||||
SourceType,
|
||||
SystemMetadataKey,
|
||||
} from 'src/enum';
|
||||
import { WithoutProperty } from 'src/repositories/asset.repository';
|
||||
import { BoundingBox } from 'src/repositories/machine-learning.repository';
|
||||
import { UpdateFacesData } from 'src/repositories/person.repository';
|
||||
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 { mimeTypes } from 'src/utils/mime-types';
|
||||
import { isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc';
|
||||
import { usePagination } from 'src/utils/pagination';
|
||||
|
||||
@Injectable()
|
||||
export class PersonService extends BaseService {
|
||||
@ -265,23 +263,19 @@ export class PersonService extends BaseService {
|
||||
await this.handlePersonCleanup();
|
||||
}
|
||||
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force === false
|
||||
? this.assetRepository.getWithout(pagination, WithoutProperty.FACES)
|
||||
: this.assetRepository.getAll(pagination, {
|
||||
orderDirection: 'desc',
|
||||
withFaces: true,
|
||||
withArchived: true,
|
||||
isVisible: true,
|
||||
});
|
||||
});
|
||||
let jobs: JobItem[] = [];
|
||||
const assets = this.assetJobRepository.streamForDetectFacesJob(force);
|
||||
for await (const asset of assets) {
|
||||
jobs.push({ name: JobName.FACE_DETECTION, data: { id: asset.id } });
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
await this.jobRepository.queueAll(
|
||||
assets.map((asset) => ({ name: JobName.FACE_DETECTION, data: { id: asset.id } })),
|
||||
);
|
||||
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
jobs = [];
|
||||
}
|
||||
}
|
||||
|
||||
await this.jobRepository.queueAll(jobs);
|
||||
|
||||
if (force === undefined) {
|
||||
await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP });
|
||||
}
|
||||
|
@ -15,7 +15,6 @@ import {
|
||||
SmartSearchDto,
|
||||
} from 'src/dtos/search.dto';
|
||||
import { AssetOrder } from 'src/enum';
|
||||
import { SearchExploreItem } from 'src/repositories/search.repository';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { getMyPartnerIds } from 'src/utils/asset.util';
|
||||
import { isSmartSearchEnabled } from 'src/utils/misc';
|
||||
@ -32,7 +31,7 @@ export class SearchService extends BaseService {
|
||||
return places.map((place) => mapPlaces(place));
|
||||
}
|
||||
|
||||
async getExploreData(auth: AuthDto): Promise<SearchExploreItem<AssetResponseDto>[]> {
|
||||
async getExploreData(auth: AuthDto) {
|
||||
const options = { maxFields: 12, minAssetsPerField: 5 };
|
||||
const cities = await this.assetRepository.getAssetIdByCity(auth.user.id, options);
|
||||
const assets = await this.assetRepository.getByIdsWithAllRelationsButStacks(cities.items.map(({ data }) => data));
|
||||
|
@ -151,7 +151,6 @@ describe(SmartInfoService.name, () => {
|
||||
|
||||
await sut.handleQueueEncodeClip({});
|
||||
|
||||
expect(mocks.asset.getWithout).not.toHaveBeenCalled();
|
||||
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
@ -116,6 +116,11 @@ export class StorageTemplateService extends BaseService {
|
||||
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 })
|
||||
async handleMigrationSingle({ id }: JobOf<JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE>): Promise<JobStatus> {
|
||||
const config = await this.getConfig({ withCache: true });
|
||||
|
@ -177,9 +177,10 @@ export interface IDelayedJob extends IBaseJob {
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export type JobSource = 'upload' | 'sidecar-write' | 'copy';
|
||||
export interface IEntityJob extends IBaseJob {
|
||||
id: string;
|
||||
source?: 'upload' | 'sidecar-write' | 'copy';
|
||||
source?: JobSource;
|
||||
notify?: boolean;
|
||||
}
|
||||
|
||||
@ -251,7 +252,7 @@ export interface INotifyAlbumInviteJob extends IEntityJob {
|
||||
}
|
||||
|
||||
export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob {
|
||||
recipientIds: string[];
|
||||
recipientId: string;
|
||||
}
|
||||
|
||||
export interface JobCounts {
|
||||
|
@ -8,22 +8,6 @@ export interface PaginationResult<T> {
|
||||
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> {
|
||||
const hasNextPage = items.length > take;
|
||||
items.splice(take);
|
||||
|
@ -13,14 +13,11 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
|
||||
getByIds: vitest.fn().mockResolvedValue([]),
|
||||
getByIdsWithAllRelationsButStacks: vitest.fn().mockResolvedValue([]),
|
||||
getByDeviceIds: vitest.fn(),
|
||||
getByUserId: vitest.fn(),
|
||||
getById: vitest.fn(),
|
||||
getWithout: vitest.fn(),
|
||||
getByChecksum: vitest.fn(),
|
||||
getByChecksums: vitest.fn(),
|
||||
getUploadAssetIdByChecksum: vitest.fn(),
|
||||
getRandom: vitest.fn(),
|
||||
getAll: vitest.fn().mockResolvedValue({ items: [], hasNextPage: false }),
|
||||
getAllByDeviceId: vitest.fn(),
|
||||
getLivePhotoCount: vitest.fn(),
|
||||
getLibraryAssetCount: vitest.fn(),
|
||||
|
@ -10,6 +10,7 @@ const envData: EnvData = {
|
||||
buildMetadata: {},
|
||||
bull: {
|
||||
config: {
|
||||
connection: {},
|
||||
prefix: 'immich_bull',
|
||||
},
|
||||
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" run build
|
||||
|
||||
|
||||
COUNT=0
|
||||
UPSTREAM="${IMMICH_SERVER_URL:-http://immich-server:2283/}"
|
||||
until wget --spider --quiet "${UPSTREAM}/api/server/config"; do
|
||||
echo 'waiting for api server...'
|
||||
until wget --spider --quiet "${UPSTREAM}/api/server/config" > /dev/null 2>&1; do
|
||||
if [ $((COUNT % 10)) -eq 0 ]; then
|
||||
echo "Waiting for $UPSTREAM to start..."
|
||||
fi
|
||||
COUNT=$((COUNT + 1))
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Connected to $UPSTREAM"
|
||||
|
||||
node ./node_modules/.bin/vite dev --host 0.0.0.0 --port 3000
|
||||
|
@ -1,32 +1,24 @@
|
||||
<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 { 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 { isTenMinutesApart } from '$lib/utils/timesince';
|
||||
import {
|
||||
ReactionType,
|
||||
createActivity,
|
||||
deleteActivity,
|
||||
getActivities,
|
||||
type ActivityResponseDto,
|
||||
type AssetTypeEnum,
|
||||
type UserResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { mdiClose, mdiDotsVertical, mdiHeart, mdiSend, mdiDeleteOutline } from '@mdi/js';
|
||||
import { ReactionType, type ActivityResponseDto, type AssetTypeEnum, type UserResponseDto } from '@immich/sdk';
|
||||
import { mdiClose, mdiDeleteOutline, mdiDotsVertical, mdiHeart, mdiSend } from '@mdi/js';
|
||||
import * as luxon from 'luxon';
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
|
||||
import LoadingSpinner from '../shared-components/loading-spinner.svelte';
|
||||
import { NotificationType, notificationController } from '../shared-components/notification/notification';
|
||||
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'];
|
||||
|
||||
@ -48,34 +40,16 @@
|
||||
};
|
||||
|
||||
interface Props {
|
||||
reactions: ActivityResponseDto[];
|
||||
user: UserResponseDto;
|
||||
assetId?: string | undefined;
|
||||
albumId: string;
|
||||
assetType?: AssetTypeEnum | undefined;
|
||||
albumOwnerId: string;
|
||||
disabled: boolean;
|
||||
isLiked: ActivityResponseDto | null;
|
||||
onDeleteComment: () => void;
|
||||
onDeleteLike: () => void;
|
||||
onAddComment: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
reactions = $bindable(),
|
||||
user,
|
||||
assetId = undefined,
|
||||
albumId,
|
||||
assetType = undefined,
|
||||
albumOwnerId,
|
||||
disabled,
|
||||
isLiked,
|
||||
onDeleteComment,
|
||||
onDeleteLike,
|
||||
onAddComment,
|
||||
onClose,
|
||||
}: Props = $props();
|
||||
let { user, assetId = undefined, albumId, assetType = undefined, albumOwnerId, disabled, onClose }: Props = $props();
|
||||
|
||||
let innerHeight: number = $state(0);
|
||||
let activityHeight: number = $state(0);
|
||||
@ -85,36 +59,18 @@
|
||||
let message = $state('');
|
||||
let isSendingMessage = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
await getReactions();
|
||||
});
|
||||
|
||||
const getReactions = async () => {
|
||||
try {
|
||||
reactions = await getActivities({ assetId, albumId });
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_load_asset_activity'));
|
||||
}
|
||||
};
|
||||
|
||||
const timeOptions = {
|
||||
const timeOptions: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
} as Intl.DateTimeFormatOptions;
|
||||
};
|
||||
|
||||
const handleDeleteReaction = async (reaction: ActivityResponseDto, index: number) => {
|
||||
try {
|
||||
await deleteActivity({ id: reaction.id });
|
||||
reactions.splice(index, 1);
|
||||
if (isLiked && reaction.type === ReactionType.Like && reaction.id == isLiked.id) {
|
||||
onDeleteLike();
|
||||
} else {
|
||||
onDeleteComment();
|
||||
}
|
||||
await activityManager.deleteActivity(reaction, index);
|
||||
|
||||
const deleteMessages: Record<ReactionType, string> = {
|
||||
[ReactionType.Comment]: $t('comment_deleted'),
|
||||
@ -135,13 +91,9 @@
|
||||
}
|
||||
const timeout = setTimeout(() => (isSendingMessage = true), timeBeforeShowLoadingSpinner);
|
||||
try {
|
||||
const data = await createActivity({
|
||||
activityCreateDto: { albumId, assetId, type: ReactionType.Comment, comment: message },
|
||||
});
|
||||
reactions.push(data);
|
||||
await activityManager.addActivity({ albumId, assetId, type: ReactionType.Comment, comment: message });
|
||||
|
||||
message = '';
|
||||
onAddComment();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_add_comment'));
|
||||
} finally {
|
||||
@ -156,7 +108,6 @@
|
||||
});
|
||||
$effect(() => {
|
||||
if (assetId && previousAssetId != assetId) {
|
||||
handlePromiseError(getReactions());
|
||||
previousAssetId = assetId;
|
||||
}
|
||||
});
|
||||
@ -184,7 +135,7 @@
|
||||
class="overflow-y-auto immich-scrollbar relative w-full px-2"
|
||||
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}
|
||||
<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">
|
||||
@ -221,7 +172,7 @@
|
||||
{/if}
|
||||
</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
|
||||
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)}
|
||||
@ -273,7 +224,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</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
|
||||
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)}
|
||||
|
@ -5,8 +5,8 @@
|
||||
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 { AssetAction, ProjectionType } from '$lib/constants';
|
||||
import { activityManager } from '$lib/managers/activity-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 { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { isShowDetail } from '$lib/stores/preferences.store';
|
||||
@ -19,15 +19,9 @@
|
||||
import {
|
||||
AssetJobName,
|
||||
AssetTypeEnum,
|
||||
ReactionType,
|
||||
createActivity,
|
||||
deleteActivity,
|
||||
getActivities,
|
||||
getActivityStatistics,
|
||||
getAllAlbums,
|
||||
getStack,
|
||||
runAssetJobs,
|
||||
type ActivityResponseDto,
|
||||
type AlbumResponseDto,
|
||||
type AssetResponseDto,
|
||||
type PersonResponseDto,
|
||||
@ -61,7 +55,6 @@
|
||||
person?: PersonResponseDto | null;
|
||||
preAction?: PreAction | undefined;
|
||||
onAction?: OnAction | undefined;
|
||||
reactions?: ActivityResponseDto[];
|
||||
showCloseButton?: boolean;
|
||||
onClose: (dto: { asset: AssetResponseDto }) => void;
|
||||
onNext: () => Promise<HasAsset>;
|
||||
@ -80,7 +73,6 @@
|
||||
person = null,
|
||||
preAction = undefined,
|
||||
onAction = undefined,
|
||||
reactions = $bindable([]),
|
||||
showCloseButton,
|
||||
onClose,
|
||||
onNext,
|
||||
@ -107,8 +99,6 @@
|
||||
let previewStackedAsset: AssetResponseDto | undefined = $state();
|
||||
let isShowActivity = $state(false);
|
||||
let isShowEditor = $state(false);
|
||||
let isLiked: ActivityResponseDto | null = $state(null);
|
||||
let numberOfComments = $state(0);
|
||||
let fullscreenElement = $state<Element>();
|
||||
let unsubscribes: (() => void)[] = [];
|
||||
let selectedEditType: string = $state('');
|
||||
@ -136,59 +126,20 @@
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddComment = () => {
|
||||
numberOfComments++;
|
||||
updateNumberOfComments(1);
|
||||
};
|
||||
|
||||
const handleRemoveComment = () => {
|
||||
numberOfComments--;
|
||||
updateNumberOfComments(-1);
|
||||
};
|
||||
|
||||
const handleFavorite = async () => {
|
||||
if (album && album.isActivityEnabled) {
|
||||
try {
|
||||
if (isLiked) {
|
||||
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];
|
||||
}
|
||||
await activityManager.toggleLike();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_change_favorite'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getFavorite = 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 () => {
|
||||
const updateComments = async () => {
|
||||
if (album) {
|
||||
try {
|
||||
const { comments } = await getActivityStatistics({ assetId: asset.id, albumId: album.id });
|
||||
numberOfComments = comments;
|
||||
await activityManager.refreshActivities(album.id, asset.id);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.unable_to_get_comments_number'));
|
||||
}
|
||||
@ -227,6 +178,10 @@
|
||||
if (!sharedLink) {
|
||||
await handleGetAllAlbums();
|
||||
}
|
||||
|
||||
if (album) {
|
||||
activityManager.init(album.id, asset.id);
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
@ -241,6 +196,8 @@
|
||||
for (const unsubscribe of unsubscribes) {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
activityManager.reset();
|
||||
});
|
||||
|
||||
const handleGetAllAlbums = async () => {
|
||||
@ -402,14 +359,13 @@
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
if (album && !album.isActivityEnabled && numberOfComments === 0) {
|
||||
if (album && !album.isActivityEnabled && activityManager.commentCount === 0) {
|
||||
isShowActivity = false;
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
if (isShared && asset.id) {
|
||||
handlePromiseError(getFavorite());
|
||||
handlePromiseError(getNumberOfComments());
|
||||
handlePromiseError(updateComments());
|
||||
}
|
||||
});
|
||||
$effect(() => {
|
||||
@ -547,12 +503,12 @@
|
||||
onVideoStarted={handleVideoStarted}
|
||||
/>
|
||||
{/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">
|
||||
<ActivityStatus
|
||||
disabled={!album?.isActivityEnabled}
|
||||
{isLiked}
|
||||
{numberOfComments}
|
||||
isLiked={activityManager.isLiked}
|
||||
numberOfComments={activityManager.commentCount}
|
||||
onFavorite={handleFavorite}
|
||||
onOpenActivityTab={handleOpenActivity}
|
||||
/>
|
||||
@ -642,11 +598,6 @@
|
||||
albumOwnerId={album.ownerId}
|
||||
albumId={album.id}
|
||||
assetId={asset.id}
|
||||
{isLiked}
|
||||
bind:reactions
|
||||
onAddComment={handleAddComment}
|
||||
onDeleteComment={handleRemoveComment}
|
||||
onDeleteLike={() => (isLiked = null)}
|
||||
onClose={() => (isShowActivity = false)}
|
||||
/>
|
||||
</div>
|
||||
|
@ -2,14 +2,15 @@
|
||||
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
|
||||
import { dialogController } from '$lib/components/shared-components/dialog/dialog';
|
||||
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 { 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 { Canvas, InteractiveFabricObject, Rect } from 'fabric';
|
||||
import { onMount } from 'svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
import { handleError } from '$lib/utils/handle-error';
|
||||
import { t } from 'svelte-i18n';
|
||||
|
||||
interface Props {
|
||||
htmlElement: HTMLImageElement | HTMLVideoElement;
|
||||
@ -316,7 +317,7 @@
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
<Input placeholder="Search person..." bind:value={searchTerm} size="tiny" />
|
||||
@ -348,11 +349,11 @@
|
||||
</div>
|
||||
{:else}
|
||||
<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>
|
||||
{/if}
|
||||
</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>
|
||||
|
@ -243,6 +243,26 @@
|
||||
class={['group absolute top-[0px] bottom-[0px]', { 'cursor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
|
||||
style:width="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 -->
|
||||
{#if !usingMobileDevice && mouseOver && !disableLinkMouseOver}
|
||||
|
@ -1,38 +1,38 @@
|
||||
<script lang="ts">
|
||||
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 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 type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
|
||||
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
|
||||
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 { searchStore } from '$lib/stores/search.svelte';
|
||||
import { featureFlags } from '$lib/stores/server-config.store';
|
||||
import { handlePromiseError } from '$lib/utils';
|
||||
import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
|
||||
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
|
||||
import { focusNext } from '$lib/utils/focus-util';
|
||||
import { navigate } from '$lib/utils/navigation';
|
||||
import { type ScrubberListener } from '$lib/utils/timeline-util';
|
||||
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk';
|
||||
import { DateTime } from 'luxon';
|
||||
import { onMount, type Snippet } from 'svelte';
|
||||
import type { UpdatePayload } from 'vite';
|
||||
import Portal from '../shared-components/portal/portal.svelte';
|
||||
import Scrubber from '../shared-components/scrubber/scrubber.svelte';
|
||||
import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
|
||||
import AssetDateGroup from './asset-date-group.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 {
|
||||
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 RemoveFromAlbum from '$lib/components/photos-page/actions/remove-from-album.svelte';
|
||||
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
|
||||
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
|
||||
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
|
||||
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
|
||||
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.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';
|
||||
@ -33,14 +34,16 @@
|
||||
notificationController,
|
||||
} from '$lib/components/shared-components/notification/notification';
|
||||
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
|
||||
import { AppRoute, AlbumPageViewMode } from '$lib/constants';
|
||||
import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store';
|
||||
import { AlbumPageViewMode, AppRoute } from '$lib/constants';
|
||||
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 { AssetStore } from '$lib/stores/assets-store.svelte';
|
||||
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { preferences, user } from '$lib/stores/user.store';
|
||||
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 { handleError } from '$lib/utils/handle-error';
|
||||
import {
|
||||
@ -53,18 +56,11 @@
|
||||
import {
|
||||
AlbumUserRole,
|
||||
AssetOrder,
|
||||
ReactionLevel,
|
||||
ReactionType,
|
||||
addAssetsToAlbum,
|
||||
addUsersToAlbum,
|
||||
createActivity,
|
||||
deleteActivity,
|
||||
deleteAlbum,
|
||||
getActivities,
|
||||
getActivityStatistics,
|
||||
getAlbumInfo,
|
||||
updateAlbumInfo,
|
||||
type ActivityResponseDto,
|
||||
type AlbumUserAddDto,
|
||||
} from '@immich/sdk';
|
||||
import {
|
||||
@ -80,13 +76,10 @@
|
||||
mdiPresentationPlay,
|
||||
mdiShareVariantOutline,
|
||||
} from '@mdi/js';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fly } from 'svelte/transition';
|
||||
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 {
|
||||
data: PageData;
|
||||
@ -103,8 +96,6 @@
|
||||
let viewMode: AlbumPageViewMode = $state(AlbumPageViewMode.VIEW);
|
||||
let isCreatingSharedAlbum = $state(false);
|
||||
let isShowActivity = $state(false);
|
||||
let isLiked: ActivityResponseDto | null = $state(null);
|
||||
let reactions: ActivityResponseDto[] = $state([]);
|
||||
let albumOrder: AssetOrder | undefined = $state(data.album.order);
|
||||
|
||||
const assetInteraction = new AssetInteraction();
|
||||
@ -154,44 +145,15 @@
|
||||
|
||||
const handleFavorite = async () => {
|
||||
try {
|
||||
if (isLiked) {
|
||||
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];
|
||||
}
|
||||
await activityManager.toggleLike();
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.cant_change_asset_favorite'));
|
||||
}
|
||||
};
|
||||
|
||||
const getFavorite = async () => {
|
||||
if ($user) {
|
||||
try {
|
||||
const data = await getActivities({
|
||||
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 () => {
|
||||
const updateComments = async () => {
|
||||
try {
|
||||
const { comments } = await getActivityStatistics({ albumId: album.id });
|
||||
setNumberOfComments(comments);
|
||||
await activityManager.refreshActivities(album.id);
|
||||
} catch (error) {
|
||||
handleError(error, $t('errors.cant_get_number_of_comments'));
|
||||
}
|
||||
@ -398,7 +360,7 @@
|
||||
let albumId = $derived(album.id);
|
||||
|
||||
$effect(() => {
|
||||
if (!album.isActivityEnabled && $numberOfComments === 0) {
|
||||
if (!album.isActivityEnabled && activityManager.commentCount === 0) {
|
||||
isShowActivity = false;
|
||||
}
|
||||
});
|
||||
@ -412,7 +374,16 @@
|
||||
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();
|
||||
// $effect(() => void timelineStore.updateOptions({ isArchived: false, withPartners: true, timelineAlbumId: albumId }));
|
||||
// onDestroy(() => timelineStore.destroy());
|
||||
@ -420,7 +391,7 @@
|
||||
let isOwned = $derived($user.id == album.ownerId);
|
||||
|
||||
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(
|
||||
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));
|
||||
$effect(() => {
|
||||
if (album.albumUsers.length > 0) {
|
||||
handlePromiseError(getFavorite());
|
||||
handlePromiseError(getNumberOfComments());
|
||||
handlePromiseError(updateComments());
|
||||
}
|
||||
});
|
||||
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">
|
||||
<ActivityStatus
|
||||
disabled={!album.isActivityEnabled}
|
||||
{isLiked}
|
||||
numberOfComments={$numberOfComments}
|
||||
isLiked={activityManager.isLiked}
|
||||
numberOfComments={activityManager.commentCount}
|
||||
onFavorite={handleFavorite}
|
||||
onOpenActivityTab={handleOpenAndCloseActivityTab}
|
||||
/>
|
||||
@ -733,11 +703,6 @@
|
||||
disabled={!album.isActivityEnabled}
|
||||
albumOwnerId={album.ownerId}
|
||||
albumId={album.id}
|
||||
{isLiked}
|
||||
bind:reactions
|
||||
onAddComment={() => updateNumberOfComments(1)}
|
||||
onDeleteComment={() => updateNumberOfComments(-1)}
|
||||
onDeleteLike={() => (isLiked = null)}
|
||||
onClose={handleOpenAndCloseActivityTab}
|
||||
/>
|
||||
</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