Compare commits

..

9 Commits

Author SHA1 Message Date
github-actions a2ff075e9a chore: version v2.6.3 2026-03-26 16:23:35 +00:00
Brandon Wees d8b39906f9 fix: incorrect asset face sync (#27243)
* fix: incorrect asset face sync

* chore: sync sql
2026-03-26 09:39:02 -05:00
Michel Heusschen b36911a16b fix(server): filter out empty search suggestions (#27292)
* fix(server): filter out empty search suggestions

* make sql
2026-03-26 09:36:04 -05:00
Alex b074ee202e chore: move slideshow control button group to the left (#27287) 2026-03-26 14:31:11 +00:00
bo0tzz 78bb6cf926 chore: log id of existing asset on duplicate upload (#27266) 2026-03-26 11:50:53 +01:00
Yaros c980f5fc19 chore(docs): withPeople parameter description (#27262)
* fix(server): withPeople inconsistent

* fix: query failing in some occasions

* test: add medium tests for withPeople option

* Revert "test: add medium tests for withPeople option"

This reverts commit 6c1505ba6b.

* Revert "fix: query failing in some occasions"

This reverts commit 221feeca45.

* Revert "fix(server): withPeople inconsistent"

This reverts commit 4289a9f23d.

* chore: change endpoint description

* chore: generate open-api
2026-03-26 11:50:29 +01:00
Yaros a26d9e05ba fix(web): shifting motion image button (#27275) 2026-03-26 11:49:21 +01:00
bo0tzz c862163204 fix: explicitly specify repo in auto-close job (#27291) 2026-03-26 10:43:51 +00:00
Michel Heusschen 5fb8f9bf1a fix(web): prevent horizontal scroll bar in asset viewer side panel (#27270)
* fix(web): prevent horizontal scroll bar in asset viewer side panel

* simplify
2026-03-25 21:02:31 -05:00
35 changed files with 95 additions and 429 deletions
+3 -3
View File
@@ -66,7 +66,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: gh pr edit "$PR_NUMBER" --add-label "auto-closed:template"
run: gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --add-label "auto-closed:template"
close_llm:
runs-on: ubuntu-latest
@@ -113,7 +113,7 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: gh pr edit "$PR_NUMBER" --remove-label "auto-closed:template" || true
run: gh pr edit "$PR_NUMBER" --repo "${{ github.repository }}" --remove-label "auto-closed:template" || true
- name: Check for remaining auto-closed labels
id: check_labels
@@ -121,7 +121,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
REMAINING=$(gh pr view "$PR_NUMBER" --json labels \
REMAINING=$(gh pr view "$PR_NUMBER" --repo "${{ github.repository }}" --json labels \
--jq '[.labels[].name | select(startswith("auto-closed:"))] | length')
echo "remaining=$REMAINING" >> "$GITHUB_OUTPUT"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.6.2",
"version": "2.6.3",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",
+2 -2
View File
@@ -1,7 +1,7 @@
[
{
"label": "v2.6.2",
"url": "https://docs.v2.6.2.archive.immich.app"
"label": "v2.6.3",
"url": "https://docs.v2.6.3.archive.immich.app"
},
{
"label": "v2.5.6",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "2.6.2",
"version": "2.6.3",
"description": "",
"main": "index.js",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-i18n",
"version": "2.6.2",
"version": "2.6.3",
"private": true,
"scripts": {
"format": "prettier --cache --check .",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "immich-ml"
version = "2.6.2"
version = "2.6.3"
description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.11,<4.0"
+1 -1
View File
@@ -898,7 +898,7 @@ wheels = [
[[package]]
name = "immich-ml"
version = "2.6.2"
version = "2.6.3"
source = { editable = "." }
dependencies = [
{ name = "aiocache" },
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3040,
"android.injected.version.name" => "2.6.2",
"android.injected.version.code" => 3041,
"android.injected.version.name" => "2.6.3",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
+1 -1
View File
@@ -80,7 +80,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.6.2</string>
<string>2.6.3</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
+1 -1
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 2.6.2
- API version: 2.6.3
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
+1 -1
View File
@@ -379,7 +379,7 @@ class MetadataSearchDto {
///
bool? withExif;
/// Include assets with people
/// Include people data in response
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
+1 -1
View File
@@ -273,7 +273,7 @@ class RandomSearchDto {
///
bool? withExif;
/// Include assets with people
/// Include people data in response
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 2.6.2+3040
version: 2.6.3+3041
environment:
sdk: '>=3.8.0 <4.0.0'
+3 -3
View File
@@ -15166,7 +15166,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "2.6.2",
"version": "2.6.3",
"contact": {}
},
"tags": [
@@ -19129,7 +19129,7 @@
"type": "boolean"
},
"withPeople": {
"description": "Include assets with people",
"description": "Include people data in response",
"type": "boolean"
},
"withStacked": {
@@ -20868,7 +20868,7 @@
"type": "boolean"
},
"withPeople": {
"description": "Include assets with people",
"description": "Include people data in response",
"type": "boolean"
},
"withStacked": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "2.6.2",
"version": "2.6.3",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",
+3 -3
View File
@@ -1,6 +1,6 @@
/**
* Immich
* 2.6.2
* 2.6.3
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
@@ -1741,7 +1741,7 @@ export type MetadataSearchDto = {
withDeleted?: boolean;
/** Include EXIF data in response */
withExif?: boolean;
/** Include assets with people */
/** Include people data in response */
withPeople?: boolean;
/** Include stacked assets */
withStacked?: boolean;
@@ -1855,7 +1855,7 @@ export type RandomSearchDto = {
withDeleted?: boolean;
/** Include EXIF data in response */
withExif?: boolean;
/** Include assets with people */
/** Include people data in response */
withPeople?: boolean;
/** Include stacked assets */
withStacked?: boolean;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-monorepo",
"version": "2.6.2",
"version": "2.6.3",
"description": "Monorepo for Immich",
"private": true,
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
+3 -49
View File
@@ -431,8 +431,8 @@ importers:
specifier: ^5.51.0
version: 5.70.4
chokidar:
specifier: ^5.0.0
version: 5.0.0
specifier: ^4.0.3
version: 4.0.3
class-transformer:
specifier: ^0.5.1
version: 0.5.1
@@ -592,7 +592,7 @@ importers:
version: 11.0.16(@swc/core@1.15.18(@swc/helpers@0.5.17))(@types/node@24.12.0)(esbuild@0.27.3)
'@nestjs/schematics':
specifier: ^11.0.0
version: 11.0.9(chokidar@5.0.0)(typescript@5.9.3)
version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3)
'@nestjs/testing':
specifier: ^11.0.4
version: 11.1.16(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(@nestjs/platform-express@11.1.16)
@@ -6050,10 +6050,6 @@ packages:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
chokidar@5.0.0:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
@@ -10544,10 +10540,6 @@ packages:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
readdirp@5.0.0:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
recma-build-jsx@1.0.0:
resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==}
@@ -12610,17 +12602,6 @@ snapshots:
optionalDependencies:
chokidar: 4.0.3
'@angular-devkit/core@19.2.17(chokidar@5.0.0)':
dependencies:
ajv: 8.17.1
ajv-formats: 3.0.1(ajv@8.17.1)
jsonc-parser: 3.3.1
picomatch: 4.0.2
rxjs: 7.8.1
source-map: 0.7.4
optionalDependencies:
chokidar: 5.0.0
'@angular-devkit/core@19.2.19(chokidar@4.0.3)':
dependencies:
ajv: 8.17.1
@@ -12654,16 +12635,6 @@ snapshots:
transitivePeerDependencies:
- chokidar
'@angular-devkit/schematics@19.2.17(chokidar@5.0.0)':
dependencies:
'@angular-devkit/core': 19.2.17(chokidar@5.0.0)
jsonc-parser: 3.3.1
magic-string: 0.30.17
ora: 5.4.1
rxjs: 7.8.1
transitivePeerDependencies:
- chokidar
'@angular-devkit/schematics@19.2.19(chokidar@4.0.3)':
dependencies:
'@angular-devkit/core': 19.2.19(chokidar@4.0.3)
@@ -15749,17 +15720,6 @@ snapshots:
transitivePeerDependencies:
- chokidar
'@nestjs/schematics@11.0.9(chokidar@5.0.0)(typescript@5.9.3)':
dependencies:
'@angular-devkit/core': 19.2.17(chokidar@5.0.0)
'@angular-devkit/schematics': 19.2.17(chokidar@5.0.0)
comment-json: 4.4.1
jsonc-parser: 3.3.1
pluralize: 8.0.0
typescript: 5.9.3
transitivePeerDependencies:
- chokidar
'@nestjs/swagger@11.2.6(@nestjs/common@11.1.16(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.16)(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)':
dependencies:
'@microsoft/tsdoc': 0.16.0
@@ -18481,10 +18441,6 @@ snapshots:
dependencies:
readdirp: 4.1.2
chokidar@5.0.0:
dependencies:
readdirp: 5.0.0
chownr@1.1.4: {}
chownr@2.0.0: {}
@@ -23716,8 +23672,6 @@ snapshots:
readdirp@4.1.2: {}
readdirp@5.0.0: {}
recma-build-jsx@1.0.0:
dependencies:
'@types/estree': 1.0.8
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "2.6.2",
"version": "2.6.3",
"description": "",
"author": "",
"private": true,
@@ -64,7 +64,7 @@
"bcrypt": "^6.0.0",
"body-parser": "^2.2.0",
"bullmq": "^5.51.0",
"chokidar": "^5.0.0",
"chokidar": "^4.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.0",
"compression": "^1.8.0",
+1 -1
View File
@@ -146,7 +146,7 @@ export class RandomSearchDto extends BaseSearchWithResultsDto {
@ValidateBoolean({ optional: true, description: 'Include stacked assets' })
withStacked?: boolean;
@ValidateBoolean({ optional: true, description: 'Include assets with people' })
@ValidateBoolean({ optional: true, description: 'Include people data in response' })
withPeople?: boolean;
}
+5
View File
@@ -254,6 +254,7 @@ where
and "visibility" = $2
and "deletedAt" is null
and "state" is not null
and "state" != $3
-- SearchRepository.getCities
select distinct
@@ -266,6 +267,7 @@ where
and "visibility" = $2
and "deletedAt" is null
and "city" is not null
and "city" != $3
-- SearchRepository.getCameraMakes
select distinct
@@ -278,6 +280,7 @@ where
and "visibility" = $2
and "deletedAt" is null
and "make" is not null
and "make" != $3
-- SearchRepository.getCameraModels
select distinct
@@ -290,6 +293,7 @@ where
and "visibility" = $2
and "deletedAt" is null
and "model" is not null
and "model" != $3
-- SearchRepository.getCameraLensModels
select distinct
@@ -302,3 +306,4 @@ where
and "visibility" = $2
and "deletedAt" is null
and "lensModel" is not null
and "lensModel" != $3
-1
View File
@@ -582,7 +582,6 @@ where
"asset_face"."updateId" < $1
and "asset_face"."updateId" > $2
and "asset"."ownerId" = $3
and "asset_face"."isVisible" = $4
order by
"asset_face"."updateId" asc
+3 -5
View File
@@ -502,10 +502,7 @@ export class SearchRepository {
return res.map((row) => row.lensModel!);
}
private getExifField<K extends 'city' | 'state' | 'country' | 'make' | 'model' | 'lensModel'>(
field: K,
userIds: string[],
) {
private getExifField(field: 'city' | 'state' | 'country' | 'make' | 'model' | 'lensModel', userIds: string[]) {
return this.db
.selectFrom('asset_exif')
.select(field)
@@ -514,6 +511,7 @@ export class SearchRepository {
.where('ownerId', '=', anyUuid(userIds))
.where('visibility', '=', AssetVisibility.Timeline)
.where('deletedAt', 'is', null)
.where(field, 'is not', null);
.where(field, 'is not', null)
.where(field, '!=', '');
}
}
@@ -487,7 +487,6 @@ class AssetFaceSync extends BaseSync {
])
.leftJoin('asset', 'asset.id', 'asset_face.assetId')
.where('asset.ownerId', '=', options.userId)
.where('asset_face.isVisible', '=', true)
.stream();
}
}
@@ -0,0 +1,10 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
// Sync query for faces was incorrect on server <=2.6.2
await sql`DELETE FROM session_sync_checkpoint WHERE type in ('AssetFaceV1', 'AssetFaceV2')`.execute(db);
}
export async function down(): Promise<void> {
// Not implemented
}
@@ -356,6 +356,7 @@ export class AssetMediaService extends BaseService {
await this.addToSharedLink(auth.sharedLink, duplicateId);
}
this.logger.debug(`Duplicate asset upload rejected: existing asset ${duplicateId}`);
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
}
+11 -27
View File
@@ -26,21 +26,6 @@ import { JobOf } from 'src/types';
import { mimeTypes } from 'src/utils/mime-types';
import { handlePromiseError } from 'src/utils/misc';
const createMatchers = (exclusionPatterns: string[]) => {
const supportedExtensions = mimeTypes.getSupportedFileExtensions().map((extension) => extension.toLowerCase());
const expandedPatterns = exclusionPatterns.flatMap((pattern) =>
pattern.endsWith('/**') ? [pattern, pattern.slice(0, -3)] : [pattern],
);
const excludeMatcher = picomatch(expandedPatterns, { nocase: true });
return {
isExcluded: (path: string) => excludeMatcher(path.replaceAll('\\', '/')),
isSupported: (path: string) => {
const normalizedPath = path.toLowerCase();
return supportedExtensions.some((extension) => normalizedPath.endsWith(extension));
},
};
};
@Injectable()
export class LibraryService extends BaseService {
private watchLibraries = false;
@@ -105,24 +90,24 @@ export class LibraryService extends BaseService {
this.logger.log(`Starting to watch library ${library.id} with import path(s) ${library.importPaths}`);
const { isExcluded, isSupported } = createMatchers(library.exclusionPatterns);
const matcher = picomatch(`**/*{${mimeTypes.getSupportedFileExtensions().join(',')}}`, {
nocase: true,
ignore: library.exclusionPatterns,
});
let _resolve: () => void;
const ready$ = new Promise<void>((resolve) => (_resolve = resolve));
const handler = async (event: string, path: string) => {
const ignored = !isSupported(path);
if (ignored) {
if (matcher(path)) {
this.logger.debug(`File ${event} event received for ${path} in library ${library.id}}`);
await this.jobRepository.queue({
name: JobName.LibrarySyncFiles,
data: { libraryId: library.id, paths: [path] },
});
} else {
this.logger.verbose(`Ignoring file ${event} event for ${path} in library ${library.id}`);
return;
}
this.logger.debug(`File ${event} event received for ${path} in library ${library.id}}`);
await this.jobRepository.queue({
name: JobName.LibrarySyncFiles,
data: { libraryId: library.id, paths: [path] },
});
};
const deletionHandler = async (path: string) => {
@@ -138,7 +123,6 @@ export class LibraryService extends BaseService {
{
usePolling: false,
ignoreInitial: true,
ignored: isExcluded,
awaitWriteFinish: {
stabilityThreshold: 5000,
pollInterval: 1000,
-3
View File
@@ -30,7 +30,6 @@ import { DatabaseRepository } from 'src/repositories/database.repository';
import { EmailRepository } from 'src/repositories/email.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LibraryRepository } from 'src/repositories/library.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { MachineLearningRepository } from 'src/repositories/machine-learning.repository';
import { MapRepository } from 'src/repositories/map.repository';
@@ -406,7 +405,6 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
case AssetRepository:
case AssetEditRepository:
case AssetJobRepository:
case LibraryRepository:
case MemoryRepository:
case NotificationRepository:
case OcrRepository:
@@ -468,7 +466,6 @@ const newMockRepository = <T>(key: ClassConstructor<T>) => {
case AlbumRepository:
case AssetRepository:
case AssetJobRepository:
case LibraryRepository:
case ConfigRepository:
case CryptoRepository:
case MemoryRepository:
@@ -1,135 +0,0 @@
import { Kysely } from 'kysely';
import { mkdtemp, rm, unlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
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>;
const setup = (db?: Kysely<DB>) => {
const { ctx } = newMediumService(BaseService, {
database: db || defaultDatabase,
real: [],
mock: [LoggingRepository],
});
return { ctx, sut: ctx.get(StorageRepository) };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
const watchForEvent = (
sut: StorageRepository,
folder: string,
event: 'add' | 'change' | 'unlink',
action: () => Promise<void>,
): Promise<string> => {
return new Promise<string>((resolve, reject) => {
const timeout = setTimeout(() => {
void close().finally(() => reject(new Error(`Timed out waiting for ${event} event`)));
}, 10_000);
const onResolve = (path: string) => {
clearTimeout(timeout);
void close().finally(() => resolve(path));
};
const onReject = (error: Error) => {
clearTimeout(timeout);
void close().finally(() => reject(error));
};
const close = sut.watch(
[folder],
{
ignoreInitial: true,
usePolling: true,
interval: 50,
},
{
onReady: () => {
void action().catch((error) => onReject(error as Error));
},
onAdd: (path) => {
if (event === 'add') {
onResolve(path);
}
},
onChange: (path) => {
if (event === 'change') {
onResolve(path);
}
},
onUnlink: (path) => {
if (event === 'unlink') {
onResolve(path);
}
},
onError: (error) => onReject(error),
},
);
});
};
describe(StorageRepository.name, () => {
describe('watch', () => {
it('should fire create (add) events', async () => {
const { sut } = setup();
const folder = await mkdtemp(join(tmpdir(), 'immich-storage-watch-add-'));
const file = join(folder, 'created.jpg');
try {
const changedPath = await watchForEvent(sut, folder, 'add', async () => {
await writeFile(file, 'created');
});
expect(changedPath).toBe(file);
} finally {
await rm(folder, { recursive: true, force: true });
}
});
it('should fire change events', async () => {
const { sut } = setup();
const folder = await mkdtemp(join(tmpdir(), 'immich-storage-watch-change-'));
const file = join(folder, 'changed.jpg');
await writeFile(file, 'before');
try {
const changedPath = await watchForEvent(sut, folder, 'change', async () => {
await writeFile(file, 'after');
});
expect(changedPath).toBe(file);
} finally {
await rm(folder, { recursive: true, force: true });
}
});
it('should fire unlink events', async () => {
const { sut } = setup();
const folder = await mkdtemp(join(tmpdir(), 'immich-storage-watch-unlink-'));
const file = join(folder, 'deleted.jpg');
await writeFile(file, 'content');
try {
const changedPath = await watchForEvent(sut, folder, 'unlink', async () => {
await unlink(file);
});
expect(changedPath).toBe(file);
} finally {
await rm(folder, { recursive: true, force: true });
}
});
});
});
@@ -1,169 +0,0 @@
import { ChokidarOptions } from 'chokidar';
import { Kysely } from 'kysely';
import { JobName } from 'src/enum';
import { EventRepository } from 'src/repositories/event.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LibraryRepository } from 'src/repositories/library.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { StorageRepository, WatchEvents } from 'src/repositories/storage.repository';
import { DB } from 'src/schema';
import { LibraryService } from 'src/services/library.service';
import { newMediumService } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
return newMediumService(LibraryService, {
database: db || defaultDatabase,
real: [LibraryRepository],
mock: [EventRepository, JobRepository, LoggingRepository, StorageRepository],
});
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
const closeWatcher = () => Promise.resolve();
const setWatchMode = (sut: LibraryService) => {
const service = sut as unknown as { lock: boolean; watchLibraries: boolean };
service.lock = true;
service.watchLibraries = true;
};
const makeMockWatcher =
(paths: { add?: string[]; change?: string[]; unlink?: string[] }) =>
(_paths: string[], options: ChokidarOptions, events: Partial<WatchEvents>): (() => Promise<void>) => {
const ignored = options.ignored as ((path: string) => boolean) | undefined;
events.onReady?.();
for (const path of paths.add ?? []) {
if (!ignored?.(path)) {
events.onAdd?.(path);
}
}
for (const path of paths.change ?? []) {
if (!ignored?.(path)) {
events.onChange?.(path);
}
}
for (const path of paths.unlink ?? []) {
if (!ignored?.(path)) {
events.onUnlink?.(path);
}
}
return closeWatcher;
};
describe(`${LibraryService.name} (watch, medium)`, () => {
it('should queue add and change events for supported files', async () => {
const { sut, ctx } = setup();
const storageRepo = ctx.getMock(StorageRepository);
const jobRepo = ctx.getMock(JobRepository);
jobRepo.queue.mockResolvedValue();
setWatchMode(sut);
const { user } = await ctx.newUser();
const library = await sut.create({
ownerId: user.id,
importPaths: ['/test-assets/temp/watcher-behavior'],
exclusionPatterns: ['**/@eaDir/**'],
});
storageRepo.watch.mockImplementation(
makeMockWatcher({
add: ['/test-assets/temp/watcher-behavior/add.png'],
change: ['/test-assets/temp/watcher-behavior/change.jpg'],
}),
);
await sut.watchAll();
expect(jobRepo.queue).toHaveBeenCalledWith({
name: JobName.LibrarySyncFiles,
data: { libraryId: library.id, paths: ['/test-assets/temp/watcher-behavior/add.png'] },
});
expect(jobRepo.queue).toHaveBeenCalledWith({
name: JobName.LibrarySyncFiles,
data: { libraryId: library.id, paths: ['/test-assets/temp/watcher-behavior/change.jpg'] },
});
await sut.onShutdown();
});
it('should queue unlink events for tracked files', async () => {
const { sut, ctx } = setup();
const storageRepo = ctx.getMock(StorageRepository);
const jobRepo = ctx.getMock(JobRepository);
jobRepo.queue.mockResolvedValue();
setWatchMode(sut);
const { user } = await ctx.newUser();
const library = await sut.create({
ownerId: user.id,
importPaths: ['/test-assets/temp/watcher-behavior'],
exclusionPatterns: ['**/@eaDir/**'],
});
storageRepo.watch.mockImplementation(
makeMockWatcher({
unlink: ['/test-assets/temp/watcher-behavior/delete.png'],
}),
);
await sut.watchAll();
expect(jobRepo.queue).toHaveBeenCalledWith({
name: JobName.LibraryRemoveAsset,
data: { libraryId: library.id, paths: ['/test-assets/temp/watcher-behavior/delete.png'] },
});
await sut.onShutdown();
});
it('should ignore add, change, and unlink events in excluded directories', async () => {
const { sut, ctx } = setup();
const storageRepo = ctx.getMock(StorageRepository);
const jobRepo = ctx.getMock(JobRepository);
jobRepo.queue.mockResolvedValue();
setWatchMode(sut);
await ctx.newUser().then(({ user }) =>
sut.create({
ownerId: user.id,
importPaths: ['/test-assets/temp/watcher-behavior'],
exclusionPatterns: ['**/@eaDir/**'],
}),
);
storageRepo.watch.mockImplementation(
makeMockWatcher({
add: ['/test-assets/temp/watcher-behavior/@eaDir/add.png'],
change: ['/test-assets/temp/watcher-behavior/@eaDir/change.png'],
unlink: ['/test-assets/temp/watcher-behavior/@eaDir/unlink.png'],
}),
);
await sut.watchAll();
expect(jobRepo.queue).not.toHaveBeenCalledWith(
expect.objectContaining({
name: JobName.LibrarySyncFiles,
}),
);
expect(jobRepo.queue).not.toHaveBeenCalledWith(
expect.objectContaining({
name: JobName.LibraryRemoveAsset,
}),
);
await sut.onShutdown();
});
});
@@ -1,4 +1,5 @@
import { Kysely } from 'kysely';
import { SearchSuggestionType } from 'src/dtos/search.dto';
import { AccessRepository } from 'src/repositories/access.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
@@ -108,4 +109,25 @@ describe(SearchService.name, () => {
expect(response.assets.items[0].id).toBe(unstackedAsset.id);
});
});
describe('getSearchSuggestions', () => {
it('should filter out empty search suggestions', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
const { asset: assetWithEmptyMake } = await ctx.newAsset({ ownerId: user.id });
await ctx.newExif({ assetId: assetWithEmptyMake.id, make: '' });
const auth = factory.auth({ user: { id: user.id } });
const suggestions = await sut.getSearchSuggestions(auth, {
type: SearchSuggestionType.CAMERA_MAKE,
includeNull: true,
});
expect(suggestions).toEqual(['Canon', null]);
});
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "2.6.2",
"version": "2.6.3",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {
@@ -120,10 +120,10 @@
<ActionButton action={Cast} />
<ActionButton action={Actions.Share} />
<ActionButton action={Actions.Offline} />
<ActionButton action={Actions.PlayMotionPhoto} />
<ActionButton action={Actions.StopMotionPhoto} />
<ActionButton action={Actions.ZoomIn} />
<ActionButton action={Actions.ZoomOut} />
<ActionButton action={Actions.PlayMotionPhoto} />
<ActionButton action={Actions.StopMotionPhoto} />
<ActionButton action={Actions.Copy} />
<ActionButton action={Actions.SharedLinkDownload} />
<ActionButton action={Actions.Info} />
@@ -1,5 +1,6 @@
import { getAnimateMock } from '$lib/__mocks__/animate.mock';
import { getResizeObserverMock } from '$lib/__mocks__/resize-observer.mock';
import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { preferences as preferencesStore, resetSavedUser, user as userStore } from '$lib/stores/user.store';
import { renderWithTooltips } from '$tests/helpers';
import { updateAsset } from '@immich/sdk';
@@ -41,6 +42,7 @@ describe('AssetViewer', () => {
});
afterEach(() => {
slideshowStore.slideshowState.set(SlideshowState.None);
resetSavedUser();
vi.clearAllMocks();
});
@@ -485,7 +485,7 @@
{/if}
{#if $slideshowState != SlideshowState.None}
<div class="absolute w-full flex justify-center">
<div class="absolute inset-s-0 top-0 flex w-full justify-start">
<SlideshowBar
{isFullScreen}
assetType={previewStackedAsset?.type ?? asset.type}
@@ -580,17 +580,16 @@
<div
transition:fly={{ duration: 150 }}
id="detail-panel"
class="row-start-1 row-span-4 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light"
class={[
'row-start-1 row-span-4 overflow-y-auto transition-all dark:border-l dark:border-s-immich-dark-gray bg-light',
showDetailPanel ? 'w-90' : 'w-100',
]}
translate="yes"
>
{#if showDetailPanel}
<div class="w-90 h-full">
<DetailPanel {asset} currentAlbum={album} />
</div>
<DetailPanel {asset} currentAlbum={album} />
{:else if assetViewerManager.isShowEditor}
<div class="w-100 h-full">
<EditorPanel {asset} onClose={closeEditor} />
</div>
<EditorPanel {asset} onClose={closeEditor} />
{/if}
</div>
{/if}