From 13af48d4dbac280a542428a392e0c079306696e3 Mon Sep 17 00:00:00 2001 From: Eli Gao Date: Wed, 25 Dec 2024 18:26:33 +0800 Subject: [PATCH 01/17] feat(cli): watch paths for auto uploading daemon --- cli/package-lock.json | 90 +++++++++++++++++++++++++++++++++------ cli/package.json | 7 ++- cli/src/commands/asset.ts | 83 +++++++++++++++++++++++++++++++++--- cli/src/index.ts | 3 ++ cli/src/utils.ts | 61 ++++++++++++++++++++++++++ 5 files changed, 223 insertions(+), 21 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index af339110d0457..f08c86673a8a0 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -9,9 +9,12 @@ "version": "2.2.37", "license": "GNU Affero General Public License version 3", "dependencies": { + "chokidar": "^4.0.3", "fast-glob": "^3.3.2", "fastq": "^1.17.1", - "lodash-es": "^4.17.21" + "lodash-es": "^4.17.21", + "micromatch": "^4.0.8", + "p-throttle": "^7.0.0" }, "bin": { "immich": "dist/index.js" @@ -23,6 +26,7 @@ "@types/byte-size": "^8.1.0", "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", + "@types/micromatch": "^4.0.9", "@types/mock-fs": "^4.13.1", "@types/node": "^22.10.2", "@typescript-eslint/eslint-plugin": "^8.15.0", @@ -1343,6 +1347,13 @@ "win32" ] }, + "node_modules/@types/braces": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/braces/-/braces-3.0.4.tgz", + "integrity": "sha512-0WR3b8eaISjEW7RpZnclONaLFDf7buaowRHdqLp4vLj54AsSAYWfh3DRbfiYJY9XDxMgx1B4sE1Afw2PGpuHOA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/byte-size": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/@types/byte-size/-/byte-size-8.1.2.tgz", @@ -1387,6 +1398,16 @@ "@types/lodash": "*" } }, + "node_modules/@types/micromatch": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.9.tgz", + "integrity": "sha512-7V+8ncr22h4UoYRLnLXSpTxjQrNUXtWHGeMPRJt1nULXI57G9bIcpyrHlmrQ7QK24EyyuXvYcSSWAM8GA9nqCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/braces": "*" + } + }, "node_modules/@types/mock-fs": { "version": "4.13.4", "resolved": "https://registry.npmjs.org/@types/mock-fs/-/mock-fs-4.13.4.tgz", @@ -1865,11 +1886,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -2013,6 +2035,21 @@ "node": ">= 16" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/ci-info": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.0.0.tgz", @@ -2644,9 +2681,10 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -2928,6 +2966,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", "engines": { "node": ">=0.12.0" } @@ -3172,11 +3211,12 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -3333,6 +3373,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-throttle": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/p-throttle/-/p-throttle-7.0.0.tgz", + "integrity": "sha512-aio0v+S0QVkH1O+9x4dHtD4dgCExACcL+3EtNaGqC01GBudS9ijMuUsmN8OVScyV4OOp0jqdLShZFuSlbL/AsA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -3675,6 +3727,19 @@ "node": ">=8" } }, + "node_modules/readdirp": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", + "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "license": "MIT", + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/regexp-tree": { "version": "0.1.27", "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", @@ -4092,6 +4157,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", "dependencies": { "is-number": "^7.0.0" }, diff --git a/cli/package.json b/cli/package.json index bdfaa0f528a3e..8d67a603276d9 100644 --- a/cli/package.json +++ b/cli/package.json @@ -19,6 +19,7 @@ "@types/byte-size": "^8.1.0", "@types/cli-progress": "^3.11.0", "@types/lodash-es": "^4.17.12", + "@types/micromatch": "^4.0.9", "@types/mock-fs": "^4.13.1", "@types/node": "^22.10.2", "@typescript-eslint/eslint-plugin": "^8.15.0", @@ -62,11 +63,13 @@ "node": ">=20.0.0" }, "dependencies": { + "chokidar": "^4.0.3", "fast-glob": "^3.3.2", "fastq": "^1.17.1", - "lodash-es": "^4.17.21" + "lodash-es": "^4.17.21", + "micromatch": "^4.0.8" }, "volta": { "node": "22.12.0" } -} +} \ No newline at end of file diff --git a/cli/src/commands/asset.ts b/cli/src/commands/asset.ts index 4cf6742f24669..8880bbbbc33a7 100644 --- a/cli/src/commands/asset.ts +++ b/cli/src/commands/asset.ts @@ -12,13 +12,18 @@ import { getSupportedMediaTypes, } from '@immich/sdk'; import byteSize from 'byte-size'; +import { Matcher, watch as watchFs } from 'chokidar'; import { MultiBar, Presets, SingleBar } from 'cli-progress'; import { chunk } from 'lodash-es'; +import micromatch from 'micromatch'; import { Stats, createReadStream } from 'node:fs'; import { stat, unlink } from 'node:fs/promises'; import path, { basename } from 'node:path'; import { Queue } from 'src/queue'; -import { BaseOptions, authenticate, crawl, sha1 } from 'src/utils'; +import { BaseOptions, Batcher, authenticate, crawl, sha1 } from 'src/utils'; + +const UPLOAD_WATCH_BATCH_SIZE = 100; +const UPLOAD_WATCH_DEBOUNCE_TIME_MS = 10_000; const s = (count: number) => (count === 1 ? '' : 's'); @@ -36,6 +41,7 @@ export interface UploadOptionsDto { albumName?: string; includeHidden?: boolean; concurrency: number; + watch?: boolean; } class UploadFile extends File { @@ -55,19 +61,82 @@ class UploadFile extends File { } } +const uploadBatch = async (files: string[], options: UploadOptionsDto) => { + const { newFiles, duplicates } = await checkForDuplicates(files, options); + const newAssets = await uploadFiles(newFiles, options); + await updateAlbums([...newAssets, ...duplicates], options); + await deleteFiles(newFiles, options); +}; + +const startWatch = async (paths: string[], options: UploadOptionsDto) => { + const watcherIgnored: Matcher[] = []; + const { image, video } = await getSupportedMediaTypes(); + const extensions = new Set([...image, ...video]); + + if (options.ignore) { + watcherIgnored.push((path) => micromatch.contains(path, `**/${options.ignore}`)); + } + watcherIgnored.push((path, stats) => { + if (stats?.isDirectory()) { + return false; + } + const ext = path.split('.').pop()?.toLowerCase(); + return !extensions.has(ext ?? ''); + }); + + const pathsBatcher = new Batcher({ + batchSize: UPLOAD_WATCH_BATCH_SIZE, + debounceTimeMs: UPLOAD_WATCH_DEBOUNCE_TIME_MS, + onBatch: async (paths: string[]) => { + const uniquePaths = [...new Set(paths)]; + await uploadBatch(uniquePaths, options); + }, + }); + + const fsWatchListener = async (path: string) => { + console.log(`Change detected: ${path}`); + pathsBatcher.add(path); + }; + const fsWatcher = watchFs(paths, { + ignoreInitial: true, + ignored: watcherIgnored, + awaitWriteFinish: true, + depth: options.recursive ? undefined : 1, + persistent: true, + }) + .on('add', fsWatchListener) + .on('change', fsWatchListener) + .on('error', (error) => console.error(`Watcher error: ${error}`)); + + process.on('SIGINT', async () => { + console.log('Exiting...'); + await fsWatcher.close(); + process.exit(); + }); +}; + export const upload = async (paths: string[], baseOptions: BaseOptions, options: UploadOptionsDto) => { await authenticate(baseOptions); const scanFiles = await scan(paths, options); + if (scanFiles.length === 0) { - console.log('No files found, exiting'); - return; + if (options.watch) { + console.log('No files found initially.'); + } else { + console.log('No files found, exiting'); + return; + } } - const { newFiles, duplicates } = await checkForDuplicates(scanFiles, options); - const newAssets = await uploadFiles(newFiles, options); - await updateAlbums([...newAssets, ...duplicates], options); - await deleteFiles(newFiles, options); + if (options.watch) { + console.log('Watching for changes...'); + await startWatch(paths, options); + // watcher does not handle the initial scan + // as the scan() is a more efficient quick start with batched results + } + + await uploadBatch(scanFiles, options); }; const scan = async (pathsToCrawl: string[], options: UploadOptionsDto) => { diff --git a/cli/src/index.ts b/cli/src/index.ts index 341a70bef024a..0f7d60b0d7737 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -69,6 +69,9 @@ program .default(4), ) .addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS')) + .addOption( + new Option('--watch', 'Watch for changes and upload automatically').env('IMMICH_WATCH_CHANGES').default(false), + ) .argument('[paths...]', 'One or more paths to assets to be uploaded') .action((paths, options) => upload(paths, program.opts(), options)); diff --git a/cli/src/utils.ts b/cli/src/utils.ts index 7bbbb5615b640..d3c7ff27876b8 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -172,3 +172,64 @@ export const sha1 = (filepath: string) => { rs.on('end', () => resolve(hash.digest('hex'))); }); }; + +/** + * Batches items and calls onBatch to process them + * when the batch size is reached or the debounce time has passed. + */ +export class Batcher { + private readonly items: T[] = []; + private readonly batchSize: number; + private readonly debounceTimeMs?: number; + private readonly onBatch: (items: T[]) => void; + private debounceTimer?: NodeJS.Timeout; + + constructor({ + batchSize, + debounceTimeMs, + onBatch, + }: { + batchSize: number; + debounceTimeMs?: number; + onBatch: (items: T[]) => Promise; + }) { + this.batchSize = batchSize; + this.debounceTimeMs = debounceTimeMs; + this.onBatch = onBatch; + } + + private setDebounceTimer() { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + if (this.debounceTimeMs) { + this.debounceTimer = setTimeout(() => this.flush(), this.debounceTimeMs); + } + } + + private clearDebounceTimer() { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + this.debounceTimer = undefined; + } + } + + add(item: T) { + this.items.push(item); + this.setDebounceTimer(); + if (this.items.length >= this.batchSize) { + this.flush(); + } + } + + flush() { + this.clearDebounceTimer(); + if (this.items.length === 0) { + return; + } + + this.onBatch([...this.items]); + + this.items.length = 0; + } +} From 937e87cc288fd1b1361151a1b3a534db4101b29f Mon Sep 17 00:00:00 2001 From: Eli Gao Date: Wed, 25 Dec 2024 23:40:18 +0800 Subject: [PATCH 02/17] chore: update package-lock --- cli/package-lock.json | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index f08c86673a8a0..23f2e50f28bb0 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -13,8 +13,7 @@ "fast-glob": "^3.3.2", "fastq": "^1.17.1", "lodash-es": "^4.17.21", - "micromatch": "^4.0.8", - "p-throttle": "^7.0.0" + "micromatch": "^4.0.8" }, "bin": { "immich": "dist/index.js" @@ -3373,18 +3372,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-throttle": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/p-throttle/-/p-throttle-7.0.0.tgz", - "integrity": "sha512-aio0v+S0QVkH1O+9x4dHtD4dgCExACcL+3EtNaGqC01GBudS9ijMuUsmN8OVScyV4OOp0jqdLShZFuSlbL/AsA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", From 9aaa654edf385c90ff47a4e349cb481bd37c4ef6 Mon Sep 17 00:00:00 2001 From: Eli Gao Date: Thu, 26 Dec 2024 04:01:45 +0800 Subject: [PATCH 03/17] test(cli): Batcher util calss --- cli/src/utils.spec.ts | 38 +++++++++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/cli/src/utils.spec.ts b/cli/src/utils.spec.ts index 3e7e55fcb69e1..28bda18d3ffc6 100644 --- a/cli/src/utils.spec.ts +++ b/cli/src/utils.spec.ts @@ -1,6 +1,7 @@ import mockfs from 'mock-fs'; import { readFileSync } from 'node:fs'; -import { CrawlOptions, crawl } from 'src/utils'; +import { Batcher, CrawlOptions, crawl } from 'src/utils'; +import { Mock } from 'vitest'; interface Test { test: string; @@ -286,3 +287,38 @@ describe('crawl', () => { } }); }); + +describe('Batcher', () => { + let batcher: Batcher; + let onBatch: Mock; + beforeEach(() => { + onBatch = vi.fn(); + batcher = new Batcher({ batchSize: 2, onBatch }); + }); + + it('should trigger onBatch() when a batch limit is reached', async () => { + batcher.add('a'); + batcher.add('b'); + batcher.add('c'); + expect(onBatch).toHaveBeenCalledOnce(); + expect(onBatch).toHaveBeenCalledWith(['a', 'b']); + }); + + it('should trigger onBatch() when flush() is called', async () => { + batcher.add('a'); + batcher.flush(); + expect(onBatch).toHaveBeenCalledOnce(); + expect(onBatch).toHaveBeenCalledWith(['a']); + }); + + it('should trigger onBatch() when debounce time reached', async () => { + vi.useFakeTimers(); + batcher = new Batcher({ batchSize: 2, debounceTimeMs: 100, onBatch }); + batcher.add('a'); + expect(onBatch).not.toHaveBeenCalled(); + vi.advanceTimersByTime(200); + expect(onBatch).toHaveBeenCalledOnce(); + expect(onBatch).toHaveBeenCalledWith(['a']); + vi.useRealTimers(); + }); +}); From 75dc882e0dbd5eee8a376043f880f52c26353ea3 Mon Sep 17 00:00:00 2001 From: Eli Gao Date: Thu, 26 Dec 2024 19:10:18 +0800 Subject: [PATCH 04/17] feat(cli): expose batcher params from startWatch() --- cli/src/commands/asset.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/cli/src/commands/asset.ts b/cli/src/commands/asset.ts index 8880bbbbc33a7..77cc262bca9be 100644 --- a/cli/src/commands/asset.ts +++ b/cli/src/commands/asset.ts @@ -22,8 +22,8 @@ import path, { basename } from 'node:path'; import { Queue } from 'src/queue'; import { BaseOptions, Batcher, authenticate, crawl, sha1 } from 'src/utils'; -const UPLOAD_WATCH_BATCH_SIZE = 100; -const UPLOAD_WATCH_DEBOUNCE_TIME_MS = 10_000; +export const UPLOAD_WATCH_BATCH_SIZE = 100; +export const UPLOAD_WATCH_DEBOUNCE_TIME_MS = 10_000; const s = (count: number) => (count === 1 ? '' : 's'); @@ -68,7 +68,14 @@ const uploadBatch = async (files: string[], options: UploadOptionsDto) => { await deleteFiles(newFiles, options); }; -const startWatch = async (paths: string[], options: UploadOptionsDto) => { +export const startWatch = async ( + paths: string[], + options: UploadOptionsDto, + { + batchSize = UPLOAD_WATCH_BATCH_SIZE, + debounceTimeMs = UPLOAD_WATCH_DEBOUNCE_TIME_MS, + }: { batchSize?: number; debounceTimeMs?: number } = {}, +) => { const watcherIgnored: Matcher[] = []; const { image, video } = await getSupportedMediaTypes(); const extensions = new Set([...image, ...video]); @@ -78,15 +85,15 @@ const startWatch = async (paths: string[], options: UploadOptionsDto) => { } watcherIgnored.push((path, stats) => { if (stats?.isDirectory()) { - return false; + return true; } const ext = path.split('.').pop()?.toLowerCase(); return !extensions.has(ext ?? ''); }); const pathsBatcher = new Batcher({ - batchSize: UPLOAD_WATCH_BATCH_SIZE, - debounceTimeMs: UPLOAD_WATCH_DEBOUNCE_TIME_MS, + batchSize, + debounceTimeMs, onBatch: async (paths: string[]) => { const uniquePaths = [...new Set(paths)]; await uploadBatch(uniquePaths, options); From 5c951cc173a429c058bc8c41a459ec60064d84c3 Mon Sep 17 00:00:00 2001 From: Eli Gao Date: Thu, 26 Dec 2024 19:10:48 +0800 Subject: [PATCH 05/17] test(cli): startWatch() for `--watch` --- cli/src/commands/asset.spec.ts | 47 ++++++++++++++++++++++++++++++++-- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/cli/src/commands/asset.spec.ts b/cli/src/commands/asset.spec.ts index 4bac1d00abf97..d64fe463bd86b 100644 --- a/cli/src/commands/asset.spec.ts +++ b/cli/src/commands/asset.spec.ts @@ -3,10 +3,12 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { describe, expect, it, vi } from 'vitest'; -import { Action, checkBulkUpload, defaults, Reason } from '@immich/sdk'; +import { Action, checkBulkUpload, defaults, getSupportedMediaTypes, Reason } from '@immich/sdk'; import createFetchMock from 'vitest-fetch-mock'; -import { checkForDuplicates, getAlbumName, uploadFiles, UploadOptionsDto } from './asset'; +import { checkForDuplicates, getAlbumName, startWatch, uploadFiles, UploadOptionsDto } from 'src/commands/asset'; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); vi.mock('@immich/sdk'); @@ -199,3 +201,44 @@ describe('checkForDuplicates', () => { }); }); }); + +describe('startWatch', () => { + it('should start watching a directory and upload new files', async () => { + vi.restoreAllMocks(); + const testFolder = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'test-startWatch-')); + const testFilePath = path.join(testFolder, 'test.jpg'); + const checkBulkUploadMocked = vi.mocked(checkBulkUpload); + checkBulkUploadMocked.mockResolvedValue({ + results: [ + { + action: Action.Accept, + id: testFilePath, + }, + ], + }); + vi.mocked(getSupportedMediaTypes).mockResolvedValue({ + image: ['jpg'], + sidecar: ['xmp'], + video: ['mp4'], + }); + try { + await startWatch([testFolder], { concurrency: 1 }, { batchSize: 1, debounceTimeMs: 10 }); + await sleep(100); // to debounce the watcher from considering the test file as a existing file + await fs.promises.writeFile(testFilePath, 'testjpg'); + + await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000); + expect(getSupportedMediaTypes).toHaveBeenCalled(); + expect(checkBulkUpload).toHaveBeenCalledWith({ + assetBulkUploadCheckDto: { + assets: [ + expect.objectContaining({ + id: testFilePath, + }), + ], + }, + }); + } finally { + await fs.promises.rm(testFolder, { recursive: true, force: true }); + } + }); +}); From a8c20f6a89a57fe23acc00c72662f20b25c0c36a Mon Sep 17 00:00:00 2001 From: Eli Gao Date: Fri, 27 Dec 2024 03:17:07 +0800 Subject: [PATCH 06/17] refactor(cli): more reliable watcher --- cli/src/commands/asset.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/cli/src/commands/asset.ts b/cli/src/commands/asset.ts index 77cc262bca9be..284af4c61eb36 100644 --- a/cli/src/commands/asset.ts +++ b/cli/src/commands/asset.ts @@ -83,13 +83,6 @@ export const startWatch = async ( if (options.ignore) { watcherIgnored.push((path) => micromatch.contains(path, `**/${options.ignore}`)); } - watcherIgnored.push((path, stats) => { - if (stats?.isDirectory()) { - return true; - } - const ext = path.split('.').pop()?.toLowerCase(); - return !extensions.has(ext ?? ''); - }); const pathsBatcher = new Batcher({ batchSize, @@ -100,13 +93,21 @@ export const startWatch = async ( }, }); - const fsWatchListener = async (path: string) => { + const fsWatchListener = async (path: string, stats?: Stats) => { + if (stats?.isDirectory()) { + return; + } + const ext = path.split('.').pop()?.toLowerCase(); + if (!extensions.has(ext ?? '')) { + return; + } console.log(`Change detected: ${path}`); pathsBatcher.add(path); }; const fsWatcher = watchFs(paths, { ignoreInitial: true, ignored: watcherIgnored, + alwaysStat: true, awaitWriteFinish: true, depth: options.recursive ? undefined : 1, persistent: true, From 32ba427773bd6ba11fb329ea655591349101fc7b Mon Sep 17 00:00:00 2001 From: Eli Gao Date: Fri, 27 Dec 2024 03:23:52 +0800 Subject: [PATCH 07/17] feat(cli): disable progress bar on --no-progress or --watch --- cli/src/commands/asset.ts | 15 ++++++++++----- cli/src/index.ts | 6 +++++- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/cli/src/commands/asset.ts b/cli/src/commands/asset.ts index 284af4c61eb36..7069b1e10ae29 100644 --- a/cli/src/commands/asset.ts +++ b/cli/src/commands/asset.ts @@ -41,6 +41,7 @@ export interface UploadOptionsDto { albumName?: string; includeHidden?: boolean; concurrency: number; + progress?: boolean; watch?: boolean; } @@ -162,7 +163,7 @@ const scan = async (pathsToCrawl: string[], options: UploadOptionsDto) => { return files; }; -export const checkForDuplicates = async (files: string[], { concurrency, skipHash }: UploadOptionsDto) => { +export const checkForDuplicates = async (files: string[], { concurrency, skipHash, progress }: UploadOptionsDto) => { if (skipHash) { console.log('Skipping hash check, assuming all files are new'); return { newFiles: files, duplicates: [] }; @@ -173,8 +174,12 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas Presets.shades_classic, ); - const hashProgressBar = multiBar.create(files.length, 0, { message: 'Hashing files ' }); - const checkProgressBar = multiBar.create(files.length, 0, { message: 'Checking for duplicates' }); + const hashProgressBar = progress + ? multiBar.create(files.length, 0, { message: 'Hashing files ' }) + : undefined; + const checkProgressBar = progress + ? multiBar.create(files.length, 0, { message: 'Checking for duplicates' }) + : undefined; const newFiles: string[] = []; const duplicates: Asset[] = []; @@ -194,7 +199,7 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas } } - checkProgressBar.increment(assets.length); + checkProgressBar?.increment(assets.length); }, { concurrency, retry: 3 }, ); @@ -214,7 +219,7 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas void checkBulkUploadQueue.push(batch); } - hashProgressBar.increment(); + hashProgressBar?.increment(); return results; }, { concurrency, retry: 3 }, diff --git a/cli/src/index.ts b/cli/src/index.ts index 0f7d60b0d7737..5da4b50722b3c 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -69,8 +69,12 @@ program .default(4), ) .addOption(new Option('--delete', 'Delete local assets after upload').env('IMMICH_DELETE_ASSETS')) + .addOption(new Option('--no-progress', 'Hide progress bars').env('IMMICH_PROGRESS_BAR').default(true)) .addOption( - new Option('--watch', 'Watch for changes and upload automatically').env('IMMICH_WATCH_CHANGES').default(false), + new Option('--watch', 'Watch for changes and upload automatically') + .env('IMMICH_WATCH_CHANGES') + .default(false) + .implies({ progress: false }), ) .argument('[paths...]', 'One or more paths to assets to be uploaded') .action((paths, options) => upload(paths, program.opts(), options)); From 5906de28c643fde57849c9a33ede098eb2c2c76d Mon Sep 17 00:00:00 2001 From: Eli Gao Date: Fri, 27 Dec 2024 19:37:48 +0800 Subject: [PATCH 08/17] fix(cli): extensions match when upload with watch --- cli/src/commands/asset.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/commands/asset.ts b/cli/src/commands/asset.ts index 7069b1e10ae29..2f1d8df10fbee 100644 --- a/cli/src/commands/asset.ts +++ b/cli/src/commands/asset.ts @@ -98,7 +98,7 @@ export const startWatch = async ( if (stats?.isDirectory()) { return; } - const ext = path.split('.').pop()?.toLowerCase(); + const ext = '.' + path.split('.').pop()?.toLowerCase(); if (!extensions.has(ext ?? '')) { return; } From 928a9657d1bca3edbdcff40fccb409bc5f40c9c6 Mon Sep 17 00:00:00 2001 From: Eli Gao Date: Sat, 28 Dec 2024 01:50:06 +0800 Subject: [PATCH 09/17] feat(cli): basic logs without progress on upload --- cli/src/commands/asset.ts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/cli/src/commands/asset.ts b/cli/src/commands/asset.ts index 2f1d8df10fbee..796d6a7f3c1c9 100644 --- a/cli/src/commands/asset.ts +++ b/cli/src/commands/asset.ts @@ -169,17 +169,19 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas return { newFiles: files, duplicates: [] }; } - const multiBar = new MultiBar( - { format: '{message} | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' }, - Presets.shades_classic, - ); + let multiBar: MultiBar | undefined; + + if (progress) { + multiBar = new MultiBar( + { format: '{message} | {bar} | {percentage}% | ETA: {eta}s | {value}/{total} assets' }, + Presets.shades_classic, + ); + } else { + console.log(`Received ${files.length} files, hashing...`); + } - const hashProgressBar = progress - ? multiBar.create(files.length, 0, { message: 'Hashing files ' }) - : undefined; - const checkProgressBar = progress - ? multiBar.create(files.length, 0, { message: 'Checking for duplicates' }) - : undefined; + const hashProgressBar = multiBar?.create(files.length, 0, { message: 'Hashing files ' }); + const checkProgressBar = multiBar?.create(files.length, 0, { message: 'Checking for duplicates' }); const newFiles: string[] = []; const duplicates: Asset[] = []; @@ -237,7 +239,7 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas await checkBulkUploadQueue.drained(); - multiBar.stop(); + multiBar?.stop(); console.log(`Found ${newFiles.length} new files and ${duplicates.length} duplicate${s(duplicates.length)}`); From ec35527b54150dbebb1a025583def5e53ddddd5d Mon Sep 17 00:00:00 2001 From: Eli Gao Date: Sat, 28 Dec 2024 01:54:03 +0800 Subject: [PATCH 10/17] feat(cli): hide progress in uploadFiles() --- cli/src/commands/asset.ts | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/cli/src/commands/asset.ts b/cli/src/commands/asset.ts index 796d6a7f3c1c9..94eb1b5dec66a 100644 --- a/cli/src/commands/asset.ts +++ b/cli/src/commands/asset.ts @@ -255,7 +255,10 @@ export const checkForDuplicates = async (files: string[], { concurrency, skipHas return { newFiles, duplicates }; }; -export const uploadFiles = async (files: string[], { dryRun, concurrency }: UploadOptionsDto): Promise => { +export const uploadFiles = async ( + files: string[], + { dryRun, concurrency, progress }: UploadOptionsDto, +): Promise => { if (files.length === 0) { console.log('All assets were already uploaded, nothing to do.'); return []; @@ -275,12 +278,20 @@ export const uploadFiles = async (files: string[], { dryRun, concurrency }: Uplo return files.map((filepath) => ({ id: '', filepath })); } - const uploadProgress = new SingleBar( - { format: 'Uploading assets | {bar} | {percentage}% | ETA: {eta_formatted} | {value_formatted}/{total_formatted}' }, - Presets.shades_classic, - ); - uploadProgress.start(totalSize, 0); - uploadProgress.update({ value_formatted: 0, total_formatted: byteSize(totalSize) }); + let uploadProgress: SingleBar | undefined; + + if (progress) { + uploadProgress = new SingleBar( + { + format: 'Uploading assets | {bar} | {percentage}% | ETA: {eta_formatted} | {value_formatted}/{total_formatted}', + }, + Presets.shades_classic, + ); + } else { + console.log(`Uploading ${files.length} asset${s(files.length)} (${byteSize(totalSize)})`); + } + uploadProgress?.start(totalSize, 0); + uploadProgress?.update({ value_formatted: 0, total_formatted: byteSize(totalSize) }); let duplicateCount = 0; let duplicateSize = 0; @@ -306,7 +317,7 @@ export const uploadFiles = async (files: string[], { dryRun, concurrency }: Uplo successSize += stats.size ?? 0; } - uploadProgress.update(successSize, { value_formatted: byteSize(successSize + duplicateSize) }); + uploadProgress?.update(successSize, { value_formatted: byteSize(successSize + duplicateSize) }); return response; }, @@ -319,7 +330,7 @@ export const uploadFiles = async (files: string[], { dryRun, concurrency }: Uplo await queue.drained(); - uploadProgress.stop(); + uploadProgress?.stop(); console.log(`Successfully uploaded ${successCount} new asset${s(successCount)} (${byteSize(successSize)})`); if (duplicateCount > 0) { From f1d2557761ef63b0f60cbbbd8b09825a63e57fb5 Mon Sep 17 00:00:00 2001 From: Eli Gao Date: Mon, 30 Dec 2024 14:51:56 +0800 Subject: [PATCH 11/17] refactor(cli): use promise-based setTimeout() instead of hand crafted sleep() --- cli/src/commands/asset.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cli/src/commands/asset.spec.ts b/cli/src/commands/asset.spec.ts index d64fe463bd86b..86e7f098d52a6 100644 --- a/cli/src/commands/asset.spec.ts +++ b/cli/src/commands/asset.spec.ts @@ -1,6 +1,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; +import { setTimeout as sleep } from 'node:timers/promises'; import { describe, expect, it, vi } from 'vitest'; import { Action, checkBulkUpload, defaults, getSupportedMediaTypes, Reason } from '@immich/sdk'; @@ -8,8 +9,6 @@ import createFetchMock from 'vitest-fetch-mock'; import { checkForDuplicates, getAlbumName, startWatch, uploadFiles, UploadOptionsDto } from 'src/commands/asset'; -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - vi.mock('@immich/sdk'); describe('getAlbumName', () => { From fe71f7303ddc0950f7f8464853dac27fdf99fe38 Mon Sep 17 00:00:00 2001 From: Eli Gao Date: Mon, 30 Dec 2024 14:53:46 +0800 Subject: [PATCH 12/17] refactor(cli): unexport UPLOAD_WATCH consts --- cli/src/commands/asset.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/src/commands/asset.ts b/cli/src/commands/asset.ts index 94eb1b5dec66a..3a913bfed160a 100644 --- a/cli/src/commands/asset.ts +++ b/cli/src/commands/asset.ts @@ -22,8 +22,8 @@ import path, { basename } from 'node:path'; import { Queue } from 'src/queue'; import { BaseOptions, Batcher, authenticate, crawl, sha1 } from 'src/utils'; -export const UPLOAD_WATCH_BATCH_SIZE = 100; -export const UPLOAD_WATCH_DEBOUNCE_TIME_MS = 10_000; +const UPLOAD_WATCH_BATCH_SIZE = 100; +const UPLOAD_WATCH_DEBOUNCE_TIME_MS = 10_000; const s = (count: number) => (count === 1 ? '' : 's'); From 2b99f882b9e315acb26905378c54563458e60d89 Mon Sep 17 00:00:00 2001 From: Eli Gao Date: Mon, 30 Dec 2024 15:01:22 +0800 Subject: [PATCH 13/17] refactor(cli): rename fsWatchListener() to onFile() --- cli/src/commands/asset.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/src/commands/asset.ts b/cli/src/commands/asset.ts index 3a913bfed160a..376b41e40608c 100644 --- a/cli/src/commands/asset.ts +++ b/cli/src/commands/asset.ts @@ -94,7 +94,7 @@ export const startWatch = async ( }, }); - const fsWatchListener = async (path: string, stats?: Stats) => { + const onFile = async (path: string, stats?: Stats) => { if (stats?.isDirectory()) { return; } @@ -113,8 +113,8 @@ export const startWatch = async ( depth: options.recursive ? undefined : 1, persistent: true, }) - .on('add', fsWatchListener) - .on('change', fsWatchListener) + .on('add', onFile) + .on('change', onFile) .on('error', (error) => console.error(`Watcher error: ${error}`)); process.on('SIGINT', async () => { From 3e74c9862a01658ad837fde707e12beb13a976b6 Mon Sep 17 00:00:00 2001 From: Eli Gao Date: Mon, 30 Dec 2024 15:42:47 +0800 Subject: [PATCH 14/17] test(cli): prefix dot to mocked getSupportedMediaTypes() --- cli/src/commands/asset.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/src/commands/asset.spec.ts b/cli/src/commands/asset.spec.ts index 86e7f098d52a6..767f2a2a02cbe 100644 --- a/cli/src/commands/asset.spec.ts +++ b/cli/src/commands/asset.spec.ts @@ -216,9 +216,9 @@ describe('startWatch', () => { ], }); vi.mocked(getSupportedMediaTypes).mockResolvedValue({ - image: ['jpg'], - sidecar: ['xmp'], - video: ['mp4'], + image: ['.jpg'], + sidecar: ['.xmp'], + video: ['.mp4'], }); try { await startWatch([testFolder], { concurrency: 1 }, { batchSize: 1, debounceTimeMs: 10 }); From e72e677b70ef11f21025d349fea0df83eab1f4a6 Mon Sep 17 00:00:00 2001 From: Eli Gao Date: Mon, 30 Dec 2024 16:07:23 +0800 Subject: [PATCH 15/17] test(cli): add tests for ignored patterns/ unsupported exts --- cli/src/commands/asset.spec.ts | 132 +++++++++++++++++++++++++-------- 1 file changed, 100 insertions(+), 32 deletions(-) diff --git a/cli/src/commands/asset.spec.ts b/cli/src/commands/asset.spec.ts index 767f2a2a02cbe..21137a3296549 100644 --- a/cli/src/commands/asset.spec.ts +++ b/cli/src/commands/asset.spec.ts @@ -2,7 +2,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { setTimeout as sleep } from 'node:timers/promises'; -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, MockedFunction, vi } from 'vitest'; import { Action, checkBulkUpload, defaults, getSupportedMediaTypes, Reason } from '@immich/sdk'; import createFetchMock from 'vitest-fetch-mock'; @@ -202,42 +202,110 @@ describe('checkForDuplicates', () => { }); describe('startWatch', () => { - it('should start watching a directory and upload new files', async () => { + let testFolder: string; + let checkBulkUploadMocked: MockedFunction; + + beforeEach(async () => { vi.restoreAllMocks(); - const testFolder = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'test-startWatch-')); - const testFilePath = path.join(testFolder, 'test.jpg'); - const checkBulkUploadMocked = vi.mocked(checkBulkUpload); - checkBulkUploadMocked.mockResolvedValue({ - results: [ - { - action: Action.Accept, - id: testFilePath, - }, - ], - }); + vi.mocked(getSupportedMediaTypes).mockResolvedValue({ image: ['.jpg'], sidecar: ['.xmp'], video: ['.mp4'], }); - try { - await startWatch([testFolder], { concurrency: 1 }, { batchSize: 1, debounceTimeMs: 10 }); - await sleep(100); // to debounce the watcher from considering the test file as a existing file - await fs.promises.writeFile(testFilePath, 'testjpg'); - - await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000); - expect(getSupportedMediaTypes).toHaveBeenCalled(); - expect(checkBulkUpload).toHaveBeenCalledWith({ - assetBulkUploadCheckDto: { - assets: [ - expect.objectContaining({ - id: testFilePath, - }), - ], - }, - }); - } finally { - await fs.promises.rm(testFolder, { recursive: true, force: true }); - } + + testFolder = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'test-startWatch-')); + checkBulkUploadMocked = vi.mocked(checkBulkUpload); + checkBulkUploadMocked.mockResolvedValue({ + results: [], + }); + }); + + it('should start watching a directory and upload new files', async () => { + const testFilePath = path.join(testFolder, 'test.jpg'); + + await startWatch([testFolder], { concurrency: 1 }, { batchSize: 1, debounceTimeMs: 10 }); + await sleep(100); // to debounce the watcher from considering the test file as a existing file + await fs.promises.writeFile(testFilePath, 'testjpg'); + + await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000); + expect(checkBulkUpload).toHaveBeenCalledWith({ + assetBulkUploadCheckDto: { + assets: [ + expect.objectContaining({ + id: testFilePath, + }), + ], + }, + }); + }); + + it('should filter out unsupported files', async () => { + const testFilePath = path.join(testFolder, 'test.jpg'); + const unsupportedFilePath = path.join(testFolder, 'test.txt'); + + await startWatch([testFolder], { concurrency: 1 }, { batchSize: 1, debounceTimeMs: 10 }); + await sleep(100); // to debounce the watcher from considering the test file as a existing file + await fs.promises.writeFile(testFilePath, 'testjpg'); + await fs.promises.writeFile(unsupportedFilePath, 'testtxt'); + + await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000); + expect(checkBulkUpload).toHaveBeenCalledWith({ + assetBulkUploadCheckDto: { + assets: expect.arrayContaining([ + expect.objectContaining({ + id: testFilePath, + }), + ]), + }, + }); + + expect(checkBulkUpload).not.toHaveBeenCalledWith({ + assetBulkUploadCheckDto: { + assets: expect.arrayContaining([ + expect.objectContaining({ + id: unsupportedFilePath, + }), + ]), + }, + }); + }); + + it('should filger out ignored patterns', async () => { + const testFilePath = path.join(testFolder, 'test.jpg'); + const ignoredPattern = 'ignored'; + const ignoredFolder = path.join(testFolder, ignoredPattern); + await fs.promises.mkdir(ignoredFolder, { recursive: true }); + const ignoredFilePath = path.join(ignoredFolder, 'ignored.jpg'); + + await startWatch([testFolder], { concurrency: 1, ignore: ignoredPattern }, { batchSize: 1, debounceTimeMs: 10 }); + await sleep(100); // to debounce the watcher from considering the test file as a existing file + await fs.promises.writeFile(testFilePath, 'testjpg'); + await fs.promises.writeFile(ignoredFilePath, 'ignoredjpg'); + + await vi.waitUntil(() => checkBulkUploadMocked.mock.calls.length > 0, 3000); + expect(checkBulkUpload).toHaveBeenCalledWith({ + assetBulkUploadCheckDto: { + assets: expect.arrayContaining([ + expect.objectContaining({ + id: testFilePath, + }), + ]), + }, + }); + + expect(checkBulkUpload).not.toHaveBeenCalledWith({ + assetBulkUploadCheckDto: { + assets: expect.arrayContaining([ + expect.objectContaining({ + id: ignoredFilePath, + }), + ]), + }, + }); + }); + + afterEach(async () => { + await fs.promises.rm(testFolder, { recursive: true, force: true }); }); }); From aad557fefc034746a8f405ca12396f512a9fee72 Mon Sep 17 00:00:00 2001 From: Eli Gao Date: Thu, 23 Jan 2025 14:25:38 +0800 Subject: [PATCH 16/17] refactor(cli): minor changes for code reviews --- cli/src/commands/asset.ts | 2 +- cli/src/utils.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cli/src/commands/asset.ts b/cli/src/commands/asset.ts index 376b41e40608c..010de083d792b 100644 --- a/cli/src/commands/asset.ts +++ b/cli/src/commands/asset.ts @@ -99,7 +99,7 @@ export const startWatch = async ( return; } const ext = '.' + path.split('.').pop()?.toLowerCase(); - if (!extensions.has(ext ?? '')) { + if (!ext || !extensions.has(ext)) { return; } console.log(`Change detected: ${path}`); diff --git a/cli/src/utils.ts b/cli/src/utils.ts index d3c7ff27876b8..d747d30de9c4b 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -178,7 +178,7 @@ export const sha1 = (filepath: string) => { * when the batch size is reached or the debounce time has passed. */ export class Batcher { - private readonly items: T[] = []; + private items: T[] = []; private readonly batchSize: number; private readonly debounceTimeMs?: number; private readonly onBatch: (items: T[]) => void; @@ -228,8 +228,8 @@ export class Batcher { return; } - this.onBatch([...this.items]); + this.onBatch(this.items); - this.items.length = 0; + this.items = []; } } From 4020c8ff1a0daaf08ec0726028a9c389afd43d40 Mon Sep 17 00:00:00 2001 From: Eli Gao Date: Thu, 23 Jan 2025 14:31:24 +0800 Subject: [PATCH 17/17] feat(cli): disable onFile logs when progress bar is enabled --- cli/src/commands/asset.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cli/src/commands/asset.ts b/cli/src/commands/asset.ts index 010de083d792b..d06b30e984e72 100644 --- a/cli/src/commands/asset.ts +++ b/cli/src/commands/asset.ts @@ -102,7 +102,11 @@ export const startWatch = async ( if (!ext || !extensions.has(ext)) { return; } - console.log(`Change detected: ${path}`); + + if (!options.progress) { + // logging when progress is disabled as it can cause issues with the progress bar rendering + console.log(`Change detected: ${path}`); + } pathsBatcher.add(path); }; const fsWatcher = watchFs(paths, {