diff --git a/PR_CHECKLIST.md b/PR_CHECKLIST.md deleted file mode 100644 index 9d9979724d..0000000000 --- a/PR_CHECKLIST.md +++ /dev/null @@ -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. \ No newline at end of file diff --git a/server/apps/immich/src/api-v1/server-info/server-info.controller.ts b/server/apps/immich/src/api-v1/server-info/server-info.controller.ts deleted file mode 100644 index 9c944ac501..0000000000 --- a/server/apps/immich/src/api-v1/server-info/server-info.controller.ts +++ /dev/null @@ -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 { - return await this.serverInfoService.getServerInfo(); - } - - @Get('/ping') - async pingServer(): Promise { - return new ServerPingResponse('pong'); - } - - @Get('/version') - async getServerVersion(): Promise { - return serverVersion; - } - - @Authenticated({ admin: true }) - @Get('/stats') - async getStats(): Promise { - return await this.serverInfoService.getStats(); - } -} diff --git a/server/apps/immich/src/api-v1/server-info/server-info.module.ts b/server/apps/immich/src/api-v1/server-info/server-info.module.ts deleted file mode 100644 index 25d8a19e22..0000000000 --- a/server/apps/immich/src/api-v1/server-info/server-info.module.ts +++ /dev/null @@ -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 {} diff --git a/server/apps/immich/src/api-v1/server-info/server-info.service.ts b/server/apps/immich/src/api-v1/server-info/server-info.service.ts deleted file mode 100644 index 779d4163e6..0000000000 --- a/server/apps/immich/src/api-v1/server-info/server-info.service.ts +++ /dev/null @@ -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, - ) {} - - async getServerInfo(): Promise { - 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 { - 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; - } -} diff --git a/server/apps/immich/src/app.module.ts b/server/apps/immich/src/app.module.ts index 12fd9d231c..bc566b1fa9 100644 --- a/server/apps/immich/src/app.module.ts +++ b/server/apps/immich/src/app.module.ts @@ -2,7 +2,6 @@ import { immichAppConfig } from '@app/common/config'; import { Module, OnModuleInit } from '@nestjs/common'; import { AssetModule } from './api-v1/asset/asset.module'; import { ConfigModule } from '@nestjs/config'; -import { ServerInfoModule } from './api-v1/server-info/server-info.module'; import { AlbumModule } from './api-v1/album/album.module'; import { AppController } from './app.controller'; import { ScheduleModule } from '@nestjs/schedule'; @@ -17,6 +16,7 @@ import { JobController, OAuthController, SearchController, + ServerInfoController, ShareController, SystemConfigController, UserController, @@ -34,8 +34,6 @@ import { AuthGuard } from './middlewares/auth.guard'; AssetModule, - ServerInfoModule, - AlbumModule, ScheduleModule.forRoot(), @@ -52,6 +50,7 @@ import { AuthGuard } from './middlewares/auth.guard'; JobController, OAuthController, SearchController, + ServerInfoController, ShareController, SystemConfigController, UserController, diff --git a/server/apps/immich/src/config/asset-upload.config.ts b/server/apps/immich/src/config/asset-upload.config.ts index 2e1faf4e40..e1bbb2953a 100644 --- a/server/apps/immich/src/config/asset-upload.config.ts +++ b/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 { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface'; import { createHash, randomUUID } from 'crypto'; diff --git a/server/apps/immich/src/config/profile-image-upload.config.ts b/server/apps/immich/src/config/profile-image-upload.config.ts index a9bcd47f5f..6a970020d4 100644 --- a/server/apps/immich/src/config/profile-image-upload.config.ts +++ b/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 { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface'; import { Request } from 'express'; diff --git a/server/apps/immich/src/controllers/index.ts b/server/apps/immich/src/controllers/index.ts index a621302b51..f8eee61f83 100644 --- a/server/apps/immich/src/controllers/index.ts +++ b/server/apps/immich/src/controllers/index.ts @@ -4,6 +4,7 @@ export * from './device-info.controller'; export * from './job.controller'; export * from './oauth.controller'; export * from './search.controller'; +export * from './server-info.controller'; export * from './share.controller'; export * from './system-config.controller'; export * from './user.controller'; diff --git a/server/apps/immich/src/controllers/server-info.controller.ts b/server/apps/immich/src/controllers/server-info.controller.ts new file mode 100644 index 0000000000..db66da1e46 --- /dev/null +++ b/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 { + 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 { + return this.service.getStats(); + } +} diff --git a/server/apps/immich/src/main.ts b/server/apps/immich/src/main.ts index b90df5e238..e97609ef31 100644 --- a/server/apps/immich/src/main.ts +++ b/server/apps/immich/src/main.ts @@ -6,12 +6,11 @@ import cookieParser from 'cookie-parser'; import { writeFileSync } from 'fs'; import path from 'path'; import { AppModule } from './app.module'; -import { SERVER_VERSION } from './constants/server_version.constant'; import { RedisIoAdapter } from './middlewares/redis-io.adapter.middleware'; import { json } from 'body-parser'; import { patchOpenAPI } from './utils/patch-open-api.util'; 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'); diff --git a/server/apps/immich/src/modules/download/download.service.ts b/server/apps/immich/src/modules/download/download.service.ts index 7b541be9e2..011b173cc9 100644 --- a/server/apps/immich/src/modules/download/download.service.ts +++ b/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 archiver from 'archiver'; import { extname } from 'path'; -import { asHumanReadable, HumanReadableSize } from '../../utils/human-readable.util'; +import { asHumanReadable, HumanReadableSize } from '@app/domain'; export interface DownloadArchive { stream: StreamableFile; diff --git a/server/apps/microservices/src/main.ts b/server/apps/microservices/src/main.ts index a236e45d24..715376bd88 100644 --- a/server/apps/microservices/src/main.ts +++ b/server/apps/microservices/src/main.ts @@ -1,6 +1,6 @@ import { Logger } from '@nestjs/common'; 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 { RedisIoAdapter } from '../../immich/src/middlewares/redis-io.adapter.middleware'; import { MicroservicesModule } from './microservices.module'; diff --git a/server/apps/microservices/src/processors/video-transcode.processor.ts b/server/apps/microservices/src/processors/video-transcode.processor.ts index 4d8fd6b48e..8dc84a2b8e 100644 --- a/server/apps/microservices/src/processors/video-transcode.processor.ts +++ b/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 { + APP_UPLOAD_LOCATION, IAssetJob, IAssetRepository, IBaseJob, @@ -10,6 +9,7 @@ import { SystemConfigService, WithoutProperty, } from '@app/domain'; +import { AssetEntity, AssetType } from '@app/infra'; import { Process, Processor } from '@nestjs/bull'; import { Inject, Logger } from '@nestjs/common'; import { Job } from 'bull'; diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 51f9058c20..36025eb425 100644 --- a/server/immich-openapi-specs.json +++ b/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": { "get": { "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": { @@ -4330,6 +4330,148 @@ "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": { "type": "string", "enum": [ @@ -5271,148 +5413,6 @@ "required": [ "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" - ] } } } diff --git a/server/libs/common/src/constants/index.ts b/server/libs/common/src/constants/index.ts index 436e4d6f00..92313a895f 100644 --- a/server/libs/common/src/constants/index.ts +++ b/server/libs/common/src/constants/index.ts @@ -1,7 +1,5 @@ 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_ENABLED = MACHINE_LEARNING_URL !== 'false'; diff --git a/server/libs/common/src/constants/upload_location.constant.ts b/server/libs/common/src/constants/upload_location.constant.ts deleted file mode 100644 index cba2491f4e..0000000000 --- a/server/libs/common/src/constants/upload_location.constant.ts +++ /dev/null @@ -1 +0,0 @@ -export const APP_UPLOAD_LOCATION = './upload'; diff --git a/server/apps/immich/src/constants/server_version.constant.ts b/server/libs/domain/src/domain.constant.ts similarity index 80% rename from server/apps/immich/src/constants/server_version.constant.ts rename to server/libs/domain/src/domain.constant.ts index 9dc0f5e1b2..afe62a17b8 100644 --- a/server/apps/immich/src/constants/server_version.constant.ts +++ b/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('.'); @@ -15,3 +15,5 @@ export const serverVersion: IServerVersion = { }; export const SERVER_VERSION = `${serverVersion.major}.${serverVersion.minor}.${serverVersion.patch}`; + +export const APP_UPLOAD_LOCATION = './upload'; diff --git a/server/libs/domain/src/domain.module.ts b/server/libs/domain/src/domain.module.ts index 0e9071ee8f..7a6793d480 100644 --- a/server/libs/domain/src/domain.module.ts +++ b/server/libs/domain/src/domain.module.ts @@ -7,6 +7,7 @@ import { JobService } from './job'; import { MediaService } from './media'; import { OAuthService } from './oauth'; import { SearchService } from './search'; +import { ServerInfoService } from './server-info'; import { ShareService } from './share'; import { SmartInfoService } from './smart-info'; import { StorageService } from './storage'; @@ -22,6 +23,7 @@ const providers: Provider[] = [ JobService, MediaService, OAuthService, + ServerInfoService, SmartInfoService, StorageService, StorageTemplateService, diff --git a/server/apps/immich/src/utils/human-readable.util.ts b/server/libs/domain/src/domain.util.ts similarity index 80% rename from server/apps/immich/src/utils/human-readable.util.ts rename to server/libs/domain/src/domain.util.ts index e837c81b9e..5c69912ab5 100644 --- a/server/apps/immich/src/utils/human-readable.util.ts +++ b/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 MiB = Math.pow(1024, 2); const GiB = Math.pow(1024, 3); diff --git a/server/libs/domain/src/index.ts b/server/libs/domain/src/index.ts index 734640f94b..a73ad0e280 100644 --- a/server/libs/domain/src/index.ts +++ b/server/libs/domain/src/index.ts @@ -5,11 +5,14 @@ export * from './auth'; export * from './communication'; export * from './crypto'; export * from './device-info'; +export * from './domain.constant'; export * from './domain.module'; +export * from './domain.util'; export * from './job'; export * from './media'; export * from './oauth'; export * from './search'; +export * from './server-info'; export * from './share'; export * from './smart-info'; export * from './storage'; @@ -18,4 +21,3 @@ export * from './system-config'; export * from './tag'; export * from './user'; export * from './user-token'; -export * from './util'; diff --git a/server/libs/domain/src/media/media.service.ts b/server/libs/domain/src/media/media.service.ts index f39cc2ab8b..483db502d5 100644 --- a/server/libs/domain/src/media/media.service.ts +++ b/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 { Inject, Injectable, Logger } from '@nestjs/common'; import { join } from 'path'; import sanitize from 'sanitize-filename'; import { IAssetRepository, mapAsset, WithoutProperty } from '../asset'; import { CommunicationEvent, ICommunicationRepository } from '../communication'; +import { APP_UPLOAD_LOCATION } from '../domain.constant'; import { IAssetJob, IBaseJob, IJobRepository, JobName } from '../job'; import { IStorageRepository } from '../storage'; import { IMediaRepository } from './media.repository'; diff --git a/server/libs/domain/src/server-info/index.ts b/server/libs/domain/src/server-info/index.ts new file mode 100644 index 0000000000..72113665a8 --- /dev/null +++ b/server/libs/domain/src/server-info/index.ts @@ -0,0 +1,2 @@ +export * from './response-dto'; +export * from './server-info.service'; diff --git a/server/libs/domain/src/server-info/response-dto/index.ts b/server/libs/domain/src/server-info/response-dto/index.ts new file mode 100644 index 0000000000..47cbd2ff8f --- /dev/null +++ b/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'; diff --git a/server/apps/immich/src/api-v1/server-info/response-dto/server-info-response.dto.ts b/server/libs/domain/src/server-info/response-dto/server-info-response.dto.ts similarity index 100% rename from server/apps/immich/src/api-v1/server-info/response-dto/server-info-response.dto.ts rename to server/libs/domain/src/server-info/response-dto/server-info-response.dto.ts diff --git a/server/apps/immich/src/api-v1/server-info/response-dto/server-ping-response.dto.ts b/server/libs/domain/src/server-info/response-dto/server-ping-response.dto.ts similarity index 100% rename from server/apps/immich/src/api-v1/server-info/response-dto/server-ping-response.dto.ts rename to server/libs/domain/src/server-info/response-dto/server-ping-response.dto.ts diff --git a/server/apps/immich/src/api-v1/server-info/response-dto/server-stats-response.dto.ts b/server/libs/domain/src/server-info/response-dto/server-stats-response.dto.ts similarity index 100% rename from server/apps/immich/src/api-v1/server-info/response-dto/server-stats-response.dto.ts rename to server/libs/domain/src/server-info/response-dto/server-stats-response.dto.ts diff --git a/server/apps/immich/src/api-v1/server-info/response-dto/server-version-response.dto.ts b/server/libs/domain/src/server-info/response-dto/server-version-response.dto.ts similarity index 76% rename from server/apps/immich/src/api-v1/server-info/response-dto/server-version-response.dto.ts rename to server/libs/domain/src/server-info/response-dto/server-version-response.dto.ts index 38d1a74e18..a4c7eeca41 100644 --- a/server/apps/immich/src/api-v1/server-info/response-dto/server-version-response.dto.ts +++ b/server/libs/domain/src/server-info/response-dto/server-version-response.dto.ts @@ -1,5 +1,5 @@ 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 { @ApiProperty({ type: 'integer' }) diff --git a/server/apps/immich/src/api-v1/server-info/response-dto/usage-by-user-response.dto.ts b/server/libs/domain/src/server-info/response-dto/usage-by-user-response.dto.ts similarity index 100% rename from server/apps/immich/src/api-v1/server-info/response-dto/usage-by-user-response.dto.ts rename to server/libs/domain/src/server-info/response-dto/usage-by-user-response.dto.ts diff --git a/server/libs/domain/src/server-info/server-info.service.spec.ts b/server/libs/domain/src/server-info/server-info.service.spec.ts new file mode 100644 index 0000000000..10afd539eb --- /dev/null +++ b/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; + let userMock: jest.Mocked; + + 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(); + }); + }); +}); diff --git a/server/libs/domain/src/server-info/server-info.service.ts b/server/libs/domain/src/server-info/server-info.service.ts new file mode 100644 index 0000000000..53f8ecfc69 --- /dev/null +++ b/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 { + 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 { + 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; + } +} diff --git a/server/libs/domain/src/storage-template/storage-template.core.ts b/server/libs/domain/src/storage-template/storage-template.core.ts index 8769ed1054..a08a512690 100644 --- a/server/libs/domain/src/storage-template/storage-template.core.ts +++ b/server/libs/domain/src/storage-template/storage-template.core.ts @@ -1,4 +1,3 @@ -import { APP_UPLOAD_LOCATION } from '@app/common'; import { IStorageRepository, ISystemConfigRepository, @@ -15,6 +14,7 @@ import handlebar from 'handlebars'; import * as luxon from 'luxon'; import path from 'node:path'; import sanitize from 'sanitize-filename'; +import { APP_UPLOAD_LOCATION } from '../domain.constant'; import { SystemConfigCore } from '../system-config/system-config.core'; export class StorageTemplateCore { diff --git a/server/libs/domain/src/storage-template/storage-template.service.ts b/server/libs/domain/src/storage-template/storage-template.service.ts index 96e03e12a5..9cf225fd43 100644 --- a/server/libs/domain/src/storage-template/storage-template.service.ts +++ b/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 { Inject, Injectable, Logger } from '@nestjs/common'; import { IAssetRepository } from '../asset/asset.repository'; +import { APP_UPLOAD_LOCATION } from '../domain.constant'; import { IStorageRepository } from '../storage/storage.repository'; import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config'; import { StorageTemplateCore } from './storage-template.core'; diff --git a/server/libs/domain/src/storage/storage.repository.ts b/server/libs/domain/src/storage/storage.repository.ts index 2af1120b7c..7765e10ca3 100644 --- a/server/libs/domain/src/storage/storage.repository.ts +++ b/server/libs/domain/src/storage/storage.repository.ts @@ -6,6 +6,12 @@ export interface ImmichReadStream { length: number; } +export interface DiskUsage { + available: number; + free: number; + total: number; +} + export const IStorageRepository = 'IStorageRepository'; export interface IStorageRepository { @@ -16,4 +22,5 @@ export interface IStorageRepository { moveFile(source: string, target: string): Promise; checkFileExists(filepath: string): Promise; mkdirSync(filepath: string): void; + checkDiskUsage(folder: string): Promise; } diff --git a/server/libs/domain/src/user/user.repository.ts b/server/libs/domain/src/user/user.repository.ts index 33669e583a..126922a5f8 100644 --- a/server/libs/domain/src/user/user.repository.ts +++ b/server/libs/domain/src/user/user.repository.ts @@ -4,6 +4,15 @@ export interface UserListFilter { excludeId?: string; } +export interface UserStatsQueryResponse { + userId: string; + userFirstName: string; + userLastName: string; + photos: number; + videos: number; + usage: number; +} + export const IUserRepository = 'IUserRepository'; export interface IUserRepository { @@ -13,6 +22,7 @@ export interface IUserRepository { getByOAuthId(oauthId: string): Promise; getDeletedUsers(): Promise; getList(filter?: UserListFilter): Promise; + getUserStats(): Promise; create(user: Partial): Promise; update(id: string, user: Partial): Promise; delete(user: UserEntity, hard?: boolean): Promise; diff --git a/server/libs/domain/src/user/user.service.ts b/server/libs/domain/src/user/user.service.ts index f2a8aee29a..741741cae1 100644 --- a/server/libs/domain/src/user/user.service.ts +++ b/server/libs/domain/src/user/user.service.ts @@ -3,12 +3,12 @@ import { BadRequestException, Inject, Injectable, Logger, NotFoundException } fr import { randomBytes } from 'crypto'; import { ReadStream } from 'fs'; import { join } from 'path'; -import { APP_UPLOAD_LOCATION } from '@app/common'; import { IAlbumRepository } from '../album/album.repository'; import { IKeyRepository } from '../api-key/api-key.repository'; import { IAssetRepository } from '../asset/asset.repository'; import { AuthUserDto } from '../auth'; import { ICryptoRepository } from '../crypto/crypto.repository'; +import { APP_UPLOAD_LOCATION } from '../domain.constant'; import { IJobRepository, IUserDeletionJob, JobName } from '../job'; import { IStorageRepository } from '../storage/storage.repository'; import { IUserTokenRepository } from '../user-token/user-token.repository'; diff --git a/server/libs/domain/src/util.ts b/server/libs/domain/src/util.ts deleted file mode 100644 index b575ae5f0b..0000000000 --- a/server/libs/domain/src/util.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { basename, extname } from 'node:path'; - -export function getFileNameWithoutExtension(path: string): string { - return basename(path, extname(path)); -} diff --git a/server/libs/domain/test/storage.repository.mock.ts b/server/libs/domain/test/storage.repository.mock.ts index 7661d6449f..d58681aecc 100644 --- a/server/libs/domain/test/storage.repository.mock.ts +++ b/server/libs/domain/test/storage.repository.mock.ts @@ -9,5 +9,6 @@ export const newStorageRepositoryMock = (): jest.Mocked => { moveFile: jest.fn(), checkFileExists: jest.fn(), mkdirSync: jest.fn(), + checkDiskUsage: jest.fn(), }; }; diff --git a/server/libs/domain/test/user.repository.mock.ts b/server/libs/domain/test/user.repository.mock.ts index 0938250366..94263ba8ec 100644 --- a/server/libs/domain/test/user.repository.mock.ts +++ b/server/libs/domain/test/user.repository.mock.ts @@ -6,6 +6,7 @@ export const newUserRepositoryMock = (): jest.Mocked => { getAdmin: jest.fn(), getByEmail: jest.fn(), getByOAuthId: jest.fn(), + getUserStats: jest.fn(), getList: jest.fn(), create: jest.fn(), update: jest.fn(), diff --git a/server/libs/infra/src/db/repository/user.repository.ts b/server/libs/infra/src/db/repository/user.repository.ts index fadeba99e6..b6849d30f8 100644 --- a/server/libs/infra/src/db/repository/user.repository.ts +++ b/server/libs/infra/src/db/repository/user.repository.ts @@ -1,5 +1,5 @@ import { UserEntity } from '../entities'; -import { IUserRepository, UserListFilter } from '@app/domain'; +import { IUserRepository, UserListFilter, UserStatsQueryResponse } from '@app/domain'; import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { IsNull, Not, Repository } from 'typeorm'; @@ -76,4 +76,28 @@ export class UserRepository implements IUserRepository { async restore(user: UserEntity): Promise { return this.userRepository.recover(user); } + + async getUserStats(): Promise { + 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; + } } diff --git a/server/libs/infra/src/storage/filesystem.provider.ts b/server/libs/infra/src/storage/filesystem.provider.ts index 9441f58184..a5150aa1e5 100644 --- a/server/libs/infra/src/storage/filesystem.provider.ts +++ b/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 fs from 'fs/promises'; import mv from 'mv'; import { promisify } from 'node:util'; +import diskUsage from 'diskusage'; import path from 'path'; const moveFile = promisify(mv); @@ -66,4 +67,8 @@ export class FilesystemProvider implements IStorageRepository { mkdirSync(filepath, { recursive: true }); } } + + checkDiskUsage(folder: string): Promise { + return diskUsage.check(folder); + } } diff --git a/server/package-lock.json b/server/package-lock.json index 02560950fb..44407444ed 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "immich", - "version": "1.50.1", + "version": "1.51.0", "license": "UNLICENSED", "dependencies": { "@babel/runtime": "^7.20.13", diff --git a/server/package.json b/server/package.json index 81ac8129e2..6f57bdb311 100644 --- a/server/package.json +++ b/server/package.json @@ -129,7 +129,7 @@ "rootDir": ".", "testRegex": ".*\\.spec\\.ts$", "transform": { - "^.+\\.(t|j)s$": "ts-jest" + "^.+\\.ts$": "ts-jest" }, "collectCoverageFrom": [ "**/*.(t|j)s", @@ -137,10 +137,6 @@ ], "coverageDirectory": "./coverage", "coverageThreshold": { - "global": { - "lines": 17, - "statements": 17 - }, "./libs/domain/": { "branches": 80, "functions": 85, diff --git a/server/tsconfig.json b/server/tsconfig.json index 506ec3b594..3647f64a0d 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -18,8 +18,6 @@ "paths": { "@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/domain": ["libs/domain/src"],