Compare commits

..

1 Commits

Author SHA1 Message Date
midzelis c9bc84c6a7 refactor: rename image cancel method 2026-02-20 02:57:13 +00:00
18 changed files with 467 additions and 481 deletions
+1
View File
@@ -8,6 +8,7 @@ readme = "README.md"
dependencies = [
"aiocache>=0.12.1,<1.0",
"fastapi>=0.95.2,<1.0",
"ftfy>=6.1.1",
"gunicorn>=21.1.0",
"huggingface-hub>=0.20.1,<1.0",
"insightface>=0.7.3,<1.0",
@@ -150,12 +150,10 @@ class DriftSearchPage extends HookConsumerWidget {
handleOnSelect(Set<PersonDto> value) {
filter.value = filter.value.copyWith(people: value);
final label = value.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', ');
if (label.isNotEmpty) {
peopleCurrentFilterWidget.value = Text(label, style: context.textTheme.labelLarge);
} else {
peopleCurrentFilterWidget.value = null;
}
peopleCurrentFilterWidget.value = Text(
value.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', '),
style: context.textTheme.labelLarge,
);
}
handleClear() {
+3 -9
View File
@@ -343,9 +343,6 @@ importers:
'@extism/extism':
specifier: 2.0.0-rc13
version: 2.0.0-rc13
'@immich/walkrs':
specifier: ^0.0.13
version: 0.0.13
'@nestjs/bullmq':
specifier: ^11.0.1
version: 11.0.4(@nestjs/common@11.1.13(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.13)(bullmq@5.68.0)
@@ -457,6 +454,9 @@ importers:
express:
specifier: ^5.1.0
version: 5.2.1
fast-glob:
specifier: ^3.3.2
version: 3.3.3
fluent-ffmpeg:
specifier: ^2.1.2
version: 2.1.3
@@ -3023,10 +3023,6 @@ packages:
peerDependencies:
svelte: ^5.0.0
'@immich/walkrs@0.0.13':
resolution: {integrity: sha512-qKDoXFgy3d2Z7SIJBn25BcNyQnPLAp2zZEBcewpWxG5+qAXDPi3M3sweJ9qJ11Eha+YlmpUO3c8yd5CCBeq96A==}
engines: {pnpm: '>=10.0.0'}
'@inquirer/ansi@1.0.2':
resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==}
engines: {node: '>=18'}
@@ -14839,8 +14835,6 @@ snapshots:
transitivePeerDependencies:
- '@sveltejs/kit'
'@immich/walkrs@0.0.13': {}
'@inquirer/ansi@1.0.2': {}
'@inquirer/checkbox@4.3.2(@types/node@24.10.13)':
+15 -24
View File
@@ -9,9 +9,6 @@ packages:
- plugins
- web
- .github
dedupePeerDependents: false
ignoredBuiltDependencies:
- '@nestjs/core'
- '@parcel/watcher'
@@ -28,48 +25,42 @@ ignoredBuiltDependencies:
- protobufjs
- ssh2
- utimes
injectWorkspacePackages: true
onlyBuiltDependencies:
- sharp
- '@tailwindcss/oxide'
- bcrypt
overrides:
canvas: 2.11.2
sharp: ^0.34.5
packageExtensions:
'@immich/ui':
dependencies:
tailwindcss: '>=4.1'
'@photo-sphere-viewer/equirectangular-video-adapter':
dependencies:
three: '*'
'@photo-sphere-viewer/video-plugin':
dependencies:
three: '*'
bcrypt:
dependencies:
node-addon-api: '*'
node-gyp: '*'
nestjs-kysely:
dependencies:
tslib: '*'
nestjs-otel:
dependencies:
tslib: '*'
'@photo-sphere-viewer/equirectangular-video-adapter':
dependencies:
three: '*'
'@photo-sphere-viewer/video-plugin':
dependencies:
three: '*'
sharp:
dependencies:
node-addon-api: '*'
node-gyp: '*'
'@immich/ui':
dependencies:
tailwindcss: '>=4.1'
tailwind-variants:
dependencies:
tailwindcss: '>=4.1'
bcrypt:
dependencies:
node-addon-api: '*'
node-gyp: '*'
dedupePeerDependents: false
preferWorkspacePackages: true
injectWorkspacePackages: true
shamefullyHoist: false
verifyDepsBeforeRun: install
+1 -1
View File
@@ -35,7 +35,6 @@
},
"dependencies": {
"@extism/extism": "2.0.0-rc13",
"@immich/walkrs": "^0.0.13",
"@nestjs/bullmq": "^11.0.1",
"@nestjs/common": "^11.0.4",
"@nestjs/core": "^11.0.4",
@@ -73,6 +72,7 @@
"cron": "4.4.0",
"exiftool-vendored": "^34.3.0",
"express": "^5.1.0",
"fast-glob": "^3.3.2",
"fluent-ffmpeg": "^2.1.2",
"geo-tz": "^8.0.0",
"handlebars": "^4.7.8",
+6 -2
View File
@@ -54,12 +54,16 @@ export class UpdateLibraryDto {
exclusionPatterns?: string[];
}
export interface WalkOptionsDto {
pathsToWalk: string[];
export interface CrawlOptionsDto {
pathsToCrawl: string[];
includeHidden?: boolean;
exclusionPatterns?: string[];
}
export interface WalkOptionsDto extends CrawlOptionsDto {
take: number;
}
export class ValidateLibraryDto {
@ApiPropertyOptional({ description: 'Import paths to validate (max 128)' })
@Optional()
@@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { isEmpty, isUndefined, omitBy } from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { LockableProperty, Stack } from 'src/database';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators';
@@ -0,0 +1,208 @@
import mockfs from 'mock-fs';
import { CrawlOptionsDto } from 'src/dtos/library.dto';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { automock } from 'test/utils';
interface Test {
test: string;
options: CrawlOptionsDto;
files: Record<string, boolean>;
}
const cwd = process.cwd();
const tests: Test[] = [
{
test: 'should return empty when crawling an empty path list',
options: {
pathsToCrawl: [],
},
files: {},
},
{
test: 'should crawl a single path',
options: {
pathsToCrawl: ['/photos/'],
},
files: {
'/photos/image.jpg': true,
},
},
{
test: 'should exclude by file extension',
options: {
pathsToCrawl: ['/photos/'],
exclusionPatterns: ['**/*.tif'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.tif': false,
},
},
{
test: 'should exclude by file extension without case sensitivity',
options: {
pathsToCrawl: ['/photos/'],
exclusionPatterns: ['**/*.TIF'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.tif': false,
},
},
{
test: 'should exclude by folder',
options: {
pathsToCrawl: ['/photos/'],
exclusionPatterns: ['**/raw/**'],
},
files: {
'/photos/image.jpg': true,
'/photos/raw/image.jpg': false,
'/photos/raw2/image.jpg': true,
'/photos/folder/raw/image.jpg': false,
'/photos/crawl/image.jpg': true,
},
},
{
test: 'should crawl multiple paths',
options: {
pathsToCrawl: ['/photos/', '/images/', '/albums/'],
},
files: {
'/photos/image1.jpg': true,
'/images/image2.jpg': true,
'/albums/image3.jpg': true,
},
},
{
test: 'should crawl a single path without trailing slash',
options: {
pathsToCrawl: ['/photos'],
},
files: {
'/photos/image.jpg': true,
},
},
{
test: 'should crawl a single path',
options: {
pathsToCrawl: ['/photos/'],
},
files: {
'/photos/image.jpg': true,
'/photos/subfolder/image1.jpg': true,
'/photos/subfolder/image2.jpg': true,
'/image1.jpg': false,
},
},
{
test: 'should filter file extensions',
options: {
pathsToCrawl: ['/photos/'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.txt': false,
'/photos/1': false,
},
},
{
test: 'should include photo and video extensions',
options: {
pathsToCrawl: ['/photos/', '/videos/'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.jpeg': true,
'/photos/image.heic': true,
'/photos/image.heif': true,
'/photos/image.png': true,
'/photos/image.gif': true,
'/photos/image.tif': true,
'/photos/image.tiff': true,
'/photos/image.webp': true,
'/photos/image.dng': true,
'/photos/image.nef': true,
'/videos/video.mp4': true,
'/videos/video.mov': true,
'/videos/video.webm': true,
},
},
{
test: 'should check file extensions without case sensitivity',
options: {
pathsToCrawl: ['/photos/'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.Jpg': true,
'/photos/image.jpG': true,
'/photos/image.JPG': true,
'/photos/image.jpEg': true,
'/photos/image.TIFF': true,
'/photos/image.tif': true,
'/photos/image.dng': true,
'/photos/image.NEF': true,
},
},
{
test: 'should normalize the path',
options: {
pathsToCrawl: ['/photos/1/../2'],
},
files: {
'/photos/1/image.jpg': false,
'/photos/2/image.jpg': true,
},
},
{
test: 'should return absolute paths',
options: {
pathsToCrawl: ['photos'],
},
files: {
[`${cwd}/photos/1.jpg`]: true,
[`${cwd}/photos/2.jpg`]: true,
[`/photos/3.jpg`]: false,
},
},
{
test: 'should support special characters in paths',
options: {
pathsToCrawl: ['/photos (new)'],
},
files: {
['/photos (new)/1.jpg']: true,
},
},
];
describe(StorageRepository.name, () => {
let sut: StorageRepository;
beforeEach(() => {
// eslint-disable-next-line no-sparse-arrays
sut = new StorageRepository(automock(LoggingRepository, { args: [, { getEnv: () => ({}) }], strict: false }));
});
afterEach(() => {
mockfs.restore();
});
describe('crawl', () => {
for (const { test, options, files } of tests) {
it(test, async () => {
mockfs(Object.fromEntries(Object.keys(files).map((file) => [file, ''])));
const actual = await sut.crawl(options);
const expected = Object.entries(files)
.filter((entry) => entry[1])
.map(([file]) => file);
expect(actual.toSorted()).toEqual(expected.toSorted());
});
}
});
});
+50 -12
View File
@@ -1,13 +1,13 @@
import type { WalkItem } from '@immich/walkrs' with { 'resolution-mode': 'import' };
import { Injectable } from '@nestjs/common';
import archiver from 'archiver';
import chokidar, { ChokidarOptions } from 'chokidar';
import { escapePath, glob, globStream } from 'fast-glob';
import { constants, createReadStream, createWriteStream, existsSync, mkdirSync, ReadOptionsWithBuffer } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { PassThrough, Readable, Writable } from 'node:stream';
import { createGunzip, createGzip } from 'node:zlib';
import { WalkOptionsDto } from 'src/dtos/library.dto';
import { CrawlOptionsDto, WalkOptionsDto } from 'src/dtos/library.dto';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { mimeTypes } from 'src/utils/mime-types';
@@ -198,22 +198,54 @@ export class StorageRepository {
};
}
async *walk(walkOptions: WalkOptionsDto): AsyncGenerator<WalkItem[], void, unknown> {
const { pathsToWalk, exclusionPatterns, includeHidden } = walkOptions;
if (pathsToWalk.length === 0) {
return;
crawl(crawlOptions: CrawlOptionsDto): Promise<string[]> {
const { pathsToCrawl, exclusionPatterns, includeHidden } = crawlOptions;
if (pathsToCrawl.length === 0) {
return Promise.resolve([]);
}
const { walk } = await import('@immich/walkrs');
const globbedPaths = pathsToCrawl.map((path) => this.asGlob(path));
yield* walk({
paths: pathsToWalk.map((p) => path.resolve(p)),
includeHidden: includeHidden ?? false,
exclusionPatterns,
extensions: mimeTypes.getSupportedFileExtensions(),
return glob(globbedPaths, {
absolute: true,
caseSensitiveMatch: false,
onlyFiles: true,
dot: includeHidden,
ignore: exclusionPatterns,
});
}
async *walk(walkOptions: WalkOptionsDto): AsyncGenerator<string[]> {
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: ChokidarOptions, events: Partial<WatchEvents>) {
const watcher = chokidar.watch(paths, options);
@@ -225,4 +257,10 @@ export class StorageRepository {
return () => watcher.close();
}
private asGlob(pathToCrawl: string): string {
const escapedPath = escapePath(pathToCrawl).replaceAll('"', '["]').replaceAll("'", "[']").replaceAll('`', '[`]');
const extensions = `*{${mimeTypes.getSupportedFileExtensions().join(',')}}`;
return `${escapedPath}/**/${extensions}`;
}
}
+40 -32
View File
@@ -1,6 +1,7 @@
import { BadRequestException } from '@nestjs/common';
import { Stats } from 'node:fs';
import { defaults, SystemConfig } from 'src/config';
import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants';
import { mapLibrary } from 'src/dtos/library.dto';
import { AssetType, CronJob, ImmichWorker, JobName, JobStatus } from 'src/enum';
import { LibraryService } from 'src/services/library.service';
@@ -13,6 +14,10 @@ import { factory, newDate, newUuid } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
import { vitest } from 'vitest';
async function* mockWalk() {
yield await Promise.resolve(['/data/user1/photo.jpg']);
}
describe(LibraryService.name, () => {
let sut: LibraryService;
@@ -160,11 +165,7 @@ describe(LibraryService.name, () => {
const library = factory.library({ importPaths: ['/foo', '/bar'] });
mocks.library.get.mockResolvedValue(library);
mocks.storage.walk.mockReturnValue(
(async function* () {
yield await Promise.resolve([{ type: 'entry', path: '/data/user1/photo.jpg' }]);
})(),
);
mocks.storage.walk.mockImplementation(mockWalk);
mocks.storage.stat.mockResolvedValue({ isDirectory: () => true } as Stats);
mocks.storage.checkFileExists.mockResolvedValue(true);
mocks.asset.filterNewExternalAssetPaths.mockResolvedValue(['/data/user1/photo.jpg']);
@@ -200,20 +201,16 @@ describe(LibraryService.name, () => {
});
mocks.storage.checkFileExists.mockResolvedValue(true);
mocks.storage.walk.mockReturnValue(
(async function* () {
yield await Promise.resolve([{ type: 'entry', path: '/data/user1/photo.jpg' }]);
})(),
);
mocks.library.get.mockResolvedValue(library);
mocks.asset.filterNewExternalAssetPaths.mockResolvedValue(['/data/user1/photo.jpg']);
await sut.handleQueueSyncFiles({ id: library.id });
expect(mocks.storage.walk).toHaveBeenCalledWith({
pathsToWalk: [library.importPaths[1]],
pathsToCrawl: [library.importPaths[1]],
exclusionPatterns: [],
includeHidden: false,
take: JOBS_LIBRARY_PAGINATION_SIZE,
});
});
});
@@ -223,11 +220,7 @@ describe(LibraryService.name, () => {
const library = factory.library({ importPaths: ['/foo', '/bar'] });
mocks.library.get.mockResolvedValue(library);
mocks.storage.walk.mockReturnValue(
(async function* () {
yield await Promise.resolve([{ type: 'entry', path: '/data/user1/photo.jpg' }]);
})(),
);
mocks.storage.walk.mockImplementation(mockWalk);
mocks.storage.stat.mockResolvedValue({ isDirectory: () => true } as Stats);
mocks.storage.checkFileExists.mockResolvedValue(true);
mocks.asset.filterNewExternalAssetPaths.mockResolvedValue(['/data/user1/photo.jpg']);
@@ -249,6 +242,33 @@ describe(LibraryService.name, () => {
await expect(sut.handleQueueSyncFiles({ id: library.id })).resolves.toBe(JobStatus.Skipped);
});
it('should ignore import paths that do not exist', async () => {
const library = factory.library({ importPaths: ['/foo', '/bar'] });
mocks.storage.stat.mockImplementation((path): Promise<Stats> => {
if (path === library.importPaths[0]) {
const error = { code: 'ENOENT' } as any;
throw error;
}
return Promise.resolve({
isDirectory: () => true,
} as Stats);
});
mocks.storage.checkFileExists.mockResolvedValue(true);
mocks.library.get.mockResolvedValue(library);
await sut.handleQueueSyncFiles({ id: library.id });
expect(mocks.storage.walk).toHaveBeenCalledWith({
pathsToCrawl: [library.importPaths[1]],
exclusionPatterns: [],
includeHidden: false,
take: JOBS_LIBRARY_PAGINATION_SIZE,
});
});
});
describe('handleQueueSyncAssets', () => {
@@ -256,11 +276,7 @@ describe(LibraryService.name, () => {
const library = factory.library();
mocks.library.get.mockResolvedValue(library);
mocks.storage.walk.mockReturnValue(
(async function* () {
yield await Promise.resolve([]);
})(),
);
mocks.storage.walk.mockImplementation(async function* generator() {});
mocks.asset.getLibraryAssetCount.mockResolvedValue(1);
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: 1n });
@@ -278,11 +294,7 @@ describe(LibraryService.name, () => {
const library = factory.library();
mocks.library.get.mockResolvedValue(library);
mocks.storage.walk.mockReturnValue(
(async function* () {
yield await Promise.resolve([]);
})(),
);
mocks.storage.walk.mockImplementation(async function* generator() {});
mocks.asset.getLibraryAssetCount.mockResolvedValue(0);
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: 1n });
@@ -297,11 +309,7 @@ describe(LibraryService.name, () => {
const asset = AssetFactory.create({ libraryId: library.id, isExternal: true });
mocks.library.get.mockResolvedValue(library);
mocks.storage.walk.mockReturnValue(
(async function* () {
yield await Promise.resolve([]);
})(),
);
mocks.storage.walk.mockImplementation(async function* generator() {});
mocks.library.streamAssetIds.mockReturnValue(makeStream([asset]));
mocks.asset.getLibraryAssetCount.mockResolvedValue(1);
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: 0n });
+28 -46
View File
@@ -4,7 +4,6 @@ import { R_OK } from 'node:constants';
import { Stats } from 'node:fs';
import path, { basename, isAbsolute, parse } from 'node:path';
import picomatch from 'picomatch';
import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent, OnJob } from 'src/decorators';
@@ -248,11 +247,9 @@ export class LibraryService extends BaseService {
return JobStatus.Failed;
}
const newPaths = await this.assetRepository.filterNewExternalAssetPaths(library.id, job.paths);
const assetImports: Insertable<AssetTable>[] = [];
await Promise.all(
newPaths.map((path) =>
job.paths.map((path) =>
this.processEntity(path, library.ownerId, job.libraryId)
.then((asset) => assetImports.push(asset))
.catch((error: any) => this.logger.error(`Error processing ${path} for library ${job.libraryId}: ${error}`)),
@@ -397,7 +394,6 @@ export class LibraryService extends BaseService {
private async processEntity(filePath: string, ownerId: string, libraryId: string) {
const assetPath = path.normalize(filePath);
const stat = await this.storageRepository.stat(assetPath);
return {
@@ -640,56 +636,42 @@ export class LibraryService extends BaseService {
return JobStatus.Skipped;
}
this.logger.log(`Starting disk crawl of ${validImportPaths.length} import path(s) for library ${library.id}...`);
const fileWalker = this.storageRepository.walk({
pathsToWalk: validImportPaths,
includeHidden: false, // TODO: make this configurable?
const pathsOnDisk = this.storageRepository.walk({
pathsToCrawl: validImportPaths,
includeHidden: false,
exclusionPatterns: library.exclusionPatterns,
take: JOBS_LIBRARY_PAGINATION_SIZE,
});
const walkStart = Date.now();
let progressCounter = 0;
let lastLoggedMilestone = 0;
let importCount = 0;
let crawlCount = 0;
for await (const walkItems of fileWalker) {
const paths: string[] = [];
for (const item of walkItems) {
if (item.type === 'error') {
this.logger.warn(`Error walking ${item.path ?? 'unknown path'}: ${item.message} for library ${library.id}`);
} else {
paths.push(item.path);
}
this.logger.log(`Starting disk crawl of ${validImportPaths.length} import path(s) for library ${library.id}...`);
for await (const pathBatch of pathsOnDisk) {
crawlCount += pathBatch.length;
const paths = await this.assetRepository.filterNewExternalAssetPaths(library.id, pathBatch);
if (paths.length > 0) {
importCount += paths.length;
await this.jobRepository.queue({
name: JobName.LibrarySyncFiles,
data: {
libraryId: library.id,
paths,
progressCounter: crawlCount,
},
});
}
if (paths.length === 0) {
continue;
}
progressCounter += paths.length;
await this.jobRepository.queue({
name: JobName.LibrarySyncFiles,
data: {
libraryId: library.id,
paths,
progressCounter,
},
});
const currentMilestone = Math.floor(progressCounter / 100_000);
// Log every 100k files found to give some feedback on progress for large libraries
if (currentMilestone > lastLoggedMilestone) {
const roundedCount = currentMilestone * 100_000;
this.logger.log(
`Disk walk found ${roundedCount} file(s) so far (${((Date.now() - walkStart) / 1000).toFixed(2)}s elapsed) for library ${library.id}...`,
);
lastLoggedMilestone = currentMilestone;
}
this.logger.log(
`Crawled ${crawlCount} file(s) so far: ${paths.length} of current batch of ${pathBatch.length} will be imported to library ${library.id}...`,
);
}
this.logger.log(
`Finished disk walk, ${progressCounter} file(s) found on disk in ${((Date.now() - walkStart) / 1000).toFixed(2)}s for library ${library.id}`,
`Finished disk crawl, ${crawlCount} file(s) found on disk and queued ${importCount} file(s) for import into ${library.id}`,
);
await this.libraryRepository.update(job.id, { refreshedAt: new Date() });
@@ -1,333 +0,0 @@
import type { WalkError, WalkItem } from '@immich/walkrs' with { 'resolution-mode': 'import' };
import { Kysely } from 'kysely';
import fs from 'node:fs/promises';
import os from 'node:os';
import path, { join } from 'node:path';
import { WalkOptionsDto } from 'src/dtos/library.dto';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { DB } from 'src/schema';
import { BaseService } from 'src/services/base.service';
import { newMediumService } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
interface Test {
test: string;
options: WalkOptionsDto;
files: Record<string, boolean>;
}
const createTestFiles = async (basePath: string, files: string[]) => {
await Promise.all(
files.map(async (file) => {
const fullPath = path.join(basePath, file.replace(/^\//, ''));
await fs.mkdir(path.dirname(fullPath), { recursive: true });
await fs.writeFile(fullPath, '');
}),
);
};
const tests: Test[] = [
{
test: 'should return empty when walking an empty path list',
options: {
pathsToWalk: [],
},
files: {},
},
{
test: 'should walk a single path',
options: {
pathsToWalk: ['/photos/'],
},
files: {
'/photos/image.jpg': true,
},
},
{
test: 'should exclude by file extension',
options: {
pathsToWalk: ['/photos/'],
exclusionPatterns: ['**/*.tif'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.tif': false,
},
},
{
test: 'should exclude by file extension without case sensitivity',
options: {
pathsToWalk: ['/photos/'],
exclusionPatterns: ['**/*.TIF'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.tif': false,
'/photos/image.tIf': false,
'/photos/image.TIF': false,
},
},
{
test: 'should exclude by folder',
options: {
pathsToWalk: ['/photos/'],
exclusionPatterns: ['**/raw/**'],
},
files: {
'/photos/image.jpg': true,
'/photos/raw/image.jpg': false,
'/photos/raw2/image.jpg': true,
'/photos/folder/raw/image.jpg': false,
'/photos/walk/image.jpg': true,
},
},
{
test: 'should walk multiple paths',
options: {
pathsToWalk: ['/photos/', '/images/', '/albums/'],
},
files: {
'/photos/image1.jpg': true,
'/images/image2.jpg': true,
'/albums/image3.jpg': true,
},
},
{
test: 'should walk a single path without trailing slash',
options: {
pathsToWalk: ['/photos'],
},
files: {
'/photos/image.jpg': true,
},
},
{
test: 'should walk a single path',
options: {
pathsToWalk: ['/photos/'],
},
files: {
'/photos/image.jpg': true,
'/photos/subfolder/image1.jpg': true,
'/photos/subfolder/image2.jpg': true,
'/image1.jpg': false,
},
},
{
test: 'should filter file extensions',
options: {
pathsToWalk: ['/photos/'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.txt': false,
'/photos/1': false,
},
},
{
test: 'should include photo and video extensions',
options: {
pathsToWalk: ['/photos/', '/videos/'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.jpeg': true,
'/photos/image.heic': true,
'/photos/image.heif': true,
'/photos/image.png': true,
'/photos/image.gif': true,
'/photos/image.tif': true,
'/photos/image.tiff': true,
'/photos/image.webp': true,
'/photos/image.dng': true,
'/photos/image.nef': true,
'/videos/video.mp4': true,
'/videos/video.mov': true,
'/videos/video.webm': true,
},
},
{
test: 'should check file extensions without case sensitivity',
options: {
pathsToWalk: ['/photos/'],
},
files: {
'/photos/image.jpg': true,
'/photos/image.Jpg': true,
'/photos/image.jpG': true,
'/photos/image.JPG': true,
'/photos/image.jpEg': true,
'/photos/image.TIFF': true,
'/photos/image.tif': true,
'/photos/image.dng': true,
'/photos/image.NEF': true,
},
},
{
test: 'should normalize the path',
options: {
pathsToWalk: ['/photos/1/../2'],
},
files: {
'/photos/1/image.jpg': false,
'/photos/2/image.jpg': true,
},
},
{
test: 'should support special characters in paths',
options: {
pathsToWalk: ['/photos (new)'],
},
files: {
['/photos (new)/1.jpg']: true,
},
},
];
const setup = (db?: Kysely<DB>) => {
const { ctx } = newMediumService(BaseService, {
database: db || defaultDatabase,
real: [],
mock: [LoggingRepository],
});
return { sut: ctx.get(StorageRepository) };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(StorageRepository.name, () => {
let sut: StorageRepository;
beforeEach(() => {
({ sut } = setup());
});
describe('walk', () => {
for (const { test, options, files } of tests) {
describe(test, () => {
const fileList = Object.keys(files);
let tempDir: string;
beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'immich-storage-test-'));
await createTestFiles(tempDir, fileList);
});
afterEach(async () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
it('returns expected files', async () => {
const adjustedOptions = {
...options,
pathsToWalk: options.pathsToWalk.map((p) => path.join(tempDir, p.replace(/^\//, ''))),
};
const actual: string[] = [];
for await (const batch of sut.walk(adjustedOptions)) {
for (const item of batch) {
if (item.type === 'entry') {
actual.push(item.path);
}
}
}
const expected = Object.entries(files)
.filter((entry) => entry[1])
.map(([file]) => path.join(tempDir, file.replace(/^\//, '')));
expect(actual.toSorted()).toEqual(expected.toSorted());
});
});
}
it('should handle access denied errors gracefully', async () => {
const testDir = await fs.mkdtemp(join(os.tmpdir(), 'immich-test-access-denied-'));
const restrictedDir = join(testDir, 'restricted');
const restrictedFile = join(restrictedDir, 'file.jpg');
const accessibleFile = join(testDir, 'accessible.jpg');
try {
// Create test directory structure
await fs.mkdir(restrictedDir, { recursive: true });
await fs.writeFile(accessibleFile, 'accessible content');
await fs.writeFile(restrictedFile, 'restricted content');
// Remove all permissions from restricted directory to simulate access denied
await fs.chmod(restrictedDir, 0o000);
const actual: string[] = [];
const errors: WalkItem[] = [];
for await (const batch of sut.walk({ pathsToWalk: [testDir] })) {
for (const item of batch) {
if (item.type === 'entry') {
actual.push(item.path);
} else {
errors.push(item);
}
}
}
// Should successfully walk accessible file but skip restricted directory
expect(actual).toContain(accessibleFile);
expect(actual).not.toContain(restrictedFile);
// Should have encountered an error for the restricted directory
expect(errors.length).toBe(1);
expect(errors.some((e) => e.type === 'error' && e.message?.includes('restricted'))).toBe(true);
} finally {
// Cleanup: restore permissions before deletion
try {
await fs.chmod(restrictedDir, 0o755);
} catch {
// Ignore errors if directory was already deleted or permissions cannot be restored
}
await fs.rm(testDir, { recursive: true, force: true });
}
});
it('should return error details for access denied paths', async () => {
const testDir = await fs.mkdtemp(join(os.tmpdir(), 'immich-test-access-denied-'));
const restrictedDir = join(testDir, 'restricted');
const restrictedFile = join(restrictedDir, 'file.jpg');
const accessibleFile = join(testDir, 'accessible.jpg');
try {
// Create test directory structure
await fs.mkdir(restrictedDir, { recursive: true });
await fs.writeFile(accessibleFile, 'accessible content');
await fs.writeFile(restrictedFile, 'restricted content');
// Remove all permissions from restricted directory to simulate access denied
await fs.chmod(restrictedDir, 0o000);
const errors: WalkError[] = [];
for await (const batch of sut.walk({ pathsToWalk: [testDir] })) {
for (const item of batch) {
if (item.type === 'error') {
errors.push(item);
}
}
}
// Should have error details including path and message
expect(errors.length).toBe(1);
const restrictedError = errors.find((e) => e.type === 'error' && e.message?.includes('restricted'));
expect(restrictedError).toBeDefined();
expect(restrictedError?.type).toBe('error');
expect(restrictedError?.message).toBeDefined();
} finally {
// Cleanup: restore permissions before deletion
try {
await fs.chmod(restrictedDir, 0o755);
} catch {
// Ignore errors if directory was already deleted or permissions cannot be restored
}
await fs.rm(testDir, { recursive: true, force: true });
}
});
});
});
@@ -68,7 +68,8 @@ export const newStorageRepositoryMock = (): Mocked<RepositoryInterface<StorageRe
readdir: vitest.fn(),
realpath: vitest.fn().mockImplementation((filepath: string) => Promise.resolve(filepath)),
stat: vitest.fn(),
walk: vitest.fn(),
crawl: vitest.fn(),
walk: vitest.fn().mockImplementation(async function* () {}),
rename: vitest.fn(),
copyFile: vitest.fn(),
utimes: vitest.fn(),
@@ -159,7 +159,7 @@
imageError = imageLoaded = true;
};
onDestroy(() => imageManager.cancelPreloadUrl(imageLoaderUrl));
onDestroy(() => imageManager.cancel(asset, targetImageSize));
let imageLoaderUrl = $derived(
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || assetViewerManager.zoom > 1 }),
@@ -1,6 +1,6 @@
<script lang="ts">
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { Icon } from '@immich/ui';
import { mdiEyeOffOutline } from '@mdi/js';
import type { ActionReturn } from 'svelte/action';
@@ -60,7 +60,7 @@
onComplete?.(false);
}
return {
destroy: () => imageManager.cancelPreloadUrl(url),
destroy: () => cancelImageUrl(url),
};
}
@@ -32,12 +32,6 @@ class ImageManager {
}
}
}
cancelPreloadUrl(url: string | undefined) {
if (url) {
cancelImageUrl(url);
}
}
}
export const imageManager = new ImageManager();
@@ -0,0 +1,99 @@
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { getAssetMediaUrl } from '$lib/utils';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { AssetMediaSize } from '@immich/sdk';
import { assetFactory } from '@test-data/factories/asset-factory';
vi.mock('$lib/utils/sw-messaging', () => ({
cancelImageUrl: vi.fn(),
}));
vi.mock('$lib/utils', () => ({
getAssetMediaUrl: vi.fn(),
}));
describe('ImageManager', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('preload', () => {
it('creates an Image with the correct URL', () => {
vi.mocked(getAssetMediaUrl).mockReturnValue('/api/assets/123/media');
const asset = assetFactory.build();
imageManager.preload(asset);
expect(getAssetMediaUrl).toHaveBeenCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
});
it('does nothing for undefined asset', () => {
imageManager.preload(undefined);
expect(getAssetMediaUrl).not.toHaveBeenCalled();
});
it('does nothing when getAssetMediaUrl returns falsy', () => {
vi.mocked(getAssetMediaUrl).mockReturnValue('');
const asset = assetFactory.build();
imageManager.preload(asset);
expect(getAssetMediaUrl).toHaveBeenCalled();
});
it('uses the specified size', () => {
vi.mocked(getAssetMediaUrl).mockReturnValue('/api/assets/123/media');
const asset = assetFactory.build();
imageManager.preload(asset, AssetMediaSize.Thumbnail);
expect(getAssetMediaUrl).toHaveBeenCalledWith({
id: asset.id,
size: AssetMediaSize.Thumbnail,
cacheKey: asset.thumbhash,
});
});
});
describe('cancel', () => {
it('calls cancelImageUrl with the correct URL', () => {
vi.mocked(getAssetMediaUrl).mockReturnValue('/api/assets/123/media');
const asset = assetFactory.build();
imageManager.cancel(asset, AssetMediaSize.Preview);
expect(cancelImageUrl).toHaveBeenCalledWith('/api/assets/123/media');
});
it('does nothing for undefined asset', () => {
imageManager.cancel(undefined);
expect(getAssetMediaUrl).not.toHaveBeenCalled();
expect(cancelImageUrl).not.toHaveBeenCalled();
});
it('cancels all sizes when size is "all"', () => {
vi.mocked(getAssetMediaUrl).mockImplementation(({ size }) => `/api/assets/123/${size}`);
const asset = assetFactory.build();
imageManager.cancel(asset, 'all');
expect(getAssetMediaUrl).toHaveBeenCalledTimes(Object.values(AssetMediaSize).length);
for (const size of Object.values(AssetMediaSize)) {
expect(cancelImageUrl).toHaveBeenCalledWith(`/api/assets/123/${size}`);
}
});
it('does not call cancelImageUrl when URL is falsy', () => {
vi.mocked(getAssetMediaUrl).mockReturnValue('');
const asset = assetFactory.build();
imageManager.cancel(asset, AssetMediaSize.Preview);
expect(cancelImageUrl).not.toHaveBeenCalled();
});
});
});
+7 -5
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { handleResetPinCode } from '$lib/services/user.service';
import { BasicModal, Field, FormModal, HelperText, PasswordInput, Stack, type ModalSize } from '@immich/ui';
import { Field, FormModal, HelperText, Modal, ModalBody, PasswordInput, Stack, type ModalSize } from '@immich/ui';
import { mdiLockReset } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -23,7 +23,7 @@
const common = $derived({ title: $t('reset'), size: 'small' as ModalSize, icon: mdiLockReset, onClose });
</script>
{#if featureFlagsManager.value.passwordLogin}
{#if featureFlagsManager.value.passwordLogin === false}
<FormModal {...common} submitColor="danger" submitText={$t('reset')} disabled={!password} {onSubmit}>
<Stack gap={4}>
<div>{$t('reset_pin_code_description')}</div>
@@ -37,7 +37,9 @@
</Stack>
</FormModal>
{:else}
<BasicModal {...common}>
<div>{$t('reset_pin_code_description')}</div>
</BasicModal>
<Modal {...common} closeOnBackdropClick>
<ModalBody>
<div>{$t('reset_pin_code_description')}</div>
</ModalBody>
</Modal>
{/if}