From 6982987f3f89d2a2ea4229774635bc320ec1a108 Mon Sep 17 00:00:00 2001 From: Jonathan Jogenfors Date: Fri, 20 Feb 2026 23:40:23 +0100 Subject: [PATCH] feat: add offline library statistics --- .../lib/model/library_stats_response_dto.dart | 11 ++++- open-api/immich-openapi-specs.json | 6 +++ open-api/typescript-sdk/src/fetch-client.ts | 2 + server/src/dtos/library.dto.ts | 3 ++ server/src/queries/library.repository.sql | 18 +++++-- server/src/repositories/library.repository.ts | 22 ++++++++- server/src/services/library.service.spec.ts | 9 +++- .../ServerStatisticsCard.svelte | 19 ++++--- .../library-management/(list)/+layout.svelte | 46 ++++++++++++++--- .../library-management/(list)/+layout.ts | 4 +- .../library-management/[id]/+layout.svelte | 49 ++++++++++++++++--- .../admin/library-management/[id]/+layout.ts | 4 +- .../routes/admin/server-status/+page.svelte | 21 ++++++-- web/src/routes/admin/server-status/+page.ts | 4 +- 14 files changed, 181 insertions(+), 37 deletions(-) diff --git a/mobile/openapi/lib/model/library_stats_response_dto.dart b/mobile/openapi/lib/model/library_stats_response_dto.dart index 6eec3ae8d7..220605b388 100644 --- a/mobile/openapi/lib/model/library_stats_response_dto.dart +++ b/mobile/openapi/lib/model/library_stats_response_dto.dart @@ -13,12 +13,16 @@ part of openapi.api; class LibraryStatsResponseDto { /// Returns a new [LibraryStatsResponseDto] instance. LibraryStatsResponseDto({ + this.offline = 0, this.photos = 0, this.total = 0, this.usage = 0, this.videos = 0, }); + /// Number of offline assets + int offline; + /// Number of photos int photos; @@ -33,6 +37,7 @@ class LibraryStatsResponseDto { @override bool operator ==(Object other) => identical(this, other) || other is LibraryStatsResponseDto && + other.offline == offline && other.photos == photos && other.total == total && other.usage == usage && @@ -41,16 +46,18 @@ class LibraryStatsResponseDto { @override int get hashCode => // ignore: unnecessary_parenthesis + (offline.hashCode) + (photos.hashCode) + (total.hashCode) + (usage.hashCode) + (videos.hashCode); @override - String toString() => 'LibraryStatsResponseDto[photos=$photos, total=$total, usage=$usage, videos=$videos]'; + String toString() => 'LibraryStatsResponseDto[offline=$offline, photos=$photos, total=$total, usage=$usage, videos=$videos]'; Map toJson() { final json = {}; + json[r'offline'] = this.offline; json[r'photos'] = this.photos; json[r'total'] = this.total; json[r'usage'] = this.usage; @@ -67,6 +74,7 @@ class LibraryStatsResponseDto { final json = value.cast(); return LibraryStatsResponseDto( + offline: mapValueOfType(json, r'offline')!, photos: mapValueOfType(json, r'photos')!, total: mapValueOfType(json, r'total')!, usage: mapValueOfType(json, r'usage')!, @@ -118,6 +126,7 @@ class LibraryStatsResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'offline', 'photos', 'total', 'usage', diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 0e57fc4819..3d2d5df59d 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -18264,6 +18264,11 @@ }, "LibraryStatsResponseDto": { "properties": { + "offline": { + "default": 0, + "description": "Number of offline assets", + "type": "integer" + }, "photos": { "default": 0, "description": "Number of photos", @@ -18287,6 +18292,7 @@ } }, "required": [ + "offline", "photos", "total", "usage", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index acd8109cd3..99cb20574a 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1323,6 +1323,8 @@ export type UpdateLibraryDto = { name?: string; }; export type LibraryStatsResponseDto = { + /** Number of offline assets */ + offline: number; /** Number of photos */ photos: number; /** Total number of assets */ diff --git a/server/src/dtos/library.dto.ts b/server/src/dtos/library.dto.ts index 3f71b8a0ed..b3265a0b3a 100644 --- a/server/src/dtos/library.dto.ts +++ b/server/src/dtos/library.dto.ts @@ -136,6 +136,9 @@ export class LibraryStatsResponseDto { @ApiProperty({ type: 'integer', description: 'Total number of assets' }) total = 0; + @ApiProperty({ type: 'integer', description: 'Number of offline assets' }) + offline = 0; + @ApiProperty({ type: 'integer', format: 'int64', description: 'Storage usage in bytes' }) usage = 0; } diff --git a/server/src/queries/library.repository.sql b/server/src/queries/library.repository.sql index f0bd05973f..1766b9017b 100644 --- a/server/src/queries/library.repository.sql +++ b/server/src/queries/library.repository.sql @@ -36,27 +36,37 @@ select ( "asset"."type" = $1 and "asset"."visibility" != $2 + and "asset"."isOffline" = $3 ) ) as "photos", count(*) filter ( where ( - "asset"."type" = $3 - and "asset"."visibility" != $4 + "asset"."type" = $4 + and "asset"."visibility" != $5 + and "asset"."isOffline" = $6 ) ) as "videos", - coalesce(sum("asset_exif"."fileSizeInByte"), $5) as "usage" + count(*) filter ( + where + ( + "asset"."isOffline" = $7 + and "asset"."visibility" != $8 + ) + ) as "offline", + coalesce(sum("asset_exif"."fileSizeInByte"), $9) as "usage" from "library" inner join "asset" on "asset"."libraryId" = "library"."id" left join "asset_exif" on "asset_exif"."assetId" = "asset"."id" where - "library"."id" = $6 + "library"."id" = $10 group by "library"."id" select 0::int as "photos", 0::int as "videos", + 0::int as "offline", 0::int as "usage", 0::int as "total" from diff --git a/server/src/repositories/library.repository.ts b/server/src/repositories/library.repository.ts index 68102ab765..4341f940cc 100644 --- a/server/src/repositories/library.repository.ts +++ b/server/src/repositories/library.repository.ts @@ -79,7 +79,11 @@ export class LibraryRepository { eb.fn .countAll() .filterWhere((eb) => - eb.and([eb('asset.type', '=', AssetType.Image), eb('asset.visibility', '!=', AssetVisibility.Hidden)]), + eb.and([ + eb('asset.type', '=', AssetType.Image), + eb('asset.visibility', '!=', AssetVisibility.Hidden), + eb('asset.isOffline', '=', false), + ]), ) .as('photos'), ) @@ -87,10 +91,22 @@ export class LibraryRepository { eb.fn .countAll() .filterWhere((eb) => - eb.and([eb('asset.type', '=', AssetType.Video), eb('asset.visibility', '!=', AssetVisibility.Hidden)]), + eb.and([ + eb('asset.type', '=', AssetType.Video), + eb('asset.visibility', '!=', AssetVisibility.Hidden), + eb('asset.isOffline', '=', false), + ]), ) .as('videos'), ) + .select((eb) => + eb.fn + .countAll() + .filterWhere((eb) => + eb.and([eb('asset.isOffline', '=', true), eb('asset.visibility', '!=', AssetVisibility.Hidden)]), + ) + .as('offline'), + ) .select((eb) => eb.fn.coalesce((eb) => eb.fn.sum('asset_exif.fileSizeInByte'), eb.val(0)).as('usage')) .groupBy('library.id') .where('library.id', '=', id) @@ -103,6 +119,7 @@ export class LibraryRepository { .selectFrom('library') .select(zero.as('photos')) .select(zero.as('videos')) + .select(zero.as('offline')) .select(zero.as('usage')) .select(zero.as('total')) .where('library.id', '=', id) @@ -112,6 +129,7 @@ export class LibraryRepository { return { photos: stats.photos, videos: stats.videos, + offline: stats.offline, usage: stats.usage, total: stats.photos + stats.videos, }; diff --git a/server/src/services/library.service.spec.ts b/server/src/services/library.service.spec.ts index d0c2d0a785..2c447bfad8 100644 --- a/server/src/services/library.service.spec.ts +++ b/server/src/services/library.service.spec.ts @@ -681,12 +681,19 @@ describe(LibraryService.name, () => { it('should return library statistics', async () => { const library = factory.library(); - mocks.library.getStatistics.mockResolvedValue({ photos: 10, videos: 0, total: 10, usage: 1337 }); + mocks.library.getStatistics.mockResolvedValue({ + photos: 10, + videos: 0, + total: 10, + usage: 1337, + offline: 67, + }); await expect(sut.getStatistics(library.id)).resolves.toEqual({ photos: 10, videos: 0, total: 10, usage: 1337, + offline: 67, }); expect(mocks.library.getStatistics).toHaveBeenCalledWith(library.id); diff --git a/web/src/lib/components/server-statistics/ServerStatisticsCard.svelte b/web/src/lib/components/server-statistics/ServerStatisticsCard.svelte index 5d4291472b..b978ae9b33 100644 --- a/web/src/lib/components/server-statistics/ServerStatisticsCard.svelte +++ b/web/src/lib/components/server-statistics/ServerStatisticsCard.svelte @@ -1,17 +1,20 @@