Compare commits

..

11 Commits

Author SHA1 Message Date
Thomas Way b68c442356 fix(mobile): don't pop when opening albums
The asset viewer closes when opening an album from the asset details.
This doesn't seem desirable, and it likely a side-effect from the
previous pop behaviour which would close just the bottom sheet, and not
the asset viewer.
2026-02-20 21:21:03 +00:00
Peter Ombodi 82c6302549 feat(mobile): timeline - add persistentBottomBar flag (#25634)
* feat(mobile): timeline - add selectable all-assets control

* feature(mobile): introduce bottomWidgetBuilder in Timeline
remove redundant code

* fix(mobile): remove redundant code

* refactor(mobile): refactor new code in Timeline

* fix(mobile): fix format

* refactor(mobile): replace unsupported Dart syntax for analyzer compatibility

* refactor(mobile): remove Timeline.bottomSheet and migrate to bottomWidgetBuilder

* refactor(mobile): restore Timeline.bottomSheet and remove bottomWidgetBuilder
add withPersistentBottomBar param to Timeline class

* refactor(mobile): refactor var name

---------

Co-authored-by: Peter Ombodi <peter.ombodi@gmail.com>
2026-02-20 23:51:26 +05:30
Min Idzelis aae64b5e2f test: thumbnail selector (#26383)
* test: face ordering issue/flakiness

* test: thumbnail selector
2026-02-20 15:04:17 +00:00
Benjamin Nguyen 18bf96b4b2 fix(mobile): handle userPreferencesProvider error state during sync (#26332)
fix drift_search_page render bug
2026-02-20 08:57:28 -06:00
Timon 84f2956941 fix(cli): delete sidecar files after upload if requested (#26353)
* fix(cli): delete sidecar files after upload if requested

Introduced a new function, findSidecar, to locate XMP sidecar files based on specified naming conventions. Updated the deleteFiles function to delete associated sidecar files when the main asset file is deleted. Added unit tests for findSidecar to ensure correct functionality.

* lint and format

* fix test

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-02-20 14:54:08 +00:00
Min Idzelis 6044b41648 fix: align devcontainers with standard development containers (#26321) 2026-02-20 09:37:07 -05:00
Min Idzelis b4e16efdf4 test: face ordering issue/flakiness (#26382) 2026-02-20 09:23:40 -05:00
Min Idzelis 19da655390 fix: exiftool-vendored.exe (#26393) 2026-02-20 09:16:42 -05:00
Benjamin Nguyen a1839b3676 fix(mobile): Reset "People" search filter chip if no selections are made (#26267)
* filter by tags

* reset people search filter chip if no selections
2026-02-20 16:37:26 +05:30
dotlambda 7461479f60 chore(ml): remove unused dependency ftfy (#25529) 2026-02-19 22:58:25 +00:00
Jason Rasmussen 01050a3d54 fix: pin code reset modal (#26370) 2026-02-19 21:50:39 +00:00
23 changed files with 198 additions and 281 deletions
+3 -24
View File
@@ -2,6 +2,7 @@
"name": "Immich - Backend, Frontend and ML", "name": "Immich - Backend, Frontend and ML",
"service": "immich-server", "service": "immich-server",
"runServices": [ "runServices": [
"immich-init",
"immich-server", "immich-server",
"redis", "redis",
"database", "database",
@@ -31,29 +32,8 @@
"tasks": { "tasks": {
"version": "2.0.0", "version": "2.0.0",
"tasks": [ "tasks": [
{
"label": "Fix Permissions, Install Dependencies",
"type": "shell",
"command": "[ -f /immich-devcontainer/container-start.sh ] && /immich-devcontainer/container-start.sh || exit 0",
"isBackground": true,
"presentation": {
"echo": true,
"reveal": "always",
"focus": false,
"panel": "dedicated",
"showReuseMessage": true,
"clear": false,
"group": "Devcontainer tasks",
"close": true
},
"runOptions": {
"runOn": "default"
},
"problemMatcher": []
},
{ {
"label": "Immich API Server (Nest)", "label": "Immich API Server (Nest)",
"dependsOn": ["Fix Permissions, Install Dependencies"],
"type": "shell", "type": "shell",
"command": "[ -f /immich-devcontainer/container-start-backend.sh ] && /immich-devcontainer/container-start-backend.sh || exit 0", "command": "[ -f /immich-devcontainer/container-start-backend.sh ] && /immich-devcontainer/container-start-backend.sh || exit 0",
"isBackground": true, "isBackground": true,
@@ -74,7 +54,6 @@
}, },
{ {
"label": "Immich Web Server (Vite)", "label": "Immich Web Server (Vite)",
"dependsOn": ["Fix Permissions, Install Dependencies"],
"type": "shell", "type": "shell",
"command": "[ -f /immich-devcontainer/container-start-frontend.sh ] && /immich-devcontainer/container-start-frontend.sh || exit 0", "command": "[ -f /immich-devcontainer/container-start-frontend.sh ] && /immich-devcontainer/container-start-frontend.sh || exit 0",
"isBackground": true, "isBackground": true,
@@ -130,8 +109,8 @@
} }
}, },
"overrideCommand": true, "overrideCommand": true,
"workspaceFolder": "/workspaces/immich", "workspaceFolder": "/usr/src/app",
"remoteUser": "node", "remoteUser": "root",
"userEnvProbe": "loginInteractiveShell", "userEnvProbe": "loginInteractiveShell",
"remoteEnv": { "remoteEnv": {
// The location where your uploaded files are stored // The location where your uploaded files are stored
@@ -1,23 +1,17 @@
services: services:
immich-app-base:
image: busybox
immich-server: immich-server:
extends:
service: immich-app-base
profiles: !reset []
image: immich-server-dev:latest
build: build:
target: dev-container-mobile target: dev-container-mobile
environment: environment:
- IMMICH_SERVER_URL=http://127.0.0.1:2283/ - IMMICH_SERVER_URL=http://127.0.0.1:2283/
volumes: !override # bind mount host to /workspaces/immich volumes:
- ..:/workspaces/immich
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data - ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
- pnpm-store:/usr/src/app/.pnpm-store
- server-node_modules:/usr/src/app/server/node_modules
- web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules
- docs-node_modules:/usr/src/app/docs/node_modules
- e2e-node_modules:/usr/src/app/e2e/node_modules
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
immich-web: immich-web:
env_file: !reset [] env_file: !reset []
+2 -1
View File
@@ -2,6 +2,7 @@
"name": "Immich - Mobile", "name": "Immich - Mobile",
"service": "immich-server", "service": "immich-server",
"runServices": [ "runServices": [
"immich-init",
"immich-server", "immich-server",
"redis", "redis",
"database", "database",
@@ -35,7 +36,7 @@
}, },
"forwardPorts": [], "forwardPorts": [],
"overrideCommand": true, "overrideCommand": true,
"workspaceFolder": "/workspaces/immich", "workspaceFolder": "/usr/src/app",
"remoteUser": "node", "remoteUser": "node",
"userEnvProbe": "loginInteractiveShell", "userEnvProbe": "loginInteractiveShell",
"remoteEnv": { "remoteEnv": {
+1 -50
View File
@@ -2,11 +2,6 @@
export IMMICH_PORT="${DEV_SERVER_PORT:-2283}" export IMMICH_PORT="${DEV_SERVER_PORT:-2283}"
export DEV_PORT="${DEV_PORT:-3000}" export DEV_PORT="${DEV_PORT:-3000}"
# search for immich directory inside workspace.
# /workspaces/immich is the bind mount, but other directories can be mounted if runing
# Devcontainer: Clone [repository|pull request] in container volumne
WORKSPACES_DIR="/workspaces"
IMMICH_DIR="$WORKSPACES_DIR/immich"
IMMICH_DEVCONTAINER_LOG="$HOME/immich-devcontainer.log" IMMICH_DEVCONTAINER_LOG="$HOME/immich-devcontainer.log"
log() { log() {
@@ -30,52 +25,8 @@ run_cmd() {
return "${PIPESTATUS[0]}" return "${PIPESTATUS[0]}"
} }
# Find directories excluding /workspaces/immich export IMMICH_WORKSPACE="/usr/src/app"
mapfile -t other_dirs < <(find "$WORKSPACES_DIR" -mindepth 1 -maxdepth 1 -type d ! -path "$IMMICH_DIR" ! -name ".*")
if [ ${#other_dirs[@]} -gt 1 ]; then
log "Error: More than one directory found in $WORKSPACES_DIR other than $IMMICH_DIR."
exit 1
elif [ ${#other_dirs[@]} -eq 1 ]; then
export IMMICH_WORKSPACE="${other_dirs[0]}"
else
export IMMICH_WORKSPACE="$IMMICH_DIR"
fi
log "Found immich workspace in $IMMICH_WORKSPACE" log "Found immich workspace in $IMMICH_WORKSPACE"
log "" log ""
fix_permissions() {
log "Fixing permissions for ${IMMICH_WORKSPACE}"
# Change ownership for directories that exist
for dir in "${IMMICH_WORKSPACE}/.vscode" \
"${IMMICH_WORKSPACE}/server/upload" \
"${IMMICH_WORKSPACE}/.pnpm-store" \
"${IMMICH_WORKSPACE}/.github/node_modules" \
"${IMMICH_WORKSPACE}/cli/node_modules" \
"${IMMICH_WORKSPACE}/e2e/node_modules" \
"${IMMICH_WORKSPACE}/open-api/typescript-sdk/node_modules" \
"${IMMICH_WORKSPACE}/server/node_modules" \
"${IMMICH_WORKSPACE}/server/dist" \
"${IMMICH_WORKSPACE}/web/node_modules" \
"${IMMICH_WORKSPACE}/web/dist"; do
if [ -d "$dir" ]; then
run_cmd sudo chown node -R "$dir"
fi
done
log ""
}
install_dependencies() {
log "Installing dependencies"
(
cd "${IMMICH_WORKSPACE}" || exit 1
export CI=1 FROZEN=1 OFFLINE=1
run_cmd make setup-web-dev setup-server-dev
)
log ""
}
@@ -1,26 +1,21 @@
services: services:
immich-app-base:
image: busybox
immich-server: immich-server:
extends:
service: immich-app-base
profiles: !reset []
image: immich-server-dev:latest
build: build:
target: dev-container-server target: dev-container-server
env_file: !reset [] env_file: !reset []
hostname: immich-dev hostname: immich-dev
environment: environment:
- IMMICH_SERVER_URL=http://127.0.0.1:2283/ - IMMICH_SERVER_URL=http://127.0.0.1:2283/
volumes: !override volumes:
- ..:/workspaces/immich
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data - ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
- pnpm-store:/usr/src/app/.pnpm-store - pnpm_store_server:/buildcache/pnpm-store
- server-node_modules:/usr/src/app/server/node_modules
- web-node_modules:/usr/src/app/web/node_modules
- github-node_modules:/usr/src/app/.github/node_modules
- cli-node_modules:/usr/src/app/cli/node_modules
- docs-node_modules:/usr/src/app/docs/node_modules
- e2e-node_modules:/usr/src/app/e2e/node_modules
- sdk-node_modules:/usr/src/app/open-api/typescript-sdk/node_modules
- app-node_modules:/usr/src/app/node_modules
- sveltekit:/usr/src/app/web/.svelte-kit
- coverage:/usr/src/app/web/coverage
- ../plugins:/build/corePlugin - ../plugins:/build/corePlugin
immich-web: immich-web:
env_file: !reset [] env_file: !reset []
-17
View File
@@ -1,17 +0,0 @@
#!/bin/bash
# shellcheck source=common.sh
# shellcheck disable=SC1091
source /immich-devcontainer/container-common.sh
log "Setting up Immich dev container..."
fix_permissions
log "Setup complete, please wait while backend and frontend services automatically start"
log
log "If necessary, the services may be manually started using"
log
log "$ /immich-devcontainer/container-start-backend.sh"
log "$ /immich-devcontainer/container-start-frontend.sh"
log
log "From different terminal windows, as these scripts automatically restart the server"
log "on error, and will continuously run in a loop"
+11 -5
View File
@@ -4,12 +4,18 @@ module.exports = {
if (!pkg.name) { if (!pkg.name) {
return pkg; return pkg;
} }
// make exiftool-vendored.pl a regular dependency since Docker prod
// images build with --no-optional to reduce image size
if (pkg.name === "exiftool-vendored") { if (pkg.name === "exiftool-vendored") {
if (pkg.optionalDependencies["exiftool-vendored.pl"]) { const binaryPackage =
// make exiftool-vendored.pl a regular dependency process.platform === "win32"
pkg.dependencies["exiftool-vendored.pl"] = ? "exiftool-vendored.exe"
pkg.optionalDependencies["exiftool-vendored.pl"]; : "exiftool-vendored.pl";
delete pkg.optionalDependencies["exiftool-vendored.pl"];
if (pkg.optionalDependencies[binaryPackage]) {
pkg.dependencies[binaryPackage] =
pkg.optionalDependencies[binaryPackage];
delete pkg.optionalDependencies[binaryPackage];
} }
} }
return pkg; return pkg;
+91 -1
View File
@@ -7,7 +7,15 @@ import { describe, expect, it, MockedFunction, vi } from 'vitest';
import { Action, checkBulkUpload, defaults, getSupportedMediaTypes, Reason } from '@immich/sdk'; import { Action, checkBulkUpload, defaults, getSupportedMediaTypes, Reason } from '@immich/sdk';
import createFetchMock from 'vitest-fetch-mock'; import createFetchMock from 'vitest-fetch-mock';
import { checkForDuplicates, getAlbumName, startWatch, uploadFiles, UploadOptionsDto } from 'src/commands/asset'; import {
checkForDuplicates,
deleteFiles,
findSidecar,
getAlbumName,
startWatch,
uploadFiles,
UploadOptionsDto,
} from 'src/commands/asset';
vi.mock('@immich/sdk'); vi.mock('@immich/sdk');
@@ -309,3 +317,85 @@ describe('startWatch', () => {
await fs.promises.rm(testFolder, { recursive: true, force: true }); await fs.promises.rm(testFolder, { recursive: true, force: true });
}); });
}); });
describe('findSidecar', () => {
let testDir: string;
let testFilePath: string;
beforeEach(() => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-sidecar-'));
testFilePath = path.join(testDir, 'test.jpg');
fs.writeFileSync(testFilePath, 'test');
});
afterEach(() => {
fs.rmSync(testDir, { recursive: true, force: true });
});
it('should find sidecar file with photo.xmp naming convention', () => {
const sidecarPath = path.join(testDir, 'test.xmp');
fs.writeFileSync(sidecarPath, 'xmp data');
const result = findSidecar(testFilePath);
expect(result).toBe(sidecarPath);
});
it('should find sidecar file with photo.ext.xmp naming convention', () => {
const sidecarPath = path.join(testDir, 'test.jpg.xmp');
fs.writeFileSync(sidecarPath, 'xmp data');
const result = findSidecar(testFilePath);
expect(result).toBe(sidecarPath);
});
it('should prefer photo.ext.xmp over photo.xmp when both exist', () => {
const sidecarPath1 = path.join(testDir, 'test.xmp');
const sidecarPath2 = path.join(testDir, 'test.jpg.xmp');
fs.writeFileSync(sidecarPath1, 'xmp data 1');
fs.writeFileSync(sidecarPath2, 'xmp data 2');
const result = findSidecar(testFilePath);
// Should return the first one found (photo.xmp) based on the order in the code
expect(result).toBe(sidecarPath1);
});
it('should return undefined when no sidecar file exists', () => {
const result = findSidecar(testFilePath);
expect(result).toBeUndefined();
});
});
describe('deleteFiles', () => {
let testDir: string;
let testFilePath: string;
beforeEach(() => {
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-delete-'));
testFilePath = path.join(testDir, 'test.jpg');
fs.writeFileSync(testFilePath, 'test');
});
afterEach(() => {
fs.rmSync(testDir, { recursive: true, force: true });
});
it('should delete asset and sidecar file when main file is deleted', async () => {
const sidecarPath = path.join(testDir, 'test.xmp');
fs.writeFileSync(sidecarPath, 'xmp data');
await deleteFiles([{ id: 'test-id', filepath: testFilePath }], [], { delete: true, concurrency: 1 });
expect(fs.existsSync(testFilePath)).toBe(false);
expect(fs.existsSync(sidecarPath)).toBe(false);
});
it('should not delete sidecar file when delete option is false', async () => {
const sidecarPath = path.join(testDir, 'test.xmp');
fs.writeFileSync(sidecarPath, 'xmp data');
await deleteFiles([{ id: 'test-id', filepath: testFilePath }], [], { delete: false, concurrency: 1 });
expect(fs.existsSync(testFilePath)).toBe(true);
expect(fs.existsSync(sidecarPath)).toBe(true);
});
});
+32 -22
View File
@@ -17,7 +17,7 @@ import { Matcher, watch as watchFs } from 'chokidar';
import { MultiBar, Presets, SingleBar } from 'cli-progress'; import { MultiBar, Presets, SingleBar } from 'cli-progress';
import { chunk } from 'lodash-es'; import { chunk } from 'lodash-es';
import micromatch from 'micromatch'; import micromatch from 'micromatch';
import { Stats, createReadStream } from 'node:fs'; import { Stats, createReadStream, existsSync } from 'node:fs';
import { stat, unlink } from 'node:fs/promises'; import { stat, unlink } from 'node:fs/promises';
import path, { basename } from 'node:path'; import path, { basename } from 'node:path';
import { Queue } from 'src/queue'; import { Queue } from 'src/queue';
@@ -403,23 +403,6 @@ export const uploadFiles = async (
const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaResponseDto> => { const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaResponseDto> => {
const { baseUrl, headers } = defaults; const { baseUrl, headers } = defaults;
const assetPath = path.parse(input);
const noExtension = path.join(assetPath.dir, assetPath.name);
const sidecarsFiles = await Promise.all(
// XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp
[`${noExtension}.xmp`, `${input}.xmp`].map(async (sidecarPath) => {
try {
const stats = await stat(sidecarPath);
return new UploadFile(sidecarPath, stats.size);
} catch {
return false;
}
}),
);
const sidecarData = sidecarsFiles.find((file): file is UploadFile => file !== false);
const formData = new FormData(); const formData = new FormData();
formData.append('deviceAssetId', `${basename(input)}-${stats.size}`.replaceAll(/\s+/g, '')); formData.append('deviceAssetId', `${basename(input)}-${stats.size}`.replaceAll(/\s+/g, ''));
formData.append('deviceId', 'CLI'); formData.append('deviceId', 'CLI');
@@ -429,8 +412,15 @@ const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaRespon
formData.append('isFavorite', 'false'); formData.append('isFavorite', 'false');
formData.append('assetData', new UploadFile(input, stats.size)); formData.append('assetData', new UploadFile(input, stats.size));
if (sidecarData) { const sidecarPath = findSidecar(input);
formData.append('sidecarData', sidecarData); if (sidecarPath) {
try {
const stats = await stat(sidecarPath);
const sidecarData = new UploadFile(sidecarPath, stats.size);
formData.append('sidecarData', sidecarData);
} catch {
// noop
}
} }
const response = await fetch(`${baseUrl}/assets`, { const response = await fetch(`${baseUrl}/assets`, {
@@ -446,7 +436,19 @@ const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaRespon
return response.json(); return response.json();
}; };
const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], options: UploadOptionsDto): Promise<void> => { export const findSidecar = (filepath: string): string | undefined => {
const assetPath = path.parse(filepath);
const noExtension = path.join(assetPath.dir, assetPath.name);
// XMP sidecars can come in two filename formats. For a photo named photo.ext, the filenames are photo.ext.xmp and photo.xmp
for (const sidecarPath of [`${noExtension}.xmp`, `${filepath}.xmp`]) {
if (existsSync(sidecarPath)) {
return sidecarPath;
}
}
};
export const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], options: UploadOptionsDto): Promise<void> => {
let fileCount = 0; let fileCount = 0;
if (options.delete) { if (options.delete) {
fileCount += uploaded.length; fileCount += uploaded.length;
@@ -474,7 +476,15 @@ const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], options: Uplo
const chunkDelete = async (files: Asset[]) => { const chunkDelete = async (files: Asset[]) => {
for (const assetBatch of chunk(files, options.concurrency)) { for (const assetBatch of chunk(files, options.concurrency)) {
await Promise.all(assetBatch.map((input: Asset) => unlink(input.filepath))); await Promise.all(
assetBatch.map(async (input: Asset) => {
await unlink(input.filepath);
const sidecarPath = findSidecar(input.filepath);
if (sidecarPath) {
await unlink(sidecarPath);
}
}),
);
deletionProgress.update(assetBatch.length); deletionProgress.update(assetBatch.length);
} }
}; };
+2 -1
View File
@@ -253,7 +253,8 @@ describe('/asset', () => {
expect(status).toBe(200); expect(status).toBe(200);
expect(body.id).toEqual(facesAsset.id); expect(body.id).toEqual(facesAsset.id);
expect(body.people).toMatchObject(expectedFaces); const sortedPeople = body.people.toSorted((a: any, b: any) => a.name.localeCompare(b.name));
expect(sortedPeople).toMatchObject(expectedFaces);
}); });
}); });
+2 -5
View File
@@ -65,7 +65,7 @@ export const thumbnailUtils = {
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button`); return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button`);
}, },
selectedAsset(page: Page) { selectedAsset(page: Page) {
return page.locator('[data-thumbnail-focus-container]:has(button[aria-checked])'); return page.locator('[data-thumbnail-focus-container][data-selected]');
}, },
async clickAssetId(page: Page, assetId: string) { async clickAssetId(page: Page, assetId: string) {
await thumbnailUtils.withAssetId(page, assetId).click(); await thumbnailUtils.withAssetId(page, assetId).click();
@@ -103,11 +103,8 @@ export const thumbnailUtils = {
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0); await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
}, },
async expectSelectedReadonly(page: Page, assetId: string) { async expectSelectedReadonly(page: Page, assetId: string) {
// todo - need a data attribute for selected
await expect( await expect(
page.locator( page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"][data-selected]`),
`[data-thumbnail-focus-container][data-asset="${assetId}"] > .group.cursor-not-allowed > .rounded-xl`,
),
).toBeVisible(); ).toBeVisible();
}, },
async expectTimelineHasOnScreenAssets(page: Page) { async expectTimelineHasOnScreenAssets(page: Page) {
-1
View File
@@ -8,7 +8,6 @@ readme = "README.md"
dependencies = [ dependencies = [
"aiocache>=0.12.1,<1.0", "aiocache>=0.12.1,<1.0",
"fastapi>=0.95.2,<1.0", "fastapi>=0.95.2,<1.0",
"ftfy>=6.1.1",
"gunicorn>=21.1.0", "gunicorn>=21.1.0",
"huggingface-hub>=0.20.1,<1.0", "huggingface-hub>=0.20.1,<1.0",
"insightface>=0.7.3,<1.0", "insightface>=0.7.3,<1.0",
@@ -150,10 +150,12 @@ class DriftSearchPage extends HookConsumerWidget {
handleOnSelect(Set<PersonDto> value) { handleOnSelect(Set<PersonDto> value) {
filter.value = filter.value.copyWith(people: value); filter.value = filter.value.copyWith(people: value);
peopleCurrentFilterWidget.value = Text( final label = value.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', ');
value.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', '), if (label.isNotEmpty) {
style: context.textTheme.labelLarge, peopleCurrentFilterWidget.value = Text(label, style: context.textTheme.labelLarge);
); } else {
peopleCurrentFilterWidget.value = null;
}
} }
handleClear() { handleClear() {
@@ -696,7 +698,7 @@ class DriftSearchPage extends HookConsumerWidget {
label: 'search_filter_location'.t(context: context), label: 'search_filter_location'.t(context: context),
currentFilter: locationCurrentFilterWidget.value, currentFilter: locationCurrentFilterWidget.value,
), ),
if (userPreferences.value?.tagsEnabled ?? false) if (userPreferences.valueOrNull?.tagsEnabled ?? false)
SearchFilterChip( SearchFilterChip(
icon: Icons.sell_outlined, icon: Icons.sell_outlined,
onTap: showTagPicker, onTap: showTagPicker,
@@ -722,14 +724,13 @@ class DriftSearchPage extends HookConsumerWidget {
label: 'search_filter_media_type'.t(context: context), label: 'search_filter_media_type'.t(context: context),
currentFilter: mediaTypeCurrentFilterWidget.value, currentFilter: mediaTypeCurrentFilterWidget.value,
), ),
if (userPreferences.value?.ratingsEnabled ?? false) ...[ if (userPreferences.valueOrNull?.ratingsEnabled ?? false)
SearchFilterChip( SearchFilterChip(
icon: Icons.star_outline_rounded, icon: Icons.star_outline_rounded,
onTap: showStarRatingPicker, onTap: showStarRatingPicker,
label: 'search_filter_star_rating'.t(context: context), label: 'search_filter_star_rating'.t(context: context),
currentFilter: ratingCurrentFilterWidget.value, currentFilter: ratingCurrentFilterWidget.value,
), ),
],
SearchFilterChip( SearchFilterChip(
icon: Icons.display_settings_outlined, icon: Icons.display_settings_outlined,
onTap: showDisplayOptionPicker, onTap: showDisplayOptionPicker,
@@ -60,8 +60,7 @@ class AppearsInDetails extends ConsumerWidget {
album: album, album: album,
isOwner: isOwner, isOwner: isOwner,
onAlbumSelected: (album) async { onAlbumSelected: (album) async {
ref.invalidate(assetViewerProvider); unawaited(context.router.push(RemoteAlbumRoute(album: album)));
unawaited(context.router.popAndPush(RemoteAlbumRoute(album: album)));
}, },
); );
}).toList(), }).toList(),
@@ -74,6 +74,7 @@ class Timeline extends StatefulWidget {
this.snapToMonth = true, this.snapToMonth = true,
this.initialScrollOffset, this.initialScrollOffset,
this.readOnly = false, this.readOnly = false,
this.persistentBottomBar = false,
}); });
final Widget? topSliverWidget; final Widget? topSliverWidget;
@@ -87,6 +88,7 @@ class Timeline extends StatefulWidget {
final bool snapToMonth; final bool snapToMonth;
final double? initialScrollOffset; final double? initialScrollOffset;
final bool readOnly; final bool readOnly;
final bool persistentBottomBar;
@override @override
State<Timeline> createState() => _TimelineState(); State<Timeline> createState() => _TimelineState();
@@ -143,6 +145,7 @@ class _TimelineState extends State<Timeline> {
appBar: widget.appBar, appBar: widget.appBar,
bottomSheet: widget.bottomSheet, bottomSheet: widget.bottomSheet,
withScrubber: widget.withScrubber, withScrubber: widget.withScrubber,
persistentBottomBar: widget.persistentBottomBar,
snapToMonth: widget.snapToMonth, snapToMonth: widget.snapToMonth,
initialScrollOffset: widget.initialScrollOffset, initialScrollOffset: widget.initialScrollOffset,
), ),
@@ -173,6 +176,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
this.appBar, this.appBar,
this.bottomSheet, this.bottomSheet,
this.withScrubber = true, this.withScrubber = true,
this.persistentBottomBar = false,
this.snapToMonth = true, this.snapToMonth = true,
this.initialScrollOffset, this.initialScrollOffset,
}); });
@@ -182,6 +186,7 @@ class _SliverTimeline extends ConsumerStatefulWidget {
final Widget? appBar; final Widget? appBar;
final Widget? bottomSheet; final Widget? bottomSheet;
final bool withScrubber; final bool withScrubber;
final bool persistentBottomBar;
final bool snapToMonth; final bool snapToMonth;
final double? initialScrollOffset; final double? initialScrollOffset;
@@ -404,6 +409,9 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
final isSelectionMode = ref.watch(multiSelectProvider.select((s) => s.forceEnable)); final isSelectionMode = ref.watch(multiSelectProvider.select((s) => s.forceEnable));
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled)); final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider); final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final isMultiSelectStatusVisible = !isSelectionMode && isMultiSelectEnabled;
final isBottomWidgetVisible =
widget.bottomSheet != null && (isMultiSelectStatusVisible || widget.persistentBottomBar);
return PopScope( return PopScope(
canPop: !isMultiSelectEnabled, canPop: !isMultiSelectEnabled,
@@ -519,7 +527,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
child: Stack( child: Stack(
children: [ children: [
timeline, timeline,
if (!isSelectionMode && isMultiSelectEnabled) ...[ if (isMultiSelectStatusVisible)
Positioned( Positioned(
top: MediaQuery.paddingOf(context).top, top: MediaQuery.paddingOf(context).top,
left: 25, left: 25,
@@ -528,8 +536,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
child: Center(child: _MultiSelectStatusButton()), child: Center(child: _MultiSelectStatusButton()),
), ),
), ),
if (widget.bottomSheet != null) widget.bottomSheet!, if (isBottomWidgetVisible) widget.bottomSheet!,
],
], ],
), ),
), ),
+1 -1
View File
@@ -11,7 +11,7 @@ overrides:
packageExtensionsChecksum: sha256-3l4AQg4iuprBDup+q+2JaPvbPg/7XodWCE0ZteH+s54= packageExtensionsChecksum: sha256-3l4AQg4iuprBDup+q+2JaPvbPg/7XodWCE0ZteH+s54=
pnpmfileChecksum: sha256-AG/qwrPNpmy9q60PZwCpecoYVptglTHgH+N6RKQHOM0= pnpmfileChecksum: sha256-un98do36L0wZyqsjcLozQ3YUadCAn2yz5bXcBbOuyDA=
importers: importers:
+4 -6
View File
@@ -27,16 +27,14 @@ ENTRYPOINT ["tini", "--", "/bin/bash", "-c"]
FROM dev AS dev-container-server FROM dev AS dev-container-server
RUN apt-get update --allow-releaseinfo-change && \ RUN apt-get update --allow-releaseinfo-change && \
apt-get install sudo inetutils-ping openjdk-21-jre-headless \ apt-get install inetutils-ping openjdk-21-jre-headless \
vim nano curl \ vim nano curl \
-y --no-install-recommends --fix-missing -y --no-install-recommends --fix-missing
RUN usermod -aG sudo node && \ RUN mkdir -p /workspaces && \
echo "node ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers && \ ln -s /usr/src/app /workspaces/immich
mkdir -p /workspaces/immich
RUN chown node:node -R /workspaces COPY --chmod=755 ../.devcontainer/server/*.sh /immich-devcontainer/
COPY --chown=node:node --chmod=755 ../.devcontainer/server/*.sh /immich-devcontainer/
WORKDIR /workspaces/immich WORKDIR /workspaces/immich
@@ -159,7 +159,7 @@
imageError = imageLoaded = true; imageError = imageLoaded = true;
}; };
onDestroy(() => imageManager.cancel(asset, targetImageSize)); onDestroy(() => imageManager.cancelPreloadUrl(imageLoaderUrl));
let imageLoaderUrl = $derived( let imageLoaderUrl = $derived(
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || assetViewerManager.zoom > 1 }), getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || assetViewerManager.zoom > 1 }),
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { cancelImageUrl } from '$lib/utils/sw-messaging'; import { imageManager } from '$lib/managers/ImageManager.svelte';
import { Icon } from '@immich/ui'; import { Icon } from '@immich/ui';
import { mdiEyeOffOutline } from '@mdi/js'; import { mdiEyeOffOutline } from '@mdi/js';
import type { ActionReturn } from 'svelte/action'; import type { ActionReturn } from 'svelte/action';
@@ -60,7 +60,7 @@
onComplete?.(false); onComplete?.(false);
} }
return { return {
destroy: () => cancelImageUrl(url), destroy: () => imageManager.cancelPreloadUrl(url),
}; };
} }
@@ -223,6 +223,7 @@
bind:this={element} bind:this={element}
data-asset={asset.id} data-asset={asset.id}
data-thumbnail-focus-container data-thumbnail-focus-container
data-selected={selected ? true : undefined}
tabindex={0} tabindex={0}
role="link" role="link"
> >
@@ -32,6 +32,12 @@ class ImageManager {
} }
} }
} }
cancelPreloadUrl(url: string | undefined) {
if (url) {
cancelImageUrl(url);
}
}
} }
export const imageManager = new ImageManager(); export const imageManager = new ImageManager();
@@ -1,99 +0,0 @@
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { getAssetMediaUrl } from '$lib/utils';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { AssetMediaSize } from '@immich/sdk';
import { assetFactory } from '@test-data/factories/asset-factory';
vi.mock('$lib/utils/sw-messaging', () => ({
cancelImageUrl: vi.fn(),
}));
vi.mock('$lib/utils', () => ({
getAssetMediaUrl: vi.fn(),
}));
describe('ImageManager', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('preload', () => {
it('creates an Image with the correct URL', () => {
vi.mocked(getAssetMediaUrl).mockReturnValue('/api/assets/123/media');
const asset = assetFactory.build();
imageManager.preload(asset);
expect(getAssetMediaUrl).toHaveBeenCalledWith({
id: asset.id,
size: AssetMediaSize.Preview,
cacheKey: asset.thumbhash,
});
});
it('does nothing for undefined asset', () => {
imageManager.preload(undefined);
expect(getAssetMediaUrl).not.toHaveBeenCalled();
});
it('does nothing when getAssetMediaUrl returns falsy', () => {
vi.mocked(getAssetMediaUrl).mockReturnValue('');
const asset = assetFactory.build();
imageManager.preload(asset);
expect(getAssetMediaUrl).toHaveBeenCalled();
});
it('uses the specified size', () => {
vi.mocked(getAssetMediaUrl).mockReturnValue('/api/assets/123/media');
const asset = assetFactory.build();
imageManager.preload(asset, AssetMediaSize.Thumbnail);
expect(getAssetMediaUrl).toHaveBeenCalledWith({
id: asset.id,
size: AssetMediaSize.Thumbnail,
cacheKey: asset.thumbhash,
});
});
});
describe('cancel', () => {
it('calls cancelImageUrl with the correct URL', () => {
vi.mocked(getAssetMediaUrl).mockReturnValue('/api/assets/123/media');
const asset = assetFactory.build();
imageManager.cancel(asset, AssetMediaSize.Preview);
expect(cancelImageUrl).toHaveBeenCalledWith('/api/assets/123/media');
});
it('does nothing for undefined asset', () => {
imageManager.cancel(undefined);
expect(getAssetMediaUrl).not.toHaveBeenCalled();
expect(cancelImageUrl).not.toHaveBeenCalled();
});
it('cancels all sizes when size is "all"', () => {
vi.mocked(getAssetMediaUrl).mockImplementation(({ size }) => `/api/assets/123/${size}`);
const asset = assetFactory.build();
imageManager.cancel(asset, 'all');
expect(getAssetMediaUrl).toHaveBeenCalledTimes(Object.values(AssetMediaSize).length);
for (const size of Object.values(AssetMediaSize)) {
expect(cancelImageUrl).toHaveBeenCalledWith(`/api/assets/123/${size}`);
}
});
it('does not call cancelImageUrl when URL is falsy', () => {
vi.mocked(getAssetMediaUrl).mockReturnValue('');
const asset = assetFactory.build();
imageManager.cancel(asset, AssetMediaSize.Preview);
expect(cancelImageUrl).not.toHaveBeenCalled();
});
});
});
+5 -7
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { handleResetPinCode } from '$lib/services/user.service'; import { handleResetPinCode } from '$lib/services/user.service';
import { Field, FormModal, HelperText, Modal, ModalBody, PasswordInput, Stack, type ModalSize } from '@immich/ui'; import { BasicModal, Field, FormModal, HelperText, PasswordInput, Stack, type ModalSize } from '@immich/ui';
import { mdiLockReset } from '@mdi/js'; import { mdiLockReset } from '@mdi/js';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -23,7 +23,7 @@
const common = $derived({ title: $t('reset'), size: 'small' as ModalSize, icon: mdiLockReset, onClose }); const common = $derived({ title: $t('reset'), size: 'small' as ModalSize, icon: mdiLockReset, onClose });
</script> </script>
{#if featureFlagsManager.value.passwordLogin === false} {#if featureFlagsManager.value.passwordLogin}
<FormModal {...common} submitColor="danger" submitText={$t('reset')} disabled={!password} {onSubmit}> <FormModal {...common} submitColor="danger" submitText={$t('reset')} disabled={!password} {onSubmit}>
<Stack gap={4}> <Stack gap={4}>
<div>{$t('reset_pin_code_description')}</div> <div>{$t('reset_pin_code_description')}</div>
@@ -37,9 +37,7 @@
</Stack> </Stack>
</FormModal> </FormModal>
{:else} {:else}
<Modal {...common} closeOnBackdropClick> <BasicModal {...common}>
<ModalBody> <div>{$t('reset_pin_code_description')}</div>
<div>{$t('reset_pin_code_description')}</div> </BasicModal>
</ModalBody>
</Modal>
{/if} {/if}