mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge branch 'main' of https://github.com/immich-app/immich into feat/crawl-wrapper
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
24.13.0
|
||||
24.13.1
|
||||
|
||||
+8
-11
@@ -3,22 +3,19 @@ FROM ghcr.io/immich-app/base-server-dev:202601131104@sha256:8d907eb3fe10dba4a1e0
|
||||
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
CI=1 \
|
||||
COREPACK_HOME=/tmp
|
||||
COREPACK_HOME=/tmp \
|
||||
PNPM_HOME=/buildcache/pnpm-store
|
||||
|
||||
RUN npm install --global corepack@latest && \
|
||||
corepack enable pnpm && \
|
||||
echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \
|
||||
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \
|
||||
echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc
|
||||
echo "cache-dir=/buildcache/pnpm-cache" >> /usr/local/etc/npmrc && \
|
||||
echo "# Retry configuration - default is 2" >> /usr/local/etc/npmrc && \
|
||||
echo "fetch-retries=5" >> /usr/local/etc/npmrc && \
|
||||
mkdir -p /buildcache/pnpm-store /buildcache/pnpm-cache /buildcache/node-gyp && \
|
||||
chmod -R o+rw /buildcache
|
||||
|
||||
COPY ./package* ./pnpm* .pnpmfile.cjs /tmp/create-dep-cache/
|
||||
COPY ./web/package* ./web/pnpm* /tmp/create-dep-cache/web/
|
||||
COPY ./server/package* ./server/pnpm* /tmp/create-dep-cache/server/
|
||||
COPY ./open-api/typescript-sdk/package* ./open-api/typescript-sdk/pnpm* /tmp/create-dep-cache/open-api/typescript-sdk/
|
||||
COPY --from=walkrs ./package*.json /tmp/walkrs/
|
||||
COPY --from=walkrs ./Cargo.toml /tmp/walkrs/
|
||||
COPY --from=walkrs ./src /tmp/walkrs/src/
|
||||
WORKDIR /tmp/create-dep-cache
|
||||
RUN pnpm fetch && rm -rf /tmp/create-dep-cache && chmod -R o+rw /buildcache
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
ENV PATH="${PATH}:/usr/src/app/server/bin:/usr/src/app/web/bin" \
|
||||
|
||||
+3
-4
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@extism/extism": "2.0.0-rc13",
|
||||
"@immich/walkrs": "0.0.0",
|
||||
"@immich/walkrs": "^0.0.12",
|
||||
"@nestjs/bullmq": "^11.0.1",
|
||||
"@nestjs/common": "^11.0.4",
|
||||
"@nestjs/core": "^11.0.4",
|
||||
@@ -73,7 +73,6 @@
|
||||
"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",
|
||||
@@ -136,7 +135,7 @@
|
||||
"@types/luxon": "^3.6.2",
|
||||
"@types/mock-fs": "^4.13.1",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/node": "^24.10.11",
|
||||
"@types/node": "^24.10.13",
|
||||
"@types/nodemailer": "^7.0.0",
|
||||
"@types/picomatch": "^4.0.0",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
@@ -168,7 +167,7 @@
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.13.0"
|
||||
"node": "24.13.1"
|
||||
},
|
||||
"overrides": {
|
||||
"sharp": "^0.34.5"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable, UpdateResult } from 'kysely';
|
||||
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';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { walk } from '@immich/walkrs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import archiver from 'archiver';
|
||||
import chokidar, { ChokidarOptions } from 'chokidar';
|
||||
@@ -198,19 +197,19 @@ export class StorageRepository {
|
||||
};
|
||||
}
|
||||
|
||||
async walk(walkOptions: WalkOptionsDto): Promise<string[]> {
|
||||
async *walk(walkOptions: WalkOptionsDto): AsyncGenerator<string[], void, unknown> {
|
||||
const { pathsToWalk, exclusionPatterns, includeHidden } = walkOptions;
|
||||
if (pathsToWalk.length === 0) {
|
||||
return [];
|
||||
return;
|
||||
}
|
||||
|
||||
const extensions = mimeTypes.getSupportedFileExtensions().map((ext) => ext.toLowerCase());
|
||||
const { walk } = await import('@immich/walkrs');
|
||||
|
||||
return await walk({
|
||||
yield* walk({
|
||||
paths: pathsToWalk.map((p) => path.resolve(p)),
|
||||
includeHidden: includeHidden ?? false,
|
||||
exclusionPatterns,
|
||||
extensions,
|
||||
extensions: mimeTypes.getSupportedFileExtensions(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -160,7 +160,11 @@ describe(LibraryService.name, () => {
|
||||
const library = factory.library({ importPaths: ['/foo', '/bar'] });
|
||||
|
||||
mocks.library.get.mockResolvedValue(library);
|
||||
mocks.storage.walk.mockResolvedValue(['/data/user1/photo.jpg']);
|
||||
mocks.storage.walk.mockReturnValue(
|
||||
(async function* () {
|
||||
yield await Promise.resolve(['/data/user1/photo.jpg']);
|
||||
})(),
|
||||
);
|
||||
mocks.storage.stat.mockResolvedValue({ isDirectory: () => true } as Stats);
|
||||
mocks.storage.checkFileExists.mockResolvedValue(true);
|
||||
mocks.asset.filterNewExternalAssetPaths.mockResolvedValue(['/data/user1/photo.jpg']);
|
||||
@@ -196,7 +200,11 @@ describe(LibraryService.name, () => {
|
||||
});
|
||||
|
||||
mocks.storage.checkFileExists.mockResolvedValue(true);
|
||||
mocks.storage.walk.mockResolvedValue(['/data/user1/photo.jpg']);
|
||||
mocks.storage.walk.mockReturnValue(
|
||||
(async function* () {
|
||||
yield await Promise.resolve(['/data/user1/photo.jpg']);
|
||||
})(),
|
||||
);
|
||||
mocks.library.get.mockResolvedValue(library);
|
||||
mocks.asset.filterNewExternalAssetPaths.mockResolvedValue(['/data/user1/photo.jpg']);
|
||||
|
||||
@@ -215,7 +223,11 @@ describe(LibraryService.name, () => {
|
||||
const library = factory.library({ importPaths: ['/foo', '/bar'] });
|
||||
|
||||
mocks.library.get.mockResolvedValue(library);
|
||||
mocks.storage.walk.mockResolvedValue(['/data/user1/photo.jpg']);
|
||||
mocks.storage.walk.mockReturnValue(
|
||||
(async function* () {
|
||||
yield await Promise.resolve(['/data/user1/photo.jpg']);
|
||||
})(),
|
||||
);
|
||||
mocks.storage.stat.mockResolvedValue({ isDirectory: () => true } as Stats);
|
||||
mocks.storage.checkFileExists.mockResolvedValue(true);
|
||||
mocks.asset.filterNewExternalAssetPaths.mockResolvedValue(['/data/user1/photo.jpg']);
|
||||
@@ -244,7 +256,11 @@ describe(LibraryService.name, () => {
|
||||
const library = factory.library();
|
||||
|
||||
mocks.library.get.mockResolvedValue(library);
|
||||
mocks.storage.walk.mockResolvedValue([]);
|
||||
mocks.storage.walk.mockReturnValue(
|
||||
(async function* () {
|
||||
yield await Promise.resolve([]);
|
||||
})(),
|
||||
);
|
||||
mocks.asset.getLibraryAssetCount.mockResolvedValue(1);
|
||||
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: 1n });
|
||||
|
||||
@@ -262,7 +278,11 @@ describe(LibraryService.name, () => {
|
||||
const library = factory.library();
|
||||
|
||||
mocks.library.get.mockResolvedValue(library);
|
||||
mocks.storage.walk.mockResolvedValue([]);
|
||||
mocks.storage.walk.mockReturnValue(
|
||||
(async function* () {
|
||||
yield await Promise.resolve([]);
|
||||
})(),
|
||||
);
|
||||
mocks.asset.getLibraryAssetCount.mockResolvedValue(0);
|
||||
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: 1n });
|
||||
|
||||
@@ -277,7 +297,11 @@ describe(LibraryService.name, () => {
|
||||
const asset = AssetFactory.create({ libraryId: library.id, isExternal: true });
|
||||
|
||||
mocks.library.get.mockResolvedValue(library);
|
||||
mocks.storage.walk.mockResolvedValue([]);
|
||||
mocks.storage.walk.mockReturnValue(
|
||||
(async function* () {
|
||||
yield await Promise.resolve([]);
|
||||
})(),
|
||||
);
|
||||
mocks.library.streamAssetIds.mockReturnValue(makeStream([asset]));
|
||||
mocks.asset.getLibraryAssetCount.mockResolvedValue(1);
|
||||
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: 0n });
|
||||
|
||||
@@ -395,15 +395,7 @@ export class LibraryService extends BaseService {
|
||||
private async processEntity(filePath: string, ownerId: string, libraryId: string) {
|
||||
const assetPath = path.normalize(filePath);
|
||||
|
||||
let stat: Stats;
|
||||
try {
|
||||
stat = await this.storageRepository.stat(assetPath);
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
this.logger.error(`File not found during import: ${assetPath} (original path: ${filePath})`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const stat = await this.storageRepository.stat(assetPath);
|
||||
|
||||
return {
|
||||
ownerId,
|
||||
@@ -647,44 +639,41 @@ export class LibraryService extends BaseService {
|
||||
|
||||
this.logger.log(`Starting disk crawl of ${validImportPaths.length} import path(s) for library ${library.id}...`);
|
||||
|
||||
const crawlStart = Date.now();
|
||||
|
||||
const pathsOnDisk = await this.storageRepository.walk({
|
||||
const fileGenerator = this.storageRepository.walk({
|
||||
pathsToWalk: validImportPaths,
|
||||
includeHidden: false,
|
||||
includeHidden: false, // TODO: make this configurable?
|
||||
exclusionPatterns: library.exclusionPatterns,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Found ${pathsOnDisk.length} file(s) on disk in ${((Date.now() - crawlStart) / 1000).toFixed(2)}s, queuing for import...`,
|
||||
);
|
||||
const walkStart = Date.now();
|
||||
let progressCounter = 0;
|
||||
let lastLoggedMilestone = 0;
|
||||
|
||||
let importCount = 0;
|
||||
for await (const paths of fileGenerator) {
|
||||
progressCounter += paths.length;
|
||||
|
||||
for (let i = 0; i < pathsOnDisk.length; i += JOBS_LIBRARY_PAGINATION_SIZE) {
|
||||
const pathChunk = pathsOnDisk.slice(i, i + JOBS_LIBRARY_PAGINATION_SIZE);
|
||||
const paths = await this.assetRepository.filterNewExternalAssetPaths(library.id, pathChunk);
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.LibrarySyncFiles,
|
||||
data: {
|
||||
libraryId: library.id,
|
||||
paths,
|
||||
progressCounter,
|
||||
},
|
||||
});
|
||||
|
||||
if (paths.length > 0) {
|
||||
importCount += paths.length;
|
||||
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.LibrarySyncFiles,
|
||||
data: {
|
||||
libraryId: library.id,
|
||||
paths,
|
||||
progressCounter: i + pathChunk.length,
|
||||
},
|
||||
});
|
||||
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(
|
||||
`Processed ${i + pathChunk.length} file(s): ${paths.length} of current batch of ${pathChunk.length} will be imported to library ${library.id}...`,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Finished disk crawl, ${pathsOnDisk.length} file(s) found on disk and queued ${importCount} file(s) for import into ${library.id}`,
|
||||
`Finished disk walk, ${progressCounter} file(s) found on disk in ${((Date.now() - walkStart) / 1000).toFixed(2)}s for library ${library.id}`,
|
||||
);
|
||||
|
||||
await this.libraryRepository.update(job.id, { refreshedAt: new Date() });
|
||||
|
||||
@@ -295,7 +295,7 @@ describe(MetadataService.name, () => {
|
||||
id: asset.id,
|
||||
duration: null,
|
||||
fileCreatedAt: asset.fileCreatedAt,
|
||||
fileModifiedAt: asset.fileCreatedAt,
|
||||
fileModifiedAt: asset.fileModifiedAt,
|
||||
localDateTime: asset.fileCreatedAt,
|
||||
width: null,
|
||||
height: null,
|
||||
@@ -919,7 +919,7 @@ describe(MetadataService.name, () => {
|
||||
Orientation: 0,
|
||||
ProfileDescription: 'extensive description',
|
||||
ProjectionType: 'equirectangular',
|
||||
tz: 'UTC-11:30',
|
||||
zone: 'UTC-11:30',
|
||||
TagsList: ['parent/child'],
|
||||
Rating: 3,
|
||||
};
|
||||
@@ -955,7 +955,7 @@ describe(MetadataService.name, () => {
|
||||
orientation: tags.Orientation?.toString(),
|
||||
profileDescription: tags.ProfileDescription,
|
||||
projectionType: 'EQUIRECTANGULAR',
|
||||
timeZone: tags.tz,
|
||||
timeZone: tags.zone,
|
||||
rating: tags.Rating,
|
||||
country: null,
|
||||
state: null,
|
||||
@@ -987,7 +987,7 @@ describe(MetadataService.name, () => {
|
||||
|
||||
const tags: ImmichTags = {
|
||||
DateTimeOriginal: ExifDateTime.fromISO(someDate + '+00:00'),
|
||||
tz: undefined,
|
||||
zone: undefined,
|
||||
};
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mockReadTags(tags);
|
||||
|
||||
@@ -36,6 +36,10 @@ import { mergeTimeZone } from 'src/utils/date';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { isFaceImportEnabled } from 'src/utils/misc';
|
||||
import { upsertTags } from 'src/utils/tag';
|
||||
import { Tasks } from 'src/utils/tasks';
|
||||
|
||||
const POSTGRES_INT_MAX = 2_147_483_647;
|
||||
const POSTGRES_INT_MIN = -2_147_483_648;
|
||||
|
||||
/** look for a date from these tags (in order) */
|
||||
const EXIF_DATE_TAGS: Array<keyof ImmichTags> = [
|
||||
@@ -89,7 +93,10 @@ const validate = <T>(value: T): NonNullable<T> | null => {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof value === 'number' && (Number.isNaN(value) || !Number.isFinite(value))) {
|
||||
if (
|
||||
typeof value === 'number' &&
|
||||
(Number.isNaN(value) || !Number.isFinite(value) || value < POSTGRES_INT_MIN || value > POSTGRES_INT_MAX)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -307,33 +314,38 @@ export class MetadataService extends BaseService {
|
||||
const assetWidth = isSidewards ? validate(height) : validate(width);
|
||||
const assetHeight = isSidewards ? validate(width) : validate(height);
|
||||
|
||||
const promises: Promise<unknown>[] = [
|
||||
this.assetRepository.update({
|
||||
id: asset.id,
|
||||
duration: this.getDuration(exifTags),
|
||||
localDateTime: dates.localDateTime,
|
||||
fileCreatedAt: dates.dateTimeOriginal ?? undefined,
|
||||
fileModifiedAt: stats.mtime,
|
||||
const tasks = new Tasks();
|
||||
|
||||
// only update the dimensions if they don't already exist
|
||||
// we don't want to overwrite width/height that are modified by edits
|
||||
width: asset.width == null ? assetWidth : undefined,
|
||||
height: asset.height == null ? assetHeight : undefined,
|
||||
}),
|
||||
];
|
||||
tasks.push(
|
||||
() =>
|
||||
this.assetRepository.update({
|
||||
id: asset.id,
|
||||
duration: this.getDuration(exifTags),
|
||||
localDateTime: dates.localDateTime,
|
||||
fileCreatedAt: dates.dateTimeOriginal ?? undefined,
|
||||
fileModifiedAt: stats.mtime,
|
||||
|
||||
await this.assetRepository.upsertExif(exifData, { lockedPropertiesBehavior: 'skip' });
|
||||
await this.applyTagList(asset);
|
||||
// only update the dimensions if they don't already exist
|
||||
// we don't want to overwrite width/height that are modified by edits
|
||||
width: asset.width == null ? assetWidth : undefined,
|
||||
height: asset.height == null ? assetHeight : undefined,
|
||||
}),
|
||||
async () => {
|
||||
await this.assetRepository.upsertExif(exifData, { lockedPropertiesBehavior: 'skip' });
|
||||
await this.applyTagList(asset);
|
||||
},
|
||||
);
|
||||
|
||||
if (this.isMotionPhoto(asset, exifTags)) {
|
||||
promises.push(this.applyMotionPhotos(asset, exifTags, dates, stats));
|
||||
tasks.push(() => this.applyMotionPhotos(asset, exifTags, dates, stats));
|
||||
}
|
||||
|
||||
if (isFaceImportEnabled(metadata) && this.hasTaggedFaces(exifTags)) {
|
||||
promises.push(this.applyTaggedFaces(asset, exifTags));
|
||||
tasks.push(() => this.applyTaggedFaces(asset, exifTags));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
await tasks.all();
|
||||
|
||||
if (exifData.livePhotoCID) {
|
||||
await this.linkLivePhotos(asset, exifData);
|
||||
}
|
||||
@@ -527,6 +539,15 @@ export class MetadataService extends BaseService {
|
||||
for (const tag of EXIF_DATE_TAGS) {
|
||||
delete mediaTags[tag];
|
||||
}
|
||||
|
||||
// exiftool-vendored derives tz information from the date.
|
||||
// if the sidecar file has date information, we also assume the tz information come from there.
|
||||
//
|
||||
// this is especially important in the case of UTC+0 where exiftool-vendored does not return tz/zone fields
|
||||
// and as such the tags aren't overwritten when returning all tags.
|
||||
for (const tag of ['zone', 'tz', 'tzSource'] as const) {
|
||||
delete mediaTags[tag];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -897,8 +918,8 @@ export class MetadataService extends BaseService {
|
||||
}
|
||||
|
||||
// timezone
|
||||
let timeZone = exifTags.tz ?? null;
|
||||
if (timeZone == null && dateTime?.rawValue?.endsWith('+00:00')) {
|
||||
let timeZone = exifTags.zone ?? null;
|
||||
if (timeZone == null && (dateTime?.rawValue?.endsWith('Z') || dateTime?.rawValue?.endsWith('+00:00'))) {
|
||||
// exiftool-vendored returns "no timezone" information even though "+00:00" might be set explicitly
|
||||
// https://github.com/photostructure/exiftool-vendored.js/issues/203
|
||||
timeZone = 'UTC+0';
|
||||
@@ -906,7 +927,7 @@ export class MetadataService extends BaseService {
|
||||
|
||||
if (timeZone) {
|
||||
this.logger.verbose(
|
||||
`Found timezone ${timeZone} via ${exifTags.tzSource} for asset ${asset.id}: ${asset.originalPath}`,
|
||||
`Found timezone ${timeZone} via ${exifTags.zoneSource} for asset ${asset.id}: ${asset.originalPath}`,
|
||||
);
|
||||
} else {
|
||||
this.logger.debug(`No timezone information found for asset ${asset.id}: ${asset.originalPath}`);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export type Task = () => Promise<unknown> | unknown;
|
||||
|
||||
export class Tasks {
|
||||
private tasks: Task[] = [];
|
||||
|
||||
push(...tasks: Task[]) {
|
||||
this.tasks.push(...tasks);
|
||||
}
|
||||
|
||||
async all() {
|
||||
await Promise.all(this.tasks.map((item) => item()));
|
||||
}
|
||||
}
|
||||
@@ -30,14 +30,14 @@ const createTestFiles = async (basePath: string, files: string[]) => {
|
||||
|
||||
const tests: Test[] = [
|
||||
{
|
||||
test: 'should return empty when crawling an empty path list',
|
||||
test: 'should return empty when walking an empty path list',
|
||||
options: {
|
||||
pathsToWalk: [],
|
||||
},
|
||||
files: {},
|
||||
},
|
||||
{
|
||||
test: 'should crawl a single path',
|
||||
test: 'should walk a single path',
|
||||
options: {
|
||||
pathsToWalk: ['/photos/'],
|
||||
},
|
||||
@@ -80,11 +80,11 @@ const tests: Test[] = [
|
||||
'/photos/raw/image.jpg': false,
|
||||
'/photos/raw2/image.jpg': true,
|
||||
'/photos/folder/raw/image.jpg': false,
|
||||
'/photos/crawl/image.jpg': true,
|
||||
'/photos/walk/image.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should crawl multiple paths',
|
||||
test: 'should walk multiple paths',
|
||||
options: {
|
||||
pathsToWalk: ['/photos/', '/images/', '/albums/'],
|
||||
},
|
||||
@@ -95,7 +95,7 @@ const tests: Test[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should crawl a single path without trailing slash',
|
||||
test: 'should walk a single path without trailing slash',
|
||||
options: {
|
||||
pathsToWalk: ['/photos'],
|
||||
},
|
||||
@@ -104,7 +104,7 @@ const tests: Test[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should crawl a single path',
|
||||
test: 'should walk a single path',
|
||||
options: {
|
||||
pathsToWalk: ['/photos/'],
|
||||
},
|
||||
@@ -206,7 +206,7 @@ describe(StorageRepository.name, () => {
|
||||
({ sut } = setup());
|
||||
});
|
||||
|
||||
describe('crawl', () => {
|
||||
describe('walk', () => {
|
||||
for (const { test, options, files } of tests) {
|
||||
describe(test, () => {
|
||||
const fileList = Object.keys(files);
|
||||
@@ -227,7 +227,10 @@ describe(StorageRepository.name, () => {
|
||||
pathsToWalk: options.pathsToWalk.map((p) => path.join(tempDir, p.replace(/^\//, ''))),
|
||||
};
|
||||
|
||||
const actual = await sut.walk(adjustedOptions);
|
||||
const actual: string[] = [];
|
||||
for await (const batch of sut.walk(adjustedOptions)) {
|
||||
actual.push(...batch);
|
||||
}
|
||||
const expected = Object.entries(files)
|
||||
.filter((entry) => entry[1])
|
||||
.map(([file]) => path.join(tempDir, file.replace(/^\//, '')));
|
||||
|
||||
@@ -398,6 +398,23 @@ describe(AssetService.name, () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update dateTimeOriginal with time zone UTC+0', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(JobRepository).queue.mockResolvedValue();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, description: 'test', timeZone: 'UTC-7' });
|
||||
|
||||
await sut.update(auth, asset.id, { dateTimeOriginal: '2023-11-19T18:11:00.000Z' });
|
||||
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-19T18:11:00+00:00', timeZone: 'UTC' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAll', () => {
|
||||
@@ -456,7 +473,7 @@ describe(AssetService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should relatively update an assets with timezone', async () => {
|
||||
it('should relatively update assets with timezone', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
|
||||
const { user } = await ctx.newUser();
|
||||
@@ -477,7 +494,7 @@ describe(AssetService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should relatively update an assets and set a timezone', async () => {
|
||||
it('should relatively update assets and set a timezone', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
|
||||
const { user } = await ctx.newUser();
|
||||
@@ -497,6 +514,26 @@ describe(AssetService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should set asset time zones to UTC', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, dateTimeOriginal: '2023-11-19T18:11:00', timeZone: 'UTC-7' });
|
||||
|
||||
await sut.updateAll(auth, { ids: [asset.id], timeZone: 'UTC' });
|
||||
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
exifInfo: expect.objectContaining({
|
||||
dateTimeOriginal: '2023-11-19T18:11:00+00:00',
|
||||
timeZone: 'UTC',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update dateTimeOriginal', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
|
||||
@@ -530,6 +567,23 @@ describe(AssetService.name, () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update dateTimeOriginal with UTC time zone', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
ctx.getMock(JobRepository).queueAll.mockResolvedValue();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
await ctx.newExif({ assetId: asset.id, description: 'test', timeZone: 'UTC-7' });
|
||||
|
||||
await sut.updateAll(auth, { ids: [asset.id], dateTimeOriginal: '2023-11-19T18:11:00.000Z' });
|
||||
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id, { exifInfo: true })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
exifInfo: expect.objectContaining({ dateTimeOriginal: '2023-11-19T18:11:00+00:00', timeZone: 'UTC' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertBulkMetadata', () => {
|
||||
|
||||
Reference in New Issue
Block a user