From f73796597c4f0456cc7022fa2789f00f909b3d78 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:34:37 +0530 Subject: [PATCH 01/54] fix: albums broken on v2 mobile clients (#29446) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/domain/services/sync_stream.service.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/mobile/lib/domain/services/sync_stream.service.dart b/mobile/lib/domain/services/sync_stream.service.dart index 5a197dc88a..3d0bf6955e 100644 --- a/mobile/lib/domain/services/sync_stream.service.dart +++ b/mobile/lib/domain/services/sync_stream.service.dart @@ -25,6 +25,7 @@ enum SyncMigrationTask { v20260128_CopyExifWidthHeightToAsset, // Asset table has incorrect width and height for video ratio calculations. v20260128_ResetAssetV1, // Asset v2.5.0 has width and height information that were edited assets. v20260597_ResetAssetV1AssetV2, // Assets didn't include the uploadedAt column. + v20260701_ResetAlbumsV1, // Album user migration dropped the owner. Sync fresh albums from the server to re-populate them. } class SyncStreamService { @@ -103,6 +104,12 @@ class SyncStreamService { } Future _runPreSyncTasks(List migrations, SemVer semVer) async { + if (!migrations.contains(SyncMigrationTask.v20260701_ResetAlbumsV1.name)) { + _logger.info("Running pre-sync task: v20260701_ResetAlbumsV1"); + await _syncApiRepository.deleteSyncAck([SyncEntityType.albumV1]); + migrations.add(SyncMigrationTask.v20260701_ResetAlbumsV1.name); + } + if (!migrations.contains(SyncMigrationTask.v20260128_ResetExifV1.name)) { _logger.info("Running pre-sync task: v20260128_ResetExifV1"); await _syncApiRepository.deleteSyncAck([ From bf4b020856e42219f2da65930d84cb886d57a7bc Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:06:03 +0200 Subject: [PATCH 02/54] fix: setting input field description spacing for description snippet (#29433) --- .../shared-components/settings/SettingInputField.svelte | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/src/lib/components/shared-components/settings/SettingInputField.svelte b/web/src/lib/components/shared-components/settings/SettingInputField.svelte index 8668d71d8b..40cd96f73c 100644 --- a/web/src/lib/components/shared-components/settings/SettingInputField.svelte +++ b/web/src/lib/components/shared-components/settings/SettingInputField.svelte @@ -101,7 +101,9 @@ {description}

{:else} - {@render descriptionSnippet?.()} +
+ {@render descriptionSnippet?.()} +
{/if} {#if inputType !== SettingInputFieldType.PASSWORD} From f77c8a4699aec043ec4a1b404559a7a291d592dc Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 19:24:13 +0000 Subject: [PATCH 03/54] chore: version v3.0.1 --- docs/static/archived-versions.json | 4 ++-- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- machine-learning/uv.lock | 2 +- mobile/android/fastlane/Fastfile | 8 ++++---- mobile/ios/Runner/Info.plist | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- package.json | 2 +- packages/cli/package.json | 2 +- packages/sdk/package.json | 2 +- packages/sdk/src/fetch-client.ts | 2 +- server/package.json | 2 +- web/package.json | 2 +- 15 files changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/static/archived-versions.json b/docs/static/archived-versions.json index aa52b78032..5f9f727219 100644 --- a/docs/static/archived-versions.json +++ b/docs/static/archived-versions.json @@ -1,7 +1,7 @@ [ { - "label": "v3.0.0", - "url": "https://docs.v3.0.0.archive.immich.app" + "label": "v3.0.1", + "url": "https://docs.v3.0.1.archive.immich.app" }, { "label": "v2.7.5", diff --git a/e2e/package.json b/e2e/package.json index 55ab61d0e6..a89c00c5e4 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "3.0.0", + "version": "3.0.1", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 70c137a354..fe22b91a14 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "immich-ml" -version = "3.0.0" +version = "3.0.1" description = "" authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }] requires-python = ">=3.11,<4.0" diff --git a/machine-learning/uv.lock b/machine-learning/uv.lock index 5d84196dc9..8e081c12b4 100644 --- a/machine-learning/uv.lock +++ b/machine-learning/uv.lock @@ -974,7 +974,7 @@ wheels = [ [[package]] name = "immich-ml" -version = "3.0.0" +version = "3.0.1" source = { editable = "." } dependencies = [ { name = "aiocache" }, diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 2ae20504e9..d4d2b761fb 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -22,8 +22,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 3053, - "android.injected.version.name" => "3.0.0", + "android.injected.version.code" => 3054, + "android.injected.version.name" => "3.0.1", } ) 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', track: 'beta') @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 3053, - "android.injected.version.name" => "3.0.0", + "android.injected.version.code" => 3054, + "android.injected.version.name" => "3.0.1", } ) 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') diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 0ff269bf41..e80c3f1ad9 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -78,7 +78,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 3.0.0 + 3.0.1 CFBundleSignature ???? CFBundleURLTypes diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index f057b53c5a..ece44d310a 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 3.0.0 +- API version: 3.0.1 - Generator version: 7.22.0 - Build package: org.openapitools.codegen.languages.DartClientCodegen diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 54ed028f75..f2864a468e 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 3.0.0+3053 +version: 3.0.1+3054 environment: sdk: '>=3.12.0 <4.0.0' diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index a138f7db8c..e674f4712a 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -16206,7 +16206,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "3.0.0", + "version": "3.0.1", "contact": {} }, "tags": [ diff --git a/package.json b/package.json index 8cc0820610..9427a3078a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "immich-monorepo", - "version": "3.0.0", + "version": "3.0.1", "description": "Monorepo for Immich", "type": "module", "private": true, diff --git a/packages/cli/package.json b/packages/cli/package.json index 014f42cf66..7c4ca29645 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@immich/cli", - "version": "3.0.0", + "version": "3.0.1", "description": "Command Line Interface (CLI) for Immich", "repository": { "type": "git", diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 15cdf26ad6..54e681d66a 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "3.0.0", + "version": "3.0.1", "description": "Auto-generated TypeScript SDK for the Immich API", "repository": { "type": "git", diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index a168b4fb23..6413a26eb3 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 3.0.0 + * 3.0.1 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package.json b/server/package.json index 60c3a06710..888cd4ffa5 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "3.0.0", + "version": "3.0.1", "description": "", "author": "", "private": true, diff --git a/web/package.json b/web/package.json index cd52422e99..1acc0e2652 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "3.0.0", + "version": "3.0.1", "license": "GNU Affero General Public License version 3", "type": "module", "scripts": { From 13d17b3a11e052c79cd0062f0141bd8f22b58b21 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Fri, 3 Jul 2026 11:05:22 +0200 Subject: [PATCH 04/54] fix: re-add gCastEnabled negation (#29499) --- web/src/lib/utils/cast/gcast-destination.svelte.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/utils/cast/gcast-destination.svelte.ts b/web/src/lib/utils/cast/gcast-destination.svelte.ts index c43e029682..f2a51c5c6a 100644 --- a/web/src/lib/utils/cast/gcast-destination.svelte.ts +++ b/web/src/lib/utils/cast/gcast-destination.svelte.ts @@ -25,7 +25,7 @@ export class GCastDestination implements ICastDestination { private currentUrl: string | null = null; async initialize(): Promise { - if (!authManager.authenticated || authManager.preferences.cast.gCastEnabled) { + if (!authManager.authenticated || !authManager.preferences.cast.gCastEnabled) { this.isAvailable = false; return false; } From 5a104f9dbe217854bea800985096aad6002eccc3 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 3 Jul 2026 05:27:18 -0500 Subject: [PATCH 05/54] chore: update docs with v3 tags (#29312) From 5645149e094d1945477d661b359b9cd7b8abac19 Mon Sep 17 00:00:00 2001 From: Lukas Schaefer Date: Fri, 3 Jul 2026 10:55:12 -0400 Subject: [PATCH 06/54] fix: downmix audio to stereo to fix broken channel layout (#29485) * fix: downmix audio to stereo to fix broken channel layout Signed-off-by: Lukas Schaefer * add todo Signed-off-by: Lukas Schaefer --------- Signed-off-by: Lukas Schaefer --- server/src/services/transcoding.service.spec.ts | 2 ++ server/src/utils/media.ts | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/server/src/services/transcoding.service.spec.ts b/server/src/services/transcoding.service.spec.ts index 8909a43342..68e1700743 100644 --- a/server/src/services/transcoding.service.spec.ts +++ b/server/src/services/transcoding.service.spec.ts @@ -381,6 +381,8 @@ describe(TranscodingService.name, () => { '50', '-keyint_min', '50', + '-ac', + '2', '-copyts', '-r', '50130000/2012441', diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index eb775e20d3..de925cf571 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -200,6 +200,11 @@ export class BaseConfig implements VideoCodecSWConfig { const options = ['-c:v', videoCodec, '-c:a', audioCodec, '-map', `0:${videoStream.index}`, '-map_metadata', '-1']; if (audioStream) { options.push('-map', `0:${audioStream.index}`); + // If there are more than 2 channels sometimes the channel config is broken when re-encoded + // TODO: Store the number of channels in the db and then set it during the transcoding: -channel_layout 5.1 + if ([TranscodeTarget.All, TranscodeTarget.Audio].includes(target)) { + options.push('-ac', '2'); + } } if (this.getBFrames() > -1) { options.push('-bf', `${this.getBFrames()}`); From e7ba8d36322c0ae25b724a09c2560de53837e6b5 Mon Sep 17 00:00:00 2001 From: Lukas Schaefer Date: Fri, 3 Jul 2026 11:34:37 -0400 Subject: [PATCH 07/54] fix: live transcoding when viewing from a link share (#29471) --- .../components/asset-viewer/VideoNativeViewer.svelte | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/web/src/lib/components/asset-viewer/VideoNativeViewer.svelte b/web/src/lib/components/asset-viewer/VideoNativeViewer.svelte index ca0b8d3ef7..d71e1682f0 100644 --- a/web/src/lib/components/asset-viewer/VideoNativeViewer.svelte +++ b/web/src/lib/components/asset-viewer/VideoNativeViewer.svelte @@ -5,6 +5,7 @@ import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { castManager } from '$lib/managers/cast-manager.svelte'; import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; + import { authManager } from '$lib/managers/auth-manager.svelte'; import { mediaCapabilitiesManager } from '$lib/managers/media-capabilities-manager.svelte'; import { autoPlayVideo, lang, loopVideo as loopVideoPreference } from '$lib/stores/preferences.store'; import { getAssetHlsSessionUrl, getAssetHlsUrl, getAssetMediaUrl, getAssetPlaybackUrl } from '$lib/utils'; @@ -150,6 +151,15 @@ }, }, useMediaCapabilities: false, + xhrSetup: (xhr: XMLHttpRequest, url: string) => { + const authenticatedUrl = new URL(url, globalThis.location.origin); + for (const [key, value] of Object.entries(authManager.params)) { + if (value) { + authenticatedUrl.searchParams.set(key, value as string); + } + } + xhr.open('GET', authenticatedUrl.toString()); + }, }; const releaseSession = () => { From 6da890ee37d2d4005d0219242c2f6eead137f85a Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:41:25 +0200 Subject: [PATCH 08/54] fix: web search timeline visibility (#29518) --- .../search/[[photos=photos]]/[[assetId=id]]/+page.svelte | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte index 40bdc4b01f..a174ea2a04 100644 --- a/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/search/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -34,6 +34,7 @@ import { type AlbumResponseDto, type AssetResponseDto, + AssetVisibility, getPerson, getTagById, type MetadataSearchDto, @@ -141,8 +142,10 @@ try { const { albums, assets } = ('query' in searchDto || 'queryAssetId' in searchDto) && smartSearchEnabled - ? await searchSmart({ smartSearchDto: { ...searchDto, language: $lang } }) - : await searchAssets({ metadataSearchDto: searchDto }); + ? await searchSmart({ + smartSearchDto: { visibility: AssetVisibility.Timeline, ...searchDto, language: $lang }, + }) + : await searchAssets({ metadataSearchDto: { visibility: AssetVisibility.Timeline, ...searchDto } }); searchResultAlbums.push(...albums.items); searchResultAssets.push(...assets.items); From 103585ceb972abf34d64918f43687df5daadc17a Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Mon, 6 Jul 2026 01:35:47 -0700 Subject: [PATCH 09/54] fix(web): add ARIA label to ServerStatisticsCard (#29497) Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> --- .../server-statistics/ServerStatisticsCard.svelte | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/lib/components/server-statistics/ServerStatisticsCard.svelte b/web/src/lib/components/server-statistics/ServerStatisticsCard.svelte index 73b4f62dc0..ab2e6ce4f4 100644 --- a/web/src/lib/components/server-statistics/ServerStatisticsCard.svelte +++ b/web/src/lib/components/server-statistics/ServerStatisticsCard.svelte @@ -37,18 +37,18 @@ {#await valuePromise} -
+
{zeros()}
{:then data} -
+
{zeros(data)}{data.value} {#if data.unit} {data.unit} {/if}
{:catch _} -
+
{zeros()}
{/await} From 43abb9a92fe466576d86b082fe12c16bdd09c5cf Mon Sep 17 00:00:00 2001 From: Wonsuk Choi Date: Mon, 6 Jul 2026 16:57:04 +0700 Subject: [PATCH 10/54] fix(web): wrap hardcoded strings with i18n translation function (#29612) Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> --- i18n/en.json | 1 + web/src/lib/components/SchemaConfiguration.svelte | 2 +- .../[[photos=photos]]/[[assetId=id]]/LargeAssetData.svelte | 5 ++++- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 45951c6ffe..743b269b28 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -2150,6 +2150,7 @@ "unknown": "Unknown", "unknown_country": "Unknown Country", "unknown_date": "Unknown date", + "unknown_schema": "Unknown schema", "unknown_year": "Unknown Year", "unlimited": "Unlimited", "unlink_motion_video": "Unlink motion video", diff --git a/web/src/lib/components/SchemaConfiguration.svelte b/web/src/lib/components/SchemaConfiguration.svelte index 1e85e6d945..77727b4163 100644 --- a/web/src/lib/components/SchemaConfiguration.svelte +++ b/web/src/lib/components/SchemaConfiguration.svelte @@ -107,6 +107,6 @@ getValue(), setValue} /> {:else} - Unknown schema + {$t('unknown_schema')} {/if} diff --git a/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/LargeAssetData.svelte b/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/LargeAssetData.svelte index 74c36c1850..1dcd0f7fcf 100644 --- a/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/LargeAssetData.svelte +++ b/web/src/routes/(user)/utilities/large-files/[[photos=photos]]/[[assetId=id]]/LargeAssetData.svelte @@ -3,6 +3,7 @@ import { getFileSize } from '$lib/utils/asset-utils'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import { type AssetResponseDto } from '@immich/sdk'; + import { t } from 'svelte-i18n'; interface Props { asset: AssetResponseDto; @@ -25,7 +26,9 @@ onViewAsset(asset)} thumbnailSize={boxWidth} /> {#if !!asset.libraryId} -
External
+
+ {$t('external')} +
{/if}
From be02c00afd3821da6b16dacc2e38397cbca620cd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:52:42 +0200 Subject: [PATCH 11/54] chore(deps): lock file maintenance (mise) (#29636) --- deployment/mise.lock | 14 ++++++++++++++ mise.lock | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/deployment/mise.lock b/deployment/mise.lock index fb7be27b99..663ae4985d 100644 --- a/deployment/mise.lock +++ b/deployment/mise.lock @@ -7,30 +7,37 @@ backend = "aqua:opentofu/opentofu" [tools.opentofu."platforms.linux-arm64"] checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850" url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536382" [tools.opentofu."platforms.linux-arm64-musl"] checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850" url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536382" [tools.opentofu."platforms.linux-x64"] checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e" url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536401" [tools.opentofu."platforms.linux-x64-musl"] checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e" url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536401" [tools.opentofu."platforms.macos-arm64"] checksum = "sha256:62d7fa8539e13b444827aa0a3b90c5972da5c47e8f8882d9dcf2e430e78840c1" url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_arm64.tar.gz" +url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536399" [tools.opentofu."platforms.macos-x64"] checksum = "sha256:1408cdef1c380f914565e6b4bb70794c6b163f195fcb233357f3d6c5745906b6" url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_amd64.tar.gz" +url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536384" [tools.opentofu."platforms.windows-x64"] checksum = "sha256:27323f70c875b8251bfd7e61a4cffc3ebff4e56ed1e611b955016f0c7077367e" url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_windows_amd64.tar.gz" +url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536406" [[tools.terragrunt]] version = "1.0.3" @@ -39,27 +46,34 @@ backend = "aqua:gruntwork-io/terragrunt" [tools.terragrunt."platforms.linux-arm64"] checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654253" [tools.terragrunt."platforms.linux-arm64-musl"] checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654253" [tools.terragrunt."platforms.linux-x64"] checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654196" [tools.terragrunt."platforms.linux-x64-musl"] checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654196" [tools.terragrunt."platforms.macos-arm64"] checksum = "sha256:aacb5be2ca5475300cbce246dfbd8a45eb47510fbaa70fab8561c49ef5db03aa" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_arm64.tar.gz" +url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654084" [tools.terragrunt."platforms.macos-x64"] checksum = "sha256:3133c2251e191aede8e3dd2a5b3aee2e91c5f08f88f117aee40eed9a24c8ef6b" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_amd64.tar.gz" +url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406653970" [tools.terragrunt."platforms.windows-x64"] checksum = "sha256:183b2745b4e04980a6bfa4450ff81956a12596ca22d70f7aaa793980f5b036db" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_windows_amd64.exe.tar.gz" +url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654412" diff --git a/mise.lock b/mise.lock index 44ecd6fdbe..f12064a23c 100644 --- a/mise.lock +++ b/mise.lock @@ -254,30 +254,37 @@ backend = "aqua:opentofu/opentofu" [tools.opentofu."platforms.linux-arm64"] checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850" url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536382" [tools.opentofu."platforms.linux-arm64-musl"] checksum = "sha256:d4f2ab15776925864b049bb329d69682851de6f5204f256e9fa86d07a0308850" url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536382" [tools.opentofu."platforms.linux-x64"] checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e" url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536401" [tools.opentofu."platforms.linux-x64-musl"] checksum = "sha256:02800fafa2753a9f50c38483e2fdf5bc353fd62895eb9e25eec9a5145df3a69e" url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536401" [tools.opentofu."platforms.macos-arm64"] checksum = "sha256:62d7fa8539e13b444827aa0a3b90c5972da5c47e8f8882d9dcf2e430e78840c1" url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_arm64.tar.gz" +url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536399" [tools.opentofu."platforms.macos-x64"] checksum = "sha256:1408cdef1c380f914565e6b4bb70794c6b163f195fcb233357f3d6c5745906b6" url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_darwin_amd64.tar.gz" +url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536384" [tools.opentofu."platforms.windows-x64"] checksum = "sha256:27323f70c875b8251bfd7e61a4cffc3ebff4e56ed1e611b955016f0c7077367e" url = "https://github.com/opentofu/opentofu/releases/download/v1.11.6/tofu_1.11.6_windows_amd64.tar.gz" +url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/391536406" [[tools.pnpm]] version = "11.6.0" @@ -286,31 +293,37 @@ backend = "aqua:pnpm/pnpm" [tools.pnpm."platforms.linux-arm64"] checksum = "sha256:2fec653ff6dadab340d1c3d2214688a7451cc471f39710839440b293ca7c53b0" url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-arm64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120174" provenance = "github-attestations" [tools.pnpm."platforms.linux-arm64-musl"] checksum = "sha256:56a78c08cf22adf29e7dacb6f7f100139731693863d20fb94a7883463a62169e" url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-arm64-musl.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120177" provenance = "github-attestations" [tools.pnpm."platforms.linux-x64"] checksum = "sha256:74d64c1646385fb21691f32f0ab6aca1a9f5c829ba54d3cda3a24838a228e68c" url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-x64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120176" provenance = "github-attestations" [tools.pnpm."platforms.linux-x64-musl"] checksum = "sha256:7a0c463a09d912fba6b7d9eca0a946bc228ea50f3015a05c09a29e7e85a932d7" url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-linux-x64-musl.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120175" provenance = "github-attestations" [tools.pnpm."platforms.macos-arm64"] checksum = "sha256:87c901635a14481fb30566a3749041134ffd4317bc6fe866c345b69fdf9b6b85" url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-darwin-arm64.tar.gz" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120180" provenance = "github-attestations" [tools.pnpm."platforms.windows-x64"] checksum = "sha256:91c753435542b04859c689304fae0dd64eba6b40937cfa426a48485b712e4e9c" url = "https://github.com/pnpm/pnpm/releases/download/v11.6.0/pnpm-win32-x64.zip" +url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/445120173" provenance = "github-attestations" [[tools.terragrunt]] @@ -320,27 +333,34 @@ backend = "aqua:gruntwork-io/terragrunt" [tools.terragrunt."platforms.linux-arm64"] checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654253" [tools.terragrunt."platforms.linux-arm64-musl"] checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz" +url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654253" [tools.terragrunt."platforms.linux-x64"] checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654196" [tools.terragrunt."platforms.linux-x64-musl"] checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz" +url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654196" [tools.terragrunt."platforms.macos-arm64"] checksum = "sha256:aacb5be2ca5475300cbce246dfbd8a45eb47510fbaa70fab8561c49ef5db03aa" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_arm64.tar.gz" +url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654084" [tools.terragrunt."platforms.macos-x64"] checksum = "sha256:3133c2251e191aede8e3dd2a5b3aee2e91c5f08f88f117aee40eed9a24c8ef6b" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_amd64.tar.gz" +url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406653970" [tools.terragrunt."platforms.windows-x64"] checksum = "sha256:183b2745b4e04980a6bfa4450ff81956a12596ca22d70f7aaa793980f5b036db" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_windows_amd64.exe.tar.gz" +url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654412" From 628de417e9f889ba9d8a4a827ba3b0cd29bfc6e8 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:52:48 +0200 Subject: [PATCH 12/54] chore(deps): lock file maintenance (mise) (#29637) --- machine-learning/mise.lock | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/machine-learning/mise.lock b/machine-learning/mise.lock index e366eaa4be..560a5bcbd3 100644 --- a/machine-learning/mise.lock +++ b/machine-learning/mise.lock @@ -5,38 +5,38 @@ version = "3.11.15" backend = "core:python" [tools.python."platforms.linux-arm64"] -checksum = "sha256:cbce0660e88cd9c56be7aaf2a2df92bea51f359388a521838b6b01817d728df0" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz" +checksum = "sha256:9ac18c9a761e91e6c6452bc0ef0082922a00a3fdec734555635d57c3169309b7" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260623/cpython-3.11.15+20260623-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz" provenance = "github-attestations" [tools.python."platforms.linux-arm64-musl"] -checksum = "sha256:a55ea44225ee3741d4157c383f3d5c3e8eee5f9665e2ea069233486b4275d928" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-aarch64-unknown-linux-musl-install_only_stripped.tar.gz" +checksum = "sha256:bcd414f2e9d8cc5d4ac7fab2c8f912e30379c150fa6b94da19d64daf85cfc5d4" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260623/cpython-3.11.15+20260623-aarch64-unknown-linux-musl-install_only_stripped.tar.gz" provenance = "github-attestations" [tools.python."platforms.linux-x64"] -checksum = "sha256:67a5b22f796e96f4d7fa628f95866d5fd1d524d0588f74e4601facd82b66792b" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz" +checksum = "sha256:0604cd029b142dc223e131f17f5941c0c8d2d5074997c8178b515b19eea2a6c2" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260623/cpython-3.11.15+20260623-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz" provenance = "github-attestations" [tools.python."platforms.linux-x64-musl"] -checksum = "sha256:5a8544aa4303da3ca4b7505c98dd8453b671157039d25cd551e55abea0f83a60" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-x86_64-unknown-linux-musl-install_only_stripped.tar.gz" +checksum = "sha256:45bffdb320cf4a2c2dbf183b634aeb7e28f9cb0646f715b2f4117c11084d839a" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260623/cpython-3.11.15+20260623-x86_64-unknown-linux-musl-install_only_stripped.tar.gz" provenance = "github-attestations" [tools.python."platforms.macos-arm64"] -checksum = "sha256:8c56f1f59142e0f9f8861ad897bdfd97fd84403afa7b3d8b0f33b208ec471355" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-aarch64-apple-darwin-install_only_stripped.tar.gz" +checksum = "sha256:2318799eaf104f8a29bc09a93b0851b05dbbcb4ce9a5f045ddea169c0c7ff3a5" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260623/cpython-3.11.15+20260623-aarch64-apple-darwin-install_only_stripped.tar.gz" provenance = "github-attestations" [tools.python."platforms.macos-x64"] -checksum = "sha256:8cd3878c656ba1698314cbcb65f78df4c37b7c8eabff958558115c6db11adb3d" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-x86_64-apple-darwin-install_only_stripped.tar.gz" +checksum = "sha256:4925e5aaa9bc77c85302d350b36c1d9def2002996a6bcfa55c88ba6eb318de29" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260623/cpython-3.11.15+20260623-x86_64-apple-darwin-install_only_stripped.tar.gz" provenance = "github-attestations" [tools.python."platforms.windows-x64"] -checksum = "sha256:f081a733b4e7ba0e5e5e12d533b3c795dbef3ecbebf92f0b4202e5329bf7c8ab" -url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260610/cpython-3.11.15+20260610-x86_64-pc-windows-msvc-install_only_stripped.tar.gz" +checksum = "sha256:6589ca6d63f520bec4096d62b3ab91da3d0a80b16b594c99a6b677e335814683" +url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260623/cpython-3.11.15+20260623-x86_64-pc-windows-msvc-install_only_stripped.tar.gz" provenance = "github-attestations" [[tools.uv]] @@ -46,27 +46,34 @@ backend = "aqua:astral-sh/uv" [tools.uv."platforms.linux-arm64"] checksum = "sha256:23ea21a05c62c4c307ce691f29bff2f15c94c4f07f2b83d9b356f0664bc8b3a2" url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/289235419" [tools.uv."platforms.linux-arm64-musl"] checksum = "sha256:23ea21a05c62c4c307ce691f29bff2f15c94c4f07f2b83d9b356f0664bc8b3a2" url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-aarch64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/289235419" [tools.uv."platforms.linux-x64"] checksum = "sha256:d0fec58f3124e05e0a1af0f6541abfce4333253cdaf23c7b6bb2e6128bf138ea" url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/289235475" [tools.uv."platforms.linux-x64-musl"] checksum = "sha256:d0fec58f3124e05e0a1af0f6541abfce4333253cdaf23c7b6bb2e6128bf138ea" url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-x86_64-unknown-linux-musl.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/289235475" [tools.uv."platforms.macos-arm64"] checksum = "sha256:103367962c5cb00bf7370d84cbaa3fec5a9807be9cc833ea9d8eea400c119fa2" url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-aarch64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/289235412" [tools.uv."platforms.macos-x64"] checksum = "sha256:2bbef70982e97dfc36454de173f35ec1a5e83ae11e3885df6a50db3fd76171cb" url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-x86_64-apple-darwin.tar.gz" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/289235464" [tools.uv."platforms.windows-x64"] checksum = "sha256:459d95892a5cc5c21779532f4f41b9238594b79e312a5142da2148ecfa10e705" url = "https://github.com/astral-sh/uv/releases/download/0.8.15/uv-x86_64-pc-windows-msvc.zip" +url_api = "https://api.github.com/repos/astral-sh/uv/releases/assets/289235467" From ed83fb3f5aa5ca8ae44c11bb1769af3b57ce26a1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:54:02 +0200 Subject: [PATCH 13/54] chore(deps): update ghcr.io/jdx/mise docker tag to v2026.6.14 (#29632) --- server/Dockerfile | 2 +- server/Dockerfile.dev | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index 1df476dccd..7dee1f2b59 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -56,7 +56,7 @@ FROM builder AS plugins ARG TARGETPLATFORM -COPY --from=ghcr.io/jdx/mise:2026.6.10@sha256:f57ac375a262f52f8ac3f9101348dbff2187d5e4b59612154f2f2808dbe46ef6 /usr/local/bin/mise /usr/local/bin/mise +COPY --from=ghcr.io/jdx/mise:2026.6.14@sha256:b8f8c20fc3308f8b1d00ccca2bc968e4e208af1c5c1069e1ad9753baa099acff /usr/local/bin/mise /usr/local/bin/mise WORKDIR /app COPY ./mise.toml ./mise.toml diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev index b54e10831c..6c9e7d902f 100644 --- a/server/Dockerfile.dev +++ b/server/Dockerfile.dev @@ -2,7 +2,7 @@ FROM ghcr.io/immich-app/base-server-dev:202606161235@sha256:9f88b07acc8b7bf37a1dd3d5a19193f664443eaaab4e08e9f9341414c5e4b23f AS dev -COPY --from=ghcr.io/jdx/mise:2026.6.10@sha256:f57ac375a262f52f8ac3f9101348dbff2187d5e4b59612154f2f2808dbe46ef6 /usr/local/bin/mise /usr/local/bin/mise +COPY --from=ghcr.io/jdx/mise:2026.6.14@sha256:b8f8c20fc3308f8b1d00ccca2bc968e4e208af1c5c1069e1ad9753baa099acff /usr/local/bin/mise /usr/local/bin/mise RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \ echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \ From af32a8ca7cf3f8eb313631763087871ef290ba4f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 15:25:52 +0200 Subject: [PATCH 14/54] chore(deps): update github-actions (#29400) --- .github/workflows/build-mobile.yml | 8 ++++---- .github/workflows/check-openapi.yml | 2 +- .github/workflows/cli.yml | 2 +- .github/workflows/prepare-release.yml | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-mobile.yml b/.github/workflows/build-mobile.yml index 71b57ecb2d..e423c2260b 100644 --- a/.github/workflows/build-mobile.yml +++ b/.github/workflows/build-mobile.yml @@ -103,14 +103,14 @@ jobs: working-directory: ./mobile run: printf "%s" $KEY_JKS | base64 -d > android/key.jks - - uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0 + - uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 with: distribution: 'zulu' java-version: '17' - name: Restore Gradle Cache id: cache-gradle-restore - uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/restore@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 with: path: | ~/.gradle/caches @@ -181,7 +181,7 @@ jobs: - name: Save Gradle Cache id: cache-gradle-save - uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + uses: actions/cache/save@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 if: github.ref == 'refs/heads/main' with: path: | @@ -237,7 +237,7 @@ jobs: run: flutter build ios --config-only --no-codesign - name: Setup Ruby - uses: ruby/setup-ruby@89f90524b88a01fe6e0b732220432cc6142926af # v1.313.0 + uses: ruby/setup-ruby@0dafeac902942906541bc140009cdbf32665b601 # v1.315.0 with: ruby-version: '3.3' bundler-cache: true diff --git a/.github/workflows/check-openapi.yml b/.github/workflows/check-openapi.yml index 9a93e931d3..51d56aa0a7 100644 --- a/.github/workflows/check-openapi.yml +++ b/.github/workflows/check-openapi.yml @@ -25,7 +25,7 @@ jobs: persist-credentials: false - name: Check for breaking API changes - uses: oasdiff/oasdiff-action/breaking@e24529087d93f837b28b50bb66ba9016380a7fcc # v0.1.2 + uses: oasdiff/oasdiff-action/breaking@ae5ef6402ebe218ac6e872c59d0fa4557694e75c # v0.1.4 with: base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json revision: open-api/immich-openapi-specs.json diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index 821427aef5..68c3684f4f 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -75,7 +75,7 @@ jobs: token: ${{ steps.token.outputs.token }} - name: Set up QEMU - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 + uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 35b3249855..016032758b 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -149,7 +149,7 @@ jobs: github-token: ${{ steps.generate-token.outputs.token }} - name: Create draft release - uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0 + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 with: draft: true prerelease: ${{ needs.bump_version.outputs.rc }} From c04acc89518d9f86ae961802aadf50368339747e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:01:22 +0200 Subject: [PATCH 15/54] chore(deps): update base-image to v202607061334 (#29634) --- server/Dockerfile | 4 ++-- server/Dockerfile.dev | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/Dockerfile b/server/Dockerfile index 7dee1f2b59..c052b08e43 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -1,4 +1,4 @@ -FROM ghcr.io/immich-app/base-server-dev:202606161235@sha256:9f88b07acc8b7bf37a1dd3d5a19193f664443eaaab4e08e9f9341414c5e4b23f AS builder +FROM ghcr.io/immich-app/base-server-dev:202607061334@sha256:c83ef9c6d7f5e7f6a276fe96647484d46b7bf6cacc1e35b823dd1d7a1aeda3b8 AS builder ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \ CI=1 \ COREPACK_HOME=/tmp \ @@ -80,7 +80,7 @@ RUN --mount=type=cache,id=pnpm-packages,target=/buildcache/pnpm-store \ --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \ mise //:plugins -FROM ghcr.io/immich-app/base-server-prod:202606161235@sha256:c6d59e3923f548d29a212b4dc51b6281a722cfa1da7972a009c0f3830f5762d6 +FROM ghcr.io/immich-app/base-server-prod:202607061334@sha256:c07ef6510ff2ccb6ead28434ade7c7a0c08aef33d7d3466b14beccdd431e86f7 WORKDIR /usr/src/app ENV NODE_ENV=production \ diff --git a/server/Dockerfile.dev b/server/Dockerfile.dev index 6c9e7d902f..b2ea99bb40 100644 --- a/server/Dockerfile.dev +++ b/server/Dockerfile.dev @@ -1,5 +1,5 @@ # dev build -FROM ghcr.io/immich-app/base-server-dev:202606161235@sha256:9f88b07acc8b7bf37a1dd3d5a19193f664443eaaab4e08e9f9341414c5e4b23f AS dev +FROM ghcr.io/immich-app/base-server-dev:202607061334@sha256:c83ef9c6d7f5e7f6a276fe96647484d46b7bf6cacc1e35b823dd1d7a1aeda3b8 AS dev COPY --from=ghcr.io/jdx/mise:2026.6.14@sha256:b8f8c20fc3308f8b1d00ccca2bc968e4e208af1c5c1069e1ad9753baa099acff /usr/local/bin/mise /usr/local/bin/mise From b3abe0bb5db030232559d828c2725a8f418da915 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 6 Jul 2026 09:40:18 -0500 Subject: [PATCH 16/54] chore: keep selection state after sharing action (#29640) --- .../widgets/action_buttons/share_action_button.widget.dart | 3 --- 1 file changed, 3 deletions(-) diff --git a/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart index 53bd15b457..6109b137a1 100644 --- a/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/share_action_button.widget.dart @@ -159,8 +159,6 @@ class ShareActionButton extends ConsumerWidget { return; } - ref.read(multiSelectProvider.notifier).reset(); - if (!result.success) { ImmichToast.show( context: context, @@ -173,7 +171,6 @@ class ShareActionButton extends ConsumerWidget { buildContext.pop(); }); - // Show download progress with a "Preparing" message return preparingDialog; }, barrierDismissible: false, From 4f5e0d35051e75bdcc1a635f9989265bea20efba Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:40:26 +0200 Subject: [PATCH 17/54] chore: clearer album users dropdown (#29629) --- web/src/lib/modals/AlbumOptionsModal.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/lib/modals/AlbumOptionsModal.svelte b/web/src/lib/modals/AlbumOptionsModal.svelte index 93c95bbeaf..40d605daa2 100644 --- a/web/src/lib/modals/AlbumOptionsModal.svelte +++ b/web/src/lib/modals/AlbumOptionsModal.svelte @@ -122,7 +122,7 @@ options={[ { label: $t('role_editor'), value: AlbumUserRole.Editor }, { label: $t('role_viewer'), value: AlbumUserRole.Viewer }, - { label: $t('owner'), value: AlbumUserRole.Owner }, + { label: $t('owner'), value: AlbumUserRole.Owner, disabled: true }, { label: $t('remove_user'), value: 'none' }, ] as SelectOption[]} onChange={(value) => handleRoleSelect(user, value)} From 4e25db2989e40121a82b340d4dde3e826da3ad19 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:42:47 +0200 Subject: [PATCH 18/54] fix: chinese browser locales recognition (#29622) --- web/src/lib/i18n.spec.ts | 9 +++++++++ web/src/lib/utils/i18n.ts | 12 +++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/web/src/lib/i18n.spec.ts b/web/src/lib/i18n.spec.ts index ccc60a835b..ebf2fdfa3b 100644 --- a/web/src/lib/i18n.spec.ts +++ b/web/src/lib/i18n.spec.ts @@ -58,5 +58,14 @@ describe('i18n', () => { expect(getClosestAvailableLocale(['sr_Cyrl'], allLocales)).toBe('sr_Cyrl'); expect(getClosestAvailableLocale(['zh_Hant'], allLocales)).toBe('zh_Hant'); }); + + it('should handle language aliases', () => { + const allLocales = ['zh-Hans', 'zh-Hant']; + expect(getClosestAvailableLocale(['zh-CN'], allLocales)).toBe('zh-Hans'); + expect(getClosestAvailableLocale(['zh-HK'], allLocales)).toBe('zh-Hant'); + expect(getClosestAvailableLocale(['zh-MO'], allLocales)).toBe('zh-Hant'); + expect(getClosestAvailableLocale(['zh-SG'], allLocales)).toBe('zh-Hans'); + expect(getClosestAvailableLocale(['zh-TW'], allLocales)).toBe('zh-Hant'); + }); }); }); diff --git a/web/src/lib/utils/i18n.ts b/web/src/lib/utils/i18n.ts index 85331ddf7c..758721c384 100644 --- a/web/src/lib/utils/i18n.ts +++ b/web/src/lib/utils/i18n.ts @@ -13,6 +13,14 @@ export const getFormatter = async () => { return get(t); }; +const aliases: Record = { + 'zh-CN': 'zh-Hans', + 'zh-HK': 'zh-Hant', + 'zh-MO': 'zh-Hant', + 'zh-SG': 'zh-Hans', + 'zh-TW': 'zh-Hant', +}; + const modules = import.meta.glob('$i18n/*.json'); const fileCodes = Object.keys(modules) @@ -33,7 +41,9 @@ const getSubLocales = (locale: string) => { export const getClosestAvailableLocale = (locales: readonly string[], allLocales: readonly string[]) => { const allLocalesSet = new Set(allLocales.map((locale) => convertBCP47(locale))); - return locales.find((locale) => getSubLocales(locale).some((subLocale) => allLocalesSet.has(subLocale))); + return locales + .map((locale) => aliases[locale] ?? locale) + .find((locale) => getSubLocales(locale).some((subLocale) => allLocalesSet.has(subLocale))); }; export const getPreferredLocale = () => getClosestAvailableLocale(navigator.languages, langCodes); From 6aa565103288004c9768d70ed569b8c01a3cd850 Mon Sep 17 00:00:00 2001 From: Tom B <5631509+track0x1@users.noreply.github.com> Date: Mon, 6 Jul 2026 11:13:22 -0400 Subject: [PATCH 19/54] fix(docs): use correct heading name for DB dump (#29483) match docs to actual UI copy --- docs/docs/administration/backup-and-restore.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/administration/backup-and-restore.md b/docs/docs/administration/backup-and-restore.md index debbad7575..df34671a1b 100644 --- a/docs/docs/administration/backup-and-restore.md +++ b/docs/docs/administration/backup-and-restore.md @@ -31,7 +31,7 @@ You can trigger a database backup manually: 1. Go to **Administration > Job Queues** 2. Click **Create job** in the top right -3. Select **Create Database Backup** and click **Confirm** +3. Select **Create Database Dump** and click **Confirm** The backup will appear in `UPLOAD_LOCATION/backups` and counts toward your retention limit. From 32352fa3751dcc98f8164442e1508a0c9eaf4d0e Mon Sep 17 00:00:00 2001 From: Jins K Joy Date: Mon, 6 Jul 2026 17:15:00 +0200 Subject: [PATCH 20/54] feat(docs): add Malayalam language support in README files (#29593) --- README.md | 1 + readme_i18n/README_ar_JO.md | 1 + readme_i18n/README_ca_ES.md | 1 + readme_i18n/README_de_DE.md | 1 + readme_i18n/README_es_ES.md | 1 + readme_i18n/README_fr_FR.md | 1 + readme_i18n/README_it_IT.md | 1 + readme_i18n/README_ja_JP.md | 1 + readme_i18n/README_ko_KR.md | 1 + readme_i18n/README_ml_IN.md | 133 ++++++++++++++++++++++++++++++++++++ readme_i18n/README_nl_NL.md | 1 + readme_i18n/README_pt_BR.md | 1 + readme_i18n/README_ru_RU.md | 1 + readme_i18n/README_sv_SE.md | 1 + readme_i18n/README_th_TH.md | 1 + readme_i18n/README_tr_TR.md | 1 + readme_i18n/README_uk_UA.md | 1 + readme_i18n/README_vi_VN.md | 1 + readme_i18n/README_zh_CN.md | 1 + readme_i18n/README_zh_TW.md | 1 + 20 files changed, 152 insertions(+) create mode 100644 readme_i18n/README_ml_IN.md diff --git a/README.md b/README.md index 7e06d9de4b..13c4009b68 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ العربية Tiếng Việt ภาษาไทย + മലയാളം

diff --git a/readme_i18n/README_ar_JO.md b/readme_i18n/README_ar_JO.md index 860ba24eaf..10ec669edb 100644 --- a/readme_i18n/README_ar_JO.md +++ b/readme_i18n/README_ar_JO.md @@ -35,6 +35,7 @@ Português Brasileiro Svenska ภาษาไทย + മലയാളം

> [!WARNING] diff --git a/readme_i18n/README_ca_ES.md b/readme_i18n/README_ca_ES.md index 49aebd9c10..829cc52661 100644 --- a/readme_i18n/README_ca_ES.md +++ b/readme_i18n/README_ca_ES.md @@ -35,6 +35,7 @@ Svenska العربية ภาษาไทย + മലയാളം

> [!WARNING] diff --git a/readme_i18n/README_de_DE.md b/readme_i18n/README_de_DE.md index 11f9e24686..5deb21740b 100644 --- a/readme_i18n/README_de_DE.md +++ b/readme_i18n/README_de_DE.md @@ -36,6 +36,7 @@ العربية Tiếng Việt ภาษาไทย + മലയാളം

> [!WARNING] diff --git a/readme_i18n/README_es_ES.md b/readme_i18n/README_es_ES.md index 2aa4d16ecf..2d4077295f 100644 --- a/readme_i18n/README_es_ES.md +++ b/readme_i18n/README_es_ES.md @@ -35,6 +35,7 @@ Svenska العربية ภาษาไทย + മലയാളം

> [!WARNING] diff --git a/readme_i18n/README_fr_FR.md b/readme_i18n/README_fr_FR.md index e4fef66525..cd0394f0b9 100644 --- a/readme_i18n/README_fr_FR.md +++ b/readme_i18n/README_fr_FR.md @@ -35,6 +35,7 @@ Svenska العربية ภาษาไทย + മലയാളം

> [!WARNING] diff --git a/readme_i18n/README_it_IT.md b/readme_i18n/README_it_IT.md index 5d8d8b0880..fff7611ca3 100644 --- a/readme_i18n/README_it_IT.md +++ b/readme_i18n/README_it_IT.md @@ -36,6 +36,7 @@ Svenska العربية ภาษาไทย + മലയാളം

> [!WARNING] diff --git a/readme_i18n/README_ja_JP.md b/readme_i18n/README_ja_JP.md index 8c2088247a..460ddc6143 100644 --- a/readme_i18n/README_ja_JP.md +++ b/readme_i18n/README_ja_JP.md @@ -34,6 +34,7 @@ Svenska العربية ภาษาไทย + മലയാളം

> [!WARNING] diff --git a/readme_i18n/README_ko_KR.md b/readme_i18n/README_ko_KR.md index 66ff4c79c3..a5784aa863 100644 --- a/readme_i18n/README_ko_KR.md +++ b/readme_i18n/README_ko_KR.md @@ -36,6 +36,7 @@ Svenska العربية ภาษาไทย + മലയാളം

diff --git a/readme_i18n/README_ml_IN.md b/readme_i18n/README_ml_IN.md new file mode 100644 index 0000000000..238cfa9a6a --- /dev/null +++ b/readme_i18n/README_ml_IN.md @@ -0,0 +1,133 @@ +

+
+ License: AGPLv3 + + Discord + +
+
+

+ +

+ +

+

ഫോട്ടോകളും വീഡിയോകളും കൈകാര്യം ചെയ്യുന്നതിനുള്ള ഉയർന്ന കാര്യക്ഷമതയുള്ള സെൽഫ്-ഹോസ്റ്റഡ് ആപ്ലിക്കേഷൻ

+
+ + + +
+ +

+ English + Català + Español + Français + Italiano + 日本語 + 한국어 + Deutsch + Nederlands + Türkçe + 简体中文 + 正體中文 + Українська + Русский + Português Brasileiro + Svenska + العربية + Tiếng Việt + ภาษาไทย +

+ + +> [!WARNING] +> ⚠️ നിങ്ങളുടെ വിലയേറിയ ഫോട്ടോകൾക്കും വീഡിയോകൾക്കും എല്ലായ്പ്പോഴും [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) ബാക്കപ്പ് പ്ലാൻ പിന്തുടരുക! +> + +> [!NOTE] +> ഇൻസ്റ്റാളേഷൻ ഗൈഡുകൾ ഉൾപ്പെടെയുള്ള പ്രധാന ഡോക്യുമെന്റേഷൻ https://immich.app/ എന്ന വെബ്സൈറ്റിൽ നിങ്ങൾക്ക് കണ്ടെത്താം. + +## ലിങ്കുകൾ + +- [ഡോക്യുമെന്റേഷൻ](https://docs.immich.app/) +- [വിവരങ്ങൾ](https://docs.immich.app/overview/introduction) +- [ഇൻസ്റ്റാളേഷൻ](https://docs.immich.app/install/requirements) +- [റോഡ്മാപ്പ്](https://immich.app/roadmap) +- [ഡെമോ](#ഡെമോ) +- [സവിശേഷതകൾ](#സവിശേഷതകൾ) +- [വിവർത്തനങ്ങൾ](https://docs.immich.app/developer/translations) +- [സംഭാവന നൽകൽ](https://docs.immich.app/overview/support-the-project) + +## ഡെമോ + +ഡെമോ വെബ്സൈറ്റ് [ഇവിടെ](https://demo.immich.app) ആക്സസ് ചെയ്യാം. മൊബൈൽ ആപ്പിന്റെ ഡെമോ കാണാൻ ആപ്പ് ഇൻസ്റ്റാൾ ചെയ്ത ശേഷം, `Server Endpoint URL` എന്ന സെറ്റിങ്ങിൽ നിങ്ങൾക്ക് `https://demo.immich.app` ഉപയോഗിക്കാം. + +### ലോഗിൻ വിവരങ്ങൾ + +| ഇമെയിൽ | പാസ്‌വേഡ് | +| --------------- | -------- | +| demo@immich.app | demo | + +## സവിശേഷതകൾ + +| സവിശേഷതകൾ | മൊബൈൽ | വെബ് | +| :-------------------------------------------------- | ------ | --- | +| ഫോട്ടോകളും വീഡിയോകളും അപ്‌ലോഡ് ചെയ്യാനും കാണാനും സാധിക്കും | ഉണ്ട് | ഉണ്ട് | +| ആപ്പ് തുറക്കുമ്പോൾ ഓട്ടോമാറ്റിക് ബാക്കപ്പ് | ഉണ്ട് | N/A | +| ഫയലുകളുടെ ഇരട്ടിപ്പ് (duplication) തടയുന്നു | ഉണ്ട് | ഉണ്ട് | +| ബാക്കപ്പിനായി നിർദ്ദിഷ്ട ആൽബങ്ങൾ തിരഞ്ഞെടുക്കാം | ഉണ്ട് | N/A | +| ഫോട്ടോകളും വീഡിയോകളും ലോക്കൽ ഡിവൈസിലേക്ക് ഡൗൺലോഡ് ചെയ്യാം | ഉണ്ട് | ഉണ്ട് | +| ഒന്നിലധികം ഉപയോക്താക്കൾക്കുള്ള പിന്തുണ (Multi-user) | ഉണ്ട് | ഉണ്ട് | +| ആൽബങ്ങളും പങ്കിട്ട ആൽബങ്ങളും | ഉണ്ട് | ഉണ്ട് | +| സ്ക്രബ്ബ് ചെയ്യാവുന്ന/വലിച്ചിഴക്കാവുന്ന സ്ക്രോൾബാർ | ഉണ്ട് | ഉണ്ട് | +| റോ (RAW) ഫോർമാറ്റുകൾക്കുള്ള പിന്തുണ | ഉണ്ട് | ഉണ്ട് | +| മെറ്റാഡാറ്റ കാഴ്‌ച (EXIF, മാപ്പ്) | ഉണ്ട് | ഉണ്ട് | +| മെറ്റാഡാറ്റ, ഒബ്‌ജക്റ്റുകൾ, മുഖങ്ങൾ, CLIP എന്നിവ ഉപയോഗിച്ച് തിരയാം | ഉണ്ട് | ഉണ്ട് | +| അഡ്മിനിസ്ട്രേറ്റീവ് പ്രവർത്തനങ്ങൾ (യൂസർ മാനേജ്‌മെന്റ്) | ഇല്ല | ഉണ്ട് | +| ബാക്ക്ഗ്രൗണ്ട് ബാക്കപ്പ് | ഉണ്ട് | N/A | +| വിർച്വൽ സ്ക്രോൾ | ഉണ്ട് | ഉണ്ട് | +| OAuth സപ്പോർട്ട് | ഉണ്ട് | ഉണ്ട് | +| API കീകൾ | N/A | ഉണ്ട് | +| ലൈവ് ഫോട്ടോ/മോഷൻ ഫോട്ടോ ബാക്കപ്പും പ്ലേബാക്കും | ഉണ്ട് | ഉണ്ട് | +| 360 ഡിഗ്രി ഇമേജ് ഡിസ്‌പ്ലേ പിന്തുണ | ഇല്ല | ഉണ്ട് | +| ഉപയോക്താവ് നിർവചിക്കുന്ന സ്റ്റോറേജ് ഘടന | ഉണ്ട് | ഉണ്ട് | +| പബ്ലിക് ഷെയറിംഗ് | ഉണ്ട് | ഉണ്ട് | +| ആർക്കൈവും പ്രിയപ്പെട്ടവയും (Favorites) | ഉണ്ട് | ഉണ്ട് | +| ആഗോള മാപ്പ് (Global Map) | ഉണ്ട് | ഉണ്ട് | +| പങ്കാളി പങ്കിടൽ (Partner Sharing) | ഉണ്ട് | ഉണ്ട് | +| മുഖം തിരിച്ചറിയലും ക്ലസ്റ്ററിംഗും | ഉണ്ട് | ഉണ്ട് | +| ഓർമ്മകൾ (Memories - x വർഷങ്ങൾക്ക് മുമ്പ്) | ഉണ്ട് | ഉണ്ട് | +| ഓഫ്‌ലൈൻ പിന്തുണ | ഉണ്ട് | ഇല്ല | +| റീഡ്-ഒൺലി ഗാലറി | ഉണ്ട് | ഉണ്ട് | +| അടുക്കിവെച്ച ഫോട്ടോകൾ (Stacked Photos) | ഉണ്ട് | ഉണ്ട് | +| ടാഗുകൾ | ഇല്ല | ഉണ്ട് | +| ഫോൾഡർ കാഴ്‌ച | ഉണ്ട് | ഉണ്ട് | + +## വിവർത്തനങ്ങൾ + +വിവർത്തനങ്ങളെക്കുറിച്ച് കൂടുതൽ [ഇവിടെ](https://docs.immich.app/developer/translations) വായിക്കാം. + + +Translation status + + +## റെപ്പോസിറ്ററി പ്രവർത്തനം + +![Activities](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Repobeats analytics image") + +## സ്റ്റാർ ചരിത്രം + + + + + + Star History Chart + + + +## സംഭാവന ചെയ്തവർ + + + + diff --git a/readme_i18n/README_nl_NL.md b/readme_i18n/README_nl_NL.md index ef7591b618..c5a5b26e46 100644 --- a/readme_i18n/README_nl_NL.md +++ b/readme_i18n/README_nl_NL.md @@ -35,6 +35,7 @@ Svenska العربية ภาษาไทย + മലയാളം

> [!WARNING] diff --git a/readme_i18n/README_pt_BR.md b/readme_i18n/README_pt_BR.md index 1e1861a797..90f0a2d150 100644 --- a/readme_i18n/README_pt_BR.md +++ b/readme_i18n/README_pt_BR.md @@ -37,6 +37,7 @@ العربية Tiếng Việt ภาษาไทย + മലയാളം

diff --git a/readme_i18n/README_ru_RU.md b/readme_i18n/README_ru_RU.md index 765e876f37..cacca01bff 100644 --- a/readme_i18n/README_ru_RU.md +++ b/readme_i18n/README_ru_RU.md @@ -37,6 +37,7 @@ العربية Tiếng Việt ภาษาไทย + മലയാളം

> [!WARNING] diff --git a/readme_i18n/README_sv_SE.md b/readme_i18n/README_sv_SE.md index b3a3cc804f..20d109402e 100644 --- a/readme_i18n/README_sv_SE.md +++ b/readme_i18n/README_sv_SE.md @@ -36,6 +36,7 @@ Português Brasileiro العربية ภาษาไทย + മലയാളം

> [!WARNING] diff --git a/readme_i18n/README_th_TH.md b/readme_i18n/README_th_TH.md index 22a7f6a501..e25a0d7a79 100644 --- a/readme_i18n/README_th_TH.md +++ b/readme_i18n/README_th_TH.md @@ -39,6 +39,7 @@ Svenska العربية Tiếng Việt + മലയാളം

diff --git a/readme_i18n/README_tr_TR.md b/readme_i18n/README_tr_TR.md index 7cafb48025..1b3002fa5c 100644 --- a/readme_i18n/README_tr_TR.md +++ b/readme_i18n/README_tr_TR.md @@ -35,6 +35,7 @@ Svenska العربية ภาษาไทย + മലയാളം

> [!WARNING] diff --git a/readme_i18n/README_uk_UA.md b/readme_i18n/README_uk_UA.md index 6b5849b01c..b4eaf50f97 100644 --- a/readme_i18n/README_uk_UA.md +++ b/readme_i18n/README_uk_UA.md @@ -37,6 +37,7 @@ العربية Tiếng Việt ภาษาไทย + മലയാളം

> [!WARNING] diff --git a/readme_i18n/README_vi_VN.md b/readme_i18n/README_vi_VN.md index 54e8a9bb90..05fc1cfd8c 100644 --- a/readme_i18n/README_vi_VN.md +++ b/readme_i18n/README_vi_VN.md @@ -38,6 +38,7 @@ العربية Tiếng Việt ภาษาไทย + മലയാളം

diff --git a/readme_i18n/README_zh_CN.md b/readme_i18n/README_zh_CN.md index 6bd82b13e2..fb3fd1a27d 100644 --- a/readme_i18n/README_zh_CN.md +++ b/readme_i18n/README_zh_CN.md @@ -40,6 +40,7 @@ العربية Tiếng Việt ภาษาไทย + മലയാളം

diff --git a/readme_i18n/README_zh_TW.md b/readme_i18n/README_zh_TW.md index bf14ce2dc0..1615f6bc87 100644 --- a/readme_i18n/README_zh_TW.md +++ b/readme_i18n/README_zh_TW.md @@ -38,6 +38,7 @@ العربية Tiếng Việt ภาษาไทย + മലയാളം

> [!WARNING] From a6f02535448262959c9321e594840ae2e93e6722 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:21:49 +0200 Subject: [PATCH 21/54] chore: improve added to album messages (#29641) --- i18n/en.json | 1 + web/src/lib/services/album.service.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/i18n/en.json b/i18n/en.json index 743b269b28..934227f3b9 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -593,6 +593,7 @@ "asset_viewer_settings_title": "Asset Viewer", "assets": "Assets", "assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album", + "assets_added_to_album_partial_count": "Added {successCount} out of {totalCount} {totalCount, plural, one {asset} other {assets}} to the album", "assets_added_to_albums_count": "Added {assetTotal, plural, one {# asset} other {# assets}} to {albumTotal, plural, one {# album} other {# albums}}", "assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} cannot be added to the album", "assets_cannot_be_added_to_albums": "{count, plural, one {Asset} other {Assets}} cannot be added to any of the albums", diff --git a/web/src/lib/services/album.service.ts b/web/src/lib/services/album.service.ts index 09796d440a..fc09a11215 100644 --- a/web/src/lib/services/album.service.ts +++ b/web/src/lib/services/album.service.ts @@ -134,10 +134,13 @@ const notifyAddToAlbum = ($t: MessageFormatter, albumId: string, assetIds: strin const successCount = results.filter(({ success }) => success).length; const duplicateCount = results.filter(({ error }) => error === 'duplicate').length; let description = $t('assets_cannot_be_added_to_album_count', { values: { count: assetIds.length } }); - if (successCount > 0) { - description = $t('assets_added_to_album_count', { values: { count: successCount } }); - } else if (duplicateCount > 0) { + + if (duplicateCount === assetIds.length) { description = $t('assets_were_part_of_album_count', { values: { count: duplicateCount } }); + } else if (successCount === assetIds.length) { + description = $t('assets_added_to_album_count', { values: { count: successCount } }); + } else if (successCount > 0) { + description = $t('assets_added_to_album_partial_count', { values: { successCount, totalCount: assetIds.length } }); } toastManager.primary( From 69d568f289866a66af6a03d4a0c4dd37263cfcf0 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:57:48 -0400 Subject: [PATCH 22/54] feat(server): hls variant configuration (#29613) * configure variants * openapi * dynamic level * unnecessary width/height fields * add web config * allow 1440p and 2160p * more helpful descriptions * simplify level picker * formatting * integer instead of number --- i18n/en.json | 4 + mobile/openapi/README.md | 1 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api_client.dart | 2 + mobile/openapi/lib/api_helper.dart | 3 + .../lib/model/hls_video_resolution.dart | 94 ++++++++++++++++ .../system_config_f_fmpeg_realtime_dto.dart | 24 +++- open-api/immich-openapi-specs.json | 29 ++++- packages/sdk/src/fetch-client.ts | 11 ++ server/src/config.ts | 5 + server/src/constants.ts | 69 ++++++++++-- server/src/dtos/system-config.dto.ts | 3 + server/src/enum.ts | 13 +++ server/src/services/hls.service.spec.ts | 105 +++++++++++------- server/src/services/hls.service.ts | 21 ++-- .../services/system-config.service.spec.ts | 3 + server/src/utils/media.ts | 25 ++++- .../system-settings/FFmpegSettings.svelte | 39 ++++++- .../system-settings/SettingCheckboxes.svelte | 2 +- 19 files changed, 387 insertions(+), 67 deletions(-) create mode 100644 mobile/openapi/lib/model/hls_video_resolution.dart diff --git a/i18n/en.json b/i18n/en.json index 934227f3b9..df284906b1 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -431,6 +431,10 @@ "transcoding_realtime_description": "Allows transcoding to be performed in real-time as the video is being streamed. Enables quality switching, but may cause higher playback latency and stuttering depending on server capabilities.", "transcoding_realtime_enabled": "Enable real-time transcoding", "transcoding_realtime_enabled_description": "If disabled, the server will refuse to start new real-time transcoding sessions.", + "transcoding_realtime_resolutions": "Resolutions", + "transcoding_realtime_resolutions_description": "The resolutions offered for real-time transcoding. A variant is only offered when its resolution is no larger than the source. Higher resolutions may cause playback issues if the server cannot transcode them quickly enough.", + "transcoding_realtime_video_codecs": "Video codecs", + "transcoding_realtime_video_codecs_description": "The video codecs offered for real-time transcoding. Clients will choose the best option they support during playback. AV1 is more efficient than HEVC, which is more efficient than H.264. When using hardware acceleration, only select the codecs the accelerator can encode. When using software transcoding, note that H.264 is faster than AV1, which is faster than HEVC.", "transcoding_reference_frames": "Reference frames", "transcoding_reference_frames_description": "The number of frames to reference when compressing a given frame. Higher values improve compression efficiency, but slow down encoding. 0 sets this value automatically.", "transcoding_required_description": "Only videos not in an accepted format", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index ece44d310a..5a082cb1e0 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -452,6 +452,7 @@ Class | Method | HTTP request | Description - [FacialRecognitionConfig](doc//FacialRecognitionConfig.md) - [FoldersResponse](doc//FoldersResponse.md) - [FoldersUpdate](doc//FoldersUpdate.md) + - [HlsVideoResolution](doc//HlsVideoResolution.md) - [ImageFormat](doc//ImageFormat.md) - [IntegrityReport](doc//IntegrityReport.md) - [IntegrityReportResponseDto](doc//IntegrityReportResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 4a20fe79a6..47425b56fe 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -173,6 +173,7 @@ part 'model/face_dto.dart'; part 'model/facial_recognition_config.dart'; part 'model/folders_response.dart'; part 'model/folders_update.dart'; +part 'model/hls_video_resolution.dart'; part 'model/image_format.dart'; part 'model/integrity_report.dart'; part 'model/integrity_report_response_dto.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 137b0a47c5..873b05b8d2 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -391,6 +391,8 @@ class ApiClient { return FoldersResponse.fromJson(value); case 'FoldersUpdate': return FoldersUpdate.fromJson(value); + case 'HlsVideoResolution': + return HlsVideoResolutionTypeTransformer().decode(value); case 'ImageFormat': return ImageFormatTypeTransformer().decode(value); case 'IntegrityReport': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index a985dece82..c8efcb5466 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -106,6 +106,9 @@ String parameterToString(dynamic value) { if (value is Colorspace) { return ColorspaceTypeTransformer().encode(value).toString(); } + if (value is HlsVideoResolution) { + return HlsVideoResolutionTypeTransformer().encode(value).toString(); + } if (value is ImageFormat) { return ImageFormatTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/hls_video_resolution.dart b/mobile/openapi/lib/model/hls_video_resolution.dart new file mode 100644 index 0000000000..d28fb9055c --- /dev/null +++ b/mobile/openapi/lib/model/hls_video_resolution.dart @@ -0,0 +1,94 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +/// HLS video resolution +class HlsVideoResolution { + /// Instantiate a new enum with the provided [value]. + const HlsVideoResolution._(this.value); + + /// The underlying value of this enum member. + final int value; + + @override + String toString() => value.toString(); + + int toJson() => value; + + static const number480 = HlsVideoResolution._(480); + static const number720 = HlsVideoResolution._(720); + static const number1080 = HlsVideoResolution._(1080); + static const number1440 = HlsVideoResolution._(1440); + static const number2160 = HlsVideoResolution._(2160); + + /// List of all possible values in this [enum][HlsVideoResolution]. + static const values = [ + number480, + number720, + number1080, + number1440, + number2160, + ]; + + static HlsVideoResolution? fromJson(dynamic value) => HlsVideoResolutionTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = HlsVideoResolution.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [HlsVideoResolution] to int, +/// and [decode] dynamic data back to [HlsVideoResolution]. +class HlsVideoResolutionTypeTransformer { + factory HlsVideoResolutionTypeTransformer() => _instance ??= const HlsVideoResolutionTypeTransformer._(); + + const HlsVideoResolutionTypeTransformer._(); + + int encode(HlsVideoResolution data) => data.value; + + /// Decodes a [dynamic value][data] to a HlsVideoResolution. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + HlsVideoResolution? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case 480: return HlsVideoResolution.number480; + case 720: return HlsVideoResolution.number720; + case 1080: return HlsVideoResolution.number1080; + case 1440: return HlsVideoResolution.number1440; + case 2160: return HlsVideoResolution.number2160; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [HlsVideoResolutionTypeTransformer] instance. + static HlsVideoResolutionTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/system_config_f_fmpeg_realtime_dto.dart b/mobile/openapi/lib/model/system_config_f_fmpeg_realtime_dto.dart index 1a8669912e..e88cd0d5c1 100644 --- a/mobile/openapi/lib/model/system_config_f_fmpeg_realtime_dto.dart +++ b/mobile/openapi/lib/model/system_config_f_fmpeg_realtime_dto.dart @@ -14,26 +14,40 @@ class SystemConfigFFmpegRealtimeDto { /// Returns a new [SystemConfigFFmpegRealtimeDto] instance. SystemConfigFFmpegRealtimeDto({ required this.enabled, + this.resolutions = const [], + this.videoCodecs = const [], }); /// Enable real-time HLS transcoding (alpha) bool enabled; + /// Resolutions to use for real-time HLS transcoding + List resolutions; + + /// Video codecs to use for real-time HLS transcoding + List videoCodecs; + @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigFFmpegRealtimeDto && - other.enabled == enabled; + other.enabled == enabled && + _deepEquality.equals(other.resolutions, resolutions) && + _deepEquality.equals(other.videoCodecs, videoCodecs); @override int get hashCode => // ignore: unnecessary_parenthesis - (enabled.hashCode); + (enabled.hashCode) + + (resolutions.hashCode) + + (videoCodecs.hashCode); @override - String toString() => 'SystemConfigFFmpegRealtimeDto[enabled=$enabled]'; + String toString() => 'SystemConfigFFmpegRealtimeDto[enabled=$enabled, resolutions=$resolutions, videoCodecs=$videoCodecs]'; Map toJson() { final json = {}; json[r'enabled'] = this.enabled; + json[r'resolutions'] = this.resolutions; + json[r'videoCodecs'] = this.videoCodecs; return json; } @@ -47,6 +61,8 @@ class SystemConfigFFmpegRealtimeDto { return SystemConfigFFmpegRealtimeDto( enabled: mapValueOfType(json, r'enabled')!, + resolutions: HlsVideoResolution.listFromJson(json[r'resolutions']), + videoCodecs: VideoCodec.listFromJson(json[r'videoCodecs']), ); } return null; @@ -95,6 +111,8 @@ class SystemConfigFFmpegRealtimeDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'enabled', + 'resolutions', + 'videoCodecs', }; } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index e674f4712a..b1330fb55a 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -19120,6 +19120,17 @@ }, "type": "object" }, + "HlsVideoResolution": { + "description": "HLS video resolution", + "enum": [ + 480, + 720, + 1080, + 1440, + 2160 + ], + "type": "integer" + }, "ImageFormat": { "description": "Image format", "enum": [ @@ -25754,10 +25765,26 @@ "enabled": { "description": "Enable real-time HLS transcoding (alpha)", "type": "boolean" + }, + "resolutions": { + "description": "Resolutions to use for real-time HLS transcoding", + "items": { + "$ref": "#/components/schemas/HlsVideoResolution" + }, + "type": "array" + }, + "videoCodecs": { + "description": "Video codecs to use for real-time HLS transcoding", + "items": { + "$ref": "#/components/schemas/VideoCodec" + }, + "type": "array" } }, "required": [ - "enabled" + "enabled", + "resolutions", + "videoCodecs" ], "type": "object" }, diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 6413a26eb3..209a7b7f66 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -2302,6 +2302,10 @@ export type SystemConfigBackupsDto = { export type SystemConfigFFmpegRealtimeDto = { /** Enable real-time HLS transcoding (alpha) */ enabled: boolean; + /** Resolutions to use for real-time HLS transcoding */ + resolutions: HlsVideoResolution[]; + /** Video codecs to use for real-time HLS transcoding */ + videoCodecs: VideoCodec[]; }; export type SystemConfigFFmpegDto = { accel: TranscodeHWAccel; @@ -7629,6 +7633,13 @@ export enum CQMode { Cqp = "cqp", Icq = "icq" } +export enum HlsVideoResolution { + $480 = 480, + $720 = 720, + $1080 = 1080, + $1440 = 1440, + $2160 = 2160 +} export enum ToneMapping { Hable = "hable", Mobius = "mobius", diff --git a/server/src/config.ts b/server/src/config.ts index 723e7e564e..55304080a3 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -4,6 +4,7 @@ import { AudioCodec, Colorspace, CQMode, + HlsVideoResolution, ImageFormat, LogLevel, OAuthTokenEndpointAuthMethod, @@ -48,6 +49,8 @@ export type SystemConfig = { tonemap: ToneMapping; realtime: { enabled: boolean; + videoCodecs: VideoCodec[]; + resolutions: HlsVideoResolution[]; }; }; integrityChecks: { @@ -247,6 +250,8 @@ export const defaults = Object.freeze({ accelDecode: true, realtime: { enabled: false, + videoCodecs: [VideoCodec.H264, VideoCodec.Hevc], + resolutions: [HlsVideoResolution.p480, HlsVideoResolution.p720, HlsVideoResolution.p1080], }, }, integrityChecks: { diff --git a/server/src/constants.ts b/server/src/constants.ts index e0a6296e64..815a400ed8 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -235,14 +235,65 @@ export const HLS_PLAYLIST_CONTENT_TYPE = 'application/vnd.apple.mpegurl'; export const HLS_SEGMENT_DURATION = 2; export const HLS_SEGMENT_FILENAME_REGEX = /^seg_(\d+)\.m4s$/; export const HLS_VARIANTS = [ - { resolution: 480, codec: VideoCodec.Av1, bitrate: 1_000_000, codecString: 'av01.0.04M.08' }, - { resolution: 480, codec: VideoCodec.Hevc, bitrate: 1_200_000, codecString: 'hvc1.1.6.L90.B0' }, - { resolution: 480, codec: VideoCodec.H264, bitrate: 2_500_000, codecString: 'avc1.64001e' }, - { resolution: 720, codec: VideoCodec.Av1, bitrate: 2_000_000, codecString: 'av01.0.08M.08' }, - { resolution: 720, codec: VideoCodec.Hevc, bitrate: 2_500_000, codecString: 'hvc1.1.6.L93.B0' }, - { resolution: 720, codec: VideoCodec.H264, bitrate: 5_000_000, codecString: 'avc1.64001f' }, - { resolution: 1080, codec: VideoCodec.Av1, bitrate: 4_000_000, codecString: 'av01.0.09M.08' }, - { resolution: 1080, codec: VideoCodec.Hevc, bitrate: 4_500_000, codecString: 'hvc1.1.6.L120.B0' }, - { resolution: 1080, codec: VideoCodec.H264, bitrate: 8_000_000, codecString: 'avc1.640028' }, + { resolution: 480, codec: VideoCodec.Av1, bitrate: 1_000_000 }, + { resolution: 480, codec: VideoCodec.Hevc, bitrate: 1_200_000 }, + { resolution: 480, codec: VideoCodec.H264, bitrate: 2_500_000 }, + { resolution: 720, codec: VideoCodec.Av1, bitrate: 2_000_000 }, + { resolution: 720, codec: VideoCodec.Hevc, bitrate: 2_500_000 }, + { resolution: 720, codec: VideoCodec.H264, bitrate: 5_000_000 }, + { resolution: 1080, codec: VideoCodec.Av1, bitrate: 4_000_000 }, + { resolution: 1080, codec: VideoCodec.Hevc, bitrate: 4_500_000 }, + { resolution: 1080, codec: VideoCodec.H264, bitrate: 8_000_000 }, + { resolution: 1440, codec: VideoCodec.Av1, bitrate: 7_000_000 }, + { resolution: 1440, codec: VideoCodec.Hevc, bitrate: 8_000_000 }, + { resolution: 1440, codec: VideoCodec.H264, bitrate: 14_000_000 }, + { resolution: 2160, codec: VideoCodec.Av1, bitrate: 12_000_000 }, + { resolution: 2160, codec: VideoCodec.Hevc, bitrate: 14_000_000 }, + { resolution: 2160, codec: VideoCodec.H264, bitrate: 25_000_000 }, ]; export const HLS_VERSION = 7; + +export type CodecLevel = { maxFrame: number; maxRate: number; token: string }; + +// H.264 High profile: token is the hex level_idc. +export const H264_LEVELS: CodecLevel[] = [ + { maxFrame: 1620, maxRate: 40_500, token: '1e' }, // 3.0 + { maxFrame: 3600, maxRate: 108_000, token: '1f' }, // 3.1 + { maxFrame: 5120, maxRate: 216_000, token: '20' }, // 3.2 + { maxFrame: 8192, maxRate: 245_760, token: '28' }, // 4.0 + { maxFrame: 8704, maxRate: 522_240, token: '2a' }, // 4.2 + { maxFrame: 22_080, maxRate: 589_824, token: '32' }, // 5.0 + { maxFrame: 36_864, maxRate: 983_040, token: '33' }, // 5.1 + { maxFrame: 36_864, maxRate: 2_073_600, token: '34' }, // 5.2 + { maxFrame: 139_264, maxRate: 4_177_920, token: '3c' }, // 6.0 + { maxFrame: 139_264, maxRate: 8_355_840, token: '3d' }, // 6.1 + { maxFrame: 139_264, maxRate: 16_711_680, token: '3e' }, // 6.2 +]; + +// HEVC Main profile, Main tier: token is `L` + level_idc (level × 30). +export const HEVC_LEVELS: CodecLevel[] = [ + { maxFrame: 552_960, maxRate: 16_588_800, token: 'L90' }, // 3.0 + { maxFrame: 983_040, maxRate: 33_177_600, token: 'L93' }, // 3.1 + { maxFrame: 2_228_224, maxRate: 66_846_720, token: 'L120' }, // 4.0 + { maxFrame: 2_228_224, maxRate: 133_693_440, token: 'L123' }, // 4.1 + { maxFrame: 8_912_896, maxRate: 267_386_880, token: 'L150' }, // 5.0 + { maxFrame: 8_912_896, maxRate: 534_773_760, token: 'L153' }, // 5.1 + { maxFrame: 8_912_896, maxRate: 1_069_547_520, token: 'L156' }, // 5.2 + { maxFrame: 35_651_584, maxRate: 1_069_547_520, token: 'L180' }, // 6.0 + { maxFrame: 35_651_584, maxRate: 2_139_095_040, token: 'L183' }, // 6.1 + { maxFrame: 35_651_584, maxRate: 4_278_190_080, token: 'L186' }, // 6.2 +]; + +// AV1 Main profile (0), Main tier (M): token is the two-digit seq_level_idx + `M`. +export const AV1_LEVELS: CodecLevel[] = [ + { maxFrame: 665_856, maxRate: 19_975_168, token: '04M' }, // 3.0 + { maxFrame: 1_065_024, maxRate: 31_950_336, token: '05M' }, // 3.1 + { maxFrame: 2_359_296, maxRate: 70_778_880, token: '08M' }, // 4.0 + { maxFrame: 2_359_296, maxRate: 141_557_760, token: '09M' }, // 4.1 + { maxFrame: 8_912_896, maxRate: 267_386_880, token: '12M' }, // 5.0 + { maxFrame: 8_912_896, maxRate: 534_773_760, token: '13M' }, // 5.1 + { maxFrame: 8_912_896, maxRate: 1_069_547_520, token: '14M' }, // 5.2 + { maxFrame: 35_651_584, maxRate: 1_069_547_520, token: '16M' }, // 6.0 + { maxFrame: 35_651_584, maxRate: 2_139_095_040, token: '17M' }, // 6.1 + { maxFrame: 35_651_584, maxRate: 4_278_190_080, token: '18M' }, // 6.2 +]; diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 85618723b2..2569e1a52e 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -11,6 +11,7 @@ import { AudioCodecSchema, ColorspaceSchema, CQModeSchema, + HlsVideoResolutionSchema, ImageFormatSchema, LogLevelSchema, OAuthTokenEndpointAuthMethodSchema, @@ -115,6 +116,8 @@ const SystemConfigFFmpegSchema = z realtime: z .object({ enabled: configBool.describe('Enable real-time HLS transcoding (alpha)'), + videoCodecs: z.array(VideoCodecSchema).describe('Video codecs to use for real-time HLS transcoding'), + resolutions: z.array(HlsVideoResolutionSchema).describe('Resolutions to use for real-time HLS transcoding'), }) .meta({ id: 'SystemConfigFFmpegRealtimeDto' }), }) diff --git a/server/src/enum.ts b/server/src/enum.ts index 491beca10b..0996abe6fc 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -529,6 +529,19 @@ export enum CQMode { export const CQModeSchema = z.enum(CQMode).describe('CQ mode').meta({ id: 'CQMode' }); +export enum HlsVideoResolution { + p480 = 480, + p720 = 720, + p1080 = 1080, + p1440 = 1440, + p2160 = 2160, +} + +export const HlsVideoResolutionSchema = z + .enum(HlsVideoResolution) + .describe('HLS video resolution') + .meta({ id: 'HlsVideoResolution', type: 'integer' }); + export enum Colorspace { Srgb = 'srgb', P3 = 'p3', diff --git a/server/src/services/hls.service.spec.ts b/server/src/services/hls.service.spec.ts index a18f7c6d22..adfc3d5bcc 100644 --- a/server/src/services/hls.service.spec.ts +++ b/server/src/services/hls.service.spec.ts @@ -1,5 +1,5 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; -import { TranscodeHardwareAcceleration } from 'src/enum'; +import { HlsVideoResolution, VideoCodec } from 'src/enum'; import { HlsService } from 'src/services/hls.service'; import { eiffelTower, train, waterfall } from 'test/fixtures/media.stub'; import { factory } from 'test/small.factory'; @@ -96,67 +96,79 @@ seg_10.m4s const sessionId = '00000000-0000-0000-0000-000000000000'; -const eiffelExpectedMasterDisabled = `#EXTM3U +const eiffelExpectedMasterAv1 = `#EXTM3U #EXT-X-VERSION:7 #EXT-X-INDEPENDENT-SEGMENTS -#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 +#EXT-X-STREAM-INF:BANDWIDTH=1350000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 ${sessionId}/0/playlist.m3u8 -#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 +#EXT-X-STREAM-INF:BANDWIDTH=1620000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 ${sessionId}/1/playlist.m3u8 -#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 +#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 ${sessionId}/2/playlist.m3u8 -#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=720x1280,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 +#EXT-X-STREAM-INF:BANDWIDTH=2700000,RESOLUTION=720x1280,CODECS="av01.0.05M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 ${sessionId}/3/playlist.m3u8 -#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 +#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 ${sessionId}/4/playlist.m3u8 -#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 +#EXT-X-STREAM-INF:BANDWIDTH=6750000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 ${sessionId}/5/playlist.m3u8 -#EXT-X-STREAM-INF:BANDWIDTH=4000000,RESOLUTION=1080x1920,CODECS="av01.0.09M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 +#EXT-X-STREAM-INF:BANDWIDTH=5400000,RESOLUTION=1080x1920,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 ${sessionId}/6/playlist.m3u8 -#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 +#EXT-X-STREAM-INF:BANDWIDTH=6075000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 ${sessionId}/7/playlist.m3u8 -#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 +#EXT-X-STREAM-INF:BANDWIDTH=10800000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 ${sessionId}/8/playlist.m3u8 `; -const eiffelExpectedMasterRkmpp = `#EXTM3U +const eiffelExpectedMasterNoAv1 = `#EXTM3U #EXT-X-VERSION:7 #EXT-X-INDEPENDENT-SEGMENTS -#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 +#EXT-X-STREAM-INF:BANDWIDTH=1620000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 ${sessionId}/1/playlist.m3u8 -#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 +#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 ${sessionId}/2/playlist.m3u8 -#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 +#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 ${sessionId}/4/playlist.m3u8 -#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 +#EXT-X-STREAM-INF:BANDWIDTH=6750000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 ${sessionId}/5/playlist.m3u8 -#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 +#EXT-X-STREAM-INF:BANDWIDTH=6075000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 ${sessionId}/7/playlist.m3u8 -#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 +#EXT-X-STREAM-INF:BANDWIDTH=10800000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=24.910 ${sessionId}/8/playlist.m3u8 `; -const waterfallExpectedMasterDisabled = `#EXTM3U +const waterfallExpectedMasterAv1 = `#EXTM3U #EXT-X-VERSION:7 #EXT-X-INDEPENDENT-SEGMENTS -#EXT-X-STREAM-INF:BANDWIDTH=1000000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830 +#EXT-X-STREAM-INF:BANDWIDTH=1350000,RESOLUTION=480x852,CODECS="av01.0.04M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830 ${sessionId}/0/playlist.m3u8 -#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830 +#EXT-X-STREAM-INF:BANDWIDTH=1620000,RESOLUTION=480x852,CODECS="hvc1.1.6.L90.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830 ${sessionId}/1/playlist.m3u8 -#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=480x852,CODECS="avc1.64001e,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830 +#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=480x852,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830 ${sessionId}/2/playlist.m3u8 -#EXT-X-STREAM-INF:BANDWIDTH=2000000,RESOLUTION=720x1280,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830 +#EXT-X-STREAM-INF:BANDWIDTH=2700000,RESOLUTION=720x1280,CODECS="av01.0.05M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830 ${sessionId}/3/playlist.m3u8 -#EXT-X-STREAM-INF:BANDWIDTH=2500000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830 +#EXT-X-STREAM-INF:BANDWIDTH=3375000,RESOLUTION=720x1280,CODECS="hvc1.1.6.L93.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830 ${sessionId}/4/playlist.m3u8 -#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830 +#EXT-X-STREAM-INF:BANDWIDTH=6750000,RESOLUTION=720x1280,CODECS="avc1.64001f,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830 ${sessionId}/5/playlist.m3u8 -#EXT-X-STREAM-INF:BANDWIDTH=4000000,RESOLUTION=1080x1920,CODECS="av01.0.09M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830 +#EXT-X-STREAM-INF:BANDWIDTH=5400000,RESOLUTION=1080x1920,CODECS="av01.0.08M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830 ${sessionId}/6/playlist.m3u8 -#EXT-X-STREAM-INF:BANDWIDTH=4500000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830 +#EXT-X-STREAM-INF:BANDWIDTH=6075000,RESOLUTION=1080x1920,CODECS="hvc1.1.6.L120.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830 ${sessionId}/7/playlist.m3u8 -#EXT-X-STREAM-INF:BANDWIDTH=8000000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830 +#EXT-X-STREAM-INF:BANDWIDTH=10800000,RESOLUTION=1080x1920,CODECS="avc1.640028,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830 ${sessionId}/8/playlist.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=9450000,RESOLUTION=1440x2560,CODECS="av01.0.12M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830 +${sessionId}/9/playlist.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=10800000,RESOLUTION=1440x2560,CODECS="hvc1.1.6.L150.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830 +${sessionId}/10/playlist.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=18900000,RESOLUTION=1440x2560,CODECS="avc1.640032,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830 +${sessionId}/11/playlist.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=16200000,RESOLUTION=2160x3840,CODECS="av01.0.12M.08,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830 +${sessionId}/12/playlist.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=18900000,RESOLUTION=2160x3840,CODECS="hvc1.1.6.L150.B0,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830 +${sessionId}/13/playlist.m3u8 +#EXT-X-STREAM-INF:BANDWIDTH=33750000,RESOLUTION=2160x3840,CODECS="avc1.640033,mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=29.830 +${sessionId}/14/playlist.m3u8 `; describe(HlsService.name, () => { @@ -171,9 +183,24 @@ describe(HlsService.name, () => { const auth = factory.auth(); const assetId = 'asset-1'; - const setup = (asset: typeof eiffelTower | typeof waterfall, accel: TranscodeHardwareAcceleration) => { + const allCodecs = [VideoCodec.Av1, VideoCodec.Hevc, VideoCodec.H264]; + const allResolutions = [ + HlsVideoResolution.p480, + HlsVideoResolution.p720, + HlsVideoResolution.p1080, + HlsVideoResolution.p1440, + HlsVideoResolution.p2160, + ]; + + const setup = ( + asset: typeof eiffelTower | typeof waterfall, + videoCodecs?: VideoCodec[], + resolutions?: HlsVideoResolution[], + ) => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId])); - mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { realtime: { enabled: true }, accel } }); + mocks.systemMetadata.get.mockResolvedValue({ + ffmpeg: { realtime: { enabled: true, videoCodecs, resolutions } }, + }); mocks.videoStream.getForMainPlaylist.mockResolvedValue(asset); mocks.crypto.randomUUID.mockReturnValue(sessionId); mocks.websocket.serverSend.mockImplementation((event, ...rest) => { @@ -184,19 +211,19 @@ describe(HlsService.name, () => { }); }; - it('returns main playlist for eiffel-tower (1080p portrait, no acceleration)', async () => { - setup(eiffelTower, TranscodeHardwareAcceleration.Disabled); - await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterDisabled); + it('offers AV1, HEVC, and H.264 when AV1 is configured and the accelerator supports it', async () => { + setup(eiffelTower, allCodecs); + await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterAv1); }); - it('returns main playlist for eiffel-tower with RKMPP (no AV1 variants)', async () => { - setup(eiffelTower, TranscodeHardwareAcceleration.Rkmpp); - await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterRkmpp); + it('omits AV1 when it is not in the configured codecs', async () => { + setup(eiffelTower); + await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(eiffelExpectedMasterNoAv1); }); - it('returns main playlist for waterfall (4K landscape) with no acceleration', async () => { - setup(waterfall, TranscodeHardwareAcceleration.Disabled); - await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(waterfallExpectedMasterDisabled); + it('offers every resolution up to the source and derives 4K codec levels (waterfall, 4K, 29.83fps)', async () => { + setup(waterfall, allCodecs, allResolutions); + await expect(sut.getMainPlaylist(auth, assetId)).resolves.toBe(waterfallExpectedMasterAv1); }); it('throws BadRequestException when realtime transcoding is disabled', async () => { diff --git a/server/src/services/hls.service.ts b/server/src/services/hls.service.ts index 0cbb53e1cd..94a8a46ace 100644 --- a/server/src/services/hls.service.ts +++ b/server/src/services/hls.service.ts @@ -1,13 +1,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { constants } from 'node:fs'; import { join } from 'node:path'; -import { - HLS_SEGMENT_DURATION, - HLS_SEGMENT_FILENAME_REGEX, - HLS_VARIANTS, - HLS_VERSION, - SUPPORTED_HWA_CODECS, -} from 'src/constants'; +import { HLS_SEGMENT_DURATION, HLS_SEGMENT_FILENAME_REGEX, HLS_VARIANTS, HLS_VERSION } from 'src/constants'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -18,7 +12,7 @@ import { BaseService } from 'src/services/base.service'; import { VideoPacketInfo, VideoStreamInfo } from 'src/types'; import { PendingEvents } from 'src/utils/event'; import { ImmichFileResponse } from 'src/utils/file'; -import { getOutputSize } from 'src/utils/media'; +import { getCodecString, getOutputSize } from 'src/utils/media'; type AssetWithStreamInfo = { videoStream: VideoStreamInfo & { timeBase: number }; packets: VideoPacketInfo }; type Segmentation = { fps: number; framesPerSegment: number; segmentCount: number; segmentDuration: number }; @@ -131,18 +125,21 @@ export class HlsService extends BaseService { } private generateMainPlaylist(sessionId: string, ffmpeg: SystemConfigFFmpegDto, asset: AssetWithStreamInfo) { - const fps = ((asset.packets.packetCount * asset.videoStream.timeBase) / asset.packets.totalDuration).toFixed(3); + const fps = (asset.packets.packetCount * asset.videoStream.timeBase) / asset.packets.totalDuration; + const roundedFps = fps.toFixed(3); const sourceResolution = Math.min(asset.videoStream.height, asset.videoStream.width); const targetResolution = Math.max(sourceResolution, HLS_VARIANTS[0].resolution); const lines = ['#EXTM3U', `#EXT-X-VERSION:${HLS_VERSION}`, '#EXT-X-INDEPENDENT-SEGMENTS']; + const { videoCodecs, resolutions } = ffmpeg.realtime; for (let i = 0; i < HLS_VARIANTS.length; i++) { - const { resolution, bitrate, codec, codecString } = HLS_VARIANTS[i]; - if (resolution > targetResolution || !SUPPORTED_HWA_CODECS[ffmpeg.accel].includes(codec)) { + const { resolution, bitrate, codec } = HLS_VARIANTS[i]; + if (resolution > targetResolution || !videoCodecs.includes(codec) || !resolutions.includes(resolution)) { continue; } const { width, height } = getOutputSize(asset.videoStream, resolution); + const codecString = getCodecString(codec, width, height, fps); lines.push( - `#EXT-X-STREAM-INF:BANDWIDTH=${bitrate},RESOLUTION=${width}x${height},CODECS="${codecString},mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=${fps}`, + `#EXT-X-STREAM-INF:BANDWIDTH=${Math.round(bitrate * 1.35)},RESOLUTION=${width}x${height},CODECS="${codecString},mp4a.40.2",VIDEO-RANGE=SDR,FRAME-RATE=${roundedFps}`, `${sessionId}/${i}/playlist.m3u8`, ); } diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index 920b9b9346..d5404048c0 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -5,6 +5,7 @@ import { AudioCodec, Colorspace, CQMode, + HlsVideoResolution, ImageFormat, LogLevel, OAuthTokenEndpointAuthMethod, @@ -76,6 +77,8 @@ const updatedConfig = Object.freeze({ tonemap: ToneMapping.Hable, realtime: { enabled: false, + videoCodecs: [VideoCodec.H264, VideoCodec.Hevc], + resolutions: [HlsVideoResolution.p480, HlsVideoResolution.p720, HlsVideoResolution.p1080], }, }, integrityChecks: { diff --git a/server/src/utils/media.ts b/server/src/utils/media.ts index de925cf571..5c81001eba 100644 --- a/server/src/utils/media.ts +++ b/server/src/utils/media.ts @@ -1,4 +1,4 @@ -import { AUDIO_ENCODER, SUPPORTED_HWA_CODECS } from 'src/constants'; +import { AUDIO_ENCODER, AV1_LEVELS, CodecLevel, H264_LEVELS, HEVC_LEVELS, SUPPORTED_HWA_CODECS } from 'src/constants'; import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto'; import { ColorMatrix, @@ -36,6 +36,29 @@ export const getOutputSize = (videoStream: VideoStreamInfo, targetRes: number) = return isVideoVertical(videoStream) ? { width: targetRes, height: larger } : { width: larger, height: targetRes }; }; +const pickLevel = (levels: CodecLevel[], frame: number, rate: number) => + levels.find((level) => frame <= level.maxFrame && rate <= level.maxRate) ?? levels.at(-1)!; + +export const getCodecString = (codec: VideoCodec, width: number, height: number, fps: number): string => { + switch (codec) { + case VideoCodec.H264: { + const macroblocks = Math.ceil(width / 16) * Math.ceil(height / 16); + return `avc1.6400${pickLevel(H264_LEVELS, macroblocks, macroblocks * fps).token}`; + } + case VideoCodec.Hevc: { + const samples = width * height; + return `hvc1.1.6.${pickLevel(HEVC_LEVELS, samples, samples * fps).token}.B0`; + } + case VideoCodec.Av1: { + const samples = width * height; + return `av01.0.${pickLevel(AV1_LEVELS, samples, samples * fps).token}.08`; + } + default: { + throw new Error(`Codec '${codec}' does not support HLS codec strings`); + } + } +}; + export class BaseConfig implements VideoCodecSWConfig { readonly presets = ['veryslow', 'slower', 'slow', 'medium', 'fast', 'faster', 'veryfast', 'superfast', 'ultrafast']; protected constructor( diff --git a/web/src/routes/admin/system-settings/FFmpegSettings.svelte b/web/src/routes/admin/system-settings/FFmpegSettings.svelte index 101de86008..2e173eaa7e 100644 --- a/web/src/routes/admin/system-settings/FFmpegSettings.svelte +++ b/web/src/routes/admin/system-settings/FFmpegSettings.svelte @@ -12,6 +12,7 @@ import { AudioCodec, CQMode, + HlsVideoResolution, ToneMapping, TranscodeHWAccel, TranscodePolicy, @@ -401,9 +402,45 @@ title={$t('admin.transcoding_realtime_enabled')} subtitle={$t('admin.transcoding_realtime_enabled_description')} bind:checked={configToEdit.ffmpeg.realtime.enabled} - isEdited={configToEdit.ffmpeg.realtime.enabled !== configToEdit.ffmpeg.realtime.enabled} + isEdited={configToEdit.ffmpeg.realtime.enabled !== config.ffmpeg.realtime.enabled} {disabled} /> + + + +
diff --git a/web/src/routes/admin/system-settings/SettingCheckboxes.svelte b/web/src/routes/admin/system-settings/SettingCheckboxes.svelte index d085482d27..5676ab6584 100644 --- a/web/src/routes/admin/system-settings/SettingCheckboxes.svelte +++ b/web/src/routes/admin/system-settings/SettingCheckboxes.svelte @@ -1,4 +1,4 @@ - From 2ad554fc6129d7e67698546594482d9c25dec93f Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Thu, 9 Jul 2026 01:13:32 +0600 Subject: [PATCH 47/54] fix(mobile): update timeline args on layout constraint changes (#29653) * fix(mobile): update timeline args on layout constraint changes * chore(mobile): temporary blank timeline diagnostics * chore(mobile): error logs when the timeline goes blank * chore(mobile): remove blank timeline debug logging --------- Co-authored-by: Alex --- .../widgets/timeline/timeline.state.dart | 11 +- .../widgets/timeline/timeline.widget.dart | 71 +++---- .../infrastructure/timeline.provider.dart | 8 +- .../widgets/timeline/timeline_args_test.dart | 190 ++++++++++++++++++ 4 files changed, 240 insertions(+), 40 deletions(-) create mode 100644 mobile/test/presentation/widgets/timeline/timeline_args_test.dart diff --git a/mobile/lib/presentation/widgets/timeline/timeline.state.dart b/mobile/lib/presentation/widgets/timeline/timeline.state.dart index 8dd87f9868..b39c431b43 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.state.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.state.dart @@ -86,13 +86,14 @@ class TimelineStateNotifier extends Notifier { // This provider watches the buckets from the timeline service & args and serves the segments. // It should be used only after the timeline service and timeline args provider is overridden final timelineSegmentProvider = StreamProvider.autoDispose>((ref) async* { - final args = ref.watch(timelineArgsProvider); - final columnCount = args.columnCount; - final spacing = args.spacing; - final availableTileWidth = args.maxWidth - (spacing * (columnCount - 1)); + // maxHeight is left out on purpose, a height-only change must not restart the bucket stream + final (maxWidth, columnCount, spacing, groupByArg) = ref.watch( + timelineArgsProvider.select((args) => (args.maxWidth, args.columnCount, args.spacing, args.groupBy)), + ); + final availableTileWidth = maxWidth - (spacing * (columnCount - 1)); final tileExtent = math.max(0, availableTileWidth) / columnCount; - final groupBy = args.groupBy ?? ref.watch(appConfigProvider.select((config) => config.timeline.groupAssetsBy)); + final groupBy = groupByArg ?? ref.watch(appConfigProvider.select((config) => config.timeline.groupAssetsBy)); final timelineService = ref.watch(timelineServiceProvider); yield* timelineService.watchBuckets().map((buckets) { diff --git a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart index c13725ba4c..3f05edc2a4 100644 --- a/mobile/lib/presentation/widgets/timeline/timeline.widget.dart +++ b/mobile/lib/presentation/widgets/timeline/timeline.widget.dart @@ -29,7 +29,7 @@ import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart'; -class Timeline extends StatelessWidget { +class Timeline extends ConsumerWidget { const Timeline({ super.key, this.topSliverWidget, @@ -62,35 +62,40 @@ class Timeline extends StatelessWidget { final Widget? loadingWidget; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final columnCount = ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)); return LayoutBuilder( - builder: (_, constraints) => ProviderScope( - overrides: [ - timelineArgsProvider.overrideWith( - (ref) => TimelineArgs( - maxWidth: constraints.maxWidth, - maxHeight: constraints.maxHeight, - columnCount: ref.watch(appConfigProvider.select((config) => config.timeline.tilesPerRow)), - showStorageIndicator: showStorageIndicator, - withStack: withStack, - groupBy: groupBy, + builder: (_, constraints) { + return ProviderScope( + overrides: [ + // overrideWithValue keeps the scoped args in sync with the latest constraints on rebuilds, + // a function override would stay locked to the first frame's constraints for the whole session + timelineArgsProvider.overrideWithValue( + TimelineArgs( + maxWidth: constraints.maxWidth, + maxHeight: constraints.maxHeight, + columnCount: columnCount, + showStorageIndicator: showStorageIndicator, + withStack: withStack, + groupBy: groupBy, + ), ), + if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()), + ], + child: _SliverTimeline( + topSliverWidget: topSliverWidget, + topSliverWidgetHeight: topSliverWidgetHeight, + bottomSliverWidget: bottomSliverWidget, + appBar: appBar, + bottomSheet: bottomSheet, + withScrubber: withScrubber, + persistentBottomBar: persistentBottomBar, + snapToMonth: snapToMonth, + maxWidth: constraints.maxWidth, + loadingWidget: loadingWidget, ), - if (readOnly) readonlyModeProvider.overrideWith(() => _AlwaysReadOnlyNotifier()), - ], - child: _SliverTimeline( - topSliverWidget: topSliverWidget, - topSliverWidgetHeight: topSliverWidgetHeight, - bottomSliverWidget: bottomSliverWidget, - appBar: appBar, - bottomSheet: bottomSheet, - withScrubber: withScrubber, - persistentBottomBar: persistentBottomBar, - snapToMonth: snapToMonth, - maxWidth: constraints.maxWidth, - loadingWidget: loadingWidget, - ), - ), + ); + }, ); } } @@ -169,13 +174,11 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> with WidgetsBi void didUpdateWidget(covariant _SliverTimeline oldWidget) { super.didUpdateWidget(oldWidget); if (widget.maxWidth != oldWidget.maxWidth) { - final asyncSegments = ref.read(timelineSegmentProvider); - asyncSegments.whenData((segments) { - final index = _getCurrentAssetIndex(segments); - // Refresh to wait for new segments to be generated with the updated width before restoring the scroll position - final _ = ref.refresh(timelineArgsProvider); - _restoreAssetIndex = index; - }); + // The updated args already regenerate the segments, only remember the scroll position to restore it afterwards + final segments = ref.read(timelineSegmentProvider).valueOrNull; + if (segments != null && _scrollController.hasClients) { + _restoreAssetIndex = _getCurrentAssetIndex(segments); + } } } diff --git a/mobile/lib/providers/infrastructure/timeline.provider.dart b/mobile/lib/providers/infrastructure/timeline.provider.dart index b22c693033..a67b8dd822 100644 --- a/mobile/lib/providers/infrastructure/timeline.provider.dart +++ b/mobile/lib/providers/infrastructure/timeline.provider.dart @@ -1,3 +1,4 @@ +import 'package:collection/collection.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/timeline.service.dart'; import 'package:immich_mobile/infrastructure/repositories/timeline.repository.dart'; @@ -39,5 +40,10 @@ final timelineUsersProvider = StreamProvider>((ref) { return Stream.value([]); } - return ref.watch(timelineRepositoryProvider).watchTimelineUserIds(currentUserId); + // Drift re-emits a fresh but content-identical list on unrelated table updates, + // which would dispose and rebuild the timeline service mid-load + return ref + .watch(timelineRepositoryProvider) + .watchTimelineUserIds(currentUserId) + .distinct(const ListEquality().equals); }); diff --git a/mobile/test/presentation/widgets/timeline/timeline_args_test.dart b/mobile/test/presentation/widgets/timeline/timeline_args_test.dart new file mode 100644 index 0000000000..4eb49bd3d6 --- /dev/null +++ b/mobile/test/presentation/widgets/timeline/timeline_args_test.dart @@ -0,0 +1,190 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/config/app_config.dart'; +import 'package:immich_mobile/domain/models/timeline.model.dart'; +import 'package:immich_mobile/domain/services/timeline.service.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.state.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/settings.provider.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; + +// A first fetch that never delivers - the state a suspended or storm-starved +// bucket watch is stuck in when the timeline mounts on a zero-sized first frame +class _FrozenBucketService implements TimelineService { + final _ctrl = StreamController>.broadcast(); + + @override + Stream> Function() get watchBuckets => + () => _ctrl.stream; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _EmptyBucketService implements TimelineService { + @override + Stream> Function() get watchBuckets => + () => Stream.value(const []); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +// Counts how many times the bucket stream is subscribed. Each subscription is a +// fresh photo query, so the count is our proxy for "did the relayout re-run the +// segment query for an input it does not use". +class _CountingBucketService implements TimelineService { + int watchCount = 0; + final _ctrl = StreamController>.broadcast(); + + @override + Stream> Function() get watchBuckets => () { + watchCount++; + return _ctrl.stream; + }; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + testWidgets('timeline args follow constraints after a zero-sized first frame while buckets are still loading', ( + tester, + ) async { + tester.view.physicalSize = Size.zero; + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + TimelineArgs? probed; + final probe = Consumer( + builder: (_, ref, __) { + probed = ref.watch(timelineArgsProvider); + return const SizedBox.shrink(); + }, + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + timelineServiceProvider.overrideWithValue(_FrozenBucketService()), + appConfigProvider.overrideWithValue(const AppConfig()), + ], + child: MaterialApp(home: Timeline(withScrubber: false, readOnly: true, loadingWidget: probe)), + ), + ); + await tester.pump(); + + expect(probed, isNotNull); + expect(probed!.maxWidth, 0.0); + + tester.view.physicalSize = const Size(1206, 2622); + await tester.pump(); + await tester.pump(); + + expect( + probed!.maxWidth, + 402.0, + reason: 'args locked to the zero-sized first frame leave the timeline blank for the whole session', + ); + }); + + testWidgets('timeline args follow constraints after a zero-sized first frame once buckets resolve', (tester) async { + tester.view.physicalSize = Size.zero; + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + + TimelineArgs? probed; + final probe = SliverToBoxAdapter( + child: Consumer( + builder: (_, ref, __) { + probed = ref.watch(timelineArgsProvider); + return const SizedBox.shrink(); + }, + ), + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + timelineServiceProvider.overrideWithValue(_EmptyBucketService()), + appConfigProvider.overrideWithValue(const AppConfig()), + ], + child: MaterialApp( + home: Timeline( + withScrubber: false, + readOnly: true, + appBar: const SliverToBoxAdapter(child: SizedBox.shrink()), + topSliverWidget: probe, + ), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + tester.view.physicalSize = const Size(1206, 2622); + await tester.pump(); + await tester.pump(); + await tester.pump(); + + expect(probed, isNotNull); + expect(probed!.maxWidth, 402.0); + }); + + testWidgets('a height-only relayout (multiselect app bar toggle) keeps the timeline, a width change refreshes it', ( + tester, + ) async { + final service = _CountingBucketService(); + tester.view.devicePixelRatio = 3.0; + tester.view.physicalSize = const Size(1206, 2622); + addTearDown(tester.view.reset); + + TimelineArgs? probed; + final probe = Consumer( + builder: (_, ref, __) { + probed = ref.watch(timelineArgsProvider); + return const SizedBox.shrink(); + }, + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + timelineServiceProvider.overrideWithValue(service), + appConfigProvider.overrideWithValue(const AppConfig()), + ], + child: MaterialApp(home: Timeline(withScrubber: false, readOnly: true, loadingWidget: probe)), + ), + ); + await tester.pump(); + + final initialSubscriptions = service.watchCount; + final initialWidth = probed!.maxWidth; + final initialHeight = probed!.maxHeight; + expect(initialSubscriptions, greaterThan(0)); + + // toggling multiselect changes the app bar, so only the available height moves + tester.view.physicalSize = const Size(1206, 2000); + await tester.pump(); + await tester.pump(); + + expect(probed!.maxHeight, isNot(initialHeight), reason: 'the height should have actually changed'); + expect(probed!.maxWidth, initialWidth); + expect( + service.watchCount, + initialSubscriptions, + reason: 'a height-only change must not re-run the bucket query for an input the segments do not use', + ); + + // a real width change (rotation, fold, split screen) should refresh the tiles + tester.view.physicalSize = const Size(1000, 2000); + await tester.pump(); + await tester.pump(); + + expect(probed!.maxWidth, lessThan(initialWidth)); + expect(service.watchCount, greaterThan(initialSubscriptions)); + }); +} From 053a52aec857fe6576fdbc52e47c429a1d8f02d6 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:53:16 +0200 Subject: [PATCH 48/54] fix: iso date parsing for three digit years (#29739) --- server/src/utils/date.spec.ts | 4 ++++ server/src/validation.ts | 8 ++------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/server/src/utils/date.spec.ts b/server/src/utils/date.spec.ts index 729b515467..476b5cb526 100644 --- a/server/src/utils/date.spec.ts +++ b/server/src/utils/date.spec.ts @@ -14,6 +14,10 @@ describe('asDateString', () => { const date = new Date(2000, 0, 15); // 15 Jan 2000, local midnight expect(asDateString(date)).toBe('2000-01-15'); }); + + it('should correctly pad years with a leading 0', () => { + expect(asDateString(new Date('280-12-12'))).toBe('0280-12-12'); + }); }); describe('asDateTimeString', () => { diff --git a/server/src/validation.ts b/server/src/validation.ts index 220d89e4a0..419d0cb0dc 100644 --- a/server/src/validation.ts +++ b/server/src/validation.ts @@ -1,4 +1,5 @@ import { ArgumentMetadata, FileValidator, Injectable, ParseUUIDPipe } from '@nestjs/common'; +import { DateTime } from 'luxon'; import { createZodDto } from 'nestjs-zod'; import sanitize from 'sanitize-filename'; import { isIP, isIPRange } from 'validator'; @@ -173,12 +174,7 @@ export const isoDateToDate = z z.date(), { decode: (isoString) => new Date(isoString), - encode: (date) => { - const y = date.getFullYear(); - const m = String(date.getMonth() + 1).padStart(2, '0'); - const d = String(date.getDate()).padStart(2, '0'); - return `${y}-${m}-${d}`; - }, + encode: (date) => DateTime.fromJSDate(date).toFormat('yyyy-MM-dd'), }, ) .meta({ example: '2024-01-01' }); From 66e30d5fa16148243f4c1833f90d7bc175e4f888 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Wed, 8 Jul 2026 21:53:46 +0200 Subject: [PATCH 49/54] fix: missing files integrity checks (#29737) --- server/src/queries/integrity.repository.sql | 2 +- server/src/repositories/integrity.repository.ts | 4 ++-- server/src/services/integrity.service.ts | 2 +- server/test/medium/specs/services/integrity.service.spec.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/server/src/queries/integrity.repository.sql b/server/src/queries/integrity.repository.sql index 937d4454a2..8d8461e700 100644 --- a/server/src/queries/integrity.repository.sql +++ b/server/src/queries/integrity.repository.sql @@ -86,7 +86,7 @@ select from "asset_file" --- IntegrityRepository.streamAssetPaths +-- IntegrityRepository.streamAssetPathsForMissingFiles select "allPaths"."path" as "path", "allPaths"."assetId", diff --git a/server/src/repositories/integrity.repository.ts b/server/src/repositories/integrity.repository.ts index 19435650df..3eb9fb58d7 100644 --- a/server/src/repositories/integrity.repository.ts +++ b/server/src/repositories/integrity.repository.ts @@ -119,7 +119,7 @@ export class IntegrityRepository { } @GenerateSql({ params: [], stream: true }) - streamAssetPaths() { + streamAssetPathsForMissingFiles() { return this.db .selectFrom((eb) => eb @@ -143,7 +143,7 @@ export class IntegrityRepository { ) .leftJoin('integrity_report', (join) => join - .on('integrity_report.type', '=', IntegrityReport.UntrackedFile) + .on('integrity_report.type', '=', IntegrityReport.MissingFile) .on((eb) => eb.or([ eb('integrity_report.assetId', '=', eb.ref('allPaths.assetId')), diff --git a/server/src/services/integrity.service.ts b/server/src/services/integrity.service.ts index b3aa3fcd3f..2a9d869be3 100644 --- a/server/src/services/integrity.service.ts +++ b/server/src/services/integrity.service.ts @@ -360,7 +360,7 @@ export class IntegrityService extends BaseService { this.logger.log(`Scanning for missing files...`); - const assetPaths = this.integrityRepository.streamAssetPaths(); + const assetPaths = this.integrityRepository.streamAssetPathsForMissingFiles(); let total = 0; for await (const batchPaths of chunk(assetPaths, JOBS_LIBRARY_PAGINATION_SIZE)) { diff --git a/server/test/medium/specs/services/integrity.service.spec.ts b/server/test/medium/specs/services/integrity.service.spec.ts index f5de014867..919b009479 100644 --- a/server/test/medium/specs/services/integrity.service.spec.ts +++ b/server/test/medium/specs/services/integrity.service.spec.ts @@ -408,7 +408,7 @@ describe(IntegrityService.name, () => { } = await ctx.newAsset({ ownerId, originalPath: '/path/to/file2' }); const { id: reportId } = await ctx.get(IntegrityRepository).create({ - type: IntegrityReport.UntrackedFile, + type: IntegrityReport.MissingFile, path: '/path/to/file2', assetId: assetId2, }); From c4f084f4a50974285b102e70d33a0d259925c881 Mon Sep 17 00:00:00 2001 From: Santo Shakil Date: Thu, 9 Jul 2026 02:22:35 +0600 Subject: [PATCH 50/54] fix(mobile): play videos from search results and folder view (#29693) * fix(mobile): play videos from search results and folder view * pr suggestion --------- Co-authored-by: Alex --- .../domain/models/asset/base_asset.model.dart | 11 +++++ .../asset_viewer/asset_page.widget.dart | 4 +- .../test/domain/models/base_asset_test.dart | 46 +++++++++++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 mobile/test/domain/models/base_asset_test.dart diff --git a/mobile/lib/domain/models/asset/base_asset.model.dart b/mobile/lib/domain/models/asset/base_asset.model.dart index b458a64a7f..3397dbc6bd 100644 --- a/mobile/lib/domain/models/asset/base_asset.model.dart +++ b/mobile/lib/domain/models/asset/base_asset.model.dart @@ -61,6 +61,17 @@ sealed class BaseAsset { bool get isLocalOnly => storage == AssetState.local; bool get isRemoteOnly => storage == AssetState.remote; + // Same asset even if localId is known on one side but not the other (heroTag isn't stable then) + bool refersToSameAsset(BaseAsset other) { + if (remoteId != null && other.remoteId != null) { + return remoteId == other.remoteId; + } + if (localId != null && other.localId != null) { + return localId == other.localId; + } + return checksum != null && checksum == other.checksum; + } + bool get isEditable => false; // Overridden in subclasses diff --git a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart index 6c974376a1..96331477dd 100644 --- a/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart +++ b/mobile/lib/presentation/widgets/asset_viewer/asset_page.widget.dart @@ -395,7 +395,7 @@ class _AssetPageState extends ConsumerState { @override Widget build(BuildContext context) { - final currentHeroTag = ref.watch(assetViewerProvider.select((s) => s.currentAsset?.heroTag)); + final currentAsset = ref.watch(assetViewerProvider.select((s) => s.currentAsset)); _showingDetails = ref.watch(assetViewerProvider.select((s) => s.showingDetails)); final stackIndex = ref.watch(assetViewerProvider.select((s) => s.stackIndex)); final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider); @@ -414,7 +414,7 @@ class _AssetPageState extends ConsumerState { displayAsset = stackChildren.elementAt(stackIndex); } - final isCurrent = currentHeroTag == displayAsset.heroTag; + final isCurrent = currentAsset != null && currentAsset.refersToSameAsset(displayAsset); final viewportWidth = MediaQuery.widthOf(context); final viewportHeight = MediaQuery.heightOf(context); diff --git a/mobile/test/domain/models/base_asset_test.dart b/mobile/test/domain/models/base_asset_test.dart new file mode 100644 index 0000000000..4c2eb3ca38 --- /dev/null +++ b/mobile/test/domain/models/base_asset_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../../unit/factories/local_asset_factory.dart'; +import '../../unit/factories/remote_asset_factory.dart'; + +void main() { + group('BaseAsset.refersToSameAsset', () { + test('search/folder copy (localId null) matches the merged DB copy (localId set)', () { + // #29472: search and folder assets arrive with localId null, then the viewer + // watches the DB copy which fills localId. heroTag embeds localId, so it + // diverges for the same asset and isCurrent used to go false. + final searchCopy = RemoteAssetFactory.create(id: 'asset-1'); + final mergedCopy = searchCopy.copyWith(localId: 'local-1'); + + expect(searchCopy.localId, isNull); + expect(mergedCopy.localId, 'local-1'); + expect(searchCopy.heroTag, isNot(mergedCopy.heroTag)); + + expect(searchCopy.refersToSameAsset(mergedCopy), isTrue); + expect(mergedCopy.refersToSameAsset(searchCopy), isTrue); + }); + + test('different remote assets are not the same', () { + final a = RemoteAssetFactory.create(id: 'asset-1'); + final b = RemoteAssetFactory.create(id: 'asset-2'); + + expect(a.refersToSameAsset(b), isFalse); + }); + + test('same checksum but different remote ids are not the same (duplicate files)', () { + final a = RemoteAssetFactory.create(id: 'asset-1'); + final b = RemoteAssetFactory.create(id: 'asset-2').copyWith(checksum: a.checksum); + + expect(a.checksum, b.checksum); + expect(a.refersToSameAsset(b), isFalse); + }); + + test('falls back to checksum when only one side has an id (local-only vs remote-only)', () { + final remoteOnly = RemoteAssetFactory.create(id: 'asset-1'); + final localOnly = LocalAssetFactory.create(id: 'local-1').copyWith(checksum: remoteOnly.checksum); + + expect(remoteOnly.refersToSameAsset(localOnly), isTrue); + expect(localOnly.refersToSameAsset(remoteOnly), isTrue); + }); + }); +} From c5222c7579c5268c862621ba68a128346ca3d06d Mon Sep 17 00:00:00 2001 From: Ben Beckford Date: Wed, 8 Jul 2026 17:15:48 -0700 Subject: [PATCH 51/54] chore(server): use bounded numbers in workflows (#29009) * chore(server): use bounded numbers in workflows * chore: increase precision for lat/lon filtering * chore: re-add integer to a json schema type --- packages/plugin-core/manifest.json | 17 ++++++++++++----- packages/plugin-core/src/index.ts | 6 +++--- server/src/dtos/json-schema.dto.ts | 3 +++ .../lib/components/SchemaConfiguration.svelte | 2 +- web/src/lib/types.ts | 3 +++ 5 files changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/plugin-core/manifest.json b/packages/plugin-core/manifest.json index ac0f2466e8..66a12c36f2 100644 --- a/packages/plugin-core/manifest.json +++ b/packages/plugin-core/manifest.json @@ -163,19 +163,26 @@ "description": "Filter by distance to a coordinate", "properties": { "latitude": { - "type": "string", + "type": "number", "title": "Latitude", - "description": "GPS latitude of a coordinate which the asset must be close to" + "description": "GPS latitude of a coordinate which the asset must be close to", + "minimum": -90, + "maximum": 90, + "precision": 0.000001 }, "longitude": { - "type": "string", + "type": "number", "title": "Longitude", - "description": "GPS longitude of a coordinate which the asset must be close to" + "description": "GPS longitude of a coordinate which the asset must be close to", + "minimum": -180, + "maximum": 180, + "precision": 0.000001 }, "radius": { "type": "number", "title": "Maximum distance", - "description": "How close in kilometres the asset must be to the given point" + "description": "How close in kilometres the asset must be to the given point", + "minimum": 0 } } } diff --git a/packages/plugin-core/src/index.ts b/packages/plugin-core/src/index.ts index fc6e94a872..706a787e1a 100644 --- a/packages/plugin-core/src/index.ts +++ b/packages/plugin-core/src/index.ts @@ -96,10 +96,10 @@ const methods = wrapper({ return { workflow: { continue: false } }; } - const configLat = Number.parseFloat(config.coordinate?.latitude ?? ''); - const configLon = Number.parseFloat(config.coordinate?.longitude ?? ''); + const configLat = config.coordinate?.latitude; + const configLon = config.coordinate?.longitude; - if (Number.isNaN(configLat) || Number.isNaN(configLat)) { + if (configLat === undefined || configLon === undefined) { return { workflow: { continue: true } }; } diff --git a/server/src/dtos/json-schema.dto.ts b/server/src/dtos/json-schema.dto.ts index 81b14ad62a..7811a08ecf 100644 --- a/server/src/dtos/json-schema.dto.ts +++ b/server/src/dtos/json-schema.dto.ts @@ -12,6 +12,9 @@ const JsonSchemaPropertySchema = z description: z.string().describe('Description'), default: z.any().optional().describe('Default value'), enum: z.array(z.string()).optional().describe('Valid choices for enum types'), + minimum: z.number().optional().describe('Minimum value for number types'), + maximum: z.number().optional().describe('Maximum value for number types'), + precision: z.number().default(1).optional().describe('Smallest interval (granularity) for number types'), array: z.boolean().optional().describe('Type is an array type'), required: z.array(z.string()).optional().describe('A list of required properties'), uiHint: z diff --git a/web/src/lib/components/SchemaConfiguration.svelte b/web/src/lib/components/SchemaConfiguration.svelte index 77727b4163..249331a5e4 100644 --- a/web/src/lib/components/SchemaConfiguration.svelte +++ b/web/src/lib/components/SchemaConfiguration.svelte @@ -100,7 +100,7 @@ {:else if schema.type === 'number'} - + {:else if schema.type === 'string'} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index ee5a03bb1b..09d882dd7d 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -93,6 +93,9 @@ export type JSONSchemaProperty = { // eslint-disable-next-line @typescript-eslint/no-explicit-any default?: any; enum?: string[]; + minimum?: number; + maximum?: number; + precision?: number; array?: boolean; properties?: Record; required?: string[]; From 4fd58885e27ae69a058748507e22fb0f774535ab Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:39:12 +0200 Subject: [PATCH 52/54] fix(web): recursively unnest class arrays (#29755) --- web/src/lib/components/assets/thumbnail/Thumbnail.svelte | 2 +- web/src/lib/index.spec.ts | 4 ++++ web/src/lib/index.ts | 6 ++++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/web/src/lib/components/assets/thumbnail/Thumbnail.svelte b/web/src/lib/components/assets/thumbnail/Thumbnail.svelte index 8583b9f8ac..6e73952b18 100644 --- a/web/src/lib/components/assets/thumbnail/Thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/Thumbnail.svelte @@ -247,7 +247,7 @@ > { it('should unnest arrays', () => { expect(cleanClass('class1', ['class2', 'class3'])).toBe('class1 class2 class3'); }); + + it('should unnest doubly nested arrays', () => { + expect(cleanClass('class1', [['class2a', 'class2b'], 'class3'])).toBe('class1 class2a class2b class3'); + }); }); describe('isDefined', () => { diff --git a/web/src/lib/index.ts b/web/src/lib/index.ts index 10f3deaadc..121087fdd3 100644 --- a/web/src/lib/index.ts +++ b/web/src/lib/index.ts @@ -3,8 +3,7 @@ import { twMerge } from 'tailwind-merge'; export const cleanClass = (...classNames: unknown[]) => { return twMerge( - classNames - .flatMap((className) => (Array.isArray(className) ? className : [className])) + flattenClass(classNames) .filter((className) => { if (!className || typeof className === 'boolean') { return false; @@ -16,6 +15,9 @@ export const cleanClass = (...classNames: unknown[]) => { ); }; +const flattenClass = (classNames: unknown[]): unknown[] => + classNames.flatMap((className) => (Array.isArray(className) ? flattenClass(className) : [className])); + export const isDefined = (value: T): value is NonNullable => value !== null && value !== undefined; export const getHeatmapRange = () => { From fea0db02f3985419fbbbfb03ab18567d0e7c06f4 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:21:16 +0200 Subject: [PATCH 53/54] fix: emit album update event when uploading assets through shared link (#29757) --- server/src/services/asset-media.service.ts | 17 ++++++++++++++--- .../specs/services/asset-media.service.spec.ts | 5 +++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index de8c2bbc91..1da5146d91 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -342,9 +342,20 @@ export class AssetMediaService extends BaseService { } private async addToSharedLink(sharedLink: AuthSharedLink, assetId: string) { - await (sharedLink.albumId - ? this.albumRepository.addAssetIds(sharedLink.albumId, [assetId]) - : this.sharedLinkRepository.addAssets(sharedLink.id, [assetId])); + if (!sharedLink.albumId) { + await this.sharedLinkRepository.addAssets(sharedLink.id, [assetId]); + return; + } + + const album = await this.albumRepository.getById(sharedLink.albumId, { withAssets: false }); + if (!album) { + return; + } + + await this.albumRepository.addAssetIds(album.id, [assetId]); + for (const { user } of album.albumUsers) { + await this.eventRepository.emit('AlbumUpdate', { id: album.id, recipientId: user.id }); + } } private requireQuota(auth: AuthDto, size: number) { diff --git a/server/test/medium/specs/services/asset-media.service.spec.ts b/server/test/medium/specs/services/asset-media.service.spec.ts index e9395bfebf..2d3546d9b0 100644 --- a/server/test/medium/specs/services/asset-media.service.spec.ts +++ b/server/test/medium/specs/services/asset-media.service.spec.ts @@ -213,6 +213,11 @@ describe(AssetService.name, () => { const assets = [...result]; expect(assets).toHaveLength(1); expect(assets[0]).toEqual(response.id); + + expect(ctx.getMock(EventRepository).emit).toHaveBeenCalledWith('AlbumUpdate', { + id: album.id, + recipientId: user.id, + }); }); it('should handle adding a duplicate asset to an album shared link', async () => { From 2db1e02cdf977897b8f77b2d2ec35f6a965429f3 Mon Sep 17 00:00:00 2001 From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:40:15 +0200 Subject: [PATCH 54/54] fix(web): always initialize language manager to set lang and dir (#29696) --- web/src/lib/managers/language-manager.svelte.ts | 10 +++++++++- web/src/routes/+layout.ts | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/web/src/lib/managers/language-manager.svelte.ts b/web/src/lib/managers/language-manager.svelte.ts index 3e67160140..c690197e1f 100644 --- a/web/src/lib/managers/language-manager.svelte.ts +++ b/web/src/lib/managers/language-manager.svelte.ts @@ -5,12 +5,20 @@ import { langs } from '$lib/utils/i18n'; class LanguageManager { constructor() { eventManager.on({ - AppInit: () => lang.subscribe((lang) => this.setLanguage(lang)), + AppInit: () => this.init(), }); } + initialized = $state(false); rtl = $state(false); + init() { + if (!this.initialized) { + this.initialized = true; + lang.subscribe((lang) => this.setLanguage(lang)); + } + } + setLanguage(code: string) { const item = langs.find((item) => item.code === code); if (!item) { diff --git a/web/src/routes/+layout.ts b/web/src/routes/+layout.ts index a2e01b247b..6dacfe7b42 100644 --- a/web/src/routes/+layout.ts +++ b/web/src/routes/+layout.ts @@ -1,5 +1,6 @@ import { commandPaletteManager } from '@immich/ui'; import { goto } from '$app/navigation'; +import { languageManager } from '$lib/managers/language-manager.svelte'; import { serverConfigManager } from '$lib/managers/server-config-manager.svelte'; import { maintenanceCreateUrl, maintenanceReturnUrl, maintenanceShouldRedirect } from '$lib/utils/maintenance'; import { init } from '$lib/utils/server'; @@ -23,6 +24,7 @@ export const load = (async ({ fetch, url }) => { } commandPaletteManager.enable(); + languageManager.init(); return { error,