import { Inject, Injectable } from '@nestjs/common'; import archiver from 'archiver'; import chokidar, { WatchOptions } from 'chokidar'; import { escapePath, glob, globStream } from 'fast-glob'; import { constants, createReadStream, createWriteStream, existsSync, mkdirSync } from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; import { Writable } from 'node:stream'; import { CrawlOptionsDto, WalkOptionsDto } from 'src/dtos/library.dto'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { DiskUsage, IStorageRepository, ImmichReadStream, ImmichZipStream, WatchEvents, } from 'src/interfaces/storage.interface'; import { mimeTypes } from 'src/utils/mime-types'; @Injectable() export class StorageRepository implements IStorageRepository { constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) { this.logger.setContext(StorageRepository.name); } realpath(filepath: string) { return fs.realpath(filepath); } readdir(folder: string): Promise { return fs.readdir(folder); } copyFile(source: string, target: string) { return fs.copyFile(source, target); } stat(filepath: string) { return fs.stat(filepath); } createFile(filepath: string, buffer: Buffer) { return fs.writeFile(filepath, buffer, { flag: 'wx' }); } createWriteStream(filepath: string): Writable { return createWriteStream(filepath, { flags: 'w' }); } createOrOverwriteFile(filepath: string, buffer: Buffer) { return fs.writeFile(filepath, buffer, { flag: 'w' }); } overwriteFile(filepath: string, buffer: Buffer) { return fs.writeFile(filepath, buffer, { flag: 'r+' }); } rename(source: string, target: string) { return fs.rename(source, target); } utimes(filepath: string, atime: Date, mtime: Date) { return fs.utimes(filepath, atime, mtime); } createZipStream(): ImmichZipStream { const archive = archiver('zip', { store: true }); const addFile = (input: string, filename: string) => { archive.file(input, { name: filename, mode: 0o644 }); }; const finalize = () => archive.finalize(); return { stream: archive, addFile, finalize }; } async createReadStream(filepath: string, mimeType?: string | null): Promise { const { size } = await fs.stat(filepath); await fs.access(filepath, constants.R_OK); return { stream: createReadStream(filepath), length: size, type: mimeType || undefined, }; } async readFile(filepath: string, options?: fs.FileReadOptions): Promise { const file = await fs.open(filepath); try { const { buffer } = await file.read(options); return buffer; } finally { await file.close(); } } async checkFileExists(filepath: string, mode = constants.F_OK): Promise { try { await fs.access(filepath, mode); return true; } catch { return false; } } async unlink(file: string) { try { await fs.unlink(file); } catch (error) { if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') { this.logger.warn(`File ${file} does not exist.`); } else { throw error; } } } async unlinkDir(folder: string, options: { recursive?: boolean; force?: boolean }) { await fs.rm(folder, options); } async removeEmptyDirs(directory: string, self: boolean = false) { // lstat does not follow symlinks (in contrast to stat) const stats = await fs.lstat(directory); if (!stats.isDirectory()) { return; } const files = await fs.readdir(directory); await Promise.all(files.map((file) => this.removeEmptyDirs(path.join(directory, file), true))); if (self) { const updated = await fs.readdir(directory); if (updated.length === 0) { await fs.rmdir(directory); } } } mkdirSync(filepath: string): void { if (!existsSync(filepath)) { mkdirSync(filepath, { recursive: true }); } } async checkDiskUsage(folder: string): Promise { const stats = await fs.statfs(folder); return { available: stats.bavail * stats.bsize, free: stats.bfree * stats.bsize, total: stats.blocks * stats.bsize, }; } crawl(crawlOptions: CrawlOptionsDto): Promise { const { pathsToCrawl, exclusionPatterns, includeHidden } = crawlOptions; if (pathsToCrawl.length === 0) { return Promise.resolve([]); } const globbedPaths = pathsToCrawl.map((path) => this.asGlob(path)); return glob(globbedPaths, { absolute: true, caseSensitiveMatch: false, onlyFiles: true, dot: includeHidden, ignore: exclusionPatterns, }); } async *walk(walkOptions: WalkOptionsDto): AsyncGenerator { const { pathsToCrawl, exclusionPatterns, includeHidden } = walkOptions; if (pathsToCrawl.length === 0) { async function* emptyGenerator() {} return emptyGenerator(); } const globbedPaths = pathsToCrawl.map((path) => this.asGlob(path)); const stream = globStream(globbedPaths, { absolute: true, caseSensitiveMatch: false, onlyFiles: true, dot: includeHidden, ignore: exclusionPatterns, }); let batch: string[] = []; for await (const value of stream) { batch.push(value.toString()); if (batch.length === walkOptions.take) { yield batch; batch = []; } } if (batch.length > 0) { yield batch; } } watch(paths: string[], options: WatchOptions, events: Partial) { const watcher = chokidar.watch(paths, options); watcher.on('ready', () => events.onReady?.()); watcher.on('add', (path) => events.onAdd?.(path)); watcher.on('change', (path) => events.onChange?.(path)); watcher.on('unlink', (path) => events.onUnlink?.(path)); watcher.on('error', (error) => events.onError?.(error)); return () => watcher.close(); } private asGlob(pathToCrawl: string): string { const escapedPath = escapePath(pathToCrawl); const extensions = `*{${mimeTypes.getSupportedFileExtensions().join(',')}}`; return `${escapedPath}/**/${extensions}`; } }