Compare commits

...

15 Commits

Author SHA1 Message Date
Jason Rasmussen
a5e88f41ea refactor!: remove replace asset 2026-03-18 15:14:50 -04:00
Jason Rasmussen
77020e742a fix: validate accept header before returning html (#27019) 2026-03-18 14:15:48 -04:00
Jason Rasmussen
38b135ff36 fix: bounding box return type (#27014) 2026-03-18 11:58:40 -04:00
Jason Rasmussen
cda4a2a5fc fix: filter after searching by asset id (#26994)
* fix: filter after searching by asset id

* Update web/src/lib/modals/SearchFilterModal.svelte

Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>

---------

Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
2026-03-18 13:32:54 +00:00
Min Idzelis
88002cf7fe fix(web): allow images to be downloaded again(long-press or right click) (#26992) 2026-03-18 12:40:36 +01:00
Andreas Heinz
694ea151f5 fix(web): escape handling for tagging and adding a face in asset viewer (#26870) 2026-03-18 12:39:25 +01:00
Jason Rasmussen
b092c8b601 fix: healthcheck (#26989) 2026-03-17 17:54:39 -04:00
Jason Rasmussen
48e6e17829 feat: primary notifications (#26988) 2026-03-17 17:54:11 -04:00
Jason Rasmussen
0519833d75 refactor: prefer tv (#26993) 2026-03-17 17:53:48 -04:00
Thomas
34caed3b2b fix(server): flaky metadata tests (#26964) 2026-03-17 18:06:22 +01:00
Thomas
677cb660f5 fix(mobile): reflect asset deletions instantly (#26835)
Sometimes the current asset won't update when deleted, or it won't
refresh until an event (like showing details) happens.
2026-03-17 06:43:14 -05:00
Michel Heusschen
9b0b2bfcf2 fix(web): jump to primary stacked asset from memory (#26978) 2026-03-17 06:39:39 -05:00
Preslav Penchev
ac6938a629 fix(web): allow pasting PIN code from clipboard or password manager (#26944)
* fix(web): allow pasting PIN code from clipboard or password manager

The keydown handler was blocking Ctrl+V/Cmd+V since it called
preventDefault() on all non-numeric keys. Also adds an onpaste
handler to distribute pasted digits across the individual inputs.

* refactor: handle paste in handleInput, remove maxlength

* cleanup + fix digit focus

---------

Co-authored-by: Preslav Penchev <preslav.penchev@acronis.com>
Co-authored-by: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com>
2026-03-17 06:38:06 -05:00
Thomas
16749ff8ba fix(server): sync files to disk (#26881)
Ensure that all files are flushed after they've been written.

At current, files are not explicitly flushed to disk, which can cause
data corruption. In extreme circumstances, it's possible that uploaded
files may not ever be persisted at all.
2026-03-17 06:33:43 -05:00
renovate[bot]
bba4a00eb1 chore(deps): update github-actions (#26967)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-17 10:40:22 +01:00
106 changed files with 479 additions and 1100 deletions

View File

@@ -24,7 +24,7 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@65fef71494258f00f911d7a71edb0482c5378899 # v0.0.30
uses: oasdiff/oasdiff-action/breaking@748daafaf3aac877a36307f842a48d55db938ac8 # v0.0.31
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json

View File

@@ -42,10 +42,10 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './cli/.nvmrc'
registry-url: 'https://registry.npmjs.org'

View File

@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -70,7 +70,7 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
uses: github/codeql-action/autobuild@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
@@ -83,6 +83,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
with:
category: '/language:${{matrix.language}}'

View File

@@ -67,10 +67,10 @@ jobs:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './docs/.nvmrc'
cache: 'pnpm'

View File

@@ -29,10 +29,10 @@ jobs:
persist-credentials: true
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'

View File

@@ -63,13 +63,13 @@ jobs:
ref: main
- name: Install uv
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'

View File

@@ -30,10 +30,10 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
# Setup .npmrc file to publish to npm
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './open-api/typescript-sdk/.nvmrc'
registry-url: 'https://registry.npmjs.org'

View File

@@ -75,9 +75,9 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -119,9 +119,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
@@ -166,9 +166,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './cli/.nvmrc'
cache: 'pnpm'
@@ -208,9 +208,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -252,9 +252,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -290,9 +290,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './web/.nvmrc'
cache: 'pnpm'
@@ -338,9 +338,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -385,9 +385,9 @@ jobs:
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -424,9 +424,9 @@ jobs:
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -496,9 +496,9 @@ jobs:
submodules: 'recursive'
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './e2e/.nvmrc'
cache: 'pnpm'
@@ -620,7 +620,7 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Install uv
uses: astral-sh/setup-uv@eac588ad8def6316056a12d4907a9d4d84ff7a3b # v7.3.0
uses: astral-sh/setup-uv@6ee6290f1cbc4156c0bdd66691b2c144ef8df19a # v7.4.0
with:
python-version: 3.11
- name: Install dependencies
@@ -661,9 +661,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './.github/.nvmrc'
cache: 'pnpm'
@@ -712,9 +712,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'
@@ -774,9 +774,9 @@ jobs:
persist-credentials: false
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
- name: Setup Node
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version-file: './server/.nvmrc'
cache: 'pnpm'

View File

@@ -81,7 +81,7 @@ export const connect = async (url: string, key: string) => {
const [error] = await withError(getMyUser());
if (isHttpError(error)) {
logError(error, 'Failed to connect to server');
logError(error, `Failed to connect to server ${url}`);
process.exit(1);
}

View File

@@ -64,17 +64,17 @@ test.describe('Photo Viewer', () => {
await expect(original).toHaveAttribute('src', /fullsize/);
});
test('reloads photo when checksum changes', async ({ page }) => {
test('right-click targets the img element', async ({ page }) => {
await page.goto(`/photos/${asset.id}`);
const preview = page.getByTestId('preview').filter({ visible: true });
await expect(preview).toHaveAttribute('src', /.+/);
const initialSrc = await preview.getAttribute('src');
const websocketEvent = utils.waitForWebsocketEvent({ event: 'assetUpdate', id: asset.id });
await utils.replaceAsset(admin.accessToken, asset.id);
await websocketEvent;
await expect(preview).not.toHaveAttribute('src', initialSrc!);
const box = await preview.boundingBox();
const tagAtCenter = await page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.tagName, {
x: box!.x + box!.width / 2,
y: box!.y + box!.height / 2,
});
expect(tagAtCenter).toBe('IMG');
});
});

View File

@@ -375,40 +375,6 @@ export const utils = {
return body as AssetMediaResponseDto;
},
replaceAsset: async (
accessToken: string,
assetId: string,
dto?: Partial<Omit<AssetMediaCreateDto, 'assetData'>> & { assetData?: FileData },
) => {
const _dto = {
deviceAssetId: 'test-1',
deviceId: 'test',
fileCreatedAt: new Date().toISOString(),
fileModifiedAt: new Date().toISOString(),
...dto,
};
const assetData = dto?.assetData?.bytes || makeRandomImage();
const filename = dto?.assetData?.filename || 'example.png';
if (dto?.assetData?.bytes) {
console.log(`Uploading ${filename}`);
}
const builder = request(app)
.put(`/assets/${assetId}/original`)
.attach('assetData', assetData, filename)
.set('Authorization', `Bearer ${accessToken}`);
for (const [key, value] of Object.entries(_dto)) {
void builder.field(key, String(value));
}
const { body } = await builder;
return body as AssetMediaResponseDto;
},
createImageFile: (path: string) => {
if (!existsSync(dirname(path))) {
mkdirSync(dirname(path), { recursive: true });

View File

@@ -14,13 +14,13 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
Future<void> performArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
if (!context.mounted) return;
final result = await ref.read(actionProvider.notifier).archive(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).archive(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'archive_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {

View File

@@ -57,13 +57,13 @@ class DeleteActionButton extends ConsumerWidget {
if (confirm != true) return;
}
final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).trashRemoteAndDeleteLocal(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'delete_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {

View File

@@ -35,13 +35,13 @@ class DeletePermanentActionButton extends ConsumerWidget {
false;
if (!confirm) return;
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).deleteRemoteAndLocal(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'delete_permanently_action_prompt'.t(
context: context,
args: {'count': result.count.toString()},

View File

@@ -14,13 +14,13 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
Future<void> performMoveToLockFolderAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
if (!context.mounted) return;
final result = await ref.read(actionProvider.notifier).moveToLockFolder(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).moveToLockFolder(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'move_to_lock_folder_action_prompt'.t(
context: context,
args: {'count': result.count.toString()},

View File

@@ -29,13 +29,13 @@ class RemoveFromAlbumActionButton extends ConsumerWidget {
return;
}
final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).removeFromAlbum(source, albumId);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'remove_from_album_action_prompt'.t(
context: context,
args: {'count': result.count.toString()},

View File

@@ -25,13 +25,13 @@ class TrashActionButton extends ConsumerWidget {
return;
}
final result = await ref.read(actionProvider.notifier).trash(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).trash(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'trash_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {

View File

@@ -16,13 +16,13 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
Future<void> performUnArchiveAction(BuildContext context, WidgetRef ref, {required ActionSource source}) async {
if (!context.mounted) return;
final result = await ref.read(actionProvider.notifier).unArchive(source);
ref.read(multiSelectProvider.notifier).reset();
if (source == ActionSource.viewer) {
EventStream.shared.emit(const ViewerReloadAssetEvent());
}
final result = await ref.read(actionProvider.notifier).unArchive(source);
ref.read(multiSelectProvider.notifier).reset();
final successMessage = 'unarchive_action_prompt'.t(context: context, args: {'count': result.count.toString()});
if (context.mounted) {

View File

@@ -81,19 +81,17 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
late final _preloader = AssetPreloader(timelineService: ref.read(timelineServiceProvider), mounted: () => mounted);
late int _currentPage = widget.initialIndex;
late int _totalAssets = ref.read(timelineServiceProvider).totalAssets;
StreamSubscription? _reloadSubscription;
KeepAliveLink? _stackChildrenKeepAlive;
bool _assetReloadRequested = false;
void _onTapNavigate(int direction) {
final page = _pageController.page?.toInt();
if (page == null) return;
final target = page + direction;
final maxPage = ref.read(timelineServiceProvider).totalAssets - 1;
final maxPage = _totalAssets - 1;
if (target >= 0 && target <= maxPage) {
_currentPage = target;
_pageController.jumpToPage(target);
_onAssetChanged(target);
}
@@ -141,7 +139,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final page = _pageController.page?.round();
if (page != null && page != _currentPage) {
_currentPage = page;
_onAssetChanged(page);
}
return false;
@@ -153,8 +150,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
void _onAssetChanged(int index) async {
final timelineService = ref.read(timelineServiceProvider);
final asset = await timelineService.getAssetAsync(index);
_currentPage = index;
final asset = await ref.read(timelineServiceProvider).getAssetAsync(index);
if (asset == null) return;
AssetViewer._setAsset(ref, asset);
@@ -193,11 +191,20 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
case TimelineReloadEvent():
_onTimelineReloadEvent();
case ViewerReloadAssetEvent():
_assetReloadRequested = true;
_onViewerReloadEvent();
default:
}
}
void _onViewerReloadEvent() {
if (_totalAssets <= 1) return;
final index = _pageController.page?.round() ?? 0;
final target = index >= _totalAssets - 1 ? index - 1 : index + 1;
_pageController.animateToPage(target, duration: Durations.medium1, curve: Curves.easeInOut);
_onAssetChanged(target);
}
void _onTimelineReloadEvent() {
final timelineService = ref.read(timelineServiceProvider);
final totalAssets = timelineService.totalAssets;
@@ -207,43 +214,24 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
return;
}
var index = _pageController.page?.round() ?? 0;
final currentAsset = ref.read(assetViewerProvider).currentAsset;
if (currentAsset != null) {
final newIndex = timelineService.getIndex(currentAsset.heroTag);
if (newIndex != null && newIndex != index) {
index = newIndex;
_currentPage = index;
_pageController.jumpToPage(index);
}
}
final assetIndex = currentAsset != null ? timelineService.getIndex(currentAsset.heroTag) : null;
final index = (assetIndex ?? _currentPage).clamp(0, totalAssets - 1);
if (index >= totalAssets) {
index = totalAssets - 1;
_currentPage = index;
if (index != _currentPage) {
_pageController.jumpToPage(index);
_onAssetChanged(index);
} else if (currentAsset != null && assetIndex == null) {
_onAssetChanged(index);
}
if (_assetReloadRequested) {
_assetReloadRequested = false;
_onAssetReloadEvent(index);
if (_totalAssets != totalAssets) {
setState(() {
_totalAssets = totalAssets;
});
}
}
void _onAssetReloadEvent(int index) async {
final timelineService = ref.read(timelineServiceProvider);
final newAsset = await timelineService.getAssetAsync(index);
if (newAsset == null) return;
final currentAsset = ref.read(assetViewerProvider).currentAsset;
// Do not reload if the asset has not changed
if (newAsset.heroTag == currentAsset?.heroTag) return;
_onAssetChanged(index);
}
void _setSystemUIMode(bool controls, bool details) {
final mode = !controls || (CurrentPlatform.isIOS && details)
? SystemUiMode.immersiveSticky
@@ -301,7 +289,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
: CurrentPlatform.isIOS
? const FastScrollPhysics()
: const FastClampingScrollPhysics(),
itemCount: ref.read(timelineServiceProvider).totalAssets,
itemCount: _totalAssets,
itemBuilder: (context, index) =>
AssetPage(index: index, heroOffset: _heroOffset, onTapNavigate: _onTapNavigate),
),

View File

@@ -113,7 +113,6 @@ Class | Method | HTTP request | Description
*AssetsApi* | [**getRandom**](doc//AssetsApi.md#getrandom) | **GET** /assets/random | Get random assets
*AssetsApi* | [**playAssetVideo**](doc//AssetsApi.md#playassetvideo) | **GET** /assets/{id}/video/playback | Play asset video
*AssetsApi* | [**removeAssetEdits**](doc//AssetsApi.md#removeassetedits) | **DELETE** /assets/{id}/edits | Remove edits from an existing asset
*AssetsApi* | [**replaceAsset**](doc//AssetsApi.md#replaceasset) | **PUT** /assets/{id}/original | Replace asset
*AssetsApi* | [**runAssetJobs**](doc//AssetsApi.md#runassetjobs) | **POST** /assets/jobs | Run an asset job
*AssetsApi* | [**updateAsset**](doc//AssetsApi.md#updateasset) | **PUT** /assets/{id} | Update an asset
*AssetsApi* | [**updateAssetMetadata**](doc//AssetsApi.md#updateassetmetadata) | **PUT** /assets/{id}/metadata | Update asset metadata
@@ -149,7 +148,6 @@ Class | Method | HTTP request | Description
*DeprecatedApi* | [**getFullSyncForUser**](doc//DeprecatedApi.md#getfullsyncforuser) | **POST** /sync/full-sync | Get full sync for user
*DeprecatedApi* | [**getQueuesLegacy**](doc//DeprecatedApi.md#getqueueslegacy) | **GET** /jobs | Retrieve queue counts and status
*DeprecatedApi* | [**getRandom**](doc//DeprecatedApi.md#getrandom) | **GET** /assets/random | Get random assets
*DeprecatedApi* | [**replaceAsset**](doc//DeprecatedApi.md#replaceasset) | **PUT** /assets/{id}/original | Replace asset
*DeprecatedApi* | [**runQueueCommandLegacy**](doc//DeprecatedApi.md#runqueuecommandlegacy) | **PUT** /jobs/{name} | Run jobs
*DownloadApi* | [**downloadArchive**](doc//DownloadApi.md#downloadarchive) | **POST** /download/archive | Download asset archive
*DownloadApi* | [**getDownloadInfo**](doc//DownloadApi.md#getdownloadinfo) | **POST** /download/info | Retrieve download information

View File

@@ -1115,154 +1115,6 @@ class AssetsApi {
}
}
/// Replace asset
///
/// Replace the asset with new file, without changing its id.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [MultipartFile] assetData (required):
/// Asset file data
///
/// * [String] deviceAssetId (required):
/// Device asset ID
///
/// * [String] deviceId (required):
/// Device ID
///
/// * [DateTime] fileCreatedAt (required):
/// File creation date
///
/// * [DateTime] fileModifiedAt (required):
/// File modification date
///
/// * [String] key:
///
/// * [String] slug:
///
/// * [String] duration:
/// Duration (for videos)
///
/// * [String] filename:
/// Filename
Future<Response> replaceAssetWithHttpInfo(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? duration, String? filename, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/original'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (slug != null) {
queryParams.addAll(_queryParams('', 'slug', slug));
}
const contentTypes = <String>['multipart/form-data'];
bool hasFields = false;
final mp = MultipartRequest('PUT', Uri.parse(apiPath));
if (assetData != null) {
hasFields = true;
mp.fields[r'assetData'] = assetData.field;
mp.files.add(assetData);
}
if (deviceAssetId != null) {
hasFields = true;
mp.fields[r'deviceAssetId'] = parameterToString(deviceAssetId);
}
if (deviceId != null) {
hasFields = true;
mp.fields[r'deviceId'] = parameterToString(deviceId);
}
if (duration != null) {
hasFields = true;
mp.fields[r'duration'] = parameterToString(duration);
}
if (fileCreatedAt != null) {
hasFields = true;
mp.fields[r'fileCreatedAt'] = parameterToString(fileCreatedAt);
}
if (fileModifiedAt != null) {
hasFields = true;
mp.fields[r'fileModifiedAt'] = parameterToString(fileModifiedAt);
}
if (filename != null) {
hasFields = true;
mp.fields[r'filename'] = parameterToString(filename);
}
if (hasFields) {
postBody = mp;
}
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Replace asset
///
/// Replace the asset with new file, without changing its id.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [MultipartFile] assetData (required):
/// Asset file data
///
/// * [String] deviceAssetId (required):
/// Device asset ID
///
/// * [String] deviceId (required):
/// Device ID
///
/// * [DateTime] fileCreatedAt (required):
/// File creation date
///
/// * [DateTime] fileModifiedAt (required):
/// File modification date
///
/// * [String] key:
///
/// * [String] slug:
///
/// * [String] duration:
/// Duration (for videos)
///
/// * [String] filename:
/// Filename
Future<AssetMediaResponseDto?> replaceAsset(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? duration, String? filename, }) async {
final response = await replaceAssetWithHttpInfo(id, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, slug: slug, duration: duration, filename: filename, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetMediaResponseDto',) as AssetMediaResponseDto;
}
return null;
}
/// Run an asset job
///
/// Run a specific job on a set of assets.

View File

@@ -363,154 +363,6 @@ class DeprecatedApi {
return null;
}
/// Replace asset
///
/// Replace the asset with new file, without changing its id.
///
/// Note: This method returns the HTTP [Response].
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [MultipartFile] assetData (required):
/// Asset file data
///
/// * [String] deviceAssetId (required):
/// Device asset ID
///
/// * [String] deviceId (required):
/// Device ID
///
/// * [DateTime] fileCreatedAt (required):
/// File creation date
///
/// * [DateTime] fileModifiedAt (required):
/// File modification date
///
/// * [String] key:
///
/// * [String] slug:
///
/// * [String] duration:
/// Duration (for videos)
///
/// * [String] filename:
/// Filename
Future<Response> replaceAssetWithHttpInfo(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? duration, String? filename, }) async {
// ignore: prefer_const_declarations
final apiPath = r'/assets/{id}/original'
.replaceAll('{id}', id);
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
if (slug != null) {
queryParams.addAll(_queryParams('', 'slug', slug));
}
const contentTypes = <String>['multipart/form-data'];
bool hasFields = false;
final mp = MultipartRequest('PUT', Uri.parse(apiPath));
if (assetData != null) {
hasFields = true;
mp.fields[r'assetData'] = assetData.field;
mp.files.add(assetData);
}
if (deviceAssetId != null) {
hasFields = true;
mp.fields[r'deviceAssetId'] = parameterToString(deviceAssetId);
}
if (deviceId != null) {
hasFields = true;
mp.fields[r'deviceId'] = parameterToString(deviceId);
}
if (duration != null) {
hasFields = true;
mp.fields[r'duration'] = parameterToString(duration);
}
if (fileCreatedAt != null) {
hasFields = true;
mp.fields[r'fileCreatedAt'] = parameterToString(fileCreatedAt);
}
if (fileModifiedAt != null) {
hasFields = true;
mp.fields[r'fileModifiedAt'] = parameterToString(fileModifiedAt);
}
if (filename != null) {
hasFields = true;
mp.fields[r'filename'] = parameterToString(filename);
}
if (hasFields) {
postBody = mp;
}
return apiClient.invokeAPI(
apiPath,
'PUT',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Replace asset
///
/// Replace the asset with new file, without changing its id.
///
/// Parameters:
///
/// * [String] id (required):
///
/// * [MultipartFile] assetData (required):
/// Asset file data
///
/// * [String] deviceAssetId (required):
/// Device asset ID
///
/// * [String] deviceId (required):
/// Device ID
///
/// * [DateTime] fileCreatedAt (required):
/// File creation date
///
/// * [DateTime] fileModifiedAt (required):
/// File modification date
///
/// * [String] key:
///
/// * [String] slug:
///
/// * [String] duration:
/// Duration (for videos)
///
/// * [String] filename:
/// Filename
Future<AssetMediaResponseDto?> replaceAsset(String id, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, { String? key, String? slug, String? duration, String? filename, }) async {
final response = await replaceAssetWithHttpInfo(id, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, key: key, slug: slug, duration: duration, filename: filename, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetMediaResponseDto',) as AssetMediaResponseDto;
}
return null;
}
/// Run jobs
///
/// Queue all assets for a specific job type. Defaults to only queueing assets that have not yet been processed, but the force command can be used to re-process all assets.

View File

@@ -41,7 +41,6 @@ class Permission {
static const assetPeriodView = Permission._(r'asset.view');
static const assetPeriodDownload = Permission._(r'asset.download');
static const assetPeriodUpload = Permission._(r'asset.upload');
static const assetPeriodReplace = Permission._(r'asset.replace');
static const assetPeriodCopy = Permission._(r'asset.copy');
static const assetPeriodDerive = Permission._(r'asset.derive');
static const assetPeriodEditPeriodGet = Permission._(r'asset.edit.get');
@@ -200,7 +199,6 @@ class Permission {
assetPeriodView,
assetPeriodDownload,
assetPeriodUpload,
assetPeriodReplace,
assetPeriodCopy,
assetPeriodDerive,
assetPeriodEditPeriodGet,
@@ -394,7 +392,6 @@ class PermissionTypeTransformer {
case r'asset.view': return Permission.assetPeriodView;
case r'asset.download': return Permission.assetPeriodDownload;
case r'asset.upload': return Permission.assetPeriodUpload;
case r'asset.replace': return Permission.assetPeriodReplace;
case r'asset.copy': return Permission.assetPeriodCopy;
case r'asset.derive': return Permission.assetPeriodDerive;
case r'asset.edit.get': return Permission.assetPeriodEditPeriodGet;

View File

@@ -4216,89 +4216,6 @@
],
"x-immich-permission": "asset.download",
"x-immich-state": "Stable"
},
"put": {
"deprecated": true,
"description": "Replace the asset with new file, without changing its id.",
"operationId": "replaceAsset",
"parameters": [
{
"name": "id",
"required": true,
"in": "path",
"schema": {
"format": "uuid",
"type": "string"
}
},
{
"name": "key",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
},
{
"name": "slug",
"required": false,
"in": "query",
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/AssetMediaReplaceDto"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetMediaResponseDto"
}
}
},
"description": "Asset replaced successfully"
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Replace asset",
"tags": [
"Assets",
"Deprecated"
],
"x-immich-history": [
{
"version": "v1",
"state": "Added"
},
{
"version": "v1",
"state": "Deprecated",
"replacementId": "copyAsset"
}
],
"x-immich-permission": "asset.replace",
"x-immich-state": "Deprecated"
}
},
"/assets/{id}/thumbnail": {
@@ -16610,49 +16527,6 @@
],
"type": "object"
},
"AssetMediaReplaceDto": {
"properties": {
"assetData": {
"description": "Asset file data",
"format": "binary",
"type": "string"
},
"deviceAssetId": {
"description": "Device asset ID",
"type": "string"
},
"deviceId": {
"description": "Device ID",
"type": "string"
},
"duration": {
"description": "Duration (for videos)",
"type": "string"
},
"fileCreatedAt": {
"description": "File creation date",
"format": "date-time",
"type": "string"
},
"fileModifiedAt": {
"description": "File modification date",
"format": "date-time",
"type": "string"
},
"filename": {
"description": "Filename",
"type": "string"
}
},
"required": [
"assetData",
"deviceAssetId",
"deviceId",
"fileCreatedAt",
"fileModifiedAt"
],
"type": "object"
},
"AssetMediaResponseDto": {
"properties": {
"id": {
@@ -19699,7 +19573,6 @@
"asset.view",
"asset.download",
"asset.upload",
"asset.replace",
"asset.copy",
"asset.derive",
"asset.edit.get",

View File

@@ -1028,22 +1028,6 @@ export type AssetOcrResponseDto = {
/** Normalized y coordinate of box corner 4 (0-1) */
y4: number;
};
export type AssetMediaReplaceDto = {
/** Asset file data */
assetData: Blob;
/** Device asset ID */
deviceAssetId: string;
/** Device ID */
deviceId: string;
/** Duration (for videos) */
duration?: string;
/** File creation date */
fileCreatedAt: string;
/** File modification date */
fileModifiedAt: string;
/** Filename */
filename?: string;
};
export type SignUpDto = {
/** User email */
email: string;
@@ -4270,27 +4254,6 @@ export function downloadAsset({ edited, id, key, slug }: {
...opts
}));
}
/**
* Replace asset
*/
export function replaceAsset({ id, key, slug, assetMediaReplaceDto }: {
id: string;
key?: string;
slug?: string;
assetMediaReplaceDto: AssetMediaReplaceDto;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: AssetMediaResponseDto;
}>(`/assets/${encodeURIComponent(id)}/original${QS.query(QS.explode({
key,
slug
}))}`, oazapfts.multipart({
...opts,
method: "PUT",
body: assetMediaReplaceDto
})));
}
/**
* View asset thumbnail
*/
@@ -6920,7 +6883,6 @@ export enum Permission {
AssetView = "asset.view",
AssetDownload = "asset.download",
AssetUpload = "asset.upload",
AssetReplace = "asset.replace",
AssetCopy = "asset.copy",
AssetDerive = "asset.derive",
AssetEditGet = "asset.edit.get",

View File

@@ -8,7 +8,6 @@ import {
Param,
ParseFilePipe,
Post,
Put,
Query,
Req,
Res,
@@ -28,10 +27,8 @@ import {
AssetBulkUploadCheckDto,
AssetMediaCreateDto,
AssetMediaOptionsDto,
AssetMediaReplaceDto,
AssetMediaSize,
CheckExistingAssetsDto,
UploadFieldName,
} from 'src/dtos/asset-media.dto';
import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
@@ -112,36 +109,6 @@ export class AssetMediaController {
await sendFile(res, next, () => this.service.downloadOriginal(auth, id, dto), this.logger);
}
@Put(':id/original')
@UseInterceptors(FileUploadInterceptor)
@ApiConsumes('multipart/form-data')
@ApiResponse({
status: 200,
description: 'Asset replaced successfully',
type: AssetMediaResponseDto,
})
@Endpoint({
summary: 'Replace asset',
description: 'Replace the asset with new file, without changing its id.',
history: new HistoryBuilder().added('v1').deprecated('v1', { replacementId: 'copyAsset' }),
})
@Authenticated({ permission: Permission.AssetReplace, sharedLink: true })
async replaceAsset(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator([UploadFieldName.ASSET_DATA])] }))
files: UploadFiles,
@Body() dto: AssetMediaReplaceDto,
@Res({ passthrough: true }) res: Response,
): Promise<AssetMediaResponseDto> {
const { file } = getFiles(files);
const responseDto = await this.service.replaceAsset(auth, id, dto, file);
if (responseDto.status === AssetMediaStatus.DUPLICATE) {
res.status(HttpStatus.OK);
}
return responseDto;
}
@Get(':id/thumbnail')
@FileResponse()
@Authenticated({ permission: Permission.AssetView, sharedLink: true })

View File

@@ -93,8 +93,6 @@ export class AssetMediaCreateDto extends AssetMediaBase {
[UploadFieldName.SIDECAR_DATA]?: any;
}
export class AssetMediaReplaceDto extends AssetMediaBase {}
export class AssetBulkUploadCheckItem {
@ApiProperty({ description: 'Asset ID' })
@IsString()

View File

@@ -105,7 +105,6 @@ export enum Permission {
AssetView = 'asset.view',
AssetDownload = 'asset.download',
AssetUpload = 'asset.upload',
AssetReplace = 'asset.replace',
AssetCopy = 'asset.copy',
AssetDerive = 'asset.derive',

View File

@@ -20,7 +20,7 @@ import {
MaintenanceStatusResponseDto,
SetMaintenanceModeDto,
} from 'src/dtos/maintenance.dto';
import { ServerConfigDto, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { ServerConfigDto, ServerPingResponse, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { ImmichCookie } from 'src/enum';
import { MaintenanceRoute } from 'src/maintenance/maintenance-auth.guard';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
@@ -52,6 +52,11 @@ export class MaintenanceWorkerController {
return this.service.getSystemConfig();
}
@Get('server/ping')
pingServer(): ServerPingResponse {
return this.service.ping();
}
@Get('server/version')
getServerVersion(): ServerVersionResponseDto {
return this.service.getVersion();

View File

@@ -12,7 +12,7 @@ import {
MaintenanceStatusResponseDto,
SetMaintenanceModeDto,
} from 'src/dtos/maintenance.dto';
import { ServerConfigDto, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { ServerConfigDto, ServerPingResponse, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { DatabaseLock, ImmichCookie, MaintenanceAction, SystemMetadataKey } from 'src/enum';
import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.repository';
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
@@ -121,6 +121,10 @@ export class MaintenanceWorkerService {
return ServerVersionResponseDto.fromSemVer(serverVersion);
}
ping(): ServerPingResponse {
return { res: 'pong' };
}
/**
* {@link _ApiService.ssr}
*/

View File

@@ -3,13 +3,16 @@ import { PATH_METADATA } from '@nestjs/common/constants';
import { Reflector } from '@nestjs/core';
import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils';
import { NextFunction, RequestHandler } from 'express';
import multer, { StorageEngine, diskStorage } from 'multer';
import multer from 'multer';
import { createHash, randomUUID } from 'node:crypto';
import { join } from 'node:path';
import { pipeline } from 'node:stream';
import { Observable } from 'rxjs';
import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { RouteKey } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { AssetMediaService } from 'src/services/asset-media.service';
import { ImmichFile, UploadFile, UploadFiles } from 'src/types';
import { asUploadRequest, mapToUploadFile } from 'src/utils/asset.util';
@@ -26,8 +29,6 @@ export function getFiles(files: UploadFiles) {
};
}
type DiskStorageCallback = (error: Error | null, result: string) => void;
type ImmichMulterFile = Express.Multer.File & { uuid: string };
interface Callback<T> {
@@ -35,34 +36,21 @@ interface Callback<T> {
(error: null, result: T): void;
}
const callbackify = <T>(target: (...arguments_: any[]) => T, callback: Callback<T>) => {
try {
return callback(null, target());
} catch (error: Error | any) {
return callback(error);
}
};
@Injectable()
export class FileUploadInterceptor implements NestInterceptor {
private handlers: {
userProfile: RequestHandler;
assetUpload: RequestHandler;
};
private defaultStorage: StorageEngine;
constructor(
private reflect: Reflector,
private assetService: AssetMediaService,
private storageRepository: StorageRepository,
private logger: LoggingRepository,
) {
this.logger.setContext(FileUploadInterceptor.name);
this.defaultStorage = diskStorage({
filename: this.filename.bind(this),
destination: this.destination.bind(this),
});
const instance = multer({
fileFilter: this.fileFilter.bind(this),
storage: {
@@ -99,60 +87,60 @@ export class FileUploadInterceptor implements NestInterceptor {
}
private fileFilter(request: AuthRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) {
return callbackify(() => this.assetService.canUploadFile(asUploadRequest(request, file)), callback);
}
private filename(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
return callbackify(
() => this.assetService.getUploadFilename(asUploadRequest(request, file)),
callback as Callback<string>,
);
}
private destination(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
return callbackify(
() => this.assetService.getUploadFolder(asUploadRequest(request, file)),
callback as Callback<string>,
);
try {
callback(null, this.assetService.canUploadFile(asUploadRequest(request, file)));
} catch (error: Error | any) {
callback(error);
}
}
private handleFile(request: AuthRequest, file: Express.Multer.File, callback: Callback<Partial<ImmichFile>>) {
(file as ImmichMulterFile).uuid = randomUUID();
request.on('error', (error) => {
this.logger.warn('Request error while uploading file, cleaning up', error);
this.assetService.onUploadError(request, file).catch(this.logger.error);
});
if (!this.isAssetUploadFile(file)) {
this.defaultStorage._handleFile(request, file, callback);
return;
}
try {
(file as ImmichMulterFile).uuid = randomUUID();
const hash = createHash('sha1');
file.stream.on('data', (chunk) => hash.update(chunk));
this.defaultStorage._handleFile(request, file, (error, info) => {
if (error) {
hash.destroy();
callback(error);
} else {
callback(null, { ...info, checksum: hash.digest() });
}
});
const uploadRequest = asUploadRequest(request, file);
const path = join(
this.assetService.getUploadFolder(uploadRequest),
this.assetService.getUploadFilename(uploadRequest),
);
const writeStream = this.storageRepository.createWriteStream(path);
const hash = file.fieldname === UploadFieldName.ASSET_DATA ? createHash('sha1') : null;
let size = 0;
file.stream.on('data', (chunk) => {
hash?.update(chunk);
size += chunk.length;
});
pipeline(file.stream, writeStream, (error) => {
if (error) {
hash?.destroy();
return callback(error);
}
callback(null, {
path,
size,
checksum: hash?.digest(),
});
});
} catch (error: Error | any) {
callback(error);
}
}
private removeFile(request: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) {
this.defaultStorage._removeFile(request, file, callback);
}
private isAssetUploadFile(file: Express.Multer.File) {
switch (file.fieldname as UploadFieldName) {
case UploadFieldName.ASSET_DATA: {
return true;
}
}
return false;
private removeFile(_request: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) {
this.storageRepository
.unlink(file.path)
.then(() => callback(null))
.catch(callback);
}
private getHandler(route: RouteKey) {

View File

@@ -63,7 +63,7 @@ export class StorageRepository {
}
createWriteStream(filepath: string): Writable {
return createWriteStream(filepath, { flags: 'w' });
return createWriteStream(filepath, { flags: 'w', flush: true });
}
createOrOverwriteFile(filepath: string, buffer: Buffer) {

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Injectable, NotAcceptableException } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { NextFunction, Request, Response } from 'express';
import { readFileSync } from 'node:fs';
@@ -72,6 +72,13 @@ export class ApiService {
return next();
}
const responseType = request.accepts('text/html');
if (!responseType) {
throw new NotAcceptableException(
`The route ${request.path} was requested as ${request.header('accept')}, but only returns text/html`,
);
}
let status = 200;
let html = index;
@@ -105,7 +112,7 @@ export class ApiService {
html = render(index, meta);
}
res.status(status).type('text/html').header('Cache-Control', 'no-store').send(html);
res.status(status).type(responseType).header('Cache-Control', 'no-store').send(html);
};
}
}

View File

@@ -2,7 +2,6 @@ import { BadRequestException, Injectable, InternalServerErrorException, NotFound
import { extname } from 'node:path';
import sanitize from 'sanitize-filename';
import { StorageCore } from 'src/cores/storage.core';
import { Asset } from 'src/database';
import {
AssetBulkUploadCheckResponseDto,
AssetMediaResponseDto,
@@ -15,22 +14,13 @@ import {
AssetBulkUploadCheckDto,
AssetMediaCreateDto,
AssetMediaOptionsDto,
AssetMediaReplaceDto,
AssetMediaSize,
CheckExistingAssetsDto,
UploadFieldName,
} from 'src/dtos/asset-media.dto';
import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
AssetFileType,
AssetStatus,
AssetVisibility,
CacheControl,
JobName,
Permission,
StorageFolder,
} from 'src/enum';
import { AssetFileType, AssetVisibility, CacheControl, JobName, Permission, StorageFolder } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard';
import { BaseService } from 'src/services/base.service';
import { UploadFile, UploadRequest } from 'src/types';
@@ -163,40 +153,6 @@ export class AssetMediaService extends BaseService {
}
}
async replaceAsset(
auth: AuthDto,
id: string,
dto: AssetMediaReplaceDto,
file: UploadFile,
sidecarFile?: UploadFile,
): Promise<AssetMediaResponseDto> {
try {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] });
const asset = await this.assetRepository.getById(id);
if (!asset) {
throw new Error('Asset not found');
}
this.requireQuota(auth, file.size);
await this.replaceFileData(asset.id, dto, file, sidecarFile?.originalPath);
// Next, create a backup copy of the existing record. The db record has already been updated above,
// but the local variable holds the original file data paths.
const copiedPhoto = await this.createCopy(asset);
// and immediate trash it
await this.assetRepository.updateAll([copiedPhoto.id], { deletedAt: new Date(), status: AssetStatus.Trashed });
await this.eventRepository.emit('AssetTrash', { assetId: copiedPhoto.id, userId: auth.user.id });
await this.userRepository.updateUsage(auth.user.id, file.size);
return { status: AssetMediaStatus.REPLACED, id: copiedPhoto.id };
} catch (error: any) {
return this.handleUploadError(error, auth, file, sidecarFile);
}
}
async downloadOriginal(auth: AuthDto, id: string, dto: AssetDownloadOriginalDto): Promise<ImmichFileResponse> {
await this.requireAccess({ auth, permission: Permission.AssetDownload, ids: [id] });
@@ -357,82 +313,6 @@ export class AssetMediaService extends BaseService {
throw error;
}
/**
* Updates the specified assetId to the specified photo data file properties: checksum, path,
* timestamps, deviceIds, and sidecar. Derived properties like: faces, smart search info, etc
* are UNTOUCHED. The photo data files modification times on the filesysytem are updated to
* the specified timestamps. The exif db record is upserted, and then A METADATA_EXTRACTION
* job is queued to update these derived properties.
*/
private async replaceFileData(
assetId: string,
dto: AssetMediaReplaceDto,
file: UploadFile,
sidecarPath?: string,
): Promise<void> {
await this.assetRepository.update({
id: assetId,
checksum: file.checksum,
originalPath: file.originalPath,
type: mimeTypes.assetType(file.originalPath),
originalFileName: file.originalName,
deviceAssetId: dto.deviceAssetId,
deviceId: dto.deviceId,
fileCreatedAt: dto.fileCreatedAt,
fileModifiedAt: dto.fileModifiedAt,
localDateTime: dto.fileCreatedAt,
duration: dto.duration || null,
livePhotoVideoId: null,
});
await (sidecarPath
? this.assetRepository.upsertFile({ assetId, type: AssetFileType.Sidecar, path: sidecarPath })
: this.assetRepository.deleteFile({ assetId, type: AssetFileType.Sidecar }));
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
await this.assetRepository.upsertExif(
{ assetId, fileSizeInByte: file.size },
{ lockedPropertiesBehavior: 'override' },
);
await this.jobRepository.queue({
name: JobName.AssetExtractMetadata,
data: { id: assetId, source: 'upload' },
});
}
/**
* Create a 'shallow' copy of the specified asset record creating a new asset record in the database.
* Uses only vital properties excluding things like: stacks, faces, smart search info, etc,
* and then queues a METADATA_EXTRACTION job.
*/
private async createCopy(asset: Omit<Asset, 'id'>) {
const created = await this.assetRepository.create({
ownerId: asset.ownerId,
originalPath: asset.originalPath,
originalFileName: asset.originalFileName,
libraryId: asset.libraryId,
deviceAssetId: asset.deviceAssetId,
deviceId: asset.deviceId,
type: asset.type,
checksum: asset.checksum,
fileCreatedAt: asset.fileCreatedAt,
localDateTime: asset.localDateTime,
fileModifiedAt: asset.fileModifiedAt,
livePhotoVideoId: asset.livePhotoVideoId,
});
const { size } = await this.storageRepository.stat(created.originalPath);
await this.assetRepository.upsertExif(
{ assetId: created.id, fileSizeInByte: size },
{ lockedPropertiesBehavior: 'override' },
);
await this.jobRepository.queue({ name: JobName.AssetExtractMetadata, data: { id: created.id, source: 'copy' } });
return created;
}
private async create(ownerId: string, dto: AssetMediaCreateDto, file: UploadFile, sidecarFile?: UploadFile) {
const asset = await this.assetRepository.create({
ownerId,

View File

@@ -330,7 +330,7 @@ describe(MetadataService.name, () => {
duration: null,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
localDateTime: asset.localDateTime,
localDateTime: asset.fileCreatedAt,
width: null,
height: null,
});
@@ -360,7 +360,7 @@ describe(MetadataService.name, () => {
duration: null,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
localDateTime: asset.localDateTime,
localDateTime: asset.fileCreatedAt,
width: null,
height: null,
});

View File

@@ -155,6 +155,33 @@ describe('transformFaceBoundingBox', () => {
expect(result.boundingBoxX2).toBe(50);
expect(result.boundingBoxY2).toBe(50);
});
it('should always return whole numbers', () => {
const edits: AssetEditActionItem[] = [
{ action: AssetEditAction.Crop, parameters: { x: 50, y: 50, width: 250, height: 250 } },
];
expect(transformFaceBoundingBox(baseFace, edits, { width: 1000, height: 400 })).toMatchObject({
boundingBoxX1: 50,
boundingBoxY1: 0,
boundingBoxX2: 150,
boundingBoxY2: 50,
});
expect(transformFaceBoundingBox(baseFace, edits, { width: 1001, height: 401 })).toMatchObject({
boundingBoxX1: 50,
boundingBoxY1: 0,
boundingBoxX2: 150,
boundingBoxY2: 50,
});
expect(transformFaceBoundingBox(baseFace, edits, { width: 999, height: 399 })).toMatchObject({
boundingBoxX1: 49,
boundingBoxY1: -0,
boundingBoxX2: 149,
boundingBoxY2: 49,
});
});
});
});

View File

@@ -179,10 +179,10 @@ export const transformFaceBoundingBox = (
// Ensure x1,y1 is top-left and x2,y2 is bottom-right
const [p1, p2] = transformedPoints;
return {
boundingBoxX1: Math.min(p1.x, p2.x),
boundingBoxY1: Math.min(p1.y, p2.y),
boundingBoxX2: Math.max(p1.x, p2.x),
boundingBoxY2: Math.max(p1.y, p2.y),
boundingBoxX1: Math.trunc(Math.min(p1.x, p2.x)),
boundingBoxY1: Math.trunc(Math.min(p1.y, p2.y)),
boundingBoxX2: Math.trunc(Math.max(p1.x, p2.x)),
boundingBoxY2: Math.trunc(Math.max(p1.y, p2.y)),
imageWidth: currentWidth,
imageHeight: currentHeight,
};

View File

@@ -19,7 +19,7 @@ import {
UserLike,
} from 'test/factories/types';
import { UserFactory } from 'test/factories/user.factory';
import { newDate, newSha1, newUuid, newUuidV7 } from 'test/small.factory';
import { newSha1, newUuid, newUuidV7 } from 'test/small.factory';
export class AssetFactory {
#owner!: UserFactory;
@@ -43,10 +43,12 @@ export class AssetFactory {
const originalFileName = dto.originalFileName ?? (dto.type === AssetType.Video ? `MOV_${id}.mp4` : `IMG_${id}.jpg`);
let now = Date.now();
return new AssetFactory({
id,
createdAt: newDate(),
updatedAt: newDate(),
createdAt: new Date(now++),
updatedAt: new Date(now++),
deletedAt: null,
updateId: newUuidV7(),
status: AssetStatus.Active,
@@ -55,14 +57,14 @@ export class AssetFactory {
deviceId: '',
duplicateId: null,
duration: null,
fileCreatedAt: newDate(),
fileModifiedAt: newDate(),
fileCreatedAt: new Date(now++),
fileModifiedAt: new Date(now++),
isExternal: false,
isFavorite: false,
isOffline: false,
libraryId: null,
livePhotoVideoId: null,
localDateTime: newDate(),
localDateTime: new Date(now),
originalFileName,
originalPath: `/data/library/${originalFileName}`,
ownerId: newUuid(),

View File

@@ -591,10 +591,10 @@ describe(PersonService.name, () => {
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.id }),
boundingBoxX1: expect.closeTo(25, 1),
boundingBoxY1: expect.closeTo(50, 1),
boundingBoxX2: expect.closeTo(100, 1),
boundingBoxY2: expect.closeTo(100, 1),
boundingBoxX1: 25,
boundingBoxY1: 49,
boundingBoxX2: 99,
boundingBoxY2: 100,
}),
]),
);

View File

@@ -36,7 +36,7 @@
onLoad={() => adaptiveImageLoader.onLoad(quality)}
onError={() => adaptiveImageLoader.onError(quality)}
bind:ref
class="h-full w-full bg-transparent"
class="h-full w-full bg-transparent pointer-events-auto"
{alt}
{role}
draggable={false}

View File

@@ -112,7 +112,7 @@
switch (dto.command) {
case QueueCommand.Empty: {
toastManager.success($t('admin.cleared_jobs', { values: { job: item.title } }));
toastManager.primary($t('admin.cleared_jobs', { values: { job: item.title } }));
break;
}
}

View File

@@ -55,7 +55,7 @@
try {
await unlinkAllOAuthAccountsAdmin();
toastManager.success();
toastManager.primary();
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}

View File

@@ -45,7 +45,7 @@
},
});
toastManager.success($t('admin.notification_email_test_email_sent', { values: { email: $user.email } }));
toastManager.primary($t('admin.notification_email_test_email_sent', { values: { email: $user.email } }));
if (!disabled) {
await handleSystemConfigSave({ notifications: configToEdit.notifications });

View File

@@ -4,6 +4,7 @@
import { handleError } from '$lib/utils/handle-error';
import { updateAlbumInfo } from '@immich/sdk';
import { t } from 'svelte-i18n';
import { tv } from 'tailwind-variants';
interface Props {
id: string;
@@ -36,14 +37,22 @@
return;
}
};
const styles = tv({
base: 'w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-primary outline-none transition-all focus:border-b-2 focus:border-immich-primary focus:outline-none bg-light dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray placeholder:text-primary/90',
variants: {
isOwned: {
true: 'hover:border-gray-400',
false: 'hover:border-transparent',
},
},
});
</script>
<input
use:shortcut={{ shortcut: { key: 'Enter' }, onShortcut: (e) => e.currentTarget.blur() }}
onblur={handleUpdateName}
class="w-[99%] mb-2 border-b-2 border-transparent text-2xl md:text-4xl lg:text-6xl text-primary outline-none transition-all {isOwned
? 'hover:border-gray-400'
: 'hover:border-transparent'} focus:border-b-2 focus:border-immich-primary focus:outline-none bg-light dark:focus:border-immich-dark-primary dark:focus:bg-immich-dark-gray placeholder:text-primary/90"
class={styles({ isOwned })}
type="text"
bind:value={newAlbumName}
disabled={!isOwned}

View File

@@ -40,7 +40,7 @@
preAction({ type: AssetAction.DELETE, asset: timelineAsset });
await deleteAssets({ assetBulkDeleteDto: { ids: [asset.id], force: true } });
onAction({ type: AssetAction.DELETE, asset: timelineAsset });
toastManager.success($t('permanently_deleted_asset'));
toastManager.primary($t('permanently_deleted_asset'));
} catch (error) {
handleError(error, $t('errors.unable_to_delete_asset'));
}

View File

@@ -21,7 +21,7 @@
await restoreAssets({ bulkIdsDto: { ids: [asset.id] } });
asset.isTrashed = false;
onAction({ type: AssetAction.RESTORE, asset: toTimelineAsset(asset) });
toastManager.success($t('restored_asset'));
toastManager.primary($t('restored_asset'));
} catch (error) {
handleError(error, $t('errors.unable_to_restore_assets'));
}

View File

@@ -23,7 +23,7 @@
},
});
eventManager.emit('AlbumUpdate', response);
toastManager.success($t('album_cover_updated'));
toastManager.primary($t('album_cover_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_album_cover'));
}

View File

@@ -31,7 +31,7 @@
person,
});
toastManager.success($t('feature_photo_updated'));
toastManager.primary($t('feature_photo_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_set_feature_photo'));
}

View File

@@ -74,7 +74,7 @@
[ReactionType.Comment]: $t('comment_deleted'),
[ReactionType.Like]: $t('like_deleted'),
};
toastManager.success(deleteMessages[reaction.type]);
toastManager.primary(deleteMessages[reaction.type]);
} catch (error) {
handleError(error, $t('errors.unable_to_remove_reaction'));
}

View File

@@ -22,6 +22,7 @@
import { Route } from '$lib/route';
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions } from '$lib/services/asset.service';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { user } from '$lib/stores/user.store';
import { getSharedLink, withoutIcons } from '$lib/utils';
import type { OnUndoDelete } from '$lib/utils/actions';
@@ -88,7 +89,7 @@
title: $t('go_back'),
type: $t('assets'),
icon: languageManager.rtl ? mdiArrowRight : mdiArrowLeft,
$if: () => !!onClose,
$if: () => !!onClose && !isFaceEditMode.value,
onAction: () => onClose?.(),
shortcuts: [{ key: 'Escape' }],
});

View File

@@ -22,7 +22,7 @@
}
try {
await updateAsset({ id: asset.id, updateAssetDto: { description } });
toastManager.success($t('asset_description_updated'));
toastManager.primary($t('asset_description_updated'));
} catch (error) {
handleError(error, $t('cannot_update_the_description'));
}

View File

@@ -6,6 +6,7 @@
import { getNaturalSize, scaleToFit } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error';
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { shortcut } from '$lib/actions/shortcut';
import { Button, Input, modalManager, toastManager } from '@immich/ui';
import { Canvas, InteractiveFabricObject, Rect } from 'fabric';
import { clamp } from 'lodash-es';
@@ -289,6 +290,8 @@
};
</script>
<svelte:document use:shortcut={{ shortcut: { key: 'Escape' }, onShortcut: cancel }} />
<div
id="face-editor-data"
class="absolute start-0 top-0 z-5 h-full w-full overflow-hidden"

View File

@@ -67,7 +67,7 @@
if (failCount > 0) {
toastManager.warning($t('errors.unable_to_change_visibility', { values: { count: failCount } }));
}
toastManager.success($t('visibility_changed', { values: { count: successCount } }));
toastManager.primary($t('visibility_changed', { values: { count: successCount } }));
}
for (const person of people) {

View File

@@ -72,7 +72,7 @@
});
const mergedPerson = await getPerson({ id: person.id });
const count = results.filter(({ success }) => success).length;
toastManager.success($t('merged_people_count', { values: { count } }));
toastManager.primary($t('merged_people_count', { values: { count } }));
onMerge(mergedPerson);
} catch (error) {
handleError(error, $t('cannot_merge_people'));

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import OnEvents from '$lib/components/OnEvents.svelte';
import { timeBeforeShowLoadingSpinner } from '$lib/constants';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { boundingBoxesArray } from '$lib/stores/people.store';
@@ -25,7 +26,6 @@
import { fly } from 'svelte/transition';
import ImageThumbnail from '../assets/thumbnail/image-thumbnail.svelte';
import AssignFaceSidePanel from './assign-face-side-panel.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
interface Props {
assetId: string;
@@ -126,7 +126,7 @@
}
}
toastManager.success($t('people_edits_count', { values: { count: numberOfChanges } }));
toastManager.primary($t('people_edits_count', { values: { count: numberOfChanges } }));
} catch (error) {
handleError(error, $t('errors.cant_apply_changes'));
}

View File

@@ -71,7 +71,7 @@
disableButtons = true;
const data = await createPerson({ personCreateDto: {} });
await reassignFaces({ id: data.id, assetFaceUpdateDto: { data: selectedPeople } });
toastManager.success($t('reassigned_assets_to_new_person', { values: { count: assetIds.length } }));
toastManager.primary($t('reassigned_assets_to_new_person', { values: { count: assetIds.length } }));
} catch (error) {
handleError(error, $t('errors.unable_to_reassign_assets_new_person'));
} finally {
@@ -88,7 +88,7 @@
disableButtons = true;
if (selectedPerson) {
await reassignFaces({ id: selectedPerson.id, assetFaceUpdateDto: { data: selectedPeople } });
toastManager.success(
toastManager.primary(
$t('reassigned_assets_to_existing_person', {
values: { count: assetIds.length, name: selectedPerson.name || null },
}),

View File

@@ -63,8 +63,9 @@
let playerInitialized = $state(false);
let paused = $state(false);
let current = $state<MemoryAsset | undefined>(undefined);
let currentMemoryAssetFull = $derived.by(async () =>
current?.asset ? await getAssetInfo({ ...authManager.params, id: current.asset.id }) : undefined,
const currentAssetId = $derived(current?.asset.id);
const currentMemoryAssetFull = $derived.by(async () =>
currentAssetId ? await getAssetInfo({ ...authManager.params, id: currentAssetId }) : undefined,
);
let currentTimelineAssets = $derived(current?.memory.assets ?? []);
let viewerAssets = $derived([
@@ -205,7 +206,7 @@
}
await memoryStore.deleteMemory(current.memory.id);
toastManager.success($t('removed_memory'));
toastManager.primary($t('removed_memory'));
init(page);
};
@@ -216,7 +217,7 @@
const newSavedState = !current.memory.isSaved;
await memoryStore.updateMemorySaved(current.memory.id, newSavedState);
toastManager.success(newSavedState ? $t('added_to_favorites') : $t('removed_from_favorites'));
toastManager.primary(newSavedState ? $t('added_to_favorites') : $t('removed_from_favorites'));
init(page);
};
@@ -550,14 +551,18 @@
</div>
<div>
<IconButton
href={Route.photos({ at: current.asset.id })}
icon={mdiImageSearch}
aria-label={$t('view_in_timeline')}
color="secondary"
variant="ghost"
shape="round"
/>
{#await currentMemoryAssetFull then asset}
{#if asset}
<IconButton
href={Route.photos({ at: asset.stack?.primaryAssetId ?? asset.id })}
icon={mdiImageSearch}
aria-label={$t('view_in_timeline')}
color="secondary"
variant="ghost"
shape="round"
/>
{/if}
{/await}
</div>
</div>
<!-- CONTROL BUTTONS -->

View File

@@ -52,7 +52,7 @@
? openFileUploadDialog()
: fileUploadHandler({ files }));
toastManager.success();
toastManager.primary();
} catch (error) {
handleError(error, $t('errors.unable_to_add_assets_to_shared_link'));
}

View File

@@ -345,8 +345,10 @@
{
shortcut: { key: 'Escape' },
onShortcut: (event) => {
event.stopPropagation();
closeDropdown();
if (isOpen) {
event.stopPropagation();
closeDropdown();
}
},
},
]}

View File

@@ -14,11 +14,11 @@
import { t } from 'svelte-i18n';
import SearchHistoryBox from './search-history-box.svelte';
interface Props {
type Props = {
value?: string;
grayTheme: boolean;
searchQuery?: MetadataSearchDto | SmartSearchDto;
}
};
let { value = $bindable(''), grayTheme, searchQuery = {} }: Props = $props();

View File

@@ -1,21 +1,14 @@
<script lang="ts" module>
export interface SearchCameraFilter {
make?: string;
model?: string;
lensModel?: string;
}
</script>
<script lang="ts">
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
import type { SearchCameraFilter } from '$lib/types';
import { handlePromiseError } from '$lib/utils';
import { SearchSuggestionType, getSearchSuggestions } from '@immich/sdk';
import { Text } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
type Props = {
filters: SearchCameraFilter;
}
};
let { filters = $bindable() }: Props = $props();

View File

@@ -1,18 +1,11 @@
<script lang="ts" module>
export interface SearchDateFilter {
takenBefore?: DateTime;
takenAfter?: DateTime;
}
</script>
<script lang="ts">
import type { SearchDateFilter } from '$lib/types';
import { DatePicker, Text } from '@immich/ui';
import type { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
interface Props {
type Props = {
filters: SearchDateFilter;
}
};
let { filters = $bindable() }: Props = $props();

View File

@@ -1,19 +1,11 @@
<script lang="ts" module>
export interface SearchDisplayFilters {
isNotInAlbum: boolean;
isArchive: boolean;
isFavorite: boolean;
}
</script>
<script lang="ts">
import type { SearchDisplayFilters } from '$lib/types';
import { Checkbox, Label, Text } from '@immich/ui';
import { t } from 'svelte-i18n';
interface Props {
type Props = {
filters: SearchDisplayFilters;
}
};
let { filters = $bindable() }: Props = $props();
</script>

View File

@@ -1,22 +1,15 @@
<script lang="ts" module>
export interface SearchLocationFilter {
country?: string;
state?: string;
city?: string;
}
</script>
<script lang="ts">
import Combobox, { asComboboxOptions, asSelectedOption } from '$lib/components/shared-components/combobox.svelte';
import type { SearchLocationFilter } from '$lib/types';
import { handlePromiseError } from '$lib/utils';
import { getSearchSuggestions, SearchSuggestionType } from '@immich/sdk';
import { Text } from '@immich/ui';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
interface Props {
type Props = {
filters: SearchLocationFilter;
}
};
let { filters = $bindable() }: Props = $props();

View File

@@ -9,6 +9,7 @@
import { mdiArrowRight, mdiClose } from '@mdi/js';
import { t } from 'svelte-i18n';
import type { SvelteSet } from 'svelte/reactivity';
import { tv } from 'tailwind-variants';
interface Props {
selectedPeople: SvelteSet<string>;
@@ -49,6 +50,16 @@
const nameLower = name.toLowerCase();
return name ? list.filter((p) => p.name.toLowerCase().includes(nameLower)) : list;
};
const styles = tv({
base: 'flex flex-col items-center rounded-3xl border-2 hover:bg-subtle dark:hover:bg-immich-dark-primary/20 p-2 transition-all',
variants: {
selected: {
true: 'dark:border-slate-500 border-slate-400 bg-slate-200 dark:bg-slate-800 dark:text-white',
false: 'border-transparent',
},
},
});
</script>
{#await peoplePromise}
@@ -74,11 +85,7 @@
{#each peopleList as person (person.id)}
<button
type="button"
class="flex flex-col items-center rounded-3xl border-2 hover:bg-subtle dark:hover:bg-immich-dark-primary/20 p-2 transition-all {selectedPeople.has(
person.id,
)
? 'dark:border-slate-500 border-slate-400 bg-slate-200 dark:bg-slate-800 dark:text-white'
: 'border-transparent'}"
class={styles({ selected: selectedPeople.has(person.id) })}
onclick={() => togglePersonSelection(person.id)}
>
<ImageThumbnail circle shadow url={getPeopleThumbnailUrl(person)} altText={person.name} widthStyle="100%" />

View File

@@ -5,7 +5,7 @@
children?: import('svelte').Snippet<[{ itemCount: number }]>;
}
let { class: className = '', itemCount = $bindable(1), children }: Props = $props();
let { class: className, itemCount = $bindable(1), children }: Props = $props();
let container: HTMLElement | undefined = $state();
let contentRect: DOMRectReadOnly | undefined = $state();

View File

@@ -30,7 +30,7 @@
if ($stats.errors > 0) {
toastManager.danger($t('upload_errors', { values: { count: $stats.errors } }));
} else if ($stats.success > 0) {
toastManager.success($t('upload_success'));
toastManager.primary($t('upload_success'));
}
if ($stats.duplicates > 0) {
toastManager.warning($t('upload_skipped_duplicates', { values: { count: $stats.duplicates } }));

View File

@@ -42,7 +42,7 @@
onFavorite?.(ids, isFavorite);
toastManager.success(
toastManager.primary(
isFavorite
? $t('added_to_favorites_count', { values: { count: ids.length } })
: $t('removed_from_favorites_count', { values: { count: ids.length } }),

View File

@@ -38,7 +38,7 @@
onRemove?.(ids);
const count = results.filter(({ success }) => success).length;
toastManager.success($t('assets_removed_count', { values: { count } }));
toastManager.primary($t('assets_removed_count', { values: { count } }));
clearSelect();
} catch (error) {

View File

@@ -24,7 +24,7 @@
const ids = [...getAssets()].map((a) => a.id);
await restoreAssets({ bulkIdsDto: { ids } });
onRestore?.(ids);
toastManager.success($t('assets_restored_count', { values: { count: ids.length } }));
toastManager.primary($t('assets_restored_count', { values: { count: ids.length } }));
clearSelect();
} catch (error) {
handleError(error, $t('errors.unable_to_restore_assets'));

View File

@@ -22,7 +22,7 @@
try {
await changePinCode({ pinCodeChangeDto: { pinCode: currentPinCode, newPinCode } });
resetForm();
toastManager.success($t('pin_code_changed_successfully'));
toastManager.primary($t('pin_code_changed_successfully'));
} catch (error) {
handleError(error, $t('unable_to_change_pin_code'));
} finally {

View File

@@ -26,7 +26,7 @@
isLoading = true;
try {
await setupPinCode({ pinCodeSetupDto: { pinCode: newPinCode } });
toastManager.success($t('pin_code_setup_successfully'));
toastManager.primary($t('pin_code_setup_successfully'));
onCreated?.(newPinCode);
resetForm();
} catch (error) {

View File

@@ -49,25 +49,22 @@
const handleInput = (event: Event, index: number) => {
const target = event.target as HTMLInputElement;
let currentPinValue = target.value;
const digits = target.value.replaceAll(/\D/g, '').slice(0, pinLength - index);
if (target.value.length > 1) {
currentPinValue = value.slice(0, 1);
}
if (Number.isNaN(Number(value))) {
if (digits.length === 0) {
pinValues[index] = '';
target.value = '';
value = pinValues.join('').trim();
return;
}
pinValues[index] = currentPinValue;
for (let i = 0; i < digits.length; i++) {
pinValues[index + i] = digits[i];
}
value = pinValues.join('').trim();
if (value && index < pinLength - 1) {
focusNext(index);
}
const lastFilledIndex = Math.min(index + digits.length, pinLength - 1);
pinCodeInputElements[lastFilledIndex]?.focus();
if (value.length === pinLength) {
onFilled?.(value);
@@ -104,12 +101,6 @@
}
return;
}
default: {
if (Number.isNaN(Number(event.key))) {
event.preventDefault();
}
break;
}
}
}
</script>
@@ -125,7 +116,6 @@
{type}
inputmode="numeric"
pattern="[0-9]*"
maxlength="1"
bind:this={pinCodeInputElements[index]}
id="pin-code-{index}"
class="h-12 w-10 rounded-xl border-2 border-suble dark:border-gray-700 text-center text-lg font-medium focus:border-immich-primary focus:ring-primary dark:focus:border-primary font-mono bg-white dark:bg-light"

View File

@@ -25,7 +25,7 @@
try {
await deleteSession({ id: device.id });
toastManager.success($t('logged_out_device'));
toastManager.primary($t('logged_out_device'));
} catch (error) {
handleError(error, $t('errors.unable_to_log_out_device'));
} finally {
@@ -41,7 +41,7 @@
try {
await deleteAllSessions();
toastManager.success($t('logged_out_all_devices'));
toastManager.primary($t('logged_out_all_devices'));
} catch (error) {
handleError(error, $t('errors.unable_to_log_out_all_devices'));
} finally {

View File

@@ -25,7 +25,7 @@
});
$preferences = newPreferences;
toastManager.success($t('saved_settings'));
toastManager.primary($t('saved_settings'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_settings'));
}

View File

@@ -53,7 +53,7 @@
$preferences = { ...data };
toastManager.success($t('saved_settings'));
toastManager.primary($t('saved_settings'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_settings'));
}

View File

@@ -26,7 +26,7 @@
$preferences.emailNotifications.albumInvite = data.emailNotifications.albumInvite;
$preferences.emailNotifications.albumUpdate = data.emailNotifications.albumUpdate;
toastManager.success($t('saved_settings'));
toastManager.primary($t('saved_settings'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_settings'));
}

View File

@@ -22,7 +22,7 @@
try {
loading = true;
user = await oauth.link(globalThis.location);
toastManager.success($t('linked_oauth_account'));
toastManager.primary($t('linked_oauth_account'));
} catch (error) {
handleError(error, $t('errors.unable_to_link_oauth_account'));
} finally {
@@ -36,7 +36,7 @@
const handleUnlink = async () => {
try {
user = await oauth.unlink();
toastManager.success($t('unlinked_oauth_account'));
toastManager.primary($t('unlinked_oauth_account'));
} catch (error) {
handleError(error, $t('errors.unable_to_unlink_account'));
}

View File

@@ -23,7 +23,7 @@
Object.assign(editedUser, data);
$user = data;
toastManager.success($t('saved_profile'));
toastManager.primary($t('saved_profile'));
} catch (error) {
handleError(error, $t('errors.unable_to_save_profile'));
}

View File

@@ -142,7 +142,7 @@ export class EditManager {
eventManager.emit('AssetEditsApplied', assetId);
toastManager.success(t('editor_edits_applied_success'));
toastManager.primary(t('editor_edits_applied_success'));
this.hasAppliedEdits = true;
return true;

View File

@@ -20,7 +20,7 @@
await deleteProfileImage();
}
toastManager.success($t('saved_profile'));
toastManager.primary($t('saved_profile'));
$user = await updateMyUser({ userUpdateMeDto: { avatarColor: color } });
onClose();

View File

@@ -38,7 +38,7 @@
id: personToBeMergedInto.id,
mergePersonDto: { ids: [personToMerge.id] },
});
toastManager.success($t('merge_people_successfully'));
toastManager.primary($t('merge_people_successfully'));
onClose([personToMerge, personToBeMergedInto]);
} catch (error) {
handleError(error, $t('errors.unable_to_save_name'));

View File

@@ -69,7 +69,7 @@
}
const file = new File([blob], 'profile-picture.png', { type: 'image/png' });
const { profileImagePath, profileChangedAt } = await createProfileImage({ createProfileImageDto: { file } });
toastManager.success($t('profile_picture_set'));
toastManager.primary($t('profile_picture_set'));
$user.profileImagePath = profileImagePath;
$user.profileChangedAt = profileChangedAt;

View File

@@ -1,28 +1,5 @@
<script lang="ts" module>
import { MediaType, QueryType, validQueryTypes } from '$lib/constants';
import type { SearchDateFilter } from '../components/shared-components/search-bar/search-date-section.svelte';
import type { SearchDisplayFilters } from '../components/shared-components/search-bar/search-display-section.svelte';
import type { SearchLocationFilter } from '../components/shared-components/search-bar/search-location-section.svelte';
export type SearchFilter = {
query: string;
ocr?: string;
queryType: 'smart' | 'metadata' | 'description' | 'ocr';
personIds: SvelteSet<string>;
tagIds: SvelteSet<string> | null;
location: SearchLocationFilter;
camera: SearchCameraFilter;
date: SearchDateFilter;
display: SearchDisplayFilters;
mediaType: MediaType;
rating?: number | null;
};
</script>
<script lang="ts">
import SearchCameraSection, {
type SearchCameraFilter,
} from '$lib/components/shared-components/search-bar/search-camera-section.svelte';
import SearchCameraSection from '$lib/components/shared-components/search-bar/search-camera-section.svelte';
import SearchDateSection from '$lib/components/shared-components/search-bar/search-date-section.svelte';
import SearchDisplaySection from '$lib/components/shared-components/search-bar/search-display-section.svelte';
import SearchLocationSection from '$lib/components/shared-components/search-bar/search-location-section.svelte';
@@ -31,7 +8,9 @@
import SearchRatingsSection from '$lib/components/shared-components/search-bar/search-ratings-section.svelte';
import SearchTagsSection from '$lib/components/shared-components/search-bar/search-tags-section.svelte';
import SearchTextSection from '$lib/components/shared-components/search-bar/search-text-section.svelte';
import { MediaType, QueryType, validQueryTypes } from '$lib/constants';
import { preferences } from '$lib/stores/user.store';
import type { SearchFilter } from '$lib/types';
import { parseUtcDate } from '$lib/utils/date-time';
import { generateId } from '$lib/utils/generate-id';
import { AssetTypeEnum, AssetVisibility, type MetadataSearchDto, type SmartSearchDto } from '@immich/sdk';
@@ -41,10 +20,10 @@
import { t } from 'svelte-i18n';
import { SvelteSet } from 'svelte/reactivity';
interface Props {
type Props = {
searchQuery: MetadataSearchDto | SmartSearchDto;
onClose: (search?: SmartSearchDto | MetadataSearchDto) => void;
}
};
let { searchQuery, onClose }: Props = $props();
@@ -66,52 +45,57 @@
return validQueryTypes.has(storedQueryType) ? storedQueryType : QueryType.SMART;
}
let query = '';
if ('query' in searchQuery && searchQuery.query) {
query = searchQuery.query;
}
if ('originalFileName' in searchQuery && searchQuery.originalFileName) {
query = searchQuery.originalFileName;
}
const asFilter = (searchQuery: SmartSearchDto | MetadataSearchDto): SearchFilter => {
let query = '';
if ('query' in searchQuery && searchQuery.query) {
query = searchQuery.query;
}
if ('originalFileName' in searchQuery && searchQuery.originalFileName) {
query = searchQuery.originalFileName;
}
let filter: SearchFilter = $state({
query,
ocr: searchQuery.ocr,
queryType: defaultQueryType(),
personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []),
tagIds:
'tagIds' in searchQuery
? searchQuery.tagIds === null
? null
: new SvelteSet(searchQuery.tagIds)
: new SvelteSet(),
location: {
country: withNullAsUndefined(searchQuery.country),
state: withNullAsUndefined(searchQuery.state),
city: withNullAsUndefined(searchQuery.city),
},
camera: {
make: withNullAsUndefined(searchQuery.make),
model: withNullAsUndefined(searchQuery.model),
lensModel: withNullAsUndefined(searchQuery.lensModel),
},
date: {
takenAfter: searchQuery.takenAfter ? toStartOfDayDate(searchQuery.takenAfter) : undefined,
takenBefore: searchQuery.takenBefore ? toStartOfDayDate(searchQuery.takenBefore) : undefined,
},
display: {
isArchive: searchQuery.visibility === AssetVisibility.Archive,
isFavorite: searchQuery.isFavorite ?? false,
isNotInAlbum: 'isNotInAlbum' in searchQuery ? (searchQuery.isNotInAlbum ?? false) : false,
},
mediaType:
searchQuery.type === AssetTypeEnum.Image
? MediaType.Image
: searchQuery.type === AssetTypeEnum.Video
? MediaType.Video
: MediaType.All,
rating: searchQuery.rating,
});
return {
query,
ocr: searchQuery.ocr,
queryType: defaultQueryType(),
queryAssetId: 'queryAssetId' in searchQuery ? searchQuery.queryAssetId : undefined,
personIds: new SvelteSet('personIds' in searchQuery ? searchQuery.personIds : []),
tagIds:
'tagIds' in searchQuery
? searchQuery.tagIds === null
? null
: new SvelteSet(searchQuery.tagIds)
: new SvelteSet(),
location: {
country: withNullAsUndefined(searchQuery.country),
state: withNullAsUndefined(searchQuery.state),
city: withNullAsUndefined(searchQuery.city),
},
camera: {
make: withNullAsUndefined(searchQuery.make),
model: withNullAsUndefined(searchQuery.model),
lensModel: withNullAsUndefined(searchQuery.lensModel),
},
date: {
takenAfter: searchQuery.takenAfter ? toStartOfDayDate(searchQuery.takenAfter) : undefined,
takenBefore: searchQuery.takenBefore ? toStartOfDayDate(searchQuery.takenBefore) : undefined,
},
display: {
isArchive: searchQuery.visibility === AssetVisibility.Archive,
isFavorite: searchQuery.isFavorite ?? false,
isNotInAlbum: 'isNotInAlbum' in searchQuery ? (searchQuery.isNotInAlbum ?? false) : false,
},
mediaType:
searchQuery.type === AssetTypeEnum.Image
? MediaType.Image
: searchQuery.type === AssetTypeEnum.Video
? MediaType.Video
: MediaType.All,
rating: searchQuery.rating,
};
};
let filter: SearchFilter = $state(asFilter(searchQuery));
const resetForm = () => {
filter = {
@@ -145,6 +129,7 @@
let payload: SmartSearchDto | MetadataSearchDto = {
query: filter.queryType === 'smart' ? query : undefined,
queryAssetId: filter.queryAssetId || undefined,
ocr: filter.queryType === 'ocr' ? query : undefined,
originalFileName: filter.queryType === 'metadata' ? query : undefined,
description: filter.queryType === 'description' ? query : undefined,

View File

@@ -163,7 +163,7 @@ const notifyAddToAlbums = (
} else if (results.error) {
toastManager.warning($t('assets_cannot_be_added_to_albums', { values: { count: assetIds.length } }));
} else {
toastManager.success(
toastManager.primary(
$t('assets_added_to_albums_count', {
values: { albumTotal: albumIds.length, assetTotal: assetIds.length },
}),
@@ -269,7 +269,7 @@ export const handleDeleteAlbum = async (album: AlbumResponseDto, options?: { pro
await deleteAlbum({ id: album.id });
eventManager.emit('AlbumDelete', album);
if (notify) {
toastManager.success();
toastManager.primary();
}
return true;
} catch (error) {

View File

@@ -80,7 +80,7 @@ export const handleUpdateApiKey = async (apiKey: { id: string }, dto: ApiKeyUpda
try {
const response = await updateApiKey({ id: apiKey.id, apiKeyUpdateDto: dto });
eventManager.emit('ApiKeyUpdate', response);
toastManager.success($t('saved_api_key'));
toastManager.primary($t('saved_api_key'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_save_api_key'));
@@ -98,7 +98,7 @@ export const handleDeleteApiKey = async (apiKey: ApiKeyResponseDto) => {
try {
await deleteApiKey({ id: apiKey.id });
eventManager.emit('ApiKeyDelete', apiKey);
toastManager.success($t('removed_api_key', { values: { name: apiKey.name } }));
toastManager.primary($t('removed_api_key', { values: { name: apiKey.name } }));
} catch (error) {
handleError(error, $t('errors.unable_to_remove_api_key'));
}

View File

@@ -11,7 +11,7 @@ import { vitest } from 'vitest';
vitest.mock('@immich/ui', () => ({
toastManager: {
success: vitest.fn(),
primary: vitest.fn(),
},
}));
@@ -67,7 +67,7 @@ describe('AssetService', () => {
const asset = assetFactory.build({ originalFileName: 'asset.heic' });
await handleDownloadAsset(asset, { edited: false });
expect($t).toHaveBeenNthCalledWith(1, 'downloading_asset_filename', { values: { filename: 'asset.heic' } });
expect(toastManager.success).toHaveBeenCalledWith('formatter');
expect(toastManager.primary).toHaveBeenCalledWith('formatter');
});
it('should use the motion asset originalFileName when showing toasts', async () => {
@@ -79,7 +79,7 @@ describe('AssetService', () => {
await handleDownloadAsset(asset, { edited: false });
expect($t).toHaveBeenNthCalledWith(1, 'downloading_asset_filename', { values: { filename: 'asset.heic' } });
expect($t).toHaveBeenNthCalledWith(2, 'downloading_asset_filename', { values: { filename: 'asset.mov' } });
expect(toastManager.success).toHaveBeenCalledWith('formatter');
expect(toastManager.primary).toHaveBeenCalledWith('formatter');
});
});
});

View File

@@ -334,7 +334,7 @@ export const handleDownloadAsset = async (asset: AssetResponseDto, { edited }: {
}
try {
toastManager.success($t('downloading_asset_filename', { values: { filename } }));
toastManager.primary($t('downloading_asset_filename', { values: { filename } }));
downloadUrl(
getBaseUrl() +
`/assets/${id}/original` +
@@ -352,7 +352,7 @@ const handleFavorite = async (asset: AssetResponseDto) => {
try {
const response = await updateAsset({ id: asset.id, updateAssetDto: { isFavorite: true } });
toastManager.success($t('added_to_favorites'));
toastManager.primary($t('added_to_favorites'));
eventManager.emit('AssetUpdate', response);
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: asset.isFavorite } }));
@@ -364,7 +364,7 @@ const handleUnfavorite = async (asset: AssetResponseDto) => {
try {
const response = await updateAsset({ id: asset.id, updateAssetDto: { isFavorite: false } });
toastManager.success($t('removed_from_favorites'));
toastManager.primary($t('removed_from_favorites'));
eventManager.emit('AssetUpdate', response);
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: asset.isFavorite } }));
@@ -387,7 +387,7 @@ const handleRunAssetJob = async (dto: AssetJobsDto) => {
try {
await runAssetJobs({ assetJobsDto: dto });
toastManager.success(getAssetJobMessage($t, dto.name));
toastManager.primary(getAssetJobMessage($t, dto.name));
} catch (error) {
handleError(error, $t('errors.unable_to_submit_job'));
}

View File

@@ -8,7 +8,7 @@ export const handleCreateJob = async (dto: JobCreateDto) => {
try {
await createJob({ jobCreateDto: dto });
toastManager.success($t('admin.job_created'));
toastManager.primary($t('admin.job_created'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_submit_job'));

View File

@@ -161,7 +161,7 @@ export const handleCreateLibrary = async (dto: CreateLibraryDto) => {
try {
const library = await createLibrary({ createLibraryDto: dto });
eventManager.emit('LibraryCreate', library);
toastManager.success($t('admin.library_created', { values: { library: library.name } }));
toastManager.primary($t('admin.library_created', { values: { library: library.name } }));
return library;
} catch (error) {
handleError(error, $t('errors.unable_to_create_library'));
@@ -174,7 +174,7 @@ export const handleUpdateLibrary = async (library: LibraryResponseDto, dto: Upda
try {
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: dto });
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
toastManager.primary($t('admin.library_updated'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
@@ -205,7 +205,7 @@ const handleDeleteLibrary = async (library: LibraryResponseDto) => {
try {
await deleteLibrary({ id: library.id });
eventManager.emit('LibraryDelete', { id: library.id });
toastManager.success($t('admin.library_deleted'));
toastManager.primary($t('admin.library_deleted'));
} catch (error) {
handleError(error, $t('errors.unable_to_remove_library'));
}
@@ -225,7 +225,7 @@ export const handleAddLibraryFolder = async (library: LibraryResponseDto, folder
updateLibraryDto: { importPaths: [...library.importPaths, folder] },
});
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
toastManager.primary($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
@@ -246,7 +246,7 @@ export const handleEditLibraryFolder = async (library: LibraryResponseDto, oldVa
try {
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: { importPaths } });
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
toastManager.primary($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
@@ -273,7 +273,7 @@ const handleDeleteLibraryFolder = async (library: LibraryResponseDto, folder: st
updateLibraryDto: { importPaths: library.importPaths.filter((path) => path !== folder) },
});
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
toastManager.primary($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
}
@@ -293,7 +293,7 @@ export const handleAddLibraryExclusionPattern = async (library: LibraryResponseD
updateLibraryDto: { exclusionPatterns: [...library.exclusionPatterns, exclusionPattern] },
});
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
toastManager.primary($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
@@ -314,7 +314,7 @@ export const handleEditExclusionPattern = async (library: LibraryResponseDto, ol
try {
const updatedLibrary = await updateLibrary({ id: library.id, updateLibraryDto: { exclusionPatterns } });
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
toastManager.primary($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
return false;
@@ -339,7 +339,7 @@ const handleDeleteExclusionPattern = async (library: LibraryResponseDto, exclusi
},
});
eventManager.emit('LibraryUpdate', updatedLibrary);
toastManager.success($t('admin.library_updated'));
toastManager.primary($t('admin.library_updated'));
} catch (error) {
handleError(error, $t('errors.unable_to_update_library'));
}

View File

@@ -57,7 +57,7 @@ const handleFavoritePerson = async (person: { id: string }) => {
try {
const response = await updatePerson({ id: person.id, personUpdateDto: { isFavorite: true } });
eventManager.emit('PersonUpdate', response);
toastManager.success($t('added_to_favorites'));
toastManager.primary($t('added_to_favorites'));
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: false } }));
}
@@ -69,7 +69,7 @@ const handleUnfavoritePerson = async (person: { id: string }) => {
try {
const response = await updatePerson({ id: person.id, personUpdateDto: { isFavorite: false } });
eventManager.emit('PersonUpdate', response);
toastManager.success($t('removed_from_favorites'));
toastManager.primary($t('removed_from_favorites'));
} catch (error) {
handleError(error, $t('errors.unable_to_add_remove_favorites', { values: { favorite: false } }));
}
@@ -80,7 +80,7 @@ const handleHidePerson = async (person: { id: string }) => {
try {
const response = await updatePerson({ id: person.id, personUpdateDto: { isHidden: true } });
toastManager.success($t('changed_visibility_successfully'));
toastManager.primary($t('changed_visibility_successfully'));
eventManager.emit('PersonUpdate', response);
} catch (error) {
handleError(error, $t('errors.unable_to_hide_person'));
@@ -92,7 +92,7 @@ const handleShowPerson = async (person: { id: string }) => {
try {
const response = await updatePerson({ id: person.id, personUpdateDto: { isHidden: false } });
toastManager.success($t('changed_visibility_successfully'));
toastManager.primary($t('changed_visibility_successfully'));
eventManager.emit('PersonUpdate', response);
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
@@ -104,7 +104,7 @@ export const handleUpdatePersonBirthDate = async (person: PersonResponseDto, bir
try {
const response = await updatePerson({ id: person.id, personUpdateDto: { birthDate } });
toastManager.success($t('date_of_birth_saved'));
toastManager.primary($t('date_of_birth_saved'));
eventManager.emit('PersonUpdate', response);
return true;
} catch (error) {

View File

@@ -129,7 +129,7 @@ export const handleEmptyQueue = async (queue: QueueResponseDto) => {
await emptyQueue({ name: queue.name, queueDeleteDto: { failed: false } });
const response = await getQueue({ name: queue.name });
eventManager.emit('QueueUpdate', response);
toastManager.success($t('admin.cleared_jobs', { values: { job: item.title } }));
toastManager.primary($t('admin.cleared_jobs', { values: { job: item.title } }));
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}
@@ -155,7 +155,7 @@ const handleRemoveFailedJobs = async (queue: QueueResponseDto) => {
await emptyQueue({ name: queue.name, queueDeleteDto: { failed: true } });
const response = await getQueue({ name: queue.name });
eventManager.emit('QueueUpdate', response);
toastManager.success();
toastManager.primary();
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}

View File

@@ -95,7 +95,7 @@ export const handleUpdateSharedLink = async (sharedLink: SharedLinkResponseDto,
const response = await updateSharedLink({ id: sharedLink.id, sharedLinkEditDto: dto });
eventManager.emit('SharedLinkUpdate', { album: sharedLink.album, ...response });
toastManager.success($t('saved'));
toastManager.primary($t('saved'));
return true;
} catch (error) {
@@ -118,7 +118,7 @@ const handleDeleteSharedLink = async (sharedLink: SharedLinkResponseDto) => {
try {
await removeSharedLink({ id: sharedLink.id });
eventManager.emit('SharedLinkDelete', sharedLink);
toastManager.success($t('deleted_shared_link'));
toastManager.primary($t('deleted_shared_link'));
} catch (error) {
handleError(error, $t('errors.unable_to_delete_shared_link'));
}
@@ -150,7 +150,7 @@ export const handleRemoveSharedLinkAssets = async (sharedLink: SharedLinkRespons
}
const count = results.filter((item) => item.success).length;
toastManager.success($t('assets_removed_count', { values: { count } }));
toastManager.primary($t('assets_removed_count', { values: { count } }));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_remove_assets_from_shared_link'));

View File

@@ -62,7 +62,7 @@ export const handleSystemConfigSave = async (update: Partial<SystemConfigDto>) =
const newConfig = await updateConfig({ systemConfigDto });
eventManager.emit('SystemConfigUpdate', newConfig);
toastManager.success($t('settings_saved'));
toastManager.primary($t('settings_saved'));
} catch (error) {
handleError(error, $t('errors.unable_to_save_settings'));
}

View File

@@ -42,7 +42,7 @@ export const handleCreateTag = async (tagValue: string) => {
return;
}
toastManager.success($t('tag_created', { values: { tag: tag.value } }));
toastManager.primary($t('tag_created', { values: { tag: tag.value } }));
eventManager.emit('TagCreate', tag);
return true;
@@ -61,7 +61,7 @@ export const handleUpdateTag = async (tag: TreeNode, dto: TagUpdateDto) => {
try {
const response = await updateTag({ id: tag.id, tagUpdateDto: dto });
toastManager.success($t('tag_updated', { values: { tag: tag.value } }));
toastManager.primary($t('tag_updated', { values: { tag: tag.value } }));
eventManager.emit('TagUpdate', response);
return true;
@@ -91,7 +91,7 @@ const handleDeleteTag = async (tag: TreeNode) => {
try {
await deleteTag({ id: tagId });
eventManager.emit('TagDelete', tag);
toastManager.success();
toastManager.primary();
} catch (error) {
handleError(error, $t('errors.something_went_wrong'));
}

View File

@@ -31,7 +31,7 @@ export const handleEmptyTrash = async () => {
try {
const { count } = await emptyTrash();
toastManager.success($t('assets_permanently_deleted_count', { values: { count } }));
toastManager.primary($t('assets_permanently_deleted_count', { values: { count } }));
} catch (error) {
handleError(error, $t('errors.unable_to_empty_trash'));
}
@@ -47,7 +47,7 @@ export const handleRestoreTrash = async () => {
try {
const { count } = await restoreTrash();
toastManager.success($t('assets_restored_count', { values: { count } }));
toastManager.primary($t('assets_restored_count', { values: { count } }));
} catch (error) {
handleError(error, $t('errors.unable_to_restore_trash'));
}

View File

@@ -109,7 +109,7 @@ export const handleCreateUserAdmin = async (dto: UserAdminCreateDto) => {
try {
const response = await createUserAdmin({ userAdminCreateDto: dto });
eventManager.emit('UserAdminCreate', response);
toastManager.success();
toastManager.primary();
return response;
} catch (error) {
handleError(error, $t('errors.unable_to_create_user'));
@@ -122,7 +122,7 @@ export const handleUpdateUserAdmin = async (user: UserAdminResponseDto, dto: Use
try {
const response = await updateUserAdmin({ id: user.id, userAdminUpdateDto: dto });
eventManager.emit('UserAdminUpdate', response);
toastManager.success();
toastManager.primary();
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_update_user'));
@@ -136,7 +136,7 @@ export const handleDeleteUserAdmin = async (user: UserAdminResponseDto, dto: Use
try {
const result = await deleteUserAdmin({ id: user.id, userAdminDeleteDto: dto });
eventManager.emit('UserAdminDelete', result);
toastManager.success();
toastManager.primary();
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_delete_user'));
@@ -149,7 +149,7 @@ export const handleRestoreUserAdmin = async (user: UserAdminResponseDto) => {
try {
const response = await restoreUserAdmin({ id: user.id });
eventManager.emit('UserAdminRestore', response);
toastManager.success();
toastManager.primary();
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_restore_user'));
@@ -190,7 +190,7 @@ const handleResetPasswordUserAdmin = async (user: UserAdminResponseDto) => {
const dto = { password: generatePassword(), shouldChangePassword: true };
const response = await updateUserAdmin({ id: user.id, userAdminUpdateDto: dto });
eventManager.emit('UserAdminUpdate', response);
toastManager.success();
toastManager.primary();
await modalManager.show(PasswordResetSuccessModal, { newPassword: dto.password });
} catch (error) {
handleError(error, $t('errors.unable_to_reset_password'));
@@ -208,7 +208,7 @@ const handleResetPinCodeUserAdmin = async (user: UserAdminResponseDto) => {
try {
const response = await updateUserAdmin({ id: user.id, userAdminUpdateDto: { pinCode: null } });
eventManager.emit('UserAdminUpdate', response);
toastManager.success($t('pin_code_reset_successfully'));
toastManager.primary($t('pin_code_reset_successfully'));
} catch (error) {
handleError(error, $t('errors.unable_to_reset_pin_code'));
}

View File

@@ -39,7 +39,7 @@ export const handleResetPinCode = async (dto: PinCodeResetDto) => {
try {
await resetPinCode({ pinCodeResetDto: dto });
toastManager.success($t('pin_code_reset_successfully'));
toastManager.primary($t('pin_code_reset_successfully'));
eventManager.emit('UserPinCodeReset');
return true;
} catch (error) {
@@ -52,7 +52,7 @@ export const handleChangePassword = async (dto: ChangePasswordDto) => {
try {
await changePassword({ changePasswordDto: dto });
toastManager.success($t('updated_password'));
toastManager.primary($t('updated_password'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_change_password'));

View File

@@ -397,7 +397,7 @@ export const handleToggleWorkflowEnabled = async (
});
eventManager.emit('WorkflowUpdate', updated);
toastManager.success($t('workflow_updated'));
toastManager.primary($t('workflow_updated'));
return updated;
} catch (error) {
handleError(error, $t('errors.unable_to_update_workflow'));
@@ -419,7 +419,7 @@ export const handleDeleteWorkflow = async (workflow: WorkflowResponseDto): Promi
try {
await deleteWorkflow({ id: workflow.id });
eventManager.emit('WorkflowDelete', workflow);
toastManager.success($t('workflow_deleted'));
toastManager.primary($t('workflow_deleted'));
return true;
} catch (error) {
handleError(error, $t('errors.unable_to_delete_workflow'));

View File

@@ -1,6 +1,9 @@
import { MediaType } from '$lib/constants';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import type { QueueResponseDto, ServerVersionResponseDto } from '@immich/sdk';
import type { ActionItem } from '@immich/ui';
import type { DateTime } from 'luxon';
import type { SvelteSet } from 'svelte/reactivity';
export interface ReleaseEvent {
isAvailable: boolean;
@@ -48,3 +51,41 @@ export type AssetControlContext = {
getOwnedAssets: () => TimelineAsset[]; // Only assets owned by the user
clearSelect: () => void;
};
export type SearchCameraFilter = {
make?: string;
model?: string;
lensModel?: string;
};
export type SearchDateFilter = {
takenBefore?: DateTime;
takenAfter?: DateTime;
};
export type SearchDisplayFilters = {
isNotInAlbum: boolean;
isArchive: boolean;
isFavorite: boolean;
};
export type SearchLocationFilter = {
country?: string;
state?: string;
city?: string;
};
export type SearchFilter = {
query: string;
ocr?: string;
queryType: 'smart' | 'metadata' | 'description' | 'ocr';
personIds: SvelteSet<string>;
tagIds: SvelteSet<string> | null;
location: SearchLocationFilter;
queryAssetId?: string;
camera: SearchCameraFilter;
date: SearchDateFilter;
display: SearchDisplayFilters;
mediaType: MediaType;
rating?: number | null;
};

Some files were not shown because too many files have changed in this diff Show More