Compare commits

..

8 Commits

Author SHA1 Message Date
Jonathan Jogenfors
292fbef180 move to medium tests 2026-03-24 14:49:07 +01:00
Jonathan Jogenfors
05b07ca233 feat: add e2e tests for library watcher 2026-03-24 13:17:23 +01:00
github-actions
ce9b32a61a chore: version v2.6.2 2026-03-24 02:51:55 +00:00
Yaros
4ddc288cd1 fix(mobile/web): album cover buttons consistency (#27213)
* fix(mobile/web): album cover buttons consistency

* test: adjust test
2026-03-23 21:40:17 -05:00
Yaros
94b15b8678 fix(server): album permissions for editors (#27214)
* fix(server): album permissions for editors

* test: adjust e2e test

* test: fix test
2026-03-23 21:39:30 -05:00
Daniel Dietzler
ff9ae24219 fix: album picker show all albums (#27211) 2026-03-23 19:08:57 -05:00
Matthew Momjian
b456f78771 fix(docs): clarify ML CPU architecture (#27187)
* ML architecture

* format

* clarify amd/arm
2026-03-23 18:29:58 -04:00
Mert
1506776891 fix(mobile): add cookie for auxiliary url (#27209)
add cookie before validating
2026-03-23 16:22:46 -05:00
29 changed files with 400 additions and 47 deletions

View File

@@ -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",

View File

@@ -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.

View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "2.6.1",
"version": "2.6.2",
"description": "",
"main": "index.js",
"type": "module",

View File

@@ -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',
}),
);
});
});

View File

@@ -1,6 +1,6 @@
{
"name": "immich-i18n",
"version": "2.6.1",
"version": "2.6.2",
"private": true,
"scripts": {
"format": "prettier --cache --check .",

View File

@@ -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"

View File

@@ -898,7 +898,7 @@ wheels = [
[[package]]
name = "immich-ml"
version = "2.6.1"
version = "2.6.2"
source = { editable = "." }
dependencies = [
{ name = "aiocache" },

View File

@@ -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')

View File

@@ -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>

View File

@@ -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) {

View File

@@ -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 =>

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.1
- API version: 2.6.2
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen

View File

@@ -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'

View File

@@ -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', () {

View File

@@ -15166,7 +15166,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "2.6.1",
"version": "2.6.2",
"contact": {}
},
"tags": [

View File

@@ -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",

View File

@@ -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
*/

View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "2.6.1",
"version": "2.6.2",
"description": "",
"author": "",
"private": true,

View File

@@ -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,

View File

@@ -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: {

View File

@@ -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:

View 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 });
}
});
});
});

View 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();
});
});

View File

@@ -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": {

View File

@@ -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;
});

View File

@@ -0,0 +1,10 @@
import { writable } from 'svelte/store';
function createAlbumAssetSelectionStore() {
const isAlbumAssetSelectionOpen = writable<boolean>(false);
return {
isAlbumAssetSelectionOpen,
};
}
export const albumAssetSelectionStore = createAlbumAssetSelectionStore();

View File

@@ -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 />