Compare commits

..

1 Commits

Author SHA1 Message Date
midzelis c9bc84c6a7 refactor: rename image cancel method 2026-02-20 02:57:13 +00:00
23 changed files with 281 additions and 198 deletions
+24 -3
View File
@@ -2,7 +2,6 @@
"name": "Immich - Backend, Frontend and ML",
"service": "immich-server",
"runServices": [
"immich-init",
"immich-server",
"redis",
"database",
@@ -32,8 +31,29 @@
"tasks": {
"version": "2.0.0",
"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)",
"dependsOn": ["Fix Permissions, Install Dependencies"],
"type": "shell",
"command": "[ -f /immich-devcontainer/container-start-backend.sh ] && /immich-devcontainer/container-start-backend.sh || exit 0",
"isBackground": true,
@@ -54,6 +74,7 @@
},
{
"label": "Immich Web Server (Vite)",
"dependsOn": ["Fix Permissions, Install Dependencies"],
"type": "shell",
"command": "[ -f /immich-devcontainer/container-start-frontend.sh ] && /immich-devcontainer/container-start-frontend.sh || exit 0",
"isBackground": true,
@@ -109,8 +130,8 @@
}
},
"overrideCommand": true,
"workspaceFolder": "/usr/src/app",
"remoteUser": "root",
"workspaceFolder": "/workspaces/immich",
"remoteUser": "node",
"userEnvProbe": "loginInteractiveShell",
"remoteEnv": {
// The location where your uploaded files are stored
@@ -1,17 +1,23 @@
services:
immich-app-base:
image: busybox
immich-server:
extends:
service: immich-app-base
profiles: !reset []
image: immich-server-dev:latest
build:
target: dev-container-mobile
environment:
- IMMICH_SERVER_URL=http://127.0.0.1:2283/
volumes:
volumes: !override # bind mount host to /workspaces/immich
- ..:/workspaces/immich
- ${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
immich-web:
env_file: !reset []
+1 -2
View File
@@ -2,7 +2,6 @@
"name": "Immich - Mobile",
"service": "immich-server",
"runServices": [
"immich-init",
"immich-server",
"redis",
"database",
@@ -36,7 +35,7 @@
},
"forwardPorts": [],
"overrideCommand": true,
"workspaceFolder": "/usr/src/app",
"workspaceFolder": "/workspaces/immich",
"remoteUser": "node",
"userEnvProbe": "loginInteractiveShell",
"remoteEnv": {
+50 -1
View File
@@ -2,6 +2,11 @@
export IMMICH_PORT="${DEV_SERVER_PORT:-2283}"
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"
log() {
@@ -25,8 +30,52 @@ run_cmd() {
return "${PIPESTATUS[0]}"
}
export IMMICH_WORKSPACE="/usr/src/app"
# Find directories excluding /workspaces/immich
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 ""
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,21 +1,26 @@
services:
immich-app-base:
image: busybox
immich-server:
extends:
service: immich-app-base
profiles: !reset []
image: immich-server-dev:latest
build:
target: dev-container-server
env_file: !reset []
hostname: immich-dev
environment:
- IMMICH_SERVER_URL=http://127.0.0.1:2283/
volumes:
volumes: !override
- ..:/workspaces/immich
- ${UPLOAD_LOCATION:-upload-devcontainer-volume}${UPLOAD_LOCATION:+/photos}:/data
- /etc/localtime:/etc/localtime:ro
- pnpm_store_server:/buildcache/pnpm-store
- 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
- ../plugins:/build/corePlugin
immich-web:
env_file: !reset []
+17
View File
@@ -0,0 +1,17 @@
#!/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"
+5 -11
View File
@@ -4,18 +4,12 @@ module.exports = {
if (!pkg.name) {
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") {
const binaryPackage =
process.platform === "win32"
? "exiftool-vendored.exe"
: "exiftool-vendored.pl";
if (pkg.optionalDependencies[binaryPackage]) {
pkg.dependencies[binaryPackage] =
pkg.optionalDependencies[binaryPackage];
delete pkg.optionalDependencies[binaryPackage];
if (pkg.optionalDependencies["exiftool-vendored.pl"]) {
// make exiftool-vendored.pl a regular dependency
pkg.dependencies["exiftool-vendored.pl"] =
pkg.optionalDependencies["exiftool-vendored.pl"];
delete pkg.optionalDependencies["exiftool-vendored.pl"];
}
}
return pkg;
+1 -91
View File
@@ -7,15 +7,7 @@ import { describe, expect, it, MockedFunction, vi } from 'vitest';
import { Action, checkBulkUpload, defaults, getSupportedMediaTypes, Reason } from '@immich/sdk';
import createFetchMock from 'vitest-fetch-mock';
import {
checkForDuplicates,
deleteFiles,
findSidecar,
getAlbumName,
startWatch,
uploadFiles,
UploadOptionsDto,
} from 'src/commands/asset';
import { checkForDuplicates, getAlbumName, startWatch, uploadFiles, UploadOptionsDto } from 'src/commands/asset';
vi.mock('@immich/sdk');
@@ -317,85 +309,3 @@ describe('startWatch', () => {
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);
});
});
+22 -32
View File
@@ -17,7 +17,7 @@ import { Matcher, watch as watchFs } from 'chokidar';
import { MultiBar, Presets, SingleBar } from 'cli-progress';
import { chunk } from 'lodash-es';
import micromatch from 'micromatch';
import { Stats, createReadStream, existsSync } from 'node:fs';
import { Stats, createReadStream } from 'node:fs';
import { stat, unlink } from 'node:fs/promises';
import path, { basename } from 'node:path';
import { Queue } from 'src/queue';
@@ -403,6 +403,23 @@ export const uploadFiles = async (
const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaResponseDto> => {
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();
formData.append('deviceAssetId', `${basename(input)}-${stats.size}`.replaceAll(/\s+/g, ''));
formData.append('deviceId', 'CLI');
@@ -412,15 +429,8 @@ const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaRespon
formData.append('isFavorite', 'false');
formData.append('assetData', new UploadFile(input, stats.size));
const sidecarPath = findSidecar(input);
if (sidecarPath) {
try {
const stats = await stat(sidecarPath);
const sidecarData = new UploadFile(sidecarPath, stats.size);
formData.append('sidecarData', sidecarData);
} catch {
// noop
}
if (sidecarData) {
formData.append('sidecarData', sidecarData);
}
const response = await fetch(`${baseUrl}/assets`, {
@@ -436,19 +446,7 @@ const uploadFile = async (input: string, stats: Stats): Promise<AssetMediaRespon
return response.json();
};
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> => {
const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], options: UploadOptionsDto): Promise<void> => {
let fileCount = 0;
if (options.delete) {
fileCount += uploaded.length;
@@ -476,15 +474,7 @@ export const deleteFiles = async (uploaded: Asset[], duplicates: Asset[], option
const chunkDelete = async (files: Asset[]) => {
for (const assetBatch of chunk(files, options.concurrency)) {
await Promise.all(
assetBatch.map(async (input: Asset) => {
await unlink(input.filepath);
const sidecarPath = findSidecar(input.filepath);
if (sidecarPath) {
await unlink(sidecarPath);
}
}),
);
await Promise.all(assetBatch.map((input: Asset) => unlink(input.filepath)));
deletionProgress.update(assetBatch.length);
}
};
+1 -2
View File
@@ -253,8 +253,7 @@ describe('/asset', () => {
expect(status).toBe(200);
expect(body.id).toEqual(facesAsset.id);
const sortedPeople = body.people.toSorted((a: any, b: any) => a.name.localeCompare(b.name));
expect(sortedPeople).toMatchObject(expectedFaces);
expect(body.people).toMatchObject(expectedFaces);
});
});
+5 -2
View File
@@ -65,7 +65,7 @@ export const thumbnailUtils = {
return page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"] button`);
},
selectedAsset(page: Page) {
return page.locator('[data-thumbnail-focus-container][data-selected]');
return page.locator('[data-thumbnail-focus-container]:has(button[aria-checked])');
},
async clickAssetId(page: Page, assetId: string) {
await thumbnailUtils.withAssetId(page, assetId).click();
@@ -103,8 +103,11 @@ export const thumbnailUtils = {
await expect(thumbnailUtils.withAssetId(page, assetId).locator('[data-icon-archive]')).toHaveCount(0);
},
async expectSelectedReadonly(page: Page, assetId: string) {
// todo - need a data attribute for selected
await expect(
page.locator(`[data-thumbnail-focus-container][data-asset="${assetId}"][data-selected]`),
page.locator(
`[data-thumbnail-focus-container][data-asset="${assetId}"] > .group.cursor-not-allowed > .rounded-xl`,
),
).toBeVisible();
},
async expectTimelineHasOnScreenAssets(page: Page) {
+1
View File
@@ -8,6 +8,7 @@ readme = "README.md"
dependencies = [
"aiocache>=0.12.1,<1.0",
"fastapi>=0.95.2,<1.0",
"ftfy>=6.1.1",
"gunicorn>=21.1.0",
"huggingface-hub>=0.20.1,<1.0",
"insightface>=0.7.3,<1.0",
@@ -150,12 +150,10 @@ class DriftSearchPage extends HookConsumerWidget {
handleOnSelect(Set<PersonDto> value) {
filter.value = filter.value.copyWith(people: value);
final label = value.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', ');
if (label.isNotEmpty) {
peopleCurrentFilterWidget.value = Text(label, style: context.textTheme.labelLarge);
} else {
peopleCurrentFilterWidget.value = null;
}
peopleCurrentFilterWidget.value = Text(
value.map((e) => e.name != '' ? e.name : 'no_name'.t(context: context)).join(', '),
style: context.textTheme.labelLarge,
);
}
handleClear() {
@@ -698,7 +696,7 @@ class DriftSearchPage extends HookConsumerWidget {
label: 'search_filter_location'.t(context: context),
currentFilter: locationCurrentFilterWidget.value,
),
if (userPreferences.valueOrNull?.tagsEnabled ?? false)
if (userPreferences.value?.tagsEnabled ?? false)
SearchFilterChip(
icon: Icons.sell_outlined,
onTap: showTagPicker,
@@ -724,13 +722,14 @@ class DriftSearchPage extends HookConsumerWidget {
label: 'search_filter_media_type'.t(context: context),
currentFilter: mediaTypeCurrentFilterWidget.value,
),
if (userPreferences.valueOrNull?.ratingsEnabled ?? false)
if (userPreferences.value?.ratingsEnabled ?? false) ...[
SearchFilterChip(
icon: Icons.star_outline_rounded,
onTap: showStarRatingPicker,
label: 'search_filter_star_rating'.t(context: context),
currentFilter: ratingCurrentFilterWidget.value,
),
],
SearchFilterChip(
icon: Icons.display_settings_outlined,
onTap: showDisplayOptionPicker,
@@ -60,7 +60,8 @@ class AppearsInDetails extends ConsumerWidget {
album: album,
isOwner: isOwner,
onAlbumSelected: (album) async {
unawaited(context.router.push(RemoteAlbumRoute(album: album)));
ref.invalidate(assetViewerProvider);
unawaited(context.router.popAndPush(RemoteAlbumRoute(album: album)));
},
);
}).toList(),
@@ -74,7 +74,6 @@ class Timeline extends StatefulWidget {
this.snapToMonth = true,
this.initialScrollOffset,
this.readOnly = false,
this.persistentBottomBar = false,
});
final Widget? topSliverWidget;
@@ -88,7 +87,6 @@ class Timeline extends StatefulWidget {
final bool snapToMonth;
final double? initialScrollOffset;
final bool readOnly;
final bool persistentBottomBar;
@override
State<Timeline> createState() => _TimelineState();
@@ -145,7 +143,6 @@ class _TimelineState extends State<Timeline> {
appBar: widget.appBar,
bottomSheet: widget.bottomSheet,
withScrubber: widget.withScrubber,
persistentBottomBar: widget.persistentBottomBar,
snapToMonth: widget.snapToMonth,
initialScrollOffset: widget.initialScrollOffset,
),
@@ -176,7 +173,6 @@ class _SliverTimeline extends ConsumerStatefulWidget {
this.appBar,
this.bottomSheet,
this.withScrubber = true,
this.persistentBottomBar = false,
this.snapToMonth = true,
this.initialScrollOffset,
});
@@ -186,7 +182,6 @@ class _SliverTimeline extends ConsumerStatefulWidget {
final Widget? appBar;
final Widget? bottomSheet;
final bool withScrubber;
final bool persistentBottomBar;
final bool snapToMonth;
final double? initialScrollOffset;
@@ -409,9 +404,6 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
final isSelectionMode = ref.watch(multiSelectProvider.select((s) => s.forceEnable));
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
final isMultiSelectStatusVisible = !isSelectionMode && isMultiSelectEnabled;
final isBottomWidgetVisible =
widget.bottomSheet != null && (isMultiSelectStatusVisible || widget.persistentBottomBar);
return PopScope(
canPop: !isMultiSelectEnabled,
@@ -527,7 +519,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
child: Stack(
children: [
timeline,
if (isMultiSelectStatusVisible)
if (!isSelectionMode && isMultiSelectEnabled) ...[
Positioned(
top: MediaQuery.paddingOf(context).top,
left: 25,
@@ -536,7 +528,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
child: Center(child: _MultiSelectStatusButton()),
),
),
if (isBottomWidgetVisible) widget.bottomSheet!,
if (widget.bottomSheet != null) widget.bottomSheet!,
],
],
),
),
+1 -1
View File
@@ -11,7 +11,7 @@ overrides:
packageExtensionsChecksum: sha256-3l4AQg4iuprBDup+q+2JaPvbPg/7XodWCE0ZteH+s54=
pnpmfileChecksum: sha256-un98do36L0wZyqsjcLozQ3YUadCAn2yz5bXcBbOuyDA=
pnpmfileChecksum: sha256-AG/qwrPNpmy9q60PZwCpecoYVptglTHgH+N6RKQHOM0=
importers:
+6 -4
View File
@@ -27,14 +27,16 @@ ENTRYPOINT ["tini", "--", "/bin/bash", "-c"]
FROM dev AS dev-container-server
RUN apt-get update --allow-releaseinfo-change && \
apt-get install inetutils-ping openjdk-21-jre-headless \
apt-get install sudo inetutils-ping openjdk-21-jre-headless \
vim nano curl \
-y --no-install-recommends --fix-missing
RUN mkdir -p /workspaces && \
ln -s /usr/src/app /workspaces/immich
RUN usermod -aG sudo node && \
echo "node ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers && \
mkdir -p /workspaces/immich
COPY --chmod=755 ../.devcontainer/server/*.sh /immich-devcontainer/
RUN chown node:node -R /workspaces
COPY --chown=node:node --chmod=755 ../.devcontainer/server/*.sh /immich-devcontainer/
WORKDIR /workspaces/immich
@@ -159,7 +159,7 @@
imageError = imageLoaded = true;
};
onDestroy(() => imageManager.cancelPreloadUrl(imageLoaderUrl));
onDestroy(() => imageManager.cancel(asset, targetImageSize));
let imageLoaderUrl = $derived(
getAssetUrl({ asset, sharedLink, forceOriginal: originalImageLoaded || assetViewerManager.zoom > 1 }),
@@ -1,6 +1,6 @@
<script lang="ts">
import BrokenAsset from '$lib/components/assets/broken-asset.svelte';
import { imageManager } from '$lib/managers/ImageManager.svelte';
import { cancelImageUrl } from '$lib/utils/sw-messaging';
import { Icon } from '@immich/ui';
import { mdiEyeOffOutline } from '@mdi/js';
import type { ActionReturn } from 'svelte/action';
@@ -60,7 +60,7 @@
onComplete?.(false);
}
return {
destroy: () => imageManager.cancelPreloadUrl(url),
destroy: () => cancelImageUrl(url),
};
}
@@ -223,7 +223,6 @@
bind:this={element}
data-asset={asset.id}
data-thumbnail-focus-container
data-selected={selected ? true : undefined}
tabindex={0}
role="link"
>
@@ -32,12 +32,6 @@ class ImageManager {
}
}
}
cancelPreloadUrl(url: string | undefined) {
if (url) {
cancelImageUrl(url);
}
}
}
export const imageManager = new ImageManager();
@@ -0,0 +1,99 @@
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();
});
});
});
+7 -5
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { handleResetPinCode } from '$lib/services/user.service';
import { BasicModal, Field, FormModal, HelperText, PasswordInput, Stack, type ModalSize } from '@immich/ui';
import { Field, FormModal, HelperText, Modal, ModalBody, PasswordInput, Stack, type ModalSize } from '@immich/ui';
import { mdiLockReset } from '@mdi/js';
import { t } from 'svelte-i18n';
@@ -23,7 +23,7 @@
const common = $derived({ title: $t('reset'), size: 'small' as ModalSize, icon: mdiLockReset, onClose });
</script>
{#if featureFlagsManager.value.passwordLogin}
{#if featureFlagsManager.value.passwordLogin === false}
<FormModal {...common} submitColor="danger" submitText={$t('reset')} disabled={!password} {onSubmit}>
<Stack gap={4}>
<div>{$t('reset_pin_code_description')}</div>
@@ -37,7 +37,9 @@
</Stack>
</FormModal>
{:else}
<BasicModal {...common}>
<div>{$t('reset_pin_code_description')}</div>
</BasicModal>
<Modal {...common} closeOnBackdropClick>
<ModalBody>
<div>{$t('reset_pin_code_description')}</div>
</ModalBody>
</Modal>
{/if}