mirror of
https://github.com/immich-app/immich.git
synced 2026-03-24 11:04:30 -07:00
Compare commits
8 Commits
chore/remo
...
fix/librar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
292fbef180 | ||
|
|
05b07ca233 | ||
|
|
ce9b32a61a | ||
|
|
4ddc288cd1 | ||
|
|
94b15b8678 | ||
|
|
ff9ae24219 | ||
|
|
b456f78771 | ||
|
|
1506776891 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.2",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"type": "module",
|
||||
"exports": "./dist/index.js",
|
||||
|
||||
@@ -8,7 +8,7 @@ Hardware and software requirements for Immich:
|
||||
|
||||
## Hardware
|
||||
|
||||
- **OS**: Recommended Linux or \*nix operating system (Ubuntu, Debian, etc).
|
||||
- **OS**: Recommended Linux or \*nix 64-bit operating system (Ubuntu, Debian, etc).
|
||||
- Non-Linux OSes tend to provide a poor Docker experience and are strongly discouraged.
|
||||
Our ability to assist with setup or troubleshooting on non-Linux OSes will be severely reduced.
|
||||
If you still want to try to use a non-Linux OS, you can set it up as follows:
|
||||
@@ -19,6 +19,10 @@ Hardware and software requirements for Immich:
|
||||
If you have issues, we recommend that you switch to a supported VM deployment.
|
||||
- **RAM**: Minimum 6GB, recommended 8GB.
|
||||
- **CPU**: Minimum 2 cores, recommended 4 cores.
|
||||
- Immich runs on the `amd64` and `arm64` platforms.
|
||||
Since `v2.6`, the machine learning container on `amd64` requires the `>= x86-64-v2` [microarchitecture level](https://en.wikipedia.org/wiki/X86-64#Microarchitecture_levels).
|
||||
Most CPUs released since ~2012 support this microarchitecture.
|
||||
If you are using a virtual machine, ensure you have selected a [supported microarchitecture](https://pve.proxmox.com/pve-docs/chapter-qm.html#_qemu_cpu_types).
|
||||
- **Storage**: Recommended Unix-compatible filesystem (EXT4, ZFS, APFS, etc.) with support for user/group ownership and permissions.
|
||||
- The generation of thumbnails and transcoded video can increase the size of the photo library by 10-20% on average.
|
||||
|
||||
|
||||
4
docs/static/archived-versions.json
vendored
4
docs/static/archived-versions.json
vendored
@@ -1,7 +1,7 @@
|
||||
[
|
||||
{
|
||||
"label": "v2.6.1",
|
||||
"url": "https://docs.v2.6.1.archive.immich.app"
|
||||
"label": "v2.6.2",
|
||||
"url": "https://docs.v2.6.2.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.5.6",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.2",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -524,14 +524,19 @@ describe('/albums', () => {
|
||||
expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
|
||||
});
|
||||
|
||||
it('should not be able to update as an editor', async () => {
|
||||
it('should be able to update as an editor', async () => {
|
||||
const { status, body } = await request(app)
|
||||
.patch(`/albums/${user1Albums[0].id}`)
|
||||
.set('Authorization', `Bearer ${user2.accessToken}`)
|
||||
.send({ albumName: 'New album name' });
|
||||
|
||||
expect(status).toBe(400);
|
||||
expect(body).toEqual(errorDto.badRequest('Not found or no album.update access'));
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
id: user1Albums[0].id,
|
||||
albumName: 'New album name',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-i18n",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"format": "prettier --cache --check .",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "immich-ml"
|
||||
version = "2.6.1"
|
||||
version = "2.6.2"
|
||||
description = ""
|
||||
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
||||
requires-python = ">=3.11,<4.0"
|
||||
|
||||
2
machine-learning/uv.lock
generated
2
machine-learning/uv.lock
generated
@@ -898,7 +898,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "immich-ml"
|
||||
version = "2.6.1"
|
||||
version = "2.6.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocache" },
|
||||
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3039,
|
||||
"android.injected.version.name" => "2.6.1",
|
||||
"android.injected.version.code" => 3040,
|
||||
"android.injected.version.name" => "2.6.2",
|
||||
}
|
||||
)
|
||||
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')
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.6.1</string>
|
||||
<string>2.6.2</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
|
||||
@@ -67,6 +67,9 @@ class AuthService {
|
||||
bool isValid = false;
|
||||
|
||||
try {
|
||||
final urls = ApiService.getServerUrls();
|
||||
urls.add(url);
|
||||
await NetworkRepository.setHeaders(ApiService.getRequestHeaders(), urls);
|
||||
final uri = Uri.parse('$url/users/me');
|
||||
final response = await NetworkRepository.client.get(uri);
|
||||
if (response.statusCode == 200) {
|
||||
|
||||
@@ -143,8 +143,7 @@ enum ActionButtonType {
|
||||
!context.isInLockedView && //
|
||||
context.currentAlbum != null,
|
||||
ActionButtonType.setAlbumCover =>
|
||||
context.isOwner && //
|
||||
!context.isInLockedView && //
|
||||
!context.isInLockedView && //
|
||||
context.currentAlbum != null && //
|
||||
context.selectedCount == 1,
|
||||
ActionButtonType.unstack =>
|
||||
|
||||
2
mobile/openapi/README.md
generated
2
mobile/openapi/README.md
generated
@@ -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.1
|
||||
- API version: 2.6.2
|
||||
- Generator version: 7.8.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 2.6.1+3039
|
||||
version: 2.6.2+3040
|
||||
|
||||
environment:
|
||||
sdk: '>=3.8.0 <4.0.0'
|
||||
|
||||
@@ -727,7 +727,7 @@ void main() {
|
||||
expect(ActionButtonType.setAlbumCover.shouldShow(context), isTrue);
|
||||
});
|
||||
|
||||
test('should not show when not owner', () {
|
||||
test('should show when not owner', () {
|
||||
final album = createRemoteAlbum();
|
||||
final context = ActionButtonContext(
|
||||
asset: mergedAsset,
|
||||
@@ -742,7 +742,7 @@ void main() {
|
||||
selectedCount: 1,
|
||||
);
|
||||
|
||||
expect(ActionButtonType.setAlbumCover.shouldShow(context), isFalse);
|
||||
expect(ActionButtonType.setAlbumCover.shouldShow(context), isTrue);
|
||||
});
|
||||
|
||||
test('should not show when in locked view', () {
|
||||
|
||||
@@ -15166,7 +15166,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.2",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.2",
|
||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||
"type": "module",
|
||||
"main": "./build/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 2.6.1
|
||||
* 2.6.2
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-monorepo",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.2",
|
||||
"description": "Monorepo for Immich",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.2",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
@@ -90,24 +90,33 @@ export class LibraryService extends BaseService {
|
||||
|
||||
this.logger.log(`Starting to watch library ${library.id} with import path(s) ${library.importPaths}`);
|
||||
|
||||
const matcher = picomatch(`**/*{${mimeTypes.getSupportedFileExtensions().join(',')}}`, {
|
||||
nocase: true,
|
||||
ignore: library.exclusionPatterns,
|
||||
});
|
||||
const supportedExtensions = mimeTypes.getSupportedFileExtensions().map((extension) => extension.toLowerCase());
|
||||
const exclusionPatterns = library.exclusionPatterns.flatMap((pattern) =>
|
||||
pattern.endsWith('/**') ? [pattern, pattern.slice(0, -3)] : [pattern],
|
||||
);
|
||||
const excludeMatcher = picomatch(exclusionPatterns, { nocase: true });
|
||||
const isExcluded = (path: string) => excludeMatcher(path.replaceAll('\\', '/'));
|
||||
const isSupportedFile = (path: string) => {
|
||||
const normalizedPath = path.toLowerCase();
|
||||
return supportedExtensions.some((extension) => normalizedPath.endsWith(extension));
|
||||
};
|
||||
|
||||
let _resolve: () => void;
|
||||
const ready$ = new Promise<void>((resolve) => (_resolve = resolve));
|
||||
|
||||
const handler = async (event: string, path: string) => {
|
||||
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 {
|
||||
const ignored = !isSupportedFile(path);
|
||||
|
||||
if (ignored) {
|
||||
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) => {
|
||||
@@ -123,6 +132,7 @@ export class LibraryService extends BaseService {
|
||||
{
|
||||
usePolling: false,
|
||||
ignoreInitial: true,
|
||||
ignored: isExcluded,
|
||||
awaitWriteFinish: {
|
||||
stabilityThreshold: 5000,
|
||||
pollInterval: 1000,
|
||||
|
||||
@@ -190,7 +190,13 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
|
||||
}
|
||||
|
||||
case Permission.AlbumUpdate: {
|
||||
return await access.album.checkOwnerAccess(auth.user.id, ids);
|
||||
const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids);
|
||||
const isShared = await access.album.checkSharedAlbumAccess(
|
||||
auth.user.id,
|
||||
setDifference(ids, isOwner),
|
||||
AlbumUserRole.Editor,
|
||||
);
|
||||
return setUnion(isOwner, isShared);
|
||||
}
|
||||
|
||||
case Permission.AlbumDelete: {
|
||||
@@ -198,7 +204,13 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
|
||||
}
|
||||
|
||||
case Permission.AlbumShare: {
|
||||
return await access.album.checkOwnerAccess(auth.user.id, ids);
|
||||
const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids);
|
||||
const isShared = await access.album.checkSharedAlbumAccess(
|
||||
auth.user.id,
|
||||
setDifference(ids, isOwner),
|
||||
AlbumUserRole.Editor,
|
||||
);
|
||||
return setUnion(isOwner, isShared);
|
||||
}
|
||||
|
||||
case Permission.AlbumDownload: {
|
||||
|
||||
@@ -30,6 +30,7 @@ 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';
|
||||
@@ -405,6 +406,7 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
|
||||
case AssetRepository:
|
||||
case AssetEditRepository:
|
||||
case AssetJobRepository:
|
||||
case LibraryRepository:
|
||||
case MemoryRepository:
|
||||
case NotificationRepository:
|
||||
case OcrRepository:
|
||||
@@ -466,6 +468,7 @@ const newMockRepository = <T>(key: ClassConstructor<T>) => {
|
||||
case AlbumRepository:
|
||||
case AssetRepository:
|
||||
case AssetJobRepository:
|
||||
case LibraryRepository:
|
||||
case ConfigRepository:
|
||||
case CryptoRepository:
|
||||
case MemoryRepository:
|
||||
|
||||
135
server/test/medium/specs/repositories/storage.repository.spec.ts
Normal file
135
server/test/medium/specs/repositories/storage.repository.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
169
server/test/medium/specs/services/library.service.spec.ts
Normal file
169
server/test/medium/specs/services/library.service.spec.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
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,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "2.6.1",
|
||||
"version": "2.6.2",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -28,7 +28,10 @@
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
onMount(async () => {
|
||||
albums = await getAllAlbums({});
|
||||
// TODO the server should *really* just return all albums (paginated ideally)
|
||||
const ownedAlbums = await getAllAlbums({ shared: false });
|
||||
ownedAlbums.push.apply(ownedAlbums, await getAllAlbums({ shared: true }));
|
||||
albums = ownedAlbums;
|
||||
recentAlbums = albums.sort((a, b) => (new Date(a.updatedAt) > new Date(b.updatedAt) ? -1 : 1)).slice(0, 3);
|
||||
loading = false;
|
||||
});
|
||||
|
||||
10
web/src/lib/stores/album-asset-selection.store.ts
Normal file
10
web/src/lib/stores/album-asset-selection.store.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
function createAlbumAssetSelectionStore() {
|
||||
const isAlbumAssetSelectionOpen = writable<boolean>(false);
|
||||
return {
|
||||
isAlbumAssetSelectionOpen,
|
||||
};
|
||||
}
|
||||
|
||||
export const albumAssetSelectionStore = createAlbumAssetSelectionStore();
|
||||
@@ -476,13 +476,6 @@
|
||||
<ChangeDate menuItem />
|
||||
<ChangeDescription menuItem />
|
||||
<ChangeLocation menuItem />
|
||||
{#if assetInteraction.selectedAssets.length === 1}
|
||||
<MenuOption
|
||||
text={$t('set_as_album_cover')}
|
||||
icon={mdiImageOutline}
|
||||
onClick={() => updateThumbnailUsingCurrentSelection()}
|
||||
/>
|
||||
{/if}
|
||||
<ArchiveAction
|
||||
menuItem
|
||||
unarchive={assetInteraction.isAllArchived}
|
||||
@@ -490,6 +483,13 @@
|
||||
/>
|
||||
<SetVisibilityAction menuItem onVisibilitySet={handleSetVisibility} />
|
||||
{/if}
|
||||
{#if assetInteraction.selectedAssets.length === 1}
|
||||
<MenuOption
|
||||
text={$t('set_as_album_cover')}
|
||||
icon={mdiImageOutline}
|
||||
onClick={() => updateThumbnailUsingCurrentSelection()}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if $preferences.tags.enabled && assetInteraction.isAllUserOwned}
|
||||
<TagAction menuItem />
|
||||
|
||||
Reference in New Issue
Block a user