Compare commits

..

13 Commits

Author SHA1 Message Date
github-actions 3254d31cd2 chore: version v2.7.3 2026-04-09 17:51:40 +00:00
Jason Rasmussen 7b269d1638 fix: ssr open graph tags (#27639)
fix: SSR open graph tags
2026-04-09 12:16:41 -04:00
Luis Nachtigall b5bed02300 fix(mobile): get provider refs before async gaps in backup page (#27597)
* fix(mobile): get provider refs before async gaps in backup page

* fix(mobile): use previously created provider refs in start backup function
2026-04-08 20:55:53 -05:00
Zack Pollard 5553910236 fix(web): don't cache empty search results for people search (#27632) 2026-04-09 02:33:04 +01:00
Zack Pollard 8d67c1f820 fix(server): people search not showing for 3 or less characters (#27629)
Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com>
2026-04-09 01:56:07 +01:00
Matthew Momjian ed0ec30917 fix(docs): updated docker deprecation link (#27633)
new link
2026-04-08 20:33:11 -04:00
Luis Nachtigall 2b0f6c9202 fix(mobile): improve image load cancellation handling (#27624)
fix(image): improve image load cancellation handling
2026-04-08 17:23:42 -04:00
André Erasmus 55ab8c65b6 fix(server): avoid false restore failures on large database imports (#27420)
* fix(server): increase restore health check timeout and reject with Error

* chore: clean up

---------

Co-authored-by: André Erasmus <25480506+NoBadDays@users.noreply.github.com>
Co-authored-by: Jason Rasmussen <jason@rasm.me>
2026-04-08 16:03:41 -04:00
Cullen Jennings 781d568f29 fix(docs): typo 'Start rating' to 'Star rating' (#27606) 2026-04-08 18:25:45 +00:00
Zack Pollard 6a361dae72 fix(server): use randomized cron for version check scheduling (#27626)
Also removes unnecessary rate limit
2026-04-08 19:15:38 +01:00
renovate[bot] 64766c8c06 chore(deps): update github-actions (#27560)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-08 17:32:54 +02:00
github-actions 6a63e814a5 chore: version v2.7.2 2026-04-07 20:58:38 +00:00
Jason Rasmussen 6441c3b77c fix: server build (#27599) 2026-04-07 20:53:04 +00:00
42 changed files with 172 additions and 145 deletions
+1 -1
View File
@@ -210,7 +210,7 @@ jobs:
working-directory: ./mobile
- name: Setup Ruby
uses: ruby/setup-ruby@c515ec17f69368147deb311832da000dd229d338 # v1.297.0
uses: ruby/setup-ruby@3ff19f5e2baf30647122352b96108b1fbe250c64 # v1.299.0
with:
ruby-version: '3.3'
bundler-cache: true
+1 -1
View File
@@ -89,7 +89,7 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
registry: ghcr.io
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
needs: [get_body, should_run]
if: ${{ needs.should_run.outputs.should_run == 'true' }}
container:
image: ghcr.io/immich-app/mdq:main@sha256:df7188ba88abb0800d73cc97d3633280f0c0c3d4c441d678225067bf154150fb
image: ghcr.io/immich-app/mdq:main@sha256:557cca601891b8b7d78b940071d35aaf7aaeb9b327d19b22cf282118edbc5272
outputs:
checked: ${{ steps.get_checkbox.outputs.checked }}
steps:
+3 -3
View File
@@ -57,7 +57,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
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@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
uses: github/codeql-action/autobuild@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
# ️ 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@38697555549f1db7851b81482ff19f1fa5c4fedc # v4.34.1
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
with:
category: '/language:${{matrix.language}}'
+2 -2
View File
@@ -60,7 +60,7 @@ jobs:
suffix: ['', '-cuda', '-rocm', '-openvino', '-armnn', '-rknn']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -90,7 +90,7 @@ jobs:
suffix: ['']
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
+3 -3
View File
@@ -19,7 +19,7 @@ jobs:
app-id: ${{ secrets.PUSH_O_MATIC_APP_ID }}
private-key: ${{ secrets.PUSH_O_MATIC_APP_KEY }}
- uses: mshick/add-pr-comment@ffd016c7e151d97d69d21a843022fd4cd5b96fe5 # v3.9.0
- uses: mshick/add-pr-comment@64b8e914979889d746c99dea15a76e77ef64580a # v3.10.0
with:
github-token: ${{ steps.token.outputs.token }}
message-id: 'preview-status'
@@ -48,14 +48,14 @@ jobs:
name: 'preview'
})
- uses: mshick/add-pr-comment@ffd016c7e151d97d69d21a843022fd4cd5b96fe5 # v3.9.0
- uses: mshick/add-pr-comment@64b8e914979889d746c99dea15a76e77ef64580a # v3.10.0
if: ${{ github.event.pull_request.head.repo.fork }}
with:
github-token: ${{ steps.token.outputs.token }}
message-id: 'preview-status'
message: 'PRs from forks cannot have preview environments.'
- uses: mshick/add-pr-comment@ffd016c7e151d97d69d21a843022fd4cd5b96fe5 # v3.9.0
- uses: mshick/add-pr-comment@64b8e914979889d746c99dea15a76e77ef64580a # v3.10.0
if: ${{ !github.event.pull_request.head.repo.fork }}
with:
github-token: ${{ steps.token.outputs.token }}
+1
View File
@@ -28,4 +28,5 @@ vite.config.js.timestamp-*
.pnpm-store
.devcontainer/library
.devcontainer/.env*
*.tsbuildinfo
*.tsbuildInfo
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/cli",
"version": "2.7.1",
"version": "2.7.3",
"description": "Command Line Interface (CLI) for Immich",
"type": "module",
"exports": "./dist/index.js",
+1 -1
View File
@@ -26,7 +26,7 @@ You can search the following types of content:
| Time frame | Start and end date of a specific time bucket |
| Media type | Image or video or both |
| Display options | In Archive, in Favorites or Not in any album |
| Start rating | User-assigned start rating |
| Star rating | User-assigned star rating |
<img src={require('./img/advanced-search-filters.webp').default} width="70%" title='Advanced search filters' />
+1 -1
View File
@@ -49,7 +49,7 @@ Immich requires [**Docker**](https://docs.docker.com/get-started/get-docker/) wi
The Compose plugin will be installed by both Docker Engine and Desktop by following the linked installation guides; it can also be [separately installed](https://docs.docker.com/compose/install/).
:::note
Immich requires the command `docker compose`; the similarly named `docker-compose` is [deprecated](https://docs.docker.com/compose/migrate/) and is no longer supported by Immich.
Immich requires the command `docker compose`; the similarly named `docker-compose` is [deprecated](https://docs.docker.com/retired/#docker-compose-v1-replaced-by-compose-v2) and is no longer supported by Immich.
:::
### Special requirements for Windows users
+2 -2
View File
@@ -1,7 +1,7 @@
[
{
"label": "v2.7.1",
"url": "https://docs.v2.7.1.archive.immich.app"
"label": "v2.7.3",
"url": "https://docs.v2.7.3.archive.immich.app"
},
{
"label": "v2.6.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-e2e",
"version": "2.7.1",
"version": "2.7.3",
"description": "",
"main": "index.js",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-i18n",
"version": "2.7.1",
"version": "2.7.3",
"private": true,
"scripts": {
"format": "prettier --cache --check .",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "immich-ml"
version = "2.7.1"
version = "2.7.3"
description = ""
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
requires-python = ">=3.11,<4.0"
+1 -1
View File
@@ -898,7 +898,7 @@ wheels = [
[[package]]
name = "immich-ml"
version = "2.7.1"
version = "2.7.3"
source = { editable = "." }
dependencies = [
{ name = "aiocache" },
+2 -2
View File
@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 3042,
"android.injected.version.name" => "2.7.1",
"android.injected.version.code" => 3044,
"android.injected.version.name" => "2.7.3",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
+1 -1
View File
@@ -80,7 +80,7 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>2.7.1</string>
<string>2.7.3</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
+10 -7
View File
@@ -45,14 +45,17 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
}
WidgetsBinding.instance.addPostFrameCallback((_) async {
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
final backupNotifier = ref.read(driftBackupProvider.notifier);
final syncManager = ref.read(backgroundSyncProvider);
ref.read(driftBackupProvider.notifier).updateSyncing(true);
syncSuccess = await ref.read(backgroundSyncProvider).syncRemote();
ref.read(driftBackupProvider.notifier).updateSyncing(false);
await backupNotifier.getBackupStatus(currentUser.id);
backupNotifier.updateSyncing(true);
syncSuccess = await syncManager.syncRemote();
backupNotifier.updateSyncing(false);
if (mounted) {
await ref.read(driftBackupProvider.notifier).getBackupStatus(currentUser.id);
await backupNotifier.getBackupStatus(currentUser.id);
}
});
}
@@ -82,9 +85,9 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
}
if (syncSuccess == null) {
ref.read(driftBackupProvider.notifier).updateSyncing(true);
backupNotifier.updateSyncing(true);
syncSuccess = await backupSyncManager.syncRemote();
ref.read(driftBackupProvider.notifier).updateSyncing(false);
backupNotifier.updateSyncing(false);
}
await backupNotifier.getBackupStatus(currentUser.id);
@@ -19,6 +19,7 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
static final _log = Logger('CancellableImageProviderMixin');
bool isCancelled = false;
bool isFinished = false;
ImageRequest? request;
CancelableOperation<ImageInfo?>? cachedOperation;
@@ -53,13 +54,15 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
Stream<ImageInfo> loadRequest(ImageRequest request, ImageDecoderCallback decode, {bool evictOnError = true}) async* {
if (isCancelled) {
this.request = null;
PaintingBinding.instance.imageCache.evict(this);
return;
}
try {
final image = await request.load(decode);
if ((image == null && evictOnError) || isCancelled) {
if (isCancelled) {
return;
}
if (image == null && evictOnError) {
PaintingBinding.instance.imageCache.evict(this);
return;
} else if (image == null) {
@@ -67,6 +70,9 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
}
yield image;
} catch (e, stack) {
if (isCancelled) {
return;
}
if (evictOnError) {
PaintingBinding.instance.imageCache.evict(this);
rethrow;
@@ -80,20 +86,24 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
Future<ui.Codec?> loadCodecRequest(ImageRequest request) async {
if (isCancelled) {
this.request = null;
PaintingBinding.instance.imageCache.evict(this);
return null;
}
try {
final codec = await request.loadCodec();
if (codec == null || isCancelled) {
if (isCancelled) {
codec?.dispose();
return null;
}
if (codec == null) {
PaintingBinding.instance.imageCache.evict(this);
return null;
}
return codec;
} catch (e) {
PaintingBinding.instance.imageCache.evict(this);
if (!isCancelled) {
PaintingBinding.instance.imageCache.evict(this);
}
rethrow;
} finally {
this.request = null;
@@ -121,6 +131,8 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
@override
void cancel() {
isCancelled = true;
final hasActiveWork = !isFinished;
final request = this.request;
if (request != null) {
this.request = null;
@@ -132,6 +144,10 @@ mixin CancellableImageProviderMixin<T extends Object> on CancellableImageProvide
cachedOperation = null;
operation.cancel();
}
if (hasActiveWork) {
PaintingBinding.instance.imageCache.evict(this);
}
}
}
@@ -100,7 +100,6 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
yield* initialImageStream();
if (isCancelled) {
PaintingBinding.instance.imageCache.evict(this);
return;
}
@@ -113,24 +112,24 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
yield* loadRequest(request, decode);
if (!Store.get(StoreKey.loadOriginal, false)) {
isFinished = true;
return;
}
if (isCancelled) {
PaintingBinding.instance.imageCache.evict(this);
return;
}
request = this.request = LocalImageRequest(localId: key.id, assetType: key.assetType, size: Size.zero);
yield* loadRequest(request, decode);
isFinished = true;
}
Stream<Object> _animatedCodec(LocalFullImageProvider key, ImageDecoderCallback decode) async* {
yield* initialImageStream();
if (isCancelled) {
PaintingBinding.instance.imageCache.evict(this);
return;
}
@@ -143,7 +142,6 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
yield* loadRequest(previewRequest, decode);
if (isCancelled) {
PaintingBinding.instance.imageCache.evict(this);
return;
}
@@ -151,9 +149,11 @@ class LocalFullImageProvider extends CancellableImageProvider<LocalFullImageProv
final originalRequest = request = LocalImageRequest(localId: key.id, size: Size.zero, assetType: key.assetType);
final codec = await loadCodecRequest(originalRequest);
if (codec == null) {
if (isCancelled) return;
throw StateError('Failed to load animated codec for local asset ${key.id}');
}
yield codec;
isFinished = true;
}
@override
@@ -105,7 +105,6 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
yield* initialImageStream();
if (isCancelled) {
PaintingBinding.instance.imageCache.evict(this);
return;
}
@@ -116,23 +115,23 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
yield* loadRequest(previewRequest, decode, evictOnError: !loadOriginal);
if (!loadOriginal) {
isFinished = true;
return;
}
if (isCancelled) {
PaintingBinding.instance.imageCache.evict(this);
return;
}
final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId));
yield* loadRequest(originalRequest, decode);
isFinished = true;
}
Stream<Object> _animatedCodec(RemoteFullImageProvider key, ImageDecoderCallback decode) async* {
yield* initialImageStream();
if (isCancelled) {
PaintingBinding.instance.imageCache.evict(this);
return;
}
@@ -142,7 +141,6 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
yield* loadRequest(previewRequest, decode, evictOnError: false);
if (isCancelled) {
PaintingBinding.instance.imageCache.evict(this);
return;
}
@@ -150,9 +148,13 @@ class RemoteFullImageProvider extends CancellableImageProvider<RemoteFullImagePr
final originalRequest = request = RemoteImageRequest(uri: getOriginalUrlForRemoteId(key.assetId));
final codec = await loadCodecRequest(originalRequest);
if (codec == null) {
if (isCancelled) {
return;
}
throw StateError('Failed to load animated codec for asset ${key.assetId}');
}
yield codec;
isFinished = true;
}
@override
+1 -1
View File
@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 2.7.1
- API version: 2.7.3
- Generator version: 7.8.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none'
version: 2.7.1+3042
version: 2.7.3+3044
environment:
sdk: '>=3.8.0 <4.0.0'
+1 -1
View File
@@ -15225,7 +15225,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "2.7.1",
"version": "2.7.3",
"contact": {}
},
"tags": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@immich/sdk",
"version": "2.7.1",
"version": "2.7.3",
"description": "Auto-generated TypeScript SDK for the Immich API",
"type": "module",
"main": "./build/index.js",
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* Immich
* 2.7.1
* 2.7.3
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-monorepo",
"version": "2.7.1",
"version": "2.7.3",
"description": "Monorepo for Immich",
"private": true,
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
-46
View File
@@ -553,9 +553,6 @@ importers:
sanitize-filename:
specifier: ^1.6.3
version: 1.6.4
sanitize-html:
specifier: ^2.14.0
version: 2.17.2
semver:
specifier: ^7.6.2
version: 7.7.4
@@ -659,9 +656,6 @@ importers:
'@types/react':
specifier: ^19.0.0
version: 19.2.14
'@types/sanitize-html':
specifier: ^2.13.0
version: 2.16.1
'@types/semver':
specifier: ^7.5.8
version: 7.7.1
@@ -5167,9 +5161,6 @@ packages:
'@types/retry@0.12.2':
resolution: {integrity: sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==}
'@types/sanitize-html@2.16.1':
resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==}
'@types/sax@1.2.7':
resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==}
@@ -7971,9 +7962,6 @@ packages:
webpack:
optional: true
htmlparser2@10.1.0:
resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==}
htmlparser2@6.1.0:
resolution: {integrity: sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==}
@@ -8303,10 +8291,6 @@ packages:
resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==}
engines: {node: '>=0.10.0'}
is-plain-object@5.0.0:
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
engines: {node: '>=0.10.0'}
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
@@ -9692,9 +9676,6 @@ packages:
parse-numeric-range@1.3.0:
resolution: {integrity: sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==}
parse-srcset@1.0.2:
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
parse5-htmlparser2-tree-adapter@7.1.0:
resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==}
@@ -10892,9 +10873,6 @@ packages:
sanitize-filename@1.6.4:
resolution: {integrity: sha512-9ZyI08PsvdQl2r/bBIGubpVdR3RR9sY6RDiWFPreA21C/EFlQhmgo20UZlNjZMMZNubusLhAQozkA0Od5J21Eg==}
sanitize-html@2.17.2:
resolution: {integrity: sha512-EnffJUl46VE9uvZ0XeWzObHLurClLlT12gsOk1cHyP2Ol1P0BnBnsXmShlBmWVJM+dKieQI68R0tsPY5m/B+Jg==}
sass@1.97.1:
resolution: {integrity: sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==}
engines: {node: '>=14.0.0'}
@@ -17459,10 +17437,6 @@ snapshots:
'@types/retry@0.12.2': {}
'@types/sanitize-html@2.16.1':
dependencies:
htmlparser2: 10.1.0
'@types/sax@1.2.7':
dependencies:
'@types/node': 24.12.2
@@ -20865,13 +20839,6 @@ snapshots:
optionalDependencies:
webpack: 5.104.1
htmlparser2@10.1.0:
dependencies:
domelementtype: 2.3.0
domhandler: 5.0.3
domutils: 3.2.2
entities: 7.0.1
htmlparser2@6.1.0:
dependencies:
domelementtype: 2.3.0
@@ -21200,8 +21167,6 @@ snapshots:
dependencies:
isobject: 3.0.1
is-plain-object@5.0.0: {}
is-potential-custom-element-name@1.0.1:
optional: true
@@ -22933,8 +22898,6 @@ snapshots:
parse-numeric-range@1.3.0: {}
parse-srcset@1.0.2: {}
parse5-htmlparser2-tree-adapter@7.1.0:
dependencies:
domhandler: 5.0.3
@@ -24278,15 +24241,6 @@ snapshots:
dependencies:
truncate-utf8-bytes: 1.0.2
sanitize-html@2.17.2:
dependencies:
deepmerge: 4.3.1
escape-string-regexp: 4.0.0
htmlparser2: 10.1.0
is-plain-object: 5.0.0
parse-srcset: 1.0.2
postcss: 8.5.8
sass@1.97.1:
dependencies:
chokidar: 4.0.3
+1 -3
View File
@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "2.7.1",
"version": "2.7.3",
"description": "",
"author": "",
"private": true,
@@ -110,7 +110,6 @@
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1",
"sanitize-filename": "^1.6.3",
"sanitize-html": "^2.14.0",
"semver": "^7.6.2",
"sharp": "^0.34.5",
"sirv": "^3.0.0",
@@ -147,7 +146,6 @@
"@types/picomatch": "^4.0.0",
"@types/pngjs": "^6.0.5",
"@types/react": "^19.0.0",
"@types/sanitize-html": "^2.13.0",
"@types/semver": "^7.5.8",
"@types/supertest": "^7.0.0",
"@types/ua-parser-js": "^0.7.36",
+1
View File
@@ -848,6 +848,7 @@ export enum AssetVisibility {
export enum CronJob {
LibraryScan = 'LibraryScan',
NightlyJobs = 'NightlyJobs',
VersionCheck = 'VersionCheck',
}
export enum ApiTag {
@@ -36,15 +36,17 @@ export class MaintenanceHealthRepository {
}
});
worker.on('exit', (code, signal) => reject(`Server health check failed, server exited with ${signal ?? code}`));
worker.on('error', (error) => reject(`Server health check failed, process threw: ${error}`));
worker.on('exit', (code, signal) =>
reject(new Error(`Server health check failed, server exited with ${signal ?? code}`)),
);
worker.on('error', (error) => reject(new Error(`Server health check failed, process threw: ${error}`)));
setTimeout(() => {
if (worker.exitCode === null) {
reject('Server health check failed, took too long to start.');
reject(new Error('Server health check failed, took too long to start.'));
worker.kill('SIGTERM');
}
}, 20_000);
}, 180_000);
});
}
}
+7 -1
View File
@@ -195,13 +195,19 @@ where
"asset_face"."id" = $2
-- PersonRepository.getByName
with
"similarity_threshold" as (
select
set_config('pg_trgm.word_similarity_threshold', '0.5', true) as "thresh"
)
select
"person".*
from
"similarity_threshold",
"person"
where
"person"."ownerId" = $1
and f_unaccent ("person"."name") %>> f_unaccent ($2)
and f_unaccent ("person"."name") %> f_unaccent ($2)
order by
f_unaccent ("person"."name") <->>> f_unaccent ($3)
limit
+5 -2
View File
@@ -310,10 +310,13 @@ export class PersonRepository {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.STRING, { withHidden: true }] })
getByName(userId: string, personName: string, { withHidden }: PersonNameSearchOptions) {
return this.db
.selectFrom('person')
.with('similarity_threshold', (db) =>
db.selectNoFrom(sql`set_config('pg_trgm.word_similarity_threshold', '0.5', true)`.as('thresh')),
)
.selectFrom(['similarity_threshold', 'person'])
.selectAll('person')
.where('person.ownerId', '=', userId)
.where(() => sql`f_unaccent("person"."name") %>> f_unaccent(${personName})`)
.where(() => sql`f_unaccent("person"."name") %> f_unaccent(${personName})`)
.orderBy(sql`f_unaccent("person"."name") <->>> f_unaccent(${personName})`)
.limit(100)
.$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false))
+36
View File
@@ -0,0 +1,36 @@
import { ApiService, render } from 'src/services/api.service';
describe(ApiService.name, () => {
describe('render', () => {
it('should correctly render open graph tags', () => {
const output = render('<!-- metadata:tags -->', {
title: 'title',
description: 'description',
imageUrl: 'https://demo.immich.app/api/assets/123',
});
expect(output).toContain('<meta property="og:title" content="title" />');
expect(output).toContain('<meta property="og:description" content="description" />');
expect(output).toContain('<meta property="og:image" content="https://demo.immich.app/api/assets/123" />');
});
it('should escape html tags', () => {
expect(
render('<!-- metadata:tags -->', {
title: "<script>console.log('hello')</script>Test",
description: 'description',
}),
).toContain(
'<meta property="og:title" content="&lt;script&gt;console.log(&#39;hello&#39;)&lt;/script&gt;Test" />',
);
});
it('should escape quotes', () => {
expect(
render('<!-- metadata:tags -->', {
title: `0;url=https://example.com" http-equiv="refresh`,
description: 'description',
}),
).toContain('<meta property="og:title" content="0;url=https://example.com&quot; http-equiv=&quot;refresh" />');
});
});
});
+2 -11
View File
@@ -1,19 +1,16 @@
import { Injectable, NotAcceptableException } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { NextFunction, Request, Response } from 'express';
import { escape } from 'lodash';
import { readFileSync } from 'node:fs';
import sanitizeHtml from 'sanitize-html';
import { ONE_HOUR } from 'src/constants';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { AuthService } from 'src/services/auth.service';
import { SharedLinkService } from 'src/services/shared-link.service';
import { VersionService } from 'src/services/version.service';
import { OpenGraphTags } from 'src/utils/misc';
export const render = (index: string, meta: OpenGraphTags) => {
const [title, description, imageUrl] = [meta.title, meta.description, meta.imageUrl].map((item) =>
item ? sanitizeHtml(item, { allowedTags: [] }) : '',
item ? escape(item) : '',
);
const tags = `
@@ -40,18 +37,12 @@ export class ApiService {
constructor(
private authService: AuthService,
private sharedLinkService: SharedLinkService,
private versionService: VersionService,
private configRepository: ConfigRepository,
private logger: LoggingRepository,
) {
this.logger.setContext(ApiService.name);
}
@Interval(ONE_HOUR.as('milliseconds'))
async onVersionCheck() {
await this.versionService.handleQueueVersionCheck();
}
ssr(excludePaths: string[]) {
const { resourcePaths } = this.configRepository.getEnv();
+18 -14
View File
@@ -2,7 +2,7 @@ import { DateTime } from 'luxon';
import { SemVer } from 'semver';
import { defaults } from 'src/config';
import { serverVersion } from 'src/constants';
import { JobName, JobStatus, SystemMetadataKey } from 'src/enum';
import { CronJob, JobName, JobStatus, SystemMetadataKey } from 'src/enum';
import { VersionService } from 'src/services/version.service';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@@ -18,6 +18,8 @@ describe(VersionService.name, () => {
beforeEach(() => {
({ sut, mocks } = newTestService(VersionService));
mocks.cron.create.mockResolvedValue();
mocks.cron.update.mockResolvedValue();
});
it('should work', () => {
@@ -44,6 +46,20 @@ describe(VersionService.name, () => {
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(mocks.versionHistory.create).not.toHaveBeenCalled();
});
it('should create a version check cron job', async () => {
mocks.versionHistory.getLatest.mockResolvedValue({
id: 'version-1',
createdAt: new Date(),
version: serverVersion.toString(),
});
await sut.onBootstrap();
expect(mocks.cron.create).toHaveBeenCalledWith(
expect.objectContaining({
name: CronJob.VersionCheck,
}),
);
});
});
describe('getVersion', () => {
@@ -72,25 +88,13 @@ describe(VersionService.name, () => {
});
describe('handVersionCheck', () => {
it('should not run if the last check was < 60 minutes ago', async () => {
mocks.systemMetadata.get.mockResolvedValue({
checkedAt: DateTime.utc().minus({ minutes: 5 }).toISO(),
releaseVersion: '1.0.0',
});
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Skipped);
});
it('should not run if version check is disabled', async () => {
mocks.systemMetadata.get.mockResolvedValue({ newVersionCheck: { enabled: false } });
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Skipped);
});
it('should run if it has been > 60 minutes', async () => {
it('should run and notify if a new version is available', async () => {
mocks.serverInfo.getLatestRelease.mockResolvedValue(mockVersionResponse('v100.0.0'));
mocks.systemMetadata.get.mockResolvedValue({
checkedAt: DateTime.utc().minus({ minutes: 65 }).toISO(),
releaseVersion: '1.0.0',
});
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.Success);
expect(mocks.systemMetadata.set).toHaveBeenCalled();
expect(mocks.logger.log).toHaveBeenCalled();
+11 -11
View File
@@ -4,10 +4,11 @@ import semver, { SemVer } from 'semver';
import { serverVersion } from 'src/constants';
import { OnEvent, OnJob } from 'src/decorators';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { DatabaseLock, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum';
import { CronJob, DatabaseLock, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service';
import { VersionCheckMetadata } from 'src/types';
import { handlePromiseError } from 'src/utils/misc';
const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => {
return {
@@ -24,6 +25,15 @@ export class VersionService extends BaseService {
async onBootstrap(): Promise<void> {
await this.handleVersionCheck();
const randomMinute = Math.floor(Math.random() * 60);
const expression = `${randomMinute} * * * *`;
this.logger.debug(`Scheduling version check for cron ${expression}`);
this.cronRepository.create({
name: CronJob.VersionCheck,
expression,
onTick: () => handlePromiseError(this.handleQueueVersionCheck(), this.logger),
});
await this.databaseRepository.withLock(DatabaseLock.VersionHistory, async () => {
const previous = await this.versionRepository.getLatest();
const current = serverVersion.toString();
@@ -76,16 +86,6 @@ export class VersionService extends BaseService {
return JobStatus.Skipped;
}
const versionCheck = await this.systemMetadataRepository.get(SystemMetadataKey.VersionCheckState);
if (versionCheck?.checkedAt) {
const lastUpdate = DateTime.fromISO(versionCheck.checkedAt);
const elapsedTime = DateTime.now().diff(lastUpdate).as('minutes');
// check once per hour (max)
if (elapsedTime < 60) {
return JobStatus.Skipped;
}
}
const { version: releaseVersion, published_at: publishedAt } = await this.serverInfoRepository.getLatestRelease();
const metadata: VersionCheckMetadata = { checkedAt: DateTime.utc().toISO(), releaseVersion };
+5
View File
@@ -26,6 +26,7 @@ import { AssetEditRepository } from 'src/repositories/asset-edit.repository';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { CronRepository } from 'src/repositories/cron.repository';
import { CryptoRepository } from 'src/repositories/crypto.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { EmailRepository } from 'src/repositories/email.repository';
@@ -500,6 +501,10 @@ const newMockRepository = <T>(key: ClassConstructor<T>) => {
});
}
case CronRepository: {
return automock(CronRepository, { args: [undefined, { setContext: () => {} }], strict: false });
}
case EmailRepository: {
return automock(EmailRepository, { args: [{ setContext: () => {} }] });
}
@@ -1,6 +1,7 @@
import { Kysely } from 'kysely';
import { serverVersion } from 'src/constants';
import { JobName } from 'src/enum';
import { CronRepository } from 'src/repositories/cron.repository';
import { DatabaseRepository } from 'src/repositories/database.repository';
import { JobRepository } from 'src/repositories/job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -16,7 +17,7 @@ const setup = (db?: Kysely<DB>) => {
return newMediumService(VersionService, {
database: db || defaultDatabase,
real: [DatabaseRepository, VersionHistoryRepository],
mock: [LoggingRepository, JobRepository],
mock: [LoggingRepository, JobRepository, CronRepository],
});
};
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "immich-web",
"version": "2.7.1",
"version": "2.7.3",
"license": "GNU Affero General Public License version 3",
"type": "module",
"scripts": {
@@ -70,7 +70,12 @@
reset();
return;
}
if (!force && searchedPeople.length < maximumLengthSearchPeople && searchName.startsWith(searchWord)) {
if (
!force &&
searchedPeople.length > 0 &&
searchedPeople.length < maximumLengthSearchPeople &&
searchName.startsWith(searchWord)
) {
search();
return;
}