Merge remote-tracking branch 'origin/main' into keynav_timeline

This commit is contained in:
Min Idzelis 2025-05-02 00:49:04 +00:00
commit 8cba1f4068
72 changed files with 572 additions and 3174 deletions

View File

@ -338,12 +338,15 @@ jobs:
name: End-to-End Tests (Server & CLI) name: End-to-End Tests (Server & CLI)
needs: pre-job needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_e2e_server_cli == 'true' }} if: ${{ needs.pre-job.outputs.should_run_e2e_server_cli == 'true' }}
runs-on: mich runs-on: ${{ matrix.runner }}
permissions: permissions:
contents: read contents: read
defaults: defaults:
run: run:
working-directory: ./e2e working-directory: ./e2e
strategy:
matrix:
runner: [mich, ubuntu-24.04-arm]
steps: steps:
- name: Checkout code - name: Checkout code
@ -383,12 +386,15 @@ jobs:
name: End-to-End Tests (Web) name: End-to-End Tests (Web)
needs: pre-job needs: pre-job
if: ${{ needs.pre-job.outputs.should_run_e2e_web == 'true' }} if: ${{ needs.pre-job.outputs.should_run_e2e_web == 'true' }}
runs-on: mich runs-on: ${{ matrix.runner }}
permissions: permissions:
contents: read contents: read
defaults: defaults:
run: run:
working-directory: ./e2e working-directory: ./e2e
strategy:
matrix:
runner: [mich, ubuntu-24.04-arm]
steps: steps:
- name: Checkout code - name: Checkout code
@ -423,6 +429,21 @@ jobs:
run: npx playwright test run: npx playwright test
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
success-check-e2e:
name: End-to-End Tests Success
needs: [e2e-tests-server-cli, e2e-tests-web]
permissions: {}
runs-on: ubuntu-latest
if: always()
steps:
- name: Any jobs failed?
if: ${{ contains(needs.*.result, 'failure') }}
run: exit 1
- name: All jobs passed or skipped
if: ${{ !(contains(needs.*.result, 'failure')) }}
# zizmor: ignore[template-injection]
run: echo "All jobs passed or skipped" && echo "${{ toJSON(needs.*.result) }}"
mobile-unit-tests: mobile-unit-tests:
name: Unit Test Mobile name: Unit Test Mobile
needs: pre-job needs: pre-job

View File

@ -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);
});
});
});

View File

@ -1260,6 +1260,7 @@
"no_favorites_message": "Add favorites to quickly find your best pictures and videos", "no_favorites_message": "Add favorites to quickly find your best pictures and videos",
"no_libraries_message": "Create an external library to view your photos and videos", "no_libraries_message": "Create an external library to view your photos and videos",
"no_name": "No Name", "no_name": "No Name",
"no_people_found": "No matching people found",
"no_places": "No places", "no_places": "No places",
"no_results": "No results", "no_results": "No results",
"no_results_description": "Try a synonym or more general keyword", "no_results_description": "Try a synonym or more general keyword",
@ -1572,6 +1573,7 @@
"select_keep_all": "Select keep all", "select_keep_all": "Select keep all",
"select_library_owner": "Select library owner", "select_library_owner": "Select library owner",
"select_new_face": "Select new face", "select_new_face": "Select new face",
"select_person_to_tag": "Select a person to tag",
"select_photos": "Select photos", "select_photos": "Select photos",
"select_trash_all": "Select trash all", "select_trash_all": "Select trash all",
"select_user_for_sharing_page_err_album": "Failed to create album", "select_user_for_sharing_page_err_album": "Failed to create album",

View File

@ -100,7 +100,6 @@ Class | Method | HTTP request | Description
*AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | getAllUserAssetsByDeviceId *AssetsApi* | [**getAllUserAssetsByDeviceId**](doc//AssetsApi.md#getalluserassetsbydeviceid) | **GET** /assets/device/{deviceId} | getAllUserAssetsByDeviceId
*AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} | *AssetsApi* | [**getAssetInfo**](doc//AssetsApi.md#getassetinfo) | **GET** /assets/{id} |
*AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics | *AssetsApi* | [**getAssetStatistics**](doc//AssetsApi.md#getassetstatistics) | **GET** /assets/statistics |
*AssetsApi* | [**getMemoryLane**](doc//AssetsApi.md#getmemorylane) | **GET** /assets/memory-lane |
*AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random | *AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random |
*AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback | *AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback |
*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | replaceAsset *AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | replaceAsset
@ -122,9 +121,6 @@ Class | Method | HTTP request | Description
*FacesApi* | [**deleteFace**](doc//FacesApi.md#deleteface) | **DELETE** /faces/{id} | *FacesApi* | [**deleteFace**](doc//FacesApi.md#deleteface) | **DELETE** /faces/{id} |
*FacesApi* | [**getFaces**](doc//FacesApi.md#getfaces) | **GET** /faces | *FacesApi* | [**getFaces**](doc//FacesApi.md#getfaces) | **GET** /faces |
*FacesApi* | [**reassignFacesById**](doc//FacesApi.md#reassignfacesbyid) | **PUT** /faces/{id} | *FacesApi* | [**reassignFacesById**](doc//FacesApi.md#reassignfacesbyid) | **PUT** /faces/{id} |
*FileReportsApi* | [**fixAuditFiles**](doc//FileReportsApi.md#fixauditfiles) | **POST** /reports/fix |
*FileReportsApi* | [**getAuditFiles**](doc//FileReportsApi.md#getauditfiles) | **GET** /reports |
*FileReportsApi* | [**getFileChecksums**](doc//FileReportsApi.md#getfilechecksums) | **POST** /reports/checksum |
*JobsApi* | [**createJob**](doc//JobsApi.md#createjob) | **POST** /jobs | *JobsApi* | [**createJob**](doc//JobsApi.md#createjob) | **POST** /jobs |
*JobsApi* | [**getAllJobsStatus**](doc//JobsApi.md#getalljobsstatus) | **GET** /jobs | *JobsApi* | [**getAllJobsStatus**](doc//JobsApi.md#getalljobsstatus) | **GET** /jobs |
*JobsApi* | [**sendJobCommand**](doc//JobsApi.md#sendjobcommand) | **PUT** /jobs/{id} | *JobsApi* | [**sendJobCommand**](doc//JobsApi.md#sendjobcommand) | **PUT** /jobs/{id} |
@ -332,11 +328,6 @@ Class | Method | HTTP request | Description
- [ExifResponseDto](doc//ExifResponseDto.md) - [ExifResponseDto](doc//ExifResponseDto.md)
- [FaceDto](doc//FaceDto.md) - [FaceDto](doc//FaceDto.md)
- [FacialRecognitionConfig](doc//FacialRecognitionConfig.md) - [FacialRecognitionConfig](doc//FacialRecognitionConfig.md)
- [FileChecksumDto](doc//FileChecksumDto.md)
- [FileChecksumResponseDto](doc//FileChecksumResponseDto.md)
- [FileReportDto](doc//FileReportDto.md)
- [FileReportFixDto](doc//FileReportFixDto.md)
- [FileReportItemDto](doc//FileReportItemDto.md)
- [FoldersResponse](doc//FoldersResponse.md) - [FoldersResponse](doc//FoldersResponse.md)
- [FoldersUpdate](doc//FoldersUpdate.md) - [FoldersUpdate](doc//FoldersUpdate.md)
- [ImageFormat](doc//ImageFormat.md) - [ImageFormat](doc//ImageFormat.md)
@ -361,7 +352,6 @@ Class | Method | HTTP request | Description
- [MemoriesResponse](doc//MemoriesResponse.md) - [MemoriesResponse](doc//MemoriesResponse.md)
- [MemoriesUpdate](doc//MemoriesUpdate.md) - [MemoriesUpdate](doc//MemoriesUpdate.md)
- [MemoryCreateDto](doc//MemoryCreateDto.md) - [MemoryCreateDto](doc//MemoryCreateDto.md)
- [MemoryLaneResponseDto](doc//MemoryLaneResponseDto.md)
- [MemoryResponseDto](doc//MemoryResponseDto.md) - [MemoryResponseDto](doc//MemoryResponseDto.md)
- [MemoryType](doc//MemoryType.md) - [MemoryType](doc//MemoryType.md)
- [MemoryUpdateDto](doc//MemoryUpdateDto.md) - [MemoryUpdateDto](doc//MemoryUpdateDto.md)
@ -381,8 +371,6 @@ Class | Method | HTTP request | Description
- [OnThisDayDto](doc//OnThisDayDto.md) - [OnThisDayDto](doc//OnThisDayDto.md)
- [PartnerDirection](doc//PartnerDirection.md) - [PartnerDirection](doc//PartnerDirection.md)
- [PartnerResponseDto](doc//PartnerResponseDto.md) - [PartnerResponseDto](doc//PartnerResponseDto.md)
- [PathEntityType](doc//PathEntityType.md)
- [PathType](doc//PathType.md)
- [PeopleResponse](doc//PeopleResponse.md) - [PeopleResponse](doc//PeopleResponse.md)
- [PeopleResponseDto](doc//PeopleResponseDto.md) - [PeopleResponseDto](doc//PeopleResponseDto.md)
- [PeopleUpdate](doc//PeopleUpdate.md) - [PeopleUpdate](doc//PeopleUpdate.md)

View File

@ -39,7 +39,6 @@ part 'api/deprecated_api.dart';
part 'api/download_api.dart'; part 'api/download_api.dart';
part 'api/duplicates_api.dart'; part 'api/duplicates_api.dart';
part 'api/faces_api.dart'; part 'api/faces_api.dart';
part 'api/file_reports_api.dart';
part 'api/jobs_api.dart'; part 'api/jobs_api.dart';
part 'api/libraries_api.dart'; part 'api/libraries_api.dart';
part 'api/map_api.dart'; part 'api/map_api.dart';
@ -133,11 +132,6 @@ part 'model/email_notifications_update.dart';
part 'model/exif_response_dto.dart'; part 'model/exif_response_dto.dart';
part 'model/face_dto.dart'; part 'model/face_dto.dart';
part 'model/facial_recognition_config.dart'; part 'model/facial_recognition_config.dart';
part 'model/file_checksum_dto.dart';
part 'model/file_checksum_response_dto.dart';
part 'model/file_report_dto.dart';
part 'model/file_report_fix_dto.dart';
part 'model/file_report_item_dto.dart';
part 'model/folders_response.dart'; part 'model/folders_response.dart';
part 'model/folders_update.dart'; part 'model/folders_update.dart';
part 'model/image_format.dart'; part 'model/image_format.dart';
@ -162,7 +156,6 @@ part 'model/map_reverse_geocode_response_dto.dart';
part 'model/memories_response.dart'; part 'model/memories_response.dart';
part 'model/memories_update.dart'; part 'model/memories_update.dart';
part 'model/memory_create_dto.dart'; part 'model/memory_create_dto.dart';
part 'model/memory_lane_response_dto.dart';
part 'model/memory_response_dto.dart'; part 'model/memory_response_dto.dart';
part 'model/memory_type.dart'; part 'model/memory_type.dart';
part 'model/memory_update_dto.dart'; part 'model/memory_update_dto.dart';
@ -182,8 +175,6 @@ part 'model/o_auth_token_endpoint_auth_method.dart';
part 'model/on_this_day_dto.dart'; part 'model/on_this_day_dto.dart';
part 'model/partner_direction.dart'; part 'model/partner_direction.dart';
part 'model/partner_response_dto.dart'; part 'model/partner_response_dto.dart';
part 'model/path_entity_type.dart';
part 'model/path_type.dart';
part 'model/people_response.dart'; part 'model/people_response.dart';
part 'model/people_response_dto.dart'; part 'model/people_response_dto.dart';
part 'model/people_update.dart'; part 'model/people_update.dart';

View File

@ -404,63 +404,6 @@ class AssetsApi {
return null; return null;
} }
/// Performs an HTTP 'GET /assets/memory-lane' operation and returns the [Response].
/// Parameters:
///
/// * [int] day (required):
///
/// * [int] month (required):
Future<Response> getMemoryLaneWithHttpInfo(int day, int month,) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/memory-lane';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
queryParams.addAll(_queryParams('', 'day', day));
queryParams.addAll(_queryParams('', 'month', month));
const contentTypes = <String>[];
return apiClient.invokeAPI(
apiPath,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [int] day (required):
///
/// * [int] month (required):
Future<List<MemoryLaneResponseDto>?> getMemoryLane(int day, int month,) async {
final response = await getMemoryLaneWithHttpInfo(day, month,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<MemoryLaneResponseDto>') as List)
.cast<MemoryLaneResponseDto>()
.toList(growable: false);
}
return null;
}
/// This property was deprecated in v1.116.0 /// This property was deprecated in v1.116.0
/// ///
/// Note: This method returns the HTTP [Response]. /// Note: This method returns the HTTP [Response].

View File

@ -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;
}
}

View File

@ -320,16 +320,6 @@ class ApiClient {
return FaceDto.fromJson(value); return FaceDto.fromJson(value);
case 'FacialRecognitionConfig': case 'FacialRecognitionConfig':
return FacialRecognitionConfig.fromJson(value); return FacialRecognitionConfig.fromJson(value);
case 'FileChecksumDto':
return FileChecksumDto.fromJson(value);
case 'FileChecksumResponseDto':
return FileChecksumResponseDto.fromJson(value);
case 'FileReportDto':
return FileReportDto.fromJson(value);
case 'FileReportFixDto':
return FileReportFixDto.fromJson(value);
case 'FileReportItemDto':
return FileReportItemDto.fromJson(value);
case 'FoldersResponse': case 'FoldersResponse':
return FoldersResponse.fromJson(value); return FoldersResponse.fromJson(value);
case 'FoldersUpdate': case 'FoldersUpdate':
@ -378,8 +368,6 @@ class ApiClient {
return MemoriesUpdate.fromJson(value); return MemoriesUpdate.fromJson(value);
case 'MemoryCreateDto': case 'MemoryCreateDto':
return MemoryCreateDto.fromJson(value); return MemoryCreateDto.fromJson(value);
case 'MemoryLaneResponseDto':
return MemoryLaneResponseDto.fromJson(value);
case 'MemoryResponseDto': case 'MemoryResponseDto':
return MemoryResponseDto.fromJson(value); return MemoryResponseDto.fromJson(value);
case 'MemoryType': case 'MemoryType':
@ -418,10 +406,6 @@ class ApiClient {
return PartnerDirectionTypeTransformer().decode(value); return PartnerDirectionTypeTransformer().decode(value);
case 'PartnerResponseDto': case 'PartnerResponseDto':
return PartnerResponseDto.fromJson(value); return PartnerResponseDto.fromJson(value);
case 'PathEntityType':
return PathEntityTypeTypeTransformer().decode(value);
case 'PathType':
return PathTypeTypeTransformer().decode(value);
case 'PeopleResponse': case 'PeopleResponse':
return PeopleResponse.fromJson(value); return PeopleResponse.fromJson(value);
case 'PeopleResponseDto': case 'PeopleResponseDto':

View File

@ -112,12 +112,6 @@ String parameterToString(dynamic value) {
if (value is PartnerDirection) { if (value is PartnerDirection) {
return PartnerDirectionTypeTransformer().encode(value).toString(); return PartnerDirectionTypeTransformer().encode(value).toString();
} }
if (value is PathEntityType) {
return PathEntityTypeTypeTransformer().encode(value).toString();
}
if (value is PathType) {
return PathTypeTypeTransformer().encode(value).toString();
}
if (value is Permission) { if (value is Permission) {
return PermissionTypeTransformer().encode(value).toString(); return PermissionTypeTransformer().encode(value).toString();
} }

View File

@ -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',
};
}

View File

@ -1,107 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class 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',
};
}

View File

@ -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',
};
}

View File

@ -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',
};
}

View File

@ -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',
};
}

View File

@ -1,107 +0,0 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class 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',
};
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -1726,62 +1726,6 @@
] ]
} }
}, },
"/assets/memory-lane": {
"get": {
"operationId": "getMemoryLane",
"parameters": [
{
"name": "day",
"required": true,
"in": "query",
"schema": {
"minimum": 1,
"maximum": 31,
"type": "integer"
}
},
{
"name": "month",
"required": true,
"in": "query",
"schema": {
"minimum": 1,
"maximum": 12,
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/MemoryLaneResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"Assets"
]
}
},
"/assets/random": { "/assets/random": {
"get": { "get": {
"deprecated": true, "deprecated": true,
@ -4651,118 +4595,6 @@
] ]
} }
}, },
"/reports": {
"get": {
"operationId": "getAuditFiles",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FileReportDto"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"File Reports"
]
}
},
"/reports/checksum": {
"post": {
"operationId": "getFileChecksums",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FileChecksumDto"
}
}
},
"required": true
},
"responses": {
"201": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/FileChecksumResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"File Reports"
]
}
},
"/reports/fix": {
"post": {
"operationId": "fixAuditFiles",
"parameters": [],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/FileReportFixDto"
}
}
},
"required": true
},
"responses": {
"201": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"tags": [
"File Reports"
]
}
},
"/search/cities": { "/search/cities": {
"get": { "get": {
"operationId": "getAssetsByCity", "operationId": "getAssetsByCity",
@ -9749,105 +9581,6 @@
], ],
"type": "object" "type": "object"
}, },
"FileChecksumDto": {
"properties": {
"filenames": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"filenames"
],
"type": "object"
},
"FileChecksumResponseDto": {
"properties": {
"checksum": {
"type": "string"
},
"filename": {
"type": "string"
}
},
"required": [
"checksum",
"filename"
],
"type": "object"
},
"FileReportDto": {
"properties": {
"extras": {
"items": {
"type": "string"
},
"type": "array"
},
"orphans": {
"items": {
"$ref": "#/components/schemas/FileReportItemDto"
},
"type": "array"
}
},
"required": [
"extras",
"orphans"
],
"type": "object"
},
"FileReportFixDto": {
"properties": {
"items": {
"items": {
"$ref": "#/components/schemas/FileReportItemDto"
},
"type": "array"
}
},
"required": [
"items"
],
"type": "object"
},
"FileReportItemDto": {
"properties": {
"checksum": {
"type": "string"
},
"entityId": {
"format": "uuid",
"type": "string"
},
"entityType": {
"allOf": [
{
"$ref": "#/components/schemas/PathEntityType"
}
]
},
"pathType": {
"allOf": [
{
"$ref": "#/components/schemas/PathType"
}
]
},
"pathValue": {
"type": "string"
}
},
"required": [
"entityId",
"entityType",
"pathType",
"pathValue"
],
"type": "object"
},
"FoldersResponse": { "FoldersResponse": {
"properties": { "properties": {
"enabled": { "enabled": {
@ -10328,24 +10061,6 @@
], ],
"type": "object" "type": "object"
}, },
"MemoryLaneResponseDto": {
"properties": {
"assets": {
"items": {
"$ref": "#/components/schemas/AssetResponseDto"
},
"type": "array"
},
"yearsAgo": {
"type": "integer"
}
},
"required": [
"assets",
"yearsAgo"
],
"type": "object"
},
"MemoryResponseDto": { "MemoryResponseDto": {
"properties": { "properties": {
"assets": { "assets": {
@ -10889,27 +10604,6 @@
], ],
"type": "object" "type": "object"
}, },
"PathEntityType": {
"enum": [
"asset",
"person",
"user"
],
"type": "string"
},
"PathType": {
"enum": [
"original",
"fullsize",
"preview",
"thumbnail",
"encoded_video",
"sidecar",
"face",
"profile"
],
"type": "string"
},
"PeopleResponse": { "PeopleResponse": {
"properties": { "properties": {
"enabled": { "enabled": {

View File

@ -462,10 +462,6 @@ export type AssetJobsDto = {
assetIds: string[]; assetIds: string[];
name: AssetJobName; name: AssetJobName;
}; };
export type MemoryLaneResponseDto = {
assets: AssetResponseDto[];
yearsAgo: number;
};
export type AssetStatsResponseDto = { export type AssetStatsResponseDto = {
images: number; images: number;
total: number; total: number;
@ -800,27 +796,6 @@ export type AssetFaceUpdateDto = {
export type PersonStatisticsResponseDto = { export type PersonStatisticsResponseDto = {
assets: number; assets: number;
}; };
export type FileReportItemDto = {
checksum?: string;
entityId: string;
entityType: PathEntityType;
pathType: PathType;
pathValue: string;
};
export type FileReportDto = {
extras: string[];
orphans: FileReportItemDto[];
};
export type FileChecksumDto = {
filenames: string[];
};
export type FileChecksumResponseDto = {
checksum: string;
filename: string;
};
export type FileReportFixDto = {
items: FileReportItemDto[];
};
export type SearchExploreItem = { export type SearchExploreItem = {
data: AssetResponseDto; data: AssetResponseDto;
value: string; value: string;
@ -1887,20 +1862,6 @@ export function runAssetJobs({ assetJobsDto }: {
body: assetJobsDto body: assetJobsDto
}))); })));
} }
export function getMemoryLane({ day, month }: {
day: number;
month: number;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: MemoryLaneResponseDto[];
}>(`/assets/memory-lane${QS.query(QS.explode({
day,
month
}))}`, {
...opts
}));
}
/** /**
* This property was deprecated in v1.116.0 * This property was deprecated in v1.116.0
*/ */
@ -2663,35 +2624,6 @@ export function getPersonThumbnail({ id }: {
...opts ...opts
})); }));
} }
export function getAuditFiles(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: FileReportDto;
}>("/reports", {
...opts
}));
}
export function getFileChecksums({ fileChecksumDto }: {
fileChecksumDto: FileChecksumDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 201;
data: FileChecksumResponseDto[];
}>("/reports/checksum", oazapfts.json({
...opts,
method: "POST",
body: fileChecksumDto
})));
}
export function fixAuditFiles({ fileReportFixDto }: {
fileReportFixDto: FileReportFixDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/reports/fix", oazapfts.json({
...opts,
method: "POST",
body: fileReportFixDto
})));
}
export function getAssetsByCity(opts?: Oazapfts.RequestOpts) { export function getAssetsByCity(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{ return oazapfts.ok(oazapfts.fetchJson<{
status: 200; status: 200;
@ -3751,21 +3683,6 @@ export enum PartnerDirection {
SharedBy = "shared-by", SharedBy = "shared-by",
SharedWith = "shared-with" SharedWith = "shared-with"
} }
export enum PathEntityType {
Asset = "asset",
Person = "person",
User = "user"
}
export enum PathType {
Original = "original",
Fullsize = "fullsize",
Preview = "preview",
Thumbnail = "thumbnail",
EncodedVideo = "encoded_video",
Sidecar = "sidecar",
Face = "face",
Profile = "profile"
}
export enum SearchSuggestionType { export enum SearchSuggestionType {
Country = "country", Country = "country",
State = "state", State = "state",

View File

@ -28,7 +28,7 @@
"archiver": "^7.0.0", "archiver": "^7.0.0",
"async-lock": "^1.4.0", "async-lock": "^1.4.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"bullmq": "^4.8.0", "bullmq": "^5.51.0",
"chokidar": "^3.5.3", "chokidar": "^3.5.3",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
@ -6886,63 +6886,20 @@
} }
}, },
"node_modules/bullmq": { "node_modules/bullmq": {
"version": "4.18.2", "version": "5.51.0",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-4.18.2.tgz", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.51.0.tgz",
"integrity": "sha512-Cx0O98IlGiFw7UBa+zwGz+nH0Pcl1wfTvMVBlsMna3s0219hXroVovh1xPRgomyUcbyciHiugGCkW0RRNZDHYQ==", "integrity": "sha512-YjX+CO2U4nmbCq2ZgNb/Hnu6Xk953j8EFmp0eehTuudavPyNstoZsbnyvvM6PX9rfD9clhcc5kRLyyWoFEM3Lg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cron-parser": "^4.6.0", "cron-parser": "^4.9.0",
"glob": "^8.0.3", "ioredis": "^5.4.1",
"ioredis": "^5.3.2", "msgpackr": "^1.11.2",
"lodash": "^4.17.21",
"msgpackr": "^1.6.2",
"node-abort-controller": "^3.1.1", "node-abort-controller": "^3.1.1",
"semver": "^7.5.4", "semver": "^7.5.4",
"tslib": "^2.0.0", "tslib": "^2.0.0",
"uuid": "^9.0.0" "uuid": "^9.0.0"
} }
}, },
"node_modules/bullmq/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/bullmq/node_modules/glob": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^5.0.1",
"once": "^1.3.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/bullmq/node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=10"
}
},
"node_modules/busboy": { "node_modules/busboy": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",

View File

@ -53,7 +53,7 @@
"archiver": "^7.0.0", "archiver": "^7.0.0",
"async-lock": "^1.4.0", "async-lock": "^1.4.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"bullmq": "^4.8.0", "bullmq": "^5.51.0",
"chokidar": "^3.5.3", "chokidar": "^3.5.3",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.0", "class-validator": "^0.14.0",

View File

@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common'; import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { EndpointLifecycle } from 'src/decorators'; import { EndpointLifecycle } from 'src/decorators';
import { AssetResponseDto, MemoryLaneResponseDto } from 'src/dtos/asset-response.dto'; import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { import {
AssetBulkDeleteDto, AssetBulkDeleteDto,
AssetBulkUpdateDto, AssetBulkUpdateDto,
@ -13,7 +13,6 @@ import {
UpdateAssetDto, UpdateAssetDto,
} from 'src/dtos/asset.dto'; } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { MemoryLaneDto } from 'src/dtos/search.dto';
import { RouteKey } from 'src/enum'; import { RouteKey } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { AssetService } from 'src/services/asset.service'; import { AssetService } from 'src/services/asset.service';
@ -24,12 +23,6 @@ import { UUIDParamDto } from 'src/validation';
export class AssetController { export class AssetController {
constructor(private service: AssetService) {} constructor(private service: AssetService) {}
@Get('memory-lane')
@Authenticated()
getMemoryLane(@Auth() auth: AuthDto, @Query() dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> {
return this.service.getMemoryLane(auth, dto);
}
@Get('random') @Get('random')
@Authenticated() @Authenticated()
@EndpointLifecycle({ deprecatedAt: 'v1.116.0' }) @EndpointLifecycle({ deprecatedAt: 'v1.116.0' })

View File

@ -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);
}
}

View File

@ -8,7 +8,6 @@ import { AuthController } from 'src/controllers/auth.controller';
import { DownloadController } from 'src/controllers/download.controller'; import { DownloadController } from 'src/controllers/download.controller';
import { DuplicateController } from 'src/controllers/duplicate.controller'; import { DuplicateController } from 'src/controllers/duplicate.controller';
import { FaceController } from 'src/controllers/face.controller'; import { FaceController } from 'src/controllers/face.controller';
import { ReportController } from 'src/controllers/file-report.controller';
import { JobController } from 'src/controllers/job.controller'; import { JobController } from 'src/controllers/job.controller';
import { LibraryController } from 'src/controllers/library.controller'; import { LibraryController } from 'src/controllers/library.controller';
import { MapController } from 'src/controllers/map.controller'; import { MapController } from 'src/controllers/map.controller';
@ -53,7 +52,6 @@ export const controllers = [
OAuthController, OAuthController,
PartnerController, PartnerController,
PersonController, PersonController,
ReportController,
SearchController, SearchController,
ServerController, ServerController,
SessionController, SessionController,

View File

@ -46,7 +46,7 @@ export class SearchController {
@Get('explore') @Get('explore')
@Authenticated() @Authenticated()
getExploreData(@Auth() auth: AuthDto): Promise<SearchExploreResponseDto[]> { getExploreData(@Auth() auth: AuthDto): Promise<SearchExploreResponseDto[]> {
return this.service.getExploreData(auth) as Promise<SearchExploreResponseDto[]>; return this.service.getExploreData(auth);
} }
@Get('person') @Get('person')

View File

@ -199,10 +199,3 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
resized: true, resized: true,
}; };
} }
export class MemoryLaneResponseDto {
@ApiProperty({ type: 'integer' })
yearsAgo!: number;
assets!: AssetResponseDto[];
}

View File

@ -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;
}

View File

@ -194,15 +194,16 @@ where
"asset_files"."assetId" = $1 "asset_files"."assetId" = $1
and "asset_files"."type" = $2 and "asset_files"."type" = $2
-- AssetJobRepository.streamForEncodeClip -- AssetJobRepository.streamForSearchDuplicates
select select
"assets"."id" "assets"."id"
from from
"assets" "assets"
inner join "asset_job_status" as "job_status" on "assetId" = "assets"."id" inner join "asset_job_status" as "job_status" on "assetId" = "assets"."id"
where where
"job_status"."previewAt" is not null "assets"."isVisible" = $1
and "assets"."isVisible" = $1 and "assets"."deletedAt" is null
and "job_status"."previewAt" is not null
and not exists ( and not exists (
select select
from from
@ -210,7 +211,25 @@ where
where where
"assetId" = "assets"."id" "assetId" = "assets"."id"
) )
and "job_status"."duplicatesDetectedAt" is null
-- AssetJobRepository.streamForEncodeClip
select
"assets"."id"
from
"assets"
inner join "asset_job_status" as "job_status" on "assetId" = "assets"."id"
where
"assets"."isVisible" = $1
and "assets"."deletedAt" is null and "assets"."deletedAt" is null
and "job_status"."previewAt" is not null
and not exists (
select
from
"smart_search"
where
"assetId" = "assets"."id"
)
-- AssetJobRepository.getForClipEncoding -- AssetJobRepository.getForClipEncoding
select select
@ -450,3 +469,37 @@ from
"assets" "assets"
where where
"assets"."deletedAt" <= $1 "assets"."deletedAt" <= $1
-- AssetJobRepository.streamForSidecar
select
"assets"."id"
from
"assets"
where
(
"assets"."sidecarPath" = $1
or "assets"."sidecarPath" is null
)
and "assets"."isVisible" = $2
-- AssetJobRepository.streamForDetectFacesJob
select
"assets"."id"
from
"assets"
inner join "asset_job_status" as "job_status" on "assetId" = "assets"."id"
where
"assets"."isVisible" = $1
and "assets"."deletedAt" is null
and "job_status"."previewAt" is not null
and "job_status"."facesRecognizedAt" is null
order by
"assets"."createdAt" desc
-- AssetJobRepository.streamForMigrationJob
select
"id"
from
"assets"
where
"assets"."deletedAt" is null

View File

@ -232,25 +232,6 @@ where
limit limit
$3 $3
-- AssetRepository.getWithout (sidecar)
select
"assets".*
from
"assets"
where
(
"assets"."sidecarPath" = $1
or "assets"."sidecarPath" is null
)
and "assets"."isVisible" = $2
and "deletedAt" is null
order by
"createdAt"
limit
$3
offset
$4
-- AssetRepository.getTimeBuckets -- AssetRepository.getTimeBuckets
with with
"assets" as ( "assets" as (

View File

@ -135,20 +135,33 @@ export class AssetJobRepository {
.execute(); .execute();
} }
@GenerateSql({ params: [], stream: true }) private assetsWithPreviews() {
streamForEncodeClip(force?: boolean) {
return this.db return this.db
.selectFrom('assets') .selectFrom('assets')
.select(['assets.id'])
.innerJoin('asset_job_status as job_status', 'assetId', 'assets.id')
.where('job_status.previewAt', 'is not', null)
.where('assets.isVisible', '=', true) .where('assets.isVisible', '=', true)
.where('assets.deletedAt', 'is', null)
.innerJoin('asset_job_status as job_status', 'assetId', 'assets.id')
.where('job_status.previewAt', 'is not', null);
}
@GenerateSql({ params: [], stream: true })
streamForSearchDuplicates(force?: boolean) {
return this.assetsWithPreviews()
.where((eb) => eb.not((eb) => eb.exists(eb.selectFrom('smart_search').whereRef('assetId', '=', 'assets.id'))))
.$if(!force, (qb) => qb.where('job_status.duplicatesDetectedAt', 'is', null))
.select(['assets.id'])
.stream();
}
@GenerateSql({ params: [], stream: true })
streamForEncodeClip(force?: boolean) {
return this.assetsWithPreviews()
.select(['assets.id'])
.$if(!force, (qb) => .$if(!force, (qb) =>
qb.where((eb) => qb.where((eb) =>
eb.not((eb) => eb.exists(eb.selectFrom('smart_search').whereRef('assetId', '=', 'assets.id'))), eb.not((eb) => eb.exists(eb.selectFrom('smart_search').whereRef('assetId', '=', 'assets.id'))),
), ),
) )
.where('assets.deletedAt', 'is', null)
.stream(); .stream();
} }
@ -309,4 +322,30 @@ export class AssetJobRepository {
.where('assets.deletedAt', '<=', trashedBefore) .where('assets.deletedAt', '<=', trashedBefore)
.stream(); .stream();
} }
@GenerateSql({ params: [], stream: true })
streamForSidecar(force?: boolean) {
return this.db
.selectFrom('assets')
.select(['assets.id'])
.$if(!force, (qb) =>
qb.where((eb) => eb.or([eb('assets.sidecarPath', '=', ''), eb('assets.sidecarPath', 'is', null)])),
)
.where('assets.isVisible', '=', true)
.stream();
}
@GenerateSql({ params: [], stream: true })
streamForDetectFacesJob(force?: boolean) {
return this.assetsWithPreviews()
.$if(!force, (qb) => qb.where('job_status.facesRecognizedAt', 'is', null))
.select(['assets.id'])
.orderBy('assets.createdAt', 'desc')
.stream();
}
@GenerateSql({ params: [DummyValue.DATE], stream: true })
streamForMigrationJob() {
return this.db.selectFrom('assets').select(['id']).where('assets.deletedAt', 'is', null).stream();
}
} }

View File

@ -7,13 +7,11 @@ import { AssetFiles, AssetJobStatus, Assets, DB, Exif } from 'src/db';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { MapAsset } from 'src/dtos/asset-response.dto'; import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum'; import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum';
import { AssetSearchOptions, SearchExploreItem, SearchExploreItemSet } from 'src/repositories/search.repository';
import { import {
anyUuid, anyUuid,
asUuid, asUuid,
hasPeople, hasPeople,
removeUndefinedKeys, removeUndefinedKeys,
searchAssetBuilder,
truncatedDate, truncatedDate,
unnest, unnest,
withExif, withExif,
@ -27,7 +25,6 @@ import {
withTags, withTags,
} from 'src/utils/database'; } from 'src/utils/database';
import { globToSqlPattern } from 'src/utils/misc'; import { globToSqlPattern } from 'src/utils/misc';
import { PaginationOptions, paginationHelper } from 'src/utils/pagination';
export type AssetStats = Record<AssetType, number>; export type AssetStats = Record<AssetType, number>;
@ -45,15 +42,6 @@ export interface LivePhotoSearchOptions {
type: AssetType; type: AssetType;
} }
export enum WithoutProperty {
THUMBNAIL = 'thumbnail',
ENCODED_VIDEO = 'encoded-video',
EXIF = 'exif',
DUPLICATE = 'duplicate',
FACES = 'faces',
SIDECAR = 'sidecar',
}
export enum WithProperty { export enum WithProperty {
SIDECAR = 'sidecar', SIDECAR = 'sidecar',
} }
@ -335,10 +323,6 @@ export class AssetRepository {
return assets.map((asset) => asset.deviceAssetId); return assets.map((asset) => asset.deviceAssetId);
} }
getByUserId(pagination: PaginationOptions, userId: string, options: Omit<AssetSearchOptions, 'userIds'> = {}) {
return this.getAll(pagination, { ...options, userIds: [userId] });
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING] })
getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string) { getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string) {
return this.db return this.db
@ -350,16 +334,6 @@ export class AssetRepository {
.executeTakeFirst(); .executeTakeFirst();
} }
async getAll(pagination: PaginationOptions, { orderDirection, ...options }: AssetSearchOptions = {}) {
const builder = searchAssetBuilder(this.db, options)
.select(withFiles)
.orderBy('assets.createdAt', orderDirection ?? 'asc')
.limit(pagination.take + 1)
.offset(pagination.skip ?? 0);
const items = await builder.execute();
return paginationHelper(items, pagination.take);
}
/** /**
* Get assets by device's Id on the database * Get assets by device's Id on the database
* @param ownerId * @param ownerId
@ -529,68 +503,6 @@ export class AssetRepository {
.executeTakeFirst(); .executeTakeFirst();
} }
@GenerateSql(
...Object.values(WithProperty).map((property) => ({
name: property,
params: [DummyValue.PAGINATION, property],
})),
)
async getWithout(pagination: PaginationOptions, property: WithoutProperty) {
const items = await this.db
.selectFrom('assets')
.selectAll('assets')
.$if(property === WithoutProperty.DUPLICATE, (qb) =>
qb
.innerJoin('asset_job_status as job_status', 'assets.id', 'job_status.assetId')
.where('job_status.duplicatesDetectedAt', 'is', null)
.where('job_status.previewAt', 'is not', null)
.where((eb) => eb.exists(eb.selectFrom('smart_search').where('assetId', '=', eb.ref('assets.id'))))
.where('assets.isVisible', '=', true),
)
.$if(property === WithoutProperty.ENCODED_VIDEO, (qb) =>
qb
.where('assets.type', '=', AssetType.VIDEO)
.where((eb) => eb.or([eb('assets.encodedVideoPath', 'is', null), eb('assets.encodedVideoPath', '=', '')])),
)
.$if(property === WithoutProperty.EXIF, (qb) =>
qb
.leftJoin('asset_job_status as job_status', 'assets.id', 'job_status.assetId')
.where((eb) => eb.or([eb('job_status.metadataExtractedAt', 'is', null), eb('assetId', 'is', null)]))
.where('assets.isVisible', '=', true),
)
.$if(property === WithoutProperty.FACES, (qb) =>
qb
.innerJoin('asset_job_status as job_status', 'assetId', 'assets.id')
.where('job_status.previewAt', 'is not', null)
.where('job_status.facesRecognizedAt', 'is', null)
.where('assets.isVisible', '=', true),
)
.$if(property === WithoutProperty.SIDECAR, (qb) =>
qb
.where((eb) => eb.or([eb('assets.sidecarPath', '=', ''), eb('assets.sidecarPath', 'is', null)]))
.where('assets.isVisible', '=', true),
)
.$if(property === WithoutProperty.THUMBNAIL, (qb) =>
qb
.innerJoin('asset_job_status as job_status', 'assetId', 'assets.id')
.where('assets.isVisible', '=', true)
.where((eb) =>
eb.or([
eb('job_status.previewAt', 'is', null),
eb('job_status.thumbnailAt', 'is', null),
eb('assets.thumbhash', 'is', null),
]),
),
)
.where('deletedAt', 'is', null)
.limit(pagination.take + 1)
.offset(pagination.skip ?? 0)
.orderBy('createdAt')
.execute();
return paginationHelper(items, pagination.take);
}
getStatistics(ownerId: string, { isArchived, isFavorite, isTrashed }: AssetStatsOptions): Promise<AssetStats> { getStatistics(ownerId: string, { isArchived, isFavorite, isTrashed }: AssetStatsOptions): Promise<AssetStats> {
return this.db return this.db
.selectFrom('assets') .selectFrom('assets')
@ -774,10 +686,7 @@ export class AssetRepository {
} }
@GenerateSql({ params: [DummyValue.UUID, { minAssetsPerField: 5, maxFields: 12 }] }) @GenerateSql({ params: [DummyValue.UUID, { minAssetsPerField: 5, maxFields: 12 }] })
async getAssetIdByCity( async getAssetIdByCity(ownerId: string, { minAssetsPerField, maxFields }: AssetExploreFieldOptions) {
ownerId: string,
{ minAssetsPerField, maxFields }: AssetExploreFieldOptions,
): Promise<SearchExploreItem<string>> {
const items = await this.db const items = await this.db
.with('cities', (qb) => .with('cities', (qb) =>
qb qb
@ -792,6 +701,7 @@ export class AssetRepository {
.innerJoin('cities', 'exif.city', 'cities.city') .innerJoin('cities', 'exif.city', 'cities.city')
.distinctOn('exif.city') .distinctOn('exif.city')
.select(['assetId as data', 'exif.city as value']) .select(['assetId as data', 'exif.city as value'])
.$narrowType<{ value: NotNull }>()
.where('ownerId', '=', asUuid(ownerId)) .where('ownerId', '=', asUuid(ownerId))
.where('isVisible', '=', true) .where('isVisible', '=', true)
.where('isArchived', '=', false) .where('isArchived', '=', false)
@ -800,7 +710,7 @@ export class AssetRepository {
.limit(maxFields) .limit(maxFields)
.execute(); .execute();
return { fieldName: 'exifInfo.city', items: items as SearchExploreItemSet<string> }; return { fieldName: 'exifInfo.city', items };
} }
@GenerateSql({ @GenerateSql({

View File

@ -19,7 +19,7 @@ import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.d
import { ImmichWorker, MetadataKey, QueueName } from 'src/enum'; import { ImmichWorker, MetadataKey, QueueName } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { JobItem } from 'src/types'; import { JobItem, JobSource } from 'src/types';
import { handlePromiseError } from 'src/utils/misc'; import { handlePromiseError } from 'src/utils/misc';
type EmitHandlers = Partial<{ [T in EmitEvent]: Array<EventItem<T>> }>; type EmitHandlers = Partial<{ [T in EmitEvent]: Array<EventItem<T>> }>;
@ -48,7 +48,7 @@ type EventMap = {
'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; 'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }];
// album events // album events
'album.update': [{ id: string; recipientIds: string[] }]; 'album.update': [{ id: string; recipientId: string }];
'album.invite': [{ id: string; userId: string }]; 'album.invite': [{ id: string; userId: string }];
// asset events // asset events
@ -58,6 +58,7 @@ type EventMap = {
'asset.show': [{ assetId: string; userId: string }]; 'asset.show': [{ assetId: string; userId: string }];
'asset.trash': [{ assetId: string; userId: string }]; 'asset.trash': [{ assetId: string; userId: string }];
'asset.delete': [{ assetId: string; userId: string }]; 'asset.delete': [{ assetId: string; userId: string }];
'asset.metadataExtracted': [{ assetId: string; userId: string; source?: JobSource }];
// asset bulk events // asset bulk events
'assets.trash': [{ assetIds: string[]; userId: string }]; 'assets.trash': [{ assetIds: string[]; userId: string }];

View File

@ -9,7 +9,7 @@ import { JobName, JobStatus, MetadataKey, QueueCleanType, QueueName } from 'src/
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { EventRepository } from 'src/repositories/event.repository'; import { EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository'; import { LoggingRepository } from 'src/repositories/logging.repository';
import { IEntityJob, JobCounts, JobItem, JobOf, QueueStatus } from 'src/types'; import { JobCounts, JobItem, JobOf, QueueStatus } from 'src/types';
import { getKeyByValue, getMethodNames, ImmichStartupError } from 'src/utils/misc'; import { getKeyByValue, getMethodNames, ImmichStartupError } from 'src/utils/misc';
type JobMapItem = { type JobMapItem = {
@ -206,7 +206,10 @@ export class JobRepository {
private getJobOptions(item: JobItem): JobsOptions | null { private getJobOptions(item: JobItem): JobsOptions | null {
switch (item.name) { switch (item.name) {
case JobName.NOTIFY_ALBUM_UPDATE: { case JobName.NOTIFY_ALBUM_UPDATE: {
return { jobId: item.data.id, delay: item.data?.delay }; return {
jobId: `${item.data.id}/${item.data.recipientId}`,
delay: item.data?.delay,
};
} }
case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: { case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: {
return { jobId: item.data.id }; return { jobId: item.data.id };
@ -227,19 +230,12 @@ export class JobRepository {
return this.moduleRef.get<Queue>(getQueueToken(queue), { strict: false }); return this.moduleRef.get<Queue>(getQueueToken(queue), { strict: false });
} }
public async removeJob(jobId: string, name: JobName): Promise<IEntityJob | undefined> { /** @deprecated */
const existingJob = await this.getQueue(this.getQueueName(name)).getJob(jobId); // todo: remove this when asset notifications no longer need it.
if (!existingJob) { public async removeJob(name: JobName, jobID: string): Promise<void> {
return; const existingJob = await this.getQueue(this.getQueueName(name)).getJob(jobID);
} if (existingJob) {
try {
await existingJob.remove(); await existingJob.remove();
} catch (error: any) { }
if (error.message?.includes('Missing key for job')) {
return;
}
throw error;
}
return existingJob.data;
} }
} }

View File

@ -6,7 +6,7 @@ import { AssetFaces, DB, FaceSearch, Person } from 'src/db';
import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
import { AssetFileType, SourceType } from 'src/enum'; import { AssetFileType, SourceType } from 'src/enum';
import { removeUndefinedKeys } from 'src/utils/database'; import { removeUndefinedKeys } from 'src/utils/database';
import { PaginationOptions } from 'src/utils/pagination'; import { paginationHelper, PaginationOptions } from 'src/utils/pagination';
export interface PersonSearchOptions { export interface PersonSearchOptions {
minimumFaceCount: number; minimumFaceCount: number;
@ -200,11 +200,7 @@ export class PersonRepository {
.limit(pagination.take + 1) .limit(pagination.take + 1)
.execute(); .execute();
if (items.length > pagination.take) { return paginationHelper(items, pagination.take);
return { items: items.slice(0, -1), hasNextPage: true };
}
return { items, hasNextPage: false };
} }
@GenerateSql() @GenerateSql()

View File

@ -8,41 +8,10 @@ import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetStatus, AssetType } from 'src/enum'; import { AssetStatus, AssetType } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { anyUuid, asUuid, searchAssetBuilder, vectorIndexQuery } from 'src/utils/database'; import { anyUuid, asUuid, searchAssetBuilder, vectorIndexQuery } from 'src/utils/database';
import { paginationHelper } from 'src/utils/pagination';
import { isValidInteger } from 'src/validation'; import { isValidInteger } from 'src/validation';
export interface SearchResult<T> { export interface SearchAssetIdOptions {
/** total matches */
total: number;
/** collection size */
count: number;
/** current page */
page: number;
/** items for page */
items: T[];
/** score */
distances: number[];
facets: SearchFacet[];
}
export interface SearchFacet {
fieldName: string;
counts: Array<{
count: number;
value: string;
}>;
}
export type SearchExploreItemSet<T> = Array<{
value: string;
data: T;
}>;
export interface SearchExploreItem<T> {
fieldName: string;
items: SearchExploreItemSet<T>;
}
export interface SearchAssetIDOptions {
checksum?: Buffer; checksum?: Buffer;
deviceAssetId?: string; deviceAssetId?: string;
id?: string; id?: string;
@ -54,7 +23,7 @@ export interface SearchUserIdOptions {
userIds?: string[]; userIds?: string[];
} }
export type SearchIdOptions = SearchAssetIDOptions & SearchUserIdOptions; export type SearchIdOptions = SearchAssetIdOptions & SearchUserIdOptions;
export interface SearchStatusOptions { export interface SearchStatusOptions {
isArchived?: boolean; isArchived?: boolean;
@ -144,8 +113,6 @@ type BaseAssetSearchOptions = SearchDateOptions &
export type AssetSearchOptions = BaseAssetSearchOptions & SearchRelationOptions; export type AssetSearchOptions = BaseAssetSearchOptions & SearchRelationOptions;
export type AssetSearchOneToOneRelationOptions = BaseAssetSearchOptions & SearchOneToOneRelationOptions;
export type AssetSearchBuilderOptions = Omit<AssetSearchOptions, 'orderDirection'>; export type AssetSearchBuilderOptions = Omit<AssetSearchOptions, 'orderDirection'>;
export type SmartSearchOptions = SearchDateOptions & export type SmartSearchOptions = SearchDateOptions &
@ -226,9 +193,8 @@ export class SearchRepository {
.limit(pagination.size + 1) .limit(pagination.size + 1)
.offset((pagination.page - 1) * pagination.size) .offset((pagination.page - 1) * pagination.size)
.execute(); .execute();
const hasNextPage = items.length > pagination.size;
items.splice(pagination.size); return paginationHelper(items, pagination.size);
return { items, hasNextPage };
} }
@GenerateSql({ @GenerateSql({
@ -283,9 +249,7 @@ export class SearchRepository {
.offset((pagination.page - 1) * pagination.size) .offset((pagination.page - 1) * pagination.size)
.execute(); .execute();
const hasNextPage = items.length > pagination.size; return paginationHelper(items, pagination.size);
items.splice(pagination.size);
return { items, hasNextPage };
} }
@GenerateSql({ @GenerateSql({

View File

@ -606,7 +606,7 @@ describe(AlbumService.name, () => {
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
expect(mocks.event.emit).toHaveBeenCalledWith('album.update', { expect(mocks.event.emit).toHaveBeenCalledWith('album.update', {
id: 'album-123', id: 'album-123',
recipientIds: ['admin_id'], recipientId: 'admin_id',
}); });
}); });

View File

@ -170,8 +170,8 @@ export class AlbumService extends BaseService {
(userId) => userId !== auth.user.id, (userId) => userId !== auth.user.id,
); );
if (allUsersExceptUs.length > 0) { for (const recipientId of allUsersExceptUs) {
await this.eventRepository.emit('album.update', { id, recipientIds: allUsersExceptUs }); await this.eventRepository.emit('album.update', { id, recipientId });
} }
} }

View File

@ -1,6 +1,6 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { MapAsset, mapAsset } from 'src/dtos/asset-response.dto'; import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto'; import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto';
import { AssetStatus, AssetType, JobName, JobStatus } from 'src/enum'; import { AssetStatus, AssetType, JobName, JobStatus } from 'src/enum';
import { AssetStats } from 'src/repositories/asset.repository'; import { AssetStats } from 'src/repositories/asset.repository';
@ -11,7 +11,6 @@ import { faceStub } from 'test/fixtures/face.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { factory } from 'test/small.factory'; import { factory } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils'; import { makeStream, newTestService, ServiceMocks } from 'test/utils';
import { vitest } from 'vitest';
const stats: AssetStats = { const stats: AssetStats = {
[AssetType.IMAGE]: 10, [AssetType.IMAGE]: 10,
@ -44,62 +43,6 @@ describe(AssetService.name, () => {
mockGetById([assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]); mockGetById([assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]);
}); });
describe('getMemoryLane', () => {
beforeAll(() => {
vitest.useFakeTimers();
vitest.setSystemTime(new Date('2024-01-15'));
});
afterAll(() => {
vitest.useRealTimers();
});
it('should group the assets correctly', async () => {
const image1 = { ...assetStub.image, localDateTime: new Date(2023, 1, 15, 0, 0, 0) };
const image2 = { ...assetStub.image, localDateTime: new Date(2023, 1, 15, 1, 0, 0) };
const image3 = { ...assetStub.image, localDateTime: new Date(2015, 1, 15) };
const image4 = { ...assetStub.image, localDateTime: new Date(2009, 1, 15) };
mocks.partner.getAll.mockResolvedValue([]);
mocks.asset.getByDayOfYear.mockResolvedValue([
{
year: 2023,
assets: [image1, image2],
},
{
year: 2015,
assets: [image3],
},
{
year: 2009,
assets: [image4],
},
] as any);
await expect(sut.getMemoryLane(authStub.admin, { day: 15, month: 1 })).resolves.toEqual([
{ yearsAgo: 1, title: '1 year ago', assets: [mapAsset(image1), mapAsset(image2)] },
{ yearsAgo: 9, title: '9 years ago', assets: [mapAsset(image3)] },
{ yearsAgo: 15, title: '15 years ago', assets: [mapAsset(image4)] },
]);
expect(mocks.asset.getByDayOfYear.mock.calls).toEqual([[[authStub.admin.user.id], { day: 15, month: 1 }]]);
});
it('should get memories with partners with inTimeline enabled', async () => {
const partner = factory.partner();
const auth = factory.auth({ user: { id: partner.sharedWithId } });
mocks.partner.getAll.mockResolvedValue([partner]);
mocks.asset.getByDayOfYear.mockResolvedValue([]);
await sut.getMemoryLane(auth, { day: 15, month: 1 });
expect(mocks.asset.getByDayOfYear.mock.calls).toEqual([
[[auth.user.id, partner.sharedById], { day: 15, month: 1 }],
]);
});
});
describe('getStatistics', () => { describe('getStatistics', () => {
it('should get the statistics for a user, excluding archived assets', async () => { it('should get the statistics for a user, excluding archived assets', async () => {
mocks.asset.getStatistics.mockResolvedValue(stats); mocks.asset.getStatistics.mockResolvedValue(stats);

View File

@ -3,13 +3,7 @@ import _ from 'lodash';
import { DateTime, Duration } from 'luxon'; import { DateTime, Duration } from 'luxon';
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { OnJob } from 'src/decorators'; import { OnJob } from 'src/decorators';
import { import { AssetResponseDto, MapAsset, SanitizedAssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
AssetResponseDto,
MapAsset,
MemoryLaneResponseDto,
SanitizedAssetResponseDto,
mapAsset,
} from 'src/dtos/asset-response.dto';
import { import {
AssetBulkDeleteDto, AssetBulkDeleteDto,
AssetBulkUpdateDto, AssetBulkUpdateDto,
@ -20,7 +14,6 @@ import {
mapStats, mapStats,
} from 'src/dtos/asset.dto'; } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { MemoryLaneDto } from 'src/dtos/search.dto';
import { AssetStatus, JobName, JobStatus, Permission, QueueName } from 'src/enum'; import { AssetStatus, JobName, JobStatus, Permission, QueueName } from 'src/enum';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { ISidecarWriteJob, JobItem, JobOf } from 'src/types'; import { ISidecarWriteJob, JobItem, JobOf } from 'src/types';
@ -28,26 +21,6 @@ import { getAssetFiles, getMyPartnerIds, onAfterUnlink, onBeforeLink, onBeforeUn
@Injectable() @Injectable()
export class AssetService extends BaseService { export class AssetService extends BaseService {
async getMemoryLane(auth: AuthDto, dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> {
const partnerIds = await getMyPartnerIds({
userId: auth.user.id,
repository: this.partnerRepository,
timelineEnabled: true,
});
const userIds = [auth.user.id, ...partnerIds];
const groups = await this.assetRepository.getByDayOfYear(userIds, dto);
return groups.map(({ year, assets }) => {
const yearsAgo = DateTime.utc().year - year;
return {
yearsAgo,
// TODO move this to clients
title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`,
assets: assets.map((asset) => mapAsset(asset, { auth })),
};
});
}
async getStatistics(auth: AuthDto, dto: AssetStatsDto) { async getStatistics(auth: AuthDto, dto: AssetStatsDto) {
const stats = await this.assetRepository.getStatistics(auth.user.id, dto); const stats = await this.assetRepository.getStatistics(auth.user.id, dto);
return mapStats(stats); return mapStats(stats);

View File

@ -1,6 +1,4 @@
import { BadRequestException } from '@nestjs/common'; import { JobStatus } from 'src/enum';
import { FileReportItemDto } from 'src/dtos/audit.dto';
import { AssetFileType, AssetPathType, JobStatus, PersonPathType, UserPathType } from 'src/enum';
import { AuditService } from 'src/services/audit.service'; import { AuditService } from 'src/services/audit.service';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService, ServiceMocks } from 'test/utils';
@ -25,148 +23,4 @@ describe(AuditService.name, () => {
expect(mocks.audit.removeBefore).toHaveBeenCalledWith(expect.any(Date)); expect(mocks.audit.removeBefore).toHaveBeenCalledWith(expect.any(Date));
}); });
}); });
describe('getChecksums', () => {
it('should fail if the file is not in the immich path', async () => {
await expect(sut.getChecksums({ filenames: ['foo/bar'] })).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.crypto.hashFile).not.toHaveBeenCalled();
});
it('should get checksum for valid file', async () => {
await expect(sut.getChecksums({ filenames: ['./upload/my-file.jpg'] })).resolves.toEqual([
{ filename: './upload/my-file.jpg', checksum: expect.any(String) },
]);
expect(mocks.crypto.hashFile).toHaveBeenCalledWith('./upload/my-file.jpg');
});
});
describe('fixItems', () => {
it('should fail if the file is not in the immich path', async () => {
await expect(
sut.fixItems([
{ entityId: 'my-id', pathType: AssetPathType.ORIGINAL, pathValue: 'foo/bar' } as FileReportItemDto,
]),
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.asset.update).not.toHaveBeenCalled();
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.user.update).not.toHaveBeenCalled();
});
it('should update encoded video path', async () => {
await sut.fixItems([
{
entityId: 'my-id',
pathType: AssetPathType.ENCODED_VIDEO,
pathValue: './upload/my-video.mp4',
} as FileReportItemDto,
]);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'my-id', encodedVideoPath: './upload/my-video.mp4' });
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.user.update).not.toHaveBeenCalled();
});
it('should update preview path', async () => {
await sut.fixItems([
{
entityId: 'my-id',
pathType: AssetPathType.PREVIEW,
pathValue: './upload/my-preview.png',
} as FileReportItemDto,
]);
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
assetId: 'my-id',
type: AssetFileType.PREVIEW,
path: './upload/my-preview.png',
});
expect(mocks.asset.update).not.toHaveBeenCalled();
expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.user.update).not.toHaveBeenCalled();
});
it('should update thumbnail path', async () => {
await sut.fixItems([
{
entityId: 'my-id',
pathType: AssetPathType.THUMBNAIL,
pathValue: './upload/my-thumbnail.webp',
} as FileReportItemDto,
]);
expect(mocks.asset.upsertFile).toHaveBeenCalledWith({
assetId: 'my-id',
type: AssetFileType.THUMBNAIL,
path: './upload/my-thumbnail.webp',
});
expect(mocks.asset.update).not.toHaveBeenCalled();
expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.user.update).not.toHaveBeenCalled();
});
it('should update original path', async () => {
await sut.fixItems([
{
entityId: 'my-id',
pathType: AssetPathType.ORIGINAL,
pathValue: './upload/my-original.png',
} as FileReportItemDto,
]);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'my-id', originalPath: './upload/my-original.png' });
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.user.update).not.toHaveBeenCalled();
});
it('should update sidecar path', async () => {
await sut.fixItems([
{
entityId: 'my-id',
pathType: AssetPathType.SIDECAR,
pathValue: './upload/my-sidecar.xmp',
} as FileReportItemDto,
]);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'my-id', sidecarPath: './upload/my-sidecar.xmp' });
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
expect(mocks.person.update).not.toHaveBeenCalled();
expect(mocks.user.update).not.toHaveBeenCalled();
});
it('should update face path', async () => {
await sut.fixItems([
{
entityId: 'my-id',
pathType: PersonPathType.FACE,
pathValue: './upload/my-face.jpg',
} as FileReportItemDto,
]);
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'my-id', thumbnailPath: './upload/my-face.jpg' });
expect(mocks.asset.update).not.toHaveBeenCalled();
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
expect(mocks.user.update).not.toHaveBeenCalled();
});
it('should update profile path', async () => {
await sut.fixItems([
{
entityId: 'my-id',
pathType: UserPathType.PROFILE,
pathValue: './upload/my-profile-pic.jpg',
} as FileReportItemDto,
]);
expect(mocks.user.update).toHaveBeenCalledWith('my-id', { profileImagePath: './upload/my-profile-pic.jpg' });
expect(mocks.asset.update).not.toHaveBeenCalled();
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
expect(mocks.person.update).not.toHaveBeenCalled();
});
});
}); });

View File

@ -1,23 +1,9 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { resolve } from 'node:path'; import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
import { AUDIT_LOG_MAX_DURATION, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { OnJob } from 'src/decorators'; import { OnJob } from 'src/decorators';
import { FileChecksumDto, FileChecksumResponseDto, FileReportItemDto, PathEntityType } from 'src/dtos/audit.dto'; import { JobName, JobStatus, QueueName } from 'src/enum';
import {
AssetFileType,
AssetPathType,
JobName,
JobStatus,
PersonPathType,
QueueName,
StorageFolder,
UserPathType,
} from 'src/enum';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { getAssetFiles } from 'src/utils/asset.util';
import { usePagination } from 'src/utils/pagination';
@Injectable() @Injectable()
export class AuditService extends BaseService { export class AuditService extends BaseService {
@ -26,187 +12,4 @@ export class AuditService extends BaseService {
await this.auditRepository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate()); await this.auditRepository.removeBefore(DateTime.now().minus(AUDIT_LOG_MAX_DURATION).toJSDate());
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
async getChecksums(dto: FileChecksumDto) {
const results: FileChecksumResponseDto[] = [];
for (const filename of dto.filenames) {
if (!StorageCore.isImmichPath(filename)) {
throw new BadRequestException(
`Could not get the checksum of ${filename} because the file isn't accessible by Immich`,
);
}
const checksum = await this.cryptoRepository.hashFile(filename);
results.push({ filename, checksum: checksum.toString('base64') });
}
return results;
}
async fixItems(items: FileReportItemDto[]) {
for (const { entityId: id, pathType, pathValue } of items) {
if (!StorageCore.isImmichPath(pathValue)) {
throw new BadRequestException(
`Could not fix item ${id} with path ${pathValue} because the file isn't accessible by Immich`,
);
}
switch (pathType) {
case AssetPathType.ENCODED_VIDEO: {
await this.assetRepository.update({ id, encodedVideoPath: pathValue });
break;
}
case AssetPathType.PREVIEW: {
await this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.PREVIEW, path: pathValue });
break;
}
case AssetPathType.THUMBNAIL: {
await this.assetRepository.upsertFile({ assetId: id, type: AssetFileType.THUMBNAIL, path: pathValue });
break;
}
case AssetPathType.ORIGINAL: {
await this.assetRepository.update({ id, originalPath: pathValue });
break;
}
case AssetPathType.SIDECAR: {
await this.assetRepository.update({ id, sidecarPath: pathValue });
break;
}
case PersonPathType.FACE: {
await this.personRepository.update({ id, thumbnailPath: pathValue });
break;
}
case UserPathType.PROFILE: {
await this.userRepository.update(id, { profileImagePath: pathValue });
break;
}
}
}
}
private fullPath(filename: string) {
return resolve(filename);
}
async getFileReport() {
const hasFile = (items: Set<string>, filename: string) => items.has(filename) || items.has(this.fullPath(filename));
const crawl = async (folder: StorageFolder) =>
new Set(
await this.storageRepository.crawl({
includeHidden: true,
pathsToCrawl: [StorageCore.getBaseFolder(folder)],
}),
);
const uploadFiles = await crawl(StorageFolder.UPLOAD);
const libraryFiles = await crawl(StorageFolder.LIBRARY);
const thumbFiles = await crawl(StorageFolder.THUMBNAILS);
const videoFiles = await crawl(StorageFolder.ENCODED_VIDEO);
const profileFiles = await crawl(StorageFolder.PROFILE);
const allFiles = new Set<string>();
for (const list of [libraryFiles, thumbFiles, videoFiles, profileFiles, uploadFiles]) {
for (const item of list) {
allFiles.add(item);
}
}
const track = (filename: string | null | undefined) => {
if (!filename) {
return;
}
allFiles.delete(filename);
allFiles.delete(this.fullPath(filename));
};
this.logger.log(
`Found ${libraryFiles.size} original files, ${thumbFiles.size} thumbnails, ${videoFiles.size} encoded videos, ${profileFiles.size} profile files`,
);
const pagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (options) =>
this.assetRepository.getAll(options, { withDeleted: true, withArchived: true }),
);
let assetCount = 0;
const orphans: FileReportItemDto[] = [];
for await (const assets of pagination) {
assetCount += assets.length;
for (const { id, files, originalPath, encodedVideoPath, isExternal, checksum } of assets) {
const { fullsizeFile, previewFile, thumbnailFile } = getAssetFiles(files);
for (const file of [
originalPath,
fullsizeFile?.path,
previewFile?.path,
encodedVideoPath,
thumbnailFile?.path,
]) {
track(file);
}
const entity = { entityId: id, entityType: PathEntityType.ASSET, checksum: checksum.toString('base64') };
if (
originalPath &&
!hasFile(libraryFiles, originalPath) &&
!hasFile(uploadFiles, originalPath) &&
// Android motion assets
!hasFile(videoFiles, originalPath) &&
// ignore external library assets
!isExternal
) {
orphans.push({ ...entity, pathType: AssetPathType.ORIGINAL, pathValue: originalPath });
}
if (previewFile && !hasFile(thumbFiles, previewFile.path)) {
orphans.push({ ...entity, pathType: AssetPathType.PREVIEW, pathValue: previewFile.path });
}
if (thumbnailFile && !hasFile(thumbFiles, thumbnailFile.path)) {
orphans.push({ ...entity, pathType: AssetPathType.THUMBNAIL, pathValue: thumbnailFile.path });
}
if (encodedVideoPath && !hasFile(videoFiles, encodedVideoPath)) {
orphans.push({ ...entity, pathType: AssetPathType.THUMBNAIL, pathValue: encodedVideoPath });
}
}
}
const users = await this.userRepository.getList();
for (const { id, profileImagePath } of users) {
track(profileImagePath);
const entity = { entityId: id, entityType: PathEntityType.USER };
if (profileImagePath && !hasFile(profileFiles, profileImagePath)) {
orphans.push({ ...entity, pathType: UserPathType.PROFILE, pathValue: profileImagePath });
}
}
let peopleCount = 0;
for await (const { id, thumbnailPath } of this.personRepository.getAll()) {
track(thumbnailPath);
const entity = { entityId: id, entityType: PathEntityType.PERSON };
if (thumbnailPath && !hasFile(thumbFiles, thumbnailPath)) {
orphans.push({ ...entity, pathType: PersonPathType.FACE, pathValue: thumbnailPath });
}
if (peopleCount === JOBS_ASSET_PAGINATION_SIZE) {
this.logger.log(`Found ${assetCount} assets, ${users.length} users, ${peopleCount} people`);
peopleCount = 0;
}
}
this.logger.log(`Found ${assetCount} assets, ${users.length} users, ${peopleCount} people`);
const extras: string[] = [];
for (const file of allFiles) {
extras.push(file);
}
// send as absolute paths
for (const orphan of orphans) {
orphan.pathValue = this.fullPath(orphan.pathValue);
}
return { orphans, extras };
}
} }

View File

@ -1,10 +1,9 @@
import { AssetFileType, AssetType, JobName, JobStatus } from 'src/enum'; import { AssetFileType, AssetType, JobName, JobStatus } from 'src/enum';
import { WithoutProperty } from 'src/repositories/asset.repository';
import { DuplicateService } from 'src/services/duplicate.service'; import { DuplicateService } from 'src/services/duplicate.service';
import { SearchService } from 'src/services/search.service'; import { SearchService } from 'src/services/search.service';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { newTestService, ServiceMocks } from 'test/utils'; import { makeStream, newTestService, ServiceMocks } from 'test/utils';
import { beforeEach, vitest } from 'vitest'; import { beforeEach, vitest } from 'vitest';
vitest.useFakeTimers(); vitest.useFakeTimers();
@ -113,14 +112,11 @@ describe(SearchService.name, () => {
}); });
it('should queue missing assets', async () => { it('should queue missing assets', async () => {
mocks.asset.getWithout.mockResolvedValue({ mocks.assetJob.streamForSearchDuplicates.mockReturnValue(makeStream([assetStub.image]));
items: [assetStub.image],
hasNextPage: false,
});
await sut.handleQueueSearchDuplicates({}); await sut.handleQueueSearchDuplicates({});
expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.DUPLICATE); expect(mocks.assetJob.streamForSearchDuplicates).toHaveBeenCalledWith(undefined);
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.DUPLICATE_DETECTION, name: JobName.DUPLICATE_DETECTION,
@ -130,14 +126,11 @@ describe(SearchService.name, () => {
}); });
it('should queue all assets', async () => { it('should queue all assets', async () => {
mocks.asset.getAll.mockResolvedValue({ mocks.assetJob.streamForSearchDuplicates.mockReturnValue(makeStream([assetStub.image]));
items: [assetStub.image],
hasNextPage: false,
});
await sut.handleQueueSearchDuplicates({ force: true }); await sut.handleQueueSearchDuplicates({ force: true });
expect(mocks.asset.getAll).toHaveBeenCalled(); expect(mocks.assetJob.streamForSearchDuplicates).toHaveBeenCalledWith(true);
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.DUPLICATE_DETECTION, name: JobName.DUPLICATE_DETECTION,

View File

@ -5,13 +5,11 @@ import { mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { DuplicateResponseDto } from 'src/dtos/duplicate.dto'; import { DuplicateResponseDto } from 'src/dtos/duplicate.dto';
import { AssetFileType, JobName, JobStatus, QueueName } from 'src/enum'; import { AssetFileType, JobName, JobStatus, QueueName } from 'src/enum';
import { WithoutProperty } from 'src/repositories/asset.repository';
import { AssetDuplicateResult } from 'src/repositories/search.repository'; import { AssetDuplicateResult } from 'src/repositories/search.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { JobOf } from 'src/types'; import { JobItem, JobOf } from 'src/types';
import { getAssetFile } from 'src/utils/asset.util'; import { getAssetFile } from 'src/utils/asset.util';
import { isDuplicateDetectionEnabled } from 'src/utils/misc'; import { isDuplicateDetectionEnabled } from 'src/utils/misc';
import { usePagination } from 'src/utils/pagination';
@Injectable() @Injectable()
export class DuplicateService extends BaseService { export class DuplicateService extends BaseService {
@ -30,17 +28,21 @@ export class DuplicateService extends BaseService {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
} }
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { let jobs: JobItem[] = [];
return force const queueAll = async () => {
? this.assetRepository.getAll(pagination, { isVisible: true }) await this.jobRepository.queueAll(jobs);
: this.assetRepository.getWithout(pagination, WithoutProperty.DUPLICATE); jobs = [];
}); };
for await (const assets of assetPagination) { const assets = this.assetJobRepository.streamForSearchDuplicates(force);
await this.jobRepository.queueAll( for await (const asset of assets) {
assets.map((asset) => ({ name: JobName.DUPLICATE_DETECTION, data: { id: asset.id } })), jobs.push({ name: JobName.DUPLICATE_DETECTION, data: { id: asset.id } });
); if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
await queueAll();
} }
}
await queueAll();
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }

View File

@ -239,10 +239,6 @@ describe(JobService.name, () => {
item: { name: JobName.SIDECAR_DISCOVERY, data: { id: 'asset-1' } }, item: { name: JobName.SIDECAR_DISCOVERY, data: { id: 'asset-1' } },
jobs: [JobName.METADATA_EXTRACTION], jobs: [JobName.METADATA_EXTRACTION],
}, },
{
item: { name: JobName.METADATA_EXTRACTION, data: { id: 'asset-1' } },
jobs: [JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE],
},
{ {
item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1', source: 'upload' } }, item: { name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { id: 'asset-1', source: 'upload' } },
jobs: [JobName.GENERATE_THUMBNAILS], jobs: [JobName.GENERATE_THUMBNAILS],

View File

@ -264,17 +264,6 @@ export class JobService extends BaseService {
break; break;
} }
case JobName.METADATA_EXTRACTION: {
if (item.data.source === 'sidecar-write') {
const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([item.data.id]);
if (asset) {
this.eventRepository.clientSend('on_asset_update', asset.ownerId, mapAsset(asset));
}
}
await this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: item.data });
break;
}
case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: { case JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE: {
if (item.data.source === 'upload' || item.data.source === 'copy') { if (item.data.source === 'upload' || item.data.source === 'copy') {
await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: item.data }); await this.jobRepository.queue({ name: JobName.GENERATE_THUMBNAILS, data: item.data });

View File

@ -273,7 +273,6 @@ describe(LibraryService.name, () => {
mocks.library.get.mockResolvedValue(library); mocks.library.get.mockResolvedValue(library);
mocks.storage.walk.mockImplementation(async function* generator() {}); mocks.storage.walk.mockImplementation(async function* generator() {});
mocks.asset.getAll.mockResolvedValue({ items: [assetStub.external], hasNextPage: false });
mocks.asset.getLibraryAssetCount.mockResolvedValue(1); mocks.asset.getLibraryAssetCount.mockResolvedValue(1);
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: BigInt(1) }); mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: BigInt(1) });
@ -292,7 +291,6 @@ describe(LibraryService.name, () => {
mocks.library.get.mockResolvedValue(library); mocks.library.get.mockResolvedValue(library);
mocks.storage.walk.mockImplementation(async function* generator() {}); mocks.storage.walk.mockImplementation(async function* generator() {});
mocks.asset.getAll.mockResolvedValue({ items: [assetStub.external], hasNextPage: false });
mocks.asset.getLibraryAssetCount.mockResolvedValue(0); mocks.asset.getLibraryAssetCount.mockResolvedValue(0);
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: BigInt(1) }); mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: BigInt(1) });

View File

@ -38,10 +38,6 @@ describe(MediaService.name, () => {
describe('handleQueueGenerateThumbnails', () => { describe('handleQueueGenerateThumbnails', () => {
it('should queue all assets', async () => { it('should queue all assets', async () => {
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.image])); mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.image]));
mocks.asset.getAll.mockResolvedValue({
items: [assetStub.image],
hasNextPage: false,
});
mocks.person.getAll.mockReturnValue(makeStream([personStub.newThumbnail])); mocks.person.getAll.mockReturnValue(makeStream([personStub.newThumbnail]));
mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]); mocks.person.getFacesByIds.mockResolvedValue([faceStub.face1]);
@ -67,10 +63,6 @@ describe(MediaService.name, () => {
it('should queue trashed assets when force is true', async () => { it('should queue trashed assets when force is true', async () => {
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.archived])); mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.archived]));
mocks.asset.getAll.mockResolvedValue({
items: [assetStub.trashed],
hasNextPage: false,
});
mocks.person.getAll.mockReturnValue(makeStream()); mocks.person.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: true }); await sut.handleQueueGenerateThumbnails({ force: true });
@ -171,7 +163,7 @@ describe(MediaService.name, () => {
describe('handleQueueMigration', () => { describe('handleQueueMigration', () => {
it('should remove empty directories and queue jobs', async () => { it('should remove empty directories and queue jobs', async () => {
mocks.asset.getAll.mockResolvedValue({ hasNextPage: false, items: [assetStub.image] }); mocks.assetJob.streamForMigrationJob.mockReturnValue(makeStream([assetStub.image]));
mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts); mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts);
mocks.person.getAll.mockReturnValue(makeStream([personStub.withName])); mocks.person.getAll.mockReturnValue(makeStream([personStub.withName]));

View File

@ -36,7 +36,6 @@ import {
import { getAssetFiles } from 'src/utils/asset.util'; import { getAssetFiles } from 'src/utils/asset.util';
import { BaseConfig, ThumbnailConfig } from 'src/utils/media'; import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
import { mimeTypes } from 'src/utils/mime-types'; import { mimeTypes } from 'src/utils/mime-types';
import { usePagination } from 'src/utils/pagination';
@Injectable() @Injectable()
export class MediaService extends BaseService { export class MediaService extends BaseService {
@ -50,18 +49,26 @@ export class MediaService extends BaseService {
@OnJob({ name: JobName.QUEUE_GENERATE_THUMBNAILS, queue: QueueName.THUMBNAIL_GENERATION }) @OnJob({ name: JobName.QUEUE_GENERATE_THUMBNAILS, queue: QueueName.THUMBNAIL_GENERATION })
async handleQueueGenerateThumbnails({ force }: JobOf<JobName.QUEUE_GENERATE_THUMBNAILS>): Promise<JobStatus> { async handleQueueGenerateThumbnails({ force }: JobOf<JobName.QUEUE_GENERATE_THUMBNAILS>): Promise<JobStatus> {
const thumbJobs: JobItem[] = []; let jobs: JobItem[] = [];
const queueAll = async () => {
await this.jobRepository.queueAll(jobs);
jobs = [];
};
for await (const asset of this.assetJobRepository.streamForThumbnailJob(!!force)) { for await (const asset of this.assetJobRepository.streamForThumbnailJob(!!force)) {
const { previewFile, thumbnailFile } = getAssetFiles(asset.files); const { previewFile, thumbnailFile } = getAssetFiles(asset.files);
if (!previewFile || !thumbnailFile || !asset.thumbhash || force) { if (!previewFile || !thumbnailFile || !asset.thumbhash || force) {
thumbJobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id: asset.id } }); jobs.push({ name: JobName.GENERATE_THUMBNAILS, data: { id: asset.id } });
continue;
} }
}
await this.jobRepository.queueAll(thumbJobs);
const jobs: JobItem[] = []; if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
await queueAll();
}
}
await queueAll();
const people = this.personRepository.getAll(force ? undefined : { thumbnailPath: '' }); const people = this.personRepository.getAll(force ? undefined : { thumbnailPath: '' });
@ -76,32 +83,36 @@ export class MediaService extends BaseService {
} }
jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: person.id } }); jobs.push({ name: JobName.GENERATE_PERSON_THUMBNAIL, data: { id: person.id } });
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
await queueAll();
}
} }
await this.jobRepository.queueAll(jobs); await queueAll();
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }
@OnJob({ name: JobName.QUEUE_MIGRATION, queue: QueueName.MIGRATION }) @OnJob({ name: JobName.QUEUE_MIGRATION, queue: QueueName.MIGRATION })
async handleQueueMigration(): Promise<JobStatus> { async handleQueueMigration(): Promise<JobStatus> {
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) =>
this.assetRepository.getAll(pagination),
);
const { active, waiting } = await this.jobRepository.getJobCounts(QueueName.MIGRATION); const { active, waiting } = await this.jobRepository.getJobCounts(QueueName.MIGRATION);
if (active === 1 && waiting === 0) { if (active === 1 && waiting === 0) {
await this.storageCore.removeEmptyDirs(StorageFolder.THUMBNAILS); await this.storageCore.removeEmptyDirs(StorageFolder.THUMBNAILS);
await this.storageCore.removeEmptyDirs(StorageFolder.ENCODED_VIDEO); await this.storageCore.removeEmptyDirs(StorageFolder.ENCODED_VIDEO);
} }
for await (const assets of assetPagination) { let jobs: JobItem[] = [];
await this.jobRepository.queueAll( const assets = this.assetJobRepository.streamForMigrationJob();
assets.map((asset) => ({ name: JobName.MIGRATE_ASSET, data: { id: asset.id } })), for await (const asset of assets) {
); jobs.push({ name: JobName.MIGRATE_ASSET, data: { id: asset.id } });
if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
await this.jobRepository.queueAll(jobs);
jobs = [];
}
} }
let jobs: { name: JobName.MIGRATE_PERSON; data: { id: string } }[] = []; await this.jobRepository.queueAll(jobs);
jobs = [];
for await (const person of this.personRepository.getAll()) { for await (const person of this.personRepository.getAll()) {
jobs.push({ name: JobName.MIGRATE_PERSON, data: { id: person.id } }); jobs.push({ name: JobName.MIGRATE_PERSON, data: { id: person.id } });
@ -255,7 +266,9 @@ export class MediaService extends BaseService {
const { info, data, colorspace } = await this.decodeImage( const { info, data, colorspace } = await this.decodeImage(
extracted ? extracted.buffer : asset.originalPath, extracted ? extracted.buffer : asset.originalPath,
asset.exifInfo, // only specify orientation to extracted images which don't have EXIF orientation data
// or it can double rotate the image
extracted ? asset.exifInfo : { ...asset.exifInfo, orientation: null },
convertFullsize ? undefined : image.preview.size, convertFullsize ? undefined : image.preview.size,
); );

View File

@ -5,7 +5,6 @@ import { constants } from 'node:fs/promises';
import { defaults } from 'src/config'; import { defaults } from 'src/config';
import { MapAsset } from 'src/dtos/asset-response.dto'; import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetType, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum'; import { AssetType, ExifOrientation, ImmichWorker, JobName, JobStatus, SourceType } from 'src/enum';
import { WithoutProperty } from 'src/repositories/asset.repository';
import { ImmichTags } from 'src/repositories/metadata.repository'; import { ImmichTags } from 'src/repositories/metadata.repository';
import { MetadataService } from 'src/services/metadata.service'; import { MetadataService } from 'src/services/metadata.service';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
@ -144,7 +143,8 @@ describe(MetadataService.name, () => {
it('should handle an asset that could not be found', async () => { it('should handle an asset that could not be found', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(void 0); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(void 0);
await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.FAILED);
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
expect(mocks.asset.upsertExif).not.toHaveBeenCalled(); expect(mocks.asset.upsertExif).not.toHaveBeenCalled();
@ -527,7 +527,7 @@ describe(MetadataService.name, () => {
ContainerDirectory: [{ Foo: 100 }], ContainerDirectory: [{ Foo: 100 }],
}); });
await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); await sut.handleMetadataExtraction({ id: assetStub.image.id });
}); });
it('should extract the correct video orientation', async () => { it('should extract the correct video orientation', async () => {
@ -1202,7 +1202,7 @@ describe(MetadataService.name, () => {
it('should handle livePhotoCID not set', async () => { it('should handle livePhotoCID not set', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
await expect(sut.handleMetadataExtraction({ id: assetStub.image.id })).resolves.toBe(JobStatus.SUCCESS); await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id);
expect(mocks.asset.findLivePhotoMatch).not.toHaveBeenCalled(); expect(mocks.asset.findLivePhotoMatch).not.toHaveBeenCalled();
@ -1215,9 +1215,7 @@ describe(MetadataService.name, () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.livePhotoMotionAsset); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.livePhotoMotionAsset);
mockReadTags({ ContentIdentifier: 'CID' }); mockReadTags({ ContentIdentifier: 'CID' });
await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id })).resolves.toBe( await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id });
JobStatus.SUCCESS,
);
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id);
expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({ expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({
@ -1236,9 +1234,7 @@ describe(MetadataService.name, () => {
mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
mockReadTags({ ContentIdentifier: 'CID' }); mockReadTags({ ContentIdentifier: 'CID' });
await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
JobStatus.SUCCESS,
);
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoStillAsset.id); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoStillAsset.id);
expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({ expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({
@ -1262,9 +1258,7 @@ describe(MetadataService.name, () => {
mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
mockReadTags({ ContentIdentifier: 'CID' }); mockReadTags({ ContentIdentifier: 'CID' });
await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
JobStatus.SUCCESS,
);
expect(mocks.event.emit).toHaveBeenCalledWith('asset.hide', { expect(mocks.event.emit).toHaveBeenCalledWith('asset.hide', {
userId: assetStub.livePhotoMotionAsset.ownerId, userId: assetStub.livePhotoMotionAsset.ownerId,
@ -1280,10 +1274,12 @@ describe(MetadataService.name, () => {
mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
mockReadTags({ ContentIdentifier: 'CID' }); mockReadTags({ ContentIdentifier: 'CID' });
await expect(sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id })).resolves.toBe( await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
JobStatus.SUCCESS,
);
expect(mocks.event.emit).toHaveBeenCalledWith('asset.metadataExtracted', {
assetId: assetStub.livePhotoStillAsset.id,
userId: assetStub.livePhotoStillAsset.ownerId,
});
expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({ expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({
ownerId: 'user-id', ownerId: 'user-id',
otherAssetId: 'live-photo-still-asset', otherAssetId: 'live-photo-still-asset',
@ -1346,12 +1342,11 @@ describe(MetadataService.name, () => {
describe('handleQueueSidecar', () => { describe('handleQueueSidecar', () => {
it('should queue assets with sidecar files', async () => { it('should queue assets with sidecar files', async () => {
mocks.asset.getAll.mockResolvedValue({ items: [assetStub.sidecar], hasNextPage: false }); mocks.assetJob.streamForSidecar.mockReturnValue(makeStream([assetStub.image]));
await sut.handleQueueSidecar({ force: true }); await sut.handleQueueSidecar({ force: true });
expect(mocks.assetJob.streamForSidecar).toHaveBeenCalledWith(true);
expect(mocks.asset.getAll).toHaveBeenCalledWith({ take: 1000, skip: 0 });
expect(mocks.asset.getWithout).not.toHaveBeenCalled();
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.SIDECAR_SYNC, name: JobName.SIDECAR_SYNC,
@ -1361,12 +1356,11 @@ describe(MetadataService.name, () => {
}); });
it('should queue assets without sidecar files', async () => { it('should queue assets without sidecar files', async () => {
mocks.asset.getWithout.mockResolvedValue({ items: [assetStub.image], hasNextPage: false }); mocks.assetJob.streamForSidecar.mockReturnValue(makeStream([assetStub.image]));
await sut.handleQueueSidecar({ force: false }); await sut.handleQueueSidecar({ force: false });
expect(mocks.asset.getWithout).toHaveBeenCalledWith({ take: 1000, skip: 0 }, WithoutProperty.SIDECAR); expect(mocks.assetJob.streamForSidecar).toHaveBeenCalledWith(false);
expect(mocks.asset.getAll).not.toHaveBeenCalled();
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.SIDECAR_DISCOVERY, name: JobName.SIDECAR_DISCOVERY,

View File

@ -22,14 +22,12 @@ import {
QueueName, QueueName,
SourceType, SourceType,
} from 'src/enum'; } from 'src/enum';
import { WithoutProperty } from 'src/repositories/asset.repository';
import { ArgOf } from 'src/repositories/event.repository'; import { ArgOf } from 'src/repositories/event.repository';
import { ReverseGeocodeResult } from 'src/repositories/map.repository'; import { ReverseGeocodeResult } from 'src/repositories/map.repository';
import { ImmichTags } from 'src/repositories/metadata.repository'; import { ImmichTags } from 'src/repositories/metadata.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { JobOf } from 'src/types'; import { JobItem, JobOf } from 'src/types';
import { isFaceImportEnabled } from 'src/utils/misc'; import { isFaceImportEnabled } from 'src/utils/misc';
import { usePagination } from 'src/utils/pagination';
import { upsertTags } from 'src/utils/tag'; import { upsertTags } from 'src/utils/tag';
/** look for a date from these tags (in order) */ /** look for a date from these tags (in order) */
@ -184,14 +182,14 @@ export class MetadataService extends BaseService {
} }
@OnJob({ name: JobName.METADATA_EXTRACTION, queue: QueueName.METADATA_EXTRACTION }) @OnJob({ name: JobName.METADATA_EXTRACTION, queue: QueueName.METADATA_EXTRACTION })
async handleMetadataExtraction(data: JobOf<JobName.METADATA_EXTRACTION>): Promise<JobStatus> { async handleMetadataExtraction(data: JobOf<JobName.METADATA_EXTRACTION>) {
const [{ metadata, reverseGeocoding }, asset] = await Promise.all([ const [{ metadata, reverseGeocoding }, asset] = await Promise.all([
this.getConfig({ withCache: true }), this.getConfig({ withCache: true }),
this.assetJobRepository.getForMetadataExtraction(data.id), this.assetJobRepository.getForMetadataExtraction(data.id),
]); ]);
if (!asset) { if (!asset) {
return JobStatus.FAILED; return;
} }
const [exifTags, stats] = await Promise.all([ const [exifTags, stats] = await Promise.all([
@ -285,26 +283,30 @@ export class MetadataService extends BaseService {
await this.assetRepository.upsertJobStatus({ assetId: asset.id, metadataExtractedAt: new Date() }); await this.assetRepository.upsertJobStatus({ assetId: asset.id, metadataExtractedAt: new Date() });
return JobStatus.SUCCESS; await this.eventRepository.emit('asset.metadataExtracted', {
assetId: asset.id,
userId: asset.ownerId,
source: data.source,
});
} }
@OnJob({ name: JobName.QUEUE_SIDECAR, queue: QueueName.SIDECAR }) @OnJob({ name: JobName.QUEUE_SIDECAR, queue: QueueName.SIDECAR })
async handleQueueSidecar(job: JobOf<JobName.QUEUE_SIDECAR>): Promise<JobStatus> { async handleQueueSidecar({ force }: JobOf<JobName.QUEUE_SIDECAR>): Promise<JobStatus> {
const { force } = job; let jobs: JobItem[] = [];
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { const queueAll = async () => {
return force await this.jobRepository.queueAll(jobs);
? this.assetRepository.getAll(pagination) jobs = [];
: this.assetRepository.getWithout(pagination, WithoutProperty.SIDECAR); };
});
for await (const assets of assetPagination) { const assets = this.assetJobRepository.streamForSidecar(force);
await this.jobRepository.queueAll( for await (const asset of assets) {
assets.map((asset) => ({ jobs.push({ name: force ? JobName.SIDECAR_SYNC : JobName.SIDECAR_DISCOVERY, data: { id: asset.id } });
name: force ? JobName.SIDECAR_SYNC : JobName.SIDECAR_DISCOVERY, if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
data: { id: asset.id }, await queueAll();
})),
);
} }
}
await queueAll();
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }

View File

@ -154,10 +154,10 @@ describe(NotificationService.name, () => {
describe('onAlbumUpdateEvent', () => { describe('onAlbumUpdateEvent', () => {
it('should queue notify album update event', async () => { it('should queue notify album update event', async () => {
await sut.onAlbumUpdate({ id: 'album', recipientIds: ['42'] }); await sut.onAlbumUpdate({ id: 'album', recipientId: '42' });
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.NOTIFY_ALBUM_UPDATE, name: JobName.NOTIFY_ALBUM_UPDATE,
data: { id: 'album', recipientIds: ['42'], delay: 300_000 }, data: { id: 'album', recipientId: '42', delay: 300_000 },
}); });
}); });
}); });
@ -414,14 +414,14 @@ describe(NotificationService.name, () => {
describe('handleAlbumUpdate', () => { describe('handleAlbumUpdate', () => {
it('should skip if album could not be found', async () => { it('should skip if album could not be found', async () => {
await expect(sut.handleAlbumUpdate({ id: '', recipientIds: ['1'] })).resolves.toBe(JobStatus.SKIPPED); await expect(sut.handleAlbumUpdate({ id: '', recipientId: '1' })).resolves.toBe(JobStatus.SKIPPED);
expect(mocks.user.get).not.toHaveBeenCalled(); expect(mocks.user.get).not.toHaveBeenCalled();
}); });
it('should skip if owner could not be found', async () => { it('should skip if owner could not be found', async () => {
mocks.album.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail); mocks.album.getById.mockResolvedValue(albumStub.emptyWithValidThumbnail);
await expect(sut.handleAlbumUpdate({ id: '', recipientIds: ['1'] })).resolves.toBe(JobStatus.SKIPPED); await expect(sut.handleAlbumUpdate({ id: '', recipientId: '1' })).resolves.toBe(JobStatus.SKIPPED);
expect(mocks.systemMetadata.get).not.toHaveBeenCalled(); expect(mocks.systemMetadata.get).not.toHaveBeenCalled();
}); });
@ -434,7 +434,7 @@ describe(NotificationService.name, () => {
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); await sut.handleAlbumUpdate({ id: '', recipientId: userStub.user1.id });
expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
expect(mocks.email.renderEmail).not.toHaveBeenCalled(); expect(mocks.email.renderEmail).not.toHaveBeenCalled();
}); });
@ -456,7 +456,7 @@ describe(NotificationService.name, () => {
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); await sut.handleAlbumUpdate({ id: '', recipientId: userStub.user1.id });
expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
expect(mocks.email.renderEmail).not.toHaveBeenCalled(); expect(mocks.email.renderEmail).not.toHaveBeenCalled();
}); });
@ -478,7 +478,7 @@ describe(NotificationService.name, () => {
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); await sut.handleAlbumUpdate({ id: '', recipientId: userStub.user1.id });
expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
expect(mocks.email.renderEmail).not.toHaveBeenCalled(); expect(mocks.email.renderEmail).not.toHaveBeenCalled();
}); });
@ -492,21 +492,21 @@ describe(NotificationService.name, () => {
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' }); mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]); mocks.assetJob.getAlbumThumbnailFiles.mockResolvedValue([]);
await sut.handleAlbumUpdate({ id: '', recipientIds: [userStub.user1.id] }); await sut.handleAlbumUpdate({ id: '', recipientId: userStub.user1.id });
expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false }); expect(mocks.user.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: false });
expect(mocks.email.renderEmail).toHaveBeenCalled(); expect(mocks.email.renderEmail).toHaveBeenCalled();
expect(mocks.job.queue).toHaveBeenCalled(); expect(mocks.job.queue).toHaveBeenCalled();
}); });
it('should add new recipients for new images if job is already queued', async () => { it('should add new recipients for new images if job is already queued', async () => {
mocks.job.removeJob.mockResolvedValue({ id: '1', recipientIds: ['2', '3', '4'] } as INotifyAlbumUpdateJob); await sut.onAlbumUpdate({ id: '1', recipientId: '2' } as INotifyAlbumUpdateJob);
await sut.onAlbumUpdate({ id: '1', recipientIds: ['1', '2', '3'] } as INotifyAlbumUpdateJob); expect(mocks.job.removeJob).toHaveBeenCalledWith(JobName.NOTIFY_ALBUM_UPDATE, '1/2');
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.NOTIFY_ALBUM_UPDATE, name: JobName.NOTIFY_ALBUM_UPDATE,
data: { data: {
id: '1', id: '1',
delay: 300_000, delay: 300_000,
recipientIds: ['1', '2', '3', '4'], recipientId: '2',
}, },
}); });
}); });

View File

@ -1,5 +1,6 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { OnEvent, OnJob } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { import {
mapNotification, mapNotification,
@ -22,7 +23,7 @@ import {
import { EmailTemplate } from 'src/repositories/email.repository'; import { EmailTemplate } from 'src/repositories/email.repository';
import { ArgOf } from 'src/repositories/event.repository'; import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { EmailImageAttachment, IEntityJob, INotifyAlbumUpdateJob, JobItem, JobOf } from 'src/types'; import { EmailImageAttachment, JobOf } from 'src/types';
import { getFilenameExtension } from 'src/utils/file'; import { getFilenameExtension } from 'src/utils/file';
import { getExternalDomain } from 'src/utils/misc'; import { getExternalDomain } from 'src/utils/misc';
import { isEqualObject } from 'src/utils/object'; import { isEqualObject } from 'src/utils/object';
@ -152,6 +153,18 @@ export class NotificationService extends BaseService {
this.eventRepository.clientSend('on_asset_trash', userId, assetIds); this.eventRepository.clientSend('on_asset_trash', userId, assetIds);
} }
@OnEvent({ name: 'asset.metadataExtracted' })
async onAssetMetadataExtracted({ assetId, userId, source }: ArgOf<'asset.metadataExtracted'>) {
if (source !== 'sidecar-write') {
return;
}
const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([assetId]);
if (asset) {
this.eventRepository.clientSend('on_asset_update', userId, mapAsset(asset));
}
}
@OnEvent({ name: 'assets.restore' }) @OnEvent({ name: 'assets.restore' })
onAssetsRestore({ assetIds, userId }: ArgOf<'assets.restore'>) { onAssetsRestore({ assetIds, userId }: ArgOf<'assets.restore'>) {
this.eventRepository.clientSend('on_asset_restore', userId, assetIds); this.eventRepository.clientSend('on_asset_restore', userId, assetIds);
@ -185,30 +198,12 @@ export class NotificationService extends BaseService {
} }
@OnEvent({ name: 'album.update' }) @OnEvent({ name: 'album.update' })
async onAlbumUpdate({ id, recipientIds }: ArgOf<'album.update'>) { async onAlbumUpdate({ id, recipientId }: ArgOf<'album.update'>) {
// if recipientIds is empty, album likely only has one user part of it, don't queue notification if so await this.jobRepository.removeJob(JobName.NOTIFY_ALBUM_UPDATE, `${id}/${recipientId}`);
if (recipientIds.length === 0) { await this.jobRepository.queue({
return;
}
const job: JobItem = {
name: JobName.NOTIFY_ALBUM_UPDATE, name: JobName.NOTIFY_ALBUM_UPDATE,
data: { id, recipientIds, delay: NotificationService.albumUpdateEmailDelayMs }, data: { id, recipientId, delay: NotificationService.albumUpdateEmailDelayMs },
}; });
const previousJobData = await this.jobRepository.removeJob(id, JobName.NOTIFY_ALBUM_UPDATE);
if (previousJobData && this.isAlbumUpdateJob(previousJobData)) {
for (const id of previousJobData.recipientIds) {
if (!recipientIds.includes(id)) {
recipientIds.push(id);
}
}
}
await this.jobRepository.queue(job);
}
private isAlbumUpdateJob(job: IEntityJob): job is INotifyAlbumUpdateJob {
return 'recipientIds' in job;
} }
@OnEvent({ name: 'album.invite' }) @OnEvent({ name: 'album.invite' })
@ -399,7 +394,7 @@ export class NotificationService extends BaseService {
} }
@OnJob({ name: JobName.NOTIFY_ALBUM_UPDATE, queue: QueueName.NOTIFICATION }) @OnJob({ name: JobName.NOTIFY_ALBUM_UPDATE, queue: QueueName.NOTIFICATION })
async handleAlbumUpdate({ id, recipientIds }: JobOf<JobName.NOTIFY_ALBUM_UPDATE>) { async handleAlbumUpdate({ id, recipientId }: JobOf<JobName.NOTIFY_ALBUM_UPDATE>) {
const album = await this.albumRepository.getById(id, { withAssets: false }); const album = await this.albumRepository.getById(id, { withAssets: false });
if (!album) { if (!album) {
@ -411,23 +406,19 @@ export class NotificationService extends BaseService {
return JobStatus.SKIPPED; return JobStatus.SKIPPED;
} }
const recipients = [...album.albumUsers.map((user) => user.user), owner].filter((user) =>
recipientIds.includes(user.id),
);
const attachment = await this.getAlbumThumbnailAttachment(album); const attachment = await this.getAlbumThumbnailAttachment(album);
const { server, templates } = await this.getConfig({ withCache: false }); const { server, templates } = await this.getConfig({ withCache: false });
for (const recipient of recipients) { const user = await this.userRepository.get(recipientId, { withDeleted: false });
const user = await this.userRepository.get(recipient.id, { withDeleted: false });
if (!user) { if (!user) {
continue; return JobStatus.SKIPPED;
} }
const { emailNotifications } = getPreferences(user.metadata); const { emailNotifications } = getPreferences(user.metadata);
if (!emailNotifications.enabled || !emailNotifications.albumUpdate) { if (!emailNotifications.enabled || !emailNotifications.albumUpdate) {
continue; return JobStatus.SKIPPED;
} }
const { html, text } = await this.emailRepository.renderEmail({ const { html, text } = await this.emailRepository.renderEmail({
@ -436,7 +427,7 @@ export class NotificationService extends BaseService {
baseUrl: getExternalDomain(server), baseUrl: getExternalDomain(server),
albumId: album.id, albumId: album.id,
albumName: album.albumName, albumName: album.albumName,
recipientName: recipient.name, recipientName: user.name,
cid: attachment ? attachment.cid : undefined, cid: attachment ? attachment.cid : undefined,
}, },
customTemplate: templates.email.albumUpdateTemplate, customTemplate: templates.email.albumUpdateTemplate,
@ -445,14 +436,13 @@ export class NotificationService extends BaseService {
await this.jobRepository.queue({ await this.jobRepository.queue({
name: JobName.SEND_EMAIL, name: JobName.SEND_EMAIL,
data: { data: {
to: recipient.email, to: user.email,
subject: `New media has been added to an album - ${album.albumName}`, subject: `New media has been added to an album - ${album.albumName}`,
html, html,
text, text,
imageAttachments: attachment ? [attachment] : undefined, imageAttachments: attachment ? [attachment] : undefined,
}, },
}); });
}
return JobStatus.SUCCESS; return JobStatus.SUCCESS;
} }

View File

@ -2,7 +2,6 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.dto'; import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.dto';
import { CacheControl, Colorspace, ImageFormat, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum'; import { CacheControl, Colorspace, ImageFormat, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum';
import { WithoutProperty } from 'src/repositories/asset.repository';
import { DetectedFaces } from 'src/repositories/machine-learning.repository'; import { DetectedFaces } from 'src/repositories/machine-learning.repository';
import { FaceSearchResult } from 'src/repositories/search.repository'; import { FaceSearchResult } from 'src/repositories/search.repository';
import { PersonService } from 'src/services/person.service'; import { PersonService } from 'src/services/person.service';
@ -455,14 +454,11 @@ describe(PersonService.name, () => {
}); });
it('should queue missing assets', async () => { it('should queue missing assets', async () => {
mocks.asset.getWithout.mockResolvedValue({ mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([assetStub.image]));
items: [assetStub.image],
hasNextPage: false,
});
await sut.handleQueueDetectFaces({ force: false }); await sut.handleQueueDetectFaces({ force: false });
expect(mocks.asset.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.FACES); expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(false);
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.FACE_DETECTION, name: JobName.FACE_DETECTION,
@ -472,10 +468,7 @@ describe(PersonService.name, () => {
}); });
it('should queue all assets', async () => { it('should queue all assets', async () => {
mocks.asset.getAll.mockResolvedValue({ mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([assetStub.image]));
items: [assetStub.image],
hasNextPage: false,
});
mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.withName]); mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.withName]);
await sut.handleQueueDetectFaces({ force: true }); await sut.handleQueueDetectFaces({ force: true });
@ -483,7 +476,7 @@ describe(PersonService.name, () => {
expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING }); expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MACHINE_LEARNING });
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.withName.id]); expect(mocks.person.delete).toHaveBeenCalledWith([personStub.withName.id]);
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath); expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath);
expect(mocks.asset.getAll).toHaveBeenCalled(); expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(true);
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.FACE_DETECTION, name: JobName.FACE_DETECTION,
@ -493,17 +486,14 @@ describe(PersonService.name, () => {
}); });
it('should refresh all assets', async () => { it('should refresh all assets', async () => {
mocks.asset.getAll.mockResolvedValue({ mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([assetStub.image]));
items: [assetStub.image],
hasNextPage: false,
});
await sut.handleQueueDetectFaces({ force: undefined }); await sut.handleQueueDetectFaces({ force: undefined });
expect(mocks.person.delete).not.toHaveBeenCalled(); expect(mocks.person.delete).not.toHaveBeenCalled();
expect(mocks.person.deleteFaces).not.toHaveBeenCalled(); expect(mocks.person.deleteFaces).not.toHaveBeenCalled();
expect(mocks.storage.unlink).not.toHaveBeenCalled(); expect(mocks.storage.unlink).not.toHaveBeenCalled();
expect(mocks.asset.getAll).toHaveBeenCalled(); expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(undefined);
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.FACE_DETECTION, name: JobName.FACE_DETECTION,
@ -516,16 +506,13 @@ describe(PersonService.name, () => {
it('should delete existing people and faces if forced', async () => { it('should delete existing people and faces if forced', async () => {
mocks.person.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson])); mocks.person.getAll.mockReturnValue(makeStream([faceStub.face1.person, personStub.randomPerson]));
mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1])); mocks.person.getAllFaces.mockReturnValue(makeStream([faceStub.face1]));
mocks.asset.getAll.mockResolvedValue({ mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([assetStub.image]));
items: [assetStub.image],
hasNextPage: false,
});
mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]); mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]);
mocks.person.deleteFaces.mockResolvedValue(); mocks.person.deleteFaces.mockResolvedValue();
await sut.handleQueueDetectFaces({ force: true }); await sut.handleQueueDetectFaces({ force: true });
expect(mocks.asset.getAll).toHaveBeenCalled(); expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(true);
expect(mocks.job.queueAll).toHaveBeenCalledWith([ expect(mocks.job.queueAll).toHaveBeenCalledWith([
{ {
name: JobName.FACE_DETECTION, name: JobName.FACE_DETECTION,

View File

@ -36,7 +36,6 @@ import {
SourceType, SourceType,
SystemMetadataKey, SystemMetadataKey,
} from 'src/enum'; } from 'src/enum';
import { WithoutProperty } from 'src/repositories/asset.repository';
import { BoundingBox } from 'src/repositories/machine-learning.repository'; import { BoundingBox } from 'src/repositories/machine-learning.repository';
import { UpdateFacesData } from 'src/repositories/person.repository'; import { UpdateFacesData } from 'src/repositories/person.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
@ -44,7 +43,6 @@ import { CropOptions, ImageDimensions, InputDimensions, JobItem, JobOf } from 's
import { ImmichFileResponse } from 'src/utils/file'; import { ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types'; import { mimeTypes } from 'src/utils/mime-types';
import { isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc'; import { isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc';
import { usePagination } from 'src/utils/pagination';
@Injectable() @Injectable()
export class PersonService extends BaseService { export class PersonService extends BaseService {
@ -265,22 +263,18 @@ export class PersonService extends BaseService {
await this.handlePersonCleanup(); await this.handlePersonCleanup();
} }
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => { let jobs: JobItem[] = [];
return force === false const assets = this.assetJobRepository.streamForDetectFacesJob(force);
? this.assetRepository.getWithout(pagination, WithoutProperty.FACES) for await (const asset of assets) {
: this.assetRepository.getAll(pagination, { jobs.push({ name: JobName.FACE_DETECTION, data: { id: asset.id } });
orderDirection: 'desc',
withFaces: true,
withArchived: true,
isVisible: true,
});
});
for await (const assets of assetPagination) { if (jobs.length >= JOBS_ASSET_PAGINATION_SIZE) {
await this.jobRepository.queueAll( await this.jobRepository.queueAll(jobs);
assets.map((asset) => ({ name: JobName.FACE_DETECTION, data: { id: asset.id } })), jobs = [];
);
} }
}
await this.jobRepository.queueAll(jobs);
if (force === undefined) { if (force === undefined) {
await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP }); await this.jobRepository.queue({ name: JobName.PERSON_CLEANUP });

View File

@ -15,7 +15,6 @@ import {
SmartSearchDto, SmartSearchDto,
} from 'src/dtos/search.dto'; } from 'src/dtos/search.dto';
import { AssetOrder } from 'src/enum'; import { AssetOrder } from 'src/enum';
import { SearchExploreItem } from 'src/repositories/search.repository';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { getMyPartnerIds } from 'src/utils/asset.util'; import { getMyPartnerIds } from 'src/utils/asset.util';
import { isSmartSearchEnabled } from 'src/utils/misc'; import { isSmartSearchEnabled } from 'src/utils/misc';
@ -32,7 +31,7 @@ export class SearchService extends BaseService {
return places.map((place) => mapPlaces(place)); return places.map((place) => mapPlaces(place));
} }
async getExploreData(auth: AuthDto): Promise<SearchExploreItem<AssetResponseDto>[]> { async getExploreData(auth: AuthDto) {
const options = { maxFields: 12, minAssetsPerField: 5 }; const options = { maxFields: 12, minAssetsPerField: 5 };
const cities = await this.assetRepository.getAssetIdByCity(auth.user.id, options); const cities = await this.assetRepository.getAssetIdByCity(auth.user.id, options);
const assets = await this.assetRepository.getByIdsWithAllRelationsButStacks(cities.items.map(({ data }) => data)); const assets = await this.assetRepository.getByIdsWithAllRelationsButStacks(cities.items.map(({ data }) => data));

View File

@ -151,7 +151,6 @@ describe(SmartInfoService.name, () => {
await sut.handleQueueEncodeClip({}); await sut.handleQueueEncodeClip({});
expect(mocks.asset.getWithout).not.toHaveBeenCalled();
expect(mocks.search.setDimensionSize).not.toHaveBeenCalled(); expect(mocks.search.setDimensionSize).not.toHaveBeenCalled();
}); });

View File

@ -116,6 +116,11 @@ export class StorageTemplateService extends BaseService {
return { ...storageTokens, presetOptions: storagePresets }; return { ...storageTokens, presetOptions: storagePresets };
} }
@OnEvent({ name: 'asset.metadataExtracted' })
async onAssetMetadataExtracted({ source, assetId }: ArgOf<'asset.metadataExtracted'>) {
await this.jobRepository.queue({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, data: { source, id: assetId } });
}
@OnJob({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, queue: QueueName.STORAGE_TEMPLATE_MIGRATION }) @OnJob({ name: JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE, queue: QueueName.STORAGE_TEMPLATE_MIGRATION })
async handleMigrationSingle({ id }: JobOf<JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE>): Promise<JobStatus> { async handleMigrationSingle({ id }: JobOf<JobName.STORAGE_TEMPLATE_MIGRATION_SINGLE>): Promise<JobStatus> {
const config = await this.getConfig({ withCache: true }); const config = await this.getConfig({ withCache: true });

View File

@ -177,9 +177,10 @@ export interface IDelayedJob extends IBaseJob {
delay?: number; delay?: number;
} }
export type JobSource = 'upload' | 'sidecar-write' | 'copy';
export interface IEntityJob extends IBaseJob { export interface IEntityJob extends IBaseJob {
id: string; id: string;
source?: 'upload' | 'sidecar-write' | 'copy'; source?: JobSource;
notify?: boolean; notify?: boolean;
} }
@ -251,7 +252,7 @@ export interface INotifyAlbumInviteJob extends IEntityJob {
} }
export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob { export interface INotifyAlbumUpdateJob extends IEntityJob, IDelayedJob {
recipientIds: string[]; recipientId: string;
} }
export interface JobCounts { export interface JobCounts {

View File

@ -8,22 +8,6 @@ export interface PaginationResult<T> {
hasNextPage: boolean; hasNextPage: boolean;
} }
export type Paginated<T> = Promise<PaginationResult<T>>;
/** @deprecated use `this.db. ... .stream()` instead */
export async function* usePagination<T>(
pageSize: number,
getNextPage: (pagination: PaginationOptions) => PaginationResult<T> | Paginated<T>,
) {
let hasNextPage = true;
for (let skip = 0; hasNextPage; skip += pageSize) {
const result = await getNextPage({ take: pageSize, skip });
hasNextPage = result.hasNextPage;
yield result.items;
}
}
export function paginationHelper<Entity extends object>(items: Entity[], take: number): PaginationResult<Entity> { export function paginationHelper<Entity extends object>(items: Entity[], take: number): PaginationResult<Entity> {
const hasNextPage = items.length > take; const hasNextPage = items.length > take;
items.splice(take); items.splice(take);

View File

@ -13,14 +13,11 @@ export const newAssetRepositoryMock = (): Mocked<RepositoryInterface<AssetReposi
getByIds: vitest.fn().mockResolvedValue([]), getByIds: vitest.fn().mockResolvedValue([]),
getByIdsWithAllRelationsButStacks: vitest.fn().mockResolvedValue([]), getByIdsWithAllRelationsButStacks: vitest.fn().mockResolvedValue([]),
getByDeviceIds: vitest.fn(), getByDeviceIds: vitest.fn(),
getByUserId: vitest.fn(),
getById: vitest.fn(), getById: vitest.fn(),
getWithout: vitest.fn(),
getByChecksum: vitest.fn(), getByChecksum: vitest.fn(),
getByChecksums: vitest.fn(), getByChecksums: vitest.fn(),
getUploadAssetIdByChecksum: vitest.fn(), getUploadAssetIdByChecksum: vitest.fn(),
getRandom: vitest.fn(), getRandom: vitest.fn(),
getAll: vitest.fn().mockResolvedValue({ items: [], hasNextPage: false }),
getAllByDeviceId: vitest.fn(), getAllByDeviceId: vitest.fn(),
getLivePhotoCount: vitest.fn(), getLivePhotoCount: vitest.fn(),
getLibraryAssetCount: vitest.fn(), getLibraryAssetCount: vitest.fn(),

View File

@ -10,6 +10,7 @@ const envData: EnvData = {
buildMetadata: {}, buildMetadata: {},
bull: { bull: {
config: { config: {
connection: {},
prefix: 'immich_bull', prefix: 'immich_bull',
}, },
queues: [{ name: 'queue-1' }], queues: [{ name: 'queue-1' }],

View File

@ -5,10 +5,17 @@ TYPESCRIPT_SDK=/usr/src/open-api/typescript-sdk
npm --prefix "$TYPESCRIPT_SDK" install npm --prefix "$TYPESCRIPT_SDK" install
npm --prefix "$TYPESCRIPT_SDK" run build npm --prefix "$TYPESCRIPT_SDK" run build
COUNT=0
UPSTREAM="${IMMICH_SERVER_URL:-http://immich-server:2283/}" UPSTREAM="${IMMICH_SERVER_URL:-http://immich-server:2283/}"
until wget --spider --quiet "${UPSTREAM}/api/server/config"; do until wget --spider --quiet "${UPSTREAM}/api/server/config" > /dev/null 2>&1; do
echo 'waiting for api server...' if [ $((COUNT % 10)) -eq 0 ]; then
echo "Waiting for $UPSTREAM to start..."
fi
COUNT=$((COUNT + 1))
sleep 1 sleep 1
done done
echo "Connected to $UPSTREAM"
node ./node_modules/.bin/vite dev --host 0.0.0.0 --port 3000 node ./node_modules/.bin/vite dev --host 0.0.0.0 --port 3000

View File

@ -1,32 +1,24 @@
<script lang="ts"> <script lang="ts">
import Icon from '$lib/components/elements/icon.svelte';
import { AppRoute, timeBeforeShowLoadingSpinner } from '$lib/constants';
import { getAssetThumbnailUrl, handlePromiseError } from '$lib/utils';
import { getAssetType } from '$lib/utils/asset-utils';
import { autoGrowHeight } from '$lib/actions/autogrow'; import { autoGrowHeight } from '$lib/actions/autogrow';
import { shortcut } from '$lib/actions/shortcut';
import Icon from '$lib/components/elements/icon.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import { AppRoute, timeBeforeShowLoadingSpinner } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { locale } from '$lib/stores/preferences.store';
import { getAssetThumbnailUrl } from '$lib/utils';
import { getAssetType } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { isTenMinutesApart } from '$lib/utils/timesince'; import { isTenMinutesApart } from '$lib/utils/timesince';
import { import { ReactionType, type ActivityResponseDto, type AssetTypeEnum, type UserResponseDto } from '@immich/sdk';
ReactionType, import { mdiClose, mdiDeleteOutline, mdiDotsVertical, mdiHeart, mdiSend } from '@mdi/js';
createActivity,
deleteActivity,
getActivities,
type ActivityResponseDto,
type AssetTypeEnum,
type UserResponseDto,
} from '@immich/sdk';
import { mdiClose, mdiDotsVertical, mdiHeart, mdiSend, mdiDeleteOutline } from '@mdi/js';
import * as luxon from 'luxon'; import * as luxon from 'luxon';
import { onMount } from 'svelte'; import { t } from 'svelte-i18n';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import LoadingSpinner from '../shared-components/loading-spinner.svelte'; import LoadingSpinner from '../shared-components/loading-spinner.svelte';
import { NotificationType, notificationController } from '../shared-components/notification/notification'; import { NotificationType, notificationController } from '../shared-components/notification/notification';
import UserAvatar from '../shared-components/user-avatar.svelte'; import UserAvatar from '../shared-components/user-avatar.svelte';
import { locale } from '$lib/stores/preferences.store';
import { shortcut } from '$lib/actions/shortcut';
import { t } from 'svelte-i18n';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second']; const units: Intl.RelativeTimeFormatUnit[] = ['year', 'month', 'week', 'day', 'hour', 'minute', 'second'];
@ -48,34 +40,16 @@
}; };
interface Props { interface Props {
reactions: ActivityResponseDto[];
user: UserResponseDto; user: UserResponseDto;
assetId?: string | undefined; assetId?: string | undefined;
albumId: string; albumId: string;
assetType?: AssetTypeEnum | undefined; assetType?: AssetTypeEnum | undefined;
albumOwnerId: string; albumOwnerId: string;
disabled: boolean; disabled: boolean;
isLiked: ActivityResponseDto | null;
onDeleteComment: () => void;
onDeleteLike: () => void;
onAddComment: () => void;
onClose: () => void; onClose: () => void;
} }
let { let { user, assetId = undefined, albumId, assetType = undefined, albumOwnerId, disabled, onClose }: Props = $props();
reactions = $bindable(),
user,
assetId = undefined,
albumId,
assetType = undefined,
albumOwnerId,
disabled,
isLiked,
onDeleteComment,
onDeleteLike,
onAddComment,
onClose,
}: Props = $props();
let innerHeight: number = $state(0); let innerHeight: number = $state(0);
let activityHeight: number = $state(0); let activityHeight: number = $state(0);
@ -85,36 +59,18 @@
let message = $state(''); let message = $state('');
let isSendingMessage = $state(false); let isSendingMessage = $state(false);
onMount(async () => { const timeOptions: Intl.DateTimeFormatOptions = {
await getReactions();
});
const getReactions = async () => {
try {
reactions = await getActivities({ assetId, albumId });
} catch (error) {
handleError(error, $t('errors.unable_to_load_asset_activity'));
}
};
const timeOptions = {
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
hour12: false, hour12: false,
} as Intl.DateTimeFormatOptions; };
const handleDeleteReaction = async (reaction: ActivityResponseDto, index: number) => { const handleDeleteReaction = async (reaction: ActivityResponseDto, index: number) => {
try { try {
await deleteActivity({ id: reaction.id }); await activityManager.deleteActivity(reaction, index);
reactions.splice(index, 1);
if (isLiked && reaction.type === ReactionType.Like && reaction.id == isLiked.id) {
onDeleteLike();
} else {
onDeleteComment();
}
const deleteMessages: Record<ReactionType, string> = { const deleteMessages: Record<ReactionType, string> = {
[ReactionType.Comment]: $t('comment_deleted'), [ReactionType.Comment]: $t('comment_deleted'),
@ -135,13 +91,9 @@
} }
const timeout = setTimeout(() => (isSendingMessage = true), timeBeforeShowLoadingSpinner); const timeout = setTimeout(() => (isSendingMessage = true), timeBeforeShowLoadingSpinner);
try { try {
const data = await createActivity({ await activityManager.addActivity({ albumId, assetId, type: ReactionType.Comment, comment: message });
activityCreateDto: { albumId, assetId, type: ReactionType.Comment, comment: message },
});
reactions.push(data);
message = ''; message = '';
onAddComment();
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_add_comment')); handleError(error, $t('errors.unable_to_add_comment'));
} finally { } finally {
@ -156,7 +108,6 @@
}); });
$effect(() => { $effect(() => {
if (assetId && previousAssetId != assetId) { if (assetId && previousAssetId != assetId) {
handlePromiseError(getReactions());
previousAssetId = assetId; previousAssetId = assetId;
} }
}); });
@ -184,7 +135,7 @@
class="overflow-y-auto immich-scrollbar relative w-full px-2" class="overflow-y-auto immich-scrollbar relative w-full px-2"
style="height: {divHeight}px;padding-bottom: {chatHeight}px" style="height: {divHeight}px;padding-bottom: {chatHeight}px"
> >
{#each reactions as reaction, index (reaction.id)} {#each activityManager.activities as reaction, index (reaction.id)}
{#if reaction.type === ReactionType.Comment} {#if reaction.type === ReactionType.Comment}
<div class="flex dark:bg-gray-800 bg-gray-200 py-3 ps-3 mt-3 rounded-lg gap-4 justify-start"> <div class="flex dark:bg-gray-800 bg-gray-200 py-3 ps-3 mt-3 rounded-lg gap-4 justify-start">
<div class="flex items-center"> <div class="flex items-center">
@ -221,7 +172,7 @@
{/if} {/if}
</div> </div>
{#if (index != reactions.length - 1 && !shouldGroup(reactions[index].createdAt, reactions[index + 1].createdAt)) || index === reactions.length - 1} {#if (index != activityManager.activities.length - 1 && !shouldGroup(activityManager.activities[index].createdAt, activityManager.activities[index + 1].createdAt)) || index === activityManager.activities.length - 1}
<div <div
class="pt-1 px-2 text-right w-full text-sm text-gray-500 dark:text-gray-300" class="pt-1 px-2 text-right w-full text-sm text-gray-500 dark:text-gray-300"
title={new Date(reaction.createdAt).toLocaleDateString(undefined, timeOptions)} title={new Date(reaction.createdAt).toLocaleDateString(undefined, timeOptions)}
@ -273,7 +224,7 @@
</div> </div>
{/if} {/if}
</div> </div>
{#if (index != reactions.length - 1 && isTenMinutesApart(reactions[index].createdAt, reactions[index + 1].createdAt)) || index === reactions.length - 1} {#if (index != activityManager.activities.length - 1 && isTenMinutesApart(activityManager.activities[index].createdAt, activityManager.activities[index + 1].createdAt)) || index === activityManager.activities.length - 1}
<div <div
class="pt-1 px-2 text-right w-full text-sm text-gray-500 dark:text-gray-300" class="pt-1 px-2 text-right w-full text-sm text-gray-500 dark:text-gray-300"
title={new Date(reaction.createdAt).toLocaleDateString(navigator.language, timeOptions)} title={new Date(reaction.createdAt).toLocaleDateString(navigator.language, timeOptions)}

View File

@ -5,8 +5,8 @@
import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte'; import NextAssetAction from '$lib/components/asset-viewer/actions/next-asset-action.svelte';
import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte'; import PreviousAssetAction from '$lib/components/asset-viewer/actions/previous-asset-action.svelte';
import { AssetAction, ProjectionType } from '$lib/constants'; import { AssetAction, ProjectionType } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import { updateNumberOfComments } from '$lib/stores/activity.store';
import { closeEditorCofirm } from '$lib/stores/asset-editor.store'; import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isShowDetail } from '$lib/stores/preferences.store'; import { isShowDetail } from '$lib/stores/preferences.store';
@ -19,15 +19,9 @@
import { import {
AssetJobName, AssetJobName,
AssetTypeEnum, AssetTypeEnum,
ReactionType,
createActivity,
deleteActivity,
getActivities,
getActivityStatistics,
getAllAlbums, getAllAlbums,
getStack, getStack,
runAssetJobs, runAssetJobs,
type ActivityResponseDto,
type AlbumResponseDto, type AlbumResponseDto,
type AssetResponseDto, type AssetResponseDto,
type PersonResponseDto, type PersonResponseDto,
@ -61,7 +55,6 @@
person?: PersonResponseDto | null; person?: PersonResponseDto | null;
preAction?: PreAction | undefined; preAction?: PreAction | undefined;
onAction?: OnAction | undefined; onAction?: OnAction | undefined;
reactions?: ActivityResponseDto[];
showCloseButton?: boolean; showCloseButton?: boolean;
onClose: (dto: { asset: AssetResponseDto }) => void; onClose: (dto: { asset: AssetResponseDto }) => void;
onNext: () => Promise<HasAsset>; onNext: () => Promise<HasAsset>;
@ -80,7 +73,6 @@
person = null, person = null,
preAction = undefined, preAction = undefined,
onAction = undefined, onAction = undefined,
reactions = $bindable([]),
showCloseButton, showCloseButton,
onClose, onClose,
onNext, onNext,
@ -107,8 +99,6 @@
let previewStackedAsset: AssetResponseDto | undefined = $state(); let previewStackedAsset: AssetResponseDto | undefined = $state();
let isShowActivity = $state(false); let isShowActivity = $state(false);
let isShowEditor = $state(false); let isShowEditor = $state(false);
let isLiked: ActivityResponseDto | null = $state(null);
let numberOfComments = $state(0);
let fullscreenElement = $state<Element>(); let fullscreenElement = $state<Element>();
let unsubscribes: (() => void)[] = []; let unsubscribes: (() => void)[] = [];
let selectedEditType: string = $state(''); let selectedEditType: string = $state('');
@ -136,59 +126,20 @@
}); });
}; };
const handleAddComment = () => {
numberOfComments++;
updateNumberOfComments(1);
};
const handleRemoveComment = () => {
numberOfComments--;
updateNumberOfComments(-1);
};
const handleFavorite = async () => { const handleFavorite = async () => {
if (album && album.isActivityEnabled) { if (album && album.isActivityEnabled) {
try { try {
if (isLiked) { await activityManager.toggleLike();
const activityId = isLiked.id;
await deleteActivity({ id: activityId });
reactions = reactions.filter((reaction) => reaction.id !== activityId);
isLiked = null;
} else {
const data = await createActivity({
activityCreateDto: { albumId: album.id, assetId: asset.id, type: ReactionType.Like },
});
isLiked = data;
reactions = [...reactions, isLiked];
}
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_change_favorite')); handleError(error, $t('errors.unable_to_change_favorite'));
} }
} }
}; };
const getFavorite = async () => { const updateComments = async () => {
if (album && $user) {
try {
const data = await getActivities({
userId: $user.id,
assetId: asset.id,
albumId: album.id,
$type: ReactionType.Like,
});
isLiked = data.length > 0 ? data[0] : null;
} catch (error) {
handleError(error, $t('errors.unable_to_load_liked_status'));
}
}
};
const getNumberOfComments = async () => {
if (album) { if (album) {
try { try {
const { comments } = await getActivityStatistics({ assetId: asset.id, albumId: album.id }); await activityManager.refreshActivities(album.id, asset.id);
numberOfComments = comments;
} catch (error) { } catch (error) {
handleError(error, $t('errors.unable_to_get_comments_number')); handleError(error, $t('errors.unable_to_get_comments_number'));
} }
@ -227,6 +178,10 @@
if (!sharedLink) { if (!sharedLink) {
await handleGetAllAlbums(); await handleGetAllAlbums();
} }
if (album) {
activityManager.init(album.id, asset.id);
}
}); });
onDestroy(() => { onDestroy(() => {
@ -241,6 +196,8 @@
for (const unsubscribe of unsubscribes) { for (const unsubscribe of unsubscribes) {
unsubscribe(); unsubscribe();
} }
activityManager.reset();
}); });
const handleGetAllAlbums = async () => { const handleGetAllAlbums = async () => {
@ -402,14 +359,13 @@
} }
}); });
$effect(() => { $effect(() => {
if (album && !album.isActivityEnabled && numberOfComments === 0) { if (album && !album.isActivityEnabled && activityManager.commentCount === 0) {
isShowActivity = false; isShowActivity = false;
} }
}); });
$effect(() => { $effect(() => {
if (isShared && asset.id) { if (isShared && asset.id) {
handlePromiseError(getFavorite()); handlePromiseError(updateComments());
handlePromiseError(getNumberOfComments());
} }
}); });
$effect(() => { $effect(() => {
@ -547,12 +503,12 @@
onVideoStarted={handleVideoStarted} onVideoStarted={handleVideoStarted}
/> />
{/if} {/if}
{#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || numberOfComments > 0)} {#if $slideshowState === SlideshowState.None && isShared && ((album && album.isActivityEnabled) || activityManager.commentCount > 0)}
<div class="z-[9999] absolute bottom-0 end-0 mb-20 me-8"> <div class="z-[9999] absolute bottom-0 end-0 mb-20 me-8">
<ActivityStatus <ActivityStatus
disabled={!album?.isActivityEnabled} disabled={!album?.isActivityEnabled}
{isLiked} isLiked={activityManager.isLiked}
{numberOfComments} numberOfComments={activityManager.commentCount}
onFavorite={handleFavorite} onFavorite={handleFavorite}
onOpenActivityTab={handleOpenActivity} onOpenActivityTab={handleOpenActivity}
/> />
@ -642,11 +598,6 @@
albumOwnerId={album.ownerId} albumOwnerId={album.ownerId}
albumId={album.id} albumId={album.id}
assetId={asset.id} assetId={asset.id}
{isLiked}
bind:reactions
onAddComment={handleAddComment}
onDeleteComment={handleRemoveComment}
onDeleteLike={() => (isLiked = null)}
onClose={() => (isShowActivity = false)} onClose={() => (isShowActivity = false)}
/> />
</div> </div>

View File

@ -2,14 +2,15 @@
import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte'; import ImageThumbnail from '$lib/components/assets/thumbnail/image-thumbnail.svelte';
import { dialogController } from '$lib/components/shared-components/dialog/dialog'; import { dialogController } from '$lib/components/shared-components/dialog/dialog';
import { notificationController } from '$lib/components/shared-components/notification/notification'; import { notificationController } from '$lib/components/shared-components/notification/notification';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils'; import { getPeopleThumbnailUrl } from '$lib/utils';
import { getAllPeople, createFace, type PersonResponseDto } from '@immich/sdk'; import { handleError } from '$lib/utils/handle-error';
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, Input } from '@immich/ui'; import { Button, Input } from '@immich/ui';
import { Canvas, InteractiveFabricObject, Rect } from 'fabric'; import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { t } from 'svelte-i18n';
import { handleError } from '$lib/utils/handle-error';
interface Props { interface Props {
htmlElement: HTMLImageElement | HTMLVideoElement; htmlElement: HTMLImageElement | HTMLVideoElement;
@ -316,7 +317,7 @@
bind:this={faceSelectorEl} bind:this={faceSelectorEl}
class="absolute top-[calc(50%-250px)] start-[calc(50%-125px)] max-w-[250px] w-[250px] bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800" class="absolute top-[calc(50%-250px)] start-[calc(50%-125px)] max-w-[250px] w-[250px] bg-white dark:bg-immich-dark-gray dark:text-immich-dark-fg backdrop-blur-sm px-2 py-4 rounded-xl border border-gray-200 dark:border-gray-800"
> >
<p class="text-center text-sm">Select a person to tag</p> <p class="text-center text-sm">{$t('select_person_to_tag')}</p>
<div class="my-3 relative"> <div class="my-3 relative">
<Input placeholder="Search person..." bind:value={searchTerm} size="tiny" /> <Input placeholder="Search person..." bind:value={searchTerm} size="tiny" />
@ -348,11 +349,11 @@
</div> </div>
{:else} {:else}
<div class="flex items-center justify-center py-4"> <div class="flex items-center justify-center py-4">
<p class="text-sm text-gray-500">No matching people found</p> <p class="text-sm text-gray-500">{$t('no_people_found')}</p>
</div> </div>
{/if} {/if}
</div> </div>
<Button size="small" fullWidth onclick={cancel} color="danger" class="mt-2">Cancel</Button> <Button size="small" fullWidth onclick={cancel} color="danger" class="mt-2">{$t('cancel')}</Button>
</div> </div>
</div> </div>

View File

@ -243,6 +243,26 @@
class={['group absolute top-[0px] bottom-[0px]', { 'cursor-not-allowed': disabled, 'cursor-pointer': !disabled }]} class={['group absolute top-[0px] bottom-[0px]', { 'cursor-not-allowed': disabled, 'cursor-pointer': !disabled }]}
style:width="inherit" style:width="inherit"
style:height="inherit" style:height="inherit"
onmouseenter={onMouseEnter}
onmouseleave={onMouseLeave}
use:longPress={{ onLongPress: () => onSelect?.($state.snapshot(asset)) }}
onkeydown={(evt) => {
if (evt.key === 'Enter') {
callClickHandlers();
}
if (evt.key === 'x') {
onSelect?.(asset);
}
if (document.activeElement === element && evt.key === 'Escape') {
focusNext((element) => element.dataset.thumbnailFocusContainer === undefined, true);
}
}}
onclick={handleClick}
bind:this={element}
onfocus={handleFocus}
data-thumbnail-focus-container
tabindex={0}
role="link"
> >
<!-- Select asset button --> <!-- Select asset button -->
{#if !usingMobileDevice && mouseOver && !disableLinkMouseOver} {#if !usingMobileDevice && mouseOver && !disableLinkMouseOver}

View File

@ -1,38 +1,38 @@
<script lang="ts"> <script lang="ts">
import { afterNavigate, beforeNavigate, goto } from '$app/navigation'; import { afterNavigate, beforeNavigate, goto } from '$app/navigation';
import { page } from '$app/stores';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut'; import { shortcuts, type ShortcutOptions } from '$lib/actions/shortcut';
import type { Action } from '$lib/components/asset-viewer/actions/action'; import type { Action } from '$lib/components/asset-viewer/actions/action';
import {
setFocusToAsset as setFocusAssetInit,
setFocusTo as setFocusToInit,
} from '$lib/components/photos-page/actions/focus-actions';
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
import SelectDate from '$lib/components/shared-components/select-date.svelte';
import { AppRoute, AssetAction } from '$lib/constants'; import { AppRoute, AssetAction } from '$lib/constants';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetBucket, assetsSnapshot, AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte'; import { AssetBucket, assetsSnapshot, AssetStore, isSelectingAllAssets } from '$lib/stores/assets-store.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { showDeleteModal } from '$lib/stores/preferences.store'; import { showDeleteModal } from '$lib/stores/preferences.store';
import { searchStore } from '$lib/stores/search.svelte'; import { searchStore } from '$lib/stores/search.svelte';
import { featureFlags } from '$lib/stores/server-config.store'; import { featureFlags } from '$lib/stores/server-config.store';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions'; import { deleteAssets, updateStackedAssetInTimeline, updateUnstackedAssetInTimeline } from '$lib/utils/actions';
import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils'; import { archiveAssets, cancelMultiselect, selectAllAssets, stackAssets } from '$lib/utils/asset-utils';
import { focusNext } from '$lib/utils/focus-util';
import { navigate } from '$lib/utils/navigation'; import { navigate } from '$lib/utils/navigation';
import { type ScrubberListener } from '$lib/utils/timeline-util'; import { type ScrubberListener } from '$lib/utils/timeline-util';
import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk'; import type { AlbumResponseDto, AssetResponseDto, PersonResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { onMount, type Snippet } from 'svelte'; import { onMount, type Snippet } from 'svelte';
import type { UpdatePayload } from 'vite';
import Portal from '../shared-components/portal/portal.svelte'; import Portal from '../shared-components/portal/portal.svelte';
import Scrubber from '../shared-components/scrubber/scrubber.svelte'; import Scrubber from '../shared-components/scrubber/scrubber.svelte';
import ShowShortcuts from '../shared-components/show-shortcuts.svelte'; import ShowShortcuts from '../shared-components/show-shortcuts.svelte';
import AssetDateGroup from './asset-date-group.svelte'; import AssetDateGroup from './asset-date-group.svelte';
import DeleteAssetDialog from './delete-asset-dialog.svelte'; import DeleteAssetDialog from './delete-asset-dialog.svelte';
import { resizeObserver, type OnResizeCallback } from '$lib/actions/resize-observer';
import Skeleton from '$lib/components/photos-page/skeleton.svelte';
import { page } from '$app/stores';
import type { UpdatePayload } from 'vite';
import type { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { mobileDevice } from '$lib/stores/mobile-device.svelte';
import { focusNext } from '$lib/utils/focus-util';
import SelectDate from '$lib/components/shared-components/select-date.svelte';
import { DateTime } from 'luxon';
import {
setFocusTo as setFocusToInit,
setFocusToAsset as setFocusAssetInit,
} from '$lib/components/photos-page/actions/focus-actions';
interface Props { interface Props {
isSelectionMode?: boolean; isSelectionMode?: boolean;

View 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();

View File

@ -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);
};

View File

@ -22,9 +22,10 @@
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte'; import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import RemoveFromAlbum from '$lib/components/photos-page/actions/remove-from-album.svelte'; import RemoveFromAlbum from '$lib/components/photos-page/actions/remove-from-album.svelte';
import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte'; import SelectAllAssets from '$lib/components/photos-page/actions/select-all-assets.svelte';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
import AssetGrid from '$lib/components/photos-page/asset-grid.svelte'; import AssetGrid from '$lib/components/photos-page/asset-grid.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte'; import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte'; import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte'; import CreateSharedLinkModal from '$lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte';
@ -33,14 +34,16 @@
notificationController, notificationController,
} from '$lib/components/shared-components/notification/notification'; } from '$lib/components/shared-components/notification/notification';
import UserAvatar from '$lib/components/shared-components/user-avatar.svelte'; import UserAvatar from '$lib/components/shared-components/user-avatar.svelte';
import { AppRoute, AlbumPageViewMode } from '$lib/constants'; import { AlbumPageViewMode, AppRoute } from '$lib/constants';
import { numberOfComments, setNumberOfComments, updateNumberOfComments } from '$lib/stores/activity.store'; import { activityManager } from '$lib/managers/activity-manager.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { AssetStore } from '$lib/stores/assets-store.svelte'; import { AssetStore } from '$lib/stores/assets-store.svelte';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { preferences, user } from '$lib/stores/user.store'; import { preferences, user } from '$lib/stores/user.store';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { downloadAlbum, cancelMultiselect } from '$lib/utils/asset-utils'; import { confirmAlbumDelete } from '$lib/utils/album-utils';
import { cancelMultiselect, downloadAlbum } from '$lib/utils/asset-utils';
import { openFileUploadDialog } from '$lib/utils/file-uploader'; import { openFileUploadDialog } from '$lib/utils/file-uploader';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { import {
@ -53,18 +56,11 @@
import { import {
AlbumUserRole, AlbumUserRole,
AssetOrder, AssetOrder,
ReactionLevel,
ReactionType,
addAssetsToAlbum, addAssetsToAlbum,
addUsersToAlbum, addUsersToAlbum,
createActivity,
deleteActivity,
deleteAlbum, deleteAlbum,
getActivities,
getActivityStatistics,
getAlbumInfo, getAlbumInfo,
updateAlbumInfo, updateAlbumInfo,
type ActivityResponseDto,
type AlbumUserAddDto, type AlbumUserAddDto,
} from '@immich/sdk'; } from '@immich/sdk';
import { import {
@ -80,13 +76,10 @@
mdiPresentationPlay, mdiPresentationPlay,
mdiShareVariantOutline, mdiShareVariantOutline,
} from '@mdi/js'; } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
import { fly } from 'svelte/transition'; import { fly } from 'svelte/transition';
import type { PageData } from './$types'; import type { PageData } from './$types';
import { t } from 'svelte-i18n';
import { onDestroy } from 'svelte';
import { confirmAlbumDelete } from '$lib/utils/album-utils';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
interface Props { interface Props {
data: PageData; data: PageData;
@ -103,8 +96,6 @@
let viewMode: AlbumPageViewMode = $state(AlbumPageViewMode.VIEW); let viewMode: AlbumPageViewMode = $state(AlbumPageViewMode.VIEW);
let isCreatingSharedAlbum = $state(false); let isCreatingSharedAlbum = $state(false);
let isShowActivity = $state(false); let isShowActivity = $state(false);
let isLiked: ActivityResponseDto | null = $state(null);
let reactions: ActivityResponseDto[] = $state([]);
let albumOrder: AssetOrder | undefined = $state(data.album.order); let albumOrder: AssetOrder | undefined = $state(data.album.order);
const assetInteraction = new AssetInteraction(); const assetInteraction = new AssetInteraction();
@ -154,44 +145,15 @@
const handleFavorite = async () => { const handleFavorite = async () => {
try { try {
if (isLiked) { await activityManager.toggleLike();
const activityId = isLiked.id;
await deleteActivity({ id: activityId });
reactions = reactions.filter((reaction) => reaction.id !== activityId);
isLiked = null;
} else {
isLiked = await createActivity({
activityCreateDto: { albumId: album.id, type: ReactionType.Like },
});
reactions = [...reactions, isLiked];
}
} catch (error) { } catch (error) {
handleError(error, $t('errors.cant_change_asset_favorite')); handleError(error, $t('errors.cant_change_asset_favorite'));
} }
}; };
const getFavorite = async () => { const updateComments = async () => {
if ($user) {
try { try {
const data = await getActivities({ await activityManager.refreshActivities(album.id);
userId: $user.id,
albumId: album.id,
$type: ReactionType.Like,
level: ReactionLevel.Album,
});
if (data.length > 0) {
isLiked = data[0];
}
} catch (error) {
handleError(error, $t('errors.unable_to_load_liked_status'));
}
}
};
const getNumberOfComments = async () => {
try {
const { comments } = await getActivityStatistics({ albumId: album.id });
setNumberOfComments(comments);
} catch (error) { } catch (error) {
handleError(error, $t('errors.cant_get_number_of_comments')); handleError(error, $t('errors.cant_get_number_of_comments'));
} }
@ -398,7 +360,7 @@
let albumId = $derived(album.id); let albumId = $derived(album.id);
$effect(() => { $effect(() => {
if (!album.isActivityEnabled && $numberOfComments === 0) { if (!album.isActivityEnabled && activityManager.commentCount === 0) {
isShowActivity = false; isShowActivity = false;
} }
}); });
@ -412,7 +374,16 @@
void assetStore.updateOptions({ isArchived: false, withPartners: true, timelineAlbumId: albumId }); void assetStore.updateOptions({ isArchived: false, withPartners: true, timelineAlbumId: albumId });
} }
}); });
onDestroy(() => assetStore.destroy());
$effect(() => {
activityManager.reset();
activityManager.init(album.id);
});
onDestroy(() => {
activityManager.reset();
assetStore.destroy();
});
// let timelineStore = new AssetStore(); // let timelineStore = new AssetStore();
// $effect(() => void timelineStore.updateOptions({ isArchived: false, withPartners: true, timelineAlbumId: albumId })); // $effect(() => void timelineStore.updateOptions({ isArchived: false, withPartners: true, timelineAlbumId: albumId }));
// onDestroy(() => timelineStore.destroy()); // onDestroy(() => timelineStore.destroy());
@ -420,7 +391,7 @@
let isOwned = $derived($user.id == album.ownerId); let isOwned = $derived($user.id == album.ownerId);
let showActivityStatus = $derived( let showActivityStatus = $derived(
album.albumUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || $numberOfComments > 0), album.albumUsers.length > 0 && !$showAssetViewer && (album.isActivityEnabled || activityManager.commentCount > 0),
); );
let isEditor = $derived( let isEditor = $derived(
album.albumUsers.find(({ user: { id } }) => id === $user.id)?.role === AlbumUserRole.Editor || album.albumUsers.find(({ user: { id } }) => id === $user.id)?.role === AlbumUserRole.Editor ||
@ -430,8 +401,7 @@
let albumHasViewers = $derived(album.albumUsers.some(({ role }) => role === AlbumUserRole.Viewer)); let albumHasViewers = $derived(album.albumUsers.some(({ role }) => role === AlbumUserRole.Viewer));
$effect(() => { $effect(() => {
if (album.albumUsers.length > 0) { if (album.albumUsers.length > 0) {
handlePromiseError(getFavorite()); handlePromiseError(updateComments());
handlePromiseError(getNumberOfComments());
} }
}); });
const isShared = $derived(viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : album.albumUsers.length > 0); const isShared = $derived(viewMode === AlbumPageViewMode.SELECT_ASSETS ? false : album.albumUsers.length > 0);
@ -711,8 +681,8 @@
<div class="absolute z-[2] bottom-0 end-0 mb-6 me-6 justify-self-end"> <div class="absolute z-[2] bottom-0 end-0 mb-6 me-6 justify-self-end">
<ActivityStatus <ActivityStatus
disabled={!album.isActivityEnabled} disabled={!album.isActivityEnabled}
{isLiked} isLiked={activityManager.isLiked}
numberOfComments={$numberOfComments} numberOfComments={activityManager.commentCount}
onFavorite={handleFavorite} onFavorite={handleFavorite}
onOpenActivityTab={handleOpenAndCloseActivityTab} onOpenActivityTab={handleOpenAndCloseActivityTab}
/> />
@ -733,11 +703,6 @@
disabled={!album.isActivityEnabled} disabled={!album.isActivityEnabled}
albumOwnerId={album.ownerId} albumOwnerId={album.ownerId}
albumId={album.id} albumId={album.id}
{isLiked}
bind:reactions
onAddComment={() => updateNumberOfComments(1)}
onDeleteComment={() => updateNumberOfComments(-1)}
onDeleteLike={() => (isLiked = null)}
onClose={handleOpenAndCloseActivityTab} onClose={handleOpenAndCloseActivityTab}
/> />
</div> </div>

View File

@ -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>

View File

@ -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;