diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index b336b1bfb6..e03f4dac77 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -130,8 +130,8 @@ Class | Method | HTTP request | Description *LibrariesApi* | [**createLibrary**](doc//LibrariesApi.md#createlibrary) | **POST** /libraries | *LibrariesApi* | [**deleteLibrary**](doc//LibrariesApi.md#deletelibrary) | **DELETE** /libraries/{id} | *LibrariesApi* | [**getAllLibraries**](doc//LibrariesApi.md#getalllibraries) | **GET** /libraries | +*LibrariesApi* | [**getAssetCount**](doc//LibrariesApi.md#getassetcount) | **GET** /libraries/{id}/count | *LibrariesApi* | [**getLibrary**](doc//LibrariesApi.md#getlibrary) | **GET** /libraries/{id} | -*LibrariesApi* | [**getLibraryStatistics**](doc//LibrariesApi.md#getlibrarystatistics) | **GET** /libraries/{id}/statistics | *LibrariesApi* | [**scanLibrary**](doc//LibrariesApi.md#scanlibrary) | **POST** /libraries/{id}/scan | *LibrariesApi* | [**updateLibrary**](doc//LibrariesApi.md#updatelibrary) | **PUT** /libraries/{id} | *LibrariesApi* | [**validate**](doc//LibrariesApi.md#validate) | **POST** /libraries/{id}/validate | @@ -337,7 +337,6 @@ Class | Method | HTTP request | Description - [JobSettingsDto](doc//JobSettingsDto.md) - [JobStatusDto](doc//JobStatusDto.md) - [LibraryResponseDto](doc//LibraryResponseDto.md) - - [LibraryStatsResponseDto](doc//LibraryStatsResponseDto.md) - [LicenseKeyDto](doc//LicenseKeyDto.md) - [LicenseResponseDto](doc//LicenseResponseDto.md) - [LogLevel](doc//LogLevel.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 73eb02d89e..3fccede06e 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -150,7 +150,6 @@ part 'model/job_name.dart'; part 'model/job_settings_dto.dart'; part 'model/job_status_dto.dart'; part 'model/library_response_dto.dart'; -part 'model/library_stats_response_dto.dart'; part 'model/license_key_dto.dart'; part 'model/license_response_dto.dart'; part 'model/log_level.dart'; diff --git a/mobile/openapi/lib/api/libraries_api.dart b/mobile/openapi/lib/api/libraries_api.dart index 36d98d9a88..6010b7a9fc 100644 --- a/mobile/openapi/lib/api/libraries_api.dart +++ b/mobile/openapi/lib/api/libraries_api.dart @@ -147,6 +147,54 @@ class LibrariesApi { return null; } + /// Performs an HTTP 'GET /libraries/{id}/count' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future getAssetCountWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final path = r'/libraries/{id}/count' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future getAssetCount(String id,) async { + final response = await getAssetCountWithHttpInfo(id,); + 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), 'num',) as num; + + } + return null; + } + /// Performs an HTTP 'GET /libraries/{id}' operation and returns the [Response]. /// Parameters: /// @@ -195,54 +243,6 @@ class LibrariesApi { return null; } - /// Performs an HTTP 'GET /libraries/{id}/statistics' operation and returns the [Response]. - /// Parameters: - /// - /// * [String] id (required): - Future getLibraryStatisticsWithHttpInfo(String id,) async { - // ignore: prefer_const_declarations - final path = r'/libraries/{id}/statistics' - .replaceAll('{id}', id); - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [String] id (required): - Future getLibraryStatistics(String id,) async { - final response = await getLibraryStatisticsWithHttpInfo(id,); - 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), 'LibraryStatsResponseDto',) as LibraryStatsResponseDto; - - } - return null; - } - /// Performs an HTTP 'POST /libraries/{id}/scan' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index a6f8d551da..aa5db6589b 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -354,8 +354,6 @@ class ApiClient { return JobStatusDto.fromJson(value); case 'LibraryResponseDto': return LibraryResponseDto.fromJson(value); - case 'LibraryStatsResponseDto': - return LibraryStatsResponseDto.fromJson(value); case 'LicenseKeyDto': return LicenseKeyDto.fromJson(value); case 'LicenseResponseDto': diff --git a/mobile/openapi/lib/model/library_stats_response_dto.dart b/mobile/openapi/lib/model/library_stats_response_dto.dart deleted file mode 100644 index afe67da31a..0000000000 --- a/mobile/openapi/lib/model/library_stats_response_dto.dart +++ /dev/null @@ -1,123 +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 LibraryStatsResponseDto { - /// Returns a new [LibraryStatsResponseDto] instance. - LibraryStatsResponseDto({ - this.photos = 0, - this.total = 0, - this.usage = 0, - this.videos = 0, - }); - - int photos; - - int total; - - int usage; - - int videos; - - @override - bool operator ==(Object other) => identical(this, other) || other is LibraryStatsResponseDto && - other.photos == photos && - other.total == total && - other.usage == usage && - other.videos == videos; - - @override - int get hashCode => - // ignore: unnecessary_parenthesis - (photos.hashCode) + - (total.hashCode) + - (usage.hashCode) + - (videos.hashCode); - - @override - String toString() => 'LibraryStatsResponseDto[photos=$photos, total=$total, usage=$usage, videos=$videos]'; - - Map toJson() { - final json = {}; - json[r'photos'] = this.photos; - json[r'total'] = this.total; - json[r'usage'] = this.usage; - json[r'videos'] = this.videos; - return json; - } - - /// Returns a new [LibraryStatsResponseDto] instance and imports its values from - /// [value] if it's a [Map], null otherwise. - // ignore: prefer_constructors_over_static_methods - static LibraryStatsResponseDto? fromJson(dynamic value) { - upgradeDto(value, "LibraryStatsResponseDto"); - if (value is Map) { - final json = value.cast(); - - return LibraryStatsResponseDto( - photos: mapValueOfType(json, r'photos')!, - total: mapValueOfType(json, r'total')!, - usage: mapValueOfType(json, r'usage')!, - videos: mapValueOfType(json, r'videos')!, - ); - } - return null; - } - - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; - if (json is List && json.isNotEmpty) { - for (final row in json) { - final value = LibraryStatsResponseDto.fromJson(row); - if (value != null) { - result.add(value); - } - } - } - return result.toList(growable: growable); - } - - static Map mapFromJson(dynamic json) { - final map = {}; - if (json is Map && json.isNotEmpty) { - json = json.cast(); // ignore: parameter_assignments - for (final entry in json.entries) { - final value = LibraryStatsResponseDto.fromJson(entry.value); - if (value != null) { - map[entry.key] = value; - } - } - } - return map; - } - - // maps a json object with a list of LibraryStatsResponseDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; - if (json is Map && json.isNotEmpty) { - // ignore: parameter_assignments - json = json.cast(); - for (final entry in json.entries) { - map[entry.key] = LibraryStatsResponseDto.listFromJson(entry.value, growable: growable,); - } - } - return map; - } - - /// The list of required keys that must be present in a JSON. - static const requiredKeys = { - 'photos', - 'total', - 'usage', - 'videos', - }; -} - diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 7c8aba3b5e..554fed25d6 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2853,9 +2853,9 @@ ] } }, - "/libraries/{id}/scan": { - "post": { - "operationId": "scanLibrary", + "/libraries/{id}/count": { + "get": { + "operationId": "getAssetCount", "parameters": [ { "name": "id", @@ -2868,7 +2868,14 @@ } ], "responses": { - "204": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "number" + } + } + }, "description": "" } }, @@ -2888,9 +2895,9 @@ ] } }, - "/libraries/{id}/statistics": { - "get": { - "operationId": "getLibraryStatistics", + "/libraries/{id}/scan": { + "post": { + "operationId": "scanLibrary", "parameters": [ { "name": "id", @@ -2903,14 +2910,7 @@ } ], "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/LibraryStatsResponseDto" - } - } - }, + "204": { "description": "" } }, @@ -9464,34 +9464,6 @@ ], "type": "object" }, - "LibraryStatsResponseDto": { - "properties": { - "photos": { - "default": 0, - "type": "integer" - }, - "total": { - "default": 0, - "type": "integer" - }, - "usage": { - "default": 0, - "format": "int64", - "type": "integer" - }, - "videos": { - "default": 0, - "type": "integer" - } - }, - "required": [ - "photos", - "total", - "usage", - "videos" - ], - "type": "object" - }, "LicenseKeyDto": { "properties": { "activationKey": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index c31e71d05e..f441f47fc5 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -574,12 +574,6 @@ export type UpdateLibraryDto = { importPaths?: string[]; name?: string; }; -export type LibraryStatsResponseDto = { - photos: number; - total: number; - usage: number; - videos: number; -}; export type ValidateLibraryDto = { exclusionPatterns?: string[]; importPaths?: string[]; @@ -2099,6 +2093,16 @@ export function updateLibrary({ id, updateLibraryDto }: { body: updateLibraryDto }))); } +export function getAssetCount({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: number; + }>(`/libraries/${encodeURIComponent(id)}/count`, { + ...opts + })); +} export function scanLibrary({ id }: { id: string; }, opts?: Oazapfts.RequestOpts) { @@ -2107,16 +2111,6 @@ export function scanLibrary({ id }: { method: "POST" })); } -export function getLibraryStatistics({ id }: { - id: string; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: LibraryStatsResponseDto; - }>(`/libraries/${encodeURIComponent(id)}/statistics`, { - ...opts - })); -} export function validate({ id, validateLibraryDto }: { id: string; validateLibraryDto: ValidateLibraryDto; diff --git a/server/src/controllers/library.controller.ts b/server/src/controllers/library.controller.ts index b8959ca288..adf0f6c106 100644 --- a/server/src/controllers/library.controller.ts +++ b/server/src/controllers/library.controller.ts @@ -57,10 +57,10 @@ export class LibraryController { return this.service.validate(id, dto); } - @Get(':id/statistics') + @Get(':id/count') @Authenticated({ permission: Permission.LIBRARY_STATISTICS, admin: true }) - getLibraryStatistics(@Param() { id }: UUIDParamDto): Promise { - return this.service.getStatistics(id); + getAssetCount(@Param() { id }: UUIDParamDto): Promise { + return this.service.getAssetCount(id); } @Post(':id/scan') diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index b388a23392..f9e9a4dd21 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -201,5 +201,5 @@ export interface IAssetRepository { upsertFiles(files: UpsertFileOptions[]): Promise; updateOffline(library: LibraryEntity): Promise; getNewPaths(libraryId: string, paths: string[]): Promise; - getAssetCount(id: string, options: AssetSearchOptions): Promise; + getAssetCount(options: AssetSearchOptions): Promise; } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 6f8d81408e..cc01d0c9be 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -786,7 +786,7 @@ export class AssetRepository implements IAssetRepository { .then((result) => result.map((row: { path: string }) => row.path)); } - async getAssetCount(id: string, options: AssetSearchOptions = {}): Promise { + async getAssetCount(options: AssetSearchOptions = {}): Promise { let builder = this.repository.createQueryBuilder('asset').leftJoinAndSelect('asset.files', 'files'); builder = searchAssetBuilder(builder, options); return builder.getCount(); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 8751037119..f2bc09c907 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -249,7 +249,12 @@ export class AssetService extends BaseService { const { thumbnailFile, previewFile } = getAssetFiles(asset.files); const files = [thumbnailFile?.path, previewFile?.path, asset.encodedVideoPath]; - if (deleteOnDisk) { + + if (deleteOnDisk && !asset.isOffline) { + /* We don't want to delete an offline asset because it is either... + ...missing from disk => don't delete the file since it doesn't exist where we expect + ...outside of any import path => don't delete the file since we're not responsible for it + ...matching an exclusion pattern => don't delete the file since it's excluded */ files.push(asset.sidecarPath, asset.originalPath); } diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 2faed0a516..a9a430858e 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -266,7 +266,7 @@ export class JobService extends BaseService { } case JobName.GENERATE_THUMBNAILS: { - if (!item.data.notify && item.data.source !== 'upload') { + if (!item.data.notify && item.data.source !== 'upload' && item.data.source !== 'library-import') { break; } diff --git a/server/src/services/library.service.ts b/server/src/services/library.service.ts index 7fbaa40f6f..3c4e7f7a28 100644 --- a/server/src/services/library.service.ts +++ b/server/src/services/library.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable, InternalServerErrorException } from '@nestjs/common'; import { R_OK } from 'node:constants'; import path, { basename, isAbsolute, parse } from 'node:path'; import picomatch from 'picomatch'; @@ -174,12 +174,12 @@ export class LibraryService extends BaseService { } } - async getStatistics(id: string): Promise { - const statistics = await this.libraryRepository.getStatistics(id); - if (!statistics) { - throw new BadRequestException(`Library ${id} not found`); + async getAssetCount(id: string): Promise { + const count = await this.assetRepository.getAssetCount({ libraryId: id }); + if (count == undefined) { + throw new InternalServerErrorException(`Failed to get asset count for library ${id}`); } - return statistics; + return count; } async get(id: string): Promise { @@ -354,7 +354,8 @@ export class LibraryService extends BaseService { private processEntity(filePath: string, ownerId: string, libraryId: string): AssetCreate { const assetPath = path.normalize(filePath); - const now = new Date(); + // This date will be set until metadata extraction runs + const datePlaceholder = new Date('1900-01-01'); return { ownerId: ownerId, @@ -365,9 +366,9 @@ export class LibraryService extends BaseService { // TODO: device asset id is deprecated, remove it deviceAssetId: `${basename(assetPath)}`.replaceAll(/\s+/g, ''), deviceId: 'Library Import', - fileCreatedAt: now, - fileModifiedAt: now, - localDateTime: now, + fileCreatedAt: datePlaceholder, + fileModifiedAt: datePlaceholder, + localDateTime: datePlaceholder, type: mimeTypes.isVideo(assetPath) ? AssetType.VIDEO : AssetType.IMAGE, originalFileName: parse(assetPath).base, isExternal: true, @@ -620,7 +621,7 @@ export class LibraryService extends BaseService { return JobStatus.SKIPPED; } - const assetCount = await this.assetRepository.getAssetCount(library.id, { withDeleted: true }); + const assetCount = await this.assetRepository.getAssetCount({ libraryId: job.id, withDeleted: true }); if (!assetCount) { this.logger.log(`Library ${library.id} is empty, no need to check assets`); diff --git a/server/src/services/trash.service.ts b/server/src/services/trash.service.ts index 621dee0f81..549963772d 100644 --- a/server/src/services/trash.service.ts +++ b/server/src/services/trash.service.ts @@ -52,7 +52,7 @@ export class TrashService extends BaseService { ); for await (const assetIds of assetPagination) { - this.logger.debug(`Queueing ${assetIds.length} assets for deletion from the trash`); + this.logger.debug(`Queueing ${assetIds.length} asset(s) for deletion from the trash`); count += assetIds.length; await this.jobRepository.queueAll( assetIds.map((assetId) => ({ diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte index b89e81ebf6..20d35ff76d 100644 --- a/web/src/routes/admin/library-management/+page.svelte +++ b/web/src/routes/admin/library-management/+page.svelte @@ -12,18 +12,16 @@ notificationController, NotificationType, } from '$lib/components/shared-components/notification/notification'; - import { ByteUnit, getBytesWithUnit } from '$lib/utils/byte-units'; import { handleError } from '$lib/utils/handle-error'; import { createLibrary, deleteLibrary, getAllLibraries, - getLibraryStatistics, + getAssetCount, getUserAdmin, scanLibrary, updateLibrary, type LibraryResponseDto, - type LibraryStatsResponseDto, type UserResponseDto, } from '@immich/sdk'; import { mdiDatabase, mdiDotsVertical, mdiPlusBoxOutline, mdiSync } from '@mdi/js'; @@ -44,13 +42,8 @@ let libraries: LibraryResponseDto[] = $state([]); - let stats: LibraryStatsResponseDto[] = []; let owner: UserResponseDto[] = $state([]); - let photos: number[] = []; - let videos: number[] = []; - let totalCount: number[] = $state([]); - let diskUsage: number[] = $state([]); - let diskUsageUnit: ByteUnit[] = $state([]); + let assetCount: number[] = $state([]); let editImportPaths: number | undefined = $state(); let editScanSettings: number | undefined = $state(); let renameLibrary: number | undefined = $state(); @@ -74,12 +67,8 @@ }; const refreshStats = async (listIndex: number) => { - stats[listIndex] = await getLibraryStatistics({ id: libraries[listIndex].id }); + assetCount[listIndex] = await getAssetCount({ id: libraries[listIndex].id }); owner[listIndex] = await getUserAdmin({ id: libraries[listIndex].ownerId }); - photos[listIndex] = stats[listIndex].photos; - videos[listIndex] = stats[listIndex].videos; - totalCount[listIndex] = stats[listIndex].total; - [diskUsage[listIndex], diskUsageUnit[listIndex]] = getBytesWithUnit(stats[listIndex].usage, 0); }; async function readLibraryList() { @@ -190,10 +179,10 @@ } await refreshStats(index); - const assetCount = totalCount[index]; - if (assetCount > 0) { + const count = assetCount[index]; + if (count > 0) { const isConfirmed = await dialogController.show({ - prompt: $t('admin.confirm_delete_library_assets', { values: { count: assetCount } }), + prompt: $t('admin.confirm_delete_library_assets', { values: { count } }), }); if (!isConfirmed) { @@ -242,19 +231,18 @@ - + {$t('type')} {$t('name')} {$t('owner')} {$t('assets')} - {$t('size')} {#each libraries as library, index (library.id)} - {#if totalCount[index] == undefined} + {#if assetCount[index] == undefined} {:else} - {totalCount[index].toLocaleString($locale)} - {/if} - - - {#if diskUsage[index] == undefined} - - {:else} - {diskUsage[index]} - {diskUsageUnit[index]} + {assetCount[index].toLocaleString($locale)} {/if}