Browse Source

refactor: server-info (#2038)

Jason Rasmussen 2 năm trước cách đây
mục cha
commit
b9bc621e2a
43 tập tin đã thay đổi với 632 bổ sung420 xóa
  1. 0 17
      PR_CHECKLIST.md
  2. 0 36
      server/apps/immich/src/api-v1/server-info/server-info.controller.ts
  3. 0 12
      server/apps/immich/src/api-v1/server-info/server-info.module.ts
  4. 0 81
      server/apps/immich/src/api-v1/server-info/server-info.service.ts
  5. 2 3
      server/apps/immich/src/app.module.ts
  6. 1 1
      server/apps/immich/src/config/asset-upload.config.ts
  7. 1 1
      server/apps/immich/src/config/profile-image-upload.config.ts
  8. 1 0
      server/apps/immich/src/controllers/index.ts
  9. 37 0
      server/apps/immich/src/controllers/server-info.controller.ts
  10. 1 2
      server/apps/immich/src/main.ts
  11. 1 1
      server/apps/immich/src/modules/download/download.service.ts
  12. 1 1
      server/apps/microservices/src/main.ts
  13. 2 2
      server/apps/microservices/src/processors/video-transcode.processor.ts
  14. 238 238
      server/immich-openapi-specs.json
  15. 0 2
      server/libs/common/src/constants/index.ts
  16. 0 1
      server/libs/common/src/constants/upload_location.constant.ts
  17. 3 1
      server/libs/domain/src/domain.constant.ts
  18. 2 0
      server/libs/domain/src/domain.module.ts
  19. 6 0
      server/libs/domain/src/domain.util.ts
  20. 3 1
      server/libs/domain/src/index.ts
  21. 1 1
      server/libs/domain/src/media/media.service.ts
  22. 2 0
      server/libs/domain/src/server-info/index.ts
  23. 5 0
      server/libs/domain/src/server-info/response-dto/index.ts
  24. 0 0
      server/libs/domain/src/server-info/response-dto/server-info-response.dto.ts
  25. 0 0
      server/libs/domain/src/server-info/response-dto/server-ping-response.dto.ts
  26. 0 0
      server/libs/domain/src/server-info/response-dto/server-stats-response.dto.ts
  27. 1 1
      server/libs/domain/src/server-info/response-dto/server-version-response.dto.ts
  28. 0 0
      server/libs/domain/src/server-info/response-dto/usage-by-user-response.dto.ts
  29. 209 0
      server/libs/domain/src/server-info/server-info.service.spec.ts
  30. 60 0
      server/libs/domain/src/server-info/server-info.service.ts
  31. 1 1
      server/libs/domain/src/storage-template/storage-template.core.ts
  32. 1 1
      server/libs/domain/src/storage-template/storage-template.service.ts
  33. 7 0
      server/libs/domain/src/storage/storage.repository.ts
  34. 10 0
      server/libs/domain/src/user/user.repository.ts
  35. 1 1
      server/libs/domain/src/user/user.service.ts
  36. 0 5
      server/libs/domain/src/util.ts
  37. 1 0
      server/libs/domain/test/storage.repository.mock.ts
  38. 1 0
      server/libs/domain/test/user.repository.mock.ts
  39. 25 1
      server/libs/infra/src/db/repository/user.repository.ts
  40. 6 1
      server/libs/infra/src/storage/filesystem.provider.ts
  41. 1 1
      server/package-lock.json
  42. 1 5
      server/package.json
  43. 0 2
      server/tsconfig.json

+ 0 - 17
PR_CHECKLIST.md

@@ -1,17 +0,0 @@
-# Deployment checklist for iOS/Android/Server
-
-[ ] Up version in [mobile/pubspec.yml](/mobile/pubspec.yaml)
-
-[ ] Up version in [docker/docker-compose.yml](/docker/docker-compose.yml) for `immich_server` service
-
-[ ] Up version in [docker/docker-compose.gpu.yml](/docker/docker-compose.gpu.yml) for `immich_server` service
-
-[ ] Up version in [docker/docker-compose.dev.yml](/docker/docker-compose.dev.yml) for `immich_server` service
-
-[ ] Up version in [server/src/constants/server_version.constant.ts](/server/src/constants/server_version.constant.ts)
-
-[ ] Up version in iOS Fastlane [/mobile/ios/fastlane/Fastfile](/mobile/ios/fastlane/Fastfile)
-
-[ ] Add changelog to [Android Fastlane F-droid folder](/mobile/android/fastlane/metadata/android/en-US/changelogs)
-
-All of the version should be the same.

+ 0 - 36
server/apps/immich/src/api-v1/server-info/server-info.controller.ts

@@ -1,36 +0,0 @@
-import { Controller, Get } from '@nestjs/common';
-import { ServerInfoService } from './server-info.service';
-import { serverVersion } from '../../constants/server_version.constant';
-import { ApiTags } from '@nestjs/swagger';
-import { ServerPingResponse } from './response-dto/server-ping-response.dto';
-import { ServerVersionReponseDto } from './response-dto/server-version-response.dto';
-import { ServerInfoResponseDto } from './response-dto/server-info-response.dto';
-import { ServerStatsResponseDto } from './response-dto/server-stats-response.dto';
-import { Authenticated } from '../../decorators/authenticated.decorator';
-
-@ApiTags('Server Info')
-@Controller('server-info')
-export class ServerInfoController {
-  constructor(private readonly serverInfoService: ServerInfoService) {}
-
-  @Get()
-  async getServerInfo(): Promise<ServerInfoResponseDto> {
-    return await this.serverInfoService.getServerInfo();
-  }
-
-  @Get('/ping')
-  async pingServer(): Promise<ServerPingResponse> {
-    return new ServerPingResponse('pong');
-  }
-
-  @Get('/version')
-  async getServerVersion(): Promise<ServerVersionReponseDto> {
-    return serverVersion;
-  }
-
-  @Authenticated({ admin: true })
-  @Get('/stats')
-  async getStats(): Promise<ServerStatsResponseDto> {
-    return await this.serverInfoService.getStats();
-  }
-}

+ 0 - 12
server/apps/immich/src/api-v1/server-info/server-info.module.ts

@@ -1,12 +0,0 @@
-import { Module } from '@nestjs/common';
-import { ServerInfoService } from './server-info.service';
-import { ServerInfoController } from './server-info.controller';
-import { UserEntity } from '@app/infra';
-import { TypeOrmModule } from '@nestjs/typeorm';
-
-@Module({
-  imports: [TypeOrmModule.forFeature([UserEntity])],
-  controllers: [ServerInfoController],
-  providers: [ServerInfoService],
-})
-export class ServerInfoModule {}

+ 0 - 81
server/apps/immich/src/api-v1/server-info/server-info.service.ts

@@ -1,81 +0,0 @@
-import { APP_UPLOAD_LOCATION } from '@app/common/constants';
-import { Injectable } from '@nestjs/common';
-import { ServerInfoResponseDto } from './response-dto/server-info-response.dto';
-import diskusage from 'diskusage';
-import { ServerStatsResponseDto } from './response-dto/server-stats-response.dto';
-import { UsageByUserDto } from './response-dto/usage-by-user-response.dto';
-import { UserEntity } from '@app/infra';
-import { Repository } from 'typeorm';
-import { InjectRepository } from '@nestjs/typeorm';
-import { asHumanReadable } from '../../utils/human-readable.util';
-
-@Injectable()
-export class ServerInfoService {
-  constructor(
-    @InjectRepository(UserEntity)
-    private userRepository: Repository<UserEntity>,
-  ) {}
-
-  async getServerInfo(): Promise<ServerInfoResponseDto> {
-    const diskInfo = await diskusage.check(APP_UPLOAD_LOCATION);
-
-    const usagePercentage = (((diskInfo.total - diskInfo.free) / diskInfo.total) * 100).toFixed(2);
-
-    const serverInfo = new ServerInfoResponseDto();
-    serverInfo.diskAvailable = asHumanReadable(diskInfo.available);
-    serverInfo.diskSize = asHumanReadable(diskInfo.total);
-    serverInfo.diskUse = asHumanReadable(diskInfo.total - diskInfo.free);
-    serverInfo.diskAvailableRaw = diskInfo.available;
-    serverInfo.diskSizeRaw = diskInfo.total;
-    serverInfo.diskUseRaw = diskInfo.total - diskInfo.free;
-    serverInfo.diskUsagePercentage = parseFloat(usagePercentage);
-    return serverInfo;
-  }
-
-  async getStats(): Promise<ServerStatsResponseDto> {
-    type UserStatsQueryResponse = {
-      userId: string;
-      userFirstName: string;
-      userLastName: string;
-      photos: string;
-      videos: string;
-      usage: string;
-    };
-
-    const userStatsQueryResponse: UserStatsQueryResponse[] = await this.userRepository
-      .createQueryBuilder('users')
-      .select('users.id', 'userId')
-      .addSelect('users.firstName', 'userFirstName')
-      .addSelect('users.lastName', 'userLastName')
-      .addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'IMAGE' AND assets.isVisible)`, 'photos')
-      .addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'VIDEO' AND assets.isVisible)`, 'videos')
-      .addSelect('COALESCE(SUM(exif.fileSizeInByte), 0)', 'usage')
-      .leftJoin('users.assets', 'assets')
-      .leftJoin('assets.exifInfo', 'exif')
-      .groupBy('users.id')
-      .orderBy('users.createdAt', 'ASC')
-      .getRawMany();
-
-    const usageByUser = userStatsQueryResponse.map((userStats) => {
-      const usage = new UsageByUserDto();
-      usage.userId = userStats.userId;
-      usage.userFirstName = userStats.userFirstName;
-      usage.userLastName = userStats.userLastName;
-      usage.photos = Number(userStats.photos);
-      usage.videos = Number(userStats.videos);
-      usage.usage = Number(userStats.usage);
-
-      return usage;
-    });
-
-    const serverStats = new ServerStatsResponseDto();
-    usageByUser.forEach((user) => {
-      serverStats.photos += user.photos;
-      serverStats.videos += user.videos;
-      serverStats.usage += user.usage;
-    });
-    serverStats.usageByUser = usageByUser;
-
-    return serverStats;
-  }
-}

+ 2 - 3
server/apps/immich/src/app.module.ts

@@ -2,7 +2,6 @@ import { immichAppConfig } from '@app/common/config';
 import { Module, OnModuleInit } from '@nestjs/common';
 import { Module, OnModuleInit } from '@nestjs/common';
 import { AssetModule } from './api-v1/asset/asset.module';
 import { AssetModule } from './api-v1/asset/asset.module';
 import { ConfigModule } from '@nestjs/config';
 import { ConfigModule } from '@nestjs/config';
-import { ServerInfoModule } from './api-v1/server-info/server-info.module';
 import { AlbumModule } from './api-v1/album/album.module';
 import { AlbumModule } from './api-v1/album/album.module';
 import { AppController } from './app.controller';
 import { AppController } from './app.controller';
 import { ScheduleModule } from '@nestjs/schedule';
 import { ScheduleModule } from '@nestjs/schedule';
@@ -17,6 +16,7 @@ import {
   JobController,
   JobController,
   OAuthController,
   OAuthController,
   SearchController,
   SearchController,
+  ServerInfoController,
   ShareController,
   ShareController,
   SystemConfigController,
   SystemConfigController,
   UserController,
   UserController,
@@ -34,8 +34,6 @@ import { AuthGuard } from './middlewares/auth.guard';
 
 
     AssetModule,
     AssetModule,
 
 
-    ServerInfoModule,
-
     AlbumModule,
     AlbumModule,
 
 
     ScheduleModule.forRoot(),
     ScheduleModule.forRoot(),
@@ -52,6 +50,7 @@ import { AuthGuard } from './middlewares/auth.guard';
     JobController,
     JobController,
     OAuthController,
     OAuthController,
     SearchController,
     SearchController,
+    ServerInfoController,
     ShareController,
     ShareController,
     SystemConfigController,
     SystemConfigController,
     UserController,
     UserController,

+ 1 - 1
server/apps/immich/src/config/asset-upload.config.ts

@@ -1,4 +1,4 @@
-import { APP_UPLOAD_LOCATION } from '@app/common/constants';
+import { APP_UPLOAD_LOCATION } from '@app/domain/domain.constant';
 import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common';
 import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common';
 import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
 import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
 import { createHash, randomUUID } from 'crypto';
 import { createHash, randomUUID } from 'crypto';

+ 1 - 1
server/apps/immich/src/config/profile-image-upload.config.ts

@@ -1,4 +1,4 @@
-import { APP_UPLOAD_LOCATION } from '@app/common/constants';
+import { APP_UPLOAD_LOCATION } from '@app/domain/domain.constant';
 import { BadRequestException, UnauthorizedException } from '@nestjs/common';
 import { BadRequestException, UnauthorizedException } from '@nestjs/common';
 import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
 import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
 import { Request } from 'express';
 import { Request } from 'express';

+ 1 - 0
server/apps/immich/src/controllers/index.ts

@@ -4,6 +4,7 @@ export * from './device-info.controller';
 export * from './job.controller';
 export * from './job.controller';
 export * from './oauth.controller';
 export * from './oauth.controller';
 export * from './search.controller';
 export * from './search.controller';
+export * from './server-info.controller';
 export * from './share.controller';
 export * from './share.controller';
 export * from './system-config.controller';
 export * from './system-config.controller';
 export * from './user.controller';
 export * from './user.controller';

+ 37 - 0
server/apps/immich/src/controllers/server-info.controller.ts

@@ -0,0 +1,37 @@
+import {
+  ServerInfoResponseDto,
+  ServerInfoService,
+  ServerPingResponse,
+  ServerStatsResponseDto,
+  ServerVersionReponseDto,
+} from '@app/domain';
+import { Controller, Get } from '@nestjs/common';
+import { ApiTags } from '@nestjs/swagger';
+import { Authenticated } from '../decorators/authenticated.decorator';
+
+@ApiTags('Server Info')
+@Controller('server-info')
+export class ServerInfoController {
+  constructor(private readonly service: ServerInfoService) {}
+
+  @Get()
+  getServerInfo(): Promise<ServerInfoResponseDto> {
+    return this.service.getInfo();
+  }
+
+  @Get('/ping')
+  pingServer(): ServerPingResponse {
+    return this.service.ping();
+  }
+
+  @Get('/version')
+  getServerVersion(): ServerVersionReponseDto {
+    return this.service.getVersion();
+  }
+
+  @Authenticated({ admin: true })
+  @Get('/stats')
+  getStats(): Promise<ServerStatsResponseDto> {
+    return this.service.getStats();
+  }
+}

+ 1 - 2
server/apps/immich/src/main.ts

@@ -6,12 +6,11 @@ import cookieParser from 'cookie-parser';
 import { writeFileSync } from 'fs';
 import { writeFileSync } from 'fs';
 import path from 'path';
 import path from 'path';
 import { AppModule } from './app.module';
 import { AppModule } from './app.module';
-import { SERVER_VERSION } from './constants/server_version.constant';
 import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware';
 import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware';
 import { json } from 'body-parser';
 import { json } from 'body-parser';
 import { patchOpenAPI } from './utils/patch-open-api.util';
 import { patchOpenAPI } from './utils/patch-open-api.util';
 import { getLogLevels, MACHINE_LEARNING_ENABLED } from '@app/common';
 import { getLogLevels, MACHINE_LEARNING_ENABLED } from '@app/common';
-import { IMMICH_ACCESS_COOKIE, SearchService } from '@app/domain';
+import { SERVER_VERSION, IMMICH_ACCESS_COOKIE, SearchService } from '@app/domain';
 
 
 const logger = new Logger('ImmichServer');
 const logger = new Logger('ImmichServer');
 
 

+ 1 - 1
server/apps/immich/src/modules/download/download.service.ts

@@ -2,7 +2,7 @@ import { AssetEntity } from '@app/infra';
 import { BadRequestException, Injectable, InternalServerErrorException, Logger, StreamableFile } from '@nestjs/common';
 import { BadRequestException, Injectable, InternalServerErrorException, Logger, StreamableFile } from '@nestjs/common';
 import archiver from 'archiver';
 import archiver from 'archiver';
 import { extname } from 'path';
 import { extname } from 'path';
-import { asHumanReadable, HumanReadableSize } from '../../utils/human-readable.util';
+import { asHumanReadable, HumanReadableSize } from '@app/domain';
 
 
 export interface DownloadArchive {
 export interface DownloadArchive {
   stream: StreamableFile;
   stream: StreamableFile;

+ 1 - 1
server/apps/microservices/src/main.ts

@@ -1,6 +1,6 @@
 import { Logger } from '@nestjs/common';
 import { Logger } from '@nestjs/common';
 import { NestFactory } from '@nestjs/core';
 import { NestFactory } from '@nestjs/core';
-import { SERVER_VERSION } from 'apps/immich/src/constants/server_version.constant';
+import { SERVER_VERSION } from '@app/domain';
 import { getLogLevels } from '@app/common';
 import { getLogLevels } from '@app/common';
 import { RedisIoAdapter } from '../../immich/src/middlewares/redis-io.adapter.middleware';
 import { RedisIoAdapter } from '../../immich/src/middlewares/redis-io.adapter.middleware';
 import { MicroservicesModule } from './microservices.module';
 import { MicroservicesModule } from './microservices.module';

+ 2 - 2
server/apps/microservices/src/processors/video-transcode.processor.ts

@@ -1,6 +1,5 @@
-import { APP_UPLOAD_LOCATION } from '@app/common/constants';
-import { AssetEntity, AssetType } from '@app/infra';
 import {
 import {
+  APP_UPLOAD_LOCATION,
   IAssetJob,
   IAssetJob,
   IAssetRepository,
   IAssetRepository,
   IBaseJob,
   IBaseJob,
@@ -10,6 +9,7 @@ import {
   SystemConfigService,
   SystemConfigService,
   WithoutProperty,
   WithoutProperty,
 } from '@app/domain';
 } from '@app/domain';
+import { AssetEntity, AssetType } from '@app/infra';
 import { Process, Processor } from '@nestjs/bull';
 import { Process, Processor } from '@nestjs/bull';
 import { Inject, Logger } from '@nestjs/common';
 import { Inject, Logger } from '@nestjs/common';
 import { Job } from 'bull';
 import { Job } from 'bull';

+ 238 - 238
server/immich-openapi-specs.json

@@ -834,6 +834,102 @@
         ]
         ]
       }
       }
     },
     },
+    "/server-info": {
+      "get": {
+        "operationId": "getServerInfo",
+        "description": "",
+        "parameters": [],
+        "responses": {
+          "200": {
+            "description": "",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/ServerInfoResponseDto"
+                }
+              }
+            }
+          }
+        },
+        "tags": [
+          "Server Info"
+        ]
+      }
+    },
+    "/server-info/ping": {
+      "get": {
+        "operationId": "pingServer",
+        "description": "",
+        "parameters": [],
+        "responses": {
+          "200": {
+            "description": "",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/ServerPingResponse"
+                }
+              }
+            }
+          }
+        },
+        "tags": [
+          "Server Info"
+        ]
+      }
+    },
+    "/server-info/version": {
+      "get": {
+        "operationId": "getServerVersion",
+        "description": "",
+        "parameters": [],
+        "responses": {
+          "200": {
+            "description": "",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/ServerVersionReponseDto"
+                }
+              }
+            }
+          }
+        },
+        "tags": [
+          "Server Info"
+        ]
+      }
+    },
+    "/server-info/stats": {
+      "get": {
+        "operationId": "getStats",
+        "description": "",
+        "parameters": [],
+        "responses": {
+          "200": {
+            "description": "",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/ServerStatsResponseDto"
+                }
+              }
+            }
+          }
+        },
+        "tags": [
+          "Server Info"
+        ],
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          }
+        ]
+      }
+    },
     "/share": {
     "/share": {
       "get": {
       "get": {
         "operationId": "getAllSharedLinks",
         "operationId": "getAllSharedLinks",
@@ -3270,102 +3366,6 @@
           }
           }
         ]
         ]
       }
       }
-    },
-    "/server-info": {
-      "get": {
-        "operationId": "getServerInfo",
-        "description": "",
-        "parameters": [],
-        "responses": {
-          "200": {
-            "description": "",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "$ref": "#/components/schemas/ServerInfoResponseDto"
-                }
-              }
-            }
-          }
-        },
-        "tags": [
-          "Server Info"
-        ]
-      }
-    },
-    "/server-info/ping": {
-      "get": {
-        "operationId": "pingServer",
-        "description": "",
-        "parameters": [],
-        "responses": {
-          "200": {
-            "description": "",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "$ref": "#/components/schemas/ServerPingResponse"
-                }
-              }
-            }
-          }
-        },
-        "tags": [
-          "Server Info"
-        ]
-      }
-    },
-    "/server-info/version": {
-      "get": {
-        "operationId": "getServerVersion",
-        "description": "",
-        "parameters": [],
-        "responses": {
-          "200": {
-            "description": "",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "$ref": "#/components/schemas/ServerVersionReponseDto"
-                }
-              }
-            }
-          }
-        },
-        "tags": [
-          "Server Info"
-        ]
-      }
-    },
-    "/server-info/stats": {
-      "get": {
-        "operationId": "getStats",
-        "description": "",
-        "parameters": [],
-        "responses": {
-          "200": {
-            "description": "",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "$ref": "#/components/schemas/ServerStatsResponseDto"
-                }
-              }
-            }
-          }
-        },
-        "tags": [
-          "Server Info"
-        ],
-        "security": [
-          {
-            "bearer": []
-          },
-          {
-            "cookie": []
-          }
-        ]
-      }
     }
     }
   },
   },
   "info": {
   "info": {
@@ -4330,6 +4330,148 @@
           "items"
           "items"
         ]
         ]
       },
       },
+      "ServerInfoResponseDto": {
+        "type": "object",
+        "properties": {
+          "diskSizeRaw": {
+            "type": "integer",
+            "format": "int64"
+          },
+          "diskUseRaw": {
+            "type": "integer",
+            "format": "int64"
+          },
+          "diskAvailableRaw": {
+            "type": "integer",
+            "format": "int64"
+          },
+          "diskUsagePercentage": {
+            "type": "number",
+            "format": "float"
+          },
+          "diskSize": {
+            "type": "string"
+          },
+          "diskUse": {
+            "type": "string"
+          },
+          "diskAvailable": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "diskSizeRaw",
+          "diskUseRaw",
+          "diskAvailableRaw",
+          "diskUsagePercentage",
+          "diskSize",
+          "diskUse",
+          "diskAvailable"
+        ]
+      },
+      "ServerPingResponse": {
+        "type": "object",
+        "properties": {
+          "res": {
+            "type": "string",
+            "readOnly": true,
+            "example": "pong"
+          }
+        },
+        "required": [
+          "res"
+        ]
+      },
+      "ServerVersionReponseDto": {
+        "type": "object",
+        "properties": {
+          "major": {
+            "type": "integer"
+          },
+          "minor": {
+            "type": "integer"
+          },
+          "patch": {
+            "type": "integer"
+          }
+        },
+        "required": [
+          "major",
+          "minor",
+          "patch"
+        ]
+      },
+      "UsageByUserDto": {
+        "type": "object",
+        "properties": {
+          "userId": {
+            "type": "string"
+          },
+          "userFirstName": {
+            "type": "string"
+          },
+          "userLastName": {
+            "type": "string"
+          },
+          "photos": {
+            "type": "integer"
+          },
+          "videos": {
+            "type": "integer"
+          },
+          "usage": {
+            "type": "integer",
+            "format": "int64"
+          }
+        },
+        "required": [
+          "userId",
+          "userFirstName",
+          "userLastName",
+          "photos",
+          "videos",
+          "usage"
+        ]
+      },
+      "ServerStatsResponseDto": {
+        "type": "object",
+        "properties": {
+          "photos": {
+            "type": "integer",
+            "default": 0
+          },
+          "videos": {
+            "type": "integer",
+            "default": 0
+          },
+          "usage": {
+            "type": "integer",
+            "default": 0,
+            "format": "int64"
+          },
+          "usageByUser": {
+            "default": [],
+            "title": "Array of usage for each user",
+            "example": [
+              {
+                "photos": 1,
+                "videos": 1,
+                "diskUsageRaw": 1
+              }
+            ],
+            "type": "array",
+            "items": {
+              "$ref": "#/components/schemas/UsageByUserDto"
+            }
+          }
+        },
+        "required": [
+          "photos",
+          "videos",
+          "usage",
+          "usageByUser"
+        ]
+      },
       "SharedLinkType": {
       "SharedLinkType": {
         "type": "string",
         "type": "string",
         "enum": [
         "enum": [
@@ -5271,148 +5413,6 @@
         "required": [
         "required": [
           "albumId"
           "albumId"
         ]
         ]
-      },
-      "ServerInfoResponseDto": {
-        "type": "object",
-        "properties": {
-          "diskSizeRaw": {
-            "type": "integer",
-            "format": "int64"
-          },
-          "diskUseRaw": {
-            "type": "integer",
-            "format": "int64"
-          },
-          "diskAvailableRaw": {
-            "type": "integer",
-            "format": "int64"
-          },
-          "diskUsagePercentage": {
-            "type": "number",
-            "format": "float"
-          },
-          "diskSize": {
-            "type": "string"
-          },
-          "diskUse": {
-            "type": "string"
-          },
-          "diskAvailable": {
-            "type": "string"
-          }
-        },
-        "required": [
-          "diskSizeRaw",
-          "diskUseRaw",
-          "diskAvailableRaw",
-          "diskUsagePercentage",
-          "diskSize",
-          "diskUse",
-          "diskAvailable"
-        ]
-      },
-      "ServerPingResponse": {
-        "type": "object",
-        "properties": {
-          "res": {
-            "type": "string",
-            "readOnly": true,
-            "example": "pong"
-          }
-        },
-        "required": [
-          "res"
-        ]
-      },
-      "ServerVersionReponseDto": {
-        "type": "object",
-        "properties": {
-          "major": {
-            "type": "integer"
-          },
-          "minor": {
-            "type": "integer"
-          },
-          "patch": {
-            "type": "integer"
-          }
-        },
-        "required": [
-          "major",
-          "minor",
-          "patch"
-        ]
-      },
-      "UsageByUserDto": {
-        "type": "object",
-        "properties": {
-          "userId": {
-            "type": "string"
-          },
-          "userFirstName": {
-            "type": "string"
-          },
-          "userLastName": {
-            "type": "string"
-          },
-          "photos": {
-            "type": "integer"
-          },
-          "videos": {
-            "type": "integer"
-          },
-          "usage": {
-            "type": "integer",
-            "format": "int64"
-          }
-        },
-        "required": [
-          "userId",
-          "userFirstName",
-          "userLastName",
-          "photos",
-          "videos",
-          "usage"
-        ]
-      },
-      "ServerStatsResponseDto": {
-        "type": "object",
-        "properties": {
-          "photos": {
-            "type": "integer",
-            "default": 0
-          },
-          "videos": {
-            "type": "integer",
-            "default": 0
-          },
-          "usage": {
-            "type": "integer",
-            "default": 0,
-            "format": "int64"
-          },
-          "usageByUser": {
-            "default": [],
-            "title": "Array of usage for each user",
-            "example": [
-              {
-                "photos": 1,
-                "videos": 1,
-                "diskUsageRaw": 1
-              }
-            ],
-            "type": "array",
-            "items": {
-              "$ref": "#/components/schemas/UsageByUserDto"
-            }
-          }
-        },
-        "required": [
-          "photos",
-          "videos",
-          "usage",
-          "usageByUser"
-        ]
       }
       }
     }
     }
   }
   }

+ 0 - 2
server/libs/common/src/constants/index.ts

@@ -1,7 +1,5 @@
 import { BadRequestException } from '@nestjs/common';
 import { BadRequestException } from '@nestjs/common';
 
 
-export * from './upload_location.constant';
-
 export const MACHINE_LEARNING_URL = process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003';
 export const MACHINE_LEARNING_URL = process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003';
 export const MACHINE_LEARNING_ENABLED = MACHINE_LEARNING_URL !== 'false';
 export const MACHINE_LEARNING_ENABLED = MACHINE_LEARNING_URL !== 'false';
 
 

+ 0 - 1
server/libs/common/src/constants/upload_location.constant.ts

@@ -1 +0,0 @@
-export const APP_UPLOAD_LOCATION = './upload';

+ 3 - 1
server/apps/immich/src/constants/server_version.constant.ts → server/libs/domain/src/domain.constant.ts

@@ -1,4 +1,4 @@
-import pkg from 'package.json';
+import pkg from '../../../package.json';
 
 
 const [major, minor, patch] = pkg.version.split('.');
 const [major, minor, patch] = pkg.version.split('.');
 
 
@@ -15,3 +15,5 @@ export const serverVersion: IServerVersion = {
 };
 };
 
 
 export const SERVER_VERSION = `${serverVersion.major}.${serverVersion.minor}.${serverVersion.patch}`;
 export const SERVER_VERSION = `${serverVersion.major}.${serverVersion.minor}.${serverVersion.patch}`;
+
+export const APP_UPLOAD_LOCATION = './upload';

+ 2 - 0
server/libs/domain/src/domain.module.ts

@@ -7,6 +7,7 @@ import { JobService } from './job';
 import { MediaService } from './media';
 import { MediaService } from './media';
 import { OAuthService } from './oauth';
 import { OAuthService } from './oauth';
 import { SearchService } from './search';
 import { SearchService } from './search';
+import { ServerInfoService } from './server-info';
 import { ShareService } from './share';
 import { ShareService } from './share';
 import { SmartInfoService } from './smart-info';
 import { SmartInfoService } from './smart-info';
 import { StorageService } from './storage';
 import { StorageService } from './storage';
@@ -22,6 +23,7 @@ const providers: Provider[] = [
   JobService,
   JobService,
   MediaService,
   MediaService,
   OAuthService,
   OAuthService,
+  ServerInfoService,
   SmartInfoService,
   SmartInfoService,
   StorageService,
   StorageService,
   StorageTemplateService,
   StorageTemplateService,

+ 6 - 0
server/apps/immich/src/utils/human-readable.util.ts → server/libs/domain/src/domain.util.ts

@@ -1,3 +1,9 @@
+import { basename, extname } from 'node:path';
+
+export function getFileNameWithoutExtension(path: string): string {
+  return basename(path, extname(path));
+}
+
 const KiB = Math.pow(1024, 1);
 const KiB = Math.pow(1024, 1);
 const MiB = Math.pow(1024, 2);
 const MiB = Math.pow(1024, 2);
 const GiB = Math.pow(1024, 3);
 const GiB = Math.pow(1024, 3);

+ 3 - 1
server/libs/domain/src/index.ts

@@ -5,11 +5,14 @@ export * from './auth';
 export * from './communication';
 export * from './communication';
 export * from './crypto';
 export * from './crypto';
 export * from './device-info';
 export * from './device-info';
+export * from './domain.constant';
 export * from './domain.module';
 export * from './domain.module';
+export * from './domain.util';
 export * from './job';
 export * from './job';
 export * from './media';
 export * from './media';
 export * from './oauth';
 export * from './oauth';
 export * from './search';
 export * from './search';
+export * from './server-info';
 export * from './share';
 export * from './share';
 export * from './smart-info';
 export * from './smart-info';
 export * from './storage';
 export * from './storage';
@@ -18,4 +21,3 @@ export * from './system-config';
 export * from './tag';
 export * from './tag';
 export * from './user';
 export * from './user';
 export * from './user-token';
 export * from './user-token';
-export * from './util';

+ 1 - 1
server/libs/domain/src/media/media.service.ts

@@ -1,10 +1,10 @@
-import { APP_UPLOAD_LOCATION } from '@app/common';
 import { AssetType } from '@app/infra/db/entities';
 import { AssetType } from '@app/infra/db/entities';
 import { Inject, Injectable, Logger } from '@nestjs/common';
 import { Inject, Injectable, Logger } from '@nestjs/common';
 import { join } from 'path';
 import { join } from 'path';
 import sanitize from 'sanitize-filename';
 import sanitize from 'sanitize-filename';
 import { IAssetRepository, mapAsset, WithoutProperty } from '../asset';
 import { IAssetRepository, mapAsset, WithoutProperty } from '../asset';
 import { CommunicationEvent, ICommunicationRepository } from '../communication';
 import { CommunicationEvent, ICommunicationRepository } from '../communication';
+import { APP_UPLOAD_LOCATION } from '../domain.constant';
 import { IAssetJob, IBaseJob, IJobRepository, JobName } from '../job';
 import { IAssetJob, IBaseJob, IJobRepository, JobName } from '../job';
 import { IStorageRepository } from '../storage';
 import { IStorageRepository } from '../storage';
 import { IMediaRepository } from './media.repository';
 import { IMediaRepository } from './media.repository';

+ 2 - 0
server/libs/domain/src/server-info/index.ts

@@ -0,0 +1,2 @@
+export * from './response-dto';
+export * from './server-info.service';

+ 5 - 0
server/libs/domain/src/server-info/response-dto/index.ts

@@ -0,0 +1,5 @@
+export * from './server-info-response.dto';
+export * from './server-ping-response.dto';
+export * from './server-stats-response.dto';
+export * from './server-version-response.dto';
+export * from './usage-by-user-response.dto';

+ 0 - 0
server/apps/immich/src/api-v1/server-info/response-dto/server-info-response.dto.ts → server/libs/domain/src/server-info/response-dto/server-info-response.dto.ts


+ 0 - 0
server/apps/immich/src/api-v1/server-info/response-dto/server-ping-response.dto.ts → server/libs/domain/src/server-info/response-dto/server-ping-response.dto.ts


+ 0 - 0
server/apps/immich/src/api-v1/server-info/response-dto/server-stats-response.dto.ts → server/libs/domain/src/server-info/response-dto/server-stats-response.dto.ts


+ 1 - 1
server/apps/immich/src/api-v1/server-info/response-dto/server-version-response.dto.ts → server/libs/domain/src/server-info/response-dto/server-version-response.dto.ts

@@ -1,5 +1,5 @@
 import { ApiProperty } from '@nestjs/swagger';
 import { ApiProperty } from '@nestjs/swagger';
-import { IServerVersion } from 'apps/immich/src/constants/server_version.constant';
+import { IServerVersion } from '@app/domain';
 
 
 export class ServerVersionReponseDto implements IServerVersion {
 export class ServerVersionReponseDto implements IServerVersion {
   @ApiProperty({ type: 'integer' })
   @ApiProperty({ type: 'integer' })

+ 0 - 0
server/apps/immich/src/api-v1/server-info/response-dto/usage-by-user-response.dto.ts → server/libs/domain/src/server-info/response-dto/usage-by-user-response.dto.ts


+ 209 - 0
server/libs/domain/src/server-info/server-info.service.spec.ts

@@ -0,0 +1,209 @@
+import { newStorageRepositoryMock, newUserRepositoryMock } from '../../test';
+import { serverVersion } from '../domain.constant';
+import { IStorageRepository } from '../storage';
+import { IUserRepository } from '../user';
+import { ServerInfoService } from './server-info.service';
+
+describe(ServerInfoService.name, () => {
+  let sut: ServerInfoService;
+  let storageMock: jest.Mocked<IStorageRepository>;
+  let userMock: jest.Mocked<IUserRepository>;
+
+  beforeEach(() => {
+    storageMock = newStorageRepositoryMock();
+    userMock = newUserRepositoryMock();
+
+    sut = new ServerInfoService(userMock, storageMock);
+  });
+
+  it('should work', () => {
+    expect(sut).toBeDefined();
+  });
+
+  describe('getInfo', () => {
+    it('should return the disk space as B', async () => {
+      storageMock.checkDiskUsage.mockResolvedValue({ free: 200, available: 300, total: 500 });
+
+      await expect(sut.getInfo()).resolves.toEqual({
+        diskAvailable: '300 B',
+        diskAvailableRaw: 300,
+        diskSize: '500 B',
+        diskSizeRaw: 500,
+        diskUsagePercentage: 60,
+        diskUse: '300 B',
+        diskUseRaw: 300,
+      });
+
+      expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('./upload');
+    });
+
+    it('should return the disk space as KiB', async () => {
+      storageMock.checkDiskUsage.mockResolvedValue({ free: 200_000, available: 300_000, total: 500_000 });
+
+      await expect(sut.getInfo()).resolves.toEqual({
+        diskAvailable: '293.0 KiB',
+        diskAvailableRaw: 300000,
+        diskSize: '488.3 KiB',
+        diskSizeRaw: 500000,
+        diskUsagePercentage: 60,
+        diskUse: '293.0 KiB',
+        diskUseRaw: 300000,
+      });
+
+      expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('./upload');
+    });
+
+    it('should return the disk space as MiB', async () => {
+      storageMock.checkDiskUsage.mockResolvedValue({ free: 200_000_000, available: 300_000_000, total: 500_000_000 });
+
+      await expect(sut.getInfo()).resolves.toEqual({
+        diskAvailable: '286.1 MiB',
+        diskAvailableRaw: 300000000,
+        diskSize: '476.8 MiB',
+        diskSizeRaw: 500000000,
+        diskUsagePercentage: 60,
+        diskUse: '286.1 MiB',
+        diskUseRaw: 300000000,
+      });
+
+      expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('./upload');
+    });
+
+    it('should return the disk space as GiB', async () => {
+      storageMock.checkDiskUsage.mockResolvedValue({
+        free: 200_000_000_000,
+        available: 300_000_000_000,
+        total: 500_000_000_000,
+      });
+
+      await expect(sut.getInfo()).resolves.toEqual({
+        diskAvailable: '279.4 GiB',
+        diskAvailableRaw: 300000000000,
+        diskSize: '465.7 GiB',
+        diskSizeRaw: 500000000000,
+        diskUsagePercentage: 60,
+        diskUse: '279.4 GiB',
+        diskUseRaw: 300000000000,
+      });
+
+      expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('./upload');
+    });
+
+    it('should return the disk space as TiB', async () => {
+      storageMock.checkDiskUsage.mockResolvedValue({
+        free: 200_000_000_000_000,
+        available: 300_000_000_000_000,
+        total: 500_000_000_000_000,
+      });
+
+      await expect(sut.getInfo()).resolves.toEqual({
+        diskAvailable: '272.8 TiB',
+        diskAvailableRaw: 300000000000000,
+        diskSize: '454.7 TiB',
+        diskSizeRaw: 500000000000000,
+        diskUsagePercentage: 60,
+        diskUse: '272.8 TiB',
+        diskUseRaw: 300000000000000,
+      });
+
+      expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('./upload');
+    });
+
+    it('should return the disk space as PiB', async () => {
+      storageMock.checkDiskUsage.mockResolvedValue({
+        free: 200_000_000_000_000_000,
+        available: 300_000_000_000_000_000,
+        total: 500_000_000_000_000_000,
+      });
+
+      await expect(sut.getInfo()).resolves.toEqual({
+        diskAvailable: '266.5 PiB',
+        diskAvailableRaw: 300000000000000000,
+        diskSize: '444.1 PiB',
+        diskSizeRaw: 500000000000000000,
+        diskUsagePercentage: 60,
+        diskUse: '266.5 PiB',
+        diskUseRaw: 300000000000000000,
+      });
+
+      expect(storageMock.checkDiskUsage).toHaveBeenCalledWith('./upload');
+    });
+  });
+
+  describe('ping', () => {
+    it('should respond with pong', () => {
+      expect(sut.ping()).toEqual({ res: 'pong' });
+    });
+  });
+
+  describe('getVersion', () => {
+    it('should respond the server version', () => {
+      expect(sut.getVersion()).toEqual(serverVersion);
+    });
+  });
+
+  describe('getStats', () => {
+    it('should total up usage by user', async () => {
+      userMock.getUserStats.mockResolvedValue([
+        {
+          userId: 'user1',
+          userFirstName: '1',
+          userLastName: 'User',
+          photos: 10,
+          videos: 11,
+          usage: 12345,
+        },
+        {
+          userId: 'user2',
+          userFirstName: '2',
+          userLastName: 'User',
+          photos: 10,
+          videos: 20,
+          usage: 123456,
+        },
+        {
+          userId: 'user3',
+          userFirstName: '3',
+          userLastName: 'User',
+          photos: 100,
+          videos: 0,
+          usage: 987654,
+        },
+      ]);
+
+      await expect(sut.getStats()).resolves.toEqual({
+        photos: 120,
+        videos: 31,
+        usage: 1123455,
+        usageByUser: [
+          {
+            photos: 10,
+            usage: 12345,
+            userFirstName: '1',
+            userId: 'user1',
+            userLastName: 'User',
+            videos: 11,
+          },
+          {
+            photos: 10,
+            usage: 123456,
+            userFirstName: '2',
+            userId: 'user2',
+            userLastName: 'User',
+            videos: 20,
+          },
+          {
+            photos: 100,
+            usage: 987654,
+            userFirstName: '3',
+            userId: 'user3',
+            userLastName: 'User',
+            videos: 0,
+          },
+        ],
+      });
+
+      expect(userMock.getUserStats).toHaveBeenCalled();
+    });
+  });
+});

+ 60 - 0
server/libs/domain/src/server-info/server-info.service.ts

@@ -0,0 +1,60 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { APP_UPLOAD_LOCATION, serverVersion } from '../domain.constant';
+import { asHumanReadable } from '../domain.util';
+import { IStorageRepository } from '../storage';
+import { IUserRepository, UserStatsQueryResponse } from '../user';
+import { ServerInfoResponseDto, ServerPingResponse, ServerStatsResponseDto, UsageByUserDto } from './response-dto';
+
+@Injectable()
+export class ServerInfoService {
+  constructor(
+    @Inject(IUserRepository) private userRepository: IUserRepository,
+    @Inject(IStorageRepository) private storageRepository: IStorageRepository,
+  ) {}
+
+  async getInfo(): Promise<ServerInfoResponseDto> {
+    const diskInfo = await this.storageRepository.checkDiskUsage(APP_UPLOAD_LOCATION);
+
+    const usagePercentage = (((diskInfo.total - diskInfo.free) / diskInfo.total) * 100).toFixed(2);
+
+    const serverInfo = new ServerInfoResponseDto();
+    serverInfo.diskAvailable = asHumanReadable(diskInfo.available);
+    serverInfo.diskSize = asHumanReadable(diskInfo.total);
+    serverInfo.diskUse = asHumanReadable(diskInfo.total - diskInfo.free);
+    serverInfo.diskAvailableRaw = diskInfo.available;
+    serverInfo.diskSizeRaw = diskInfo.total;
+    serverInfo.diskUseRaw = diskInfo.total - diskInfo.free;
+    serverInfo.diskUsagePercentage = parseFloat(usagePercentage);
+    return serverInfo;
+  }
+
+  ping(): ServerPingResponse {
+    return new ServerPingResponse('pong');
+  }
+
+  getVersion() {
+    return serverVersion;
+  }
+
+  async getStats(): Promise<ServerStatsResponseDto> {
+    const userStats: UserStatsQueryResponse[] = await this.userRepository.getUserStats();
+    const serverStats = new ServerStatsResponseDto();
+
+    for (const user of userStats) {
+      const usage = new UsageByUserDto();
+      usage.userId = user.userId;
+      usage.userFirstName = user.userFirstName;
+      usage.userLastName = user.userLastName;
+      usage.photos = user.photos;
+      usage.videos = user.videos;
+      usage.usage = user.usage;
+
+      serverStats.photos += usage.photos;
+      serverStats.videos += usage.videos;
+      serverStats.usage += usage.usage;
+      serverStats.usageByUser.push(usage);
+    }
+
+    return serverStats;
+  }
+}

+ 1 - 1
server/libs/domain/src/storage-template/storage-template.core.ts

@@ -1,4 +1,3 @@
-import { APP_UPLOAD_LOCATION } from '@app/common';
 import {
 import {
   IStorageRepository,
   IStorageRepository,
   ISystemConfigRepository,
   ISystemConfigRepository,
@@ -15,6 +14,7 @@ import handlebar from 'handlebars';
 import * as luxon from 'luxon';
 import * as luxon from 'luxon';
 import path from 'node:path';
 import path from 'node:path';
 import sanitize from 'sanitize-filename';
 import sanitize from 'sanitize-filename';
+import { APP_UPLOAD_LOCATION } from '../domain.constant';
 import { SystemConfigCore } from '../system-config/system-config.core';
 import { SystemConfigCore } from '../system-config/system-config.core';
 
 
 export class StorageTemplateCore {
 export class StorageTemplateCore {

+ 1 - 1
server/libs/domain/src/storage-template/storage-template.service.ts

@@ -1,7 +1,7 @@
-import { APP_UPLOAD_LOCATION } from '@app/common';
 import { AssetEntity, SystemConfig } from '@app/infra/db/entities';
 import { AssetEntity, SystemConfig } from '@app/infra/db/entities';
 import { Inject, Injectable, Logger } from '@nestjs/common';
 import { Inject, Injectable, Logger } from '@nestjs/common';
 import { IAssetRepository } from '../asset/asset.repository';
 import { IAssetRepository } from '../asset/asset.repository';
+import { APP_UPLOAD_LOCATION } from '../domain.constant';
 import { IStorageRepository } from '../storage/storage.repository';
 import { IStorageRepository } from '../storage/storage.repository';
 import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
 import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
 import { StorageTemplateCore } from './storage-template.core';
 import { StorageTemplateCore } from './storage-template.core';

+ 7 - 0
server/libs/domain/src/storage/storage.repository.ts

@@ -6,6 +6,12 @@ export interface ImmichReadStream {
   length: number;
   length: number;
 }
 }
 
 
+export interface DiskUsage {
+  available: number;
+  free: number;
+  total: number;
+}
+
 export const IStorageRepository = 'IStorageRepository';
 export const IStorageRepository = 'IStorageRepository';
 
 
 export interface IStorageRepository {
 export interface IStorageRepository {
@@ -16,4 +22,5 @@ export interface IStorageRepository {
   moveFile(source: string, target: string): Promise<void>;
   moveFile(source: string, target: string): Promise<void>;
   checkFileExists(filepath: string): Promise<boolean>;
   checkFileExists(filepath: string): Promise<boolean>;
   mkdirSync(filepath: string): void;
   mkdirSync(filepath: string): void;
+  checkDiskUsage(folder: string): Promise<DiskUsage>;
 }
 }

+ 10 - 0
server/libs/domain/src/user/user.repository.ts

@@ -4,6 +4,15 @@ export interface UserListFilter {
   excludeId?: string;
   excludeId?: string;
 }
 }
 
 
+export interface UserStatsQueryResponse {
+  userId: string;
+  userFirstName: string;
+  userLastName: string;
+  photos: number;
+  videos: number;
+  usage: number;
+}
+
 export const IUserRepository = 'IUserRepository';
 export const IUserRepository = 'IUserRepository';
 
 
 export interface IUserRepository {
 export interface IUserRepository {
@@ -13,6 +22,7 @@ export interface IUserRepository {
   getByOAuthId(oauthId: string): Promise<UserEntity | null>;
   getByOAuthId(oauthId: string): Promise<UserEntity | null>;
   getDeletedUsers(): Promise<UserEntity[]>;
   getDeletedUsers(): Promise<UserEntity[]>;
   getList(filter?: UserListFilter): Promise<UserEntity[]>;
   getList(filter?: UserListFilter): Promise<UserEntity[]>;
+  getUserStats(): Promise<UserStatsQueryResponse[]>;
   create(user: Partial<UserEntity>): Promise<UserEntity>;
   create(user: Partial<UserEntity>): Promise<UserEntity>;
   update(id: string, user: Partial<UserEntity>): Promise<UserEntity>;
   update(id: string, user: Partial<UserEntity>): Promise<UserEntity>;
   delete(user: UserEntity, hard?: boolean): Promise<UserEntity>;
   delete(user: UserEntity, hard?: boolean): Promise<UserEntity>;

+ 1 - 1
server/libs/domain/src/user/user.service.ts

@@ -3,12 +3,12 @@ import { BadRequestException, Inject, Injectable, Logger, NotFoundException } fr
 import { randomBytes } from 'crypto';
 import { randomBytes } from 'crypto';
 import { ReadStream } from 'fs';
 import { ReadStream } from 'fs';
 import { join } from 'path';
 import { join } from 'path';
-import { APP_UPLOAD_LOCATION } from '@app/common';
 import { IAlbumRepository } from '../album/album.repository';
 import { IAlbumRepository } from '../album/album.repository';
 import { IKeyRepository } from '../api-key/api-key.repository';
 import { IKeyRepository } from '../api-key/api-key.repository';
 import { IAssetRepository } from '../asset/asset.repository';
 import { IAssetRepository } from '../asset/asset.repository';
 import { AuthUserDto } from '../auth';
 import { AuthUserDto } from '../auth';
 import { ICryptoRepository } from '../crypto/crypto.repository';
 import { ICryptoRepository } from '../crypto/crypto.repository';
+import { APP_UPLOAD_LOCATION } from '../domain.constant';
 import { IJobRepository, IUserDeletionJob, JobName } from '../job';
 import { IJobRepository, IUserDeletionJob, JobName } from '../job';
 import { IStorageRepository } from '../storage/storage.repository';
 import { IStorageRepository } from '../storage/storage.repository';
 import { IUserTokenRepository } from '../user-token/user-token.repository';
 import { IUserTokenRepository } from '../user-token/user-token.repository';

+ 0 - 5
server/libs/domain/src/util.ts

@@ -1,5 +0,0 @@
-import { basename, extname } from 'node:path';
-
-export function getFileNameWithoutExtension(path: string): string {
-  return basename(path, extname(path));
-}

+ 1 - 0
server/libs/domain/test/storage.repository.mock.ts

@@ -9,5 +9,6 @@ export const newStorageRepositoryMock = (): jest.Mocked<IStorageRepository> => {
     moveFile: jest.fn(),
     moveFile: jest.fn(),
     checkFileExists: jest.fn(),
     checkFileExists: jest.fn(),
     mkdirSync: jest.fn(),
     mkdirSync: jest.fn(),
+    checkDiskUsage: jest.fn(),
   };
   };
 };
 };

+ 1 - 0
server/libs/domain/test/user.repository.mock.ts

@@ -6,6 +6,7 @@ export const newUserRepositoryMock = (): jest.Mocked<IUserRepository> => {
     getAdmin: jest.fn(),
     getAdmin: jest.fn(),
     getByEmail: jest.fn(),
     getByEmail: jest.fn(),
     getByOAuthId: jest.fn(),
     getByOAuthId: jest.fn(),
+    getUserStats: jest.fn(),
     getList: jest.fn(),
     getList: jest.fn(),
     create: jest.fn(),
     create: jest.fn(),
     update: jest.fn(),
     update: jest.fn(),

+ 25 - 1
server/libs/infra/src/db/repository/user.repository.ts

@@ -1,5 +1,5 @@
 import { UserEntity } from '../entities';
 import { UserEntity } from '../entities';
-import { IUserRepository, UserListFilter } from '@app/domain';
+import { IUserRepository, UserListFilter, UserStatsQueryResponse } from '@app/domain';
 import { Injectable, InternalServerErrorException } from '@nestjs/common';
 import { Injectable, InternalServerErrorException } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
 import { InjectRepository } from '@nestjs/typeorm';
 import { IsNull, Not, Repository } from 'typeorm';
 import { IsNull, Not, Repository } from 'typeorm';
@@ -76,4 +76,28 @@ export class UserRepository implements IUserRepository {
   async restore(user: UserEntity): Promise<UserEntity> {
   async restore(user: UserEntity): Promise<UserEntity> {
     return this.userRepository.recover(user);
     return this.userRepository.recover(user);
   }
   }
+
+  async getUserStats(): Promise<UserStatsQueryResponse[]> {
+    const stats = await this.userRepository
+      .createQueryBuilder('users')
+      .select('users.id', 'userId')
+      .addSelect('users.firstName', 'userFirstName')
+      .addSelect('users.lastName', 'userLastName')
+      .addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'IMAGE' AND assets.isVisible)`, 'photos')
+      .addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'VIDEO' AND assets.isVisible)`, 'videos')
+      .addSelect('COALESCE(SUM(exif.fileSizeInByte), 0)', 'usage')
+      .leftJoin('users.assets', 'assets')
+      .leftJoin('assets.exifInfo', 'exif')
+      .groupBy('users.id')
+      .orderBy('users.createdAt', 'ASC')
+      .getRawMany();
+
+    for (const stat of stats) {
+      stat.photos = Number(stat.photos);
+      stat.videos = Number(stat.videos);
+      stat.usage = Number(stat.usage);
+    }
+
+    return stats;
+  }
 }
 }

+ 6 - 1
server/libs/infra/src/storage/filesystem.provider.ts

@@ -1,8 +1,9 @@
-import { ImmichReadStream, IStorageRepository } from '@app/domain';
+import { DiskUsage, ImmichReadStream, IStorageRepository } from '@app/domain';
 import { constants, createReadStream, existsSync, mkdirSync } from 'fs';
 import { constants, createReadStream, existsSync, mkdirSync } from 'fs';
 import fs from 'fs/promises';
 import fs from 'fs/promises';
 import mv from 'mv';
 import mv from 'mv';
 import { promisify } from 'node:util';
 import { promisify } from 'node:util';
+import diskUsage from 'diskusage';
 import path from 'path';
 import path from 'path';
 
 
 const moveFile = promisify<string, string, mv.Options>(mv);
 const moveFile = promisify<string, string, mv.Options>(mv);
@@ -66,4 +67,8 @@ export class FilesystemProvider implements IStorageRepository {
       mkdirSync(filepath, { recursive: true });
       mkdirSync(filepath, { recursive: true });
     }
     }
   }
   }
+
+  checkDiskUsage(folder: string): Promise<DiskUsage> {
+    return diskUsage.check(folder);
+  }
 }
 }

+ 1 - 1
server/package-lock.json

@@ -6,7 +6,7 @@
   "packages": {
   "packages": {
     "": {
     "": {
       "name": "immich",
       "name": "immich",
-      "version": "1.50.1",
+      "version": "1.51.0",
       "license": "UNLICENSED",
       "license": "UNLICENSED",
       "dependencies": {
       "dependencies": {
         "@babel/runtime": "^7.20.13",
         "@babel/runtime": "^7.20.13",

+ 1 - 5
server/package.json

@@ -129,7 +129,7 @@
     "rootDir": ".",
     "rootDir": ".",
     "testRegex": ".*\\.spec\\.ts$",
     "testRegex": ".*\\.spec\\.ts$",
     "transform": {
     "transform": {
-      "^.+\\.(t|j)s$": "ts-jest"
+      "^.+\\.ts$": "ts-jest"
     },
     },
     "collectCoverageFrom": [
     "collectCoverageFrom": [
       "**/*.(t|j)s",
       "**/*.(t|j)s",
@@ -137,10 +137,6 @@
     ],
     ],
     "coverageDirectory": "./coverage",
     "coverageDirectory": "./coverage",
     "coverageThreshold": {
     "coverageThreshold": {
-      "global": {
-        "lines": 17,
-        "statements": 17
-      },
       "./libs/domain/": {
       "./libs/domain/": {
         "branches": 80,
         "branches": 80,
         "functions": 85,
         "functions": 85,

+ 0 - 2
server/tsconfig.json

@@ -18,8 +18,6 @@
     "paths": {
     "paths": {
       "@app/common": ["libs/common/src"],
       "@app/common": ["libs/common/src"],
       "@app/common/*": ["libs/common/src/*"],
       "@app/common/*": ["libs/common/src/*"],
-      "@app/storage": ["libs/storage/src"],
-      "@app/storage/*": ["libs/storage/src/*"],
       "@app/infra": ["libs/infra/src"],
       "@app/infra": ["libs/infra/src"],
       "@app/infra/*": ["libs/infra/src/*"],
       "@app/infra/*": ["libs/infra/src/*"],
       "@app/domain": ["libs/domain/src"],
       "@app/domain": ["libs/domain/src"],