feat(server, web): quotas (#4471)

* feat: quotas

* chore: open api

* chore: update status box and upload error message

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
cfitzw 2024-01-12 18:43:36 -06:00 committed by GitHub
parent f4edb6c4bd
commit deb1f970a8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
63 changed files with 525 additions and 113 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -7409,6 +7409,11 @@
"password": {
"type": "string"
},
"quotaSizeInBytes": {
"format": "int64",
"nullable": true,
"type": "integer"
},
"storageLabel": {
"nullable": true,
"type": "string"
@ -8192,6 +8197,15 @@
"profileImagePath": {
"type": "string"
},
"quotaSizeInBytes": {
"format": "int64",
"nullable": true,
"type": "integer"
},
"quotaUsageInBytes": {
"format": "int64",
"type": "integer"
},
"shouldChangePassword": {
"type": "boolean"
},
@ -8206,10 +8220,8 @@
},
"required": [
"avatarColor",
"id",
"name",
"email",
"profileImagePath",
"quotaSizeInBytes",
"quotaUsageInBytes",
"storageLabel",
"externalPath",
"shouldChangePassword",
@ -8217,7 +8229,11 @@
"createdAt",
"deletedAt",
"updatedAt",
"oauthId"
"oauthId",
"id",
"name",
"email",
"profileImagePath"
],
"type": "object"
},
@ -9757,6 +9773,11 @@
"password": {
"type": "string"
},
"quotaSizeInBytes": {
"format": "int64",
"nullable": true,
"type": "integer"
},
"shouldChangePassword": {
"type": "boolean"
},
@ -9774,6 +9795,11 @@
"photos": {
"type": "integer"
},
"quotaSizeInBytes": {
"format": "int64",
"nullable": true,
"type": "integer"
},
"usage": {
"format": "int64",
"type": "integer"
@ -9793,7 +9819,8 @@
"userName",
"photos",
"videos",
"usage"
"usage",
"quotaSizeInBytes"
],
"type": "object"
},
@ -9878,6 +9905,15 @@
"profileImagePath": {
"type": "string"
},
"quotaSizeInBytes": {
"format": "int64",
"nullable": true,
"type": "integer"
},
"quotaUsageInBytes": {
"format": "int64",
"type": "integer"
},
"shouldChangePassword": {
"type": "boolean"
},
@ -9892,10 +9928,8 @@
},
"required": [
"avatarColor",
"id",
"name",
"email",
"profileImagePath",
"quotaSizeInBytes",
"quotaUsageInBytes",
"storageLabel",
"externalPath",
"shouldChangePassword",
@ -9903,7 +9937,11 @@
"createdAt",
"deletedAt",
"updatedAt",
"oauthId"
"oauthId",
"id",
"name",
"email",
"profileImagePath"
],
"type": "object"
},

View file

@ -1472,6 +1472,12 @@ export interface CreateUserDto {
* @memberof CreateUserDto
*/
'password': string;
/**
*
* @type {number}
* @memberof CreateUserDto
*/
'quotaSizeInBytes'?: number | null;
/**
*
* @type {string}
@ -2479,6 +2485,18 @@ export interface PartnerResponseDto {
* @memberof PartnerResponseDto
*/
'profileImagePath': string;
/**
*
* @type {number}
* @memberof PartnerResponseDto
*/
'quotaSizeInBytes': number | null;
/**
*
* @type {number}
* @memberof PartnerResponseDto
*/
'quotaUsageInBytes': number;
/**
*
* @type {boolean}
@ -4544,6 +4562,12 @@ export interface UpdateUserDto {
* @memberof UpdateUserDto
*/
'password'?: string;
/**
*
* @type {number}
* @memberof UpdateUserDto
*/
'quotaSizeInBytes'?: number | null;
/**
*
* @type {boolean}
@ -4571,6 +4595,12 @@ export interface UsageByUserDto {
* @memberof UsageByUserDto
*/
'photos': number;
/**
*
* @type {number}
* @memberof UsageByUserDto
*/
'quotaSizeInBytes': number | null;
/**
*
* @type {number}
@ -4729,6 +4759,18 @@ export interface UserResponseDto {
* @memberof UserResponseDto
*/
'profileImagePath': string;
/**
*
* @type {number}
* @memberof UserResponseDto
*/
'quotaSizeInBytes': number | null;
/**
*
* @type {number}
* @memberof UserResponseDto
*/
'quotaUsageInBytes': number;
/**
*
* @type {boolean}

View file

@ -250,7 +250,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(user1Albums[0]);
expect(body).toEqual({ ...user1Albums[0], assets: [expect.objectContaining(user1Albums[0].assets[0])] });
});
it('should return album info for shared album', async () => {
@ -259,7 +259,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(user2Albums[0]);
expect(body).toEqual({ ...user2Albums[0], assets: [expect.objectContaining(user2Albums[0].assets[0])] });
});
it('should return album info with assets when withoutAssets is undefined', async () => {
@ -268,7 +268,7 @@ describe(`${AlbumController.name} (e2e)`, () => {
.set('Authorization', `Bearer ${user1.accessToken}`);
expect(status).toBe(200);
expect(body).toEqual(user1Albums[0]);
expect(body).toEqual({ ...user1Albums[0], assets: [expect.objectContaining(user1Albums[0].assets[0])] });
});
it('should return album info without assets when withoutAssets is true', async () => {

View file

@ -43,6 +43,7 @@ describe(`${AssetController.name} (e2e)`, () => {
let assetRepository: IAssetRepository;
let user1: LoginResponseDto;
let user2: LoginResponseDto;
let userWithQuota: LoginResponseDto;
let libraries: LibraryResponseDto[];
let asset1: AssetResponseDto;
let asset2: AssetResponseDto;
@ -75,11 +76,13 @@ describe(`${AssetController.name} (e2e)`, () => {
await Promise.all([
api.userApi.create(server, admin.accessToken, userDto.user1),
api.userApi.create(server, admin.accessToken, userDto.user2),
api.userApi.create(server, admin.accessToken, userDto.userWithQuota),
]);
[user1, user2] = await Promise.all([
[user1, user2, userWithQuota] = await Promise.all([
api.authApi.login(server, userDto.user1),
api.authApi.login(server, userDto.user2),
api.authApi.login(server, userDto.userWithQuota),
]);
const [user1Libraries, user2Libraries] = await Promise.all([
@ -634,6 +637,46 @@ describe(`${AssetController.name} (e2e)`, () => {
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Not found or no asset.upload access'));
});
it('should update the used quota', async () => {
const content = randomBytes(32);
const { body, status } = await request(server)
.post('/asset/upload')
.set('Authorization', `Bearer ${userWithQuota.accessToken}`)
.field('deviceAssetId', 'example-image')
.field('deviceId', 'TEST')
.field('fileCreatedAt', new Date().toISOString())
.field('fileModifiedAt', new Date().toISOString())
.field('isFavorite', 'true')
.field('duration', '0:00:00.000000')
.attach('assetData', content, 'example.jpg');
expect(status).toBe(201);
expect(body).toEqual({ id: expect.any(String), duplicate: false });
const { body: user } = await request(server)
.get('/user/me')
.set('Authorization', `Bearer ${userWithQuota.accessToken}`);
expect(user).toEqual(expect.objectContaining({ quotaUsageInBytes: 32 }));
});
it('should not upload an asset if it would exceed the quota', async () => {
const content = randomBytes(420);
const { body, status } = await request(server)
.post('/asset/upload')
.set('Authorization', `Bearer ${userWithQuota.accessToken}`)
.field('deviceAssetId', 'example-image')
.field('deviceId', 'TEST')
.field('fileCreatedAt', new Date().toISOString())
.field('fileModifiedAt', new Date().toISOString())
.field('isFavorite', 'true')
.field('duration', '0:00:00.000000')
.attach('assetData', content, 'example.jpg');
expect(status).toBe(400);
expect(body).toEqual(errorStub.badRequest('Quota has been exceeded!'));
});
});
describe('PUT /asset/:id', () => {

View file

@ -32,6 +32,8 @@ const adminSignupResponse = {
deletedAt: null,
oauthId: '',
memoriesEnabled: true,
quotaUsageInBytes: 0,
quotaSizeInBytes: null,
};
describe(`${AuthController.name} (e2e)`, () => {

View file

@ -128,6 +128,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
usage: 0,
usageByUser: [
{
quotaSizeInBytes: null,
photos: 0,
usage: 0,
userName: 'Immich Admin',
@ -135,6 +136,7 @@ describe(`${ServerInfoController.name} (e2e)`, () => {
videos: 0,
},
{
quotaSizeInBytes: null,
photos: 0,
usage: 0,
userName: 'User 1',

View file

@ -13,6 +13,7 @@ import {
newPartnerRepositoryMock,
newStorageRepositoryMock,
newSystemConfigRepositoryMock,
newUserRepositoryMock,
} from '@test';
import { when } from 'jest-when';
import { Readable } from 'stream';
@ -28,6 +29,7 @@ import {
IPartnerRepository,
IStorageRepository,
ISystemConfigRepository,
IUserRepository,
JobItem,
TimeBucketSize,
} from '../repositories';
@ -67,6 +69,7 @@ const uploadFile = {
checksum: Buffer.from('checksum', 'utf8'),
originalPath: 'upload/admin/image.jpeg',
originalName: 'image.jpeg',
size: 1000,
},
},
filename: (fieldName: UploadFieldName, filename: string) => {
@ -79,6 +82,7 @@ const uploadFile = {
checksum: Buffer.from('checksum', 'utf8'),
originalPath: `upload/admin/${filename}`,
originalName: filename,
size: 1000,
},
};
},
@ -167,6 +171,7 @@ describe(AssetService.name, () => {
let cryptoMock: jest.Mocked<ICryptoRepository>;
let jobMock: jest.Mocked<IJobRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
let userMock: jest.Mocked<IUserRepository>;
let communicationMock: jest.Mocked<ICommunicationRepository>;
let configMock: jest.Mocked<ISystemConfigRepository>;
let partnerMock: jest.Mocked<IPartnerRepository>;
@ -182,6 +187,7 @@ describe(AssetService.name, () => {
cryptoMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock();
storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock();
configMock = newSystemConfigRepositoryMock();
partnerMock = newPartnerRepositoryMock();
@ -192,6 +198,7 @@ describe(AssetService.name, () => {
jobMock,
configMock,
storageMock,
userMock,
communicationMock,
partnerMock,
);
@ -836,7 +843,7 @@ describe(AssetService.name, () => {
});
it('should remove faces', async () => {
const assetWithFace = { ...(assetStub.image as AssetEntity), faces: [faceStub.face1, faceStub.mergeFace1] };
const assetWithFace = { ...assetStub.image, faces: [faceStub.face1, faceStub.mergeFace1] };
when(assetMock.getById).calledWith(assetWithFace.id).mockResolvedValue(assetWithFace);
@ -863,9 +870,7 @@ describe(AssetService.name, () => {
});
it('should update stack parent if asset has stack children', async () => {
when(assetMock.getById)
.calledWith(assetStub.primaryImage.id)
.mockResolvedValue(assetStub.primaryImage as AssetEntity);
when(assetMock.getById).calledWith(assetStub.primaryImage.id).mockResolvedValue(assetStub.primaryImage);
await sut.handleAssetDeletion({ id: assetStub.primaryImage.id });
@ -878,9 +883,7 @@ describe(AssetService.name, () => {
});
it('should not schedule delete-files job for readonly assets', async () => {
when(assetMock.getById)
.calledWith(assetStub.readOnly.id)
.mockResolvedValue(assetStub.readOnly as AssetEntity);
when(assetMock.getById).calledWith(assetStub.readOnly.id).mockResolvedValue(assetStub.readOnly);
await sut.handleAssetDeletion({ id: assetStub.readOnly.id });
@ -890,21 +893,17 @@ describe(AssetService.name, () => {
});
it('should not process assets from external library without fromExternal flag', async () => {
when(assetMock.getById)
.calledWith(assetStub.external.id)
.mockResolvedValue(assetStub.external as AssetEntity);
when(assetMock.getById).calledWith(assetStub.external.id).mockResolvedValue(assetStub.external);
await sut.handleAssetDeletion({ id: assetStub.external.id });
expect(jobMock.queue).not.toBeCalled();
expect(jobMock.queueAll).not.toBeCalled();
expect(assetMock.remove).not.toBeCalled();
expect(jobMock.queue).not.toHaveBeenCalled();
expect(jobMock.queueAll).not.toHaveBeenCalled();
expect(assetMock.remove).not.toHaveBeenCalled();
});
it('should process assets from external library with fromExternal flag', async () => {
when(assetMock.getById)
.calledWith(assetStub.external.id)
.mockResolvedValue(assetStub.external as AssetEntity);
when(assetMock.getById).calledWith(assetStub.external.id).mockResolvedValue(assetStub.external);
await sut.handleAssetDeletion({ id: assetStub.external.id, fromExternal: true });
@ -949,6 +948,13 @@ describe(AssetService.name, () => {
],
]);
});
it('should update usage', async () => {
when(assetMock.getById).calledWith(assetStub.image.id).mockResolvedValue(assetStub.image);
await sut.handleAssetDeletion({ id: assetStub.image.id });
expect(userMock.updateUsage).toHaveBeenCalledWith(assetStub.image.ownerId, -5000);
});
});
describe('run', () => {

View file

@ -20,6 +20,7 @@ import {
IPartnerRepository,
IStorageRepository,
ISystemConfigRepository,
IUserRepository,
ImmichReadStream,
JobItem,
TimeBucketOptions,
@ -75,6 +76,7 @@ export interface UploadFile {
checksum: Buffer;
originalPath: string;
originalName: string;
size: number;
}
export class AssetService {
@ -89,6 +91,7 @@ export class AssetService {
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ICommunicationRepository) private communicationRepository: ICommunicationRepository,
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
) {
@ -481,6 +484,7 @@ export class AssetService {
}
await this.assetRepository.remove(asset);
await this.userRepository.updateUsage(asset.ownerId, -(asset.exifInfo?.fileSizeInByte || 0));
this.communicationRepository.send(ClientEvent.ASSET_DELETE, asset.ownerId, id);
// TODO refactor this to use cascades

View file

@ -37,9 +37,10 @@ export enum JobName {
METADATA_EXTRACTION = 'metadata-extraction',
LINK_LIVE_PHOTOS = 'link-live-photos',
// user deletion
// user
USER_DELETION = 'user-deletion',
USER_DELETE_CHECK = 'user-delete-check',
USER_SYNC_USAGE = 'user-sync-usage',
// asset
ASSET_DELETION = 'asset-deletion',
@ -95,6 +96,7 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
[JobName.CLEAN_OLD_AUDIT_LOGS]: QueueName.BACKGROUND_TASK,
[JobName.PERSON_CLEANUP]: QueueName.BACKGROUND_TASK,
[JobName.PERSON_DELETE]: QueueName.BACKGROUND_TASK,
[JobName.USER_SYNC_USAGE]: QueueName.BACKGROUND_TASK,
// conversion
[JobName.QUEUE_VIDEO_CONVERSION]: QueueName.VIDEO_CONVERSION,

View file

@ -60,6 +60,7 @@ describe(JobService.name, () => {
{ name: JobName.PERSON_CLEANUP },
{ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } },
{ name: JobName.CLEAN_OLD_AUDIT_LOGS },
{ name: JobName.USER_SYNC_USAGE },
]);
});
});

View file

@ -164,6 +164,7 @@ export class JobService {
{ name: JobName.PERSON_CLEANUP },
{ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } },
{ name: JobName.CLEAN_OLD_AUDIT_LOGS },
{ name: JobName.USER_SYNC_USAGE },
]);
}

View file

@ -21,7 +21,9 @@ const responseDto = {
externalPath: null,
memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY,
quotaSizeInBytes: null,
inTimeline: true,
quotaUsageInBytes: 0,
},
user1: <PartnerResponseDto>{
email: 'immich@test.com',
@ -39,6 +41,8 @@ const responseDto = {
memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY,
inTimeline: true,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
},
};

View file

@ -39,9 +39,10 @@ export type JobItem =
| { name: JobName.GENERATE_WEBP_THUMBNAIL; data: IEntityJob }
| { name: JobName.GENERATE_THUMBHASH_THUMBNAIL; data: IEntityJob }
// User Deletion
// User
| { name: JobName.USER_DELETE_CHECK; data?: IBaseJob }
| { name: JobName.USER_DELETION; data: IEntityJob }
| { name: JobName.USER_SYNC_USAGE; data?: IBaseJob }
// Storage Template
| { name: JobName.STORAGE_TEMPLATE_MIGRATION; data?: IBaseJob }

View file

@ -10,6 +10,7 @@ export interface UserStatsQueryResponse {
photos: number;
videos: number;
usage: number;
quotaSizeInBytes: number | null;
}
export interface UserFindOptions {
@ -32,4 +33,6 @@ export interface IUserRepository {
update(id: string, user: Partial<UserEntity>): Promise<UserEntity>;
delete(user: UserEntity, hard?: boolean): Promise<UserEntity>;
restore(user: UserEntity): Promise<UserEntity>;
updateUsage(id: string, delta: number): Promise<void>;
syncUsage(): Promise<void>;
}

View file

@ -45,6 +45,8 @@ export class UsageByUserDto {
videos!: number;
@ApiProperty({ type: 'integer', format: 'int64' })
usage!: number;
@ApiProperty({ type: 'integer', format: 'int64' })
quotaSizeInBytes!: number | null;
}
export class ServerStatsResponseDto {

View file

@ -220,6 +220,7 @@ describe(ServerInfoService.name, () => {
photos: 10,
videos: 11,
usage: 12345,
quotaSizeInBytes: 0,
},
{
userId: 'user2',
@ -227,6 +228,7 @@ describe(ServerInfoService.name, () => {
photos: 10,
videos: 20,
usage: 123456,
quotaSizeInBytes: 0,
},
{
userId: 'user3',
@ -234,6 +236,7 @@ describe(ServerInfoService.name, () => {
photos: 100,
videos: 0,
usage: 987654,
quotaSizeInBytes: 0,
},
]);
@ -244,6 +247,7 @@ describe(ServerInfoService.name, () => {
usageByUser: [
{
photos: 10,
quotaSizeInBytes: 0,
usage: 12345,
userName: '1 User',
userId: 'user1',
@ -251,6 +255,7 @@ describe(ServerInfoService.name, () => {
},
{
photos: 10,
quotaSizeInBytes: 0,
usage: 123456,
userName: '2 User',
userId: 'user2',
@ -258,6 +263,7 @@ describe(ServerInfoService.name, () => {
},
{
photos: 100,
quotaSizeInBytes: 0,
usage: 987654,
userName: '3 User',
userId: 'user3',

View file

@ -118,6 +118,7 @@ export class ServerInfoService {
usage.photos = user.photos;
usage.videos = user.videos;
usage.usage = user.usage;
usage.quotaSizeInBytes = user.quotaSizeInBytes;
serverStats.photos += usage.photos;
serverStats.videos += usage.videos;

View file

@ -1,5 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsEmail, IsNotEmpty, IsString } from 'class-validator';
import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsPositive, IsString } from 'class-validator';
import { Optional, toEmail, toSanitized } from '../../domain.util';
export class CreateUserDto {
@ -27,6 +28,12 @@ export class CreateUserDto {
@Optional()
@IsBoolean()
memoriesEnabled?: boolean;
@Optional({ nullable: true })
@IsNumber()
@IsPositive()
@ApiProperty({ type: 'integer', format: 'int64' })
quotaSizeInBytes?: number | null;
}
export class CreateAdminDto {

View file

@ -1,7 +1,7 @@
import { UserAvatarColor } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsString, IsUUID } from 'class-validator';
import { IsBoolean, IsEmail, IsEnum, IsNotEmpty, IsNumber, IsPositive, IsString, IsUUID } from 'class-validator';
import { Optional, toEmail, toSanitized } from '../../domain.util';
export class UpdateUserDto {
@ -50,4 +50,10 @@ export class UpdateUserDto {
@IsEnum(UserAvatarColor)
@ApiProperty({ enumName: 'UserAvatarColor', enum: UserAvatarColor })
avatarColor?: UserAvatarColor;
@Optional({ nullable: true })
@IsNumber()
@IsPositive()
@ApiProperty({ type: 'integer', format: 'int64' })
quotaSizeInBytes?: number | null;
}

View file

@ -33,6 +33,10 @@ export class UserResponseDto extends UserDto {
updatedAt!: Date;
oauthId!: string;
memoriesEnabled?: boolean;
@ApiProperty({ type: 'integer', format: 'int64' })
quotaSizeInBytes!: number | null;
@ApiProperty({ type: 'integer', format: 'int64' })
quotaUsageInBytes!: number;
}
export const mapSimpleUser = (entity: UserEntity): UserDto => {
@ -57,5 +61,7 @@ export function mapUser(entity: UserEntity): UserResponseDto {
updatedAt: entity.updatedAt,
oauthId: entity.oauthId,
memoriesEnabled: entity.memoriesEnabled,
quotaSizeInBytes: entity.quotaSizeInBytes,
quotaUsageInBytes: entity.quotaUsageInBytes,
};
}

View file

@ -512,4 +512,11 @@ describe(UserService.name, () => {
expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/library/admin', options);
});
});
describe('handleUserSyncUsage', () => {
it('should sync usage', async () => {
await sut.handleUserSyncUsage();
expect(userMock.syncUsage).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -127,6 +127,11 @@ export class UserService {
return { admin, password, provided: !!providedPassword };
}
async handleUserSyncUsage() {
await this.userRepository.syncUsage();
return true;
}
async handleUserDeleteCheck() {
const users = await this.userRepository.getDeletedUsers();
await this.jobRepository.queueAll(

View file

@ -1,5 +1,5 @@
import { AssetCreate } from '@app/domain';
import { AssetEntity } from '@app/infra/entities';
import { AssetEntity, ExifEntity } from '@app/infra/entities';
import { OptionalBetween } from '@app/infra/infra.utils';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
@ -23,6 +23,7 @@ export interface AssetOwnerCheck extends AssetCheck {
export interface IAssetRepository {
get(id: string): Promise<AssetEntity | null>;
create(asset: AssetCreate): Promise<AssetEntity>;
upsertExif(exif: Partial<ExifEntity>): Promise<void>;
getAllByUserId(userId: string, dto: AssetSearchDto): Promise<AssetEntity[]>;
getAllByDeviceId(userId: string, deviceId: string): Promise<string[]>;
getById(assetId: string): Promise<AssetEntity>;
@ -38,7 +39,10 @@ export const IAssetRepository = 'IAssetRepository';
@Injectable()
export class AssetRepository implements IAssetRepository {
constructor(@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>) {}
constructor(
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(ExifEntity) private exifRepository: Repository<ExifEntity>,
) {}
getSearchPropertiesByUserId(userId: string): Promise<SearchPropertiesDto[]> {
return this.assetRepository
@ -162,6 +166,10 @@ export class AssetRepository implements IAssetRepository {
return this.assetRepository.save(asset);
}
async upsertExif(exif: Partial<ExifEntity>): Promise<void> {
await this.exifRepository.upsert(exif, { conflictPaths: ['assetId'] });
}
/**
* Get assets by device's Id on the database
* @param ownerId

View file

@ -1,5 +1,6 @@
import { AuthDto, IJobRepository, JobName, mimeTypes, UploadFile } from '@app/domain';
import { AssetEntity } from '@app/infra/entities';
import { BadRequestException } from '@nestjs/common';
import { parse } from 'node:path';
import { IAssetRepository } from './asset-repository';
import { CreateAssetDto } from './dto/create-asset.dto';
@ -52,8 +53,15 @@ export class AssetCore {
isOffline: dto.isOffline ?? false,
});
await this.repository.upsertExif({ assetId: asset.id, fileSizeInByte: file.size });
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } });
return asset;
}
static requireQuota(auth: AuthDto, size: number) {
if (auth.user.quotaSizeInBytes && auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) {
throw new BadRequestException('Quota has been exceeded!');
}
}
}

View file

@ -1,4 +1,4 @@
import { IJobRepository, ILibraryRepository, JobName } from '@app/domain';
import { IJobRepository, ILibraryRepository, IUserRepository, JobName } from '@app/domain';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { BadRequestException } from '@nestjs/common';
import {
@ -9,6 +9,7 @@ import {
newAccessRepositoryMock,
newJobRepositoryMock,
newLibraryRepositoryMock,
newUserRepositoryMock,
} from '@test';
import { when } from 'jest-when';
import { QueryFailedError } from 'typeorm';
@ -87,11 +88,13 @@ describe('AssetService', () => {
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
let jobMock: jest.Mocked<IJobRepository>;
let libraryMock: jest.Mocked<ILibraryRepository>;
let userMock: jest.Mocked<IUserRepository>;
beforeEach(() => {
assetRepositoryMock = {
get: jest.fn(),
create: jest.fn(),
upsertExif: jest.fn(),
getAllByUserId: jest.fn(),
getAllByDeviceId: jest.fn(),
@ -107,8 +110,9 @@ describe('AssetService', () => {
accessMock = newAccessRepositoryMock();
jobMock = newJobRepositoryMock();
libraryMock = newLibraryRepositoryMock();
userMock = newUserRepositoryMock();
sut = new AssetService(accessMock, assetRepositoryMock, jobMock, libraryMock);
sut = new AssetService(accessMock, assetRepositoryMock, jobMock, libraryMock, userMock);
when(assetRepositoryMock.get)
.calledWith(assetStub.livePhotoStillAsset.id)
@ -127,6 +131,7 @@ describe('AssetService', () => {
mimeType: 'image/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg',
size: 42,
};
const dto = _getCreateAssetDto();
@ -136,6 +141,7 @@ describe('AssetService', () => {
await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: false, id: 'id_1' });
expect(assetRepositoryMock.create).toHaveBeenCalled();
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, file.size);
});
it('should handle a duplicate', async () => {
@ -145,6 +151,7 @@ describe('AssetService', () => {
mimeType: 'image/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg',
size: 0,
};
const dto = _getCreateAssetDto();
const error = new QueryFailedError('', [], '');
@ -160,6 +167,7 @@ describe('AssetService', () => {
name: JobName.DELETE_FILES,
data: { files: ['fake_path/asset_1.jpeg', undefined, undefined] },
});
expect(userMock.updateUsage).not.toHaveBeenCalled();
});
it('should handle a live photo', async () => {
@ -187,6 +195,7 @@ describe('AssetService', () => {
],
[{ name: JobName.METADATA_EXTRACTION, data: { id: assetStub.livePhotoStillAsset.id, source: 'upload' } }],
]);
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, 111);
});
});

View file

@ -8,6 +8,7 @@ import {
IJobRepository,
ILibraryRepository,
ImmichFileResponse,
IUserRepository,
JobName,
mapAsset,
mimeTypes,
@ -49,6 +50,7 @@ export class AssetService {
@Inject(IAssetRepository) private _assetRepository: IAssetRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ILibraryRepository) private libraryRepository: ILibraryRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
) {
this.assetCore = new AssetCore(_assetRepository, jobRepository);
this.access = AccessCore.create(accessRepository);
@ -73,6 +75,7 @@ export class AssetService {
try {
const libraryId = await this.getLibraryId(auth, dto.libraryId);
await this.access.requirePermission(auth, Permission.ASSET_UPLOAD, libraryId);
AssetCore.requireQuota(auth, file.size);
if (livePhotoFile) {
const livePhotoDto = { ...dto, assetType: AssetType.VIDEO, isVisible: false, libraryId };
livePhotoAsset = await this.assetCore.create(auth, livePhotoDto, livePhotoFile);
@ -86,6 +89,8 @@ export class AssetService {
sidecarFile?.originalPath,
);
await this.userRepository.updateUsage(auth.user.id, (livePhotoFile?.size || 0) + file.size);
return { id: asset.id, duplicate: false };
} catch (error: any) {
// clean up files

View file

@ -1,6 +1,6 @@
import { DomainModule } from '@app/domain';
import { InfraModule } from '@app/infra';
import { AssetEntity } from '@app/infra/entities';
import { AssetEntity, ExifEntity } from '@app/infra/entities';
import { Module, OnModuleInit } from '@nestjs/common';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
@ -40,7 +40,7 @@ import { ErrorInterceptor, FileUploadInterceptor } from './interceptors';
InfraModule,
DomainModule,
ScheduleModule.forRoot(),
TypeOrmModule.forFeature([AssetEntity]),
TypeOrmModule.forFeature([AssetEntity, ExifEntity]),
],
controllers: [
ActivityController,

View file

@ -27,6 +27,7 @@ export function mapToUploadFile(file: ImmichFile): UploadFile {
checksum: file.checksum,
originalPath: file.path,
originalName: Buffer.from(file.originalname, 'latin1').toString('utf8'),
size: file.size,
};
}

View file

@ -75,4 +75,10 @@ export class UserEntity {
@OneToMany(() => AssetEntity, (asset) => asset.owner)
assets!: AssetEntity[];
@Column({ type: 'bigint', nullable: true })
quotaSizeInBytes!: number | null;
@Column({ type: 'bigint', default: 0 })
quotaUsageInBytes!: number;
}

View file

@ -0,0 +1,16 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddQuotaColumnsToUser1704382918223 implements MigrationInterface {
name = 'AddQuotaColumnsToUser1704382918223'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" ADD "quotaSizeInBytes" bigint`);
await queryRunner.query(`ALTER TABLE "users" ADD "quotaUsageInBytes" bigint NOT NULL DEFAULT '0'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "quotaUsageInBytes"`);
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "quotaSizeInBytes"`);
}
}

View file

@ -2,12 +2,15 @@ import { IUserRepository, UserFindOptions, UserListFilter, UserStatsQueryRespons
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Not, Repository } from 'typeorm';
import { UserEntity } from '../entities';
import { AssetEntity, UserEntity } from '../entities';
import { DummyValue, GenerateSql } from '../infra.util';
@Injectable()
export class UserRepository implements IUserRepository {
constructor(@InjectRepository(UserEntity) private userRepository: Repository<UserEntity>) {}
constructor(
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(UserEntity) private userRepository: Repository<UserEntity>,
) {}
async get(userId: string, options: UserFindOptions): Promise<UserEntity | null> {
options = options || {};
@ -91,6 +94,7 @@ export class UserRepository implements IUserRepository {
.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')
.addSelect('users.quotaSizeInBytes', 'quotaSizeInBytes')
.leftJoin('users.assets', 'assets')
.leftJoin('assets.exifInfo', 'exif')
.groupBy('users.id')
@ -101,11 +105,32 @@ export class UserRepository implements IUserRepository {
stat.photos = Number(stat.photos);
stat.videos = Number(stat.videos);
stat.usage = Number(stat.usage);
stat.quotaSizeInBytes = stat.quotaSizeInBytes;
}
return stats;
}
async updateUsage(id: string, delta: number): Promise<void> {
await this.userRepository.increment({ id }, 'quotaUsageInBytes', delta);
}
async syncUsage() {
const subQuery = this.assetRepository
.createQueryBuilder('assets')
.select('COALESCE(SUM(exif."fileSizeInByte"), 0)')
.leftJoin('assets.exifInfo', 'exif')
.where('assets.ownerId = users.id')
.withDeleted();
await this.userRepository
.createQueryBuilder('users')
.leftJoin('users.assets', 'assets')
.update()
.set({ quotaUsageInBytes: () => `(${subQuery.getQuery()})` })
.execute();
}
private async save(user: Partial<UserEntity>) {
const { id } = await this.userRepository.save(user);
return this.userRepository.findOneByOrFail({ id });

View file

@ -29,6 +29,8 @@ FROM
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes",
"AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id",
"AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name",
"AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor",
@ -43,6 +45,8 @@ FROM
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaUsageInBytes",
"AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id",
"AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description",
"AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password",
@ -101,6 +105,8 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes",
"AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id",
"AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name",
"AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor",
@ -114,7 +120,9 @@ SELECT
"AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt",
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled"
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaUsageInBytes"
FROM
"albums" "AlbumEntity"
LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId"
@ -155,6 +163,8 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes",
"AlbumEntity__AlbumEntity_sharedUsers"."id" AS "AlbumEntity__AlbumEntity_sharedUsers_id",
"AlbumEntity__AlbumEntity_sharedUsers"."name" AS "AlbumEntity__AlbumEntity_sharedUsers_name",
"AlbumEntity__AlbumEntity_sharedUsers"."avatarColor" AS "AlbumEntity__AlbumEntity_sharedUsers_avatarColor",
@ -168,7 +178,9 @@ SELECT
"AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt",
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled"
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaUsageInBytes"
FROM
"albums" "AlbumEntity"
LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId"
@ -273,6 +285,8 @@ SELECT
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaUsageInBytes",
"AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id",
"AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description",
"AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password",
@ -298,7 +312,9 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled"
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes"
FROM
"albums" "AlbumEntity"
LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId" = "AlbumEntity"."id"
@ -342,6 +358,8 @@ SELECT
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaUsageInBytes",
"AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id",
"AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description",
"AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password",
@ -367,7 +385,9 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled"
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes"
FROM
"albums" "AlbumEntity"
LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId" = "AlbumEntity"."id"
@ -424,6 +444,8 @@ SELECT
"AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt",
"AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_sharedUsers"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaUsageInBytes",
"AlbumEntity__AlbumEntity_sharedLinks"."id" AS "AlbumEntity__AlbumEntity_sharedLinks_id",
"AlbumEntity__AlbumEntity_sharedLinks"."description" AS "AlbumEntity__AlbumEntity_sharedLinks_description",
"AlbumEntity__AlbumEntity_sharedLinks"."password" AS "AlbumEntity__AlbumEntity_sharedLinks_password",
@ -449,7 +471,9 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled"
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes"
FROM
"albums" "AlbumEntity"
LEFT JOIN "albums_shared_users_users" "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers" ON "AlbumEntity_AlbumEntity__AlbumEntity_sharedUsers"."albumsId" = "AlbumEntity"."id"
@ -498,7 +522,9 @@ SELECT
"AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt",
"AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt",
"AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt",
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled"
"AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled",
"AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes",
"AlbumEntity__AlbumEntity_owner"."quotaUsageInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaUsageInBytes"
FROM
"albums" "AlbumEntity"
LEFT JOIN "users" "AlbumEntity__AlbumEntity_owner" ON "AlbumEntity__AlbumEntity_owner"."id" = "AlbumEntity"."ownerId"

View file

@ -22,7 +22,9 @@ FROM
"APIKeyEntity__APIKeyEntity_user"."createdAt" AS "APIKeyEntity__APIKeyEntity_user_createdAt",
"APIKeyEntity__APIKeyEntity_user"."deletedAt" AS "APIKeyEntity__APIKeyEntity_user_deletedAt",
"APIKeyEntity__APIKeyEntity_user"."updatedAt" AS "APIKeyEntity__APIKeyEntity_user_updatedAt",
"APIKeyEntity__APIKeyEntity_user"."memoriesEnabled" AS "APIKeyEntity__APIKeyEntity_user_memoriesEnabled"
"APIKeyEntity__APIKeyEntity_user"."memoriesEnabled" AS "APIKeyEntity__APIKeyEntity_user_memoriesEnabled",
"APIKeyEntity__APIKeyEntity_user"."quotaSizeInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaSizeInBytes",
"APIKeyEntity__APIKeyEntity_user"."quotaUsageInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaUsageInBytes"
FROM
"api_keys" "APIKeyEntity"
LEFT JOIN "users" "APIKeyEntity__APIKeyEntity_user" ON "APIKeyEntity__APIKeyEntity_user"."id" = "APIKeyEntity"."userId"

View file

@ -30,7 +30,9 @@ FROM
"LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt",
"LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt",
"LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt",
"LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled"
"LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled",
"LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes",
"LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes"
FROM
"libraries" "LibraryEntity"
LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId"
@ -144,7 +146,9 @@ SELECT
"LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt",
"LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt",
"LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt",
"LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled"
"LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled",
"LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes",
"LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes"
FROM
"libraries" "LibraryEntity"
LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId"
@ -188,7 +192,9 @@ SELECT
"LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt",
"LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt",
"LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt",
"LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled"
"LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled",
"LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes",
"LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes"
FROM
"libraries" "LibraryEntity"
LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId"
@ -226,7 +232,9 @@ SELECT
"LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt",
"LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt",
"LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt",
"LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled"
"LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled",
"LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes",
"LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes"
FROM
"libraries" "LibraryEntity"
LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId"

View file

@ -155,7 +155,9 @@ FROM
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."createdAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_createdAt",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."deletedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_deletedAt",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."updatedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_updatedAt",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."memoriesEnabled" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_memoriesEnabled"
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."memoriesEnabled" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_memoriesEnabled",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaSizeInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaSizeInBytes",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaUsageInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaUsageInBytes"
FROM
"shared_links" "SharedLinkEntity"
LEFT JOIN "shared_link__asset" "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity" ON "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity"."sharedLinksId" = "SharedLinkEntity"."id"
@ -257,7 +259,9 @@ SELECT
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."createdAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_createdAt",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."deletedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_deletedAt",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."updatedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_updatedAt",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."memoriesEnabled" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_memoriesEnabled"
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."memoriesEnabled" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_memoriesEnabled",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaSizeInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaSizeInBytes",
"6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaUsageInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaUsageInBytes"
FROM
"shared_links" "SharedLinkEntity"
LEFT JOIN "shared_link__asset" "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity" ON "SharedLinkEntity__SharedLinkEntity_assets_SharedLinkEntity"."sharedLinksId" = "SharedLinkEntity"."id"
@ -309,7 +313,9 @@ FROM
"SharedLinkEntity__SharedLinkEntity_user"."createdAt" AS "SharedLinkEntity__SharedLinkEntity_user_createdAt",
"SharedLinkEntity__SharedLinkEntity_user"."deletedAt" AS "SharedLinkEntity__SharedLinkEntity_user_deletedAt",
"SharedLinkEntity__SharedLinkEntity_user"."updatedAt" AS "SharedLinkEntity__SharedLinkEntity_user_updatedAt",
"SharedLinkEntity__SharedLinkEntity_user"."memoriesEnabled" AS "SharedLinkEntity__SharedLinkEntity_user_memoriesEnabled"
"SharedLinkEntity__SharedLinkEntity_user"."memoriesEnabled" AS "SharedLinkEntity__SharedLinkEntity_user_memoriesEnabled",
"SharedLinkEntity__SharedLinkEntity_user"."quotaSizeInBytes" AS "SharedLinkEntity__SharedLinkEntity_user_quotaSizeInBytes",
"SharedLinkEntity__SharedLinkEntity_user"."quotaUsageInBytes" AS "SharedLinkEntity__SharedLinkEntity_user_quotaUsageInBytes"
FROM
"shared_links" "SharedLinkEntity"
LEFT JOIN "users" "SharedLinkEntity__SharedLinkEntity_user" ON "SharedLinkEntity__SharedLinkEntity_user"."id" = "SharedLinkEntity"."userId"

View file

@ -15,7 +15,9 @@ SELECT
"UserEntity"."createdAt" AS "UserEntity_createdAt",
"UserEntity"."deletedAt" AS "UserEntity_deletedAt",
"UserEntity"."updatedAt" AS "UserEntity_updatedAt",
"UserEntity"."memoriesEnabled" AS "UserEntity_memoriesEnabled"
"UserEntity"."memoriesEnabled" AS "UserEntity_memoriesEnabled",
"UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes",
"UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes"
FROM
"users" "UserEntity"
WHERE
@ -60,7 +62,9 @@ SELECT
"user"."createdAt" AS "user_createdAt",
"user"."deletedAt" AS "user_deletedAt",
"user"."updatedAt" AS "user_updatedAt",
"user"."memoriesEnabled" AS "user_memoriesEnabled"
"user"."memoriesEnabled" AS "user_memoriesEnabled",
"user"."quotaSizeInBytes" AS "user_quotaSizeInBytes",
"user"."quotaUsageInBytes" AS "user_quotaUsageInBytes"
FROM
"users" "user"
WHERE
@ -82,7 +86,9 @@ SELECT
"UserEntity"."createdAt" AS "UserEntity_createdAt",
"UserEntity"."deletedAt" AS "UserEntity_deletedAt",
"UserEntity"."updatedAt" AS "UserEntity_updatedAt",
"UserEntity"."memoriesEnabled" AS "UserEntity_memoriesEnabled"
"UserEntity"."memoriesEnabled" AS "UserEntity_memoriesEnabled",
"UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes",
"UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes"
FROM
"users" "UserEntity"
WHERE
@ -106,7 +112,9 @@ SELECT
"UserEntity"."createdAt" AS "UserEntity_createdAt",
"UserEntity"."deletedAt" AS "UserEntity_deletedAt",
"UserEntity"."updatedAt" AS "UserEntity_updatedAt",
"UserEntity"."memoriesEnabled" AS "UserEntity_memoriesEnabled"
"UserEntity"."memoriesEnabled" AS "UserEntity_memoriesEnabled",
"UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes",
"UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes"
FROM
"users" "UserEntity"
WHERE
@ -119,6 +127,7 @@ LIMIT
SELECT
"users"."id" AS "userId",
"users"."name" AS "userName",
"users"."quotaSizeInBytes" AS "quotaSizeInBytes",
COUNT("assets"."id") FILTER (
WHERE
"assets"."type" = 'IMAGE'

View file

@ -25,7 +25,9 @@ FROM
"UserTokenEntity__UserTokenEntity_user"."createdAt" AS "UserTokenEntity__UserTokenEntity_user_createdAt",
"UserTokenEntity__UserTokenEntity_user"."deletedAt" AS "UserTokenEntity__UserTokenEntity_user_deletedAt",
"UserTokenEntity__UserTokenEntity_user"."updatedAt" AS "UserTokenEntity__UserTokenEntity_user_updatedAt",
"UserTokenEntity__UserTokenEntity_user"."memoriesEnabled" AS "UserTokenEntity__UserTokenEntity_user_memoriesEnabled"
"UserTokenEntity__UserTokenEntity_user"."memoriesEnabled" AS "UserTokenEntity__UserTokenEntity_user_memoriesEnabled",
"UserTokenEntity__UserTokenEntity_user"."quotaSizeInBytes" AS "UserTokenEntity__UserTokenEntity_user_quotaSizeInBytes",
"UserTokenEntity__UserTokenEntity_user"."quotaUsageInBytes" AS "UserTokenEntity__UserTokenEntity_user_quotaUsageInBytes"
FROM
"user_token" "UserTokenEntity"
LEFT JOIN "users" "UserTokenEntity__UserTokenEntity_user" ON "UserTokenEntity__UserTokenEntity_user"."id" = "UserTokenEntity"."userId"

View file

@ -45,6 +45,7 @@ export class AppService {
[JobName.CLEAN_OLD_AUDIT_LOGS]: () => this.auditService.handleCleanup(),
[JobName.USER_DELETE_CHECK]: () => this.userService.handleUserDeleteCheck(),
[JobName.USER_DELETION]: (data) => this.userService.handleUserDelete(data),
[JobName.USER_SYNC_USAGE]: () => this.userService.handleUserSyncUsage(),
[JobName.QUEUE_ENCODE_CLIP]: (data) => this.smartInfoService.handleQueueEncodeClip(data),
[JobName.ENCODE_CLIP]: (data) => this.smartInfoService.handleEncodeClip(data),
[JobName.STORAGE_TEMPLATE_MIGRATION]: () => this.storageTemplateService.handleMigration(),

View file

@ -4,11 +4,13 @@ export const fileStub = {
originalPath: 'fake_path/asset_1.jpeg',
checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg',
size: 42,
}),
livePhotoMotion: Object.freeze({
uuid: 'random-uuid',
originalPath: 'fake_path/asset_1.mp4',
checksum: Buffer.from('live photo file hash', 'utf8'),
originalName: 'asset_1.mp4',
size: 69,
}),
};

View file

@ -17,6 +17,12 @@ export const userDto = {
password: 'Password123',
name: 'User 3',
},
userWithQuota: {
email: 'quota-user@immich.app',
password: 'Password123',
name: 'User with quota',
quotaSizeInBytes: 42,
},
};
export const userStub = {
@ -36,6 +42,8 @@ export const userStub = {
assets: [],
memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
}),
user1: Object.freeze<UserEntity>({
...authStub.user1.user,
@ -53,6 +61,8 @@ export const userStub = {
assets: [],
memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
}),
user2: Object.freeze<UserEntity>({
...authStub.user2.user,
@ -70,6 +80,8 @@ export const userStub = {
assets: [],
memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
}),
storageLabel: Object.freeze<UserEntity>({
...authStub.user1.user,
@ -87,6 +99,8 @@ export const userStub = {
assets: [],
memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
}),
externalPath1: Object.freeze<UserEntity>({
...authStub.user1.user,
@ -104,6 +118,8 @@ export const userStub = {
assets: [],
memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
}),
externalPath2: Object.freeze<UserEntity>({
...authStub.user1.user,
@ -121,6 +137,8 @@ export const userStub = {
assets: [],
memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
}),
profilePath: Object.freeze<UserEntity>({
...authStub.user1.user,
@ -138,5 +156,7 @@ export const userStub = {
assets: [],
memoriesEnabled: true,
avatarColor: UserAvatarColor.PRIMARY,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
}),
};

View file

@ -19,5 +19,7 @@ export const newUserRepositoryMock = (reset = true): jest.Mocked<IUserRepository
getDeletedUsers: jest.fn(),
restore: jest.fn(),
hasAdmin: jest.fn(),
updateUsage: jest.fn(),
syncUsage: jest.fn(),
};
};

View file

@ -3,7 +3,7 @@
import type { ServerStatsResponseDto } from '@api';
import { asByteUnitString, getBytesWithUnit } from '$lib/utils/byte-units';
import StatsCard from './stats-card.svelte';
import { mdiCameraIris, mdiMemory, mdiPlayCircle } from '@mdi/js';
import { mdiCameraIris, mdiChartPie, mdiPlayCircle } from '@mdi/js';
import Icon from '$lib/components/elements/icon.svelte';
export let stats: ServerStatsResponseDto = {
@ -32,7 +32,7 @@
<div class="mt-5 hidden justify-between lg:flex">
<StatsCard icon={mdiCameraIris} title="PHOTOS" value={stats.photos} />
<StatsCard icon={mdiPlayCircle} title="VIDEOS" value={stats.videos} />
<StatsCard icon={mdiMemory} title="STORAGE" value={statsUsage} unit={statsUsageUnit} />
<StatsCard icon={mdiChartPie} title="STORAGE" value={statsUsage} unit={statsUsageUnit} />
</div>
<div class="mt-5 flex lg:hidden">
<div class="flex flex-col justify-between rounded-3xl bg-immich-gray p-5 dark:bg-immich-dark-gray">
@ -62,7 +62,7 @@
</div>
<div class="flex flex-wrap gap-x-7">
<div class="flex place-items-center gap-4 text-immich-primary dark:text-immich-dark-primary">
<Icon path={mdiMemory} size="25" />
<Icon path={mdiChartPie} size="25" />
<p>STORAGE</p>
</div>
@ -87,7 +87,7 @@
<th class="w-1/4 text-center text-sm font-medium">User</th>
<th class="w-1/4 text-center text-sm font-medium">Photos</th>
<th class="w-1/4 text-center text-sm font-medium">Videos</th>
<th class="w-1/4 text-center text-sm font-medium">Size</th>
<th class="w-1/4 text-center text-sm font-medium">Usage</th>
</tr>
</thead>
<tbody
@ -100,7 +100,16 @@
<td class="w-1/4 text-ellipsis px-2 text-sm">{user.userName}</td>
<td class="w-1/4 text-ellipsis px-2 text-sm">{user.photos.toLocaleString($locale)}</td>
<td class="w-1/4 text-ellipsis px-2 text-sm">{user.videos.toLocaleString($locale)}</td>
<td class="w-1/4 text-ellipsis px-2 text-sm">{asByteUnitString(user.usage, $locale)}</td>
<td class="w-1/4 text-ellipsis px-2 text-sm">
{asByteUnitString(user.usage, $locale, 0)}
<span class="text-immich-primary dark:text-immich-dark-primary">
{#if user.quotaSizeInBytes}
({((user.usage / user.quotaSizeInBytes) * 100).toFixed(0)}%)
{:else}
(Unlimited)
{/if}
</span>
</td>
</tr>
{/each}
</tbody>

View file

@ -4,6 +4,7 @@
import ImmichLogo from '../shared-components/immich-logo.svelte';
import { notificationController, NotificationType } from '../shared-components/notification/notification';
import Button from '../elements/buttons/button.svelte';
import { convertToBytes } from '$lib/utils/byte-converter';
let error: string;
let success: string;
@ -42,6 +43,7 @@
const email = form.get('email');
const password = form.get('password');
const name = form.get('name');
const quotaSize = form.get('quotaSize');
try {
const { status } = await api.userApi.createUser({
@ -49,6 +51,7 @@
email: String(email),
password: String(password),
name: String(name),
quotaSizeInBytes: quotaSize ? convertToBytes(Number(quotaSize), 'GiB') : null,
},
});
@ -117,6 +120,11 @@
<input class="immich-form-input" id="name" name="name" type="text" required />
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="quotaSize">Quota Size (GB)</label>
<input class="immich-form-input" id="quotaSize" name="quotaSize" type="number" min="0" />
</div>
{#if error}
<p class="ml-4 text-sm text-red-400">{error}</p>
{/if}

View file

@ -4,11 +4,12 @@
import { notificationController, NotificationType } from '../shared-components/notification/notification';
import Button from '../elements/buttons/button.svelte';
import ConfirmDialogue from '$lib/components/shared-components/confirm-dialogue.svelte';
import { handleError } from '../../utils/handle-error';
import Icon from '$lib/components/elements/icon.svelte';
import { mdiAccountEditOutline, mdiClose } from '@mdi/js';
import { AppRoute } from '$lib/constants';
import CircleIconButton from '../elements/buttons/circle-icon-button.svelte';
import { handleError } from '$lib/utils/handle-error';
import { convertFromBytes, convertToBytes } from '$lib/utils/byte-converter';
export let user: UserResponseDto;
export let canResetPassword = true;
@ -24,6 +25,8 @@
editSuccess: void;
}>();
let quotaSize = user.quotaSizeInBytes ? convertFromBytes(user.quotaSizeInBytes, 'GiB') : null;
const editUser = async () => {
try {
const { id, email, name, storageLabel, externalPath } = user;
@ -34,6 +37,7 @@
name,
storageLabel: storageLabel || '',
externalPath: externalPath || '',
quotaSizeInBytes: quotaSize ? convertToBytes(Number(quotaSize), 'GiB') : null,
},
});
@ -97,6 +101,11 @@
<input class="immich-form-input" id="name" name="name" type="text" required bind:value={user.name} />
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="quotaSize">Quota Size (GB)</label>
<input class="immich-form-input" id="quotaSize" name="quotaSize" type="number" min="0" bind:value={quotaSize} />
</div>
<div class="m-4 flex flex-col gap-2">
<label class="immich-form-label" for="storage-label">Storage Label</label>
<input

View file

@ -1,19 +1,43 @@
<script lang="ts">
import { browser } from '$app/environment';
import Icon from '$lib/components/elements/icon.svelte';
import { locale } from '$lib/stores/preferences.store';
import { websocketStore } from '$lib/stores/websocket';
import { api } from '@api';
import { onDestroy, onMount } from 'svelte';
import Icon from '$lib/components/elements/icon.svelte';
import { UserResponseDto, api } from '@api';
import { onMount } from 'svelte';
import { asByteUnitString } from '../../utils/byte-units';
import LoadingSpinner from './loading-spinner.svelte';
import { mdiCloud, mdiDns } from '@mdi/js';
import { mdiChartPie, mdiDns } from '@mdi/js';
import { serverInfoStore } from '$lib/stores/server-info.store';
const { serverVersion, connected } = websocketStore;
let userInfo: UserResponseDto;
let usageClasses = '';
$: version = $serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null;
$: usedPercentage = Math.round(($serverInfoStore?.diskUseRaw / $serverInfoStore?.diskSizeRaw) * 100);
$: hasQuota = userInfo?.quotaSizeInBytes !== null;
$: availableBytes = (hasQuota ? userInfo?.quotaSizeInBytes : $serverInfoStore.diskSizeRaw) || 0;
$: usedBytes = (hasQuota ? userInfo?.quotaUsageInBytes : $serverInfoStore.diskUseRaw) || 0;
$: usedPercentage = Math.round((usedBytes / availableBytes) * 100);
const onUpdate = () => {
usedPercentage = 81;
usageClasses = getUsageClass();
};
const getUsageClass = () => {
if (usedPercentage >= 95) {
return 'bg-red-500';
}
if (usedPercentage > 80) {
return 'bg-yellow-500';
}
return 'bg-immich-primary dark:bg-immich-dark-primary';
};
$: userInfo && onUpdate();
onMount(async () => {
await refresh();
@ -21,39 +45,33 @@
const refresh = async () => {
try {
const { data } = await api.serverInfoApi.getServerInfo();
$serverInfoStore = data;
[$serverInfoStore, userInfo] = await Promise.all([
api.serverInfoApi.getServerInfo().then(({ data }) => data),
api.userApi.getMyUserInfo().then(({ data }) => data),
]);
} catch (e) {
console.log('Error [StatusBox] [onMount]');
}
};
let interval: number;
if (browser) {
interval = window.setInterval(() => refresh(), 10_000);
}
onDestroy(() => clearInterval(interval));
</script>
<div class="dark:text-immich-dark-fg">
<div class="storage-status grid grid-cols-[64px_auto]">
<div
class="storage-status grid grid-cols-[64px_auto]"
title="Used {asByteUnitString(usedBytes, $locale, 3)} of {asByteUnitString(availableBytes, $locale, 3)}"
>
<div class="pb-[2.15rem] pl-5 pr-6 text-immich-primary dark:text-immich-dark-primary group-hover:sm:pb-0 md:pb-0">
<Icon path={mdiCloud} size={'24'} />
<Icon path={mdiChartPie} size="24" />
</div>
<div class="hidden group-hover:sm:block md:block">
<p class="text-sm font-medium text-immich-primary dark:text-immich-dark-primary">Storage</p>
{#if $serverInfoStore}
<div class="my-2 h-[7px] w-full rounded-full bg-gray-200 dark:bg-gray-700">
<!-- style={`width: ${$downloadAssets[fileName]}%`} -->
<div
class="h-[7px] rounded-full bg-immich-primary dark:bg-immich-dark-primary"
style="width: {usedPercentage}%"
/>
<div class="h-[7px] rounded-full {usageClasses}" style="width: {usedPercentage}%" />
</div>
<p class="text-xs">
{asByteUnitString($serverInfoStore?.diskUseRaw, $locale)} of
{asByteUnitString($serverInfoStore?.diskSizeRaw, $locale)} used
{asByteUnitString(usedBytes, $locale)} of
{asByteUnitString(availableBytes, $locale)} used
</p>
{:else}
<div class="mt-2">
@ -67,7 +85,7 @@
</div>
<div class="server-status grid grid-cols-[64px_auto]">
<div class="pb-11 pl-5 pr-6 text-immich-primary dark:text-immich-dark-primary group-hover:sm:pb-0 md:pb-0">
<Icon path={mdiDns} size={'24'} />
<Icon path={mdiDns} size="26" />
</div>
<div class="hidden text-xs group-hover:sm:block md:block">
<p class="text-sm font-medium text-immich-primary dark:text-immich-dark-primary">Server</p>

View file

@ -0,0 +1,37 @@
/**
* Convert to bytes from on a specified unit.
*
* * `1, 'GiB'`, returns `1073741824` bytes
*
* @param size value to be converted
* @param unit unit to convert from
* @returns bytes (number)
*/
export function convertToBytes(size: number, unit: string): number {
let bytes = 0;
if (unit === 'GiB') {
bytes = size * 1073741824;
}
return bytes;
}
/**
* Convert from bytes to a specified unit.
*
* * `11073741824, 'GiB'`, returns `1` GiB
*
* @param bytes value to be converted
* @param unit unit to convert to
* @returns bytes (number)
*/
export function convertFromBytes(bytes: number, unit: string): number {
let size = 0;
if (unit === 'GiB') {
size = bytes / 1073741824;
}
return size;
}

View file

@ -1,9 +1,9 @@
import { uploadAssetsStore } from '$lib/stores/upload';
import { addAssetsToAlbum } from '$lib/utils/asset-utils';
import { api, AssetFileUploadResponseDto } from '@api';
import { notificationController, NotificationType } from './../components/shared-components/notification/notification';
import { UploadState } from '$lib/models/upload-asset';
import { ExecutorQueue } from '$lib/utils/executor-queue';
import { getServerErrorMessage, handleError } from './handle-error';
let _extensions: string[];
@ -115,26 +115,10 @@ async function fileUploader(asset: File, albumId: string | undefined = undefined
return res.id;
}
})
.catch((reason) => {
console.log('error uploading file ', reason);
.catch(async (error) => {
await handleError(error, 'Unable to upload file');
const reason = (await getServerErrorMessage(error)) || error;
uploadAssetsStore.updateAsset(deviceAssetId, { state: UploadState.ERROR, error: reason });
handleUploadError(asset, JSON.stringify(reason));
return undefined;
});
}
function handleUploadError(asset: File, respBody = '{}', extraMessage?: string) {
try {
const res = JSON.parse(respBody);
const extraMsg = res ? ' ' + res?.message : '';
const messageSuffix = extraMessage !== undefined ? ` ${extraMessage}` : '';
notificationController.show({
type: NotificationType.Error,
message: `Cannot upload file ${asset.name} ${extraMsg}${messageSuffix}`,
timeout: 5000,
});
} catch (e) {
console.error('ERROR parsing data JSON in handleUploadError');
}
}

View file

@ -17,4 +17,6 @@ export const userFactory = Sync.makeFactory<UserResponseDto>({
memoriesEnabled: true,
oauthId: '',
avatarColor: UserAvatarColor.Primary,
quotaUsageInBytes: 0,
quotaSizeInBytes: null,
});