mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00:00
feat(web,server): offline/untracked files admin tool (#4447)
* feat: admin repair orphans tool * chore: open api * fix: include upload folder * fix: bugs * feat: empty placeholder * fix: checks * feat: move buttons to top of page * feat: styling and clipboard * styling * better clicking hitbox * fix: show title on hover * feat: download report * restrict file access to immich related files * Add description --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com> Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
This commit is contained in:
@@ -2286,6 +2286,118 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/audit/file-report": {
|
||||
"get": {
|
||||
"operationId": "getAuditFiles",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FileReportDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Audit"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/audit/file-report/checksum": {
|
||||
"post": {
|
||||
"operationId": "getFileChecksums",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FileChecksumDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FileChecksumResponseDto"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Audit"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/audit/file-report/fix": {
|
||||
"post": {
|
||||
"operationId": "fixAuditFiles",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FileReportFixDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Audit"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/auth/admin-sign-up": {
|
||||
"post": {
|
||||
"operationId": "adminSignUp",
|
||||
@@ -6580,6 +6692,97 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"FileChecksumDto": {
|
||||
"properties": {
|
||||
"filenames": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"filenames"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"FileChecksumResponseDto": {
|
||||
"properties": {
|
||||
"checksum": {
|
||||
"type": "string"
|
||||
},
|
||||
"filename": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"filename",
|
||||
"checksum"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"FileReportDto": {
|
||||
"properties": {
|
||||
"extras": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"orphans": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FileReportItemDto"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"orphans",
|
||||
"extras"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"FileReportFixDto": {
|
||||
"properties": {
|
||||
"items": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FileReportItemDto"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"items"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"FileReportItemDto": {
|
||||
"properties": {
|
||||
"checksum": {
|
||||
"type": "string"
|
||||
},
|
||||
"entityId": {
|
||||
"format": "uuid",
|
||||
"type": "string"
|
||||
},
|
||||
"entityType": {
|
||||
"$ref": "#/components/schemas/PathEntityType"
|
||||
},
|
||||
"pathType": {
|
||||
"$ref": "#/components/schemas/PathType"
|
||||
},
|
||||
"pathValue": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"entityId",
|
||||
"entityType",
|
||||
"pathType",
|
||||
"pathValue"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ImportAssetDto": {
|
||||
"properties": {
|
||||
"assetPath": {
|
||||
@@ -7027,6 +7230,26 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PathEntityType": {
|
||||
"enum": [
|
||||
"asset",
|
||||
"person",
|
||||
"user"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PathType": {
|
||||
"enum": [
|
||||
"original",
|
||||
"jpeg_thumbnail",
|
||||
"webp_thumbnail",
|
||||
"encoded_video",
|
||||
"sidecar",
|
||||
"face",
|
||||
"profile"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"PeopleResponseDto": {
|
||||
"properties": {
|
||||
"people": {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { EntityType } from '@app/infra/entities';
|
||||
import { AssetPathType, EntityType, PathType, PersonPathType, UserPathType } from '@app/infra/entities';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsDate, IsEnum, IsUUID } from 'class-validator';
|
||||
import { Optional } from '../domain.util';
|
||||
import { IsArray, IsDate, IsEnum, IsString, IsUUID, ValidateNested } from 'class-validator';
|
||||
import { Optional, ValidateUUID } from '../domain.util';
|
||||
|
||||
const PathEnum = Object.values({ ...AssetPathType, ...PersonPathType, ...UserPathType });
|
||||
|
||||
export class AuditDeletesDto {
|
||||
@IsDate()
|
||||
@@ -19,7 +21,54 @@ export class AuditDeletesDto {
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export enum PathEntityType {
|
||||
ASSET = 'asset',
|
||||
PERSON = 'person',
|
||||
USER = 'user',
|
||||
}
|
||||
|
||||
export class AuditDeletesResponseDto {
|
||||
needsFullSync!: boolean;
|
||||
ids!: string[];
|
||||
}
|
||||
|
||||
export class FileReportDto {
|
||||
orphans!: FileReportItemDto[];
|
||||
extras!: string[];
|
||||
}
|
||||
|
||||
export class FileChecksumDto {
|
||||
@IsString({ each: true })
|
||||
filenames!: string[];
|
||||
}
|
||||
|
||||
export class FileChecksumResponseDto {
|
||||
filename!: string;
|
||||
checksum!: string;
|
||||
}
|
||||
|
||||
export class FileReportFixDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => FileReportItemDto)
|
||||
items!: FileReportItemDto[];
|
||||
}
|
||||
|
||||
// used both as request and response dto
|
||||
export class FileReportItemDto {
|
||||
@ValidateUUID()
|
||||
entityId!: string;
|
||||
|
||||
@ApiProperty({ enumName: 'PathEntityType', enum: PathEntityType })
|
||||
@IsEnum(PathEntityType)
|
||||
entityType!: PathEntityType;
|
||||
|
||||
@ApiProperty({ enumName: 'PathType', enum: PathEnum })
|
||||
@IsEnum(PathEnum)
|
||||
pathType!: PathType;
|
||||
|
||||
@IsString()
|
||||
pathValue!: string;
|
||||
|
||||
checksum?: string;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,45 @@
|
||||
import { DatabaseAction, EntityType } from '@app/infra/entities';
|
||||
import { IAccessRepositoryMock, auditStub, authStub, newAccessRepositoryMock, newAuditRepositoryMock } from '@test';
|
||||
import { IAuditRepository } from '../repositories';
|
||||
import {
|
||||
IAccessRepositoryMock,
|
||||
auditStub,
|
||||
authStub,
|
||||
newAccessRepositoryMock,
|
||||
newAssetRepositoryMock,
|
||||
newAuditRepositoryMock,
|
||||
newCryptoRepositoryMock,
|
||||
newPersonRepositoryMock,
|
||||
newStorageRepositoryMock,
|
||||
newUserRepositoryMock,
|
||||
} from '@test';
|
||||
import {
|
||||
IAssetRepository,
|
||||
IAuditRepository,
|
||||
ICryptoRepository,
|
||||
IPersonRepository,
|
||||
IStorageRepository,
|
||||
IUserRepository,
|
||||
} from '../repositories';
|
||||
import { AuditService } from './audit.service';
|
||||
|
||||
describe(AuditService.name, () => {
|
||||
let sut: AuditService;
|
||||
let accessMock: IAccessRepositoryMock;
|
||||
let assetMock: jest.Mocked<IAssetRepository>;
|
||||
let auditMock: jest.Mocked<IAuditRepository>;
|
||||
let cryptoMock: jest.Mocked<ICryptoRepository>;
|
||||
let personMock: jest.Mocked<IPersonRepository>;
|
||||
let storageMock: jest.Mocked<IStorageRepository>;
|
||||
let userMock: jest.Mocked<IUserRepository>;
|
||||
|
||||
beforeEach(async () => {
|
||||
accessMock = newAccessRepositoryMock();
|
||||
assetMock = newAssetRepositoryMock();
|
||||
cryptoMock = newCryptoRepositoryMock();
|
||||
auditMock = newAuditRepositoryMock();
|
||||
sut = new AuditService(accessMock, auditMock);
|
||||
personMock = newPersonRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
sut = new AuditService(accessMock, assetMock, cryptoMock, personMock, auditMock, storageMock, userMock);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
@@ -1,19 +1,44 @@
|
||||
import { DatabaseAction } from '@app/infra/entities';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { AssetPathType, DatabaseAction, PersonPathType, UserPathType } from '@app/infra/entities';
|
||||
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { resolve } from 'node:path';
|
||||
import { AccessCore, Permission } from '../access';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { AUDIT_LOG_MAX_DURATION } from '../domain.constant';
|
||||
import { IAccessRepository, IAuditRepository } from '../repositories';
|
||||
import { AuditDeletesDto, AuditDeletesResponseDto } from './audit.dto';
|
||||
import { usePagination } from '../domain.util';
|
||||
import { JOBS_ASSET_PAGINATION_SIZE } from '../job';
|
||||
import {
|
||||
IAccessRepository,
|
||||
IAssetRepository,
|
||||
IAuditRepository,
|
||||
ICryptoRepository,
|
||||
IPersonRepository,
|
||||
IStorageRepository,
|
||||
IUserRepository,
|
||||
} from '../repositories';
|
||||
import { StorageCore, StorageFolder } from '../storage';
|
||||
import {
|
||||
AuditDeletesDto,
|
||||
AuditDeletesResponseDto,
|
||||
FileChecksumDto,
|
||||
FileChecksumResponseDto,
|
||||
FileReportItemDto,
|
||||
PathEntityType,
|
||||
} from './audit.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AuditService {
|
||||
private access: AccessCore;
|
||||
private logger = new Logger(AuditService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(IAccessRepository) accessRepository: IAccessRepository,
|
||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
|
||||
@Inject(IPersonRepository) private personRepository: IPersonRepository,
|
||||
@Inject(IAuditRepository) private repository: IAuditRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||
) {
|
||||
this.access = new AccessCore(accessRepository);
|
||||
}
|
||||
@@ -40,4 +65,160 @@ export class AuditService {
|
||||
ids: audits.map(({ entityId }) => entityId),
|
||||
};
|
||||
}
|
||||
|
||||
async getChecksums(dto: FileChecksumDto) {
|
||||
const results: FileChecksumResponseDto[] = [];
|
||||
for (const filename of dto.filenames) {
|
||||
if (!StorageCore.isImmichPath(filename)) {
|
||||
throw new BadRequestException(
|
||||
`Could not get the checksum of ${filename} because the file isn't accessible by Immich`,
|
||||
);
|
||||
}
|
||||
|
||||
const checksum = await this.cryptoRepository.hashFile(filename);
|
||||
results.push({ filename, checksum: checksum.toString('base64') });
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async fixItems(items: FileReportItemDto[]) {
|
||||
for (const { entityId: id, pathType, pathValue } of items) {
|
||||
if (!StorageCore.isImmichPath(pathValue)) {
|
||||
throw new BadRequestException(
|
||||
`Could not fix item ${id} with path ${pathValue} because the file isn't accessible by Immich`,
|
||||
);
|
||||
}
|
||||
|
||||
switch (pathType) {
|
||||
case AssetPathType.ENCODED_VIDEO:
|
||||
await this.assetRepository.save({ id, encodedVideoPath: pathValue });
|
||||
break;
|
||||
|
||||
case AssetPathType.JPEG_THUMBNAIL:
|
||||
await this.assetRepository.save({ id, resizePath: pathValue });
|
||||
break;
|
||||
|
||||
case AssetPathType.WEBP_THUMBNAIL:
|
||||
await this.assetRepository.save({ id, webpPath: pathValue });
|
||||
break;
|
||||
|
||||
case AssetPathType.ORIGINAL:
|
||||
await this.assetRepository.save({ id, originalPath: pathValue });
|
||||
break;
|
||||
|
||||
case AssetPathType.SIDECAR:
|
||||
await this.assetRepository.save({ id, sidecarPath: pathValue });
|
||||
break;
|
||||
|
||||
case PersonPathType.FACE:
|
||||
await this.personRepository.update({ id, thumbnailPath: pathValue });
|
||||
break;
|
||||
|
||||
case UserPathType.PROFILE:
|
||||
await this.userRepository.update(id, { profileImagePath: pathValue });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getFileReport() {
|
||||
const fullPath = (filename: string) => resolve(filename);
|
||||
const hasFile = (items: Set<string>, filename: string) => items.has(filename) || items.has(fullPath(filename));
|
||||
const crawl = async (folder: StorageFolder) =>
|
||||
new Set(await this.storageRepository.crawl({ pathsToCrawl: [StorageCore.getBaseFolder(folder)] }));
|
||||
|
||||
const uploadFiles = await crawl(StorageFolder.UPLOAD);
|
||||
const libraryFiles = await crawl(StorageFolder.LIBRARY);
|
||||
const thumbFiles = await crawl(StorageFolder.THUMBNAILS);
|
||||
const videoFiles = await crawl(StorageFolder.ENCODED_VIDEO);
|
||||
const profileFiles = await crawl(StorageFolder.PROFILE);
|
||||
const allFiles = new Set<string>();
|
||||
for (const list of [libraryFiles, thumbFiles, videoFiles, profileFiles, uploadFiles]) {
|
||||
for (const item of list) {
|
||||
allFiles.add(item);
|
||||
}
|
||||
}
|
||||
|
||||
const track = (filename: string | null) => {
|
||||
if (!filename) {
|
||||
return;
|
||||
}
|
||||
allFiles.delete(filename);
|
||||
allFiles.delete(fullPath(filename));
|
||||
};
|
||||
|
||||
this.logger.log(
|
||||
`Found ${libraryFiles.size} original files, ${thumbFiles.size} thumbnails, ${videoFiles.size} encoded videos, ${profileFiles.size} profile files`,
|
||||
);
|
||||
const pagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (options) =>
|
||||
this.assetRepository.getAll(options, { withDeleted: true }),
|
||||
);
|
||||
|
||||
let assetCount = 0;
|
||||
|
||||
const orphans: FileReportItemDto[] = [];
|
||||
for await (const assets of pagination) {
|
||||
assetCount += assets.length;
|
||||
for (const { id, originalPath, resizePath, encodedVideoPath, webpPath, isExternal, checksum } of assets) {
|
||||
for (const file of [originalPath, resizePath, encodedVideoPath, webpPath]) {
|
||||
track(file);
|
||||
}
|
||||
|
||||
const entity = { entityId: id, entityType: PathEntityType.ASSET, checksum: checksum.toString('base64') };
|
||||
if (
|
||||
originalPath &&
|
||||
!hasFile(libraryFiles, originalPath) &&
|
||||
!hasFile(uploadFiles, originalPath) &&
|
||||
// Android motion assets
|
||||
!hasFile(videoFiles, originalPath) &&
|
||||
// ignore external library assets
|
||||
!isExternal
|
||||
) {
|
||||
orphans.push({ ...entity, pathType: AssetPathType.ORIGINAL, pathValue: originalPath });
|
||||
}
|
||||
if (resizePath && !hasFile(thumbFiles, resizePath)) {
|
||||
orphans.push({ ...entity, pathType: AssetPathType.JPEG_THUMBNAIL, pathValue: resizePath });
|
||||
}
|
||||
if (webpPath && !hasFile(thumbFiles, webpPath)) {
|
||||
orphans.push({ ...entity, pathType: AssetPathType.WEBP_THUMBNAIL, pathValue: webpPath });
|
||||
}
|
||||
if (encodedVideoPath && !hasFile(videoFiles, encodedVideoPath)) {
|
||||
orphans.push({ ...entity, pathType: AssetPathType.WEBP_THUMBNAIL, pathValue: encodedVideoPath });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const users = await this.userRepository.getList();
|
||||
for (const { id, profileImagePath } of users) {
|
||||
track(profileImagePath);
|
||||
|
||||
const entity = { entityId: id, entityType: PathEntityType.USER };
|
||||
if (profileImagePath && !hasFile(profileFiles, profileImagePath)) {
|
||||
orphans.push({ ...entity, pathType: UserPathType.PROFILE, pathValue: profileImagePath });
|
||||
}
|
||||
}
|
||||
|
||||
const people = await this.personRepository.getAll();
|
||||
for (const { id, thumbnailPath } of people) {
|
||||
track(thumbnailPath);
|
||||
const entity = { entityId: id, entityType: PathEntityType.PERSON };
|
||||
if (thumbnailPath && !hasFile(thumbFiles, thumbnailPath)) {
|
||||
orphans.push({ ...entity, pathType: PersonPathType.FACE, pathValue: thumbnailPath });
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Found ${assetCount} assets, ${users.length} users, ${people.length} people`);
|
||||
|
||||
const extras: string[] = [];
|
||||
for (const file of allFiles) {
|
||||
extras.push(file);
|
||||
}
|
||||
|
||||
// send as absolute paths
|
||||
for (const orphan of orphans) {
|
||||
orphan.pathValue = fullPath(orphan.pathValue);
|
||||
}
|
||||
|
||||
return { orphans, extras };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,6 +289,9 @@ export class MetadataService {
|
||||
});
|
||||
const checksum = this.cryptoRepository.hashSha1(video);
|
||||
|
||||
const motionPath = this.storageCore.getAndroidMotionPath(asset);
|
||||
this.storageCore.ensureFolders(motionPath);
|
||||
|
||||
let motionAsset = await this.assetRepository.getByChecksum(asset.ownerId, checksum);
|
||||
if (!motionAsset) {
|
||||
const createdAt = asset.fileCreatedAt ?? asset.createdAt;
|
||||
@@ -300,7 +303,7 @@ export class MetadataService {
|
||||
localDateTime: createdAt,
|
||||
checksum,
|
||||
ownerId: asset.ownerId,
|
||||
originalPath: this.storageCore.getAndroidMotionPath(asset),
|
||||
originalPath: motionPath,
|
||||
originalFileName: asset.originalFileName,
|
||||
isVisible: false,
|
||||
isReadOnly: false,
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface AssetSearchOptions {
|
||||
trashedBefore?: Date;
|
||||
type?: AssetType;
|
||||
order?: 'ASC' | 'DESC';
|
||||
withDeleted?: boolean;
|
||||
}
|
||||
|
||||
export interface LivePhotoSearchOptions {
|
||||
|
||||
@@ -1,40 +1,20 @@
|
||||
import {
|
||||
newAssetRepositoryMock,
|
||||
newMoveRepositoryMock,
|
||||
newPersonRepositoryMock,
|
||||
newStorageRepositoryMock,
|
||||
newSystemConfigRepositoryMock,
|
||||
newUserRepositoryMock,
|
||||
} from '@test';
|
||||
import { newStorageRepositoryMock, newSystemConfigRepositoryMock, newUserRepositoryMock } from '@test';
|
||||
import { serverVersion } from '../domain.constant';
|
||||
import {
|
||||
IAssetRepository,
|
||||
IMoveRepository,
|
||||
IPersonRepository,
|
||||
IStorageRepository,
|
||||
ISystemConfigRepository,
|
||||
IUserRepository,
|
||||
} from '../repositories';
|
||||
import { IStorageRepository, ISystemConfigRepository, IUserRepository } from '../repositories';
|
||||
import { ServerInfoService } from './server-info.service';
|
||||
|
||||
describe(ServerInfoService.name, () => {
|
||||
let sut: ServerInfoService;
|
||||
let assetMock: jest.Mocked<IAssetRepository>;
|
||||
let configMock: jest.Mocked<ISystemConfigRepository>;
|
||||
let moveMock: jest.Mocked<IMoveRepository>;
|
||||
let personMock: jest.Mocked<IPersonRepository>;
|
||||
let storageMock: jest.Mocked<IStorageRepository>;
|
||||
let userMock: jest.Mocked<IUserRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
assetMock = newAssetRepositoryMock();
|
||||
configMock = newSystemConfigRepositoryMock();
|
||||
moveMock = newMoveRepositoryMock();
|
||||
personMock = newPersonRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
|
||||
sut = new ServerInfoService(assetMock, configMock, moveMock, personMock, userMock, storageMock);
|
||||
sut = new ServerInfoService(configMock, userMock, storageMock);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { mimeTypes, serverVersion } from '../domain.constant';
|
||||
import { asHumanReadable } from '../domain.util';
|
||||
import {
|
||||
IAssetRepository,
|
||||
IMoveRepository,
|
||||
IPersonRepository,
|
||||
IStorageRepository,
|
||||
ISystemConfigRepository,
|
||||
IUserRepository,
|
||||
UserStatsQueryResponse,
|
||||
} from '../repositories';
|
||||
import { IStorageRepository, ISystemConfigRepository, IUserRepository, UserStatsQueryResponse } from '../repositories';
|
||||
import { StorageCore, StorageFolder } from '../storage';
|
||||
import { SystemConfigCore } from '../system-config';
|
||||
import {
|
||||
@@ -25,22 +17,17 @@ import {
|
||||
@Injectable()
|
||||
export class ServerInfoService {
|
||||
private configCore: SystemConfigCore;
|
||||
private storageCore: StorageCore;
|
||||
|
||||
constructor(
|
||||
@Inject(IAssetRepository) assetRepository: IAssetRepository,
|
||||
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
|
||||
@Inject(IMoveRepository) moveRepository: IMoveRepository,
|
||||
@Inject(IPersonRepository) personRepository: IPersonRepository,
|
||||
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
) {
|
||||
this.configCore = SystemConfigCore.create(configRepository);
|
||||
this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository);
|
||||
}
|
||||
|
||||
async getInfo(): Promise<ServerInfoResponseDto> {
|
||||
const libraryBase = this.storageCore.getBaseFolder(StorageFolder.LIBRARY);
|
||||
const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY);
|
||||
const diskInfo = await this.storageRepository.checkDiskUsage(libraryBase);
|
||||
|
||||
const usagePercentage = (((diskInfo.total - diskInfo.free) / diskInfo.total) * 100).toFixed(2);
|
||||
|
||||
@@ -90,7 +90,7 @@ export class StorageTemplateService {
|
||||
}
|
||||
|
||||
this.logger.debug('Cleaning up empty directories...');
|
||||
const libraryFolder = this.storageCore.getBaseFolder(StorageFolder.LIBRARY);
|
||||
const libraryFolder = StorageCore.getBaseFolder(StorageFolder.LIBRARY);
|
||||
await this.storageRepository.removeEmptyDirs(libraryFolder);
|
||||
|
||||
this.logger.log('Finished storage template migration');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AssetEntity, AssetPathType, PathType, PersonEntity, PersonPathType } from '@app/infra/entities';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { APP_MEDIA_LOCATION } from '../domain.constant';
|
||||
import { IAssetRepository, IMoveRepository, IPersonRepository, IStorageRepository } from '../repositories';
|
||||
|
||||
@@ -32,14 +32,14 @@ export class StorageCore {
|
||||
) {}
|
||||
|
||||
getFolderLocation(folder: StorageFolder, userId: string) {
|
||||
return join(this.getBaseFolder(folder), userId);
|
||||
return join(StorageCore.getBaseFolder(folder), userId);
|
||||
}
|
||||
|
||||
getLibraryFolder(user: { storageLabel: string | null; id: string }) {
|
||||
return join(this.getBaseFolder(StorageFolder.LIBRARY), user.storageLabel || user.id);
|
||||
return join(StorageCore.getBaseFolder(StorageFolder.LIBRARY), user.storageLabel || user.id);
|
||||
}
|
||||
|
||||
getBaseFolder(folder: StorageFolder) {
|
||||
static getBaseFolder(folder: StorageFolder) {
|
||||
return join(APP_MEDIA_LOCATION, folder);
|
||||
}
|
||||
|
||||
@@ -64,7 +64,11 @@ export class StorageCore {
|
||||
}
|
||||
|
||||
isAndroidMotionPath(originalPath: string) {
|
||||
return originalPath.startsWith(this.getBaseFolder(StorageFolder.ENCODED_VIDEO));
|
||||
return originalPath.startsWith(StorageCore.getBaseFolder(StorageFolder.ENCODED_VIDEO));
|
||||
}
|
||||
|
||||
static isImmichPath(path: string) {
|
||||
return resolve(path).startsWith(resolve(APP_MEDIA_LOCATION));
|
||||
}
|
||||
|
||||
async moveAssetFile(asset: AssetEntity, pathType: GeneratedAssetPath) {
|
||||
@@ -135,7 +139,7 @@ export class StorageCore {
|
||||
}
|
||||
|
||||
removeEmptyDirs(folder: StorageFolder) {
|
||||
return this.repository.removeEmptyDirs(this.getBaseFolder(folder));
|
||||
return this.repository.removeEmptyDirs(StorageCore.getBaseFolder(folder));
|
||||
}
|
||||
|
||||
private savePath(pathType: PathType, id: string, newPath: string) {
|
||||
|
||||
@@ -1,25 +1,14 @@
|
||||
import {
|
||||
newAssetRepositoryMock,
|
||||
newMoveRepositoryMock,
|
||||
newPersonRepositoryMock,
|
||||
newStorageRepositoryMock,
|
||||
} from '@test';
|
||||
import { IAssetRepository, IMoveRepository, IPersonRepository, IStorageRepository } from '../repositories';
|
||||
import { newStorageRepositoryMock } from '@test';
|
||||
import { IStorageRepository } from '../repositories';
|
||||
import { StorageService } from './storage.service';
|
||||
|
||||
describe(StorageService.name, () => {
|
||||
let sut: StorageService;
|
||||
let assetMock: jest.Mocked<IAssetRepository>;
|
||||
let moveMock: jest.Mocked<IMoveRepository>;
|
||||
let personMock: jest.Mocked<IPersonRepository>;
|
||||
let storageMock: jest.Mocked<IStorageRepository>;
|
||||
|
||||
beforeEach(async () => {
|
||||
assetMock = newAssetRepositoryMock();
|
||||
moveMock = newMoveRepositoryMock();
|
||||
personMock = newPersonRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
sut = new StorageService(assetMock, moveMock, personMock, storageMock);
|
||||
sut = new StorageService(storageMock);
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
|
||||
@@ -1,24 +1,16 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { IDeleteFilesJob } from '../job';
|
||||
import { IAssetRepository, IMoveRepository, IPersonRepository, IStorageRepository } from '../repositories';
|
||||
import { IStorageRepository } from '../repositories';
|
||||
import { StorageCore, StorageFolder } from './storage.core';
|
||||
|
||||
@Injectable()
|
||||
export class StorageService {
|
||||
private logger = new Logger(StorageService.name);
|
||||
private storageCore: StorageCore;
|
||||
|
||||
constructor(
|
||||
@Inject(IAssetRepository) assetRepository: IAssetRepository,
|
||||
@Inject(IMoveRepository) private moveRepository: IMoveRepository,
|
||||
@Inject(IPersonRepository) personRepository: IPersonRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
) {
|
||||
this.storageCore = new StorageCore(storageRepository, assetRepository, moveRepository, personRepository);
|
||||
}
|
||||
constructor(@Inject(IStorageRepository) private storageRepository: IStorageRepository) {}
|
||||
|
||||
init() {
|
||||
const libraryBase = this.storageCore.getBaseFolder(StorageFolder.LIBRARY);
|
||||
const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY);
|
||||
this.storageRepository.mkdirSync(libraryBase);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { AuditDeletesDto, AuditDeletesResponseDto, AuditService, AuthUserDto } from '@app/domain';
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import {
|
||||
AuditDeletesDto,
|
||||
AuditDeletesResponseDto,
|
||||
AuditService,
|
||||
AuthUserDto,
|
||||
FileChecksumDto,
|
||||
FileChecksumResponseDto,
|
||||
FileReportDto,
|
||||
FileReportFixDto,
|
||||
} from '@app/domain';
|
||||
import { Body, Controller, Get, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { AuthUser, Authenticated } from '../app.guard';
|
||||
import { AdminRoute, AuthUser, Authenticated } from '../app.guard';
|
||||
import { UseValidation } from '../app.utils';
|
||||
|
||||
@ApiTags('Audit')
|
||||
@@ -15,4 +24,22 @@ export class AuditController {
|
||||
getAuditDeletes(@AuthUser() authUser: AuthUserDto, @Query() dto: AuditDeletesDto): Promise<AuditDeletesResponseDto> {
|
||||
return this.service.getDeletes(authUser, dto);
|
||||
}
|
||||
|
||||
@AdminRoute()
|
||||
@Get('file-report')
|
||||
getAuditFiles(): Promise<FileReportDto> {
|
||||
return this.service.getFileReport();
|
||||
}
|
||||
|
||||
@AdminRoute()
|
||||
@Post('file-report/checksum')
|
||||
getFileChecksums(@Body() dto: FileChecksumDto): Promise<FileChecksumResponseDto[]> {
|
||||
return this.service.getChecksums(dto);
|
||||
}
|
||||
|
||||
@AdminRoute()
|
||||
@Post('file-report/fix')
|
||||
fixAuditFiles(@Body() dto: FileReportFixDto): Promise<void> {
|
||||
return this.service.fixItems(dto.items);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,4 +34,8 @@ export enum PersonPathType {
|
||||
FACE = 'face',
|
||||
}
|
||||
|
||||
export type PathType = AssetPathType | PersonPathType;
|
||||
export enum UserPathType {
|
||||
PROFILE = 'profile',
|
||||
}
|
||||
|
||||
export type PathType = AssetPathType | PersonPathType | UserPathType;
|
||||
|
||||
@@ -174,7 +174,7 @@ export class AssetRepository implements IAssetRepository {
|
||||
person: true,
|
||||
},
|
||||
},
|
||||
withDeleted: !!options.trashedBefore,
|
||||
withDeleted: options.withDeleted ?? !!options.trashedBefore,
|
||||
order: {
|
||||
// Ensures correct order when paginating
|
||||
createdAt: options.order ?? 'ASC',
|
||||
|
||||
Reference in New Issue
Block a user