Author SHA1 Message Date
Codex 8119a5ee22 Finalize local12 formatting and document guarded browser APIs 2026-10-27 23:27:18 +03:00
Codex 54881128d6 Adapt local features and dependency locks for SvelteKit 3 and local12 2026-10-27 23:17:33 +03:00
renovate[bot]andCodex d15be0dd83 chore(deps): update rust crate libc to v0.2.191 (#32753)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 12381a3b4bca8634f785c23cd4c6bf4063477b3b)
2026-10-27 23:12:52 +03:00
Eduard Ereza MartínezandCodex 47cc38061e fix(docs): update README_ca_ES.md to match the English version (#30300)
* Update README_ca_ES.md to match the English version

* fix(docs): update README_ca_ES.md to match the English version

(cherry picked from commit 0fe886d1b573d579fdb3fd243a477e0e6ac08f52)
2026-10-27 23:12:52 +03:00
MertandCodex db411a4497 feat(server): add jxl as target format (#32732)
(cherry picked from commit 9560989cb97fb93cd6ab74f7811acce133a9b768)
2026-10-27 23:12:52 +03:00
renovate[bot]andCodex f1a09c1389 chore(deps): update dependency fastapi to v0.160.0 (#32756)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit cadb945108d7a547845ba5279c745fca64ba2cf8)
2026-10-27 23:12:52 +03:00
renovate[bot]andCodex c8222bba94 chore(deps): update dependency terragrunt to v1.2.0 (#32757)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit e50ada17c817499a131eefb58a112850dfd22ea6)
2026-10-27 23:12:51 +03:00
TimonandCodex af66681b99 chore: bump openapi-generator version to v7.26 (#32454)
(cherry picked from commit b3c1b3d260b0186a9d6e82277f0e57a6e6d88738)
2026-10-27 23:12:42 +03:00
bo0tzzandCodex a93bbdf3e7 feat: inline hashes in helmet CSP (#30529)
* feat: inline hashes in helmet CSP

* tests

* fix: svelte3 merge

* chore: clean up

---------

Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
Co-authored-by: Jason Rasmussen <jason@rasm.me>
(cherry picked from commit 02a5d4b2060dad6f5779c196b7bb2b43f871ffb2)
2026-10-27 23:12:41 +03:00
bo0tzzandCodex 1707635743 fix(server): require update access on the motion video when linking a live photo (#32606)
* fix(server): require update access on the motion video when linking a live photo

* chore: allow linking with existing shared link assets

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
(cherry picked from commit a7afb4c9e9d2f099f9204a41f62e17b54fca3b17)
2026-10-27 23:12:41 +03:00
renovate[bot]andCodex 7a6123f8e6 fix(deps): update dependency @immich/ui to ^0.91.0 (#32587)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit d073539afdd20530baaf85ba414f396b94112b5c)
2026-10-27 23:12:40 +03:00
renovate[bot]andCodex 4960eddaf3 fix(deps): update typescript-projects (#32755)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 8aa9054366fac2738799a2356f9b760a2a441709)
2026-10-27 23:12:40 +03:00
bo0tzzandCodex d35a6613e6 chore: tighten auto-close template check (#32719)
(cherry picked from commit 2400de96668d3510a1a50dd1c658d483e9ad01aa)
2026-10-27 23:12:21 +03:00
Mees FrenselandCodex f96816b55f refactor(web): bulk (un)archive actions (#32189)
(cherry picked from commit d26c0eec10f1bb49117b71b667c88a8a715b2489)
2026-10-27 23:12:21 +03:00
YarosandCodex eca952720a fix: permission for admin notification endpoints (#32567)
(cherry picked from commit 045d295d2d0b506af6f2abe14d1ed2cb89c5235f)
2026-10-27 23:12:13 +03:00
Kairui LiuandCodex c482c1e53e fix: preserve stack primary asset selection (#31380)
* fix: preserve stack primary asset selection

* test: select stacked assets by id

* chore: limit dart formatting changes

* fix: address stack viewer review feedback

* fix: align stack ordering with server

* fix: keep stack primary asset first

---------

Co-authored-by: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
(cherry picked from commit 5a5d7be11084969c3c00a294ce6c0527042f88e7)
2026-10-27 23:12:13 +03:00
cevdetandCodex 75a1d7c5c8 perf(web): reduce favicon assets (#32594)
(cherry picked from commit 5177913e0a1dba27d5d46ae4ba46222e94e17d0b)
2026-10-27 23:12:13 +03:00
Mees FrenselandCodex 5630e541e3 refactor(web): clean up (#32627)
(cherry picked from commit 5ff8436781681755042fe6416024d0c4885b851d)
2026-10-27 23:12:13 +03:00
Mees FrenselandCodex 27ccbee82d refactor(web): use svelte builtin id generator (#32631)
(cherry picked from commit 285552785f1bd7cb5ad431c8b3423d1d5e858188)
2026-10-27 23:12:13 +03:00
Antonio SarroandCodex 3458767d66 fix(web): downloads aborted when switching assets in the viewer (#32663)
* fix(web): downloads aborted when switching assets in the viewer

The service worker also handled the download request, so the viewer cancelling its image urls killed the download too. Only handle image requests there.

* chore: clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
(cherry picked from commit 8c3fdda7ead1a70158780e45a67a11413b208dba)
2026-10-27 23:12:13 +03:00
Mees FrenselandCodex cd28300f94 fix(web): show metadata field if it is missing for one duplicate asset (#32601)
(cherry picked from commit b203227fdddff02c4686ff8db9d6054818900929)
2026-10-27 23:12:13 +03:00
Maximilian RinkandCodex d8030f3d14 fix(server): index edited assets in smart search (#32300)
(cherry picked from commit adca900fe277bd6dff93b96b6a7841a8632ce62b)
2026-10-27 23:12:13 +03:00
renovate[bot]andCodex 0ff6470791 chore(deps): update dependency opentofu to v1.13.2 (#32751)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 7cae55ec4ee7ab53d610cdf82b04a817e4ad8e8e)
2026-10-27 23:12:13 +03:00
renovate[bot]andCodex 717152b191 chore(deps): update terraform cloudflare to v4.52.10 (#32754)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 054e6cdce4b0f94c15fa3f4c0d0a076742f4a4d0)
2026-10-27 23:11:57 +03:00
renovate[bot]andCodex 06dbcf7c84 chore(deps): update prom/prometheus docker digest to fa33022 (#32749)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 1a1f15639011971d1674b124d6f0bbdaaf053822)
2026-10-27 23:11:57 +03:00
renovate[bot]andCodex 9767ea9c02 chore(deps): update node.js to 43b9ba1 (#32748)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 0632acf701f3d9f559199bee89b5d3cd86b84822)
2026-10-27 23:11:45 +03:00
JakksonnandCodex 071b8db397 docs: add Polish README translation (#32745)
* docs: added Polish README translation

* docs: improved translations

(cherry picked from commit 5ce14cff4db45fc7ed544f6ec52eef4bc35ef6e2)
2026-10-27 23:11:45 +03:00
renovate[bot]andCodex 1c9813e130 chore(deps): update github-actions (#32758)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 8875673b6856eb1b793137cd7b0e2be76acc6f0b)
2026-10-27 23:11:45 +03:00
Santo ShakilandCodex 6569aad605 feat(server): accept the iOS app origin for mobile passkeys (#32733)
(cherry picked from commit 1119e9c7a7c9f5ad657aa4e893b897772f75a124)
2026-10-27 23:11:30 +03:00
renovate[bot]andCodex a2794f0986 chore(deps): update ghcr.io/jdx/mise docker tag to v2026.10.20 (#32752)
(cherry picked from commit 506c83e8265bba829505deb23ad05a62eca579fc)
2026-10-27 23:11:30 +03:00
renovate[bot]andCodex 6ffc365b75 fix(deps): update dependency chokidar to v6 (#32760)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 4c33587fe88c52e50cb63dc8cea347fac6ef01ac)
2026-10-27 23:11:30 +03:00
renovate[bot]andCodex dc19d3a259 fix(deps): update opentelemetry-js monorepo to v3 (#32762)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit 14977d6cc8c27e226334681ba878aa1243887055)
2026-10-27 23:11:29 +03:00
renovate[bot]andCodex f2a502448d chore(deps): update dependency @sveltejs/enhanced-img to v1 (#32395)
Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
(cherry picked from commit df50249c03ddac9417a2172cfbbaef848163ed4f)
2026-10-27 23:11:28 +03:00
Jason RasmussenandCodex a3ffe3b3c3 refactor: asset face queries (#32679)
(cherry picked from commit 5077a85d5e6775c48b38de7fcf25f2a3864b6a83)
2026-10-27 23:11:20 +03:00
Daniel DietzlerandCodex 8125af4859 chore: migrate web imports from .js to .ts (#32742)
(cherry picked from commit f97c2243fbe5f64a4e1bf96128b82a37cc481506)
2026-10-27 23:11:20 +03:00
Daniel DietzlerandCodex e5b4dd1041 feat: sveltekit 3 (#32737)
(cherry picked from commit 2576ee28cfb790e99ddf401a129e1d9a18713121)
2026-10-27 23:11:06 +03:00
Codex 621504fcda Prepare local11 release and cover concurrent JPEG XL color transforms 2026-10-26 22:38:18 +03:00
YarosandCodex 919a4fd9ef fix: watch asset delete/archive in search (#31343)
* fix: watch asset delete/archive in search

* fix: restore via toast

* chore: format

* refactor: simplify search asset pruning

* chore: implement suggestions

* chore: more suggestions

* fix: mounted check

(cherry picked from commit f7b8ae5da370f947223b006618e5b4e5fb42fd33)
2026-10-26 22:35:01 +03:00
MertandCodex 2ed9207c3f perf(mobile): cache srgb transform (#32730)
(cherry picked from commit 18f1a44d1b934fc9d98ddb2be6918b7617c9c896)
2026-10-26 22:34:24 +03:00
Mees FrenselandCodex c48394aa6e refactor(web): use declaration tags instead of @const (#32639)
(cherry picked from commit c6d1e15c77c5f3786b26b292def27247b258cde5)
2026-10-26 22:34:24 +03:00
renovate[bot]andCodex 86c8b9e04d chore(deps): update machine-learning (#32125)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit 256eb55dbe92ac77e02794442f9487e88ffed2b1)
2026-10-25 16:24:17 +03:00
renovate[bot]andCodex 1190d0cc80 fix(deps): update dependency pillow to v13 [security] (#32621)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
(cherry picked from commit ea7080bd755f7e879e0753dea59fabde331667b7)
2026-10-25 16:23:45 +03:00
bo0tzzandCodex 9669f7cc6e perf(server): use a createdAt range for "recently added" time buckets (#32434)
(cherry picked from commit cf719e733e6250e0fcd8f45d2e2c46f166d3557d)
2026-10-25 16:23:45 +03:00
747 changed files with 5749 additions and 4754 deletions
+9 -2
View File
@@ -26,10 +26,17 @@ jobs:
env: env:
BODY: ${{ github.event.pull_request.body }} BODY: ${{ github.event.pull_request.body }}
run: | run: |
BODY=$(printf '%s\n' "$BODY" | tr -d '\r' | perl -0pe 's/<!--.*?-->//gs')
section() { printf '%s\n' "$BODY" | awk -v h="$1" 'index($0, h) == 1 { f = 1; next } /^## / { f = 0 } f'; }
OK=true OK=true
while IFS= read -r header; do while IFS= read -r header; do
printf '%s\n' "$BODY" | grep -qF "$header" || OK=false printf '%s\n' "$BODY" | grep -qF "$header" || OK=false
done < <(sed '/<!--/,/-->/d' .github/pull_request_template.md | grep "^## ") done < <(perl -0pe 's/<!--.*?-->//gs' .github/pull_request_template.md | grep "^## ")
section "## Checklist" | grep -qE '^\s*- \[[xX]\]' || OK=false
[[ "$(section "## Please describe to which degree" | tr -d '[:space:]')" =~ ^(\.\.\.)?$ ]] && OK=false
echo "uses_template=$OK" | tee --append "$GITHUB_OUTPUT" echo "uses_template=$OK" | tee --append "$GITHUB_OUTPUT"
close_template: close_template:
@@ -51,7 +58,7 @@ jobs:
run: | run: |
gh api graphql \ gh api graphql \
-f prId="$NODE_ID" \ -f prId="$NODE_ID" \
-f body="This PR has been automatically closed as the description doesn't follow [our template](https://github.com/immich-app/immich/blob/main/.github/pull_request_template.md). After you edit it to match the template, the PR will automatically be reopened." \ -f body="This PR has been automatically closed as the description doesn't follow [our template](https://github.com/immich-app/immich/blob/main/.github/pull_request_template.md). Please make sure every section is present and filled in. After you edit it to match the template, the PR will automatically be reopened." \
-f query=' -f query='
mutation CommentAndClosePR($prId: ID!, $body: String!) { mutation CommentAndClosePR($prId: ID!, $body: String!) {
addComment(input: { addComment(input: {
+1 -1
View File
@@ -230,7 +230,7 @@ jobs:
working_directory: ./mobile working_directory: ./mobile
- name: Setup Ruby - name: Setup Ruby
uses: ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0 uses: ruby/setup-ruby@e1bd6146574da5e0795075b5319b300edb30c5b0 # v1.333.0
with: with:
ruby-version: '3.4' ruby-version: '3.4'
bundler-cache: true # zizmor: ignore[cache-poisoning] release branches cannot read caches written by pull requests bundler-cache: true # zizmor: ignore[cache-poisoning] release branches cannot read caches written by pull requests
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Check for breaking API changes - name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@5e81b5c380accc6b523f9d32a637ca630e33620b # v0.1.17 uses: oasdiff/oasdiff-action/breaking@9ce3a29eb0e6478dd0140932a678c7fb59d08f22 # v0.1.20
with: with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json revision: open-api/immich-openapi-specs.json
+5 -5
View File
@@ -58,7 +58,7 @@ jobs:
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # If you wish to specify custom queries, you can do so here or in a config file.
@@ -71,10 +71,10 @@ jobs:
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below) # If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 uses: github/codeql-action/autobuild@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
# ℹ️ Command-line programs to run using the OS shell. # в„№пёЏ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # рџ“љ See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# If the Autobuild fails above, remove it and uncomment the following three lines. # If the Autobuild fails above, remove it and uncomment the following three lines.
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
@@ -84,6 +84,6 @@ jobs:
# ./location_of_script_within_repo/buildscript.sh # ./location_of_script_within_repo/buildscript.sh
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0 uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1
with: with:
category: '/language:${{matrix.language}}' category: '/language:${{matrix.language}}'
+1
View File
@@ -33,6 +33,7 @@
<a href="readme_i18n/README_uk_UA.md">Українська</a> <a href="readme_i18n/README_uk_UA.md">Українська</a>
<a href="readme_i18n/README_ru_RU.md">Русский</a> <a href="readme_i18n/README_ru_RU.md">Русский</a>
<a href="readme_i18n/README_bg_BG.md">Български</a> <a href="readme_i18n/README_bg_BG.md">Български</a>
<a href="readme_i18n/README_pl_PL.md">Polski</a>
<a href="readme_i18n/README_pt_BR.md">Português Brasileiro</a> <a href="readme_i18n/README_pt_BR.md">Português Brasileiro</a>
<a href="readme_i18n/README_sv_SE.md">Svenska</a> <a href="readme_i18n/README_sv_SE.md">Svenska</a>
<a href="readme_i18n/README_ar_JO.md">العربية</a> <a href="readme_i18n/README_ar_JO.md">العربية</a>
+42 -42
View File
@@ -1,69 +1,69 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html # @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools.opentofu]] [[tools.opentofu]]
version = "1.13.1" version = "1.13.2"
backend = "aqua:opentofu/opentofu" backend = "aqua:opentofu/opentofu"
[tools.opentofu."platforms.linux-arm64"] [tools.opentofu."platforms.linux-arm64"]
checksum = "sha256:9c1ef375aa1852db0b2888aa921b640c71f8140d4682aa4fec99378a64fa7dc3" checksum = "sha256:88e2d9e3f94f97b76ab2faf55ade87578f56aed19c5e45fa139d70e994366d1f"
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_linux_arm64.tar.gz" checksum_source = "mise-versions"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708055" url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742799"
[tools.opentofu."platforms.linux-x64"] [tools.opentofu."platforms.linux-x64"]
checksum = "sha256:378ada19d4bc70c43732004e8159be771b23b9a5afdf059e5f8a2b3fa2c70a69" checksum = "sha256:ac05d9952662c7db28dd57e93f0a68018837bfdf9342384ec0987977c16a9645"
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_linux_amd64.tar.gz" checksum_source = "mise-versions"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708017" url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742636"
[tools.opentofu."platforms.macos-arm64"] [tools.opentofu."platforms.macos-arm64"]
checksum = "sha256:be78f659f04ef06a9dbd9b3934d46af95d787a3aa38396d459dea395261816a9" checksum = "sha256:0d1cdc85f38992055b48e7fb161304a2ddfe2e5ee0ff68ffec9d0349c8bd1b59"
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_darwin_arm64.tar.gz" checksum_source = "mise-versions"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708059" url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_darwin_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742637"
[tools.opentofu."platforms.macos-x64"] [tools.opentofu."platforms.macos-x64"]
checksum = "sha256:a73720443ba38712d7d96dc1e857add02c15a790919c653ad07492e9952f8c27" checksum = "sha256:dc6e4eb1d3ed6550c2ef052596f933dbcd8c806957665ea83ae7537147cb3074"
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_darwin_amd64.tar.gz" checksum_source = "mise-versions"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708142" url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_darwin_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742982"
[tools.opentofu."platforms.windows-x64"] [tools.opentofu."platforms.windows-x64"]
checksum = "sha256:6825ae311e9a15747ce2ede7b7e97c8bb744a4d271fa8113259eeeee3d09438c" checksum = "sha256:e74fc2bda0c81ce696b6b7016a4d5c1f557638161239e4101e9ee7d8e0faba59"
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_windows_amd64.tar.gz" checksum_source = "mise-versions"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708102" url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_windows_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742635"
[[tools.terragrunt]] [[tools.terragrunt]]
version = "1.0.3" version = "1.2.0"
backend = "aqua:gruntwork-io/terragrunt" backend = "aqua:gruntwork-io/terragrunt"
[tools.terragrunt."platforms.linux-arm64"] [tools.terragrunt."platforms.linux-arm64"]
checksum = "sha256:e5b60ab05b5214db694e6bc215d8124fb626e277cdb56b86f6147ae110d510fe" checksum = "sha256:1e13b7f0646fa7962bc6c5bfba14a30d6a589325b82561e21b66be5981d86395"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_arm64.tar.gz" checksum_source = "mise-versions"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654253" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662437526"
[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"] [tools.terragrunt."platforms.linux-x64"]
checksum = "sha256:6d48049baf82e0bf9c804368dc85cbfeadc10955e33777e9e8de3e020b94b073" checksum = "sha256:23b5f2f9203d34919f7fd270a4b635b721b4903beca9762a1e4853b159bc9be8"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_linux_amd64.tar.gz" checksum_source = "mise-versions"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654196" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662437263"
[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"] [tools.terragrunt."platforms.macos-arm64"]
checksum = "sha256:aacb5be2ca5475300cbce246dfbd8a45eb47510fbaa70fab8561c49ef5db03aa" checksum = "sha256:79790d841ce71fe94c689fb6f5034afd31ad53d5904937c7a10934cca9d9734b"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_arm64.tar.gz" checksum_source = "mise-versions"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654084" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_darwin_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662436516"
[tools.terragrunt."platforms.macos-x64"] [tools.terragrunt."platforms.macos-x64"]
checksum = "sha256:3133c2251e191aede8e3dd2a5b3aee2e91c5f08f88f117aee40eed9a24c8ef6b" checksum = "sha256:0b0e63e80509eb1ffb4f89b892bc6fbf7728f27f0a73974400f1bd82a80b8627"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_darwin_amd64.tar.gz" checksum_source = "mise-versions"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406653970" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_darwin_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662436142"
[tools.terragrunt."platforms.windows-x64"] [tools.terragrunt."platforms.windows-x64"]
checksum = "sha256:183b2745b4e04980a6bfa4450ff81956a12596ca22d70f7aaa793980f5b036db" checksum = "sha256:c9a146b9aad456a289614023ad5778123d819af3cf9efd1c6130f196cfcb1999"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.0.3/terragrunt_windows_amd64.exe.tar.gz" checksum_source = "mise-versions"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/406654412" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_windows_amd64.exe.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662438255"
+2 -2
View File
@@ -1,6 +1,6 @@
[tools] [tools]
terragrunt = "1.1.1" terragrunt = "1.2.0"
opentofu = "1.13.1" opentofu = "1.13.2"
[tasks."tg:fmt"] [tasks."tg:fmt"]
run = "terragrunt hclfmt" run = "terragrunt hclfmt"
+28 -28
View File
@@ -2,34 +2,34 @@
# Manual edits may be lost in future updates. # Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" { provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.9" version = "4.52.10"
constraints = "4.52.9" constraints = "4.52.10"
hashes = [ hashes = [
"h1:LRBYMKjZyuld8kgVhzoceRtdtkht+w8LunrdpgqrL28=", "h1:57oeYqz+pYEPAw2GvHARjcER0QYU0teS6DJzS26kCAY=",
"h1:Q2NeLtrjSTZwHHsMZXKpqOAbxF8DWgqpmH5j+1pUZGw=", "h1:6bnAgXSboppQbj79XmRtQLj/58d+b4EozydI8XrqwRE=",
"h1:Q4wiux11bDVWRmJScdjp8jLbxMRsj8k3xGtPtY4BPDo=", "h1:6eP/jGOz1ioH17d3pygKhvsRa4AEKvedKqd+wgyiHYk=",
"h1:VVW111jEbRMTu7hXeGQMmZ/oSTB8LIYhjM5ztyymqAI=", "h1:84gJm57BBSvtfwt7t9ZiU7EKKDQARjsztoDuqB5DfqU=",
"h1:Z7sTk8/B9XYFAEpTq1ib/WsB2QvpegGwef395tHCUSo=", "h1:99tME9L/Ug/dXElX3Ac7Kx+KD7nhWEEwOTqaQubW18w=",
"h1:eakzS6UsflV4gN/oQWdRPYvvmySKkhgKPyykCfPSVH0=", "h1:TnMGVaPpIH5NZX324v+y6j1iW3ym0MqqNekxHlzXT3A=",
"h1:eesNMA1JUNBKCnuEvRLW2CT3tNrIejbXJG4A176nA9Q=", "h1:Z1L5jI4GiJxAAsfNyZHU4MGTluWEjjKtlB8Xn6cA0Dg=",
"h1:o8txgjqnDDODxQQ6ITwHsvPqHllCbMGnJkjCWNJK5ME=", "h1:dB/WsY27MLla/cwjYggolJhVeHUeS59rlOObAWK2e24=",
"h1:rDTPIzab3tbGdtU3NmES08+tRlKG8jygNAFDFbheiuM=", "h1:gpffNocq9fHK+uQehZQK6YmL1aHeCGrVM1p53dcFP/0=",
"h1:sIoAt5vaxgFBrst1Esfm7ixQQ/T7/JNPZu0m4bw+Bc4=", "h1:jvfw1A2Uck4AjNmmWXwGH6P0sGQrnNY/KUVlqgyqYUM=",
"h1:sPCs3KnD/db5TMz2CNgiYix7sRS8+FP92T4ipSYT9vw=", "h1:ojvrl9uI1EPtAwKRHim9eSc4Lc88thU/2BFGdJLEwrc=",
"h1:uXPLsMQUYVJjRGUfmfDQXsKzYr1scvF6PYKj+3f0Nr4=", "h1:rJZJK06NYCcSZhY5fwsRUSznSmAmRWVg4sO0auvbURo=",
"h1:vQibC9lgBRQjRQ5KxFH61wd3/q2I40groLPjZ/jiikQ=", "h1:y2m6i2SnQ3ZxiJHlMWNLHJrEbNqQT6OpDGyhFP+Nmds=",
"zh:0826cad9b57368f06466ec3e1f766086f44a373288753e144f681ddc0e61ab49", "zh:06c09cc50d6c61fb5f1ca79e4b22d5f383c6a848f885ed542dadef44ab4bfd55",
"zh:15bf8d8abdcd202652466f3ef91110f143a7fad13c6173c7180e3dc0dbe9254b", "zh:205bdfdac49d6b25ec2ca7ecffd39bf47126340e1943066bdbbd64d6d72577d2",
"zh:1a60d1d2e55a17942902b1ccc91e8f6cb150c035843d3eefc074f1291a0e7d5f", "zh:341ed688772d8e2bd9a148c57d56d5dfbe3d625bc50296a505cbae3d29694562",
"zh:23373a63fe06bbfa90c6c105c08429031719bedafa2458973cbdad32a384b5a7", "zh:3790619bae55558ab4d93f53686f7cf55f35138f800ea07b0b9c20cce75b9c0d",
"zh:256a97b79cedee2575002f77ac3bf18888ce0fc97170ae8a357db406bb0a88e3", "zh:44284a5b67e34f1381bbc47b6e765cee581037e25cd3b459caec069493b63898",
"zh:3dd9f93c167cd59491ae77781032491d36a80843a1e44f9ee68b41b3f8e3ab8e", "zh:493a173f3d5245db82782e8c1aba63e34c6fcf6b952b9288926b519bfe9d9725",
"zh:5182d2d61930dd83584d8feb56102614f80ddeec532b939b58085ad74b796a6e", "zh:4eb7846381fee6eefdb7e0aaf80153ed71d35547752f96987218a9429645844f",
"zh:5e3ca1888ba3b3bd8abd0f7dfcdd82fb5a8819affee899ed7fd891a33c17e0cb", "zh:850b1d1da770f48def40223d754ff65c31fabb1a7f334ed6ebb40eba09c41572",
"zh:7a3ce2119e39fb3a90a6a459a26863851309dbc92e0735336cbc98692539cb4d", "zh:9a29a08a9fd40d7c1beefe8597617c2fc475d62ec6d5ca8fcc2cd1a056a67a2e",
"zh:c095dc680a646cc078c10bb0551e2ea8f08e7abdbcbc58cfd332c56b58788631", "zh:9e57472f0a56863204f5b2eef125d4fb1222eca793c6a5ac9f4df6c1776edc48",
"zh:d2400e010e069c19916c99136e200cf873eba6db2b18d3eb9541b38eb1f565fa", "zh:a9eff98931da596c2d335359841bfd668f4b6cde257e15546a558c7f5d72773b",
"zh:dbc2f19f09aaefbd69334005bca2bcb0c9b690de1ed76f7ba97ce8e59a6e85df", "zh:c9293fb3390c79e4f5072e019c0a2c4c7408a55a55e78ae37f739a1da75886ef",
"zh:e685225f432da3e23b8717848de8c0ad0e22363450b2ac92cd24a56739ab5e9b", "zh:c9e98abec7f7a1e2669dd9c065896217125d774640e4673c1303a942736b3eb4",
] ]
} }
@@ -5,7 +5,7 @@ terraform {
required_providers { required_providers {
cloudflare = { cloudflare = {
source = "cloudflare/cloudflare" source = "cloudflare/cloudflare"
version = "4.52.9" version = "4.52.10"
} }
} }
} }
+28 -28
View File
@@ -2,34 +2,34 @@
# Manual edits may be lost in future updates. # Manual edits may be lost in future updates.
provider "registry.opentofu.org/cloudflare/cloudflare" { provider "registry.opentofu.org/cloudflare/cloudflare" {
version = "4.52.9" version = "4.52.10"
constraints = "4.52.9" constraints = "4.52.10"
hashes = [ hashes = [
"h1:LRBYMKjZyuld8kgVhzoceRtdtkht+w8LunrdpgqrL28=", "h1:57oeYqz+pYEPAw2GvHARjcER0QYU0teS6DJzS26kCAY=",
"h1:Q2NeLtrjSTZwHHsMZXKpqOAbxF8DWgqpmH5j+1pUZGw=", "h1:6bnAgXSboppQbj79XmRtQLj/58d+b4EozydI8XrqwRE=",
"h1:Q4wiux11bDVWRmJScdjp8jLbxMRsj8k3xGtPtY4BPDo=", "h1:6eP/jGOz1ioH17d3pygKhvsRa4AEKvedKqd+wgyiHYk=",
"h1:VVW111jEbRMTu7hXeGQMmZ/oSTB8LIYhjM5ztyymqAI=", "h1:84gJm57BBSvtfwt7t9ZiU7EKKDQARjsztoDuqB5DfqU=",
"h1:Z7sTk8/B9XYFAEpTq1ib/WsB2QvpegGwef395tHCUSo=", "h1:99tME9L/Ug/dXElX3Ac7Kx+KD7nhWEEwOTqaQubW18w=",
"h1:eakzS6UsflV4gN/oQWdRPYvvmySKkhgKPyykCfPSVH0=", "h1:TnMGVaPpIH5NZX324v+y6j1iW3ym0MqqNekxHlzXT3A=",
"h1:eesNMA1JUNBKCnuEvRLW2CT3tNrIejbXJG4A176nA9Q=", "h1:Z1L5jI4GiJxAAsfNyZHU4MGTluWEjjKtlB8Xn6cA0Dg=",
"h1:o8txgjqnDDODxQQ6ITwHsvPqHllCbMGnJkjCWNJK5ME=", "h1:dB/WsY27MLla/cwjYggolJhVeHUeS59rlOObAWK2e24=",
"h1:rDTPIzab3tbGdtU3NmES08+tRlKG8jygNAFDFbheiuM=", "h1:gpffNocq9fHK+uQehZQK6YmL1aHeCGrVM1p53dcFP/0=",
"h1:sIoAt5vaxgFBrst1Esfm7ixQQ/T7/JNPZu0m4bw+Bc4=", "h1:jvfw1A2Uck4AjNmmWXwGH6P0sGQrnNY/KUVlqgyqYUM=",
"h1:sPCs3KnD/db5TMz2CNgiYix7sRS8+FP92T4ipSYT9vw=", "h1:ojvrl9uI1EPtAwKRHim9eSc4Lc88thU/2BFGdJLEwrc=",
"h1:uXPLsMQUYVJjRGUfmfDQXsKzYr1scvF6PYKj+3f0Nr4=", "h1:rJZJK06NYCcSZhY5fwsRUSznSmAmRWVg4sO0auvbURo=",
"h1:vQibC9lgBRQjRQ5KxFH61wd3/q2I40groLPjZ/jiikQ=", "h1:y2m6i2SnQ3ZxiJHlMWNLHJrEbNqQT6OpDGyhFP+Nmds=",
"zh:0826cad9b57368f06466ec3e1f766086f44a373288753e144f681ddc0e61ab49", "zh:06c09cc50d6c61fb5f1ca79e4b22d5f383c6a848f885ed542dadef44ab4bfd55",
"zh:15bf8d8abdcd202652466f3ef91110f143a7fad13c6173c7180e3dc0dbe9254b", "zh:205bdfdac49d6b25ec2ca7ecffd39bf47126340e1943066bdbbd64d6d72577d2",
"zh:1a60d1d2e55a17942902b1ccc91e8f6cb150c035843d3eefc074f1291a0e7d5f", "zh:341ed688772d8e2bd9a148c57d56d5dfbe3d625bc50296a505cbae3d29694562",
"zh:23373a63fe06bbfa90c6c105c08429031719bedafa2458973cbdad32a384b5a7", "zh:3790619bae55558ab4d93f53686f7cf55f35138f800ea07b0b9c20cce75b9c0d",
"zh:256a97b79cedee2575002f77ac3bf18888ce0fc97170ae8a357db406bb0a88e3", "zh:44284a5b67e34f1381bbc47b6e765cee581037e25cd3b459caec069493b63898",
"zh:3dd9f93c167cd59491ae77781032491d36a80843a1e44f9ee68b41b3f8e3ab8e", "zh:493a173f3d5245db82782e8c1aba63e34c6fcf6b952b9288926b519bfe9d9725",
"zh:5182d2d61930dd83584d8feb56102614f80ddeec532b939b58085ad74b796a6e", "zh:4eb7846381fee6eefdb7e0aaf80153ed71d35547752f96987218a9429645844f",
"zh:5e3ca1888ba3b3bd8abd0f7dfcdd82fb5a8819affee899ed7fd891a33c17e0cb", "zh:850b1d1da770f48def40223d754ff65c31fabb1a7f334ed6ebb40eba09c41572",
"zh:7a3ce2119e39fb3a90a6a459a26863851309dbc92e0735336cbc98692539cb4d", "zh:9a29a08a9fd40d7c1beefe8597617c2fc475d62ec6d5ca8fcc2cd1a056a67a2e",
"zh:c095dc680a646cc078c10bb0551e2ea8f08e7abdbcbc58cfd332c56b58788631", "zh:9e57472f0a56863204f5b2eef125d4fb1222eca793c6a5ac9f4df6c1776edc48",
"zh:d2400e010e069c19916c99136e200cf873eba6db2b18d3eb9541b38eb1f565fa", "zh:a9eff98931da596c2d335359841bfd668f4b6cde257e15546a558c7f5d72773b",
"zh:dbc2f19f09aaefbd69334005bca2bcb0c9b690de1ed76f7ba97ce8e59a6e85df", "zh:c9293fb3390c79e4f5072e019c0a2c4c7408a55a55e78ae37f739a1da75886ef",
"zh:e685225f432da3e23b8717848de8c0ad0e22363450b2ac92cd24a56739ab5e9b", "zh:c9e98abec7f7a1e2669dd9c065896217125d774640e4673c1303a942736b3eb4",
] ]
} }
+1 -1
View File
@@ -5,7 +5,7 @@ terraform {
required_providers { required_providers {
cloudflare = { cloudflare = {
source = "cloudflare/cloudflare" source = "cloudflare/cloudflare"
version = "4.52.9" version = "4.52.10"
} }
} }
} }
+1 -1
View File
@@ -85,7 +85,7 @@ services:
container_name: immich_prometheus container_name: immich_prometheus
ports: ports:
- 9090:9090 - 9090:9090
image: prom/prometheus@sha256:5ce7540c3c00ef4ab0c9d2c995c6a5b9c421f44b4a115d97a2c7af3b1c21cbb0 image: prom/prometheus@sha256:fa3302279710be2f21a8301ec17a8b4f2301fd829bd113839b3b6b02b6f191a2
volumes: volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml - ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus - prometheus-data:/prometheus
+1 -1
View File
@@ -1,7 +1,7 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html # @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
[[tools.wrangler]] [[tools.wrangler]]
version = "4.151.0" version = "4.152.0"
backend = "npm:wrangler" backend = "npm:wrangler"
[tools.wrangler.options] [tools.wrangler.options]
+1 -1
View File
@@ -28,4 +28,4 @@ run = "prettier --write ."
run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}" run = "wrangler pages deploy build --project-name=${PROJECT_NAME} --branch=${BRANCH_NAME}"
[tools] [tools]
wrangler = "4.151.0" wrangler = "4.152.0"
+1 -1
View File
@@ -355,7 +355,7 @@ describe('/asset', () => {
.set('Authorization', `Bearer ${user1.accessToken}`) .set('Authorization', `Bearer ${user1.accessToken}`)
.send({ livePhotoVideoId: asset.id }); .send({ livePhotoVideoId: asset.id });
expect(body).toEqual(errorDto.badRequest('Live photo video does not belong to the user')); expect(body).toEqual(errorDto.badRequest('Not found or no asset.update access'));
expect(status).toEqual(400); expect(status).toEqual(400);
}); });
+1 -1
View File
@@ -103,7 +103,7 @@ test.describe('Shared Links', () => {
test('show error for invalid shared link', async ({ page }) => { test('show error for invalid shared link', async ({ page }) => {
await page.goto('/share/invalid'); await page.goto('/share/invalid');
await page.getByRole('heading', { name: 'Invalid share key' }).waitFor(); await page.getByRole('heading', { name: 'Page not found' }).waitFor();
}); });
test('auth on navigation from shared link to timeline', async ({ context, page }) => { test('auth on navigation from shared link to timeline', async ({ context, page }) => {
@@ -100,7 +100,7 @@ test.describe('OCR with stacked assets', () => {
const stackThumbnails = page.locator('#stack-slideshow [data-asset]'); const stackThumbnails = page.locator('#stack-slideshow [data-asset]');
await expect(stackThumbnails).toHaveCount(2); await expect(stackThumbnails).toHaveCount(2);
await stackThumbnails.nth(1).click(); await page.locator(`#stack-slideshow [data-asset="${secondAssetDto.id}"]`).click();
// refreshOcr() clears showOverlay when switching assets, so re-enable it // refreshOcr() clears showOverlay when switching assets, so re-enable it
await expect(ocrBoxes).toHaveCount(0); await expect(ocrBoxes).toHaveCount(0);
@@ -75,8 +75,8 @@ test.describe('asset-viewer stack', () => {
await page.goto(`/photos/${fixture.primaryAsset.id}`); await page.goto(`/photos/${fixture.primaryAsset.id}`);
await ensureDetailPanelVisible(page); await ensureDetailPanelVisible(page);
const stackAssets = page.locator('#stack-slideshow [data-asset]'); const secondAsset = page.locator(`#stack-slideshow [data-asset="${secondAssetDto.id}"]`);
await stackAssets.nth(1).click(); await secondAsset.click();
const tags = page.getByTestId('detail-panel-tags').getByRole('link'); const tags = page.getByTestId('detail-panel-tags').getByRole('link');
await expect(tags.first()).toHaveText('test/2'); await expect(tags.first()).toHaveText('test/2');
+3 -3
View File
@@ -87,7 +87,7 @@
"failed_job_command": "Command {command} failed for job: {job}", "failed_job_command": "Command {command} failed for job: {job}",
"force_delete_user_warning": "WARNING: This will immediately remove the user and all assets. This cannot be undone and the files cannot be recovered.", "force_delete_user_warning": "WARNING: This will immediately remove the user and all assets. This cannot be undone and the files cannot be recovered.",
"image_format": "Format", "image_format": "Format",
"image_format_description": "WebP produces smaller files than JPEG, but is slower to encode.", "image_format_description": "WebP produces smaller files than JPEG, but is slower to encode. JPEG XL is the best option for high-quality compression, but not all browsers and apps can display it.",
"image_fullsize_description": "Full-size image with stripped metadata, used when zoomed in", "image_fullsize_description": "Full-size image with stripped metadata, used when zoomed in",
"image_fullsize_enabled": "Enable full-size image generation", "image_fullsize_enabled": "Enable full-size image generation",
"image_fullsize_enabled_description": "Generate full-size image for non-web-friendly formats. When \"Prefer embedded preview\" is enabled, embedded previews are used directly without conversion. Does not affect web-friendly formats like JPEG.", "image_fullsize_enabled_description": "Generate full-size image for non-web-friendly formats. When \"Prefer embedded preview\" is enabled, embedded previews are used directly without conversion. Does not affect web-friendly formats like JPEG.",
@@ -101,7 +101,7 @@
"image_preview_quality_description": "Preview quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness. Setting a low value may affect machine learning quality.", "image_preview_quality_description": "Preview quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness. Setting a low value may affect machine learning quality.",
"image_preview_title": "Preview Settings", "image_preview_title": "Preview Settings",
"image_progressive": "Progressive", "image_progressive": "Progressive",
"image_progressive_description": "Encode JPEG images progressively for gradual loading display. This has no effect on WebP images.", "image_progressive_description": "Encode JPEG images progressively for gradual loading display. This has no effect on other formats.",
"image_quality": "Quality", "image_quality": "Quality",
"image_resolution": "Resolution", "image_resolution": "Resolution",
"image_resolution_description": "Higher resolutions can preserve more detail but take longer to encode, have larger file sizes and can reduce app responsiveness.", "image_resolution_description": "Higher resolutions can preserve more detail but take longer to encode, have larger file sizes and can reduce app responsiveness.",
@@ -2338,7 +2338,7 @@
"user": "User", "user": "User",
"user_has_been_deleted": "This user has been deleted.", "user_has_been_deleted": "This user has been deleted.",
"user_id": "User ID", "user_id": "User ID",
"user_liked": "{user} liked {type, select, photo {this photo} video {this video} asset {this asset} other {it}}", "user_liked": "{user} liked {type, select, image {this photo} video {this video} other {this}}",
"user_pin_code_settings": "PIN Code", "user_pin_code_settings": "PIN Code",
"user_pin_code_settings_description": "Manage your PIN code", "user_pin_code_settings_description": "Manage your PIN code",
"user_privacy": "User Privacy", "user_privacy": "User Privacy",
@@ -7,10 +7,14 @@ import numpy as np
import orjson import orjson
from numpy.typing import NDArray from numpy.typing import NDArray
from PIL import Image from PIL import Image
from pillow_jxl import JpegXLImagePlugin
_PIL_RESAMPLING_METHODS = {resampling.name.lower(): resampling for resampling in Image.Resampling} _PIL_RESAMPLING_METHODS = {resampling.name.lower(): resampling for resampling in Image.Resampling}
_PUNCTUATION_TRANS = str.maketrans("", "", string.punctuation) _PUNCTUATION_TRANS = str.maketrans("", "", string.punctuation)
# Requests are already decoded in parallel, so don't start a thread per core for each one
JpegXLImagePlugin.DECODE_THREADS = 0
def resize_pil(img: Image.Image, size: int, resample: Image.Resampling = Image.Resampling.BICUBIC) -> Image.Image: def resize_pil(img: Image.Image, size: int, resample: Image.Resampling = Image.Resampling.BICUBIC) -> Image.Image:
if img.width < img.height: if img.width < img.height:
+3 -2
View File
@@ -14,7 +14,8 @@ dependencies = [
"onnx>=1.22.0", "onnx>=1.22.0",
"opencv-python-headless>=4.7.0.72,<5.0", "opencv-python-headless>=4.7.0.72,<5.0",
"orjson>=3.9.5", "orjson>=3.9.5",
"pillow>=12.2,<13", "pillow>=13,<14",
"pillow-jxl-plugin>=1.3.8,<2.0",
"pydantic>=2.0.0,<3", "pydantic>=2.0.0,<3",
"pydantic-settings>=2.5.2,<3", "pydantic-settings>=2.5.2,<3",
"python-multipart>=0.0.6,<1.0", "python-multipart>=0.0.6,<1.0",
@@ -64,7 +65,7 @@ compile-bytecode = true
override-dependencies = ["opencv-python ; sys_platform == 'never'"] # only install the headless version override-dependencies = ["opencv-python ; sys_platform == 'never'"] # only install the headless version
[tool.uv.sources] [tool.uv.sources]
immich-model = { git = "https://github.com/immich-app/ml-models.git", rev = "8bf3fd1b44b0c8608f52fb6e50ddff52826576d8" } immich-model = { git = "https://github.com/immich-app/ml-models.git", rev = "a1eeff2c2118d76067efecf4cc4630998eac519a" }
onnxruntime-gpu = { index = "onnxruntime-cuda-12" } onnxruntime-gpu = { index = "onnxruntime-cuda-12" }
openvino = { index = "openvino-nightly" } openvino = { index = "openvino-nightly" }
+772 -534
View File
File diff suppressed because it is too large Load Diff
+100 -134
View File
@@ -1,29 +1,22 @@
# @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html # @generated - this file is auto-generated by `mise lock` https://mise.en.dev/dev-tools/mise-lock.html
lockfile_version = 3
[[tools."github:extism/cli"]] [[tools."github:extism/cli"]]
version = "1.6.3" version = "1.6.3"
backend = "github:extism/cli" backend = "github:extism/cli"
specifiers = ["1.6.3"]
[tools."github:extism/cli"."platforms.linux-arm64"] [tools."github:extism/cli"."platforms.linux-arm64"]
checksum = "sha256:d92f830c9be39637569feacb04e9750c28848df6d9a219db94152a9b4eb9452b" checksum = "sha256:d92f830c9be39637569feacb04e9750c28848df6d9a219db94152a9b4eb9452b"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-arm64.tar.gz" url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-arm64.tar.gz"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694030" url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694030"
[tools."github:extism/cli"."platforms.linux-arm64-musl"]
checksum = "sha256:d92f830c9be39637569feacb04e9750c28848df6d9a219db94152a9b4eb9452b"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-arm64.tar.gz"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694030"
[tools."github:extism/cli"."platforms.linux-x64"] [tools."github:extism/cli"."platforms.linux-x64"]
checksum = "sha256:34e7ae9bfded6e2c32dee83f70a4e50d34f9d3e80d1762b09625fe82e214d02d" checksum = "sha256:34e7ae9bfded6e2c32dee83f70a4e50d34f9d3e80d1762b09625fe82e214d02d"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-amd64.tar.gz" url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-amd64.tar.gz"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694025" url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694025"
[tools."github:extism/cli"."platforms.linux-x64-musl"]
checksum = "sha256:34e7ae9bfded6e2c32dee83f70a4e50d34f9d3e80d1762b09625fe82e214d02d"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-linux-amd64.tar.gz"
url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694025"
[tools."github:extism/cli"."platforms.macos-arm64"] [tools."github:extism/cli"."platforms.macos-arm64"]
checksum = "sha256:b4ddbc575b5ac000115247f781723f9b9f284ed87b29c600539d72161b5b29fc" checksum = "sha256:b4ddbc575b5ac000115247f781723f9b9f284ed87b29c600539d72161b5b29fc"
url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-darwin-arm64.tar.gz" url = "https://github.com/extism/cli/releases/download/v1.6.3/extism-v1.6.3-darwin-arm64.tar.gz"
@@ -42,27 +35,18 @@ url_api = "https://api.github.com/repos/extism/cli/releases/assets/275694035"
[[tools."github:extism/js-pdk"]] [[tools."github:extism/js-pdk"]]
version = "1.7.0" version = "1.7.0"
backend = "github:extism/js-pdk" backend = "github:extism/js-pdk"
specifiers = ["v1.7.0"]
[tools."github:extism/js-pdk"."platforms.linux-arm64"] [tools."github:extism/js-pdk"."platforms.linux-arm64"]
checksum = "sha256:025f4050b199d68413c159bde1187271ae270021a9f7171e7beb509922821f2a" checksum = "sha256:025f4050b199d68413c159bde1187271ae270021a9f7171e7beb509922821f2a"
url = "https://github.com/extism/js-pdk/releases/download/v1.7.0/extism-js-aarch64-linux-v1.7.0.gz" url = "https://github.com/extism/js-pdk/releases/download/v1.7.0/extism-js-aarch64-linux-v1.7.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/518632503" url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/518632503"
[tools."github:extism/js-pdk"."platforms.linux-arm64-musl"]
checksum = "sha256:025f4050b199d68413c159bde1187271ae270021a9f7171e7beb509922821f2a"
url = "https://github.com/extism/js-pdk/releases/download/v1.7.0/extism-js-aarch64-linux-v1.7.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/518632503"
[tools."github:extism/js-pdk"."platforms.linux-x64"] [tools."github:extism/js-pdk"."platforms.linux-x64"]
checksum = "sha256:63b72da2f5e88655522dc21477de549f238a2f40546a69ce4e0fce7e78654035" checksum = "sha256:63b72da2f5e88655522dc21477de549f238a2f40546a69ce4e0fce7e78654035"
url = "https://github.com/extism/js-pdk/releases/download/v1.7.0/extism-js-x86_64-linux-v1.7.0.gz" url = "https://github.com/extism/js-pdk/releases/download/v1.7.0/extism-js-x86_64-linux-v1.7.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/518632991" url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/518632991"
[tools."github:extism/js-pdk"."platforms.linux-x64-musl"]
checksum = "sha256:63b72da2f5e88655522dc21477de549f238a2f40546a69ce4e0fce7e78654035"
url = "https://github.com/extism/js-pdk/releases/download/v1.7.0/extism-js-x86_64-linux-v1.7.0.gz"
url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/518632991"
[tools."github:extism/js-pdk"."platforms.macos-arm64"] [tools."github:extism/js-pdk"."platforms.macos-arm64"]
checksum = "sha256:12c01c2bb2240a6a05a4f8babe680c793c397d84e6a1497a7ca1312e78a475c3" checksum = "sha256:12c01c2bb2240a6a05a4f8babe680c793c397d84e6a1497a7ca1312e78a475c3"
url = "https://github.com/extism/js-pdk/releases/download/v1.7.0/extism-js-aarch64-macos-v1.7.0.gz" url = "https://github.com/extism/js-pdk/releases/download/v1.7.0/extism-js-aarch64-macos-v1.7.0.gz"
@@ -81,6 +65,7 @@ url_api = "https://api.github.com/repos/extism/js-pdk/releases/assets/518636347"
[[tools."github:jellyfin/jellyfin-ffmpeg"]] [[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6" version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg" backend = "github:jellyfin/jellyfin-ffmpeg"
specifiers = ["7.1.3-6"]
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.windows-x64"] [tools."github:jellyfin/jellyfin-ffmpeg"."platforms.windows-x64"]
checksum = "sha256:7b7168149689610296f3a187c717056ce0786cc125a31caf28056737e9ba1cc1" checksum = "sha256:7b7168149689610296f3a187c717056ce0786cc125a31caf28056737e9ba1cc1"
@@ -90,18 +75,33 @@ url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets
[[tools."github:jellyfin/jellyfin-ffmpeg"]] [[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6" version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg" backend = "github:jellyfin/jellyfin-ffmpeg"
specifiers = ["7.1.3-6"]
[tools."github:jellyfin/jellyfin-ffmpeg".options] [tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz" asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-arm64"] [tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64"]
checksum = "sha256:e024d5e78d5414e75f0181036cd21373fafb9270c72894dfd7dbda2572439820" checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_macarm64-gpl.tar.xz" url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995838" url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879"
[[tools."github:jellyfin/jellyfin-ffmpeg"]] [[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6" version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg" backend = "github:jellyfin/jellyfin-ffmpeg"
specifiers = ["7.1.3-6"]
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
specifiers = ["7.1.3-6"]
[tools."github:jellyfin/jellyfin-ffmpeg".options] [tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz" asset_pattern = "jellyfin-ffmpeg_*_portable_mac64-gpl.tar.xz"
@@ -114,61 +114,31 @@ url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets
[[tools."github:jellyfin/jellyfin-ffmpeg"]] [[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6" version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg" backend = "github:jellyfin/jellyfin-ffmpeg"
specifiers = ["7.1.3-6"]
[tools."github:jellyfin/jellyfin-ffmpeg".options] [tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_linuxarm64-gpl.tar.xz" asset_pattern = "jellyfin-ffmpeg_*_portable_macarm64-gpl.tar.xz"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64"] [tools."github:jellyfin/jellyfin-ffmpeg"."platforms.macos-arm64"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886" checksum = "sha256:e024d5e78d5414e75f0181036cd21373fafb9270c72894dfd7dbda2572439820"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz" url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_macarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876" url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/408995838"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-arm64-musl"]
checksum = "sha256:bea03c670e8cc5bfe9edc0c5d624d4735421610cef5e808db93e7d8596952886"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linuxarm64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048876"
[[tools."github:jellyfin/jellyfin-ffmpeg"]]
version = "7.1.3-6"
backend = "github:jellyfin/jellyfin-ffmpeg"
[tools."github:jellyfin/jellyfin-ffmpeg".options]
asset_pattern = "jellyfin-ffmpeg_*_portable_linux64-gpl.tar.xz"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64"]
checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879"
[tools."github:jellyfin/jellyfin-ffmpeg"."platforms.linux-x64-musl"]
checksum = "sha256:39e99a7927468a6abec5f65d00f55010e8ff2ae3c2605294f179c94f6ae21af2"
url = "https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v7.1.3-6/jellyfin-ffmpeg_7.1.3-6_portable_linux64-gpl.tar.xz"
url_api = "https://api.github.com/repos/jellyfin/jellyfin-ffmpeg/releases/assets/409048879"
[[tools."github:webassembly/binaryen"]] [[tools."github:webassembly/binaryen"]]
version = "version_124" version = "version_124"
backend = "github:webassembly/binaryen" backend = "github:webassembly/binaryen"
specifiers = ["version_124"]
[tools."github:webassembly/binaryen"."platforms.linux-arm64"] [tools."github:webassembly/binaryen"."platforms.linux-arm64"]
checksum = "sha256:6291bd9a57d8e046f3bc099a4db386c147433a87f71c783a901c5b1792e38de3" checksum = "sha256:6291bd9a57d8e046f3bc099a4db386c147433a87f71c783a901c5b1792e38de3"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-aarch64-linux.tar.gz" url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-aarch64-linux.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288927659" url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288927659"
[tools."github:webassembly/binaryen"."platforms.linux-arm64-musl"]
checksum = "sha256:6291bd9a57d8e046f3bc099a4db386c147433a87f71c783a901c5b1792e38de3"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-aarch64-linux.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288927659"
[tools."github:webassembly/binaryen"."platforms.linux-x64"] [tools."github:webassembly/binaryen"."platforms.linux-x64"]
checksum = "sha256:0290c3779fedf592b8da0ded3032ff55c41a2b7bfa2d6bf7b7bac6f0e6e28963" checksum = "sha256:0290c3779fedf592b8da0ded3032ff55c41a2b7bfa2d6bf7b7bac6f0e6e28963"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-linux.tar.gz" url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-linux.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926769" url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926769"
[tools."github:webassembly/binaryen"."platforms.linux-x64-musl"]
checksum = "sha256:0290c3779fedf592b8da0ded3032ff55c41a2b7bfa2d6bf7b7bac6f0e6e28963"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-x86_64-linux.tar.gz"
url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288926769"
[tools."github:webassembly/binaryen"."platforms.macos-arm64"] [tools."github:webassembly/binaryen"."platforms.macos-arm64"]
checksum = "sha256:86a2c960ff62c6d2ea6009d1f89745c22c70100d394a095eab45eb941bdaa24c" checksum = "sha256:86a2c960ff62c6d2ea6009d1f89745c22c70100d394a095eab45eb941bdaa24c"
url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-arm64-macos.tar.gz" url = "https://github.com/WebAssembly/binaryen/releases/download/version_124/binaryen-version_124-arm64-macos.tar.gz"
@@ -187,9 +157,10 @@ url_api = "https://api.github.com/repos/WebAssembly/binaryen/releases/assets/288
[[tools.java]] [[tools.java]]
version = "21.0.2" version = "21.0.2"
backend = "core:java" backend = "core:java"
specifiers = ["21.0.2"]
[tools.java.options] [tools.java.options]
shorthand_vendor = "openjdk" shorthand_vendor = "temurin"
[tools.java."platforms.linux-arm64"] [tools.java."platforms.linux-arm64"]
checksum = "sha256:08db1392a48d4eb5ea5315cf8f18b89dbaf36cda663ba882cf03c704c9257ec2" checksum = "sha256:08db1392a48d4eb5ea5315cf8f18b89dbaf36cda663ba882cf03c704c9257ec2"
@@ -245,123 +216,118 @@ checksum = "sha256:d2046d28c2c3dcd211ac5145c0bf30e2047ef9dfb306693c8c6e2eccd8b95
url = "https://nodejs.org/dist/v24.22.1/node-v24.22.1-win-x64.zip" url = "https://nodejs.org/dist/v24.22.1/node-v24.22.1-win-x64.zip"
[[tools."npm:@openapitools/openapi-generator-cli"]] [[tools."npm:@openapitools/openapi-generator-cli"]]
version = "2.41.0" version = "2.42.0"
aube = { path = ".mise/locks/npm-openapitools-openapi-generator-cli/2.42.0", digest = "sha256:ef9f5cf15f958d31297bb706ee83d1347633603f3079431938665c7bc4136856" }
backend = "npm:@openapitools/openapi-generator-cli" backend = "npm:@openapitools/openapi-generator-cli"
specifiers = ["2.42.0"]
[[tools."npm:oazapfts"]] [[tools."npm:oazapfts"]]
version = "7.5.0" version = "7.5.0"
aube = { path = ".mise/locks/npm-oazapfts/7.5.0", digest = "sha256:74214614b0338eaa2baee026d615afde514311c5c76e274f420f8de8b1f0e017" }
backend = "npm:oazapfts" backend = "npm:oazapfts"
specifiers = ["7.5.0"]
[[tools.opentofu]] [[tools.opentofu]]
version = "1.13.1" version = "1.13.2"
backend = "aqua:opentofu/opentofu" backend = "aqua:opentofu/opentofu"
specifiers = ["1.13.1"] specifiers = ["1.13.2"]
[tools.opentofu."platforms.linux-arm64"] [tools.opentofu."platforms.linux-arm64"]
checksum = "sha256:9c1ef375aa1852db0b2888aa921b640c71f8140d4682aa4fec99378a64fa7dc3" checksum = "sha256:88e2d9e3f94f97b76ab2faf55ade87578f56aed19c5e45fa139d70e994366d1f"
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_linux_arm64.tar.gz" checksum_source = "mise-versions"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708055" url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742799"
[tools.opentofu."platforms.linux-arm64-musl"]
checksum = "sha256:9bd0228a81bcd0c88f7045c74378f45a815779f19897191dff7d9efba9976b9e"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762688"
[tools.opentofu."platforms.linux-x64"] [tools.opentofu."platforms.linux-x64"]
checksum = "sha256:378ada19d4bc70c43732004e8159be771b23b9a5afdf059e5f8a2b3fa2c70a69" checksum = "sha256:ac05d9952662c7db28dd57e93f0a68018837bfdf9342384ec0987977c16a9645"
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_linux_amd64.tar.gz" checksum_source = "mise-versions"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708017" url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742636"
[tools.opentofu."platforms.linux-x64-musl"]
checksum = "sha256:50a6106fa4de523d09c87af85f3db1dd47535fc005727fdca6852146476b88ec"
url = "https://github.com/opentofu/opentofu/releases/download/v1.12.6/tofu_1.12.6_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/520762723"
[tools.opentofu."platforms.macos-arm64"] [tools.opentofu."platforms.macos-arm64"]
checksum = "sha256:be78f659f04ef06a9dbd9b3934d46af95d787a3aa38396d459dea395261816a9" checksum = "sha256:0d1cdc85f38992055b48e7fb161304a2ddfe2e5ee0ff68ffec9d0349c8bd1b59"
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_darwin_arm64.tar.gz" checksum_source = "mise-versions"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708059" url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_darwin_arm64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742637"
[tools.opentofu."platforms.macos-x64"] [tools.opentofu."platforms.macos-x64"]
checksum = "sha256:a73720443ba38712d7d96dc1e857add02c15a790919c653ad07492e9952f8c27" checksum = "sha256:dc6e4eb1d3ed6550c2ef052596f933dbcd8c806957665ea83ae7537147cb3074"
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_darwin_amd64.tar.gz" checksum_source = "mise-versions"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708142" url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_darwin_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742982"
[tools.opentofu."platforms.windows-x64"] [tools.opentofu."platforms.windows-x64"]
checksum = "sha256:6825ae311e9a15747ce2ede7b7e97c8bb744a4d271fa8113259eeeee3d09438c" checksum = "sha256:e74fc2bda0c81ce696b6b7016a4d5c1f557638161239e4101e9ee7d8e0faba59"
url = "https://github.com/opentofu/opentofu/releases/download/v1.13.1/tofu_1.13.1_windows_amd64.tar.gz" checksum_source = "mise-versions"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/603708102" url = "https://github.com/opentofu/opentofu/releases/download/v1.13.2/tofu_1.13.2_windows_amd64.tar.gz"
url_api = "https://api.github.com/repos/opentofu/opentofu/releases/assets/662742635"
[[tools.pnpm]] [[tools.pnpm]]
version = "12.14.0" version = "12.15.0"
backend = "aqua:pnpm/pnpm" backend = "aqua:pnpm/pnpm"
specifiers = ["12.14.0"] specifiers = ["12.15.0"]
[tools.pnpm."platforms.linux-arm64"] [tools.pnpm."platforms.linux-arm64"]
checksum = "sha256:cfc780b9e690549e6633e1045fb1fb6ef84e390711e2099e6ccbae91c32456ea" checksum = "sha256:d7ce93a2c7d847d3a34bd3f559b60c14f578b1320c17cc352a2c5c5fdf956117"
url = "https://github.com/pnpm/pnpm/releases/download/v12.14.0/pnpm-linux-arm64.tar.gz" url = "https://github.com/pnpm/pnpm/releases/download/v12.15.0/pnpm-linux-arm64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/643957409" url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/662173352"
provenance = "github-attestations" provenance = "github-attestations"
[tools.pnpm."platforms.linux-x64"] [tools.pnpm."platforms.linux-x64"]
checksum = "sha256:3910c3852136bed5bc1511b9137a45fd4357ed6b3970d9d08893641e9005867e" checksum = "sha256:55b5ed0092bd11176c1669598e231ae52e7cedac59de6efd0d415730f37d0598"
url = "https://github.com/pnpm/pnpm/releases/download/v12.14.0/pnpm-linux-x64.tar.gz" url = "https://github.com/pnpm/pnpm/releases/download/v12.15.0/pnpm-linux-x64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/643957410" url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/662173360"
provenance = "github-attestations" provenance = "github-attestations"
[tools.pnpm."platforms.macos-arm64"] [tools.pnpm."platforms.macos-arm64"]
checksum = "sha256:50f48e0e17508952790a3fbf58fcf14f3b82efa5f594ca04ad904e29aaf854b0" checksum = "sha256:6a41a2877773e97f19832067187cc6fc82e186d8a2cf91fec4b4c1a5fbd6ef27"
url = "https://github.com/pnpm/pnpm/releases/download/v12.14.0/pnpm-darwin-arm64.tar.gz" url = "https://github.com/pnpm/pnpm/releases/download/v12.15.0/pnpm-darwin-arm64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/643957413" url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/662173343"
provenance = "github-attestations" provenance = "github-attestations"
[tools.pnpm."platforms.macos-x64"] [tools.pnpm."platforms.macos-x64"]
checksum = "sha256:2c412e07382049b4edb04fa6da78f3d4ef7810b75853c80544dcfb7c0ed79880" checksum = "sha256:2d7b336884574aee8a765bee48591212e9caf4fcf985fb3279588c1fb73fb47f"
url = "https://github.com/pnpm/pnpm/releases/download/v12.14.0/pnpm-darwin-x64.tar.gz" url = "https://github.com/pnpm/pnpm/releases/download/v12.15.0/pnpm-darwin-x64.tar.gz"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/643957411" url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/662173347"
provenance = "github-attestations" provenance = "github-attestations"
[tools.pnpm."platforms.windows-x64"] [tools.pnpm."platforms.windows-x64"]
checksum = "sha256:6f0f38e917c8ce76ef31db528c357e2bfa354a1bcb16464e3cdec39e6530d784" checksum = "sha256:84cf69db1be19e95bb4e5bef329291a046f2821b390d87e00f5690809d8a2ed7"
url = "https://github.com/pnpm/pnpm/releases/download/v12.14.0/pnpm-win32-x64.zip" url = "https://github.com/pnpm/pnpm/releases/download/v12.15.0/pnpm-win32-x64.zip"
url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/643957404" url_api = "https://api.github.com/repos/pnpm/pnpm/releases/assets/662173351"
provenance = "github-attestations" provenance = "github-attestations"
[[tools.terragrunt]] [[tools.terragrunt]]
version = "1.1.1" version = "1.2.0"
backend = "aqua:gruntwork-io/terragrunt" backend = "aqua:gruntwork-io/terragrunt"
specifiers = ["1.2.0"]
[tools.terragrunt."platforms.linux-arm64"] [tools.terragrunt."platforms.linux-arm64"]
checksum = "sha256:a374a7993ff3d99665a7e014007d3647ec7f0465c9d55c85e9f94c932e73cea2" checksum = "sha256:1e13b7f0646fa7962bc6c5bfba14a30d6a589325b82561e21b66be5981d86395"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_arm64.tar.gz" checksum_source = "mise-versions"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786148" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662437526"
[tools.terragrunt."platforms.linux-arm64-musl"]
checksum = "sha256:a374a7993ff3d99665a7e014007d3647ec7f0465c9d55c85e9f94c932e73cea2"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786148"
[tools.terragrunt."platforms.linux-x64"] [tools.terragrunt."platforms.linux-x64"]
checksum = "sha256:ce90077ac31ef17a2ba10d11d45f36c6501997a8f4f79d703bb7daba37032f53" checksum = "sha256:23b5f2f9203d34919f7fd270a4b635b721b4903beca9762a1e4853b159bc9be8"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_amd64.tar.gz" checksum_source = "mise-versions"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785968" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662437263"
[tools.terragrunt."platforms.linux-x64-musl"]
checksum = "sha256:ce90077ac31ef17a2ba10d11d45f36c6501997a8f4f79d703bb7daba37032f53"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_linux_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785968"
[tools.terragrunt."platforms.macos-arm64"] [tools.terragrunt."platforms.macos-arm64"]
checksum = "sha256:9ec8f678b9ae6c81d5d9d77b94cbf6349ce639d5938694b4adc9dea73e416794" checksum = "sha256:79790d841ce71fe94c689fb6f5034afd31ad53d5904937c7a10934cca9d9734b"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_darwin_arm64.tar.gz" checksum_source = "mise-versions"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785628" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_darwin_arm64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662436516"
[tools.terragrunt."platforms.macos-x64"] [tools.terragrunt."platforms.macos-x64"]
checksum = "sha256:73e768a69fa44a60f9d9174f54aaf179327e08706f633c9c79d5f5c9622c91c2" checksum = "sha256:0b0e63e80509eb1ffb4f89b892bc6fbf7728f27f0a73974400f1bd82a80b8627"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_darwin_amd64.tar.gz" checksum_source = "mise-versions"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476785520" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_darwin_amd64.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662436142"
[tools.terragrunt."platforms.windows-x64"] [tools.terragrunt."platforms.windows-x64"]
checksum = "sha256:dd50a324691e072a3ac879e9ec43d6310e2936c929fda658eb2811fefcc75115" checksum = "sha256:c9a146b9aad456a289614023ad5778123d819af3cf9efd1c6130f196cfcb1999"
url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.1.1/terragrunt_windows_amd64.exe.tar.gz" checksum_source = "mise-versions"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/476786421" url = "https://github.com/gruntwork-io/terragrunt/releases/download/v1.2.0/terragrunt_windows_amd64.exe.tar.gz"
url_api = "https://api.github.com/repos/gruntwork-io/terragrunt/releases/assets/662438255"
+4 -4
View File
@@ -16,10 +16,10 @@ config_roots = [
[tools] [tools]
node = "24.22.1" node = "24.22.1"
pnpm = "12.14.0" pnpm = "12.15.0"
terragrunt = "1.1.1" terragrunt = "1.2.0"
opentofu = "1.13.1" opentofu = "1.13.2"
"npm:@openapitools/openapi-generator-cli" = "2.41.0" "npm:@openapitools/openapi-generator-cli" = "2.42.0"
"npm:oazapfts" = "7.5.0" "npm:oazapfts" = "7.5.0"
"github:extism/cli" = "1.6.3" "github:extism/cli" = "1.6.3"
"github:webassembly/binaryen" = "version_124" "github:webassembly/binaryen" = "version_124"
@@ -60,7 +60,6 @@ class AssetService {
} }
final stack = await _remoteRepository.getStackChildren(asset); final stack = await _remoteRepository.getStackChildren(asset);
// Include the primary asset in the stack as the first item
return [asset, ...stack]; return [asset, ...stack];
} }
@@ -41,6 +41,23 @@ class RemoteAssetRepository extends DatabaseAccessor<Drift> with $RemoteAssetRep
return _assetSelectable(id).watchSingleOrNull(); return _assetSelectable(id).watchSingleOrNull();
} }
Stream<Set<String>> watchDeletedAssetIds(Iterable<String> ids, AssetVisibility visibility) {
final idList = ids.toList(growable: false);
if (idList.isEmpty) {
return Stream.value(const <String>{});
}
final query = _db.remoteAssetEntity.selectOnly()
..addColumns([_db.remoteAssetEntity.id])
..where(
_db.remoteAssetEntity.id.isIn(idList) &
(_db.remoteAssetEntity.deletedAt.isNotNull() |
_db.remoteAssetEntity.visibility.equalsValue(visibility).not()),
);
return query.map((row) => row.read(_db.remoteAssetEntity.id)!).watch().map((rows) => rows.toSet());
}
Future<RemoteAsset?> get(String id) { Future<RemoteAsset?> get(String id) {
return _assetSelectable(id).getSingleOrNull(); return _assetSelectable(id).getSingleOrNull();
} }
@@ -59,7 +76,7 @@ class RemoteAssetRepository extends DatabaseAccessor<Drift> with $RemoteAssetRep
final query = _db.remoteAssetEntity.select() final query = _db.remoteAssetEntity.select()
..where((row) => row.stackId.equals(stackId) & row.id.equals(asset.id).not()) ..where((row) => row.stackId.equals(stackId) & row.id.equals(asset.id).not())
..orderBy([(row) => OrderingTerm.desc(row.createdAt)]); ..orderBy([(row) => OrderingTerm.asc(row.createdAt)]);
return query.map((row) => row.toDto()).get(); return query.map((row) => row.toDto()).get();
} }
@@ -3,7 +3,9 @@ import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/search.service.dart'; import 'package:immich_mobile/domain/services/search.service.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart'; import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/search.provider.dart'; import 'package:immich_mobile/providers/infrastructure/search.provider.dart';
final searchPreFilterProvider = NotifierProvider<SearchFilterProvider, SearchFilter?>(SearchFilterProvider.new); final searchPreFilterProvider = NotifierProvider<SearchFilterProvider, SearchFilter?>(SearchFilterProvider.new);
@@ -32,16 +34,21 @@ class SearchState {
} }
final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchState>( final paginatedSearchProvider = StateNotifierProvider<PaginatedSearchNotifier, SearchState>(
(ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider)), (ref) => PaginatedSearchNotifier(ref.watch(searchServiceProvider), ref.watch(driftProvider).remoteAssetRepository),
); );
class PaginatedSearchNotifier extends StateNotifier<SearchState> { class PaginatedSearchNotifier extends StateNotifier<SearchState> {
final SearchService _searchService; final SearchService _searchService;
final RemoteAssetRepository _remoteAssetRepository;
final _assetCountController = StreamController<int>.broadcast(); final _assetCountController = StreamController<int>.broadcast();
PaginatedSearchNotifier(this._searchService) : super(const SearchState()); StreamSubscription<Set<String>>? _deletedIdsSubscription;
List<BaseAsset> _results = const [];
int? _nextPage = 1;
Stream<int> get assetCount => _assetCountController.stream; PaginatedSearchNotifier(this._searchService, this._remoteAssetRepository) : super(const SearchState());
Stream<int> get assetCount => _assetCountController.stream.distinct();
Future<void> search(SearchFilter filter) async { Future<void> search(SearchFilter filter) async {
if (state.nextPage == null || state.isLoading) { if (state.nextPage == null || state.isLoading) {
@@ -52,24 +59,47 @@ class PaginatedSearchNotifier extends StateNotifier<SearchState> {
final result = await _searchService.search(filter, state.nextPage!); final result = await _searchService.search(filter, state.nextPage!);
if (!mounted) {
return;
}
if (result == null) { if (result == null) {
state = SearchState(assets: state.assets, nextPage: state.nextPage); state = SearchState(assets: state.assets, nextPage: state.nextPage);
return; return;
} }
final assets = [...state.assets, ...result.assets]; _results = [..._results, ...result.assets];
state = SearchState(assets: assets, nextPage: result.nextPage); _nextPage = result.nextPage;
_watchDeletedIds(filter.display.isArchive ? AssetVisibility.archive : AssetVisibility.timeline);
_assetCountController.add(assets.length);
} }
void clear() { void clear() {
state = const SearchState(); unawaited(_deletedIdsSubscription?.cancel());
_assetCountController.add(0); _deletedIdsSubscription = null;
_results = const [];
_nextPage = 1;
_emit(const {});
}
void _watchDeletedIds(AssetVisibility visibility) {
unawaited(_deletedIdsSubscription?.cancel());
final ids = _results.whereType<RemoteAsset>().map((asset) => asset.id);
_deletedIdsSubscription = _remoteAssetRepository.watchDeletedAssetIds(ids, visibility).listen(_emit);
}
void _emit(Set<String> deletedIds) {
final visible = deletedIds.isEmpty
? _results
: _results.where((asset) => asset is! RemoteAsset || !deletedIds.contains(asset.id)).toList(growable: false);
state = SearchState(assets: visible, nextPage: _nextPage);
_assetCountController.add(visible.length);
} }
@override @override
void dispose() { void dispose() {
unawaited(_deletedIdsSubscription?.cancel());
unawaited(_assetCountController.close()); unawaited(_assetCountController.close());
super.dispose(); super.dispose();
} }
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none' publish_to: 'none'
version: 3.3.5-local.9+3030502 version: 3.3.5-local.12+3030504
environment: environment:
sdk: '>=3.12.0 <4.0.0' sdk: '>=3.12.0 <4.0.0'
@@ -1,4 +1,6 @@
import 'package:async/async.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import '../repository_context.dart'; import '../repository_context.dart';
@@ -16,6 +18,37 @@ void main() {
await ctx.dispose(); await ctx.dispose();
}); });
group('getStackChildren', () {
test('returns stack children from oldest to newest', () async {
final user = await ctx.newUser();
const stackId = 'stack';
final primary = await ctx.newRemoteAsset(
id: 'primary',
ownerId: user.id,
stackId: stackId,
createdAt: DateTime(2026, 1, 2),
);
final beforePrimary = await ctx.newRemoteAsset(
id: 'before-primary',
ownerId: user.id,
stackId: stackId,
createdAt: DateTime(2026, 1, 1),
);
final afterPrimary = await ctx.newRemoteAsset(
id: 'after-primary',
ownerId: user.id,
stackId: stackId,
createdAt: DateTime(2026, 1, 3),
);
await ctx.newRemoteAsset(ownerId: user.id, stackId: 'other-stack');
final primaryAsset = await sut.get(primary.id);
final stack = await sut.getStackChildren(primaryAsset!);
expect(stack.map((asset) => asset.id), [beforePrimary.id, afterPrimary.id]);
});
});
group('getByChecksum', () { group('getByChecksum', () {
late String userId; late String userId;
@@ -64,4 +97,53 @@ void main() {
expect(result[0].id, remote.id); expect(result[0].id, remote.id);
}); });
}); });
group('watchDeletedAssetIds', () {
late String userId;
setUp(() async {
final user = await ctx.newUser();
userId = user.id;
await ctx.newAuthUser(id: userId);
});
test('hides trashed and archived assets but keeps matching and unsynced ones', () async {
final timeline = await ctx.newRemoteAsset(ownerId: userId);
final trashed = await ctx.newRemoteAsset(ownerId: userId, deletedAt: DateTime(2020));
final archived = await ctx.newRemoteAsset(ownerId: userId, visibility: AssetVisibility.archive);
final result = await sut.watchDeletedAssetIds([
timeline.id,
trashed.id,
archived.id,
'never-synced',
], AssetVisibility.timeline).first;
expect(result, {trashed.id, archived.id});
});
test('hides assets not matching the requested visibility for archive searches', () async {
final timeline = await ctx.newRemoteAsset(ownerId: userId);
final archived = await ctx.newRemoteAsset(ownerId: userId, visibility: AssetVisibility.archive);
final result = await sut.watchDeletedAssetIds([timeline.id, archived.id], AssetVisibility.archive).first;
expect(result, {timeline.id});
});
test('emits when an asset is trashed and again when it is restored', () async {
final asset = await ctx.newRemoteAsset(ownerId: userId);
final queue = StreamQueue(sut.watchDeletedAssetIds([asset.id], AssetVisibility.timeline));
expect(await queue.next, isEmpty);
await sut.trash([asset.id]);
expect(await queue.next, {asset.id});
await sut.restoreTrash([asset.id]);
expect(await queue.next, isEmpty);
await queue.cancel();
});
});
} }
+2 -2
View File
@@ -251,9 +251,9 @@ dependencies = [
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.190" version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
+23 -2
View File
@@ -1,5 +1,5 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, LazyLock};
use jxl::api::{ use jxl::api::{
AlphaMode, Endianness, Event, ExtraChannel, JxlColorEncoding, JxlColorProfile, JxlColorType, AlphaMode, Endianness, Event, ExtraChannel, JxlColorEncoding, JxlColorProfile, JxlColorType,
@@ -7,6 +7,7 @@ use jxl::api::{
JxlParallelRunnerFun, JxlPixelFormat, JxlPrimaries, JxlTransferFunction, JxlWhitePoint, JxlParallelRunnerFun, JxlPixelFormat, JxlPrimaries, JxlTransferFunction, JxlWhitePoint,
}; };
use moxcms::{Layout, MatrixCoefficients, Transform16BitExecutor}; use moxcms::{Layout, MatrixCoefficients, Transform16BitExecutor};
use quick_cache::sync::Cache;
use rayon::prelude::*; use rayon::prelude::*;
use crate::color::{self, CicpColorPrimaries, CicpProfile, Target, TransferCharacteristics}; use crate::color::{self, CicpColorPrimaries, CicpProfile, Target, TransferCharacteristics};
@@ -98,7 +99,7 @@ impl<'a> Jxl<'a> {
transform: None, transform: None,
}); });
} }
let (transform, layout) = color::to_srgb(&profile.try_as_icc()?)?; let (transform, layout) = srgb_transform(profile)?;
decoder decoder
.set_pixel_format(JxlPixelFormat { .set_pixel_format(JxlPixelFormat {
color_type: match layout { color_type: match layout {
@@ -233,3 +234,23 @@ fn cicp(profile: &JxlColorProfile) -> Option<CicpProfile> {
full_range: true, full_range: true,
}) })
} }
/// Reuse the transform to sRGB for a given source profile.
type Transforms<K> = Cache<K, (Arc<Transform16BitExecutor>, Layout)>;
/// [`color::to_srgb`] for `profile`, built once per distinct profile.
fn srgb_transform(profile: &JxlColorProfile) -> Option<(Arc<Transform16BitExecutor>, Layout)> {
const TRANSFORMS: usize = 8;
static ENCODINGS: LazyLock<Transforms<String>> = LazyLock::new(|| Cache::new(TRANSFORMS));
static ICCS: LazyLock<Transforms<Vec<u8>>> = LazyLock::new(|| Cache::new(TRANSFORMS));
match profile {
JxlColorProfile::Simple(encoding) => ENCODINGS
.get_or_insert_with(&encoding.get_color_encoding_description(), || {
color::to_srgb(&profile.try_as_icc().ok_or(())?).ok_or(())
}),
JxlColorProfile::Icc(icc) => {
ICCS.get_or_insert_with(icc.as_slice(), || color::to_srgb(icc).ok_or(()))
}
}
.ok()
}
+30
View File
@@ -83,6 +83,36 @@ fn converts_lossy_icc_to_srgb() {
} }
} }
#[test]
fn concurrent_decodes_keep_profile_colors_when_transforms_are_reused() {
let cases: [(&[u8], [u8; 8]); 3] = [
(
include_bytes!("fixtures/p3.jxl"),
[255, 0, 0, 255, 255, 255, 255, 255],
),
(
include_bytes!("fixtures/gray_icc.jxl"),
[81, 81, 81, 255, 203, 203, 203, 255],
),
(
include_bytes!("fixtures/srgb_alpha.jxl"),
[128, 0, 0, 128, 0, 0, 255, 255],
),
];
std::thread::scope(|scope| {
for _ in 0..4 {
scope.spawn(|| {
for _ in 0..8 {
for (data, expected) in cases {
let rgba = decode_srgb(data).unwrap();
assert_eq!(rgba.pixels().as_flattened(), &expected);
}
}
});
}
});
}
#[test] #[test]
fn tone_maps_hdr() { fn tone_maps_hdr() {
let rgba = decode_srgb(include_bytes!("fixtures/pq_grays.jxl")).unwrap(); let rgba = decode_srgb(include_bytes!("fixtures/pq_grays.jxl")).unwrap();
+4
View File
@@ -1237,6 +1237,7 @@
"state": "Stable" "state": "Stable"
} }
], ],
"x-immich-permission": "notification.create",
"x-immich-state": "Stable" "x-immich-state": "Stable"
} }
}, },
@@ -1306,6 +1307,7 @@
"state": "Stable" "state": "Stable"
} }
], ],
"x-immich-permission": "notification.create",
"x-immich-state": "Stable" "x-immich-state": "Stable"
} }
}, },
@@ -1366,6 +1368,7 @@
"state": "Stable" "state": "Stable"
} }
], ],
"x-immich-permission": "notification.create",
"x-immich-state": "Stable" "x-immich-state": "Stable"
} }
}, },
@@ -23130,6 +23133,7 @@
"description": "Image format", "description": "Image format",
"enum": [ "enum": [
"jpeg", "jpeg",
"jxl",
"webp" "webp"
], ],
"type": "string" "type": "string"
+1 -1
View File
@@ -2,6 +2,6 @@
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json", "$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2, "spaces": 2,
"generator-cli": { "generator-cli": {
"version": "7.25.0" "version": "7.26.0"
} }
} }
+1 -1
View File
@@ -8,7 +8,7 @@
"format": "prettier --cache --check i18n/", "format": "prettier --cache --check i18n/",
"format:fix": "prettier --cache --write --list-different i18n" "format:fix": "prettier --cache --write --list-different i18n"
}, },
"packageManager": "pnpm@12.14.0", "packageManager": "pnpm@12.15.0",
"engines": { "engines": {
"pnpm": ">=10.0.0" "pnpm": ">=10.0.0"
}, },
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24.22.1-trixie-slim@sha256:fdc9220097e0ae3bd947c44b10cd3af5867a67a6d3a23b30b30b70929219034e AS core FROM node:24.22.1-trixie-slim@sha256:43b9ba1142b8dcf33fa8f922ca1be85714adc444e0586c7cf7726daaeabc1bea AS core
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY package* pnpm* .pnpmfile.cjs ./ COPY package* pnpm* .pnpmfile.cjs ./
+1 -1
View File
@@ -62,7 +62,7 @@
"node": ">=22.0.0" "node": ">=22.0.0"
}, },
"dependencies": { "dependencies": {
"chokidar": "^5.0.0", "chokidar": "^6.0.0",
"fast-glob": "^3.3.2", "fast-glob": "^3.3.2",
"fastq": "^1.17.1", "fastq": "^1.17.1",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24.22.1-trixie-slim@sha256:fdc9220097e0ae3bd947c44b10cd3af5867a67a6d3a23b30b30b70929219034e FROM node:24.22.1-trixie-slim@sha256:43b9ba1142b8dcf33fa8f922ca1be85714adc444e0586c7cf7726daaeabc1bea
WORKDIR /usr/src/app WORKDIR /usr/src/app
RUN corepack enable RUN corepack enable
+1 -1
View File
@@ -13,5 +13,5 @@
"oidc-provider": "^9.0.0", "oidc-provider": "^9.0.0",
"tsx": "^4.20.6" "tsx": "^4.20.6"
}, },
"packageManager": "pnpm@12.14.0" "packageManager": "pnpm@12.15.0"
} }
+2 -2
View File
@@ -27,13 +27,13 @@
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"packageManager": "pnpm@12.14.0", "packageManager": "pnpm@12.15.0",
"devDependencies": { "devDependencies": {
"@extism/js-pdk": "^1.1.1", "@extism/js-pdk": "^1.1.1",
"@immich/sdk": "workspace:*", "@immich/sdk": "workspace:*",
"@types/node": "^24.19.2", "@types/node": "^24.19.2",
"esbuild": "^0.28.0", "esbuild": "^0.28.0",
"tsc-alias": "1.9.5", "tsc-alias": "1.9.9",
"typescript": "^7.0.0" "typescript": "^7.0.0"
}, },
"peerDependencies": { "peerDependencies": {
+1 -1
View File
@@ -33,5 +33,5 @@
"vite": "^8.0.16", "vite": "^8.0.16",
"vitest": "^5.0.0" "vitest": "^5.0.0"
}, },
"packageManager": "pnpm@12.14.0" "packageManager": "pnpm@12.15.0"
} }
+1
View File
@@ -8214,6 +8214,7 @@ export enum Colorspace {
} }
export enum ImageFormat { export enum ImageFormat {
Jpeg = "jpeg", Jpeg = "jpeg",
Jxl = "jxl",
Webp = "webp" Webp = "webp"
} }
export enum LogLevel { export enum LogLevel {
+444 -365
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -32,7 +32,7 @@ overrides:
canvas: 3.2.3 canvas: 3.2.3
sharp: ^0.35.3 sharp: ^0.35.3
# 1.9.3-1.9.5 are confirmed broken # 1.9.3-1.9.5 are confirmed broken
tsc-alias: 1.9.2 tsc-alias: 1.9.9
packageExtensions: packageExtensions:
'@koddsson/eslint-plugin-tscompat': '@koddsson/eslint-plugin-tscompat':
dependencies: dependencies:
+1
View File
@@ -33,6 +33,7 @@
<a href="README_uk_UA.md">Українська</a> <a href="README_uk_UA.md">Українська</a>
<a href="README_ru_RU.md">Русский</a> <a href="README_ru_RU.md">Русский</a>
<a href="README_bg_BG.md">Български</a> <a href="README_bg_BG.md">Български</a>
<a href="README_pl_PL.md">Polski</a>
<a href="README_pt_BR.md">Português Brasileiro</a> <a href="README_pt_BR.md">Português Brasileiro</a>
<a href="README_sv_SE.md">Svenska</a> <a href="README_sv_SE.md">Svenska</a>
<a href="README_th_TH.md">ภาษาไทย</a> <a href="README_th_TH.md">ภาษาไทย</a>
+1
View File
@@ -32,6 +32,7 @@
<a href="README_zh_TW.md">正體中文</a> <a href="README_zh_TW.md">正體中文</a>
<a href="README_uk_UA.md">Українська</a> <a href="README_uk_UA.md">Українська</a>
<a href="README_ru_RU.md">Русский</a> <a href="README_ru_RU.md">Русский</a>
<a href="README_pl_PL.md">Polski</a>
<a href="README_pt_BR.md">Português Brasileiro</a> <a href="README_pt_BR.md">Português Brasileiro</a>
<a href="README_sv_SE.md">Svenska</a> <a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a> <a href="README_ar_JO.md">العربية</a>
+85 -48
View File
@@ -1,22 +1,23 @@
<p align="center"> <p align="center">
<br/> <br/>
<a href="https://opensource.org/license/agpl-v3"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg?color=3F51B5&style=for-the-badge&label=Llicència&logoColor=000000&labelColor=ececec" alt="Llicència: MIT"></a> <a href="https://opensource.org/license/agpl-v3"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg?color=3F51B5&style=for-the-badge&label=Llic%C3%A8ncia&logoColor=000000&labelColor=ececec" alt="Llicència: AGPLv3"></a>
<a href="https://discord.immich.app"> <a href="https://discord.immich.app">
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" atl="Discord"/> <img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" alt="Discord"/>
</a> </a>
<br/> <br/>
<br/> <br/>
</p> </p>
<p align="center"> <p align="center">
<img src="../design/immich-logo-stacked-light.svg" width="300" title="Iniciar sessió amb URL personalitzada"> <img src="../design/immich-logo-stacked-light.svg" width="300" title="Inicia la sessió amb un URL personalitzat">
</p> </p>
<h3 align="center">Immich - Solució de còpia de seguretat d'alta rendiment per a fotos i vídeos auto-allotjada</h3> <h3 align="center">Solució de gestió de fotos i vídeos autoallotjada i d’alt rendiment </h3>
<br/> <br/>
<a href="https://immich.app"> <a href="https://immich.app">
<img src="../design/immich-screenshots.png" title="Captura de pantalla principal"> <img src="../design/immich-screenshots.png" title="Captura de pantalla principal">
</a> </a>
<br/> <br/>
<p align="center"> <p align="center">
<a href="../README.md">English</a> <a href="../README.md">English</a>
<a href="README_es_ES.md">Español</a> <a href="README_es_ES.md">Español</a>
@@ -32,67 +33,103 @@
<a href="README_uk_UA.md">Українська</a> <a href="README_uk_UA.md">Українська</a>
<a href="README_ru_RU.md">Русский</a> <a href="README_ru_RU.md">Русский</a>
<a href="README_bg_BG.md">Български</a> <a href="README_bg_BG.md">Български</a>
<a href="README_pl_PL.md">Polski</a>
<a href="README_pt_BR.md">Português Brasileiro</a> <a href="README_pt_BR.md">Português Brasileiro</a>
<a href="README_sv_SE.md">Svenska</a> <a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a> <a href="README_ar_JO.md">العربية</a>
<a href="README_vi_VN.md">Tiếng Việt</a>
<a href="README_th_TH.md">ภาษาไทย</a> <a href="README_th_TH.md">ภาษาไทย</a>
<a href="README_ml_IN.md">മലയാളം</a> <a href="README_ml_IN.md">മലയാളം</a>
</p> </p>
> [!WARNING] > [!WARNING]
> ⚠️ Always follow [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) backup plan for your precious photos and videos! > ⚠️ Seguiu sempre un pla de còpies de seguretat [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) per a les vostres valuoses fotos i vídeos!
> >
> [!NOTE] > [!NOTE]
> Podeu trobar la documentació principal, incloent les guies d'instal·lació, a https://immich.app/. > Podeu trobar la documentació principal, incloent-hi les guies d’instal·lació, a https://immich.app/.
## Contingut ## Enllaços
- [Documentació](https://docs.immich.app/) - [Documentació](https://docs.immich.app/)
- [Introducció](https://docs.immich.app/overview/introduction) - [Quant a](https://docs.immich.app/overview/introduction)
- [Instal·lació](https://docs.immich.app/install/requirements) - [Instal·lació](https://docs.immich.app/install/requirements)
- [Mapa de ruta](https://immich.app/roadmap) - [Full de ruta](https://immich.app/roadmap)
- [Demo](#demo) - [Demostració](#demostració)
- [Funcionalitats](#funcionalitats) - [Funcionalitats](#funcionalitats)
- [Traduccions](https://docs.immich.app/developer/translations) - [Traduccions](https://docs.immich.app/developer/translations)
- [Directrius de contribució](https://docs.immich.app/overview/support-the-project) - [Contribucions](https://docs.immich.app/overview/support-the-project)
## Demo ## Demostració
Podeu accedir a la demostració web a https://demo.immich.app. Per a l'aplicació mòbil, podeu utilitzar `https://demo.immich.app` com a "URL de punt final del servidor". Accediu a la demostració [aquí](https://demo.immich.app). Per a l’aplicació mòbil, podeu utilitzar `https://demo.immich.app` com a `URL de l’endpoint del servidor`.
```bash title="Credencials de la demo" ### Credencials d’inici de sessió
Les credencials
email: demo@immich.app | Adreça electrònica | Contrasenya |
contrasenya: demo | ------------------ | ----------- |
``` | demo@immich.app | demo |
# Funcionalitats # Funcionalitats
| Característiques | Mòbil | Web | | Funcionalitats | Mòbil | Web |
| -------------------------------------------- | ------ | --- | | --------------------------------------------------------------- | ----- | --- |
| Pujar i veure vídeos i fotos | Sí | Sí | | Pujada i visualització de vídeos i fotos | Sí | Sí |
| Còpia de seguretat automàtica en obrir l'aplicació | Sí | N/A | | Còpia al servidor automàtica en obrir l’aplicació | Sí | N/A |
| Selecció d'àlbums per a la còpia de seguretat | Sí | N/A | | Sistema per a evitar la duplicació de recursos | Sí | Sí |
| Descarregar fotos i vídeos a l'aparell local | Sí | Sí | | Selecció d’àlbums per a la còpia al servidor | Sí | N/A |
| Suport per a múltiples usuaris | Sí | Sí | | Baixada de fotos i vídeos al dispositiu local | Sí | Sí |
| Àlbums i àlbums compartits | Sí | Sí | | Suport per a múltiples usuaris | Sí | Sí |
| Barra de desplaçament amb funció de rasclet/arrossegament | Sí | Sí | | Àlbums i àlbums compartits | Sí | Sí |
| Suport per a formats raw | Sí | Sí | | Barra de desplaçament amb arrossegament interactiu | Sí | Sí |
| Visualització de metadades (EXIF, mapa) | Sí | Sí | | Suport per a formats RAW | Sí | Sí |
| Cerca per metadades, objectes, cares i CLIP | Sí | Sí | | Visualització de metadades (EXIF, mapa) | Sí | Sí |
| Funcions administratives (gestió d'usuaris) | No | Sí | | Cerca per metadades, objectes, cares i CLIP | Sí | Sí |
| Còpia de seguretat en segon pla | Sí | N/A | | Funcions administratives (gestió d’usuaris) | No | Sí |
| Desplaçament virtual | Sí | Sí | | Còpia al servidor en segon pla | Sí | N/A |
| Suport per a OAuth | Sí | Sí | | Desplaçament virtual | Sí | Sí |
| Claus d'API | N/A | Sí | | Suport per a OAuth | Sí | Sí |
| Còpia de seguretat i reproducció de LivePhoto | iOS | Sí | | Claus de l’API | N/A | Sí |
| Estructura d'emmagatzematge definida per l'usuari | Sí | Sí | | Còpia al servidor i reproducció de Live Photo/fotos en moviment | Sí | Sí |
| Compartició pública | No | Sí | | Suport per a la visualització d’imatges en 360 graus | No | Sí |
| Arxiu i preferits | Sí | Sí | | Estructura d’emmagatzematge definida per l’usuari | Sí | Sí |
| Mapa global | No | Sí | | Compartició pública | Sí | Sí |
| Compartició amb associats | Sí | Sí | | Arxiu i preferits | Sí | Sí |
| Reconeixement facial i agrupament | Sí | Sí | | Mapa global | Sí | Sí |
| Records (fa x anys) | Sí | Sí | | Compartició amb companys | Sí | Sí |
| Suport fora de línia | Sí | No | | Reconeixement facial i agrupament | Sí | Sí |
| Galeria de només lectura | Sí | Sí | | Records (fa x anys) | Sí | Sí |
| Suport per a mode fora de línia | Sí | No |
| Galeria de només lectura | Sí | Sí |
| Fotos apilades | Sí | Sí |
| Etiquetes | No | Sí |
| Visualització de carpetes | Sí | Sí |
## Traduccions
Obtingueu més informació sobre les traduccions [aquí](https://docs.immich.app/developer/translations).
<a href="https://hosted.weblate.org/engage/immich/">
<img src="https://hosted.weblate.org/widget/immich/immich/multi-auto.svg" alt="Estat de les traduccions" />
</a>
## Activitat al repositori
![Activitats](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Imatge d’analítiques de Repobeats")
## Historial d’estrelles
<a href="https://star-history.com/#immich-app/immich&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=immich-app/immich&type=date" />
<img alt="Gràfic de l’historial d’estrelles" src="https://api.star-history.com/svg?repos=immich-app/immich&type=date" width="100%" />
</picture>
</a>
## Contribuïdors
<a href="https://github.com/immich-app/immich/graphs/contributors">
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
</a>
+1
View File
@@ -32,6 +32,7 @@
<a href="README_uk_UA.md">Українська</a> <a href="README_uk_UA.md">Українська</a>
<a href="README_ru_RU.md">Русский</a> <a href="README_ru_RU.md">Русский</a>
<a href="README_bg_BG.md">Български</a> <a href="README_bg_BG.md">Български</a>
<a href="README_pl_PL.md">Polski</a>
<a href="README_pt_BR.md">Português Brasileiro</a> <a href="README_pt_BR.md">Português Brasileiro</a>
<a href="README_sv_SE.md">Svenska</a> <a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a> <a href="README_ar_JO.md">العربية</a>
+1
View File
@@ -32,6 +32,7 @@
<a href="README_uk_UA.md">Українська</a> <a href="README_uk_UA.md">Українська</a>
<a href="README_ru_RU.md">Русский</a> <a href="README_ru_RU.md">Русский</a>
<a href="README_bg_BG.md">Български</a> <a href="README_bg_BG.md">Български</a>
<a href="README_pl_PL.md">Polski</a>
<a href="README_pt_BR.md">Português Brasileiro</a> <a href="README_pt_BR.md">Português Brasileiro</a>
<a href="README_sv_SE.md">Svenska</a> <a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a> <a href="README_ar_JO.md">العربية</a>
+1
View File
@@ -32,6 +32,7 @@
<a href="README_uk_UA.md">Українська</a> <a href="README_uk_UA.md">Українська</a>
<a href="README_ru_RU.md">Русский</a> <a href="README_ru_RU.md">Русский</a>
<a href="README_bg_BG.md">Български</a> <a href="README_bg_BG.md">Български</a>
<a href="README_pl_PL.md">Polski</a>
<a href="README_pt_BR.md">Português Brasileiro</a> <a href="README_pt_BR.md">Português Brasileiro</a>
<a href="README_sv_SE.md">Svenska</a> <a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a> <a href="README_ar_JO.md">العربية</a>
+1
View File
@@ -33,6 +33,7 @@
<a href="README_uk_UA.md">Українська</a> <a href="README_uk_UA.md">Українська</a>
<a href="README_ru_RU.md">Русский</a> <a href="README_ru_RU.md">Русский</a>
<a href="README_bg_BG.md">Български</a> <a href="README_bg_BG.md">Български</a>
<a href="README_pl_PL.md">Polski</a>
<a href="README_pt_BR.md">Português Brasileiro</a> <a href="README_pt_BR.md">Português Brasileiro</a>
<a href="README_sv_SE.md">Svenska</a> <a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a> <a href="README_ar_JO.md">العربية</a>
+1
View File
@@ -31,6 +31,7 @@
<a href="README_zh_TW.md">正體中文</a> <a href="README_zh_TW.md">正體中文</a>
<a href="README_ru_RU.md">Русский</a> <a href="README_ru_RU.md">Русский</a>
<a href="README_bg_BG.md">Български</a> <a href="README_bg_BG.md">Български</a>
<a href="README_pl_PL.md">Polski</a>
<a href="README_pt_BR.md">Português Brasileiro</a> <a href="README_pt_BR.md">Português Brasileiro</a>
<a href="README_sv_SE.md">Svenska</a> <a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a> <a href="README_ar_JO.md">العربية</a>
+1
View File
@@ -33,6 +33,7 @@
<a href="README_uk_UA.md">Українська</a> <a href="README_uk_UA.md">Українська</a>
<a href="README_ru_RU.md">Русский</a> <a href="README_ru_RU.md">Русский</a>
<a href="README_bg_BG.md">Български</a> <a href="README_bg_BG.md">Български</a>
<a href="README_pl_PL.md">Polski</a>
<a href="README_pt_BR.md">Português Brasileiro</a> <a href="README_pt_BR.md">Português Brasileiro</a>
<a href="README_sv_SE.md">Svenska</a> <a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a> <a href="README_ar_JO.md">العربية</a>
+1
View File
@@ -33,6 +33,7 @@
<a href="README_zh_TW.md">正體中文</a> <a href="README_zh_TW.md">正體中文</a>
<a href="README_uk_UA.md">Українська</a> <a href="README_uk_UA.md">Українська</a>
<a href="README_ru_RU.md">Русский</a> <a href="README_ru_RU.md">Русский</a>
<a href="README_pl_PL.md">Polski</a>
<a href="README_pt_BR.md">Português Brasileiro</a> <a href="README_pt_BR.md">Português Brasileiro</a>
<a href="README_sv_SE.md">Svenska</a> <a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a> <a href="README_ar_JO.md">العربية</a>
+1
View File
@@ -32,6 +32,7 @@
<a href="README_uk_UA.md">Українська</a> <a href="README_uk_UA.md">Українська</a>
<a href="README_ru_RU.md">Русский</a> <a href="README_ru_RU.md">Русский</a>
<a href="README_bg_BG.md">Български</a> <a href="README_bg_BG.md">Български</a>
<a href="README_pl_PL.md">Polski</a>
<a href="README_pt_BR.md">Português Brasileiro</a> <a href="README_pt_BR.md">Português Brasileiro</a>
<a href="README_sv_SE.md">Svenska</a> <a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a> <a href="README_ar_JO.md">العربية</a>
+127
View File
@@ -0,0 +1,127 @@
<p align="center">
<br/>
<a href="https://opensource.org/license/agpl-v3"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg?color=3F51B5&style=for-the-badge&label=License&logoColor=000000&labelColor=ececec" alt="Licencja: AGPLv3"></a>
<a href="https://discord.immich.app">
<img src="https://img.shields.io/discord/979116623879368755.svg?label=Discord&logo=Discord&style=for-the-badge&logoColor=000000&labelColor=ececec" alt="Discord"/>
</a>
<br/>
<br/>
</p>
<p align="center">
<img src="/design/immich-logo-stacked-light.svg" width="300" title="Login With Custom URL">
</p>
<h3 align="center">Hostowane lokalnie, wydajne rozwiązanie do zarządzania zdjęciami i filmami</h3>
<br/>
<a href="https://immich.app">
<img src="/design/immich-screenshots.png" title="Główny zrzut ekranu">
</a>
<br/>
<p align="center">
<a href="../README.md">English</a>
<a href="README_ca_ES.md">Català</a>
<a href="README_es_ES.md">Español</a>
<a href="README_fr_FR.md">Français</a>
<a href="README_it_IT.md">Italiano</a>
<a href="README_ja_JP.md">日本語</a>
<a href="README_ko_KR.md">한국어</a>
<a href="README_de_DE.md">Deutsch</a>
<a href="README_nl_NL.md">Nederlands</a>
<a href="README_tr_TR.md">Türkçe</a>
<a href="README_zh_CN.md">简体中文</a>
<a href="README_zh_TW.md">正體中文</a>
<a href="README_uk_UA.md">Українська</a>
<a href="README_ru_RU.md">Русский</a>
<a href="README_bg_BG.md">Български</a>
<a href="README_pl_PL.md">Polski</a>
<a href="README_pt_BR.md">Português Brasileiro</a>
<a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a>
<a href="README_vi_VN.md">Tiếng Việt</a>
<a href="README_th_TH.md">ภาษาไทย</a>
<a href="README_ml_IN.md">മലയാളം</a>
</p>
> [!WARNING]
> ⚠️ Zawsze stosuj strategię kopii zapasowych [3-2-1](https://www.backblaze.com/blog/the-3-2-1-backup-strategy/) dla ochrony swoich multimediów!
>
> [!NOTE]
> Dokumentację oraz instrukcję instalacji znajdziesz na https://immich.app/.
## Linki
- [Dokumentacja](https://docs.immich.app/)
- [O projekcie](https://docs.immich.app/overview/introduction)
- [Instalacja](https://docs.immich.app/install/requirements)
- [Plan rozwoju](https://immich.app/roadmap)
- [Demo](#demo)
- [Funkcje](#funkcje)
- [Tłumaczenia](https://docs.immich.app/developer/translations)
- [Współtworzenie projektu](https://docs.immich.app/overview/support-the-project)
## Demo
Wersję demo znajdziesz [tutaj](https://demo.immich.app). W aplikacji mobilnej w polu z adresem serwera (`Punkt końcowy serwera`) użyj `https://demo.immich.app`.
### Dane logowania
| E-mail | Hasło |
| --------------- | ----- |
| demo@immich.app | demo |
## Funkcje
| Funkcje | Aplikacja mobilna | Web |
| :--------------------------------------------------------- | ----------------- | --- |
| Przesyłanie i przeglądanie zdjęć i filmów | Tak | Tak |
| Automatyczna kopia zapasowa po uruchomieniu aplikacji | Tak | N/D |
| Zapobieganie duplikowaniu plików | Tak | Tak |
| Wybór albumów do kopii zapasowej | Tak | N/D |
| Pobieranie zdjęć i filmów na urządzenie | Tak | Tak |
| Obsługa wielu użytkowników | Tak | Tak |
| Albumy i Albumy udostępnione | Tak | Tak |
| Pasek przewijania z możliwością przeciągania | Tak | Tak |
| Obsługa formatów RAW | Tak | Tak |
| Podgląd metadanych (EXIF, mapa) | Tak | Tak |
| Wyszukiwanie według metadanych, obiektów, twarzy i CLIP | Tak | Tak |
| Funkcje administracyjne (zarządzanie użytkownikami) | Nie | Tak |
| Kopia zapasowa w tle | Tak | N/D |
| Wirtualne przewijanie | Tak | Tak |
| Obsługa OAuth | Tak | Tak |
| Klucze API | N/D | Tak |
| Kopia zapasowa i odtwarzanie Live Photo / Motion Photo | Tak | Tak |
| Wyświetlanie obrazów 360° | Nie | Tak |
| Własna struktura przechowywania plików | Tak | Tak |
| Udostępnianie publiczne | Tak | Tak |
| Archiwum i Ulubione | Tak | Tak |
| Mapa globalna | Tak | Tak |
| Udostępnianie partnerowi | Tak | Tak |
| Rozpoznawanie i grupowanie twarzy | Tak | Tak |
| Wspomnienia (x lat temu) | Tak | Tak |
| Obsługa trybu offline | Tak | Nie |
| Galeria tylko do odczytu | Tak | Tak |
| Grupowanie zdjęć w stosy | Tak | Tak |
| Tagi | Nie | Tak |
| Widok folderów | Tak | Tak |
## Tłumaczenia
Więcej o tłumaczeniach przeczytasz [tutaj](https://docs.immich.app/developer/translations).
<a href="https://hosted.weblate.org/engage/immich/">
<img src="https://hosted.weblate.org/widget/immich/immich/multi-auto.svg" alt="Status tłumaczeń" />
</a>
## Aktywność w repozytorium
![Aktywność](https://repobeats.axiom.co/api/embed/9e86d9dc3ddd137161f2f6d2e758d7863b1789cb.svg "Wykres aktywności Repobeats")
## Współtwórcy
<a href="https://github.com/immich-app/immich/graphs/contributors">
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
</a>
+1
View File
@@ -34,6 +34,7 @@
<a href="README_uk_UA.md">Українська</a> <a href="README_uk_UA.md">Українська</a>
<a href="README_ru_RU.md">Русский</a> <a href="README_ru_RU.md">Русский</a>
<a href="README_bg_BG.md">Български</a> <a href="README_bg_BG.md">Български</a>
<a href="README_pl_PL.md">Polski</a>
<a href="README_sv_SE.md">Svenska</a> <a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a> <a href="README_ar_JO.md">العربية</a>
<a href="README_vi_VN.md">Tiếng Việt</a> <a href="README_vi_VN.md">Tiếng Việt</a>
+1
View File
@@ -32,6 +32,7 @@
<a href="README_zh_TW.md">正體中文</a> <a href="README_zh_TW.md">正體中文</a>
<a href="README_uk_UA.md">Українська</a> <a href="README_uk_UA.md">Українська</a>
<a href="README_bg_BG.md">Български</a> <a href="README_bg_BG.md">Български</a>
<a href="README_pl_PL.md">Polski</a>
<a href="README_pt_BR.md">Português Brasileiro</a> <a href="README_pt_BR.md">Português Brasileiro</a>
<a href="README_sv_SE.md">Svenska</a> <a href="README_sv_SE.md">Svenska</a>
<br> <br>
+1
View File
@@ -34,6 +34,7 @@
<a href="README_uk_UA.md">Українська</a> <a href="README_uk_UA.md">Українська</a>
<a href="README_ru_RU.md">Русский</a> <a href="README_ru_RU.md">Русский</a>
<a href="README_bg_BG.md">Български</a> <a href="README_bg_BG.md">Български</a>
<a href="README_pl_PL.md">Polski</a>
<a href="README_pt_BR.md">Português Brasileiro</a> <a href="README_pt_BR.md">Português Brasileiro</a>
<a href="README_ar_JO.md">العربية</a> <a href="README_ar_JO.md">العربية</a>
<a href="README_th_TH.md">ภาษาไทย</a> <a href="README_th_TH.md">ภาษาไทย</a>
+1
View File
@@ -36,6 +36,7 @@
<a href="README_uk_UA.md">Українська</a> <a href="README_uk_UA.md">Українська</a>
<a href="README_ru_RU.md">Русский</a> <a href="README_ru_RU.md">Русский</a>
<a href="README_bg_BG.md">Български</a> <a href="README_bg_BG.md">Български</a>
<a href="README_pl_PL.md">Polski</a>
<a href="README_pt_BR.md">Português Brasileiro</a> <a href="README_pt_BR.md">Português Brasileiro</a>
<a href="README_sv_SE.md">Svenska</a> <a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a> <a href="README_ar_JO.md">العربية</a>
+1
View File
@@ -32,6 +32,7 @@
<a href="README_uk_UA.md">Українська</a> <a href="README_uk_UA.md">Українська</a>
<a href="README_ru_RU.md">Русский</a> <a href="README_ru_RU.md">Русский</a>
<a href="README_bg_BG.md">Български</a> <a href="README_bg_BG.md">Български</a>
<a href="README_pl_PL.md">Polski</a>
<a href="README_pt_BR.md">Português Brasileiro</a> <a href="README_pt_BR.md">Português Brasileiro</a>
<a href="README_sv_SE.md">Svenska</a> <a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a> <a href="README_ar_JO.md">العربية</a>
+1
View File
@@ -33,6 +33,7 @@
<a href="README_zh_TW.md">正體中文</a> <a href="README_zh_TW.md">正體中文</a>
<a href="README_ru_RU.md">Русский</a> <a href="README_ru_RU.md">Русский</a>
<a href="README_bg_BG.md">Български</a> <a href="README_bg_BG.md">Български</a>
<a href="README_pl_PL.md">Polski</a>
<a href="README_pt_BR.md">Português Brasileiro</a> <a href="README_pt_BR.md">Português Brasileiro</a>
<a href="README_sv_SE.md">Svenska</a> <a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a> <a href="README_ar_JO.md">العربية</a>
+1
View File
@@ -34,6 +34,7 @@
<a href="README_uk_UA.md">Українська</a> <a href="README_uk_UA.md">Українська</a>
<a href="README_ru_RU.md">Русский</a> <a href="README_ru_RU.md">Русский</a>
<a href="README_bg_BG.md">Български</a> <a href="README_bg_BG.md">Български</a>
<a href="README_pl_PL.md">Polski</a>
<a href="README_pt_BR.md">Português Brasileiro</a> <a href="README_pt_BR.md">Português Brasileiro</a>
<a href="README_sv_SE.md">Svenska</a> <a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a> <a href="README_ar_JO.md">العربية</a>
+1
View File
@@ -33,6 +33,7 @@
<a href="README_uk_UA.md">Українська</a> <a href="README_uk_UA.md">Українська</a>
<a href="README_ru_RU.md">Русский</a> <a href="README_ru_RU.md">Русский</a>
<a href="README_bg_BG.md">Български</a> <a href="README_bg_BG.md">Български</a>
<a href="README_pl_PL.md">Polski</a>
<a href="README_pt_BR.md">Português Brasileiro</a> <a href="README_pt_BR.md">Português Brasileiro</a>
<a href="README_sv_SE.md">Svenska</a> <a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a> <a href="README_ar_JO.md">العربية</a>
+1
View File
@@ -34,6 +34,7 @@
<a href="README_uk_UA.md">Українська</a> <a href="README_uk_UA.md">Українська</a>
<a href="README_ru_RU.md">Русский</a> <a href="README_ru_RU.md">Русский</a>
<a href="README_bg_BG.md">Български</a> <a href="README_bg_BG.md">Български</a>
<a href="README_pl_PL.md">Polski</a>
<a href="README_pt_BR.md">Português Brasileiro</a> <a href="README_pt_BR.md">Português Brasileiro</a>
<a href="README_sv_SE.md">Svenska</a> <a href="README_sv_SE.md">Svenska</a>
<a href="README_ar_JO.md">العربية</a> <a href="README_ar_JO.md">العربية</a>
+1 -1
View File
@@ -68,7 +68,7 @@ FROM plugin-sdk AS plugins
ARG TARGETPLATFORM ARG TARGETPLATFORM
COPY --from=ghcr.io/jdx/mise:2026.10.15@sha256:0593528c637b0dae8a0acca66b5d49266cf262a811f06530d7273d3cc002f3a0 /usr/local/bin/mise /usr/local/bin/mise COPY --from=ghcr.io/jdx/mise:2026.10.20@sha256:a3023a390ce9d79343741039d35a36aa41e4fe8198464bf8cc9ffd23ae081a24 /usr/local/bin/mise /usr/local/bin/mise
COPY ./mise.toml ./mise.toml COPY ./mise.toml ./mise.toml
COPY ./mise.lock ./mise.lock COPY ./mise.lock ./mise.lock
+1 -1
View File
@@ -2,7 +2,7 @@
FROM ghcr.io/immich-app/base-server-dev:202610211607@sha256:f99c605b44b291834cb680b80fd606f2b7c4bee5d8f9591bfdac51fdbd68c21d AS dev FROM ghcr.io/immich-app/base-server-dev:202610211607@sha256:f99c605b44b291834cb680b80fd606f2b7c4bee5d8f9591bfdac51fdbd68c21d AS dev
COPY --from=ghcr.io/jdx/mise:2026.10.15@sha256:0593528c637b0dae8a0acca66b5d49266cf262a811f06530d7273d3cc002f3a0 /usr/local/bin/mise /usr/local/bin/mise COPY --from=ghcr.io/jdx/mise:2026.10.20@sha256:a3023a390ce9d79343741039d35a36aa41e4fe8198464bf8cc9ffd23ae081a24 /usr/local/bin/mise /usr/local/bin/mise
RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \ RUN echo "devdir=/buildcache/node-gyp" >> /usr/local/etc/npmrc && \
echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \ echo "store-dir=/buildcache/pnpm-store" >> /usr/local/etc/npmrc && \
+3 -2
View File
@@ -2,8 +2,9 @@
"contentSecurityPolicy": { "contentSecurityPolicy": {
"directives": { "directives": {
"default-src": ["'self'"], "default-src": ["'self'"],
"script-src": ["'self'", "'wasm-unsafe-eval'", "'unsafe-inline'", "https://www.gstatic.com"], "script-src": ["'self'", "'wasm-unsafe-eval'", "https://www.gstatic.com"],
"style-src": ["'self'", "'unsafe-inline'"], "style-src": ["'self'"],
"style-src-attr": ["'unsafe-inline'"],
"img-src": ["'self'", "data:", "blob:"], "img-src": ["'self'", "data:", "blob:"],
"media-src": ["'self'", "data:", "blob:"], "media-src": ["'self'", "data:", "blob:"],
"connect-src": [ "connect-src": [
+5 -5
View File
@@ -51,25 +51,25 @@
"@nestjs/swagger": "^12.0.0", "@nestjs/swagger": "^12.0.0",
"@nestjs/websockets": "^12.0.0", "@nestjs/websockets": "^12.0.0",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "^2.0.0", "@opentelemetry/context-async-hooks": "^3.0.0",
"@opentelemetry/exporter-prometheus": "^0.223.0", "@opentelemetry/exporter-prometheus": "^0.223.0",
"@opentelemetry/instrumentation-http": "^0.223.0", "@opentelemetry/instrumentation-http": "^0.223.0",
"@opentelemetry/instrumentation-ioredis": "^0.71.0", "@opentelemetry/instrumentation-ioredis": "^0.71.0",
"@opentelemetry/instrumentation-nestjs-core": "^0.70.0", "@opentelemetry/instrumentation-nestjs-core": "^0.70.0",
"@opentelemetry/instrumentation-pg": "^0.76.0", "@opentelemetry/instrumentation-pg": "^0.76.0",
"@opentelemetry/resources": "^2.0.1", "@opentelemetry/resources": "^3.0.0",
"@opentelemetry/sdk-metrics": "^2.0.1", "@opentelemetry/sdk-metrics": "^3.0.0",
"@opentelemetry/sdk-node": "^0.223.0", "@opentelemetry/sdk-node": "^0.223.0",
"@opentelemetry/semantic-conventions": "^1.34.0", "@opentelemetry/semantic-conventions": "^1.34.0",
"@simplewebauthn/server": "14.0.3", "@simplewebauthn/server": "14.0.3",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"aaguids": "^0.0.71", "aaguids": "^0.0.72",
"archiver": "^8.0.0", "archiver": "^8.0.0",
"async-lock": "^1.4.0", "async-lock": "^1.4.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"body-parser": "^2.2.0", "body-parser": "^2.2.0",
"bullmq": "^6.0.0", "bullmq": "^6.0.0",
"chokidar": "^5.0.0", "chokidar": "^6.0.0",
"compression": "^1.8.0", "compression": "^1.8.0",
"cookie": "^2.0.0", "cookie": "^2.0.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
+3
View File
@@ -64,6 +64,9 @@ export const excludePaths = ['/.well-known/immich', '/.well-known/webauthn', '/c
export const FACE_THUMBNAIL_SIZE = 250; export const FACE_THUMBNAIL_SIZE = 250;
// The fastest JPEG XL tier skips smoothing filters, which also keeps small thumbnails sharper
export const THUMBNAIL_DECODING_TIER = 4;
type ModelInfo = { dimSize: number }; type ModelInfo = { dimSize: number };
export const CLIP_MODEL_INFO: Record<string, ModelInfo> = { export const CLIP_MODEL_INFO: Record<string, ModelInfo> = {
RN101__openai: { dimSize: 512 }, RN101__openai: { dimSize: 512 },
@@ -1,5 +1,6 @@
import request from 'supertest'; import request from 'supertest';
import { NotificationAdminController } from 'src/controllers/notification-admin.controller.js'; import { NotificationAdminController } from 'src/controllers/notification-admin.controller.js';
import { Permission } from 'src/enum.js';
import { NotificationAdminService } from 'src/services/notification-admin.service.js'; import { NotificationAdminService } from 'src/services/notification-admin.service.js';
import { factory } from 'test/small.factory.js'; import { factory } from 'test/small.factory.js';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils.js'; import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils.js';
@@ -27,5 +28,39 @@ describe(NotificationAdminController.name, () => {
.send({ title: 'Test', userId: factory.uuid(), readAt: null }); .send({ title: 'Test', userId: factory.uuid(), readAt: null });
expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ readAt: null })); expect(service.create).toHaveBeenCalledWith(undefined, expect.objectContaining({ readAt: null }));
}); });
it('should require notification create permission', async () => {
await request(ctx.getHttpServer()).post(`/admin/notifications`).send({ title: 'Test', userId: factory.uuid() });
expect(ctx.authenticate).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({ permission: Permission.NotificationCreate, adminRoute: true }),
}),
);
});
});
describe('POST /admin/notifications/test-email', () => {
it('should require notification create permission', async () => {
await request(ctx.getHttpServer()).post(`/admin/notifications/test-email`).send({});
expect(ctx.authenticate).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({ permission: Permission.NotificationCreate, adminRoute: true }),
}),
);
});
});
describe('POST /admin/notifications/templates/:name', () => {
it('should require notification create permission', async () => {
await request(ctx.getHttpServer()).post(`/admin/notifications/templates/welcome`).send({ template: '' });
expect(ctx.authenticate).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({ permission: Permission.NotificationCreate, adminRoute: true }),
}),
);
});
}); });
}); });
@@ -10,7 +10,7 @@ import {
TemplateResponseDto, TemplateResponseDto,
TestEmailResponseDto, TestEmailResponseDto,
} from 'src/dtos/notification.dto.js'; } from 'src/dtos/notification.dto.js';
import { ApiTag } from 'src/enum.js'; import { ApiTag, Permission } from 'src/enum.js';
import { Auth, Authenticated } from 'src/middleware/auth.guard.js'; import { Auth, Authenticated } from 'src/middleware/auth.guard.js';
import { EmailTemplate } from 'src/repositories/email.repository.js'; import { EmailTemplate } from 'src/repositories/email.repository.js';
import { NotificationAdminService } from 'src/services/notification-admin.service.js'; import { NotificationAdminService } from 'src/services/notification-admin.service.js';
@@ -21,7 +21,7 @@ export class NotificationAdminController {
constructor(private service: NotificationAdminService) {} constructor(private service: NotificationAdminService) {}
@Post() @Post()
@Authenticated({ admin: true }) @Authenticated({ permission: Permission.NotificationCreate, admin: true })
@Endpoint({ @Endpoint({
summary: 'Create a notification', summary: 'Create a notification',
description: 'Create a new notification for a specific user.', description: 'Create a new notification for a specific user.',
@@ -32,7 +32,7 @@ export class NotificationAdminController {
} }
@Post('test-email') @Post('test-email')
@Authenticated({ admin: true }) @Authenticated({ permission: Permission.NotificationCreate, admin: true })
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Endpoint({ @Endpoint({
summary: 'Send test email', summary: 'Send test email',
@@ -44,7 +44,7 @@ export class NotificationAdminController {
} }
@Post('templates/:name') @Post('templates/:name')
@Authenticated({ admin: true }) @Authenticated({ permission: Permission.NotificationCreate, admin: true })
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Endpoint({ @Endpoint({
summary: 'Render email template', summary: 'Render email template',
+1
View File
@@ -610,6 +610,7 @@ export const ColorspaceSchema = z.enum(Colorspace).describe('Colorspace').meta({
export enum ImageFormat { export enum ImageFormat {
Jpeg = 'jpeg', Jpeg = 'jpeg',
Jxl = 'jxl',
Webp = 'webp', Webp = 'webp',
} }
+9 -1
View File
@@ -818,6 +818,7 @@ export class AssetRepository {
}) })
getTimeBucket(timeBucket: string, options: TimeBucketOptions, auth: AuthDto) { getTimeBucket(timeBucket: string, options: TimeBucketOptions, auth: AuthDto) {
const order = options.order ?? 'desc'; const order = options.order ?? 'desc';
const bucket = timeBucket.replace(/^[+-]/, '');
const query = this.db const query = this.db
.with('cte', (qb) => .with('cte', (qb) =>
qb qb
@@ -869,7 +870,14 @@ export class AssetRepository {
return withBoundingBox(withBoundingCircle, bbox); return withBoundingBox(withBoundingCircle, bbox);
}) })
.where(truncatedDate(options.orderBy), '=', timeBucket.replace(/^[+-]/, '')) .$if(options.orderBy === AssetOrderBy.CreatedAt, (qb) =>
qb
.where('asset.createdAt', '>=', sql<Date>`${bucket}::timestamp AT TIME ZONE 'UTC'`)
.where('asset.createdAt', '<', sql<Date>`(${bucket}::timestamp + interval '1 month') AT TIME ZONE 'UTC'`),
)
.$if(options.orderBy !== AssetOrderBy.CreatedAt, (qb) =>
qb.where(truncatedDate(options.orderBy), '=', bucket),
)
.$if(!!options.albumId, (qb) => .$if(!!options.albumId, (qb) =>
qb.where((eb) => qb.where((eb) =>
eb.exists( eb.exists(
@@ -1,5 +1,5 @@
import { ImmichTelemetry } from 'src/enum.js'; import { ImmichTelemetry } from 'src/enum.js';
import { ConfigRepository, clearEnvCache } from 'src/repositories/config.repository.js'; import { ConfigRepository, applyCspHashes, clearEnvCache } from 'src/repositories/config.repository.js';
const getEnv = () => { const getEnv = () => {
clearEnvCache(); clearEnvCache();
@@ -325,3 +325,35 @@ describe('getEnv', () => {
}); });
}); });
}); });
describe('applyCspHashes', () => {
const manifest = { 'script-src': [`'sha256-script'`], 'style-src': [`'sha256-style'`] };
it('should append the hashes of the inline content', () => {
const directives = applyCspHashes({ 'script-src': [`'self'`], 'style-src': [`'self'`] }, manifest);
expect(directives).toEqual({
'script-src': [`'self'`, `'sha256-script'`],
'style-src': [`'self'`, `'sha256-style'`],
});
});
it('should skip a directive that allows unsafe-inline, which a hash would disable', () => {
const directives = applyCspHashes({ 'script-src': [`'self'`, `'unsafe-inline'`] }, manifest);
expect(directives['script-src']).toEqual([`'self'`, `'unsafe-inline'`]);
});
it('should skip a directive the policy does not declare', () => {
const directives = applyCspHashes({ 'script-src': [`'self'`] }, manifest);
expect(directives).not.toHaveProperty('style-src');
});
it('should not mutate the directives it is given', () => {
const directives = { 'script-src': [`'self'`] };
applyCspHashes(directives, manifest);
expect(directives).toEqual({ 'script-src': [`'self'`] });
});
});
+43 -4
View File
@@ -7,7 +7,7 @@ import { HelmetOptions } from 'helmet';
import { RedisOptions } from 'ioredis'; import { RedisOptions } from 'ioredis';
import { CLS_ID, ClsModuleOptions } from 'nestjs-cls'; import { CLS_ID, ClsModuleOptions } from 'nestjs-cls';
import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces/index.js'; import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces/index.js';
import { readFileSync } from 'node:fs'; import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import type { VectorExtension } from 'src/types.js'; import type { VectorExtension } from 'src/types.js';
import { IWorker, citiesFile } from 'src/constants.js'; import { IWorker, citiesFile } from 'src/constants.js';
@@ -152,7 +152,22 @@ const asSet = <T>(value: string | undefined, defaults: T[]) => {
return new Set(values.length === 0 ? defaults : (values as T[])); return new Set(values.length === 0 ? defaults : (values as T[]));
}; };
const resolveHelmetFile = (helmetFile: 'true' | 'false' | string | undefined) => { type CspOptions = Exclude<NonNullable<HelmetOptions['contentSecurityPolicy']>, boolean>;
type CspDirectives = NonNullable<CspOptions['directives']>;
export const applyCspHashes = (directives: CspDirectives, manifest: Record<string, string[]>): CspDirectives => {
const merged = { ...directives };
for (const [directive, hashes] of Object.entries(manifest)) {
const sources = merged[directive];
if (Array.isArray(sources) && !sources.includes(`'unsafe-inline'`)) {
merged[directive] = [...sources, ...hashes];
}
}
return merged;
};
const resolveHelmetFile = (helmetFile: 'true' | 'false' | string | undefined, webRoot: string) => {
// default is off // default is off
if (!helmetFile || helmetFile === 'false') { if (!helmetFile || helmetFile === 'false') {
return; return;
@@ -160,11 +175,35 @@ const resolveHelmetFile = (helmetFile: 'true' | 'false' | string | undefined) =>
helmetFile = helmetFile === 'true' ? join(import.meta.dirname, '..', '..', 'helmet.json') : helmetFile; helmetFile = helmetFile === 'true' ? join(import.meta.dirname, '..', '..', 'helmet.json') : helmetFile;
let config: HelmetOptions;
try { try {
return JSON.parse(readFileSync(helmetFile).toString()) as HelmetOptions; config = JSON.parse(readFileSync(helmetFile).toString()) as HelmetOptions;
} catch (error) { } catch (error) {
throw new Error(`Failed to read helmet file: ${helmetFile}`, { cause: error }); throw new Error(`Failed to read helmet file: ${helmetFile}`, { cause: error });
} }
const contentSecurityPolicy = config.contentSecurityPolicy;
if (!contentSecurityPolicy || typeof contentSecurityPolicy === 'boolean' || !contentSecurityPolicy.directives) {
return config;
}
// merge with csp build-time metadata
const manifestFile = join(webRoot, '.csp.json');
if (!existsSync(join(webRoot, 'index.html')) || !existsSync(manifestFile)) {
return config;
}
let manifest: Record<string, string[]>;
try {
manifest = JSON.parse(readFileSync(manifestFile).toString()) as Record<string, string[]>;
} catch (error) {
throw new Error(`Failed to read CSP manifest: ${manifestFile}`, { cause: error });
}
const directives = applyCspHashes(contentSecurityPolicy.directives, manifest);
return { ...config, contentSecurityPolicy: { ...contentSecurityPolicy, directives } };
}; };
const getEnv = (): EnvData => { const getEnv = (): EnvData => {
@@ -311,7 +350,7 @@ const getEnv = (): EnvData => {
}, },
helmet: { helmet: {
config: resolveHelmetFile(dto.IMMICH_HELMET_FILE), config: resolveHelmetFile(dto.IMMICH_HELMET_FILE, folders.web),
}, },
licensePublicKey: isProd ? productionKeys : stagingKeys, licensePublicKey: isProd ? productionKeys : stagingKeys,
@@ -331,7 +331,13 @@ describe(MediaRepository.name, () => {
const decoded = await sut.decodeImage(buffer, { colorspace: Colorspace.P3, processInvalidImages: false }); const decoded = await sut.decodeImage(buffer, { colorspace: Colorspace.P3, processInvalidImages: false });
await sut.generateThumbnail( await sut.generateThumbnail(
decoded, decoded,
{ colorspace: Colorspace.P3, quality: 80, format: ImageFormat.Webp, processInvalidImages: false }, {
colorspace: Colorspace.P3,
quality: 80,
format: ImageFormat.Webp,
processInvalidImages: false,
decodingTier: 0,
},
file, file,
); );
@@ -186,6 +186,7 @@ export class MediaRepository {
// this is default in libvips (except the threshold is 90), but we need to set it manually in sharp // this is default in libvips (except the threshold is 90), but we need to set it manually in sharp
chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0', chromaSubsampling: options.quality >= 80 ? '4:4:4' : '4:2:0',
progressive: options.progressive, progressive: options.progressive,
decodingTier: options.decodingTier,
}) })
.toFile(output); .toFile(output);
} }
@@ -422,6 +422,7 @@ describe(AssetMediaService.name, () => {
.owner(authStub.user1.user) .owner(authStub.user1.user)
.build(); .build();
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id }); const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([motionAsset.id]));
mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset)); mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset));
mocks.asset.create.mockResolvedValueOnce(asset); mocks.asset.create.mockResolvedValueOnce(asset);
@@ -439,6 +440,7 @@ describe(AssetMediaService.name, () => {
it('should hide the linked motion asset', async () => { it('should hide the linked motion asset', async () => {
const motionAsset = AssetFactory.from({ type: AssetType.Video }).owner(authStub.user1.user).build(); const motionAsset = AssetFactory.from({ type: AssetType.Video }).owner(authStub.user1.user).build();
const asset = AssetFactory.create(); const asset = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([motionAsset.id]));
mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset)); mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset));
mocks.asset.create.mockResolvedValueOnce(asset); mocks.asset.create.mockResolvedValueOnce(asset);
@@ -456,6 +458,63 @@ describe(AssetMediaService.name, () => {
}); });
}); });
it('should link a live photo video that is already in the shared link', async () => {
const motionAsset = AssetFactory.from({ type: AssetType.Video }).owner(authStub.user1.user).build();
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
const auth = AuthFactory.from(authStub.user1.user).sharedLink({ allowUpload: true }).build();
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([motionAsset.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([motionAsset.id]));
mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset));
mocks.asset.create.mockResolvedValueOnce(asset);
mocks.sharedLink.addAssets.mockResolvedValue([]);
await expect(
sut.uploadAsset(auth, { ...createDto, livePhotoVideoId: motionAsset.id }, fileStub.livePhotoStill),
).resolves.toEqual({
status: AssetMediaStatus.CREATED,
id: asset.id,
});
expect(mocks.access.asset.checkSharedLinkAccess).toHaveBeenCalledWith(
auth.sharedLink!.id,
new Set([motionAsset.id]),
);
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([motionAsset.id]), false);
expect(mocks.asset.getById).toHaveBeenCalledWith(motionAsset.id);
expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, visibility: AssetVisibility.Hidden });
expect(mocks.asset.create).toHaveBeenCalledWith(expect.objectContaining({ livePhotoVideoId: motionAsset.id }));
});
it('should not link a live photo video that is not in the shared link', async () => {
const motionAsset = AssetFactory.from({ type: AssetType.Video }).owner(authStub.user1.user).build();
const auth = AuthFactory.from(authStub.user1.user).sharedLink({ allowUpload: true }).build();
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set());
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([motionAsset.id]));
await expect(
sut.uploadAsset(auth, { ...createDto, livePhotoVideoId: motionAsset.id }, fileStub.livePhotoStill),
).rejects.toThrow('Not found or no asset.update permission');
expect(mocks.asset.getById).not.toHaveBeenCalled();
expect(mocks.asset.update).not.toHaveBeenCalled();
expect(mocks.asset.create).not.toHaveBeenCalled();
});
it('should not link a live photo video in the shared link that is owned by another user', async () => {
const motionAsset = AssetFactory.from({ type: AssetType.Video }).build();
const auth = AuthFactory.from(authStub.user1.user).sharedLink({ allowUpload: true }).build();
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([motionAsset.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set());
await expect(
sut.uploadAsset(auth, { ...createDto, livePhotoVideoId: motionAsset.id }, fileStub.livePhotoStill),
).rejects.toThrow('Not found or no asset.update permission');
expect(mocks.asset.getById).not.toHaveBeenCalled();
expect(mocks.asset.update).not.toHaveBeenCalled();
expect(mocks.asset.create).not.toHaveBeenCalled();
});
it('should handle a sidecar file', async () => { it('should handle a sidecar file', async () => {
const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).build(); const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).build();
mocks.asset.getById.mockResolvedValueOnce(getForAsset(asset)); mocks.asset.getById.mockResolvedValueOnce(getForAsset(asset));
@@ -142,6 +142,19 @@ export class AssetMediaService extends BaseService {
this.requireQuota(auth, file.size); this.requireQuota(auth, file.size);
if (dto.livePhotoVideoId) { if (dto.livePhotoVideoId) {
if (auth.sharedLink) {
const ids = new Set([dto.livePhotoVideoId]);
const [assetIds1, assetIds2] = await Promise.all([
this.accessRepository.asset.checkSharedLinkAccess(auth.sharedLink.id, ids),
this.accessRepository.asset.checkOwnerAccess(auth.user.id, ids, false),
]);
if (!assetIds1.intersection(assetIds2).has(dto.livePhotoVideoId)) {
throw new BadRequestException(`Not found or no ${Permission.AssetUpdate} permission`);
}
} else {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [dto.livePhotoVideoId] });
}
await onBeforeLink( await onBeforeLink(
{ asset: this.assetRepository, event: this.eventRepository }, { asset: this.assetRepository, event: this.eventRepository },
{ userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId }, { userId: auth.user.id, livePhotoVideoId: dto.livePhotoVideoId },
+7 -2
View File
@@ -262,7 +262,9 @@ describe(AssetService.name, () => {
const auth = AuthFactory.create(); const auth = AuthFactory.create();
const motionAsset = AssetFactory.from().owner(auth.user).build(); const motionAsset = AssetFactory.from().owner(auth.user).build();
const asset = AssetFactory.create(); const asset = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.access.asset.checkOwnerAccess
.mockResolvedValueOnce(new Set([asset.id]))
.mockResolvedValueOnce(new Set([motionAsset.id]));
mocks.asset.getById.mockResolvedValue(getForAsset(asset)); mocks.asset.getById.mockResolvedValue(getForAsset(asset));
await expect( await expect(
@@ -315,7 +317,10 @@ describe(AssetService.name, () => {
it('should link a live video', async () => { it('should link a live video', async () => {
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Timeline }); const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Timeline });
const stillAsset = AssetFactory.create(); const stillAsset = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([stillAsset.id])); mocks.access.asset.checkOwnerAccess
.mockResolvedValueOnce(new Set([stillAsset.id]))
.mockResolvedValueOnce(new Set([motionAsset.id]))
.mockResolvedValue(new Set([stillAsset.id]));
mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset)); mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset));
mocks.asset.getById.mockResolvedValueOnce(getForAsset(stillAsset)); mocks.asset.getById.mockResolvedValueOnce(getForAsset(stillAsset));
mocks.asset.update.mockResolvedValue(getForAsset(stillAsset)); mocks.asset.update.mockResolvedValue(getForAsset(stillAsset));
+1
View File
@@ -91,6 +91,7 @@ export class AssetService extends BaseService {
let previousMotion: { id: string } | null = null; let previousMotion: { id: string } | null = null;
if (rest.livePhotoVideoId) { if (rest.livePhotoVideoId) {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [rest.livePhotoVideoId] });
await onBeforeLink(repos, { userId: auth.user.id, livePhotoVideoId: rest.livePhotoVideoId }); await onBeforeLink(repos, { userId: auth.user.id, livePhotoVideoId: rest.livePhotoVideoId });
} else if (rest.livePhotoVideoId === null) { } else if (rest.livePhotoVideoId === null) {
const asset = await this.findOrFail(id); const asset = await this.findOrFail(id);
+63
View File
@@ -454,6 +454,7 @@ describe(MediaService.name, () => {
progressive: false, progressive: false,
processInvalidImages: false, processInvalidImages: false,
edits: [], edits: [],
decodingTier: 0,
}, },
expect.any(String), expect.any(String),
); );
@@ -467,6 +468,7 @@ describe(MediaService.name, () => {
progressive: false, progressive: false,
processInvalidImages: false, processInvalidImages: false,
edits: [], edits: [],
decodingTier: 4,
}, },
expect.any(String), expect.any(String),
); );
@@ -551,6 +553,30 @@ describe(MediaService.name, () => {
]); ]);
}); });
it('should generate WebP video thumbnails when JPEG XL is configured', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build();
mocks.systemMetadata.get.mockResolvedValue({
image: { preview: { format: ImageFormat.Jxl }, thumbnail: { format: ImageFormat.Jxl } },
});
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({
...getForGenerateThumbnail(asset),
...probeStub.videoStream2160p,
});
await sut.handleGenerateThumbnails({ id: asset.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
expect.stringMatching(/_preview\.webp$/),
expect.any(Object),
);
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
expect.stringMatching(/_thumbnail\.webp$/),
expect.any(Object),
);
});
it('should tonemap thumbnail for hdr video', async () => { it('should tonemap thumbnail for hdr video', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({
@@ -726,6 +752,7 @@ describe(MediaService.name, () => {
progressive: false, progressive: false,
processInvalidImages: false, processInvalidImages: false,
edits: [], edits: [],
decodingTier: 0,
}, },
previewPath, previewPath,
); );
@@ -739,6 +766,7 @@ describe(MediaService.name, () => {
progressive: false, progressive: false,
processInvalidImages: false, processInvalidImages: false,
edits: [], edits: [],
decodingTier: 4,
}, },
thumbnailPath, thumbnailPath,
); );
@@ -774,6 +802,7 @@ describe(MediaService.name, () => {
progressive: false, progressive: false,
processInvalidImages: false, processInvalidImages: false,
edits: [], edits: [],
decodingTier: 0,
}, },
previewPath, previewPath,
); );
@@ -787,6 +816,7 @@ describe(MediaService.name, () => {
progressive: false, progressive: false,
processInvalidImages: false, processInvalidImages: false,
edits: [], edits: [],
decodingTier: 4,
}, },
thumbnailPath, thumbnailPath,
); );
@@ -870,6 +900,24 @@ describe(MediaService.name, () => {
]); ]);
}); });
it('should never set isProgressive for JPEG XL', async () => {
const asset = AssetFactory.from().exif().build();
mocks.systemMetadata.get.mockResolvedValue({
image: {
preview: { format: ImageFormat.Jxl, progressive: true },
thumbnail: { format: ImageFormat.Jxl, progressive: true },
},
});
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
expect.objectContaining({ type: AssetFileType.Preview, isProgressive: false }),
expect.objectContaining({ type: AssetFileType.Thumbnail, isProgressive: false }),
]);
});
it('should never set isProgressive for videos', async () => { it('should never set isProgressive for videos', async () => {
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build(); const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build();
mocks.systemMetadata.get.mockResolvedValue({ mocks.systemMetadata.get.mockResolvedValue({
@@ -1079,6 +1127,7 @@ describe(MediaService.name, () => {
progressive: false, progressive: false,
processInvalidImages: false, processInvalidImages: false,
edits: [], edits: [],
decodingTier: 0,
}, },
expect.any(String), expect.any(String),
); );
@@ -1114,6 +1163,7 @@ describe(MediaService.name, () => {
progressive: false, progressive: false,
processInvalidImages: false, processInvalidImages: false,
edits: [], edits: [],
decodingTier: 0,
}, },
expect.any(String), expect.any(String),
); );
@@ -1127,6 +1177,7 @@ describe(MediaService.name, () => {
progressive: false, progressive: false,
processInvalidImages: false, processInvalidImages: false,
edits: [], edits: [],
decodingTier: 0,
}, },
expect.any(String), expect.any(String),
); );
@@ -1160,6 +1211,7 @@ describe(MediaService.name, () => {
progressive: false, progressive: false,
processInvalidImages: false, processInvalidImages: false,
edits: [], edits: [],
decodingTier: 0,
}, },
expect.any(String), expect.any(String),
); );
@@ -1173,6 +1225,7 @@ describe(MediaService.name, () => {
size: 1440, size: 1440,
processInvalidImages: false, processInvalidImages: false,
edits: [], edits: [],
decodingTier: 0,
}, },
expect.any(String), expect.any(String),
); );
@@ -1210,6 +1263,7 @@ describe(MediaService.name, () => {
progressive: false, progressive: false,
processInvalidImages: false, processInvalidImages: false,
edits: [], edits: [],
decodingTier: 0,
}, },
expect.any(String), expect.any(String),
); );
@@ -1274,6 +1328,7 @@ describe(MediaService.name, () => {
progressive: false, progressive: false,
processInvalidImages: false, processInvalidImages: false,
edits: [], edits: [],
decodingTier: 0,
}, },
expect.any(String), expect.any(String),
); );
@@ -1316,6 +1371,7 @@ describe(MediaService.name, () => {
progressive: false, progressive: false,
processInvalidImages: false, processInvalidImages: false,
edits: [], edits: [],
decodingTier: 0,
}, },
expect.any(String), expect.any(String),
); );
@@ -1577,6 +1633,7 @@ describe(MediaService.name, () => {
], ],
processInvalidImages: false, processInvalidImages: false,
size: 250, size: 250,
decodingTier: 4,
}, },
expect.any(String), expect.any(String),
); );
@@ -1630,6 +1687,7 @@ describe(MediaService.name, () => {
], ],
processInvalidImages: false, processInvalidImages: false,
size: 250, size: 250,
decodingTier: 4,
}, },
expect.any(String), expect.any(String),
); );
@@ -1678,6 +1736,7 @@ describe(MediaService.name, () => {
], ],
processInvalidImages: false, processInvalidImages: false,
size: 250, size: 250,
decodingTier: 4,
}, },
expect.any(String), expect.any(String),
); );
@@ -1722,6 +1781,7 @@ describe(MediaService.name, () => {
], ],
processInvalidImages: false, processInvalidImages: false,
size: 250, size: 250,
decodingTier: 4,
}, },
expect.any(String), expect.any(String),
); );
@@ -1766,6 +1826,7 @@ describe(MediaService.name, () => {
], ],
processInvalidImages: false, processInvalidImages: false,
size: 250, size: 250,
decodingTier: 4,
}, },
expect.any(String), expect.any(String),
); );
@@ -1810,6 +1871,7 @@ describe(MediaService.name, () => {
], ],
processInvalidImages: false, processInvalidImages: false,
size: 250, size: 250,
decodingTier: 4,
}, },
expect.any(String), expect.any(String),
); );
@@ -1859,6 +1921,7 @@ describe(MediaService.name, () => {
], ],
processInvalidImages: false, processInvalidImages: false,
size: 250, size: 250,
decodingTier: 4,
}, },
expect.any(String), expect.any(String),
); );
+18 -10
View File
@@ -10,7 +10,7 @@ import type {
VideoInterfaces, VideoInterfaces,
VideoStreamInfo, VideoStreamInfo,
} from 'src/types.js'; } from 'src/types.js';
import { FACE_THUMBNAIL_SIZE } from 'src/constants.js'; import { FACE_THUMBNAIL_SIZE, THUMBNAIL_DECODING_TIER } from 'src/constants.js';
import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core.js'; import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core.js';
import { AssetFile } from 'src/database.js'; import { AssetFile } from 'src/database.js';
import { OnEvent, OnJob } from 'src/decorators.js'; import { OnEvent, OnJob } from 'src/decorators.js';
@@ -303,14 +303,14 @@ export class MediaService extends BaseService {
fileType: AssetFileType.Preview, fileType: AssetFileType.Preview,
format: previewFormat, format: previewFormat,
isEdited: useEdits, isEdited: useEdits,
isProgressive: !!image.preview.progressive && previewFormat !== ImageFormat.Webp, isProgressive: !!image.preview.progressive && previewFormat === ImageFormat.Jpeg,
isTransparent, isTransparent,
}); });
const thumbnailFile = this.getImageFile(asset, { const thumbnailFile = this.getImageFile(asset, {
fileType: AssetFileType.Thumbnail, fileType: AssetFileType.Thumbnail,
format: thumbnailFormat, format: thumbnailFormat,
isEdited: useEdits, isEdited: useEdits,
isProgressive: !!image.thumbnail.progressive && thumbnailFormat !== ImageFormat.Webp, isProgressive: !!image.thumbnail.progressive && thumbnailFormat === ImageFormat.Jpeg,
isTransparent, isTransparent,
}); });
this.storageCore.ensureFolders(previewFile.path); this.storageCore.ensureFolders(previewFile.path);
@@ -318,8 +318,13 @@ export class MediaService extends BaseService {
// generate final images // generate final images
const decoded = { data, info }; const decoded = { data, info };
const baseOptions = { colorspace, processInvalidImages: false, edits: useEdits ? asset.edits : [] }; const baseOptions = { colorspace, processInvalidImages: false, edits: useEdits ? asset.edits : [] };
const thumbnailOptions = { ...image.thumbnail, ...baseOptions, format: thumbnailFormat }; const thumbnailOptions = {
const previewOptions = { ...image.preview, ...baseOptions, format: previewFormat }; ...image.thumbnail,
...baseOptions,
format: thumbnailFormat,
decodingTier: THUMBNAIL_DECODING_TIER,
} as const;
const previewOptions = { ...image.preview, ...baseOptions, format: previewFormat, decodingTier: 0 } as const;
const promises = [ const promises = [
this.mediaRepository.generateThumbhash(decoded, baseOptions), this.mediaRepository.generateThumbhash(decoded, baseOptions),
this.mediaRepository.generateThumbnail(decoded, thumbnailOptions, thumbnailFile.path), this.mediaRepository.generateThumbnail(decoded, thumbnailOptions, thumbnailFile.path),
@@ -335,7 +340,7 @@ export class MediaService extends BaseService {
fileType: AssetFileType.FullSize, fileType: AssetFileType.FullSize,
format: fullsizeFormat, format: fullsizeFormat,
isEdited: useEdits, isEdited: useEdits,
isProgressive: !!image.fullsize.progressive && fullsizeFormat !== ImageFormat.Webp, isProgressive: !!image.fullsize.progressive && fullsizeFormat === ImageFormat.Jpeg,
isTransparent, isTransparent,
}); });
const fullsizeOptions = { const fullsizeOptions = {
@@ -343,14 +348,15 @@ export class MediaService extends BaseService {
format: fullsizeFormat, format: fullsizeFormat,
quality: image.fullsize.quality, quality: image.fullsize.quality,
progressive: image.fullsize.progressive, progressive: image.fullsize.progressive,
}; decodingTier: 0,
} as const;
promises.push(this.mediaRepository.generateThumbnail(decoded, fullsizeOptions, fullsizeFile.path)); promises.push(this.mediaRepository.generateThumbnail(decoded, fullsizeOptions, fullsizeFile.path));
} else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) { } else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) {
fullsizeFile = this.getImageFile(asset, { fullsizeFile = this.getImageFile(asset, {
fileType: AssetFileType.FullSize, fileType: AssetFileType.FullSize,
format: extracted.format, format: extracted.format,
isEdited: false, isEdited: false,
isProgressive: !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp, isProgressive: !!image.fullsize.progressive && image.fullsize.format === ImageFormat.Jpeg,
isTransparent, isTransparent,
}); });
this.storageCore.ensureFolders(fullsizeFile.path); this.storageCore.ensureFolders(fullsizeFile.path);
@@ -444,6 +450,7 @@ export class MediaService extends BaseService {
), ),
}, },
], ],
decodingTier: THUMBNAIL_DECODING_TIER,
}; };
await this.mediaRepository.generateThumbnail(decoded, thumbnailOptions, thumbnailPath); await this.mediaRepository.generateThumbnail(decoded, thumbnailOptions, thumbnailPath);
@@ -493,14 +500,15 @@ export class MediaService extends BaseService {
private async generateVideoThumbnails(asset: ThumbnailAsset, { ffmpeg, image }: SystemConfig) { private async generateVideoThumbnails(asset: ThumbnailAsset, { ffmpeg, image }: SystemConfig) {
const previewFile = this.getImageFile(asset, { const previewFile = this.getImageFile(asset, {
fileType: AssetFileType.Preview, fileType: AssetFileType.Preview,
format: image.preview.format, // TODO: write JPEG XL for video thumbnails
format: image.preview.format === ImageFormat.Jxl ? ImageFormat.Webp : image.preview.format,
isEdited: false, isEdited: false,
isProgressive: false, isProgressive: false,
isTransparent: false, isTransparent: false,
}); });
const thumbnailFile = this.getImageFile(asset, { const thumbnailFile = this.getImageFile(asset, {
fileType: AssetFileType.Thumbnail, fileType: AssetFileType.Thumbnail,
format: image.thumbnail.format, format: image.thumbnail.format === ImageFormat.Jxl ? ImageFormat.Webp : image.thumbnail.format,
isEdited: false, isEdited: false,
isProgressive: false, isProgressive: false,
isTransparent: false, isTransparent: false,
+2 -1
View File
@@ -308,7 +308,7 @@ describe(PasskeyService.name, () => {
expect(mocks.passkey.create).not.toHaveBeenCalled(); expect(mocks.passkey.create).not.toHaveBeenCalled();
}); });
it('should verify against the android app origins when the origin header is missing', async () => { it('should verify against the mobile app origins when the origin header is missing', async () => {
const auth = AuthFactory.from().session().build(); const auth = AuthFactory.from().session().build();
const passkey = PasskeyFactory.create({ userId: auth.user.id }); const passkey = PasskeyFactory.create({ userId: auth.user.id });
@@ -323,6 +323,7 @@ describe(PasskeyService.name, () => {
expect(mocks.webAuthn.verifyRegistrationResponse).toHaveBeenCalledWith( expect(mocks.webAuthn.verifyRegistrationResponse).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
expectedOrigin: [ expectedOrigin: [
'https://my.immich.app',
'android:apk-key-hash:hsXEVd-vSYWSOo81rbMdDJ4LlX1_lMLSr2okOKqWACA', 'android:apk-key-hash:hsXEVd-vSYWSOo81rbMdDJ4LlX1_lMLSr2okOKqWACA',
'android:apk-key-hash:WiLBg0dUBfVJxOufsmwuk6PvnFdmFQp684yNP-UVzNY', 'android:apk-key-hash:WiLBg0dUBfVJxOufsmwuk6PvnFdmFQp684yNP-UVzNY',
], ],
+4 -1
View File
@@ -32,6 +32,9 @@ const CHALLENGE_TTL = Duration.fromObject({ minutes: 5 });
const MOBILE_RP_ID = 'my.immich.app'; const MOBILE_RP_ID = 'my.immich.app';
// iOS apps send https:// + the rp id as their origin
const IOS_ORIGIN = `https://${MOBILE_RP_ID}`;
// keep in sync with https://my.immich.app/.well-known/assetlinks.json // keep in sync with https://my.immich.app/.well-known/assetlinks.json
const ANDROID_CERTIFICATE_FINGERPRINTS = [ const ANDROID_CERTIFICATE_FINGERPRINTS = [
'86:C5:C4:55:DF:AF:49:85:92:3A:8F:35:AD:B3:1D:0C:9E:0B:95:7D:7F:94:C2:D2:AF:6A:24:38:AA:96:00:20', '86:C5:C4:55:DF:AF:49:85:92:3A:8F:35:AD:B3:1D:0C:9E:0B:95:7D:7F:94:C2:D2:AF:6A:24:38:AA:96:00:20',
@@ -273,7 +276,7 @@ export class PasskeyService extends BaseService {
const origin = headers.origin; const origin = headers.origin;
// no origin implies a mobile app // no origin implies a mobile app
if (!origin) { if (!origin) {
return { origin: ANDROID_ORIGINS, rpId: MOBILE_RP_ID }; return { origin: [IOS_ORIGIN, ...ANDROID_ORIGINS], rpId: MOBILE_RP_ID };
} }
let hostname: string; let hostname: string;
@@ -211,6 +211,36 @@ describe(SmartInfoService.name, () => {
expect(mocks.search.upsert).toHaveBeenCalledWith(asset.id, '[0.01, 0.02, 0.03]'); expect(mocks.search.upsert).toHaveBeenCalledWith(asset.id, '[0.01, 0.02, 0.03]');
}); });
it('should use the edited preview when both preview variants exist', async () => {
const asset = AssetFactory.from()
.file({ type: AssetFileType.Preview, isEdited: false, path: 'original-preview' })
.file({ type: AssetFileType.Preview, isEdited: true, path: 'edited-preview' })
.build();
mocks.machineLearning.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]');
mocks.assetJob.getForClipEncoding.mockResolvedValue(asset);
expect(await sut.handleEncodeClip({ id: asset.id })).toEqual(JobStatus.Success);
expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith(
'edited-preview',
expect.objectContaining({ modelName: 'ViT-B-32__openai' }),
);
});
it('should select the edited preview regardless of file order', async () => {
const asset = AssetFactory.from()
.file({ type: AssetFileType.Preview, isEdited: true, path: 'edited-preview' })
.file({ type: AssetFileType.Preview, isEdited: false, path: 'original-preview' })
.build();
mocks.machineLearning.encodeImage.mockResolvedValue('[0.01, 0.02, 0.03]');
mocks.assetJob.getForClipEncoding.mockResolvedValue(asset);
expect(await sut.handleEncodeClip({ id: asset.id })).toEqual(JobStatus.Success);
expect(mocks.machineLearning.encodeImage).toHaveBeenCalledWith(
'edited-preview',
expect.objectContaining({ modelName: 'ViT-B-32__openai' }),
);
});
it('should skip invisible assets', async () => { it('should skip invisible assets', async () => {
const asset = AssetFactory.from({ visibility: AssetVisibility.Hidden }) const asset = AssetFactory.from({ visibility: AssetVisibility.Hidden })
.file({ type: AssetFileType.Preview }) .file({ type: AssetFileType.Preview })
+3 -2
View File
@@ -92,7 +92,8 @@ export class SmartInfoService extends BaseService {
} }
const asset = await this.assetJobRepository.getForClipEncoding(id); const asset = await this.assetJobRepository.getForClipEncoding(id);
if (!asset || asset.files.length !== 1) { const previewFile = asset?.files.find((file) => file.isEdited) ?? asset?.files[0];
if (!asset || !previewFile) {
return JobStatus.Failed; return JobStatus.Failed;
} }
@@ -100,7 +101,7 @@ export class SmartInfoService extends BaseService {
return JobStatus.Skipped; return JobStatus.Skipped;
} }
const embedding = await this.machineLearningRepository.encodeImage(asset.files[0].path, machineLearning.clip); const embedding = await this.machineLearningRepository.encodeImage(previewFile.path, machineLearning.clip);
if (this.databaseRepository.isBusy(DatabaseLock.CLIPDimSize)) { if (this.databaseRepository.isBusy(DatabaseLock.CLIPDimSize)) {
this.logger.verbose(`Waiting for CLIP dimension size to be updated`); this.logger.verbose(`Waiting for CLIP dimension size to be updated`);
+4 -11
View File
@@ -43,20 +43,15 @@ export type DeepPartial<T> = T extends Date
export type RepositoryInterface<T extends object> = Pick<T, keyof T>; export type RepositoryInterface<T extends object> = Pick<T, keyof T>;
export type FullsizeImageOptions = {
format: ImageFormat;
quality: number;
enabled: boolean;
progressive?: boolean;
};
export type ImageOptions = { export type ImageOptions = {
format: ImageFormat; format: ImageFormat;
quality: number; quality: number;
size: number;
progressive?: boolean; progressive?: boolean;
decodingTier: 0 | 1 | 2 | 3 | 4;
}; };
export type FullsizeImageOptions = ImageOptions & { enabled: boolean };
export type RawImageInfo = { export type RawImageInfo = {
width: number; width: number;
height: number; height: number;
@@ -84,9 +79,7 @@ export type TransformOptions = {
edits?: AssetEditActionItem[]; edits?: AssetEditActionItem[];
}; };
export type GenerateThumbnailOptions = Pick<ImageOptions, 'format' | 'quality' | 'progressive'> & export type GenerateThumbnailOptions = ImageOptions & ImageColorOptions & TransformOptions;
ImageColorOptions &
TransformOptions;
export type GenerateThumbhashOptions = ImageColorOptions & Pick<TransformOptions, 'edits'>; export type GenerateThumbhashOptions = ImageColorOptions & Pick<TransformOptions, 'edits'>;
export interface VideoStreamInfo { export interface VideoStreamInfo {
+1 -4
View File
@@ -153,11 +153,8 @@ export const onBeforeLink = async (
if (motionAsset.type !== AssetType.Video) { if (motionAsset.type !== AssetType.Video) {
throw new BadRequestException('Live photo video must be a video'); throw new BadRequestException('Live photo video must be a video');
} }
if (motionAsset.ownerId !== userId) {
throw new BadRequestException('Live photo video does not belong to the user');
}
if (!motionAsset || motionAsset.visibility !== AssetVisibility.Timeline) { if (motionAsset.visibility !== AssetVisibility.Timeline) {
return; return;
} }
+2
View File
@@ -1,4 +1,5 @@
import { join } from 'node:path'; import { join } from 'node:path';
import { THUMBNAIL_DECODING_TIER } from 'src/constants.js';
import { StorageCore } from 'src/cores/storage.core.js'; import { StorageCore } from 'src/cores/storage.core.js';
import { SystemConfig } from 'src/dtos/config.dto.js'; import { SystemConfig } from 'src/dtos/config.dto.js';
import { StorageFolder } from 'src/enum.js'; import { StorageFolder } from 'src/enum.js';
@@ -33,6 +34,7 @@ export const generateProfileImage = async (
progressive: image.thumbnail.progressive, progressive: image.thumbnail.progressive,
size: image.thumbnail.size, size: image.thumbnail.size,
processInvalidImages: false, processInvalidImages: false,
decodingTier: THUMBNAIL_DECODING_TIER,
}, },
outputPath, outputPath,
); );
@@ -1,6 +1,6 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
import { AssetVisibility, SharedLinkType } from 'src/enum.js'; import { AssetOrderBy, AssetVisibility, SharedLinkType } from 'src/enum.js';
import { AccessRepository } from 'src/repositories/access.repository.js'; import { AccessRepository } from 'src/repositories/access.repository.js';
import { AssetRepository } from 'src/repositories/asset.repository.js'; import { AssetRepository } from 'src/repositories/asset.repository.js';
import { LoggingRepository } from 'src/repositories/logging.repository.js'; import { LoggingRepository } from 'src/repositories/logging.repository.js';
@@ -162,6 +162,34 @@ describe(TimelineService.name, () => {
const rawResponse = await sut.getTimeBucket(factory.auth(), { timeBucket: '012345-01-01' }); const rawResponse = await sut.getTimeBucket(factory.auth(), { timeBucket: '012345-01-01' });
const response = JSON.parse(rawResponse); const response = JSON.parse(rawResponse);
expect(response).toEqual(expect.objectContaining({ id: [] })); expect(response).toEqual(expect.objectContaining({ id: [] }));
const createdAtResponse = await sut.getTimeBucket(factory.auth(), {
timeBucket: '012345-01-01',
orderBy: AssetOrderBy.CreatedAt,
});
expect(JSON.parse(createdAtResponse)).toEqual(expect.objectContaining({ id: [] }));
});
it('should bucket by upload month when ordering by createdAt', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const auth = factory.auth({ user: { id: user.id } });
const dates = [
'1970-01-31T23:59:59.999Z',
'1970-02-01T00:00:00.000Z',
'1970-02-28T23:59:59.999Z',
'1970-03-01T00:00:00.000Z',
];
const ids: string[] = [];
for (const createdAt of dates) {
const { asset } = await ctx.newAsset({ ownerId: user.id, createdAt, localDateTime: new Date('2000-01-01') });
await ctx.newExif({ assetId: asset.id, make: 'Canon' });
ids.push(asset.id);
}
const rawResponse = await sut.getTimeBucket(auth, { timeBucket: '1970-02-01', orderBy: AssetOrderBy.CreatedAt });
const response = JSON.parse(rawResponse);
expect(response.id).toEqual([ids[2], ids[1]]);
}); });
it('should return time bucket in trash', async () => { it('should return time bucket in trash', async () => {
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"importOrder": ["<THIRD_PARTY_MODULES>", "<BUILTIN_MODULES>", "^\\$(.*)$", "^@test-data(.*)$", "^[./]"], "importOrder": ["<THIRD_PARTY_MODULES>", "<BUILTIN_MODULES>", "^\\$(.*)$", "^#test-data(.*)$", "^[./]"],
"importOrderSeparation": false, "importOrderSeparation": false,
"jsonRecursiveSort": true, "jsonRecursiveSort": true,
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }], "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }],
+3 -1
View File
@@ -53,7 +53,7 @@ export default defineConfig(
tsconfigRootDir: import.meta.dirname, tsconfigRootDir: import.meta.dirname,
}, },
}, },
// ignores: ['**/service-worker/**'], ignores: ['**/service-worker/**'],
}, },
{ {
ignores: [ ignores: [
@@ -74,6 +74,7 @@ export default defineConfig(
'tailwind.config.js', 'tailwind.config.js',
'coverage', 'coverage',
'vite.config.ts', 'vite.config.ts',
'**/service-worker/**',
], ],
}, },
typescriptEslint.configs.recommended, typescriptEslint.configs.recommended,
@@ -164,6 +165,7 @@ export default defineConfig(
'@typescript-eslint/switch-exhaustiveness-check': ['error', { considerDefaultExhaustiveForUnions: true }], '@typescript-eslint/switch-exhaustiveness-check': ['error', { considerDefaultExhaustiveForUnions: true }],
'svelte/button-has-type': 'error', 'svelte/button-has-type': 'error',
'object-shorthand': ['error', 'always'], 'object-shorthand': ['error', 'always'],
'svelte/no-at-const-tags': 'error',
'svelte/no-navigation-without-resolve': 'off', 'svelte/no-navigation-without-resolve': 'off',
'unicorn/prefer-early-return': 'off', 'unicorn/prefer-early-return': 'off',
'unicorn/prefer-ternary': 'off', 'unicorn/prefer-ternary': 'off',
+19 -10
View File
@@ -3,6 +3,14 @@
"version": "3.3.5", "version": "3.3.5",
"license": "GNU Affero General Public License version 3", "license": "GNU Affero General Public License version 3",
"type": "module", "type": "module",
"imports": {
"#lib": "./src/lib/index.js",
"#lib/*": "./src/lib/*",
"#tests": "./src/../tests",
"#tests/*": "./src/../tests/*",
"#i18n/*": "../i18n/*",
"#test-data/*": "./src/test-data/*"
},
"scripts": { "scripts": {
"dev": "vite dev --host 0.0.0.0 --port 3000", "dev": "vite dev --host 0.0.0.0 --port 3000",
"build": "vite build", "build": "vite build",
@@ -27,7 +35,7 @@
"@formatjs/icu-messageformat-parser": "^3.0.0", "@formatjs/icu-messageformat-parser": "^3.0.0",
"@immich/justified-layout-wasm": "^0.4.3", "@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "workspace:*", "@immich/sdk": "workspace:*",
"@immich/ui": "^0.90.0", "@immich/ui": "^0.91.0",
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
"@noble/hashes": "^2.2.0", "@noble/hashes": "^2.2.0",
"@photo-sphere-viewer/core": "^5.14.0", "@photo-sphere-viewer/core": "^5.14.0",
@@ -36,8 +44,8 @@
"@photo-sphere-viewer/resolution-plugin": "^5.14.0", "@photo-sphere-viewer/resolution-plugin": "^5.14.0",
"@photo-sphere-viewer/settings-plugin": "^5.14.0", "@photo-sphere-viewer/settings-plugin": "^5.14.0",
"@photo-sphere-viewer/video-plugin": "^5.14.0", "@photo-sphere-viewer/video-plugin": "^5.14.0",
"@tanstack/svelte-query": "^6.3.2",
"@simplewebauthn/browser": "14.0.0", "@simplewebauthn/browser": "14.0.0",
"@tanstack/svelte-query": "^6.3.2",
"@types/geojson": "^7946.0.16", "@types/geojson": "^7946.0.16",
"@zoom-image/core": "^0.43.0", "@zoom-image/core": "^0.43.0",
"@zoom-image/svelte": "^0.3.0", "@zoom-image/svelte": "^0.3.0",
@@ -78,9 +86,9 @@
"@faker-js/faker": "^10.0.0", "@faker-js/faker": "^10.0.0",
"@koddsson/eslint-plugin-tscompat": "^0.2.0", "@koddsson/eslint-plugin-tscompat": "^0.2.0",
"@socket.io/component-emitter": "^3.1.0", "@socket.io/component-emitter": "^3.1.0",
"@sveltejs/adapter-static": "^3.0.8", "@sveltejs/adapter-static": "^4.0.0",
"@sveltejs/enhanced-img": "^0.11.0", "@sveltejs/enhanced-img": "^1.0.0",
"@sveltejs/kit": "^2.56.1", "@sveltejs/kit": "^3.0.0",
"@sveltejs/vite-plugin-svelte": "7.3.1", "@sveltejs/vite-plugin-svelte": "7.3.1",
"@tailwindcss/vite": "^4.2.4", "@tailwindcss/vite": "^4.2.4",
"@testing-library/jest-dom": "^7.0.0", "@testing-library/jest-dom": "^7.0.0",
@@ -101,22 +109,23 @@
"eslint-plugin-better-tailwindcss": "^4.5.0", "eslint-plugin-better-tailwindcss": "^4.5.0",
"eslint-plugin-compat": "^7.0.0", "eslint-plugin-compat": "^7.0.0",
"eslint-plugin-prettier": "^5.5.6", "eslint-plugin-prettier": "^5.5.6",
"eslint-plugin-svelte": "^3.12.4", "eslint-plugin-svelte": "^3.23.1",
"eslint-plugin-unicorn": "^75.0.0", "eslint-plugin-unicorn": "^75.0.0",
"factory.ts": "^1.4.1", "factory.ts": "^1.4.1",
"globals": "^17.0.0", "globals": "^17.0.0",
"happy-dom": "^21.0.0", "happy-dom": "^21.0.0",
"parse5": "^8.0.1",
"prettier": "^3.7.4", "prettier": "^3.7.4",
"prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-sort-json": "^4.1.1",
"prettier-plugin-svelte": "^4.0.0", "prettier-plugin-svelte": "^4.0.0",
"rollup-plugin-visualizer": "^7.0.0", "rollup-plugin-visualizer": "^7.0.0",
"svelte": "5.57.1", "svelte": "5.57.2",
"svelte-check": "^4.4.6", "svelte-check": "^4.7.5",
"svelte-eslint-parser": "^1.8.1", "svelte-eslint-parser": "^1.8.1",
"tailwindcss": "^4.2.4", "tailwindcss": "^4.2.4",
"typescript": "npm:@typescript/typescript6@^6.0.2", "typescript": "npm:@typescript/typescript6@^6.0.2",
"typescript-eslint": "8.71.2", "typescript-eslint": "8.72.0",
"vite": "^8.0.0", "vite": "^8.0.12",
"vitest": "^5.0.0" "vitest": "^5.0.0"
} }
} }
+2 -2
View File
@@ -93,7 +93,7 @@
@layer utilities { @layer utilities {
@font-face { @font-face {
font-family: 'GoogleSans'; font-family: 'GoogleSans';
src: url('$lib/assets/fonts/GoogleSans/GoogleSans.ttf') format('truetype-variations'); src: url('#lib/assets/fonts/GoogleSans/GoogleSans.ttf') format('truetype-variations');
font-weight: 410 900; font-weight: 410 900;
font-style: normal; font-style: normal;
ascent-override: 106.25%; ascent-override: 106.25%;
@@ -102,7 +102,7 @@
@font-face { @font-face {
font-family: 'GoogleSansCode'; font-family: 'GoogleSansCode';
src: url('$lib/assets/fonts/GoogleSansCode/GoogleSansCode.ttf') format('truetype-variations'); src: url('#lib/assets/fonts/GoogleSansCode/GoogleSansCode.ttf') format('truetype-variations');
font-weight: 1 900; font-weight: 1 900;
font-style: monospace; font-style: monospace;
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import type en from '#i18n/en.json';
import 'svelte-i18n'; import 'svelte-i18n';
import type en from '$i18n/en.json';
/// <reference types="@sveltejs/kit" /> /// <reference types="@sveltejs/kit" />
@@ -34,7 +34,7 @@ type NestedKeys<T, K = keyof T> = K extends keyof T & string
: never; : never;
declare module 'svelte-i18n' { declare module 'svelte-i18n' {
import type { InterpolationValues } from '$lib/elements/format-message'; import type { InterpolationValues } from '#lib/elements/format-message.ts';
import type { Readable } from 'svelte/store'; import type { Readable } from 'svelte/store';
type Translations = NestedKeys<typeof en>; type Translations = NestedKeys<typeof en>;
+2 -6
View File
@@ -19,12 +19,8 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<link rel="shortcut icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" sizes="32x32" href="/favicon.ico" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" /> <link rel="icon" type="image/svg+xml" sizes="any" href="/icon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="48x48" href="/favicon-48.png" />
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96.png" />
<link rel="icon" type="image/png" sizes="144x144" href="/favicon-144.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-icon-180.png" />
<link rel="preload" as="font" type="font/ttf" href="%app.font%" crossorigin="anonymous" /> <link rel="preload" as="font" type="font/ttf" href="%app.font%" crossorigin="anonymous" />
<link rel="preload" as="font" type="font/ttf" href="%app.monofont%" crossorigin="anonymous" /> <link rel="preload" as="font" type="font/ttf" href="%app.monofont%" crossorigin="anonymous" />
+6
View File
@@ -0,0 +1,6 @@
import { defineEnvVars } from '@sveltejs/kit/env';
export const variables = defineEnvVars({
PUBLIC_IMMICH_BUY_HOST: { public: true, static: true },
PUBLIC_IMMICH_PAY_HOST: { public: true, static: true },
});
+10 -6
View File
@@ -1,5 +1,5 @@
import { isHttpError, type ApiHttpError } from '@immich/sdk'; import { isHttpError, type ApiHttpError } from '@immich/sdk';
import type { HandleClientError } from '@sveltejs/kit'; import type { HandleClientError } from '@sveltejs/kit/hooks';
const DEFAULT_MESSAGE = 'Hmm, not sure about that. Check the logs or open a ticket?'; const DEFAULT_MESSAGE = 'Hmm, not sure about that. Check the logs or open a ticket?';
@@ -19,19 +19,23 @@ const parseHTTPError = (httpError: ApiHttpError) => {
}; };
}; };
const parseError = (error: unknown, status: number, message: string) => { const parseError = (error: App.Error | { status: number; message: string }) => {
if (isHttpError(error)) { if (isHttpError(error)) {
return parseHTTPError(error); return parseHTTPError(error);
} }
return { return {
message: (error as Error)?.message || message || DEFAULT_MESSAGE, message: error.message || DEFAULT_MESSAGE,
code: status, code: error.status,
}; };
}; };
export const handleError: HandleClientError = ({ error, status, message }) => { export const handleError: HandleClientError = ({ kind, error }) => {
const result = parseError(error, status, message); if (kind === 'unknown') {
console.error(error);
return;
}
const result = parseError(error);
console.error(`[hooks.client.ts]:handleError ${result.message}`, error); console.error(`[hooks.client.ts]:handleError ${result.message}`, error);
return result; return result;
}; };

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