diff --git a/i18n/en.json b/i18n/en.json index 277db70a23f29..2972e0045c969 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1237,6 +1237,7 @@ "toggle_settings": "Toggle settings", "toggle_theme": "Toggle dark theme", "total_usage": "Total usage", + "transcoding": "Transcoding", "trash": "Trash", "trash_all": "Trash All", "trash_count": "Trash {count, number}", diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 20ebe607a41e5..7c603e9478c6e 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -10972,7 +10972,8 @@ "videos": 1, "diskUsageRaw": 2, "usagePhotos": 1, - "usageVideos": 1 + "usageVideos": 1, + "usageTranscode": 1 } ], "items": { @@ -10986,6 +10987,11 @@ "format": "int64", "type": "integer" }, + "usageTranscode": { + "default": 0, + "format": "int64", + "type": "integer" + }, "usageVideos": { "default": 0, "format": "int64", @@ -11001,6 +11007,7 @@ "usage", "usageByUser", "usagePhotos", + "usageTranscode", "usageVideos", "videos" ], @@ -12529,6 +12536,11 @@ "format": "int64", "type": "integer" }, + "usageTranscode": { + "default": 0, + "format": "int64", + "type": "integer" + }, "usageVideos": { "format": "int64", "type": "integer" @@ -12548,6 +12560,7 @@ "quotaSizeInBytes", "usage", "usagePhotos", + "usageTranscode", "usageVideos", "userId", "userName", diff --git a/server/src/dtos/server.dto.ts b/server/src/dtos/server.dto.ts index e1f94dbaa55b2..6d3754af764fa 100644 --- a/server/src/dtos/server.dto.ts +++ b/server/src/dtos/server.dto.ts @@ -90,6 +90,8 @@ export class UsageByUserDto { @ApiProperty({ type: 'integer', format: 'int64' }) usageVideos!: number; @ApiProperty({ type: 'integer', format: 'int64' }) + usageTranscode: number = 0; + @ApiProperty({ type: 'integer', format: 'int64' }) quotaSizeInBytes!: number | null; } @@ -109,6 +111,9 @@ export class ServerStatsResponseDto { @ApiProperty({ type: 'integer', format: 'int64' }) usageVideos = 0; + @ApiProperty({ type: 'integer', format: 'int64' }) + usageTranscode = 0; + @ApiProperty({ isArray: true, type: UsageByUserDto, @@ -120,6 +125,7 @@ export class ServerStatsResponseDto { diskUsageRaw: 2, usagePhotos: 1, usageVideos: 1, + usageTranscode: 1, }, ], }) diff --git a/server/src/interfaces/user.interface.ts b/server/src/interfaces/user.interface.ts index 385a4d3d50e91..4bfeecff09f5c 100644 --- a/server/src/interfaces/user.interface.ts +++ b/server/src/interfaces/user.interface.ts @@ -14,6 +14,7 @@ export interface UserStatsQueryResponse { usagePhotos: number; usageVideos: number; quotaSizeInBytes: number | null; + encodedVideoPaths: string[]; } export interface UserFindOptions { diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index a2e4375701a2a..de6574072e363 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -117,6 +117,10 @@ export class UserRepository implements IUserRepository { 'usageVideos', ) .addSelect('users.quotaSizeInBytes', 'quotaSizeInBytes') + .addSelect( + `ARRAY_AGG(assets.encodedVideoPath) FILTER (WHERE assets.encodedVideoPath IS NOT NULL)`, + 'encodedVideoPaths', + ) .leftJoin('users.assets', 'assets') .leftJoin('assets.exifInfo', 'exif') .groupBy('users.id') @@ -130,6 +134,7 @@ export class UserRepository implements IUserRepository { stat.usagePhotos = Number(stat.usagePhotos); stat.usageVideos = Number(stat.usageVideos); stat.quotaSizeInBytes = stat.quotaSizeInBytes; + stat.encodedVideoPaths = stat.encodedVideoPaths || []; } return stats; diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index 3f7fafcebf1dd..460fcd89d4860 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -1,3 +1,4 @@ +import * as fs from 'node:fs'; import { SystemMetadataKey } from 'src/enum'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; @@ -177,8 +178,16 @@ describe(ServerService.name, () => { }); }); + vi.mock('fs', async () => { + const actual = (await vi.importActual('fs'))!; + return { + ...actual, + statSync: vi.fn().mockReturnValue({ size: 500 }), + }; + }); + describe('getStats', () => { - it('should total up usage by user', async () => { + it('should total up usage by user and server', async () => { userMock.getUserStats.mockResolvedValue([ { userId: 'user1', @@ -189,6 +198,7 @@ describe(ServerService.name, () => { usagePhotos: 1, usageVideos: 11_345, quotaSizeInBytes: 0, + encodedVideoPaths: ['upload/encoded-video/video1.mp4'], }, { userId: 'user2', @@ -199,6 +209,7 @@ describe(ServerService.name, () => { usagePhotos: 100, usageVideos: 23_456, quotaSizeInBytes: 0, + encodedVideoPaths: ['upload/encoded-video/video2.mp4', 'upload/encoded-video/video3.mp4'], }, { userId: 'user3', @@ -209,15 +220,17 @@ describe(ServerService.name, () => { usagePhotos: 900, usageVideos: 87_654, quotaSizeInBytes: 0, + encodedVideoPaths: [''], }, ]); await expect(sut.getStatistics()).resolves.toEqual({ photos: 120, videos: 31, - usage: 1_123_455, + usage: 1_124_955, usagePhotos: 1001, usageVideos: 122_455, + usageTranscode: 1500, usageByUser: [ { photos: 10, @@ -228,6 +241,7 @@ describe(ServerService.name, () => { userName: '1 User', userId: 'user1', videos: 11, + usageTranscode: 500, }, { photos: 10, @@ -238,6 +252,7 @@ describe(ServerService.name, () => { userName: '2 User', userId: 'user2', videos: 20, + usageTranscode: 1000, }, { photos: 100, @@ -248,6 +263,7 @@ describe(ServerService.name, () => { userName: '3 User', userId: 'user3', videos: 0, + usageTranscode: 0, }, ], }); diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index e9dd908a7c3ac..790031c772dce 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -1,4 +1,5 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import * as fs from 'node:fs'; import { serverVersion } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent } from 'src/decorators'; @@ -131,11 +132,26 @@ export class ServerService extends BaseService { usage.usageVideos = user.usageVideos; usage.quotaSizeInBytes = user.quotaSizeInBytes; + for (const path of user.encodedVideoPaths) { + if (path.trim() === '') { + continue; + } + + try { + const stats = fs.statSync(path); + usage.usageTranscode += stats.size; + } catch (error) { + console.error(`Error accessing file at path "${path}":`, (error as Error).message); + } + } + serverStats.photos += usage.photos; serverStats.videos += usage.videos; serverStats.usage += usage.usage; + serverStats.usage += usage.usageTranscode; // Necessary because transcoded videos don't count toward quota serverStats.usagePhotos += usage.usagePhotos; serverStats.usageVideos += usage.usageVideos; + serverStats.usageTranscode += usage.usageTranscode; serverStats.usageByUser.push(usage); } diff --git a/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte b/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte index bb288511accf7..d62a55246f123 100644 --- a/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte +++ b/web/src/lib/components/admin-page/server-stats/server-stats-panel.svelte @@ -18,6 +18,7 @@ usage: 0, usagePhotos: 0, usageVideos: 0, + usageTranscode: 0, usageByUser: [], }, }: Props = $props(); @@ -93,10 +94,11 @@ class="mb-4 flex h-12 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" > - {$t('user')} - {$t('photos')} - {$t('videos')} - {$t('usage')} + {$t('user')} + {$t('photos')} + {$t('videos')} + {$t('usage')} + {$t('transcoding')} - {user.userName} - {user.userName} + {user.photos.toLocaleString($locale)} ({getByteUnitString(user.usagePhotos, $locale, 0)}) - {user.videos.toLocaleString($locale)} ({getByteUnitString(user.usageVideos, $locale, 0)}) - + {getByteUnitString(user.usage, $locale, 0)} {#if user.quotaSizeInBytes} / {getByteUnitString(user.quotaSizeInBytes, $locale, 0)} @@ -129,6 +131,7 @@ {/if} + {getByteUnitString(user.usageTranscode, $locale, 0)} {/each}